use figlet_rs::FIGlet;
use ratatui::{
Frame,
layout::Rect,
style::{Color, Style},
widgets::Paragraph,
};
use tui_big_text::{BigText, PixelSize};
use crate::options::OptionSchema;
use crate::payload::{Body, TextData};
use crate::theme::{self, ColorKey, Theme};
use super::{Registry, RenderOptions, Renderer, Shape};
#[derive(Debug, Clone, Default, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct Options {
#[serde(default)]
pub font: Option<String>,
#[serde(default)]
pub pixel_size: Option<String>,
}
const COLOR_KEYS: &[ColorKey] = &[theme::TEXT, theme::PANEL_TITLE];
const OPTION_SCHEMAS: &[OptionSchema] = &[
OptionSchema {
name: "style",
type_hint: "\"blocks\" | \"figlet\"",
required: false,
default: Some("\"blocks\""),
description: "Visual treatment. `blocks` uses half-block glyphs via `tui-big-text`; `figlet` uses the classic FIGlet engine.",
},
OptionSchema {
name: "pixel_size",
type_hint: "\"full\" | \"quadrant\" | \"sextant\"",
required: false,
default: Some("adaptive (picked by area height)"),
description: "Glyph density for `style = \"blocks\"`. Ignored when `style = \"figlet\"`.",
},
OptionSchema {
name: "font",
type_hint: "\"standard\" | \"small\" | \"big\" | \"banner\" | \"ansi_shadow\" | \"slant\" | \"isometric\" | \"doom\"",
required: false,
default: Some("\"standard\""),
description: "FIGlet font when `style = \"figlet\"`. Unknown names fall back to `standard`. `ansi_shadow` and `isometric` use Unicode box-drawing characters; `slant` is the classic italic; `doom` is a bold block face.",
},
OptionSchema {
name: "color",
type_hint: "theme token name (e.g. \"panel_title\", \"text\")",
required: false,
default: Some("\"text\""),
description: "Foreground colour token. Any `[theme]` key name is accepted; unknown names fall back to the default.",
},
OptionSchema {
name: "align",
type_hint: "\"left\" | \"center\" | \"right\"",
required: false,
default: Some("\"left\""),
description: "Horizontal alignment of the rendered text within its cell.",
},
];
const FONT_STANDARD: &str = include_str!("figlet_fonts/standard.flf");
const FONT_SMALL: &str = include_str!("figlet_fonts/small.flf");
const FONT_BIG: &str = include_str!("figlet_fonts/big.flf");
const FONT_BANNER: &str = include_str!("figlet_fonts/banner.flf");
const FONT_ANSI_SHADOW: &str = include_str!("figlet_fonts/ansi_shadow.flf");
const FONT_SLANT: &str = include_str!("figlet_fonts/slant.flf");
const FONT_ISOMETRIC: &str = include_str!("figlet_fonts/isometric1.flf");
const FONT_DOOM: &str = include_str!("figlet_fonts/doom.flf");
pub struct TextAsciiRenderer;
impl Renderer for TextAsciiRenderer {
fn name(&self) -> &str {
"text_ascii"
}
fn description(&self) -> &'static str {
"Chunky multi-row big-text via half-block glyphs (`style = \"blocks\"`) or classic FIGlet ASCII art (`style = \"figlet\"`). The hero treatment for short strings like a clock or greeting; use `text_plain` for anything multi-line."
}
fn accepts(&self) -> &[Shape] {
&[Shape::Text]
}
fn option_schemas(&self) -> &[OptionSchema] {
OPTION_SCHEMAS
}
fn color_keys(&self) -> &[ColorKey] {
COLOR_KEYS
}
fn render(
&self,
frame: &mut Frame,
area: Rect,
body: &Body,
opts: &RenderOptions,
theme: &Theme,
_registry: &Registry,
) {
if let Body::Text(d) = body {
let specific: Options = opts.parse_specific();
match opts.style.as_deref() {
Some("figlet") => render_figlet(frame, area, d, opts, &specific, theme),
_ => render_blocks(frame, area, d, opts, &specific, theme),
}
}
}
fn natural_height(
&self,
body: &Body,
opts: &RenderOptions,
max_width: u16,
_registry: &Registry,
) -> u16 {
let Body::Text(d) = body else {
return 1;
};
let specific: Options = opts.parse_specific();
match opts.style.as_deref() {
Some("figlet") => {
let rendered =
figlet_wrapped(&d.value, specific.font.as_deref(), max_width, u16::MAX);
rendered.lines().count().clamp(1, u16::MAX as usize) as u16
}
_ => blocks_height(specific.pixel_size.as_deref()),
}
}
}
fn blocks_height(pixel_size: Option<&str>) -> u16 {
match parse_pixel_size(pixel_size.unwrap_or("quadrant")) {
Some(PixelSize::Full) => 8,
Some(PixelSize::Quadrant) => 4,
Some(PixelSize::Sextant) => 3,
_ => 4,
}
}
fn render_blocks(
frame: &mut Frame,
area: Rect,
data: &TextData,
opts: &RenderOptions,
specific: &Options,
theme: &Theme,
) {
let pixel_size = specific
.pixel_size
.as_deref()
.and_then(parse_pixel_size)
.unwrap_or_else(|| pick_pixel_size(area));
let natural_width = blocks_width(&data.value, pixel_size);
let target = align_rect(area, natural_width, opts.align.as_deref());
let big = BigText::builder()
.pixel_size(pixel_size)
.style(Style::default().fg(resolve_color(opts, theme)))
.lines(vec![data.value.clone().into()])
.build();
frame.render_widget(big, target);
}
fn render_figlet(
frame: &mut Frame,
area: Rect,
data: &TextData,
opts: &RenderOptions,
specific: &Options,
theme: &Theme,
) {
let rendered = figlet_wrapped(
&data.value,
specific.font.as_deref(),
area.width,
area.height,
);
let p = Paragraph::new(rendered)
.style(Style::default().fg(resolve_color(opts, theme)))
.alignment(parse_alignment(opts.align.as_deref()));
frame.render_widget(p, area);
}
fn figlet_wrapped(text: &str, font_name: Option<&str>, max_width: u16, max_height: u16) -> String {
let font = load_font(font_name);
let render = |s: &str| font.convert(s).map(|f| f.to_string());
let Some(full) = render(text) else {
return text.to_string();
};
if block_width(&full) <= max_width as usize || !text.contains(' ') {
return full;
}
let mut blocks: Vec<String> = Vec::new();
let mut line_words: Vec<&str> = Vec::new();
for word in text.split_whitespace() {
line_words.push(word);
let candidate = line_words.join(" ");
let fits = render(&candidate).is_some_and(|r| block_width(&r) <= max_width as usize);
if fits {
continue;
}
line_words.pop();
if !line_words.is_empty()
&& let Some(rendered) = render(&line_words.join(" "))
{
blocks.push(rendered);
}
line_words.clear();
line_words.push(word);
}
if !line_words.is_empty()
&& let Some(rendered) = render(&line_words.join(" "))
{
blocks.push(rendered);
}
if blocks.is_empty() {
return full;
}
let joined = blocks.join("\n");
if joined.lines().count() as u16 <= max_height {
joined
} else {
full
}
}
fn block_width(block: &str) -> usize {
block.lines().map(|l| l.chars().count()).max().unwrap_or(0)
}
fn resolve_color(opts: &RenderOptions, theme: &Theme) -> Color {
match opts.color.as_deref() {
Some(name) => theme::token_color(theme, name).unwrap_or(theme.text),
None => theme.text,
}
}
fn blocks_width(text: &str, pixel_size: PixelSize) -> u16 {
let chars = text.chars().count() as u16;
let glyph_cols = match pixel_size {
PixelSize::Full => 8,
PixelSize::Quadrant => 4,
PixelSize::Sextant => 4,
_ => 4,
};
chars.saturating_mul(glyph_cols)
}
fn align_rect(area: Rect, content_width: u16, align: Option<&str>) -> Rect {
if content_width == 0 || content_width >= area.width {
return area;
}
let offset = match align {
Some("center") => (area.width - content_width) / 2,
Some("right") => area.width - content_width,
_ => return area,
};
Rect {
x: area.x + offset,
y: area.y,
width: content_width,
height: area.height,
}
}
fn parse_alignment(s: Option<&str>) -> ratatui::layout::Alignment {
match s {
Some("center") => ratatui::layout::Alignment::Center,
Some("right") => ratatui::layout::Alignment::Right,
_ => ratatui::layout::Alignment::Left,
}
}
fn load_font(name: Option<&str>) -> FIGlet {
let source = match name.unwrap_or("standard") {
"small" => FONT_SMALL,
"big" => FONT_BIG,
"banner" => FONT_BANNER,
"ansi_shadow" => FONT_ANSI_SHADOW,
"slant" => FONT_SLANT,
"isometric" => FONT_ISOMETRIC,
"doom" => FONT_DOOM,
_ => FONT_STANDARD,
};
FIGlet::from_content(source).unwrap_or_else(|_| FIGlet::standard().expect("standard font"))
}
fn parse_pixel_size(s: &str) -> Option<PixelSize> {
match s {
"full" => Some(PixelSize::Full),
"quadrant" => Some(PixelSize::Quadrant),
"sextant" => Some(PixelSize::Sextant),
_ => None,
}
}
fn pick_pixel_size(area: Rect) -> PixelSize {
if area.height >= 8 {
PixelSize::Full
} else if area.height >= 4 {
PixelSize::Quadrant
} else {
PixelSize::Sextant
}
}
#[cfg(test)]
mod tests {
use ratatui::layout::Alignment;
use super::*;
use crate::payload::{Payload, TextBlockData, TextData};
use crate::render::test_utils::render_to_buffer_with_spec;
use crate::render::{Registry, RenderSpec};
use crate::theme::{self, Theme};
fn payload(text: &str) -> Payload {
Payload {
icon: None,
status: None,
format: None,
body: Body::Text(TextData { value: text.into() }),
}
}
fn pixel_name(pixel: PixelSize) -> &'static str {
match pixel {
PixelSize::Full => "full",
PixelSize::Quadrant => "quadrant",
PixelSize::Sextant => "sextant",
_ => "other",
}
}
#[test]
fn default_style_uses_blocks() {
let registry = Registry::with_builtins();
let spec = RenderSpec::Short("text_ascii".into());
let _ = render_to_buffer_with_spec(&payload("12:34"), Some(&spec), ®istry, 40, 10);
}
#[test]
fn renderer_exposes_docs_metadata() {
let renderer = TextAsciiRenderer;
assert_eq!(
renderer.description(),
"Chunky multi-row big-text via half-block glyphs (`style = \"blocks\"`) or classic FIGlet ASCII art (`style = \"figlet\"`). The hero treatment for short strings like a clock or greeting; use `text_plain` for anything multi-line."
);
assert_eq!(renderer.accepts(), &[Shape::Text]);
assert_eq!(renderer.option_schemas()[0].name, "style");
assert_eq!(renderer.option_schemas()[4].name, "align");
assert_eq!(renderer.color_keys().len(), COLOR_KEYS.len());
assert_eq!(renderer.color_keys()[0].name, theme::TEXT.name);
assert_eq!(renderer.color_keys()[1].name, theme::PANEL_TITLE.name);
}
#[test]
fn figlet_style_routes_through_figlet_rs() {
#[derive(serde::Deserialize)]
struct W {
render: RenderSpec,
}
let w: W = toml::from_str(r#"render = { type = "text_ascii", style = "figlet" }"#).unwrap();
let registry = Registry::with_builtins();
let _ = render_to_buffer_with_spec(&payload("hi"), Some(&w.render), ®istry, 60, 10);
}
#[test]
fn figlet_fonts_load_without_panicking() {
for name in [
"standard",
"small",
"big",
"banner",
"ansi_shadow",
"slant",
"isometric",
"doom",
] {
let text = figlet_wrapped("hi", Some(name), 200, 200);
assert!(!text.is_empty());
}
}
#[test]
fn unknown_font_falls_back_to_standard() {
let fallback = figlet_wrapped("hi", Some("no-such-font"), 200, 200);
let std_out = figlet_wrapped("hi", Some("standard"), 200, 200);
assert_eq!(fallback, std_out);
}
#[test]
fn figlet_wraps_multiword_hero_to_fit_width() {
let joined = figlet_wrapped("Windows Terminal", Some("ansi_shadow"), 200, 200)
.lines()
.count();
let wrapped = figlet_wrapped("Windows Terminal", Some("ansi_shadow"), 60, 200)
.lines()
.count();
assert!(wrapped > joined);
}
#[test]
fn figlet_skips_wrap_when_no_room_for_stack() {
let joined = figlet_wrapped("Windows Terminal", Some("ansi_shadow"), 200, 200);
let wrapped = figlet_wrapped("Windows Terminal", Some("ansi_shadow"), 60, 7);
assert_eq!(wrapped.lines().count(), joined.lines().count());
}
#[test]
fn figlet_keeps_short_text_on_one_block() {
let rendered = figlet_wrapped("Fri 24", Some("ansi_shadow"), 100, 200);
assert!(rendered.lines().count() <= 8);
}
#[test]
fn color_option_resolves_theme_token() {
let theme = Theme::default();
let opts = RenderOptions {
color: Some("panel_title".into()),
..RenderOptions::default()
};
assert_eq!(resolve_color(&opts, &theme), theme.panel_title);
let opts_unknown = RenderOptions {
color: Some("bogus".into()),
..RenderOptions::default()
};
assert_eq!(resolve_color(&opts_unknown, &theme), theme.text);
}
#[test]
fn helper_branches_cover_geometry_alignment_and_pixel_size() {
assert_eq!(blocks_height(Some("full")), 8);
assert_eq!(blocks_height(Some("quadrant")), 4);
assert_eq!(blocks_height(Some("sextant")), 3);
assert_eq!(blocks_height(Some("bogus")), 4);
assert_eq!(blocks_width("hi", PixelSize::Sextant), 8);
let area = Rect::new(2, 3, 20, 4);
assert_eq!(align_rect(area, 8, Some("center")), Rect::new(8, 3, 8, 4));
assert_eq!(align_rect(area, 8, Some("right")), Rect::new(14, 3, 8, 4));
assert_eq!(align_rect(area, 0, Some("center")), area);
assert_eq!(align_rect(area, area.width, Some("right")), area);
assert_eq!(parse_alignment(Some("center")), Alignment::Center);
assert_eq!(parse_alignment(Some("right")), Alignment::Right);
assert_eq!(parse_alignment(Some("bogus")), Alignment::Left);
assert_eq!(pixel_name(pick_pixel_size(Rect::new(0, 0, 20, 8))), "full");
assert_eq!(
pixel_name(pick_pixel_size(Rect::new(0, 0, 20, 4))),
"quadrant"
);
assert_eq!(
pixel_name(pick_pixel_size(Rect::new(0, 0, 20, 2))),
"sextant"
);
assert_eq!(pixel_name(parse_pixel_size("full").unwrap()), "full");
assert_eq!(
pixel_name(parse_pixel_size("quadrant").unwrap()),
"quadrant"
);
assert_eq!(pixel_name(parse_pixel_size("sextant").unwrap()), "sextant");
assert!(parse_pixel_size("bogus").is_none());
}
#[test]
fn natural_height_defaults_to_one_for_non_text_bodies() {
let body = Body::TextBlock(TextBlockData {
lines: vec!["not ascii".into()],
});
let renderer = TextAsciiRenderer;
let registry = Registry::with_builtins();
assert_eq!(
renderer.natural_height(&body, &RenderOptions::default(), 20, ®istry),
1
);
}
#[test]
fn natural_height_uses_blocks_height_when_not_figlet() {
let renderer = TextAsciiRenderer;
let registry = Registry::with_builtins();
let opts = RenderOptions::default().with_extra("pixel_size", "full");
assert_eq!(
renderer.natural_height(&payload("HI").body, &opts, 20, ®istry),
8
);
}
#[test]
fn figlet_wrapped_returns_raw_input_when_font_cannot_render_it() {
assert_eq!(figlet_wrapped("", None, 200, 200), "");
assert_eq!(figlet_wrapped("\u{1}\u{2}", None, 200, 200), "\u{1}\u{2}");
}
#[test]
fn blocks_width_and_pixel_name_fall_back_for_unrecognised_pixel_size() {
assert_eq!(blocks_width("hi", PixelSize::HalfHeight), 8);
assert_eq!(pixel_name(PixelSize::HalfHeight), "other");
}
#[test]
fn render_draws_nothing_for_a_non_text_body() {
use ratatui::{Terminal, backend::TestBackend};
let mut terminal = Terminal::new(TestBackend::new(20, 4)).unwrap();
let body = Body::TextBlock(TextBlockData {
lines: vec!["ignored".into()],
});
let registry = Registry::with_builtins();
let theme = Theme::default();
let opts = RenderOptions::default();
terminal
.draw(|f| TextAsciiRenderer.render(f, f.area(), &body, &opts, &theme, ®istry))
.unwrap();
let buf = terminal.backend().buffer().clone();
assert!(
(0..buf.area.width).all(|x| buf.cell((x, 0)).unwrap().symbol() == " "),
"a non-text body must leave the cell blank",
);
}
}