Remove `radicle-tui` crate

This crate is now in its own repository:

  rid:z39mP9rQAaGmERfUMPULfPUi473tY
This commit is contained in:
cloudhead 2023-08-08 16:31:25 +02:00
parent b456d3a401
commit 1b9906db09
No known key found for this signature in database
29 changed files with 2 additions and 4266 deletions

175
Cargo.lock generated
View File

@ -38,17 +38,6 @@ dependencies = [
"cpufeatures",
]
[[package]]
name = "ahash"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47"
dependencies = [
"getrandom 0.2.10",
"once_cell",
"version_check",
]
[[package]]
name = "ahash"
version = "0.8.3"
@ -420,12 +409,6 @@ version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89b2fd2a0dcf38d7971e2194b6b6eebab45ae01067456a7fd93d5547a61b70be"
[[package]]
name = "cassowary"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
[[package]]
name = "cc"
version = "1.0.79"
@ -1053,9 +1036,6 @@ name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
dependencies = [
"ahash 0.7.6",
]
[[package]]
name = "hashbrown"
@ -1063,7 +1043,7 @@ version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e"
dependencies = [
"ahash 0.8.3",
"ahash",
]
[[package]]
@ -1307,15 +1287,6 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "isolang"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f80f221db1bc708b71128757b9396727c04de86968081e18e89b0575e03be071"
dependencies = [
"phf",
]
[[package]]
name = "itoa"
version = "1.0.8"
@ -1360,29 +1331,6 @@ dependencies = [
"cpufeatures",
]
[[package]]
name = "lazy-regex"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff63c423c68ea6814b7da9e88ce585f793c87ddd9e78f646970891769c8235d4"
dependencies = [
"lazy-regex-proc_macros",
"once_cell",
"regex",
]
[[package]]
name = "lazy-regex-proc_macros"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8edfc11b8f56ce85e207e62ea21557cfa09bb24a8f6b04ae181b086ff8611c22"
dependencies = [
"proc-macro2",
"quote",
"regex",
"syn 1.0.109",
]
[[package]]
name = "lazy_static"
version = "1.4.0"
@ -1736,24 +1684,6 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94"
[[package]]
name = "phf"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc"
dependencies = [
"phf_shared",
]
[[package]]
name = "phf_shared"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b"
dependencies = [
"siphasher",
]
[[package]]
name = "pin-project"
version = "1.1.2"
@ -1969,7 +1899,7 @@ dependencies = [
"similar",
"tempfile",
"thiserror",
"timeago 0.3.1",
"timeago",
"ureq",
"zeroize",
]
@ -2202,21 +2132,6 @@ dependencies = [
"radicle-git-ext",
]
[[package]]
name = "radicle-tui"
version = "0.1.0"
dependencies = [
"anyhow",
"lexopt",
"radicle",
"radicle-cli",
"radicle-surf",
"radicle-term",
"timeago 0.4.1",
"tui-realm-stdlib",
"tuirealm",
]
[[package]]
name = "rand"
version = "0.7.3"
@ -2634,12 +2549,6 @@ version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62bb4feee49fdd9f707ef802e22365a35de4b7b299de4763d44bfea899442ff9"
[[package]]
name = "smawk"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f67ad224767faa3c7d8b6d91985b78e70a1324408abcb1cfcc2be4c06bc06043"
[[package]]
name = "snapbox"
version = "0.4.11"
@ -2853,17 +2762,6 @@ dependencies = [
"redox_termios",
]
[[package]]
name = "textwrap"
version = "0.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7b3e525a49ec206798b40326a44121291b530c963cfb01018f63e135bac543d"
dependencies = [
"smawk",
"unicode-linebreak",
"unicode-width",
]
[[package]]
name = "thiserror"
version = "1.0.43"
@ -2938,16 +2836,6 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ec32dde57efb15c035ac074118d7f32820451395f28cb0524a01d4e94983b26"
[[package]]
name = "timeago"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5082dc942361cdfb74eab98bf995762d6015e5bb3a20bf7c5c71213778b4fcb4"
dependencies = [
"chrono",
"isolang",
]
[[package]]
name = "tinyvec"
version = "1.6.0"
@ -3134,55 +3022,6 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3528ecfd12c466c6f163363caf2d02a71161dd5e1cc6ae7b34207ea2d42d81ed"
[[package]]
name = "tui"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccdd26cbd674007e649a272da4475fb666d3aa0ad0531da7136db6fab0e5bad1"
dependencies = [
"bitflags 1.3.2",
"cassowary",
"termion 1.5.6",
"unicode-segmentation",
"unicode-width",
]
[[package]]
name = "tui-realm-stdlib"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66f252bf8b07c6fd708ddd6349b5f044ae5b488b26929c745728d9c7e2cebfa6"
dependencies = [
"textwrap",
"tuirealm",
"unicode-width",
]
[[package]]
name = "tuirealm"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "265411b5606f400459af94fbc5aae6a7bc0e98094d08cb5868390c932be88e26"
dependencies = [
"bitflags 1.3.2",
"lazy-regex",
"termion 1.5.6",
"thiserror",
"tui",
"tuirealm_derive",
]
[[package]]
name = "tuirealm_derive"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0adcdaf59881626555558eae08f8a53003c8a1961723b4d7a10c51599abbc81"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "typenum"
version = "1.16.0"
@ -3201,16 +3040,6 @@ version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22049a19f4a68748a168c0fc439f9516686aa045927ff767eca0a85101fb6e73"
[[package]]
name = "unicode-linebreak"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5faade31a542b8b35855fff6e8def199853b2da8da256da52f52f1316ee3137"
dependencies = [
"hashbrown 0.12.3",
"regex",
]
[[package]]
name = "unicode-normalization"
version = "0.1.22"

View File

@ -12,7 +12,6 @@ members = [
"radicle-remote-helper",
"radicle-ssh",
"radicle-tools",
"radicle-tui",
]
default-members = [
"radicle",

View File

@ -1,31 +0,0 @@
[package]
name = "radicle-tui"
license = "MIT OR Apache-2.0"
version = "0.1.0"
authors = ["Erik Kundt <erik@zirkular.io>"]
edition = "2021"
build = "../build.rs"
[[bin]]
name = "radicle-tui"
path = "src/main.rs"
[dependencies]
anyhow = { version = "1" }
lexopt = { version = "0.2" }
radicle-surf = { version = "0.14.0" }
timeago = { version = "0.4.1" }
tuirealm = { version = "1.8.0", default-features = false, features = [ "with-termion" ] }
tui-realm-stdlib = { version = "1.2.0", default-features = false, features = [ "with-termion" ] }
[dependencies.radicle]
version = "0"
path = "../radicle"
[dependencies.radicle-cli]
version = "0"
path = "../radicle-cli"
[dependencies.radicle-term]
version = "0"
path = "../radicle-term"

View File

@ -1,208 +0,0 @@
pub mod event;
pub mod page;
pub mod subscription;
use anyhow::Result;
use radicle::cob::issue::IssueId;
use radicle::cob::patch::PatchId;
use radicle::identity::{Id, Project};
use radicle::profile::Profile;
use tuirealm::application::PollStrategy;
use tuirealm::{Application, Frame, NoUserEvent};
use radicle_tui::ui::context::Context;
use radicle_tui::ui::theme::{self, Theme};
use radicle_tui::Tui;
use radicle_tui::{cob, ui};
use page::{HomeView, PatchView};
use self::page::{IssuePage, PageStack};
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
pub enum HomeCid {
Header,
Dashboard,
IssueBrowser,
PatchBrowser,
}
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
pub enum PatchCid {
Header,
Activity,
Files,
}
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
pub enum IssueCid {
Header,
List,
Details,
Shortcuts,
}
/// All component ids known to this application.
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
pub enum Cid {
Home(HomeCid),
Issue(IssueCid),
Patch(PatchCid),
GlobalListener,
}
/// Messages handled by this application.
#[derive(Debug, Eq, PartialEq)]
pub enum HomeMessage {}
#[derive(Debug, Eq, PartialEq)]
pub enum IssueMessage {
Show(IssueId),
Changed(IssueId),
Focus(IssueCid),
Leave,
}
#[derive(Debug, Eq, PartialEq)]
pub enum PatchMessage {
Show(PatchId),
Leave,
}
#[derive(Debug, Eq, PartialEq)]
pub enum Message {
Home(HomeMessage),
Issue(IssueMessage),
Patch(PatchMessage),
NavigationChanged(u16),
Tick,
Quit,
}
#[allow(dead_code)]
pub struct App {
context: Context,
pages: PageStack,
theme: Theme,
quit: bool,
}
/// Creates a new application using a tui-realm-application, mounts all
/// components and sets focus to a default one.
impl App {
pub fn new(profile: Profile, id: Id, project: Project) -> Self {
Self {
context: Context::new(profile, id, project),
pages: PageStack::default(),
theme: theme::default_dark(),
quit: false,
}
}
fn view_home(
&mut self,
app: &mut Application<Cid, Message, NoUserEvent>,
theme: &Theme,
) -> Result<()> {
let home = Box::<HomeView>::default();
self.pages.push(home, app, &self.context, theme)?;
Ok(())
}
fn view_patch(
&mut self,
app: &mut Application<Cid, Message, NoUserEvent>,
id: PatchId,
theme: &Theme,
) -> Result<()> {
let repo = self.context.repository();
if let Some(patch) = cob::patch::find(repo, &id)? {
let view = Box::new(PatchView::new((id, patch)));
self.pages.push(view, app, &self.context, theme)?;
Ok(())
} else {
Err(anyhow::anyhow!(
"Could not mount 'page::PatchView'. Patch not found."
))
}
}
fn view_issue(
&mut self,
app: &mut Application<Cid, Message, NoUserEvent>,
id: IssueId,
theme: &Theme,
) -> Result<()> {
let repo = self.context.repository();
if let Some(issue) = cob::issue::find(repo, &id)? {
let view = Box::new(IssuePage::new((id, issue)));
self.pages.push(view, app, &self.context, theme)?;
Ok(())
} else {
Err(anyhow::anyhow!(
"Could not mount 'page::IssueView'. Issue not found."
))
}
}
}
impl Tui<Cid, Message> for App {
fn init(&mut self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
self.view_home(app, &self.theme.clone())?;
// Add global key listener and subscribe to key events
let global = ui::widget::common::global_listener().to_boxed();
app.mount(Cid::GlobalListener, global, subscription::global())?;
Ok(())
}
fn view(&mut self, app: &mut Application<Cid, Message, NoUserEvent>, frame: &mut Frame) {
if let Ok(page) = self.pages.peek_mut() {
page.view(app, frame);
}
}
fn update(&mut self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<bool> {
match app.tick(PollStrategy::Once) {
Ok(messages) if !messages.is_empty() => {
let theme = theme::default_dark();
for message in messages {
match message {
Message::Issue(IssueMessage::Show(id)) => {
self.view_issue(app, id, &theme)?;
}
Message::Issue(IssueMessage::Leave) => {
self.pages.pop(app)?;
}
Message::Patch(PatchMessage::Show(id)) => {
self.view_patch(app, id, &theme)?;
}
Message::Patch(PatchMessage::Leave) => {
self.pages.pop(app)?;
}
Message::Quit => self.quit = true,
_ => {
self.pages
.peek_mut()?
.update(app, &self.context, &theme, message)?;
}
}
}
Ok(true)
}
_ => Ok(false),
}
}
fn quit(&self) -> bool {
self.quit
}
}

View File

@ -1,197 +0,0 @@
use tuirealm::command::{Cmd, CmdResult, Direction as MoveDirection};
use tuirealm::event::{Event, Key, KeyEvent};
use tuirealm::{MockComponent, NoUserEvent, State, StateValue};
use radicle_tui::ui::widget::common::container::{AppHeader, GlobalListener, LabeledContainer};
use radicle_tui::ui::widget::common::context::{ContextBar, Shortcuts};
use radicle_tui::ui::widget::common::list::PropertyList;
use radicle_tui::ui::widget::home::{Dashboard, IssueBrowser, PatchBrowser};
use radicle_tui::ui::widget::{issue, patch};
use radicle_tui::ui::widget::Widget;
use super::{IssueMessage, Message, PatchMessage};
/// Since the framework does not know the type of messages that are being
/// passed around in the app, the following handlers need to be implemented for
/// each component used.
///
/// TODO: should handle `Event::WindowResize`, which is not emitted by `termion`.
impl tuirealm::Component<Message, NoUserEvent> for Widget<GlobalListener> {
fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
match event {
Event::Keyboard(KeyEvent {
code: Key::Char('q'),
..
}) => Some(Message::Quit),
_ => None,
}
}
}
impl tuirealm::Component<Message, NoUserEvent> for Widget<AppHeader> {
fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
match event {
Event::Keyboard(KeyEvent { code: Key::Tab, .. }) => {
match self.perform(Cmd::Move(MoveDirection::Right)) {
CmdResult::Changed(State::One(StateValue::U16(index))) => {
Some(Message::NavigationChanged(index))
}
_ => None,
}
}
_ => None,
}
}
}
impl tuirealm::Component<Message, NoUserEvent> for Widget<issue::LargeList> {
fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
match event {
Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
Some(Message::Issue(IssueMessage::Leave))
}
Event::Keyboard(KeyEvent { code: Key::Up, .. }) => {
let result = self.perform(Cmd::Move(MoveDirection::Up));
match result {
CmdResult::Changed(State::One(StateValue::Usize(selected))) => {
let item = self.items().get(selected)?;
Some(Message::Issue(IssueMessage::Changed(item.id().to_owned())))
}
_ => None,
}
}
Event::Keyboard(KeyEvent {
code: Key::Down, ..
}) => {
let result = self.perform(Cmd::Move(MoveDirection::Down));
match result {
CmdResult::Changed(State::One(StateValue::Usize(selected))) => {
let item = self.items().get(selected)?;
Some(Message::Issue(IssueMessage::Changed(item.id().to_owned())))
}
_ => None,
}
}
_ => None,
}
}
}
impl tuirealm::Component<Message, NoUserEvent> for Widget<issue::Details> {
fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
None
}
}
impl tuirealm::Component<Message, NoUserEvent> for Widget<PatchBrowser> {
fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
match event {
Event::Keyboard(KeyEvent { code: Key::Up, .. }) => {
self.perform(Cmd::Move(MoveDirection::Up));
Some(Message::Tick)
}
Event::Keyboard(KeyEvent {
code: Key::Down, ..
}) => {
self.perform(Cmd::Move(MoveDirection::Down));
Some(Message::Tick)
}
Event::Keyboard(KeyEvent {
code: Key::Enter, ..
}) => {
let result = self.perform(Cmd::Submit);
match result {
CmdResult::Submit(State::One(StateValue::Usize(selected))) => {
let item = self.items().get(selected)?;
Some(Message::Patch(PatchMessage::Show(item.id().to_owned())))
}
_ => None,
}
}
_ => None,
}
}
}
impl tuirealm::Component<Message, NoUserEvent> for Widget<IssueBrowser> {
fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
match event {
Event::Keyboard(KeyEvent { code: Key::Up, .. }) => {
self.perform(Cmd::Move(MoveDirection::Up));
Some(Message::Tick)
}
Event::Keyboard(KeyEvent {
code: Key::Down, ..
}) => {
self.perform(Cmd::Move(MoveDirection::Down));
Some(Message::Tick)
}
Event::Keyboard(KeyEvent {
code: Key::Enter, ..
}) => {
let result = self.perform(Cmd::Submit);
match result {
CmdResult::Submit(State::One(StateValue::Usize(selected))) => {
let item = self.items().get(selected)?;
Some(Message::Issue(IssueMessage::Show(item.id().to_owned())))
}
_ => None,
}
}
_ => None,
}
}
}
impl tuirealm::Component<Message, NoUserEvent> for Widget<Dashboard> {
fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
None
}
}
impl tuirealm::Component<Message, NoUserEvent> for Widget<patch::Activity> {
fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
match event {
Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
Some(Message::Patch(PatchMessage::Leave))
}
_ => None,
}
}
}
impl tuirealm::Component<Message, NoUserEvent> for Widget<patch::Files> {
fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
match event {
Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
Some(Message::Patch(PatchMessage::Leave))
}
_ => None,
}
}
}
impl tuirealm::Component<Message, NoUserEvent> for Widget<LabeledContainer> {
fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
None
}
}
impl tuirealm::Component<Message, NoUserEvent> for Widget<PropertyList> {
fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
None
}
}
impl tuirealm::Component<Message, NoUserEvent> for Widget<ContextBar> {
fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
None
}
}
impl tuirealm::Component<Message, NoUserEvent> for Widget<Shortcuts> {
fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
None
}
}

