aether-tui 0.1.7

A lightweight terminal UI rendering library for building rich CLI applications
Documentation
use crossterm::event::KeyCode;

use super::component::{Component, Event};
use super::panel::Panel;
use super::split_panel::{Either, SplitLayout, SplitPanel};
use super::wrap_selection;
use crate::line::Line;
use crate::rendering::frame::{Cursor, Frame};
use crate::rendering::render_context::ViewContext;
use crate::style::Style;

pub enum GalleryMessage {
    Quit,
}

pub struct Gallery<T: Component> {
    split: SplitPanel<GallerySidebar, GalleryPreview<T>>,
}

impl<T: Component> Gallery<T> {
    pub fn new(entries: Vec<(String, T)>) -> Self {
        let names: Vec<String> = entries.iter().map(|(n, _)| n.clone()).collect();
        let longest = names.iter().map(String::len).max().unwrap_or(0);
        let layout = SplitLayout::fixed((longest + 6).clamp(20, 30));
        let sidebar = GallerySidebar { names, selected: 0, focused: true };
        let preview = GalleryPreview { entries, active: 0, focused: false };

        Self { split: SplitPanel::new(sidebar, preview, layout).with_separator("", Style::default()) }
    }
}

impl<T: Component> Component for Gallery<T> {
    type Message = GalleryMessage;

    async fn on_event(&mut self, event: &Event) -> Option<Vec<Self::Message>> {
        if let Event::Tick = event {
            let _ = self.split.right_mut().on_event(event).await;
            return Some(vec![]);
        }

        if let Event::Key(key) = event
            && key.code == KeyCode::Esc
        {
            return Some(vec![GalleryMessage::Quit]);
        }

        match self.split.on_event(event).await {
            Some(msgs) => {
                for msg in &msgs {
                    if let Either::Left(GallerySidebarMessage::Selected(idx)) = msg {
                        self.split.right_mut().active = *idx;
                    }
                }
                Some(vec![])
            }
            None => None,
        }
    }

    fn render(&mut self, ctx: &ViewContext) -> Frame {
        if self.split.left().names.is_empty() {
            return Frame::new(vec![Line::new("No stories")]);
        }

        let left_focused = self.split.is_left_focused();
        self.split.left_mut().focused = left_focused;
        self.split.right_mut().focused = !left_focused;
        self.split.set_separator_style(Style::fg(ctx.theme.muted()));

        self.split.render(ctx)
    }
}

enum GallerySidebarMessage {
    Selected(usize),
}

struct GallerySidebar {
    names: Vec<String>,
    selected: usize,
    focused: bool,
}

impl Component for GallerySidebar {
    type Message = GallerySidebarMessage;

    async fn on_event(&mut self, event: &Event) -> Option<Vec<Self::Message>> {
        if let Event::Key(key) = event {
            match key.code {
                KeyCode::Up => {
                    wrap_selection(&mut self.selected, self.names.len(), -1);
                    Some(vec![GallerySidebarMessage::Selected(self.selected)])
                }
                KeyCode::Down => {
                    wrap_selection(&mut self.selected, self.names.len(), 1);
                    Some(vec![GallerySidebarMessage::Selected(self.selected)])
                }
                _ => Some(vec![]),
            }
        } else {
            None
        }
    }

    fn render(&mut self, ctx: &ViewContext) -> Frame {
        let width = ctx.size.width as usize;
        let height = ctx.size.height as usize;
        let mut lines = Vec::with_capacity(height);

        lines.push(Line::with_style(" Gallery", Style::fg(ctx.theme.accent()).bold()));
        lines.push(Line::default());

        for (i, name) in self.names.iter().enumerate() {
            let is_selected = i == self.selected;
            let indicator = if is_selected { ">" } else { " " };
            let style = if is_selected && self.focused {
                ctx.theme.selected_row_style()
            } else if is_selected {
                Style::fg(ctx.theme.text_primary()).bold()
            } else {
                Style::fg(ctx.theme.text_secondary())
            };
            let mut line = Line::with_style(format!(" {indicator} {name}"), style);
            line.extend_bg_to_width(width);
            lines.push(line);
        }

        while lines.len() < height {
            lines.push(Line::default());
        }
        lines.truncate(height);

        Frame::new(lines)
    }
}

struct GalleryPreview<T: Component> {
    entries: Vec<(String, T)>,
    active: usize,
    focused: bool,
}

impl<T: Component> Component for GalleryPreview<T> {
    type Message = ();

