Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
issue/list: Port to imUI
Merged did:key:z6MkgFq6...nBGz opened 3 months ago
14 files changed +1571 -1561 731a828e 5269df37
modified bin/commands/inbox/list.rs
@@ -373,6 +373,7 @@ impl App {
                    &notifs,
                    header.to_vec(),
                    Some("".into()),
+
                    Spacing::from(1),
                    Some(Borders::BottomSides),
                );
                if table.changed {
modified bin/commands/issue.rs
@@ -219,8 +219,8 @@ pub async fn run(options: Options, ctx: impl Context) -> anyhow::Result<()> {
                    comment: state.comment_id,
                };

-
                let app = list::App::new(context, terminal_info.clone());
-
                let selection = app.run().await?;
+
                let tui = list::Tui::new(context, terminal_info.clone());
+
                let selection = tui.run().await?;

                if opts.json {
                    let selection = selection
@@ -294,8 +294,8 @@ pub async fn run(options: Options, ctx: impl Context) -> anyhow::Result<()> {
                                search,
                            } => {
                                let comment_id = comment(
-
                                    &app.context().profile,
-
                                    &app.context().repository,
+
                                    &tui.context().profile,
+
                                    &tui.context().repository,
                                    id,
                                    Message::Edit,
                                    reply_to,
modified bin/commands/issue/list.rs
@@ -1,54 +1,69 @@
-
#[path = "list/ui.rs"]
-
mod ui;
-

use std::collections::{HashMap, HashSet};
use std::str::FromStr;
+
use std::sync::{Arc, Mutex};

use anyhow::{bail, Result};

-
use ratatui::layout::Constraint;
+
use radicle_tui::ui::im::widget::TreeState;
+
use radicle_tui::ui::ToRow;
+
use ratatui::layout::{Alignment, Constraint, Layout, Position};
use ratatui::style::Stylize;
-
use ratatui::text::Text;
-
use ratatui::Viewport;
+
use ratatui::text::{Line, Span, Text};
+
use ratatui::{Frame, Viewport};

use radicle::cob::thread::CommentId;
-
use radicle::git::Oid;
use radicle::issue::IssueId;
use radicle::storage::git::Repository;
use radicle::Profile;

use radicle_tui as tui;

-
use tui::event::{Event, Key};
+
use tui::event::Key;
use tui::store;
use tui::task::EmptyProcessors;
-
use tui::ui::rm::widget::container::{
-
    Container, ContainerProps, Footer, FooterProps, Header, HeaderProps, SectionGroup,
-
    SectionGroupProps, SplitContainer, SplitContainerFocus, SplitContainerProps,
-
};
-
use tui::ui::rm::widget::list::{Tree, TreeProps};
-
use tui::ui::rm::widget::text::{TextView, TextViewProps, TextViewState};
-
use tui::ui::rm::widget::window::{
-
    Page, PageProps, Shortcuts, ShortcutsProps, Window, WindowProps,
-
};
-
use tui::ui::rm::widget::{PredefinedLayout, ToWidget, Widget};
-
use tui::ui::theme::Theme;
+
use tui::ui::im;
+
use tui::ui::im::widget::{ContainerState, TableState, TextEditState, TextViewState, Window};
+
use tui::ui::im::{Borders, Show};
use tui::ui::Column;
-
use tui::ui::{span, BufferedValue};
-
use tui::{BoxedAny, Channel, Exit, PageStack};
+
use tui::ui::{span, BufferedValue, Spacing};
+
use tui::{Channel, Exit};

use crate::cob::issue;
use crate::settings::{self, ThemeBundle, ThemeMode};
-
use crate::ui::items::{CommentItem, IssueItem, IssueItemFilter};
-
use crate::ui::rm::{BrowserState, IssueDetails, IssueDetailsProps};
-
use crate::ui::TerminalInfo;
-

-
use self::ui::{Browser, BrowserProps};
+
use crate::ui::items::filter::Filter;
+
use crate::ui::items::issue::{Issue, IssueFilter};
+
use crate::ui::items::HasId;
+
use crate::ui::{format, TerminalInfo};

-
use super::common::IssueOperation;
+
use crate::tui_issue::common::IssueOperation;

type Selection = tui::Selection<IssueOperation>;

+
const HELP: &str = r#"# Generic keybindings
+

+
`↑,k`:      move cursor one line up
+
`↓,j:       move cursor one line down
+
`PageUp`:   move cursor one page up
+
`PageDown`: move cursor one page down
+
`Home`:     move cursor to the first line
+
`End`:      move cursor to the last line
+
`Tab`:      focus next section
+
`BackTab`:  focus previous section
+
`Esc`:      Cancel
+
`q`:        Quit
+

+
# Specific keybindings
+

+
`/`:        Search
+
`Enter`:    Show issue
+
`e`:        Edit issue
+
`s`:        Solve issue
+
`l`:        Close issue
+
`o`:        Re-open issue
+
`c`:        Reply to comment
+
`p`:        Toggle issue preview
+
`?`:        Show help"#;
+

pub struct Context {
    pub profile: Profile,
    pub repository: Repository,
@@ -58,112 +73,174 @@ pub struct Context {
    pub comment: Option<CommentId>,
}

-
pub struct App {
-
    context: Context,
-
    terminal_info: TerminalInfo,
+
pub(crate) struct Tui {
+
    pub(crate) context: Context,
+
    pub(crate) terminal_info: TerminalInfo,
}

-
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
-
pub enum AppPage {
-
    Browser,
-
    Help,
-
}
+
impl Tui {
+
    pub fn new(context: Context, terminal_info: TerminalInfo) -> Self {
+
        Self {
+
            context,
+
            terminal_info,
+
        }
+
    }

-
#[derive(Clone, Default, Debug, Eq, PartialEq)]
-
pub enum Section {
-
    #[default]
-
    Browser,
-
    Details,
-
    Comment,
-
}
+
    pub async fn run(&self) -> Result<Option<Selection>> {
+
        let viewport = Viewport::Inline(20);
+
        let channel = Channel::default();
+
        let state = App::try_from((&self.context, &self.terminal_info))?;

-
impl TryFrom<usize> for Section {
-
    type Error = anyhow::Error;
+
        tui::im(state, viewport, channel, EmptyProcessors::new()).await
+
    }

-
    fn try_from(value: usize) -> Result<Self, Self::Error> {
-
        match value {
-
            0 => Ok(Section::Browser),
-
            1 => Ok(Section::Details),
-
            2 => Ok(Section::Comment),
-
            _ => bail!("Unknown section index: {}", value),
-
        }
+
    pub fn context(&self) -> &Context {
+
        &self.context
    }
}

-
impl From<Section> for usize {
-
    fn from(section: Section) -> Self {
-
        match section {
-
            Section::Browser => 0,
-
            Section::Details => 1,
-
            Section::Comment => 2,
-
        }
+
mod state {
+
    use super::*;
+
    use crate::ui::items::CommentItem;
+

+
    #[derive(Clone, Debug)]
+
    pub(crate) enum Page {
+
        Main,
+
        Help,
    }
-
}

-
#[derive(Clone, Debug)]
-
pub struct PreviewState {
-
    /// If preview is visible.
-
    show: bool,
-
    /// Currently selected issue item.
-
    issue: Option<IssueItem>,
-
    /// Tree selection per issue.
-
    selected_comments: HashMap<IssueId, Vec<CommentId>>,
-
    /// State of currently selected comment
-
    comment: TextViewState,
-
}
+
    #[derive(Clone, Default, Debug, Eq, PartialEq)]
+
    pub(crate) enum Section {
+
        #[default]
+
        Browser,
+
        Issue,
+
        Comment,
+
    }
+

+
    impl TryFrom<usize> for Section {
+
        type Error = anyhow::Error;

-
impl PreviewState {
-
    pub fn root_comments(&self) -> Vec<CommentItem> {
-
        self.issue
-
            .as_ref()
-
            .map(|item| item.root_comments())
-
            .unwrap_or_default()
+
        fn try_from(value: usize) -> Result<Self, Self::Error> {
+
            match value {
+
                0 => Ok(Section::Browser),
+
                1 => Ok(Section::Issue),
+
                2 => Ok(Section::Comment),
+
                _ => bail!("Unknown section index: {}", value),
+
            }
+
        }
    }

-
    pub fn selected_comment(&self) -> Option<&CommentItem> {
-
        self.issue.as_ref().and_then(|item| {
-
            self.selected_comments
-
                .get(&item.id)
-
                .and_then(|selection| selection.last().copied())
-
                .and_then(|comment_id| item.comments.iter().find(|item| item.id == comment_id))
-
        })
+
    #[derive(Clone, Debug)]
+
    pub(crate) struct Browser {
+
        pub(crate) issues: TableState,
+
        pub(crate) search: BufferedValue<TextEditState>,
+
        pub(crate) show_search: bool,
    }

-
    pub fn selected_comment_ids(&self) -> Vec<String> {
-
        self.issue
-
            .as_ref()
-
            .and_then(|item| self.selected_comments.get(&item.id))
-
            .map(|selected| selected.iter().map(|oid| oid.to_string()).collect())
-
            .unwrap_or_default()
+
    impl Browser {
+
        pub fn selected(&self) -> Option<usize> {
+
            self.issues.selected()
+
        }
    }

-
    pub fn opened_comments(&self) -> HashSet<Vec<String>> {
-
        let mut opened = HashSet::new();
-
        if let Some(item) = &self.issue {
-
            for comment in item.root_comments() {
-
                append_opened(&mut opened, vec![], comment.clone());
+
    #[derive(Clone, Debug)]
+
    pub(crate) struct Preview {
+
        /// If preview is visible.
+
        pub(crate) show: bool,
+
        /// Currently selected issue item.
+
        pub(crate) issue: Option<Issue>,
+
        /// Tree selection per issue.
+
        pub(crate) selected_comments: HashMap<IssueId, Vec<CommentId>>,
+
        /// State of currently selected comment
+
        pub(crate) comment: TextViewState,
+
    }
+

+
    impl Preview {
+
        pub fn root_comments(&self) -> Vec<CommentItem> {
+
            self.issue
+
                .as_ref()
+
                .map(|item| item.root_comments())
+
                .unwrap_or_default()
+
        }
+

+
        pub fn selected_comment(&self) -> Option<&CommentItem> {
+
            self.issue.as_ref().and_then(|item| {
+
                self.selected_comments
+
                    .get(&item.id)
+
                    .and_then(|selection| selection.last().copied())
+
                    .and_then(|comment_id| item.comments.iter().find(|item| item.id == comment_id))
+
            })
+
        }
+

+
        pub fn selected_comment_ids(&self) -> Vec<String> {
+
            self.issue
+
                .as_ref()
+
                .and_then(|item| self.selected_comments.get(&item.id))
+
                .map(|selected| selected.iter().map(|oid| oid.to_string()).collect())
+
                .unwrap_or_default()
+
        }
+

+
        pub fn opened_comments(&self) -> HashSet<Vec<String>> {
+
            let mut opened = HashSet::new();
+
            if let Some(item) = &self.issue {
+
                for comment in item.root_comments() {
+
                    append_opened(&mut opened, vec![], comment.clone());
+
                }
            }
+
            opened
+
        }
+
    }
+

+
    fn append_opened(all: &mut HashSet<Vec<String>>, path: Vec<String>, comment: CommentItem) {
+
        all.insert([path.clone(), [comment.id.to_string()].to_vec()].concat());
+

+
        for reply in comment.replies {
+
            append_opened(
+
                all,
+
                [path.clone(), [comment.id.to_string()].to_vec()].concat(),
+
                reply,
+
            );
        }
-
        opened
    }
}

#[derive(Clone, Debug)]
-
pub struct HelpState {
-
    text: TextViewState,
+
pub enum Change {
+
    Page { page: state::Page },
+
    Section { state: ContainerState },
+
    Issue { state: TableState },
+
    Comment { state: TreeState<String> },
+
    CommentBody { state: TextViewState },
+
    ShowSearch { state: bool, apply: bool },
+
    ShowPreview { state: bool },
+
    Search { state: BufferedValue<TextEditState> },
+
    Help { state: TextViewState },
}

#[derive(Clone, Debug)]
-
pub struct State {
-
    pages: PageStack<AppPage>,
-
    browser: BrowserState<IssueItem, IssueItemFilter>,
-
    preview: PreviewState,
-
    section: Option<Section>,
-
    help: HelpState,
-
    theme: Theme,
+
pub enum Message {
+
    Changed(Change),
+
    Exit { operation: Option<IssueOperation> },
+
    Quit,
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct AppState {
+
    page: state::Page,
+
    sections: ContainerState,
+
    browser: state::Browser,
+
    preview: state::Preview,
+
    help: TextViewState,
+
    filter: IssueFilter,
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct App {
+
    issues: Arc<Mutex<Vec<Issue>>>,
+
    state: AppState,
}

-
impl TryFrom<(&Context, &TerminalInfo)> for State {
+
impl TryFrom<(&Context, &TerminalInfo)> for App {
    type Error = anyhow::Error;

    fn try_from(value: (&Context, &TerminalInfo)) -> Result<Self, Self::Error> {
@@ -173,11 +250,11 @@ impl TryFrom<(&Context, &TerminalInfo)> for State {
        let issues = issue::all(&context.profile, &context.repository)?;
        let search =
            BufferedValue::new(context.search.clone().unwrap_or(context.filter.to_string()));
-
        let filter = IssueItemFilter::from_str(&search.read()).unwrap_or_default();
+
        let filter = IssueFilter::from_str(&search.read()).unwrap_or_default();

        let default_bundle = ThemeBundle::default();
        let theme_bundle = settings.theme.active_bundle().unwrap_or(&default_bundle);
-
        let theme = match settings.theme.mode() {
+
        let _theme = match settings.theme.mode() {
            ThemeMode::Auto => {
                if terminal_info.is_dark() {
                    theme_bundle.dark.clone()
@@ -192,7 +269,7 @@ impl TryFrom<(&Context, &TerminalInfo)> for State {
        // Convert into UI items
        let mut issues: Vec<_> = issues
            .into_iter()
-
            .flat_map(|issue| IssueItem::new(&context.profile, issue).ok())
+
            .flat_map(|issue| Issue::new(&context.profile, issue).ok())
            .collect();

        issues.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
@@ -217,601 +294,856 @@ impl TryFrom<(&Context, &TerminalInfo)> for State {
            })
            .collect();

-
        let browser = BrowserState::build(issues, context.issue, filter, search);
-
        let preview = PreviewState {
+
        let browser = state::Browser {
+
            issues: TableState::new(
+
                context
+
                    .issue
+
                    .map(|id| {
+
                        issues
+
                            .iter()
+
                            .filter(|item| filter.matches(item))
+
                            .position(|item| item.id() == id)
+
                    })
+
                    .unwrap_or(issues.first().map(|_| 0)),
+
            ),
+
            search: BufferedValue::new(TextEditState {
+
                text: search.read().clone(),
+
                cursor: search.read().len(),
+
            }),
+
            show_search: false,
+
        };
+

+
        let preview = state::Preview {
            show: true,
-
            issue: browser.selected_item().cloned(),
+
            issue: browser
+
                .selected()
+
                .and_then(|s| {
+
                    issues
+
                        .iter()
+
                        .filter(|item| filter.matches(item))
+
                        .collect::<Vec<_>>()
+
                        .get(s)
+
                        .cloned()
+
                })
+
                .cloned(),
            selected_comments,
-
            comment: TextViewState::default(),
+
            comment: TextViewState::new(Position::default()),
        };

        let section = if context.comment.is_some() {
-
            Some(Section::Details)
+
            state::Section::Issue
        } else {
-
            Some(Section::Browser)
+
            state::Section::Comment
        };

        Ok(Self {
-
            pages: PageStack::new(vec![AppPage::Browser]),
-
            browser,
-
            preview,
-
            section,
-
            help: HelpState {
-
                text: TextViewState::default().content(help_text()),
+
            issues: Arc::new(Mutex::new(issues)),
+
            state: AppState {
+
                page: state::Page::Main,
+
                sections: ContainerState::new(3, Some(section as usize)),
+
                browser,
+
                preview,
+
                filter,
+
                help: TextViewState::new(Position::default()),
            },
-
            theme,
        })
    }
}

-
#[derive(Clone, Debug)]
-
pub enum RequestedIssueOperation {
-
    Edit,
-
    EditComment,
-
    Show,
-
    Reply,
-
    Solve,
-
    Close,
-
    Reopen,
-
}
-

-
#[derive(Clone, Debug)]
-
pub enum Message {
-
    Quit,
-
    Exit {
-
        operation: Option<RequestedIssueOperation>,
-
    },
-
    SelectIssue {
-
        selected: Option<usize>,
-
    },
-
    OpenSearch,
-
    UpdateSearch {
-
        value: String,
-
    },
-
    ApplySearch,
-
    CloseSearch,
-
    TogglePreview,
-
    FocusSection {
-
        section: Option<Section>,
-
    },
-
    SelectComment {
-
        selected: Option<Vec<CommentId>>,
-
    },
-
    ScrollComment {
-
        state: TextViewState,
-
    },
-
    OpenHelp,
-
    LeavePage,
-
    ScrollHelp {
-
        state: TextViewState,
-
    },
-
}
-

-
impl store::Update<Message> for State {
+
impl store::Update<Message> for App {
    type Return = Selection;

-
    fn update(&mut self, message: Message) -> Option<Exit<Selection>> {
+
    fn update(&mut self, message: Message) -> Option<tui::Exit<Selection>> {
        match message {
            Message::Quit => Some(Exit { value: None }),
-
            Message::Exit { operation } => {
-
                let issue = self.browser.selected_item();
-
                let comment = self.preview.selected_comment();
-
                let operation = match operation {
-
                    Some(RequestedIssueOperation::Show) => {
-
                        issue.map(|issue| IssueOperation::Show { id: issue.id })
-
                    }
-
                    Some(RequestedIssueOperation::Edit) => {
-
                        issue.map(|issue| IssueOperation::Edit {
-
                            id: issue.id,
-
                            comment_id: None,
-
                            search: self.browser.read_search(),
-
                        })
-
                    }
-
                    Some(RequestedIssueOperation::EditComment) => {
-
                        issue.map(|issue| IssueOperation::Edit {
-
                            id: issue.id,
-
                            comment_id: comment.map(|c| c.id),
-
                            search: self.browser.read_search(),
-
                        })
+
            Message::Exit { operation } => Some(Exit {
+
                value: Some(Selection {
+
                    operation,
+
                    args: vec![],
+
                }),
+
            }),
+
            Message::Changed(changed) => match changed {
+
                Change::Page { page } => {
+
                    self.state.page = page;
+
                    None
+
                }
+
                Change::Section { state } => {
+
                    self.state.sections = state;
+
                    None
+
                }
+
                Change::Issue { state } => {
+
                    let issues = self.issues.lock().unwrap();
+
                    let issues = issues
+
                        .clone()
+
                        .into_iter()
+
                        .filter(|issue| self.state.filter.matches(issue))
+
                        .collect::<Vec<_>>();
+

+
                    self.state.browser.issues = state;
+
                    self.state.preview.issue = self
+
                        .state
+
                        .browser
+
                        .selected()
+
                        .and_then(|s| issues.get(s).cloned());
+
                    self.state.preview.comment = TextViewState::new(Position::default());
+
                    None
+
                }
+
                Change::ShowSearch { state, apply } => {
+
                    if state {
+
                        self.state.sections = ContainerState::new(self.state.sections.len(), None);
+
                        self.state.browser.show_search = true;
+
                    } else {
+
                        let issues = self.issues.lock().unwrap();
+
                        let issues = issues
+
                            .clone()
+
                            .into_iter()
+
                            .filter(|issue| self.state.filter.matches(issue))
+
                            .collect::<Vec<_>>();
+

+
                        self.state.preview.issue = self
+
                            .state
+
                            .browser
+
                            .selected()
+
                            .and_then(|s| issues.get(s).cloned());
+
                        self.state.sections =
+
                            ContainerState::new(self.state.sections.len(), Some(0));
+
                        self.state.browser.show_search = false;
+

+
                        if apply {
+
                            self.state.browser.search.apply();
+
                        } else {
+
                            self.state.browser.search.reset();
+
                        }
+

+
                        self.state.filter =
+
                            IssueFilter::from_str(&self.state.browser.search.read().text)
+
                                .unwrap_or_default();
                    }
-
                    Some(RequestedIssueOperation::Solve) => {
-
                        issue.map(|issue| IssueOperation::Solve { id: issue.id })
+
                    None
+
                }
+
                Change::ShowPreview { state } => {
+
                    self.state.preview.show = state;
+
                    self.state.sections = ContainerState::new(if state { 3 } else { 1 }, Some(0));
+
                    None
+
                }
+
                Change::Search { state } => {
+
                    let issues = self.issues.lock().unwrap();
+
                    let issues = issues
+
                        .clone()
+
                        .into_iter()
+
                        .filter(|issue| self.state.filter.matches(issue))
+
                        .collect::<Vec<_>>();
+

+
                    self.state.browser.search = state.clone();
+
                    self.state.filter =
+
                        IssueFilter::from_str(&state.read().text).unwrap_or_default();
+
                    self.state.browser.issues.select_first();
+

+
                    self.state.preview.issue = self
+
                        .state
+
                        .browser
+
                        .selected()
+
                        .and_then(|s| issues.get(s).cloned());
+
                    None
+
                }
+
                Change::Comment { state } => {
+
                    log::info!("Change::Comments: {state:?}");
+
                    if let Some(item) = &self.state.preview.issue {
+
                        self.state.preview.selected_comments.insert(
+
                            item.id,
+
                            state
+
                                .internal
+
                                .selected()
+
                                .iter()
+
                                .map(|s| CommentId::from_str(s).unwrap())
+
                                .collect(),
+
                        );
                    }
-
                    Some(RequestedIssueOperation::Close) => {
-
                        issue.map(|issue| IssueOperation::Close { id: issue.id })
+
                    self.state.preview.comment = TextViewState::new(Position::default());
+
                    None
+
                }
+
                Change::CommentBody { state } => {
+
                    self.state.preview.comment = state;
+
                    None
+
                }
+
                Change::Help { state } => {
+
                    self.state.help = state;
+
                    None
+
                }
+
            },
+
        }
+
    }
+
}
+

+
impl Show<Message> for App {
+
    fn show(&self, ctx: &im::Context<Message>, frame: &mut Frame) -> Result<()> {
+
        Window::default().show(ctx, |ui| {
+
            match self.state.page.clone() {
+
                state::Page::Main => {
+
                    let show_search = self.state.browser.show_search;
+
                    let page_focus = if show_search { Some(1) } else { Some(0) };
+

+
                    ui.layout(
+
                        Layout::vertical([Constraint::Fill(1), Constraint::Length(2)]),
+
                        page_focus,
+
                        |ui| {
+
                            let (mut focus, count) =
+
                                { (self.state.sections.focus(), self.state.sections.len()) };
+

+
                            let group = ui.container(
+
                                im::Layout::Expandable3 {
+
                                    left_only: !self.state.preview.show,
+
                                },
+
                                &mut focus,
+
                                |ui| {
+
                                    self.show_browser(frame, ui);
+
                                    self.show_issue(frame, ui);
+
                                    self.show_comment(frame, ui);
+
                                },
+
                            );
+

+
                            if group.response.changed {
+
                                ui.send_message(Message::Changed(Change::Section {
+
                                    state: ContainerState::new(count, focus),
+
                                }));
+
                            }
+

+
                            ui.layout(
+
                                Layout::vertical(match show_search {
+
                                    true => [2, 0],
+
                                    false => [1, 1],
+
                                }),
+
                                Some(0),
+
                                |ui| {
+
                                    use state::Section;
+
                                    if let Some(section) = focus {
+
                                        match Section::try_from(section).unwrap_or_default() {
+
                                            Section::Browser => {
+
                                                self.show_browser_context(frame, ui);
+
                                                self.show_browser_shortcuts(frame, ui);
+
                                            }
+
                                            Section::Issue => {
+
                                                self.show_issue_context(frame, ui);
+
                                                self.show_issue_shortcuts(frame, ui);
+
                                            }
+
                                            Section::Comment => {
+
                                                self.show_comment_context(frame, ui);
+
                                                self.show_comment_shortcuts(frame, ui);
+
                                            }
+
                                        }
+
                                    } else if show_search {
+
                                        self.show_browser_search(frame, ui);
+
                                    }
+
                                },
+
                            );
+
                        },
+
                    );
+

+
                    if ui.has_input(|key| key == Key::Char('p')) {
+
                        ui.send_message(Message::Changed(Change::ShowPreview {
+
                            state: !self.state.preview.show,
+
                        }));
                    }
-
                    Some(RequestedIssueOperation::Reopen) => {
-
                        issue.map(|issue| IssueOperation::Reopen { id: issue.id })
+
                    if ui.has_input(|key| key == Key::Char('?')) {
+
                        ui.send_message(Message::Changed(Change::Page {
+
                            page: state::Page::Help,
+
                        }));
                    }
-
                    Some(RequestedIssueOperation::Reply) => {
-
                        issue.map(|issue| IssueOperation::Comment {
-
                            id: issue.id,
-
                            reply_to: comment.map(|c| c.id),
-
                            search: self.browser.read_search(),
-
                        })
+
                }
+
                state::Page::Help => {
+
                    let layout = Layout::vertical([
+
                        Constraint::Length(3),
+
                        Constraint::Fill(1),
+
                        Constraint::Length(1),
+
                        Constraint::Length(1),
+
                    ]);
+

+
                    ui.container(layout, &mut Some(1), |ui| {
+
                        self.show_help_text(frame, ui);
+
                        self.show_help_context(frame, ui);
+

+
                        ui.shortcuts(frame, &[("?", "close")], '∙', Alignment::Left);
+
                    });
+

+
                    if ui.has_input(|key| key == Key::Char('?')) {
+
                        ui.send_message(Message::Changed(Change::Page {
+
                            page: state::Page::Main,
+
                        }));
                    }
-
                    _ => None,
-
                };
-
                Some(Exit {
-
                    value: Some(Selection {
-
                        operation,
-
                        args: vec![],
-
                    }),
-
                })
-
            }
-
            Message::SelectIssue { selected } => {
-
                self.browser.select_item(selected);
-
                self.preview.issue = self.browser.selected_item().cloned();
-
                self.preview.comment.reset_cursor();
-
                None
-
            }
-
            Message::TogglePreview => {
-
                self.preview.show = !self.preview.show;
-
                self.section = Some(Section::Browser);
-
                None
-
            }
-
            Message::FocusSection { section } => {
-
                self.section = section;
-
                None
-
            }
-
            Message::SelectComment { selected } => {
-
                if let Some(item) = &self.preview.issue {
-
                    self.preview
-
                        .selected_comments
-
                        .insert(item.id, selected.unwrap_or(vec![]));
                }
-
                self.preview.comment.reset_cursor();
-
                None
-
            }
-
            Message::ScrollComment { state } => {
-
                self.preview.comment = state;
-
                None
            }
-
            Message::OpenSearch => {
-
                self.browser.show_search();
-
                None
+

+
            if ui.has_input(|key| key == Key::Char('q')) {
+
                ui.send_message(Message::Quit);
            }
-
            Message::UpdateSearch { value } => {
-
                self.browser.update_search(value);
-
                self.preview.issue = self.browser.select_first_item().cloned();
-
                None
+
            if ui.has_input(|key| key == Key::Ctrl('c')) {
+
                ui.send_message(Message::Quit);
            }
-
            Message::ApplySearch => {
-
                self.browser.hide_search();
-
                self.browser.apply_search();
-
                None
+
        });
+

+
        Ok(())
+
    }
+
}
+

+
impl App {
+
    pub fn show_browser(&self, frame: &mut Frame, ui: &mut im::Ui<Message>) {
+
        let issues = self.issues.lock().unwrap();
+
        let issues = issues
+
            .iter()
+
            .filter(|patch| self.state.filter.matches(patch))
+
            .cloned()
+
            .collect::<Vec<_>>();
+
        let browser = &self.state.browser;
+
        let preview = &self.state.preview;
+
        let mut selected = browser.issues.selected();
+

+
        let header = [
+
            Column::new(" ● ", Constraint::Length(3)),
+
            Column::new("ID", Constraint::Length(8)),
+
            Column::new("Title", Constraint::Fill(5)),
+
            Column::new("Author", Constraint::Length(16)).hide_small(),
+
            Column::new("", Constraint::Length(16)).hide_medium(),
+
            Column::new("Labels", Constraint::Fill(1)).hide_medium(),
+
            Column::new("Assignees", Constraint::Fill(1)).hide_medium(),
+
            Column::new("Opened", Constraint::Length(16)).hide_small(),
+
        ];
+

+
        ui.layout(
+
            Layout::vertical([Constraint::Length(3), Constraint::Min(1)]),
+
            Some(1),
+
            |ui| {
+
                ui.column_bar(frame, header.to_vec(), Spacing::from(1), Some(Borders::Top));
+

+
                let table = ui.table(
+
                    frame,
+
                    &mut selected,
+
                    &issues,
+
                    header.to_vec(),
+
                    None,
+
                    Spacing::from(1),
+
                    Some(Borders::BottomSides),
+
                );
+
                if table.changed {
+
                    ui.send_message(Message::Changed(Change::Issue {
+
                        state: TableState::new(selected),
+
                    }));
+
                }
+
            },
+
        );
+

+
        if ui.has_input(|key| key == Key::Char('/')) {
+
            ui.send_message(Message::Changed(Change::ShowSearch {
+
                state: true,
+
                apply: false,
+
            }));
+
        }
+

+
        if let Some(issue) = selected.and_then(|s| issues.get(s)) {
+
            if ui.has_input(|key| key == Key::Enter) {
+
                ui.send_message(Message::Exit {
+
                    operation: Some(IssueOperation::Show { id: issue.id }),
+
                });
            }
-
            Message::CloseSearch => {
-
                self.browser.hide_search();
-
                self.browser.reset_search();

-
                self.preview.issue = self.browser.selected_item().cloned();
-
                self.preview.comment.reset_cursor();
-
                None
+
            if ui.has_input(|key| key == Key::Char('e')) {
+
                ui.send_message(Message::Exit {
+
                    operation: Some(IssueOperation::Edit {
+
                        id: issue.id,
+
                        comment_id: preview.selected_comment().map(|c| c.id),
+
                        search: browser.search.read().text,
+
                    }),
+
                });
            }
-
            Message::OpenHelp => {
-
                self.pages.push(AppPage::Help);
-
                None
+

+
            if ui.has_input(|key| key == Key::Char('s')) {
+
                ui.send_message(Message::Exit {
+
                    operation: Some(IssueOperation::Solve { id: issue.id }),
+
                });
            }
-
            Message::LeavePage => {
-
                self.pages.pop();
-
                None
+

+
            if ui.has_input(|key| key == Key::Char('l')) {
+
                ui.send_message(Message::Exit {
+
                    operation: Some(IssueOperation::Close { id: issue.id }),
+
                });
            }
-
            Message::ScrollHelp { state } => {
-
                self.help.text = state;
-
                None
+

+
            if ui.has_input(|key| key == Key::Char('o')) {
+
                ui.send_message(Message::Exit {
+
                    operation: Some(IssueOperation::Reopen { id: issue.id }),
+
                });
            }
        }
    }
-
}

-
impl App {
-
    pub fn new(context: Context, terminal_info: TerminalInfo) -> Self {
-
        Self {
-
            context,
-
            terminal_info,
+
    pub fn show_browser_search(&self, frame: &mut Frame, ui: &mut im::Ui<Message>) {
+
        let mut search = self.state.browser.search.clone();
+
        let (mut search_text, mut search_cursor) =
+
            (search.clone().read().text, search.clone().read().cursor);
+

+
        let text_edit = ui.text_edit_singleline(
+
            frame,
+
            &mut search_text,
+
            &mut search_cursor,
+
            Some("Search".to_string()),
+
            Some(Borders::Spacer { top: 0, left: 0 }),
+
        );
+

+
        if text_edit.changed {
+
            search.write(TextEditState {
+
                text: search_text,
+
                cursor: search_cursor,
+
            });
+
            ui.send_message(Message::Changed(Change::Search { state: search }));
+
        }
+

+
        if ui.has_input(|key| key == Key::Esc) {
+
            ui.send_message(Message::Changed(Change::ShowSearch {
+
                state: false,
+
                apply: false,
+
            }));
+
        }
+
        if ui.has_input(|key| key == Key::Enter) {
+
            ui.send_message(Message::Changed(Change::ShowSearch {
+
                state: false,
+
                apply: true,
+
            }));
        }
    }

-
    pub async fn run(&self) -> Result<Option<Selection>> {
-
        let channel = Channel::default();
-
        let state = State::try_from((&self.context, &self.terminal_info))?;
-
        let tx = channel.tx.clone();
-

-
        let window = Window::default()
-
            .page(AppPage::Browser, browser_page(&channel))
-
            .page(AppPage::Help, help_page(&channel))
-
            .to_widget(tx.clone())
-
            .on_update(|state| {
-
                WindowProps::default()
-
                    .current_page(state.pages.peek().unwrap_or(&AppPage::Browser).clone())
-
                    .to_boxed_any()
-
                    .into()
-
            });
+
    fn show_browser_context(&self, frame: &mut Frame, ui: &mut im::Ui<Message>) {
+
        use radicle::issue::{CloseReason, State};
+

+
        let context = {
+
            let issues = self.issues.lock().unwrap();
+
            let filter = &self.state.filter;
+
            let filtered = issues
+
                .iter()
+
                .filter(|issue| filter.matches(issue))
+
                .collect::<Vec<_>>();
+

+
            let browser = &self.state.browser;
+
            let search = browser.search.read().text;
+

+
            let mut open = 0;
+
            let mut other = 0;
+
            let mut solved = 0;
+
            for issue in &filtered {
+
                match issue.state {
+
                    State::Open => open += 1,
+
                    State::Closed {
+
                        reason: CloseReason::Other,
+
                    } => other += 1,
+
                    State::Closed {
+
                        reason: CloseReason::Solved,
+
                    } => solved += 1,
+
                }
+
            }
+
            let closed = solved + other;
+

+
            let filtered_counts = format!(" {}/{} ", filtered.len(), issues.len());
+
            let mut columns = vec![
+
                Column::new(
+
                    Span::raw(" Issue ".to_string()).cyan().dim().reversed(),
+
                    Constraint::Length(7),
+
                ),
+
                Column::new(
+
                    Span::raw(format!(" {search} "))
+
                        .into_left_aligned_line()
+
                        .style(ui.theme().bar_on_black_style)
+
                        .cyan()
+
                        .dim(),
+
                    Constraint::Fill(1),
+
                ),
+
            ];
+

+
            if filter.state().is_none() {
+
                columns.extend_from_slice(&[
+
                    Column::new(
+
                        Span::raw(" ● ")
+
                            .into_right_aligned_line()
+
                            .style(ui.theme().bar_on_black_style)
+
                            .green()
+
                            .dim(),
+
                        Constraint::Length(3),
+
                    ),
+
                    Column::new(
+
                        Span::from(open.to_string())
+
                            .style(ui.theme().bar_on_black_style)
+
                            .into_right_aligned_line(),
+
                        Constraint::Length(open.to_string().chars().count() as u16),
+
                    ),
+
                    Column::new(
+
                        Span::raw(" ● ")
+
                            .style(ui.theme().bar_on_black_style)
+
                            .into_right_aligned_line()
+
                            .red()
+
                            .dim(),
+
                        Constraint::Length(3),
+
                    ),
+
                    Column::new(
+
                        Span::from(closed.to_string())
+
                            .style(ui.theme().bar_on_black_style)
+
                            .into_right_aligned_line(),
+
                        Constraint::Length(closed.to_string().chars().count() as u16),
+
                    ),
+
                    Column::new(
+
                        Span::from(" ")
+
                            .style(ui.theme().bar_on_black_style)
+
                            .into_right_aligned_line(),
+
                        Constraint::Length(1),
+
                    ),
+
                ]);
+
            }
+

+
            columns.extend_from_slice(&[Column::new(
+
                Span::raw(filtered_counts.clone())
+
                    .into_right_aligned_line()
+
                    .cyan()
+
                    .dim()
+
                    .reversed(),
+
                Constraint::Length(filtered_counts.chars().count() as u16),
+
            )]);
+
            columns
+
        };

-
        tui::rm(
-
            state,
-
            window,
-
            Viewport::Inline(20),
-
            channel,
-
            EmptyProcessors::new(),
-
        )
-
        .await
+
        ui.column_bar(frame, context, Spacing::from(0), Some(Borders::None));
    }

-
    pub fn context(&self) -> &Context {
-
        &self.context
+
    pub fn show_browser_shortcuts(&self, frame: &mut Frame, ui: &mut im::Ui<Message>) {
+
        use radicle::issue::State;
+

+
        let issues = self.issues.lock().unwrap();
+
        let issues = issues
+
            .iter()
+
            .filter(|issue| self.state.filter.matches(issue))
+
            .collect::<Vec<_>>();
+

+
        let mut shortcuts = vec![("/", "search"), ("enter", "show"), ("e", "edit")];
+
        if let Some(issue) = self.state.browser.selected().and_then(|i| issues.get(i)) {
+
            let actions = match issue.state {
+
                State::Open => vec![("s", "solve"), ("l", "close")],
+
                State::Closed { .. } => vec![("o", "re-open")],
+
            };
+
            shortcuts.extend_from_slice(&actions);
+
        }
+

+
        let global_shortcuts = vec![("p", "toggle preview"), ("?", "help")];
+

+
        ui.layout(
+
            Layout::horizontal([Constraint::Fill(1), Constraint::Length(30)]),
+
            None,
+
            |ui| {
+
                ui.shortcuts(frame, &shortcuts, '∙', Alignment::Left);
+
                ui.shortcuts(frame, &global_shortcuts, '∙', Alignment::Right);
+
            },
+
        );
    }
-
}

-
fn browser_page(channel: &Channel<Message>) -> Widget<State, Message> {
-
    let tx = channel.tx.clone();
-

-
    let shortcuts = Shortcuts::default()
-
        .to_widget(tx.clone())
-
        .on_update(|state: &State| {
-
            let shortcuts = if state.browser.is_search_shown() {
-
                vec![("esc", "cancel"), ("enter", "apply")]
-
            } else {
-
                match state.section {
-
                    Some(Section::Browser) => {
-
                        let mut shortcuts =
-
                            [("/", "search"), ("enter", "show"), ("e", "edit")].to_vec();
-
                        if let Some(issue) = state.browser.selected_item() {
-
                            use radicle::issue::State;
-
                            let actions = match issue.state {
-
                                State::Open => [("s", "solve"), ("l", "close")].to_vec(),
-
                                State::Closed { .. } => [("o", "re-open")].to_vec(),
-
                            };
-
                            shortcuts = [shortcuts, actions.to_vec()].concat();
+
    pub fn show_issue(&self, frame: &mut Frame, ui: &mut im::Ui<Message>) {
+
        #[derive(Clone)]
+
        struct Property<'a>(Span<'a>, Text<'a>);
+

+
        impl<'a> ToRow<3> for Property<'a> {
+
            fn to_row(&self) -> [ratatui::widgets::Cell<'_>; 3] {
+
                ["".into(), self.0.clone().into(), self.1.clone().into()]
+
            }
+
        }
+

+
        let issues = self.issues.lock().unwrap();
+
        let issues = issues
+
            .iter()
+
            .filter(|issue| self.state.filter.matches(issue))
+
            .collect::<Vec<_>>();
+
        let issue = self.state.browser.selected().and_then(|i| issues.get(i));
+
        let properties = issue
+
            .map(|issue| {
+
                use radicle::issue;
+

+
                let author: Text<'_> = match &issue.author.alias {
+
                    Some(alias) => {
+
                        if issue.author.you {
+
                            Line::from(
+
                                [
+
                                    span::alias(alias.as_ref()),
+
                                    Span::raw(" "),
+
                                    span::alias("(you)").dim().italic(),
+
                                ]
+
                                .to_vec(),
+
                            )
+
                            .into()
+
                        } else {
+
                            Line::from(
+
                                [
+
                                    span::alias(alias.as_ref()),
+
                                    Span::raw(" "),
+
                                    span::alias(&format!(
+
                                        "({})",
+
                                        issue.author.human_nid.clone().unwrap_or_default()
+
                                    ))
+
                                    .dim()
+
                                    .italic(),
+
                                ]
+
                                .to_vec(),
+
                            )
+
                            .into()
                        }
-
                        shortcuts
                    }
-
                    _ => [("e", "edit"), ("c", "reply")].to_vec(),
-
                }
-
            };
-
            let global_shortcuts = vec![("p", "toggle preview"), ("?", "help")];
-

-
            ShortcutsProps::default()
-
                .shortcuts(&shortcuts)
-
                .global_shortcuts(&global_shortcuts)
-
                .shortcuts_keys_style(state.theme.shortcuts_keys_style)
-
                .shortcuts_action_style(state.theme.shortcuts_action_style)
-
                .to_boxed_any()
-
                .into()
-
        });
+
                    None => match &issue.author.human_nid {
+
                        Some(nid) => span::alias(nid).dim().into(),
+
                        None => span::blank().into(),
+
                    },
+
                };

-
    Page::default()
-
        .content(
-
            SectionGroup::default()
-
                .section(browser(channel))
-
                .section(issue(channel))
-
                .section(comment(channel))
-
                .to_widget(tx.clone())
-
                .on_event(|_, vs, _| {
-
                    Some(Message::FocusSection {
-
                        section: vs.and_then(|vs| {
-
                            vs.unwrap_section_group()
-
                                .and_then(|sgs| sgs.focus)
-
                                .map(|s| s.try_into().unwrap_or_default())
-
                        }),
-
                    })
-
                })
-
                .on_update(|state: &State| {
-
                    SectionGroupProps::default()
-
                        .handle_keys(state.preview.show && !state.browser.is_search_shown())
-
                        .layout(PredefinedLayout::Expandable3 {
-
                            left_only: !state.preview.show,
-
                        })
-
                        .focus(state.section.as_ref().map(|s| s.clone().into()))
-
                        .to_boxed_any()
-
                        .into()
-
                }),
-
        )
-
        .shortcuts(shortcuts)
-
        .to_widget(tx.clone())
-
        .on_event(|event, _, props| {
-
            let default = PageProps::default();
-
            let props = props
-
                .and_then(|props| props.inner_ref::<PageProps>())
-
                .unwrap_or(&default);
-
            if props.handle_keys {
-
                if let Event::Key(key) = event {
-
                    match key {
-
                        Key::Char('q') | Key::Ctrl('c') => Some(Message::Quit),
-
                        Key::Char('p') => Some(Message::TogglePreview),
-
                        Key::Char('?') => Some(Message::OpenHelp),
-
                        _ => None,
-
                    }
-
                } else {
-
                    None
-
                }
-
            } else {
-
                None
-
            }
-
        })
-
        .on_update(|state: &State| {
-
            PageProps::default()
-
                .handle_keys(!state.browser.is_search_shown())
-
                .to_boxed_any()
-
                .into()
-
        })
-
}
+
                let status = match issue.state {
+
                    issue::State::Open => Text::from("open").green(),
+
                    issue::State::Closed { reason } => match reason {
+
                        issue::CloseReason::Solved => Line::from(
+
                            [
+
                                Span::from("closed").red(),
+
                                Span::raw(" "),
+
                                Span::from("(solved)").red().italic().dim(),
+
                            ]
+
                            .to_vec(),
+
                        )
+
                        .into(),
+
                        issue::CloseReason::Other => Text::from("closed").red(),
+
                    },
+
                };

-
fn browser(channel: &Channel<Message>) -> Widget<State, Message> {
-
    let tx = channel.tx.clone();
-

-
    Browser::new(tx.clone())
-
        .to_widget(tx.clone())
-
        .on_update(|state| BrowserProps::from(state).to_boxed_any().into())
-
        .on_event(|event, _, props| {
-
            let default = BrowserProps::default();
-
            let props = props
-
                .and_then(|props| props.inner_ref::<BrowserProps>())
-
                .unwrap_or(&default);
-
            if !props.show_search() {
-
                if let Event::Key(key) = event {
-
                    match key {
-
                        Key::Enter => Some(Message::Exit {
-
                            operation: Some(RequestedIssueOperation::Show),
-
                        }),
-
                        Key::Char('e') => Some(Message::Exit {
-
                            operation: Some(RequestedIssueOperation::Edit),
-
                        }),
-
                        Key::Char('s') => Some(Message::Exit {
-
                            operation: Some(RequestedIssueOperation::Solve),
-
                        }),
-
                        Key::Char('l') => Some(Message::Exit {
-
                            operation: Some(RequestedIssueOperation::Close),
-
                        }),
-
                        Key::Char('o') => Some(Message::Exit {
-
                            operation: Some(RequestedIssueOperation::Reopen),
-
                        }),
-
                        _ => None,
+
                vec![
+
                    Property(Span::from("Title"), Text::from(issue.title.clone()).bold()),
+
                    Property(Span::from("Issue"), Text::from(issue.id.to_string()).cyan()),
+
                    Property(Span::from("Author"), author.magenta()),
+
                    Property(
+
                        Span::from("Labels"),
+
                        Text::from(format::labels(&issue.labels)).blue(),
+
                    ),
+
                    Property(Span::from("Status"), status),
+
                ]
+
            })
+
            .unwrap_or_default();
+

+
        let browser = &self.state.browser;
+
        let search = browser.search.read();
+

+
        let preview = &self.state.preview;
+
        let comment = preview.selected_comment();
+
        let root = preview.root_comments();
+
        let mut opened = Some(preview.opened_comments());
+
        let mut selected = Some(preview.selected_comment_ids());
+

+
        ui.layout(
+
            Layout::vertical([Constraint::Length(7), Constraint::Fill(1)]),
+
            Some(1),
+
            |ui| {
+
                ui.table(
+
                    frame,
+
                    &mut None,
+
                    &properties,
+
                    vec![
+
                        Column::new("", Constraint::Length(1)),
+
                        Column::new("", Constraint::Length(12)),
+
                        Column::new("", Constraint::Fill(1)),
+
                    ],
+
                    None,
+
                    Spacing::from(0),
+
                    Some(Borders::Top),
+
                );
+
                let comments = ui.tree(
+
                    frame,
+
                    &root,
+
                    &mut opened,
+
                    &mut selected,
+
                    Some(Borders::BottomSides),
+
                );
+
                if comments.changed {
+
                    let mut state = tui_tree_widget::TreeState::default();
+
                    if let Some(opened) = opened {
+
                        for open in opened {
+
                            state.open(open);
+
                        }
                    }
-
                } else {
-
                    None
+
                    if let Some(selected) = selected {
+
                        state.select(selected);
+
                    }
+

+
                    ui.send_message(Message::Changed(Change::Comment {
+
                        state: TreeState { internal: state },
+
                    }));
                }
-
            } else {
-
                None
-
            }
-
        })
-
}

-
fn issue(channel: &Channel<Message>) -> Widget<State, Message> {
-
    let tx = channel.tx.clone();
-

-
    SplitContainer::default()
-
        .top(issue_details(channel))
-
        .bottom(comment_tree(channel))
-
        .to_widget(tx.clone())
-
        .on_update(|state| {
-
            SplitContainerProps::default()
-
                .heights([Constraint::Length(5), Constraint::Min(1)])
-
                .border_style(state.theme.border_style)
-
                .focus_border_style(state.theme.focus_border_style)
-
                .split_focus(SplitContainerFocus::Bottom)
-
                .to_boxed_any()
-
                .into()
-
        })
-
}
+
                if let Some(issue) = issue {
+
                    if ui.has_input(|key| key == Key::Char('c')) {
+
                        ui.send_message(Message::Exit {
+
                            operation: Some(IssueOperation::Comment {
+
                                id: issue.id,
+
                                reply_to: comment.map(|c| c.id),
+
                                search: search.text.clone(),
+
                            }),
+
                        });
+
                    }

-
fn issue_details(channel: &Channel<Message>) -> Widget<State, Message> {
-
    let tx = channel.tx.clone();
-

-
    IssueDetails::default()
-
        .to_widget(tx.clone())
-
        .on_update(|state: &State| {
-
            IssueDetailsProps::default()
-
                .issue(state.preview.issue.clone())
-
                .dim(state.theme.dim_no_focus)
-
                .to_boxed_any()
-
                .into()
-
        })
-
}
+
                    if ui.has_input(|key| key == Key::Char('e')) {
+
                        ui.send_message(Message::Exit {
+
                            operation: Some(IssueOperation::Edit {
+
                                id: issue.id,
+
                                comment_id: comment.map(|c| c.id),
+
                                search: search.text,
+
                            }),
+
                        });
+
                    }
+
                }
+
            },
+
        );
+
    }

-
fn comment_tree(channel: &Channel<Message>) -> Widget<State, Message> {
-
    let tx = channel.tx.clone();
-

-
    Tree::<State, Message, CommentItem, String>::default()
-
        .to_widget(tx.clone())
-
        .on_update(|state| {
-
            let root = &state.preview.root_comments();
-
            let opened = &state.preview.opened_comments();
-
            let selected = &state.preview.selected_comment_ids();
-

-
            TreeProps::<CommentItem, String>::default()
-
                .items(root.to_vec())
-
                .selected(Some(selected))
-
                .opened(Some(opened.clone()))
-
                .dim(state.theme.dim_no_focus)
-
                .to_boxed_any()
-
                .into()
-
        })
-
        .on_event(|event, s, _| match event {
-
            Event::Key(Key::Char('c')) => Some(Message::Exit {
-
                operation: Some(RequestedIssueOperation::Reply),
-
            }),
-
            Event::Key(Key::Char('e')) => Some(Message::Exit {
-
                operation: Some(RequestedIssueOperation::EditComment),
-
            }),
-
            _ => Some(Message::SelectComment {
-
                selected: s.and_then(|s| {
-
                    s.unwrap_tree()
-
                        .map(|tree| tree.iter().map(|id| Oid::from_str(id).unwrap()).collect())
-
                }),
-
            }),
-
        })
-
}
+
    pub fn show_issue_context(&self, frame: &mut Frame, ui: &mut im::Ui<Message>) {
+
        ui.column_bar(
+
            frame,
+
            [
+
                Column::new(
+
                    Span::raw(" Comment ".to_string()).cyan().dim().reversed(),
+
                    Constraint::Length(9),
+
                ),
+
                Column::new(
+
                    Span::raw(" ".to_string())
+
                        .into_left_aligned_line()
+
                        .style(ui.theme().bar_on_black_style),
+
                    Constraint::Fill(1),
+
                ),
+
            ]
+
            .to_vec(),
+
            Spacing::from(0),
+
            Some(Borders::None),
+
        );
+
    }

-
fn comment(channel: &Channel<Message>) -> Widget<State, Message> {
-
    let tx = channel.tx.clone();
+
    pub fn show_issue_shortcuts(&self, frame: &mut Frame, ui: &mut im::Ui<Message>) {
+
        let shortcuts = vec![("e", "edit"), ("c", "reply")];
+
        let global_shortcuts = vec![("p", "toggle preview"), ("?", "help")];

-
    Container::default()
-
        .content(
-
            TextView::default()
-
                .to_widget(tx.clone())
-
                .on_event(|_, vs, _| {
-
                    let state = vs.and_then(|p| p.unwrap_textview()).unwrap_or_default();
-
                    Some(Message::ScrollComment { state })
-
                })
-
                .on_update(|state: &State| {
-
                    let comment = state.preview.selected_comment();
-
                    let body: String = comment
-
                        .map(|comment| comment.body.clone())
-
                        .unwrap_or_default();
-
                    let reactions = comment
-
                        .map(|comment| {
-
                            let reactions = comment.accumulated_reactions().iter().fold(
-
                                String::new(),
-
                                |all, (r, acc)| {
-
                                    if *acc > 1_usize {
-
                                        [all, format!("{r}{acc} ")].concat()
-
                                    } else {
-
                                        [all, format!("{r} ")].concat()
-
                                    }
-
                                },
-
                            );
-
                            reactions
-
                        })
-
                        .unwrap_or_default();
-

-
                    TextViewProps::default()
-
                        .state(Some(state.preview.comment.clone().content(body)))
-
                        .footer(Some(reactions))
-
                        .show_scroll_progress(true)
-
                        .dim(state.theme.dim_no_focus)
-
                        .to_boxed_any()
-
                        .into()
-
                }),
-
        )
-
        .to_widget(tx.clone())
-
        .on_update(|state| {
-
            ContainerProps::default()
-
                .border_style(state.theme.border_style)
-
                .focus_border_style(state.theme.focus_border_style)
-
                .to_boxed_any()
-
                .into()
-
        })
-
        .on_event(|event, _, _| match event {
-
            Event::Key(Key::Char('c')) => Some(Message::Exit {
-
                operation: Some(RequestedIssueOperation::Reply),
-
            }),
-
            _ => None,
-
        })
-
}
+
        ui.layout(
+
            Layout::horizontal([Constraint::Fill(1), Constraint::Length(30)]),
+
            None,
+
            |ui| {
+
                ui.shortcuts(frame, &shortcuts, '∙', Alignment::Left);
+
                ui.shortcuts(frame, &global_shortcuts, '∙', Alignment::Right);
+
            },
+
        );
+
    }

-
fn help_page(channel: &Channel<Message>) -> Widget<State, Message> {
-
    let tx = channel.tx.clone();
-

-
    let content = Container::default()
-
        .header(Header::default().to_widget(tx.clone()).on_update(|_| {
-
            HeaderProps::default()
-
                .columns([Column::new(" Help ", Constraint::Fill(1))].to_vec())
-
                .to_boxed_any()
-
                .into()
-
        }))
-
        .content(
-
            TextView::default()
-
                .to_widget(tx.clone())
-
                .on_event(|_, view_state, _| {
-
                    view_state
-
                        .and_then(|tv| tv.unwrap_textview())
-
                        .map(|tvs| Message::ScrollHelp { state: tvs })
+
    pub fn show_comment(&self, frame: &mut Frame, ui: &mut im::Ui<Message>) {
+
        let (text, footer, mut cursor) = {
+
            let comment = self.state.preview.selected_comment();
+
            let body: String = comment
+
                .map(|comment| comment.body.clone())
+
                .unwrap_or_default();
+
            let reactions = comment
+
                .map(|comment| {
+
                    let reactions = comment.accumulated_reactions().iter().fold(
+
                        String::new(),
+
                        |all, (r, acc)| {
+
                            if *acc > 1_usize {
+
                                [all, format!("{r}{acc} ")].concat()
+
                            } else {
+
                                [all, format!("{r} ")].concat()
+
                            }
+
                        },
+
                    );
+
                    reactions
                })
-
                .on_update(|state: &State| {
-
                    TextViewProps::default()
-
                        .state(Some(state.help.text.clone()))
-
                        .dim(state.theme.dim_no_focus)
-
                        .to_boxed_any()
-
                        .into()
-
                }),
-
        )
-
        .footer(
-
            Footer::default()
-
                .to_widget(tx.clone())
-
                .on_update(|state: &State| {
-
                    FooterProps::default()
-
                        .columns(
-
                            [
-
                                Column::new(Text::raw(""), Constraint::Fill(1)),
-
                                Column::new(
-
                                    span::default(&format!("{}%", state.help.text.scroll)).dim(),
-
                                    Constraint::Min(4),
-
                                ),
-
                            ]
-
                            .to_vec(),
-
                        )
-
                        .to_boxed_any()
-
                        .into()
-
                }),
-
        )
-
        .to_widget(tx.clone())
-
        .on_update(|state| {
-
            ContainerProps::default()
-
                .border_style(state.theme.border_style)
-
                .focus_border_style(state.theme.focus_border_style)
-
                .to_boxed_any()
-
                .into()
-
        });
-

-
    let shortcuts = Shortcuts::default().to_widget(tx.clone()).on_update(|_| {
-
        ShortcutsProps::default()
-
            .shortcuts(&[("?", "close")])
-
            .to_boxed_any()
-
            .into()
-
    });
-

-
    Page::default()
-
        .content(content)
-
        .shortcuts(shortcuts)
-
        .to_widget(tx.clone())
-
        .on_event(|event, _, _| match event {
-
            Event::Key(Key::Char('q')) | Event::Key(Key::Ctrl('c')) => Some(Message::Quit),
-
            Event::Key(Key::Char('?')) => Some(Message::LeavePage),
-
            _ => None,
-
        })
-
        .on_update(|_| PageProps::default().handle_keys(true).to_boxed_any().into())
-
}
+
                .unwrap_or_default();

-
fn help_text() -> String {
-
    r#"# Generic keybindings
-

-
`↑,k`:      move cursor one line up
-
`↓,j:       move cursor one line down
-
`PageUp`:   move cursor one page up
-
`PageDown`: move cursor one page down
-
`Home`:     move cursor to the first line
-
`End`:      move cursor to the last line
-
`Tab`:      focus next section
-
`BackTab`:  focus previous section
-
`Esc`:      Cancel
-
`q`:        Quit
+
            (body, reactions, self.state.preview.comment.clone().cursor())
+
        };
+
        let comment =
+
            ui.text_view_with_footer(frame, text, footer, &mut cursor, Some(Borders::All));
+
        if comment.changed {
+
            ui.send_message(Message::Changed(Change::CommentBody {
+
                state: TextViewState::new(cursor),
+
            }))
+
        }
+
    }

-
# Specific keybindings
+
    pub fn show_comment_context(&self, frame: &mut Frame, ui: &mut im::Ui<Message>) {
+
        ui.column_bar(
+
            frame,
+
            [
+
                Column::new(
+
                    Span::raw(" Comment ".to_string()).cyan().dim().reversed(),
+
                    Constraint::Length(9),
+
                ),
+
                Column::new(
+
                    Span::raw(" ".to_string())
+
                        .into_left_aligned_line()
+
                        .style(ui.theme().bar_on_black_style),
+
                    Constraint::Fill(1),
+
                ),
+
            ]
+
            .to_vec(),
+
            Spacing::from(0),
+
            Some(Borders::None),
+
        );
+
    }

-
`Enter`:    Show issue
-
`e`:        Edit issue
-
`c`:        Reply to comment
-
`p`:        Toggle issue preview
-
`/`:        Search
-
`?`:        Show help
+
    pub fn show_comment_shortcuts(&self, frame: &mut Frame, ui: &mut im::Ui<Message>) {
+
        let shortcuts = vec![("e", "edit"), ("c", "reply")];
+
        let global_shortcuts = vec![("p", "toggle preview"), ("?", "help")];

-
# Searching
+
        ui.layout(
+
            Layout::horizontal([Constraint::Fill(1), Constraint::Length(30)]),
+
            None,
+
            |ui| {
+
                ui.shortcuts(frame, &shortcuts, '∙', Alignment::Left);
+
                ui.shortcuts(frame, &global_shortcuts, '∙', Alignment::Right);
+
            },
+
        );
+
    }

-
Pattern:    is:<state> | is:authored | is:assigned | authors:[<did>, ...] | assignees:[<did>, ...] | <search>
-
Example:    is:solved is:authored alias"#
-
        .into()
-
}
+
    fn show_help_text(&self, frame: &mut Frame, ui: &mut im::Ui<Message>) {
+
        ui.column_bar(
+
            frame,
+
            [Column::new(Span::raw(" Help ").bold(), Constraint::Fill(1))].to_vec(),
+
            Spacing::from(0),
+
            Some(Borders::Top),
+
        );

-
fn append_opened(all: &mut HashSet<Vec<String>>, path: Vec<String>, comment: CommentItem) {
-
    all.insert([path.clone(), [comment.id.to_string()].to_vec()].concat());
+
        let mut cursor = self.state.help.cursor();
+
        let text_view = ui.text_view(
+
            frame,
+
            HELP.to_string(),
+
            &mut cursor,
+
            Some(Borders::BottomSides),
+
        );
+
        if text_view.changed {
+
            ui.send_message(Message::Changed(Change::Help {
+
                state: TextViewState::new(cursor),
+
            }))
+
        }
+
    }

-
    for reply in comment.replies {
-
        append_opened(
-
            all,
-
            [path.clone(), [comment.id.to_string()].to_vec()].concat(),
-
            reply,
+
    fn show_help_context(&self, frame: &mut Frame, ui: &mut im::Ui<Message>) {
+
        ui.column_bar(
+
            frame,
+
            [
+
                Column::new(
+
                    Span::raw(" ".to_string())
+
                        .into_left_aligned_line()
+
                        .style(ui.theme().bar_on_black_style),
+
                    Constraint::Fill(1),
+
                ),
+
                Column::new(
+
                    Span::raw(" ")
+
                        .into_right_aligned_line()
+
                        .cyan()
+
                        .dim()
+
                        .reversed(),
+
                    Constraint::Length(6),
+
                ),
+
            ]
+
            .to_vec(),
+
            Spacing::from(0),
+
            Some(Borders::None),
        );
    }
}
deleted bin/commands/issue/list/ui.rs
@@ -1,332 +0,0 @@
-
use std::collections::HashMap;
-
use std::str::FromStr;
-
use std::vec;
-

-
use radicle::issue::{self, CloseReason};
-

-
use ratatui::Frame;
-
use tokio::sync::broadcast;
-

-
use ratatui::layout::{Constraint, Layout};
-
use ratatui::style::Stylize;
-
use ratatui::text::{Line, Text};
-

-
use radicle_tui as tui;
-

-
use tui::event::{Event, Key};
-
use tui::ui::rm::widget;
-
use tui::ui::rm::widget::container::{
-
    Container, ContainerProps, Footer, FooterProps, Header, HeaderProps,
-
};
-
use tui::ui::rm::widget::list::{Table, TableProps};
-
use tui::ui::rm::widget::text::{TextField, TextFieldProps};
-
use tui::ui::rm::widget::ViewProps;
-
use tui::ui::rm::widget::{RenderProps, ToWidget, View};
-
use tui::ui::span;
-
use tui::ui::Column;
-

-
use tui::BoxedAny;
-

-
use crate::ui::items::{IssueItem, IssueItemFilter};
-

-
use super::{Message, State};
-

-
type Widget = widget::Widget<State, Message>;
-

-
#[derive(Clone, Default)]
-
pub struct BrowserProps<'a> {
-
    /// Filtered issues.
-
    issues: Vec<IssueItem>,
-
    /// Issue statistics.
-
    stats: HashMap<String, usize>,
-
    /// Header columns
-
    header: Vec<Column<'a>>,
-
    /// Table columns
-
    columns: Vec<Column<'a>>,
-
    /// If search widget should be shown.
-
    show_search: bool,
-
    /// Current search string.
-
    search: String,
-
}
-

-
impl<'a> BrowserProps<'a> {
-
    pub fn show_search(&self) -> bool {
-
        self.show_search
-
    }
-
}
-

-
impl From<&State> for BrowserProps<'_> {
-
    fn from(state: &State) -> Self {
-
        use radicle::issue::State;
-

-
        let issues = state.browser.items();
-

-
        let mut open = 0;
-
        let mut other = 0;
-
        let mut solved = 0;
-

-
        for issue in &issues {
-
            match issue.state {
-
                State::Open => open += 1,
-
                State::Closed {
-
                    reason: CloseReason::Other,
-
                } => other += 1,
-
                State::Closed {
-
                    reason: CloseReason::Solved,
-
                } => solved += 1,
-
            }
-
        }
-

-
        let closed = solved + other;
-

-
        let stats = HashMap::from([
-
            ("Open".to_string(), open),
-
            ("Other".to_string(), other),
-
            ("Solved".to_string(), solved),
-
            ("Closed".to_string(), closed),
-
        ]);
-

-
        Self {
-
            issues,
-
            stats,
-
            header: [
-
                Column::new(" ● ", Constraint::Length(3)),
-
                Column::new("ID", Constraint::Length(8)),
-
                Column::new("Title", Constraint::Fill(5)),
-
                Column::new("Author", Constraint::Length(16)).hide_small(),
-
                Column::new("", Constraint::Length(16)).hide_medium(),
-
                Column::new("Labels", Constraint::Fill(1)).hide_medium(),
-
                Column::new("Assignees", Constraint::Fill(1)).hide_medium(),
-
                Column::new("Opened", Constraint::Length(16)).hide_small(),
-
            ]
-
            .to_vec(),
-
            columns: [
-
                Column::new(" ● ", Constraint::Length(3)),
-
                Column::new("ID", Constraint::Length(8)),
-
                Column::new("Title", Constraint::Fill(5)),
-
                Column::new("Author", Constraint::Length(16)).hide_small(),
-
                Column::new("", Constraint::Length(16)).hide_medium(),
-
                Column::new("Labels", Constraint::Fill(1)).hide_medium(),
-
                Column::new("Assignees", Constraint::Fill(1)).hide_medium(),
-
                Column::new("Opened", Constraint::Length(16)).hide_small(),
-
            ]
-
            .to_vec(),
-
            search: state.browser.read_search(),
-
            show_search: state.browser.is_search_shown(),
-
        }
-
    }
-
}
-

-
pub struct Browser {
-
    /// Notifications widget
-
    issues: Widget,
-
    /// Search widget
-
    search: Widget,
-
}
-

-
impl Browser {
-
    pub fn new(tx: broadcast::Sender<Message>) -> Self {
-
        Self {
-
            issues: Container::default()
-
                .header(Header::default().to_widget(tx.clone()).on_update(|state| {
-
                    // TODO: remove and use state directly
-
                    let props = BrowserProps::from(state);
-

-
                    HeaderProps::default()
-
                        .columns(props.header.clone())
-
                        .border_style(state.theme.border_style)
-
                        .focus_border_style(state.theme.focus_border_style)
-
                        .to_boxed_any()
-
                        .into()
-
                }))
-
                .content(
-
                    Table::<State, Message, IssueItem, 8>::default()
-
                        .to_widget(tx.clone())
-
                        .on_event(|_, s, _| {
-
                            let (selected, _) =
-
                                s.and_then(|s| s.unwrap_table()).unwrap_or_default();
-
                            Some(Message::SelectIssue {
-
                                selected: Some(selected),
-
                            })
-
                        })
-
                        .on_update(|state| {
-
                            let props = BrowserProps::from(state);
-

-
                            TableProps::default()
-
                                .columns(props.columns)
-
                                .items(state.browser.items())
-
                                .selected(state.browser.selected())
-
                                .dim(state.theme.dim_no_focus)
-
                                .to_boxed_any()
-
                                .into()
-
                        }),
-
                )
-
                .footer(Footer::default().to_widget(tx.clone()).on_update(|state| {
-
                    let props = BrowserProps::from(state);
-

-
                    FooterProps::default()
-
                        .columns(browse_footer(&props))
-
                        .border_style(state.theme.border_style)
-
                        .focus_border_style(state.theme.focus_border_style)
-
                        .to_boxed_any()
-
                        .into()
-
                }))
-
                .to_widget(tx.clone())
-
                .on_update(|state| {
-
                    ContainerProps::default()
-
                        .border_style(state.theme.border_style)
-
                        .focus_border_style(state.theme.focus_border_style)
-
                        .hide_footer(BrowserProps::from(state).show_search)
-
                        .to_boxed_any()
-
                        .into()
-
                }),
-
            search: TextField::default()
-
                .to_widget(tx.clone())
-
                .on_event(|_, s, _| {
-
                    Some(Message::UpdateSearch {
-
                        value: s.and_then(|i| i.unwrap_string()).unwrap_or_default(),
-
                    })
-
                })
-
                .on_update(|state: &State| {
-
                    TextFieldProps::default()
-
                        .text(&state.browser.read_search())
-
                        .title("Search")
-
                        .inline(true)
-
                        .to_boxed_any()
-
                        .into()
-
                }),
-
        }
-
    }
-
}
-

-
impl View for Browser {
-
    type Message = Message;
-
    type State = State;
-

-
    fn handle_event(&mut self, props: Option<&ViewProps>, event: Event) -> Option<Self::Message> {
-
        let default = BrowserProps::default();
-
        let props = props
-
            .and_then(|props| props.inner_ref::<BrowserProps>())
-
            .unwrap_or(&default);
-

-
        if props.show_search {
-
            match event {
-
                Event::Key(Key::Esc) => {
-
                    self.search.reset();
-
                    Some(Message::CloseSearch)
-
                }
-
                Event::Key(Key::Enter) => Some(Message::ApplySearch),
-
                _ => {
-
                    self.search.handle_event(event);
-
                    None
-
                }
-
            }
-
        } else {
-
            match event {
-
                Event::Key(Key::Char('/')) => Some(Message::OpenSearch),
-
                _ => {
-
                    self.issues.handle_event(event);
-
                    None
-
                }
-
            }
-
        }
-
    }
-

-
    fn update(&mut self, _props: Option<&ViewProps>, state: &Self::State) {
-
        self.issues.update(state);
-
        self.search.update(state);
-
    }
-

-
    fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
-
        let default = BrowserProps::default();
-
        let props = props
-
            .and_then(|props| props.inner_ref::<BrowserProps>())
-
            .unwrap_or(&default);
-

-
        if props.show_search {
-
            let [table_area, search_area] =
-
                Layout::vertical([Constraint::Min(1), Constraint::Length(2)]).areas(render.area);
-
            let [_, search_area, _] = Layout::horizontal([
-
                Constraint::Length(1),
-
                Constraint::Min(1),
-
                Constraint::Length(1),
-
            ])
-
            .areas(search_area);
-

-
            self.issues.render(RenderProps::from(table_area), frame);
-
            self.search
-
                .render(RenderProps::from(search_area).focus(render.focus), frame);
-
        } else {
-
            self.issues.render(render, frame);
-
        }
-
    }
-
}
-