View File

@ -1,403 +0,0 @@
use anyhow::Result;
use radicle::cob::issue::{Issue, IssueId};
use radicle::cob::patch::{Patch, PatchId};
use radicle_tui::cob;
use tuirealm::{Frame, NoUserEvent, Sub, SubClause};
use radicle_tui::ui::context::Context;
use radicle_tui::ui::layout;
use radicle_tui::ui::theme::Theme;
use radicle_tui::ui::widget;
use super::{subscription, Application, Cid, HomeCid, IssueCid, IssueMessage, Message, PatchCid};
/// `tuirealm`'s event and prop system is designed to work with flat component hierarchies.
/// Building deep nested component hierarchies would need a lot more additional effort to
/// properly pass events and props down these hierarchies. This makes it hard to implement
/// full app views (home, patch details etc) as components.
///
/// View pages take into account these flat component hierarchies, and provide
/// switchable sets of components.
pub trait ViewPage {
/// Will be called whenever a view page is pushed onto the page stack. Should create and mount all widgets.
fn mount(
&self,
app: &mut Application<Cid, Message, NoUserEvent>,
context: &Context,
theme: &Theme,
) -> Result<()>;
/// Will be called whenever a view page is popped from the page stack. Should unmount all widgets.
fn unmount(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()>;
/// Will be called whenever a view page is on top of the stack and can be used to update its internal
/// state depending on the message passed.
fn update(
&mut self,
app: &mut Application<Cid, Message, NoUserEvent>,
context: &Context,
theme: &Theme,
message: Message,
) -> Result<()>;
/// Will be called whenever a view page is on top of the page stack and needs to be rendered.
fn view(&mut self, app: &mut Application<Cid, Message, NoUserEvent>, frame: &mut Frame);
/// Will be called whenever this view page is pushed to the stack, or it is on top of the stack again
/// after another view page was popped from the stack.
fn subscribe(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()>;
/// Will be called whenever this view page is on top of the stack and another view page is pushed
/// to the stack, or if this is popped from the stack.
fn unsubscribe(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()>;
}
///
/// Home
///
pub struct HomeView {
active_component: Cid,
}
impl Default for HomeView {
fn default() -> Self {
HomeView {
active_component: Cid::Home(HomeCid::Dashboard),
}
}
}
impl ViewPage for HomeView {
fn mount(
&self,
app: &mut Application<Cid, Message, NoUserEvent>,
context: &Context,
theme: &Theme,
) -> Result<()> {
let navigation = widget::home::navigation(theme);
let header = widget::common::app_header(context, theme, Some(navigation)).to_boxed();
let dashboard = widget::home::dashboard(context, theme).to_boxed();
let issue_browser = widget::home::issues(context, theme).to_boxed();
let patch_browser = widget::home::patches(context, theme).to_boxed();
app.remount(Cid::Home(HomeCid::Header), header, vec![])?;
app.remount(Cid::Home(HomeCid::Dashboard), dashboard, vec![])?;
app.remount(Cid::Home(HomeCid::IssueBrowser), issue_browser, vec![])?;
app.remount(Cid::Home(HomeCid::PatchBrowser), patch_browser, vec![])?;
app.active(&self.active_component)?;
Ok(())
}
fn unmount(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
app.umount(&Cid::Home(HomeCid::Header))?;
app.umount(&Cid::Home(HomeCid::Dashboard))?;
app.umount(&Cid::Home(HomeCid::IssueBrowser))?;
app.umount(&Cid::Home(HomeCid::PatchBrowser))?;
Ok(())
}
fn update(
&mut self,
app: &mut Application<Cid, Message, NoUserEvent>,
_context: &Context,
_theme: &Theme,
message: Message,
) -> Result<()> {
if let Message::NavigationChanged(index) = message {
self.active_component = Cid::Home(HomeCid::from(index as usize));
app.active(&self.active_component)?;
}
Ok(())
}
fn view(&mut self, app: &mut Application<Cid, Message, NoUserEvent>, frame: &mut Frame) {
let area = frame.size();
let layout = layout::default_page(area);
app.view(&Cid::Home(HomeCid::Header), frame, layout[0]);
app.view(&self.active_component, frame, layout[1]);
}
fn subscribe(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
app.subscribe(
&Cid::Home(HomeCid::Header),
Sub::new(subscription::navigation_clause(), SubClause::Always),
)?;
Ok(())
}
fn unsubscribe(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
app.unsubscribe(
&Cid::Home(HomeCid::Header),
subscription::navigation_clause(),
)?;
Ok(())
}
}
///
/// Issue detail page
///
pub struct IssuePage {
issue: (IssueId, Issue),
}
impl IssuePage {
pub fn new(issue: (IssueId, Issue)) -> Self {
IssuePage { issue }
}
}
impl ViewPage for IssuePage {
fn mount(
&self,
app: &mut Application<Cid, Message, NoUserEvent>,
context: &Context,
theme: &Theme,
) -> Result<()> {
let (id, issue) = &self.issue;
let header = widget::common::app_header(context, theme, None).to_boxed();
let list = widget::issue::list(context, theme, (*id, issue.clone())).to_boxed();
let details = widget::issue::details(context, theme, (*id, issue.clone())).to_boxed();
let shortcuts = widget::common::shortcuts(
theme,
vec![
widget::common::shortcut(theme, "esc", "back"),
widget::common::shortcut(theme, "q", "quit"),
],
)
.to_boxed();
app.remount(Cid::Issue(IssueCid::Header), header, vec![])?;
app.remount(Cid::Issue(IssueCid::List), list, vec![])?;
app.remount(Cid::Issue(IssueCid::Details), details, vec![])?;
app.remount(Cid::Issue(IssueCid::Shortcuts), shortcuts, vec![])?;
app.active(&Cid::Issue(IssueCid::List))?;
Ok(())
}
fn unmount(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
app.umount(&Cid::Issue(IssueCid::Header))?;
app.umount(&Cid::Issue(IssueCid::List))?;
app.umount(&Cid::Issue(IssueCid::Details))?;
app.umount(&Cid::Issue(IssueCid::Shortcuts))?;
Ok(())
}
fn update(
&mut self,
app: &mut Application<Cid, Message, NoUserEvent>,
context: &Context,
theme: &Theme,
message: Message,
) -> Result<()> {
match message {
Message::Issue(IssueMessage::Changed(id)) => {
let repo = context.repository();
if let Some(issue) = cob::issue::find(repo, &id)? {
let details = widget::issue::details(context, theme, (id, issue)).to_boxed();
app.remount(Cid::Issue(IssueCid::Details), details, vec![])?;
}
}
Message::Issue(IssueMessage::Focus(cid)) => {
app.active(&Cid::Issue(cid))?;
}
_ => {}
}
Ok(())
}
fn view(&mut self, app: &mut Application<Cid, Message, NoUserEvent>, frame: &mut Frame) {
let area = frame.size();
let shortcuts_h = 1u16;
let layout = layout::issue_preview(area, shortcuts_h);
app.view(&Cid::Issue(IssueCid::Header), frame, layout.header);
app.view(&Cid::Issue(IssueCid::List), frame, layout.list);
app.view(&Cid::Issue(IssueCid::Details), frame, layout.details);
app.view(&Cid::Issue(IssueCid::Shortcuts), frame, layout.shortcuts);
}
fn subscribe(&self, _app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
Ok(())
}
fn unsubscribe(&self, _app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
Ok(())
}
}
///
/// Patch detail page
///
pub struct PatchView {
active_component: Cid,
patch: (PatchId, Patch),
}
impl PatchView {
pub fn new(patch: (PatchId, Patch)) -> Self {
PatchView {
active_component: Cid::Patch(PatchCid::Activity),
patch,
}
}
}
impl ViewPage for PatchView {
fn mount(
&self,
app: &mut Application<Cid, Message, NoUserEvent>,
context: &Context,
theme: &Theme,
) -> Result<()> {
let (id, patch) = &self.patch;
let navigation = widget::patch::navigation(theme);
let header = widget::common::app_header(context, theme, Some(navigation)).to_boxed();
let activity = widget::patch::activity(theme, (*id, patch), context.profile()).to_boxed();
let files = widget::patch::files(theme, (*id, patch), context.profile()).to_boxed();
app.remount(Cid::Patch(PatchCid::Header), header, vec![])?;
app.remount(Cid::Patch(PatchCid::Activity), activity, vec![])?;
app.remount(Cid::Patch(PatchCid::Files), files, vec![])?;
app.active(&self.active_component)?;
Ok(())
}
fn unmount(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
app.umount(&Cid::Patch(PatchCid::Header))?;
app.umount(&Cid::Patch(PatchCid::Activity))?;
app.umount(&Cid::Patch(PatchCid::Files))?;
Ok(())
}
fn update(
&mut self,
app: &mut Application<Cid, Message, NoUserEvent>,
_context: &Context,
_theme: &Theme,
message: Message,
) -> Result<()> {
if let Message::NavigationChanged(index) = message {
self.active_component = Cid::Patch(PatchCid::from(index as usize));
}
app.active(&self.active_component)?;
Ok(())
}
fn view(&mut self, app: &mut Application<Cid, Message, NoUserEvent>, frame: &mut Frame) {
let area = frame.size();
let layout = layout::default_page(area);
app.view(&Cid::Patch(PatchCid::Header), frame, layout[0]);
app.view(&self.active_component, frame, layout[1]);
}
fn subscribe(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
app.subscribe(
&Cid::Patch(PatchCid::Header),
Sub::new(subscription::navigation_clause(), SubClause::Always),
)?;
Ok(())
}
fn unsubscribe(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
app.unsubscribe(
&Cid::Patch(PatchCid::Header),
subscription::navigation_clause(),
)?;
Ok(())
}
}
/// View pages need to preserve their state (e.g. selected navigation tab, contents
/// and the selected row of a table). Therefor they should not be (re-)created
/// each time they are displayed.
/// Instead the application can push a new page onto the page stack if it needs to
/// be displayed. Its components are then created using the internal state. If a
/// new page needs to be displayed, it will also be pushed onto the stack. Leaving
/// that page again will pop it from the stack. The application can then return to
/// the previously displayed page in the state it was left.
#[derive(Default)]
pub struct PageStack {
pages: Vec<Box<dyn ViewPage>>,
}
impl PageStack {
pub fn push(
&mut self,
page: Box<dyn ViewPage>,
app: &mut Application<Cid, Message, NoUserEvent>,
context: &Context,
theme: &Theme,
) -> Result<()> {
if let Some(page) = self.pages.last() {
page.unsubscribe(app)?;
}
page.mount(app, context, theme)?;
page.subscribe(app)?;
self.pages.push(page);
Ok(())
}
pub fn pop(&mut self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
self.peek_mut()?.unsubscribe(app)?;
self.peek_mut()?.unmount(app)?;
self.pages.pop();
self.peek_mut()?.subscribe(app)?;
Ok(())
}
pub fn peek_mut(&mut self) -> Result<&mut Box<dyn ViewPage>> {
match self.pages.last_mut() {
Some(page) => Ok(page),
None => Err(anyhow::anyhow!(
"Could not peek active page. Page stack is empty."
)),
}
}
}
impl From<usize> for HomeCid {
fn from(index: usize) -> Self {
match index {
0 => HomeCid::Dashboard,
1 => HomeCid::IssueBrowser,
2 => HomeCid::PatchBrowser,
_ => HomeCid::Dashboard,
}
}
}
impl From<usize> for PatchCid {
fn from(index: usize) -> Self {
match index {
0 => PatchCid::Activity,
1 => PatchCid::Files,
_ => PatchCid::Activity,
}
}
}

View File

@ -1,31 +0,0 @@
use std::hash::Hash;
use tuirealm::event::{Key, KeyEvent, KeyModifiers};
use tuirealm::{Sub, SubClause, SubEventClause};
pub fn navigation_clause<UserEvent>() -> SubEventClause<UserEvent>
where
UserEvent: Clone + Eq + PartialEq + PartialOrd,
{
SubEventClause::Keyboard(KeyEvent {
code: Key::Tab,
modifiers: KeyModifiers::NONE,
})
}
pub fn global<Id, UserEvent>() -> Vec<Sub<Id, UserEvent>>
where
Id: Clone + Hash + Eq + PartialEq,
UserEvent: Clone + Eq + PartialEq + PartialOrd,
{
vec![
Sub::new(
SubEventClause::Keyboard(KeyEvent {
code: Key::Char('q'),
modifiers: KeyModifiers::NONE,
}),
SubClause::Always,
),
Sub::new(SubEventClause::WindowResize, SubClause::Always),
]
}

View File

@ -1,2 +0,0 @@
pub mod issue;
pub mod patch;

View File

@ -1,19 +0,0 @@
use anyhow::Result;
use radicle::cob::issue::{Issue, IssueId, Issues};
use radicle::storage::git::Repository;
pub fn all(repository: &Repository) -> Result<Vec<(IssueId, Issue)>> {
let patches = Issues::open(repository)?
.all()
.map(|iter| iter.flatten().collect::<Vec<_>>())?;
Ok(patches
.into_iter()
.map(|(id, issue)| (id, issue))
.collect::<Vec<_>>())
}
pub fn find(repository: &Repository, id: &IssueId) -> Result<Option<Issue>> {
let issues = Issues::open(repository)?;
Ok(issues.get(id)?)
}

View File

@ -1,20 +0,0 @@
use anyhow::Result;
use radicle::cob::patch::{Patch, PatchId, Patches};
use radicle::storage::git::Repository;
pub fn all(repository: &Repository) -> Result<Vec<(PatchId, Patch)>> {
let patches = Patches::open(repository)?
.all()
.map(|iter| iter.flatten().collect::<Vec<_>>())?;
Ok(patches
.into_iter()
.map(|(id, patch)| (id, patch))
.collect::<Vec<_>>())
}
pub fn find(repository: &Repository, id: &PatchId) -> Result<Option<Patch>> {
let patches = Patches::open(repository)?;
Ok(patches.get(id)?)
}

View File

@ -1,98 +0,0 @@
use std::hash::Hash;
use std::time::Duration;
use anyhow::Result;
use tuirealm::terminal::TerminalBridge;
use tuirealm::tui::layout::Rect;
use tuirealm::Frame;
use tuirealm::{Application, EventListenerCfg, NoUserEvent};
pub mod cob;
pub mod ui;
/// Trait that must be implemented by client applications in order to be run
/// as tui-application using tui-realm. Implementors act as models to the
/// tui-realm application that can be polled for new messages, updated
/// accordingly and rendered with new state.
///
/// Please see `examples/` for further information on how to use it.
pub trait Tui<Id, Message>
where
Id: Eq + PartialEq + Clone + Hash,
Message: Eq,
{
/// Should initialize an application by mounting and activating components.
fn init(&mut self, app: &mut Application<Id, Message, NoUserEvent>) -> Result<()>;
/// Should update the current state by handling a message from the view. Returns true
/// if view should be updated (e.g. a message was received and the current state changed).
fn update(&mut self, app: &mut Application<Id, Message, NoUserEvent>) -> Result<bool>;
/// Should draw the application to a frame.
fn view(&mut self, app: &mut Application<Id, Message, NoUserEvent>, frame: &mut Frame);
/// Should return true if the application is requested to quit.
fn quit(&self) -> bool;
}
/// A tui-window using the cross-platform Terminal helper provided
/// by tui-realm.
pub struct Window {
/// Helper around `Terminal` to quickly setup and perform on terminal.
pub terminal: TerminalBridge,
}
impl Default for Window {
fn default() -> Self {
Self::new()
}
}
/// Provides a way to create and run a new tui-application.
impl Window {
/// Creates a tui-window using the default cross-platform Terminal
/// helper and panics if its creation fails.
pub fn new() -> Self {
Self {
terminal: TerminalBridge::new().expect("Cannot create terminal bridge"),
}
}
/// Runs this tui-window with the tui-application given and performs the
/// following steps:
/// 1. Enter alternative terminal screen
/// 2. Run main loop until application should quit and with each iteration
/// - poll new events (tick or user event)
/// - update application state
/// - redraw view
/// 3. Leave alternative terminal screen
pub fn run<T, Id, Message>(&mut self, tui: &mut T, interval: u64) -> Result<()>
where
T: Tui<Id, Message>,
Id: Eq + PartialEq + Clone + Hash,
Message: Eq,
{
let mut update = true;
let mut resize = false;
let mut size = Rect::default();
let mut app = Application::init(
EventListenerCfg::default().default_input_listener(Duration::from_millis(interval)),
);
tui.init(&mut app)?;
while !tui.quit() {
if update || resize {
self.terminal
.raw_mut()
.draw(|frame| tui.view(&mut app, frame))?;
}
update = tui.update(&mut app)?;
resize = size != self.terminal.raw().size()?;
size = self.terminal.raw().size()?;
}
Ok(())
}
}

View File

@ -1,84 +0,0 @@
use std::process;
use anyhow::{anyhow, Context};
use radicle::storage::ReadStorage;
use radicle_cli as cli;
use radicle_term as term;
use radicle_tui::Window;
mod app;
pub const NAME: &str = "radicle-tui";
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const GIT_HEAD: &str = env!("GIT_HEAD");
pub const FPS: u64 = 60;
pub const HELP: &str = r#"
Usage
radicle-tui [<option>...]
Options
--version Print version
--help Print help
"#;
struct Options;
impl Options {
fn from_env() -> Result<Self, anyhow::Error> {
use lexopt::prelude::*;
let mut parser = lexopt::Parser::from_env();
while let Some(arg) = parser.next()? {
match arg {
Long("version") => {
println!("{NAME} {VERSION}+{GIT_HEAD}");
process::exit(0);
}
Long("help") | Short('h') => {
println!("{HELP}");
process::exit(0);
}
_ => anyhow::bail!(arg.unexpected()),
}
}
Ok(Self {})
}
}
fn execute() -> anyhow::Result<()> {
let _ = Options::from_env()?;
let (_, id) = radicle::rad::cwd()
.map_err(|_| anyhow!("this command must be run in the context of a project"))?;
let profile = cli::terminal::profile()?;
let signer = cli::terminal::signer(&profile)?;
let storage = &profile.storage;
let payload = storage
.get(signer.public_key(), id)?
.context("No project with such `id` exists")?;
let project = payload.project()?;
let mut window = Window::default();
window.run(&mut app::App::new(profile, id, project), 1000 / FPS)?;
Ok(())
}
fn main() {
if let Err(err) = execute() {
term::error(format!("Error: rad-tui: {err}"));
process::exit(1);
}
}

View File

@ -1,7 +0,0 @@
pub mod cob;
pub mod context;
pub mod ext;
pub mod layout;
pub mod state;
pub mod theme;
pub mod widget;

View File

@ -1,361 +0,0 @@
use radicle_surf;
use cli::terminal::format;
use radicle_cli as cli;
use radicle::prelude::Did;
use radicle::storage::git::Repository;
use radicle::storage::{Oid, ReadRepository};
use radicle::Profile;
use radicle::cob::issue::{Issue, IssueId, State as IssueState};
use radicle::cob::patch::{Patch, PatchId, State as PatchState};
use radicle::cob::{Label, Timestamp};
use tuirealm::props::{Color, Style};
use tuirealm::tui::text::{Span, Spans};
use tuirealm::tui::widgets::Cell;
use crate::ui::theme::Theme;
use crate::ui::widget::common::list::TableItem;
use super::widget::common::list::ListItem;
/// An author item that can be used in tables, list or trees.
///
/// Breaks up dependencies to [`Profile`] and [`Repository`] that
/// would be needed if [`AuthorItem`] would be used directly.
#[derive(Clone)]
pub struct AuthorItem {
/// The author's DID.
did: Did,
/// True if the author is the current user.
is_you: bool,
}
impl AuthorItem {
pub fn did(&self) -> Did {
self.did
}
pub fn is_you(&self) -> bool {
self.is_you
}
}
/// A patch item that can be used in tables, list or trees.
///
/// Breaks up dependencies to [`Profile`] and [`Repository`] that
/// would be needed if [`Patch`] would be used directly.
#[derive(Clone)]
pub struct PatchItem {
/// Patch OID.
id: PatchId,
/// Patch state.
state: PatchState,
/// Patch title.
title: String,
/// Author of the latest revision.
author: AuthorItem,
/// Head of the latest revision.
head: Oid,
/// Lines added by the latest revision.
added: u16,
/// Lines removed by the latest revision.
removed: u16,
/// Time when patch was opened.
timestamp: Timestamp,
}
impl PatchItem {
pub fn id(&self) -> &PatchId {
&self.id
}
pub fn state(&self) -> &PatchState {
&self.state
}
pub fn title(&self) -> &String {
&self.title
}
pub fn author(&self) -> &AuthorItem {
&self.author
}
pub fn head(&self) -> &Oid {
&self.head
}
pub fn added(&self) -> u16 {
self.added
}
pub fn removed(&self) -> u16 {
self.removed
}
pub fn timestamp(&self) -> &Timestamp {
&self.timestamp
}
}
impl TryFrom<(&Profile, &Repository, PatchId, Patch)> for PatchItem {
type Error = anyhow::Error;
fn try_from(value: (&Profile, &Repository, PatchId, Patch)) -> Result<Self, Self::Error> {
let (profile, repo, id, patch) = value;
let (_, rev) = patch.latest();
let repo = radicle_surf::Repository::open(repo.path())?;
let base = repo.commit(rev.base())?;
let head = repo.commit(rev.head())?;
let diff = repo.diff(base.id, head.id)?;
Ok(PatchItem {
id,
state: patch.state().clone(),
title: patch.title().into(),
author: AuthorItem {
did: patch.author().id,
is_you: *patch.author().id == *profile.did(),
},
head: rev.head(),
added: diff.stats().insertions as u16,
removed: diff.stats().deletions as u16,
timestamp: rev.timestamp(),
})
}
}
impl TableItem<8> for PatchItem {
fn row(&self, theme: &Theme) -> [Cell; 8] {
let (icon, color) = format_patch_state(&self.state);
let state = Cell::from(icon).style(Style::default().fg(color));
let id = Cell::from(format::cob(&self.id))
.style(Style::default().fg(theme.colors.browser_list_id));
let title = Cell::from(self.title.clone())
.style(Style::default().fg(theme.colors.browser_list_title));
let author = Cell::from(format_author(&self.author.did, self.author.is_you))
.style(Style::default().fg(theme.colors.browser_list_author));
let head = Cell::from(format::oid(self.head).item)
.style(Style::default().fg(theme.colors.browser_patch_list_head));
let added = Cell::from(format!("{}", self.added))
.style(Style::default().fg(theme.colors.browser_patch_list_added));
let removed = Cell::from(format!("{}", self.removed))
.style(Style::default().fg(theme.colors.browser_patch_list_removed));
let updated = Cell::from(format::timestamp(&self.timestamp).to_string())
.style(Style::default().fg(theme.colors.browser_list_timestamp));
[state, id, title, author, head, added, removed, updated]
}
}
/// An issue item that can be used in tables, list or trees.
///
/// Breaks up dependencies to [`Profile`] and [`Repository`] that
/// would be needed if [`Issue`] would be used directly.
#[derive(Clone)]
pub struct IssueItem {
/// Issue OID.
id: IssueId,
/// Issue state.
state: IssueState,
/// Issue title.
title: String,
/// Issue author.
author: AuthorItem,
/// Issue labels.
labels: Vec<Label>,
/// Issue assignees.
assignees: Vec<AuthorItem>,
/// Time when issue was opened.
timestamp: Timestamp,
}
impl IssueItem {
pub fn id(&self) -> &IssueId {
&self.id
}
pub fn state(&self) -> &IssueState {
&self.state
}
pub fn title(&self) -> &String {
&self.title
}
pub fn author(&self) -> &AuthorItem {
&self.author
}
pub fn labels(&self) -> &Vec<Label> {
&self.labels
}
pub fn assignees(&self) -> &Vec<AuthorItem> {
&self.assignees
}
pub fn timestamp(&self) -> &Timestamp {
&self.timestamp
}
}
impl From<(&Profile, &Repository, IssueId, Issue)> for IssueItem {
fn from(value: (&Profile, &Repository, IssueId, Issue)) -> Self {
let (profile, _, id, issue) = value;
IssueItem {
id,
state: *issue.state(),
title: issue.title().into(),
author: AuthorItem {
did: issue.author().id,
is_you: *issue.author().id == *profile.did(),
},
labels: issue.labels().cloned().collect(),
assignees: issue
.assigned()
.cloned()
.map(|did| AuthorItem {
did,
is_you: did == profile.did(),
})
.collect::<Vec<_>>(),
timestamp: issue.timestamp(),
}
}
}
impl TableItem<7> for IssueItem {
fn row(&self, theme: &Theme) -> [Cell; 7] {
let (icon, color) = format_issue_state(&self.state);
let state = Cell::from(icon).style(Style::default().fg(color));
let id = Cell::from(format::cob(&self.id))
.style(Style::default().fg(theme.colors.browser_list_id));
let title = Cell::from(self.title.clone())
.style(Style::default().fg(theme.colors.browser_list_title));
let author = Cell::from(format_author(&self.author.did, self.author.is_you))
.style(Style::default().fg(theme.colors.browser_list_author));
let tags = Cell::from(format_labels(&self.labels))
.style(Style::default().fg(theme.colors.browser_list_tags));
let assignees = self
.assignees
.iter()
.map(|author| (author.did, author.is_you))
.collect::<Vec<_>>();
let assignees = Cell::from(format_assignees(&assignees))
.style(Style::default().fg(theme.colors.browser_list_author));
let opened = Cell::from(format::timestamp(&self.timestamp).to_string())
.style(Style::default().fg(theme.colors.browser_list_timestamp));
[state, id, title, author, tags, assignees, opened]
}
}
impl ListItem for IssueItem {
fn row(&self, theme: &Theme) -> tuirealm::tui::widgets::ListItem {
let (state, state_color) = format_issue_state(&self.state);
let lines = vec![
Spans::from(vec![
Span::styled(state, Style::default().fg(state_color)),
Span::styled(
self.title.clone(),
Style::default().fg(theme.colors.browser_list_title),
),
]),
Spans::from(vec![
Span::raw(String::from(" ")),
Span::styled(
format_author(&self.author.did, self.author.is_you),
Style::default().fg(theme.colors.browser_list_author),
),
Span::styled(
format!(" {} ", theme.icons.property_divider),
Style::default().fg(theme.colors.property_divider_fg),
),
Span::styled(
format::timestamp(&self.timestamp).to_string(),
Style::default().fg(theme.colors.browser_list_timestamp),
),
]),
];
tuirealm::tui::widgets::ListItem::new(lines)
}
}
impl PartialEq for IssueItem {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
pub fn format_patch_state(state: &PatchState) -> (String, Color) {
match state {
PatchState::Open { conflicts: _ } => ("".into(), Color::Green),
PatchState::Archived => ("".into(), Color::Yellow),
PatchState::Draft => ("".into(), Color::Gray),
PatchState::Merged {
revision: _,
commit: _,
} => ("".into(), Color::Blue),
}
}
pub fn format_author(did: &Did, is_you: bool) -> String {
if is_you {
format!("{} (you)", format::did(did))
} else {
format!("{}", format::did(did))
}
}
pub fn format_issue_state(state: &IssueState) -> (String, Color) {
match state {
IssueState::Open => ("".into(), Color::Green),
IssueState::Closed { reason: _ } => ("".into(), Color::Red),
}
}
pub fn format_labels(labels: &[Label]) -> String {
let mut output = String::new();
let mut labels = labels.iter().peekable();
while let Some(tag) = labels.next() {
output.push_str(&tag.to_string());
if labels.peek().is_some() {
output.push(',');
}
}
output
}
pub fn format_assignees(assignees: &[(Did, bool)]) -> String {
let mut output = String::new();
let mut assignees = assignees.iter().peekable();
while let Some((assignee, is_you)) = assignees.next() {
output.push_str(&format_author(assignee, *is_you));
if assignees.peek().is_some() {
output.push(',');
}
}
output
}

View File

@ -1,40 +0,0 @@
use radicle::prelude::{Id, Project};
use radicle::Profile;
use radicle::storage::git::Repository;
use radicle::storage::ReadStorage;
pub struct Context {
profile: Profile,
id: Id,
project: Project,
repository: Repository,
}
impl Context {
pub fn new(profile: Profile, id: Id, project: Project) -> Self {
let repository = profile.storage.repository(id).unwrap();
Self {
id,
profile,
project,
repository,
}
}
pub fn profile(&self) -> &Profile {
&self.profile
}
pub fn id(&self) -> &Id {
&self.id
}
pub fn project(&self) -> &Project {
&self.project
}
pub fn repository(&self) -> &Repository {
&self.repository
}
}

View File

@ -1,113 +0,0 @@
use tuirealm::tui::buffer::Buffer;
use tuirealm::tui::layout::Rect;
use tuirealm::tui::style::Style;
use tuirealm::tui::widgets::{BorderType, Borders, Widget};
pub struct HeaderBlock {
/// Visible borders
borders: Borders,
/// Border style
border_style: Style,
/// Type of the border. The default is plain lines but one can choose to have rounded corners
/// or doubled lines instead.
border_type: BorderType,
/// Widget style
style: Style,
}
impl Default for HeaderBlock {
fn default() -> HeaderBlock {
HeaderBlock {
borders: Borders::NONE,
border_style: Default::default(),
border_type: BorderType::Plain,
style: Default::default(),
}
}
}
impl HeaderBlock {
pub fn border_style(mut self, style: Style) -> HeaderBlock {
self.border_style = style;
self
}
pub fn style(mut self, style: Style) -> HeaderBlock {
self.style = style;
self
}
pub fn borders(mut self, flag: Borders) -> HeaderBlock {
self.borders = flag;
self
}
pub fn border_type(mut self, border_type: BorderType) -> HeaderBlock {
self.border_type = border_type;
self
}
}
impl Widget for HeaderBlock {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.area() == 0 {
return;
}
buf.set_style(area, self.style);
let symbols = BorderType::line_symbols(self.border_type);
// Sides
if self.borders.intersects(Borders::LEFT) {
for y in area.top()..area.bottom() {
buf.get_mut(area.left(), y)
.set_symbol(symbols.vertical)
.set_style(self.border_style);
}
}
if self.borders.intersects(Borders::TOP) {
for x in area.left()..area.right() {
buf.get_mut(x, area.top())
.set_symbol(symbols.horizontal)
.set_style(self.border_style);
}
}
if self.borders.intersects(Borders::RIGHT) {
let x = area.right() - 1;
for y in area.top()..area.bottom() {
buf.get_mut(x, y)
.set_symbol(symbols.vertical)
.set_style(self.border_style);
}
}
if self.borders.intersects(Borders::BOTTOM) {
let y = area.bottom() - 1;
for x in area.left()..area.right() {
buf.get_mut(x, y)
.set_symbol(symbols.horizontal)
.set_style(self.border_style);
}
}
// Corners
if self.borders.contains(Borders::RIGHT | Borders::BOTTOM) {
buf.get_mut(area.right() - 1, area.bottom() - 1)
.set_symbol(symbols.vertical_left)
.set_style(self.border_style);
}
if self.borders.contains(Borders::RIGHT | Borders::TOP) {
buf.get_mut(area.right() - 1, area.top())
.set_symbol(symbols.top_right)
.set_style(self.border_style);
}
if self.borders.contains(Borders::LEFT | Borders::BOTTOM) {
buf.get_mut(area.left(), area.bottom() - 1)
.set_symbol(symbols.vertical_right)
.set_style(self.border_style);
}
if self.borders.contains(Borders::LEFT | Borders::TOP) {
buf.get_mut(area.left(), area.top())
.set_symbol(symbols.top_left)
.set_style(self.border_style);
}
}
}

View File

@ -1,210 +0,0 @@
use tuirealm::props::{AttrValue, Attribute};
use tuirealm::tui::layout::{Constraint, Direction, Layout, Rect};
use tuirealm::MockComponent;
pub struct AppHeader {
pub nav: Rect,
pub info: Rect,
pub line: Rect,
}
pub struct IssuePreview {
pub header: Rect,
pub list: Rect,
pub details: Rect,
pub discussion: Rect,
pub shortcuts: Rect,
}
pub fn v_stack(
widgets: Vec<Box<dyn MockComponent>>,
area: Rect,
) -> Vec<(Box<dyn MockComponent>, Rect)> {
let constraints = widgets
.iter()
.map(|w| {
Constraint::Length(
w.query(Attribute::Height)
.unwrap_or(AttrValue::Size(0))
.unwrap_size(),
)
})
.collect::<Vec<_>>();
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(area);
widgets.into_iter().zip(layout.into_iter()).collect()
}
pub fn h_stack(
widgets: Vec<Box<dyn MockComponent>>,
area: Rect,
) -> Vec<(Box<dyn MockComponent>, Rect)> {
let constraints = widgets
.iter()
.map(|w| {
Constraint::Length(
w.query(Attribute::Width)
.unwrap_or(AttrValue::Size(0))
.unwrap_size(),
)
})
.collect::<Vec<_>>();
let layout = Layout::default()
.direction(Direction::Horizontal)
.constraints(constraints)
.split(area);
widgets.into_iter().zip(layout.into_iter()).collect()
}
pub fn app_header(area: Rect, info_w: u16) -> AppHeader {
let nav_w = area.width.saturating_sub(info_w);
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints(vec![
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
])
.split(area);
let top = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(nav_w), Constraint::Length(info_w)].as_ref())
.split(layout[1]);
AppHeader {
nav: top[0],
info: top[1],
line: layout[2],
}
}
pub fn default_page(area: Rect) -> Vec<Rect> {
let nav_h = 3u16;
let margin_h = 1u16;
let content_h = area.height.saturating_sub(nav_h.saturating_add(margin_h));
Layout::default()
.direction(Direction::Vertical)
.horizontal_margin(margin_h)
.constraints([Constraint::Length(nav_h), Constraint::Length(content_h)].as_ref())
.split(area)
}
pub fn headerless_page(area: Rect) -> Vec<Rect> {
let margin_h = 1u16;
let content_h = area.height.saturating_sub(margin_h);
Layout::default()
.direction(Direction::Vertical)
.horizontal_margin(margin_h)
.constraints([Constraint::Length(content_h)].as_ref())
.split(area)
}
pub fn root_component(area: Rect, shortcuts_h: u16) -> Vec<Rect> {
let content_h = area.height.saturating_sub(shortcuts_h);
Layout::default()
.direction(Direction::Vertical)
.constraints(
[
Constraint::Length(content_h),
Constraint::Length(shortcuts_h),
]
.as_ref(),
)
.split(area)
}
pub fn root_component_with_context(area: Rect, context_h: u16, shortcuts_h: u16) -> Vec<Rect> {
let content_h = area
.height
.saturating_sub(shortcuts_h.saturating_add(context_h));
Layout::default()
.direction(Direction::Vertical)
.constraints(
[
Constraint::Length(content_h),
Constraint::Length(context_h),
Constraint::Length(shortcuts_h),
]
.as_ref(),
)
.split(area)
}
pub fn centered_label(label_w: u16, area: Rect) -> Rect {
let label_h = 1u16;
let spacer_w = area.width.saturating_sub(label_w).saturating_div(2);
let spacer_h = area.height.saturating_sub(label_h).saturating_div(2);
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints(
[
Constraint::Length(spacer_h),
Constraint::Length(label_h),
Constraint::Length(spacer_h),
]
.as_ref(),
)
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints(
[
Constraint::Length(spacer_w),
Constraint::Length(label_w),
Constraint::Length(spacer_w),
]
.as_ref(),
)
.split(layout[1])[1]
}
pub fn issue_preview(area: Rect, shortcuts_h: u16) -> IssuePreview {
let header_h = 3u16;
let content_h = area
.height
.saturating_sub(header_h)
.saturating_sub(shortcuts_h);
let root = Layout::default()
.direction(Direction::Vertical)
.horizontal_margin(1)
.constraints(
[
Constraint::Length(header_h),
Constraint::Length(content_h),
Constraint::Length(shortcuts_h),
]
.as_ref(),
)
.split(area);
let split = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
.split(root[1]);
let right = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(6), Constraint::Min(0)].as_ref())
.split(split[1]);
IssuePreview {
header: root[0],
list: split[0],
details: right[0],
discussion: right[1],
shortcuts: root[2],
}
}

