use std::collections::HashMap;
use std::io::{self, Write};
use crate::primer::{Palette, Rgb, EDGE_ALPHA};
pub const COLUMNS_PER_DAY: u16 = 2;
const SQUARE_OF_PITCH: f32 = 11.0 / 14.0;
const RADIUS_OF_SQUARE: f32 = 2.0 / 11.0;
const BORDER_OF_SQUARE: f32 = 0.5 / 11.0;
const RING_OF_SQUARE: f32 = 2.0 / 11.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Protocol {
Kitty,
Sixel,
}
impl Protocol {
pub const fn name(self) -> &'static str {
match self {
Self::Kitty => "kitty",
Self::Sixel => "sixel",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Image {
pub width: usize,
pub height: usize,
pixels: Vec<[u8; 4]>,
}
impl Image {
pub fn new(width: usize, height: usize) -> Self {
Self {
width,
height,
pixels: vec![[0; 4]; width * height],
}
}
pub fn rounded_rect(&mut self, x: f32, y: f32, side: f32, radius: f32, color: Rgb, alpha: f32) {
let radius = radius.clamp(0.0, side / 2.0);
let (cx, cy) = (x + side / 2.0, y + side / 2.0);
let half = side / 2.0 - radius;
let low = |v: f32| (v.floor() as isize).max(0);
let high = |v: f32, limit: usize| (v.ceil() as isize + 1).min(limit as isize);
for py in low(y)..high(y + side, self.height) {
for px in low(x)..high(x + side, self.width) {
let dx = (px as f32 + 0.5 - cx).abs() - half;
let dy = (py as f32 + 0.5 - cy).abs() - half;
let outside = dx.max(0.0).hypot(dy.max(0.0));
let distance = outside + dx.max(dy).min(0.0) - radius;
let coverage = (0.5 - distance).clamp(0.0, 1.0) * alpha;
if coverage > 0.0 {
self.blend(px as usize, py as usize, color, coverage);
}
}
}
}
pub fn rounded_ring(
&mut self,
at: (f32, f32),
side: f32,
radius: f32,
width: f32,
color: Rgb,
alpha: f32,
) {
let (x, y) = at;
let coverage = |px: isize, py: isize, x: f32, y: f32, side: f32, radius: f32| -> f32 {
let radius = radius.clamp(0.0, side / 2.0);
let (cx, cy) = (x + side / 2.0, y + side / 2.0);
let half = side / 2.0 - radius;
let dx = (px as f32 + 0.5 - cx).abs() - half;
let dy = (py as f32 + 0.5 - cy).abs() - half;
let outside = dx.max(0.0).hypot(dy.max(0.0));
let distance = outside + dx.max(dy).min(0.0) - radius;
(0.5 - distance).clamp(0.0, 1.0)
};
let low = |v: f32| (v.floor() as isize).max(0);
let high = |v: f32, limit: usize| (v.ceil() as isize + 1).min(limit as isize);
let inner = (side - 2.0 * width).max(0.0);
for py in low(y)..high(y + side, self.height) {
for px in low(x)..high(x + side, self.width) {
let outer = coverage(px, py, x, y, side, radius);
let hole = coverage(
px,
py,
x + width,
y + width,
inner,
(radius - width).max(0.0),
);
let ring = (outer - hole).clamp(0.0, 1.0) * alpha;
if ring > 0.0 {
self.blend(px as usize, py as usize, color, ring);
}
}
}
}
fn blend(&mut self, x: usize, y: usize, color: Rgb, alpha: f32) {
let under = self.pixels[y * self.width + x];
let below = f32::from(under[3]) / 255.0;
let out = alpha + below * (1.0 - alpha);
let mix = |src: u8, dst: u8| {
if out <= 0.0 {
return 0;
}
let value = (f32::from(src) * alpha + f32::from(dst) * below * (1.0 - alpha)) / out;
value.round().clamp(0.0, 255.0) as u8
};
self.pixels[y * self.width + x] = [
mix(color.0, under[0]),
mix(color.1, under[1]),
mix(color.2, under[2]),
(out * 255.0).round().clamp(0.0, 255.0) as u8,
];
}
pub fn rgba_at(&self, x: usize, y: usize) -> [u8; 4] {
self.pixels[y * self.width + x]
}
fn rgba(&self) -> Vec<u8> {
self.pixels.iter().flatten().copied().collect()
}
pub fn is_blank(&self) -> bool {
self.pixels.iter().all(|pixel| pixel[3] == 0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Ring {
Cursor,
Hover,
}
impl Ring {
const fn slot(self) -> usize {
match self {
Self::Cursor => 0,
Self::Hover => 1,
}
}
fn color(self, palette: &Palette) -> Rgb {
match self {
Self::Cursor => palette.fg,
Self::Hover => palette.accent,
}
}
}
fn square(cell: (u16, u16)) -> (f32, f32, f32) {
let pitch_w = f32::from(cell.0) * f32::from(COLUMNS_PER_DAY);
let pitch_h = f32::from(cell.1);
let side = (pitch_w.min(pitch_h) * SQUARE_OF_PITCH).round().max(3.0);
(
((pitch_w - side) / 2.0).round(),
((pitch_h - side) / 2.0).round(),
side,
)
}
fn day(
image: &mut Image,
x: f32,
y: f32,
side: f32,
fill: Rgb,
palette: &Palette,
ring: Option<Ring>,
) {
let radius = side * RADIUS_OF_SQUARE;
match ring {
Some(ring) => {
let width = (side * RING_OF_SQUARE).max(1.0);
let half = width / 2.0;
image.rounded_rect(
x - half,
y - half,
side + width,
radius + half,
ring.color(palette),
1.0,
);
image.rounded_rect(x + half, y + half, side - width, radius - half, fill, 1.0);
}
None => {
let border = (side * BORDER_OF_SQUARE).max(0.5);
image.rounded_rect(x, y, side, radius, fill, 1.0);
image.rounded_ring((x, y), side, radius, border, palette.edge, EDGE_ALPHA);
}
}
}
pub fn grid(levels: &[[Option<u8>; 7]], palette: &Palette, cell: (u16, u16)) -> Image {
let (dx, dy, side) = square(cell);
let mut image = Image::new(
levels.len() * usize::from(cell.0) * usize::from(COLUMNS_PER_DAY),
7 * usize::from(cell.1),
);
for (week, column) in levels.iter().enumerate() {
for (weekday, level) in column.iter().enumerate() {
let Some(level) = level else { continue };
let x = (week * usize::from(cell.0) * usize::from(COLUMNS_PER_DAY)) as f32 + dx;
let y = (weekday * usize::from(cell.1)) as f32 + dy;
day(&mut image, x, y, side, palette.cell(*level), palette, None);
}
}
image
}
pub fn legend(palette: &Palette, cell: (u16, u16)) -> Image {
grid(
&(0..5)
.map(|level| {
let mut column = [None; 7];
column[0] = Some(level as u8);
column
})
.collect::<Vec<_>>(),
palette,
cell,
)
.crop_rows(usize::from(cell.1))
}
impl Image {
fn crop_rows(mut self, rows: usize) -> Self {
let rows = rows.min(self.height);
self.pixels.truncate(rows * self.width);
self.height = rows;
self
}
}
pub fn patch(level: Option<u8>, ring: Option<Ring>, palette: &Palette, cell: (u16, u16)) -> Image {
let (dx, dy, side) = square(cell);
let mut image = Image::new(
usize::from(cell.0) * usize::from(COLUMNS_PER_DAY),
usize::from(cell.1),
);
match (level, ring) {
(Some(level), ring) => day(&mut image, dx, dy, side, palette.cell(level), palette, ring),
(None, Some(ring)) => {
let width = (side * RING_OF_SQUARE).max(1.0);
image.rounded_ring(
(dx - width / 2.0, dy - width / 2.0),
side + width,
side * RADIUS_OF_SQUARE + width / 2.0,
width,
ring.color(palette),
1.0,
);
}
(None, None) => {}
}
image
}
const BASE_ID: u32 = 7380;
const LEGEND_ID: u32 = 7381;
const RING_ID: u32 = 7382;
const Z_GRID: i32 = -2;
const Z_RING: i32 = -1;
pub fn kitty(image: &Image, id: u32, columns: u16, rows: u16, z: i32) -> String {
let payload = base64(&miniz_oxide::deflate::compress_to_vec_zlib(
&image.rgba(),
6,
));
let control = format!(
"a=T,q=2,f=32,o=z,s={},v={},i={id},p=1,z={z},c={columns},r={rows},C=1",
image.width, image.height
);
let mut out = String::with_capacity(payload.len() + payload.len() / 32 + 64);
let mut chunks = payload.as_bytes().chunks(4096).peekable();
let mut first = true;
while let Some(chunk) = chunks.next() {
let more = u8::from(chunks.peek().is_some());
out.push_str("\x1b_G");
if first {
out.push_str(&control);
out.push(',');
first = false;
}
out.push_str(&format!("m={more};"));
out.push_str(std::str::from_utf8(chunk).unwrap_or_default());
out.push_str("\x1b\\");
}
out
}
pub fn kitty_delete(id: u32) -> String {
format!("\x1b_Ga=d,d=I,i={id},q=2\x1b\\")
}
fn base64(data: &[u8]) -> String {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
for group in data.chunks(3) {
let mut bits = 0u32;
for (index, byte) in group.iter().enumerate() {
bits |= u32::from(*byte) << (16 - 8 * index);
}
for index in 0..=group.len() {
out.push(ALPHABET[(bits >> (18 - 6 * index) & 0x3f) as usize] as char);
}
for _ in group.len()..3 {
out.push('=');
}
}
out
}
fn indexed(image: &Image, background: Rgb) -> (Vec<[u8; 3]>, Vec<Option<u8>>) {
const REGISTERS: usize = 256;
for step in [1u16, 2, 3, 4, 6, 20] {
let mut palette: Vec<[u8; 3]> = Vec::new();
let mut seen: HashMap<[u8; 3], u8> = HashMap::new();
let mut map = Vec::with_capacity(image.width * image.height);
let mut overflowed = false;
for pixel in &image.pixels {
if pixel[3] == 0 {
map.push(None);
continue;
}
let alpha = f32::from(pixel[3]) / 255.0;
let solid = Rgb(pixel[0], pixel[1], pixel[2]).over(background, alpha);
let snap = |value: u8| {
let percent = (u16::from(value) * 100 + 127) / 255;
((percent + step / 2) / step * step).min(100) as u8
};
let key = [snap(solid.0), snap(solid.1), snap(solid.2)];
let index = match seen.get(&key) {
Some(index) => *index,
None if palette.len() == REGISTERS => {
overflowed = true;
break;
}
None => {
let index = palette.len() as u8;
palette.push(key);
seen.insert(key, index);
index
}
};
map.push(Some(index));
}
if !overflowed {
return (palette, map);
}
}
unreachable!("20% steps leave 6³ = 216 shades, which fits, and cells use far fewer")
}
pub fn sixel(image: &Image, background: Rgb) -> String {
let (palette, map) = indexed(image, background);
let mut out = String::from("\x1bP0;1;0q");
out.push_str(&format!("\"1;1;{};{}", image.width, image.height));
for (index, color) in palette.iter().enumerate() {
out.push_str(&format!(
"#{index};2;{};{};{}",
color[0], color[1], color[2]
));
}
for top in (0..image.height).step_by(6) {
let mut bands: HashMap<u8, Vec<u8>> = HashMap::new();
for row in 0..6 {
let y = top + row;
if y >= image.height {
break;
}
for x in 0..image.width {
if let Some(index) = map[y * image.width + x] {
bands.entry(index).or_insert_with(|| vec![0; image.width])[x] |= 1 << row;
}
}
}
let mut indices: Vec<&u8> = bands.keys().collect();
indices.sort_unstable();
for index in indices {
let bits = &bands[index];
let last = bits.iter().rposition(|byte| *byte != 0);
let Some(last) = last else { continue };
out.push_str(&format!("#{index}"));
let (mut run, mut code) = (0usize, bits[0]);
for byte in &bits[..=last] {
if *byte == code {
run += 1;
} else {
push_run(&mut out, code, run);
(run, code) = (1, *byte);
}
}
push_run(&mut out, code, run);
out.push('$');
}
out.push('-');
}
out.push_str("\x1b\\");
out
}
fn push_run(out: &mut String, code: u8, count: usize) {
let glyph = char::from(b'?' + code);
if count >= 4 {
out.push('!');
out.push_str(&count.to_string());
out.push(glyph);
} else {
for _ in 0..count {
out.push(glyph);
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Mark {
pub week: u16,
pub weekday: u16,
pub level: Option<u8>,
pub ring: Ring,
}
#[derive(Debug)]
pub struct Scene<'a> {
pub palette: &'a Palette,
pub grid: (u16, u16),
pub legend: Option<(u16, u16)>,
pub levels: Vec<[Option<u8>; 7]>,
pub marks: [Option<Mark>; 2],
pub key: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Painted {
key: u64,
grid: (u16, u16),
legend: Option<(u16, u16)>,
}
#[derive(Debug)]
pub struct Painter {
pub protocol: Protocol,
pub cell: (u16, u16),
pub width: u16,
background: Rgb,
base: Option<Painted>,
marks: [Option<Mark>; 2],
}
impl Painter {
pub fn new(protocol: Protocol, cell: (u16, u16), background: Rgb) -> Self {
Self {
protocol,
cell,
width: u16::MAX,
background,
base: None,
marks: [None; 2],
}
}
pub fn invalidate(&mut self) {
self.base = None;
self.marks = [None; 2];
}
pub fn clear(&mut self, out: &mut impl Write) -> io::Result<()> {
if self.base.is_none() && self.marks.iter().all(Option::is_none) {
return Ok(());
}
if self.protocol == Protocol::Kitty {
for id in [BASE_ID, LEGEND_ID, RING_ID, RING_ID + 1] {
out.write_all(kitty_delete(id).as_bytes())?;
}
out.flush()?;
}
self.invalidate();
Ok(())
}
pub fn paint(&mut self, out: &mut impl Write, scene: &Scene<'_>) -> io::Result<()> {
let wanted = Painted {
key: scene.key,
grid: scene.grid,
legend: scene.legend,
};
if self.base != Some(wanted) {
self.draw_base(out, scene)?;
self.base = Some(wanted);
self.marks = [None; 2];
}
let mut wanted: [Option<Mark>; 2] = [None; 2];
for mark in scene.marks.iter().flatten() {
wanted[mark.ring.slot()] = Some(*mark);
}
for (slot, new) in wanted.into_iter().enumerate() {
let old = self.marks[slot];
if old == new {
continue;
}
if let Some(old) = old {
self.erase(out, scene, old)?;
}
if let Some(new) = new {
self.mark(out, scene, new)?;
}
self.marks[slot] = new;
}
out.flush()
}
fn draw_base(&mut self, out: &mut impl Write, scene: &Scene<'_>) -> io::Result<()> {
let image = grid(&scene.levels, scene.palette, self.cell);
self.blank(
out,
scene.grid,
scene.levels.len() as u16 * COLUMNS_PER_DAY,
7,
)?;
self.place(out, scene.grid, &image, BASE_ID, Z_GRID)?;
if let Some(at) = scene.legend {
let image = legend(scene.palette, self.cell);
self.blank(out, at, 5 * COLUMNS_PER_DAY, 1)?;
self.place(out, at, &image, LEGEND_ID, Z_GRID)?;
}
Ok(())
}
fn mark(&mut self, out: &mut impl Write, scene: &Scene<'_>, mark: Mark) -> io::Result<()> {
let image = patch(mark.level, Some(mark.ring), scene.palette, self.cell);
let at = self.cell_at(scene, mark);
let id = RING_ID + mark.ring.slot() as u32;
self.place(out, at, &image, id, Z_RING)
}
fn erase(&mut self, out: &mut impl Write, scene: &Scene<'_>, mark: Mark) -> io::Result<()> {
match self.protocol {
Protocol::Kitty => {
let id = RING_ID + mark.ring.slot() as u32;
out.write_all(kitty_delete(id).as_bytes())
}
Protocol::Sixel => {
let at = self.cell_at(scene, mark);
write!(
out,
"\x1b[{};{}H\x1b[0m{}",
at.1 + 1,
at.0 + 1,
" ".repeat(usize::from(COLUMNS_PER_DAY))
)?;
let image = patch(mark.level, None, scene.palette, self.cell);
if image.is_blank() {
return Ok(());
}
self.place(out, at, &image, RING_ID, Z_RING)
}
}
}
fn blank(
&self,
out: &mut impl Write,
at: (u16, u16),
columns: u16,
rows: u16,
) -> io::Result<()> {
if self.protocol != Protocol::Sixel {
return Ok(());
}
for row in 0..rows {
write!(
out,
"\x1b[{};{}H\x1b[0m{}",
at.1 + row + 1,
at.0 + 1,
" ".repeat(usize::from(columns.min(self.width.saturating_sub(at.0))))
)?;
}
Ok(())
}
fn cell_at(&self, scene: &Scene<'_>, mark: Mark) -> (u16, u16) {
(
scene.grid.0 + mark.week * COLUMNS_PER_DAY,
scene.grid.1 + mark.weekday,
)
}
fn place(
&self,
out: &mut impl Write,
at: (u16, u16),
image: &Image,
id: u32,
z: i32,
) -> io::Result<()> {
let payload = match self.protocol {
Protocol::Kitty => {
let columns = image.width.div_ceil(usize::from(self.cell.0)) as u16;
let rows = image.height.div_ceil(usize::from(self.cell.1)) as u16;
kitty(image, id, columns, rows, z)
}
Protocol::Sixel => sixel(image, self.background),
};
write!(out, "\x1b[{};{}H{payload}", at.1 + 1, at.0 + 1)
}
}