Allow to scroll diffs horizontally (#1327)

This commit is contained in:
Christoph Rüßler 2023-01-08 12:47:37 +01:00 committed by GitHub
parent f29178d1b3
commit 9fa5fddd93
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 343 additions and 70 deletions

View File

@ -90,6 +90,7 @@ Bugfix followup release - check `0.22.0` notes for more infos!
* switch focus to index after staging last file ([#1169](https://github.com/extrawurst/gitui/pull/1169))
* fix stashlist multi marking not updated after dropping ([#1207](https://github.com/extrawurst/gitui/pull/1207))
* exact matches have a higher priority and are placed to the top of the list when fuzzily finding files ([#1183](https://github.com/extrawurst/gitui/pull/1183))
* support horizontal scrolling in diff view ([#1017](https://github.com/extrawurst/gitui/issues/1017))
### Changed
* minimum supported rust version bumped to 1.60 ([#1279](https://github.com/extrawurst/gitui/pull/1279))

View File

@ -123,6 +123,7 @@ impl DrawableComponent for BlameFileComponent {
//
// https://github.com/fdehau/tui-rs/issues/448
table_state.selected().unwrap_or(0),
ui::Orientation::Vertical,
);
self.table_state.set(table_state);

View File

@ -8,7 +8,7 @@ use crate::{
queue::{InternalEvent, Queue},
strings::{self, symbol},
ui::style::{SharedTheme, Theme},
ui::{calc_scroll_top, draw_scrollbar},
ui::{calc_scroll_top, draw_scrollbar, Orientation},
};
use anyhow::Result;
use asyncgit::sync::{BranchInfo, CommitId, Tags};
@ -501,6 +501,7 @@ impl DrawableComponent for CommitList {
&self.theme,
self.count_total,
self.selection,
Orientation::Vertical,
);
Ok(())

View File

@ -44,7 +44,7 @@ impl DrawableComponent for CompareCommitsComponent {
) -> Result<()> {
if self.is_visible() {
let percentages = if self.diff.focused() {
(30, 70)
(0, 100)
} else {
(50, 50)
};
@ -121,7 +121,12 @@ impl Component for CompareCommitsComponent {
if let Event::Key(e) = ev {
if key_match(e, self.key_config.keys.exit_popup) {
self.hide_stacked(false);
if self.diff.focused() {
self.details.focus(true);
self.diff.focus(false);
} else {
self.hide_stacked(false);
}
} else if key_match(
e,
self.key_config.keys.focus_right,
@ -132,13 +137,6 @@ impl Component for CompareCommitsComponent {
} else if key_match(
e,
self.key_config.keys.focus_left,
) && self.diff.focused()
{
self.details.focus(true);
self.diff.focus(false);
} else if key_match(
e,
self.key_config.keys.focus_left,
) {
self.hide_stacked(false);
}

View File

@ -1,12 +1,14 @@
use super::{
utils::scroll_horizontal::HorizontalScroll,
utils::scroll_vertical::VerticalScroll, CommandBlocking,
Direction, DrawableComponent, ScrollType,
Direction, DrawableComponent, HorizontalScrollType, ScrollType,
};
use crate::{
components::{CommandInfo, Component, EventState},
keys::{key_match, SharedKeyConfig},
queue::{Action, InternalEvent, NeedsUpdate, Queue, ResetItem},
string_utils::tabs_to_spaces,
string_utils::trim_offset,
strings, try_or_popup,
ui::style::SharedTheme,
};
@ -102,13 +104,15 @@ impl Selection {
pub struct DiffComponent {
repo: RepoPathRef,
diff: Option<FileDiff>,
longest_line: usize,
pending: bool,
selection: Selection,
selected_hunk: Option<usize>,
current_size: Cell<(u16, u16)>,
focused: bool,
current: Current,
scroll: VerticalScroll,
vertical_scroll: VerticalScroll,
horizontal_scroll: HorizontalScroll,
queue: Queue,
theme: SharedTheme,
key_config: SharedKeyConfig,
@ -131,9 +135,11 @@ impl DiffComponent {
pending: false,
selected_hunk: None,
diff: None,
longest_line: 0,
current_size: Cell::new((0, 0)),
selection: Selection::Single(0),
scroll: VerticalScroll::new(),
vertical_scroll: VerticalScroll::new(),
horizontal_scroll: HorizontalScroll::new(),
theme,
key_config,
is_immutable,
@ -155,7 +161,9 @@ impl DiffComponent {
pub fn clear(&mut self, pending: bool) {
self.current = Current::default();
self.diff = None;
self.scroll.reset();
self.longest_line = 0;
self.vertical_scroll.reset();
self.horizontal_scroll.reset();
self.selection = Selection::Single(0);
self.selected_hunk = None;
self.pending = pending;
@ -182,8 +190,27 @@ impl DiffComponent {
self.diff = Some(diff);
self.longest_line = self
.diff
.iter()
.flat_map(|diff| diff.hunks.iter())
.flat_map(|hunk| hunk.lines.iter())
.map(|line| {
let converted_content = tabs_to_spaces(
line.content.as_ref().to_string(),
);
converted_content.len()
})
.max()
.map_or(0, |len| {
// Each hunk uses a 1-character wide vertical bar to its left to indicate
// selection.
len + 1
});
if reset_selection {
self.scroll.reset();
self.vertical_scroll.reset();
self.selection = Selection::Single(0);
self.update_selection(0);
} else {
@ -241,6 +268,11 @@ impl DiffComponent {
self.diff.as_ref().map_or(0, |diff| diff.lines)
}
fn max_scroll_right(&self) -> usize {
self.longest_line
.saturating_sub(self.current_size.get().0.into())
}
fn modify_selection(&mut self, direction: Direction) {
if self.diff.is_some() {
self.selection.modify(direction, self.lines_count());
@ -340,7 +372,7 @@ impl DiffComponent {
Span::raw(Cow::from(")")),
])]);
} else {
let min = self.scroll.get_top();
let min = self.vertical_scroll.get_top();
let max = min + height as usize;
let mut line_cursor = 0_usize;
@ -378,6 +410,8 @@ impl DiffComponent {
hunk_selected,
i == hunk_len - 1,
&self.theme,
self.horizontal_scroll
.get_right(),
));
lines_added += 1;
}
@ -400,6 +434,7 @@ impl DiffComponent {
selected_hunk: bool,
end_of_hunk: bool,
theme: &SharedTheme,
scrolled_right: usize,
) -> Spans<'a> {
let style = theme.diff_hunk_marker(selected_hunk);
@ -418,18 +453,22 @@ impl DiffComponent {
}
};
let content =
tabs_to_spaces(line.content.as_ref().to_string());
let content = trim_offset(&content, scrolled_right);
let filled = if selected {
// selected line
format!("{:w$}\n", line.content, w = width as usize)
format!("{content:w$}\n", w = width as usize)
} else {
// weird eof missing eol line
format!("{}\n", line.content)
format!("{content}\n")
};
Spans::from(vec![
left_side_of_line,
Span::styled(
Cow::from(tabs_to_spaces(filled)),
Cow::from(filled),
theme.diff_line(line.line_type, selected),
),
])
@ -606,14 +645,20 @@ impl DrawableComponent for DiffComponent {
r.height.saturating_sub(2),
));
let current_width = self.current_size.get().0;
let current_height = self.current_size.get().1;
self.scroll.update(
self.vertical_scroll.update(
self.selection.get_end(),
self.lines_count(),
usize::from(current_height),
);
self.horizontal_scroll.update_no_selection(
self.longest_line,
current_width.into(),
);
let title = format!(
"{}{}",
strings::title_diff(&self.key_config),
@ -643,7 +688,11 @@ impl DrawableComponent for DiffComponent {
);
if self.focused() {
self.scroll.draw(f, r, &self.theme);
self.vertical_scroll.draw(f, r, &self.theme);
if self.max_scroll_right() > 0 {
self.horizontal_scroll.draw(f, r, &self.theme);
}
}
Ok(())
@ -754,6 +803,18 @@ impl Component for DiffComponent {
{
self.move_selection(ScrollType::PageDown);
Ok(EventState::Consumed)
} else if key_match(
e,
self.key_config.keys.move_right,
) {
self.horizontal_scroll
.move_right(HorizontalScrollType::Right);
Ok(EventState::Consumed)
} else if key_match(e, self.key_config.keys.move_left)
{
self.horizontal_scroll
.move_right(HorizontalScrollType::Left);
Ok(EventState::Consumed)
} else if key_match(
e,
self.key_config.keys.stage_unstage_item,

View File

@ -11,7 +11,7 @@ use crate::{
keys::SharedKeyConfig,
queue::{InternalEvent, NeedsUpdate, Queue},
strings,
ui::{draw_scrollbar, style::SharedTheme},
ui::{draw_scrollbar, style::SharedTheme, Orientation},
};
use anyhow::Result;
use asyncgit::{
@ -412,6 +412,7 @@ impl FileRevlogComponent {
&self.theme,
self.count_total,
table_state.selected().unwrap_or(0),
Orientation::Vertical,
);
self.table_state.set(table_state);
@ -445,7 +446,7 @@ impl DrawableComponent for FileRevlogComponent {
) -> Result<()> {
if self.visible {
let percentages = if self.diff.focused() {
(30, 70)
(0, 100)
} else {
(50, 50)
};
@ -485,20 +486,17 @@ impl Component for FileRevlogComponent {
if let Event::Key(key) = event {
if key_match(key, self.key_config.keys.exit_popup) {
self.hide_stacked(false);
if self.diff.focused() {
self.diff.focus(false);
} else {
self.hide_stacked(false);
}
} else if key_match(
key,
self.key_config.keys.focus_right,
) && self.can_focus_diff()
{
self.diff.focus(true);
} else if key_match(
key,
self.key_config.keys.focus_left,
) {
if self.diff.focused() {
self.diff.focus(false);
}
} else if key_match(key, self.key_config.keys.enter) {
if let Some(commit_id) = self.selected_commit() {
self.hide_stacked(true);

View File

@ -71,7 +71,7 @@ impl DrawableComponent for InspectCommitComponent {
) -> Result<()> {
if self.is_visible() {
let percentages = if self.diff.focused() {
(30, 70)
(0, 100)
} else {
(50, 50)
};
@ -126,7 +126,7 @@ impl Component for InspectCommitComponent {
));
out.push(CommandInfo::new(
strings::commands::diff_focus_left(&self.key_config),
strings::commands::close_popup(&self.key_config),
true,
self.diff.focused() || force_all,
));
@ -157,7 +157,12 @@ impl Component for InspectCommitComponent {
if let Event::Key(e) = ev {
if key_match(e, self.key_config.keys.exit_popup) {
self.hide_stacked(false);
if self.diff.focused() {
self.details.focus(true);
self.diff.focus(false);
} else {
self.hide_stacked(false);
}
} else if key_match(
e,
self.key_config.keys.focus_right,
@ -168,13 +173,6 @@ impl Component for InspectCommitComponent {
} else if key_match(
e,
self.key_config.keys.focus_left,
) && self.diff.focused()
{
self.details.focus(true);
self.diff.focus(false);
} else if key_match(
e,
self.key_config.keys.focus_left,
) {
self.hide_stacked(false);
}

View File

@ -185,6 +185,12 @@ pub enum ScrollType {
PageDown,
}
#[derive(Copy, Clone)]
pub enum HorizontalScrollType {
Left,
Right,
}
#[derive(Copy, Clone)]
pub enum Direction {
Up,

View File

@ -238,6 +238,7 @@ impl DrawableComponent for SyntaxTextComponent {
state.height().saturating_sub(2),
)),
usize::from(state.scroll().y),
ui::Orientation::Vertical,
);
}

View File

@ -128,6 +128,7 @@ impl DrawableComponent for TagListComponent {
&self.theme,
number_of_rows,
table_state.selected().unwrap_or(0),
ui::Orientation::Vertical,
);
self.table_state.set(table_state);

View File

@ -5,6 +5,7 @@ use unicode_width::UnicodeWidthStr;
pub mod emoji;
pub mod filetree;
pub mod logitems;
pub mod scroll_horizontal;
pub mod scroll_vertical;
pub mod statustree;

View File

@ -0,0 +1,140 @@
use std::cell::Cell;
use tui::{backend::Backend, layout::Rect, Frame};
use crate::{
components::HorizontalScrollType,
ui::{draw_scrollbar, style::SharedTheme, Orientation},
};
pub struct HorizontalScroll {
right: Cell<usize>,
max_right: Cell<usize>,
}
impl HorizontalScroll {
pub const fn new() -> Self {
Self {
right: Cell::new(0),
max_right: Cell::new(0),
}
}
pub fn get_right(&self) -> usize {
self.right.get()
}
pub fn reset(&self) {
self.right.set(0);
}
pub fn move_right(
&self,
move_type: HorizontalScrollType,
) -> bool {
let old = self.right.get();
let max = self.max_right.get();
let new_scroll_right = match move_type {
HorizontalScrollType::Left => old.saturating_sub(1),
HorizontalScrollType::Right => old.saturating_add(1),
};
let new_scroll_right = new_scroll_right.clamp(0, max);
if new_scroll_right == old {
return false;
}
self.right.set(new_scroll_right);
true
}
pub fn update(
&self,
selection: usize,
max_selection: usize,
visual_width: usize,
) -> usize {
let new_right = calc_scroll_right(
self.get_right(),
visual_width,
selection,
max_selection,
);
self.right.set(new_right);
if visual_width == 0 {
self.max_right.set(0);
} else {
let new_max_right =
max_selection.saturating_sub(visual_width);
self.max_right.set(new_max_right);
}
new_right
}
pub fn update_no_selection(
&self,
column_count: usize,
visual_width: usize,
) -> usize {
self.update(self.get_right(), column_count, visual_width)
}
pub fn draw<B: Backend>(
&self,
f: &mut Frame<B>,
r: Rect,
theme: &SharedTheme,
) {
draw_scrollbar(
f,
r,
theme,
self.max_right.get(),
self.right.get(),
Orientation::Horizontal,
);
}
}
const fn calc_scroll_right(
current_right: usize,
width_in_lines: usize,
selection: usize,
selection_max: usize,
) -> usize {
if width_in_lines == 0 {
return 0;
}
if selection_max <= width_in_lines {
return 0;
}
if current_right + width_in_lines <= selection {
selection.saturating_sub(width_in_lines) + 1
} else if current_right > selection {
selection
} else {
current_right
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_scroll_no_scroll_to_right() {
assert_eq!(calc_scroll_right(1, 10, 4, 4), 0);
}
#[test]
fn test_scroll_zero_width() {
assert_eq!(calc_scroll_right(4, 0, 4, 3), 0);
}
}

View File

@ -4,7 +4,7 @@ use tui::{backend::Backend, layout::Rect, Frame};
use crate::{
components::ScrollType,
ui::{draw_scrollbar, style::SharedTheme},
ui::{draw_scrollbar, style::SharedTheme, Orientation},
};
pub struct VerticalScroll {
@ -95,6 +95,7 @@ impl VerticalScroll {
theme,
self.max_top.get(),
self.top.get(),
Orientation::Vertical,
);
}
}

View File

@ -1,3 +1,6 @@
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
///
pub fn trim_length_left(s: &str, width: usize) -> &str {
let len = s.len();
@ -21,6 +24,22 @@ pub fn tabs_to_spaces(input: String) -> String {
}
}
/// This function will return a str slice which start at specified offset.
/// As src is a unicode str, start offset has to be calculated with each character.
pub fn trim_offset(src: &str, mut offset: usize) -> &str {
let mut start = 0;
for c in UnicodeSegmentation::graphemes(src, true) {
let w = c.width();
if w <= offset {
offset -= w;
start += c.len();
} else {
break;
}
}
&src[start..]
}
#[cfg(test)]
mod test {
use pretty_assertions::assert_eq;

View File

@ -105,8 +105,8 @@ impl DrawableComponent for Status {
.constraints(
if self.focus == Focus::Diff {
[
Constraint::Percentage(30),
Constraint::Percentage(70),
Constraint::Percentage(0),
Constraint::Percentage(100),
]
} else {
[
@ -674,7 +674,7 @@ impl Status {
let focus_on_diff = self.is_focus_on_diff();
out.push(
CommandInfo::new(
strings::commands::diff_focus_left(&self.key_config),
strings::commands::close_popup(&self.key_config),
true,
(self.visible && focus_on_diff) || force_all,
)
@ -846,7 +846,7 @@ impl Component for Status {
self.switch_focus(Focus::Diff).map(Into::into)
} else if key_match(
k,
self.key_config.keys.focus_left,
self.key_config.keys.exit_popup,
) {
self.switch_focus(match self.diff_target {
DiffTarget::Stage => Focus::Stage,

View File

@ -6,7 +6,7 @@ pub mod style;
mod syntax_text;
use filetreelist::MoveSelection;
pub use scrollbar::draw_scrollbar;
pub use scrollbar::{draw_scrollbar, Orientation};
pub use scrolllist::{draw_list, draw_list_block};
pub use stateful_paragraph::{
ParagraphState, ScrollPos, StatefulParagraph,

View File

@ -1,6 +1,6 @@
use crate::string_utils::trim_offset;
use easy_cast::Cast;
use tui::text::StyledGrapheme;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
const NBSP: &str = "\u{00a0}";
@ -233,22 +233,6 @@ impl<'a, 'b> LineComposer<'a> for LineTruncator<'a, 'b> {
}
}
/// This function will return a str slice which start at specified offset.
/// As src is a unicode str, start offset has to be calculated with each character.
fn trim_offset(src: &str, mut offset: usize) -> &str {
let mut start = 0;
for c in UnicodeSegmentation::graphemes(src, true) {
let w = c.width();
if w <= offset {
offset -= w;
start += c.len();
} else {
break;
}
}
&src[start..]
}
#[cfg(test)]
mod test {
use super::*;

View File

@ -6,32 +6,40 @@ use tui::{
buffer::Buffer,
layout::{Margin, Rect},
style::Style,
symbols::{block::FULL, line::DOUBLE_VERTICAL},
symbols::{
block::FULL,
line::{DOUBLE_HORIZONTAL, DOUBLE_VERTICAL},
},
widgets::Widget,
Frame,
};
pub enum Orientation {
Vertical,
Horizontal,
}
///
struct Scrollbar {
max: u16,
pos: u16,
style_bar: Style,
style_pos: Style,
orientation: Orientation,
}
impl Scrollbar {
fn new(max: usize, pos: usize) -> Self {
fn new(max: usize, pos: usize, orientation: Orientation) -> Self {
Self {
max: u16::try_from(max).unwrap_or_default(),
pos: u16::try_from(pos).unwrap_or_default(),
style_pos: Style::default(),
style_bar: Style::default(),
orientation,
}
}
}
impl Widget for Scrollbar {
fn render(self, area: Rect, buf: &mut Buffer) {
fn render_vertical(self, area: Rect, buf: &mut Buffer) {
if area.height <= 2 {
return;
}
@ -67,6 +75,59 @@ impl Widget for Scrollbar {
buf.set_string(right, bar_top + pos, FULL, self.style_pos);
}
fn render_horizontal(self, area: Rect, buf: &mut Buffer) {
if area.width <= 2 {
return;
}
if self.max == 0 {
return;
}
let bottom = area.bottom().saturating_sub(1);
if bottom <= area.top() {
return;
};
let (bar_left, bar_width) = {
let scrollbar_area = area.inner(&Margin {
horizontal: 1,
vertical: 0,
});
(scrollbar_area.left(), scrollbar_area.width)
};
for x in bar_left..(bar_left + bar_width) {
buf.set_string(
x,
bottom,
DOUBLE_HORIZONTAL,
self.style_bar,
);
}
let progress = f32::from(self.pos) / f32::from(self.max);
let progress = if progress > 1.0 { 1.0 } else { progress };
let pos = f32::from(bar_width) * progress;
let pos: u16 = pos.cast_nearest();
let pos = pos.saturating_sub(1);
buf.set_string(bar_left + pos, bottom, FULL, self.style_pos);
}
}
impl Widget for Scrollbar {
fn render(self, area: Rect, buf: &mut Buffer) {
match &self.orientation {
Orientation::Vertical => self.render_vertical(area, buf),
Orientation::Horizontal => {
self.render_horizontal(area, buf);
}
}
}
}
pub fn draw_scrollbar<B: Backend>(
@ -75,8 +136,9 @@ pub fn draw_scrollbar<B: Backend>(
theme: &SharedTheme,
max: usize,
pos: usize,
orientation: Orientation,
) {
let mut widget = Scrollbar::new(max, pos);
let mut widget = Scrollbar::new(max, pos, orientation);
widget.style_pos = theme.scroll_bar_pos();
f.render_widget(widget, r);
}