View File

@ -1,85 +0,0 @@
use tuirealm::tui::widgets::{ListState, TableState};
/// State that holds the index of a selected tab item and the count of all tab items.
/// The index can be increased and will start at 0, if length was reached.
#[derive(Clone, Default)]
pub struct TabState {
pub selected: u16,
pub len: u16,
}
impl TabState {
pub fn incr_tab_index(&mut self, rewind: bool) {
if self.selected + 1 < self.len {
self.selected += 1;
} else if rewind {
self.selected = 0;
}
}
}
#[derive(Clone)]
pub struct ItemState {
selected: Option<usize>,
len: usize,
}
impl ItemState {
pub fn new(selected: Option<usize>, len: usize) -> Self {
Self { selected, len }
}
pub fn selected(&self) -> Option<usize> {
self.selected
}
pub fn select_previous(&mut self) -> Option<usize> {
let old_index = self.selected();
let new_index = match old_index {
Some(selected) if selected == 0 => Some(0),
Some(selected) => Some(selected.saturating_sub(1)),
None => Some(0),
};
if old_index != new_index {
self.selected = new_index;
self.selected()
} else {
None
}
}
pub fn select_next(&mut self) -> Option<usize> {
let old_index = self.selected();
let new_index = match old_index {
Some(selected) if selected >= self.len.saturating_sub(1) => {
Some(self.len.saturating_sub(1))
}
Some(selected) => Some(selected.saturating_add(1)),
None => Some(0),
};
if old_index != new_index {
self.selected = new_index;
self.selected()
} else {
None
}
}
}
impl From<&ItemState> for TableState {
fn from(value: &ItemState) -> Self {
let mut state = TableState::default();
state.select(value.selected);
state
}
}
impl From<&ItemState> for ListState {
fn from(value: &ItemState) -> Self {
let mut state = ListState::default();
state.select(value.selected);
state
}
}

