Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
lib: Add initial support for themes
Merged did:key:z6MkswQE...2C1V opened 1 year ago
7 files changed +296 -49 7c126555 eb00c21e
modified CHANGELOG.md
@@ -15,6 +15,7 @@
- Per-column visibility for tables depending on their render width
- Tables can render a scrollbar
- Predefined layouts for section groups
+
- Basic theming support via widget properties
- New widgets:
- `SplitContainer`: Vertically split container
- `Tree`: Generic tree widget
modified src/ui/theme.rs
@@ -1,3 +1,45 @@
+
use ratatui::style::{Color, Style, Stylize};
+

+
#[derive(Clone, Debug)]
+
pub struct Theme {
+
    pub border_color: Color,
+
    pub focus_border_color: Color,
+
    pub shortcuts_keys_style: Style,
+
    pub shortcuts_action_style: Style,
+
    pub textview_style: Style,
+
    pub dim_no_focus: bool,
+
}
+

+
impl Default for Theme {
+
    fn default() -> Self {
+
        Self::default_dark()
+
    }
+
}
+

+
impl Theme {
+
    pub fn default_light() -> Self {
+
        Self {
+
            border_color: Color::Rgb(170, 170, 170),
+
            focus_border_color: Color::Black,
+
            shortcuts_keys_style: style::yellow(),
+
            shortcuts_action_style: style::reset(),
+
            textview_style: style::reset(),
+
            dim_no_focus: true,
+
        }
+
    }
+

+
    pub fn default_dark() -> Self {
+
        Self {
+
            border_color: Color::Indexed(236),
+
            focus_border_color: Color::Indexed(238),
+
            shortcuts_keys_style: style::yellow().dim(),
+
            shortcuts_action_style: style::gray(),
+
            textview_style: style::reset(),
+
            dim_no_focus: true,
+
        }
+
    }
+
}
+

pub mod style {
    use ratatui::style::{Color, Style, Stylize};

@@ -41,14 +83,6 @@ pub mod style {
        Style::default().fg(Color::DarkGray)
    }

-
    pub fn border(focus: bool) -> Style {
-
        if focus {
-
            Style::default().fg(Color::Indexed(238))
-
        } else {
-
            Style::default().fg(Color::Indexed(236))
-
        }
-
    }
-