-
fn browse_footer<'a>(props: &BrowserProps<'a>) -> Vec<Column<'a>> {
-
    let search = Line::from(vec![
-
        span::default(" Search ").cyan().dim().reversed(),
-
        span::default(" "),
-
        span::default(&props.search).gray().dim(),
-
    ]);
-

-
    let open = Line::from(vec![
-
        span::positive(&props.stats.get("Open").unwrap_or(&0).to_string()).dim(),
-
        span::default(" Open").dim(),
-
    ]);
-
    let solved = Line::from(vec![
-
        span::default(&props.stats.get("Solved").unwrap_or(&0).to_string())
-
            .magenta()
-
            .dim(),
-
        span::default(" Solved").dim(),
-
    ]);
-
    let closed = Line::from(vec![
-
        span::default(&props.stats.get("Closed").unwrap_or(&0).to_string())
-
            .magenta()
-
            .dim(),
-
        span::default(" Closed").dim(),
-
    ]);
-
    let sum = Line::from(vec![
-
        span::default("Σ ").dim(),
-
        span::default(&props.issues.len().to_string()).dim(),
-
    ]);
-

-
    match IssueItemFilter::from_str(&props.search)
-
        .unwrap_or_default()
-
        .state()
-
    {
-
        Some(state) => {
-
            let block = match state {
-
                issue::State::Open => open,
-
                issue::State::Closed {
-
                    reason: issue::CloseReason::Other,
-
                } => closed,
-
                issue::State::Closed {
-
                    reason: issue::CloseReason::Solved,
-
                } => solved,
-
            };
-

-
            [
-
                Column::new(Text::from(search), Constraint::Fill(1)),
-
                Column::new(
-
                    Text::from(block.clone()),
-
                    Constraint::Min(block.width() as u16),
-
                ),
-
                Column::new(Text::from(sum.clone()), Constraint::Min(sum.width() as u16)),
-
            ]
-
            .to_vec()
-
        }
-
        None => [
-
            Column::new(Text::from(search), Constraint::Fill(1)),
-
            Column::new(
-
                Text::from(open.clone()),
-
                Constraint::Min(open.width() as u16),
-
            ),
-
            Column::new(
-
                Text::from(closed.clone()),
-
                Constraint::Min(closed.width() as u16),
-
            ),
-
            Column::new(Text::from(sum.clone()), Constraint::Min(sum.width() as u16)),
-
        ]
-
        .to_vec(),
-
    }
