tui: Improve application header

This commit is contained in:
Erik Kundt 2023-06-30 16:17:00 +02:00 committed by Alexis Sellier
parent 8811fa97d4
commit 21950e1cae
No known key found for this signature in database
8 changed files with 241 additions and 49 deletions

View File

@ -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,
}

View File

@ -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, .. }) => {

View File

@ -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(),
)?;

View File

@ -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,

View File

@ -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;

View File

@ -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,

View File

@ -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> {
let line =
label(&theme.icons.tab_overline.to_string()).foreground(theme.colors.tabs_highlighted_fg);
let tabs = Tabs::new(tabs, line);
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 line = Widget::new(VerticalLine::new(line));
let info = app_info(context, theme);
let header = AppHeader::new(nav, info, line);
Widget::new(header)
}

View File

@ -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],