    pub fn highlight(focus: bool) -> Style {
        if focus {
            cyan().not_dim().reversed()
modified src/ui/widget.rs
@@ -178,6 +178,12 @@ pub struct RenderProps {
}

impl RenderProps {
+
    /// Sets the area to render in.
+
    pub fn area(mut self, area: Rect) -> Self {
+
        self.area = area;
+
        self
+
    }
+

    /// Sets the focus of these render props.
    pub fn focus(mut self, focus: bool) -> Self {
        self.focus = focus;
modified src/ui/widget/container.rs
@@ -7,7 +7,7 @@ use ratatui::prelude::*;
use ratatui::widgets::{Block, BorderType, Borders, Row};

use crate::ui::ext::{FooterBlock, FooterBlockType, HeaderBlock};
-
use crate::ui::theme::style;
+
use crate::ui::theme::{style, Theme};
use crate::ui::{RENDER_WIDTH_LARGE, RENDER_WIDTH_MEDIUM, RENDER_WIDTH_SMALL};

use super::{PredefinedLayout, RenderProps, View, ViewProps, ViewState, Widget};
@@ -95,6 +95,8 @@ pub struct HeaderProps<'a> {
    pub columns: Vec<Column<'a>>,
    pub cutoff: usize,
    pub cutoff_after: usize,
+
    pub border_color: Color,
+
    pub focus_border_color: Color,
}

impl<'a> HeaderProps<'a> {
@@ -108,14 +110,28 @@ impl<'a> HeaderProps<'a> {
        self.cutoff_after = cutoff_after;
        self
    }
+

+
    pub fn border_color(mut self, color: Color) -> Self {
+
        self.border_color = color;
+
        self
+
    }
+

+
    pub fn focus_border_color(mut self, color: Color) -> Self {
+
        self.focus_border_color = color;
+
        self
+
    }
}

impl<'a> Default for HeaderProps<'a> {
    fn default() -> Self {
+
        let theme = Theme::default();
+

        Self {
            columns: vec![],
            cutoff: usize::MAX,
            cutoff_after: usize::MAX,
+
            border_color: theme.border_color,
+
            focus_border_color: theme.focus_border_color,
        }
    }
}
@@ -169,10 +185,16 @@ impl<'a: 'static, S, M> View for Header<S, M> {
            })
            .collect::<Vec<_>>();

+
        let border_style = if render.focus {
+
            Style::default().fg(props.focus_border_color)
+
        } else {
+
            Style::default().fg(props.border_color)
+
        };
+

        // Render header
        let block = HeaderBlock::default()
            .borders(Borders::ALL)
-
            .border_style(style::border(render.focus))
+
            .border_style(border_style)
            .border_type(BorderType::Rounded);

        let header_layout = Layout::default()
@@ -198,6 +220,8 @@ pub struct FooterProps<'a> {
    pub columns: Vec<Column<'a>>,
    pub cutoff: usize,
    pub cutoff_after: usize,
+
    pub border_color: Color,
+
    pub focus_border_color: Color,
}

impl<'a> FooterProps<'a> {
@@ -211,14 +235,28 @@ impl<'a> FooterProps<'a> {
        self.cutoff_after = cutoff_after;
        self
    }
+

+
    pub fn border_color(mut self, color: Color) -> Self {
+
        self.border_color = color;
+
        self
+
    }
+

+
    pub fn focus_border_color(mut self, color: Color) -> Self {
+
        self.focus_border_color = color;
+
        self
+
    }
}

impl<'a> Default for FooterProps<'a> {
    fn default() -> Self {
+
        let theme = Theme::default();
+

        Self {
            columns: vec![],
            cutoff: usize::MAX,
            cutoff_after: usize::MAX,
+
            border_color: theme.border_color,
+
            focus_border_color: theme.focus_border_color,
        }
    }
}
@@ -240,22 +278,22 @@ impl<'a, S, M> Footer<S, M> {
    fn render_cell(
        &self,
        frame: &mut ratatui::Frame,
-
        area: Rect,
+
        border_style: Style,
+
        render: RenderProps,
        block_type: FooterBlockType,
        text: impl Into<Text<'a>>,
-
        focus: bool,
    ) {
        let footer_layout = Layout::default()
            .direction(Direction::Vertical)
            .constraints(vec![Constraint::Min(1)])
            .vertical_margin(1)
            .horizontal_margin(1)
-
            .split(area);
+
            .split(render.area);

        let footer_block = FooterBlock::default()
-
            .border_style(style::border(focus))
+
            .border_style(border_style)
            .block_type(block_type);
-
        frame.render_widget(footer_block, area);
+
        frame.render_widget(footer_block, render.area);
        frame.render_widget(text.into(), footer_layout[0]);
    }
}
@@ -270,6 +308,12 @@ impl<'a: 'static, S, M> View for Footer<S, M> {
            .and_then(|props| props.inner_ref::<FooterProps>())
            .unwrap_or(&default);

+
        let border_style = if render.focus {
+
            Style::default().fg(props.focus_border_color)
+
        } else {
+
            Style::default().fg(props.border_color)
+
        };
+

        let widths = props
            .columns
            .iter()
@@ -297,14 +341,34 @@ impl<'a: 'static, S, M> View for Footer<S, M> {
                _ if i == last => FooterBlockType::End,
                _ => FooterBlockType::Repeat,
            };
-
            self.render_cell(frame, *area, block_type, cell.clone(), render.focus);
+
            self.render_cell(
+
                frame,
+
                border_style,
+
                render.clone().area(*area),
+
                block_type,
+
                cell.clone(),
+
            );
        }
    }
}

