lib: Remove legacy tui-realm framework

This commit is contained in:
Erik Kundt
2024-04-04 15:58:05 +02:00
parent 74b576ca7c
commit 8e752468d1
44 changed files with 20 additions and 3990 deletions
Generated
+4 -177
View File
@@ -64,15 +64,6 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43f6cb1bf222025340178f382c426f13757b2960e89779dfcb319c32542a5a41"
dependencies = [
"memchr",
]
[[package]]
name = "allocator-api2"
version = "0.2.16"
@@ -914,15 +905,6 @@ dependencies = [
"phf",
]
[[package]]
name = "itertools"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57"
dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.12.1"
@@ -965,29 +947,6 @@ dependencies = [
"cpufeatures",
]
[[package]]
name = "lazy-regex"
version = "3.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57451d19ad5e289ff6c3d69c2a2424652995c42b79dafa11e9c4d5508c913c01"
dependencies = [
"lazy-regex-proc_macros",
"once_cell",
"regex",
]
[[package]]
name = "lazy-regex-proc_macros"
version = "3.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f0a1d9139f0ee2e862e08a9c5d0ba0470f2aa21cd1e1aa1b1562f83116c725f"
dependencies = [
"proc-macro2",
"quote",
"regex",
"syn 2.0.48",
]
[[package]]
name = "lazy_static"
version = "1.4.0"
@@ -1655,7 +1614,7 @@ dependencies = [
"radicle",
"radicle-surf",
"radicle-term",
"ratatui 0.26.1",
"ratatui",
"serde",
"serde_json",
"signal-hook",
@@ -1666,8 +1625,6 @@ dependencies = [
"timeago",
"tokio",
"tokio-stream",
"tui-realm-stdlib",
"tuirealm",
]
[[package]]
@@ -1699,23 +1656,6 @@ dependencies = [
"getrandom",
]
[[package]]
name = "ratatui"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e2e4cd95294a85c3b4446e63ef054eea43e0205b1fd60120c16b74ff7ff96ad"
dependencies = [
"bitflags 2.4.1",
"cassowary",
"indoc",
"itertools 0.11.0",
"paste",
"strum 0.25.0",
"termion 2.0.1",
"unicode-segmentation",
"unicode-width",
]
[[package]]
name = "ratatui"
version = "0.26.1"
@@ -1726,11 +1666,11 @@ dependencies = [
"cassowary",
"compact_str",
"indoc",
"itertools 0.12.1",
"itertools",
"lru",
"paste",
"stability",
"strum 0.26.1",
"strum",
"termion 3.0.0",
"time 0.3.34",
"unicode-segmentation",
@@ -1776,35 +1716,6 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20145670ba436b55d91fc92d25e71160fbfbdd57831631c8d7d36377a476f1cb"
[[package]]
name = "regex"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2eae68fc220f7cf2532e4494aded17545fce192d59cd996e0fe7887f4ceb575"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7b6d6190b7594385f61bd3911cd1be99dfddcfc365a4160cc2ab5bff4aed294"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5ea92a5b6195c6ef2a0295ea818b312502c6fc94dde986c5553242e18fd4ce2"
[[package]]
name = "rfc6979"
version = "0.4.0"
@@ -2141,35 +2052,13 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "strum"
version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125"
dependencies = [
"strum_macros 0.25.3",
]
[[package]]
name = "strum"
version = "0.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "723b93e8addf9aa965ebe2d11da6d7540fa2283fcea14b3371ff055f7ba13f5f"
dependencies = [
"strum_macros 0.26.1",
]
[[package]]
name = "strum_macros"
version = "0.25.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0"
dependencies = [
"heck",
"proc-macro2",
"quote",
"rustversion",
"syn 2.0.48",
"strum_macros",
]
[[package]]
@@ -2249,18 +2138,6 @@ dependencies = [
"redox_termios",
]
[[package]]
name = "termion"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "659c1f379f3408c7e5e84c7d0da6d93404e3800b6b9d063ba24436419302ec90"
dependencies = [
"libc",
"numtoa",
"redox_syscall 0.2.16",
"redox_termios",
]
[[package]]
name = "termion"
version = "3.0.0"
@@ -2423,56 +2300,6 @@ dependencies = [
"tokio",
]
[[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.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a14fa5a0376ef64b93b484f811716b73422803df91c9ce7f83c50cc391230426"
dependencies = [
"textwrap",
"tuirealm",
"unicode-width",
]
[[package]]
name = "tuirealm"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "412447298ad477c25ff50c4a894ff5077b6ee3e25b913d42db30021d81b1af53"
dependencies = [
"bitflags 2.4.1",
"lazy-regex",
"ratatui 0.23.0",
"termion 2.0.1",
"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.17.0"
+2 -9
View File
@@ -10,11 +10,6 @@ build = "build.rs"
name = "rad-tui"
path = "bin/main.rs"
[features]
default = ["flux"]
realm = ["dep:tuirealm", "dep:tui-realm-stdlib"]
flux = ["dep:tokio", "dep:tokio-stream"]
[dependencies]
anyhow = { version = "1" }
inquire = { version = "0.6.2", default-features = false, features = ["termion", "editor"] }
@@ -35,7 +30,5 @@ timeago = { version = "0.4.1" }
termion = { version = "3" }
textwrap = { version = "0.16.0" }
thiserror = { version = "1" }
tokio = { version = "1.32.0", features = ["full"], optional = true }
tokio-stream = { version = "0.1.14", optional = true }
tuirealm = { version = "^1.9.0", default-features = false, features = ["termion", "ratatui", "derive"], optional = true }
tui-realm-stdlib = { version = "1.3.1", default-features = false, features = ["termion", "ratatui"], optional = true }
tokio = { version = "1.32.0", features = ["full"] }
tokio-stream = { version = "0.1.14" }
View File
-3
View File
@@ -1,3 +0,0 @@
pub mod cob;
pub mod context;
pub mod log;
View File
-6
View File
@@ -1,6 +0,0 @@
pub mod event;
pub mod git;
pub mod store;
pub mod task;
pub mod terminal;
pub mod ui;
View File
+10 -8
View File
@@ -1,15 +1,17 @@
pub mod cob;
pub mod context;
pub mod event;
pub mod git;
pub mod log;
pub mod store;
pub mod task;
pub mod terminal;
pub mod ui;
use anyhow::Result;
use serde::ser::{Serialize, SerializeStruct, Serializer};
pub mod common;
#[cfg(feature = "realm")]
pub mod realm;
#[cfg(feature = "flux")]
pub mod flux;
/// An optional return value.
#[derive(Clone, Debug)]
pub struct Exit<T> {
View File
-211
View File
@@ -1,211 +0,0 @@
pub mod ui;
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};
use crate::common::context::Context;
use crate::Exit;
use ui::theme::Theme;
/// 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, Return>
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 `Some` if the application is requested to quit.
fn exit(&self) -> Option<Exit<Return>>;
}
/// 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 {
let terminal = TerminalBridge::new().expect("Cannot create terminal bridge");
Self { terminal }
}
/// 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, Return>(
&mut self,
tui: &mut T,
interval: u64,
) -> Result<Option<Return>>
where
T: Tui<Id, Message, Return>,
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.exit().is_none() {
if update || resize {
self.terminal
.raw_mut()
.draw(|frame| tui.view(&mut app, frame))?;
}
update = tui.update(&mut app)?;
resize = size != self.terminal.raw_mut().size()?;
size = self.terminal.raw_mut().size()?;
}
Ok(tui.exit().unwrap().value)
}
}
/// `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<Id, Message>
where
Id: Eq + PartialEq + Clone + Hash,
Message: Eq,
{
/// Will be called whenever a view page is pushed onto the page stack. Should create and mount all widgets.
fn mount(
&mut self,
app: &mut Application<Id, 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<Id, 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<Id, Message, NoUserEvent>,
context: &Context,
theme: &Theme,
message: Message,
) -> Result<Option<Message>>;
/// 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<Id, 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<Id, 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<Id, Message, NoUserEvent>) -> Result<()>;
}
/// 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<Id, Message>
where
Id: Eq + PartialEq + Clone + Hash,
Message: Eq,
{
pages: Vec<Box<dyn ViewPage<Id, Message>>>,
}
impl<Id, Message> PageStack<Id, Message>
where
Id: Eq + PartialEq + Clone + Hash,
Message: Eq,
{
pub fn push(
&mut self,
mut page: Box<dyn ViewPage<Id, Message>>,
app: &mut Application<Id, 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<Id, 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<Id, Message>>> {
match self.pages.last_mut() {
Some(page) => Ok(page),
None => Err(anyhow::anyhow!(
"Could not peek active page. Page stack is empty."
)),
}
}
}
-164
View File
@@ -1,164 +0,0 @@
pub mod cob;
pub mod ext;
pub mod layout;
pub mod state;
pub mod subscription;
pub mod theme;
pub mod widget;
use tuirealm::props::{AttrValue, Attribute};
use tuirealm::MockComponent;
use widget::container::{
AppHeader, AppInfo, Container, GlobalListener, Header, LabeledContainer, Popup, Tabs,
VerticalLine,
};
use widget::context::{Shortcut, Shortcuts};
use widget::label::{self, Label, Textarea};
use widget::list::{ColumnWidth, Property, PropertyList, PropertyTable};
use widget::Widget;
use theme::{style, Theme};
use crate::common::context::Context;
pub fn global_listener() -> Widget<GlobalListener> {
Widget::new(GlobalListener::default())
}
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::header(&format!(" {title} ")));
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::default(short).style(style::gray());
let long = label::default(long).style(style::gray_dim());
let divider = label::default(&theme.icons.whitespace.to_string());
// 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::default(&format!(" {} ", theme.icons.shortcutbar_divider)).style(style::gray_dim());
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::property(name);
let divider = label::default(&format!(" {} ", theme.icons.property_divider));
let value = label::default(value);
// 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) -> Widget<AppInfo> {
let project = label::default(context.project().name()).style(style::cyan());
let rid = label::default(&format!(" ({})", context.rid())).style(style::yellow());
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::default(&theme.icons.tab_overline.to_string()).style(style::magenta());
let line = Widget::new(VerticalLine::new(line));
let info = app_info(context);
let header = AppHeader::new(nav, info, line);
Widget::new(header)
}
pub fn info(theme: &Theme, message: &str) -> Widget<Popup> {
let textarea = Widget::new(Textarea::default()).content(AttrValue::String(message.to_owned()));
let container = labeled_container(theme, "Info", textarea.to_boxed());
Widget::new(Popup::new(theme.clone(), container))
.width(50)
.height(20)
}
pub fn warning(theme: &Theme, message: &str) -> Widget<Popup> {
let textarea = Widget::new(Textarea::default()).content(AttrValue::String(message.to_owned()));
let container = labeled_container(theme, "Warning", textarea.to_boxed());
Widget::new(Popup::new(theme.clone(), container))
.width(50)
.height(20)
}
pub fn error(theme: &Theme, message: &str) -> Widget<Popup> {
let textarea = Widget::new(Textarea::default()).content(AttrValue::String(message.to_owned()));
let container = labeled_container(theme, "Error", textarea.to_boxed());
Widget::new(Popup::new(theme.clone(), container))
.width(50)
.height(20)
}
-615
View File
@@ -1,615 +0,0 @@
pub mod format;
use anyhow::anyhow;
use radicle_surf;
use tuirealm::props::{Color, Style};
use tuirealm::tui::text::Line;
use tuirealm::tui::widgets::Cell;
use radicle::cob::issue::{self, Issue, IssueId};
use radicle::cob::patch::{self, Patch, PatchId};
use radicle::cob::{Label, ObjectId, Timestamp};
use radicle::issue::Issues;
use radicle::node::notifications::{Notification, NotificationId, NotificationKind};
use radicle::node::{Alias, AliasStore};
use radicle::patch::Patches;
use radicle::prelude::Did;
use radicle::storage::git::Repository;
use radicle::storage::{Oid, ReadRepository, RefUpdate};
use radicle::{cob, Profile};
use super::super::ui::theme::Theme;
use super::super::ui::widget::list::{ListItem, TableItem};
use super::widget::label;
/// 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,
/// The author's alias
alias: Option<Alias>,
/// 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
}
pub fn alias(&self) -> Option<Alias> {
self.alias.clone()
}
}
/// 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: patch::State,
/// 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) -> &patch::State {
&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 PartialEq for PatchItem {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
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)?;
let author = patch.author().id;
Ok(PatchItem {
id,
state: patch.state().clone(),
title: patch.title().into(),
author: AuthorItem {
did: author,
alias: profile.aliases().alias(&author),
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, highlight: bool) -> [Cell; 8] {
let (icon, color) = format_patch_state(&self.state);
if highlight {
let state = label::reversed(&icon).into();
let id = label::reversed(&format::cob(&self.id)).into();
let title = label::reversed(&self.title.clone()).into();
let author = label::reversed(&format_author(
&self.author.did,
&self.author.alias,
self.author.is_you,
))
.into();
let head = label::reversed(&format::oid(self.head)).into();
let added = label::reversed(&format!("+{}", self.added)).into();
let removed = label::reversed(&format!("-{}", self.removed)).into();
let updated = label::reversed(&format::timestamp(&self.timestamp)).into();
[state, id, title, author, head, added, removed, updated]
} else {
let state = label::default(&icon)
.style(Style::default().fg(color))
.into();
let id = label::id(&format::cob(&self.id)).into();
let title = label::default(&self.title.clone()).into();
let author = match &self.author.alias {
Some(_) => label::alias(&format_author(
&self.author.did,
&self.author.alias,
self.author.is_you,
))
.into(),
None => label::did(&format_author(
&self.author.did,
&self.author.alias,
self.author.is_you,
))
.into(),
};
let head = label::oid(&format::oid(self.head)).into();
let added = label::positive(&format!("+{}", self.added)).into();
let removed = label::negative(&format!("-{}", self.removed)).into();
let updated = label::timestamp(&format::timestamp(&self.timestamp)).into();
[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: issue::State,
/// 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) -> &issue::State {
&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;
let author = issue.author().id;
IssueItem {
id,
state: *issue.state(),
title: issue.title().into(),
author: AuthorItem {
did: issue.author().id,
alias: profile.aliases().alias(&author),
is_you: *issue.author().id == *profile.did(),
},
labels: issue.labels().cloned().collect(),
assignees: issue
.assignees()
.map(|did| AuthorItem {
did: *did,
alias: profile.aliases().alias(did),
is_you: *did == profile.did(),
})
.collect::<Vec<_>>(),
timestamp: issue.timestamp(),
}
}
}
impl TableItem<7> for IssueItem {
fn row(&self, _theme: &Theme, highlight: bool) -> [Cell; 7] {
let (icon, color) = format_issue_state(&self.state);
if highlight {
let state = label::reversed(&icon).into();
let id = label::reversed(&format::cob(&self.id)).into();
let title = label::reversed(&self.title.clone()).into();
let author = label::reversed(&format_author(
&self.author.did,
&self.author.alias,
self.author.is_you,
))
.into();
let labels = label::reversed(&format_labels(&self.labels)).into();
let assignees = self
.assignees
.iter()
.map(|author| (author.did, author.alias.clone(), author.is_you))
.collect::<Vec<_>>();
let assignees = label::reversed(&format_assignees(&assignees)).into();
let opened = label::reversed(&format::timestamp(&self.timestamp)).into();
[state, id, title, author, labels, assignees, opened]
} else {
let state = label::default(&icon)
.style(Style::default().fg(color))
.into();
let id = label::id(&format::cob(&self.id)).into();
let title = label::default(&self.title.clone()).into();
let author = match &self.author.alias {
Some(_) => label::alias(&format_author(
&self.author.did,
&self.author.alias,
self.author.is_you,
))
.into(),
None => label::did(&format_author(
&self.author.did,
&self.author.alias,
self.author.is_you,
))
.into(),
};
let labels = label::labels(&format_labels(&self.labels)).into();
let assignees = self
.assignees
.iter()
.map(|author| (author.did, author.alias.clone(), author.is_you))
.collect::<Vec<_>>();
let assignees = label::did(&format_assignees(&assignees)).into();
let opened = label::timestamp(&format::timestamp(&self.timestamp)).into();
[state, id, title, author, labels, 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![
Line::from(vec![
label::default(&state)
.style(Style::default().fg(state_color))
.into(),
label::title(&self.title).into(),
]),
Line::from(vec![
label::default(" ").into(),
match &self.author.alias {
Some(_) => label::alias(&format_author(
&self.author.did,
&self.author.alias,
self.author.is_you,
))
.into(),
None => label::did(&format_author(
&self.author.did,
&self.author.alias,
self.author.is_you,
))
.into(),
},
label::property_divider(&format!(" {} ", theme.icons.property_divider)).into(),
label::timestamp(&format::timestamp(&self.timestamp)).into(),
]),
];
tuirealm::tui::widgets::ListItem::new(lines)
}
}
impl PartialEq for IssueItem {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
//////////////////////////////////////////////////////
#[derive(Clone)]
pub enum NotificationKindItem {
Branch {
name: String,
summary: String,
status: String,
id: Option<ObjectId>,
},
Cob {
type_name: String,
summary: String,
status: String,
id: Option<ObjectId>,
},
}
impl TryFrom<(&Repository, NotificationKind, RefUpdate)> for NotificationKindItem {
type Error = anyhow::Error;
fn try_from(value: (&Repository, NotificationKind, RefUpdate)) -> Result<Self, Self::Error> {
let (repo, kind, update) = value;
let issues = Issues::open(repo)?;
let patches = Patches::open(repo)?;
match kind {
NotificationKind::Branch { name } => {
let (head, message) = if let Some(head) = update.new() {
let message = repo.commit(head)?.summary().unwrap_or_default().to_owned();
(Some(head), message)
} else {
(None, String::new())
};
let status = match update {
RefUpdate::Updated { .. } => "updated",
RefUpdate::Created { .. } => "created",
RefUpdate::Deleted { .. } => "deleted",
RefUpdate::Skipped { .. } => "skipped",
};
Ok(NotificationKindItem::Branch {
name: name.to_string(),
summary: message,
status: status.to_string(),
id: head.map(ObjectId::from),
})
}
NotificationKind::Cob { type_name, id } => {
let (category, summary) = if type_name == *cob::issue::TYPENAME {
let issue = issues.get(&id)?.ok_or(anyhow!("missing"))?;
(String::from("issue"), issue.title().to_owned())
} else if type_name == *cob::patch::TYPENAME {
let patch = patches.get(&id)?.ok_or(anyhow!("missing"))?;
(String::from("patch"), patch.title().to_owned())
} else {
(type_name.to_string(), "".to_owned())
};
let status = match update {
RefUpdate::Updated { .. } => "updated",
RefUpdate::Created { .. } => "opened",
RefUpdate::Deleted { .. } => "deleted",
RefUpdate::Skipped { .. } => "skipped",
};
Ok(NotificationKindItem::Cob {
type_name: category.to_string(),
summary: summary.to_string(),
status: status.to_string(),
id: Some(id),
})
}
}
}
}
#[derive(Clone)]
pub struct NotificationItem {
/// Unique notification ID.
pub id: NotificationId,
/// Mark this notification as seen.
pub seen: bool,
/// Wrapped notification kind.
pub kind: NotificationKindItem,
/// Time the update has happened.
timestamp: Timestamp,
}
impl NotificationItem {
pub fn id(&self) -> &NotificationId {
&self.id
}
pub fn seen(&self) -> bool {
self.seen
}
pub fn kind(&self) -> &NotificationKindItem {
&self.kind
}
pub fn timestamp(&self) -> &Timestamp {
&self.timestamp
}
}
impl TableItem<7> for NotificationItem {
fn row(&self, _theme: &Theme, highlight: bool) -> [Cell; 7] {
let seen = if self.seen {
label::blank()
} else {
label::positive(" ● ")
};
let (type_name, summary, status, id) = match &self.kind() {
NotificationKindItem::Branch {
name,
summary,
status,
id: _,
} => ("branch".to_string(), summary, status, name.to_string()),
NotificationKindItem::Cob {
type_name,
summary,
status,
id,
} => {
let id = id.map(|id| format::cob(&id)).unwrap_or_default();
(type_name.to_string(), summary, status, id.to_string())
}
};
let timestamp = if highlight {
label::reversed(&format::timestamp(&self.timestamp))
} else {
label::timestamp(&format::timestamp(&self.timestamp))
};
[
label::default(&format!(" {}", &self.id)).into(),
seen.into(),
label::alias(&type_name).into(),
label::default(summary).into(),
label::id(&id).into(),
label::default(status).into(),
timestamp.into(),
]
}
}
impl TryFrom<(&Repository, Notification)> for NotificationItem {
type Error = anyhow::Error;
fn try_from(value: (&Repository, Notification)) -> Result<Self, Self::Error> {
let (repo, notification) = value;
let kind = NotificationKindItem::try_from((repo, notification.kind, notification.update))?;
Ok(NotificationItem {
id: notification.id,
seen: notification.status.is_read(),
kind,
timestamp: notification.timestamp.into(),
})
}
}
impl PartialEq for NotificationItem {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
pub fn format_patch_state(state: &patch::State) -> (String, Color) {
match state {
patch::State::Open { conflicts: _ } => (" ● ".into(), Color::Green),
patch::State::Archived => (" ● ".into(), Color::Yellow),
patch::State::Draft => (" ● ".into(), Color::Gray),
patch::State::Merged {
revision: _,
commit: _,
} => (" ● ".into(), Color::Cyan),
}
}
pub fn format_author(did: &Did, alias: &Option<Alias>, is_you: bool) -> String {
let author = match alias {
Some(alias) => format!("{alias}"),
None => format::did(did),
};
if is_you {
format!("{} (you)", author)
} else {
author
}
}
pub fn format_issue_state(state: &issue::State) -> (String, Color) {
match state {
issue::State::Open => (" ● ".into(), Color::Green),
issue::State::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(label) = labels.next() {
output.push_str(&label.to_string());
if labels.peek().is_some() {
output.push(',');
}
}
output
}
pub fn format_assignees(assignees: &[(Did, Option<Alias>, bool)]) -> String {
let mut output = String::new();
let mut assignees = assignees.iter().peekable();
while let Some((assignee, alias, is_you)) = assignees.next() {
output.push_str(&format_author(assignee, alias, *is_you));
if assignees.peek().is_some() {
output.push(',');
}
}
output
}
-27
View File
@@ -1,27 +0,0 @@
use radicle::cob::{ObjectId, Timestamp};
use radicle::prelude::Did;
/// Format a git Oid.
pub fn oid(oid: impl Into<radicle::git::Oid>) -> String {
format!("{:.7}", oid.into())
}
/// Format a COB id.
pub fn cob(id: &ObjectId) -> String {
format!("{:.7}", id.to_string())
}
/// Format a DID.
pub fn did(did: &Did) -> String {
let nid = did.as_key().to_human();
format!("{}…{}", &nid[..7], &nid[nid.len() - 7..])
}
/// Format a timestamp.
pub fn timestamp(time: &Timestamp) -> String {
let fmt = timeago::Formatter::new();
let now = Timestamp::now();
let duration = std::time::Duration::from_secs(now.as_secs() - time.as_secs());
fmt.convert(duration)
}
-113
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);
}
}
}
-278
View File
@@ -1,278 +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 FullPage {
pub navigation: Rect,
pub component: Rect,
pub context: Rect,
pub shortcuts: Rect,
}
pub struct DefaultPage {
pub component: Rect,
pub context: Rect,
pub shortcuts: Rect,
}
pub struct IssuePage {
pub header: Rect,
pub left: Rect,
pub right: Rect,
pub context: 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)
.to_vec();
widgets.into_iter().zip(layout).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)
.to_vec();
widgets.into_iter().zip(layout).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 full_page(area: Rect, context_h: u16, shortcuts_h: u16) -> FullPage {
let nav_h = 3u16;
let margin_h = 1u16;
let component_h = area
.height
.saturating_sub(nav_h.saturating_add(context_h).saturating_add(shortcuts_h));
let layout = Layout::default()
.direction(Direction::Vertical)
.horizontal_margin(margin_h)
.constraints(
[
Constraint::Length(nav_h),
Constraint::Length(component_h),
Constraint::Length(context_h),
Constraint::Length(shortcuts_h),
]
.as_ref(),
)
.split(area);
FullPage {
navigation: layout[0],
component: layout[1],
context: layout[2],
shortcuts: layout[3],
}
}
pub fn default_page(area: Rect, context_h: u16, shortcuts_h: u16) -> DefaultPage {
let margin_h = 1u16;
let component_h = area
.height
.saturating_sub(context_h.saturating_add(shortcuts_h));
let layout = Layout::default()
.direction(Direction::Vertical)
.horizontal_margin(margin_h)
.constraints(
[
Constraint::Length(component_h),
Constraint::Length(context_h),
Constraint::Length(shortcuts_h),
]
.as_ref(),
)
.split(area);
DefaultPage {
component: layout[0],
context: layout[1],
shortcuts: layout[2],
}
}
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)
.to_vec()
}
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)
.to_vec()
}
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 centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints(
[
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
]
.as_ref(),
)
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints(
[
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
]
.as_ref(),
)
.split(popup_layout[1])[1]
}
pub fn issue_page(area: Rect, shortcuts_h: u16) -> IssuePage {
let header_h = 3u16;
let context_h = 1u16;
let margin_h = 1u16;
let content_h = area
.height
.saturating_sub(header_h.saturating_add(context_h.saturating_add(shortcuts_h)));
let root = Layout::default()
.direction(Direction::Vertical)
.horizontal_margin(margin_h)
.constraints(
[
Constraint::Length(header_h),
Constraint::Length(content_h),
Constraint::Length(context_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]);
IssuePage {
header: root[0],
left: split[0],
right: split[1],
context: root[2],
shortcuts: root[3],
}
}
-163
View File
@@ -1,163 +0,0 @@
use anyhow::anyhow;
use tuirealm::tui::widgets::{ListState, TableState};
use tuirealm::{State, StateValue};
/// 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> {
if !self.is_empty() {
self.selected
} else {
None
}
}
pub fn select_previous(&mut self) -> Option<usize> {
let old_index = self.selected();
let new_index = match old_index {
Some(0) | None => Some(0),
Some(selected) => Some(selected.saturating_sub(1)),
};
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
}
}
pub fn len(&self) -> usize {
self.len
}
pub fn is_empty(&self) -> bool {
self.len == 0
}
}
impl TryFrom<State> for ItemState {
type Error = anyhow::Error;
fn try_from(state: State) -> Result<Self, Self::Error> {
match state {
State::Tup2((StateValue::Usize(selected), StateValue::Usize(len))) => Ok(Self {
selected: Some(selected),
len,
}),
_ => Err(anyhow!(format!(
"Cannot convert into item state: {:?}",
state
))),
}
}
}
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
}
}
#[derive(Clone)]
pub struct FormState {
focus: Option<usize>,
len: usize,
}
impl FormState {
pub fn new(focus: Option<usize>, len: usize) -> Self {
Self { focus, len }
}
pub fn focus(&self) -> Option<usize> {
self.focus
}
pub fn focus_previous(&mut self) -> Option<usize> {
let old_index = self.focus();
let new_index = match old_index {
Some(0) | None => Some(0),
Some(focus) => Some(focus.saturating_sub(1)),
};
if old_index != new_index {
self.focus = new_index;
self.focus()
} else {
None
}
}
pub fn focus_next(&mut self) -> Option<usize> {
let old_index = self.focus();
let new_index = match old_index {
Some(focus) if focus >= self.len.saturating_sub(1) => Some(self.len.saturating_sub(1)),
Some(focus) => Some(focus.saturating_add(1)),
None => Some(0),
};
if old_index != new_index {
self.focus = new_index;
self.focus()
} else {
None
}
}
}
-22
View File
@@ -1,22 +0,0 @@
use tuirealm::event::{Key, KeyEvent, KeyModifiers};
use tuirealm::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 quit_clause<UserEvent>(key: Key) -> SubEventClause<UserEvent>
where
UserEvent: Clone + Eq + PartialEq + PartialOrd,
{
SubEventClause::Keyboard(KeyEvent {
code: key,
modifiers: KeyModifiers::NONE,
})
}
-147
View File
@@ -1,147 +0,0 @@
use tuirealm::props::BorderType;
#[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. In the future, it might be defined in a JSON
/// config file.
#[derive(Debug, Clone)]
pub struct Theme {
pub name: String,
pub icons: Icons,
pub tables: Tables,
pub border_type: BorderType,
}
impl Default for Theme {
fn default() -> Theme {
Theme {
name: String::from("Default"),
icons: Icons {
property_divider: '∙',
shortcutbar_divider: '∙',
tab_divider: '|',
tab_overline: '▔',
whitespace: ' ',
},
tables: Tables { spacing: 2 },
border_type: BorderType::Rounded,
}
}
}
pub mod style {
use tuirealm::props::{Color, Style, TextModifiers};
pub fn reset() -> Style {
Style::default().fg(Color::Reset)
}
pub fn reset_dim() -> Style {
Style::default()
.fg(Color::Reset)
.add_modifier(TextModifiers::DIM)
}
pub fn red() -> Style {
Style::default().fg(Color::Red)
}
pub fn green() -> Style {
Style::default().fg(Color::Green)
}
pub fn yellow() -> Style {
Style::default().fg(Color::Yellow)
}
pub fn yellow_dim() -> Style {
yellow().add_modifier(TextModifiers::DIM)
}
pub fn yellow_dim_reversed() -> Style {
yellow_dim().add_modifier(TextModifiers::REVERSED)
}
pub fn blue() -> Style {
Style::default().fg(Color::Blue)
}
pub fn magenta() -> Style {
Style::default().fg(Color::Magenta)
}
pub fn magenta_dim() -> Style {
Style::default()
.fg(Color::Magenta)
.add_modifier(TextModifiers::DIM)
}
pub fn cyan() -> Style {
Style::default().fg(Color::Cyan)
}
pub fn lightblue() -> Style {
Style::default().fg(Color::LightBlue)
}
pub fn gray() -> Style {
Style::default().fg(Color::Gray)
}
pub fn gray_dim() -> Style {
Style::default()
.fg(Color::Gray)
.add_modifier(TextModifiers::DIM)
}
pub fn darkgray() -> Style {
Style::default().fg(Color::DarkGray)
}
pub fn reversed() -> Style {
Style::default().add_modifier(TextModifiers::REVERSED)
}
pub fn default_reversed() -> Style {
Style::default()
.fg(Color::DarkGray)
// .add_modifier(TextModifiers::DIM)
.add_modifier(TextModifiers::REVERSED)
}
pub fn magenta_reversed() -> Style {
Style::default()
.fg(Color::Magenta)
.add_modifier(TextModifiers::REVERSED)
}
pub fn yellow_reversed() -> Style {
Style::default().fg(Color::DarkGray).bg(Color::Yellow)
}
pub fn border(focus: bool) -> Style {
if focus {
gray_dim()
} else {
darkgray()
}
}
pub fn highlight() -> Style {
Style::default()
.fg(Color::Cyan)
.add_modifier(TextModifiers::REVERSED)
}
}
-102
View File
@@ -1,102 +0,0 @@
pub mod container;
pub mod context;
pub mod form;
pub mod label;
pub mod list;
mod utils;
use std::ops::Deref;
use tuirealm::command::{Cmd, CmdResult};
use tuirealm::props::{AttrValue, Attribute, Layout, Props, Style};
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 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 layout(mut self, layout: Layout) -> Self {
self.attr(Attribute::Layout, AttrValue::Layout(layout));
self
}
pub fn style(mut self, style: Style) -> Self {
self.attr(Attribute::Style, AttrValue::Style(style));
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)
}
}
-186
View File
@@ -1,186 +0,0 @@
pub mod container;
pub mod context;
pub mod form;
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, Popup, VerticalLine};
use self::label::Textarea;
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)
.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)
}
pub fn info(theme: &Theme, message: &str) -> Widget<Popup> {
let textarea =
Widget::new(Textarea::new(theme.clone())).content(AttrValue::String(message.to_owned()));
let container = labeled_container(theme, "Info", textarea.to_boxed());
Widget::new(Popup::new(theme.clone(), container))
.width(50)
.height(20)
}
pub fn warning(theme: &Theme, message: &str) -> Widget<Popup> {
let textarea =
Widget::new(Textarea::new(theme.clone())).content(AttrValue::String(message.to_owned()));
let container = labeled_container(theme, "Warning", textarea.to_boxed());
Widget::new(Popup::new(theme.clone(), container))
.width(50)
.height(20)
}
pub fn error(theme: &Theme, message: &str) -> Widget<Popup> {
let textarea =
Widget::new(Textarea::new(theme.clone())).content(AttrValue::String(message.to_owned()));
let container = labeled_container(theme, "Error", textarea.to_boxed());
Widget::new(Popup::new(theme.clone(), container))
.width(50)
.height(20)
}
-484
View File
@@ -1,484 +0,0 @@
use tuirealm::command::{Cmd, CmdResult};
use tuirealm::props::{AttrValue, Attribute, BorderSides, Props, TextModifiers};
use tuirealm::tui::layout::{Constraint, Direction, Layout, Margin, Rect};
use tuirealm::tui::widgets::{Block, Cell, Clear, Row};
use tuirealm::{Frame, MockComponent, State, StateValue};
use crate::realm::ui::ext::HeaderBlock;
use crate::realm::ui::layout;
use crate::realm::ui::state::TabState;
use crate::realm::ui::theme::{style, Theme};
use crate::realm::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).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();
if display {
let block = HeaderBlock::default()
.borders(BorderSides::all())
.border_style(style::border(focus))
.border_type(self.theme.border_type);
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::reset())
})
.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();
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)])
.split(area);
// reverse draw order: child needs to be drawn first?
self.component
.attr(Attribute::Focus, AttrValue::Flag(focus));
self.component.view(frame, layout[1]);
let block = Block::default()
.borders(BorderSides::ALL)
.border_style(style::border(focus))
.border_type(self.theme.border_type);
frame.render_widget(block, area);
}
}
fn state(&self) -> State {
self.component.state()
}
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 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::Min(1)].as_ref())
.split(area);
self.header.attr(Attribute::Focus, AttrValue::Flag(focus));
self.header.view(frame, layout[0]);
let block = Block::default()
.borders(BorderSides::BOTTOM | BorderSides::LEFT | BorderSides::RIGHT)
.border_style(style::border(focus))
.border_type(self.theme.border_type);
frame.render_widget(block.clone(), layout[1]);
self.component
.attr(Attribute::Focus, AttrValue::Flag(focus));
self.component.view(
frame,
block.inner(layout[1]).inner(&Margin {
vertical: 0,
horizontal: 1,
}),
);
}
}
fn state(&self) -> State {
self.component.state()
}
fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
self.component.perform(cmd)
}
}
pub struct Popup {
component: Widget<LabeledContainer>,
}
impl Popup {
pub fn new(_theme: Theme, component: Widget<LabeledContainer>) -> Self {
Self { component }
}
}
impl WidgetComponent for Popup {
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 width = properties
.get_or(Attribute::Width, AttrValue::Size(50))
.unwrap_size();
let height = properties
.get_or(Attribute::Height, AttrValue::Size(50))
.unwrap_size();
if display {
let size = frame.size();
let area = layout::centered_rect(width, height, size);
frame.render_widget(Clear, area);
self.component
.attr(Attribute::Focus, AttrValue::Flag(focus));
self.component.view(frame, area);
}
}
fn state(&self) -> State {
State::None
}
fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
self.component.perform(cmd)
}
}
-233
View File
@@ -1,233 +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::{self, Label, LabelGroup};
use crate::realm::ui::layout;
use crate::realm::ui::theme::{style, Theme};
use crate::realm::ui::widget::{Widget, WidgetComponent};
pub enum Progress {
Percentage(usize),
Step(usize, usize),
None,
}
impl ToString for Progress {
fn to_string(&self) -> std::string::String {
match self {
Progress::Percentage(value) => format!("{value} %"),
Progress::Step(step, total) => format!("{step}/{total}"),
_ => String::new(),
}
}
}
/// 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.
#[derive(Clone)]
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 {
col_0: Widget<LabelGroup>,
col_1: Widget<LabelGroup>,
col_2: Widget<LabelGroup>,
col_3: Widget<LabelGroup>,
col_4: Widget<LabelGroup>,
}
impl ContextBar {
pub const PROP_EDIT_MODE: &'static str = "edit-mode";
pub fn new(
col_0: Widget<LabelGroup>,
col_1: Widget<LabelGroup>,
col_2: Widget<LabelGroup>,
col_3: Widget<LabelGroup>,
col_4: Widget<LabelGroup>,
) -> Self {
Self {
col_0,
col_1,
col_2,
col_3,
col_4,
}
}
}
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 edit_mode = properties
.get_or(
Attribute::Custom(Self::PROP_EDIT_MODE),
AttrValue::Flag(false),
)
.unwrap_flag();
let col_0_w = self.col_0.query(Attribute::Width).unwrap().unwrap_size();
let col_1_w = self.col_1.query(Attribute::Width).unwrap().unwrap_size();
let col_3_w = self.col_3.query(Attribute::Width).unwrap().unwrap_size();
let col_4_w = self.col_4.query(Attribute::Width).unwrap().unwrap_size();
if edit_mode {
self.col_0.attr(
Attribute::Background,
AttrValue::Color(style::yellow_reversed().bg.unwrap()),
)
}
if display {
let content_layout = layout::h_stack(
vec![
self.col_0.clone().to_boxed(),
self.col_1.clone().to_boxed(),
self.col_2
.clone()
.width(
area.width
.saturating_sub(col_0_w + col_1_w + col_3_w + col_4_w),
)
.to_boxed(),
self.col_3.clone().to_boxed(),
self.col_4.clone().to_boxed(),
],
area,
);
for (mut component, area) in content_layout {
component.view(frame, area);
}
}
}
fn state(&self) -> State {
State::None
}
fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
CmdResult::None
}
}
pub fn bar(
_theme: &Theme,
label_0: &str,
label_1: &str,
label_2: &str,
label_3: &str,
label_4: &str,
) -> Widget<ContextBar> {
let label_0 = label::badge(&format!(" {label_0} "));
let label_1 = label::default_reversed(&format!(" {label_1} "));
let label_2 = label::default_reversed(&format!(" {label_2} "));
let label_3 = label::default_reversed(&format!(" {label_3} "));
let label_4 = label::default_reversed(&format!(" {label_4} "));
let label_0 = label::group(&[label_0]);
let label_1 = label::group(&[label_1]);
let label_2 = label::group(&[label_2]);
let label_3 = label::group(&[label_3]);
let label_4 = label::group(&[label_4]);
let context_bar = ContextBar::new(label_0, label_1, label_2, label_3, label_4);
Widget::new(context_bar).height(1)
}
-268
View File
@@ -1,268 +0,0 @@
use std::collections::LinkedList;
use tuirealm::command::{Cmd, CmdResult};
use tuirealm::tui::layout::{Constraint, Direction, Margin, Rect};
use tuirealm::{AttrValue, Attribute, Frame, MockComponent, Props, State, StateValue};
use crate::realm::ui::state::FormState;
use crate::realm::ui::theme::{style, Theme};
use crate::realm::ui::widget::{Widget, WidgetComponent};
use super::container::Container;
use super::label::{self, Label};
pub struct TextField {
input: Widget<Container>,
placeholder: Widget<Label>,
show_placeholder: bool,
}
impl TextField {
pub fn new(theme: Theme, title: &str) -> Self {
// TODO: activate again
// let input = tui_realm_textarea::TextArea::default()
// .wrap(false)
// .single_line(true)
// .cursor_line_style(Style::reset())
// .style(style::reset());
let input = tui_realm_stdlib::Textarea::default();
let container = crate::realm::ui::container(&theme, Box::new(input));
Self {
input: container,
placeholder: label::default(title).style(style::gray_dim()),
show_placeholder: true,
}
}
}
impl WidgetComponent for TextField {
fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
let focus = properties
.get_or(Attribute::Focus, AttrValue::Flag(false))
.unwrap_flag();
self.input.attr(Attribute::Focus, AttrValue::Flag(focus));
self.input.view(frame, area);
if self.show_placeholder {
let inner = area.inner(&Margin {
vertical: 1,
horizontal: 2,
});
self.placeholder.view(frame, inner);
}
}
fn state(&self) -> State {
if let State::Vec(values) = self.input.state() {
let text = match values.get(0) {
Some(StateValue::String(line)) => line.clone(),
_ => String::new(),
};
State::One(StateValue::String(text))
} else {
State::None
}
}
fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
// TODO: activate again
// use tui_realm_textarea::*;
// let cmd = match cmd {
// Cmd::Custom(Form::CMD_PASTE) => Cmd::Custom(TEXTAREA_CMD_PASTE),
// _ => cmd,
// };
// let result = self.input.perform(cmd);
// if let State::Vec(values) = self.input.state() {
// if let Some(StateValue::String(input)) = values.first() {
// self.show_placeholder = values.len() == 1 && input.is_empty();
// } else {
// self.show_placeholder = false;
// }
// }
// result
CmdResult::None
}
}
pub struct TextArea {
input: Widget<Container>,
placeholder: Widget<Label>,
show_placeholder: bool,
}
impl TextArea {
pub fn new(theme: Theme, title: &str) -> Self {
// TODO: activate again
// let input = tui_realm_textarea::TextArea::default()
// .wrap(true)
// .single_line(false)
// .cursor_line_style(Style::reset())
// .style(style::reset());
let input = tui_realm_stdlib::Textarea::default();
let container = crate::realm::ui::container(&theme, Box::new(input));
Self {
input: container,
placeholder: label::default(title).style(style::gray_dim()),
show_placeholder: true,
}
}
}
impl WidgetComponent for TextArea {
fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
let focus = properties
.get_or(Attribute::Focus, AttrValue::Flag(false))
.unwrap_flag();
self.input.attr(Attribute::Focus, AttrValue::Flag(focus));
self.input.view(frame, area);
if self.show_placeholder {
let inner = area.inner(&Margin {
vertical: 1,
horizontal: 2,
});
self.placeholder.view(frame, inner);
}
}
fn state(&self) -> State {
// Fold each input's vector of lines into a single string.
if let State::Vec(values) = self.input.state() {
let mut text = String::new();
let lines = values
.iter()
.map(|value| match value {
StateValue::String(line) => line.clone(),
_ => String::new(),
})
.collect::<Vec<_>>();
let mut lines = lines.iter().peekable();
while let Some(line) = lines.next() {
text.push_str(line);
if lines.peek().is_some() {
text.push('\n');
}
}
State::One(StateValue::String(text))
} else {
State::None
}
}
fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
// TODO: activate again
// use tui_realm_textarea::*;
// let cmd = match cmd {
// Cmd::Custom(Form::CMD_PASTE) => Cmd::Custom(TEXTAREA_CMD_PASTE),
// Cmd::Custom(Form::CMD_NEWLINE) => Cmd::Custom(TEXTAREA_CMD_NEWLINE),
// _ => cmd,
// };
// let result = self.input.perform(cmd);
// if let State::Vec(values) = self.input.state() {
// if let Some(StateValue::String(input)) = values.first() {
// self.show_placeholder = values.len() == 1 && input.is_empty();
// } else {
// self.show_placeholder = false;
// }
// }
// result
CmdResult::None
}
}
pub struct Form {
// This form's fields: title, tags, assignees, description.
inputs: Vec<Box<dyn MockComponent>>,
/// State that holds the current focus etc.
state: FormState,
}
impl Form {
pub const CMD_FOCUS_PREVIOUS: &'static str = "cmd-focus-previous";
pub const CMD_FOCUS_NEXT: &'static str = "cmd-focus-next";
pub const CMD_NEWLINE: &'static str = "cmd-newline";
pub const CMD_PASTE: &'static str = "cmd-paste";
pub const PROP_ID: &'static str = "prop-id";
pub fn new(_theme: Theme, inputs: Vec<Box<dyn MockComponent>>) -> Self {
let state = FormState::new(Some(0), inputs.len());
Self { inputs, state }
}
}
impl WidgetComponent for Form {
fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
use tuirealm::props::Layout;
// Clear and set current focus
let focus = self.state.focus().unwrap_or(0);
for input in &mut self.inputs {
input.attr(Attribute::Focus, AttrValue::Flag(false));
}
if let Some(input) = self.inputs.get_mut(focus) {
input.attr(Attribute::Focus, AttrValue::Flag(true));
}
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints(
&self
.inputs
.iter()
.map(|_| Constraint::Length(3))
.collect::<Vec<_>>(),
);
let layout = properties
.get_or(Attribute::Layout, AttrValue::Layout(layout))
.unwrap_layout();
let layout = layout.chunks(area);
for (index, area) in layout.iter().enumerate().take(self.inputs.len()) {
if let Some(input) = self.inputs.get_mut(index) {
input.view(frame, *area);
}
}
}
fn state(&self) -> State {
let states = self
.inputs
.iter()
.map(|input| input.state())
.collect::<LinkedList<_>>();
State::Linked(states)
}
fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
match cmd {
Cmd::Custom(Self::CMD_FOCUS_PREVIOUS) => {
self.state.focus_previous();
CmdResult::None
}
Cmd::Custom(Self::CMD_FOCUS_NEXT) => {
self.state.focus_next();
CmdResult::None
}
Cmd::Submit => CmdResult::Submit(self.state()),
_ => {
let focus = self.state.focus().unwrap_or(0);
if let Some(input) = self.inputs.get_mut(focus) {
return input.perform(cmd);
}
CmdResult::None
}
}
}
}
-352
View File
@@ -1,352 +0,0 @@
use tuirealm::command::{Cmd, CmdResult};
use tuirealm::props::{Alignment, AttrValue, Attribute, Color, Props, Style};
use tuirealm::tui::layout::{Constraint, Direction, Layout, Rect};
use tuirealm::tui::text::{Line, Span, Text};
use tuirealm::{Frame, MockComponent, State, StateValue};
use crate::realm::ui::layout;
use crate::realm::ui::theme::style;
use crate::realm::ui::widget::{Widget, WidgetComponent};
pub fn blank() -> Widget<Label> {
default("")
}
pub fn default(content: &str) -> Widget<Label> {
// TODO: Remove when size constraints are implemented
let width = content.chars().count() as u16;
Widget::new(Label)
.content(AttrValue::String(content.to_string()))
.height(1)
.width(width)
}
pub fn reversed(content: &str) -> Widget<Label> {
default(content).style(style::reversed())
}
pub fn default_reversed(content: &str) -> Widget<Label> {
default(content).style(style::default_reversed())
}
pub fn group(labels: &[Widget<Label>]) -> Widget<LabelGroup> {
let group = LabelGroup::new(labels);
let width = labels.iter().fold(0, |total, label| {
total
+ label
.query(Attribute::Width)
.unwrap_or(AttrValue::Size(0))
.unwrap_size()
});
Widget::new(group).width(width)
}
pub fn reversable(content: &str) -> Widget<Label> {
let content = &format!(" {content} ");
default(content)
}
pub fn header(content: &str) -> Widget<Label> {
default(content).style(style::reset_dim())
}
pub fn property(content: &str) -> Widget<Label> {
default(content).style(style::cyan())
}
pub fn property_divider(content: &str) -> Widget<Label> {
default(content).style(style::gray())
}
pub fn badge(content: &str) -> Widget<Label> {
default(content).style(style::magenta_reversed())
}
pub fn title(content: &str) -> Widget<Label> {
default(content)
}
pub fn labels(content: &str) -> Widget<Label> {
default(content).style(style::lightblue())
}
pub fn alias(content: &str) -> Widget<Label> {
default(content).style(style::magenta())
}
pub fn did(content: &str) -> Widget<Label> {
default(content).style(style::magenta_dim())
}
pub fn id(content: &str) -> Widget<Label> {
default(content).style(style::cyan())
}
pub fn oid(content: &str) -> Widget<Label> {
default(content).style(style::lightblue())
}
pub fn timestamp(content: &str) -> Widget<Label> {
default(content).style(style::gray_dim())
}
pub fn positive(content: &str) -> Widget<Label> {
default(content).style(style::green())
}
pub fn negative(content: &str) -> Widget<Label> {
default(content).style(style::red())
}
/// 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 style = properties
.get_or(Attribute::Style, AttrValue::Style(Style::default()))
.unwrap_style();
if display {
let mut label = Label::default()
.foreground(style.fg.unwrap_or(Color::Reset))
.background(style.bg.unwrap_or(Color::Reset))
.modifiers(style.add_modifier)
.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();
let style = label
.query(Attribute::Style)
.unwrap_or(AttrValue::Style(Style::default()))
.unwrap_style();
Span::styled(content, style)
}
}
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();
let style = label
.query(Attribute::Style)
.unwrap_or(AttrValue::Style(Style::default()))
.unwrap_style();
Span::styled(content, style)
}
}
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 style = label
.query(Attribute::Style)
.unwrap_or(AttrValue::Style(Style::default()))
.unwrap_style();
Text::styled(content, style)
}
}
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 style = label
.query(Attribute::Style)
.unwrap_or(AttrValue::Style(Style::default()))
.unwrap_style();
Text::styled(content, style)
}
}
#[derive(Clone, Default)]
pub struct LabelGroup {
labels: Vec<Widget<Label>>,
}
impl LabelGroup {
pub fn new(labels: &[Widget<Label>]) -> Self {
Self {
labels: labels.to_vec(),
}
}
}
impl WidgetComponent for LabelGroup {
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 labels: Vec<Box<dyn MockComponent>> = vec![];
for label in &self.labels {
labels.push(label.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
}
}
#[derive(Default)]
pub struct Textarea {
/// The scroll offset.
offset: usize,
/// The current line count.
len: usize,
/// The current display height.
height: usize,
/// The percentage scrolled.
scroll_percent: usize,
}
impl Textarea {
pub const PROP_DISPLAY_PROGRESS: &'static str = "display-progress";
fn scroll_percent(offset: usize, len: usize, height: usize) -> usize {
if height >= len {
100
} else {
let y = offset as f64;
let h = height as f64;
let t = len.saturating_sub(1) as f64;
let v = y / (t - h) * 100_f64;
std::cmp::max(0, std::cmp::min(100, v as usize))
}
}
}
impl WidgetComponent for Textarea {
fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
use tuirealm::tui::widgets::Paragraph;
let focus = properties
.get_or(Attribute::Focus, AttrValue::Flag(false))
.unwrap_flag();
let display_progress = properties
.get_or(
Attribute::Custom(Self::PROP_DISPLAY_PROGRESS),
AttrValue::Flag(false),
)
.unwrap_flag();
let content = properties
.get_or(Attribute::Content, AttrValue::String(String::default()))
.unwrap_string();
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(1)])
.split(area);
// TODO: replace with `ratatui`'s reflow module when that becomes
// public: https://github.com/tui-rs-revival/ratatui/pull/9.
//
// In the future, there should be highlighting for e.g. Markdown which
// needs be done before wrapping. So this should rather wrap styled text
// spans than plain text.
let body = textwrap::wrap(&content, area.width.saturating_sub(2) as usize);
self.len = body.len();
self.height = (layout[0].height - 1) as usize;
let body: String = body.iter().fold(String::new(), |mut body, line| {
body.push_str(&format!("{}\n", line));
body
});
let paragraph = Paragraph::new(body)
.scroll((self.offset as u16, 0))
.style(style::reset());
frame.render_widget(paragraph, layout[0]);
self.scroll_percent = Self::scroll_percent(self.offset, self.len, self.height);
if display_progress {
let progress = Line::from(vec![Span::styled(
format!("{} %", self.scroll_percent),
style::border(focus),
)]);
let progress = Paragraph::new(progress).alignment(Alignment::Right);
frame.render_widget(progress, layout[1]);
}
}
fn state(&self) -> State {
State::One(StateValue::Usize(self.scroll_percent))
}
fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
use tuirealm::command::Direction;
match cmd {
Cmd::Scroll(Direction::Up) => {
self.offset = self.offset.saturating_sub(1);
self.scroll_percent = Self::scroll_percent(self.offset, self.len, self.height);
CmdResult::None
}
Cmd::Scroll(Direction::Down) => {
if self.scroll_percent < 100 {
self.offset = self.offset.saturating_add(1);
self.scroll_percent = Self::scroll_percent(self.offset, self.len, self.height);
}
CmdResult::None
}
_ => CmdResult::None,
}
}
}
-375
View File
@@ -1,375 +0,0 @@
use tuirealm::command::{Cmd, CmdResult};
use tuirealm::props::{AttrValue, Attribute, BorderSides, Props};
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::realm::ui::layout;
use crate::realm::ui::state::ItemState;
use crate::realm::ui::theme::{style, Theme};
use crate::realm::ui::widget::{utils, Widget, WidgetComponent};
use super::container::Header;
use super::label::{self, Label};
/// 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, highlight: bool) -> [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::default("");
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 + PartialEq,
{
/// 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 + PartialEq,
{
pub fn new(
items: &[V],
selected: Option<V>,
header: [Widget<Label>; W],
widths: [ColumnWidth; W],
theme: Theme,
) -> Self {
let selected = match selected {
Some(item) => items.iter().position(|i| i == &item),
_ => None,
};
Self {
items: items.to_vec(),
header,
widths,
state: ItemState::new(selected, items.len()),
theme,
}
}
}
impl<V, const W: usize> WidgetComponent for Table<V, W>
where
V: TableItem<W> + Clone + PartialEq,
{
fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
let focus = properties
.get_or(Attribute::Focus, AttrValue::Flag(false))
.unwrap_flag();
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()
.enumerate()
.map(|(index, item)| {
Row::new(item.row(
&self.theme,
match self.state.selected() {
Some(selected) => index == selected,
None => false,
},
))
})
.collect();
let table = tuirealm::tui::widgets::Table::new(rows)
.block(
Block::default()
.borders(BorderSides::BOTTOM | BorderSides::LEFT | BorderSides::RIGHT)
.border_style(style::border(focus))
.border_type(self.theme.border_type),
)
.highlight_style(style::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 {
let selected = self.state.selected().unwrap_or_default();
let len = self.items.len();
State::Tup2((StateValue::Usize(selected), StateValue::Usize(len)))
}
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(_) => CmdResult::Changed(self.state()),
None => CmdResult::None,
},
Cmd::Move(Direction::Down) => match self.state.select_next() {
Some(_) => CmdResult::Changed(self.state()),
None => CmdResult::None,
},
Cmd::Submit => match self.state.selected() {
Some(_) => CmdResult::Submit(self.state()),
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,
};
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 rows: Vec<ListItem> = self
.items
.iter()
.map(|item| item.row(&self.theme))
.collect();
let list = List::new(rows).highlight_style(style::highlight());
frame.render_stateful_widget(list, area, &mut ListState::from(&self.state));
}
fn state(&self) -> State {
let selected = self.state.selected().unwrap_or_default();
let len = self.items.len();
State::Tup2((StateValue::Usize(selected), StateValue::Usize(len)))
}
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(_) => CmdResult::Changed(self.state()),
None => CmdResult::None,
},
Cmd::Move(Direction::Down) => match self.state.select_next() {
Some(_) => CmdResult::Changed(self.state()),
None => CmdResult::None,
},
Cmd::Submit => match self.state.selected() {
Some(_) => CmdResult::Submit(self.state()),
None => CmdResult::None,
},
_ => CmdResult::None,
}
}
}
-43
View File
@@ -1,43 +0,0 @@
use tuirealm::tui::layout::{Constraint, Rect};
use super::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()
}
View File
View File
View File
+1 -1
View File
@@ -1,7 +1,7 @@
use ratatui::style::{Style, Stylize};
use ratatui::text::Span;
use crate::flux::ui::theme::style;
use crate::ui::theme::style;
pub fn blank() -> Span<'static> {
Span::styled("", Style::default())
@@ -7,8 +7,8 @@ use termion::event::Key;
use ratatui::prelude::*;
use ratatui::widgets::{BorderType, Borders, Row};
use crate::flux::ui::ext::{FooterBlock, FooterBlockType, HeaderBlock};
use crate::flux::ui::theme::style;
use crate::ui::ext::{FooterBlock, FooterBlockType, HeaderBlock};
use crate::ui::theme::style;
use super::{Render, Widget};
@@ -7,7 +7,7 @@ use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::text::Text;
use ratatui::widgets::{Block, BorderType, Borders};
use crate::flux::ui::theme::style;
use crate::ui::theme::style;
use super::{Render, Widget};