Skip to main content

radicle_tui/
ui.rs

1pub mod ext;
2pub mod layout;
3pub mod span;
4pub mod theme;
5pub mod utils;
6pub mod widget;
7
8use std::collections::{HashSet, VecDeque};
9use std::hash::Hash;
10use std::rc::Rc;
11use std::time::Duration;
12
13use anyhow::Result;
14
15use ratatui::layout::{Alignment, Constraint, Flex, Position, Rect};
16use ratatui::prelude::*;
17use ratatui::text::{Span, Text};
18use ratatui::widgets::Cell;
19use ratatui::{Frame, Viewport};
20
21use tokio::sync::broadcast;
22use tokio::sync::mpsc::UnboundedReceiver;
23
24use tui_tree_widget::TreeItem;
25
26use crate::event::{Event, Key};
27use crate::store::Update;
28use crate::terminal::Terminal;
29use crate::ui::layout::Spacing;
30use crate::ui::theme::Theme;
31use crate::ui::widget::{AddContentFn, Borders, Column, Widget};
32use crate::{Interrupted, Share};
33
34const RENDERING_TICK_RATE: Duration = Duration::from_millis(250);
35
36/// The main UI trait for the ability to render an application.
37pub trait Show<M> {
38    fn show(&self, ctx: &Context<M>, frame: &mut Frame) -> Result<()>;
39}
40
41#[derive(Default)]
42pub struct Frontend {}
43
44impl Frontend {
45    pub async fn run<S, M, R>(
46        self,
47        message_tx: broadcast::Sender<M>,
48        mut state_rx: UnboundedReceiver<S>,
49        mut event_rx: UnboundedReceiver<Event>,
50        mut interrupt_rx: broadcast::Receiver<Interrupted<R>>,
51        viewport: Viewport,
52    ) -> anyhow::Result<Interrupted<R>>
53    where
54        S: Update<M, Return = R> + Show<M>,
55        M: Share,
56        R: Share,
57    {
58        let mut ticker = tokio::time::interval(RENDERING_TICK_RATE);
59        let mut terminal = Terminal::try_from(viewport)?;
60
61        let mut state = state_rx.recv().await.unwrap();
62        let mut ctx = Context::default().with_sender(message_tx);
63
64        let result: anyhow::Result<Interrupted<R>> = loop {
65            tokio::select! {
66                // Tick to terminate the select every N milliseconds
67                _ = ticker.tick() => (),
68                // Handle input events
69                Some(event) = event_rx.recv() => {
70                    match event {
71                        Event::Key(key) => {
72                            log::debug!("Received key event: {key:?}");
73                            ctx.store_input(event)
74                        }
75                        Event::Resize(x, y) => {
76                            log::debug!("Received resize event: {x},{y}");
77                            terminal.clear()?;
78                        },
79                        Event::Unknown => {
80                            log::debug!("Received unknown event")
81                        }
82                    }
83                },
84                // Handle state updates
85                Some(s) = state_rx.recv() => {
86                    state = s;
87                },
88                // Catch and handle interrupt signal to gracefully shutdown
89                Ok(interrupted) = interrupt_rx.recv() => {
90                    break Ok(interrupted);
91                }
92            }
93            terminal.draw(|frame| {
94                let ctx = ctx.clone().with_frame_size(frame.area());
95
96                if let Err(err) = state.show(&ctx, frame) {
97                    log::error!("Drawing failed: {err}");
98                }
99            })?;
100
101            ctx.clear_inputs();
102        };
103        terminal.restore()?;
104
105        result
106    }
107}
108
109#[derive(Default, Debug)]
110pub struct Response {
111    pub changed: bool,
112}
113
114#[derive(Debug)]
115pub struct InnerResponse<R> {
116    /// What the user closure returned.
117    pub inner: R,
118    /// The response of the area.
119    pub response: Response,
120}
121
122impl<R> InnerResponse<R> {
123    #[inline]
124    pub fn new(inner: R, response: Response) -> Self {
125        Self { inner, response }
126    }
127}
128
129/// A `Context` is held by the `Ui` and reflects the environment a `Ui` runs in.
130#[derive(Clone, Debug)]
131pub struct Context<M> {
132    /// Currently captured user inputs. Inputs that where stored via `store_input`
133    /// need to be cleared manually via `clear_inputs` (usually for each frame drawn).
134    inputs: VecDeque<Event>,
135    /// Current frame of the application.
136    pub(crate) frame_size: Rect,
137    /// The message sender used by the `Ui` to send application messages.
138    pub(crate) sender: Option<broadcast::Sender<M>>,
139}
140
141impl<M> Default for Context<M> {
142    fn default() -> Self {
143        Self {
144            inputs: VecDeque::default(),
145            frame_size: Rect::default(),
146            sender: None,
147        }
148    }
149}
150
151impl<M> Context<M> {
152    pub fn new(frame_size: Rect) -> Self {
153        Self {
154            frame_size,
155            ..Default::default()
156        }
157    }
158
159    pub fn with_inputs(mut self, inputs: VecDeque<Event>) -> Self {
160        self.inputs = inputs;
161        self
162    }
163
164    pub fn with_frame_size(mut self, frame_size: Rect) -> Self {
165        self.frame_size = frame_size;
166        self
167    }
168
169    pub fn with_sender(mut self, sender: broadcast::Sender<M>) -> Self {
170        self.sender = Some(sender);
171        self
172    }
173
174    pub fn frame_size(&self) -> Rect {
175        self.frame_size
176    }
177
178    pub fn store_input(&mut self, event: Event) {
179        self.inputs.push_back(event);
180    }
181
182    pub fn clear_inputs(&mut self) {
183        self.inputs.clear();
184    }
185}
186
187/// A `Layout` is used to support pre-defined layouts. It either represents
188/// such a predefined layout or a wrapped `ratatui` layout. It's used internally
189/// but can be build from a `ratatui` layout.
190#[derive(Clone, Default, Debug)]
191pub enum Layout {
192    #[default]
193    None,
194    Wrapped {
195        internal: ratatui::layout::Layout,
196    },
197    Expandable3 {
198        left_only: bool,
199    },
200    Popup {
201        percent_x: u16,
202        percent_y: u16,
203    },
204}
205
206impl From<ratatui::layout::Layout> for Layout {
207    fn from(layout: ratatui::layout::Layout) -> Self {
208        Layout::Wrapped { internal: layout }
209    }
210}
211
212impl Layout {
213    pub fn len(&self) -> usize {
214        match self {
215            Layout::None => 0,
216            Layout::Wrapped { internal } => internal.split(Rect::default()).len(),
217            Layout::Expandable3 { left_only } => {
218                if *left_only {
219                    1
220                } else {
221                    3
222                }
223            }
224            Layout::Popup {
225                percent_x: _,
226                percent_y: _,
227            } => 1,
228        }
229    }
230
231    pub fn is_empty(&self) -> bool {
232        self.len() == 0
233    }
234
235    pub fn split(&self, area: Rect) -> Rc<[Rect]> {
236        match self {
237            Layout::None => Rc::new([]),
238            Layout::Wrapped { internal } => internal.split(area),
239            Layout::Expandable3 { left_only } => {
240                use ratatui::layout::Layout;
241
242                if *left_only {
243                    [area].into()
244                } else if area.width <= 140 {
245                    let [left, right] = Layout::horizontal([
246                        Constraint::Percentage(50),
247                        Constraint::Percentage(50),
248                    ])
249                    .areas(area);
250                    let [right_top, right_bottom] =
251                        Layout::vertical([Constraint::Percentage(60), Constraint::Percentage(40)])
252                            .areas(right);
253
254                    [left, right_top, right_bottom].into()
255                } else {
256                    Layout::horizontal([
257                        Constraint::Percentage(33),
258                        Constraint::Percentage(33),
259                        Constraint::Percentage(33),
260                    ])
261                    .split(area)
262                }
263            }
264            Layout::Popup {
265                percent_x,
266                percent_y,
267            } => {
268                use ratatui::layout::Layout;
269
270                let vertical =
271                    Layout::vertical([Constraint::Percentage(*percent_y)]).flex(Flex::Center);
272                let horizontal =
273                    Layout::horizontal([Constraint::Percentage(*percent_x)]).flex(Flex::Center);
274                let [area] = vertical.areas(area);
275                let [area] = horizontal.areas(area);
276
277                [area].into()
278            }
279        }
280    }
281}
282
283/// The `Ui` is the main frontend component that provides render and user-input capture
284/// capabilities. An application consists of at least 1 root `Ui`. An `Ui` can build child
285/// `Ui`s that partially inherit attributes.
286#[derive(Clone, Debug)]
287pub struct Ui<M> {
288    /// The context this runs in: frame sizes, captured user-inputs etc.
289    ctx: Context<M>,
290    /// The UI theme.
291    theme: Theme,
292    /// The area this can render in.
293    area: Rect,
294    /// The layout used to calculate the next area to draw.
295    layout: Layout,
296    /// Currently focused area.
297    focus_area: Option<usize>,
298    /// If this has focus.
299    has_focus: bool,
300    /// Current rendering counter that is increased whenever the next area to draw
301    /// on is requested.
302    count: usize,
303}
304
305impl<M> Ui<M> {
306    pub fn has_input(&mut self, f: impl Fn(Key) -> bool) -> bool {
307        self.has_focus
308            && self.is_area_focused()
309            && self.ctx.inputs.iter().any(|event| {
310                if let Event::Key(key) = event {
311                    return f(*key);
312                }
313                false
314            })
315    }
316
317    pub fn has_global_input(&mut self, f: impl Fn(Key) -> bool) -> bool {
318        self.has_focus
319            && self.ctx.inputs.iter().any(|event| {
320                if let Event::Key(key) = event {
321                    return f(*key);
322                }
323                false
324            })
325    }
326
327    pub fn get_input(&mut self, f: impl Fn(Key) -> bool) -> Option<Key> {
328        if self.has_focus && self.is_area_focused() {
329            let matches = |&event| {
330                if let Event::Key(key) = event {
331                    return f(key);
332                }
333                false
334            };
335
336            if let Some(Event::Key(key)) =
337                self.ctx.inputs.iter().find(|event| matches(event)).copied()
338            {
339                return Some(key);
340            }
341            None
342        } else {
343            None
344        }
345    }
346}
347
348impl<M> Default for Ui<M> {
349    fn default() -> Self {
350        Self {
351            theme: Theme::default(),
352            area: Rect::default(),
353            layout: Layout::default(),
354            focus_area: None,
355            has_focus: true,
356            count: 0,
357            ctx: Context::default(),
358        }
359    }
360}
361
362impl<M> Ui<M> {
363    pub fn new(area: Rect) -> Self {
364        Self {
365            area,
366            ..Default::default()
367        }
368    }
369
370    pub fn with_area(mut self, area: Rect) -> Self {
371        self.area = area;
372        self
373    }
374
375    pub fn with_layout(mut self, layout: Layout) -> Self {
376        self.layout = layout;
377        self
378    }
379
380    pub fn with_area_focus(mut self, focus: Option<usize>) -> Self {
381        self.focus_area = focus;
382        self
383    }
384
385    pub fn with_ctx(mut self, ctx: Context<M>) -> Self {
386        self.ctx = ctx;
387        self
388    }
389
390    pub fn with_focus(mut self) -> Self {
391        self.has_focus = true;
392        self
393    }
394
395    pub fn without_focus(mut self) -> Self {
396        self.has_focus = false;
397        self
398    }
399
400    pub fn with_theme(mut self, theme: Theme) -> Self {
401        self.theme = theme;
402        self
403    }
404
405    pub fn theme(&self) -> &Theme {
406        &self.theme
407    }
408
409    pub fn area(&self) -> Rect {
410        self.area
411    }
412
413    pub fn next_area(&mut self) -> Option<(Rect, bool)> {
414        let area_focus = self
415            .focus_area
416            .map(|focus| self.count == focus)
417            .unwrap_or(false);
418        let rect = self.layout.split(self.area).get(self.count).cloned();
419
420        self.count += 1;
421
422        rect.map(|rect| (rect, area_focus))
423    }
424
425    pub fn current_area(&mut self) -> Option<(Rect, bool)> {
426        let count = self.count.saturating_sub(1);
427
428        let area_focus = self.focus_area.map(|focus| count == focus).unwrap_or(false);
429        let rect = self.layout.split(self.area).get(self.count).cloned();
430
431        rect.map(|rect| (rect, area_focus))
432    }
433
434    pub fn is_area_focused(&self) -> bool {
435        let count = self.count.saturating_sub(1);
436        self.focus_area.map(|focus| count == focus).unwrap_or(false)
437    }
438
439    pub fn has_focus(&self) -> bool {
440        self.has_focus
441    }
442
443    pub fn count(&self) -> usize {
444        self.count
445    }
446
447    pub fn focus_next(&mut self) {
448        if self.focus_area.is_none() {
449            self.focus_area = Some(0);
450        } else {
451            self.focus_area = Some(self.focus_area.unwrap().saturating_add(1));
452        }
453    }
454
455    pub fn send_message(&self, message: M) {
456        if let Some(sender) = &self.ctx.sender {
457            let _ = sender.send(message);
458        }
459    }
460}
461
462impl<M> Ui<M>
463where
464    M: Clone,
465{
466    pub fn add(&mut self, frame: &mut Frame, widget: impl Widget) -> Response {
467        widget.ui(self, frame)
468    }
469
470    pub fn child_ui(&mut self, area: Rect, layout: impl Into<Layout>) -> Self {
471        Ui::default()
472            .with_area(area)
473            .with_layout(layout.into())
474            .with_ctx(self.ctx.clone())
475            .with_theme(self.theme.clone())
476    }
477
478    pub fn layout<R>(
479        &mut self,
480        layout: impl Into<Layout>,
481        focus: Option<usize>,
482        add_contents: impl FnOnce(&mut Self) -> R,
483    ) -> InnerResponse<R> {
484        self.layout_dyn(layout, focus, Box::new(add_contents))
485    }
486
487    pub fn layout_dyn<R>(
488        &mut self,
489        layout: impl Into<Layout>,
490        focus: Option<usize>,
491        add_contents: Box<AddContentFn<M, R>>,
492    ) -> InnerResponse<R> {
493        let (area, area_focus) = self.next_area().unwrap_or_default();
494
495        let mut child_ui = Ui {
496            has_focus: area_focus,
497            focus_area: focus,
498            ..self.child_ui(area, layout)
499        };
500
501        InnerResponse::new(add_contents(&mut child_ui), Response::default())
502    }
503}
504
505impl<M> Ui<M>
506where
507    M: Clone,
508{
509    pub fn container<R>(
510        &mut self,
511        layout: impl Into<Layout>,
512        focus: &mut Option<usize>,
513        add_contents: impl FnOnce(&mut Ui<M>) -> R,
514    ) -> InnerResponse<R> {
515        let (area, area_focus) = self.next_area().unwrap_or_default();
516
517        let layout: Layout = layout.into();
518        let len = layout.len();
519
520        // TODO(erikli): Check if setting the focus area is needed at all.
521        let mut child_ui = Ui {
522            has_focus: area_focus,
523            focus_area: *focus,
524            ..self.child_ui(area, layout)
525        };
526
527        widget::Container::new(len, focus).show(&mut child_ui, add_contents)
528    }
529
530    pub fn popup<R>(
531        &mut self,
532        layout: impl Into<Layout>,
533        add_contents: impl FnOnce(&mut Ui<M>) -> R,
534    ) -> InnerResponse<R> {
535        let layout: Layout = layout.into();
536        let areas = layout.split(self.area());
537        let area = areas.first().cloned().unwrap_or(self.area());
538
539        let mut child_ui = self.child_ui(area, layout::fill());
540        child_ui.has_focus = true;
541
542        widget::Popup::default().show(&mut child_ui, add_contents)
543    }
544
545    pub fn label<'a>(&mut self, frame: &mut Frame, content: impl Into<Text<'a>>) -> Response {
546        widget::Label::new(content).ui(self, frame)
547    }
548
549    pub fn overline(&mut self, frame: &mut Frame) -> Response {
550        let overline = String::from("▔").repeat(256);
551        self.label(frame, Span::raw(overline).cyan())
552    }
553
554    pub fn separator(&mut self, frame: &mut Frame) -> Response {
555        let overline = String::from("─").repeat(256);
556        self.label(
557            frame,
558            Span::raw(overline).fg(self.theme.border_style.fg.unwrap_or_default()),
559        )
560    }
561
562    #[allow(clippy::too_many_arguments)]
563    pub fn table<'a, R, const W: usize>(
564        &mut self,
565        frame: &mut Frame,
566        selected: &mut Option<usize>,
567        items: &'a Vec<R>,
568        columns: Vec<Column<'a>>,
569        empty_message: Option<String>,
570        spacing: Spacing,
571        borders: Option<Borders>,
572    ) -> Response
573    where
574        R: ToRow<W> + Clone,
575    {
576        widget::Table::new(selected, items, columns, empty_message, borders)
577            .spacing(spacing)
578            .ui(self, frame)
579    }
580
581    pub fn tree<R, Id>(
582        &mut self,
583        frame: &mut Frame,
584        items: &'_ Vec<R>,
585        opened: &mut Option<HashSet<Vec<Id>>>,
586        selected: &mut Option<Vec<Id>>,
587        borders: Option<Borders>,
588    ) -> Response
589    where
590        R: ToTree<Id> + Clone,
591        Id: ToString + Clone + Eq + Hash,
592    {
593        widget::Tree::new(items, opened, selected, borders, false).ui(self, frame)
594    }
595
596    pub fn shortcuts(
597        &mut self,
598        frame: &mut Frame,
599        shortcuts: &[(&str, &str)],
600        divider: char,
601        alignment: Alignment,
602    ) -> Response {
603        widget::Shortcuts::new(shortcuts, divider, alignment).ui(self, frame)
604    }
605
606    pub fn column_bar(
607        &mut self,
608        frame: &mut Frame,
609        columns: Vec<Column<'_>>,
610        spacing: Spacing,
611        borders: Option<Borders>,
612    ) -> Response {
613        widget::ColumnBar::new(columns, spacing, borders).ui(self, frame)
614    }
615
616    pub fn text_view<'a>(
617        &mut self,
618        frame: &mut Frame,
619        text: impl Into<Text<'a>>,
620        scroll: &'a mut Position,
621        borders: Option<Borders>,
622    ) -> Response {
623        widget::TextView::new(text, None::<String>, scroll, borders).ui(self, frame)
624    }
625
626    pub fn text_view_with_footer<'a>(
627        &mut self,
628        frame: &mut Frame,
629        text: impl Into<Text<'a>>,
630        footer: impl Into<Text<'a>>,
631        scroll: &'a mut Position,
632        borders: Option<Borders>,
633    ) -> Response {
634        widget::TextView::new(text, Some(footer), scroll, borders).ui(self, frame)
635    }
636
637    pub fn centered_text_view<'a>(
638        &mut self,
639        frame: &mut Frame,
640        text: impl Into<Text<'a>>,
641        borders: Option<Borders>,
642    ) -> Response {
643        widget::CenteredTextView::new(text, borders).ui(self, frame)
644    }
645
646    pub fn text_edit_singleline(
647        &mut self,
648        frame: &mut Frame,
649        text: &mut String,
650        cursor: &mut usize,
651        label: Option<impl ToString>,
652        borders: Option<Borders>,
653    ) -> Response {
654        match label {
655            Some(label) => widget::TextEdit::new(text, cursor, borders)
656                .with_label(label)
657                .ui(self, frame),
658            _ => widget::TextEdit::new(text, cursor, borders).ui(self, frame),
659        }
660    }
661}
662
663/// Needs to be implemented for items that are supposed to be rendered in tables.
664pub trait ToRow<const W: usize> {
665    fn to_row(&self) -> [Cell<'_>; W];
666}
667
668/// Needs to be implemented for items that are supposed to be rendered in trees.
669pub trait ToTree<Id>
670where
671    Id: ToString,
672{
673    fn rows(&self) -> Vec<TreeItem<'_, Id>>;
674}
675
676/// A `BufferedValue` that writes updates to an internal
677/// buffer. This buffer can be applied or reset.
678///
679/// Reading from a `BufferedValue` will return the buffer if it's
680/// not empty. It will return the actual value otherwise.
681#[derive(Clone, Debug)]
682pub struct BufferedValue<T>
683where
684    T: Clone,
685{
686    value: T,
687    buffer: Option<T>,
688}
689
690impl<T> BufferedValue<T>
691where
692    T: Clone,
693{
694    pub fn new(value: T) -> Self {
695        Self {
696            value,
697            buffer: None,
698        }
699    }
700
701    pub fn apply(&mut self) {
702        if let Some(buffer) = self.buffer.clone() {
703            self.value = buffer;
704        }
705        self.buffer = None;
706    }
707
708    pub fn reset(&mut self) {
709        self.buffer = None;
710    }
711
712    pub fn write(&mut self, value: T) {
713        self.buffer = Some(value);
714    }
715
716    pub fn read(&self) -> T {
717        if let Some(buffer) = self.buffer.clone() {
718            buffer
719        } else {
720            self.value.clone()
721        }
722    }
723}
724
725#[cfg(test)]
726mod test {
727    use super::*;
728
729    #[test]
730    fn state_value_read_should_succeed() {
731        let value = BufferedValue::new(0);
732        assert_eq!(value.read(), 0);
733    }
734
735    #[test]
736    fn state_value_read_buffer_should_succeed() {
737        let mut value = BufferedValue::new(0);
738        value.write(1);
739
740        assert_eq!(value.read(), 1);
741    }
742
743    #[test]
744    fn state_value_apply_should_succeed() {
745        let mut value = BufferedValue::new(0);
746
747        value.write(1);
748        assert_eq!(value.read(), 1);
749
750        value.apply();
751        assert_eq!(value.read(), 1);
752    }
753
754    #[test]
755    fn state_value_reset_should_succeed() {
756        let mut value = BufferedValue::new(0);
757
758        value.write(1);
759        assert_eq!(value.read(), 1);
760
761        value.reset();
762        assert_eq!(value.read(), 0);
763    }
764
765    #[test]
766    fn state_value_reset_after_apply_should_succeed() {
767        let mut value = BufferedValue::new(0);
768
769        value.write(1);
770        assert_eq!(value.read(), 1);
771
772        value.apply();
773        value.reset();
774        assert_eq!(value.read(), 1);
775    }
776}