    async fn on_event(&mut self, event: &Event) -> Option<Vec<()>> {
        if let Some((_, component)) = self.entries.get_mut(self.active) {
            let _ = component.on_event(event).await;
        }
        match event {
            Event::Key(_) | Event::Tick => Some(vec![]),
            _ => None,
        }
    }

    fn render(&mut self, ctx: &ViewContext) -> Frame {
        let (name, component) = &mut self.entries[self.active];
        let border_color = if self.focused { ctx.theme.accent() } else { ctx.theme.muted() };

        let inner_width = Panel::inner_width(ctx.size.width);
        let inner_ctx = ctx.with_size((inner_width, ctx.size.height.saturating_sub(4)));
        let frame = component.render(&inner_ctx);
        let (content_lines, cursor) = frame.into_parts();

        let footer = if self.focused { "[Shift+Tab] sidebar  [Esc] quit" } else { "[Tab] preview  [Esc] quit" };
        let mut panel = Panel::new(border_color).title(format!(" {name} ")).footer(footer);
        panel.push(content_lines);

        let panel_cursor = if self.focused && cursor.is_visible {
            Cursor::visible(cursor.row + 2, cursor.col + 2)
        } else {
            Cursor::hidden()
        };

        panel.render(ctx).with_cursor(panel_cursor)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::rendering::line::Line;

    struct DummyComponent {
        label: String,
    }

    impl Component for DummyComponent {
        type Message = ();

        async fn on_event(&mut self, _event: &Event) -> Option<Vec<()>> {
            None
        }

        fn render(&mut self, _ctx: &ViewContext) -> Frame {
            Frame::new(vec![Line::new(&self.label)])
        }
    }

    fn dummy(name: &str, label: &str) -> (String, DummyComponent) {
        (name.into(), DummyComponent { label: label.into() })
    }

    #[test]
    fn empty_gallery_renders_placeholder() {
        let mut gallery: Gallery<DummyComponent> = Gallery::new(vec![]);
        let ctx = ViewContext::new((80, 24));
        let frame = gallery.render(&ctx);
        assert_eq!(frame.lines()[0].plain_text(), "No stories");
    }

    #[test]
    fn sidebar_shows_all_entry_names() {
        let mut gallery = Gallery::new(vec![dummy("Alpha", "a"), dummy("Beta", "b")]);
        let ctx = ViewContext::new((80, 24));
        let frame = gallery.render(&ctx);
        let text: String = frame.lines().iter().map(Line::plain_text).collect::<Vec<_>>().join("\n");
        assert!(text.contains("Alpha"), "should contain Alpha: {text}");
        assert!(text.contains("Beta"), "should contain Beta: {text}");
    }

    #[test]
    fn selected_entry_has_indicator() {
        let mut gallery = Gallery::new(vec![dummy("Alpha", "a"), dummy("Beta", "b")]);
        let ctx = ViewContext::new((80, 24));
        let frame = gallery.render(&ctx);
        let all_text: Vec<String> = frame.lines().iter().map(Line::plain_text).collect();
        assert!(all_text.iter().any(|l| l.contains("> Alpha")), "should have > Alpha indicator: {all_text:?}");
    }

    #[tokio::test]
    async fn down_arrow_changes_selection() {
        let mut gallery = Gallery::new(vec![dummy("Alpha", "a"), dummy("Beta", "b")]);
        assert_eq!(gallery.split.left().selected, 0);

        let down = Event::Key(crossterm::event::KeyEvent::new(KeyCode::Down, crossterm::event::KeyModifiers::NONE));
        gallery.on_event(&down).await;
        assert_eq!(gallery.split.left().selected, 1);
    }

    #[tokio::test]
    async fn esc_emits_quit() {
        let mut gallery = Gallery::new(vec![dummy("A", "a")]);
        let esc = Event::Key(crossterm::event::KeyEvent::new(KeyCode::Esc, crossterm::event::KeyModifiers::NONE));
        let msgs = gallery.on_event(&esc).await.unwrap();
        assert!(matches!(msgs[0], GalleryMessage::Quit));
    }

    #[tokio::test]
    async fn tab_switches_focus() {
        let mut gallery = Gallery::new(vec![dummy("A", "a")]);
        assert!(gallery.split.is_left_focused());

        let tab = Event::Key(crossterm::event::KeyEvent::new(KeyCode::Tab, crossterm::event::KeyModifiers::NONE));
        gallery.on_event(&tab).await;
        assert!(!gallery.split.is_left_focused());
    }
}