-
#[derive(Clone, Default)]
+
#[derive(Clone)]
pub struct ContainerProps {
    hide_footer: bool,
+
    border_color: Color,
+
    focus_border_color: Color,
+
}
+

+
impl Default for ContainerProps {
+
    fn default() -> Self {
+
        let theme = Theme::default();
+

+
        Self {
+
            hide_footer: false,
+
            border_color: theme.border_color,
+
            focus_border_color: theme.focus_border_color,
+
        }
+
    }
}

impl ContainerProps {
@@ -312,6 +376,16 @@ impl ContainerProps {
        self.hide_footer = hide;
        self
    }
+

+
    pub fn border_color(mut self, color: Color) -> Self {
+
        self.border_color = color;
+
        self
+
    }
+

+
    pub fn focus_border_color(mut self, color: Color) -> Self {
+
        self.focus_border_color = color;
+
        self
+
    }
}

pub struct Container<S, M> {
@@ -386,6 +460,12 @@ where
            .and_then(|props| props.inner_ref::<ContainerProps>())
            .unwrap_or(&default);

+
        let border_style = if render.focus {
+
            Style::default().fg(props.focus_border_color)
+
        } else {
+
            Style::default().fg(props.border_color)
+
        };
+

        let header_h = if self.header.is_some() { 3 } else { 0 };
        let footer_h = if self.footer.is_some() && !props.hide_footer {
            3
@@ -411,7 +491,7 @@ where
        };

        let block = Block::default()
-
            .border_style(style::border(render.focus))
+
            .border_style(border_style)
            .border_type(BorderType::Rounded)
            .borders(borders);
        frame.render_widget(block.clone(), content_area);
@@ -440,10 +520,25 @@ pub enum SplitContainerFocus {
    Bottom,
}

-
#[derive(Clone, Default)]
+
#[derive(Clone)]
pub struct SplitContainerProps {
    split_focus: SplitContainerFocus,
    heights: [Constraint; 2],
+
    border_color: Color,
+
    focus_border_color: Color,
+
}
+

+
impl Default for SplitContainerProps {
+
    fn default() -> Self {
+
        let theme = Theme::default();
+

+
        Self {
+
            split_focus: SplitContainerFocus::default(),
+
            heights: [Constraint::Percentage(50), Constraint::Percentage(50)],
+
            border_color: theme.border_color,
+
            focus_border_color: theme.focus_border_color,
+
        }
+
    }
}

impl SplitContainerProps {
@@ -456,6 +551,16 @@ impl SplitContainerProps {
        self.heights = heights;
        self
    }
+

+
    pub fn border_color(mut self, color: Color) -> Self {
+
        self.border_color = color;
+
        self
+
    }
+

+
    pub fn focus_border_color(mut self, color: Color) -> Self {
+
        self.focus_border_color = color;
+
        self
+
    }
}

pub struct SplitContainer<S, M> {
@@ -544,12 +649,18 @@ where
            })
            .collect::<Vec<_>>();

+
        let border_style = if render.focus {
+
            Style::default().fg(props.focus_border_color)
+
        } else {
+
            Style::default().fg(props.border_color)
+
        };
