Radish alpha
r
Radicle terminal user interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
lib: Rework text field
Erik Kundt committed 2 years ago
commit 853c39f105b1f316ec1a8a8128839b756c6ab632
parent 325ebde0dcc2e887080d60d4a3e45124e437461c
5 files changed +109 -133
modified bin/commands/inbox/select/ui.rs
@@ -15,7 +15,7 @@ use radicle_tui as tui;
use tui::ui::items::{Filter, NotificationItem, NotificationItemFilter, NotificationState};
use tui::ui::span;
use tui::ui::widget::container::{Footer, Header};
-
use tui::ui::widget::input::{TextField, TextFieldProps};
+
use tui::ui::widget::input::TextField;
use tui::ui::widget::text::{Paragraph, ParagraphProps};
use tui::ui::widget::{Column, Render, Shortcuts, Table, Widget};
use tui::Selection;
@@ -140,8 +140,7 @@ impl<'a> Render<()> for ListPage<'a> {

            self.notifications
                .render::<B>(frame, component_layout[0], ());
-
            self.search
-
                .render::<B>(frame, component_layout[1], SearchProps {});
+
            self.search.render::<B>(frame, component_layout[1], ());
        } else if self.props.show_help {
            self.help.render::<B>(frame, layout.component, ());
        } else {
@@ -438,11 +437,9 @@ impl<'a> Render<()> for Notifications<'a> {
    }
}

-
pub struct SearchProps {}
-

pub struct Search {
    pub action_tx: UnboundedSender<Action>,
-
    pub input: TextField,
+
    pub input: TextField<Action>,
}

impl Widget<State, Action> for Search {
@@ -450,9 +447,9 @@ impl Widget<State, Action> for Search {
    where
        Self: Sized,
    {
-
        let mut input = TextField::new(state, action_tx.clone());
-
        input.set_text(&state.search.read().to_string());
-

+
        let input = TextField::new(state, action_tx.clone())
+
            .title("Search")
+
            .inline(true);
        Self { action_tx, input }.move_with_state(state)
    }

@@ -460,8 +457,8 @@ impl Widget<State, Action> for Search {
    where
        Self: Sized,
    {
-
        let mut input = <TextField as Widget<State, Action>>::move_with_state(self.input, state);
-
        input.set_text(&state.search.read().to_string());
+
        let input = self.input.move_with_state(state);
+
        let input = input.text(&state.search.read().to_string());

        Self { input, ..self }
    }
@@ -475,30 +472,25 @@ impl Widget<State, Action> for Search {
                let _ = self.action_tx.send(Action::ApplySearch);
            }
            _ => {
-
                <TextField as Widget<State, Action>>::handle_key_event(&mut self.input, key);
+
                <TextField<Action> as Widget<State, Action>>::handle_key_event(
+
                    &mut self.input,
+
                    key,
+
                );
                let _ = self.action_tx.send(Action::UpdateSearch {
-
                    value: self.input.text().to_string(),
+
                    value: self.input.read().to_string(),
                });
            }
        }
    }
}

-
impl Render<SearchProps> for Search {
-
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, _props: SearchProps) {
+
impl Render<()> for Search {
+
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) {
        let layout = Layout::horizontal(Constraint::from_mins([0]))
            .horizontal_margin(1)
            .split(area);

-
        self.input.render::<B>(
-
            frame,
-
            layout[0],
-
            TextFieldProps {
-
                titles: ("Search".into(), "Search".into()),
-
                show_cursor: true,
-
                inline_label: true,
-
            },
-
        );
+
        self.input.render::<B>(frame, layout[0], ());
    }
}

modified bin/commands/issue/select/ui.rs
@@ -17,7 +17,7 @@ use radicle_tui as tui;
use tui::ui::items::{Filter, IssueItem, IssueItemFilter};
use tui::ui::span;
use tui::ui::widget::container::{Footer, Header};
-
use tui::ui::widget::input::{TextField, TextFieldProps};
+
use tui::ui::widget::input::TextField;
use tui::ui::widget::text::{Paragraph, ParagraphProps};
use tui::ui::widget::{Column, Render, Shortcuts, Table, Widget};
use tui::Selection;
@@ -139,8 +139,7 @@ impl<'a> Render<()> for ListPage<'a> {
                .split(layout.component);

            self.issues.render::<B>(frame, component_layout[0], ());
-
            self.search
-
                .render::<B>(frame, component_layout[1], SearchProps {});
+
            self.search.render::<B>(frame, component_layout[1], ());
        } else if self.props.show_help {
            self.help.render::<B>(frame, layout.component, ());
        } else {
@@ -459,11 +458,9 @@ impl<'a> Render<()> for Issues<'a> {
    }
}