View File

@ -1,122 +0,0 @@
use tuirealm::props::Color;
const COLOR_DEFAULT_FG: Color = Color::Rgb(200, 200, 200);
const COLOR_DEFAULT_DARK_FG: Color = Color::Rgb(150, 150, 150);
const COLOR_DEFAULT_DARK: Color = Color::Rgb(100, 100, 100);
const COLOR_DEFAULT_DARKER: Color = Color::Rgb(70, 70, 70);
const COLOR_DEFAULT_DARKEST: Color = Color::Rgb(40, 40, 40);
const COLOR_DEFAULT_FAINT: Color = Color::Rgb(20, 20, 20);
#[derive(Debug, Clone)]
pub struct Colors {
pub default_fg: Color,
pub tabs_highlighted_fg: Color,
pub app_header_project_fg: Color,
pub app_header_rid_fg: Color,
pub labeled_container_bg: Color,
pub item_list_highlighted_bg: Color,
pub property_name_fg: Color,
pub property_divider_fg: Color,
pub shortcut_short_fg: Color,
pub shortcut_long_fg: Color,
pub shortcutbar_divider_fg: Color,
pub browser_list_id: Color,
pub browser_list_title: Color,
pub browser_list_description: Color,
pub browser_list_author: Color,
pub browser_list_tags: Color,
pub browser_list_comments: Color,
pub browser_list_timestamp: Color,
pub browser_patch_list_head: Color,
pub browser_patch_list_added: Color,
pub browser_patch_list_removed: Color,
pub context_bg: Color,
pub context_light_bg: Color,
pub context_badge_bg: Color,
pub context_id_fg: Color,
pub context_id_bg: Color,
pub context_id_author_fg: Color,
pub container_border_fg: Color,
pub container_border_focus_fg: Color,
}
#[derive(Debug, Clone)]
pub struct Icons {
pub property_divider: char,
pub shortcutbar_divider: char,
pub tab_divider: char,
pub tab_overline: char,
pub whitespace: char,
}
#[derive(Debug, Clone)]
pub struct Tables {
pub spacing: u16,
}
/// The Radicle TUI theme. Will be defined in a JSON config file in the
/// future. e.g.:
/// {
/// "name": "Default",
/// "colors": {
/// "foreground": "#ffffff",
/// "propertyForeground": "#ffffff",
/// "highlightedBackground": "#000000",
/// },
/// "icons": {
/// "workspaces.divider": "|",
/// "shortcuts.divider: "∙",
/// }
/// }
#[derive(Debug, Clone)]
pub struct Theme {
pub name: String,
pub colors: Colors,
pub icons: Icons,
pub tables: Tables,
}
pub fn default_dark() -> Theme {
Theme {
name: String::from("Default"),
colors: Colors {
default_fg: COLOR_DEFAULT_FG,
tabs_highlighted_fg: Color::Magenta,
app_header_project_fg: Color::Cyan,
app_header_rid_fg: Color::Yellow,
labeled_container_bg: COLOR_DEFAULT_FAINT,
item_list_highlighted_bg: COLOR_DEFAULT_DARKER,
property_name_fg: Color::Cyan,
property_divider_fg: COLOR_DEFAULT_DARK,
shortcut_short_fg: COLOR_DEFAULT_DARK,
shortcut_long_fg: COLOR_DEFAULT_DARKER,
shortcutbar_divider_fg: COLOR_DEFAULT_DARKER,
browser_list_id: Color::Cyan,
browser_list_title: COLOR_DEFAULT_FG,
browser_list_description: COLOR_DEFAULT_DARK,
browser_list_author: Color::Gray,
browser_list_tags: Color::LightBlue,
browser_list_comments: COLOR_DEFAULT_DARK_FG,
browser_list_timestamp: COLOR_DEFAULT_DARK,
browser_patch_list_head: Color::LightBlue,
browser_patch_list_added: Color::Green,
browser_patch_list_removed: Color::Red,
context_bg: COLOR_DEFAULT_DARKEST,
context_light_bg: Color::Gray,
context_badge_bg: Color::LightRed,
context_id_fg: Color::Cyan,
context_id_bg: COLOR_DEFAULT_DARKEST,
context_id_author_fg: Color::Gray,
container_border_fg: COLOR_DEFAULT_DARKEST,
container_border_focus_fg: COLOR_DEFAULT_DARK,
},
icons: Icons {
property_divider: '',
shortcutbar_divider: '',
tab_divider: '|',
tab_overline: '',
whitespace: ' ',
},
tables: Tables { spacing: 2 },
}
}