+

        let [top_area, bottom_area] = Layout::vertical(heights).areas(render.area);

        if let Some(top) = self.top.as_mut() {
            let block = HeaderBlock::default()
                .borders(Borders::ALL)
-
                .border_style(style::border(render.focus))
+
                .border_style(border_style)
                .border_type(BorderType::Rounded);

            frame.render_widget(block, top_area);
@@ -566,7 +677,7 @@ where
        if let Some(bottom) = self.bottom.as_mut() {
            let block = Block::default()
                .borders(Borders::LEFT | Borders::RIGHT | Borders::BOTTOM)
-
                .border_style(style::border(render.focus))
+
                .border_style(border_style)
                .border_type(BorderType::Rounded);

            frame.render_widget(block, bottom_area);
modified src/ui/widget/input.rs
@@ -7,14 +7,23 @@ use ratatui::layout::{Alignment, Constraint, Layout};
use ratatui::style::{Style, Stylize};
use ratatui::text::{Line, Span, Text};

+
use crate::ui::theme::Theme;
+

use super::{utils, RenderProps, View, ViewProps, ViewState};

#[derive(Clone)]
pub struct TextFieldProps {
+
    /// The label of this input field.
    pub title: String,
+
    /// The input text.
+
    pub text: String,
+
    /// Sets if the label should be displayed inline with the input. The default is `false`.
    pub inline_label: bool,
+
    /// Sets if the cursor should be shown. The default is `true`.
    pub show_cursor: bool,
-
    pub text: String,
+
    /// Set to `true` if the content style should be dimmed whenever the widget
+
    /// has no focus.
+
    pub dim: bool,
}

impl TextFieldProps {
@@ -34,6 +43,11 @@ impl TextFieldProps {
        self.inline_label = inline;
        self
    }
+

+
    pub fn dim(mut self, dim: bool) -> Self {
+
        self.dim = dim;
+
        self
+
    }
}

impl Default for TextFieldProps {
@@ -43,6 +57,7 @@ impl Default for TextFieldProps {
            inline_label: false,
            show_cursor: true,
            text: String::new(),
+
            dim: false,
        }
    }
}
@@ -238,22 +253,33 @@ where

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

+
        let (label, input, overline) = if !render.focus && props.dim {
+
            (
+
                Span::from(label_content.clone()).magenta().dim().reversed(),
+
                Span::from(input).reset().dim(),
+
                Span::raw(overline).magenta().dim(),
+
            )
+
        } else {
+
            (
+
                Span::from(label_content.clone()).magenta().reversed(),
+
                Span::from(input).reset(),
+
                Span::raw(overline).magenta(),
+
            )
+
        };
+

        if props.inline_label {
            let top_layout = Layout::horizontal([
-
                Constraint::Length(label.chars().count() as u16),
+
                Constraint::Length(label_content.chars().count() as u16),
                Constraint::Length(1),
                Constraint::Min(1),
            ])
            .split(layout[0]);

-
            let label = Span::from(label.clone()).magenta().dim().reversed();
-
            let input = Span::from(input).reset();
-

-
            let overline = Line::from([Span::raw(overline).magenta().dim()].to_vec());
+
            let overline = Line::from([overline].to_vec());

            frame.render_widget(label, top_layout[0]);
            frame.render_widget(input, top_layout[2]);
@@ -263,14 +289,8 @@ where
                frame.set_cursor(top_layout[2].x + cursor_pos, top_layout[2].y)
            }
        } else {
-
            let top = Line::from([Span::from(input).reset()].to_vec());
-
            let bottom = Line::from(
-
                [
-
                    Span::from(label).magenta().dim().reversed(),
-
                    Span::raw(overline).magenta().dim(),
-
                ]
-
                .to_vec(),
-
            );
+
            let top = Line::from([input].to_vec());
+
            let bottom = Line::from([label, overline].to_vec());

            frame.render_widget(top, layout[0]);
            frame.render_widget(bottom, layout[1]);
@@ -306,6 +326,9 @@ pub struct TextAreaProps<'a> {
    show_scroll_progress: bool,
    /// If this text area should render its cursor progress. Default: `false`.
    show_column_progress: bool,
+
    /// Set to `true` if the content style should be dimmed whenever the widget
+
    /// has no focus.
+
    dim: bool,
}

impl<'a> Default for TextAreaProps<'a> {
@@ -317,6 +340,7 @@ impl<'a> Default for TextAreaProps<'a> {
            insert_mode: false,
            show_scroll_progress: false,
            show_column_progress: false,
+
            dim: false,
        }
    }
}
@@ -349,6 +373,11 @@ impl<'a> TextAreaProps<'a> {
        self.handle_keys = handle_keys;
        self
    }