-
pub struct SearchProps {}
-

pub struct Search {
    pub action_tx: UnboundedSender<Action>,
-
    pub input: TextField,
+
    pub input: TextField<Action>,
}

impl Widget<State, Action> for Search {
@@ -471,9 +468,9 @@ impl Widget<State, Action> for Search {
    where
        Self: Sized,
    {
-
        let mut input = TextField::new(state, action_tx.clone());
-
        input.set_text(&state.search.read().to_string());
-

+
        let input = TextField::new(state, action_tx.clone())
+
            .title("Search")
+
            .inline(true);
        Self { action_tx, input }.move_with_state(state)
    }

@@ -481,8 +478,8 @@ impl Widget<State, Action> for Search {
    where
        Self: Sized,
    {
-
        let mut input = <TextField as Widget<State, Action>>::move_with_state(self.input, state);
-
        input.set_text(&state.search.read().to_string());
+
        let input = self.input.move_with_state(state);
+
        let input = input.text(&state.search.read().to_string());

        Self { input, ..self }
    }
@@ -496,30 +493,25 @@ impl Widget<State, Action> for Search {
                let _ = self.action_tx.send(Action::ApplySearch);
            }
            _ => {
-
                <TextField as Widget<State, Action>>::handle_key_event(&mut self.input, key);
+
                <TextField<Action> as Widget<State, Action>>::handle_key_event(
+
                    &mut self.input,
+
                    key,
+
                );
                let _ = self.action_tx.send(Action::UpdateSearch {
-
                    value: self.input.text().to_string(),
+
                    value: self.input.read().to_string(),
                });
            }
        }
    }
}

-
impl Render<SearchProps> for Search {
-
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, _props: SearchProps) {
+
impl Render<()> for Search {
+
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) {
        let layout = Layout::horizontal(Constraint::from_mins([0]))
            .horizontal_margin(1)
            .split(area);

-
        self.input.render::<B>(
-
            frame,
-
            layout[0],
-
            TextFieldProps {
-
                titles: ("Search".into(), "Search".into()),
-
                show_cursor: true,
-
                inline_label: true,
-
            },
-
        );
+
        self.input.render::<B>(frame, layout[0], ());
    }
}

modified bin/commands/patch/select/ui.rs
@@ -18,7 +18,7 @@ use radicle_tui as tui;
use tui::ui::items::{Filter, PatchItem, PatchItemFilter};
use tui::ui::span;
use tui::ui::widget::container::{Footer, Header};
-
use tui::ui::widget::input::{TextField, TextFieldProps};
+
use tui::ui::widget::input::TextField;
use tui::ui::widget::text::{Paragraph, ParagraphProps};
use tui::ui::widget::{Column, Render, Shortcuts, Table, Widget};
use tui::Selection;
@@ -142,8 +142,7 @@ impl<'a> Render<()> for ListPage<'a> {
                .split(layout.component);

            self.patches.render::<B>(frame, component_layout[0], ());
-
            self.search
-
                .render::<B>(frame, component_layout[1], SearchProps {});
+
            self.search.render::<B>(frame, component_layout[1], ());
        } else if self.props.show_help {
            self.help.render::<B>(frame, layout.component, ());
        } else {
@@ -492,11 +491,9 @@ impl<'a> Render<()> for Patches<'a> {
    }
}

-
pub struct SearchProps {}
-

pub struct Search {
    pub action_tx: UnboundedSender<Action>,
-
    pub input: TextField,
+
    pub input: TextField<Action>,
}

