Radish alpha
r
Radicle terminal user interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
tui: Improve application header
Erik Kundt committed 2 years ago
commit dbcde21a907fe72031f671bc63ec594a2a981ea4
parent bc803d89adecf805e890c4297ad451afd4a568c2
8 files changed +240 -48
modified radicle-tui/src/app.rs
@@ -23,7 +23,7 @@ use self::page::{IssuePage, PageStack};

#[derive(Debug, Eq, PartialEq, Clone, Hash)]
pub enum HomeCid {
-
    Navigation,
+
    Header,
    Dashboard,
    IssueBrowser,
    PatchBrowser,
@@ -31,7 +31,7 @@ pub enum HomeCid {

#[derive(Debug, Eq, PartialEq, Clone, Hash)]
pub enum PatchCid {
-
    Navigation,
+
    Header,
    Activity,
    Files,
}
modified radicle-tui/src/app/event.rs
@@ -2,7 +2,7 @@ use tuirealm::command::{Cmd, CmdResult, Direction as MoveDirection};
use tuirealm::event::{Event, Key, KeyEvent};
use tuirealm::{MockComponent, NoUserEvent, State, StateValue};

-
use radicle_tui::ui::widget::common::container::{GlobalListener, LabeledContainer, Tabs};
+
use radicle_tui::ui::widget::common::container::{AppHeader, GlobalListener, LabeledContainer};
use radicle_tui::ui::widget::common::context::{ContextBar, Shortcuts};
use radicle_tui::ui::widget::common::list::PropertyList;
use radicle_tui::ui::widget::home::{Dashboard, IssueBrowser, PatchBrowser};
@@ -29,7 +29,7 @@ impl tuirealm::Component<Message, NoUserEvent> for Widget<GlobalListener> {
    }
}

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<Tabs> {
+
impl tuirealm::Component<Message, NoUserEvent> for Widget<AppHeader> {
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
        match event {
            Event::Keyboard(KeyEvent { code: Key::Tab, .. }) => {
modified radicle-tui/src/app/page.rs
@@ -76,13 +76,14 @@ impl ViewPage for HomeView {
        context: &Context,
        theme: &Theme,
    ) -> Result<()> {
-
        let navigation = widget::home::navigation(theme).to_boxed();
+
        let navigation = widget::home::navigation(theme);
+
        let header = widget::common::app_header(context, theme, Some(navigation)).to_boxed();

        let dashboard = widget::home::dashboard(context, theme).to_boxed();
        let issue_browser = widget::home::issues(context, theme).to_boxed();
        let patch_browser = widget::home::patches(context, theme).to_boxed();

-
        app.remount(Cid::Home(HomeCid::Navigation), navigation, vec![])?;
+
        app.remount(Cid::Home(HomeCid::Header), header, vec![])?;

        app.remount(Cid::Home(HomeCid::Dashboard), dashboard, vec![])?;
        app.remount(Cid::Home(HomeCid::IssueBrowser), issue_browser, vec![])?;
@@ -94,7 +95,7 @@ impl ViewPage for HomeView {
    }

    fn unmount(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
-
        app.umount(&Cid::Home(HomeCid::Navigation))?;
+
        app.umount(&Cid::Home(HomeCid::Header))?;
        app.umount(&Cid::Home(HomeCid::Dashboard))?;
        app.umount(&Cid::Home(HomeCid::IssueBrowser))?;
        app.umount(&Cid::Home(HomeCid::PatchBrowser))?;
@@ -120,13 +121,13 @@ impl ViewPage for HomeView {
        let area = frame.size();
        let layout = layout::default_page(area);

-
        app.view(&Cid::Home(HomeCid::Navigation), frame, layout[0]);
+
        app.view(&Cid::Home(HomeCid::Header), frame, layout[0]);
        app.view(&self.active_component, frame, layout[1]);
    }

    fn subscribe(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
        app.subscribe(
-
            &Cid::Home(HomeCid::Navigation),
+
            &Cid::Home(HomeCid::Header),
            Sub::new(subscription::navigation_clause(), SubClause::Always),
        )?;

@@ -135,7 +136,7 @@ impl ViewPage for HomeView {

    fn unsubscribe(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
        app.unsubscribe(
-
            &Cid::Home(HomeCid::Navigation),
+
            &Cid::Home(HomeCid::Header),
            subscription::navigation_clause(),
        )?;

@@ -258,11 +259,13 @@ impl ViewPage for PatchView {
        theme: &Theme,
    ) -> Result<()> {
        let (id, patch) = &self.patch;
-
        let navigation = widget::patch::navigation(theme).to_boxed();
+
        let navigation = widget::patch::navigation(theme);
+
        let header = widget::common::app_header(context, theme, Some(navigation)).to_boxed();
+

        let activity = widget::patch::activity(theme, (*id, patch), context.profile()).to_boxed();
        let files = widget::patch::files(theme, (*id, patch), context.profile()).to_boxed();

-
        app.remount(Cid::Patch(PatchCid::Navigation), navigation, vec![])?;
+
        app.remount(Cid::Patch(PatchCid::Header), header, vec![])?;
        app.remount(Cid::Patch(PatchCid::Activity), activity, vec![])?;
        app.remount(Cid::Patch(PatchCid::Files), files, vec![])?;

@@ -272,7 +275,7 @@ impl ViewPage for PatchView {
    }

    fn unmount(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
-
        app.umount(&Cid::Patch(PatchCid::Navigation))?;
+
        app.umount(&Cid::Patch(PatchCid::Header))?;
        app.umount(&Cid::Patch(PatchCid::Activity))?;
        app.umount(&Cid::Patch(PatchCid::Files))?;
        Ok(())
@@ -297,13 +300,13 @@ impl ViewPage for PatchView {
        let area = frame.size();
        let layout = layout::default_page(area);

-
        app.view(&Cid::Patch(PatchCid::Navigation), frame, layout[0]);
+
        app.view(&Cid::Patch(PatchCid::Header), frame, layout[0]);
        app.view(&self.active_component, frame, layout[1]);
    }

    fn subscribe(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
        app.subscribe(
-
            &Cid::Patch(PatchCid::Navigation),
+
            &Cid::Patch(PatchCid::Header),
            Sub::new(subscription::navigation_clause(), SubClause::Always),
        )?;

@@ -312,7 +315,7 @@ impl ViewPage for PatchView {

    fn unsubscribe(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
        app.unsubscribe(
-
            &Cid::Patch(PatchCid::Navigation),
+
            &Cid::Patch(PatchCid::Header),
            subscription::navigation_clause(),
        )?;

modified radicle-tui/src/ui/context.rs
@@ -3,7 +3,6 @@ use radicle::Profile;

use radicle::storage::git::Repository;
use radicle::storage::ReadStorage;
-

pub struct Context {
    profile: Profile,
    id: Id,
@@ -14,6 +13,7 @@ pub struct Context {
impl Context {
    pub fn new(profile: Profile, id: Id, project: Project) -> Self {
        let repository = profile.storage.repository(id).unwrap();
+

        Self {
            id,
            profile,
modified radicle-tui/src/ui/layout.rs
@@ -9,6 +9,12 @@ pub struct IssuePreview {
    pub shortcuts: Rect,
}

+
pub struct AppHeader {
+
    pub nav: Rect,
+
    pub info: Rect,
+
    pub line: Rect,
+
}
+

pub fn v_stack(
    widgets: Vec<Box<dyn MockComponent>>,
    area: Rect,
@@ -53,6 +59,30 @@ pub fn h_stack(
    widgets.into_iter().zip(layout.into_iter()).collect()
}

+
pub fn app_header(area: Rect, info_w: u16) -> AppHeader {
+
    let nav_w = area.width.saturating_sub(info_w);
+

+
    let layout = Layout::default()
+
        .direction(Direction::Vertical)
+
        .constraints(vec![
+
            Constraint::Length(1),
+
            Constraint::Length(1),
+
            Constraint::Length(1),
+
        ])
+
        .split(area);
+

+
    let top = Layout::default()
+
        .direction(Direction::Horizontal)
+
        .constraints([Constraint::Length(nav_w), Constraint::Length(info_w)].as_ref())
+
        .split(layout[1]);
+

+
    AppHeader {
+
        nav: top[0],
+
        info: top[1],
+
        line: layout[2],
+
    }
+
}
+

pub fn default_page(area: Rect) -> Vec<Rect> {
    let nav_h = 3u16;
    let margin_h = 1u16;
modified radicle-tui/src/ui/theme.rs
@@ -11,7 +11,8 @@ const COLOR_DEFAULT_FAINT: Color = Color::Rgb(20, 20, 20);
pub struct Colors {
    pub default_fg: Color,
    pub tabs_highlighted_fg: Color,
-
    pub workspaces_info_fg: Color,
+
    pub app_header_project_fg: Color,
+
    pub app_header_rid_fg: Color,
    pub labeled_container_bg: Color,
    pub item_list_highlighted_bg: Color,
    pub property_name_fg: Color,
@@ -79,7 +80,8 @@ pub fn default_dark() -> Theme {
        colors: Colors {
            default_fg: COLOR_DEFAULT_FG,
            tabs_highlighted_fg: Color::Magenta,
-
            workspaces_info_fg: Color::Yellow,
+
            app_header_project_fg: Color::Cyan,
+
            app_header_rid_fg: Color::Yellow,
            labeled_container_bg: COLOR_DEFAULT_FAINT,
            item_list_highlighted_bg: COLOR_DEFAULT_DARKER,
            property_name_fg: Color::Cyan,
modified radicle-tui/src/ui/widget/common.rs
@@ -11,11 +11,12 @@ use context::{Shortcut, Shortcuts};
use label::Label;
use list::{Property, PropertyList};

-
use self::container::Container;
+
use self::container::{AppHeader, AppInfo, Container, VerticalLine};
use self::list::{ColumnWidth, PropertyTable};

use super::Widget;

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

pub fn global_listener() -> Widget<GlobalListener> {
@@ -115,10 +116,39 @@ pub fn property_table(_theme: &Theme, properties: Vec<Widget<Property>>) -> Widg
    Widget::new(table)
}

-
pub fn tabs(theme: &Theme, tabs: Vec<Widget<Label>>) -> Widget<Tabs> {
+
pub fn tabs(_theme: &Theme, tabs: Vec<Widget<Label>>) -> Widget<Tabs> {
+
    let tabs = Tabs::new(tabs);
+

+
    Widget::new(tabs).height(2)
+
}
+

+
pub fn app_info(context: &Context, theme: &Theme) -> Widget<AppInfo> {
+
    let project = label(context.project().name()).foreground(theme.colors.app_header_project_fg);
+
    let rid = label(&format!(" ({})", context.id())).foreground(theme.colors.app_header_rid_fg);
+

+
    let project_w = project
+
        .query(Attribute::Width)
+
        .unwrap_or(AttrValue::Size(0))
+
        .unwrap_size();
+
    let rid_w = rid
+
        .query(Attribute::Width)
+
        .unwrap_or(AttrValue::Size(0))
+
        .unwrap_size();
+

+
    let info = AppInfo::new(project, rid);
+
    Widget::new(info).width(project_w.saturating_add(rid_w))
+
}
+

+
pub fn app_header(
+
    context: &Context,
+
    theme: &Theme,
+
    nav: Option<Widget<Tabs>>,
+
) -> Widget<AppHeader> {
    let line =
        label(&theme.icons.tab_overline.to_string()).foreground(theme.colors.tabs_highlighted_fg);
-
    let tabs = Tabs::new(tabs, line);
+
    let line = Widget::new(VerticalLine::new(line));
+
    let info = app_info(context, theme);
+
    let header = AppHeader::new(nav, info, line);

-
    Widget::new(tabs).height(2)
+
    Widget::new(header)
}
modified radicle-tui/src/ui/widget/common/container.rs
@@ -33,21 +33,62 @@ impl WidgetComponent for GlobalListener {
    }
}

+
/// A vertical separator.
+
#[derive(Clone)]
+
pub struct VerticalLine {
+
    line: Widget<Label>,
+
}
+

+
impl VerticalLine {
+
    pub fn new(line: Widget<Label>) -> Self {
+
        Self { line }
+
    }
+
}
+

+
impl WidgetComponent for VerticalLine {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        let display = properties
+
            .get_or(Attribute::Display, AttrValue::Flag(true))
+
            .unwrap_flag();
+

+
        if display {
+
            // Repeat and render line.
+
            let overlines = vec![self.line.clone(); area.width as usize];
+
            let overlines = overlines
+
                .iter()
+
                .map(|l| l.clone().to_boxed() as Box<dyn MockComponent>)
+
                .collect();
+
            let line_layout = layout::h_stack(overlines, area);
+
            for (mut line, area) in line_layout {
+
                line.view(frame, area);
+
            }
+
        }
+
    }
+

+
    fn state(&self) -> State {
+
        State::None
+
    }
+

+
    fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
+
        CmdResult::None
+
    }
+
}
+

+
////////////////////////////////////////////////
+

/// A tab header that displays all labels horizontally aligned and separated
/// by a divider. Highlights the label defined by the current tab index.
#[derive(Clone)]
pub struct Tabs {
    tabs: Vec<Widget<Label>>,
-
    line: Widget<Label>,
    state: TabState,
}

impl Tabs {
-
    pub fn new(tabs: Vec<Widget<Label>>, line: Widget<Label>) -> Self {
+
    pub fn new(tabs: Vec<Widget<Label>>) -> Self {
        let count = &tabs.len();
        Self {
            tabs,
-
            line,
            state: TabState {
                selected: 0,
                len: *count as u16,
@@ -64,16 +105,7 @@ impl WidgetComponent for Tabs {
            .unwrap_flag();

        if display {
-
            let layout = Layout::default()
-
                .direction(Direction::Vertical)
-
                .constraints(vec![
-
                    Constraint::Length(1),
-
                    Constraint::Length(1),
-
                    Constraint::Length(1),
-
                ])
-
                .split(area);
-

-
            // Render tabs on first row, highlighting the selected tab.
+
            // Render tabs, highlighting the selected tab.
            let mut tabs = vec![];
            for (index, tab) in self.tabs.iter().enumerate() {
                let mut tab = tab.clone().to_boxed();
@@ -87,21 +119,10 @@ impl WidgetComponent for Tabs {
            }
            tabs.push(Widget::new(Label::default()).to_boxed());

-
            let tab_layout = layout::h_stack(tabs, layout[1]);
+
            let tab_layout = layout::h_stack(tabs, area);
            for (mut tab, area) in tab_layout {
                tab.view(frame, area);
            }
-

-
            // Repeat and render line on second row.
-
            let overlines = vec![self.line.clone(); area.width as usize];
-
            let overlines = overlines
-
                .iter()
-
                .map(|l| l.clone().to_boxed() as Box<dyn MockComponent>)
-
                .collect();
-
            let line_layout = layout::h_stack(overlines, layout[2]);
-
            for (mut line, area) in line_layout {
-
                line.view(frame, area);
-
            }
        }
    }

@@ -127,6 +148,112 @@ impl WidgetComponent for Tabs {
    }
}

+
/// An application info widget that renders project / branch information
+
/// and a separator line. Used in conjunction with [`Tabs`].
+
pub struct AppInfo {
+
    project: Widget<Label>,
+
    rid: Widget<Label>,
+
}
+

+
impl AppInfo {
+
    pub fn new(project: Widget<Label>, rid: Widget<Label>) -> Self {
+
        Self { project, rid }
+
    }
+
}
+

+
impl WidgetComponent for AppInfo {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        let display = properties
+
            .get_or(Attribute::Display, AttrValue::Flag(true))
+
            .unwrap_flag();
+

+
        let project_w = self
+
            .project
+
            .query(Attribute::Width)
+
            .unwrap_or(AttrValue::Size(10))
+
            .unwrap_size();
+

+
        let rid_w = self
+
            .rid
+
            .query(Attribute::Width)
+
            .unwrap_or(AttrValue::Size(10))
+
            .unwrap_size();
+

+
        if display {
+
            let layout = Layout::default()
+
                .direction(Direction::Horizontal)
+
                .constraints(vec![
+
                    Constraint::Length(project_w),
+
                    Constraint::Length(rid_w),
+
                ])
+
                .split(area);
+

+
            self.project.view(frame, layout[0]);
+
            self.rid.view(frame, layout[1]);
+
        }
+
    }
+

+
    fn state(&self) -> State {
+
        State::None
+
    }
+

+
    fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
+
        CmdResult::None
+
    }
+
}
+

+
/// A common application header that renders project / branch
+
/// information and an optional navigation.
+
pub struct AppHeader {
+
    nav: Option<Widget<Tabs>>,
+
    info: Widget<AppInfo>,
+
    line: Widget<VerticalLine>,
+
}
+

+
impl AppHeader {
+
    pub fn new(
+
        nav: Option<Widget<Tabs>>,
+
        info: Widget<AppInfo>,
+
        line: Widget<VerticalLine>,
+
    ) -> Self {
+
        Self { nav, info, line }
+
    }
+
}
+

+
impl WidgetComponent for AppHeader {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        let display = properties
+
            .get_or(Attribute::Display, AttrValue::Flag(true))
+
            .unwrap_flag();
+
        let info_w = self
+
            .info
+
            .query(Attribute::Width)
+
            .unwrap_or(AttrValue::Size(10))
+
            .unwrap_size();
+

+
        if display {
+
            let layout = layout::app_header(area, info_w);
+

+
            if let Some(nav) = self.nav.as_mut() {
+
                nav.view(frame, layout.nav);
+
            }
+
            self.info.view(frame, layout.info);
+
            self.line.view(frame, layout.line);
+
        }
+
    }
+

+
    fn state(&self) -> State {
+
        State::None
+
    }
+

+
    fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
+
        self.nav
+
            .as_mut()
+
            .map(|nav| nav.perform(cmd))
+
            .unwrap_or(CmdResult::None)
+
    }
+
}
+

/// A labeled container header.
pub struct Header<const W: usize> {
    header: [Widget<Label>; W],