diff --git a/Cargo.lock b/Cargo.lock index 52a1846123..99d33237bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -171,7 +171,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bdfa80d47f954d53a35a64987ca1422f495b8d6483c0fe9f7117b36c2a792129" dependencies = [ "bitflags", - "textwrap", + "textwrap 0.11.0", "unicode-width", ] @@ -422,6 +422,7 @@ dependencies = [ "scopetime", "serde", "simplelog", + "textwrap 0.12.1", "tui", "unicode-width", ] @@ -1130,6 +1131,15 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "textwrap" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "203008d98caf094106cfaba70acfed15e18ed3ddb7d94e49baec153a2b462789" +dependencies = [ + "unicode-width", +] + [[package]] name = "thiserror" version = "1.0.20" diff --git a/Cargo.toml b/Cargo.toml index 110b229880..d7ea0ee358 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ ron = "0.6" serde = "1.0" anyhow = "1.0.32" unicode-width = "0.1" +textwrap = "0.12" [target.'cfg(not(windows))'.dependencies] pprof = { version = "0.3", features = ["flamegraph"], optional = true } diff --git a/asyncgit/src/sync/commit_details.rs b/asyncgit/src/sync/commit_details.rs index da1941477d..4f65ad7a07 100644 --- a/asyncgit/src/sync/commit_details.rs +++ b/asyncgit/src/sync/commit_details.rs @@ -35,6 +35,7 @@ pub struct CommitMessage { } impl CommitMessage { + /// pub fn from(s: &str) -> Self { if let Some(idx) = s.find('\n') { let (first, rest) = s.split_at(idx); diff --git a/asyncgit/src/sync/mod.rs b/asyncgit/src/sync/mod.rs index 382905b9f8..20fbac1071 100644 --- a/asyncgit/src/sync/mod.rs +++ b/asyncgit/src/sync/mod.rs @@ -19,7 +19,9 @@ pub mod utils; pub(crate) use branch::get_branch_name; pub use commit::{amend, commit, tag}; -pub use commit_details::{get_commit_details, CommitDetails}; +pub use commit_details::{ + get_commit_details, CommitDetails, CommitMessage, +}; pub use commit_files::get_commit_files; pub use commits_info::{get_commits_info, CommitId, CommitInfo}; pub use diff::get_diff_commit; diff --git a/src/components/commit_details/details.rs b/src/components/commit_details/details.rs index 075e45e1b1..44b0a798fc 100644 --- a/src/components/commit_details/details.rs +++ b/src/components/commit_details/details.rs @@ -1,24 +1,25 @@ use crate::{ components::{ dialog_paragraph, utils::time_to_string, CommandBlocking, - CommandInfo, Component, DrawableComponent, + CommandInfo, Component, DrawableComponent, ScrollType, }, - strings, + keys, + strings::{self, commands, order}, ui::style::SharedTheme, }; use anyhow::Result; use asyncgit::{ - sync::{self, CommitDetails, CommitId}, + sync::{self, CommitDetails, CommitId, CommitMessage}, CWD, }; use crossterm::event::Event; use itertools::Itertools; -use std::borrow::Cow; +use std::{borrow::Cow, cell::Cell}; use sync::CommitTags; use tui::{ backend::Backend, layout::{Constraint, Direction, Layout, Rect}, - style::Modifier, + style::{Modifier, Style}, widgets::Text, Frame, }; @@ -27,15 +28,24 @@ pub struct DetailsComponent { data: Option, tags: Vec, theme: SharedTheme, + focused: bool, + current_size: Cell<(u16, u16)>, + scroll_top: Cell, } +type WrappedCommitMessage<'a> = + (Vec>, Vec>); + impl DetailsComponent { /// - pub const fn new(theme: SharedTheme) -> Self { + pub const fn new(theme: SharedTheme, focused: bool) -> Self { Self { data: None, tags: Vec::new(), theme, + focused, + current_size: Cell::new((0, 0)), + scroll_top: Cell::new(0), } } @@ -52,6 +62,8 @@ impl DetailsComponent { None }; + self.scroll_top.set(0); + if let Some(tags) = tags { self.tags.extend(tags) } @@ -59,27 +71,80 @@ impl DetailsComponent { Ok(()) } - fn get_text_message(&self) -> Vec { + fn wrap_commit_details( + message: &CommitMessage, + width: usize, + ) -> WrappedCommitMessage<'_> { + let wrapped_title = textwrap::wrap(&message.subject, width); + + if let Some(ref body) = message.body { + let wrapped_message: Vec> = + textwrap::wrap(body, width) + .into_iter() + .skip(1) + .collect(); + + (wrapped_title, wrapped_message) + } else { + (wrapped_title, vec![]) + } + } + + fn get_wrapped_lines( + &self, + width: usize, + ) -> WrappedCommitMessage<'_> { if let Some(ref data) = self.data { if let Some(ref message) = data.message { - let mut res = vec![Text::Styled( - Cow::from(message.subject.clone()), - self.theme - .text(true, false) - .modifier(Modifier::BOLD), - )]; - - if let Some(ref body) = message.body { - res.push(Text::Styled( - Cow::from(body), - self.theme.text(true, false), - )); - } - - return res; + return Self::wrap_commit_details(message, width); } } - vec![] + + (vec![], vec![]) + } + + fn get_number_of_lines(&self, width: usize) -> usize { + let (wrapped_title, wrapped_message) = + self.get_wrapped_lines(width); + + wrapped_title.len() + wrapped_message.len() + } + + fn get_theme_for_line(&self, bold: bool) -> Style { + if bold { + self.theme.text(true, false).modifier(Modifier::BOLD) + } else { + self.theme.text(true, false) + } + } + + fn get_wrapped_text_message( + &self, + width: usize, + height: usize, + ) -> Vec { + let newline = Text::Styled( + String::from("\n").into(), + self.theme.text(true, false), + ); + + let (wrapped_title, wrapped_message) = + self.get_wrapped_lines(width); + + [&wrapped_title[..], &wrapped_message[..]] + .concat() + .iter() + .enumerate() + .skip(self.scroll_top.get()) + .take(height) + .map(|(i, line)| { + Text::Styled( + line.clone(), + self.get_theme_for_line(i < wrapped_title.len()), + ) + }) + .intersperse(newline) + .collect() } fn get_text_info(&self) -> Vec { @@ -181,6 +246,38 @@ impl DetailsComponent { vec![] } } + + fn move_scroll_top( + &mut self, + move_type: ScrollType, + ) -> Result { + if self.data.is_some() { + let old = self.scroll_top.get(); + let width = self.current_size.get().0 as usize; + let height = self.current_size.get().1 as usize; + + let number_of_lines = self.get_number_of_lines(width); + + let max = number_of_lines.saturating_sub(height) as usize; + + let new_scroll_top = match move_type { + ScrollType::Down => old.saturating_add(1), + ScrollType::Up => old.saturating_sub(1), + ScrollType::Home => 0, + ScrollType::End => max, + _ => old, + }; + + if new_scroll_top > max { + return Ok(false); + } + + self.scroll_top.set(new_scroll_top); + + return Ok(true); + } + Ok(false) + } } impl DrawableComponent for DetailsComponent { @@ -206,14 +303,27 @@ impl DrawableComponent for DetailsComponent { chunks[0], ); + // We have to take the border into account which is one character on + // each side. + let border_width: u16 = 2; + + let width = chunks[1].width.saturating_sub(border_width); + let height = chunks[1].height.saturating_sub(border_width); + + self.current_size.set((width, height)); + + let wrapped_lines = self.get_wrapped_text_message( + width as usize, + height as usize, + ); + f.render_widget( dialog_paragraph( strings::commit::DETAILS_MESSAGE_TITLE, - self.get_text_message().iter(), + wrapped_lines.iter(), &self.theme, - false, - ) - .wrap(true), + self.focused, + ), chunks[1], ); @@ -224,14 +334,122 @@ impl DrawableComponent for DetailsComponent { impl Component for DetailsComponent { fn commands( &self, - _out: &mut Vec, - _force_all: bool, + out: &mut Vec, + force_all: bool, ) -> CommandBlocking { // visibility_blocking(self) + + let width = self.current_size.get().0 as usize; + let number_of_lines = self.get_number_of_lines(width); + + out.push( + CommandInfo::new( + commands::NAVIGATE_COMMIT_MESSAGE, + number_of_lines > 0, + self.focused || force_all, + ) + .order(order::NAV), + ); + CommandBlocking::PassingOn } - fn event(&mut self, _ev: Event) -> Result { + fn event(&mut self, event: Event) -> Result { + if self.focused { + if let Event::Key(e) = event { + return match e { + keys::MOVE_UP => { + self.move_scroll_top(ScrollType::Up) + } + keys::MOVE_DOWN => { + self.move_scroll_top(ScrollType::Down) + } + keys::HOME | keys::SHIFT_UP => { + self.move_scroll_top(ScrollType::Home) + } + keys::END | keys::SHIFT_DOWN => { + self.move_scroll_top(ScrollType::End) + } + _ => Ok(false), + }; + } + } + Ok(false) } + + fn focused(&self) -> bool { + self.focused + } + + fn focus(&mut self, focus: bool) { + if focus { + let width = self.current_size.get().0 as usize; + let height = self.current_size.get().1 as usize; + + self.scroll_top.set( + self.get_number_of_lines(width) + .saturating_sub(height), + ); + } + + self.focused = focus; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn get_wrapped_lines( + message: &CommitMessage, + width: usize, + ) -> Vec> { + let (wrapped_title, wrapped_message) = + DetailsComponent::wrap_commit_details(&message, width); + + [&wrapped_title[..], &wrapped_message[..]].concat() + } + + #[test] + fn test_textwrap() { + let message = CommitMessage::from("Commit message"); + + assert_eq!( + get_wrapped_lines(&message, 7), + vec!["Commit", "message"] + ); + assert_eq!( + get_wrapped_lines(&message, 14), + vec!["Commit message"] + ); + + let message_with_newline = + CommitMessage::from("Commit message\n"); + + assert_eq!( + get_wrapped_lines(&message_with_newline, 7), + vec!["Commit", "message"] + ); + assert_eq!( + get_wrapped_lines(&message_with_newline, 14), + vec!["Commit message"] + ); + + let message_with_body = CommitMessage::from( + "Commit message\n\nFirst line\nSecond line", + ); + + assert_eq!( + get_wrapped_lines(&message_with_body, 7), + vec![ + "Commit", "message", "", "First", "line", "Second", + "line" + ] + ); + assert_eq!( + get_wrapped_lines(&message_with_body, 14), + vec!["Commit message", "", "First line", "Second line"] + ); + } } diff --git a/src/components/commit_details/mod.rs b/src/components/commit_details/mod.rs index 36d906eb6c..044f680481 100644 --- a/src/components/commit_details/mod.rs +++ b/src/components/commit_details/mod.rs @@ -5,7 +5,7 @@ use super::{ Component, DrawableComponent, FileTreeComponent, }; use crate::{ - accessors, queue::Queue, strings, ui::style::SharedTheme, + accessors, keys, queue::Queue, strings, ui::style::SharedTheme, }; use anyhow::Result; use asyncgit::{ @@ -38,7 +38,7 @@ impl CommitDetailsComponent { theme: SharedTheme, ) -> Self { Self { - details: DetailsComponent::new(theme.clone()), + details: DetailsComponent::new(theme.clone(), false), git_commit_files: AsyncCommitFiles::new(sender), file_tree: FileTreeComponent::new( "", @@ -146,6 +146,28 @@ impl Component for CommitDetailsComponent { return Ok(true); } + if self.focused() { + if let Event::Key(e) = ev { + return match e { + keys::FOCUS_BELOW if (self.details.focused()) => { + self.details.focus(false); + self.file_tree.focus(true); + + return Ok(true); + } + keys::FOCUS_ABOVE + if (self.file_tree.focused()) => + { + self.file_tree.focus(false); + self.details.focus(true); + + return Ok(true); + } + _ => Ok(false), + }; + } + } + Ok(false) } @@ -164,6 +186,7 @@ impl Component for CommitDetailsComponent { self.details.focused() || self.file_tree.focused() } fn focus(&mut self, focus: bool) { + self.details.focus(false); self.file_tree.focus(focus); self.file_tree.show_selection(true); } diff --git a/src/components/filetree.rs b/src/components/filetree.rs index 21eb1e5184..254f217361 100644 --- a/src/components/filetree.rs +++ b/src/components/filetree.rs @@ -354,5 +354,6 @@ impl Component for FileTreeComponent { } fn focus(&mut self, focus: bool) { self.focused = focus; + self.show_selection(focus); } } diff --git a/src/keys.rs b/src/keys.rs index b6f3ef479b..ab8d930a8b 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -27,6 +27,8 @@ pub const FOCUS_WORKDIR: KeyEvent = no_mod(KeyCode::Char('w')); pub const FOCUS_STAGE: KeyEvent = no_mod(KeyCode::Char('s')); pub const FOCUS_RIGHT: KeyEvent = no_mod(KeyCode::Right); pub const FOCUS_LEFT: KeyEvent = no_mod(KeyCode::Left); +pub const FOCUS_ABOVE: KeyEvent = no_mod(KeyCode::Up); +pub const FOCUS_BELOW: KeyEvent = no_mod(KeyCode::Down); pub const EXIT: KeyEvent = with_mod(KeyCode::Char('c'), KeyModifiers::CONTROL); pub const EXIT_POPUP: KeyEvent = no_mod(KeyCode::Esc); diff --git a/src/strings.rs b/src/strings.rs index e4be0bf067..7edaadea5f 100644 --- a/src/strings.rs +++ b/src/strings.rs @@ -86,6 +86,13 @@ pub mod commands { CMD_GROUP_GENERAL, ); /// + pub static NAVIGATE_COMMIT_MESSAGE: CommandText = + CommandText::new( + "Nav [\u{2191}\u{2193}]", + "navigate commit message", + CMD_GROUP_GENERAL, + ); + /// pub static NAVIGATE_TREE: CommandText = CommandText::new( "Nav [\u{2190}\u{2191}\u{2192}\u{2193}]", "navigate tree view",