tui: Split widgets into separate modules

This commit is contained in:
Erik Kundt 2023-04-20 11:01:48 +02:00 committed by Alexis Sellier
parent 6346931ce2
commit 969219c51d
No known key found for this signature in database
7 changed files with 321 additions and 298 deletions

View File

@ -18,7 +18,7 @@ use radicle_tui::ui::components::workspace::{
};
use radicle_tui::ui::layout;
use radicle_tui::ui::theme::{self, Theme};
use radicle_tui::ui::widget::Widget;
use radicle_tui::ui::widget::{self, Widget};
use radicle_tui::Tui;
@ -135,7 +135,7 @@ impl Tui<Cid, Message> for App {
self.mount_home_view(app, &self.theme.clone())?;
// Add global key listener and subscribe to key events
let global = ui::global_listener().to_boxed();
let global = ui::widget::common::global_listener().to_boxed();
app.mount(Cid::GlobalListener, global, subs::global())?;
Ok(())
@ -223,11 +223,12 @@ impl ViewPage for Home {
context: &Context,
theme: &Theme,
) -> Result<()> {
let navigation = ui::home_navigation(theme).to_boxed();
let navigation = widget::home::navigation(theme).to_boxed();
let dashboard = ui::dashboard(theme, &context.id, &context.project).to_boxed();
let issue_browser = ui::issue_browser(theme).to_boxed();
let patch_browser = ui::patch_browser(theme, &context.patches, &context.profile).to_boxed();
let dashboard = widget::home::dashboard(theme, &context.id, &context.project).to_boxed();
let issue_browser = widget::home::issues(theme).to_boxed();
let patch_browser =
widget::home::patches(theme, &context.patches, &context.profile).to_boxed();
app.remount(
Cid::Home(HomeCid::Navigation),
@ -291,9 +292,10 @@ impl ViewPage for PatchView {
theme: &Theme,
) -> Result<()> {
if let Some((id, patch)) = context.patches.get(context.selected_patch) {
let navigation = ui::patch_navigation(theme).to_boxed();
let activity = ui::patch_activity(theme, (*id, patch), &context.profile).to_boxed();
let files = ui::patch_files(theme, (*id, patch), &context.profile).to_boxed();
let navigation = widget::patch::navigation(theme).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),
@ -338,11 +340,11 @@ impl ViewPage for PatchView {
impl tuirealm::Component<Message, NoUserEvent> for Widget<GlobalListener> {
fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
match event {
Event::WindowResize(_, _) => Some(Message::Tick),
Event::Keyboard(KeyEvent {
code: Key::Char('q'),
..
}) => Some(Message::Quit),
Event::WindowResize(_, _) => Some(Message::Tick),
_ => None,
}
}

View File

@ -22,11 +22,14 @@ where
Id: Clone + Hash + Eq + PartialEq,
UserEvent: Clone + Eq + PartialEq + PartialOrd,
{
vec![Sub::new(
SubEventClause::Keyboard(KeyEvent {
code: Key::Char('q'),
modifiers: KeyModifiers::NONE,
}),
SubClause::Always,
)]
vec![
Sub::new(
SubEventClause::Keyboard(KeyEvent {
code: Key::Char('q'),
modifiers: KeyModifiers::NONE,
}),
SubClause::Always,
),
Sub::new(SubEventClause::WindowResize, SubClause::Always),
]
}

View File

@ -4,284 +4,3 @@ pub mod layout;
pub mod state;
pub mod theme;
pub mod widget;
use radicle::prelude::{Id, Project};
use radicle::Profile;
use tuirealm::props::{AttrValue, Attribute, Color, PropPayload, PropValue, TextSpan};
use tuirealm::MockComponent;
use radicle::cob::patch::{Patch, PatchId};
use components::container::{GlobalListener, LabeledContainer, Tabs};
use components::context::{Shortcut, Shortcuts};
use components::label::Label;
use components::list::{Property, PropertyList};
use widget::Widget;
use self::cob::patch;
use self::components::context::ContextBar;
use self::components::list::{List, Table};
use self::components::workspace::{Browser, Dashboard, IssueBrowser, PatchActivity, PatchFiles};
use self::theme::Theme;
pub fn global_listener() -> Widget<GlobalListener> {
Widget::new(GlobalListener::default())
}
pub fn label(content: &str) -> Widget<Label> {
// TODO: Remove when size constraints are implemented
let width = content.chars().count() as u16;
Widget::new(Label::default())
.content(AttrValue::String(content.to_string()))
.height(1)
.width(width)
}
pub fn labeled_container(
theme: &theme::Theme,
title: &str,
component: Box<dyn MockComponent>,
) -> Widget<LabeledContainer> {
let title = label(&format!(" {title} "))
.foreground(theme.colors.default_fg)
.background(theme.colors.labeled_container_bg);
let container = LabeledContainer::new(title, component);
Widget::new(container).background(theme.colors.labeled_container_bg)
}
pub fn shortcut(theme: &theme::Theme, short: &str, long: &str) -> Widget<Shortcut> {
let short = label(short).foreground(theme.colors.shortcut_short_fg);
let divider = label(&theme.icons.whitespace.to_string());
let long = label(long).foreground(theme.colors.shortcut_long_fg);
// TODO: Remove when size constraints are implemented
let short_w = short.query(Attribute::Width).unwrap().unwrap_size();
let divider_w = divider.query(Attribute::Width).unwrap().unwrap_size();
let long_w = long.query(Attribute::Width).unwrap().unwrap_size();
let width = short_w.saturating_add(divider_w).saturating_add(long_w);
let shortcut = Shortcut::new(short, divider, long);
Widget::new(shortcut).height(1).width(width)
}
pub fn shortcuts(theme: &theme::Theme, shortcuts: Vec<Widget<Shortcut>>) -> Widget<Shortcuts> {
let divider = label(&format!(" {} ", theme.icons.shortcutbar_divider))
.foreground(theme.colors.shortcutbar_divider_fg);
let shortcut_bar = Shortcuts::new(shortcuts, divider);
Widget::new(shortcut_bar).height(1)
}
pub fn property(theme: &theme::Theme, name: &str, value: &str) -> Widget<Property> {
let name = label(name).foreground(theme.colors.property_name_fg);
let divider = label(&format!(" {} ", theme.icons.property_divider));
let value = label(value).foreground(theme.colors.default_fg);
// TODO: Remove when size constraints are implemented
let name_w = name.query(Attribute::Width).unwrap().unwrap_size();
let divider_w = divider.query(Attribute::Width).unwrap().unwrap_size();
let value_w = value.query(Attribute::Width).unwrap().unwrap_size();
let width = name_w.saturating_add(divider_w).saturating_add(value_w);
let property = Property::new(name, divider, value);
Widget::new(property).height(1).width(width)
}
pub fn property_list(
_theme: &theme::Theme,
properties: Vec<Widget<Property>>,
) -> Widget<PropertyList> {
let property_list = PropertyList::new(properties);
Widget::new(property_list)
}
pub fn tabs(theme: &theme::Theme, tabs: Vec<Widget<Label>>) -> Widget<Tabs> {
let divider = label(&theme.icons.tab_divider.to_string());
let tabs = Tabs::new(tabs, divider);
Widget::new(tabs)
.height(1)
.foreground(theme.colors.tabs_fg)
.highlight(theme.colors.tabs_highlighted_fg)
}
pub fn table(theme: &theme::Theme, items: &[impl List], profile: &Profile) -> Widget<Table> {
let table = Table::default();
let items = items.iter().map(|item| item.row(theme, profile)).collect();
Widget::new(table)
.content(AttrValue::Table(items))
.background(theme.colors.labeled_container_bg)
.highlight(theme.colors.item_list_highlighted_bg)
}
pub fn issue_browser(theme: &theme::Theme) -> Widget<IssueBrowser> {
let shortcuts = shortcuts(
theme,
vec![
shortcut(theme, "tab", "section"),
shortcut(theme, "q", "quit"),
],
);
let not_implemented = label("not implemented").foreground(theme.colors.default_fg);
let browser = IssueBrowser::new(not_implemented, shortcuts);
Widget::new(browser)
}
pub fn patch_browser(
theme: &theme::Theme,
items: &[(PatchId, Patch)],
profile: &Profile,
) -> Widget<Browser<(PatchId, Patch)>> {
let shortcuts = shortcuts(
theme,
vec![
shortcut(theme, "tab", "section"),
shortcut(theme, "↑/↓", "navigate"),
shortcut(theme, "enter", "show"),
shortcut(theme, "q", "quit"),
],
);
let widths = AttrValue::Payload(PropPayload::Vec(vec![
PropValue::U16(2),
PropValue::U16(43),
PropValue::U16(15),
PropValue::U16(15),
PropValue::U16(5),
PropValue::U16(20),
]));
let header = AttrValue::Payload(PropPayload::Vec(vec![
PropValue::TextSpan(TextSpan::from("")),
PropValue::TextSpan(TextSpan::from("title")),
PropValue::TextSpan(TextSpan::from("author")),
PropValue::TextSpan(TextSpan::from("tags")),
PropValue::TextSpan(TextSpan::from("comments")),
PropValue::TextSpan(TextSpan::from("date")),
]));
let table = table(theme, items, profile)
.custom("widths", widths)
.custom("header", header);
let browser: Browser<(PatchId, Patch)> = Browser::new(table, shortcuts);
Widget::new(browser)
}
pub fn patch_activity(
theme: &theme::Theme,
patch: (PatchId, &Patch),
profile: &Profile,
) -> Widget<PatchActivity> {
let (id, patch) = patch;
let shortcuts = shortcuts(
theme,
vec![
shortcut(theme, "esc", "back"),
shortcut(theme, "tab", "section"),
shortcut(theme, "q", "quit"),
],
);
let context = patch_context(theme, (id, patch), profile);
let not_implemented = label("not implemented").foreground(theme.colors.default_fg);
let activity = PatchActivity::new(not_implemented, context, shortcuts);
Widget::new(activity)
}
pub fn patch_files(
theme: &theme::Theme,
patch: (PatchId, &Patch),
profile: &Profile,
) -> Widget<PatchFiles> {
let (id, patch) = patch;
let shortcuts = shortcuts(
theme,
vec![
shortcut(theme, "esc", "back"),
shortcut(theme, "tab", "section"),
shortcut(theme, "q", "quit"),
],
);
let context = patch_context(theme, (id, patch), profile);
let not_implemented = label("not implemented").foreground(theme.colors.default_fg);
let files = PatchFiles::new(not_implemented, context, shortcuts);
Widget::new(files)
}
pub fn patch_context(
_theme: &Theme,
patch: (PatchId, &Patch),
profile: &Profile,
) -> Widget<ContextBar> {
let (id, patch) = patch;
let id = patch::format_id(id);
let title = patch.title();
let author = patch::format_author(patch, profile);
let comments = patch::format_comments(patch);
let context = label(" patch ").background(Color::Rgb(238, 111, 248));
let id = label(&format!(" {id} "))
.foreground(Color::Rgb(117, 113, 249))
.background(Color::Rgb(40, 40, 40));
let title = label(&format!(" {title} "))
.foreground(Color::Rgb(70, 70, 70))
.background(Color::Rgb(40, 40, 40));
let author = label(&format!(" {author} "))
.foreground(Color::Rgb(117, 113, 249))
.background(Color::Rgb(40, 40, 40));
let comments = label(&format!(" {comments} "))
.foreground(Color::Rgb(70, 70, 70))
.background(Color::Rgb(50, 50, 50));
let context_bar = ContextBar::new(context, id, author, title, comments);
Widget::new(context_bar).height(1)
}
pub fn home_navigation(theme: &theme::Theme) -> Widget<Tabs> {
tabs(
theme,
vec![label("dashboard"), label("issues"), label("patches")],
)
}
pub fn patch_navigation(theme: &theme::Theme) -> Widget<Tabs> {
tabs(theme, vec![label("activity"), label("files")])
}
pub fn dashboard(theme: &theme::Theme, id: &Id, project: &Project) -> Widget<Dashboard> {
let about = labeled_container(
theme,
"about",
property_list(
theme,
vec![
property(theme, "id", &id.to_string()),
property(theme, "name", project.name()),
property(theme, "description", project.description()),
],
)
.to_boxed(),
);
let shortcuts = shortcuts(
theme,
vec![
shortcut(theme, "tab", "section"),
shortcut(theme, "q", "quit"),
],
);
let dashboard = Dashboard::new(about, shortcuts);
Widget::new(dashboard)
}

View File

@ -1,3 +1,7 @@
pub mod common;
pub mod home;
pub mod patch;
use std::ops::Deref;
use tuirealm::command::{Cmd, CmdResult};

View File

@ -0,0 +1,106 @@
use radicle::Profile;
use tuirealm::props::{AttrValue, Attribute};
use tuirealm::MockComponent;
use crate::ui::components::container::{GlobalListener, LabeledContainer, Tabs};
use crate::ui::components::context::{Shortcut, Shortcuts};
use crate::ui::components::label::Label;
use crate::ui::components::list::{Property, PropertyList};
use super::Widget;
use crate::ui::components::list::{List, Table};
use crate::ui::theme::Theme;
pub fn global_listener() -> Widget<GlobalListener> {
Widget::new(GlobalListener::default())
}
pub fn label(content: &str) -> Widget<Label> {
// TODO: Remove when size constraints are implemented
let width = content.chars().count() as u16;
Widget::new(Label::default())
.content(AttrValue::String(content.to_string()))
.height(1)
.width(width)
}
pub fn labeled_container(
theme: &Theme,
title: &str,
component: Box<dyn MockComponent>,
) -> Widget<LabeledContainer> {
let title = label(&format!(" {title} "))
.foreground(theme.colors.default_fg)
.background(theme.colors.labeled_container_bg);
let container = LabeledContainer::new(title, component);
Widget::new(container).background(theme.colors.labeled_container_bg)
}
pub fn shortcut(theme: &Theme, short: &str, long: &str) -> Widget<Shortcut> {
let short = label(short).foreground(theme.colors.shortcut_short_fg);
let divider = label(&theme.icons.whitespace.to_string());
let long = label(long).foreground(theme.colors.shortcut_long_fg);
// TODO: Remove when size constraints are implemented
let short_w = short.query(Attribute::Width).unwrap().unwrap_size();
let divider_w = divider.query(Attribute::Width).unwrap().unwrap_size();
let long_w = long.query(Attribute::Width).unwrap().unwrap_size();
let width = short_w.saturating_add(divider_w).saturating_add(long_w);
let shortcut = Shortcut::new(short, divider, long);
Widget::new(shortcut).height(1).width(width)
}
pub fn shortcuts(theme: &Theme, shortcuts: Vec<Widget<Shortcut>>) -> Widget<Shortcuts> {
let divider = label(&format!(" {} ", theme.icons.shortcutbar_divider))
.foreground(theme.colors.shortcutbar_divider_fg);
let shortcut_bar = Shortcuts::new(shortcuts, divider);
Widget::new(shortcut_bar).height(1)
}
pub fn property(theme: &Theme, name: &str, value: &str) -> Widget<Property> {
let name = label(name).foreground(theme.colors.property_name_fg);
let divider = label(&format!(" {} ", theme.icons.property_divider));
let value = label(value).foreground(theme.colors.default_fg);
// TODO: Remove when size constraints are implemented
let name_w = name.query(Attribute::Width).unwrap().unwrap_size();
let divider_w = divider.query(Attribute::Width).unwrap().unwrap_size();
let value_w = value.query(Attribute::Width).unwrap().unwrap_size();
let width = name_w.saturating_add(divider_w).saturating_add(value_w);
let property = Property::new(name, divider, value);
Widget::new(property).height(1).width(width)
}
pub fn property_list(_theme: &Theme, properties: Vec<Widget<Property>>) -> Widget<PropertyList> {
let property_list = PropertyList::new(properties);
Widget::new(property_list)
}
pub fn tabs(theme: &Theme, tabs: Vec<Widget<Label>>) -> Widget<Tabs> {
let divider = label(&theme.icons.tab_divider.to_string());
let tabs = Tabs::new(tabs, divider);
Widget::new(tabs)
.height(1)
.foreground(theme.colors.tabs_fg)
.highlight(theme.colors.tabs_highlighted_fg)
}
pub fn table(theme: &Theme, items: &[impl List], profile: &Profile) -> Widget<Table> {
let table = Table::default();
let items = items.iter().map(|item| item.row(theme, profile)).collect();
Widget::new(table)
.content(AttrValue::Table(items))
.background(theme.colors.labeled_container_bg)
.highlight(theme.colors.item_list_highlighted_bg)
}

View File

@ -0,0 +1,103 @@
use radicle::cob::patch::{Patch, PatchId};
use radicle::identity::{Id, Project};
use radicle::Profile;
use tuirealm::props::{PropPayload, PropValue, TextSpan};
use tuirealm::AttrValue;
use crate::ui::components::container::Tabs;
use crate::ui::components::workspace::{Browser, Dashboard, IssueBrowser};
use crate::ui::theme::Theme;
use super::{common, Widget};
pub fn navigation(theme: &Theme) -> Widget<Tabs> {
common::tabs(
theme,
vec![
common::label("dashboard"),
common::label("issues"),
common::label("patches"),
],
)
}
pub fn dashboard(theme: &Theme, id: &Id, project: &Project) -> Widget<Dashboard> {
let about = common::labeled_container(
theme,
"about",
common::property_list(
theme,
vec![
common::property(theme, "id", &id.to_string()),
common::property(theme, "name", project.name()),
common::property(theme, "description", project.description()),
],
)
.to_boxed(),
);
let shortcuts = common::shortcuts(
theme,
vec![
common::shortcut(theme, "tab", "section"),
common::shortcut(theme, "q", "quit"),
],
);
let dashboard = Dashboard::new(about, shortcuts);
Widget::new(dashboard)
}
pub fn patches(
theme: &Theme,
items: &[(PatchId, Patch)],
profile: &Profile,
) -> Widget<Browser<(PatchId, Patch)>> {
let shortcuts = common::shortcuts(
theme,
vec![
common::shortcut(theme, "tab", "section"),
common::shortcut(theme, "↑/↓", "navigate"),
common::shortcut(theme, "enter", "show"),
common::shortcut(theme, "q", "quit"),
],
);
let widths = AttrValue::Payload(PropPayload::Vec(vec![
PropValue::U16(2),
PropValue::U16(43),
PropValue::U16(15),
PropValue::U16(15),
PropValue::U16(5),
PropValue::U16(20),
]));
let header = AttrValue::Payload(PropPayload::Vec(vec![
PropValue::TextSpan(TextSpan::from("")),
PropValue::TextSpan(TextSpan::from("title")),
PropValue::TextSpan(TextSpan::from("author")),
PropValue::TextSpan(TextSpan::from("tags")),
PropValue::TextSpan(TextSpan::from("comments")),
PropValue::TextSpan(TextSpan::from("date")),
]));
let table = common::table(theme, items, profile)
.custom("widths", widths)
.custom("header", header);
let browser: Browser<(PatchId, Patch)> = Browser::new(table, shortcuts);
Widget::new(browser)
}
pub fn issues(theme: &Theme) -> Widget<IssueBrowser> {
let shortcuts = common::shortcuts(
theme,
vec![
common::shortcut(theme, "tab", "section"),
common::shortcut(theme, "q", "quit"),
],
);
let not_implemented = common::label("not implemented").foreground(theme.colors.default_fg);
let browser = IssueBrowser::new(not_implemented, shortcuts);
Widget::new(browser)
}

View File

@ -0,0 +1,86 @@
use radicle::Profile;
use tuirealm::props::Color;
use radicle::cob::patch::{Patch, PatchId};
use super::common;
use super::Widget;
use crate::ui::cob::patch;
use crate::ui::components::container::Tabs;
use crate::ui::components::context::ContextBar;
use crate::ui::components::workspace::{PatchActivity, PatchFiles};
use crate::ui::theme::Theme;
pub fn navigation(theme: &Theme) -> Widget<Tabs> {
common::tabs(
theme,
vec![common::label("activity"), common::label("files")],
)
}
pub fn activity(
theme: &Theme,
patch: (PatchId, &Patch),
profile: &Profile,
) -> Widget<PatchActivity> {
let (id, patch) = patch;
let shortcuts = common::shortcuts(
theme,
vec![
common::shortcut(theme, "esc", "back"),
common::shortcut(theme, "tab", "section"),
common::shortcut(theme, "q", "quit"),
],
);
let context = context(theme, (id, patch), profile);
let not_implemented = common::label("not implemented").foreground(theme.colors.default_fg);
let activity = PatchActivity::new(not_implemented, context, shortcuts);
Widget::new(activity)
}
pub fn files(theme: &Theme, patch: (PatchId, &Patch), profile: &Profile) -> Widget<PatchFiles> {
let (id, patch) = patch;
let shortcuts = common::shortcuts(
theme,
vec![
common::shortcut(theme, "esc", "back"),
common::shortcut(theme, "tab", "section"),
common::shortcut(theme, "q", "quit"),
],
);
let context = context(theme, (id, patch), profile);
let not_implemented = common::label("not implemented").foreground(theme.colors.default_fg);
let files = PatchFiles::new(not_implemented, context, shortcuts);
Widget::new(files)
}
pub fn context(_theme: &Theme, patch: (PatchId, &Patch), profile: &Profile) -> Widget<ContextBar> {
let (id, patch) = patch;
let id = patch::format_id(id);
let title = patch.title();
let author = patch::format_author(patch, profile);
let comments = patch::format_comments(patch);
let context = common::label(" patch ").background(Color::Rgb(238, 111, 248));
let id = common::label(&format!(" {} ", id))
.foreground(Color::Rgb(117, 113, 249))
.background(Color::Rgb(40, 40, 40));
let title = common::label(&format!(" {} ", title))
.foreground(Color::Rgb(70, 70, 70))
.background(Color::Rgb(40, 40, 40));
let author = common::label(&format!(" {} ", author))
.foreground(Color::Rgb(117, 113, 249))
.background(Color::Rgb(40, 40, 40));
let comments = common::label(&format!(" {} ", comments))
.foreground(Color::Rgb(70, 70, 70))
.background(Color::Rgb(50, 50, 50));
let context_bar = ContextBar::new(context, id, author, title, comments);
Widget::new(context_bar).height(1)
}