-
}
modified bin/commands/patch/list.rs
@@ -343,6 +343,7 @@ impl App {
                    &patches,
                    header.to_vec(),
                    Some("No patches found".into()),
+
                    Spacing::from(1),
                    Some(Borders::BottomSides),
                );
                if table.changed {
modified bin/commands/patch/review.rs
@@ -354,6 +354,7 @@ impl App<'_> {
                    &hunks,
                    columns,
                    None,
+
                    Spacing::from(1),
                    Some(Borders::BottomSides),
                );
                if table.changed {
modified bin/ui.rs
@@ -2,7 +2,6 @@ pub mod format;
pub mod im;
pub mod items;
pub mod layout;
-
pub mod rm;
pub mod span;

#[derive(Clone, Debug)]
modified bin/ui/im.rs
@@ -138,6 +138,7 @@ where
                    self.items,
                    self.header.to_vec(),
                    None,
+
                    Spacing::from(1),
                    if *self.show_search {
                        Some(Borders::BottomSides)
                    } else {
modified bin/ui/items.rs
@@ -1,34 +1,27 @@
+
pub mod issue;
pub mod notification;
pub mod patch;

use std::collections::HashMap;
use std::fmt::Debug;
-
use std::str::FromStr;
-

-
use nom::bytes::complete::{tag, take};
-
use nom::multi::separated_list0;
-
use nom::sequence::{delimited, preceded};
-
use nom::{IResult, Parser};

use radicle::cob::thread::{Comment, CommentId};
-
use radicle::cob::{Label, ObjectId, Timestamp};
+
use radicle::cob::{ObjectId, Timestamp};

use radicle::identity::Did;
-
use radicle::issue;
-
use radicle::issue::{CloseReason, Issue, IssueId};
+
use radicle::issue::{Issue, IssueId};
use radicle::node::{Alias, AliasStore, NodeId};
use radicle::Profile;

use ratatui::prelude::*;
-
use ratatui::style::{Style, Stylize};
-
use ratatui::widgets::Cell;
+
use ratatui::style::Stylize;

use tui_tree_widget::TreeItem;

use radicle_tui as tui;

use tui::ui::span;
-
use tui::ui::{ToRow, ToTree};
+
use tui::ui::ToTree;

use super::format;

@@ -143,291 +136,6 @@ impl AuthorItem {
    }
}

-
#[derive(Clone, Debug)]
-
pub struct IssueItem {
-
    /// Issue OID.
-
    pub id: IssueId,
-
    /// Issue state.
-
    pub state: issue::State,
-
    /// Issue title.
-
    pub title: String,
-
    /// Issue author.
-
    pub author: AuthorItem,
-
    /// Issue labels.
-
    pub labels: Vec<Label>,
-
    /// Issue assignees.
-
    pub assignees: Vec<AuthorItem>,
-
    /// Time when issue was opened.
-
    pub timestamp: Timestamp,
-
    /// Comment timeline
-
    pub comments: Vec<CommentItem>,
-
}
-

-
impl IssueItem {
-
    pub fn new(profile: &Profile, issue: (IssueId, Issue)) -> Result<Self, anyhow::Error> {
-
        let (id, issue) = issue;
-

-
        Ok(Self {
-
            id,
-
            state: *issue.state(),
-
            title: issue.title().into(),
-
            author: AuthorItem::new(Some(*issue.author().id), profile),
-
            labels: issue.labels().cloned().collect(),
-
            assignees: issue
-
                .assignees()
-
                .map(|did| AuthorItem::new(Some(**did), profile))
-
                .collect::<Vec<_>>(),
-
            timestamp: issue.timestamp(),
-
            comments: issue
-
                .comments()
-
                .map(|(comment_id, comment)| {
-
                    CommentItem::new(profile, (id, issue.clone()), (*comment_id, comment.clone()))
-
                })
-
                .collect(),
-
        })
-
    }
-

-
    pub fn root_comments(&self) -> Vec<CommentItem> {
-
        self.comments
-
            .iter()
-
            .filter(|comment| comment.reply_to.is_none())
-
            .cloned()
-
            .collect::<Vec<_>>()
-
    }
-

-
    pub fn has_comment(&self, comment_id: &CommentId) -> bool {
-
        self.comments
-
            .iter()
-
            .any(|comment| comment.id == *comment_id)
-
    }
-

-
    pub fn path_to_comment(&self, comment_id: &CommentId) -> Option<Vec<CommentId>> {
-
        for comment in &self.comments {
-
            let mut path = Vec::new();
-
            if comment.path_to(comment_id, &mut path) {
-
                return Some(path);
-
            }
-
        }
-
        None
-
    }
-
}
-

-
impl ToRow<8> for IssueItem {
-
    fn to_row(&self) -> [Cell<'_>; 8] {
-
        let (state, state_color) = format::issue_state(&self.state);
-

-
        let state = span::default(&state).style(Style::default().fg(state_color));
-
        let id = span::primary(&format::cob(&self.id));
-
        let title = span::default(&self.title.clone());
-

-
        let author = match &self.author.alias {
-
            Some(alias) => {
-
                if self.author.you {
-
                    span::alias(&format!("{alias} (you)"))
-
                } else {
-
                    span::alias(alias)
-
                }
-
            }
-
            None => match &self.author.human_nid {
-
                Some(nid) => span::alias(nid).dim(),
-
                None => span::blank(),
-
            },
-
        };
-
        let did = match &self.author.human_nid {
-
            Some(nid) => span::alias(nid).dim(),
-
            None => span::blank(),
-
        };
-
        let labels = span::labels(&format::labels(&self.labels));
-
        let assignees = self
-
            .assignees
-
            .iter()
-
            .map(|author| (author.nid, author.alias.clone(), author.you))
-
            .collect::<Vec<_>>();
-
        let assignees = span::alias(&format::assignees(&assignees));
-
        let opened = span::timestamp(&format::timestamp(&self.timestamp));
-

-
        [
-
            state.into(),
-
            id.into(),
-
            title.into(),
-
            author.into(),
-
            did.into(),
-
            labels.into(),
-
            assignees.into(),
-
            opened.into(),
-
        ]
-
    }
-
}
-

-
impl HasId for IssueItem {
-
    fn id(&self) -> ObjectId {
-
        self.id
-
    }
-
}
-

-
#[derive(Clone, Default, Debug, Eq, PartialEq)]
-
pub struct IssueItemFilter {
-
    state: Option<issue::State>,
-
    authored: bool,
-
    authors: Vec<Did>,
-
    assigned: bool,
-
    assignees: Vec<Did>,
-
    search: Option<String>,
-
}
-

-
impl IssueItemFilter {
-
    pub fn state(&self) -> Option<issue::State> {
-
        self.state
-
    }
-
}
-

-
impl filter::Filter<IssueItem> for IssueItemFilter {
-
    fn matches(&self, issue: &IssueItem) -> bool {
-
        use fuzzy_matcher::skim::SkimMatcherV2;
-
        use fuzzy_matcher::FuzzyMatcher;
-

-
        let matcher = SkimMatcherV2::default();
-

-
        let matches_state = match self.state {
-
            Some(issue::State::Closed {
-
                reason: CloseReason::Other,
-
            }) => matches!(issue.state, issue::State::Closed { .. }),
-
            Some(state) => issue.state == state,
-
            None => true,
-
        };
-

-
        let matches_authored = if self.authored {
-
            issue.author.you
-
        } else {
-
            true
-
        };
-

-
        let matches_authors = if !self.authors.is_empty() {
-
            {
-
                self.authors
-
                    .iter()
-
                    .any(|other| issue.author.nid == Some(**other))
-
            }
-
        } else {
-
            true
-
        };
-

-
        let matches_assigned = if self.assigned {
-
            issue.assignees.iter().any(|assignee| assignee.you)
-
        } else {
-
            true
-
        };
-

-
        let matches_assignees = if !self.assignees.is_empty() {
-
            {
-
                self.assignees.iter().any(|other| {
-
                    issue
-
                        .assignees
-
                        .iter()
-
                        .filter_map(|author| author.nid)
-
                        .collect::<Vec<_>>()
-
                        .contains(other)
-
                })
-
            }
-
        } else {
-
            true
-
        };
-

-
        let matches_search = match &self.search {
-
            Some(search) => match matcher.fuzzy_match(&issue.title, search) {
-
                Some(score) => score == 0 || score > 60,
-
                _ => false,
-
            },
-
            None => true,
-
        };
-

-
        matches_state
-
            && matches_authored
-
            && matches_authors
-
            && matches_assigned
-
            && matches_assignees
-
            && matches_search
-
    }
-
}
-

-
impl FromStr for IssueItemFilter {
-
    type Err = anyhow::Error;
-

-
    fn from_str(value: &str) -> Result<Self, Self::Err> {
-
        let mut state = None;
-
        let mut search = String::new();
-
        let mut authored = false;
-
        let mut authors = vec![];
-
        let mut assigned = false;
-
        let mut assignees = vec![];
-

-
        let mut authors_parser = |input| -> IResult<&str, Vec<&str>> {
-
            preceded(
-
                tag("authors:"),
-
                delimited(
-
                    tag("["),
-
                    separated_list0(tag(","), take(56_usize)),
-
                    tag("]"),
-
                ),
-
            )
-
            .parse(input)
-
        };
-

-
        let mut assignees_parser = |input| -> IResult<&str, Vec<&str>> {
-
            preceded(
-
                tag("assignees:"),
-
                delimited(
-
                    tag("["),
-
                    separated_list0(tag(","), take(56_usize)),
-
                    tag("]"),
-
                ),
-
            )
-
            .parse(input)
-
        };
-

-
        let parts = value.split(' ');
-
        for part in parts {
-
            match part {
-
                "is:open" => state = Some(issue::State::Open),
-
                "is:closed" => {
-
                    state = Some(issue::State::Closed {
-
                        reason: issue::CloseReason::Other,
-
                    })
-
                }
-
                "is:solved" => {
-
                    state = Some(issue::State::Closed {
-
                        reason: issue::CloseReason::Solved,
-
                    })
-
                }
-
                "is:authored" => authored = true,
-
                "is:assigned" => assigned = true,
-
                other => {
-
                    if let Ok((_, dids)) = assignees_parser.parse(other) {
-
                        for did in dids {
-
                            assignees.push(Did::from_str(did)?);
-
                        }
-
                    } else if let Ok((_, dids)) = authors_parser.parse(other) {
-
                        for did in dids {
-
                            authors.push(Did::from_str(did)?);
-
                        }
-
                    } else {
-
                        search.push_str(other);
-
                    }
-
                }
-
            }
-
        }
-

-
        Ok(Self {
-
            state,
-
            authored,
-
            authors,
-
            assigned,
-
            assignees,
-
            search: Some(search),
-
        })
-
    }
-
}
-

/// A `CommentItem` represents a comment COB and is constructed from an `Issue` and
/// a `Comment`.
#[derive(Clone, Debug)]
@@ -546,16 +254,21 @@ impl ToTree<String> for CommentItem {
#[cfg(test)]
mod tests {
    use anyhow::Result;
+
    use std::str::FromStr;
+

+
    use radicle::issue::State;
+

+
    use crate::ui::items::issue::IssueFilter;

    use super::*;

    #[test]
    fn issue_item_filter_from_str_should_succeed() -> Result<()> {
        let search = r#"is:open is:assigned assignees:[did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB,did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx] is:authored authors:[did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx] cli"#;
-
        let actual = IssueItemFilter::from_str(search)?;
+
        let actual = IssueFilter::from_str(search)?;

-
        let expected = IssueItemFilter {
-
            state: Some(issue::State::Open),
+
        let expected = IssueFilter {
+
            state: Some(State::Open),
            authors: vec![Did::from_str(
                "did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx",
            )?],
added bin/ui/items/issue.rs
@@ -0,0 +1,316 @@
+
use std::fmt::Debug;
+
use std::str::FromStr;
+

+
use nom::bytes::complete::{tag, take};
+
use nom::multi::separated_list0;
+
use nom::sequence::{delimited, preceded};
+
use nom::{IResult, Parser};
+

+
use radicle::cob::thread::CommentId;
+
use radicle::cob::{Label, ObjectId, Timestamp};
+
use radicle::issue::{CloseReason, IssueId};
+
use radicle::prelude::Did;
+
use radicle::Profile;
+

+
use ratatui::style::{Style, Stylize};
+
use ratatui::widgets::Cell;
+

+
use radicle_tui as tui;
+

+
use tui::ui::span;
+
use tui::ui::ToRow;
+

+
use crate::ui::format;
+
use crate::ui::items::filter::Filter;
+
use crate::ui::items::{AuthorItem, CommentItem, HasId};
+

+
#[derive(Clone, Debug)]
+
pub struct Issue {
+
    /// Issue OID.
+
    pub id: IssueId,
+
    /// Issue state.
+
    pub state: radicle::issue::State,
+
    /// Issue title.
+
    pub title: String,
+
    /// Issue author.
+
    pub author: AuthorItem,
+
    /// Issue labels.
+
    pub labels: Vec<Label>,
+
    /// Issue assignees.
+
    pub assignees: Vec<AuthorItem>,
+
    /// Time when issue was opened.
+
    pub timestamp: Timestamp,
+
    /// Comment timeline
+
    pub comments: Vec<CommentItem>,
+
}
+

+
impl Issue {
+
    pub fn new(
+
        profile: &Profile,
+
        issue: (IssueId, radicle::issue::Issue),
+
    ) -> Result<Self, anyhow::Error> {
+
        let (id, issue) = issue;
+

+
        Ok(Self {
+
            id,
+
            state: *issue.state(),
+
            title: issue.title().into(),
+
            author: AuthorItem::new(Some(*issue.author().id), profile),
+
            labels: issue.labels().cloned().collect(),
+
            assignees: issue
+
                .assignees()
+
                .map(|did| AuthorItem::new(Some(**did), profile))
+
                .collect::<Vec<_>>(),
+
            timestamp: issue.timestamp(),
+
            comments: issue
+
                .comments()
+
                .map(|(comment_id, comment)| {
+
                    CommentItem::new(profile, (id, issue.clone()), (*comment_id, comment.clone()))
+
                })
+
                .collect(),
+
        })
+
    }
+

+
    pub fn root_comments(&self) -> Vec<CommentItem> {
+
        self.comments
+
            .iter()
+
            .filter(|comment| comment.reply_to.is_none())
+
            .cloned()
+
            .collect::<Vec<_>>()
+
    }
+

+
    pub fn has_comment(&self, comment_id: &CommentId) -> bool {
+
        self.comments
+
            .iter()
+
            .any(|comment| comment.id == *comment_id)
+
    }
+

+
    pub fn path_to_comment(&self, comment_id: &CommentId) -> Option<Vec<CommentId>> {
+
        for comment in &self.comments {
+
            let mut path = Vec::new();
+
            if comment.path_to(comment_id, &mut path) {
+
                return Some(path);
+
            }
+
        }
+
        None
+
    }
+
}
+

+
impl ToRow<8> for Issue {
+
    fn to_row(&self) -> [Cell<'_>; 8] {
+
        let (state, state_color) = format::issue_state(&self.state);
+

+
        let state = span::default(&state).style(Style::default().fg(state_color));
+
        let id = span::primary(&format::cob(&self.id));
+
        let title = span::default(&self.title.clone());
+

+
        let author = match &self.author.alias {
+
            Some(alias) => {
+
                if self.author.you {
+
                    span::alias(&format!("{alias} (you)"))
+
                } else {
+
                    span::alias(alias)
+
                }
+
            }
+
            None => match &self.author.human_nid {
+
                Some(nid) => span::alias(nid).dim(),
+
                None => span::blank(),
+
            },
+
        };
+
        let did = match &self.author.human_nid {
+
            Some(nid) => span::alias(nid).dim(),
+
            None => span::blank(),
+
        };
+
        let labels = span::labels(&format::labels(&self.labels));
+
        let assignees = self
+
            .assignees
+
            .iter()
+
            .map(|author| (author.nid, author.alias.clone(), author.you))
+
            .collect::<Vec<_>>();
+
        let assignees = span::alias(&format::assignees(&assignees));
+
        let opened = span::timestamp(&format::timestamp(&self.timestamp));
+

+
        [
+
            state.into(),
+
            id.into(),
+
            title.into(),
+
            author.into(),
+
            did.into(),
+
            labels.into(),
+
            assignees.into(),
+
            opened.into(),
+
        ]
+
    }
+
}
+

+
impl HasId for Issue {
+
    fn id(&self) -> ObjectId {
+
        self.id
+
    }
+
}
+

+
#[derive(Clone, Default, Debug, Eq, PartialEq)]
+
pub(crate) struct IssueFilter {
+
    pub(crate) state: Option<radicle::issue::State>,
+
    pub(crate) authored: bool,
+
    pub(crate) authors: Vec<Did>,
+
    pub(crate) assigned: bool,
+
    pub(crate) assignees: Vec<Did>,
+
    pub(crate) search: Option<String>,
+
}
+

+
impl IssueFilter {
+
    pub fn state(&self) -> Option<radicle::issue::State> {
+
        self.state
+
    }
+
}
+

+
impl Filter<Issue> for IssueFilter {
+
    fn matches(&self, issue: &Issue) -> bool {
+
        use fuzzy_matcher::skim::SkimMatcherV2;
+
        use fuzzy_matcher::FuzzyMatcher;
+
        use radicle::issue::State;
+

+
        let matcher = SkimMatcherV2::default();
+

+
        let matches_state = match self.state {
+
            Some(State::Closed {
+
                reason: CloseReason::Other,
+
            }) => matches!(issue.state, State::Closed { .. }),
+
            Some(state) => issue.state == state,
+
            None => true,
+
        };
+

+
        let matches_authored = if self.authored {
+
            issue.author.you
+
        } else {
+
            true
+
        };
+

+
        let matches_authors = if !self.authors.is_empty() {
+
            {
+
                self.authors
+
                    .iter()
+
                    .any(|other| issue.author.nid == Some(**other))
+
            }
+
        } else {
+
            true
+
        };
+

+
        let matches_assigned = if self.assigned {
+
            issue.assignees.iter().any(|assignee| assignee.you)
+
        } else {
+
            true
+
        };
+

+
        let matches_assignees = if !self.assignees.is_empty() {
+
            {
+
                self.assignees.iter().any(|other| {
+
                    issue
+
                        .assignees
+
                        .iter()
+
                        .filter_map(|author| author.nid)
+
                        .collect::<Vec<_>>()
+
                        .contains(other)
+
                })
+
            }
+
        } else {
+
            true
+
        };
+

+
        let matches_search = match &self.search {
+
            Some(search) => match matcher.fuzzy_match(&issue.title, search) {
+
                Some(score) => score == 0 || score > 60,
+
                _ => false,
+
            },
+
            None => true,
+
        };
+

+
        matches_state
+
            && matches_authored
+
            && matches_authors
+
            && matches_assigned
+
            && matches_assignees
+
            && matches_search
+
    }
+
}
+

+
impl FromStr for IssueFilter {
+
    type Err = anyhow::Error;
+

+
    fn from_str(value: &str) -> Result<Self, Self::Err> {
+
        use radicle::issue::State;
+

+
        let mut state = None;
+
        let mut search = String::new();
+
        let mut authored = false;
+
        let mut authors = vec![];
+
        let mut assigned = false;
+
        let mut assignees = vec![];
+

+
        let mut authors_parser = |input| -> IResult<&str, Vec<&str>> {
+
            preceded(
+
                tag("authors:"),
+
                delimited(
+
                    tag("["),
+
                    separated_list0(tag(","), take(56_usize)),
+
                    tag("]"),
+
                ),
+
            )
+
            .parse(input)
+
        };
+

+
        let mut assignees_parser = |input| -> IResult<&str, Vec<&str>> {
+
            preceded(
+
                tag("assignees:"),
+
                delimited(
+
                    tag("["),
+
                    separated_list0(tag(","), take(56_usize)),
+
                    tag("]"),
+
                ),
+
            )
+
            .parse(input)
+
        };
+

+
        let parts = value.split(' ');
+
        for part in parts {
+
            match part {
+
                "is:open" => state = Some(State::Open),
+
                "is:closed" => {
+
                    state = Some(State::Closed {
+
                        reason: CloseReason::Other,
+
                    })
+
                }
+
                "is:solved" => {
+
                    state = Some(State::Closed {
+
                        reason: CloseReason::Solved,
+
                    })
+
                }
+
                "is:authored" => authored = true,
+
                "is:assigned" => assigned = true,
+
                other => {
+
                    if let Ok((_, dids)) = assignees_parser.parse(other) {
+
                        for did in dids {
+
                            assignees.push(Did::from_str(did)?);
+
                        }
+
                    } else if let Ok((_, dids)) = authors_parser.parse(other) {
+
                        for did in dids {
+
                            authors.push(Did::from_str(did)?);
+
                        }
+
                    } else {
+
                        search.push_str(other);
+
                    }
+
                }
+
            }
+
        }
+

+
        Ok(Self {
+
            state,
+
            authored,
+
            authors,
+
            assigned,
+
            assignees,
+
            search: Some(search),
+
        })
+
    }
+
}
deleted bin/ui/rm.rs
@@ -1,273 +0,0 @@
-
use std::marker::PhantomData;
-
use std::str::FromStr;
-

-
use radicle::cob::ObjectId;
-
use radicle::issue::{self, CloseReason};
-
use ratatui::layout::{Constraint, Layout};
-
use ratatui::style::Stylize;
-
use ratatui::text::{Line, Span, Text};
-
use ratatui::widgets::Row;
-
use ratatui::Frame;
-

-
use radicle_tui as tui;
-

-
use tui::ui::rm::widget::{RenderProps, View, ViewProps};
-
use tui::ui::theme::style;
-
use tui::ui::{layout, span, BufferedValue};
-

-
use super::format;
-
use super::items::IssueItem;
-

-
use crate::ui::items::filter::Filter;
-
use crate::ui::items::HasId;
-

-
/// A `BrowserState` represents the internal state of a browser widget.
-
/// A browser widget would consist of 2 child widgets: a list of items and a
-
/// buffered search field. The search fields value is used to build an
-
/// item filter that the item list reacts on dynamically.
-
#[derive(Clone, Debug)]
-
pub struct BrowserState<I, F> {
-
    items: Vec<I>,
-
    selected: Option<usize>,
-
    filter: F,
-
    search: BufferedValue<String>,
-
    show_search: bool,
-
}
-

-
impl<I, F> Default for BrowserState<I, F>
-
where
-
    I: Clone,
-
    F: Filter<I> + Default + FromStr,
-
{
-
    fn default() -> Self {
-
        Self {
-
            items: vec![],
-
            selected: None,
-
            filter: F::default(),
-
            search: BufferedValue::new(String::default()),
-
            show_search: false,
-
        }
-
    }
-
}
-

-
impl<I, F> BrowserState<I, F>
-
where
-
    I: Clone + HasId,
-
    F: Filter<I> + Default + FromStr,
-
{
-
    pub fn build(
-
        items: Vec<I>,
-
        selected: Option<ObjectId>,
-
        filter: F,
-
        search: BufferedValue<String>,
-
    ) -> Self {
-
        let selected = match selected {
-
            Some(id) => items
-
                .iter()
-
                .filter(|item| filter.matches(item))
-
                .position(|item| item.id() == id),
-
            None => items.first().map(|_| 0),
-
        };
-

-
        Self {
-
            items,
-
            selected,
-
            filter,
-
            search,
-
            ..Default::default()
-
        }
-
    }
-

-
    pub fn items(&self) -> Vec<I> {
-
        self.items_ref().into_iter().cloned().collect()
-
    }
-

-
    pub fn items_ref(&self) -> Vec<&I> {
-
        self.items
-
            .iter()
-
            .filter(|item| self.filter.matches(item))
-
            .collect()
-
    }
-

-
    pub fn selected(&self) -> Option<usize> {
-
        self.selected
-
    }
-

-
    pub fn selected_item(&self) -> Option<&I> {
-
        self.selected
-
            .and_then(|selected| self.items_ref().get(selected).copied())
-
    }
-

-
    pub fn select_item(&mut self, selected: Option<usize>) -> Option<&I> {
-
        self.selected = selected;
-
        self.selected_item()
-
    }
-

-
    pub fn select_first_item(&mut self) -> Option<&I> {
-
        self.selected.and_then(|selected| {
-
            if selected > self.items_ref().len() {
-
                self.selected = Some(0);
-
                self.items_ref().first().cloned()
-
            } else {
-
                self.items_ref().get(selected).cloned()
-
            }
-
        })
-
    }
-

-
    fn filter_items(&mut self) {
-
        self.filter = F::from_str(&self.search.read()).unwrap_or_default();
-
    }
-

-
    pub fn update_search(&mut self, value: String) {
-
        self.search.write(value);
-
        self.filter_items();
-
    }
-

-
    pub fn show_search(&mut self) {
-
        self.show_search = true;
-
    }
-

-
    pub fn hide_search(&mut self) {
-
        self.show_search = false;
-
    }
-

-
    pub fn apply_search(&mut self) {
-
        self.search.apply();
-
    }
-

-
    pub fn reset_search(&mut self) {
-
        self.search.reset();
-
        self.filter_items();
-
    }
-

-
    pub fn is_search_shown(&self) -> bool {
-
        self.show_search
-
    }
-

-
    pub fn read_search(&self) -> String {
-
        self.search.read()
-
    }
-
}
-

-
#[derive(Clone, Default)]
-
pub struct IssueDetailsProps {
-
    issue: Option<IssueItem>,
-
    dim: bool,
-
}
-

-
impl IssueDetailsProps {
-
    pub fn issue(mut self, issue: Option<IssueItem>) -> Self {
-
        self.issue = issue;
-
        self
-
    }
-

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

-
pub struct IssueDetails<S, M> {
-
    /// Phantom
-
    phantom: PhantomData<(S, M)>,
-
}
-

-
impl<S, M> Default for IssueDetails<S, M> {
-
    fn default() -> Self {
-
        Self {
-
            phantom: PhantomData,
-
        }
-
    }
-
}
-

-
impl<S, M> View for IssueDetails<S, M> {
-
    type State = S;
-
    type Message = M;
-

-
    fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
-
        let default = IssueDetailsProps::default();
-
        let props = props
-
            .and_then(|props| props.inner_ref::<IssueDetailsProps>())
-
            .unwrap_or(&default);
-

-
        let [area] = Layout::default()
-
            .constraints([Constraint::Min(1)])
-
            .horizontal_margin(1)
-
            .areas(render.area);
-

-
        if let Some(issue) = props.issue.as_ref() {
-
            let author = match &issue.author.alias {
-
                Some(alias) => span::alias(alias.as_ref()),
-
                None => match &issue.author.human_nid {
-
                    Some(nid) => span::alias(nid).dim(),
-
                    None => span::blank(),
-
                },
-
            };
-

-
            let did = match &issue.author.human_nid {
-
                Some(nid) => {
-
                    if issue.author.you {
-
                        span::alias("(you)").dim().italic()
-
                    } else {
-
                        span::alias(nid).dim()
-
                    }
-
                }
-
                None => span::blank(),
-
            };
-

-
            let labels = format::labels(&issue.labels);
-

-
            let status = match issue.state {
-
                issue::State::Open => Text::styled("open", style::green()),
-
                issue::State::Closed { reason } => match reason {
-
                    CloseReason::Solved => Line::from(
-
                        [
-
                            Span::styled("closed", style::red()),
-
                            Span::raw(" "),
-
                            Span::styled("(solved)", style::red().italic().dim()),
-
                        ]
-
                        .to_vec(),
-
                    )
-
                    .into(),
-
                    CloseReason::Other => Text::styled("closed", style::red()),
-
                },
-
            };
-

-
            let table = ratatui::widgets::Table::new(
-
                [
-
                    Row::new([
-
                        Text::raw("Title").cyan(),
-
                        Text::raw(issue.title.clone()).bold(),
-
                    ]),
-
                    Row::new([
-
                        Text::raw("Issue").cyan(),
-
                        Text::raw(issue.id.to_string()).bold(),
-
                    ]),
-
                    Row::new([
-
                        Text::raw("Author").cyan(),
-
                        Line::from([author, " ".into(), did].to_vec()).into(),
-
                    ]),
-
                    Row::new([Text::raw("Labels").cyan(), Text::from(labels).blue()]),
-
                    Row::new([Text::raw("Status").cyan(), status]),
-
                ],
-
                [Constraint::Length(8), Constraint::Fill(1)],
-
            );
-

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

-
            frame.render_widget(table, area);
-
        } else {
-
            let center = layout::centered_rect(render.area, 50, 10);
-
            let hint = Text::from(span::default("No issue selected"))
-
                .centered()
-
                .light_magenta()
-
                .dim();
-

-
            frame.render_widget(hint, center);
-
        }
-
    }
-
}
modified examples/selection.rs
@@ -109,6 +109,7 @@ impl Show<Message> for App {
                        &self.items,
                        columns,
                        None,
+
                        Spacing::from(1),
                        Some(Borders::None),
                    );
                    if table.changed {
modified src/ui/im.rs
@@ -1,7 +1,8 @@
pub mod widget;

-
use std::collections::VecDeque;
+
use std::collections::{HashSet, VecDeque};
use std::fmt::Debug;
+
use std::hash::Hash;
use std::rc::Rc;
use std::time::Duration;

@@ -19,7 +20,7 @@ use crate::event::{Event, Key};
use crate::store::Update;
use crate::terminal::Terminal;
use crate::ui::theme::Theme;
-
use crate::ui::{Column, Spacing, ToRow};
+
use crate::ui::{Column, Spacing, ToRow, ToTree};
use crate::{Interrupted, Share};

use crate::ui::im::widget::Widget;
@@ -322,6 +323,16 @@ impl<M> Ui<M> {
            })
    }

+
    pub fn has_global_input(&mut self, f: impl Fn(Key) -> bool) -> bool {
+
        self.has_focus
+
            && self.ctx.inputs.iter().any(|event| {
+
                if let Event::Key(key) = event {
+
                    return f(*key);
+
                }
+
                false
+
            })
+
    }
+

    pub fn get_input(&mut self, f: impl Fn(Key) -> bool) -> Option<Key> {
        if self.has_focus && self.is_area_focused() {
            let matches = |&event| {
@@ -551,6 +562,7 @@ where
        )
    }

+
    #[allow(clippy::too_many_arguments)]
    pub fn table<'a, R, const W: usize>(
        &mut self,
        frame: &mut Frame,
@@ -558,12 +570,30 @@ where
        items: &'a Vec<R>,
        columns: Vec<Column<'a>>,
        empty_message: Option<String>,
+
        spacing: Spacing,
        borders: Option<Borders>,
    ) -> Response
    where
        R: ToRow<W> + Clone,
    {
-
        widget::Table::new(selected, items, columns, empty_message, borders).ui(self, frame)
+
        widget::Table::new(selected, items, columns, empty_message, borders)
+
            .spacing(spacing)
+
            .ui(self, frame)
+
    }
+

+
    pub fn tree<R, Id>(
+
        &mut self,
+
        frame: &mut Frame,
+
        items: &'_ Vec<R>,
+
        opened: &mut Option<HashSet<Vec<Id>>>,
+
        selected: &mut Option<Vec<Id>>,
+
        borders: Option<Borders>,
+
    ) -> Response
+
    where
+
        R: ToTree<Id> + Clone,
+
        Id: ToString + Clone + Eq + Hash,
+
    {
+
        widget::Tree::new(items, opened, selected, borders, false).ui(self, frame)
    }

    pub fn shortcuts(
@@ -593,7 +623,18 @@ where
        scroll: &'a mut Position,
        borders: Option<Borders>,
    ) -> Response {
-
        widget::TextView::new(text, scroll, borders).ui(self, frame)
+
        widget::TextView::new(text, None::<String>, scroll, borders).ui(self, frame)
+
    }
+

+
    pub fn text_view_with_footer<'a>(
+
        &mut self,
+
        frame: &mut Frame,
+
        text: impl Into<Text<'a>>,
+
        footer: impl Into<Text<'a>>,
+
        scroll: &'a mut Position,
+
        borders: Option<Borders>,
+
    ) -> Response {
+
        widget::TextView::new(text, Some(footer), scroll, borders).ui(self, frame)
    }

    pub fn centered_text_view<'a>(
modified src/ui/im/widget.rs
@@ -1,18 +1,21 @@
use std::cmp;
+
use std::collections::HashSet;
+
use std::hash::Hash;

+
use ratatui::symbols::border;
use serde::{Deserialize, Serialize};

use ratatui::layout::{Alignment, Direction, Layout, Position, Rect};
use ratatui::style::{Style, Stylize};
use ratatui::text::{Line, Span, Text};
-
use ratatui::widgets::{Block, BorderType, Row, Scrollbar, ScrollbarState};
+
use ratatui::widgets::{Block, BorderType, Row, Scrollbar, ScrollbarOrientation, ScrollbarState};
use ratatui::Frame;
use ratatui::{layout::Constraint, widgets::Paragraph};

use crate::event::Key;
use crate::ui::ext::{FooterBlock, FooterBlockType, HeaderBlock};
use crate::ui::theme::style;
-
use crate::ui::{layout, span, Spacing};
+
use crate::ui::{layout, span, Spacing, ToTree};
use crate::ui::{Column, ToRow};

use super::{Borders, Context, InnerResponse, Response, Ui};
@@ -132,11 +135,11 @@ impl<'a> Container<'a> {
            len: self.len,
        };

-
        if ui.has_input(|key| key == Key::Tab) {
+
        if ui.has_global_input(|key| key == Key::Tab) {
            state.focus_next();
            response.changed = true;
        }
-
        if ui.has_input(|key| key == Key::BackTab) {
+
        if ui.has_global_input(|key| key == Key::BackTab) {
            state.focus_prev();
            response.changed = true;
        }
@@ -308,6 +311,7 @@ pub struct Table<'a, R, const W: usize> {
    items: &'a Vec<R>,
    selected: &'a mut Option<usize>,
    columns: Vec<Column<'a>>,
+
    spacing: Spacing,
    borders: Option<Borders>,
    show_scrollbar: bool,
    empty_message: Option<String>,
@@ -329,6 +333,7 @@ where
            items,
            selected,
            columns,
+
            spacing: Spacing::from(1),
            empty_message,
            borders,
            show_scrollbar: true,
@@ -340,6 +345,11 @@ where
        self.dim = dim;
        self
    }
+

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

impl<R, const W: usize> Widget for Table<'_, R, W>
@@ -365,7 +375,7 @@ where
            },
        };

-
        let border_style = if area_focus && ui.has_focus {
+
        let border_style = if ui.has_focus {
            ui.theme.focus_border_style
        } else {
            ui.theme.border_style
@@ -446,8 +456,8 @@ where
            let table = ratatui::widgets::Table::default()
                .rows(rows)
                .widths(widths)
-
                .column_spacing(1)
-
                .row_highlight_style(style::highlight(area_focus));
+
                .column_spacing(self.spacing.into())
+
                .row_highlight_style(style::highlight(ui.has_focus));

            let table = if !area_focus && self.dim {
                table.dim()
@@ -493,6 +503,188 @@ where
    }
}

+
#[derive(Debug)]
+
pub struct TreeState<Id>
+
where
+
    Id: ToString + Clone + Eq + Hash,
+
{
+
    pub internal: tui_tree_widget::TreeState<Id>,
+
}
+

+
impl<Id> Clone for TreeState<Id>
+
where
+
    Id: ToString + Clone + Eq + Hash,
+
{
+
    fn clone(&self) -> Self {
+
        let mut state = tui_tree_widget::TreeState::default();
+
        for path in self.internal.opened() {
+
            state.open(path.to_vec());
+
        }
+
        state.select(self.internal.selected().to_vec());
+

+
        Self { internal: state }
+
    }
+
}
+

+
pub struct Tree<'a, R, Id>
+
where
+
    R: ToTree<Id> + Clone,
+
    Id: ToString + Clone + Eq + Hash,
+
{
+
    /// Root items.
+
    items: &'a Vec<R>,
+
    /// Optional identifier set of opened items. If not `None`,
+
    /// it will override the internal tree state.
+
    opened: Option<HashSet<Vec<Id>>>,
+
    /// Optional path to selected item, e.g. ["1.0", "1.0.1", "1.0.2"]. If not `None`,
+
    /// it will override the internal tree state.
+
    selected: &'a mut Option<Vec<Id>>,
+
    /// If this widget should render its scrollbar. Default: `true`.
+
    show_scrollbar: bool,
+
    /// Set to `true` if the content style should be dimmed whenever the widget
+
    /// has no focus.
+
    dim: bool,
+
    /// The borders to use.
+
    borders: Option<Borders>,
+
}
+

+
impl<'a, R, Id> Tree<'a, R, Id>
+
where
+
    Id: ToString + Clone + Eq + Hash,
+
    R: ToTree<Id> + Clone,
+
{
+
    pub fn new(
+
        items: &'a Vec<R>,
+
        opened: &'a Option<HashSet<Vec<Id>>>,
+
        selected: &'a mut Option<Vec<Id>>,
+
        borders: Option<Borders>,
+
        dim: bool,
+
    ) -> Self {
+
        Self {
+
            items,
+
            selected,
+
            opened: opened.clone(),
+
            borders,
+
            show_scrollbar: true,
+
            dim,
+
        }
+
    }
+
}
+

+
impl<R, Id> Widget for Tree<'_, R, Id>
+
where
+
    R: ToTree<Id> + Clone,
+
    Id: ToString + Clone + Eq + Hash,
+
{
+
    fn ui<M>(self, ui: &mut Ui<M>, frame: &mut Frame) -> Response
+
    where
+
        M: Clone,
+
    {
+
        let mut response = Response::default();
+
        let mut state = TreeState {
+
            internal: {
+
                let mut state = tui_tree_widget::TreeState::default();
+

+
                if let Some(opened) = &self.opened {
+
                    if opened != state.opened() {
+
                        state.close_all();
+
                        for path in opened {
+
                            state.open(path.to_vec());
+
                        }
+
                    }
+
                }
+
                if let Some(selected) = self.selected {
+
                    state.select(selected.clone());
+
                }
+
                state
+
            },
+
        };
+

+
        let mut items = vec![];
+
        for item in self.items {
+
            items.extend(item.rows());
+
        }
+

+
        let (area, area_focus) = ui.next_area().unwrap_or_default();
+
        let border_style = if area_focus && ui.has_focus {
+
            ui.theme.focus_border_style
+
        } else {
+
            ui.theme.border_style
+
        };
+
        let area = render_block(frame, area, self.borders, border_style);
+

+
        let tree_style = if !area_focus && self.dim {
+
            Style::default().dim()
+
        } else {
+
            Style::default()
+
        };
+

+
        let show_scrollbar = self.show_scrollbar && self.items.len() >= area.height.into();
+
        let tree = if show_scrollbar {
+
            tui_tree_widget::Tree::new(&items)
+
                .expect("all item identifiers are unique")
+
                .block(
+
                    Block::default()
+
                        .borders(ratatui::widgets::Borders::RIGHT)
+
                        .border_set(border::Set {
+
                            vertical_right: " ",
+
                            ..Default::default()
+
                        })
+
                        .border_style(if area_focus {
+
                            Style::default()
+
                        } else {
+
                            Style::default().dim()
+
                        }),
+
                )
+
                .experimental_scrollbar(Some(
+
                    Scrollbar::new(ScrollbarOrientation::VerticalRight)
+
                        .begin_symbol(None)
+
                        .track_symbol(None)
+
                        .end_symbol(None)
+
                        .thumb_symbol("┃"),
+
                ))
+
                .highlight_style(style::highlight(ui.has_focus))
+
                .style(tree_style)
+
        } else {
+
            tui_tree_widget::Tree::new(&items)
+
                .expect("all item identifiers are unique")
+
                .style(tree_style)
+
                .highlight_style(style::highlight(ui.has_focus))
+
        };
+

+
        frame.render_stateful_widget(tree, area, &mut state.internal);
+

+
        if let Some(key) = ui.get_input(|_| true) {
+
            match key {
+
                Key::Up | Key::Char('k') => {
+
                    state.internal.key_up();
+
                    response.changed = true;
+
                }
+
                Key::Down | Key::Char('j') => {
+
                    state.internal.key_down();
+
                    response.changed = true;
+
                }
+
                Key::Left | Key::Char('h')
+
                    if !state.internal.selected().is_empty()
+
                        && !state.internal.opened().is_empty() =>
+
                {
+
                    state.internal.key_left();
+
                    response.changed = true;
+
                }
+
                Key::Right | Key::Char('l') => {
+
                    state.internal.key_right();
+
                    response.changed = true;
+
                }
+
                _ => {}
+
            }
+
        }
+

+
        *self.selected = Some(state.internal.selected().to_vec());
+

+
        response
+
    }
+
}
+

pub struct ColumnBar<'a> {
    columns: Vec<Column<'a>>,
    spacing: Spacing,
@@ -656,6 +848,7 @@ impl TextViewState {

pub struct TextView<'a> {
    text: Text<'a>,
+
    footer: Option<Text<'a>>,
    borders: Option<Borders>,
    cursor: &'a mut Position,
}
@@ -663,11 +856,13 @@ pub struct TextView<'a> {
impl<'a> TextView<'a> {
    pub fn new(
        text: impl Into<Text<'a>>,
+
        footer: Option<impl Into<Text<'a>>>,
        cursor: &'a mut Position,
        borders: Option<Borders>,
    ) -> Self {
        Self {
            text: text.into(),
+
            footer: footer.map(|f| f.into()),
            borders,
            cursor,
        }
@@ -710,6 +905,15 @@ impl Widget for TextView<'_> {
            },
        ])
        .areas(area);
+
        let [text_area, footer_area] = Layout::vertical([
+
            Constraint::Min(1),
+
            if self.footer.is_some() {
+
                Constraint::Length(1)
+
            } else {
+
                Constraint::Length(0)
+
            },
+
        ])
+
        .areas(text_area);

        let scroller = Scrollbar::default()
            .begin_symbol(None)
@@ -732,6 +936,9 @@ impl Widget for TextView<'_> {
            Paragraph::new(self.text.clone()).scroll((self.cursor.x, self.cursor.y)),
            text_area,
        );
+
        if let Some(footer) = self.footer {
+
            frame.render_widget(Paragraph::new(footer.clone()), footer_area);
+
        }

        let mut state = TextViewState::new(*self.cursor);

@@ -1072,7 +1279,9 @@ impl Widget for Shortcuts {
        let mut row = vec![];

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