diff --git a/src/app.rs b/src/app.rs index 70e803c740..ca58f5021a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -518,7 +518,7 @@ impl App { } InternalEvent::RenameBranch(branch_ref, cur_name) => { self.rename_branch_popup - .open(branch_ref, cur_name)?; + .open(branch_ref, &cur_name)?; } InternalEvent::SelectBranch => { self.select_branch_popup.open()?; diff --git a/src/components/commit.rs b/src/components/commit.rs index 708aeb1c40..0c1feacacb 100644 --- a/src/components/commit.rs +++ b/src/components/commit.rs @@ -183,14 +183,14 @@ impl CommitComponent { let message = message.trim().to_string(); - self.input.set_text(message); + self.input.set_text(&message); self.input.show()?; Ok(()) } fn commit(&mut self) -> Result<()> { - self.commit_msg(self.input.get_text().clone()) + self.commit_msg(self.input.get_text()) } fn commit_msg(&mut self, msg: String) -> Result<()> { @@ -272,7 +272,7 @@ impl CommitComponent { .set_title(strings::commit_title_amend(&self.key_config)); if let Some(msg) = details.message { - self.input.set_text(msg.combine()); + self.input.set_text(&msg.combine()); } Ok(()) diff --git a/src/components/cred.rs b/src/components/cred.rs index 8f0d526aa6..8f1d451de3 100644 --- a/src/components/cred.rs +++ b/src/components/cred.rs @@ -111,11 +111,7 @@ impl Component for CredComponent { } else if e == self.key_config.enter { if self.input_username.is_visible() { self.cred = BasicAuthCredential::new( - Some( - self.input_username - .get_text() - .to_owned(), - ), + Some(self.input_username.get_text()), None, ); self.input_username.hide(); @@ -123,11 +119,7 @@ impl Component for CredComponent { } else if self.input_password.is_visible() { self.cred = BasicAuthCredential::new( self.cred.username.clone(), - Some( - self.input_password - .get_text() - .to_owned(), - ), + Some(self.input_password.get_text()), ); self.input_password.hide(); self.input_password.clear(); diff --git a/src/components/mod.rs b/src/components/mod.rs index aa73b68661..4f8aa2b0ea 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -207,11 +207,11 @@ fn dialog_paragraph<'a>( fn popup_paragraph<'a>( title: &'a str, - content: Vec>, + content: Vec>, theme: &Theme, focused: bool, ) -> Paragraph<'a> { - Paragraph::new(Spans::from(content)) + Paragraph::new(content) .block( Block::default() .title(Span::styled(title, theme.title(focused))) diff --git a/src/components/rename_branch.rs b/src/components/rename_branch.rs index 81a9cda78b..801720c64a 100644 --- a/src/components/rename_branch.rs +++ b/src/components/rename_branch.rs @@ -112,7 +112,7 @@ impl RenameBranchComponent { pub fn open( &mut self, branch_ref: String, - cur_name: String, + cur_name: &str, ) -> Result<()> { self.branch_ref = None; self.branch_ref = Some(branch_ref); diff --git a/src/components/reset.rs b/src/components/reset.rs index da275c5948..220e979db3 100644 --- a/src/components/reset.rs +++ b/src/components/reset.rs @@ -11,7 +11,8 @@ use anyhow::Result; use crossterm::event::Event; use std::borrow::Cow; use tui::{ - backend::Backend, layout::Rect, text::Span, widgets::Clear, Frame, + backend::Backend, layout::Rect, text::Span, text::Spans, + widgets::Clear, Frame, }; use ui::style::SharedTheme; @@ -33,10 +34,10 @@ impl DrawableComponent for ResetComponent { if self.visible { let (title, msg) = self.get_text(); - let txt = vec![Span::styled( + let txt = vec![Spans::from(Span::styled( Cow::from(msg), self.theme.text_danger(), - )]; + ))]; let area = ui::centered_rect(30, 20, f.size()); f.render_widget(Clear, area); diff --git a/src/components/stashmsg.rs b/src/components/stashmsg.rs index 8b3e01ff7e..05cb69b1e9 100644 --- a/src/components/stashmsg.rs +++ b/src/components/stashmsg.rs @@ -62,12 +62,14 @@ impl Component for StashMsgComponent { if let Event::Key(e) = ev { if e == self.key_config.enter { + let msg = self.input.get_text(); + match sync::stash_save( CWD, - if self.input.get_text().is_empty() { + if msg.is_empty() { None } else { - Some(self.input.get_text().as_str()) + Some(&msg) }, self.options.stash_untracked, self.options.keep_index, diff --git a/src/components/tag_commit.rs b/src/components/tag_commit.rs index 063871b393..50bf84c1e9 100644 --- a/src/components/tag_commit.rs +++ b/src/components/tag_commit.rs @@ -119,7 +119,7 @@ impl TagCommitComponent { /// pub fn tag(&mut self) { if let Some(commit_id) = self.commit_id { - match sync::tag(CWD, &commit_id, self.input.get_text()) { + match sync::tag(CWD, &commit_id, &self.input.get_text()) { Ok(_) => { self.input.clear(); self.hide(); diff --git a/src/components/textinput.rs b/src/components/textinput.rs index 50c625751d..9ec0396680 100644 --- a/src/components/textinput.rs +++ b/src/components/textinput.rs @@ -11,10 +11,11 @@ use crate::{ use anyhow::Result; use crossterm::event::{Event, KeyCode, KeyModifiers}; use itertools::Itertools; +use std::cell::Cell; use std::ops::Range; use tui::{ backend::Backend, layout::Rect, style::Modifier, text::Span, - widgets::Clear, Frame, + text::Spans, widgets::Clear, Frame, }; #[derive(PartialEq)] @@ -28,12 +29,15 @@ pub enum InputType { pub struct TextInputComponent { title: String, default_msg: String, - msg: String, + msg: Vec, visible: bool, theme: SharedTheme, key_config: SharedKeyConfig, cursor_position: usize, + current_line: usize, input_type: InputType, + num_lines: Cell, + display_off: usize, } impl TextInputComponent { @@ -45,14 +49,18 @@ impl TextInputComponent { default_msg: &str, ) -> Self { Self { - msg: String::default(), + msg: vec![String::default()], visible: false, theme, key_config, title: title.to_string(), default_msg: default_msg.to_string(), cursor_position: 0, + current_line: 0, input_type: InputType::Multiline, + // tests need a few lines + num_lines: Cell::new(5), + display_off: 0, } } @@ -66,58 +74,124 @@ impl TextInputComponent { /// Clear the `msg`. pub fn clear(&mut self) { - self.msg.clear(); + self.msg = vec![String::default()]; self.cursor_position = 0; + self.current_line = 0; } /// Get the `msg`. - pub const fn get_text(&self) -> &String { - &self.msg + pub fn get_text(&self) -> String { + self.msg.join("\n") } /// Move the cursor right one char. fn incr_cursor(&mut self) { if let Some(pos) = self.next_char_position() { self.cursor_position = pos; + } else { + self.next_line(0); + } + } + fn prev_line(&mut self, cursor: usize) { + if self.current_line > 0 { + self.current_line -= 1; + self.cursor_position = std::cmp::min( + cursor, + self.msg[self.current_line].len(), + ); + if self.current_line == self.display_off + && self.display_off > 0 + { + self.display_off -= 1; + } + } + } + fn next_line(&mut self, cursor: usize) { + if self.current_line < self.msg.len() - 1 { + self.current_line += 1; + self.cursor_position = std::cmp::min( + cursor, + self.msg[self.current_line].len(), + ); + if self.current_line > self.num_lines.get() - 1 { + self.display_off += 1; + } } } - /// Move the cursor left one char. fn decr_cursor(&mut self) { - let mut index = self.cursor_position.saturating_sub(1); - while index > 0 && !self.msg.is_char_boundary(index) { - index -= 1; + if self.cursor_position == 0 { + self.prev_line(std::usize::MAX); + } else { + let mut index = self.cursor_position.saturating_sub(1); + while index > 0 + && !self.msg[self.current_line] + .is_char_boundary(index) + { + index -= 1; + } + self.cursor_position = index; } - self.cursor_position = index; } /// Get the position of the next char, or, if the cursor points /// to the last char, the `msg.len()`. /// Returns None when the cursor is already at `msg.len()`. fn next_char_position(&self) -> Option { - if self.cursor_position >= self.msg.len() { + if self.cursor_position >= self.msg[self.current_line].len() { return None; } let mut index = self.cursor_position.saturating_add(1); while index < self.msg.len() - && !self.msg.is_char_boundary(index) + && !self.msg[self.current_line].is_char_boundary(index) { index += 1; } Some(index) } + fn line_up(&mut self) { + self.prev_line(self.cursor_position); + } + fn line_down(&mut self) { + self.next_line(self.cursor_position); + } + + fn split_line(&mut self) { + self.msg[self.current_line] + .insert(self.cursor_position, '\n'); + let cl = self.current_line; + self.set_text(&self.get_text()); + self.current_line = cl; + self.next_line(0); + } + fn merge_lines(&mut self) { + let next_line = self.msg[self.current_line + 1].clone(); + self.msg.remove(self.current_line + 1); + self.msg[self.current_line].push_str(&next_line); + } + fn delete_char(&mut self) { + if self.cursor_position < self.msg[self.current_line].len() { + self.msg[self.current_line].remove(self.cursor_position); + } else if self.msg.len() + 1 > self.current_line { + self.merge_lines(); + } + } fn backspace(&mut self) { if self.cursor_position > 0 { self.decr_cursor(); - self.msg.remove(self.cursor_position); + self.msg[self.current_line].remove(self.cursor_position); + } else if self.current_line > 0 { + self.prev_line(std::usize::MAX); + self.merge_lines(); } } /// Set the `msg`. - pub fn set_text(&mut self, msg: String) { - self.msg = msg; + pub fn set_text(&mut self, msg: &str) { + self.msg = msg.split('\n').map(ToString::to_string).collect(); self.cursor_position = 0; + self.current_line = 0; } /// Set the `title`. @@ -125,59 +199,80 @@ impl TextInputComponent { self.title = t; } - fn get_draw_text(&self) -> Vec { + fn get_draw_text(&self) -> Vec { let style = self.theme.text(true, false); + let mut spans = Vec::new(); - let mut txt = Vec::new(); - // The portion of the text before the cursor is added - // if the cursor is not at the first character. - if self.cursor_position > 0 { - txt.push(Span::styled( - self.get_msg(0..self.cursor_position), - style, - )); - } - - let cursor_str = self - .next_char_position() - // if the cursor is at the end of the msg - // a whitespace is used to underline - .map_or(" ".to_owned(), |pos| { - self.get_msg(self.cursor_position..pos) - }); - - if cursor_str == "\n" { - txt.push(Span::styled( - "\u{21b5}", - self.theme - .text(false, false) - .add_modifier(Modifier::UNDERLINED), - )); - } - - txt.push(Span::styled( - cursor_str, - style.add_modifier(Modifier::UNDERLINED), - )); + for i in self.display_off + ..std::cmp::min( + self.num_lines.get() + self.display_off, + self.msg.len(), + ) + { + let mut txt = Vec::new(); + + if i == self.current_line { + // The portion of the text before the cursor is added + // if the cursor is not at the first character. + if self.cursor_position > 0 { + txt.push(Span::styled( + self.get_msg(0..self.cursor_position), + style, + )); + } - // The final portion of the text is added if there are - // still remaining characters. - if let Some(pos) = self.next_char_position() { - if pos < self.msg.len() { - txt.push(Span::styled( - self.get_msg(pos..self.msg.len()), - style, - )); + // this code with _ for the trailing cursor character needs to be revised once tui fixes + // https://github.com/fdehau/tui-rs/issues/404 + // it should be NBSP => const NBSP: &str = "\u{00a0}"; + // ... + let cursor_str = self + .next_char_position() + // if the cursor is at the end of the msg + // a whitespace is used to underline + .map_or("_".to_owned(), |pos| { + self.get_msg(self.cursor_position..pos) + }); + + if cursor_str == "\n" { + txt.push(Span::styled( + "\u{21b5}", + self.theme + .text(false, false) + .add_modifier(Modifier::UNDERLINED), + )); + } + // ... and this conditional underline needs to be removed + + if cursor_str == "_" { + txt.push(Span::styled(cursor_str, style)); + } else { + txt.push(Span::styled( + cursor_str, + style.add_modifier(Modifier::UNDERLINED), + )); + } + // The final portion of the text is added if there are + // still remaining characters. + if let Some(pos) = self.next_char_position() { + if pos < self.msg[i].len() { + txt.push(Span::styled( + self.get_msg(pos..self.msg[i].len()), + style, + )); + } + } + } else { + txt = vec![Span::raw(self.msg[i].clone())]; } + spans.push(Spans::from(txt)); } - - txt + spans } fn get_msg(&self, range: Range) -> String { match self.input_type { InputType::Password => range.map(|_| "*").join(""), - _ => self.msg[range].to_owned(), + _ => self.msg[self.current_line][range].to_owned(), } } } @@ -190,10 +285,10 @@ impl DrawableComponent for TextInputComponent { ) -> Result<()> { if self.visible { let txt = if self.msg.is_empty() { - vec![Span::styled( + vec![Spans::from(Span::styled( self.default_msg.as_str(), self.theme.text(false, false), - )] + ))] } else { self.get_draw_text() }; @@ -209,7 +304,7 @@ impl DrawableComponent for TextInputComponent { } _ => ui::centered_rect_absolute(32, 3, f.size()), }; - + self.num_lines.set(area.height as usize - 2); f.render_widget(Clear, area); f.render_widget( popup_paragraph( @@ -246,24 +341,29 @@ impl Component for TextInputComponent { fn event(&mut self, ev: Event) -> Result { if self.visible { if let Event::Key(e) = ev { + let is_ctrl = + e.modifiers.contains(KeyModifiers::CONTROL); + if (e.code == KeyCode::Enter + || e.code == KeyCode::Char('j')) + && is_ctrl + { + self.split_line(); + return Ok(true); + } if e == self.key_config.exit_popup { self.hide(); return Ok(true); } - let is_ctrl = - e.modifiers.contains(KeyModifiers::CONTROL); - match e.code { KeyCode::Char(c) if !is_ctrl => { - self.msg.insert(self.cursor_position, c); + self.msg[self.current_line] + .insert(self.cursor_position, c); self.incr_cursor(); return Ok(true); } KeyCode::Delete => { - if self.cursor_position < self.msg.len() { - self.msg.remove(self.cursor_position); - } + self.delete_char(); return Ok(true); } KeyCode::Backspace => { @@ -278,12 +378,22 @@ impl Component for TextInputComponent { self.incr_cursor(); return Ok(true); } + KeyCode::Up => { + self.line_up(); + return Ok(true); + } + KeyCode::Down => { + self.line_down(); + return Ok(true); + } + KeyCode::Home => { self.cursor_position = 0; return Ok(true); } KeyCode::End => { - self.cursor_position = self.msg.len(); + self.cursor_position = + self.msg[self.current_line].len(); return Ok(true); } _ => (), @@ -322,7 +432,7 @@ mod tests { "", ); - comp.set_text(String::from("a\nb")); + comp.set_text("a\nb"); assert_eq!(comp.cursor_position, 0); @@ -346,13 +456,13 @@ mod tests { .text(true, false) .add_modifier(Modifier::UNDERLINED); - comp.set_text(String::from("a")); + comp.set_text("a"); - let txt = comp.get_draw_text(); + let txt = &comp.get_draw_text()[0]; - assert_eq!(txt.len(), 1); - assert_eq!(get_text(&txt[0]), Some("a")); - assert_eq!(get_style(&txt[0]), Some(&underlined)); + assert_eq!(txt.width(), 1); + assert_eq!(get_text(&txt.0[0]), Some("a")); + assert_eq!(get_style(&txt.0[0]), Some(&underlined)); } #[test] @@ -364,22 +474,24 @@ mod tests { "", ); let theme = SharedTheme::default(); - let underlined = theme + + // retained for when tui trailing NBSP bug fixed + let _underlined = theme .text(true, false) .add_modifier(Modifier::UNDERLINED); let not_underlined = Style::default(); - comp.set_text(String::from("a")); + comp.set_text("a"); comp.incr_cursor(); - let txt = comp.get_draw_text(); + let txt = &comp.get_draw_text()[0]; - assert_eq!(txt.len(), 2); - assert_eq!(get_text(&txt[0]), Some("a")); - assert_eq!(get_style(&txt[0]), Some(¬_underlined)); - assert_eq!(get_text(&txt[1]), Some(" ")); - assert_eq!(get_style(&txt[1]), Some(&underlined)); + assert_eq!(txt.width(), 2); + assert_eq!(get_text(&txt.0[0]), Some("a")); + assert_eq!(get_style(&txt.0[0]), Some(¬_underlined)); + assert_eq!(get_text(&txt.0[1]), Some("_")); + assert_eq!(get_style(&txt.0[1]), Some(¬_underlined)); } #[test] @@ -392,25 +504,26 @@ mod tests { ); let theme = SharedTheme::default(); - let underlined = theme + let _underlined = theme .text(false, false) .add_modifier(Modifier::UNDERLINED); - comp.set_text(String::from("a\nb")); + comp.set_text("a\nb"); comp.incr_cursor(); - let txt = comp.get_draw_text(); + let txt = &comp.get_draw_text(); - assert_eq!(txt.len(), 4); - assert_eq!(get_text(&txt[0]), Some("a")); - assert_eq!(get_text(&txt[1]), Some("\u{21b5}")); - assert_eq!(get_style(&txt[1]), Some(&underlined)); - assert_eq!(get_text(&txt[2]), Some("\n")); - assert_eq!(get_text(&txt[3]), Some("b")); + assert_eq!(txt.len(), 2); + let l1_spans = &txt[0].0; + let l2_spans = &txt[1].0; + assert_eq!(get_text(&l1_spans[0]), Some("a")); + assert_eq!(get_text(&l1_spans[1]), Some("_")); + //assert_eq!(get_style(&l1_spans[1]), Some(&underlined)); + assert_eq!(get_text(&l2_spans[0]), Some("b")); } #[test] - fn test_invisable_newline() { + fn test_invisible_newline() { let mut comp = TextInputComponent::new( SharedTheme::default(), SharedKeyConfig::default(), @@ -423,14 +536,16 @@ mod tests { .text(true, false) .add_modifier(Modifier::UNDERLINED); - comp.set_text(String::from("a\nb")); - - let txt = comp.get_draw_text(); + comp.set_text("a\nb"); + let txt = &comp.get_draw_text(); assert_eq!(txt.len(), 2); - assert_eq!(get_text(&txt[0]), Some("a")); - assert_eq!(get_style(&txt[0]), Some(&underlined)); - assert_eq!(get_text(&txt[1]), Some("\nb")); + let l1_spans = &txt[0].0; + let l2_spans = &txt[1].0; + + assert_eq!(get_text(&l1_spans[0]), Some("a")); + assert_eq!(get_style(&l1_spans[0]), Some(&underlined)); + assert_eq!(get_text(&l2_spans[0]), Some("b")); } fn get_text<'a>(t: &'a Span) -> Option<&'a str> {