diff --git a/CHANGELOG.md b/CHANGELOG.md index 503dd7b09b..edf9bb57ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) diff --git a/src/components/blame_file.rs b/src/components/blame_file.rs index 9f41df88f8..afc3d135bf 100644 --- a/src/components/blame_file.rs +++ b/src/components/blame_file.rs @@ -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); diff --git a/src/components/commitlist.rs b/src/components/commitlist.rs index e58977c278..2d2a42b3f1 100644 --- a/src/components/commitlist.rs +++ b/src/components/commitlist.rs @@ -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(()) diff --git a/src/components/compare_commits.rs b/src/components/compare_commits.rs index c4048a9d9e..11ae5c3ea6 100644 --- a/src/components/compare_commits.rs +++ b/src/components/compare_commits.rs @@ -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); } diff --git a/src/components/diff.rs b/src/components/diff.rs index cbc27e8737..6a1500338a 100644 --- a/src/components/diff.rs +++ b/src/components/diff.rs @@ -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, + longest_line: usize, pending: bool, selection: Selection, selected_hunk: Option, 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, diff --git a/src/components/file_revlog.rs b/src/components/file_revlog.rs index cabe364f1b..6e679d6f20 100644 --- a/src/components/file_revlog.rs +++ b/src/components/file_revlog.rs @@ -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); diff --git a/src/components/inspect_commit.rs b/src/components/inspect_commit.rs index 035f0464bf..416a8ab668 100644 --- a/src/components/inspect_commit.rs +++ b/src/components/inspect_commit.rs @@ -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); } diff --git a/src/components/mod.rs b/src/components/mod.rs index fb42dbec93..8d1b5114d8 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -185,6 +185,12 @@ pub enum ScrollType { PageDown, } +#[derive(Copy, Clone)] +pub enum HorizontalScrollType { + Left, + Right, +} + #[derive(Copy, Clone)] pub enum Direction { Up, diff --git a/src/components/syntax_text.rs b/src/components/syntax_text.rs index bb0b39f886..4b20a89f3e 100644 --- a/src/components/syntax_text.rs +++ b/src/components/syntax_text.rs @@ -238,6 +238,7 @@ impl DrawableComponent for SyntaxTextComponent { state.height().saturating_sub(2), )), usize::from(state.scroll().y), + ui::Orientation::Vertical, ); } diff --git a/src/components/taglist.rs b/src/components/taglist.rs index f2c0dfb31d..2020d8a9fa 100644 --- a/src/components/taglist.rs +++ b/src/components/taglist.rs @@ -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); diff --git a/src/components/utils/mod.rs b/src/components/utils/mod.rs index d003f9588f..06f07bae22 100644 --- a/src/components/utils/mod.rs +++ b/src/components/utils/mod.rs @@ -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; diff --git a/src/components/utils/scroll_horizontal.rs b/src/components/utils/scroll_horizontal.rs new file mode 100644 index 0000000000..58ea31a77d --- /dev/null +++ b/src/components/utils/scroll_horizontal.rs @@ -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, + max_right: Cell, +} + +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( + &self, + f: &mut Frame, + 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); + } +} diff --git a/src/components/utils/scroll_vertical.rs b/src/components/utils/scroll_vertical.rs index 54fa7e1bb9..a8a62a0697 100644 --- a/src/components/utils/scroll_vertical.rs +++ b/src/components/utils/scroll_vertical.rs @@ -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, ); } } diff --git a/src/string_utils.rs b/src/string_utils.rs index 065f5554c5..c2cb6808a4 100644 --- a/src/string_utils.rs +++ b/src/string_utils.rs @@ -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; diff --git a/src/tabs/status.rs b/src/tabs/status.rs index b7b3d5996e..84085a1991 100644 --- a/src/tabs/status.rs +++ b/src/tabs/status.rs @@ -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, diff --git a/src/ui/mod.rs b/src/ui/mod.rs index eac3e3b602..4b1db6ebd1 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -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, diff --git a/src/ui/reflow.rs b/src/ui/reflow.rs index a9937361e6..81de4b840c 100644 --- a/src/ui/reflow.rs +++ b/src/ui/reflow.rs @@ -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::*; diff --git a/src/ui/scrollbar.rs b/src/ui/scrollbar.rs index adf4dec1d9..3a28fe8213 100644 --- a/src/ui/scrollbar.rs +++ b/src/ui/scrollbar.rs @@ -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( @@ -75,8 +136,9 @@ pub fn draw_scrollbar( 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); }