gitui/src/components/commitlist.rs
2021-05-06 14:11:15 +02:00

415 lines
11 KiB
Rust

use super::utils::logitems::{ItemBatch, LogEntry};
use crate::{
components::{
utils::string_width_align, CommandBlocking, CommandInfo,
Component, DrawableComponent, EventState, ScrollType,
},
keys::SharedKeyConfig,
strings,
ui::calc_scroll_top,
ui::style::{SharedTheme, Theme},
};
use anyhow::Result;
use asyncgit::sync::Tags;
use chrono::{DateTime, Local};
use crossterm::event::Event;
use std::{
borrow::Cow, cell::Cell, cmp, convert::TryFrom, time::Instant,
};
use tui::{
backend::Backend,
layout::{Alignment, Rect},
text::{Span, Spans},
widgets::{Block, Borders, Paragraph},
Frame,
};
const ELEMENTS_PER_LINE: usize = 10;
///
pub struct CommitList {
title: String,
selection: usize,
branch: Option<String>,
count_total: usize,
items: ItemBatch,
scroll_state: (Instant, f32),
tags: Option<Tags>,
current_size: Cell<(u16, u16)>,
scroll_top: Cell<usize>,
theme: SharedTheme,
key_config: SharedKeyConfig,
}
impl CommitList {
///
pub fn new(
title: &str,
theme: SharedTheme,
key_config: SharedKeyConfig,
) -> Self {
Self {
items: ItemBatch::default(),
selection: 0,
branch: None,
count_total: 0,
scroll_state: (Instant::now(), 0_f32),
tags: None,
current_size: Cell::new((0, 0)),
scroll_top: Cell::new(0),
theme,
key_config,
title: String::from(title),
}
}
///
pub fn items(&mut self) -> &mut ItemBatch {
&mut self.items
}
///
pub fn set_branch(&mut self, name: Option<String>) {
self.branch = name;
}
///
pub const fn selection(&self) -> usize {
self.selection
}
///
pub fn current_size(&self) -> (u16, u16) {
self.current_size.get()
}
///
pub fn set_count_total(&mut self, total: usize) {
self.count_total = total;
self.selection =
cmp::min(self.selection, self.selection_max());
}
///
#[allow(clippy::missing_const_for_fn)]
pub fn selection_max(&self) -> usize {
self.count_total.saturating_sub(1)
}
///
pub const fn tags(&self) -> Option<&Tags> {
self.tags.as_ref()
}
///
pub fn clear(&mut self) {
self.items.clear();
}
///
pub fn set_tags(&mut self, tags: Tags) {
self.tags = Some(tags);
}
///
pub fn selected_entry(&self) -> Option<&LogEntry> {
self.items.iter().nth(
self.selection.saturating_sub(self.items.index_offset()),
)
}
pub fn copy_entry_hash(&self) -> Result<()> {
if let Some(e) = self.items.iter().nth(
self.selection.saturating_sub(self.items.index_offset()),
) {
crate::clipboard::copy_string(&e.hash_short)?;
}
Ok(())
}
fn move_selection(&mut self, scroll: ScrollType) -> Result<bool> {
self.update_scroll_speed();
#[allow(clippy::cast_possible_truncation)]
let speed_int =
usize::try_from(self.scroll_state.1 as i64)?.max(1);
let page_offset =
usize::from(self.current_size.get().1).saturating_sub(1);
let new_selection = match scroll {
ScrollType::Up => {
self.selection.saturating_sub(speed_int)
}
ScrollType::Down => {
self.selection.saturating_add(speed_int)
}
ScrollType::PageUp => {
self.selection.saturating_sub(page_offset)
}
ScrollType::PageDown => {
self.selection.saturating_add(page_offset)
}
ScrollType::Home => 0,
ScrollType::End => self.selection_max(),
};
let new_selection =
cmp::min(new_selection, self.selection_max());
let needs_update = new_selection != self.selection;
self.selection = new_selection;
Ok(needs_update)
}
fn update_scroll_speed(&mut self) {
const REPEATED_SCROLL_THRESHOLD_MILLIS: u128 = 300;
const SCROLL_SPEED_START: f32 = 0.1_f32;
const SCROLL_SPEED_MAX: f32 = 10_f32;
const SCROLL_SPEED_MULTIPLIER: f32 = 1.05_f32;
let now = Instant::now();
let since_last_scroll =
now.duration_since(self.scroll_state.0);
self.scroll_state.0 = now;
let speed = if since_last_scroll.as_millis()
< REPEATED_SCROLL_THRESHOLD_MILLIS
{
self.scroll_state.1 * SCROLL_SPEED_MULTIPLIER
} else {
SCROLL_SPEED_START
};
self.scroll_state.1 = speed.min(SCROLL_SPEED_MAX);
}
fn get_entry_to_add<'a>(
e: &'a LogEntry,
selected: bool,
tags: Option<String>,
theme: &Theme,
width: usize,
now: DateTime<Local>,
) -> Spans<'a> {
let mut txt: Vec<Span> = Vec::new();
txt.reserve(ELEMENTS_PER_LINE);
let splitter_txt = Cow::from(" ");
let splitter =
Span::styled(splitter_txt, theme.text(true, selected));
// commit hash
txt.push(Span::styled(
Cow::from(e.hash_short.as_str()),
theme.commit_hash(selected),
));
txt.push(splitter.clone());
// commit timestamp
txt.push(Span::styled(
Cow::from(e.time_to_string(now)),
theme.commit_time(selected),
));
txt.push(splitter.clone());
let author_width =
(width.saturating_sub(19) / 3).max(3).min(20);
let author = string_width_align(&e.author, author_width);
// commit author
txt.push(Span::styled::<String>(
author,
theme.commit_author(selected),
));
txt.push(splitter.clone());
// commit tags
txt.push(Span::styled(
Cow::from(if let Some(tags) = tags {
format!(" {}", tags)
} else {
String::from("")
}),
theme.tags(selected),
));
txt.push(splitter);
// commit msg
txt.push(Span::styled(
Cow::from(e.msg.as_str()),
theme.text(true, selected),
));
Spans::from(txt)
}
fn get_text(&self, height: usize, width: usize) -> Vec<Spans> {
let selection = self.relative_selection();
let mut txt: Vec<Spans> = Vec::with_capacity(height);
let now = Local::now();
for (idx, e) in self
.items
.iter()
.skip(self.scroll_top.get())
.take(height)
.enumerate()
{
let tags = self
.tags
.as_ref()
.and_then(|t| t.get(&e.id))
.map(|tags| tags.join(" "));
txt.push(Self::get_entry_to_add(
e,
idx + self.scroll_top.get() == selection,
tags,
&self.theme,
width,
now,
));
}
txt
}
#[allow(clippy::missing_const_for_fn)]
fn relative_selection(&self) -> usize {
self.selection.saturating_sub(self.items.index_offset())
}
}
impl DrawableComponent for CommitList {
fn draw<B: Backend>(
&self,
f: &mut Frame<B>,
area: Rect,
) -> Result<()> {
let current_size = (
area.width.saturating_sub(2),
area.height.saturating_sub(2),
);
self.current_size.set(current_size);
let height_in_lines = self.current_size.get().1 as usize;
let selection = self.relative_selection();
self.scroll_top.set(calc_scroll_top(
self.scroll_top.get(),
height_in_lines,
selection,
));
let branch_post_fix =
self.branch.as_ref().map(|b| format!("- {{{}}}", b));
let title = format!(
"{} {}/{} {}",
self.title,
self.count_total.saturating_sub(self.selection),
self.count_total,
branch_post_fix.as_deref().unwrap_or(""),
);
f.render_widget(
Paragraph::new(
self.get_text(
height_in_lines,
current_size.0 as usize,
),
)
.block(
Block::default()
.borders(Borders::ALL)
.title(Span::styled(
title.as_str(),
self.theme.title(true),
))
.border_style(self.theme.block(true)),
)
.alignment(Alignment::Left),
area,
);
Ok(())
}
}
impl Component for CommitList {
fn event(&mut self, ev: Event) -> Result<EventState> {
if let Event::Key(k) = ev {
let selection_changed = if k == self.key_config.move_up {
self.move_selection(ScrollType::Up)?
} else if k == self.key_config.move_down {
self.move_selection(ScrollType::Down)?
} else if k == self.key_config.shift_up
|| k == self.key_config.home
{
self.move_selection(ScrollType::Home)?
} else if k == self.key_config.shift_down
|| k == self.key_config.end
{
self.move_selection(ScrollType::End)?
} else if k == self.key_config.page_up {
self.move_selection(ScrollType::PageUp)?
} else if k == self.key_config.page_down {
self.move_selection(ScrollType::PageDown)?
} else {
false
};
return Ok(selection_changed.into());
}
Ok(EventState::NotConsumed)
}
fn commands(
&self,
out: &mut Vec<CommandInfo>,
_force_all: bool,
) -> CommandBlocking {
out.push(CommandInfo::new(
strings::commands::scroll(&self.key_config),
self.selected_entry().is_some(),
true,
));
CommandBlocking::PassingOn
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_string_width_align() {
assert_eq!(string_width_align("123", 3), "123");
assert_eq!(string_width_align("123", 2), "..");
assert_eq!(string_width_align("123", 3), "123");
assert_eq!(string_width_align("12345", 6), "12345 ");
assert_eq!(string_width_align("1234556", 4), "12..");
}
#[test]
fn test_string_width_align_unicode() {
assert_eq!(string_width_align("äste", 3), "ä..");
assert_eq!(
string_width_align("wüsten äste", 10),
"wüsten ä.."
);
assert_eq!(
string_width_align("Jon Grythe Stødle", 19),
"Jon Grythe Stødle "
);
}
}