View File

@ -1,106 +0,0 @@
pub mod common;
pub mod home;
pub mod issue;
pub mod patch;
mod utils;
use std::ops::Deref;
use tuirealm::command::{Cmd, CmdResult};
use tuirealm::props::{AttrValue, Attribute, Color, Props};
use tuirealm::tui::layout::Rect;
use tuirealm::{Frame, MockComponent, State};
pub type BoxedWidget<T> = Box<Widget<T>>;
pub trait WidgetComponent {
fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect);
fn state(&self) -> State;
fn perform(&mut self, properties: &Props, cmd: Cmd) -> CmdResult;
}
#[derive(Clone)]
pub struct Widget<T: WidgetComponent> {
component: T,
properties: Props,
}
impl<T: WidgetComponent> Deref for Widget<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.component
}
}
impl<T: WidgetComponent> Widget<T> {
pub fn new(component: T) -> Self {
Widget {
component,
properties: Props::default(),
}
}
pub fn foreground(mut self, fg: Color) -> Self {
self.attr(Attribute::Foreground, AttrValue::Color(fg));
self
}
pub fn highlight(mut self, fg: Color) -> Self {
self.attr(Attribute::HighlightedColor, AttrValue::Color(fg));
self
}
pub fn background(mut self, bg: Color) -> Self {
self.attr(Attribute::Background, AttrValue::Color(bg));
self
}
pub fn height(mut self, h: u16) -> Self {
self.attr(Attribute::Height, AttrValue::Size(h));
self
}
pub fn width(mut self, w: u16) -> Self {
self.attr(Attribute::Width, AttrValue::Size(w));
self
}
pub fn content(mut self, content: AttrValue) -> Self {
self.attr(Attribute::Content, content);
self
}
pub fn custom(mut self, key: &'static str, value: AttrValue) -> Self {
self.attr(Attribute::Custom(key), value);
self
}
pub fn to_boxed(self) -> Box<Self> {
Box::new(self)
}
}
impl<T: WidgetComponent> MockComponent for Widget<T> {
fn view(&mut self, frame: &mut Frame, area: Rect) {
self.component.view(&self.properties, frame, area)
}
fn query(&self, attr: Attribute) -> Option<AttrValue> {
self.properties.get(attr)
}
fn attr(&mut self, attr: Attribute, value: AttrValue) {
self.properties.set(attr, value)
}
fn state(&self) -> State {
self.component.state()
}
fn perform(&mut self, cmd: Cmd) -> CmdResult {
self.component.perform(&self.properties, cmd)
}
}

View File

@ -1,154 +0,0 @@
pub mod container;
pub mod context;
pub mod label;
pub mod list;
use tuirealm::props::{AttrValue, Attribute};
use tuirealm::MockComponent;
use container::{GlobalListener, Header, LabeledContainer, Tabs};
use context::{Shortcut, Shortcuts};
use label::Label;
use list::{Property, PropertyList};
use self::container::{AppHeader, AppInfo, Container, VerticalLine};
use self::list::{ColumnWidth, PropertyTable};
use super::Widget;
use crate::ui::context::Context;
use crate::ui::theme::Theme;
pub fn global_listener() -> Widget<GlobalListener> {
Widget::new(GlobalListener::default())
}
pub fn label(content: &str) -> Widget<Label> {
// TODO: Remove when size constraints are implemented
let width = content.chars().count() as u16;
Widget::new(Label::default())
.content(AttrValue::String(content.to_string()))
.height(1)
.width(width)
}
pub fn reversable_label(content: &str) -> Widget<Label> {
let content = &format!(" {content} ");
label(content)
}
pub fn container_header(theme: &Theme, label: Widget<Label>) -> Widget<Header<1>> {
let header = Header::new([label], [ColumnWidth::Grow], theme.clone());
Widget::new(header)
}
pub fn container(theme: &Theme, component: Box<dyn MockComponent>) -> Widget<Container> {
let container = Container::new(component, theme.clone());
Widget::new(container)
}
pub fn labeled_container(
theme: &Theme,
title: &str,
component: Box<dyn MockComponent>,
) -> Widget<LabeledContainer> {
let header = container_header(
theme,
label(&format!(" {title} ")).foreground(theme.colors.default_fg),
);
let container = LabeledContainer::new(header, component, theme.clone());
Widget::new(container)
}
pub fn shortcut(theme: &Theme, short: &str, long: &str) -> Widget<Shortcut> {
let short = label(short).foreground(theme.colors.shortcut_short_fg);
let divider = label(&theme.icons.whitespace.to_string());
let long = label(long).foreground(theme.colors.shortcut_long_fg);
// TODO: Remove when size constraints are implemented
let short_w = short.query(Attribute::Width).unwrap().unwrap_size();
let divider_w = divider.query(Attribute::Width).unwrap().unwrap_size();
let long_w = long.query(Attribute::Width).unwrap().unwrap_size();
let width = short_w.saturating_add(divider_w).saturating_add(long_w);
let shortcut = Shortcut::new(short, divider, long);
Widget::new(shortcut).height(1).width(width)
}
pub fn shortcuts(theme: &Theme, shortcuts: Vec<Widget<Shortcut>>) -> Widget<Shortcuts> {
let divider = label(&format!(" {} ", theme.icons.shortcutbar_divider))
.foreground(theme.colors.shortcutbar_divider_fg);
let shortcut_bar = Shortcuts::new(shortcuts, divider);
Widget::new(shortcut_bar).height(1)
}
pub fn property(theme: &Theme, name: &str, value: &str) -> Widget<Property> {
let name = label(name).foreground(theme.colors.property_name_fg);
let divider = label(&format!(" {} ", theme.icons.property_divider));
let value = label(value).foreground(theme.colors.default_fg);
// TODO: Remove when size constraints are implemented
let name_w = name.query(Attribute::Width).unwrap().unwrap_size();
let divider_w = divider.query(Attribute::Width).unwrap().unwrap_size();
let value_w = value.query(Attribute::Width).unwrap().unwrap_size();
let width = name_w.saturating_add(divider_w).saturating_add(value_w);
let property = Property::new(name, value).with_divider(divider);
Widget::new(property).height(1).width(width)
}
pub fn property_list(_theme: &Theme, properties: Vec<Widget<Property>>) -> Widget<PropertyList> {
let property_list = PropertyList::new(properties);
Widget::new(property_list)
}
pub fn property_table(_theme: &Theme, properties: Vec<Widget<Property>>) -> Widget<PropertyTable> {
let table = PropertyTable::new(properties);
Widget::new(table)
}
pub fn tabs(_theme: &Theme, tabs: Vec<Widget<Label>>) -> Widget<Tabs> {
let tabs = Tabs::new(tabs);
Widget::new(tabs).height(2)
}
pub fn app_info(context: &Context, theme: &Theme) -> Widget<AppInfo> {
let project = label(context.project().name()).foreground(theme.colors.app_header_project_fg);
let rid = label(&format!(" ({})", context.id())).foreground(theme.colors.app_header_rid_fg);
let project_w = project
.query(Attribute::Width)
.unwrap_or(AttrValue::Size(0))
.unwrap_size();
let rid_w = rid
.query(Attribute::Width)
.unwrap_or(AttrValue::Size(0))
.unwrap_size();
let info = AppInfo::new(project, rid);
Widget::new(info).width(project_w.saturating_add(rid_w))
}
pub fn app_header(
context: &Context,
theme: &Theme,
nav: Option<Widget<Tabs>>,
) -> Widget<AppHeader> {
let line =
label(&theme.icons.tab_overline.to_string()).foreground(theme.colors.tabs_highlighted_fg);
let line = Widget::new(VerticalLine::new(line));
let info = app_info(context, theme);
let header = AppHeader::new(nav, info, line);
Widget::new(header)
}

View File

