mod common;
use abstracttui::gfx::bigtext::{
self, BigTextStyle, Content, GlyphScale, GlyphWeight, Legibility, Sampling,
};
use abstracttui::gfx::mosaic::MosaicMode;
use abstracttui::prelude::*;
use abstracttui::ui::Canvas;
use abstracttui::widgets::{Block, BorderKind};
const SCALES: [GlyphScale; 22] = [
GlyphScale { cols: 2, rows: 2 },
GlyphScale { cols: 3, rows: 2 },
GlyphScale { cols: 4, rows: 2 },
GlyphScale { cols: 5, rows: 2 }, GlyphScale { cols: 6, rows: 2 }, GlyphScale { cols: 3, rows: 3 },
GlyphScale { cols: 4, rows: 3 },
GlyphScale { cols: 5, rows: 3 },
GlyphScale { cols: 6, rows: 3 },
GlyphScale { cols: 4, rows: 4 },
GlyphScale { cols: 5, rows: 4 },
GlyphScale { cols: 6, rows: 4 },
GlyphScale { cols: 8, rows: 4 },
GlyphScale { cols: 4, rows: 5 },
GlyphScale { cols: 5, rows: 5 },
GlyphScale { cols: 6, rows: 5 },
GlyphScale { cols: 7, rows: 5 },
GlyphScale { cols: 10, rows: 5 },
GlyphScale { cols: 4, rows: 6 },
GlyphScale { cols: 5, rows: 6 },
GlyphScale { cols: 6, rows: 6 },
GlyphScale { cols: 12, rows: 6 },
];
fn start_index() -> usize {
bigtext::smallest_clear(MODES[0].0, Content::Text)
.and_then(|want| SCALES.iter().position(|s| *s == want))
.unwrap_or(0)
}
const MODES: [(MosaicMode, &str); 3] = [
(MosaicMode::Sextant, "sextant 2x3 · solid ink"),
(MosaicMode::Braille, "braille 2x4 · densest, dotted"),
(MosaicMode::Quadrant, "quadrant 2x2 · universal, coarse"),
];
const WEIGHTS: [(GlyphWeight, &str); 2] = [
(GlyphWeight::Regular, "regular"),
(GlyphWeight::Bold, "bold (synthetic)"),
];
const SAMPLINGS: [(Sampling, &str); 2] = [
(Sampling::AreaAverage, "area average"),
(Sampling::Nearest, "point sampled"),
];
const BANNER: &str = "AGORA";
const CLASSES: [(&str, Content); 3] = [
("UPPER", Content::Uppercase),
("lower+digits", Content::Text),
("icons", Content::Icons),
];
const PANEL_W: i32 = 34;
const COL_PAIR: i32 = 13;
const COL_APART: i32 = 19;
const COL_SHAPE: i32 = 24;
fn main() -> abstracttui::base::Result<()> {
if !abstracttui::term::have_tty() {
println!("bigtext: needs an interactive terminal — skipping cleanly");
return Ok(());
}
let mut app = App::new(Size::new(96, 34));
let quitter = app.quitter();
app.mount(move |cx| {
let scale = cx.signal(start_index());
let mode = cx.signal(0usize);
let weight = cx.signal(0usize);
let sampling = cx.signal(0usize);
Element::new()
.style(LayoutStyle::fill())
.shortcut(KeyChord::plain(Key::Char('q')), move |_| quitter.quit())
.shortcut(KeyChord::plain(Key::Char('s')), move |_| {
mode.update(|m| *m = (*m + 1) % MODES.len())
})
.shortcut(KeyChord::plain(Key::Char('w')), move |_| {
weight.update(|w| *w = (*w + 1) % WEIGHTS.len())
})
.shortcut(KeyChord::plain(Key::Char('a')), move |_| {
sampling.update(|s| *s = (*s + 1) % SAMPLINGS.len())
})
.shortcut(KeyChord::plain(Key::Up), move |_| {
scale.update(|s| *s = s.saturating_sub(1))
})
.shortcut(KeyChord::plain(Key::Down), move |_| {
scale.update(|s| *s = (*s + 1).min(SCALES.len() - 1))
})
.child(dyn_view(LayoutStyle::fill(), move || {
let picks = Picks {
scale: scale.get(),
mode: mode.get(),
weight: weight.get(),
sampling: sampling.get(),
};
page(picks)
}))
.build()
})?;
app.run()
}
#[derive(Clone, Copy)]
struct Picks {
scale: usize,
mode: usize,
weight: usize,
sampling: usize,
}
impl Picks {
fn style(&self) -> BigTextStyle {
BigTextStyle::new(SCALES[self.scale], MODES[self.mode].0)
.weight(WEIGHTS[self.weight].0)
.sampling(SAMPLINGS[self.sampling].0)
}
}
fn page(picks: Picks) -> View {
let style = picks.style();
let scale = style.scale;
let specimen_h = scale.rows + 4;
Element::new()
.style(
LayoutStyle::column()
.width(Dimension::Percent(1.0))
.height(Dimension::Percent(1.0)),
)
.child(title_strip(picks))
.child(
Element::new()
.style(LayoutStyle::row().grow(1.0).gap(1))
.child(
Element::new()
.style(LayoutStyle::column().grow(1.0).gap(0))
.child(specimen_panel(picks, specimen_h))
.child(pairs_panel(picks))
.child(trap_panel(picks))
.build(),
)
.child(
Element::new()
.style(LayoutStyle::column().w(PANEL_W).shrink(0.0))
.child(measured_panel(picks))
.child(sweep_panel(picks))
.build(),
)
.build(),
)
.child(
Element::new()
.style(LayoutStyle::line(1).shrink(0.0))
.draw(move |canvas, rect| {
let t = current_theme().tokens;
canvas.fill(rect, ' ', t.text_faint, t.bg);
canvas.print(
Point::new(rect.x + 2, rect.y),
"↑/↓ size · s symbols · w weight · a sampling · q quit",
t.text_faint,
t.bg,
);
})
.build(),
)
.build()
}
fn title_strip(picks: Picks) -> View {
let scale = SCALES[picks.scale];
let where_in_sweep = format!(
"{}x{} [{} of {}]",
scale.cols,
scale.rows,
picks.scale + 1,
SCALES.len()
);
let axes = format!(
"{} · {} · {}",
MODES[picks.mode].1, SAMPLINGS[picks.sampling].1, WEIGHTS[picks.weight].1
);
Element::new()
.style(LayoutStyle::line(1).shrink(0.0))
.draw(move |canvas, rect| {
let t = current_theme().tokens;
canvas.fill(rect, ' ', t.text, t.surface_raised);
canvas.print(
Point::new(rect.x + 2, rect.y),
&where_in_sweep,
t.accent,
t.surface_raised,
);
canvas.print(
Point::new(rect.x + 22, rect.y),
&axes,
t.text_muted,
t.surface_raised,
);
})
.build()
}
fn specimen_panel(picks: Picks, height: i32) -> View {
let t = current_theme().tokens;
let style = picks.style();
let scale = style.scale;
let size = bigtext::measure(BANNER, scale).unwrap_or(Size::new(0, 0));
let cost = format!("\"{BANNER}\" costs {} cols x {} rows", size.w, size.h);
Block::new()
.title("specimen")
.border(BorderKind::Rounded)
.fill(t.surface)
.layout(
LayoutStyle::column()
.width(Dimension::Percent(1.0))
.h(height)
.shrink(0.0),
)
.child(
Element::new()
.style(
LayoutStyle::column()
.width(Dimension::Percent(1.0))
.height(Dimension::Percent(1.0)),
)
.draw(move |canvas, rect| {
let t = current_theme().tokens;
canvas.fill(rect, ' ', t.text, t.surface);
blit(canvas, Point::new(rect.x, rect.y), BANNER, &style);
let y = rect.y + scale.rows;
if y < rect.bottom() {
canvas.print(Point::new(rect.x, y), &cost, t.text_faint, t.surface);
}
})
.build(),
)
.element(&t)
.build()
}
fn pairs_panel(picks: Picks) -> View {
let t = current_theme().tokens;
let style = picks.style();
let pairs: Vec<(String, char, char, u32)> = CLASSES
.iter()
.filter_map(|(label, content)| {
let (a, b, d) = bigtext::closest_pair(&style, *content)?;
Some((label.to_string(), a, b, d))
})
.collect();
Block::new()
.title("the hardest pair in each class, at this size")
.border(BorderKind::Rounded)
.fill(t.surface)
.layout(
LayoutStyle::column()
.width(Dimension::Percent(1.0))
.grow(1.0),
)
.child(
Element::new()
.style(
LayoutStyle::column()
.width(Dimension::Percent(1.0))
.height(Dimension::Percent(1.0)),
)
.draw(move |canvas, rect| {
let t = current_theme().tokens;
canvas.fill(rect, ' ', t.text, t.surface);
let mut y = rect.y;
let mut cut = 0usize;
let floor_y = rect.bottom() - 1;
for (label, a, b, d) in pairs.iter() {
if y + style.scale.rows + 1 > floor_y {
cut += 1;
continue;
}
let pair: String = [*a, *b].iter().collect();
blit(canvas, Point::new(rect.x, y), &pair, &style);
y += style.scale.rows;
let verdict = match d {
0 => "identical — this size cannot carry it",
1..=3 => "close: judge it in YOUR terminal",
_ => "clearly apart",
};
canvas.print(
Point::new(rect.x, y),
&format!("{label} {a}/{b} {d} subpixels — {verdict}"),
t.text_faint,
t.surface,
);
y += 1;
}
if cut > 0 {
canvas.print(
Point::new(rect.x, floor_y),
&format!(
"{cut} more class(es) need a taller window at {}x{}",
style.scale.cols, style.scale.rows
),
t.warn,
t.surface,
);
}
})
.build(),
)
.element(&t)
.build()
}
fn trap_panel(picks: Picks) -> View {
let t = current_theme().tokens;
let style = picks.style();
let square = GlyphScale::square(2);
let naive = GlyphScale { cols: 2, rows: 2 };
const TRAP: &str = "★◆";
Block::new()
.title("the aspect trap — a cell is twice as tall as it is wide")
.border(BorderKind::Rounded)
.fill(t.surface)
.layout(
LayoutStyle::column()
.width(Dimension::Percent(1.0))
.h(6)
.shrink(0.0),
)
.child(
Element::new()
.style(
LayoutStyle::column()
.width(Dimension::Percent(1.0))
.height(Dimension::Percent(1.0)),
)
.draw(move |canvas, rect| {
let t = current_theme().tokens;
canvas.fill(rect, ' ', t.text, t.surface);
let right = rect.x + 30;
let icon = |s: GlyphScale| BigTextStyle { scale: s, ..style };
blit(canvas, Point::new(rect.x, rect.y), TRAP, &icon(square));
blit(canvas, Point::new(right, rect.y), TRAP, &icon(naive));
let y = rect.y + 2;
if y < rect.bottom() {
canvas.print(
Point::new(rect.x, y),
"square(2) — 4x2, square",
t.ok,
t.surface,
);
canvas.print(
Point::new(right, y),
"2x2 — half as wide as tall",
t.warn,
t.surface,
);
}
})
.build(),
)
.element(&t)
.build()
}
fn measured_panel(picks: Picks) -> View {
let t = current_theme().tokens;
let style = picks.style();
#[allow(clippy::type_complexity)]
let rows: Vec<(String, String, u32, String, Legibility)> = CLASSES
.iter()
.filter_map(|(label, content)| {
let (a, b, d) = bigtext::closest_pair(&style, *content)?;
let worst = bigtext::least_faithful(&style, *content)
.map(|(c, loss)| format!("{c}{loss:.2}"))
.unwrap_or_default();
Some((
label.to_string(),
format!("{a}/{b}"),
d,
worst,
bigtext::legibility(&style, *content),
))
})
.collect();
let smallest = bigtext::smallest_clear(MODES[picks.mode].0, Content::Text);
let scale = SCALES[picks.scale];
let in_band = scale.within_aspect_band();
let aspect = if in_band {
format!("aspect: {}x{} in band", scale.cols, scale.rows)
} else if scale.cols > scale.rows {
format!("aspect: {}x{} STRETCHED WIDE", scale.cols, scale.rows)
} else {
format!("aspect: {}x{} SQUEEZED TALL", scale.cols, scale.rows)
};
Block::new()
.title("measured now")
.border(BorderKind::Rounded)
.fill(t.surface_raised)
.layout(
LayoutStyle::column()
.width(Dimension::Percent(1.0))
.h(18)
.shrink(0.0),
)
.child(
Element::new()
.style(
LayoutStyle::column()
.width(Dimension::Percent(1.0))
.height(Dimension::Percent(1.0)),
)
.draw(move |canvas, rect| {
let t = current_theme().tokens;
canvas.fill(rect, ' ', t.text, t.surface_raised);
let mut y = rect.y;
canvas.print(
Point::new(rect.x, y),
"class",
t.text_muted,
t.surface_raised,
);
canvas.print(
Point::new(rect.x + COL_PAIR, y),
"pair",
t.text_muted,
t.surface_raised,
);
canvas.print(
Point::new(rect.x + COL_APART, y),
"apart",
t.text_muted,
t.surface_raised,
);
canvas.print(
Point::new(rect.x + COL_SHAPE, y),
"shape",
t.text_muted,
t.surface_raised,
);
y += 1;
canvas.print(
Point::new(rect.x, y),
"0 apart = same picture",
t.text_faint,
t.surface_raised,
);
y += 1;
canvas.print(
Point::new(rect.x, y),
"shape over 0.35 = not a glyph",
t.text_faint,
t.surface_raised,
);
y += 2;
for (label, pair, d, worst, verdict) in rows.iter() {
if y >= rect.bottom() {
break;
}
let ink = match verdict {
Legibility::Collides => t.error,
Legibility::Distorted => t.error,
Legibility::Marginal => t.warn,
Legibility::Clear => t.ok,
};
canvas.print(Point::new(rect.x, y), label, t.text, t.surface_raised);
canvas.print(
Point::new(rect.x + COL_PAIR, y),
pair,
t.text_muted,
t.surface_raised,
);
canvas.print(
Point::new(rect.x + COL_APART, y),
&format!("{d:>3}"),
ink,
t.surface_raised,
);
canvas.print(
Point::new(rect.x + COL_SHAPE, y),
worst,
ink,
t.surface_raised,
);
y += 1;
}
y += 1;
if y < rect.bottom() {
canvas.print(
Point::new(rect.x, y),
&aspect,
if in_band { t.text_muted } else { t.error },
t.surface_raised,
);
y += 1;
}
if !in_band && y < rect.bottom() {
canvas.print(
Point::new(rect.x, y),
"the pair column is true",
t.error,
t.surface_raised,
);
y += 1;
if y < rect.bottom() {
canvas.print(
Point::new(rect.x, y),
"and not the point",
t.error,
t.surface_raised,
);
y += 1;
}
}
y += 1;
if y < rect.bottom() {
canvas.print(
Point::new(rect.x, y),
&match smallest {
Some(s) => format!("cheapest clear text: {}x{}", s.cols, s.rows),
None => "no scale clears for text".to_string(),
},
t.accent,
t.surface_raised,
);
y += 2;
}
for line in [
"derived per class and per",
"symbols — press s and",
"watch it move.",
] {
if y >= rect.bottom() {
break;
}
canvas.print(Point::new(rect.x, y), line, t.text_faint, t.surface_raised);
y += 1;
}
})
.build(),
)
.element(&t)
.build()
}
fn sweep_panel(picks: Picks) -> View {
let t = current_theme().tokens;
let here = picks.scale;
Block::new()
.title("sizes ↑/↓")
.border(BorderKind::Rounded)
.fill(t.surface)
.layout(
LayoutStyle::column()
.width(Dimension::Percent(1.0))
.grow(1.0),
)
.child(
Element::new()
.style(
LayoutStyle::column()
.width(Dimension::Percent(1.0))
.height(Dimension::Percent(1.0)),
)
.draw(move |canvas, rect| {
let t = current_theme().tokens;
canvas.fill(rect, ' ', t.text, t.surface);
let visible = (rect.h.max(1) as usize).min(SCALES.len());
let first = here.saturating_sub(visible / 2).min(SCALES.len() - visible);
for (row, i) in (first..first + visible).enumerate() {
let s = &SCALES[i];
let y = rect.y + row as i32;
if y >= rect.bottom() {
break;
}
let current = i == here;
let (fg, bg) = if current {
(t.bg, t.accent)
} else {
(t.text_muted, t.surface)
};
let cost = bigtext::measure(BANNER, *s).unwrap_or(Size::new(0, 0));
let line = format!(
" {}{}x{}{} {:>2} x {} cells",
if current { "▶ " } else { " " },
s.cols,
s.rows,
if s.within_aspect_band() { " " } else { "!" },
cost.w,
cost.h
);
canvas.fill(Rect::new(rect.x, y, rect.w, 1), ' ', fg, bg);
canvas.print(Point::new(rect.x, y), &line, fg, bg);
}
})
.build(),
)
.element(&t)
.build()
}
fn blit(canvas: &mut dyn Canvas, origin: Point, text: &str, style: &BigTextStyle) {
let t = current_theme().tokens;
let grid = match bigtext::render_with(text, style, t.text, t.surface) {
Ok(g) => g,
Err(e) => {
canvas.print(origin, &format!("bigtext refused: {e}"), t.error, t.surface);
return;
}
};
for (pos, chr, fg, bg) in grid.cell_patches(origin) {
let bg = if bg.is_transparent() { t.surface } else { bg };
canvas.put(pos, chr, fg, bg);
}
}