394 lines
13 KiB
Rust
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();
|
|
}
|
|
}
|
|
}
|