@ -1,458 +0,0 @@
use tuirealm::command::{Cmd, CmdResult};
use tuirealm::props::{AttrValue, Attribute, BorderSides, BorderType, Props, Style, TextModifiers};
use tuirealm::tui::layout::{Constraint, Direction, Layout, Rect};
use tuirealm::tui::widgets::{Block, Cell, Row};
use tuirealm::{Frame, MockComponent, State, StateValue};
use crate::ui::ext::HeaderBlock;
use crate::ui::layout;
use crate::ui::state::TabState;
use crate::ui::theme::Theme;
use crate::ui::widget::{utils, Widget, WidgetComponent};
use super::label::Label;
use super::list::ColumnWidth;
/// Some user events need to be handled globally (e.g. user presses key `q` to quit
/// the application). This component can be used in conjunction with SubEventClause
/// to handle those events.
#[derive(Default)]
pub struct GlobalListener {}
impl WidgetComponent for GlobalListener {
fn view(&mut self, _properties: &Props, _frame: &mut Frame, _area: Rect) {}
fn state(&self) -> State {
State::None
}
fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
CmdResult::None
}
}
/// A vertical separator.
#[derive(Clone)]
pub struct VerticalLine {
line: Widget<Label>,
}
impl VerticalLine {
pub fn new(line: Widget<Label>) -> Self {
Self { line }
}
}
impl WidgetComponent for VerticalLine {
fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
let display = properties
.get_or(Attribute::Display, AttrValue::Flag(true))
.unwrap_flag();
if display {
// Repeat and render line.
let overlines = vec![self.line.clone(); area.width as usize];
let overlines = overlines
.iter()
.map(|l| l.clone().to_boxed() as Box<dyn MockComponent>)
.collect();
let line_layout = layout::h_stack(overlines, area);
for (mut line, area) in line_layout {
line.view(frame, area);
}
}
}
fn state(&self) -> State {
State::None
}
fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
CmdResult::None
}
}
////////////////////////////////////////////////
/// A tab header that displays all labels horizontally aligned and separated
/// by a divider. Highlights the label defined by the current tab index.
#[derive(Clone)]
pub struct Tabs {
tabs: Vec<Widget<Label>>,
state: TabState,
}
impl Tabs {
pub fn new(tabs: Vec<Widget<Label>>) -> Self {
let count = &tabs.len();
Self {
tabs,
state: TabState {
selected: 0,
len: *count as u16,
},
}
}
}
impl WidgetComponent for Tabs {
fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
let selected = self.state().unwrap_one().unwrap_u16();
let display = properties
.get_or(Attribute::Display, AttrValue::Flag(true))
.unwrap_flag();
if display {
// Render tabs, highlighting the selected tab.
let mut tabs = vec![];
for (index, tab) in self.tabs.iter().enumerate() {
let mut tab = tab.clone().to_boxed();
if index == selected as usize {
tab.attr(
Attribute::TextProps,
AttrValue::TextModifiers(TextModifiers::REVERSED),
);
}
tabs.push(tab.clone().to_boxed() as Box<dyn MockComponent>);
}
tabs.push(Widget::new(Label::default()).to_boxed());
let tab_layout = layout::h_stack(tabs, area);
for (mut tab, area) in tab_layout {
tab.view(frame, area);
}
}
}
fn state(&self) -> State {
State::One(StateValue::U16(self.state.selected))
}
fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
use tuirealm::command::Direction;
match cmd {
Cmd::Move(Direction::Right) => {
let prev = self.state.selected;
self.state.incr_tab_index(true);
if prev != self.state.selected {
CmdResult::Changed(self.state())
} else {
CmdResult::None
}
}
_ => CmdResult::None,
}
}
}
/// An application info widget that renders project / branch information
/// and a separator line. Used in conjunction with [`Tabs`].
pub struct AppInfo {
project: Widget<Label>,
rid: Widget<Label>,
}
impl AppInfo {
pub fn new(project: Widget<Label>, rid: Widget<Label>) -> Self {
Self { project, rid }
}
}
impl WidgetComponent for AppInfo {
fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
let display = properties
.get_or(Attribute::Display, AttrValue::Flag(true))
.unwrap_flag();
let project_w = self
.project
.query(Attribute::Width)
.unwrap_or(AttrValue::Size(10))
.unwrap_size();
let rid_w = self
.rid
.query(Attribute::Width)
.unwrap_or(AttrValue::Size(10))
.unwrap_size();
if display {
let layout = Layout::default()
.direction(Direction::Horizontal)
.constraints(vec![
Constraint::Length(project_w),
Constraint::Length(rid_w),
])
.split(area);
self.project.view(frame, layout[0]);
self.rid.view(frame, layout[1]);
}
}
fn state(&self) -> State {
State::None
}
fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
CmdResult::None
}
}
/// A common application header that renders project / branch
/// information and an optional navigation.
pub struct AppHeader {
nav: Option<Widget<Tabs>>,
info: Widget<AppInfo>,
line: Widget<VerticalLine>,
}
impl AppHeader {
pub fn new(
nav: Option<Widget<Tabs>>,
info: Widget<AppInfo>,
line: Widget<VerticalLine>,
) -> Self {
Self { nav, info, line }
}
}
impl WidgetComponent for AppHeader {
fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
let display = properties
.get_or(Attribute::Display, AttrValue::Flag(true))
.unwrap_flag();
let info_w = self
.info
.query(Attribute::Width)
.unwrap_or(AttrValue::Size(10))
.unwrap_size();
if display {
let layout = layout::app_header(area, info_w);
if let Some(nav) = self.nav.as_mut() {
nav.view(frame, layout.nav);
}
self.info.view(frame, layout.info);
self.line.view(frame, layout.line);
}
}
fn state(&self) -> State {
State::None
}
fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
self.nav
.as_mut()
.map(|nav| nav.perform(cmd))
.unwrap_or(CmdResult::None)
}
}
/// A labeled container header.
pub struct Header<const W: usize> {
header: [Widget<Label>; W],
widths: [ColumnWidth; W],
theme: Theme,
}
impl<const W: usize> Header<W> {
pub fn new(header: [Widget<Label>; W], widths: [ColumnWidth; W], theme: Theme) -> Self {
Self {
header,
widths,
theme,
}
}
}
impl<const W: usize> WidgetComponent for Header<W> {
fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
let display = properties
.get_or(Attribute::Display, AttrValue::Flag(true))
.unwrap_flag();
let focus = properties
.get_or(Attribute::Focus, AttrValue::Flag(false))
.unwrap_flag();
let color = if focus {
self.theme.colors.container_border_focus_fg
} else {
self.theme.colors.container_border_fg
};
if display {
let block = HeaderBlock::default()
.borders(BorderSides::all())
.border_style(Style::default().fg(color))
.border_type(BorderType::Rounded);
frame.render_widget(block, area);
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints(vec![Constraint::Min(1)])
.vertical_margin(1)
.horizontal_margin(1)
.split(area);
let widths = utils::column_widths(area, &self.widths, self.theme.tables.spacing);
let header: [Cell; W] = self
.header
.iter()
.map(|label| {
let cell: Cell = label.into();
cell.style(Style::default().fg(self.theme.colors.default_fg))
})
.collect::<Vec<_>>()
.try_into()
.unwrap();
let header: Row<'_> = Row::new(header);
let table = tuirealm::tui::widgets::Table::new(vec![])
.column_spacing(self.theme.tables.spacing)
.header(header)
.widths(&widths);
frame.render_widget(table, layout[0]);
}
}
fn state(&self) -> State {
State::None
}
fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
CmdResult::None
}
}
pub struct Container {
component: Box<dyn MockComponent>,
theme: Theme,
}
impl Container {
pub fn new(component: Box<dyn MockComponent>, theme: Theme) -> Self {
Self { component, theme }
}
}
impl WidgetComponent for Container {
fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
let display = properties
.get_or(Attribute::Display, AttrValue::Flag(true))
.unwrap_flag();
let focus = properties
.get_or(Attribute::Focus, AttrValue::Flag(false))
.unwrap_flag();
let color = if focus {
self.theme.colors.container_border_focus_fg
} else {
self.theme.colors.container_border_fg
};
if display {
// Make some space on the left
let layout = Layout::default()
.direction(Direction::Horizontal)
.horizontal_margin(1)
.vertical_margin(1)
.constraints(vec![Constraint::Length(1), Constraint::Min(0)].as_ref())
.split(area);
// reverse draw order: child needs to be drawn first?
self.component.view(frame, layout[1]);
let block = Block::default()
.borders(BorderSides::ALL)
.border_style(Style::default().fg(color))
.border_type(BorderType::Rounded);
frame.render_widget(block, area);
}
}
fn state(&self) -> State {
State::None
}
fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
self.component.perform(cmd)
}
}
pub struct LabeledContainer {
header: Widget<Header<1>>,
component: Box<dyn MockComponent>,
theme: Theme,
}
impl LabeledContainer {
pub fn new(header: Widget<Header<1>>, component: Box<dyn MockComponent>, theme: Theme) -> Self {
Self {
header,
component,
theme,
}
}
}
impl WidgetComponent for LabeledContainer {
fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
let display = properties
.get_or(Attribute::Display, AttrValue::Flag(true))
.unwrap_flag();
let focus = properties
.get_or(Attribute::Focus, AttrValue::Flag(false))
.unwrap_flag();
let color = if focus {
self.theme.colors.container_border_focus_fg
} else {
self.theme.colors.container_border_fg
};
let header_height = self
.header
.query(Attribute::Height)
.unwrap_or(AttrValue::Size(3))
.unwrap_size();
if display {
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(header_height), Constraint::Length(0)].as_ref())
.split(area);
// Make some space on the left
let inner_layout = Layout::default()
.direction(Direction::Horizontal)
.horizontal_margin(1)
.constraints(vec![Constraint::Length(1), Constraint::Min(0)].as_ref())
.split(layout[1]);
// reverse draw order: child needs to be drawn first?
self.component
.attr(Attribute::Focus, AttrValue::Flag(focus));
self.component.view(frame, inner_layout[1]);
let block = Block::default()
.borders(BorderSides::BOTTOM | BorderSides::LEFT | BorderSides::RIGHT)
.border_style(Style::default().fg(color))
.border_type(BorderType::Rounded);
frame.render_widget(block, layout[1]);
self.header.attr(Attribute::Focus, AttrValue::Flag(focus));
self.header.view(frame, layout[0]);
}
}
fn state(&self) -> State {
State::None
}
fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
self.component.perform(cmd)
}
}

View File

@ -1,175 +0,0 @@
use tuirealm::command::{Cmd, CmdResult};
use tuirealm::props::{AttrValue, Attribute, Props};
use tuirealm::tui::layout::Rect;
use tuirealm::{Frame, MockComponent, State};
use super::label::Label;
use crate::ui::layout;
use crate::ui::widget::{Widget, WidgetComponent};
/// A shortcut that consists of a label displaying the "hotkey", a label that displays
/// the action and a spacer between them.
#[derive(Clone)]
pub struct Shortcut {
short: Widget<Label>,
divider: Widget<Label>,
long: Widget<Label>,
}
impl Shortcut {
pub fn new(short: Widget<Label>, divider: Widget<Label>, long: Widget<Label>) -> Self {
Self {
short,
divider,
long,
}
}
}
impl WidgetComponent for Shortcut {
fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
let display = properties
.get_or(Attribute::Display, AttrValue::Flag(true))
.unwrap_flag();
if display {
let labels: Vec<Box<dyn MockComponent>> = vec![
self.short.clone().to_boxed(),
self.divider.clone().to_boxed(),
self.long.clone().to_boxed(),
];
let layout = layout::h_stack(labels, area);
for (mut shortcut, area) in layout {
shortcut.view(frame, area);
}
}
}
fn state(&self) -> State {
State::None
}
fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
CmdResult::None
}
}
/// A shortcut bar that displays multiple shortcuts and separates them with a
/// divider.
pub struct Shortcuts {
shortcuts: Vec<Widget<Shortcut>>,
divider: Widget<Label>,
}
impl Shortcuts {
pub fn new(shortcuts: Vec<Widget<Shortcut>>, divider: Widget<Label>) -> Self {
Self { shortcuts, divider }
}
}
impl WidgetComponent for Shortcuts {
fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
let display = properties
.get_or(Attribute::Display, AttrValue::Flag(true))
.unwrap_flag();
if display {
let mut widgets: Vec<Box<dyn MockComponent>> = vec![];
let mut shortcuts = self.shortcuts.iter_mut().peekable();
while let Some(shortcut) = shortcuts.next() {
if shortcuts.peek().is_some() {
widgets.push(shortcut.clone().to_boxed());
widgets.push(self.divider.clone().to_boxed())
} else {
widgets.push(shortcut.clone().to_boxed());
}
}
let layout = layout::h_stack(widgets, area);
for (mut widget, area) in layout {
widget.view(frame, area);
}
}
}
fn state(&self) -> State {
State::None
}
fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
CmdResult::None
}
}
pub struct ContextBar {
context: Widget<Label>,
id: Widget<Label>,
author: Widget<Label>,
title: Widget<Label>,
comments: Widget<Label>,
}
impl ContextBar {
pub fn new(
context: Widget<Label>,
id: Widget<Label>,
author: Widget<Label>,
title: Widget<Label>,
comments: Widget<Label>,
) -> Self {
Self {
context,
id,
author,
title,
comments,
}
}
}
impl WidgetComponent for ContextBar {
fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
let display = properties
.get_or(Attribute::Display, AttrValue::Flag(true))
.unwrap_flag();
let context_w = self.context.query(Attribute::Width).unwrap().unwrap_size();
let id_w = self.id.query(Attribute::Width).unwrap().unwrap_size();
let author_w = self.author.query(Attribute::Width).unwrap().unwrap_size();
let count_w = self.comments.query(Attribute::Width).unwrap().unwrap_size();
if display {
let layout = layout::h_stack(
vec![
self.context.clone().to_boxed(),
self.id.clone().to_boxed(),
self.title
.clone()
.width(
area.width
.saturating_sub(context_w + id_w + author_w + count_w),
)
.to_boxed(),
self.author.clone().to_boxed(),
self.comments.clone().to_boxed(),
],
area,
);
for (mut component, area) in layout {
component.view(frame, area);
}
}
}
fn state(&self) -> State {
State::None
}
fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
CmdResult::None
}
}

View File

@ -1,81 +0,0 @@
use tuirealm::command::{Cmd, CmdResult};
use tuirealm::props::{AttrValue, Attribute, Color, Props, Style};
use tuirealm::tui::layout::Rect;
use tuirealm::tui::text::{Span, Text};
use tuirealm::{Frame, MockComponent, State};
use crate::ui::widget::{Widget, WidgetComponent};
/// A label that can be styled using a foreground color and text modifiers.
/// Its height is fixed, its width depends on the length of the text it displays.
#[derive(Clone, Default)]
pub struct Label;
impl WidgetComponent for Label {
fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
use tui_realm_stdlib::Label;
let content = properties
.get_or(Attribute::Content, AttrValue::String(String::default()))
.unwrap_string();
let display = properties
.get_or(Attribute::Display, AttrValue::Flag(true))
.unwrap_flag();
let foreground = properties
.get_or(Attribute::Foreground, AttrValue::Color(Color::Reset))
.unwrap_color();
let background = properties
.get_or(Attribute::Background, AttrValue::Color(Color::Reset))
.unwrap_color();
if display {
let mut label = match properties.get(Attribute::TextProps) {
Some(modifiers) => Label::default()
.foreground(foreground)
.background(background)
.modifiers(modifiers.unwrap_text_modifiers())
.text(content),
None => Label::default()
.foreground(foreground)
.background(background)
.text(content),
};
label.view(frame, area);
}
}
fn state(&self) -> State {
State::None
}
fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
CmdResult::None
}
}
impl From<&Widget<Label>> for Span<'_> {
fn from(label: &Widget<Label>) -> Self {
let content = label
.query(Attribute::Content)
.unwrap_or(AttrValue::String(String::default()))
.unwrap_string();
Span::styled(content, Style::default())
}
}
impl From<&Widget<Label>> for Text<'_> {
fn from(label: &Widget<Label>) -> Self {
let content = label
.query(Attribute::Content)
.unwrap_or(AttrValue::String(String::default()))
.unwrap_string();
let foreground = label
.query(Attribute::Foreground)
.unwrap_or(AttrValue::Color(Color::Reset))
.unwrap_color();
Text::styled(content, Style::default().fg(foreground))
}
}

View File