+

+
    pub fn dim(mut self, dim: bool) -> Self {
+
        self.dim = dim;
+
        self
+
    }
}

/// A non-editable text area that can be behave like a text editor.
@@ -463,10 +492,10 @@ impl<'a, S, M> View for TextArea<'a, S, M> {
        } else {
            cursor_line_style
        };
-
        let content_style = if render.focus {
-
            Style::default()
-
        } else {
+
        let content_style = if !render.focus && props.dim {
            Style::default().dim()
+
        } else {
+
            Style::default()
        };

        self.textarea.move_cursor(tui_textarea::CursorMove::Jump(
@@ -550,6 +579,11 @@ pub struct TextViewProps<'a> {
    show_scroll_progress: bool,
    /// An optional text that is rendered inside the footer bar on the bottom.
    footer: Option<Text<'a>>,
+
    /// The style used whenever the widget has focus.
+
    textview_style: Style,
+
    /// Set to `true` if the content style should be dimmed whenever the widget
+
    /// has no focus.
+
    dim: bool,
}

impl<'a> TextViewProps<'a> {
@@ -583,16 +617,30 @@ impl<'a> TextViewProps<'a> {
        self.handle_keys = handle_keys;
        self
    }
+

+
    pub fn textview_style(mut self, style: Style) -> Self {
+
        self.textview_style = style;
+
        self
+
    }
+

+
    pub fn dim(mut self, dim: bool) -> Self {
+
        self.dim = dim;
+
        self
+
    }
}

impl<'a> Default for TextViewProps<'a> {
    fn default() -> Self {
+
        let theme = Theme::default();
+

        Self {
            content: String::new().into(),
            cursor: (0, 0),
            handle_keys: true,
            show_scroll_progress: false,
            footer: None,
+
            textview_style: theme.textview_style,
+
            dim: false,
        }
    }
}
@@ -753,10 +801,10 @@ where
        ])
        .areas(area);

-
        let style = if render.focus {
-
            Style::default()
+
        let style = if !render.focus && props.dim {
+
            props.textview_style.dim()
        } else {
-
            Style::default().dim()
+
            props.textview_style
        };

        let content = ratatui::widgets::Paragraph::new(props.content.clone())
modified src/ui/widget/list.rs
@@ -45,6 +45,7 @@ where
    pub selected: Option<usize>,
    pub columns: Vec<Column<'a>>,
    pub show_scrollbar: bool,
+
    pub dim: bool,
}

impl<'a, R, const W: usize> Default for TableProps<'a, R, W>
@@ -57,6 +58,7 @@ where
            columns: vec![],
            show_scrollbar: true,
            selected: Some(0),
+
            dim: false,
        }
    }
}
@@ -84,6 +86,11 @@ where
        self.show_scrollbar = show_scrollbar;
        self
    }
+

+
    pub fn dim(mut self, dim: bool) -> Self {
+
        self.dim = dim;
+
        self
+
    }
}

pub struct Table<S, M, R, const W: usize>
@@ -264,7 +271,7 @@ where
                    for cell in item.to_row() {
                        if let Some(col) = it.next() {
                            if !col.skip && col.displayed(render.area.width as usize) {
-
                                cells.push(cell.clone());
+
                                cells.push(cell.clone())
                            }
                        } else {
                            continue;
@@ -275,12 +282,19 @@ where
                })
                .collect::<Vec<_>>();

-
            let rows = ratatui::widgets::Table::default()
+
            let table = ratatui::widgets::Table::default()
                .rows(rows)
                .widths(widths)
                .column_spacing(1)
                .highlight_style(style::highlight(render.focus));
-
            frame.render_stateful_widget(rows, table_area, &mut self.state.0);
+

+
            let table = if !render.focus && props.dim {
+
                table.dim()
+
            } else {
+
                table
+
            };
+

