use crate::config::StyleConfig;
use crate::error::Error;
use crate::search::RunText;
use crate::stream::{Snapshot, Streams};
use crate::style::Style;
use crate::term::TermBackend;
use crate::ui::layout::{LayoutTree, Payload, opts};
use crate::ui::{UiTree, layout_span};
use crate::{
Res,
config::Config,
items::{ItemId, RenderParams, RenderedRow, hash},
};
use crate::{item_data::ItemData, ui};
use super::Item;
use regex::{Regex, RegexBuilder};
use std::borrow::Cow;
use std::cell::RefCell;
use std::collections::HashSet;
use std::iter::{self, successors};
use std::ops::RangeInclusive;
use std::rc::Rc;
use std::sync::Arc;
use std::time::{Duration, Instant};
pub(crate) mod blame;
pub(crate) mod pager;
pub(crate) mod rebase_todo;
pub(crate) mod show_refs;
pub(crate) mod show_stash;
pub(crate) mod status;
const BOTTOM_CONTEXT_LINES: usize = 2;
#[derive(Copy, Clone, Debug)]
pub(crate) enum NavMode {
Normal,
Siblings { depth: usize },
IncludeSubLines,
}
#[derive(Clone)]
pub(crate) struct Place {
cursor: usize,
scroll: Scroll,
collapsed: HashSet<u64>,
}
struct ReturningTo {
lineage: Vec<ItemId>,
row: Option<usize>,
cursor: usize,
}
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
struct Scroll {
item_anchor: usize,
offset: usize,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub(crate) enum SearchDirection {
Forward,
Backward,
}
impl SearchDirection {
fn reverse(self) -> Self {
match self {
SearchDirection::Forward => SearchDirection::Backward,
SearchDirection::Backward => SearchDirection::Forward,
}
}
}
struct Search {
query: String,
matcher: Regex,
direction: SearchDirection,
}
pub(crate) fn matcher(query: &str) -> Res<Regex> {
RegexBuilder::new(query)
.case_insensitive(!has_uppercase(query))
.build()
.map_err(Error::InvalidSearchRegex)
}
fn has_uppercase(query: &str) -> bool {
let mut chars = query.chars();
while let Some(char) = chars.next() {
match char {
'\\' => _ = chars.next(),
_ if char.is_uppercase() => return true,
_ => (),
}
}
false
}
pub(crate) type RefreshItems = Box<dyn Fn(RenderParams) -> Res<Vec<Item>>>;
pub(crate) type RefreshStreamed = Box<dyn Fn(RenderParams) -> Res<Items>>;
pub(crate) struct Items {
pub(crate) now: Vec<Item>,
pub(crate) later: Option<Later>,
}
pub(crate) type Later = Box<dyn FnMut() -> Option<(Res<Vec<Item>>, bool)>>;
const FIRST_SCREEN_WAIT: Duration = Duration::from_millis(500);
pub(crate) fn streamed_items(
streams: Streams,
params: &RenderParams,
build: impl Fn(Vec<Snapshot>) -> Res<Vec<Item>> + 'static,
) -> Res<Items> {
let first_screens = 2 * params.size.1 as usize;
let deadline = Instant::now() + FIRST_SCREEN_WAIT;
let mut wanted = first_screens;
let (now, done) = loop {
if params.streaming {
streams.wait_at_most(wanted, deadline.saturating_duration_since(Instant::now()));
} else {
streams.wait_for(usize::MAX);
}
let snapshots = streams.snapshots();
let done = snapshots.iter().all(|snapshot| snapshot.done);
let now = if !done
&& snapshots
.iter()
.all(|snapshot| snapshot.rendered.as_deref() == Some(""))
{
Vec::new()
} else {
build(snapshots)?
};
if done || shown_rows(&now) >= first_screens || Instant::now() >= deadline {
break (now, done);
}
wanted = 2 * streams.rows().max(wanted);
};
if done {
return Ok(now.into());
}
let mut short = shown_rows(&now) < first_screens;
Ok(Items {
now,
later: Some(Box::new(move || {
if streams.is_done() {
Some((build(streams.snapshots()), true))
} else if short && streams.rows() >= wanted {
let items = build(streams.snapshots());
short = items
.as_ref()
.is_ok_and(|items| shown_rows(items) < first_screens);
wanted = 2 * streams.rows();
Some((items, false))
} else {
None
}
})),
})
}
fn shown_rows(items: &[Item]) -> usize {
let mut folded_at = None;
items
.iter()
.filter(|item| {
if folded_at.is_some_and(|depth| item.depth > depth) {
return false;
}
folded_at = (item.default_collapsed && item.data.is_section()).then_some(item.depth);
true
})
.count()
}
impl From<Vec<Item>> for Items {
fn from(now: Vec<Item>) -> Self {
Self { now, later: None }
}
}
pub(crate) struct Screen {
pub(crate) size: (u16, u16),
pub(crate) rendered_width: u16,
pub(crate) menu: Option<crate::menu::Menu>,
pub(crate) show_menu: bool,
pub(crate) is_status: bool,
cursor: usize,
anchor: Option<usize>,
renderer_headers: bool,
page_turned: Option<(usize, usize)>,
scroll: Scroll,
config: Arc<Config>,
pub(crate) features: Rc<[String]>,
pub(crate) context: Option<Rc<str>>,
streaming: bool,
pub(crate) git_command: Option<Rc<RefCell<crate::calling_process::GitCommand>>>,
refresh_items: RefreshStreamed,
later: Option<Later>,
returning_to: Option<ReturningTo>,
items: Vec<Item>,
item_heights: RefCell<Vec<Option<u16>>>,
collapsed: HashSet<u64>,
seen: HashSet<ItemId>,
unfolded: HashSet<ItemId>,
search: Option<Search>,
}
impl Screen {
pub(crate) fn new(
config: Arc<Config>,
params: RenderParams,
refresh_items: RefreshItems,
) -> Res<Self> {
Self::streamed(
config,
params,
Box::new(move |params| refresh_items(params).map(Items::from)),
)
}
pub(crate) fn streamed(
config: Arc<Config>,
params: RenderParams,
refresh_items: RefreshStreamed,
) -> Res<Self> {
let collapsed = config
.general
.collapsed_sections
.clone()
.into_iter()
.map(hash)
.collect();
let mut screen = Self {
cursor: 0,
anchor: None,
renderer_headers: false,
page_turned: None,
menu: None,
show_menu: false,
is_status: false,
scroll: Scroll::default(),
size: params.size,
rendered_width: params.size.0,
config,
features: params.features,
context: params.context,
streaming: params.streaming,
git_command: None,
refresh_items,
later: None,
returning_to: None,
items: vec![],
item_heights: RefCell::new(vec![]),
collapsed,
seen: HashSet::new(),
unfolded: HashSet::new(),
search: None,
};
let Items { now, later } = (screen.refresh_items)(screen.params())?;
screen.take_items(now);
screen.later = later;
screen.invalidate();
screen.update_cursor();
screen.cursor = screen
.find_first_hunk()
.or_else(|| screen.find_item(|item| !item.unselectable))
.unwrap_or(0);
Ok(screen)
}
fn params(&self) -> RenderParams {
RenderParams {
size: self.size,
features: Rc::clone(&self.features),
context: self.context.clone(),
streaming: self.streaming,
}
}
fn find_first_hunk(&mut self) -> Option<usize> {
self.find_item(|item| !item.unselectable && matches!(item.data, ItemData::Hunk { .. }))
}
pub(crate) fn select_next(&mut self, nav_mode: NavMode) {
self.anchor = None;
self.move_next(nav_mode);
}
fn move_next(&mut self, nav_mode: NavMode) {
self.cursor = self.find_next(nav_mode);
match nav_mode {
NavMode::IncludeSubLines => self.scroll_to_end_of(self.cursor),
NavMode::Normal | NavMode::Siblings { .. } => self.scroll_fit_end(),
}
self.scroll_fit_start();
}
fn scroll_fit_start(&mut self) {
if self.items.is_empty() {
return;
}
self.scroll_to_start_of(self.cursor);
}
fn scroll_to_start_of(&mut self, first: usize) {
let at_first = Scroll {
item_anchor: first,
offset: 0,
};
let top = self.retreated(at_first, self.items[first].depth);
self.scroll = self.scroll.min(top);
}
fn scroll_fit_end(&mut self) {
if self.items.is_empty() {
return;
}
self.scroll_to_end_of(self.last_selected());
}
fn scroll_to_end_of(&mut self, last: usize) {
if self.items.is_empty() {
return;
}
let at_last_line = Scroll {
item_anchor: last,
offset: self.item_height(last).saturating_sub(1),
};
let bottom = self.retreated(at_last_line, self.rows_above_context());
self.scroll = self.scroll.max(bottom);
}
fn rows_above_context(&self) -> usize {
(self.size.1 as usize).saturating_sub(BOTTOM_CONTEXT_LINES + 1)
}
fn last_selected(&self) -> usize {
self.subtree_end(self.cursor)
}
fn subtree_end(&self, head: usize) -> usize {
let depth = self.items[head].depth;
let last = self.items[head + 1..]
.iter()
.position(|item| item.depth <= depth)
.map_or(self.items.len() - 1, |offset| head + offset);
self.hidden_by(last, depth).unwrap_or(last)
}
pub(crate) fn find_next(&mut self, nav_mode: NavMode) -> usize {
self.visible_from(self.cursor)
.skip(1)
.find(|&item_i| self.nav_filter(item_i, nav_mode))
.unwrap_or(self.cursor)
}
fn nav_filter(&self, item_i: usize, nav_mode: NavMode) -> bool {
let item = &self.items[item_i];
match nav_mode {
NavMode::Normal => {
let is_sub_line = matches!(
item.data,
ItemData::HunkLine { .. } | ItemData::BlameCodeLine { .. }
);
!item.unselectable && !is_sub_line
}
NavMode::Siblings { depth } => {
!item.unselectable && item.data.is_section() && item.depth <= depth
}
NavMode::IncludeSubLines => !item.unselectable,
}
}
fn is_selectable(&self, item_i: usize, nav_mode: NavMode) -> bool {
self.is_visible(item_i) && self.nav_filter(item_i, nav_mode)
}
pub(crate) fn select_previous(&mut self, nav_mode: NavMode) {
self.anchor = None;
self.move_previous(nav_mode);
}
fn move_previous(&mut self, nav_mode: NavMode) {
self.cursor = self.find_previous(nav_mode);
self.scroll_fit_start();
}
pub(crate) fn extend_selection(&mut self, forwards: bool) {
let anchor = self.anchor.unwrap_or(self.cursor);
let next = if forwards {
self.find_next(NavMode::IncludeSubLines)
} else {
self.find_previous(NavMode::IncludeSubLines)
};
if !same_hunk(&self.items[anchor].data, &self.items[next].data) {
return;
}
self.anchor = Some(anchor);
self.cursor = next;
self.scroll_fit_end();
self.scroll_fit_start();
}
fn selection(&self) -> RangeInclusive<usize> {
let anchor = self.anchor.unwrap_or(self.cursor);
anchor.min(self.cursor)..=anchor.max(self.cursor)
}
pub(crate) fn selected_target(&self) -> ItemData {
let ItemData::HunkLine {
diff,
file_i,
hunk_i,
line_i,
line_range,
..
} = &self.get_selected_item().data
else {
return self.get_selected_item().data.clone();
};
let mut line_indices = self
.selection()
.filter_map(|line| match &self.items[line].data {
ItemData::HunkLine { line_indices, .. } => Some(line_indices),
_ => None,
})
.flatten()
.copied()
.collect::<Vec<_>>();
line_indices.sort_unstable();
line_indices.dedup();
ItemData::HunkLine {
diff: Rc::clone(diff),
file_i: *file_i,
hunk_i: *hunk_i,
line_i: *line_i,
line_range: line_range.clone(),
line_indices,
}
}
fn find_previous(&mut self, nav_mode: NavMode) -> usize {
self.visible_before(self.cursor)
.find(|&item_i| self.nav_filter(item_i, nav_mode))
.unwrap_or(self.cursor)
}
pub(crate) fn half_page_up(&mut self) {
self.turn_page(self.size.1 as usize / 2, false);
}
pub(crate) fn half_page_down(&mut self) {
self.turn_page(self.size.1 as usize / 2, true);
}
pub(crate) fn full_page_up(&mut self) {
self.turn_page(self.size.1 as usize, false);
}
pub(crate) fn full_page_down(&mut self) {
self.turn_page(self.size.1 as usize, true);
}
fn turn_page(&mut self, lines: usize, forwards: bool) {
self.anchor = None;
let row = self
.page_turned
.filter(|&(cursor, _)| cursor == self.cursor)
.map(|(_, row)| row)
.or_else(|| self.cursor_row());
let before = self.scroll;
if forwards {
self.scroll_view_down(lines);
} else {
self.scroll_view_up(lines);
}
if self.scroll == before {
if forwards {
self.move_cursor_to_bottom();
} else {
self.move_cursor_to_top();
}
return;
}
let scroll = self.scroll;
if let Some(row) = row {
self.move_cursor_to_screen_line(row);
}
let nav_mode = self.selected_item_nav_mode();
self.move_from_unselectable(nav_mode);
self.scroll = scroll;
self.page_turned = row.map(|row| (self.cursor, row));
}
fn cursor_row(&self) -> Option<usize> {
self.screen_rows()
.find(|&(item_i, _)| item_i == self.cursor)
.map(|(_, row)| row)
}
pub(crate) fn scroll_view_up(&mut self, lines: usize) {
self.retreat(lines);
}
pub(crate) fn scroll_view_down(&mut self, lines: usize) {
self.advance(lines);
self.clamp_scroll();
}
pub(crate) fn toggle_all_sections(&mut self) {
self.anchor = None;
let sections = || {
self.items.iter().filter(|item| {
item.data.is_section()
&& (!self.is_status || matches!(item.data, ItemData::Delta { .. }))
})
};
let Some(outermost) = sections().map(|item| item.depth).min() else {
return;
};
let headings: Vec<ItemId> = sections()
.filter(|item| item.depth == outermost)
.map(|item| item.id)
.collect();
let files: Vec<ItemId> = sections()
.filter(|item| item.depth > outermost && matches!(item.data, ItemData::Delta { .. }))
.map(|item| item.id)
.collect();
let folded = |ids: &[ItemId]| ids.iter().all(|id| self.collapsed.contains(id));
let fold = if folded(&headings) {
vec![]
} else if files.is_empty() || folded(&files) {
headings
} else {
files
};
self.renderer_headers = fold.is_empty();
self.collapsed.clear();
self.collapsed.extend(fold);
if let Some(heading) = self.hidden_by(self.cursor, 0) {
self.cursor = heading;
}
self.invalidate();
self.update_cursor();
}
pub(crate) fn toggle_section(&mut self) -> Res<()> {
self.anchor = None;
let selected = &self.items[self.cursor];
if selected.data.is_section() {
let id = selected.id;
if self.collapsed.remove(&id) {
self.unfolded.insert(id);
} else {
self.collapsed.insert(id);
self.unfolded.remove(&id);
}
self.item_heights.get_mut()[self.cursor] = None;
self.fit_anchor();
}
self.clamp_scroll();
Ok(())
}
pub(crate) fn refresh(&mut self) -> Res<()> {
self.anchor = None;
let was_on = self
.returning_to
.take()
.map(|to| (to.lineage, to.row))
.or_else(|| {
(!self.items.is_empty()).then(|| (self.lineage(self.cursor), self.cursor_row()))
});
self.rendered_width = self.size.0;
let Items { now, later } = (self.refresh_items)(self.params())?;
self.take_items(now);
self.later = later;
self.invalidate();
let found = was_on
.as_ref()
.and_then(|(lineage, _)| self.find_same_item(lineage));
if let Some(item_i) = found {
self.cursor = item_i;
}
self.update_cursor();
if found.is_none()
&& self.is_drawing()
&& let Some((lineage, row)) = was_on
{
self.returning_to = Some(ReturningTo {
lineage,
row,
cursor: self.cursor,
});
}
Ok(())
}
pub(crate) fn come_back(&mut self) -> Res<()> {
while self.returning_to.is_some() && self.is_drawing() {
if !self.finish()? {
std::thread::sleep(Duration::from_millis(10));
}
}
Ok(())
}
fn return_to_item(&mut self) {
let Some(to) = self.returning_to.take() else {
return;
};
if self.cursor != to.cursor {
return;
}
match self.find_same_item(&to.lineage) {
Some(item_i) => {
self.select_on_row(Some(item_i), to.row);
}
None if self.is_drawing() => self.returning_to = Some(to),
None => {}
}
}
fn lineage(&self, item_i: usize) -> Vec<ItemId> {
let mut depth = self.items[item_i].depth;
let mut ids = vec![self.items[item_i].id];
for item in self.items[..item_i].iter().rev() {
if depth == 0 {
break;
}
if item.depth < depth {
depth = item.depth;
ids.push(item.id);
}
}
ids
}
fn find_same_item(&self, lineage: &[ItemId]) -> Option<usize> {
let cursor = self.cursor;
self.visible_items()
.filter(|&item_i| {
let item = &self.items[item_i];
!item.unselectable && item.id == lineage[0] && self.lineage(item_i) == lineage
})
.min_by_key(|&item_i| item_i.abs_diff(cursor))
}
pub(crate) fn resize(&mut self, w: u16, h: u16) -> Res<()> {
self.fit_to(w, h);
self.update_cursor();
Ok(())
}
pub(crate) fn fit_to(&mut self, w: u16, h: u16) {
if self.size == (w, h) {
return;
}
self.size = (w, h);
self.invalidate();
}
pub(crate) fn finish(&mut self) -> Res<bool> {
let Some((items, all)) = self.later.as_mut().and_then(|later| later()) else {
return Ok(false);
};
let was_on = diff_position(&self.get_selected_item().data);
let was_at = self.cursor_row();
if all {
self.later = None;
}
let items = items?;
self.take_items(items);
self.invalidate();
self.update_cursor();
if let Some(position) = was_on {
self.select_at_row(position, was_at);
}
self.return_to_item();
Ok(true)
}
pub(crate) fn refresh_keeping_position(&mut self) -> Res<()> {
let was_on = diff_position(&self.get_selected_item().data);
let was_at = self.cursor_row();
self.refresh()?;
let Some(position) = was_on else {
return Ok(());
};
while !self.select_at_row(position, was_at) {
if !self.is_drawing() {
self.select_before_row(position, was_at);
break;
}
if !self.finish()? {
std::thread::sleep(Duration::from_millis(10));
}
}
Ok(())
}
fn select_at_row(&mut self, position: DiffPosition, row: Option<usize>) -> bool {
let item_i = self.find_matching(|data| diff_position(data) == Some(position));
self.select_on_row(item_i, row)
}
fn select_before_row(&mut self, position: DiffPosition, row: Option<usize>) -> bool {
let item_i = self.rfind_item(|item| {
!item.unselectable && diff_position(&item.data).is_some_and(|at| at < position)
});
self.select_on_row(item_i, row)
}
fn select_on_row(&mut self, item_i: Option<usize>, row: Option<usize>) -> bool {
let Some(item_i) = item_i else {
return false;
};
self.cursor = item_i;
self.scroll_fit_end();
self.scroll_fit_start();
if let Some(row) = row {
let at_cursor = Scroll {
item_anchor: self.cursor,
offset: 0,
};
self.scroll = self.retreated(at_cursor, row);
if !self.is_drawing() {
self.clamp_scroll();
}
}
true
}
fn update_cursor(&mut self) {
if self.is_empty() {
self.cursor = 0;
return;
}
self.clamp_scroll();
self.clamp_cursor();
if self.is_cursor_off_screen() {
self.move_cursor_to_screen_center();
}
self.clamp_cursor();
let nav_mode = self.selected_item_nav_mode();
self.move_from_unselectable(nav_mode);
}
fn selected_item_nav_mode(&mut self) -> NavMode {
if self.items.is_empty() {
return NavMode::Normal;
}
match self.get_selected_item().data {
ItemData::HunkLine { .. } | ItemData::BlameCodeLine { .. } => NavMode::IncludeSubLines,
_ => NavMode::Normal,
}
}
fn take_items(&mut self, items: Vec<Item>) {
let folded: Vec<ItemId> = items
.iter()
.filter(|item| !self.seen.contains(&item.id))
.filter(|item| match item.folds_like {
Some(like) if self.seen.contains(&like) => self.collapsed.contains(&like),
_ => item.default_collapsed,
})
.map(|item| item.id)
.collect();
self.collapsed.extend(folded);
let unfolded: Vec<ItemId> = items
.iter()
.filter(|item| {
item.folds_like
.is_some_and(|like| self.unfolded.contains(&like))
})
.map(|item| item.id)
.collect();
self.unfolded.extend(unfolded);
self.seen.extend(items.iter().map(|item| item.id));
self.items = fill_empty(items);
}
fn invalidate(&mut self) {
let item_heights = self.item_heights.get_mut();
item_heights.clear();
item_heights.resize(self.items.len(), None);
self.fit_anchor();
self.clamp_scroll();
}
fn fit_anchor(&mut self) {
if self.items.is_empty() {
self.scroll = Scroll::default();
return;
}
self.scroll.item_anchor = self.scroll.item_anchor.min(self.items.len() - 1);
if let Some(section) = self.hidden_by(self.scroll.item_anchor, 0) {
self.scroll = Scroll {
item_anchor: section,
offset: 0,
};
}
if self.folded_away(self.scroll.item_anchor)
&& let Some(next) = self.next_visible(self.scroll.item_anchor)
{
self.scroll = Scroll {
item_anchor: next,
offset: 0,
};
}
}
fn next_visible(&self, item_i: usize) -> Option<usize> {
self.next_shown(item_i, |item| {
item.data.is_section() && self.is_collapsed(item)
})
}
fn next_shown(&self, item_i: usize, shuts: impl Fn(&Item) -> bool) -> Option<usize> {
let item = &self.items[item_i];
let mut next = item_i + 1;
if shuts(item) {
next += self.items[next..]
.iter()
.position(|below| below.depth <= item.depth)?;
}
while next < self.items.len() && self.folded_away(next) {
next += 1;
}
(next < self.items.len()).then_some(next)
}
fn prev_visible(&self, item_i: usize) -> Option<usize> {
let prev = item_i.checked_sub(1)?;
let depth = self.items[item_i].depth;
let prev = self.hidden_by(prev, depth).unwrap_or(prev);
if self.folded_away(prev) {
return self.prev_visible(prev);
}
Some(prev)
}
fn hidden_by(&self, item_i: usize, floor: usize) -> Option<usize> {
let mut depth = self.items[item_i].depth;
if depth <= floor {
return None;
}
let mut hidden_by = None;
for (i, ancestor) in self.items[..item_i].iter().enumerate().rev() {
if ancestor.depth >= depth {
continue;
}
depth = ancestor.depth;
if ancestor.data.is_section() && self.is_collapsed(ancestor) {
hidden_by = Some(i);
}
if depth <= floor {
break;
}
}
hidden_by
}
fn visible_from(&self, item_i: usize) -> impl Iterator<Item = usize> {
let start = (item_i..self.items.len()).find(|&item_i| !self.folded_away(item_i));
successors(start, |&item_i| self.next_visible(item_i))
}
fn visible_before(&self, item_i: usize) -> impl Iterator<Item = usize> {
successors(self.prev_visible(item_i), |&item_i| {
self.prev_visible(item_i)
})
}
fn visible_items(&self) -> impl Iterator<Item = usize> {
self.visible_from(0)
}
fn visible_items_rev(&self) -> impl Iterator<Item = usize> {
let last = self
.items
.len()
.checked_sub(1)
.map(|last| self.hidden_by(last, 0).unwrap_or(last));
successors(last, |&item_i| self.prev_visible(item_i))
}
fn is_visible(&self, item_i: usize) -> bool {
self.hidden_by(item_i, 0).is_none()
}
fn item_height(&self, item_i: usize) -> usize {
if let Some(height) = self.item_heights.borrow()[item_i] {
return height as usize;
}
let mut layout = LayoutTree::new();
let view = ItemView {
item_index: item_i,
highlighted: false,
selected: false,
};
layout_item(&mut layout, self, false, view);
let height = layout
.compute([self.size.0, self.size.1])
.iter()
.map(|item| item.pos[1] + item.size[1])
.max()
.unwrap_or(0);
self.item_heights.borrow_mut()[item_i] = Some(height);
height as usize
}
fn items_from_anchor(&self) -> impl Iterator<Item = usize> {
self.visible_from(self.scroll.item_anchor)
}
fn screen_rows(&self) -> impl Iterator<Item = (usize, usize)> {
self.items_from_anchor()
.scan(0, move |row, item_i| {
let at = *row;
*row += self.item_height(item_i);
Some((item_i, at))
})
.take_while(move |&(item_i, at)| at + self.item_height(item_i) <= self.size.1 as usize)
}
fn item_at_row(&self, row: usize) -> Option<usize> {
self.screen_rows()
.take_while(|&(_, at)| at <= row)
.last()
.filter(|&(item_i, at)| row < at + self.item_height(item_i))
.map(|(item_i, _)| item_i)
}
fn advance(&mut self, mut lines: usize) {
if self.items.is_empty() {
return;
}
while lines > 0 {
let height = self.item_height(self.scroll.item_anchor);
let left = height.saturating_sub(self.scroll.offset);
if lines < left {
self.scroll.offset += lines;
return;
}
let Some(next) = self.next_visible(self.scroll.item_anchor) else {
self.scroll.offset = height.saturating_sub(1);
return;
};
lines -= left;
self.scroll = Scroll {
item_anchor: next,
offset: 0,
};
}
}
fn retreat(&mut self, lines: usize) {
self.scroll = self.retreated(self.scroll, lines);
}
fn retreated(&self, mut scroll: Scroll, mut lines: usize) -> Scroll {
if lines <= scroll.offset {
scroll.offset -= lines;
return scroll;
}
lines -= scroll.offset;
scroll.offset = 0;
while lines > 0 {
let Some(prev) = self.prev_visible(scroll.item_anchor) else {
break;
};
let height = self.item_height(prev);
scroll = Scroll {
item_anchor: prev,
offset: height.saturating_sub(lines),
};
if lines <= height {
break;
}
lines -= height;
}
scroll
}
fn lines_below_anchor(&self, limit: usize) -> usize {
if self.items.is_empty() {
return 0;
}
let mut lines = self
.item_height(self.scroll.item_anchor)
.saturating_sub(self.scroll.offset);
for item_i in self.items_from_anchor().skip(1) {
if lines >= limit {
break;
}
lines += self.item_height(item_i);
}
lines
}
fn lines_above_anchor(&self, limit: usize) -> usize {
let mut lines = self.scroll.offset;
let mut item_i = self.scroll.item_anchor;
while lines < limit {
let Some(prev) = self.prev_visible(item_i) else {
break;
};
lines += self.item_height(prev);
item_i = prev;
}
lines.min(limit)
}
pub(crate) fn is_drawing(&self) -> bool {
self.later.is_some()
}
pub(crate) fn fits_in(&self, rows: usize) -> bool {
let mut height = 0;
self.printed_items().all(|item_i| {
height += self.item_height(item_i);
height <= rows
})
}
fn printed_items(&self) -> impl Iterator<Item = usize> {
let first = (0..self.items.len()).find(|&item_i| !self.folded_away(item_i));
successors(first, |&item_i| {
self.next_shown(item_i, |item| self.folded_row(item).is_some())
})
}
fn folded_row<'a>(&self, item: &'a Item) -> Option<&'a RenderedRow> {
item.folded.as_deref().filter(|_| self.is_collapsed(item))
}
fn standing_in_row<'a>(&self, item: &'a Item) -> Option<&'a RenderedRow> {
self.folded_row(item).or_else(|| {
item.header
.as_deref()
.filter(|_| !self.renderer_headers && !self.is_collapsed(item))
})
}
fn folded_away(&self, item_i: usize) -> bool {
let item = &self.items[item_i];
let decorated = if item.decorates_next {
self.items[item_i..]
.iter()
.find(|item| !item.decorates_next)
} else if item.decorates_previous {
self.items[..item_i]
.iter()
.rev()
.find(|item| !item.decorates_previous)
} else {
None
};
decorated.is_some_and(|item| self.standing_in_row(item).is_some())
}
fn is_empty(&self) -> bool {
!self
.visible_items()
.any(|item_i| self.item_height(item_i) > 0)
}
fn is_cursor_off_screen(&self) -> bool {
!self.item_views().any(|item| item.highlighted)
}
fn move_cursor_to_screen_center(&mut self) {
let half_screen = self.size.1 as usize / 2;
let center = self
.item_at_row(half_screen)
.or_else(|| self.screen_rows().last().map(|(item_i, _)| item_i));
if let Some(item_i) = center {
self.cursor = item_i;
}
}
fn clamp_cursor(&mut self) {
self.cursor = self.cursor.clamp(0, self.items.len().saturating_sub(1));
}
fn clamp_scroll(&mut self) {
let wanted = (self.size.1 as usize).saturating_sub(BOTTOM_CONTEXT_LINES);
let below = self.lines_below_anchor(wanted);
if below >= wanted {
return;
}
let over = wanted - below;
let above = self.lines_above_anchor(over + BOTTOM_CONTEXT_LINES);
self.retreat(over.min(above.saturating_sub(BOTTOM_CONTEXT_LINES)));
}
fn move_from_unselectable(&mut self, nav_mode: NavMode) {
if !self.is_selectable(self.cursor, nav_mode) {
self.move_previous(nav_mode);
}
if !self.is_selectable(self.cursor, nav_mode) {
self.move_next(nav_mode);
}
}
pub(crate) fn move_cursor_to_screen_line(&mut self, screen_line: usize) {
let Some(new_cursor) = self.item_at_row(screen_line) else {
return;
};
if self.cursor == new_cursor {
return;
}
let old_cursor = self.cursor;
self.anchor = None;
self.cursor = new_cursor;
let nav_mode = self.selected_item_nav_mode();
self.move_from_unselectable(nav_mode);
if !self.is_selectable(self.cursor, nav_mode) {
self.cursor = old_cursor;
} else {
self.scroll_fit_start();
}
}
pub(crate) fn move_cursor_to_top(&mut self) {
self.anchor = None;
if let Some(first) = self.find_item(|item| !item.unselectable) {
self.cursor = first;
self.scroll = Scroll::default();
}
}
pub(crate) fn move_cursor_to_bottom(&mut self) {
self.anchor = None;
if let Some(last) = self.rfind_item(|item| !item.unselectable) {
self.cursor = last;
self.scroll_fit_end();
}
}
pub(crate) fn is_collapsed(&self, item: &Item) -> bool {
self.collapsed.contains(&item.id)
}
pub(crate) fn has_unfolded_here(&self) -> bool {
self.unfolded_here().is_some()
}
pub(crate) fn fold_unfolded_here(&mut self) -> Res<bool> {
let Some(item_i) = self.unfolded_here() else {
return Ok(false);
};
self.cursor = item_i;
self.toggle_section()?;
self.scroll_fit_start();
Ok(true)
}
fn unfolded_here(&self) -> Option<usize> {
if self.items.is_empty() {
return None;
}
let mut depth = self.items[self.cursor].depth + 1;
for item_i in (0..=self.cursor).rev() {
let item = &self.items[item_i];
if item.depth >= depth {
continue;
}
depth = item.depth;
if item.data.is_section()
&& self.unfolded.contains(&item.id)
&& !self.collapsed.contains(&item.id)
{
return Some(item_i);
}
if depth == 0 {
break;
}
}
None
}
#[cfg(test)]
pub(crate) fn items(&self) -> &[Item] {
&self.items
}
#[cfg(test)]
pub(crate) fn row_texts(&self) -> Vec<String> {
self.items
.iter()
.filter_map(|item| item.rendered.as_ref())
.map(|row| row.iter().map(|(text, _)| text.as_str()).collect())
.collect()
}
#[cfg(test)]
pub(crate) fn shown_row_texts(&self) -> Vec<String> {
self.visible_items()
.map(|item_i| &self.items[item_i])
.filter_map(|item| self.standing_in_row(item).or(item.rendered.as_deref()))
.map(|row| row.iter().map(|(text, _)| text.as_str()).collect())
.collect()
}
pub(crate) fn get_selected_item(&self) -> &Item {
&self.items[self.cursor]
}
pub(crate) fn select_matching<F: Fn(&ItemData) -> bool>(&mut self, predicate: F) -> bool {
let Some(item_i) = self.find_item(|item| !item.unselectable && predicate(&item.data))
else {
return false;
};
self.cursor = item_i;
self.center_on_cursor();
self.scroll_fit_end();
self.scroll_fit_start();
true
}
pub(crate) fn select_last_matching<F: Fn(&ItemData) -> bool>(&mut self, predicate: F) -> bool {
let Some(item_i) = self.rfind_item(|item| !item.unselectable && predicate(&item.data))
else {
return false;
};
self.cursor = item_i;
self.center_on_cursor();
self.scroll_fit_start();
true
}
fn center_on_cursor(&mut self) {
self.scroll = Scroll {
item_anchor: self.cursor,
offset: 0,
};
self.retreat(self.size.1 as usize / 2);
self.clamp_scroll();
}
pub(crate) fn search(&mut self, query: &str, direction: SearchDirection) -> Res<()> {
debug_assert!(!query.is_empty());
let matcher = matcher(query)?;
let found = self.move_to_match(&matcher, direction);
self.search = Some(Search {
query: query.to_string(),
matcher,
direction,
});
if found {
Ok(())
} else {
Err(Error::NoSearchMatch(query.to_string()))
}
}
pub(crate) fn select_matching_with_neighbours_in_view<F: Fn(&ItemData) -> bool>(
&mut self,
predicate: F,
) -> bool {
let Some(item_i) = self.find_matching(predicate) else {
return false;
};
self.cursor = item_i;
let next = self.find_next(NavMode::Normal);
let previous = self.find_previous(NavMode::Normal);
self.scroll_to_end_of(self.subtree_end(next));
self.scroll_to_start_of(previous);
self.scroll_fit_end();
self.scroll_fit_start();
true
}
fn find_matching<F: Fn(&ItemData) -> bool>(&self, predicate: F) -> Option<usize> {
self.find_item(|item| !item.unselectable && predicate(&item.data))
}
pub(crate) fn search_repeat(&mut self, reverse: bool) -> Res<()> {
let Some(search) = &self.search else {
return Err(Error::NoPreviousSearch);
};
let query = search.query.clone();
let matcher = search.matcher.clone();
let direction = if reverse {
search.direction.reverse()
} else {
search.direction
};
if self.move_to_match(&matcher, direction) {
Ok(())
} else {
Err(Error::NoSearchMatch(query))
}
}
fn move_to_match(&mut self, matcher: &Regex, direction: SearchDirection) -> bool {
let Some(item_i) = self.find_match(matcher, direction) else {
return false;
};
self.reveal(item_i);
self.cursor = self.selected_by_match(item_i);
self.scroll_to_cursor();
true
}
fn selected_by_match(&self, item_i: usize) -> usize {
let item = &self.items[item_i];
if !item.unselectable || !matches!(item.data, ItemData::Raw(_)) {
return item_i;
}
let mut depth = item.depth;
for (i, ancestor) in self.items[..item_i].iter().enumerate().rev() {
if ancestor.depth >= depth {
continue;
}
if !ancestor.unselectable {
return i;
}
depth = ancestor.depth;
}
item_i
}
fn reveal(&mut self, item_i: usize) {
while let Some(section) = self.hidden_by(item_i, 0) {
self.collapsed.remove(&self.items[section].id);
self.item_heights.get_mut()[section] = None;
}
self.clamp_scroll();
}
fn find_match(&self, matcher: &Regex, direction: SearchDirection) -> Option<usize> {
match direction {
SearchDirection::Forward => self
.matching_items(matcher)
.find(|&item_i| self.selected_by_match(item_i) > self.cursor)
.or_else(|| self.matching_items(matcher).next()),
SearchDirection::Backward => self
.matching_items(matcher)
.take_while(|&item_i| self.selected_by_match(item_i) < self.cursor)
.last()
.or_else(|| self.matching_items(matcher).last()),
}
}
fn matching_items<'a>(&'a self, matcher: &'a Regex) -> impl Iterator<Item = usize> + 'a {
(0..self.items.len()).filter(move |&item_i| self.item_matches(item_i, matcher))
}
fn item_matches(&self, item_i: usize, matcher: &Regex) -> bool {
let mut layout = UiTree::new();
let view = ItemView {
item_index: item_i,
highlighted: false,
selected: false,
};
layout_item(&mut layout, self, false, view);
let mut run_text = RunText::default();
layout
.leaf_runs()
.any(|run| matcher.is_match(run_text.read(run.map(|(leaf, span)| (leaf, span.text())))))
}
pub(crate) fn place(&self) -> Place {
Place {
cursor: self.cursor,
scroll: self.scroll,
collapsed: self.collapsed.clone(),
}
}
pub(crate) fn go_back_to(&mut self, place: Place) {
self.cursor = place.cursor;
self.scroll = place.scroll;
self.collapsed = place.collapsed;
self.invalidate();
}
pub(crate) fn clear_search(&mut self) {
self.search = None;
}
pub(crate) fn get_search_matcher(&self) -> Option<&Regex> {
self.search.as_ref().map(|search| &search.matcher)
}
fn scroll_to_cursor(&mut self) {
if self.is_cursor_off_screen() {
self.center_on_cursor();
}
self.scroll_fit_end();
self.scroll_fit_start();
}
fn find_item<P: Fn(&Item) -> bool>(&self, predicate: P) -> Option<usize> {
self.visible_items()
.find(|&item_i| predicate(&self.items[item_i]))
}
fn rfind_item<P: Fn(&Item) -> bool>(&self, predicate: P) -> Option<usize> {
self.visible_items_rev()
.find(|&item_i| predicate(&self.items[item_i]))
}
pub(crate) fn is_valid_screen_line(&self, screen_line: usize) -> bool {
let Some(target_item_i) = self.item_at_row(screen_line) else {
return false;
};
self.nav_filter(target_item_i, NavMode::IncludeSubLines)
}
fn is_selected(&self, item_i: usize) -> bool {
let selection = self.selection();
let Some(depth) = self.items.get(*selection.start()).map(|item| item.depth) else {
return false;
};
selection.contains(&item_i)
|| (item_i > *selection.end()
&& self.items[*selection.end() + 1..=item_i]
.iter()
.rev()
.all(|item| item.depth > depth))
}
fn item_views(&self) -> impl Iterator<Item = ItemView> {
let selection = self.selection();
let selection_depth = self.items.get(*selection.start()).map(|item| item.depth);
self.screen_rows().scan(
self.is_selected(self.scroll.item_anchor),
move |highlighted, (item_index, _)| {
if selection.contains(&item_index) {
*highlighted = true;
} else if selection_depth.is_some_and(|depth| self.items[item_index].depth <= depth)
{
*highlighted = false;
}
Some(ItemView {
item_index,
highlighted: *highlighted,
selected: selection.contains(&item_index),
})
},
)
}
}
fn fill_empty(mut items: Vec<Item>) -> Vec<Item> {
if items.is_empty() {
items.push(Item {
unselectable: true,
..Default::default()
});
}
items
}
fn same_hunk(a: &ItemData, b: &ItemData) -> bool {
match (a, b) {
(
ItemData::HunkLine {
diff,
file_i,
hunk_i,
..
},
ItemData::HunkLine {
diff: b_diff,
file_i: b_file_i,
hunk_i: b_hunk_i,
..
},
) => Rc::ptr_eq(diff, b_diff) && file_i == b_file_i && hunk_i == b_hunk_i,
_ => false,
}
}
type DiffPosition = (usize, Option<usize>, Option<usize>);
fn diff_position(data: &ItemData) -> Option<DiffPosition> {
match data {
ItemData::Delta { file_i, .. } => Some((*file_i, None, None)),
ItemData::Hunk { file_i, hunk_i, .. } => Some((*file_i, Some(*hunk_i), None)),
ItemData::HunkLine {
file_i,
hunk_i,
line_i,
..
} => Some((*file_i, Some(*hunk_i), Some(*line_i))),
_ => None,
}
}
struct ItemView {
item_index: usize,
highlighted: bool,
selected: bool,
}
pub(crate) fn layout_screen<'a>(layout: &mut UiTree<'a>, screen: &'a Screen, hide_cursor: bool) {
layout.col(opts().fill_x(), |layout| {
for view in screen.item_views() {
layout_item(layout, screen, hide_cursor, view);
}
});
}
pub(crate) fn print_screen(term: &mut TermBackend, screen: &Screen) -> Res<()> {
for item in screen.printed_items().map(|item_i| &screen.items[item_i]) {
let mut layout = UiTree::new();
layout.row(opts(), |layout| match screen.standing_in_row(item) {
Some(row) => ui::item::layout_rendered(layout, row, Style::new()),
None => ui::item::layout_item(layout, item, &screen.config, Style::new()),
});
for computed in layout.compute([screen.size.0, 1]).iter() {
if let Payload::Leaf(span) = computed.data {
ui::print_span(term, span, iter::empty(), Style::new())?;
}
}
term.queue_newline()?;
}
term.flush().map_err(Error::Term)
}
fn layout_item<'a>(layout: &mut UiTree<'a>, screen: &'a Screen, hide_cursor: bool, line: ItemView) {
let style = &screen.config.style;
let is_line_sel = line.selected;
let area_sel = area_selection_highlight(style, &line);
let line_sel = line_selection_highlight(style, &line, is_line_sel);
let bg = area_sel.patch(line_sel);
layout.row_with(bg, opts().fill_x(), |layout| {
let gutter_char = if !hide_cursor && line.highlighted {
gutter_char(style, is_line_sel, bg)
} else {
(" ".into(), Style::new())
};
layout.row(opts(), |layout| layout_span(layout, gutter_char));
let item = &screen.items[line.item_index];
if let Some(row) = screen.standing_in_row(item) {
ui::item::layout_rendered(layout, row, bg);
return;
}
ui::item::layout_item(layout, item, &screen.config, bg);
let has_children = screen
.items
.get(line.item_index + 1)
.is_some_and(|next| next.depth > item.depth);
if has_children && screen.is_collapsed(item) {
layout_span(layout, ("…".into(), bg));
}
});
}
fn gutter_char<'a>(style: &'a StyleConfig, is_line_sel: bool, bg: Style) -> (Cow<'a, str>, Style) {
if is_line_sel {
(
style.cursor.symbol.to_string().into(),
bg.patch(Style::from(&style.cursor)),
)
} else {
(
style.selection_bar.symbol.to_string().into(),
bg.patch(Style::from(&style.selection_bar)),
)
}
}
fn line_selection_highlight(style: &StyleConfig, line: &ItemView, selected_line: bool) -> Style {
if line.highlighted && selected_line {
Style::from(&style.selection_line)
} else {
Style::new()
}
}
fn area_selection_highlight(style: &StyleConfig, line: &ItemView) -> Style {
if line.highlighted {
Style::from(&style.selection_area)
} else {
Style::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::init_test_config;
use crate::item_data::SectionHeader;
fn screen_of(item_count: usize, size: (u16, u16)) -> Screen {
let config = Arc::new(init_test_config().unwrap());
Screen::new(
config,
RenderParams {
size,
features: Rc::from([]),
context: None,
streaming: false,
},
Box::new(move |_params| {
Ok((0..item_count)
.map(|i| Item {
id: i as u64,
data: ItemData::Raw(format!("item {i}")),
..Default::default()
})
.collect())
}),
)
.unwrap()
}
fn tree_screen(spec: &[(usize, bool)], size: (u16, u16)) -> Screen {
let config = Arc::new(init_test_config().unwrap());
let spec = spec.to_vec();
Screen::new(
config,
RenderParams {
size,
features: Rc::from([]),
context: None,
streaming: false,
},
Box::new(move |_params| {
Ok(spec
.iter()
.enumerate()
.map(|(i, &(depth, is_section))| Item {
id: i as u64,
depth,
data: if is_section {
ItemData::Header(SectionHeader::Tags)
} else {
ItemData::Raw(format!("item {i}"))
},
..Default::default()
})
.collect())
}),
)
.unwrap()
}
#[test]
fn moving_by_line_onto_a_section_scrolls_no_further_than_the_cursor() {
let spec: Vec<(usize, bool)> = [(0, true), (1, false), (1, false), (1, false), (0, true)]
.into_iter()
.chain([(1, false); 10])
.collect();
let mut screen = tree_screen(&spec, (80, 8));
for _ in 0..4 {
screen.select_next(NavMode::IncludeSubLines);
}
assert_eq!(screen.cursor, 4);
assert_eq!(screen.scroll.item_anchor, 0, "the view jumped");
}
const TREE: &[(usize, bool)] = &[
(0, true),
(1, false),
(1, true),
(2, false),
(2, false),
(1, false),
(0, true),
(1, false),
];
fn visible(screen: &Screen) -> (Vec<usize>, Vec<usize>) {
let down = screen.visible_items().collect::<Vec<_>>();
let mut up = screen.visible_items_rev().collect::<Vec<_>>();
up.reverse();
(down, up)
}
#[test]
fn walks_the_tree_both_ways() {
let mut screen = tree_screen(TREE, (80, 20));
assert_eq!(visible(&screen), ((0..8).collect(), (0..8).collect()));
screen.collapsed.insert(2);
assert_eq!(
visible(&screen),
(vec![0, 1, 2, 5, 6, 7], vec![0, 1, 2, 5, 6, 7])
);
assert_eq!(Some(2), screen.prev_visible(5));
assert_eq!(Some(5), screen.next_visible(2));
assert!(!screen.is_visible(3));
screen.collapsed.insert(0);
assert_eq!(visible(&screen), (vec![0, 6, 7], vec![0, 6, 7]));
assert_eq!(Some(0), screen.prev_visible(6));
assert_eq!(Some(6), screen.next_visible(0));
assert!(!screen.is_visible(2));
}
#[test]
fn scrolls_back_over_a_collapsed_section() {
let mut screen = tree_screen(TREE, (80, 4));
screen.collapsed.insert(2);
screen.scroll_view_down(3);
assert_eq!(5, screen.scroll.item_anchor);
screen.scroll_view_up(3);
assert_eq!(0, screen.scroll.item_anchor);
assert_eq!(0, screen.scroll.offset);
}
fn laid_out_count(screen: &Screen) -> usize {
screen
.item_heights
.borrow()
.iter()
.filter(|height| height.is_some())
.count()
}
#[test]
fn only_lays_out_what_it_reaches() {
let mut screen = screen_of(10_000, (80, 20));
assert!(laid_out_count(&screen) < 50, "{}", laid_out_count(&screen));
screen.scroll_view_down(100);
assert!(laid_out_count(&screen) < 150, "{}", laid_out_count(&screen));
}
#[test]
fn jumping_to_the_bottom_skips_the_middle() {
let mut screen = screen_of(10_000, (80, 20));
screen.move_cursor_to_bottom();
assert_eq!(9_999, screen.cursor);
assert!(laid_out_count(&screen) < 50, "{}", laid_out_count(&screen));
}
#[test]
fn recenter_cursor_on_a_screen_the_content_doesnt_fill() {
let mut screen = screen_of(3, (80, 20));
screen.scroll_view_down(2);
screen.refresh().unwrap();
assert_eq!(2, screen.cursor);
}
use crate::git::diff::{Diff, DiffType};
use crate::items::RenderedRow;
const DIFF: &str = "diff --git a/f.rs b/f.rs\n\
index 1..2 100644\n\
--- a/f.rs\n\
+++ b/f.rs\n\
@@ -1,3 +1,3 @@\n\
\x20keep\n\
-gone\n\
+added\n";
fn rendered_screen(rows_per_line: &'static str) -> Screen {
rendered_screen_of_lines(rows_per_line, 3, 40)
}
fn rendered_screen_of_lines(rows_per_line: &'static str, lines: usize, height: u16) -> Screen {
let diff = Rc::new(Diff {
text: DIFF.to_string(),
diff_type: DiffType::WorkdirToIndex,
file_diffs: crate::gitu_diff::Parser::new(DIFF).parse_diff().unwrap(),
commit: None,
});
Screen::new(
Arc::new(init_test_config().unwrap()),
RenderParams {
size: (80, height),
features: Rc::from([]),
context: None,
streaming: false,
},
Box::new(move |params: RenderParams| {
let rows = if params.features.iter().any(|f| f == rows_per_line) {
2
} else {
1
};
Ok(diff_items(&diff, rows, lines))
}),
)
.unwrap()
}
fn diff_items(diff: &Rc<Diff>, rows: usize, lines: usize) -> Vec<Item> {
let mut items = vec![
Item {
depth: 0,
data: ItemData::Delta {
diff: Rc::clone(diff),
file_i: 0,
commit: None,
},
..Default::default()
},
Item {
depth: 1,
data: ItemData::Hunk {
diff: Rc::clone(diff),
file_i: 0,
hunk_i: 0,
},
..Default::default()
},
];
for line_i in 0..lines {
for row in 0..rows {
let rendered: RenderedRow =
vec![(format!("line {line_i} row {row}"), Style::new())];
items.push(Item {
depth: if row == 0 { 2 } else { 3 },
unselectable: row > 0,
data: if row == 0 {
ItemData::HunkLine {
diff: Rc::clone(diff),
file_i: 0,
hunk_i: 0,
line_i,
line_range: 0..0,
line_indices: vec![line_i],
}
} else {
ItemData::default()
},
rendered: Some(Rc::new(rendered)),
..Default::default()
});
}
}
items
}
fn selected_line(screen: &Screen) -> Option<usize> {
match screen.get_selected_item().data {
ItemData::HunkLine { line_i, .. } => Some(line_i),
_ => None,
}
}
#[test]
fn paging_on_past_the_last_page_reaches_the_last_line() {
let mut screen = rendered_screen_of_lines("wide", 50, 20);
for _ in 0..10 {
screen.full_page_down();
}
assert_eq!(selected_line(&screen), Some(49));
}
#[test]
fn a_rerender_keeps_the_cursor_on_the_same_diff_line() {
let mut screen = rendered_screen("wide");
screen.select_matching(|data| matches!(data, ItemData::HunkLine { line_i: 2, .. }));
assert_eq!(selected_line(&screen), Some(2));
screen.features = Rc::from(["wide".to_string()]);
screen.refresh_keeping_position().unwrap();
assert_eq!(selected_line(&screen), Some(2));
}
#[test]
fn a_plain_refresh_keeps_only_the_place_in_the_list() {
let mut screen = rendered_screen("wide");
screen.select_matching(|data| matches!(data, ItemData::HunkLine { line_i: 2, .. }));
screen.features = Rc::from(["wide".to_string()]);
screen.refresh().unwrap();
assert_ne!(selected_line(&screen), Some(2));
}
fn row(text: &str) -> Option<Rc<RenderedRow>> {
Some(Rc::new(vec![(text.to_owned(), Style::new())]))
}
#[test]
fn a_section_folded_to_a_row_shows_only_that_row() {
fn section(id: u64, name: &str) -> [Item; 3] {
[
Item {
id,
unselectable: true,
decorates_next: true,
rendered: row("───"),
..Default::default()
},
Item {
id,
default_collapsed: true,
data: ItemData::Header(SectionHeader::Tags),
rendered: row(name),
folded: row(&format!("{name} | 1 +")),
..Default::default()
},
Item {
id: id + 1,
depth: 1,
rendered: row("+line"),
..Default::default()
},
]
}
let mut screen = Screen::new(
Arc::new(init_test_config().unwrap()),
RenderParams {
size: (80, 10),
..Default::default()
},
Box::new(move |_params| Ok([section(0, "a"), section(10, "b")].concat())),
)
.unwrap();
assert_eq!(screen.shown_row_texts(), ["a | 1 +", "b | 1 +"]);
assert!(screen.fits_in(2));
screen.select_next(NavMode::Normal);
screen.toggle_section().unwrap();
assert_eq!(screen.shown_row_texts(), ["a | 1 +", "───", "b", "+line"]);
}
}