@ -1,376 +0,0 @@
use tuirealm::command::{Cmd, CmdResult};
use tuirealm::props::{AttrValue, Attribute, BorderSides, BorderType, Color, Props, Style};
use tuirealm::tui::layout::{Constraint, Direction, Layout, Rect};
use tuirealm::tui::widgets::{Block, Cell, ListState, Row, TableState};
use tuirealm::{Frame, MockComponent, State, StateValue};
use crate::ui::layout;
use crate::ui::state::ItemState;
use crate::ui::theme::Theme;
use crate::ui::widget::{utils, Widget, WidgetComponent};
use super::container::Header;
use super::label::Label;
use super::*;
/// A generic item that can be displayed in a table with [`const W: usize`] columns.
pub trait TableItem<const W: usize> {
/// Should return fields as table cells.
fn row(&self, theme: &Theme) -> [Cell; W];
}
/// A generic item that can be displayed in a list.
pub trait ListItem {
/// Should return fields as list item.
fn row(&self, theme: &Theme) -> tuirealm::tui::widgets::ListItem;
}
/// Grow behavior of a table column.
///
/// [`tuirealm::tui::widgets::Table`] does only support percental column widths.
/// A [`ColumnWidth`] is used to specify the grow behaviour of a table column
/// and a percental column width is calculated based on that.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ColumnWidth {
/// A fixed-size column.
Fixed(u16),
/// A growable column.
Grow,
}
/// A component that displays a labeled property.
#[derive(Clone)]
pub struct Property {
name: Widget<Label>,
divider: Widget<Label>,
value: Widget<Label>,
}
impl Property {
pub fn new(name: Widget<Label>, value: Widget<Label>) -> Self {
let divider = label("");
Self {
name,
divider,
value,
}
}
pub fn with_divider(mut self, divider: Widget<Label>) -> Self {
self.divider = divider;
self
}
pub fn name(&self) -> &Widget<Label> {
&self.name
}
pub fn value(&self) -> &Widget<Label> {
&self.value
}
}
impl WidgetComponent for Property {
fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
let display = properties
.get_or(Attribute::Display, AttrValue::Flag(true))
.unwrap_flag();
if display {
let labels: Vec<Box<dyn MockComponent>> = vec![
self.name.clone().to_boxed(),
self.divider.clone().to_boxed(),
self.value.clone().to_boxed(),
];
let layout = layout::h_stack(labels, area);
for (mut label, area) in layout {
label.view(frame, area);
}
}
}
fn state(&self) -> State {
State::None
}
fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
CmdResult::None
}
}
/// A component that can display lists of labeled properties
#[derive(Default)]
pub struct PropertyList {
properties: Vec<Widget<Property>>,
}
impl PropertyList {
pub fn new(properties: Vec<Widget<Property>>) -> Self {
Self { properties }
}
}
impl WidgetComponent for PropertyList {
fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
let display = properties
.get_or(Attribute::Display, AttrValue::Flag(true))
.unwrap_flag();
if display {
let properties = self
.properties
.iter()
.map(|property| property.clone().to_boxed() as Box<dyn MockComponent>)
.collect();
let layout = layout::v_stack(properties, area);
for (mut property, area) in layout {
property.view(frame, area);
}
}
}
fn state(&self) -> State {
State::None
}
fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
CmdResult::None
}
}
pub struct PropertyTable {
properties: Vec<Widget<Property>>,
}
impl PropertyTable {
pub fn new(properties: Vec<Widget<Property>>) -> Self {
Self { properties }
}
}
impl WidgetComponent for PropertyTable {
fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
use tuirealm::tui::widgets::Table;
let display = properties
.get_or(Attribute::Display, AttrValue::Flag(true))
.unwrap_flag();
if display {
let rows = self
.properties
.iter()
.map(|p| Row::new([Cell::from(p.name()), Cell::from(p.value())]));
let table = Table::new(rows)
.widths([Constraint::Percentage(20), Constraint::Percentage(80)].as_ref());
frame.render_widget(table, area);
}
}
fn state(&self) -> State {
State::None
}
fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
CmdResult::None
}
}
/// A table component that can display a list of [`TableItem`]s.
pub struct Table<V, const W: usize>
where
V: TableItem<W> + Clone,
{
/// Items hold by this model.
items: Vec<V>,
/// The table header.
header: [Widget<Label>; W],
/// Grow behavior of table columns.
widths: [ColumnWidth; W],
/// State that keeps track of the selection.
state: ItemState,
/// The current theme.
theme: Theme,
}
impl<V, const W: usize> Table<V, W>
where
V: TableItem<W> + Clone,
{
pub fn new(
items: &[V],
header: [Widget<Label>; W],
widths: [ColumnWidth; W],
theme: Theme,
) -> Self {
Self {
items: items.to_vec(),
header,
widths,
state: ItemState::new(Some(0), items.len()),
theme,
}
}
}
impl<V, const W: usize> WidgetComponent for Table<V, W>
where
V: TableItem<W> + Clone,
{
fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
let highlight = properties
.get_or(Attribute::HighlightedColor, AttrValue::Color(Color::Reset))
.unwrap_color();
let focus = properties
.get_or(Attribute::Focus, AttrValue::Flag(false))
.unwrap_flag();
let color = if focus {
self.theme.colors.container_border_focus_fg
} else {
self.theme.colors.container_border_fg
};
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints(vec![Constraint::Length(3), Constraint::Min(1)])
.split(area);
let widths = utils::column_widths(area, &self.widths, self.theme.tables.spacing);
let rows: Vec<Row<'_>> = self
.items
.iter()
.map(|item| Row::new(item.row(&self.theme)))
.collect();
let table = tuirealm::tui::widgets::Table::new(rows)
.block(
Block::default()
.borders(BorderSides::BOTTOM | BorderSides::LEFT | BorderSides::RIGHT)
.border_style(Style::default().fg(color))
.border_type(BorderType::Rounded),
)
.highlight_style(Style::default().bg(highlight))
.column_spacing(self.theme.tables.spacing)
.widths(&widths);
let mut header = Widget::new(Header::new(
self.header.clone(),
self.widths,
self.theme.clone(),
));
header.attr(Attribute::Focus, AttrValue::Flag(focus));
header.view(frame, layout[0]);
frame.render_stateful_widget(table, layout[1], &mut TableState::from(&self.state));
}
fn state(&self) -> State {
State::None
}
fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
use tuirealm::command::Direction;
match cmd {
Cmd::Move(Direction::Up) => match self.state.select_previous() {
Some(selected) => CmdResult::Changed(State::One(StateValue::Usize(selected))),
None => CmdResult::None,
},
Cmd::Move(Direction::Down) => match self.state.select_next() {
Some(selected) => CmdResult::Changed(State::One(StateValue::Usize(selected))),
None => CmdResult::None,
},
Cmd::Submit => match self.state.selected() {
Some(selected) => CmdResult::Submit(State::One(StateValue::Usize(selected))),
None => CmdResult::None,
},
_ => CmdResult::None,
}
}
}
/// A list component that can display [`ListItem`]'s.
pub struct List<V>
where
V: ListItem + Clone + PartialEq,
{
/// Items held by this list.
items: Vec<V>,
/// State keeps track of the current selection.
state: ItemState,
/// The current theme.
theme: Theme,
}
impl<V> List<V>
where
V: ListItem + Clone + PartialEq,
{
pub fn new(items: &[V], selected: Option<V>, theme: Theme) -> Self {
let selected = match selected {
Some(item) => items.iter().position(|i| i == &item),
None => Some(0),
};
Self {
items: items.to_vec(),
state: ItemState::new(selected, items.len()),
theme,
}
}
}
impl<V> WidgetComponent for List<V>
where
V: ListItem + Clone + PartialEq,
{
fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
use tuirealm::tui::widgets::{List, ListItem};
let highlight = properties
.get_or(Attribute::HighlightedColor, AttrValue::Color(Color::Reset))
.unwrap_color();
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints(vec![Constraint::Min(1), Constraint::Length(1)])
.split(area);
let rows: Vec<ListItem> = self
.items
.iter()
.map(|item| item.row(&self.theme))
.collect();
let list = List::new(rows).highlight_style(Style::default().bg(highlight));
frame.render_stateful_widget(list, layout[0], &mut ListState::from(&self.state));
}
fn state(&self) -> State {
State::None
}
fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
use tuirealm::command::Direction;
match cmd {
Cmd::Move(Direction::Up) => match self.state.select_previous() {
Some(selected) => CmdResult::Changed(State::One(StateValue::Usize(selected))),
None => CmdResult::None,
},
Cmd::Move(Direction::Down) => match self.state.select_next() {
Some(selected) => CmdResult::Changed(State::One(StateValue::Usize(selected))),
None => CmdResult::None,
},
Cmd::Submit => match self.state.selected() {
Some(selected) => CmdResult::Submit(State::One(StateValue::Usize(selected))),
None => CmdResult::None,
},
_ => CmdResult::None,
}
}
}

View File

@ -1,284 +0,0 @@
use tuirealm::command::{Cmd, CmdResult};
use tuirealm::tui::layout::Rect;
use tuirealm::{AttrValue, Attribute, Frame, MockComponent, Props, State};
use super::common;
use super::common::container::{LabeledContainer, Tabs};
use super::common::context::Shortcuts;
use super::common::list::{ColumnWidth, Table};
use super::{Widget, WidgetComponent};
use crate::cob;
use crate::ui::cob::{IssueItem, PatchItem};
use crate::ui::context::Context;
use crate::ui::layout;
use crate::ui::theme::Theme;
pub struct Dashboard {
about: Widget<LabeledContainer>,
shortcuts: Widget<Shortcuts>,
}
impl Dashboard {
pub fn new(about: Widget<LabeledContainer>, shortcuts: Widget<Shortcuts>) -> Self {
Self { about, shortcuts }
}
}
impl WidgetComponent for Dashboard {
fn view(&mut self, _properties: &Props, frame: &mut Frame, area: Rect) {
let shortcuts_h = self
.shortcuts
.query(Attribute::Height)
.unwrap_or(AttrValue::Size(0))
.unwrap_size();
let layout = layout::root_component(area, shortcuts_h);
self.about.view(frame, layout[0]);
self.shortcuts.view(frame, layout[1]);
}
fn state(&self) -> State {
State::None
}
fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
CmdResult::None
}
}
pub struct IssueBrowser {
items: Vec<IssueItem>,
table: Widget<Table<IssueItem, 7>>,
shortcuts: Widget<Shortcuts>,
}
impl IssueBrowser {
pub fn new(context: &Context, theme: &Theme, shortcuts: Widget<Shortcuts>) -> Self {
let header = [
common::label(""),
common::label("ID"),
common::label("Title"),
common::label("Author"),
common::label("Labels"),
common::label("Assignees"),
common::label("Opened"),
];
let widths = [
ColumnWidth::Fixed(3),
ColumnWidth::Fixed(7),
ColumnWidth::Grow,
ColumnWidth::Fixed(21),
ColumnWidth::Fixed(25),
ColumnWidth::Fixed(21),
ColumnWidth::Fixed(18),
];
let repo = context.repository();
let mut items = vec![];
if let Ok(issues) = cob::issue::all(repo) {
for (id, issue) in issues {
if let Ok(item) = IssueItem::try_from((context.profile(), repo, id, issue)) {
items.push(item);
}
}
}
items.sort_by(|a, b| b.timestamp().cmp(a.timestamp()));
items.sort_by(|a, b| b.state().cmp(a.state()));
let table = Widget::new(Table::new(&items, header, widths, theme.clone()))
.highlight(theme.colors.item_list_highlighted_bg);
Self {
items,
table,
shortcuts,
}
}
pub fn items(&self) -> &Vec<IssueItem> {
&self.items
}
}
impl WidgetComponent for IssueBrowser {
fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
let shortcuts_h = self
.shortcuts
.query(Attribute::Height)
.unwrap_or(AttrValue::Size(0))
.unwrap_size();
let focus = properties
.get_or(Attribute::Focus, AttrValue::Flag(false))
.unwrap_flag();
let layout = layout::root_component(area, shortcuts_h);
self.table.attr(Attribute::Focus, AttrValue::Flag(focus));
self.table.view(frame, layout[0]);
self.shortcuts.view(frame, layout[1])
}
fn state(&self) -> State {
self.table.state()
}
fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
self.table.perform(cmd)
}
}
pub struct PatchBrowser {
items: Vec<PatchItem>,
table: Widget<Table<PatchItem, 8>>,
shortcuts: Widget<Shortcuts>,
}
impl PatchBrowser {
pub fn new(context: &Context, theme: &Theme, shortcuts: Widget<Shortcuts>) -> Self {
let header = [
common::label(""),
common::label("ID"),
common::label("Title"),
common::label("Author"),
common::label("Head"),
common::label("+"),
common::label("-"),
common::label("Updated"),
];
let widths = [
ColumnWidth::Fixed(3),
ColumnWidth::Fixed(7),
ColumnWidth::Grow,
ColumnWidth::Fixed(21),
ColumnWidth::Fixed(7),
ColumnWidth::Fixed(4),
ColumnWidth::Fixed(4),
ColumnWidth::Fixed(18),
];
let repo = context.repository();
let mut items = vec![];
if let Ok(patches) = cob::patch::all(repo) {
for (id, patch) in patches {
if let Ok(item) = PatchItem::try_from((context.profile(), repo, id, patch)) {
items.push(item);
}
}
}
items.sort_by(|a, b| b.timestamp().cmp(a.timestamp()));
items.sort_by(|a, b| a.state().cmp(b.state()));
let table = Widget::new(Table::new(&items, header, widths, theme.clone()))
.highlight(theme.colors.item_list_highlighted_bg);
Self {
items,
table,
shortcuts,
}
}
pub fn items(&self) -> &Vec<PatchItem> {
&self.items
}
}
impl WidgetComponent for PatchBrowser {
fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
let shortcuts_h = self
.shortcuts
.query(Attribute::Height)
.unwrap_or(AttrValue::Size(0))
.unwrap_size();
let focus = properties
.get_or(Attribute::Focus, AttrValue::Flag(false))
.unwrap_flag();
let layout = layout::root_component(area, shortcuts_h);
self.table.attr(Attribute::Focus, AttrValue::Flag(focus));
self.table.view(frame, layout[0]);
self.shortcuts.view(frame, layout[1]);
}
fn state(&self) -> State {
self.table.state()
}
fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
self.table.perform(cmd)
}
}
pub fn navigation(theme: &Theme) -> Widget<Tabs> {
common::tabs(
theme,
vec![
common::reversable_label("dashboard").foreground(theme.colors.tabs_highlighted_fg),
common::reversable_label("issues").foreground(theme.colors.tabs_highlighted_fg),
common::reversable_label("patches").foreground(theme.colors.tabs_highlighted_fg),
],
)
}
pub fn dashboard(context: &Context, theme: &Theme) -> Widget<Dashboard> {
let about = common::labeled_container(
theme,
"about",
common::property_list(
theme,
vec![
common::property(theme, "id", &context.id().to_string()),
common::property(theme, "name", context.project().name()),
common::property(theme, "description", context.project().description()),
],
)
.to_boxed(),
);
let shortcuts = common::shortcuts(
theme,
vec![
common::shortcut(theme, "tab", "section"),
common::shortcut(theme, "q", "quit"),
],
);
let dashboard = Dashboard::new(about, shortcuts);
Widget::new(dashboard)
}
pub fn patches(context: &Context, theme: &Theme) -> Widget<PatchBrowser> {
let shortcuts = common::shortcuts(
theme,
vec![
common::shortcut(theme, "tab", "section"),
common::shortcut(theme, "↑/↓", "navigate"),
common::shortcut(theme, "enter", "show"),
common::shortcut(theme, "q", "quit"),
],
);
Widget::new(PatchBrowser::new(context, theme, shortcuts))
}
pub fn issues(context: &Context, theme: &Theme) -> Widget<IssueBrowser> {
let shortcuts = common::shortcuts(
theme,
vec![
common::shortcut(theme, "tab", "section"),
common::shortcut(theme, "↑/↓", "navigate"),
common::shortcut(theme, "enter", "show"),
common::shortcut(theme, "q", "quit"),
],
);
Widget::new(IssueBrowser::new(context, theme, shortcuts))
}

