Files
ratatrix/src/components/backlog.rs
Val Lorentz d79f5d7527
Some checks failed
CI / lint (push) Failing after 59s
CI / Build and test (, 1.73.0) (push) Failing after 5m1s
CI / Build and test (, beta) (push) Failing after 5m8s
CI / Build and test (, nightly) (push) Failing after 4m46s
Make history-related constants configurable
2023-11-22 18:13:38 +01:00

394 lines
13 KiB
Rust

/*
* Copyright (C) 2023 Valentin Lorentz
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License version 3,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::ops::DerefMut;
use std::sync::Arc;
use color_eyre::eyre::{Result, WrapErr};
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use enum_dispatch::enum_dispatch;
use ratatui::{prelude::*, widgets::*};
use crate::components::Action;
use crate::config::{Config, ScrollAmount};
use crate::widgets::prerender::{PrerenderInner, PrerenderValue};
use crate::widgets::{
BacklogItemWidget, BottomAlignedParagraph, Divider, EmptyWidget, OverlappableWidget,
};
use super::Component;
use crate::buffers::{BufferItem, BufferItemContent};
#[derive(Debug)]
struct ScrollPosition {
/// unique_id of the buffer item the scroll is relative to
anchor: u64,
/// number of lines from the top of the item where the bottom of the viewport is
relative_scroll: i64,
}
#[derive(Debug)]
pub struct Backlog {
config: Arc<Config>,
/// Used to compute scroll on PageUp/PageDown when configured to a percentage.
last_height: u16,
scroll_position: Option<ScrollPosition>,
/// Fallback used if the scroll_position is missing or unusable
absolute_scroll: u64,
/// How many lines up (or down) the user requested the viewport to move since the last
/// render.
///
/// This is applied to `scroll_position` and `fallback_scroll` on the next render.
pending_scroll: i64,
}
impl Component for Backlog {
fn handle_key_events(&mut self, key: KeyEvent) -> Result<Option<Action>> {
match key {
KeyEvent {
code: KeyCode::PageUp,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: _,
} => match self.config.keyboard.scroll_page {
ScrollAmount::Absolute(n) => self.scroll_up(n.into()),
ScrollAmount::Percentage(n) => {
self.scroll_up(u64::from(n) * u64::from(self.last_height) / 100)
},
},
KeyEvent {
code: KeyCode::PageDown,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: _,
} => match self.config.keyboard.scroll_page {
ScrollAmount::Absolute(n) => self.scroll_down(n.into()),
ScrollAmount::Percentage(n) => {
self.scroll_down(u64::from(n) * u64::from(self.last_height) / 100)
},
},
_ => {},
}
Ok(None)
}
fn draw(
&mut self,
frame: &mut Frame<'_>,
area: Rect,
buffers: &crate::buffers::Buffers,
) -> Result<()> {
let active_buffer = buffers.active_buffer();
let undrawn_widgets_at_top =
self.draw_items(frame.buffer_mut(), area, || active_buffer.content())?;
// We are reaching the end of the backlog we have locally, ask the buffer to fetch
// more if it can
if undrawn_widgets_at_top <= self.config.history.min_prefetch
&& self.config.history.prefetch > 0
{
active_buffer.request_back_pagination(self.config.history.prefetch);
}
Ok(())
}
}
impl Backlog {
pub fn new(config: Arc<Config>) -> Self {
Backlog {
config,
last_height: 30, // Arbitrary default, only useful when user scrolls before first render
scroll_position: None,
absolute_scroll: 0,
pending_scroll: 0,
}
}
pub fn scroll_up(&mut self, lines: u64) {
self.pending_scroll = self.pending_scroll.saturating_add(lines as i64);
}
pub fn scroll_down(&mut self, lines: u64) {
self.pending_scroll = self.pending_scroll.saturating_sub(lines as i64);
}
fn build_widget<'a>(&self, content: BufferItemContent<'a>, scroll: u64) -> BacklogItemWidget<'a> {
match content {
BufferItemContent::Text(text) => BottomAlignedParagraph::new(text).scroll(scroll).into(),
BufferItemContent::Divider(text) => {
if scroll == 0 {
Divider::new(Paragraph::new(text).alignment(Alignment::Center)).into()
} else {
EmptyWidget.into()
}
},
BufferItemContent::Empty => EmptyWidget.into(),
}
}
fn get_item_height(&self, item: &BufferItem<'_>, width: u16) -> u64 {
match item.prerender.0.lock().unwrap().deref_mut() {
Some(PrerenderInner {
key,
value: PrerenderValue::Rendered(buf),
}) if *key == width => buf.area().height as u64,
Some(PrerenderInner {
key,
value: PrerenderValue::NotRendered(height),
}) if *key == width => *height,
prerender => {
let widget = self.build_widget(item.content.clone(), 0);
// widget.height() needs to run the whole word-wrapping, which is almost as
// expensive as the real render.
// This is particularly wasteful, as the last widget.height() call here will
// duplicate the work we do in widget.render_overlap() later in the loop.
// Unfortunately I can't find a way to make it work because of the lifetimes
// involved.
let expected_height = widget.height(width);
*prerender = Some(PrerenderInner {
key: width,
value: PrerenderValue::NotRendered(expected_height),
});
expected_height
},
}
}
/// Returns how many items were not drawn because they are too high up the backlog
/// (ie. older than the currently displayed items)
pub fn draw_items<'a, Items: ExactSizeIterator<Item = BufferItem<'a>>>(
&mut self,
frame_buffer: &mut Buffer,
area: Rect,
get_items: impl Fn() -> Items,
) -> Result<usize> {
let block = Block::new().borders(Borders::ALL);
let mut text_area = block.inner(area);
block.render(area, frame_buffer);
self.last_height = text_area.height;
// Recompute absolute scroll position if we are not at the bottom of the backlog
if self.absolute_scroll != 0 || self.pending_scroll != 0 {
if let Some(scroll_position) = self.scroll_position.as_ref() {
if !get_items().any(|item| item.unique_id == Some(scroll_position.anchor)) {
// The anchor doesn't exist anymore, invalidate it
self.scroll_position = None;
}
}
if let Some(scroll_position) = self.scroll_position.as_ref() {
// Compute the current absolute scroll from the anchor.
let mut found_anchor = false;
self.absolute_scroll = 0;
for item in get_items() {
self.absolute_scroll = self
.absolute_scroll
.saturating_add(self.get_item_height(&item, text_area.width));
if item.unique_id == Some(scroll_position.anchor) {
found_anchor = true;
break;
}
}
assert!(found_anchor, "anchor disappeared between get_items() calls");
self.absolute_scroll = self
.absolute_scroll
.saturating_add_signed(-scroll_position.relative_scroll);
}
if self.pending_scroll != 0 {
self.absolute_scroll = self
.absolute_scroll
.saturating_add_signed(self.pending_scroll);
self.scroll_position = None;
self.pending_scroll = 0;
}
}
assert_eq!(self.pending_scroll, 0, "pending_scroll was not applied");
let mut items = get_items();
let mut scroll = self.absolute_scroll;
// Skip widgets at the bottom (if scrolled up), and render the first visible one
loop {
let Some(item) = items.next() else {
break;
};
let expected_height = self.get_item_height(&item, text_area.width);
if scroll.saturating_sub(expected_height) > u64::from(text_area.height) {
// Paragraph is too far down, not displayed
scroll -= expected_height;
continue;
}
// TODO: cache this
let widget = self.build_widget(item.content, scroll);
let (_, height) = widget.render_overlap(text_area, frame_buffer);
text_area.height = text_area.height.saturating_sub(height);
if self.absolute_scroll != 0 && expected_height >= scroll {
// If we are not at the bottom of the backlog and this is the first item up enough
// to be visible, set it as anchor for next render
if let Some(anchor) = item.unique_id {
self.scroll_position = Some(ScrollPosition {
anchor,
relative_scroll: (expected_height - scroll) as i64, // legal because always positive
});
}
// TODO: if item.unique_id is None, pick the next item that has an id
}
scroll = scroll.saturating_sub(expected_height);
if scroll == 0 {
break;
}
}
if text_area.height == 0 {
// No more room to display other paragraphs, stop now
return Ok(items.len());
}
// Render other widgets
let total_items = items.len();
for (drawn_items, item) in items.enumerate() {
let height = match item.prerender.0.lock().unwrap().deref_mut() {
Some(PrerenderInner {
key,
value: PrerenderValue::Rendered(buf),
}) if *key == text_area.width => {
// We already rendered it, copy the buffer.
assert!(text_area.width >= buf.area.width);
let top_padding = text_area.height.saturating_sub(buf.area.height);
let skip_top_lines = buf.area.height.saturating_sub(text_area.height);
copy_buffer(
buf,
Rect {
x: 0,
y: 0,
width: buf.area.width,
height: buf.area.height - skip_top_lines,
},
frame_buffer,
Rect {
x: text_area.x,
y: text_area.y + top_padding,
width: buf.area.width,
height: text_area.height - top_padding,
},
);
buf.area().height
},
prerender => {
let widget = self.build_widget(item.content, 0);
let (drawn_width, height) = widget.render_overlap(text_area, frame_buffer);
assert!(drawn_width <= text_area.width);
// If the whole widget fits in the text_area, copy the drawn result to a buffer
// for caching
if height <= text_area.height {
let mut buf = ratatui::buffer::Buffer::empty(Rect {
x: 0,
y: 0,
width: drawn_width,
height,
});
let top_padding = text_area.height.saturating_sub(buf.area.height);
copy_buffer(
frame_buffer,
Rect {
x: text_area.x,
y: text_area.y + top_padding,
width: drawn_width,
height: text_area.height - top_padding,
},
&mut buf,
Rect {
x: 0,
y: 0,
width: drawn_width,
height,
},
);
*prerender = Some(PrerenderInner {
key: text_area.width,
value: PrerenderValue::Rendered(buf),
});
height
} else {
// the widget overflowed and we drew on the whole area
text_area.height
}
},
};
assert!(area.height >= height, "{:?} {}", area, height);
text_area.height = text_area.height.saturating_sub(height); // Remove lines at the bottom used by this paragraph
if text_area.height == 0 {
// No more room to display other paragraphs, stop now
return Ok(total_items - drawn_items - 1);
}
}
// Loop ended on its own, meaning we ran out if items
Ok(0)
}
}
fn copy_buffer(src: &Buffer, src_area: Rect, dst: &mut Buffer, dst_area: Rect) {
assert_eq!(src_area.width, dst_area.width, "width mismatch");
assert_eq!(src_area.height, dst_area.height, "height mismatch");
assert!(
src_area.x + src_area.width <= src.area.width,
"src width overflow"
);
assert!(
src_area.y + src_area.height <= src.area.height,
"src height overflow"
);
assert!(
dst_area.x + dst_area.width <= dst.area.width,
"dst width overflow"
);
assert!(
dst_area.y + dst_area.height <= dst.area.height,
"dst height overflow"
);
for (y, line) in src
.content()
.chunks(src.area.width as usize)
.skip(src_area.y as usize)
.take(dst_area.height as usize)
.enumerate()
{
for (x, cell) in line
.iter()
.skip(src_area.x as usize)
.take(src_area.width as usize)
.enumerate()
{
*dst.get_mut(dst_area.x + (x as u16), dst_area.y + (y as u16)) = src
.get(src_area.x + (x as u16), src_area.y + (y as u16))
.clone();
}
}
}