mirror of
https://seed.radicle.dev/rad:z39mP9rQAaGmERfUMPULfPUi473tY.git
synced 2026-10-18 19:10:36 +08:00
lib: Remove legacy tui-realm framework
This commit is contained in:
Generated
+4
-177
@@ -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
@@ -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" }
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
pub mod cob;
|
||||
pub mod context;
|
||||
pub mod log;
|
||||
@@ -1,6 +0,0 @@
|
||||
pub mod event;
|
||||
pub mod git;
|
||||
pub mod store;
|
||||
pub mod task;
|
||||
pub mod terminal;
|
||||
pub mod ui;
|
||||
+10
-8
@@ -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> {
|
||||
|
||||
-211
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user