View File

@ -1,182 +0,0 @@
use radicle_cli::terminal::format;
use radicle::cob::issue::Issue;
use radicle::cob::issue::IssueId;
use radicle::Profile;
use tuirealm::props::Color;
use super::common::container::Container;
use super::common::container::LabeledContainer;
use super::common::list::List;
use super::common::list::Property;
use super::Widget;
use crate::ui::cob;
use crate::ui::cob::IssueItem;
use crate::ui::context::Context;
use crate::ui::theme::Theme;
use crate::ui::widget::common::context::ContextBar;
use super::*;
pub struct LargeList {
items: Vec<IssueItem>,
list: Widget<LabeledContainer>,
}
impl LargeList {
pub fn new(context: &Context, theme: &Theme, selected: Option<(IssueId, Issue)>) -> Self {
let repo = context.repository();
let issues = crate::cob::issue::all(repo).unwrap_or_default();
let mut items = issues
.iter()
.map(|(id, issue)| IssueItem::from((context.profile(), repo, *id, issue.clone())))
.collect::<Vec<_>>();
items.sort_by(|a, b| b.timestamp().cmp(a.timestamp()));
items.sort_by(|a, b| b.state().cmp(a.state()));
let selected =
selected.map(|(id, issue)| IssueItem::from((context.profile(), repo, id, issue)));
let list = Widget::new(List::new(&items, selected, theme.clone()))
.highlight(theme.colors.item_list_highlighted_bg);
let container = common::labeled_container(theme, "Issues", list.to_boxed());
Self {
items,
list: container,
}
}
pub fn items(&self) -> &Vec<IssueItem> {
&self.items
}
}
impl WidgetComponent for LargeList {
fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
let focus = properties
.get_or(Attribute::Focus, AttrValue::Flag(false))
.unwrap_flag();
self.list.attr(Attribute::Focus, AttrValue::Flag(focus));
self.list.view(frame, area);
}
fn state(&self) -> State {
State::None
}
fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
self.list.perform(cmd)
}
}
pub struct Details {
container: Widget<Container>,
}
impl Details {
pub fn new(context: &Context, theme: &Theme, issue: (IssueId, Issue)) -> Self {
let repo = context.repository();
let (id, issue) = issue;
let item = IssueItem::from((context.profile(), repo, id, issue));
let title = Property::new(
common::label("Title").foreground(theme.colors.property_name_fg),
common::label(item.title()).foreground(theme.colors.browser_list_title),
);
let labels = Property::new(
common::label("Labels").foreground(theme.colors.property_name_fg),
common::label(&cob::format_labels(item.labels()))
.foreground(theme.colors.browser_list_tags),
);
let assignees = Property::new(
common::label("Assignees").foreground(theme.colors.property_name_fg),
common::label(&cob::format_assignees(
&item
.assignees()
.iter()
.map(|item| (item.did(), item.is_you()))
.collect::<Vec<_>>(),
))
.foreground(theme.colors.browser_list_author),
);
let state = Property::new(
common::label("Status").foreground(theme.colors.property_name_fg),
common::label(&item.state().to_string()).foreground(theme.colors.browser_list_title),
);
let table = common::property_table(
theme,
vec![
Widget::new(title),
Widget::new(labels),
Widget::new(assignees),
Widget::new(state),
],
);
let container = common::container(theme, table.to_boxed());
Self { container }
}
}
impl WidgetComponent for Details {
fn view(&mut self, _properties: &Props, frame: &mut Frame, area: Rect) {
self.container.view(frame, area);
}
fn state(&self) -> State {
State::None
}
fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
CmdResult::None
}
}
pub fn list(context: &Context, theme: &Theme, issue: (IssueId, Issue)) -> Widget<LargeList> {
let list = LargeList::new(context, theme, Some(issue));
Widget::new(list)
}
pub fn details(context: &Context, theme: &Theme, issue: (IssueId, Issue)) -> Widget<Details> {
let details = Details::new(context, theme, issue);
Widget::new(details)
}
pub fn context(theme: &Theme, issue: (IssueId, &Issue), profile: &Profile) -> Widget<ContextBar> {
let (id, issue) = issue;
let is_you = *issue.author().id() == profile.did();
let id = format::cob(&id);
let title = issue.title();
let author = cob::format_author(issue.author().id(), is_you);
let comments = issue.comments().count();
let context = common::label(" issue ").background(theme.colors.context_badge_bg);
let id = common::label(&format!(" {id} "))
.foreground(theme.colors.context_id_fg)
.background(theme.colors.context_id_bg);
let title = common::label(&format!(" {title} "))
.foreground(theme.colors.default_fg)
.background(theme.colors.context_bg);
let author = common::label(&format!(" {author} "))
.foreground(theme.colors.context_id_author_fg)
.background(theme.colors.context_bg);
let comments = common::label(&format!(" {comments} "))
.foreground(Color::Rgb(70, 70, 70))
.background(theme.colors.context_light_bg);
let context_bar = ContextBar::new(context, id, author, title, comments);
Widget::new(context_bar).height(1)
}

View File

@ -1,202 +0,0 @@
use radicle::cob::patch::{Patch, PatchId};
use radicle::Profile;
use radicle_cli::terminal::format;
use tuirealm::command::{Cmd, CmdResult};
use tuirealm::props::Color;
use tuirealm::tui::layout::Rect;
use tuirealm::{AttrValue, Attribute, Frame, MockComponent, Props, State};
use super::{Widget, WidgetComponent};
use super::common;
use super::common::container::Tabs;
use super::common::context::{ContextBar, Shortcuts};
use super::common::label::Label;
use crate::ui::theme::Theme;
use crate::ui::{cob, layout};
pub struct Activity {
label: Widget<Label>,
context: Widget<ContextBar>,
shortcuts: Widget<Shortcuts>,
}
impl Activity {
pub fn new(
label: Widget<Label>,
context: Widget<ContextBar>,
shortcuts: Widget<Shortcuts>,
) -> Self {
Self {
label,
context,
shortcuts,
}
}
}
impl WidgetComponent for Activity {
fn view(&mut self, _properties: &Props, frame: &mut Frame, area: Rect) {
let label_w = self
.label
.query(Attribute::Width)
.unwrap_or(AttrValue::Size(1))
.unwrap_size();
let context_h = self
.context
.query(Attribute::Height)
.unwrap_or(AttrValue::Size(0))
.unwrap_size();
let shortcuts_h = self
.shortcuts
.query(Attribute::Height)
.unwrap_or(AttrValue::Size(0))
.unwrap_size();
let layout = layout::root_component_with_context(area, context_h, shortcuts_h);
self.label
.view(frame, layout::centered_label(label_w, layout[0]));
self.context.view(frame, layout[1]);
self.shortcuts.view(frame, layout[2]);
}
fn state(&self) -> State {
State::None
}
fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
CmdResult::None
}
}
pub struct Files {
label: Widget<Label>,
context: Widget<ContextBar>,
shortcuts: Widget<Shortcuts>,
}
impl Files {
pub fn new(
label: Widget<Label>,
context: Widget<ContextBar>,
shortcuts: Widget<Shortcuts>,
) -> Self {
Self {
label,
context,
shortcuts,
}
}
}
impl WidgetComponent for Files {
fn view(&mut self, _properties: &Props, frame: &mut Frame, area: Rect) {
let label_w = self
.label
.query(Attribute::Width)
.unwrap_or(AttrValue::Size(1))
.unwrap_size();
let context_h = self
.context
.query(Attribute::Height)
.unwrap_or(AttrValue::Size(0))
.unwrap_size();
let shortcuts_h = self
.shortcuts
.query(Attribute::Height)
.unwrap_or(AttrValue::Size(0))
.unwrap_size();
let layout = layout::root_component_with_context(area, context_h, shortcuts_h);
self.label
.view(frame, layout::centered_label(label_w, layout[0]));
self.context.view(frame, layout[1]);
self.shortcuts.view(frame, layout[2]);
}
fn state(&self) -> State {
State::None
}
fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
CmdResult::None
}
}
pub fn navigation(theme: &Theme) -> Widget<Tabs> {
common::tabs(
theme,
vec![
common::reversable_label("activity").foreground(theme.colors.tabs_highlighted_fg),
common::reversable_label("files").foreground(theme.colors.tabs_highlighted_fg),
],
)
}
pub fn activity(theme: &Theme, patch: (PatchId, &Patch), profile: &Profile) -> Widget<Activity> {
let (id, patch) = patch;
let shortcuts = common::shortcuts(
theme,
vec![
common::shortcut(theme, "esc", "back"),
common::shortcut(theme, "tab", "section"),
common::shortcut(theme, "q", "quit"),
],
);
let context = context(theme, (id, patch), profile);
let not_implemented = common::label("not implemented").foreground(theme.colors.default_fg);
let activity = Activity::new(not_implemented, context, shortcuts);
Widget::new(activity)
}
pub fn files(theme: &Theme, patch: (PatchId, &Patch), profile: &Profile) -> Widget<Activity> {
let (id, patch) = patch;
let shortcuts = common::shortcuts(
theme,
vec![
common::shortcut(theme, "esc", "back"),
common::shortcut(theme, "tab", "section"),
common::shortcut(theme, "q", "quit"),
],
);
let context = context(theme, (id, patch), profile);
let not_implemented = common::label("not implemented").foreground(theme.colors.default_fg);
let files = Activity::new(not_implemented, context, shortcuts);
Widget::new(files)
}
pub fn context(theme: &Theme, patch: (PatchId, &Patch), profile: &Profile) -> Widget<ContextBar> {
let (id, patch) = patch;
let (_, rev) = patch.latest();
let is_you = *patch.author().id() == profile.did();
let id = format::cob(&id);
let title = patch.title();
let author = cob::format_author(patch.author().id(), is_you);
let comments = rev.discussion().len();
let context = common::label(" patch ").background(theme.colors.context_badge_bg);
let id = common::label(&format!(" {id} "))
.foreground(theme.colors.context_id_fg)
.background(theme.colors.context_id_bg);
let title = common::label(&format!(" {title} "))
.foreground(theme.colors.default_fg)
.background(theme.colors.context_bg);
let author = common::label(&format!(" {author} "))
.foreground(theme.colors.context_id_author_fg)
.background(theme.colors.context_bg);
let comments = common::label(&format!(" {comments} "))
.foreground(Color::Rgb(70, 70, 70))
.background(theme.colors.context_light_bg);
let context_bar = ContextBar::new(context, id, author, title, comments);
Widget::new(context_bar).height(1)
}

View File

@ -1,43 +0,0 @@
use tuirealm::tui::layout::{Constraint, Rect};
use super::common::list::ColumnWidth;
/// Calculates `Constraint::Percentage` for each fixed column width in `widths`,
/// taking into account the available width in `area` and the column spacing given by `spacing`.
pub fn column_widths(area: Rect, widths: &[ColumnWidth], spacing: u16) -> Vec<Constraint> {
let total_spacing = spacing.saturating_mul(widths.len() as u16);
let fixed_width = widths
.iter()
.fold(0u16, |total, &width| match width {
ColumnWidth::Fixed(w) => total + w,
ColumnWidth::Grow => total,
})
.saturating_add(total_spacing);
let grow_count = widths.iter().fold(0u16, |count, &w| {
if w == ColumnWidth::Grow {
count + 1
} else {
count
}
});
let grow_width = area
.width
.saturating_sub(fixed_width)
.checked_div(grow_count)
.unwrap_or(0);
widths
.iter()
.map(|width| match width {
ColumnWidth::Fixed(w) => {
let p: f64 = *w as f64 / area.width as f64 * 100_f64;
Constraint::Percentage(p.ceil() as u16)
}
ColumnWidth::Grow => {
let p: f64 = grow_width as f64 / area.width as f64 * 100_f64;
Constraint::Percentage(p.floor() as u16)
}
})
.collect()
}