rusticity-term 0.1.8

Terminal UI components for Rusticity
use crate::app::App;
use crate::common::SortDirection;
use crate::finder::Focus;
use crate::ui::table::{render_table, Column, TableConfig};
use crate::ui::{filter_area, format_title};
use ratatui::{
    prelude::*,
    widgets::{Clear, Paragraph},
};
use rusticity_core::finder::Resource;

pub const FILTER_PLACEHOLDER: &str =
    "Type at least 3 search characters; @s3, @dynamodb, @ec2, @rds, @iam, @lambda limit services";

enum FinderColumn {
    Service,
    Type,
    Id,
    Name,
    Region,
}
impl Column<Resource> for FinderColumn {
    fn name(&self) -> &str {
        match self {
            Self::Service => "Service",
            Self::Type => "Resource type",
            Self::Id => "ID",
            Self::Name => "Name",
            Self::Region => "Region",
        }
    }
    fn width(&self) -> u16 {
        match self {
            Self::Service => 16,
            Self::Type => 16,
            Self::Id => 48,
            Self::Name => 32,
            Self::Region => 14,
        }
    }
    fn render(&self, resource: &Resource) -> (String, Style) {
        let text = match self {
            Self::Service => resource.kind.service(),
            Self::Type => resource.kind.label(),
            Self::Id => &resource.id,
            Self::Name => &resource.name,
            Self::Region => &resource.region,
        };
        (text.into(), Style::default())
    }
}

