Multiline TextEdit cleanups (#2053)

* change commit default binding
* animated gif of multiline text edit
* update changelog
* fix style of default text
* textinput should not need to know about its users
* fix branch create popup
This commit is contained in:
extrawurst 2024-02-18 12:44:44 +01:00 committed by GitHub
parent b9a2e131f2
commit 825935ba4f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 468 additions and 447 deletions

View File

@ -7,10 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
** multiline text editor **
![multiline editor](assets/multiline-texteditor.gif)
### Breaking Change
The Commit message popup now supports multiline editing! Inserting a **newline** defaults to `enter`. This comes with a new default to confirm the commit message (`ctrl+d`).
Both commands can be overwritten via `newline` and `commit` in the key bindings. see [KEY_CONFIG](./KEY_CONFIG.md) on how.
These defaults require some adoption from existing users but feel more natural to new users.
### Added
* `theme.ron` now supports customizing line break symbol ([#1894](https://github.com/extrawurst/gitui/issues/1894))
* add confirmation for dialog for undo commit [[@TeFiLeDo](https://github.com/TeFiLeDo)] ([#1912](https://github.com/extrawurst/gitui/issues/1912))
* support `prepare-commit-msg` hook ([#1873](https://github.com/extrawurst/gitui/issues/1873))
* support for new-line in text-input (e.g. commit message editor) [[@pm100]](https://github/pm100) ([#1662](https://github.com/extrawurst/gitui/issues/1662)).
### Changed
* do not allow tag when `tag.gpgsign` enabled [[@TeFiLeDo](https://github.com/TeFiLeDo)] ([#1915](https://github.com/extrawurst/gitui/pull/1915))

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@ -512,7 +512,7 @@ impl Component for CommitComponent {
if self.is_visible() || force_all {
out.push(CommandInfo::new(
strings::commands::commit_enter(&self.key_config),
strings::commands::commit_submit(&self.key_config),
self.can_commit(),
true,
));
@ -566,11 +566,8 @@ impl Component for CommitComponent {
fn event(&mut self, ev: &Event) -> Result<EventState> {
if self.is_visible() {
if self.input.event(ev)?.is_consumed() {
return Ok(EventState::Consumed);
}
if let Event::Key(e) = ev {
let input_consumed =
if key_match(e, self.key_config.keys.commit)
&& self.can_commit()
{
@ -579,18 +576,21 @@ impl Component for CommitComponent {
"commit error:",
self.commit()
);
true
} else if key_match(
e,
self.key_config.keys.toggle_verify,
) && self.can_commit()
{
self.toggle_verify();
true
} else if key_match(
e,
self.key_config.keys.commit_amend,
) && self.can_amend()
{
self.amend()?;
true
} else if key_match(
e,
self.key_config.keys.open_commit_editor,
@ -599,6 +599,7 @@ impl Component for CommitComponent {
InternalEvent::OpenExternalEditor(None),
);
self.hide();
true
} else if key_match(
e,
self.key_config.keys.commit_history_next,
@ -611,12 +612,21 @@ impl Component for CommitComponent {
self.input.set_text(msg);
self.commit_msg_history_idx += 1;
}
true
} else if key_match(
e,
self.key_config.keys.toggle_signoff,
) {
self.signoff_commit();
true
} else {
false
};
if !input_consumed {
self.input.event(ev)?;
}
// stop key event propagation
return Ok(EventState::Consumed);
}

View File

@ -23,19 +23,25 @@ use std::cell::Cell;
use std::cell::OnceCell;
use std::convert::From;
use tui_textarea::{CursorMove, Input, Key, Scrolling, TextArea};
///
#[derive(PartialEq, Eq)]
pub enum InputType {
Singleline,
Multiline,
Password,
}
#[derive(PartialEq, Eq)]
enum SelectionState {
Selecting,
NotSelecting,
SelectionEndPending,
}
type TextAreaComponent = TextArea<'static>;
///
pub struct TextInputComponent {
title: String,
default_msg: String,
@ -75,6 +81,7 @@ impl TextInputComponent {
}
}
///
pub const fn with_input_type(
mut self,
input_type: InputType,
@ -135,18 +142,24 @@ impl TextInputComponent {
.split('\n')
.map(ToString::to_string)
.collect();
self.textarea = Some({
let style =
self.theme.text(self.selected.unwrap_or(true), false);
let mut text_area = TextArea::new(lines);
if self.input_type == InputType::Password {
text_area.set_mask_char('*');
}
text_area
.set_cursor_line_style(self.theme.text(true, false));
text_area.set_placeholder_text(self.default_msg.clone());
text_area.set_placeholder_style(style);
text_area.set_style(style);
text_area.set_placeholder_style(
self.theme
.text(self.selected.unwrap_or_default(), false),
);
text_area.set_style(
self.theme.text(self.selected.unwrap_or(true), false),
);
if !self.embed {
text_area.set_block(
Block::default()
@ -241,6 +254,361 @@ impl TextInputComponent {
}
}
}
#[allow(clippy::too_many_lines, clippy::unnested_or_patterns)]
fn process_inputs(ta: &mut TextArea<'_>, input: &Input) -> bool {
match input {
Input {
key: Key::Char(c),
ctrl: false,
alt: false,
..
} => {
ta.insert_char(*c);
true
}
Input {
key: Key::Tab,
ctrl: false,
alt: false,
..
} => {
ta.insert_tab();
true
}
Input {
key: Key::Char('h'),
ctrl: true,
alt: false,
..
}
| Input {
key: Key::Backspace,
ctrl: false,
alt: false,
..
} => {
ta.delete_char();
true
}
Input {
key: Key::Char('d'),
ctrl: true,
alt: false,
..
}
| Input {
key: Key::Delete,
ctrl: false,
alt: false,
..
} => {
ta.delete_next_char();
true
}
Input {
key: Key::Char('k'),
ctrl: true,
alt: false,
..
} => {
ta.delete_line_by_end();
true
}
Input {
key: Key::Char('j'),
ctrl: true,
alt: false,
..
} => {
ta.delete_line_by_head();
true
}
Input {
key: Key::Char('w'),
ctrl: true,
alt: false,
..
}
| Input {
key: Key::Char('h'),
ctrl: false,
alt: true,
..
}
| Input {
key: Key::Backspace,
ctrl: false,
alt: true,
..
} => {
ta.delete_word();
true
}
Input {
key: Key::Delete,
ctrl: false,
alt: true,
..
}
| Input {
key: Key::Char('d'),
ctrl: false,
alt: true,
..
} => {
ta.delete_next_word();
true
}
Input {
key: Key::Char('n'),
ctrl: true,
alt: false,
..
}
| Input {
key: Key::Down,
ctrl: false,
alt: false,
..
} => {
ta.move_cursor(CursorMove::Down);
true
}
Input {
key: Key::Char('p'),
ctrl: true,
alt: false,
..
}
| Input {
key: Key::Up,
ctrl: false,
alt: false,
..
} => {
ta.move_cursor(CursorMove::Up);
true
}
Input {
key: Key::Char('f'),
ctrl: true,
alt: false,
..
}
| Input {
key: Key::Right,
ctrl: false,
alt: false,
..
} => {
ta.move_cursor(CursorMove::Forward);
true
}
Input {
key: Key::Char('b'),
ctrl: true,
alt: false,
..
}
| Input {
key: Key::Left,
ctrl: false,
alt: false,
..
} => {
ta.move_cursor(CursorMove::Back);
true
}
Input {
key: Key::Char('a'),
ctrl: true,
alt: false,
..
}
| Input { key: Key::Home, .. }
| Input {
key: Key::Left | Key::Char('b'),
ctrl: true,
alt: true,
..
} => {
ta.move_cursor(CursorMove::Head);
true
}
Input {
key: Key::Char('e'),
ctrl: true,
alt: false,
..
}
| Input { key: Key::End, .. }
| Input {
key: Key::Right | Key::Char('f'),
ctrl: true,
alt: true,
..
} => {
ta.move_cursor(CursorMove::End);
true
}
Input {
key: Key::Char('<'),
ctrl: false,
alt: true,
..
}
| Input {
key: Key::Up | Key::Char('p'),
ctrl: true,
alt: true,
..
} => {
ta.move_cursor(CursorMove::Top);
true
}
Input {
key: Key::Char('>'),
ctrl: false,
alt: true,
..
}
| Input {
key: Key::Down | Key::Char('n'),
ctrl: true,
alt: true,
..
} => {
ta.move_cursor(CursorMove::Bottom);
true
}
Input {
key: Key::Char('f'),
ctrl: false,
alt: true,
..
}
| Input {
key: Key::Right,
ctrl: true,
alt: false,
..
} => {
ta.move_cursor(CursorMove::WordForward);
true
}
Input {
key: Key::Char('b'),
ctrl: false,
alt: true,
..
}
| Input {
key: Key::Left,
ctrl: true,
alt: false,
..
} => {
ta.move_cursor(CursorMove::WordBack);
true
}
Input {
key: Key::Char(']'),
ctrl: false,
alt: true,
..
}
| Input {
key: Key::Char('n'),
ctrl: false,
alt: true,
..
}
| Input {
key: Key::Down,
ctrl: true,
alt: false,
..
} => {
ta.move_cursor(CursorMove::ParagraphForward);
true
}
Input {
key: Key::Char('['),
ctrl: false,
alt: true,
..
}
| Input {
key: Key::Char('p'),
ctrl: false,
alt: true,
..
}
| Input {
key: Key::Up,
ctrl: true,
alt: false,
..
} => {
ta.move_cursor(CursorMove::ParagraphBack);
true
}
Input {
key: Key::Char('u'),
ctrl: true,
alt: false,
..
} => {
ta.undo();
true
}
Input {
key: Key::Char('r'),
ctrl: true,
alt: false,
..
} => {
ta.redo();
true
}
Input {
key: Key::Char('y'),
ctrl: true,
alt: false,
..
} => {
ta.paste();
true
}
Input {
key: Key::Char('v'),
ctrl: true,
alt: false,
..
}
| Input {
key: Key::PageDown, ..
} => {
ta.scroll(Scrolling::PageDown);
true
}
Input {
key: Key::Char('v'),
ctrl: false,
alt: true,
..
}
| Input {
key: Key::PageUp, ..
} => {
ta.scroll(Scrolling::PageUp);
true
}
_ => false,
}
}
}
impl DrawableComponent for TextInputComponent {
@ -297,419 +665,50 @@ impl Component for TextInputComponent {
)
.order(1),
);
//TODO: we might want to show the textarea specific commands here
visibility_blocking(self)
}
// the fixes this clippy wants make the code much harder to follow
#[allow(clippy::unnested_or_patterns)]
// it just has to be this big
#[allow(clippy::too_many_lines)]
fn event(&mut self, ev: &Event) -> Result<EventState> {
let input = Input::from(ev.clone());
self.should_select(&input);
if let Some(ta) = &mut self.textarea {
if let Event::Key(e) = ev {
let modified = if let Event::Key(e) = ev {
if key_match(e, self.key_config.keys.exit_popup) {
self.hide();
return Ok(EventState::Consumed);
}
// for a multi line box we want to allow the user to enter new lines
// so test for what might be a different enter to mean 'ok do it'
if self.input_type == InputType::Multiline {
if key_match(e, self.key_config.keys.commit) {
// means pass it back up to the caller to handle
return Ok(EventState::NotConsumed);
}
} else if key_match(e, self.key_config.keys.enter) {
// ditto - we dont want it
return Ok(EventState::NotConsumed);
}
// here all 'known' special keys for any textinput call are filtered out
if key_match(e, self.key_config.keys.toggle_verify)
|| key_match(e, self.key_config.keys.commit_amend)
|| key_match(
e,
self.key_config.keys.open_commit_editor,
) || key_match(
e,
self.key_config.keys.commit_history_next,
) {
return Ok(EventState::NotConsumed);
}
/*
here we do key handling rather than passing it to textareas input function
- so that we know which keys were handled and which were not
- to get fine control over what each key press does
- allow for key mapping based off key config....
but in fact the original textinput ignored all key bindings, up,down,right,....
so they are also ignored here
*/
// was the text buffer changed?
let modified =
if key_match(e, self.key_config.keys.newline)
&& self.input_type == InputType::Multiline
{
ta.insert_newline();
true
} else {
match input {
Input {
key: Key::Char(c),
ctrl: false,
alt: false,
..
} => {
ta.insert_char(c);
true
Self::process_inputs(ta, &input)
}
Input {
key: Key::Tab,
ctrl: false,
alt: false,
..
} => {
ta.insert_tab();
true
}
Input {
key: Key::Char('h'),
ctrl: true,
alt: false,
..
}
| Input {
key: Key::Backspace,
ctrl: false,
alt: false,
..
} => ta.delete_char(),
Input {
key: Key::Char('d'),
ctrl: true,
alt: false,
..
}
| Input {
key: Key::Delete,
ctrl: false,
alt: false,
..
} => ta.delete_next_char(),
Input {
key: Key::Char('k'),
ctrl: true,
alt: false,
..
} => ta.delete_line_by_end(),
Input {
key: Key::Char('j'),
ctrl: true,
alt: false,
..
} => ta.delete_line_by_head(),
Input {
key: Key::Char('w'),
ctrl: true,
alt: false,
..
}
| Input {
key: Key::Char('h'),
ctrl: false,
alt: true,
..
}
| Input {
key: Key::Backspace,
ctrl: false,
alt: true,
..
} => ta.delete_word(),
Input {
key: Key::Delete,
ctrl: false,
alt: true,
..
}
| Input {
key: Key::Char('d'),
ctrl: false,
alt: true,
..
} => ta.delete_next_word(),
Input {
key: Key::Char('n'),
ctrl: true,
alt: false,
..
}
| Input {
key: Key::Down,
ctrl: false,
alt: false,
..
} => {
ta.move_cursor(CursorMove::Down);
} else {
false
}
Input {
key: Key::Char('p'),
ctrl: true,
alt: false,
..
}
| Input {
key: Key::Up,
ctrl: false,
alt: false,
..
} => {
ta.move_cursor(CursorMove::Up);
false
}
Input {
key: Key::Char('f'),
ctrl: true,
alt: false,
..
}
| Input {
key: Key::Right,
ctrl: false,
alt: false,
..
} => {
ta.move_cursor(CursorMove::Forward);
false
}
Input {
key: Key::Char('b'),
ctrl: true,
alt: false,
..
}
| Input {
key: Key::Left,
ctrl: false,
alt: false,
..
} => {
ta.move_cursor(CursorMove::Back);
false
}
// normally picked up earlier as 'amend'
Input {
key: Key::Char('a'),
ctrl: true,
alt: false,
..
}
| Input { key: Key::Home, .. }
| Input {
key: Key::Left | Key::Char('b'),
ctrl: true,
alt: true,
..
} => {
ta.move_cursor(CursorMove::Head);
false
}
// normally picked up earlier as 'invoke editor'
Input {
key: Key::Char('e'),
ctrl: true,
alt: false,
..
}
| Input { key: Key::End, .. }
| Input {
key: Key::Right | Key::Char('f'),
ctrl: true,
alt: true,
..
} => {
ta.move_cursor(CursorMove::End);
false
}
Input {
key: Key::Char('<'),
ctrl: false,
alt: true,
..
}
| Input {
key: Key::Up | Key::Char('p'),
ctrl: true,
alt: true,
..
} => {
ta.move_cursor(CursorMove::Top);
false
}
Input {
key: Key::Char('>'),
ctrl: false,
alt: true,
..
}
| Input {
key: Key::Down | Key::Char('n'),
ctrl: true,
alt: true,
..
} => {
ta.move_cursor(CursorMove::Bottom);
false
}
Input {
key: Key::Char('f'),
ctrl: false,
alt: true,
..
}
| Input {
key: Key::Right,
ctrl: true,
alt: false,
..
} => {
ta.move_cursor(
CursorMove::WordForward,
);
false
}
Input {
key: Key::Char('b'),
ctrl: false,
alt: true,
..
}
| Input {
key: Key::Left,
ctrl: true,
alt: false,
..
} => {
ta.move_cursor(CursorMove::WordBack);
false
}
Input {
key: Key::Char(']'),
ctrl: false,
alt: true,
..
}
| Input {
key: Key::Char('n'),
ctrl: false,
alt: true,
..
}
| Input {
key: Key::Down,
ctrl: true,
alt: false,
..
} => {
ta.move_cursor(
CursorMove::ParagraphForward,
);
false
}
Input {
key: Key::Char('['),
ctrl: false,
alt: true,
..
}
| Input {
key: Key::Char('p'),
ctrl: false,
alt: true,
..
}
| Input {
key: Key::Up,
ctrl: true,
alt: false,
..
} => {
ta.move_cursor(
CursorMove::ParagraphBack,
);
false
}
Input {
key: Key::Char('u'),
ctrl: true,
alt: false,
..
} => ta.undo(),
Input {
key: Key::Char('r'),
ctrl: true,
alt: false,
..
} => ta.redo(),
Input {
key: Key::Char('y'),
ctrl: true,
alt: false,
..
} => ta.paste(),
Input {
key: Key::Char('v'),
ctrl: true,
alt: false,
..
}
| Input {
key: Key::PageDown, ..
} => {
ta.scroll(Scrolling::PageDown);
false
}
Input {
key: Key::Char('v'),
ctrl: false,
alt: true,
..
}
| Input {
key: Key::PageUp, ..
} => {
ta.scroll(Scrolling::PageUp);
false
}
_ => return Ok(EventState::NotConsumed),
}
};
if modified {
self.msg.take();
}
}
if self.select_state
== SelectionState::SelectionEndPending
{
ta.cancel_selection();
self.select_state = SelectionState::NotSelecting;
}
if modified {
self.msg.take();
return Ok(EventState::Consumed);
}
}
Ok(EventState::NotConsumed)
}
/*
visible maps to textarea Option
None = > not visible

View File

@ -211,8 +211,8 @@ impl Default for KeysList {
view_submodule_parent: GituiKeyEvent::new(KeyCode::Char('p'), KeyModifiers::empty()),
update_submodule: GituiKeyEvent::new(KeyCode::Char('u'), KeyModifiers::empty()),
commit_history_next: GituiKeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL),
commit: GituiKeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
newline: GituiKeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
commit: GituiKeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL),
newline: GituiKeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
}
}
}

View File

@ -964,10 +964,12 @@ pub mod commands {
CMD_GROUP_COMMIT_POPUP,
)
}
pub fn commit_enter(key_config: &SharedKeyConfig) -> CommandText {
pub fn commit_submit(
key_config: &SharedKeyConfig,
) -> CommandText {
CommandText::new(
format!(
"Commit [{}]",
"Do Commit [{}]",
key_config.get_hint(key_config.keys.commit),
),
"commit (available when commit message is non-empty)",