lib/ui: Remove rmUI

This commit is contained in:
Erik Kundt
2026-01-04 23:49:44 +01:00
parent 5269df37d5
commit 3e4e210e96
11 changed files with 0 additions and 3396 deletions
-121
View File
@@ -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(())
}
-84
View File
@@ -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
View File
@@ -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
View File
@@ -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;
-94
View File
@@ -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
}
}
-391
View File
@@ -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)
}
}
-769
View File
@@ -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()))
}
}
-542
View File
@@ -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(),
))
}
}
-967
View File
@@ -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()))
}
}
-29
View File
@@ -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)
}
}
-335
View File
@@ -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);
}
}