pub fn render(frame: &mut Frame, app: &App, area: Rect) {
    let popup = area.centered(Constraint::Percentage(90), Constraint::Percentage(80));
    frame.render_widget(Clear, popup);
    let finder = &app.resource_finder;
    let message = finder.message();
    let chunks = Layout::vertical([
        Constraint::Length(3),
        Constraint::Min(0),
        Constraint::Length(u16::from(!message.is_empty())),
    ])
    .split(popup);
    let cursor = Span::styled("█", Style::default().fg(Color::Green));
    let mut spans = Vec::new();
    if finder.input.is_empty() {
        if finder.focus == Focus::Filter {
            spans.push(cursor);
        }
        spans.push(Span::styled(
            FILTER_PLACEHOLDER,
            Style::default().fg(Color::DarkGray),
        ));
    } else {
        spans.push(Span::raw(&finder.input));
        if finder.focus == Focus::Filter {
            spans.push(cursor);
        }
    }
    frame.render_widget(filter_area(spans, finder.focus == Focus::Filter), chunks[0]);
    let columns: Vec<Box<dyn Column<Resource>>> = vec![
        Box::new(FinderColumn::Service),
        Box::new(FinderColumn::Type),
        Box::new(FinderColumn::Id),
        Box::new(FinderColumn::Name),
        Box::new(FinderColumn::Region),
    ];
    render_table(
        frame,
        TableConfig {
            items: finder.rows.iter().collect(),
            selected_index: finder.selected,
            expanded_index: None,
            columns: &columns,
            sort_column: "",
            sort_direction: SortDirection::Asc,
            title: format_title("AWS Resources"),
            area: chunks[1],
            get_expanded_content: None,
            is_active: finder.focus == Focus::Table,
        },
    );
    if !message.is_empty() {
        frame.render_widget(
            Paragraph::new(message).style(Style::default().fg(if finder.warnings.is_empty() {
                Color::DarkGray
            } else {
                Color::Yellow
            })),
            chunks[2],
        );
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{finder, keymap::Action};
    use ratatui::{backend::TestBackend, Terminal};

    fn app() -> App {
        let mut app = App::new_without_client("test".into(), Some("us-east-2".into()));
        finder::open(&mut app);
        app
    }

    fn draw(app: &App, width: u16, height: u16) -> Buffer {
        let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
        terminal.draw(|f| render(f, app, f.area())).unwrap();
        terminal.backend().buffer().clone()
    }

    fn row(buffer: &Buffer, y: u16) -> String {
        (0..buffer.area.width)
            .map(|x| buffer[(x, y)].symbol())
            .collect()
    }

    #[test]
    fn finder_placeholder_is_gray_in_both_focus_states_and_never_input_data() {
        let mut app = app();
        let popup = Rect::new(0, 0, 180, 30)
            .centered(Constraint::Percentage(90), Constraint::Percentage(80));
        for focus in [Focus::Filter, Focus::Table] {
            app.resource_finder.focus = focus;
            let buffer = draw(&app, 180, 30);
            let text = row(&buffer, popup.y + 1);
            assert!(text.contains(FILTER_PLACEHOLDER));
            assert_eq!(text.contains('█'), focus == Focus::Filter);
            let start = popup.x + 1 + u16::from(focus == Focus::Filter);
            for offset in 0..FILTER_PLACEHOLDER.len() as u16 {
                assert_eq!(buffer[(start + offset, popup.y + 1)].fg, Color::DarkGray);
            }
            assert!(app.resource_finder.input.is_empty());
            assert!(app.resource_finder.message().is_empty());
            assert!(!app.resource_finder.searching);
            assert_eq!(
                buffer[(popup.x, popup.y)].fg == Color::Green,
                focus == Focus::Filter
            );
            assert_eq!(
                buffer[(popup.x, popup.y + 3)].fg == Color::Green,
                focus == Focus::Table
            );
        }
    }

    #[test]
    fn finder_placeholder_disappears_on_first_character_and_returns_when_cleared() {
        let mut app = app();
        let popup = Rect::new(0, 0, 180, 30)
            .centered(Constraint::Percentage(90), Constraint::Percentage(80));
        app.handle_action(Action::FilterInput('a'));
        let buffer = draw(&app, 180, 30);
        assert_eq!(app.resource_finder.input, "a");
        assert!(row(&buffer, popup.y + 1).contains("a█"));
        assert!(!row(&buffer, popup.y + 1).contains("Type at least"));
        assert_ne!(buffer[(popup.x + 1, popup.y + 1)].fg, Color::DarkGray);
        assert!(app.resource_finder.message().is_empty());
        app.handle_action(Action::FilterBackspace);
        assert!(row(&draw(&app, 180, 30), popup.y + 1).contains(FILTER_PLACEHOLDER));
        assert!(app.resource_finder.input.is_empty());
    }

    #[test]
    fn finder_idle_has_no_footer_rows_and_messages_use_just_one_row() {
        let mut app = app();
        let popup = Rect::new(0, 0, 180, 30)
            .centered(Constraint::Percentage(90), Constraint::Percentage(80));
        let buffer = draw(&app, 180, 30);
        assert_eq!(buffer[(popup.x, popup.bottom() - 1)].symbol(), "╰");
        let text: String = buffer.content().iter().map(|c| c.symbol()).collect();
        assert_eq!(text.matches("Type at least").count(), 1);
        assert!(!text.contains("Enter / Tab:"));
        assert!(!text.contains("Esc: cancel"));
        assert!(!text.contains("Ctrl+R:"));

        for c in "orders".chars() {
            app.handle_action(Action::FilterInput(c));
        }
        app.resource_finder.provider = "Indexed prefix search".into();
        app.resource_finder.searching = true;
        let buffer = draw(&app, 180, 30);
        assert_eq!(buffer[(popup.x, popup.bottom() - 2)].symbol(), "╰");
        assert!(row(&buffer, popup.bottom() - 1).contains("Indexed prefix search"));
        app.resource_finder.warnings = vec!["AccessDenied: partial results".into()];
        let buffer = draw(&app, 180, 30);
        assert_eq!(buffer[(popup.x, popup.bottom() - 2)].symbol(), "╰");
        assert!(row(&buffer, popup.bottom() - 1).contains("AccessDenied"));
        assert_eq!(buffer[(popup.x, popup.bottom() - 1)].fg, Color::Yellow);
    }

    #[test]
    fn finder_placeholder_clips_safely_in_small_viewports() {
        let mut app = app();
        for focus in [Focus::Filter, Focus::Table] {
            app.resource_finder.focus = focus;
            for (width, height) in [(1, 1), (2, 3), (12, 8), (40, 12), (80, 20)] {
                draw(&app, width, height);
            }
        }
        assert!(app.resource_finder.input.is_empty());
    }
}