impl Widget<State, Action> for Search {
@@ -504,9 +501,9 @@ impl Widget<State, Action> for Search {
    where
        Self: Sized,
    {
-
        let mut input = TextField::new(state, action_tx.clone());
-
        input.set_text(&state.search.read().to_string());
-

+
        let input = TextField::new(state, action_tx.clone())
+
            .title("Search")
+
            .inline(true);
        Self { action_tx, input }.move_with_state(state)
    }

@@ -514,8 +511,8 @@ impl Widget<State, Action> for Search {
    where
        Self: Sized,
    {
-
        let mut input = <TextField as Widget<State, Action>>::move_with_state(self.input, state);
-
        input.set_text(&state.search.read().to_string());
+
        let input = self.input.move_with_state(state);
+
        let input = input.text(&state.search.read().to_string());

        Self { input, ..self }
    }
@@ -529,30 +526,25 @@ impl Widget<State, Action> for Search {
                let _ = self.action_tx.send(Action::ApplySearch);
            }
            _ => {
-
                <TextField as Widget<State, Action>>::handle_key_event(&mut self.input, key);
+
                <TextField<Action> as Widget<State, Action>>::handle_key_event(
+
                    &mut self.input,
+
                    key,
+
                );
                let _ = self.action_tx.send(Action::UpdateSearch {
-
                    value: self.input.text().to_string(),
+
                    value: self.input.read().to_string(),
                });
            }
        }
    }
}

-
impl Render<SearchProps> for Search {
-
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, _props: SearchProps) {
+
impl Render<()> for Search {
+
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) {
        let layout = Layout::horizontal(Constraint::from_mins([0]))
            .horizontal_margin(1)
            .split(area);

-
        self.input.render::<B>(
-
            frame,
-
            layout[0],
-
            TextFieldProps {
-
                titles: ("Search".into(), "Search".into()),
-
                show_cursor: true,
-
                inline_label: true,
-
            },
-
        );
+
        self.input.render::<B>(frame, layout[0], ());
    }
}

modified src/ui/widget/container.rs
@@ -138,21 +138,6 @@ impl<'a, A> Render<()> for Header<'a, A> {
    }
}

-
// #[derive(Debug)]
-
// pub struct FooterCell<'a> {
-
//     text: Text<'a>,
-
//     width: Constraint,
-
// }
-

-
// impl<'a> FooterCell<'a> {
-
//     pub fn new(text: impl Into<Text<'a>>, width: Constraint) -> Self {
-
//         Self {
-
//             text: text.into(),
-
//             width,
-
//         }
-
//     }
-
// }
-

#[derive(Debug)]
pub struct FooterProps<'a> {
    pub columns: Vec<Column<'a>>,
modified src/ui/widget/input.rs
@@ -9,84 +9,105 @@ use ratatui::text::{Line, Span};

use super::{Render, Widget};

