use badterm_core::screen::{Cell, Color, Look, Tiles, flags};
use crate::crt::{Crt, CrtMode, Curvature, Glow, Scanlines};
use crate::selection::Selection;
#[derive(Clone, Copy)]
pub struct Font {
pub height: usize,
data: &'static [u8],
pub id: u8,
}
pub const CODEPAGE_FONTS: [(u8, &str, &str); 31] = [
(1, "CP1251 swiss", "Cyrillic"),
(2, "KOI8-R", "Russian"),
(3, "ISO-8859-2", "Central European"),
(4, "ISO-8859-4 wide9", "Baltic"),
(5, "CP866 (c)", "Russian"),
(6, "ISO-8859-9", "Turkish"),
(7, "haik8", "Armenian"),
(8, "ISO-8859-8", "Hebrew"),
(9, "KOI8-U", "Ukrainian"),
(10, "ISO-8859-15 thin", "West European"),
(11, "ISO-8859-4 9bit", "Baltic"),
(12, "KOI8-R (b)", "Russian"),
(13, "ISO-8859-4 wide", "Baltic"),
(14, "ISO-8859-5", "Cyrillic"),
(15, "ARMSCII-8", "Armenian"),
(16, "ISO-8859-15", "West European"),
(17, "CP850 thin", "Latin I"),
(18, "CP850", "Latin I"),
(19, "CP865 thin", "Nordic"),
(20, "CP1251", "Cyrillic"),
(21, "ISO-8859-7", "Greek"),
(22, "KOI8-R (c)", "Russian"),
(23, "ISO-8859-4", "Baltic"),
(24, "ISO-8859-1", "West European"),
(25, "CP866", "Russian"),
(26, "CP437 thin", "English"),
(27, "CP866 (b)", "Russian"),
(28, "CP865", "Nordic"),
(29, "CP866U", "Ukrainian"),
(30, "ISO-8859-1 thin", "West European"),
(31, "CP1131 swiss", "Belarusian"),
];
pub const EXTRA_FONTS: [(u8, &str); 6] = [
(42, "Topaz"),
(40, "Topaz Plus"),
(41, "MicroKnight"),
(39, "MicroKnight Plus"),
(37, "P0T-NOoDLE"),
(38, "mO'sOul"),
];
impl Font {
pub fn vga_8x16() -> Font {
Font { height: 16, data: include_bytes!("../assets/fonts/vga_8x16.bin"), id: 0 }
}
pub fn vga_8x14() -> Font {
Font { height: 14, data: include_bytes!("../assets/fonts/vga_8x14.bin"), id: 0 }
}
pub fn vga_8x8() -> Font {
Font { height: 8, data: include_bytes!("../assets/fonts/vga_8x8.bin"), id: 0 }
}
pub fn cterm(id: u8, base: Font) -> Font {
let eight: Option<&'static [u8]> = match id {
32 | 34 => Some(include_bytes!("../assets/fonts/c64_upper.bin")),
33 | 35 => Some(include_bytes!("../assets/fonts/c64_lower.bin")),
36 => Some(include_bytes!("../assets/fonts/atari.bin")),
_ => None,
};
if let Some(data) = eight {
return Font { height: 8, data, id };
}
if id == badterm_core::viewdata::FONT {
let f = Font::viewdata(base);
return f.scaled_to(base.height);
}
if (1..=31).contains(&id) {
static TALL: &[u8] = include_bytes!("../assets/fonts/codepages_8x16.bin");
static MIDDLE: &[u8] = include_bytes!("../assets/fonts/codepages_8x14.bin");
static SHORT: &[u8] = include_bytes!("../assets/fonts/codepages_8x8.bin");
let i = id as usize - 1;
return match base.height {
8 => Font { height: 8, data: &SHORT[i * 2048..][..2048], id },
14 => Font { height: 14, data: &MIDDLE[i * 3584..][..3584], id },
_ => Font { height: 16, data: &TALL[i * 4096..][..4096], id },
};
}
if (37..=42).contains(&id) && base.height != 16 {
static SHORT: &[u8] = include_bytes!("../assets/fonts/amiga/amiga_8x8.bin");
static MIDDLE: &[u8] = include_bytes!("../assets/fonts/amiga/amiga_8x14.bin");
let i = id as usize - 37;
return match base.height {
8 => Font { height: 8, data: &SHORT[i * 2048..][..2048], id },
14 => Font { height: 14, data: &MIDDLE[i * 3584..][..3584], id },
_ => base,
};
}
let data: &'static [u8] = match (id, base.height) {
(42, 16) => include_bytes!("../assets/fonts/amiga/topaz_a1200.bin"),
(40, 16) => include_bytes!("../assets/fonts/amiga/topazplus_a1200.bin"),
(41, 16) => include_bytes!("../assets/fonts/amiga/microknight.bin"),
(39, 16) => include_bytes!("../assets/fonts/amiga/microknightplus.bin"),
(37, 16) => include_bytes!("../assets/fonts/amiga/p0t-noodle.bin"),
(38, 16) => include_bytes!("../assets/fonts/amiga/mosoul.bin"),
_ => return base,
};
Font { height: 16, data, id }
}
pub fn loaded(f: badterm_core::screen::LoadedFont, base: Font) -> Font {
Font { height: f.height as usize, data: f.data, id: f.slot }.scaled_to(base.height)
}
fn scaled_to(self, to: usize) -> Font {
let (from, f) = (self.height, self);
if from == to {
return f;
}
use std::collections::HashMap;
use std::sync::Mutex;
type Scaled = HashMap<(usize, usize), &'static [u8]>;
static SCALED: Mutex<Option<Scaled>> = Mutex::new(None);
let mut cache = SCALED.lock().unwrap_or_else(|e| e.into_inner());
let data = *cache.get_or_insert_with(HashMap::new).entry((f.data.as_ptr() as usize, to)).or_insert_with(|| {
let scaled: Vec<u8> =
(0..256).flat_map(|c| (0..to).map(move |y| f.data[c * from + y * from / to])).collect();
Box::leak(scaled.into_boxed_slice())
});
Font { height: to, data, id: f.id }
}
fn viewdata(base: Font) -> Font {
static TALL: std::sync::LazyLock<Vec<u8>> =
std::sync::LazyLock::new(|| viewdata_font(include_bytes!("../assets/fonts/vga_8x16.bin"), 16));
static SHORT: std::sync::LazyLock<Vec<u8>> =
std::sync::LazyLock::new(|| viewdata_font(include_bytes!("../assets/fonts/vga_8x8.bin"), 8));
let data: &'static [u8] = if base.height == 8 { &SHORT } else { &TALL };
Font { height: base.height, data, id: badterm_core::viewdata::FONT }
}
pub fn eight_only(id: u8) -> bool {
matches!(id, 32..=36)
}
fn row(&self, ch: u8, y: usize) -> u8 {
self.data[ch as usize * self.height + y]
}
}
fn viewdata_font(vga: &[u8], h: usize) -> Vec<u8> {
use badterm_core::viewdata as v;
let mut f = vga[..256 * h].to_vec();
let copy = |f: &mut Vec<u8>, to: u8, from: u8| {
let src = vga[from as usize * h..][..h].to_vec();
f[to as usize * h..][..h].copy_from_slice(&src);
};
copy(&mut f, v::GLYPH_POUND, 0x9C);
copy(&mut f, v::GLYPH_HALF, 0xAB);
copy(&mut f, v::GLYPH_QUARTER, 0xAC);
copy(&mut f, v::GLYPH_BARS, 0xBA);
copy(&mut f, v::GLYPH_DASH, 0xC4);
copy(&mut f, v::GLYPH_BLOCK, 0xDB);
copy(&mut f, v::GLYPH_DIVIDE, 0xF6);
copy(&mut f, v::GLYPH_THREE_QUARTERS, 0xAC);
if h == 16 {
let g = &mut f[v::GLYPH_THREE_QUARTERS as usize * h..][..h];
for (row, three) in [0xE0u8, 0x10, 0x60, 0x10, 0xE0].iter().enumerate() {
g[1 + row] = (g[1 + row] & 0x0F) | three;
}
}
let b1 = (h * 3 + 5) / 10;
let bands = [(0, b1), (b1, h - b1), (h - b1, h)];
for pattern in 0..64u8 {
for (separated, base) in [(false, 0x80usize), (true, 0xC0)] {
let g = &mut f[(base + pattern as usize) * h..][..h];
for (band, &(top, bottom)) in bands.iter().enumerate() {
let (left, right) = (pattern >> (band * 2) & 1 != 0, pattern >> (band * 2 + 1) & 1 != 0);
let (lbits, rbits, last) = if separated { (0xE0u8, 0x0E, bottom - 1) } else { (0xF0, 0x0F, bottom) };
for row in &mut g[top..bottom] {
*row = 0;
}
for row in &mut g[top..last] {
*row = if left { lbits } else { 0 } | if right { rbits } else { 0 };
}
}
}
}
f
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Scaling {
Integer,
Fill,
Stretch,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Display {
pub scaling: Scaling,
pub pixel_aspect: f64,
pub crt: CrtMode,
pub glow: Glow,
pub scanlines: Scanlines,
pub curvature: Curvature,
}
impl Default for Display {
fn default() -> Display {
Display {
scaling: Scaling::Integer,
pixel_aspect: 1.0,
crt: CrtMode::Off,
glow: Glow::Light,
scanlines: Scanlines::Medium,
curvature: Curvature::Off,
}
}
}
pub struct Frame<'a> {
pub rows: Vec<&'a [Cell]>,
pub cursor: Option<(usize, usize)>,
pub ice: bool,
pub selection: Option<Selection>,
pub look: &'a Look,
pub look_rows: usize,
pub ui_look: &'a Look,
pub graphics: Option<Graphics<'a>>,
pub tiles: Option<&'a Tiles>,
}
#[derive(Clone, Copy)]
pub struct Graphics<'a> {
pub pixels: &'a [u8],
pub width: usize,
pub height: usize,
pub palette: [u32; 16],
}
impl<'a> Frame<'a> {
pub fn plain(rows: Vec<&'a [Cell]>, cursor: Option<(usize, usize)>, ice: bool) -> Frame<'a> {
Frame {
rows,
cursor,
ice,
selection: None,
look: Look::standard(),
look_rows: 0,
ui_look: Look::standard(),
graphics: None,
tiles: None,
}
}
}
pub struct Renderer {
font: Font,
cell_w: usize,
cols: usize,
rows: usize,
native: Vec<u32>,
crt: Crt,
}
impl Renderer {
pub fn new(font: Font, cols: usize, rows: usize) -> Renderer {
let mut r = Renderer { font, cell_w: 8, cols: 0, rows: 0, native: Vec::new(), crt: Crt::default() };
r.set_layout(font, 8, cols, rows);
r
}
pub fn set_layout(&mut self, font: Font, cell_w: usize, cols: usize, rows: usize) {
self.font = font;
self.cell_w = cell_w;
self.cols = cols;
self.rows = rows;
let len = self.native_width() * self.native_height();
if self.native.len() != len {
self.native = vec![0; len];
}
}
pub fn cols(&self) -> usize {
self.cols
}
pub fn rows(&self) -> usize {
self.rows
}
pub fn native_width(&self) -> usize {
self.cols * self.cell_w
}
pub fn native_height(&self) -> usize {
self.rows * self.font.height
}
pub fn draw(&mut self, frame: &Frame, blink_on: bool, cursor_on: bool) {
let nw = self.native_width();
let (fh, cw) = (self.font.height, self.cell_w);
let blank = Cell::default();
for y in 0..self.rows {
let row = frame.rows.get(y).copied().unwrap_or(&[]);
for x in 0..self.cols {
let cell = row.get(x).unwrap_or(&blank);
let ui = y >= frame.look_rows || cell.is_ui();
let look = if ui { frame.ui_look } else { frame.look };
let (mut fg, mut bg, blinks) = cell.resolve_with(frame.ice, look);
let font = match look.font_for(cell) {
0 => self.font,
id => match look.loaded_font(id) {
Some(f) => Font::loaded(f, self.font),
None => Font::cterm(id, self.font),
},
};
if frame.selection.is_some_and(|s| s.contains(x, y)) {
std::mem::swap(&mut fg, &mut bg);
}
let hide = blinks && !blink_on;
let cursor_here = cursor_on && frame.cursor == Some((x, y));
let extend = font.id == 0 && (0xC0..=0xDF).contains(&cell.ch)
|| font.id == badterm_core::viewdata::FONT && (0x80..=0xBF).contains(&cell.ch);
let glyph_row = |gy: usize| match cell.flags & (flags::DOUBLE_TOP | flags::DOUBLE_BOTTOM) {
flags::DOUBLE_TOP => gy / 2,
flags::DOUBLE_BOTTOM => (fh + gy) / 2,
_ => gy,
};
let graphics = frame.graphics.filter(|_| {
!ui && cell.bg == Color::Pc(0) && cell.flags & flags::REVERSE == 0 && bg == look.rgb(Color::Pc(0))
});
let tile = frame.tiles.filter(|_| cell.tile != 0 && !ui).and_then(|t| t.get(cell.tile));
for gy in 0..fh {
let mut bits = if hide { 0 } else { font.row(cell.ch, glyph_row(gy)) as u16 };
if cell.flags & flags::UNDERLINE != 0 && gy == fh - 1 && !hide {
bits = 0x1FF;
}
if cursor_here && gy >= fh - 2 {
bits = 0x1FF;
}
let base = (y * fh + gy) * nw + x * cw;
for gx in 0..8 {
self.native[base + gx] = if bits & (0x80 >> gx) != 0 { fg } else { bg };
}
if cw == 9 {
let on = bits & 0x100 != 0 || extend && bits & 1 != 0;
self.native[base + 8] = if on { fg } else { bg };
}
if let Some(g) = graphics {
let (text_w, text_h) = (self.cols * cw, frame.look_rows * fh);
let gyy = ((y * fh + gy) * g.height / text_h).min(g.height - 1);
for gx in 0..cw {
let on = gx < 8 && bits & (0x80 >> gx) != 0 || gx == 8 && self.native[base + 8] == fg;
if !on {
let gxx = ((x * cw + gx) * g.width / text_w).min(g.width - 1);
self.native[base + gx] = g.palette[g.pixels[gyy * g.width + gxx] as usize & 15];
}
}
}
if let Some(t) = tile.filter(|_| !(cursor_here && gy >= fh - 2)) {
let row = &t.pixels[(gy * t.height / fh).min(t.height - 1) * t.width..][..t.width];
for gx in 0..cw {
let p = row[(gx * t.width / cw).min(t.width - 1)];
if p != 0 {
self.native[base + gx] = p & 0x00FF_FFFF;
}
}
}
}
}
}
}
pub fn flash(&mut self, rows: usize) {
let n = (rows * self.font.height * self.native_width()).min(self.native.len());
for p in &mut self.native[..n] {
*p ^= 0x00FF_FFFF;
}
}
pub fn graphics_point(
&self,
x: usize,
y: usize,
rows: usize,
width: usize,
height: usize,
) -> Option<(usize, usize)> {
let (text_w, text_h) = (self.native_width(), rows * self.font.height);
(x < text_w && y < text_h).then(|| (x * width / text_w, y * height / text_h))
}
pub fn pixels(&self) -> &[u32] {
&self.native
}
pub fn blit(
&mut self,
out: &mut [u32],
width: usize,
height: usize,
display: &Display,
) -> (usize, usize, usize, usize) {
self.blit_in(out, width, height, (0, 0, width, height), display)
}
pub fn blit_in(
&mut self,
out: &mut [u32],
width: usize,
height: usize,
area: (usize, usize, usize, usize),
display: &Display,
) -> (usize, usize, usize, usize) {
let (ax, ay) = (area.0.min(width - 1), area.1.min(height - 1));
let (aw, ah) = (area.2.clamp(1, width - ax), area.3.clamp(1, height - ay));
let (nw, nh) = (self.native_width(), self.native_height());
let pa = display.pixel_aspect.max(0.1);
let fit = (aw as f64 / nw as f64).min(ah as f64 / (nh as f64 * pa));
let scale = match display.scaling {
Scaling::Integer if fit >= 1.0 => fit.floor(),
_ => fit,
};
let (dw, dh) = match display.scaling {
Scaling::Stretch => (aw, ah),
_ => (
((nw as f64 * scale).round() as usize).clamp(1, aw),
((nh as f64 * scale * pa).round() as usize).clamp(1, ah),
),
};
let ox = ax + (aw - dw) / 2;
let oy = ay + (ah - dh) / 2;
out[..oy * width].fill(0);
out[(oy + dh) * width..].fill(0);
for y in oy..oy + dh {
let row = &mut out[y * width..(y + 1) * width];
row[..ox].fill(0);
row[ox + dw..].fill(0);
}
if display.crt == CrtMode::Off {
scale_into(&self.native, nw, nh, out, width, (ox, oy, dw, dh));
} else {
self.crt.present(
display.crt,
display.glow,
display.scanlines,
display.curvature,
&self.native,
nw,
nh,
out,
width,
(ox, oy, dw, dh),
);
}
(ox, oy, dw, dh)
}
}
pub fn scale_into(
src: &[u32],
nw: usize,
nh: usize,
out: &mut [u32],
stride: usize,
rect: (usize, usize, usize, usize),
) {
let (ox, oy, dw, dh) = rect;
if dw == 0 || dh == 0 || nw == 0 || nh == 0 {
return;
}
let region = &mut out[oy * stride..(oy + dh - 1) * stride + ox + dw];
if dw >= nw && dh >= nh {
let xs = taps(nw, dw);
let ys = taps(nh, dh);
par_rows(region, stride, dh, dw, |first, band, rows| {
let mut prev: Option<(usize, usize)> = None;
for r in 0..rows {
let (sy, wy) = ys[first + r];
let at = r * stride + ox;
if wy == 256
&& let Some((psy, pat)) = prev
&& psy == sy
{
band.copy_within(pat..pat + dw, at);
continue;
}
let row0 = &src[sy * nw..][..nw];
let row1 = &src[(sy + 1).min(nh - 1) * nw..][..nw];
let dst = &mut band[at..at + dw];
for (d, &(sx, wx)) in dst.iter_mut().zip(&xs) {
let x1 = (sx + 1).min(nw - 1);
*d = if wy == 256 {
if wx == 256 { row0[sx] } else { mix(row0[sx], row0[x1], wx) }
} else {
let top = if wx == 256 { row0[sx] } else { mix(row0[sx], row0[x1], wx) };
let bottom = if wx == 256 { row1[sx] } else { mix(row1[sx], row1[x1], wx) };
mix(top, bottom, wy)
};
}
prev = (wy == 256).then_some((sy, at));
}
});
return;
}
let xmap: Vec<usize> = (0..dw).map(|dx| dx * nw / dw).collect();
par_rows(region, stride, dh, dw, |first, band, rows| {
for r in 0..rows {
let row = &src[((first + r) * nh / dh) * nw..][..nw];
let dst = &mut band[r * stride + ox..][..dw];
for (d, &sx) in dst.iter_mut().zip(&xmap) {
*d = row[sx];
}
}
});
}
pub fn par_rows(
region: &mut [u32],
stride: usize,
rows: usize,
width: usize,
f: impl Fn(usize, &mut [u32], usize) + Sync,
) {
static CORES: std::sync::LazyLock<usize> =
std::sync::LazyLock::new(|| std::thread::available_parallelism().map_or(1, |n| n.get()).min(8));
let bands = if rows * width < 250_000 { 1 } else { (*CORES).min(rows) };
if bands <= 1 {
return f(0, region, rows);
}
let per = rows.div_ceil(bands);
std::thread::scope(|scope| {
let f = &f;
let mut chunks = region.chunks_mut(per * stride).enumerate();
let first = chunks.next();
for (i, band) in chunks {
let n = per.min(rows - i * per);
scope.spawn(move || f(i * per, band, n));
}
if let Some((_, band)) = first {
f(0, band, per.min(rows));
}
});
}
fn taps(src: usize, dst: usize) -> Vec<(usize, u32)> {
(0..dst)
.map(|d| {
let (start, end) = (d * src, (d + 1) * src);
let first = start / dst;
let boundary = (first + 1) * dst;
if end <= boundary { (first, 256) } else { (first, ((boundary - start) * 256 / src) as u32) }
})
.collect()
}
pub fn mix(a: u32, b: u32, w: u32) -> u32 {
let ch = |shift: u32| {
let (ca, cb) = ((a >> shift) & 0xFF, (b >> shift) & 0xFF);
((ca * w + cb * (256 - w)) / 256) << shift
};
ch(16) | ch(8) | ch(0)
}
#[cfg(test)]
mod tests {
use super::*;
use badterm_core::{Directory, Entry, Terminal};
use crate::ui::dialer::DialerUi;
fn write_ppm(r: &mut Renderer, path: &std::path::Path) {
let (w, h) = (r.native_width() * 2, r.native_height() * 2);
let mut out = vec![0u32; w * h];
r.blit(&mut out, w, h, &Display::default());
let mut data = format!("P6 {w} {h} 255\n").into_bytes();
for px in out {
data.extend_from_slice(&[(px >> 16) as u8, (px >> 8) as u8, px as u8]);
}
std::fs::write(path, data).unwrap();
}
#[test]
#[ignore]
fn theme_snapshot() {
use crate::ui::theme;
let dir = std::path::PathBuf::from(std::env::var("BADTERM_SNAPSHOT_DIR").expect("set BADTERM_SNAPSHOT_DIR"));
std::fs::create_dir_all(&dir).unwrap();
for name in ["BadTerm", "Bad Cluster", "Dark blue", "Dark cyan", "Dark Dracula"] {
let bc = theme::SCHEMES.iter().find(|(n, _)| *n == name).unwrap().1;
let prefix = name.to_lowercase().replace(' ', "");
theme::set_current(bc);
let look = theme::ui_look();
let mut d = Directory::default();
d.merge(vec![
Entry {
name: "Example BBS".into(),
comment: "Runs Synchronet".into(),
group: "Favorites".into(),
..Entry::from_address("bbs.example.org").unwrap()
},
Entry::from_address("ssh://secure.example.net:2222").unwrap(),
Entry::from_address("telnet://another.example.com:2323").unwrap(),
]);
d.offer_defaults();
d.sort();
let mut r = Renderer::new(Font::vga_8x16(), 80, 25);
let shot = |r: &mut Renderer, s: &badterm_core::Screen, cursor, name: &str| {
let rows: Vec<&[Cell]> = (0..25).map(|y| s.row(y)).collect();
r.draw(&Frame { ui_look: &look, ..Frame::plain(rows, cursor, true) }, true, true);
let png = crate::png::encode(r.pixels(), r.native_width(), r.native_height());
std::fs::write(dir.join(name), png).unwrap();
};
let mut ui = DialerUi::new(dir.clone(), std::rc::Rc::new(crate::secrets::tests::Memory::default()));
let mut s = badterm_core::Screen::new(80, 25, 0);
ui.draw(&mut s, &d);
shot(&mut r, &s, None, &format!("{prefix}-dialer.png"));
ui.handle_key(crate::input::Key::Char('e'), crate::input::Mods::default(), &mut d);
let cursor = ui.draw(&mut s, &d);
shot(&mut r, &s, cursor, &format!("{prefix}-edit.png"));
ui.handle_key(crate::input::Key::Up, crate::input::Mods::default(), &mut d);
let cursor = ui.draw(&mut s, &d);
shot(&mut r, &s, cursor, &format!("{prefix}-edit-end.png"));
ui.handle_key(crate::input::Key::Escape, crate::input::Mods::default(), &mut d);
ui.handle_key(crate::input::Key::Char('g'), crate::input::Mods::default(), &mut d);
ui.draw(&mut s, &d);
shot(&mut r, &s, None, &format!("{prefix}-group.png"));
ui.handle_key(crate::input::Key::Char('g'), crate::input::Mods::default(), &mut d);
let mut settings = crate::settings::Settings { colors: bc, ..Default::default() };
settings.colors = bc;
let mut sui = crate::ui::settings::SettingsUi::default();
sui.open(Vec::new(), Vec::new(), vec![std::path::PathBuf::from("/home/sysop/Downloads"); 5]);
let cursor = sui.draw(&mut s, &settings, None, None);
shot(&mut r, &s, cursor, &format!("{prefix}-settings.png"));
crate::ui::splash::draw(&mut s);
shot(&mut r, &s, None, &format!("{prefix}-splash.png"));
settings.hotkeys = [("D".to_string(), "Y".to_string())].into();
crate::hotkeys::set(&settings.hotkeys);
for k in [crate::input::Key::Tab, crate::input::Key::End, crate::input::Key::Enter] {
sui.handle_key(k, crate::input::Mods::default(), &mut settings);
}
let cursor = sui.draw(&mut s, &settings, None, None);
shot(&mut r, &s, cursor, &format!("{prefix}-hotkeys.png"));
sui.handle_key(crate::input::Key::Escape, crate::input::Mods::default(), &mut settings);
for _ in 0..3 {
sui.handle_key(crate::input::Key::Up, crate::input::Mods::default(), &mut settings);
}
let cursor = sui.draw(&mut s, &settings, None, None);
shot(&mut r, &s, cursor, &format!("{prefix}-general.png"));
crate::hotkeys::set(&Default::default());
}
theme::set_current(theme::Theme::default());
}
#[test]
#[ignore]
fn confirm_snapshot() {
let dir = std::path::PathBuf::from(std::env::var("BADTERM_SNAPSHOT_DIR").expect("set BADTERM_SNAPSHOT_DIR"));
std::fs::create_dir_all(&dir).unwrap();
let mut d = Directory::default();
d.offer_defaults();
let mut r = Renderer::new(Font::vga_8x16(), 80, 25);
let shot = |r: &mut Renderer, s: &badterm_core::Screen, name: &str| {
let rows: Vec<&[Cell]> = (0..25).map(|y| s.row(y)).collect();
r.draw(&Frame::plain(rows, None, true), true, true);
std::fs::write(dir.join(name), crate::png::encode(r.pixels(), r.native_width(), r.native_height()))
.unwrap();
};
let mut ui = DialerUi::new(dir.clone(), std::rc::Rc::new(crate::secrets::tests::Memory::default()));
let mut s = badterm_core::Screen::new(80, 25, 0);
ui.draw(&mut s, &d);
crate::ui::confirm_box(
&mut s,
"Exit BadTerm?",
"This hangs up the call to The Vortex BBS.",
&[("Y", "Exit"), ("N", "Stay")],
);
shot(&mut r, &s, "confirm-exit-call.png");
ui.draw(&mut s, &d);
crate::ui::confirm_box(&mut s, "Exit BadTerm?", "", &[("Y", "Exit"), ("N", "Stay")]);
shot(&mut r, &s, "confirm-exit.png");
ui.handle_key(crate::input::Key::Delete, crate::input::Mods::default(), &mut d);
ui.draw(&mut s, &d);
shot(&mut r, &s, "confirm-delete.png");
}
#[test]
#[ignore]
fn make_extra_previews() {
use crate::ui::theme;
let extras = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../extras");
let out_dir = extras.join("previews");
std::fs::create_dir_all(&out_dir).unwrap();
let png = |pixels: &[u32], w: usize, h: usize, path: std::path::PathBuf| {
let rgb: Vec<u8> = pixels.iter().flat_map(|&p| [(p >> 16) as u8, (p >> 8) as u8, p as u8]).collect();
let mut out = Vec::new();
{
let mut enc = png::Encoder::new(&mut out, w as u32, h as u32);
enc.set_color(png::ColorType::Rgb);
enc.set_compression(png::Compression::High);
enc.write_header().unwrap().write_image_data(&rgb).unwrap();
}
std::fs::write(path, out).unwrap();
};
let mut d = Directory::default();
d.merge(vec![
Entry {
name: "Example BBS".into(),
comment: "Runs Synchronet".into(),
..Entry::from_address("bbs.example.org").unwrap()
},
Entry::from_address("ssh://secure.example.net:2222").unwrap(),
Entry::from_address("telnet://another.example.com:2323").unwrap(),
]);
d.offer_defaults();
d.sort();
let mut r = Renderer::new(Font::vga_8x16(), 80, 25);
let mut s = badterm_core::Screen::new(80, 25, 0);
let mut themes: Vec<_> = std::fs::read_dir(extras.join("themes")).unwrap().map(|e| e.unwrap().path()).collect();
themes.sort();
for path in themes.iter().filter(|p| p.extension().is_some_and(|x| x == "toml")) {
theme::set_current(theme::ThemeFile::load(path).unwrap().colors);
let look = theme::ui_look();
let mut ui =
DialerUi::new(std::env::temp_dir(), std::rc::Rc::new(crate::secrets::tests::Memory::default()));
ui.draw(&mut s, &d);
let rows: Vec<&[Cell]> = (0..25).map(|y| s.row(y)).collect();
r.draw(&Frame { ui_look: &look, ..Frame::plain(rows, None, true) }, true, true);
let stem = path.file_stem().unwrap().to_string_lossy();
png(r.pixels(), r.native_width(), r.native_height(), out_dir.join(format!("theme-{stem}.png")));
}
theme::set_current(theme::Theme::default());
crate::ui::splash::draw(&mut s);
let rows: Vec<&[Cell]> = (0..25).map(|y| s.row(y)).collect();
r.draw(&Frame::plain(rows, None, true), true, true);
let mut bezels: Vec<_> = std::fs::read_dir(extras.join("bezels")).unwrap().map(|e| e.unwrap().path()).collect();
bezels.sort();
for path in bezels.iter().filter(|p| p.extension().is_some_and(|x| x == "png")) {
let bezel = crate::bezel::Bezel::load(path).unwrap();
let (w, h) = (640, 560);
let frame = bezel.render(w, h);
let mut out = vec![0u32; w * h];
let display = Display { scaling: Scaling::Fill, crt: CrtMode::Color, ..Display::default() };
r.blit_in(&mut out, w, h, frame.screen, &display);
crate::bezel::composite(&mut out, &frame.pixels);
let stem = path.file_stem().unwrap().to_string_lossy().to_lowercase().replace(' ', "-");
png(&out, w, h, out_dir.join(format!("bezel-{stem}.png")));
}
}
#[test]
fn code_page_fonts() {
let vga = Font::vga_8x16();
let koi = Font::cterm(2, vga);
assert_eq!((koi.height, koi.id), (16, 2));
assert!((0..16).any(|y| koi.row(0xC1, y) != vga.row(0xC1, y)));
assert!((0..16).any(|y| koi.row(b'A', y) != 0));
let small = Font::cterm(1, Font::vga_8x8());
assert_eq!(small.height, 8);
assert!((0..8).any(|y| small.row(b'A', y) != 0));
}
#[test]
fn loaded_fonts_are_drawn_and_stretched() {
let mut t = Terminal::new(80, 25, 0);
let mut old = b"\x1b[=43;1{".to_vec();
old.extend(vec![0x80u8; 3584]);
old.extend(b"\x1b[0;43 DA");
t.feed(&old);
let lines: Vec<&[Cell]> = (0..25).map(|y| t.screen.row(y)).collect();
let mut r = Renderer::new(Font::vga_8x16(), 80, 25);
r.draw(&Frame { look: &t.screen.look, look_rows: 25, ..Frame::plain(lines, None, true) }, true, true);
let (px, nw) = (r.pixels(), r.native_width());
let fg = t.screen.look.rgb(badterm_core::screen::DEFAULT_FG);
assert!((0..16).all(|y| px[y * nw] == fg));
assert!((0..16).all(|y| px[y * nw + 1] != fg));
}
#[test]
fn dialogs_over_the_bbs_are_in_the_schemes_colors() {
use crate::ui::theme;
let dracula = theme::SCHEMES.iter().find(|(n, _)| *n == "Dark Dracula").unwrap().1;
theme::set_current(dracula);
let ui_look = theme::ui_look();
let mut t = Terminal::new(80, 25, 0);
t.feed(b"\x1b[44;31mBBS text in blue and red");
let mut s = badterm_core::Screen::new(80, 25, 0);
for y in 0..25 {
for (x, c) in t.screen.row(y).iter().enumerate() {
*s.cell_mut(x, y).unwrap() = *c;
}
}
crate::ui::confirm_box(&mut s, "Exit BadTerm?", "", &[("Y", "Exit"), ("N", "Stay")]);
let rows: Vec<&[Cell]> = (0..25).map(|y| s.row(y)).collect();
let mut r = Renderer::new(Font::vga_8x16(), 80, 25);
r.draw(
&Frame { look: &t.screen.look, look_rows: 25, ui_look: &ui_look, ..Frame::plain(rows, None, true) },
true,
true,
);
let px = r.pixels();
let nw = r.native_width();
let mid = px[(12 * 16 + 8) * nw + 40 * 8 + 4];
assert_eq!(mid, 0x0A0A0A, "{mid:06x}");
assert_eq!(px[2], t.screen.look.rgb(Color::Pc(1)));
theme::set_current(theme::Theme::default());
if let Ok(dir) = std::env::var("BADTERM_SNAPSHOT_DIR") {
std::fs::write(
std::path::Path::new(&dir).join("exit-dracula.png"),
crate::png::encode(px, nw, r.native_height()),
)
.unwrap();
}
}
#[test]
fn fonts_at_every_height() {
assert_eq!(Font::vga_8x14().data.len(), 256 * 14);
for base in [Font::vga_8x8(), Font::vga_8x14(), Font::vga_8x16()] {
for id in [2, 25, 37, 42] {
let f = Font::cterm(id, base);
assert_eq!((f.height, f.data.len(), f.id), (base.height, 256 * base.height, id), "{id}");
assert!((0..f.height).any(|y| f.row(b'A', y) != 0), "{id} at {}", base.height);
}
assert_eq!(Font::cterm(34, base).height, 8);
assert_eq!(Font::cterm(badterm_core::viewdata::FONT, base).height, base.height);
}
}
#[test]
#[ignore]
fn display_bench() {
use std::time::{Duration, Instant};
let data = std::fs::read(std::env::var("BADTERM_REC").expect("set BADTERM_REC")).unwrap();
let mut chunks = Vec::new();
let mut at = 0;
while at + 8 <= data.len() {
let ms = u32::from_le_bytes(data[at..at + 4].try_into().unwrap());
let n = u32::from_le_bytes(data[at + 4..at + 8].try_into().unwrap()) as usize;
chunks.push((ms, &data[at + 8..at + 8 + n]));
at += 8 + n;
}
let total: usize = chunks.iter().map(|c| c.1.len()).sum();
let mut t = Terminal::new(80, 25, 10_000);
let start = Instant::now();
for (_, c) in &chunks {
t.feed(c);
}
let parse = start.elapsed();
println!("parse: {total} bytes in {parse:?} ({:.1} MB/s)", total as f64 / parse.as_secs_f64() / 1e6);
use crate::crt::{CrtMode, Glow};
for (label, crt, glow) in [
("crt off", CrtMode::Off, Glow::Light),
("crt color, no glow", CrtMode::Color, Glow::Off),
("crt color", CrtMode::Color, Glow::Light),
] {
let mut t = Terminal::new(80, 25, 10_000);
let mut r = Renderer::new(Font::vga_8x16(), 80, 26);
let (w, h) = (1920, 1080);
let mut out = vec![0u32; w * h];
let display = Display { scaling: Scaling::Fill, crt, glow, ..Display::default() };
let (mut feed, mut draw, mut blit, mut frames) = (Duration::ZERO, Duration::ZERO, Duration::ZERO, 0);
let mut worst = Duration::ZERO;
let mut i = 0;
let mut frame_ms = 0u32;
while i < chunks.len() {
frame_ms += 16;
let t0 = Instant::now();
while i < chunks.len() && chunks[i].0 <= frame_ms {
t.feed(chunks[i].1);
i += 1;
}
let t1 = Instant::now();
let status = vec![Cell::default(); 80];
let mut rows: Vec<&[Cell]> = (0..25).map(|y| t.screen.row(y)).collect();
rows.push(&status);
r.draw(&Frame { look: &t.screen.look, look_rows: 25, ..Frame::plain(rows, None, true) }, true, true);
let t2 = Instant::now();
r.blit(&mut out, w, h, &display);
let t3 = Instant::now();
feed += t1 - t0;
draw += t2 - t1;
blit += t3 - t2;
if frames > 0 {
worst = worst.max(t3 - t0);
}
frames += 1;
}
let per = |d: Duration| d / frames;
println!(
"{label}: {frames} frames; per frame: feed {:?}, draw {:?}, blit {:?}; worst {worst:?}",
per(feed),
per(draw),
per(blit)
);
}
}
#[test]
#[ignore]
fn fonts_snapshot() {
let dir = std::path::PathBuf::from(std::env::var("BADTERM_SNAPSHOT_DIR").expect("set BADTERM_SNAPSHOT_DIR"));
std::fs::create_dir_all(&dir).unwrap();
for (name, rows, font, base) in [
("80x28-cp437", 28, 0, Font::vga_8x14()),
("80x28-koi8", 28, 2, Font::vga_8x14()),
("80x50-topaz", 50, 42, Font::vga_8x8()),
("80x28-topaz", 28, 42, Font::vga_8x14()),
] {
let mut t = Terminal::new(80, rows, 0);
t.feed(format!("\x1b[0;{font} D").as_bytes());
t.feed(b"\x1b[1;33mThe quick brown fox \xc9\xcd\xcd\xbb \xb0\xb1\xb2\xdb\r\n\x1b[0m");
t.feed(b"\x1b[36mABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz 0123456789\r\n");
for c in 128u8..=255 {
t.feed(&[c]);
}
let lines: Vec<&[Cell]> = (0..rows).map(|y| t.screen.row(y)).collect();
let mut r = Renderer::new(base, 80, rows);
r.draw(&Frame { look: &t.screen.look, look_rows: rows, ..Frame::plain(lines, None, true) }, true, true);
let png = crate::png::encode(r.pixels(), r.native_width(), r.native_height());
std::fs::write(dir.join(format!("{name}.png")), png).unwrap();
}
}
#[test]
fn sixel_pictures_are_drawn_over_cells() {
let mut t = Terminal::new(80, 25, 0);
t.feed(b"\x1bP0;1q\"1;1#1;2;100;0;0!8~\x1b\\\x1b[2;1HX");
let lines: Vec<&[Cell]> = (0..25).map(|y| t.screen.row(y)).collect();
for cw in [8, 9] {
let mut r = Renderer::new(Font::vga_8x16(), 80, 25);
r.set_layout(Font::vga_8x16(), cw, 80, 25);
let frame =
Frame { look_rows: 25, tiles: Some(&t.screen.tiles), ..Frame::plain(lines.clone(), None, true) };
r.draw(&frame, true, true);
let px = r.pixels();
let nw = r.native_width();
assert_eq!(px[0], 0xFF0000);
assert_eq!(px[5 * nw + cw - 1], 0xFF0000, "the whole cell's width");
assert_eq!(px[6 * nw], 0, "below the picture, the cell shows through");
}
}
#[test]
#[ignore]
fn sixel_snapshot() {
let dir = std::path::PathBuf::from(std::env::var("BADTERM_SNAPSHOT_DIR").expect("set BADTERM_SNAPSHOT_DIR"));
let data = std::fs::read(std::env::var("BADTERM_SIXEL").expect("set BADTERM_SIXEL")).unwrap();
let mut t = Terminal::new(80, 25, 0);
t.feed(&data);
let lines: Vec<&[Cell]> = (0..25).map(|y| t.screen.row(y)).collect();
let mut r = Renderer::new(Font::vga_8x16(), 80, 25);
let frame =
Frame { look_rows: 25, tiles: Some(&t.screen.tiles), ..Frame::plain(lines, Some(t.screen.cursor()), true) };
r.draw(&frame, true, true);
std::fs::write(dir.join("sixel.png"), crate::png::encode(r.pixels(), r.native_width(), r.native_height()))
.unwrap();
}
#[test]
#[ignore]
fn viewdata_snapshot() {
use badterm_core::{Encoding, viewdata};
let dir = std::path::PathBuf::from(std::env::var("BADTERM_SNAPSHOT_DIR").expect("set BADTERM_SNAPSHOT_DIR"));
let mut page: Vec<u8> = vec![0x0C];
page.extend(b"\x1bD\x1b]\x1bM\x1bCBADTERM VIEWDATA \x1bG#1.50\r\n\r\n");
for (i, c) in b"QRSTUVW".iter().enumerate() {
page.extend([0x1B, *c]);
page.extend(std::iter::repeat_n(0x7F, 10 + i));
page.extend([0x1B, b'Z']);
page.extend(std::iter::repeat_n(0x7F, 8));
page.extend(b"\r\n");
}
page.extend(b"\x1bW");
for b in [0x21u8, 0x23, 0x27, 0x2F, 0x3F, 0x7F, 0x7E, 0x7C, 0x78, 0x70, 0x60, 0x20] {
page.push(b);
}
page.extend(b"\r\n\x1bAText in red, \x1bBgreen, \x1bHflashing\x1bI, \x1bFcyan.\r\n");
page.extend(b"\x1bGThe UK set: # [ \\ ] ^ _ ` { | } ~ \x7f\r\n");
page.extend(b"\r\n\x1bCType * and # to choose.");
if let Ok(file) = std::env::var("BADTERM_VIEWDATA") {
page = std::fs::read(file).unwrap();
}
let mut t = Terminal::new(viewdata::COLS, viewdata::ROWS, 0);
t.encoding = Encoding::Viewdata;
t.start_emulation();
t.feed(&page);
let rows = viewdata::ROWS;
let lines: Vec<&[Cell]> = (0..rows).map(|y| t.screen.row(y)).collect();
let mut r = Renderer::new(Font::vga_8x16(), viewdata::COLS, rows);
r.draw(&Frame { look: &t.screen.look, look_rows: rows, ..Frame::plain(lines, None, true) }, true, true);
let (w, h) = (r.native_width() * 2, r.native_height() * 2);
let mut out = vec![0u32; w * h];
r.blit(&mut out, w, h, &Display::default());
std::fs::write(dir.join("viewdata.png"), crate::png::encode(&out, w, h)).unwrap();
}
#[test]
#[ignore]
fn igs_snapshot() {
use badterm_core::{Encoding, igs};
let dir = std::path::PathBuf::from(std::env::var("BADTERM_SNAPSHOT_DIR").expect("set BADTERM_SNAPSHOT_DIR"));
let file = std::path::PathBuf::from(std::env::var("BADTERM_IGS").expect("set BADTERM_IGS"));
let stop_at = std::env::var("BADTERM_IGS_PAUSES").ok().and_then(|v| v.parse::<usize>().ok());
let mut t = Terminal::new(igs::COLS, igs::ROWS, 0);
t.encoding = Encoding::Igs;
t.start_emulation();
t.feed(&std::fs::read(&file).unwrap());
let mut pauses = 0;
while let Some(at) = t.next_poll() {
pauses += 1;
if stop_at.is_some_and(|n| pauses >= n) {
break;
}
t.poll(at);
}
let g = t.igs().unwrap();
let rows = igs::ROWS;
let lines: Vec<&[Cell]> = (0..rows).map(|y| t.screen.row(y)).collect();
let graphics =
Some(Graphics { pixels: g.pixels(), width: g.width(), height: g.height(), palette: g.palette() });
let mut r = Renderer::new(Font::vga_8x16(), igs::COLS, rows);
r.draw(&Frame { look_rows: rows, graphics, ..Frame::plain(lines, None, true) }, true, false);
let name = format!("{}.png", file.file_stem().unwrap().to_string_lossy());
std::fs::write(dir.join(name), crate::png::encode(r.pixels(), r.native_width(), r.native_height())).unwrap();
}
#[test]
#[ignore]
fn skypix_snapshot() {
use badterm_core::{Encoding, skypix};
let dir = std::path::PathBuf::from(std::env::var("BADTERM_SNAPSHOT_DIR").expect("set BADTERM_SNAPSHOT_DIR"));
let file = std::path::PathBuf::from(std::env::var("BADTERM_SKYPIX").expect("set BADTERM_SKYPIX"));
let mut t = Terminal::new(skypix::COLS, skypix::ROWS, 0);
t.encoding = Encoding::Skypix;
t.start_emulation();
t.feed(&std::fs::read(&file).unwrap());
while let Some(at) = t.next_poll() {
t.poll(at);
}
let g = t.skypix().unwrap();
let rows = skypix::ROWS;
let lines: Vec<&[Cell]> = (0..rows).map(|y| t.screen.row(y)).collect();
let graphics =
Some(Graphics { pixels: g.pixels(), width: skypix::WIDTH, height: skypix::HEIGHT, palette: g.palette() });
let mut r = Renderer::new(Font::vga_8x16(), skypix::COLS, rows);
r.draw(&Frame { look_rows: rows, graphics, ..Frame::plain(lines, None, true) }, true, false);
let name = format!("{}.png", file.file_stem().unwrap().to_string_lossy());
std::fs::write(dir.join(name), crate::png::encode(r.pixels(), r.native_width(), r.native_height())).unwrap();
}
#[test]
#[ignore]
fn retro_site_snapshot() {
use badterm_core::Encoding;
let dir = std::path::PathBuf::from(std::env::var("BADTERM_SNAPSHOT_DIR").expect("set BADTERM_SNAPSHOT_DIR"));
std::fs::create_dir_all(&dir).unwrap();
let shot = |enc: Encoding, bytes: &[u8], name: &str| {
let (cols, rows) = enc.screen_size().unwrap();
let mut t = Terminal::new(cols, rows, 0);
t.encoding = enc;
t.start_emulation();
t.feed(bytes);
let lines: Vec<&[Cell]> = (0..rows).map(|y| t.screen.row(y)).collect();
let mut r = Renderer::new(Font::vga_8x8(), cols, rows);
r.draw(&Frame { look: &t.screen.look, look_rows: rows, ..Frame::plain(lines, None, true) }, true, true);
let (w, h) = (r.native_width() * 4, r.native_height() * 4);
let mut out = vec![0u32; w * h];
r.blit(&mut out, w, h, &Display::default());
std::fs::write(dir.join(name), crate::png::encode(&out, w, h)).unwrap();
};
let menu = [
("M", "MESSAGE BASES"),
("F", "FILE AREAS"),
("D", "ONLINE GAMES"),
("C", "CHAT WITH SYSOP"),
("G", "GOODBYE"),
];
let mut c = b"\x93\x9e\x12 THE BREADBIN BBS * NODE 1 \x92\r".to_vec();
c.push(0x12);
for color in [0x1c, 0x81, 0x9e, 0x1e, 0x9f, 0x1f, 0x9c, 0x96, 0x99] {
c.extend([color, b' ', b' ', b' ', b' ']);
}
c.extend(b"\x92\r\r\x9f");
c.push(0xb0);
c.extend([0xc0; 35]);
c.extend([0xae, b'\r']);
for (key, what) in menu {
c.extend([0x9f, 0xdd]);
c.extend(b" \x9e[");
c.extend(key.bytes());
c.extend(b"]\x05 ");
c.extend(format!("{what:<30}").into_bytes());
c.extend([0x9f, 0xdd, b'\r']);
}
c.push(0xad);
c.extend([0xc0; 35]);
c.extend([0xbd, b'\r', b'\r']);
c.extend(b"\x99 LAST CALLER: \x05RETRO RICK\r\x9a CALLS TODAY: \x05 42\r\r\x9b COMMAND \x9e> \x05");
shot(Encoding::Petscii, &c, "site-c64.png");
shot(Encoding::C128, &c, "c128.png");
let inverse = |text: &str| text.bytes().map(|b| b | 0x80).collect::<Vec<u8>>();
let mut a = vec![0x7d];
a.extend(inverse(" ATARI BBS EXPRESS! BOARD 1 "));
a.extend(b"\x9b\x9b\x11");
a.extend([0x12; 35]);
a.extend([0x05, 0x9b]);
for (key, what) in [("M", "MESSAGES"), ("F", "FILES AND UPLOADS"), ("B", "BULLETINS"), ("G", "GOODBYE")] {
a.push(b'|');
a.extend(b" ");
a.extend(inverse(key));
a.extend(format!(" {what:<32}|").into_bytes());
a.push(0x9b);
}
a.push(0x1a);
a.extend([0x12; 35]);
a.extend([0x03, 0x9b, 0x9b]);
a.extend(b"LAST CALLER: ANTIC FAN\x9bTIME LEFT: 60 MINUTES\x9b\x9bCOMMAND? ");
shot(Encoding::Atascii, &a, "site-atari.png");
}
#[test]
#[ignore]
fn rip_snapshot() {
use badterm_core::rip;
let dir = std::path::PathBuf::from(std::env::var("BADTERM_SNAPSHOT_DIR").expect("set BADTERM_SNAPSHOT_DIR"));
std::fs::create_dir_all(&dir).unwrap();
let mut t = Terminal::new(rip::COLS, rip::ROWS, 0);
t.encoding = badterm_core::Encoding::Rip;
t.start_emulation();
let scene: &[&str] = &[
"!|*|S0109|B0000HR6Q|S0102|B006QHR9P|c0E|S010E|o8Q1E0U0O",
"!|c02|S010A|i5A6Q00503C1E|iDE6Q0050461Q|c0F|=00000003|R0A0A6E1O|c0B|Y00000300|@0O0FBADTERM",
"!|Y00000100|c0F|@0O1BThe badass terminal|c0C|S0104|C9W3K0Y|F9W3K0C",
"!|1B000000000002000F0F0807000000000000|1U0A8C32960000<>Files<>F^M|1U3M8C6E960000<>Goodbye<>G^M",
];
for line in scene {
t.feed(line.as_bytes());
t.feed(b"\r\n");
}
t.feed(b"\x1b[38;1H\x1b[1;37;44m Text over graphics: ANSI on a blue bar \x1b[0m");
let r_rip = t.rip().unwrap();
let graphics =
Some(Graphics { pixels: r_rip.pixels(), width: rip::WIDTH, height: rip::HEIGHT, palette: r_rip.palette() });
let mut r = Renderer::new(Font::vga_8x8(), rip::COLS, rip::ROWS);
let rows: Vec<&[Cell]> = (0..rip::ROWS).map(|y| t.screen.row(y)).collect();
let frame = Frame { look_rows: rip::ROWS, graphics, ..Frame::plain(rows, None, true) };
r.draw(&frame, true, true);
let png = crate::png::encode(r.pixels(), r.native_width(), r.native_height());
std::fs::write(dir.join("rip.png"), png).unwrap();
}
#[test]
#[ignore]
fn snapshot() {
use crate::input::Key;
let dir = std::path::PathBuf::from(std::env::var("BADTERM_SNAPSHOT_DIR").expect("set BADTERM_SNAPSHOT_DIR"));
std::fs::create_dir_all(&dir).unwrap();
let mut t = Terminal::new(80, 25, 100);
let mut ans = b"\x1b[2J\x1b[1;33;44m \xc9\xcd\xcd\xcd\xcd\xbb BadTerm test \x1b[0m\r\n".to_vec();
for i in 0..8u8 {
ans.extend_from_slice(format!("\x1b[0;{}m\u{0}", 30 + i).as_bytes());
ans.extend_from_slice(&[0xDB, 0xDB, 0xB2, 0xB1, 0xB0]);
ans.extend_from_slice(format!("\x1b[1;{}m", 30 + i).as_bytes());
ans.extend_from_slice(&[0xDB, 0xDB, 0xB2, 0xB1, 0xB0]);
}
ans.extend_from_slice(b"\x1b[0m\r\n\r\n");
for i in 0..8u8 {
ans.extend_from_slice(format!("\x1b[0;5;{};30m iCE {i} ", 40 + i).as_bytes());
}
ans.extend_from_slice(b"\x1b[0m\r\n\r\n\x1b[38;2;255;128;0mTruecolor \x1b[38;5;45m256-color\x1b[0m ");
ans.extend_from_slice(b"\x03\x04\x05\x06 \xdc\xdf\xdd\xde \xe0\xe1\xe2\xe3 \xab\xac\r\n");
ans.extend_from_slice(b"\x1b[10;10H\x1b[4;37mUnderlined\x1b[0m at 10,10");
t.feed(&ans);
t.screen.ice_colors = true;
let mut r = Renderer::new(Font::vga_8x16(), 80, 25);
let rows: Vec<&[Cell]> = (0..25).map(|y| t.screen.row(y)).collect();
r.draw(&Frame::plain(rows, Some(t.screen.cursor()), true), true, true);
write_ppm(&mut r, &dir.join("terminal.ppm"));
let mut d = Directory::default();
d.merge(vec![
Entry {
name: "Example BBS".into(),
comment: "Runs Synchronet".into(),
..Entry::from_address("bbs.example.org").unwrap()
},
Entry::from_address("ssh://secure.example.net:2222").unwrap(),
Entry::from_address("telnet://another.example.com:2323").unwrap(),
]);
d.offer_defaults();
let mut over = badterm_core::Screen::new(80, 25, 0);
for y in 0..25 {
for (x, c) in t.screen.row(y).iter().enumerate() {
*over.cell_mut(x, y).unwrap() = *c;
}
}
let progress = badterm_core::transfer::Progress {
direction: badterm_core::transfer::Direction::Download,
file_name: "ACID-0897.ZIP".into(),
file_bytes: 1_400_000,
file_size: Some(3_100_000),
files_done: 1,
files_queued: 0,
file_started: std::time::Instant::now() - std::time::Duration::from_secs(4),
resumed_at: None,
};
crate::ui::transfer::draw_progress(&mut over, "ZMODEM", &progress);
let rows: Vec<&[Cell]> = (0..25).map(|y| over.row(y)).collect();
r.draw(&Frame::plain(rows, None, true), true, true);
write_ppm(&mut r, &dir.join("transfer.ppm"));
let mut prompt = crate::ui::transfer::UploadPrompt::new(false, std::env::current_dir().unwrap(), 1);
let cursor = prompt.draw(&mut over);
let rows: Vec<&[Cell]> = (0..25).map(|y| over.row(y)).collect();
r.draw(&Frame::plain(rows, cursor, true), true, true);
write_ppm(&mut r, &dir.join("upload.ppm"));
let prompt = crate::ui::transfer::DownloadPrompt::new(3);
let cursor = prompt.draw(&mut over);
let rows: Vec<&[Cell]> = (0..25).map(|y| over.row(y)).collect();
r.draw(&Frame::plain(rows, cursor, true), true, true);
write_ppm(&mut r, &dir.join("download.ppm"));
let mut ui = DialerUi::new(dir.clone(), std::rc::Rc::new(crate::secrets::tests::Memory::default()));
let mut s = badterm_core::Screen::new(80, 25, 0);
ui.draw(&mut s, &d);
let rows: Vec<&[Cell]> = (0..25).map(|y| s.row(y)).collect();
r.draw(&Frame::plain(rows, None, true), true, true);
write_ppm(&mut r, &dir.join("dialer.ppm"));
let mut edit = DialerUi::new(dir.clone(), std::rc::Rc::new(crate::secrets::tests::Memory::default()));
let mut ed = d.clone();
ed.entries[0].crt = Some(crate::crt::CrtMode::Green);
edit.handle_key(crate::input::Key::Char('e'), crate::input::Mods::default(), &mut ed);
let mut es = badterm_core::Screen::new(80, 25, 0);
let cursor = edit.draw(&mut es, &ed);
let rows: Vec<&[Cell]> = (0..25).map(|y| es.row(y)).collect();
r.draw(&Frame::plain(rows, cursor, true), true, true);
write_ppm(&mut r, &dir.join("edit.ppm"));
ui.confirm_hang_up("Example BBS", "another.example.com", true);
ui.draw(&mut s, &d);
let rows: Vec<&[Cell]> = (0..25).map(|y| s.row(y)).collect();
r.draw(&Frame::plain(rows, None, true), true, true);
write_ppm(&mut r, &dir.join("hangup.ppm"));
let idle = crate::app::idle_screen(Some("9.9.9"));
let rows: Vec<&[Cell]> = (0..25).map(|y| idle.row(y)).collect();
r.draw(&Frame::plain(rows, None, true), true, true);
write_ppm(&mut r, &dir.join("idle.ppm"));
for y in 0..25 {
for (x, c) in t.screen.row(y).iter().enumerate() {
*over.cell_mut(x, y).unwrap() = *c;
}
}
let mut login = crate::ui::login::tests::sample(true);
login.field.value = "hunter2".into();
login.field.cursor = 7;
login.remember = true;
let cursor = login.draw(&mut over);
let rows: Vec<&[Cell]> = (0..25).map(|y| over.row(y)).collect();
r.draw(&Frame::plain(rows, Some(cursor), true), true, true);
write_ppm(&mut r, &dir.join("login.ppm"));
let rows: Vec<&[Cell]> = (0..25).map(|y| t.screen.row(y)).collect();
let selection = Some(crate::selection::Selection { anchor: (20, 0), head: (9, 4) });
r.draw(&Frame { selection, ..Frame::plain(rows, None, true) }, true, true);
write_ppm(&mut r, &dir.join("selection.ppm"));
let png = crate::png::encode(r.pixels(), r.native_width(), r.native_height());
std::fs::write(dir.join("selection.png"), png).unwrap();
let mut ui = DialerUi::new(dir.clone(), std::rc::Rc::new(crate::secrets::tests::Memory::default()));
for c in "/exa".chars() {
ui.handle_key(crate::input::Key::Char(c), crate::input::Mods::default(), &mut d);
}
ui.draw(&mut s, &d);
let rows: Vec<&[Cell]> = (0..25).map(|y| s.row(y)).collect();
r.draw(&Frame::plain(rows, None, true), true, true);
write_ppm(&mut r, &dir.join("find.ppm"));
let write_png = |r: &mut Renderer, display: &Display, name: &str| {
let (w, h) = (1920, 1080);
let mut out = vec![0u32; w * h];
r.blit(&mut out, w, h, display);
std::fs::write(dir.join(name), crate::png::encode(&out, w, h)).unwrap();
};
let rows: Vec<&[Cell]> = (0..25).map(|y| t.screen.row(y)).collect();
let mut crt = Renderer::new(Font::vga_8x16(), 80, 25);
crt.set_layout(Font::vga_8x16(), 9, 80, 25);
crt.draw(&Frame::plain(rows, None, true), true, true);
for (mode, name) in [
(crate::crt::CrtMode::Off, "aspect.png"),
(crate::crt::CrtMode::Color, "crt-color.png"),
(crate::crt::CrtMode::Green, "crt-green.png"),
(crate::crt::CrtMode::Amber, "crt-amber.png"),
] {
let display =
Display { scaling: Scaling::Fill, pixel_aspect: 720.0 / 400.0 * 0.75, crt: mode, ..Display::default() };
write_png(&mut crt, &display, name);
}
let mut t50 = Terminal::new(80, 50, 0);
t50.feed(&ans);
for y in 10..50 {
t50.feed(format!("\x1b[{};1H\x1b[1;3{}mLine {y} of 50 ", y + 1, y % 7 + 1).as_bytes());
t50.feed(&[0xB0, 0xB1, 0xB2, 0xDB]);
}
let rows: Vec<&[Cell]> = (0..50).map(|y| t50.screen.row(y)).collect();
let mut r50 = Renderer::new(Font::vga_8x8(), 80, 50);
r50.draw(&Frame::plain(rows, None, true), true, true);
write_png(&mut r50, &Display { scaling: Scaling::Fill, pixel_aspect: 1.2, ..Display::default() }, "80x50.png");
let page = |r: &mut Renderer, s: &badterm_core::Screen, name: &str| {
let rows: Vec<&[Cell]> = (0..25).map(|y| s.row(y)).collect();
r.set_layout(Font::vga_8x16(), 8, 80, 25);
r.draw(&Frame::plain(rows, None, true), true, true);
write_ppm(r, &dir.join(name));
};
let mut settings = crate::settings::Settings::default();
let mut sui = crate::ui::settings::SettingsUi::default();
let mut ps = badterm_core::Screen::new(80, 25, 0);
sui.draw(&mut ps, &settings, Some("Green screen"), None);
page(&mut r, &ps, "settings.ppm");
let mut fui = crate::ui::settings::SettingsUi::default();
fui.open(Vec::new(), Vec::new(), vec![std::path::PathBuf::from("/home/sysop/Downloads/BadTerm"); 5]);
let mut fs = crate::settings::Settings::default();
fs.folders.captures = Some("/home/sysop/BBS/captures".into());
for k in [Key::Tab, Key::Tab, Key::Down] {
fui.handle_key(k, crate::input::Mods::default(), &mut fs);
}
fui.draw(&mut ps, &fs, None, None);
page(&mut r, &ps, "folders.ppm");
for k in [
Key::Tab,
Key::Tab,
Key::Tab,
Key::Down,
Key::Enter,
Key::Down,
Key::Down,
Key::Down,
Key::Down,
Key::Enter,
Key::Right,
] {
sui.handle_key(k, crate::input::Mods::default(), &mut settings);
}
crate::ui::theme::set_current(settings.colors);
sui.draw(&mut ps, &settings, None, None);
page(&mut r, &ps, "colors.ppm");
let mut mui = crate::ui::settings::SettingsUi::default();
let mut ms = crate::settings::Settings::default();
ms.macros[0] = "{user}^M~{password}^M".into();
ms.macros[2] = "/x^M".into();
for k in [Key::Tab, Key::Down, Key::Down, Key::Down, Key::Down, Key::Enter] {
mui.handle_key(k, crate::input::Mods::default(), &mut ms);
}
crate::ui::theme::set_current(Default::default());
mui.draw(&mut ps, &ms, None, None);
page(&mut r, &ps, "macros.ppm");
crate::ui::theme::set_current(crate::ui::theme::SCHEMES[2].1);
crate::ui::help::draw(&mut ps);
page(&mut r, &ps, "help.ppm");
crate::ui::theme::set_current(Default::default());
crate::ui::splash::draw(&mut ps);
page(&mut r, &ps, "splash.ppm");
let retro = |enc: badterm_core::Encoding, bytes: &[u8], name: &str, r: &mut Renderer| {
let (cols, rows) = enc.screen_size().unwrap();
let mut t = Terminal::new(cols, rows, 0);
t.encoding = enc;
t.start_emulation();
t.feed(bytes);
let lines: Vec<&[Cell]> = (0..rows).map(|y| t.screen.row(y)).collect();
r.set_layout(Font::vga_8x8(), 8, cols, rows);
r.draw(&Frame { look: &t.screen.look, look_rows: rows, ..Frame::plain(lines, None, true) }, true, true);
write_ppm(r, &dir.join(name));
};
let mut c64 =
b"\x93\x05 **** COMMODORE 64 BASIC V2 ****\r\r\x9a 64K RAM SYSTEM 38911 BASIC BYTES FREE\r\r".to_vec();
c64.extend_from_slice(b"READY.\r\x1c\x12 RED \x92 \x1eGREEN \x1fBLUE \x9eYELLOW \x9fCYAN \x9cPURPLE\r\r\x05");
c64.extend((0xA0u8..=0xBF).chain(0xC0..=0xDF));
c64.extend_from_slice(b"\r\r\x9a\x0eLower case: Hello, World! \x8e");
retro(badterm_core::Encoding::Petscii, &c64, "petscii.ppm", &mut r);
let mut atari = b"\x7dREADY\x9bATARI BBS \xc1\xd4\xc1\xd2\xc9 (inverse)\x9b\x9b".to_vec();
atari.extend(0x00u8..0x1B);
atari.extend_from_slice(b"\x1b\x1c\x1b\x1d\x1b\x1e\x1b\x1f\x9b`abcdefg{|\x1b}\x1b~\x1b\x7f\x9b");
retro(badterm_core::Encoding::Atascii, &atari, "atascii.ppm", &mut r);
let art_path = dir.join("viewer-test.ans");
let mut art = ans.clone();
art.push(0x1A);
let mut rec = vec![0u8; 128];
rec[..7].copy_from_slice(b"SAUCE00");
rec[7..18].copy_from_slice(b"BadTerm art");
rec[42..48].copy_from_slice(b"arelas");
rec[82..90].copy_from_slice(b"20261030");
rec[94] = 1;
rec[96..98].copy_from_slice(&80u16.to_le_bytes());
rec[105] = 1;
rec[106..113].copy_from_slice(b"IBM VGA");
art.extend(rec);
std::fs::write(&art_path, &art).unwrap();
let mut view = crate::ui::art::ArtView::open(&art_path, 0).unwrap();
view.handle_key(Key::Char('i'));
let rows = view.rows();
let look = &view.art.term.screen.look;
r.set_layout(Font::vga_8x16(), 8, 80, 25);
r.draw(&Frame { look, look_rows: 25, ..Frame::plain(rows, None, true) }, true, true);
write_ppm(&mut r, &dir.join("viewer.ppm"));
let mut t132 = Terminal::new(132, 37, 0);
t132.feed(&ans);
for y in 10..37 {
t132.feed(format!("\x1b[{};1H\x1b[0;3{}m{}", y + 1, y % 7 + 1, "132 columns. ".repeat(10)).as_bytes());
}
let mut prompt = crate::ui::save::SavePrompt::new(std::path::Path::new("/tmp/badterm-scrollback.ans"));
let cursor = prompt.draw(&mut t132.screen);
let rows: Vec<&[Cell]> = (0..37).map(|y| t132.screen.row(y)).collect();
let mut r132 = Renderer::new(Font::vga_8x16(), 132, 37);
r132.draw(&Frame::plain(rows, cursor, true), true, true);
write_ppm(&mut r132, &dir.join("132x37.ppm"));
let (w, h) = (1920, 1080);
let mut out = vec![0u32; w * h];
r.blit(&mut out, w, h, &Display { scaling: Scaling::Fill, ..Display::default() });
let mut data = format!("P6 {w} {h} 255\n").into_bytes();
for px in out {
data.extend_from_slice(&[(px >> 16) as u8, (px >> 8) as u8, px as u8]);
}
std::fs::write(dir.join("fullscreen.ppm"), data).unwrap();
}
#[test]
fn integer_scaling_is_exact() {
let mut r = Renderer::new(Font::vga_8x16(), 80, 25);
let (w, h) = (1920, 1080);
let mut out = vec![1u32; w * h];
let fill = Display { scaling: Scaling::Fill, ..Display::default() };
assert_eq!(r.blit(&mut out, w, h, &Display::default()), (320, 140, 1280, 800));
assert_eq!(r.blit(&mut out, w, h, &fill), (96, 0, 1728, 1080));
assert_eq!(r.blit(&mut out, w, h, &Display { pixel_aspect: 1.2, ..fill }), (240, 0, 1440, 1080));
let stretch = Display { scaling: Scaling::Stretch, ..Display::default() };
assert_eq!(r.blit(&mut out, w, h, &stretch), (0, 0, w, h));
r.set_layout(Font::vga_8x16(), 9, 80, 25);
assert_eq!(r.native_width(), 720);
}
#[test]
fn fill_blends_only_the_seams() {
assert_eq!(taps(2, 5), [(0, 256), (0, 256), (0, 128), (1, 256), (1, 256)]);
assert!(taps(4, 12).iter().all(|&(_, w)| w == 256));
assert_eq!(mix(0xFF0000, 0x0000FF, 128), 0x7F007F);
assert_eq!(mix(0x123456, 0xABCDEF, 256), 0x123456);
}
}