+
            frame.render_stateful_widget(table, table_area, &mut self.state.0);

            let scroller = Scrollbar::default()
                .begin_symbol(None)
@@ -339,6 +353,9 @@ where
    /// Optional identifier set of opened items. If not `None`,
    /// it will override the internal tree state.
    pub opened: Option<HashSet<Vec<Id>>>,
+
    /// Set to `true` if the content style should be dimmed whenever the widget
+
    /// has no focus.
+
    pub dim: bool,
}

impl<R, Id> Default for TreeProps<R, Id>
@@ -352,6 +369,7 @@ where
            selected: None,
            show_scrollbar: true,
            opened: None,
+
            dim: false,
        }
    }
}
@@ -380,6 +398,11 @@ where
        self.show_scrollbar = show_scrollbar;
        self
    }
+

+
    pub fn dim(mut self, dim: bool) -> Self {
+
        self.dim = dim;
+
        self
+
    }
}

/// A `Tree` is an expandable, collapsable and scrollable tree widget, that takes
@@ -476,6 +499,12 @@ where
            items.extend(item.rows());
        }

+
        let tree_style = if !render.focus && props.dim {
+
            Style::default().dim()
+
        } else {
+
            Style::default()
+
        };
+

        let tree = if props.show_scrollbar {
            tui_tree_widget::Tree::new(&items)
                .expect("all item identifiers are unique")
@@ -500,9 +529,11 @@ where
                        .thumb_symbol("┃"),
                ))
                .highlight_style(style::highlight(render.focus))
+
                .style(tree_style)
        } else {
            tui_tree_widget::Tree::new(&items)
                .expect("all item identifiers are unique")
+
                .style(tree_style)
                .highlight_style(style::highlight(render.focus))
        };

modified src/ui/widget/window.rs
@@ -5,11 +5,11 @@ use ratatui::Frame;
use termion::event::Key;

use ratatui::layout::{Constraint, Layout};
-
use ratatui::style::Stylize;
+
use ratatui::style::{Style, Stylize};
use ratatui::text::Text;
use ratatui::widgets::Row;

-
use crate::ui::theme::style;
+
use crate::ui::theme::{style, Theme};

use super::{RenderProps, View, ViewProps, Widget};

@@ -206,6 +206,8 @@ where
pub struct ShortcutsProps {
    pub shortcuts: Vec<(String, String)>,
    pub divider: char,
+
    pub shortcuts_keys_style: Style,
+
    pub shortcuts_action_style: Style,
}

impl ShortcutsProps {
@@ -221,13 +223,27 @@ impl ShortcutsProps {
        }
        self
    }
+

+
    pub fn shortcuts_keys_style(mut self, style: Style) -> Self {
+
        self.shortcuts_keys_style = style;
+
        self
+
    }
+

+
    pub fn shortcuts_action_style(mut self, style: Style) -> Self {
+
        self.shortcuts_action_style = style;
+
        self
+
    }
}

impl Default for ShortcutsProps {
    fn default() -> Self {
+
        let theme = Theme::default();
+

        Self {
            shortcuts: vec![],
            divider: '∙',
+
            shortcuts_keys_style: theme.shortcuts_keys_style,
+
            shortcuts_action_style: theme.shortcuts_action_style,
        }
    }
}
@@ -261,8 +277,8 @@ impl<S, M> View for Shortcuts<S, M> {
        let mut row = vec![];

        while let Some(shortcut) = shortcuts.next() {
-
            let short = Text::from(shortcut.0.clone()).style(style::gray());
-
            let long = Text::from(shortcut.1.clone()).style(style::gray().dim());
+
            let short = Text::from(shortcut.0.clone()).style(props.shortcuts_keys_style);
+
            let long = Text::from(shortcut.1.clone()).style(props.shortcuts_action_style);
            let spacer = Text::from(String::new());
            let divider = Text::from(format!(" {} ", props.divider)).style(style::gray().dim());