use std::collections::{HashMap, HashSet};
use std::str::FromStr;
use std::sync::{Arc, Mutex};
use radicle::cob::ObjectId;
use serde::Serialize;
use anyhow::{anyhow, bail, Result};
use ratatui::layout::{Alignment, Constraint, Layout, Position};
use ratatui::style::Stylize;
use ratatui::text::{Line, Span, Text};
use ratatui::{Frame, Viewport};
use radicle::cob::thread::CommentId;
use radicle::issue::IssueId;
use radicle::storage::git::Repository;
use radicle::Profile;
use radicle_tui as tui;
use tui::event::Key;
use tui::store;
use tui::task::EmptyProcessors;
use tui::ui;
use tui::ui::layout::Spacing;
use tui::ui::span;
use tui::ui::theme::Theme;
use tui::ui::widget::{
Borders, Column, ContainerState, TableState, TextEditState, TextViewState, TreeState, Window,
};
use tui::ui::{BufferedValue, Show, ToRow, Ui};
use tui::{Channel, Exit};
use crate::cob::issue;
use crate::settings;
use crate::ui::format;
use crate::ui::items::filter::Filter;
use crate::ui::items::issue::filter::IssueFilter;
use crate::ui::items::issue::Issue;
use crate::ui::items::HasId;
type Selection = tui::Selection<IssueOperation>;
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct OperationArguments {
id: IssueId,
search: String,
}
impl OperationArguments {
pub fn id(&self) -> ObjectId {
self.id
}
pub fn search(&self) -> String {
self.search.clone()
}
}
impl TryFrom<(&Vec<Issue>, &AppState)> for OperationArguments {
type Error = anyhow::Error;
fn try_from(value: (&Vec<Issue>, &AppState)) -> Result<Self> {
let (issues, state) = value;
let selected = state.browser.selected();
let id = selected
.and_then(|s| issues.get(s))
.ok_or(anyhow!("No issue selected"))?
.id;
let search = state.browser.search.read().text;
Ok(Self { id, search })
}
}
/// The selected issue operation returned by the operation
/// selection widget.
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub enum IssueOperation {
Edit {
args: OperationArguments,
comment_id: Option<CommentId>,
},
Show {
args: OperationArguments,
},
Close {
args: OperationArguments,
},
Solve {
args: OperationArguments,
},
Reopen {
args: OperationArguments,
},
Comment {
args: OperationArguments,
reply_to: Option<CommentId>,
},
}
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
`1-9`: focus section
`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,
pub filter: IssueFilter,
pub search: Option<String>,
pub issue: Option<IssueId>,
pub comment: Option<CommentId>,
}
pub(crate) struct Tui {
pub(crate) context: Context,
}
impl Tui {
pub fn new(context: Context) -> Self {
Self { context }
}
pub async fn run(&self) -> Result<Option<Selection>> {
let viewport = Viewport::Inline(20);
let channel = Channel::default();
let state = App::try_from(&self.context)?;
tui::im(state, viewport, channel, EmptyProcessors::new()).await
}
pub fn context(&self) -> &Context {
&self.context
}
}
mod args {
use super::*;
use crate::ui::items::CommentItem;
#[derive(Clone, Debug)]
pub(crate) enum Page {
Main,
Help,
}
#[derive(Clone, Default, Debug, Eq, PartialEq)]
pub(crate) enum Section {
#[default]
Browser,
Issue,
Comment,
}
impl TryFrom<usize> for Section {
type Error = anyhow::Error;
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),
}
}
}
#[derive(Clone, Debug)]
pub(crate) struct Browser {
pub(crate) issues: TableState,
pub(crate) search: BufferedValue<TextEditState>,
pub(crate) show_search: bool,
}
impl Browser {
pub fn selected(&self) -> Option<usize> {
self.issues.selected()
}
}
#[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,
);
}
}
}
#[derive(Clone, Debug)]
pub enum Change {
Page { page: args::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 enum Message {
Changed(Change),
Exit { operation: Option<IssueOperation> },
Quit,
}
#[derive(Clone, Debug)]
pub struct AppState {
page: args::Page,
sections: ContainerState,
browser: args::Browser,
preview: args::Preview,
help: TextViewState,
filter: IssueFilter,
theme: Theme,
}
#[derive(Clone, Debug)]
pub struct App {
issues: Arc<Mutex<Vec<Issue>>>,
state: AppState,
}
impl TryFrom<&Context> for App {
type Error = anyhow::Error;
fn try_from(context: &Context) -> Result<Self, Self::Error> {
let settings = settings::Settings::default();
let theme = settings::configure_theme(&settings);
let issues = issue::all(&context.profile, &context.repository)?;
let search = context.search.as_ref().map(|s| s.trim().to_string());
let (search, filter) = match search {
Some(search) => (
search.clone(),
IssueFilter::from_str(search.trim()).unwrap_or(IssueFilter::Invalid),
),
None => {
let filter = context.filter.clone();
(filter.to_string().trim().to_string(), filter)
}
};
// Convert into UI items
let mut issues: Vec<_> = issues
.into_iter()
.flat_map(|issue| Issue::new(&context.profile, issue).ok())
.collect();
issues.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
// Pre-select comments per issue. If a comment to pre-select is given,
// find identifier path needed for selection. Select root comment
// otherwise.
let selected_comments: HashMap<_, _> = issues
.iter()
.map(|issue| {
let comment_ids = match context.comment {
Some(comment_id) if issue.has_comment(&comment_id) => {
issue.path_to_comment(&comment_id).unwrap_or_default()
}
_ => issue
.root_comments()
.first()
.map(|c| vec![c.id])
.unwrap_or_default(),
};
(issue.id, comment_ids)
})
.collect();
let browser = args::Browser {
issues: TableState::new(Some(
context
.issue
.and_then(|id| {
issues
.iter()
.filter(|item| filter.matches(item))
.position(|item| item.id() == id)
})
.unwrap_or(0),
)),
search: BufferedValue::new(TextEditState {
text: search.clone(),
cursor: search.chars().count(),
}),
show_search: false,
};
let preview = args::Preview {
show: true,
issue: browser
.selected()
.and_then(|s| {
issues
.iter()
.filter(|item| filter.matches(item))
.collect::<Vec<_>>()
.get(s)
.cloned()
})
.cloned(),
selected_comments,
comment: TextViewState::new(Position::default()),
};
let section = if context.comment.is_some() {
args::Section::Issue
} else {
args::Section::Browser
};
Ok(Self {
issues: Arc::new(Mutex::new(issues)),
state: AppState {
page: args::Page::Main,
sections: ContainerState::new(3, Some(section as usize)),
browser,
preview,
filter,
help: TextViewState::new(Position::default()),
theme,
},
})
}
}
impl store::Update<Message> for App {
type Return = Selection;
fn update(&mut self, message: Message) -> Option<tui::Exit<Selection>> {
match message {
Message::Quit => Some(Exit { value: None }),
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();
}
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 } => {
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(),
);
}
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: &ui::Context<Message>, frame: &mut Frame) -> Result<()> {
Window::default().show(ctx, self.state.theme.clone(), |ui| {
match self.state.page.clone() {
args::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(
ui::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 args::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 !show_search {
if ui.has_input(|key| key == Key::Char('p')) {
ui.send_message(Message::Changed(Change::ShowPreview {
state: !self.state.preview.show,
}));
}
if ui.has_input(|key| key == Key::Char('?')) {
ui.send_message(Message::Changed(Change::Page {
page: args::Page::Help,
}));
}
}
}
args::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: args::Page::Main,
}));
}
}
}
if ui.has_input(|key| key == Key::Char('q')) {
ui.send_message(Message::Quit);
}
if ui.has_input(|key| key == Key::Ctrl('c')) {
ui.send_message(Message::Quit);
}
});
Ok(())
}
}
impl App {
pub fn show_browser(&self, frame: &mut Frame, ui: &mut 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 Ok(args) = OperationArguments::try_from((&issues, &self.state)) {
if ui.has_input(|key| key == Key::Enter) {
ui.send_message(Message::Exit {
operation: Some(IssueOperation::Show { args: args.clone() }),
});
}
if ui.has_input(|key| key == Key::Char('e')) {
ui.send_message(Message::Exit {
operation: Some(IssueOperation::Edit {
args: args.clone(),
comment_id: preview.selected_comment().map(|c| c.id),
}),
});
}
if ui.has_input(|key| key == Key::Char('s')) {
ui.send_message(Message::Exit {
operation: Some(IssueOperation::Solve { args: args.clone() }),
});
}
if ui.has_input(|key| key == Key::Char('l')) {
ui.send_message(Message::Exit {
operation: Some(IssueOperation::Close { args: args.clone() }),
});
}
if ui.has_input(|key| key == Key::Char('o')) {
ui.send_message(Message::Exit {
operation: Some(IssueOperation::Reopen { args }),
});
}
}
}
pub fn show_browser_search(&self, frame: &mut Frame, ui: &mut 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,
}));
}
}
fn show_browser_context(&self, frame: &mut Frame, ui: &mut 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());
if !self.state.filter.has_state() {
[
Column::new(
Span::raw(" Issues ".to_string()).cyan().dim().reversed(),
Constraint::Length(8),
),
Column::new(
Span::raw(format!(" {search}"))
.into_left_aligned_line()
.style(ui.theme().bar_on_black_style)
.cyan()
.dim(),
Constraint::Fill(1),
),
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),
),
Column::new(
Span::raw(filtered_counts.clone())
.into_right_aligned_line()
.cyan()
.dim()
.reversed(),
Constraint::Length(filtered_counts.chars().count() as u16),
),
]
.to_vec()
} else {
[
Column::new(
Span::raw(" Issues ".to_string()).cyan().dim().reversed(),
Constraint::Length(8),
),
Column::new(
Span::from(" ")
.style(ui.theme().bar_on_black_style)
.into_right_aligned_line(),
Constraint::Length(1),
),
Column::new(
Span::raw(search.to_string())
.into_left_aligned_line()
.style(ui.theme().bar_on_black_style)
.cyan()
.dim(),
Constraint::Fill(1),
),
Column::new(
Span::raw(filtered_counts.clone())
.into_right_aligned_line()
.cyan()
.dim()
.reversed(),
Constraint::Length(filtered_counts.chars().count() as u16),
),
]
.to_vec()
}
};
ui.column_bar(frame, context, Spacing::from(0), Some(Borders::None));
}
pub fn show_browser_shortcuts(&self, frame: &mut Frame, ui: &mut 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);
},
);
}
pub fn show_issue(&self, frame: &mut Frame, ui: &mut 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))
.cloned()
.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()
}
}
None => match &issue.author.human_nid {
Some(nid) => span::alias(nid).dim().into(),
None => span::blank().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(),
},
};
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 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);
}
}
if let Some(selected) = selected {
state.select(selected);
}
ui.send_message(Message::Changed(Change::Comment {
state: TreeState { internal: state },
}));
}
if let Ok(args) = OperationArguments::try_from((&issues, &self.state)) {
if ui.has_input(|key| key == Key::Char('c')) {
ui.send_message(Message::Exit {
operation: Some(IssueOperation::Comment {
args: args.clone(),
reply_to: comment.map(|c| c.id),
}),
});
}
if ui.has_input(|key| key == Key::Char('e')) {
ui.send_message(Message::Exit {
operation: Some(IssueOperation::Edit {
args,
comment_id: comment.map(|c| c.id),
}),
});
}
}
},
);
}
pub fn show_issue_context(&self, frame: &mut Frame, ui: &mut 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),
);
}
pub fn show_issue_shortcuts(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
let shortcuts = vec![("e", "edit"), ("c", "reply")];
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);
},
);
}
pub fn show_comment(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
let (text, reactions, mut cursor) = {
let comment = self.state.preview.selected_comment();
let body: String = comment
.map(|comment| comment.body.clone())
.unwrap_or_default();
let reactions = comment.and_then(|comment| {
let reactions = comment.accumulated_reactions();
if !reactions.is_empty() {
let reactions = reactions.iter().fold(String::new(), |all, (r, acc)| {
if *acc > 1_usize {
[all, format!("{r}{acc} ")].concat()
} else {
[all, format!("{r} ")].concat()
}
});
Some(reactions)
} else {
None
}
});
(body, reactions, self.state.preview.comment.clone().cursor())
};
let comment = match reactions {
Some(reactions) => {
ui.text_view_with_footer(frame, text, reactions, &mut cursor, Some(Borders::All))
}
None => ui.text_view(frame, text, &mut cursor, Some(Borders::All)),
};
if comment.changed {
ui.send_message(Message::Changed(Change::CommentBody {
state: TextViewState::new(cursor),
}))
}
}
pub fn show_comment_context(&self, frame: &mut Frame, ui: &mut 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),
);
}
pub fn show_comment_shortcuts(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
let shortcuts = vec![("e", "edit"), ("c", "reply")];
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 show_help_text(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
ui.column_bar(
frame,
[Column::new(Span::raw(" Help ").bold(), Constraint::Fill(1))].to_vec(),
Spacing::from(0),
Some(Borders::Top),
);
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),
}))
}
}
fn show_help_context(&self, frame: &mut Frame, ui: &mut 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),
);
}
}