-
pub struct TextField {
-
    /// Current value of the input box
+
pub struct TextFieldProps {
+
    title: String,
+
    inline_label: bool,
+
    show_cursor: bool,
    text: String,
-
    /// Position of cursor in the editor area.
    cursor_position: usize,
}

-
impl TextField {
-
    pub fn text(&self) -> &str {
-
        &self.text
+
impl Default for TextFieldProps {
+
    fn default() -> Self {
+
        Self {
+
            title: String::new(),
+
            inline_label: false,
+
            show_cursor: true,
+
            text: String::new(),
+
            cursor_position: 0,
+
        }
+
    }
+
}
+

+
pub struct TextField<A> {
+
    /// Message sender
+
    pub action_tx: UnboundedSender<A>,
+
    /// Internal props
+
    props: TextFieldProps,
+
}
+

+
impl<A> TextField<A> {
+
    pub fn read(&self) -> &str {
+
        &self.props.text
    }

-
    pub fn set_text(&mut self, new_text: &str) {
-
        if self.text != new_text {
-
            self.text = String::from(new_text);
-
            self.cursor_position = self.text.len();
+
    pub fn text(mut self, new_text: &str) -> Self {
+
        if self.props.text != new_text {
+
            self.props.text = String::from(new_text);
+
            self.props.cursor_position = self.props.text.len();
        }
+
        self
    }

-
    pub fn reset(&mut self) {
-
        self.cursor_position = 0;
-
        self.text.clear();
+
    pub fn title(mut self, title: &str) -> Self {
+
        self.props.title = title.to_string();
+
        self
    }

-
    pub fn is_empty(&self) -> bool {
-
        self.text.is_empty()
+
    pub fn inline(mut self, inline: bool) -> Self {
+
        self.props.inline_label = inline;
+
        self
    }

    fn move_cursor_left(&mut self) {
-
        let cursor_moved_left = self.cursor_position.saturating_sub(1);
-
        self.cursor_position = self.clamp_cursor(cursor_moved_left);
+
        let cursor_moved_left = self.props.cursor_position.saturating_sub(1);
+
        self.props.cursor_position = self.clamp_cursor(cursor_moved_left);
    }

    fn move_cursor_right(&mut self) {
-
        let cursor_moved_right = self.cursor_position.saturating_add(1);
-
        self.cursor_position = self.clamp_cursor(cursor_moved_right);
+
        let cursor_moved_right = self.props.cursor_position.saturating_add(1);
+
        self.props.cursor_position = self.clamp_cursor(cursor_moved_right);
    }

    fn enter_char(&mut self, new_char: char) {
-
        self.text.insert(self.cursor_position, new_char);
-

+
        self.props.text.insert(self.props.cursor_position, new_char);
        self.move_cursor_right();
    }

    fn delete_char(&mut self) {
-
        let is_not_cursor_leftmost = self.cursor_position != 0;
+
        let is_not_cursor_leftmost = self.props.cursor_position != 0;
        if is_not_cursor_leftmost {
            // Method "remove" is not used on the saved text for deleting the selected char.
            // Reason: Using remove on String works on bytes instead of the chars.
            // Using remove would require special care because of char boundaries.

-
            let current_index = self.cursor_position;
+
            let current_index = self.props.cursor_position;
            let from_left_to_current_index = current_index - 1;

            // Getting all characters before the selected character.
-
            let before_char_to_delete = self.text.chars().take(from_left_to_current_index);
+
            let before_char_to_delete = self.props.text.chars().take(from_left_to_current_index);
            // Getting all characters after selected character.
-
            let after_char_to_delete = self.text.chars().skip(current_index);
+
            let after_char_to_delete = self.props.text.chars().skip(current_index);

            // Put all characters together except the selected one.
            // By leaving the selected one out, it is forgotten and therefore deleted.
-
            self.text = before_char_to_delete.chain(after_char_to_delete).collect();
+
            self.props.text = before_char_to_delete.chain(after_char_to_delete).collect();
            self.move_cursor_left();
        }
    }

    fn clamp_cursor(&self, new_cursor_pos: usize) -> usize {
-
        new_cursor_pos.clamp(0, self.text.len())
+
        new_cursor_pos.clamp(0, self.props.text.len())
    }
}

-
impl<S, A> Widget<S, A> for TextField {
-
    fn new(_state: &S, _action_tx: UnboundedSender<A>) -> Self {
+
impl<S, A> Widget<S, A> for TextField<A> {
+
    fn new(state: &S, action_tx: UnboundedSender<A>) -> Self {
        Self {
-
            //
-
            text: String::new(),
-
            cursor_position: 0,
+
            action_tx,
+
            props: TextFieldProps::default(),
        }
+
        .move_with_state(state)
    }

    fn move_with_state(self, _state: &S) -> Self
@@ -119,22 +140,16 @@ impl<S, A> Widget<S, A> for TextField {
    }
}

-
pub struct TextFieldProps {
-
    pub titles: (String, String),
-
    pub inline_label: bool,
-
    pub show_cursor: bool,
-
}
-

-
impl Render<TextFieldProps> for TextField {
-
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, props: TextFieldProps) {
+
impl<A> Render<()> for TextField<A> {
+
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) {
        let layout = Layout::vertical(Constraint::from_lengths([1, 1])).split(area);

-
        let input = self.text.as_str();
-
        let label = format!(" {} ", props.titles.0);
+
        let input = self.props.text.as_str();
+
        let label = format!(" {} ", self.props.title);
        let overline = String::from("▔").repeat(area.width as usize);
-
        let cursor_pos = self.cursor_position as u16;
+
        let cursor_pos = self.props.cursor_position as u16;

-
        if props.inline_label {
+
        if self.props.inline_label {
            let top_layout = Layout::horizontal([
                Constraint::Length(label.chars().count() as u16),
                Constraint::Length(1),
@@ -151,7 +166,7 @@ impl Render<TextFieldProps> for TextField {
            frame.render_widget(input, top_layout[2]);
            frame.render_widget(overline, layout[1]);

-
            if props.show_cursor {
+
            if self.props.show_cursor {
                frame.set_cursor(top_layout[2].x + cursor_pos, top_layout[2].y)
            }
        } else {
@@ -167,7 +182,7 @@ impl Render<TextFieldProps> for TextField {
            frame.render_widget(top, layout[0]);
            frame.render_widget(bottom, layout[1]);

-
            if props.show_cursor {
+
            if self.props.show_cursor {
                frame.set_cursor(area.x + cursor_pos, area.y)
            }
        }