use ratatui::Frame;
use ratatui::layout::{Alignment, Rect};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use crate::ui::theme::{Level, Theme};
pub struct EmptyState<'a> {
pub title: &'a str,
pub reason: Option<&'a str>,
pub remedy: Option<&'a str>,
pub level: Level,
}
impl<'a> EmptyState<'a> {
pub fn no_match(noun: &'a str) -> Self {
Self {
title: noun,
reason: Some("No rows match the active filter."),
remedy: Some("Press Esc to clear it, or / to edit it."),
level: Level::Neutral,
}
}
pub fn empty(title: &'a str, reason: Option<&'a str>) -> Self {
Self {
title,
reason,
remedy: None,
level: Level::Neutral,
}
}
pub fn error(title: &'a str, reason: &'a str, remedy: &'a str) -> Self {
Self {
title,
reason: Some(reason),
remedy: Some(remedy),
level: Level::Error,
}
}
pub fn waiting(what: &'a str) -> Self {
Self {
title: what,
reason: None,
remedy: None,
level: Level::Info,
}
}
}
pub fn render(frame: &mut Frame, area: Rect, state: &EmptyState<'_>, theme: &Theme) {
if area.height == 0 || area.width == 0 {
return;
}
let mut lines: Vec<Line<'static>> = Vec::with_capacity(4);
lines.push(Line::from(Span::styled(
state.title.to_string(),
theme
.level_style(state.level)
.add_modifier(ratatui::style::Modifier::BOLD),
)));
if let Some(reason) = state.reason {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(reason.to_string(), theme.dim())));
}
if let Some(remedy) = state.remedy {
lines.push(Line::from(Span::styled(remedy.to_string(), theme.subtle())));
}
let content_h = lines.len() as u16;
let top_pad = area.height.saturating_sub(content_h) / 2;
let inner = Rect {
y: area.y + top_pad.min(area.height.saturating_sub(1)),
height: area.height - top_pad.min(area.height.saturating_sub(1)),
..area
};
frame.render_widget(Paragraph::new(lines).alignment(Alignment::Center), inner);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::terminal::ColorSupport;
use ratatui::{Terminal, backend::TestBackend};
fn render_to_string(state: &EmptyState<'_>, w: u16, h: u16) -> String {
let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap();
let theme = Theme::new(ColorSupport::TrueColor);
terminal
.draw(|f| render(f, Rect::new(0, 0, w, h), state, &theme))
.unwrap();
let buf = terminal.backend().buffer().clone();
(0..h)
.map(|y| {
(0..w)
.map(|x| buf.cell((x, y)).map(|c| c.symbol()).unwrap_or(" "))
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn renders_title_reason_and_remedy() {
let s = EmptyState::error(
"No container daemon detected",
"The socket is not answering.",
"Start Docker, or run `podman system service`.",
);
let out = render_to_string(&s, 60, 10);
assert!(out.contains("No container daemon detected"));
assert!(out.contains("The socket is not answering."));
assert!(out.contains("Start Docker"));
}
#[test]
fn no_match_state_tells_the_user_how_to_recover() {
let s = EmptyState::no_match("Processes");
let out = render_to_string(&s, 60, 10);
assert!(out.contains("filter"));
assert!(out.contains("Esc"), "a dead end must offer a way out");
}
#[test]
fn tiny_areas_do_not_panic_and_still_show_the_title() {
let s = EmptyState::error("Broken", "Because.", "Fix it.");
let out = render_to_string(&s, 40, 1);
assert!(out.contains("Broken"), "the title must survive one row");
let _ = render_to_string(&s, 1, 1);
let _ = render_to_string(&s, 0, 0);
}
#[test]
fn waiting_state_has_no_false_remedy() {
let s = EmptyState::waiting("Waiting for data…");
assert!(s.remedy.is_none());
assert!(s.reason.is_none());
let out = render_to_string(&s, 40, 6);
assert!(out.contains("Waiting for data"));
}
}