From 3e4e210e96cec4e99c78baacaaa9c0381355161f Mon Sep 17 00:00:00 2001 From: Erik Kundt Date: Sun, 4 Jan 2026 23:48:27 +0100 Subject: [PATCH] lib/ui: Remove rmUI --- examples/basic_rmui.rs | 121 ----- examples/hello_rrmui.rs | 84 --- src/lib.rs | 63 --- src/ui.rs | 1 - src/ui/rm.rs | 94 ---- src/ui/rm/widget.rs | 391 -------------- src/ui/rm/widget/container.rs | 769 --------------------------- src/ui/rm/widget/list.rs | 542 ------------------- src/ui/rm/widget/text.rs | 967 ---------------------------------- src/ui/rm/widget/utils.rs | 29 - src/ui/rm/widget/window.rs | 335 ------------ 11 files changed, 3396 deletions(-) delete mode 100644 examples/basic_rmui.rs delete mode 100644 examples/hello_rrmui.rs delete mode 100644 src/ui/rm.rs delete mode 100644 src/ui/rm/widget.rs delete mode 100644 src/ui/rm/widget/container.rs delete mode 100644 src/ui/rm/widget/list.rs delete mode 100644 src/ui/rm/widget/text.rs delete mode 100644 src/ui/rm/widget/utils.rs delete mode 100644 src/ui/rm/widget/window.rs diff --git a/examples/basic_rmui.rs b/examples/basic_rmui.rs deleted file mode 100644 index e4d2e3b3..00000000 --- a/examples/basic_rmui.rs +++ /dev/null @@ -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 for App { - type Return = (); - - fn update(&mut self, message: Message) -> Option> { - match message { - Message::Quit => Some(Exit { value: None }), - Message::ReverseContent => { - self.content = self.content.chars().rev().collect::(); - 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(()) -} diff --git a/examples/hello_rrmui.rs b/examples/hello_rrmui.rs deleted file mode 100644 index bb2fc6e2..00000000 --- a/examples/hello_rrmui.rs +++ /dev/null @@ -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 for App { - type Return = (); - - fn update(&mut self, message: Message) -> Option> { - 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(()) -} diff --git a/src/lib.rs b/src/lib.rs index b2d16f3f..844be02c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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 Default for Channel { } } -/// 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( - state: S, - root: rm::widget::Widget, - viewport: Viewport, - channel: Channel, - processors: Vec, -) -> Result> -where - S: Update + Share, - T: Process + 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::::new(state_tx.clone()); - let worker = task::Worker::::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. diff --git a/src/ui.rs b/src/ui.rs index 686b59ea..ddef2ca4 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -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; diff --git a/src/ui/rm.rs b/src/ui/rm.rs deleted file mode 100644 index bda2a966..00000000 --- a/src/ui/rm.rs +++ /dev/null @@ -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( - self, - mut root: Widget, - mut state_rx: UnboundedReceiver, - mut events_rx: UnboundedReceiver, - mut interrupt_rx: broadcast::Receiver>, - viewport: Viewport, - ) -> anyhow::Result> - where - S: Update + '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> = 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 - } -} diff --git a/src/ui/rm/widget.rs b/src/ui/rm/widget.rs deleted file mode 100644 index e10b6b32..00000000 --- a/src/ui/rm/widget.rs +++ /dev/null @@ -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 = Box>; -pub type UpdateCallback = fn(&S) -> ViewProps; -pub type EventCallback = fn(Event, Option<&ViewState>, Option<&ViewProps>) -> Option; -pub type RenderCallback = fn(Option<&ViewProps>, &RenderProps) -> Option; -pub type InitCallback = fn() -> Option; - -/// `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` and downcasted to the concrete -/// type when needed. -pub struct ViewProps { - inner: Box, -} - -impl ViewProps { - pub fn inner(self) -> Option - where - T: Default + Clone + 'static, - { - self.inner.downcast::().ok().map(|inner| *inner) - } - - pub fn inner_ref(&self) -> Option<&T> - where - T: Default + Clone + 'static, - { - self.inner.downcast_ref::() - } -} - -impl From> for ViewProps { - fn from(props: Box) -> 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), - TextView(TextViewState), - TextArea(TextAreaState), - SectionGroup(SectionGroupState), -} - -impl ViewState { - pub fn unwrap_usize(&self) -> Option { - match self { - ViewState::USize(value) => Some(*value), - _ => None, - } - } - - pub fn unwrap_string(&self) -> Option { - 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 { - match self { - ViewState::TextView(state) => Some(state.clone()), - _ => None, - } - } - - pub fn unwrap_textarea(&self) -> Option { - match self { - ViewState::TextArea(state) => Some(state.clone()), - _ => None, - } - } - - pub fn unwrap_section_group(&self) -> Option { - match self { - ViewState::SectionGroup(state) => Some(state.clone()), - _ => None, - } - } - - pub fn unwrap_tree(&self) -> Option> { - 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 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 { - 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 { - 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 { - view: BoxedView, - props: Option, - sender: broadcast::Sender, - on_init: Option>, - on_update: Option>, - on_event: Option>, - on_render: Option>, -} - -unsafe impl Send for Widget {} - -impl Widget { - pub fn new(view: V, sender: broadcast::Sender) -> Self - where - Self: Sized, - V: View + '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) -> Self - where - Self: Sized, - { - self.on_init = Some(callback); - self - } - - /// Sets the optional update handler. - pub fn on_update(mut self, callback: UpdateCallback) -> Self - where - Self: Sized, - { - self.on_update = Some(callback); - self - } - - /// Sets the optional custom event handler. - pub fn on_event(mut self, callback: EventCallback) -> Self - where - Self: Sized, - { - self.on_event = Some(callback); - self - } - - /// Sets the optional update handler. - pub fn on_render(mut self, callback: RenderCallback) -> 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 { - fn to_widget(self, tx: broadcast::Sender) -> Widget - where - Self: Sized + 'static; -} - -impl ToWidget for T -where - T: View, - S: 'static, - M: 'static, -{ - fn to_widget(self, tx: broadcast::Sender) -> Widget - where - Self: Sized + 'static, - { - Widget::new(self, tx) - } -} diff --git a/src/ui/rm/widget/container.rs b/src/ui/rm/widget/container.rs deleted file mode 100644 index db90e4a5..00000000 --- a/src/ui/rm/widget/container.rs +++ /dev/null @@ -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>, - 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>) -> 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 { - /// Phantom - phantom: PhantomData<(S, M)>, -} - -impl Default for Header { - fn default() -> Self { - Self { - phantom: PhantomData, - } - } -} - -impl View for Header { - 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::()) - .unwrap_or(&default); - - let width = render.area.width.saturating_sub(2); - - let widths: Vec = 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::>(); - - 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>, - 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>) -> 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 { - /// Phantom - phantom: PhantomData<(S, M)>, -} - -impl Default for Footer { - fn default() -> Self { - Self { - phantom: PhantomData, - } - } -} - -impl<'a, S, M> Footer { - fn render_cell( - &self, - frame: &mut ratatui::Frame, - border_style: Style, - render: RenderProps, - block_type: FooterBlockType, - text: impl Into>, - ) { - 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 View for Footer { - 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::()) - .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::>(); - - let layout = Layout::horizontal(widths).split(render.area); - let cells = props - .columns - .iter() - .map(|c| c.text.clone()) - .zip(layout.iter()) - .collect::>(); - - 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 { - /// Container header - header: Option>, - /// Content widget - content: Option>, - /// Container footer - footer: Option>, -} - -impl Default for Container { - fn default() -> Self { - Self { - header: None, - content: None, - footer: None, - } - } -} - -impl Container { - pub fn header(mut self, header: Widget) -> Self { - self.header = Some(header); - self - } - - pub fn content(mut self, content: Widget) -> Self { - self.content = Some(content); - self - } - - pub fn footer(mut self, footer: Widget) -> Self { - self.footer = Some(footer); - self - } -} - -impl View for Container -where - S: 'static, - M: 'static, -{ - type Message = M; - type State = S; - - fn handle_event(&mut self, _props: Option<&ViewProps>, event: Event) -> Option { - 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::()) - .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 { - /// Container top - top: Option>, - /// Content bottom - bottom: Option>, -} - -impl Default for SplitContainer { - fn default() -> Self { - Self { - top: None, - bottom: None, - } - } -} - -impl SplitContainer { - pub fn top(mut self, top: Widget) -> Self { - self.top = Some(top); - self - } - - pub fn bottom(mut self, bottom: Widget) -> Self { - self.bottom = Some(bottom); - self - } -} - -impl View for SplitContainer -where - S: 'static, - M: 'static, -{ - type Message = M; - type State = S; - - fn handle_event(&mut self, props: Option<&ViewProps>, event: Event) -> Option { - let default = SplitContainerProps::default(); - let props = props - .and_then(|props| props.inner_ref::()) - .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::()) - .unwrap_or(&default); - - let heights = props - .heights - .iter() - .map(|c| { - if let Constraint::Length(l) = c { - Constraint::Length(l + 2) - } else { - *c - } - }) - .collect::>(); - - 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, -} - -#[derive(Clone, Default)] -pub struct SectionGroupProps { - /// Index of currently focused section. If set, it will override the widgets' - /// internal state. - focus: Option, - /// 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) -> Self { - self.focus = focus; - self - } -} - -pub struct SectionGroup { - /// All sections - sections: Vec>, - /// Internal selection and offset state - state: SectionGroupState, -} - -impl Default for SectionGroup { - fn default() -> Self { - Self { - sections: vec![], - state: SectionGroupState { focus: Some(0) }, - } - } -} - -impl SectionGroup { - pub fn section(mut self, section: Widget) -> Self { - self.sections.push(section); - self - } - - fn prev(&mut self) -> Option { - let focus = self.state.focus.map(|current| current.saturating_sub(1)); - self.state.focus = focus; - focus - } - - fn next(&mut self, len: usize) -> Option { - 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 View for SectionGroup -where - S: 'static, - M: 'static, -{ - type State = S; - type Message = M; - - fn handle_event(&mut self, props: Option<&ViewProps>, event: Event) -> Option { - let default = SectionGroupProps::default(); - let props = props - .and_then(|props| props.inner_ref::()) - .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::()) - .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::()) - .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 { - Some(ViewState::SectionGroup(self.state.clone())) - } -} diff --git a/src/ui/rm/widget/list.rs b/src/ui/rm/widget/list.rs deleted file mode 100644 index 9e97410b..00000000 --- a/src/ui/rm/widget/list.rs +++ /dev/null @@ -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, -{ - pub items: Vec, - pub selected: Option, - pub columns: Vec>, - pub show_scrollbar: bool, - pub dim: bool, -} - -impl Default for TableProps<'_, R, W> -where - R: ToRow, -{ - 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, -{ - pub fn items(mut self, items: Vec) -> Self { - self.items = items; - self - } - - pub fn selected(mut self, selected: Option) -> Self { - self.selected = selected; - self - } - - pub fn columns(mut self, columns: Vec>) -> 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 -where - R: ToRow, -{ - /// Internal selection and offset state - state: (TableState, usize), - /// Phantom - phantom: PhantomData<(S, M, R)>, - /// Current render height - height: u16, -} - -impl Default for Table -where - R: ToRow, -{ - fn default() -> Self { - Self { - state: (TableState::default().with_selected(Some(0)), 0), - phantom: PhantomData, - height: 1, - } - } -} - -impl Table -where - R: ToRow, -{ - fn prev(&mut self) -> Option { - 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 { - 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 { - 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 { - 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 View for Table -where - S: 'static, - M: 'static, - R: ToRow + Clone + 'static, -{ - type Message = M; - type State = S; - - fn handle_event(&mut self, props: Option<&ViewProps>, event: Event) -> Option { - let default = TableProps::default(); - let props = props - .and_then(|props| props.inner_ref::>()) - .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::>()) - .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::>()) - .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 = 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::>(); - - 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 { - 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 -where - R: ToTree + Clone, - Id: ToString, -{ - /// Root items. - pub items: Vec, - /// 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>, - /// 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>>, - /// Set to `true` if the content style should be dimmed whenever the widget - /// has no focus. - pub dim: bool, -} - -impl Default for TreeProps -where - R: ToTree + Clone, - Id: ToString, -{ - fn default() -> Self { - Self { - items: vec![], - selected: None, - show_scrollbar: true, - opened: None, - dim: false, - } - } -} - -impl TreeProps -where - R: ToTree + Clone, - Id: ToString + Clone, -{ - pub fn items(mut self, items: Vec) -> 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>>) -> 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 -where - R: ToTree, - Id: ToString + Clone, -{ - /// Internal selection and offset state - state: TreeState, - /// Phantom - phantom: PhantomData<(S, M, R, Id)>, -} - -impl Default for Tree -where - R: ToTree, - Id: ToString + Clone + Default, -{ - fn default() -> Self { - Self { - state: TreeState::default(), - phantom: PhantomData, - } - } -} - -impl View for Tree -where - R: ToTree + 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::>()) - .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 { - 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::>()) - .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 { - Some(ViewState::Tree( - self.state - .selected() - .to_vec() - .iter() - .map(|s| s.to_string()) - .collect(), - )) - } -} diff --git a/src/ui/rm/widget/text.rs b/src/ui/rm/widget/text.rs deleted file mode 100644 index bf476436..00000000 --- a/src/ui/rm/widget/text.rs +++ /dev/null @@ -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 { - /// Phantom - phantom: PhantomData<(S, M)>, -} - -impl Default for Label { - fn default() -> Self { - Self { - phantom: PhantomData, - } - } -} - -impl View for Label { - 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::()) - .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, - pub cursor_position: usize, -} - -pub struct TextField { - /// Internal state - state: TextFieldState, - /// Phantom - phantom: PhantomData<(S, M)>, -} - -impl Default for TextField { - fn default() -> Self { - Self { - state: TextFieldState { - text: None, - cursor_position: 0, - }, - phantom: PhantomData, - } - } -} - -impl TextField { - 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 View for TextField -where - S: 'static, - M: 'static, -{ - type Message = M; - type State = S; - - fn view_state(&self) -> Option { - 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 { - 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::()) - .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::()) - .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(mut self, content: T) -> Self - where - T: Into>, - { - 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 Default for TextArea<'_, S, M> { - fn default() -> Self { - Self { - phantom: PhantomData, - textarea: tui_textarea::TextArea::default(), - area: (0, 0), - } - } -} - -impl View for TextArea<'_, S, M> { - type State = S; - type Message = M; - - fn handle_event(&mut self, props: Option<&ViewProps>, event: Event) -> Option { - use tui_textarea::Input; - - let default = TextAreaProps::default(); - let props = props - .and_then(|props| props.inner_ref::()) - .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::()) - .unwrap_or(&default); - - self.textarea = tui_textarea::TextArea::new( - props - .content - .lines - .iter() - .map(|line| line.to_string()) - .collect::>(), - ); - } - - 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::()) - .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 { - 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(mut self, content: T) -> Self - where - T: Into, - { - 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, - /// 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>, - /// 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(mut self, footer: Option) -> Self - where - T: Into>, - { - self.footer = footer.map(|f| f.into()); - self - } - - pub fn state(mut self, state: Option) -> 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 { - /// Internal view state. - state: TextViewState, - /// Current render area. - area: (u16, u16), - /// Phantom. - phantom: PhantomData<(S, M)>, -} - -impl Default for TextView { - fn default() -> Self { - Self { - state: TextViewState::default(), - area: (0, 0), - phantom: PhantomData, - } - } -} - -impl TextView { - 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 View for TextView -where - S: 'static, - M: 'static, -{ - type Message = M; - type State = S; - - fn handle_event(&mut self, props: Option<&ViewProps>, event: Event) -> Option { - let default = TextViewProps::default(); - let props = props - .and_then(|props| props.inner_ref::()) - .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::()) - .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::()) - .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 { - Some(ViewState::TextView(self.state.clone())) - } -} diff --git a/src/ui/rm/widget/utils.rs b/src/ui/rm/widget/utils.rs deleted file mode 100644 index b0729083..00000000 --- a/src/ui/rm/widget/utils.rs +++ /dev/null @@ -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) - } -} diff --git a/src/ui/rm/widget/window.rs b/src/ui/rm/widget/window.rs deleted file mode 100644 index e01cf982..00000000 --- a/src/ui/rm/widget/window.rs +++ /dev/null @@ -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 { - current_page: Option, -} - -impl WindowProps { - pub fn current_page(mut self, page: Id) -> Self { - self.current_page = Some(page); - self - } -} - -impl Default for WindowProps { - fn default() -> Self { - Self { current_page: None } - } -} - -pub struct Window { - /// All pages known - pages: HashMap>, -} - -impl Default for Window { - fn default() -> Self { - Self { - pages: HashMap::new(), - } - } -} - -impl Window -where - Id: Clone + Hash + Eq + PartialEq, -{ - pub fn page(mut self, id: Id, page: Widget) -> Self { - self.pages.insert(id, page); - self - } -} - -impl View for Window -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 { - let default = WindowProps::default(); - let props = props - .and_then(|props| props.inner_ref::>()) - .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::>()) - .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::>()) - .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 { - /// Content widget - content: Option>, - /// Shortcut widget - shortcuts: Option>, -} - -impl Default for Page { - fn default() -> Self { - Self { - content: None, - shortcuts: None, - } - } -} - -impl Page { - pub fn content(mut self, content: Widget) -> Self { - self.content = Some(content); - self - } - - pub fn shortcuts(mut self, shortcuts: Widget) -> Self { - self.shortcuts = Some(shortcuts); - self - } -} - -impl View for Page -where - S: 'static, - M: 'static, -{ - type State = S; - type Message = M; - - fn handle_event(&mut self, _props: Option<&ViewProps>, event: Event) -> Option { - 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 { - /// Phantom - phantom: PhantomData<(S, M)>, -} - -impl Default for Shortcuts { - fn default() -> Self { - Self { - phantom: PhantomData, - } - } -} - -impl View for Shortcuts { - 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::()) - .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> = row_copy - .clone() - .iter() - .map(|(_, text)| text.clone()) - .collect(); - let widths: Vec = 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); - } -}