mirror of
https://seed.radicle.dev/rad:z39mP9rQAaGmERfUMPULfPUi473tY.git
synced 2026-11-01 09:20:33 +08:00
lib/ui: Remove rmUI
This commit is contained in:
@@ -1,121 +0,0 @@
|
||||
use anyhow::Result;
|
||||
|
||||
use ratatui::layout::Constraint;
|
||||
use ratatui::Viewport;
|
||||
|
||||
use radicle_tui as tui;
|
||||
|
||||
use tui::event::{Event, Key};
|
||||
use tui::store;
|
||||
use tui::task::EmptyProcessors;
|
||||
use tui::ui::rm::widget::container::{Container, Header, HeaderProps};
|
||||
use tui::ui::rm::widget::text::{TextView, TextViewProps, TextViewState};
|
||||
use tui::ui::rm::widget::window::{Page, Shortcuts, ShortcutsProps, Window, WindowProps};
|
||||
use tui::ui::rm::widget::ToWidget;
|
||||
use tui::ui::Column;
|
||||
use tui::{BoxedAny, Channel, Exit};
|
||||
|
||||
const CONTENT: &str = r#"
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
|
||||
incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud
|
||||
exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure
|
||||
dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
|
||||
|
||||
Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt
|
||||
mollit anim id est laborum.
|
||||
"#;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct App {
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Message {
|
||||
Quit,
|
||||
ReverseContent,
|
||||
}
|
||||
|
||||
impl store::Update<Message> for App {
|
||||
type Return = ();
|
||||
|
||||
fn update(&mut self, message: Message) -> Option<tui::Exit<()>> {
|
||||
match message {
|
||||
Message::Quit => Some(Exit { value: None }),
|
||||
Message::ReverseContent => {
|
||||
self.content = self.content.chars().rev().collect::<String>();
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
pub async fn main() -> Result<()> {
|
||||
let channel = Channel::default();
|
||||
let sender = channel.tx.clone();
|
||||
let app = App {
|
||||
content: CONTENT.to_string(),
|
||||
};
|
||||
|
||||
let page =
|
||||
Page::default()
|
||||
.content(
|
||||
Container::default()
|
||||
.header(Header::default().to_widget(sender.clone()).on_update(|_| {
|
||||
HeaderProps::default()
|
||||
.columns(vec![
|
||||
Column::new("", Constraint::Length(0)),
|
||||
Column::new(
|
||||
"The standard Lorem Ipsum passage, used since the 1500s",
|
||||
Constraint::Fill(1),
|
||||
),
|
||||
])
|
||||
.to_boxed_any()
|
||||
.into()
|
||||
}))
|
||||
.content(TextView::default().to_widget(sender.clone()).on_update(
|
||||
|app: &App| {
|
||||
let content = app.content.clone();
|
||||
TextViewProps::default()
|
||||
.state(Some(TextViewState::default().content(content)))
|
||||
.handle_keys(false)
|
||||
.to_boxed_any()
|
||||
.into()
|
||||
},
|
||||
))
|
||||
.to_widget(sender.clone()),
|
||||
)
|
||||
.shortcuts(
|
||||
Shortcuts::default()
|
||||
.to_widget(sender.clone())
|
||||
.on_update(|_| {
|
||||
ShortcutsProps::default()
|
||||
.shortcuts(&[("q", "quit"), ("r", "reverse")])
|
||||
.to_boxed_any()
|
||||
.into()
|
||||
}),
|
||||
)
|
||||
.to_widget(sender.clone());
|
||||
|
||||
let window = Window::default()
|
||||
.page(0, page)
|
||||
.to_widget(sender.clone())
|
||||
.on_event(|event, _, _| match event {
|
||||
Event::Key(Key::Char('r')) => Some(Message::ReverseContent),
|
||||
Event::Key(Key::Char('q')) => Some(Message::Quit),
|
||||
_ => None,
|
||||
})
|
||||
.on_update(|_| WindowProps::default().current_page(0).to_boxed_any().into());
|
||||
|
||||
tui::rm(
|
||||
app,
|
||||
window,
|
||||
Viewport::default(),
|
||||
channel,
|
||||
EmptyProcessors::new(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
use anyhow::Result;
|
||||
|
||||
use ratatui::Viewport;
|
||||
|
||||
use ratatui::style::Color;
|
||||
use ratatui::text::Text;
|
||||
|
||||
use radicle_tui as tui;
|
||||
|
||||
use tui::event::{Event, Key};
|
||||
use tui::store;
|
||||
use tui::task::EmptyProcessors;
|
||||
use tui::ui::rm::widget::text::{TextArea, TextAreaProps};
|
||||
use tui::ui::rm::widget::ToWidget;
|
||||
use tui::{BoxedAny, Channel, Exit};
|
||||
|
||||
const ALIEN: &str = r#"
|
||||
/// /// ,---------------------------------.
|
||||
/// /// | Hey there, press (q) to quit... |
|
||||
// // '---------------------------------'
|
||||
//,,,///,,,// ..
|
||||
/////////////////// .
|
||||
//////@@@@@//////@@@@@///
|
||||
//////@@###//////@@###///
|
||||
,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
,,, /// /// ,,,
|
||||
,,, /// /// ,,,
|
||||
/// ///
|
||||
///// /////
|
||||
"#;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct App {
|
||||
alien: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Message {
|
||||
Quit,
|
||||
}
|
||||
|
||||
impl store::Update<Message> for App {
|
||||
type Return = ();
|
||||
|
||||
fn update(&mut self, message: Message) -> Option<tui::Exit<()>> {
|
||||
match message {
|
||||
Message::Quit => Some(Exit { value: None }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
pub async fn main() -> Result<()> {
|
||||
let channel = Channel::default();
|
||||
let sender = channel.tx.clone();
|
||||
let app = App {
|
||||
alien: ALIEN.to_string(),
|
||||
};
|
||||
|
||||
let scene = TextArea::default()
|
||||
.to_widget(sender.clone())
|
||||
.on_event(|event, _, _| match event {
|
||||
Event::Key(Key::Char('q')) => Some(Message::Quit),
|
||||
_ => None,
|
||||
})
|
||||
.on_update(|app: &App| {
|
||||
TextAreaProps::default()
|
||||
.content(Text::styled(app.alien.clone(), Color::Rgb(85, 85, 255)))
|
||||
.handle_keys(false)
|
||||
.to_boxed_any()
|
||||
.into()
|
||||
});
|
||||
|
||||
tui::rm(
|
||||
app,
|
||||
scene,
|
||||
Viewport::default(),
|
||||
channel,
|
||||
EmptyProcessors::new(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
-63
@@ -22,7 +22,6 @@ use store::Update;
|
||||
use terminal::StdinReader;
|
||||
use ui::im;
|
||||
use ui::im::Show;
|
||||
use ui::rm;
|
||||
|
||||
use crate::task::Process;
|
||||
|
||||
@@ -153,68 +152,6 @@ impl<M: Clone> Default for Channel<M> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize a `Store` with the `State` given and a `Frontend` with the `Widget` given,
|
||||
/// and run their main loops in parallel. Connect them to the `Channel` and also to
|
||||
/// an interrupt broadcast channel also initialized in this function.
|
||||
/// Additionally, a list of processors can be passed. Processors will also receive all
|
||||
/// applications messages and can emit new ones. They will be executed by an internal worker.
|
||||
pub async fn rm<S, T, M, R>(
|
||||
state: S,
|
||||
root: rm::widget::Widget<S, M>,
|
||||
viewport: Viewport,
|
||||
channel: Channel<M>,
|
||||
processors: Vec<T>,
|
||||
) -> Result<Option<R>>
|
||||
where
|
||||
S: Update<M, Return = R> + Share,
|
||||
T: Process<M> + Share,
|
||||
M: Share,
|
||||
R: Share,
|
||||
{
|
||||
let (terminator, mut interrupt_rx) = create_termination();
|
||||
let (state_tx, state_rx) = unbounded_channel();
|
||||
let (event_tx, event_rx) = unbounded_channel();
|
||||
let (work_tx, work_rx) = unbounded_channel();
|
||||
|
||||
let store = store::Store::<S, M, R>::new(state_tx.clone());
|
||||
let worker = task::Worker::<T, M, R>::new(work_tx.clone());
|
||||
let frontend = rm::Frontend::default();
|
||||
let stdin_reader = StdinReader::default();
|
||||
|
||||
// TODO(erikli): Handle errors
|
||||
let _ = tokio::try_join!(
|
||||
worker.run(
|
||||
processors,
|
||||
channel.rx.resubscribe(),
|
||||
interrupt_rx.resubscribe()
|
||||
),
|
||||
store.run(
|
||||
state,
|
||||
terminator,
|
||||
channel.rx.resubscribe(),
|
||||
work_rx,
|
||||
interrupt_rx.resubscribe(),
|
||||
),
|
||||
frontend.run(
|
||||
root,
|
||||
state_rx,
|
||||
event_rx,
|
||||
interrupt_rx.resubscribe(),
|
||||
viewport
|
||||
),
|
||||
stdin_reader.run(event_tx, interrupt_rx.resubscribe()),
|
||||
)?;
|
||||
|
||||
if let Ok(reason) = interrupt_rx.recv().await {
|
||||
match reason {
|
||||
Interrupted::User { payload } => Ok(payload),
|
||||
Interrupted::OsSignal => anyhow::bail!("exited because of an os sig int"),
|
||||
}
|
||||
} else {
|
||||
anyhow::bail!("exited because of an unexpected error");
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize a `Store` with the `State` given and a `Frontend` with the `App` given,
|
||||
/// and run their main loops concurrently. Connect them to the `Channel` and also to
|
||||
/// an interrupt broadcast channel also initialized in this function.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
pub mod ext;
|
||||
pub mod im;
|
||||
pub mod layout;
|
||||
pub mod rm;
|
||||
pub mod span;
|
||||
pub mod theme;
|
||||
pub mod utils;
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
pub mod widget;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::sync::mpsc::UnboundedReceiver;
|
||||
|
||||
use ratatui::Viewport;
|
||||
|
||||
use crate::event::Event;
|
||||
use crate::store::Update;
|
||||
use crate::terminal::Terminal;
|
||||
use crate::ui::rm::widget::RenderProps;
|
||||
use crate::ui::rm::widget::Widget;
|
||||
use crate::Interrupted;
|
||||
use crate::Share;
|
||||
|
||||
const RENDERING_TICK_RATE: Duration = Duration::from_millis(250);
|
||||
|
||||
/// The `Frontend` runs an applications' view concurrently. It handles
|
||||
/// terminal events as well as state updates and renders the view accordingly.
|
||||
///
|
||||
/// Once created and run with `main_loop`, the `Frontend` will wait for new messages
|
||||
/// being sent on either the terminal event, the state or the interrupt message channel.
|
||||
#[derive(Default)]
|
||||
pub struct Frontend {}
|
||||
|
||||
impl Frontend {
|
||||
/// By calling `main_loop`, the `Frontend` will wait for new messages being sent
|
||||
/// on either the terminal event, the state or the interrupt message channel.
|
||||
/// After all, it will draw the (potentially) updated root widget.
|
||||
///
|
||||
/// State messages are being sent by the applications' `Store`. Received state updates
|
||||
/// will be passed to the root widget as well.
|
||||
///
|
||||
/// Interrupt messages are being sent to broadcast channel for retrieving the
|
||||
/// application kill signal.
|
||||
pub async fn run<S, M, R>(
|
||||
self,
|
||||
mut root: Widget<S, M>,
|
||||
mut state_rx: UnboundedReceiver<S>,
|
||||
mut events_rx: UnboundedReceiver<Event>,
|
||||
mut interrupt_rx: broadcast::Receiver<Interrupted<R>>,
|
||||
viewport: Viewport,
|
||||
) -> anyhow::Result<Interrupted<R>>
|
||||
where
|
||||
S: Update<M, Return = R> + 'static,
|
||||
M: Share,
|
||||
R: Share,
|
||||
{
|
||||
let mut ticker = tokio::time::interval(RENDERING_TICK_RATE);
|
||||
let mut terminal = Terminal::try_from(viewport)?;
|
||||
let mut root = {
|
||||
let state = state_rx.recv().await.unwrap();
|
||||
|
||||
root.init();
|
||||
root.update(&state);
|
||||
root
|
||||
};
|
||||
|
||||
let result: anyhow::Result<Interrupted<R>> = loop {
|
||||
tokio::select! {
|
||||
// Tick to terminate the select every N milliseconds
|
||||
_ = ticker.tick() => (),
|
||||
// Handle input events
|
||||
Some(event) = events_rx.recv() => match event {
|
||||
Event::Key(key) => {
|
||||
log::debug!(target: "frontend", "Received key event: {key:?}");
|
||||
root.handle_event(event);
|
||||
}
|
||||
Event::Resize(x, y) => {
|
||||
log::debug!(target: "frontend", "Received resize event: {x},{y}");
|
||||
terminal.clear()?;
|
||||
},
|
||||
Event::Unknown => {
|
||||
log::debug!(target: "frontend", "Received unknown event")
|
||||
}
|
||||
},
|
||||
// Handle state updates
|
||||
Some(state) = state_rx.recv() => {
|
||||
root.update(&state);
|
||||
},
|
||||
// Catch and handle interrupt signal to gracefully shutdown
|
||||
Ok(interrupted) = interrupt_rx.recv() => {
|
||||
break Ok(interrupted);
|
||||
}
|
||||
}
|
||||
terminal.draw(|frame| root.render(RenderProps::from(frame.area()), frame))?;
|
||||
};
|
||||
terminal.restore()?;
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
@@ -1,391 +0,0 @@
|
||||
pub mod container;
|
||||
pub mod list;
|
||||
pub mod text;
|
||||
pub mod utils;
|
||||
pub mod window;
|
||||
|
||||
use std::any::Any;
|
||||
use std::rc::Rc;
|
||||
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use ratatui::prelude::*;
|
||||
|
||||
use self::{
|
||||
container::SectionGroupState,
|
||||
text::{TextAreaState, TextViewState},
|
||||
};
|
||||
use crate::event::Event;
|
||||
|
||||
pub type BoxedView<S, M> = Box<dyn View<State = S, Message = M>>;
|
||||
pub type UpdateCallback<S> = fn(&S) -> ViewProps;
|
||||
pub type EventCallback<M> = fn(Event, Option<&ViewState>, Option<&ViewProps>) -> Option<M>;
|
||||
pub type RenderCallback<M> = fn(Option<&ViewProps>, &RenderProps) -> Option<M>;
|
||||
pub type InitCallback<M> = fn() -> Option<M>;
|
||||
|
||||
/// `ViewProps` are properties of a `View`. They define a `View`s data, configuration etc.
|
||||
/// Since the framework itself does not know the concrete type of `View`, it also does not
|
||||
/// know the concrete type of a `View`s properties.
|
||||
/// Hence, view properties are stored inside a `Box<dyn Any>` and downcasted to the concrete
|
||||
/// type when needed.
|
||||
pub struct ViewProps {
|
||||
inner: Box<dyn Any>,
|
||||
}
|
||||
|
||||
impl ViewProps {
|
||||
pub fn inner<T>(self) -> Option<T>
|
||||
where
|
||||
T: Default + Clone + 'static,
|
||||
{
|
||||
self.inner.downcast::<T>().ok().map(|inner| *inner)
|
||||
}
|
||||
|
||||
pub fn inner_ref<T>(&self) -> Option<&T>
|
||||
where
|
||||
T: Default + Clone + 'static,
|
||||
{
|
||||
self.inner.downcast_ref::<T>()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Box<dyn Any>> for ViewProps {
|
||||
fn from(props: Box<dyn Any>) -> Self {
|
||||
ViewProps { inner: props }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&'static dyn Any> for ViewProps {
|
||||
fn from(inner: &'static dyn Any) -> Self {
|
||||
Self {
|
||||
inner: Box::new(inner),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `ViewState` is the representation of a `View`s internal state. e.g. current
|
||||
/// table selection or contents of a text field.
|
||||
#[derive(Debug)]
|
||||
pub enum ViewState {
|
||||
USize(usize),
|
||||
String(String),
|
||||
Table { selected: usize, scroll: usize },
|
||||
Tree(Vec<String>),
|
||||
TextView(TextViewState),
|
||||
TextArea(TextAreaState),
|
||||
SectionGroup(SectionGroupState),
|
||||
}
|
||||
|
||||
impl ViewState {
|
||||
pub fn unwrap_usize(&self) -> Option<usize> {
|
||||
match self {
|
||||
ViewState::USize(value) => Some(*value),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unwrap_string(&self) -> Option<String> {
|
||||
match self {
|
||||
ViewState::String(value) => Some(value.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unwrap_table(&self) -> Option<(usize, usize)> {
|
||||
match self {
|
||||
ViewState::Table { selected, scroll } => Some((*selected, *scroll)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unwrap_textview(&self) -> Option<TextViewState> {
|
||||
match self {
|
||||
ViewState::TextView(state) => Some(state.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unwrap_textarea(&self) -> Option<TextAreaState> {
|
||||
match self {
|
||||
ViewState::TextArea(state) => Some(state.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unwrap_section_group(&self) -> Option<SectionGroupState> {
|
||||
match self {
|
||||
ViewState::SectionGroup(state) => Some(state.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unwrap_tree(&self) -> Option<Vec<String>> {
|
||||
match self {
|
||||
ViewState::Tree(value) => Some(value.clone().to_vec()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub enum PredefinedLayout {
|
||||
#[default]
|
||||
None,
|
||||
Expandable3 {
|
||||
left_only: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl PredefinedLayout {
|
||||
pub fn split(&self, area: Rect) -> Rc<[Rect]> {
|
||||
match self {
|
||||
Self::Expandable3 { left_only } => {
|
||||
if *left_only {
|
||||
[area].into()
|
||||
} else if area.width <= 140 {
|
||||
let [left, right] = Layout::horizontal([
|
||||
Constraint::Percentage(50),
|
||||
Constraint::Percentage(50),
|
||||
])
|
||||
.areas(area);
|
||||
let [right_top, right_bottom] =
|
||||
Layout::vertical([Constraint::Percentage(65), Constraint::Percentage(35)])
|
||||
.areas(right);
|
||||
|
||||
[left, right_top, right_bottom].into()
|
||||
} else {
|
||||
Layout::horizontal([
|
||||
Constraint::Percentage(33),
|
||||
Constraint::Percentage(33),
|
||||
Constraint::Percentage(33),
|
||||
])
|
||||
.split(area)
|
||||
}
|
||||
}
|
||||
_ => Layout::default().split(area),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// General properties that specify how a `View` is rendered.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct RenderProps {
|
||||
/// Area of the render props.
|
||||
pub area: Rect,
|
||||
/// Layout to be rendered in.
|
||||
pub layout: Layout,
|
||||
/// Focus of the render props.
|
||||
pub focus: bool,
|
||||
}
|
||||
|
||||
impl RenderProps {
|
||||
/// Sets the area to render in.
|
||||
pub fn area(mut self, area: Rect) -> Self {
|
||||
self.area = area;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the focus of these render props.
|
||||
pub fn focus(mut self, focus: bool) -> Self {
|
||||
self.focus = focus;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the layout of these render props.
|
||||
pub fn layout(mut self, layout: Layout) -> Self {
|
||||
self.layout = layout;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Rect> for RenderProps {
|
||||
fn from(area: Rect) -> Self {
|
||||
Self {
|
||||
area,
|
||||
layout: Layout::default(),
|
||||
focus: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Main trait defining a `View` behaviour, which needs be implemented in order to
|
||||
/// build a custom widget. A `View` operates on an application state and can emit
|
||||
/// application messages. It's usually is accompanied by a definition of view-specific
|
||||
/// properties, which are being built from the application state by the framework.
|
||||
pub trait View {
|
||||
type State;
|
||||
type Message;
|
||||
|
||||
/// Should return the internal state.
|
||||
fn view_state(&self) -> Option<ViewState> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Should reset the internal state and call `reset` on all children.
|
||||
fn reset(&mut self) {}
|
||||
|
||||
/// Should handle key events and call `handle_event` on all children.
|
||||
fn handle_event(&mut self, _props: Option<&ViewProps>, _event: Event) -> Option<Self::Message> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Should update the internal props of this and all children.
|
||||
fn update(&mut self, _props: Option<&ViewProps>, _state: &Self::State) {}
|
||||
|
||||
/// Should render the view using the given `RenderProps`.
|
||||
fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame);
|
||||
}
|
||||
|
||||
/// A `Widget` enhances a `View` with event and update callbacks and takes
|
||||
/// care of calling them before / after calling into the `View`.
|
||||
///
|
||||
/// In _retained mode_, a widget is defined by an implementation of the `View` trait
|
||||
/// and a `Widget` it is wrapped in. A `View` handles user-interactions, updates itself
|
||||
/// whenever the application state changed and renders itself frequently. A `Widget` adds
|
||||
/// additional support for properties and event, update and render callbacks. Properties
|
||||
/// define the data, configuration etc. of a widget. They are updated by the framework
|
||||
/// taking the properties built by the `on_update` callback. The `on_event` callback is
|
||||
/// used to emit application messages whenever a widget receives an event.
|
||||
///
|
||||
/// The main idea is to build widgets that handle their specific events already,
|
||||
/// and that are updated with the properties built by the `on_update` callback.
|
||||
/// Custom logic is added by setting the `on_event` callback. E.g. the `Table` widget
|
||||
/// handles item selection already; items are set via the `on_update` callback and
|
||||
/// application messages are emitted via the `on_event` callback.
|
||||
pub struct Widget<S, M> {
|
||||
view: BoxedView<S, M>,
|
||||
props: Option<ViewProps>,
|
||||
sender: broadcast::Sender<M>,
|
||||
on_init: Option<InitCallback<M>>,
|
||||
on_update: Option<UpdateCallback<S>>,
|
||||
on_event: Option<EventCallback<M>>,
|
||||
on_render: Option<RenderCallback<M>>,
|
||||
}
|
||||
|
||||
unsafe impl<S, M> Send for Widget<S, M> {}
|
||||
|
||||
impl<S: 'static, M: 'static> Widget<S, M> {
|
||||
pub fn new<V>(view: V, sender: broadcast::Sender<M>) -> Self
|
||||
where
|
||||
Self: Sized,
|
||||
V: View<State = S, Message = M> + 'static,
|
||||
{
|
||||
Self {
|
||||
view: Box::new(view),
|
||||
props: None,
|
||||
sender: sender.clone(),
|
||||
on_init: None,
|
||||
on_update: None,
|
||||
on_event: None,
|
||||
on_render: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calls `reset` on the wrapped view.
|
||||
pub fn reset(&mut self) {
|
||||
self.view.reset()
|
||||
}
|
||||
|
||||
/// Calls `handle_event` on the wrapped view as well as the `on_event` callback.
|
||||
/// Sends any message returned by either the view or the callback.
|
||||
pub fn handle_event(&mut self, event: Event) {
|
||||
if let Some(message) = self.view.handle_event(self.props.as_ref(), event) {
|
||||
let _ = self.sender.send(message);
|
||||
}
|
||||
|
||||
if let Some(on_event) = self.on_event {
|
||||
if let Some(message) =
|
||||
(on_event)(event, self.view.view_state().as_ref(), self.props.as_ref())
|
||||
{
|
||||
let _ = self.sender.send(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Initializes the widget
|
||||
pub fn init(&mut self) {
|
||||
if let Some(on_init) = self.on_init {
|
||||
(on_init)().and_then(|message| self.sender.send(message).ok());
|
||||
}
|
||||
}
|
||||
|
||||
/// Applications are usually defined by app-specific widgets that do know
|
||||
/// the type of `state`. These can use widgets from the library that do not know the
|
||||
/// type of `state`.
|
||||
///
|
||||
/// If `on_update` is set, implementations of this function should call it to
|
||||
/// construct and update the internal props. If it is not set, app widgets can construct
|
||||
/// props directly via their state converters, whereas library widgets can just fallback
|
||||
/// to their current props.
|
||||
pub fn update(&mut self, state: &S) {
|
||||
self.props = self.on_update.map(|on_update| (on_update)(state));
|
||||
self.view.update(self.props.as_ref(), state);
|
||||
}
|
||||
|
||||
/// Renders the wrapped view.
|
||||
pub fn render(&mut self, render: RenderProps, frame: &mut Frame) {
|
||||
self.view.render(self.props.as_ref(), render.clone(), frame);
|
||||
|
||||
if let Some(on_render) = self.on_render {
|
||||
(on_render)(self.props.as_ref(), &render)
|
||||
.and_then(|message| self.sender.send(message).ok());
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the optional custom event handler.
|
||||
pub fn on_init(mut self, callback: InitCallback<M>) -> Self
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
self.on_init = Some(callback);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the optional update handler.
|
||||
pub fn on_update(mut self, callback: UpdateCallback<S>) -> Self
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
self.on_update = Some(callback);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the optional custom event handler.
|
||||
pub fn on_event(mut self, callback: EventCallback<M>) -> Self
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
self.on_event = Some(callback);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the optional update handler.
|
||||
pub fn on_render(mut self, callback: RenderCallback<M>) -> Self
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
self.on_render = Some(callback);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A `View` needs to be wrapped into a `Widget` in order to be used with the framework.
|
||||
/// `ToWidget` provides a blanket implementation for all `View`s.
|
||||
pub trait ToWidget<S, M> {
|
||||
fn to_widget(self, tx: broadcast::Sender<M>) -> Widget<S, M>
|
||||
where
|
||||
Self: Sized + 'static;
|
||||
}
|
||||
|
||||
impl<T, S, M> ToWidget<S, M> for T
|
||||
where
|
||||
T: View<State = S, Message = M>,
|
||||
S: 'static,
|
||||
M: 'static,
|
||||
{
|
||||
fn to_widget(self, tx: broadcast::Sender<M>) -> Widget<S, M>
|
||||
where
|
||||
Self: Sized + 'static,
|
||||
{
|
||||
Widget::new(self, tx)
|
||||
}
|
||||
}
|
||||
@@ -1,769 +0,0 @@
|
||||
use std::fmt::Debug;
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use ratatui::prelude::*;
|
||||
use ratatui::widgets::{Block, BorderType, Borders, Row};
|
||||
|
||||
use crate::event::{Event, Key};
|
||||
use crate::ui::ext::{FooterBlock, FooterBlockType, HeaderBlock};
|
||||
use crate::ui::theme::{style, Theme};
|
||||
use crate::ui::Column;
|
||||
|
||||
use super::{PredefinedLayout, RenderProps, View, ViewProps, ViewState, Widget};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HeaderProps<'a> {
|
||||
pub columns: Vec<Column<'a>>,
|
||||
pub cutoff: usize,
|
||||
pub cutoff_after: usize,
|
||||
pub border_style: Style,
|
||||
pub focus_border_style: Style,
|
||||
}
|
||||
|
||||
impl<'a> HeaderProps<'a> {
|
||||
pub fn columns(mut self, columns: Vec<Column<'a>>) -> Self {
|
||||
self.columns = columns;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn cutoff(mut self, cutoff: usize, cutoff_after: usize) -> Self {
|
||||
self.cutoff = cutoff;
|
||||
self.cutoff_after = cutoff_after;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn border_style(mut self, color: Style) -> Self {
|
||||
self.border_style = color;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn focus_border_style(mut self, color: Style) -> Self {
|
||||
self.focus_border_style = color;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for HeaderProps<'_> {
|
||||
fn default() -> Self {
|
||||
let theme = Theme::default();
|
||||
|
||||
Self {
|
||||
columns: vec![],
|
||||
cutoff: usize::MAX,
|
||||
cutoff_after: usize::MAX,
|
||||
border_style: theme.border_style,
|
||||
focus_border_style: theme.focus_border_style,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Header<S, M> {
|
||||
/// Phantom
|
||||
phantom: PhantomData<(S, M)>,
|
||||
}
|
||||
|
||||
impl<S, M> Default for Header<S, M> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
phantom: PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S, M> View for Header<S, M> {
|
||||
type Message = M;
|
||||
type State = S;
|
||||
|
||||
fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
|
||||
let default = HeaderProps::default();
|
||||
let props = props
|
||||
.and_then(|props| props.inner_ref::<HeaderProps>())
|
||||
.unwrap_or(&default);
|
||||
|
||||
let width = render.area.width.saturating_sub(2);
|
||||
|
||||
let widths: Vec<Constraint> = props
|
||||
.columns
|
||||
.iter()
|
||||
.filter_map(|c| {
|
||||
if !c.skip && c.displayed(width as usize) {
|
||||
Some(c.width)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let cells = props
|
||||
.columns
|
||||
.iter()
|
||||
.filter_map(|column| {
|
||||
if !column.skip && column.displayed(width as usize) {
|
||||
Some(column.text.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let border_style = if render.focus {
|
||||
props.focus_border_style
|
||||
} else {
|
||||
props.border_style
|
||||
};
|
||||
|
||||
// Render header
|
||||
let block = HeaderBlock::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(border_style)
|
||||
.border_type(BorderType::Rounded);
|
||||
|
||||
let header_layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(vec![Constraint::Min(1)])
|
||||
.vertical_margin(1)
|
||||
.horizontal_margin(1)
|
||||
.split(render.area);
|
||||
|
||||
let header = Row::new(cells).style(style::reset().bold());
|
||||
let header = ratatui::widgets::Table::default()
|
||||
.column_spacing(1)
|
||||
.header(header)
|
||||
.widths(widths.clone());
|
||||
|
||||
frame.render_widget(block, render.area);
|
||||
frame.render_widget(header, header_layout[0]);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FooterProps<'a> {
|
||||
pub columns: Vec<Column<'a>>,
|
||||
pub cutoff: usize,
|
||||
pub cutoff_after: usize,
|
||||
pub border_style: Style,
|
||||
pub focus_border_style: Style,
|
||||
}
|
||||
|
||||
impl<'a> FooterProps<'a> {
|
||||
pub fn columns(mut self, columns: Vec<Column<'a>>) -> Self {
|
||||
self.columns = columns;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn cutoff(mut self, cutoff: usize, cutoff_after: usize) -> Self {
|
||||
self.cutoff = cutoff;
|
||||
self.cutoff_after = cutoff_after;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn border_style(mut self, color: Style) -> Self {
|
||||
self.border_style = color;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn focus_border_style(mut self, color: Style) -> Self {
|
||||
self.focus_border_style = color;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FooterProps<'_> {
|
||||
fn default() -> Self {
|
||||
let theme = Theme::default();
|
||||
|
||||
Self {
|
||||
columns: vec![],
|
||||
cutoff: usize::MAX,
|
||||
cutoff_after: usize::MAX,
|
||||
border_style: theme.border_style,
|
||||
focus_border_style: theme.focus_border_style,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Footer<S, M> {
|
||||
/// Phantom
|
||||
phantom: PhantomData<(S, M)>,
|
||||
}
|
||||
|
||||
impl<S, M> Default for Footer<S, M> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
phantom: PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, S, M> Footer<S, M> {
|
||||
fn render_cell(
|
||||
&self,
|
||||
frame: &mut ratatui::Frame,
|
||||
border_style: Style,
|
||||
render: RenderProps,
|
||||
block_type: FooterBlockType,
|
||||
text: impl Into<Text<'a>>,
|
||||
) {
|
||||
let footer_layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(vec![Constraint::Min(1)])
|
||||
.vertical_margin(1)
|
||||
.horizontal_margin(1)
|
||||
.split(render.area);
|
||||
|
||||
let footer_block = FooterBlock::default()
|
||||
.border_style(border_style)
|
||||
.block_type(block_type);
|
||||
frame.render_widget(footer_block, render.area);
|
||||
frame.render_widget(text.into(), footer_layout[0]);
|
||||
}
|
||||
}
|
||||
|
||||
impl<S, M> View for Footer<S, M> {
|
||||
type Message = M;
|
||||
type State = S;
|
||||
|
||||
fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
|
||||
let default = FooterProps::default();
|
||||
let props = props
|
||||
.and_then(|props| props.inner_ref::<FooterProps>())
|
||||
.unwrap_or(&default);
|
||||
|
||||
let border_style = if render.focus {
|
||||
props.focus_border_style
|
||||
} else {
|
||||
props.border_style
|
||||
};
|
||||
|
||||
let widths = props
|
||||
.columns
|
||||
.iter()
|
||||
.map(|c| match c.width {
|
||||
Constraint::Min(min) => Constraint::Length(min.saturating_add(3)),
|
||||
_ => c.width,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let layout = Layout::horizontal(widths).split(render.area);
|
||||
let cells = props
|
||||
.columns
|
||||
.iter()
|
||||
.map(|c| c.text.clone())
|
||||
.zip(layout.iter())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let last = cells.len().saturating_sub(1);
|
||||
let len = cells.len();
|
||||
|
||||
for (i, (cell, area)) in cells.into_iter().enumerate() {
|
||||
let block_type = match i {
|
||||
0 if len == 1 => FooterBlockType::Single { top: true },
|
||||
0 => FooterBlockType::Begin,
|
||||
_ if i == last => FooterBlockType::End,
|
||||
_ => FooterBlockType::Repeat,
|
||||
};
|
||||
self.render_cell(
|
||||
frame,
|
||||
border_style,
|
||||
render.clone().area(*area),
|
||||
block_type,
|
||||
cell.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ContainerProps {
|
||||
hide_footer: bool,
|
||||
border_style: Style,
|
||||
focus_border_style: Style,
|
||||
}
|
||||
|
||||
impl Default for ContainerProps {
|
||||
fn default() -> Self {
|
||||
let theme = Theme::default();
|
||||
|
||||
Self {
|
||||
hide_footer: false,
|
||||
border_style: theme.border_style,
|
||||
focus_border_style: theme.focus_border_style,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ContainerProps {
|
||||
pub fn hide_footer(mut self, hide: bool) -> Self {
|
||||
self.hide_footer = hide;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn border_style(mut self, color: Style) -> Self {
|
||||
self.border_style = color;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn focus_border_style(mut self, color: Style) -> Self {
|
||||
self.focus_border_style = color;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Container<S, M> {
|
||||
/// Container header
|
||||
header: Option<Widget<S, M>>,
|
||||
/// Content widget
|
||||
content: Option<Widget<S, M>>,
|
||||
/// Container footer
|
||||
footer: Option<Widget<S, M>>,
|
||||
}
|
||||
|
||||
impl<S, M> Default for Container<S, M> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
header: None,
|
||||
content: None,
|
||||
footer: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S, M> Container<S, M> {
|
||||
pub fn header(mut self, header: Widget<S, M>) -> Self {
|
||||
self.header = Some(header);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn content(mut self, content: Widget<S, M>) -> Self {
|
||||
self.content = Some(content);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn footer(mut self, footer: Widget<S, M>) -> Self {
|
||||
self.footer = Some(footer);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<S, M> View for Container<S, M>
|
||||
where
|
||||
S: 'static,
|
||||
M: 'static,
|
||||
{
|
||||
type Message = M;
|
||||
type State = S;
|
||||
|
||||
fn handle_event(&mut self, _props: Option<&ViewProps>, event: Event) -> Option<Self::Message> {
|
||||
if let Some(content) = &mut self.content {
|
||||
content.handle_event(event);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn update(&mut self, _props: Option<&ViewProps>, state: &Self::State) {
|
||||
if let Some(header) = &mut self.header {
|
||||
header.update(state);
|
||||
}
|
||||
|
||||
if let Some(content) = &mut self.content {
|
||||
content.update(state);
|
||||
}
|
||||
|
||||
if let Some(footer) = &mut self.footer {
|
||||
footer.update(state);
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
|
||||
let default = ContainerProps::default();
|
||||
let props = props
|
||||
.and_then(|props| props.inner_ref::<ContainerProps>())
|
||||
.unwrap_or(&default);
|
||||
|
||||
let border_style = if render.focus {
|
||||
props.focus_border_style
|
||||
} else {
|
||||
props.border_style
|
||||
};
|
||||
|
||||
let header_h = if self.header.is_some() { 3 } else { 0 };
|
||||
let footer_h = if self.footer.is_some() && !props.hide_footer {
|
||||
3
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let [header_area, content_area, footer_area] = Layout::vertical([
|
||||
Constraint::Length(header_h),
|
||||
Constraint::Min(1),
|
||||
Constraint::Length(footer_h),
|
||||
])
|
||||
.areas(render.area);
|
||||
|
||||
let borders = match (
|
||||
self.header.is_some(),
|
||||
(self.footer.is_some() && !props.hide_footer),
|
||||
) {
|
||||
(false, false) => Borders::ALL,
|
||||
(true, false) => Borders::BOTTOM | Borders::LEFT | Borders::RIGHT,
|
||||
(false, true) => Borders::TOP | Borders::LEFT | Borders::RIGHT,
|
||||
(true, true) => Borders::LEFT | Borders::RIGHT,
|
||||
};
|
||||
|
||||
let block = Block::default()
|
||||
.border_style(border_style)
|
||||
.border_type(BorderType::Rounded)
|
||||
.borders(borders);
|
||||
frame.render_widget(block.clone(), content_area);
|
||||
|
||||
if let Some(header) = self.header.as_mut() {
|
||||
header.render(RenderProps::from(header_area).focus(render.focus), frame);
|
||||
}
|
||||
|
||||
if let Some(content) = self.content.as_mut() {
|
||||
content.render(
|
||||
RenderProps::from(block.inner(content_area)).focus(render.focus),
|
||||
frame,
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(footer) = self.footer.as_mut() {
|
||||
footer.render(RenderProps::from(footer_area).focus(render.focus), frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub enum SplitContainerFocus {
|
||||
#[default]
|
||||
Top,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SplitContainerProps {
|
||||
split_focus: SplitContainerFocus,
|
||||
heights: [Constraint; 2],
|
||||
border_style: Style,
|
||||
focus_border_style: Style,
|
||||
}
|
||||
|
||||
impl Default for SplitContainerProps {
|
||||
fn default() -> Self {
|
||||
let theme = Theme::default();
|
||||
|
||||
Self {
|
||||
split_focus: SplitContainerFocus::default(),
|
||||
heights: [Constraint::Percentage(50), Constraint::Percentage(50)],
|
||||
border_style: theme.border_style,
|
||||
focus_border_style: theme.focus_border_style,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SplitContainerProps {
|
||||
pub fn split_focus(mut self, split_focus: SplitContainerFocus) -> Self {
|
||||
self.split_focus = split_focus;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn heights(mut self, heights: [Constraint; 2]) -> Self {
|
||||
self.heights = heights;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn border_style(mut self, color: Style) -> Self {
|
||||
self.border_style = color;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn focus_border_style(mut self, color: Style) -> Self {
|
||||
self.focus_border_style = color;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SplitContainer<S, M> {
|
||||
/// Container top
|
||||
top: Option<Widget<S, M>>,
|
||||
/// Content bottom
|
||||
bottom: Option<Widget<S, M>>,
|
||||
}
|
||||
|
||||
impl<S, M> Default for SplitContainer<S, M> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
top: None,
|
||||
bottom: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S, M> SplitContainer<S, M> {
|
||||
pub fn top(mut self, top: Widget<S, M>) -> Self {
|
||||
self.top = Some(top);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn bottom(mut self, bottom: Widget<S, M>) -> Self {
|
||||
self.bottom = Some(bottom);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<S, M> View for SplitContainer<S, M>
|
||||
where
|
||||
S: 'static,
|
||||
M: 'static,
|
||||
{
|
||||
type Message = M;
|
||||
type State = S;
|
||||
|
||||
fn handle_event(&mut self, props: Option<&ViewProps>, event: Event) -> Option<Self::Message> {
|
||||
let default = SplitContainerProps::default();
|
||||
let props = props
|
||||
.and_then(|props| props.inner_ref::<SplitContainerProps>())
|
||||
.unwrap_or(&default);
|
||||
|
||||
match props.split_focus {
|
||||
SplitContainerFocus::Top => {
|
||||
if let Some(top) = self.top.as_mut() {
|
||||
top.handle_event(event);
|
||||
}
|
||||
}
|
||||
SplitContainerFocus::Bottom => {
|
||||
if let Some(bottom) = self.bottom.as_mut() {
|
||||
bottom.handle_event(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn update(&mut self, _props: Option<&ViewProps>, state: &Self::State) {
|
||||
if let Some(top) = self.top.as_mut() {
|
||||
top.update(state);
|
||||
}
|
||||
|
||||
if let Some(bottom) = self.bottom.as_mut() {
|
||||
bottom.update(state);
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
|
||||
let default = SplitContainerProps::default();
|
||||
let props = props
|
||||
.and_then(|props| props.inner_ref::<SplitContainerProps>())
|
||||
.unwrap_or(&default);
|
||||
|
||||
let heights = props
|
||||
.heights
|
||||
.iter()
|
||||
.map(|c| {
|
||||
if let Constraint::Length(l) = c {
|
||||
Constraint::Length(l + 2)
|
||||
} else {
|
||||
*c
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let border_style = if render.focus {
|
||||
props.focus_border_style
|
||||
} else {
|
||||
props.border_style
|
||||
};
|
||||
|
||||
let [top_area, bottom_area] = Layout::vertical(heights).areas(render.area);
|
||||
|
||||
if let Some(top) = self.top.as_mut() {
|
||||
let block = HeaderBlock::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(border_style)
|
||||
.border_type(BorderType::Rounded);
|
||||
|
||||
frame.render_widget(block, top_area);
|
||||
|
||||
let [top_area] = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(vec![Constraint::Min(1)])
|
||||
.vertical_margin(1)
|
||||
.horizontal_margin(1)
|
||||
.areas(top_area);
|
||||
top.render(RenderProps::from(top_area).focus(render.focus), frame)
|
||||
}
|
||||
|
||||
if let Some(bottom) = self.bottom.as_mut() {
|
||||
let block = Block::default()
|
||||
.borders(Borders::LEFT | Borders::RIGHT | Borders::BOTTOM)
|
||||
.border_style(border_style)
|
||||
.border_type(BorderType::Rounded);
|
||||
|
||||
frame.render_widget(block, bottom_area);
|
||||
|
||||
let [bottom_area, _] = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(vec![Constraint::Min(1), Constraint::Length(1)])
|
||||
.horizontal_margin(1)
|
||||
.areas(bottom_area);
|
||||
bottom.render(RenderProps::from(bottom_area).focus(render.focus), frame)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SectionGroupState {
|
||||
/// Index of currently focused section.
|
||||
pub focus: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct SectionGroupProps {
|
||||
/// Index of currently focused section. If set, it will override the widgets'
|
||||
/// internal state.
|
||||
focus: Option<usize>,
|
||||
/// If this pages' keys should be handled.
|
||||
handle_keys: bool,
|
||||
/// Section layout
|
||||
layout: PredefinedLayout,
|
||||
}
|
||||
|
||||
impl SectionGroupProps {
|
||||
pub fn handle_keys(mut self, handle_keys: bool) -> Self {
|
||||
self.handle_keys = handle_keys;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn layout(mut self, layout: PredefinedLayout) -> Self {
|
||||
self.layout = layout;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn focus(mut self, focus: Option<usize>) -> Self {
|
||||
self.focus = focus;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SectionGroup<S, M> {
|
||||
/// All sections
|
||||
sections: Vec<Widget<S, M>>,
|
||||
/// Internal selection and offset state
|
||||
state: SectionGroupState,
|
||||
}
|
||||
|
||||
impl<S, M> Default for SectionGroup<S, M> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
sections: vec![],
|
||||
state: SectionGroupState { focus: Some(0) },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S, M> SectionGroup<S, M> {
|
||||
pub fn section(mut self, section: Widget<S, M>) -> Self {
|
||||
self.sections.push(section);
|
||||
self
|
||||
}
|
||||
|
||||
fn prev(&mut self) -> Option<usize> {
|
||||
let focus = self.state.focus.map(|current| current.saturating_sub(1));
|
||||
self.state.focus = focus;
|
||||
focus
|
||||
}
|
||||
|
||||
fn next(&mut self, len: usize) -> Option<usize> {
|
||||
let focus = self.state.focus.map(|current| {
|
||||
if current < len.saturating_sub(1) {
|
||||
current.saturating_add(1)
|
||||
} else {
|
||||
current
|
||||
}
|
||||
});
|
||||
self.state.focus = focus;
|
||||
focus
|
||||
}
|
||||
}
|
||||
|
||||
impl<S, M> View for SectionGroup<S, M>
|
||||
where
|
||||
S: 'static,
|
||||
M: 'static,
|
||||
{
|
||||
type State = S;
|
||||
type Message = M;
|
||||
|
||||
fn handle_event(&mut self, props: Option<&ViewProps>, event: Event) -> Option<Self::Message> {
|
||||
let default = SectionGroupProps::default();
|
||||
let props = props
|
||||
.and_then(|props| props.inner_ref::<SectionGroupProps>())
|
||||
.unwrap_or(&default);
|
||||
|
||||
if let Some(section) = self
|
||||
.state
|
||||
.focus
|
||||
.and_then(|focus| self.sections.get_mut(focus))
|
||||
{
|
||||
section.handle_event(event);
|
||||
}
|
||||
|
||||
if props.handle_keys {
|
||||
if let Event::Key(key) = event {
|
||||
match key {
|
||||
Key::BackTab => {
|
||||
self.prev();
|
||||
}
|
||||
Key::Tab => {
|
||||
self.next(self.sections.len());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn update(&mut self, props: Option<&ViewProps>, state: &Self::State) {
|
||||
let default = SectionGroupProps::default();
|
||||
let props = props
|
||||
.and_then(|props| props.inner_ref::<SectionGroupProps>())
|
||||
.unwrap_or(&default);
|
||||
|
||||
for section in &mut self.sections {
|
||||
section.update(state);
|
||||
}
|
||||
|
||||
if props.focus.is_some() && props.focus != self.state.focus {
|
||||
self.state.focus = props.focus;
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
|
||||
let default = SectionGroupProps::default();
|
||||
let props = props
|
||||
.and_then(|props| props.inner_ref::<SectionGroupProps>())
|
||||
.unwrap_or(&default);
|
||||
|
||||
let areas = props.layout.split(render.area);
|
||||
|
||||
for (index, area) in areas.iter().enumerate() {
|
||||
if let Some(section) = self.sections.get_mut(index) {
|
||||
let focus = self
|
||||
.state
|
||||
.focus
|
||||
.map(|focus_index| index == focus_index)
|
||||
.unwrap_or_default();
|
||||
|
||||
section.render(RenderProps::from(*area).focus(focus), frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn view_state(&self) -> Option<super::ViewState> {
|
||||
Some(ViewState::SectionGroup(self.state.clone()))
|
||||
}
|
||||
}
|
||||
@@ -1,542 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
use std::hash::Hash;
|
||||
use std::marker::PhantomData;
|
||||
use std::{cmp, vec};
|
||||
|
||||
use ratatui::layout::{Constraint, Layout};
|
||||
use ratatui::style::{Style, Stylize};
|
||||
use ratatui::symbols::border;
|
||||
use ratatui::text::Text;
|
||||
use ratatui::widgets::TableState;
|
||||
use ratatui::widgets::{Block, Borders, Row, Scrollbar, ScrollbarOrientation, ScrollbarState};
|
||||
use ratatui::Frame;
|
||||
|
||||
use tui_tree_widget::TreeState;
|
||||
|
||||
use crate::event::{Event, Key};
|
||||
use crate::ui::theme::style;
|
||||
use crate::ui::{layout, span};
|
||||
use crate::ui::{Column, ToRow, ToTree};
|
||||
|
||||
use super::{utils, ViewProps, ViewState};
|
||||
use super::{RenderProps, View};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TableProps<'a, R, const W: usize>
|
||||
where
|
||||
R: ToRow<W>,
|
||||
{
|
||||
pub items: Vec<R>,
|
||||
pub selected: Option<usize>,
|
||||
pub columns: Vec<Column<'a>>,
|
||||
pub show_scrollbar: bool,
|
||||
pub dim: bool,
|
||||
}
|
||||
|
||||
impl<R, const W: usize> Default for TableProps<'_, R, W>
|
||||
where
|
||||
R: ToRow<W>,
|
||||
{
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
items: vec![],
|
||||
columns: vec![],
|
||||
show_scrollbar: true,
|
||||
selected: Some(0),
|
||||
dim: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, R, const W: usize> TableProps<'a, R, W>
|
||||
where
|
||||
R: ToRow<W>,
|
||||
{
|
||||
pub fn items(mut self, items: Vec<R>) -> Self {
|
||||
self.items = items;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn selected(mut self, selected: Option<usize>) -> Self {
|
||||
self.selected = selected;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn columns(mut self, columns: Vec<Column<'a>>) -> Self {
|
||||
self.columns = columns;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn show_scrollbar(mut self, show_scrollbar: bool) -> Self {
|
||||
self.show_scrollbar = show_scrollbar;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn dim(mut self, dim: bool) -> Self {
|
||||
self.dim = dim;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Table<S, M, R, const W: usize>
|
||||
where
|
||||
R: ToRow<W>,
|
||||
{
|
||||
/// Internal selection and offset state
|
||||
state: (TableState, usize),
|
||||
/// Phantom
|
||||
phantom: PhantomData<(S, M, R)>,
|
||||
/// Current render height
|
||||
height: u16,
|
||||
}
|
||||
|
||||
impl<S, M, R, const W: usize> Default for Table<S, M, R, W>
|
||||
where
|
||||
R: ToRow<W>,
|
||||
{
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
state: (TableState::default().with_selected(Some(0)), 0),
|
||||
phantom: PhantomData,
|
||||
height: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S, M, R, const W: usize> Table<S, M, R, W>
|
||||
where
|
||||
R: ToRow<W>,
|
||||
{
|
||||
fn prev(&mut self) -> Option<usize> {
|
||||
let selected = self
|
||||
.state
|
||||
.0
|
||||
.selected()
|
||||
.map(|current| current.saturating_sub(1));
|
||||
self.state.0.select(selected);
|
||||
selected
|
||||
}
|
||||
|
||||
fn next(&mut self, len: usize) -> Option<usize> {
|
||||
let selected = self.state.0.selected().map(|current| {
|
||||
if current < len.saturating_sub(1) {
|
||||
current.saturating_add(1)
|
||||
} else {
|
||||
current
|
||||
}
|
||||
});
|
||||
self.state.0.select(selected);
|
||||
selected
|
||||
}
|
||||
|
||||
fn prev_page(&mut self, page_size: usize) -> Option<usize> {
|
||||
let selected = self
|
||||
.state
|
||||
.0
|
||||
.selected()
|
||||
.map(|current| current.saturating_sub(page_size));
|
||||
self.state.0.select(selected);
|
||||
selected
|
||||
}
|
||||
|
||||
fn next_page(&mut self, len: usize, page_size: usize) -> Option<usize> {
|
||||
let selected = self.state.0.selected().map(|current| {
|
||||
if current < len.saturating_sub(1) {
|
||||
cmp::min(current.saturating_add(page_size), len.saturating_sub(1))
|
||||
} else {
|
||||
current
|
||||
}
|
||||
});
|
||||
self.state.0.select(selected);
|
||||
selected
|
||||
}
|
||||
|
||||
fn begin(&mut self) {
|
||||
self.state.0.select(Some(0));
|
||||
}
|
||||
|
||||
fn end(&mut self, len: usize) {
|
||||
self.state.0.select(Some(len.saturating_sub(1)));
|
||||
}
|
||||
}
|
||||
|
||||
impl<S, M, R, const W: usize> View for Table<S, M, R, W>
|
||||
where
|
||||
S: 'static,
|
||||
M: 'static,
|
||||
R: ToRow<W> + Clone + 'static,
|
||||
{
|
||||
type Message = M;
|
||||
type State = S;
|
||||
|
||||
fn handle_event(&mut self, props: Option<&ViewProps>, event: Event) -> Option<Self::Message> {
|
||||
let default = TableProps::default();
|
||||
let props = props
|
||||
.and_then(|props| props.inner_ref::<TableProps<R, W>>())
|
||||
.unwrap_or(&default);
|
||||
|
||||
let page_size = self.height;
|
||||
|
||||
if let Event::Key(key) = event {
|
||||
match key {
|
||||
Key::Up | Key::Char('k') => {
|
||||
self.prev();
|
||||
}
|
||||
Key::Down | Key::Char('j') => {
|
||||
self.next(props.items.len());
|
||||
}
|
||||
Key::PageUp => {
|
||||
self.prev_page(page_size as usize);
|
||||
}
|
||||
Key::PageDown => {
|
||||
self.next_page(props.items.len(), page_size as usize);
|
||||
}
|
||||
Key::Home => {
|
||||
self.begin();
|
||||
}
|
||||
Key::End => {
|
||||
self.end(props.items.len());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn update(&mut self, props: Option<&ViewProps>, _state: &Self::State) {
|
||||
let default = TableProps::default();
|
||||
let props = props
|
||||
.and_then(|props| props.inner_ref::<TableProps<R, W>>())
|
||||
.unwrap_or(&default);
|
||||
|
||||
if props.selected != self.state.0.selected() {
|
||||
self.state.0.select(props.selected);
|
||||
}
|
||||
self.state.1 = props.items.len();
|
||||
}
|
||||
|
||||
fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
|
||||
let default = TableProps::default();
|
||||
let props = props
|
||||
.and_then(|props| props.inner_ref::<TableProps<R, W>>())
|
||||
.unwrap_or(&default);
|
||||
|
||||
let show_scrollbar = props.show_scrollbar && props.items.len() >= self.height.into();
|
||||
let has_items = !props.items.is_empty();
|
||||
|
||||
let widths: Vec<Constraint> = props
|
||||
.columns
|
||||
.iter()
|
||||
.filter_map(|c| {
|
||||
if !c.skip && c.displayed(render.area.width as usize) {
|
||||
Some(c.width)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if has_items {
|
||||
let [table_area, scroller_area] = Layout::horizontal([
|
||||
Constraint::Min(1),
|
||||
if show_scrollbar {
|
||||
Constraint::Length(1)
|
||||
} else {
|
||||
Constraint::Length(0)
|
||||
},
|
||||
])
|
||||
.areas(render.area);
|
||||
|
||||
let rows = props
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| {
|
||||
let mut cells = vec![];
|
||||
let mut it = props.columns.iter();
|
||||
|
||||
for cell in item.to_row() {
|
||||
if let Some(col) = it.next() {
|
||||
if !col.skip && col.displayed(render.area.width as usize) {
|
||||
cells.push(cell.clone())
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
Row::new(cells)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let table = ratatui::widgets::Table::default()
|
||||
.rows(rows)
|
||||
.widths(widths)
|
||||
.column_spacing(1)
|
||||
.row_highlight_style(style::highlight(render.focus));
|
||||
|
||||
let table = if !render.focus && props.dim {
|
||||
table.dim()
|
||||
} else {
|
||||
table
|
||||
};
|
||||
|
||||
frame.render_stateful_widget(table, table_area, &mut self.state.0);
|
||||
|
||||
let scroller = Scrollbar::default()
|
||||
.begin_symbol(None)
|
||||
.track_symbol(None)
|
||||
.end_symbol(None)
|
||||
.thumb_symbol("┃")
|
||||
.style(if render.focus {
|
||||
Style::default()
|
||||
} else {
|
||||
Style::default().dim()
|
||||
});
|
||||
let mut scroller_state = ScrollbarState::default()
|
||||
.content_length(props.items.len().saturating_sub(self.height.into()))
|
||||
.position(self.state.0.offset());
|
||||
frame.render_stateful_widget(scroller, scroller_area, &mut scroller_state);
|
||||
} else {
|
||||
let center = layout::centered_rect(render.area, 50, 10);
|
||||
let hint = Text::from(span::default("Nothing to show"))
|
||||
.centered()
|
||||
.light_magenta()
|
||||
.dim();
|
||||
|
||||
frame.render_widget(hint, center);
|
||||
}
|
||||
|
||||
self.height = render.area.height;
|
||||
}
|
||||
|
||||
fn view_state(&self) -> Option<ViewState> {
|
||||
let selected = self.state.0.selected().unwrap_or_default();
|
||||
|
||||
Some(ViewState::Table {
|
||||
selected,
|
||||
scroll: utils::scroll::percent_absolute(
|
||||
selected.saturating_sub(self.height.into()),
|
||||
self.state.1,
|
||||
self.height.into(),
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TreeProps<R, Id>
|
||||
where
|
||||
R: ToTree<Id> + Clone,
|
||||
Id: ToString,
|
||||
{
|
||||
/// Root items.
|
||||
pub items: Vec<R>,
|
||||
/// Optional path to selected item, e.g. ["1.0", "1.0.1", "1.0.2"]. If not `None`,
|
||||
/// it will override the internal tree state.
|
||||
pub selected: Option<Vec<Id>>,
|
||||
/// If this widget should render its scrollbar. Default: `true`.
|
||||
pub show_scrollbar: bool,
|
||||
/// Optional identifier set of opened items. If not `None`,
|
||||
/// it will override the internal tree state.
|
||||
pub opened: Option<HashSet<Vec<Id>>>,
|
||||
/// Set to `true` if the content style should be dimmed whenever the widget
|
||||
/// has no focus.
|
||||
pub dim: bool,
|
||||
}
|
||||
|
||||
impl<R, Id> Default for TreeProps<R, Id>
|
||||
where
|
||||
R: ToTree<Id> + Clone,
|
||||
Id: ToString,
|
||||
{
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
items: vec![],
|
||||
selected: None,
|
||||
show_scrollbar: true,
|
||||
opened: None,
|
||||
dim: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<R, Id> TreeProps<R, Id>
|
||||
where
|
||||
R: ToTree<Id> + Clone,
|
||||
Id: ToString + Clone,
|
||||
{
|
||||
pub fn items(mut self, items: Vec<R>) -> Self {
|
||||
self.items = items;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn selected(mut self, selected: Option<&[Id]>) -> Self {
|
||||
self.selected = selected.map(|s| s.to_vec());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn opened(mut self, opened: Option<HashSet<Vec<Id>>>) -> Self {
|
||||
self.opened = opened;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn show_scrollbar(mut self, show_scrollbar: bool) -> Self {
|
||||
self.show_scrollbar = show_scrollbar;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn dim(mut self, dim: bool) -> Self {
|
||||
self.dim = dim;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A `Tree` is an expandable, collapsable and scrollable tree widget, that takes
|
||||
/// a list of root items which implement `ToTree`. It can be updated with a selection
|
||||
/// and a set of opened items.
|
||||
pub struct Tree<S, M, R, Id>
|
||||
where
|
||||
R: ToTree<Id>,
|
||||
Id: ToString + Clone,
|
||||
{
|
||||
/// Internal selection and offset state
|
||||
state: TreeState<Id>,
|
||||
/// Phantom
|
||||
phantom: PhantomData<(S, M, R, Id)>,
|
||||
}
|
||||
|
||||
impl<S, M, R, Id> Default for Tree<S, M, R, Id>
|
||||
where
|
||||
R: ToTree<Id>,
|
||||
Id: ToString + Clone + Default,
|
||||
{
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
state: TreeState::default(),
|
||||
phantom: PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S, M, R, Id> View for Tree<S, M, R, Id>
|
||||
where
|
||||
R: ToTree<Id> + Clone + 'static,
|
||||
Id: ToString + Clone + Default + Eq + PartialEq + Hash + 'static,
|
||||
{
|
||||
type State = S;
|
||||
type Message = M;
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.state = TreeState::default();
|
||||
}
|
||||
|
||||
fn update(&mut self, props: Option<&ViewProps>, _state: &Self::State) {
|
||||
let default = TreeProps::default();
|
||||
let props = props
|
||||
.and_then(|props| props.inner_ref::<TreeProps<R, Id>>())
|
||||
.unwrap_or(&default);
|
||||
|
||||
if let Some(selected) = &props.selected {
|
||||
if selected != self.state.selected() {
|
||||
self.state.select(selected.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(opened) = &props.opened {
|
||||
if opened != self.state.opened() {
|
||||
self.state.close_all();
|
||||
for path in opened {
|
||||
self.state.open(path.to_vec());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_event(&mut self, _props: Option<&ViewProps>, event: Event) -> Option<Self::Message> {
|
||||
if let Event::Key(key) = event {
|
||||
match key {
|
||||
Key::Up | Key::Char('k') => {
|
||||
self.state.key_up();
|
||||
}
|
||||
Key::Down | Key::Char('j') => {
|
||||
self.state.key_down();
|
||||
}
|
||||
Key::Left | Key::Char('h')
|
||||
if !self.state.selected().is_empty() && !self.state.opened().is_empty() =>
|
||||
{
|
||||
self.state.key_left();
|
||||
}
|
||||
Key::Right | Key::Char('l') => {
|
||||
self.state.key_right();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
|
||||
let default = TreeProps::default();
|
||||
let props = props
|
||||
.and_then(|props| props.inner_ref::<TreeProps<R, Id>>())
|
||||
.unwrap_or(&default);
|
||||
|
||||
let mut items = vec![];
|
||||
for item in &props.items {
|
||||
items.extend(item.rows());
|
||||
}
|
||||
|
||||
let tree_style = if !render.focus && props.dim {
|
||||
Style::default().dim()
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
|
||||
let tree = if props.show_scrollbar {
|
||||
tui_tree_widget::Tree::new(&items)
|
||||
.expect("all item identifiers are unique")
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::RIGHT)
|
||||
.border_set(border::Set {
|
||||
vertical_right: " ",
|
||||
..Default::default()
|
||||
})
|
||||
.border_style(if render.focus {
|
||||
Style::default()
|
||||
} else {
|
||||
Style::default().dim()
|
||||
}),
|
||||
)
|
||||
.experimental_scrollbar(Some(
|
||||
Scrollbar::new(ScrollbarOrientation::VerticalRight)
|
||||
.begin_symbol(None)
|
||||
.track_symbol(None)
|
||||
.end_symbol(None)
|
||||
.thumb_symbol("┃"),
|
||||
))
|
||||
.highlight_style(style::highlight(render.focus))
|
||||
.style(tree_style)
|
||||
} else {
|
||||
tui_tree_widget::Tree::new(&items)
|
||||
.expect("all item identifiers are unique")
|
||||
.style(tree_style)
|
||||
.highlight_style(style::highlight(render.focus))
|
||||
};
|
||||
|
||||
frame.render_stateful_widget(tree, render.area, &mut self.state);
|
||||
}
|
||||
|
||||
fn view_state(&self) -> Option<ViewState> {
|
||||
Some(ViewState::Tree(
|
||||
self.state
|
||||
.selected()
|
||||
.to_vec()
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -1,967 +0,0 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use ratatui::layout::{Alignment, Constraint, Layout, Position, Rect};
|
||||
use ratatui::style::{Style, Stylize};
|
||||
use ratatui::text::{Line, Span, Text};
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::event::{Event, Key};
|
||||
use crate::ui::theme::{self, Theme};
|
||||
|
||||
use super::{utils, RenderProps, View, ViewProps, ViewState};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LabelProps {
|
||||
pub text: String,
|
||||
pub style: Style,
|
||||
}
|
||||
|
||||
impl LabelProps {
|
||||
pub fn text(mut self, text: &str) -> Self {
|
||||
self.text = text.to_string();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn style(mut self, style: Style) -> Self {
|
||||
self.style = style;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LabelProps {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
text: "".to_string(),
|
||||
style: theme::style::reset(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Label<S, M> {
|
||||
/// Phantom
|
||||
phantom: PhantomData<(S, M)>,
|
||||
}
|
||||
|
||||
impl<S, M> Default for Label<S, M> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
phantom: PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S, M> View for Label<S, M> {
|
||||
type Message = M;
|
||||
type State = S;
|
||||
|
||||
fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
|
||||
let default = LabelProps::default();
|
||||
let props = props
|
||||
.and_then(|props| props.inner_ref::<LabelProps>())
|
||||
.unwrap_or(&default);
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(props.text.clone()).style(props.style),
|
||||
render.area,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TextFieldProps {
|
||||
/// The label of this input field.
|
||||
pub title: String,
|
||||
/// The input text.
|
||||
pub text: String,
|
||||
/// Sets if the label should be displayed inline with the input. The default is `false`.
|
||||
pub inline_label: bool,
|
||||
/// Sets if the cursor should be shown. The default is `true`.
|
||||
pub show_cursor: bool,
|
||||
/// Set to `true` if the content style should be dimmed whenever the widget
|
||||
/// has no focus.
|
||||
pub dim: bool,
|
||||
}
|
||||
|
||||
impl TextFieldProps {
|
||||
pub fn text(mut self, new_text: &str) -> Self {
|
||||
if self.text != new_text {
|
||||
self.text = String::from(new_text);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn title(mut self, title: &str) -> Self {
|
||||
self.title = title.to_string();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn inline(mut self, inline: bool) -> Self {
|
||||
self.inline_label = inline;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn dim(mut self, dim: bool) -> Self {
|
||||
self.dim = dim;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TextFieldProps {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
title: String::new(),
|
||||
inline_label: false,
|
||||
show_cursor: true,
|
||||
text: String::new(),
|
||||
dim: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TextFieldState {
|
||||
pub text: Option<String>,
|
||||
pub cursor_position: usize,
|
||||
}
|
||||
|
||||
pub struct TextField<S, M> {
|
||||
/// Internal state
|
||||
state: TextFieldState,
|
||||
/// Phantom
|
||||
phantom: PhantomData<(S, M)>,
|
||||
}
|
||||
|
||||
impl<S, M> Default for TextField<S, M> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
state: TextFieldState {
|
||||
text: None,
|
||||
cursor_position: 0,
|
||||
},
|
||||
phantom: PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S, M> TextField<S, M> {
|
||||
fn move_cursor_left(&mut self) {
|
||||
let cursor_moved_left = self.state.cursor_position.saturating_sub(1);
|
||||
self.state.cursor_position = self.clamp_cursor(cursor_moved_left);
|
||||
}
|
||||
|
||||
fn move_cursor_right(&mut self) {
|
||||
let cursor_moved_right = self.state.cursor_position.saturating_add(1);
|
||||
self.state.cursor_position = self.clamp_cursor(cursor_moved_right);
|
||||
}
|
||||
|
||||
fn enter_char(&mut self, new_char: char) {
|
||||
self.state.text = Some(self.state.text.clone().unwrap_or_default());
|
||||
self.state
|
||||
.text
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.insert(self.state.cursor_position, new_char);
|
||||
self.move_cursor_right();
|
||||
}
|
||||
|
||||
fn delete_char_right(&mut self) {
|
||||
self.state.text = Some(self.state.text.clone().unwrap_or_default());
|
||||
|
||||
// Method "remove" is not used on the saved text for deleting the selected char.
|
||||
// Reason: Using remove on String works on bytes instead of the chars.
|
||||
// Using remove would require special care because of char boundaries.
|
||||
|
||||
let current_index = self.state.cursor_position;
|
||||
let from_left_to_current_index = current_index;
|
||||
|
||||
// Getting all characters before the selected character.
|
||||
let before_char_to_delete = self
|
||||
.state
|
||||
.text
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.chars()
|
||||
.take(from_left_to_current_index);
|
||||
// Getting all characters after selected character.
|
||||
let after_char_to_delete = self
|
||||
.state
|
||||
.text
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.chars()
|
||||
.skip(current_index.saturating_add(1));
|
||||
|
||||
// Put all characters together except the selected one.
|
||||
// By leaving the selected one out, it is forgotten and therefore deleted.
|
||||
self.state.text = Some(before_char_to_delete.chain(after_char_to_delete).collect());
|
||||
}
|
||||
|
||||
fn delete_char_left(&mut self) {
|
||||
self.state.text = Some(self.state.text.clone().unwrap_or_default());
|
||||
|
||||
let is_not_cursor_leftmost = self.state.cursor_position != 0;
|
||||
if is_not_cursor_leftmost {
|
||||
// Method "remove" is not used on the saved text for deleting the selected char.
|
||||
// Reason: Using remove on String works on bytes instead of the chars.
|
||||
// Using remove would require special care because of char boundaries.
|
||||
|
||||
let current_index = self.state.cursor_position;
|
||||
let from_left_to_current_index = current_index - 1;
|
||||
|
||||
// Getting all characters before the selected character.
|
||||
let before_char_to_delete = self
|
||||
.state
|
||||
.text
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.chars()
|
||||
.take(from_left_to_current_index);
|
||||
// Getting all characters after selected character.
|
||||
let after_char_to_delete = self
|
||||
.state
|
||||
.text
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.chars()
|
||||
.skip(current_index);
|
||||
|
||||
// Put all characters together except the selected one.
|
||||
// By leaving the selected one out, it is forgotten and therefore deleted.
|
||||
self.state.text = Some(before_char_to_delete.chain(after_char_to_delete).collect());
|
||||
self.move_cursor_left();
|
||||
}
|
||||
}
|
||||
|
||||
fn clamp_cursor(&self, new_cursor_pos: usize) -> usize {
|
||||
new_cursor_pos.clamp(0, self.state.text.clone().unwrap_or_default().len())
|
||||
}
|
||||
}
|
||||
|
||||
impl<S, M> View for TextField<S, M>
|
||||
where
|
||||
S: 'static,
|
||||
M: 'static,
|
||||
{
|
||||
type Message = M;
|
||||
type State = S;
|
||||
|
||||
fn view_state(&self) -> Option<ViewState> {
|
||||
self.state
|
||||
.text
|
||||
.as_ref()
|
||||
.map(|text| ViewState::String(text.to_string()))
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.state = TextFieldState {
|
||||
text: None,
|
||||
cursor_position: 0,
|
||||
};
|
||||
}
|
||||
|
||||
fn handle_event(&mut self, _props: Option<&ViewProps>, event: Event) -> Option<Self::Message> {
|
||||
if let Event::Key(key) = event {
|
||||
match key {
|
||||
Key::Char(to_insert)
|
||||
if (key != Key::Alt('\n'))
|
||||
&& (key != Key::Char('\n'))
|
||||
&& (key != Key::Ctrl('\n')) =>
|
||||
{
|
||||
self.enter_char(to_insert);
|
||||
}
|
||||
Key::Backspace => {
|
||||
self.delete_char_left();
|
||||
}
|
||||
Key::Delete => {
|
||||
self.delete_char_right();
|
||||
}
|
||||
Key::Left => {
|
||||
self.move_cursor_left();
|
||||
}
|
||||
Key::Right => {
|
||||
self.move_cursor_right();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn update(&mut self, props: Option<&ViewProps>, _state: &Self::State) {
|
||||
let default = TextFieldProps::default();
|
||||
let props = props
|
||||
.and_then(|props| props.inner_ref::<TextFieldProps>())
|
||||
.unwrap_or(&default);
|
||||
|
||||
if self.state.text.is_none() {
|
||||
self.state.cursor_position = props.text.len().saturating_sub(1);
|
||||
}
|
||||
self.state.text = Some(props.text.clone());
|
||||
}
|
||||
|
||||
fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
|
||||
let default = TextFieldProps::default();
|
||||
let props = props
|
||||
.and_then(|props| props.inner_ref::<TextFieldProps>())
|
||||
.unwrap_or(&default);
|
||||
|
||||
let area = render.area;
|
||||
let layout = Layout::vertical(Constraint::from_lengths([1, 1])).split(area);
|
||||
|
||||
let text = self.state.text.clone().unwrap_or_default();
|
||||
let input = text.as_str();
|
||||
let label_content = format!(" {} ", props.title);
|
||||
let overline = String::from("▔").repeat(area.width as usize);
|
||||
let cursor_pos = self.state.cursor_position as u16;
|
||||
|
||||
let (label, input, overline) = if !render.focus && props.dim {
|
||||
(
|
||||
Span::from(label_content.clone()).magenta().dim().reversed(),
|
||||
Span::from(input).reset().dim(),
|
||||
Span::raw(overline).magenta().dim(),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
Span::from(label_content.clone()).magenta().reversed(),
|
||||
Span::from(input).reset(),
|
||||
Span::raw(overline).magenta(),
|
||||
)
|
||||
};
|
||||
|
||||
if props.inline_label {
|
||||
let top_layout = Layout::horizontal([
|
||||
Constraint::Length(label_content.chars().count() as u16),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(1),
|
||||
])
|
||||
.split(layout[0]);
|
||||
|
||||
let overline = Line::from([overline].to_vec());
|
||||
|
||||
frame.render_widget(label, top_layout[0]);
|
||||
frame.render_widget(input, top_layout[2]);
|
||||
frame.render_widget(overline, layout[1]);
|
||||
|
||||
if props.show_cursor {
|
||||
frame.set_cursor_position(Position::new(
|
||||
top_layout[2].x + cursor_pos,
|
||||
top_layout[2].y,
|
||||
))
|
||||
}
|
||||
} else {
|
||||
let top = Line::from([input].to_vec());
|
||||
let bottom = Line::from([label, overline].to_vec());
|
||||
|
||||
frame.render_widget(top, layout[0]);
|
||||
frame.render_widget(bottom, layout[1]);
|
||||
|
||||
if props.show_cursor {
|
||||
frame.set_cursor_position(Position::new(area.x + cursor_pos, area.y))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The state of a `TextArea`.
|
||||
#[derive(Clone, Default, Debug)]
|
||||
pub struct TextAreaState {
|
||||
/// Current vertical scroll position.
|
||||
pub scroll: usize,
|
||||
/// Current cursor position.
|
||||
pub cursor: (usize, usize),
|
||||
}
|
||||
|
||||
/// The properties of a `TextArea`.
|
||||
#[derive(Clone)]
|
||||
pub struct TextAreaProps<'a> {
|
||||
/// Content of this text area.
|
||||
content: Text<'a>,
|
||||
/// Current cursor position. Default: `(0, 0)`.
|
||||
cursor: (usize, usize),
|
||||
/// If this text area should handle events. Default: `true`.
|
||||
handle_keys: bool,
|
||||
/// If this text area is in insert mode. Default: `false`.
|
||||
insert_mode: bool,
|
||||
/// If this text area should render its scroll progress. Default: `false`.
|
||||
show_scroll_progress: bool,
|
||||
/// If this text area should render its cursor progress. Default: `false`.
|
||||
show_column_progress: bool,
|
||||
/// Set to `true` if the content style should be dimmed whenever the widget
|
||||
/// has no focus.
|
||||
dim: bool,
|
||||
}
|
||||
|
||||
impl Default for TextAreaProps<'_> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
content: String::new().into(),
|
||||
cursor: (0, 0),
|
||||
handle_keys: true,
|
||||
insert_mode: false,
|
||||
show_scroll_progress: false,
|
||||
show_column_progress: false,
|
||||
dim: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> TextAreaProps<'a> {
|
||||
pub fn content<T>(mut self, content: T) -> Self
|
||||
where
|
||||
T: Into<Text<'a>>,
|
||||
{
|
||||
self.content = content.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn cursor(mut self, cursor: (usize, usize)) -> Self {
|
||||
self.cursor = cursor;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn show_scroll_progress(mut self, show_scroll_progress: bool) -> Self {
|
||||
self.show_scroll_progress = show_scroll_progress;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn show_column_progress(mut self, show_column_progress: bool) -> Self {
|
||||
self.show_column_progress = show_column_progress;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn handle_keys(mut self, handle_keys: bool) -> Self {
|
||||
self.handle_keys = handle_keys;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn dim(mut self, dim: bool) -> Self {
|
||||
self.dim = dim;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A non-editable text area that can be behave like a text editor.
|
||||
/// It can scroll through text by moving around the cursor.
|
||||
pub struct TextArea<'a, S, M> {
|
||||
phantom: PhantomData<(S, M)>,
|
||||
textarea: tui_textarea::TextArea<'a>,
|
||||
area: (u16, u16),
|
||||
}
|
||||
|
||||
impl<S, M> Default for TextArea<'_, S, M> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
phantom: PhantomData,
|
||||
textarea: tui_textarea::TextArea::default(),
|
||||
area: (0, 0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S, M> View for TextArea<'_, S, M> {
|
||||
type State = S;
|
||||
type Message = M;
|
||||
|
||||
fn handle_event(&mut self, props: Option<&ViewProps>, event: Event) -> Option<Self::Message> {
|
||||
use tui_textarea::Input;
|
||||
|
||||
let default = TextAreaProps::default();
|
||||
let props = props
|
||||
.and_then(|props| props.inner_ref::<TextAreaProps>())
|
||||
.unwrap_or(&default);
|
||||
|
||||
if props.handle_keys {
|
||||
if !props.insert_mode {
|
||||
if let Event::Key(key) = event {
|
||||
match key {
|
||||
Key::Left | Key::Char('h') => {
|
||||
self.textarea.input(Input {
|
||||
key: tui_textarea::Key::Left,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
Key::Right | Key::Char('l') => {
|
||||
self.textarea.input(Input {
|
||||
key: tui_textarea::Key::Right,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
Key::Up | Key::Char('k') => {
|
||||
self.textarea.input(Input {
|
||||
key: tui_textarea::Key::Up,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
Key::Down | Key::Char('j') => {
|
||||
self.textarea.input(Input {
|
||||
key: tui_textarea::Key::Down,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO: Implement insert mode.
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn update(&mut self, props: Option<&ViewProps>, _state: &Self::State) {
|
||||
let default = TextAreaProps::default();
|
||||
let props = props
|
||||
.and_then(|props| props.inner_ref::<TextAreaProps>())
|
||||
.unwrap_or(&default);
|
||||
|
||||
self.textarea = tui_textarea::TextArea::new(
|
||||
props
|
||||
.content
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| line.to_string())
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
|
||||
fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
|
||||
let default = TextAreaProps::default();
|
||||
let props = props
|
||||
.and_then(|props| props.inner_ref::<TextAreaProps>())
|
||||
.unwrap_or(&default);
|
||||
|
||||
let [area] = Layout::default()
|
||||
.constraints([Constraint::Min(1)])
|
||||
.horizontal_margin(1)
|
||||
.areas(render.area);
|
||||
|
||||
let [content_area, progress_area] = Layout::vertical([
|
||||
Constraint::Min(1),
|
||||
Constraint::Length(
|
||||
if props.show_scroll_progress || props.show_column_progress {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
},
|
||||
),
|
||||
])
|
||||
.areas(area);
|
||||
|
||||
let cursor_line_style = Style::default();
|
||||
let cursor_style = if render.focus {
|
||||
Style::default().reversed()
|
||||
} else {
|
||||
cursor_line_style
|
||||
};
|
||||
let content_style = if !render.focus && props.dim {
|
||||
Style::default().dim()
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
|
||||
self.textarea.move_cursor(tui_textarea::CursorMove::Jump(
|
||||
props.cursor.0 as u16,
|
||||
props.cursor.1 as u16,
|
||||
));
|
||||
self.textarea.set_cursor_line_style(cursor_line_style);
|
||||
self.textarea.set_cursor_style(cursor_style);
|
||||
self.textarea.set_style(content_style);
|
||||
|
||||
let (scroll_progress, cursor_progress) = (
|
||||
utils::scroll::percent_absolute(
|
||||
self.textarea.cursor().0,
|
||||
props.content.lines.len(),
|
||||
content_area.height.into(),
|
||||
),
|
||||
(self.textarea.cursor().0, self.textarea.cursor().1),
|
||||
);
|
||||
|
||||
frame.render_widget(&self.textarea, content_area);
|
||||
|
||||
let mut progress_info = vec![];
|
||||
|
||||
if props.show_scroll_progress {
|
||||
progress_info.push(Span::styled(
|
||||
format!("{scroll_progress}%"),
|
||||
Style::default().dim(),
|
||||
))
|
||||
}
|
||||
|
||||
if props.show_scroll_progress && props.show_column_progress {
|
||||
progress_info.push(Span::raw(" "));
|
||||
}
|
||||
|
||||
if props.show_column_progress {
|
||||
progress_info.push(Span::styled(
|
||||
format!("[{},{}]", cursor_progress.0, cursor_progress.1),
|
||||
Style::default().dim(),
|
||||
))
|
||||
}
|
||||
|
||||
frame.render_widget(
|
||||
Line::from(progress_info).alignment(Alignment::Right),
|
||||
progress_area,
|
||||
);
|
||||
|
||||
self.area = (content_area.height, content_area.width);
|
||||
}
|
||||
|
||||
fn view_state(&self) -> Option<ViewState> {
|
||||
Some(ViewState::TextArea(TextAreaState {
|
||||
cursor: self.textarea.cursor(),
|
||||
scroll: utils::scroll::percent_absolute(
|
||||
self.textarea.cursor().0.saturating_sub(self.area.0.into()),
|
||||
self.textarea.lines().len(),
|
||||
self.area.0.into(),
|
||||
),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// State of a `TextView`.
|
||||
#[derive(Clone, Default, Debug)]
|
||||
pub struct TextViewState {
|
||||
/// Current vertical scroll position.
|
||||
pub scroll: usize,
|
||||
/// Current cursor position.
|
||||
pub cursor: (usize, usize),
|
||||
/// Content of this text view.
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
impl TextViewState {
|
||||
pub fn content<T>(mut self, content: T) -> Self
|
||||
where
|
||||
T: Into<String>,
|
||||
{
|
||||
self.content = content.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn cursor(mut self, cursor: (usize, usize)) -> Self {
|
||||
self.cursor = cursor;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn scroll(mut self, scroll: usize) -> Self {
|
||||
self.scroll = scroll;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn reset_cursor(&mut self) {
|
||||
self.cursor = (0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Properties of a `TextView`.
|
||||
#[derive(Clone)]
|
||||
pub struct TextViewProps<'a> {
|
||||
/// Optional state. If set, it will override the internal view state.
|
||||
state: Option<TextViewState>,
|
||||
/// If this widget should handle events. Default: `true`.
|
||||
handle_keys: bool,
|
||||
/// If this widget should render its scroll progress. Default: `false`.
|
||||
show_scroll_progress: bool,
|
||||
/// An optional text that is rendered inside the footer bar on the bottom.
|
||||
footer: Option<Text<'a>>,
|
||||
/// The style used whenever the widget has focus.
|
||||
content_style: Style,
|
||||
/// Default scroll progress style.
|
||||
scroll_style: Style,
|
||||
/// Scroll progress style whenever the the widget has focus.
|
||||
focus_scroll_style: Style,
|
||||
/// Set to `true` if the content style should be dimmed whenever the widget
|
||||
/// has no focus.
|
||||
dim: bool,
|
||||
}
|
||||
|
||||
impl<'a> TextViewProps<'a> {
|
||||
pub fn footer<T>(mut self, footer: Option<T>) -> Self
|
||||
where
|
||||
T: Into<Text<'a>>,
|
||||
{
|
||||
self.footer = footer.map(|f| f.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn state(mut self, state: Option<TextViewState>) -> Self {
|
||||
self.state = state;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn show_scroll_progress(mut self, show_scroll_progress: bool) -> Self {
|
||||
self.show_scroll_progress = show_scroll_progress;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn handle_keys(mut self, handle_keys: bool) -> Self {
|
||||
self.handle_keys = handle_keys;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn content_style(mut self, style: Style) -> Self {
|
||||
self.content_style = style;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn scroll_style(mut self, style: Style) -> Self {
|
||||
self.scroll_style = style;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn focus_scroll_style(mut self, style: Style) -> Self {
|
||||
self.focus_scroll_style = style;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn dim(mut self, dim: bool) -> Self {
|
||||
self.dim = dim;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TextViewProps<'_> {
|
||||
fn default() -> Self {
|
||||
let theme = Theme::default();
|
||||
|
||||
Self {
|
||||
state: None,
|
||||
handle_keys: true,
|
||||
show_scroll_progress: false,
|
||||
footer: None,
|
||||
content_style: theme.textview_style,
|
||||
scroll_style: theme.textview_scroll_style,
|
||||
focus_scroll_style: theme.textview_focus_scroll_style,
|
||||
dim: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A scrollable, non-editable text view widget. It can scroll through text by
|
||||
/// moving around the viewport.
|
||||
pub struct TextView<S, M> {
|
||||
/// Internal view state.
|
||||
state: TextViewState,
|
||||
/// Current render area.
|
||||
area: (u16, u16),
|
||||
/// Phantom.
|
||||
phantom: PhantomData<(S, M)>,
|
||||
}
|
||||
|
||||
impl<S, M> Default for TextView<S, M> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
state: TextViewState::default(),
|
||||
area: (0, 0),
|
||||
phantom: PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S, M> TextView<S, M> {
|
||||
fn scroll_up(&mut self) {
|
||||
self.state.cursor.0 = self.state.cursor.0.saturating_sub(1);
|
||||
}
|
||||
|
||||
fn scroll_down(&mut self, len: usize, page_size: usize) {
|
||||
let end = len.saturating_sub(page_size);
|
||||
self.state.cursor.0 = std::cmp::min(self.state.cursor.0.saturating_add(1), end);
|
||||
}
|
||||
|
||||
fn scroll_left(&mut self) {
|
||||
self.state.cursor.1 = self.state.cursor.1.saturating_sub(3);
|
||||
}
|
||||
|
||||
fn scroll_right(&mut self, max_line_length: usize) {
|
||||
self.state.cursor.1 = std::cmp::min(
|
||||
self.state.cursor.1.saturating_add(3),
|
||||
max_line_length.saturating_add(3),
|
||||
);
|
||||
}
|
||||
|
||||
fn prev_page(&mut self, page_size: usize) {
|
||||
self.state.cursor.0 = self.state.cursor.0.saturating_sub(page_size);
|
||||
}
|
||||
|
||||
fn next_page(&mut self, len: usize, page_size: usize) {
|
||||
let end = len.saturating_sub(page_size);
|
||||
|
||||
self.state.cursor.0 = std::cmp::min(self.state.cursor.0.saturating_add(page_size), end);
|
||||
}
|
||||
|
||||
fn begin(&mut self) {
|
||||
self.state.cursor.0 = 0;
|
||||
}
|
||||
|
||||
fn end(&mut self, len: usize, page_size: usize) {
|
||||
self.state.cursor.0 = len.saturating_sub(page_size);
|
||||
}
|
||||
|
||||
fn update_area(&mut self, area: Rect) {
|
||||
self.area = (area.height, area.width);
|
||||
}
|
||||
|
||||
fn render_content(&self, frame: &mut Frame, props: &TextViewProps, render: &RenderProps) {
|
||||
let content_style = if !render.focus && props.dim {
|
||||
props.content_style.dim()
|
||||
} else {
|
||||
props.content_style
|
||||
};
|
||||
|
||||
let content = Paragraph::new(self.state.content.clone())
|
||||
.style(content_style)
|
||||
.scroll((self.state.cursor.0 as u16, self.state.cursor.1 as u16));
|
||||
|
||||
frame.render_widget(content, render.area);
|
||||
}
|
||||
|
||||
fn render_footer(
|
||||
&self,
|
||||
frame: &mut Frame,
|
||||
props: &TextViewProps,
|
||||
render: &RenderProps,
|
||||
content_height: u16,
|
||||
) {
|
||||
let [text_area, scroll_area] =
|
||||
Layout::horizontal([Constraint::Min(1), Constraint::Length(10)]).areas(render.area);
|
||||
|
||||
let scroll_style = if render.focus {
|
||||
props.focus_scroll_style
|
||||
} else {
|
||||
props.scroll_style
|
||||
};
|
||||
|
||||
let mut scroll = vec![];
|
||||
if props.show_scroll_progress {
|
||||
let content_len = self.state.content.lines().count();
|
||||
let scroll_progress = utils::scroll::percent_absolute(
|
||||
self.state.cursor.0,
|
||||
content_len,
|
||||
content_height.into(),
|
||||
);
|
||||
if (content_height as usize) < content_len {
|
||||
// vec![Span::styled(format!("All / {}", content_len), scroll_style)]
|
||||
scroll = vec![Span::styled(format!("{scroll_progress}%"), scroll_style)];
|
||||
}
|
||||
}
|
||||
|
||||
frame.render_widget(
|
||||
props
|
||||
.footer
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
.alignment(Alignment::Left)
|
||||
.dim(),
|
||||
text_area,
|
||||
);
|
||||
frame.render_widget(Line::from(scroll).alignment(Alignment::Right), scroll_area);
|
||||
}
|
||||
}
|
||||
|
||||
impl<S, M> View for TextView<S, M>
|
||||
where
|
||||
S: 'static,
|
||||
M: 'static,
|
||||
{
|
||||
type Message = M;
|
||||
type State = S;
|
||||
|
||||
fn handle_event(&mut self, props: Option<&ViewProps>, event: Event) -> Option<Self::Message> {
|
||||
let default = TextViewProps::default();
|
||||
let props = props
|
||||
.and_then(|props| props.inner_ref::<TextViewProps>())
|
||||
.unwrap_or(&default);
|
||||
|
||||
let lines = self.state.content.lines().clone();
|
||||
let len = lines.clone().count();
|
||||
let max_line_len = lines.map(|l| l.chars().count()).max().unwrap_or_default();
|
||||
let page_size = self.area.0 as usize;
|
||||
|
||||
if props.handle_keys {
|
||||
if let Event::Key(key) = event {
|
||||
match key {
|
||||
Key::Up | Key::Char('k') => {
|
||||
self.scroll_up();
|
||||
}
|
||||
Key::Down | Key::Char('j') => {
|
||||
self.scroll_down(len, page_size);
|
||||
}
|
||||
Key::Left | Key::Char('h') => {
|
||||
self.scroll_left();
|
||||
}
|
||||
Key::Right | Key::Char('l') => {
|
||||
self.scroll_right(max_line_len.saturating_sub(self.area.1.into()));
|
||||
}
|
||||
Key::PageUp => {
|
||||
self.prev_page(page_size);
|
||||
}
|
||||
Key::PageDown => {
|
||||
self.next_page(len, page_size);
|
||||
}
|
||||
Key::Home => {
|
||||
self.begin();
|
||||
}
|
||||
Key::End => {
|
||||
self.end(len, page_size);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.state.scroll = utils::scroll::percent_absolute(
|
||||
self.state.cursor.0,
|
||||
self.state.content.lines().count(),
|
||||
self.area.0.into(),
|
||||
);
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn update(&mut self, props: Option<&ViewProps>, _state: &Self::State) {
|
||||
let default = TextViewProps::default();
|
||||
let props = props
|
||||
.and_then(|props| props.inner_ref::<TextViewProps>())
|
||||
.unwrap_or(&default);
|
||||
|
||||
if let Some(state) = &props.state {
|
||||
self.state = state.clone();
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
|
||||
let default = TextViewProps::default();
|
||||
let props = props
|
||||
.and_then(|props| props.inner_ref::<TextViewProps>())
|
||||
.unwrap_or(&default);
|
||||
let render_footer = props.show_scroll_progress || props.footer.is_some();
|
||||
|
||||
let [area] = Layout::default()
|
||||
.constraints([Constraint::Min(1)])
|
||||
.horizontal_margin(1)
|
||||
.areas(render.area);
|
||||
|
||||
if render_footer {
|
||||
let [content_area, footer_area] = Layout::vertical([
|
||||
Constraint::Min(1),
|
||||
Constraint::Length(if render_footer { 1 } else { 0 }),
|
||||
])
|
||||
.areas(area);
|
||||
|
||||
self.render_content(frame, props, &render.clone().area(content_area));
|
||||
self.render_footer(frame, props, &render.area(footer_area), content_area.height);
|
||||
self.update_area(content_area);
|
||||
} else {
|
||||
self.render_content(frame, props, &render.clone().area(area));
|
||||
self.update_area(area);
|
||||
}
|
||||
}
|
||||
|
||||
fn view_state(&self) -> Option<ViewState> {
|
||||
Some(ViewState::TextView(self.state.clone()))
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
pub mod scroll {
|
||||
pub fn percent_seen(selected: usize, len: usize, page_size: usize) -> usize {
|
||||
let step = selected;
|
||||
let page_size = page_size as f64;
|
||||
let len = len as f64;
|
||||
|
||||
let lines = page_size + step.saturating_sub(page_size as usize) as f64;
|
||||
let progress = (lines / len * 100.0).ceil();
|
||||
|
||||
if progress > 97.0 {
|
||||
map_range((0.0, progress), (0.0, 100.0), progress) as usize
|
||||
} else {
|
||||
progress as usize
|
||||
}
|
||||
}
|
||||
|
||||
pub fn percent_absolute(offset: usize, len: usize, height: usize) -> usize {
|
||||
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;
|
||||
|
||||
(v as usize).clamp(0, 100)
|
||||
}
|
||||
|
||||
fn map_range(from: (f64, f64), to: (f64, f64), value: f64) -> f64 {
|
||||
to.0 + (value - from.0) * (to.1 - to.0) / (from.1 - from.0)
|
||||
}
|
||||
}
|
||||
@@ -1,335 +0,0 @@
|
||||
use std::hash::Hash;
|
||||
use std::{collections::HashMap, marker::PhantomData};
|
||||
|
||||
use ratatui::layout::{Constraint, Layout};
|
||||
use ratatui::style::{Style, Stylize};
|
||||
use ratatui::text::Text;
|
||||
use ratatui::widgets::Row;
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::event::Event;
|
||||
use crate::ui::theme::{style, Theme};
|
||||
|
||||
use super::{RenderProps, View, ViewProps, Widget};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct WindowProps<Id> {
|
||||
current_page: Option<Id>,
|
||||
}
|
||||
|
||||
impl<Id> WindowProps<Id> {
|
||||
pub fn current_page(mut self, page: Id) -> Self {
|
||||
self.current_page = Some(page);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<Id> Default for WindowProps<Id> {
|
||||
fn default() -> Self {
|
||||
Self { current_page: None }
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Window<S, M, Id> {
|
||||
/// All pages known
|
||||
pages: HashMap<Id, Widget<S, M>>,
|
||||
}
|
||||
|
||||
impl<S, M, Id> Default for Window<S, M, Id> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
pages: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S, M, Id> Window<S, M, Id>
|
||||
where
|
||||
Id: Clone + Hash + Eq + PartialEq,
|
||||
{
|
||||
pub fn page(mut self, id: Id, page: Widget<S, M>) -> Self {
|
||||
self.pages.insert(id, page);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<S, M, Id> View for Window<S, M, Id>
|
||||
where
|
||||
S: 'static,
|
||||
M: 'static,
|
||||
Id: Clone + Hash + Eq + PartialEq + 'static,
|
||||
{
|
||||
type Message = M;
|
||||
type State = S;
|
||||
|
||||
fn handle_event(&mut self, props: Option<&ViewProps>, event: Event) -> Option<Self::Message> {
|
||||
let default = WindowProps::default();
|
||||
let props = props
|
||||
.and_then(|props| props.inner_ref::<WindowProps<Id>>())
|
||||
.unwrap_or(&default);
|
||||
|
||||
let page = props
|
||||
.current_page
|
||||
.as_ref()
|
||||
.and_then(|id| self.pages.get_mut(id));
|
||||
|
||||
if let Some(page) = page {
|
||||
page.handle_event(event);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn update(&mut self, props: Option<&ViewProps>, state: &Self::State) {
|
||||
let default = WindowProps::default();
|
||||
let props = props
|
||||
.and_then(|props| props.inner_ref::<WindowProps<Id>>())
|
||||
.unwrap_or(&default);
|
||||
|
||||
let page = props
|
||||
.current_page
|
||||
.as_ref()
|
||||
.and_then(|id| self.pages.get_mut(id));
|
||||
|
||||
if let Some(page) = page {
|
||||
page.update(state);
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&mut self, props: Option<&ViewProps>, _render: RenderProps, frame: &mut Frame) {
|
||||
let default = WindowProps::default();
|
||||
let props = props
|
||||
.and_then(|props| props.inner_ref::<WindowProps<Id>>())
|
||||
.unwrap_or(&default);
|
||||
|
||||
let area = frame.area();
|
||||
|
||||
let page = props
|
||||
.current_page
|
||||
.as_ref()
|
||||
.and_then(|id| self.pages.get_mut(id));
|
||||
|
||||
if let Some(page) = page {
|
||||
page.render(RenderProps::from(area).focus(true), frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct PageProps {
|
||||
/// If this view's should handle keys
|
||||
pub handle_keys: bool,
|
||||
}
|
||||
|
||||
impl PageProps {
|
||||
pub fn handle_keys(mut self, handle_keys: bool) -> Self {
|
||||
self.handle_keys = handle_keys;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Page<S, M> {
|
||||
/// Content widget
|
||||
content: Option<Widget<S, M>>,
|
||||
/// Shortcut widget
|
||||
shortcuts: Option<Widget<S, M>>,
|
||||
}
|
||||
|
||||
impl<S, M> Default for Page<S, M> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
content: None,
|
||||
shortcuts: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S, M> Page<S, M> {
|
||||
pub fn content(mut self, content: Widget<S, M>) -> Self {
|
||||
self.content = Some(content);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn shortcuts(mut self, shortcuts: Widget<S, M>) -> Self {
|
||||
self.shortcuts = Some(shortcuts);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<S, M> View for Page<S, M>
|
||||
where
|
||||
S: 'static,
|
||||
M: 'static,
|
||||
{
|
||||
type State = S;
|
||||
type Message = M;
|
||||
|
||||
fn handle_event(&mut self, _props: Option<&ViewProps>, event: Event) -> Option<Self::Message> {
|
||||
if let Some(content) = self.content.as_mut() {
|
||||
content.handle_event(event);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn update(&mut self, _props: Option<&ViewProps>, state: &Self::State) {
|
||||
if let Some(content) = self.content.as_mut() {
|
||||
content.update(state);
|
||||
}
|
||||
if let Some(shortcuts) = self.shortcuts.as_mut() {
|
||||
shortcuts.update(state);
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&mut self, _props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
|
||||
let [content_area, shortcuts_area] =
|
||||
Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(render.area);
|
||||
|
||||
if let Some(content) = self.content.as_mut() {
|
||||
content.render(
|
||||
RenderProps::from(content_area)
|
||||
.layout(Layout::horizontal([Constraint::Min(1)]))
|
||||
.focus(true),
|
||||
frame,
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(shortcuts) = self.shortcuts.as_mut() {
|
||||
shortcuts.render(RenderProps::from(shortcuts_area), frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ShortcutsProps {
|
||||
pub shortcuts: Vec<(String, String)>,
|
||||
pub global_shortcuts: Vec<(String, String)>,
|
||||
pub divider: char,
|
||||
pub shortcuts_keys_style: Style,
|
||||
pub shortcuts_action_style: Style,
|
||||
}
|
||||
|
||||
impl ShortcutsProps {
|
||||
pub fn divider(mut self, divider: char) -> Self {
|
||||
self.divider = divider;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn shortcuts(mut self, shortcuts: &[(&str, &str)]) -> Self {
|
||||
self.shortcuts = shortcuts
|
||||
.iter()
|
||||
.map(|(s, l)| (s.to_string(), l.to_string()))
|
||||
.collect();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn global_shortcuts(mut self, shortcuts: &[(&str, &str)]) -> Self {
|
||||
self.global_shortcuts = shortcuts
|
||||
.iter()
|
||||
.map(|(s, l)| (s.to_string(), l.to_string()))
|
||||
.collect();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn shortcuts_keys_style(mut self, style: Style) -> Self {
|
||||
self.shortcuts_keys_style = style;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn shortcuts_action_style(mut self, style: Style) -> Self {
|
||||
self.shortcuts_action_style = style;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ShortcutsProps {
|
||||
fn default() -> Self {
|
||||
let theme = Theme::default();
|
||||
|
||||
Self {
|
||||
shortcuts: vec![],
|
||||
global_shortcuts: vec![],
|
||||
divider: '∙',
|
||||
shortcuts_keys_style: theme.shortcuts_keys_style,
|
||||
shortcuts_action_style: theme.shortcuts_action_style,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Shortcuts<S, M> {
|
||||
/// Phantom
|
||||
phantom: PhantomData<(S, M)>,
|
||||
}
|
||||
|
||||
impl<S, M> Default for Shortcuts<S, M> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
phantom: PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S, M> View for Shortcuts<S, M> {
|
||||
type Message = M;
|
||||
type State = S;
|
||||
|
||||
fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
|
||||
use ratatui::widgets::Table;
|
||||
|
||||
let default = ShortcutsProps::default();
|
||||
let props = props
|
||||
.and_then(|props| props.inner_ref::<ShortcutsProps>())
|
||||
.unwrap_or(&default);
|
||||
|
||||
let spacer = Text::from(String::new());
|
||||
let divider = Text::from(format!(" {} ", props.divider)).style(style::gray().dim());
|
||||
|
||||
let mut shortcuts = props.shortcuts.iter().peekable();
|
||||
let mut row = vec![];
|
||||
|
||||
let action_texts = |shortcut: &(String, String)| {
|
||||
let short = Text::from(shortcut.0.clone()).style(props.shortcuts_keys_style);
|
||||
let long = Text::from(shortcut.1.clone()).style(props.shortcuts_action_style);
|
||||
|
||||
(long, short)
|
||||
};
|
||||
|
||||
while let Some(shortcut) = shortcuts.next() {
|
||||
let (long, short) = action_texts(shortcut);
|
||||
|
||||
row.push((Constraint::Length(shortcut.0.chars().count() as u16), short));
|
||||
row.push((Constraint::Length(1), spacer.clone()));
|
||||
row.push((Constraint::Length(shortcut.1.chars().count() as u16), long));
|
||||
|
||||
if shortcuts.peek().is_some() {
|
||||
row.push((Constraint::Length(3), divider.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
row.push((Constraint::Fill(1), Text::from(String::new())));
|
||||
|
||||
let mut global_shortcuts = props.global_shortcuts.iter().peekable();
|
||||
while let Some(shortcut) = global_shortcuts.next() {
|
||||
let (long, short) = action_texts(shortcut);
|
||||
|
||||
row.push((Constraint::Length(shortcut.0.chars().count() as u16), short));
|
||||
row.push((Constraint::Length(1), spacer.clone()));
|
||||
row.push((Constraint::Length(shortcut.1.chars().count() as u16), long));
|
||||
|
||||
if global_shortcuts.peek().is_some() {
|
||||
row.push((Constraint::Length(3), divider.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
let row_copy = row.clone();
|
||||
let row: Vec<Text<'_>> = row_copy
|
||||
.clone()
|
||||
.iter()
|
||||
.map(|(_, text)| text.clone())
|
||||
.collect();
|
||||
let widths: Vec<Constraint> = row_copy.clone().iter().map(|(width, _)| *width).collect();
|
||||
|
||||
let table = Table::new([Row::new(row)], widths).column_spacing(0);
|
||||
frame.render_widget(table, render.area);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user