pub mod kitty;
pub mod typeset;
mod typewriter;
use std::collections::HashMap;
use std::env;
use std::process::{Command, Stdio};
use std::sync::{Arc, LazyLock, Mutex, MutexGuard, OnceLock};
use crossterm::terminal::window_size;
use ratatui::layout::Size;
use ratatui::text::Line;
use ratatui_image::picker::{Picker, ProtocolType};
use std::ops::Range;
use resvg::tiny_skia::{Pixmap, PixmapPaint, Transform};
use resvg::usvg::{Options, Tree};
use crate::ui::config::UI;
use kitty::{ImageId, KittyImage, Passthrough};
use typeset::{Break, Rgb, EM, PADDING};
const TMUX: &str = "tmux";
const TMUX_VARIABLE: &str = "TMUX";
const TMUX_ALLOW_PASSTHROUGH: [&str; 4] = ["set", "-p", "allow-passthrough", "on"];
const TMUX_CLIENT_TERMINAL: &str = "#{client_termtype}\n#{client_termname}";
const TERMINAL_VARIABLES: [&str; 2] = ["TERM_PROGRAM", "TERM"];
const TERM_FAMILY_PREFIX: &str = "xterm-";
const PLACEHOLDER_TERMINALS: [&str; 2] = ["kitty", "ghostty"];
#[derive(Clone, Copy, Debug)]
struct Canvas {
cell: Size,
passthrough: Passthrough,
}
static CANVAS: OnceLock<Mutex<Canvas>> = OnceLock::new();
static GALLERY: LazyLock<Mutex<Gallery>> = LazyLock::new(|| {
Mutex::new(Gallery {
formulas: HashMap::new(),
images: HashMap::new(),
last_id: ImageId::from(rand::random::<u32>()),
unsent: Vec::new(),
sources: HashMap::new(),
})
});
pub fn detect() {
let canvas = match env::var_os(TMUX_VARIABLE).is_some() {
true => tmux_canvas(),
false => queried_canvas(),
};
if let Some(canvas) = canvas {
let _ = CANVAS.set(Mutex::new(canvas));
}
}
fn queried_canvas() -> Option<Canvas> {
let picker = Picker::from_query_stdio().ok()?;
let font = picker.font_size();
(picker.protocol_type() == ProtocolType::Kitty).then(|| Canvas {
cell: Size::new(font.width, font.height),
passthrough: Passthrough::Direct,
})
}
fn tmux_canvas() -> Option<Canvas> {
terminal().filter(|name| PLACEHOLDER_TERMINALS.contains(name))?;
let window = window_size().ok()?;
if window.columns == 0 || window.rows == 0 || window.width == 0 || window.height == 0 {
return None;
}
let _ = Command::new(TMUX)
.args(TMUX_ALLOW_PASSTHROUGH)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
Some(Canvas {
cell: Size::new(window.width / window.columns, window.height / window.rows),
passthrough: Passthrough::Tmux,
})
}
pub fn terminal() -> Option<&'static str> {
static TERMINAL: LazyLock<Option<String>> = LazyLock::new(|| {
let names = match env::var_os(TMUX_VARIABLE).is_some() {
true => tmux_client_names(),
false => TERMINAL_VARIABLES
.iter()
.filter_map(|variable| env::var(variable).ok())
.collect(),
};
names.iter().find_map(|name| printable_name(name))
});
TERMINAL.as_deref()
}
fn tmux_client_names() -> Vec<String> {
Command::new(TMUX)
.args(["display-message", "-p", TMUX_CLIENT_TERMINAL])
.stdin(Stdio::null())
.stderr(Stdio::null())
.output()
.map(|output| {
String::from_utf8_lossy(&output.stdout)
.lines()
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}
fn printable_name(name: &str) -> Option<String> {
let name = name.split([' ', '(']).next()?;
let name = name
.strip_prefix(TERM_FAMILY_PREFIX)
.unwrap_or(name)
.to_lowercase();
let is_word = name.starts_with(|c: char| c.is_ascii_alphabetic())
&& name.chars().all(|c| c.is_ascii_alphanumeric());
is_word.then_some(name)
}
pub fn available() -> bool {
CANVAS.get().is_some()
}
pub fn cell_size() -> Option<Size> {
CANVAS.get()?.lock().ok().map(|canvas| canvas.cell)
}
pub fn refresh_cell_size() -> bool {
let Some(canvas) = CANVAS.get() else {
return false;
};
let Ok(window) = window_size() else {
return false;
};
if window.columns == 0 || window.rows == 0 {
return false;
}
let cell = Size::new(window.width / window.columns, window.height / window.rows);
if cell.width == 0 || cell.height == 0 {
return false;
}
let mut canvas = canvas
.lock()
.expect("the canvas lock is never held across a panic");
if canvas.cell == cell {
return false;
}
canvas.cell = cell;
true
}
#[derive(Clone, Copy, Debug)]
pub struct Typesetting<'a> {
pub tex: &'a str,
pub painted: &'a str,
pub code: &'a str,
pub matrix_row_spacing_percent: u16,
}
#[derive(Clone, Debug)]
pub struct Pictured {
pub lines: Vec<Line<'static>>,
pub baseline: usize,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
enum Placement {
Alone,
AmongText,
}
pub fn typeset(formula: Typesetting, colour: Rgb, width: u16) -> Option<Pictured> {
pictured(formula, colour, width, Placement::AmongText)
}
pub fn typeset_alone(formula: Typesetting, colour: Rgb, width: u16) -> Option<Pictured> {
pictured(formula, colour, width, Placement::Alone)
}
fn pictured(
formula: Typesetting,
colour: Rgb,
width: u16,
placement: Placement,
) -> Option<Pictured> {
let canvas = *CANVAS.get()?.lock().ok()?;
gallery()
.formula(formula, colour, canvas, width, placement)
.map(|(image, baseline)| Pictured {
lines: image.lines(),
baseline,
})
}
pub fn source(id: ImageId) -> Option<String> {
gallery().sources.get(&id).cloned()
}
pub fn unsent() -> String {
let Some(canvas) = CANVAS
.get()
.and_then(|canvas| canvas.lock().ok().map(|canvas| *canvas))
else {
return String::new();
};
gallery()
.unsent
.drain(..)
.map(|image| image.transmission(canvas.passthrough))
.collect()
}
pub fn resend_all() {
if !CANVAS.get().is_some_and(|canvas| {
canvas
.lock()
.is_ok_and(|canvas| canvas.passthrough == Passthrough::Tmux)
}) {
return;
}
let mut gallery = gallery();
let images = gallery
.images
.values()
.flatten()
.cloned()
.collect::<Vec<_>>();
gallery.unsent = images;
}
fn gallery() -> MutexGuard<'static, Gallery> {
GALLERY
.lock()
.expect("the gallery lock is never held across a panic")
}
type FormulaKey = (String, Rgb, u16);
type ImageKey = (FormulaKey, u16, Size, String, Placement);
struct Formula {
tree: Tree,
breaks: Vec<Break>,
baseline: f32,
}
struct Gallery {
formulas: HashMap<FormulaKey, Option<Arc<Formula>>>,
images: HashMap<ImageKey, Option<Arc<KittyImage>>>,
last_id: ImageId,
unsent: Vec<Arc<KittyImage>>,
sources: HashMap<ImageId, String>,
}
impl Gallery {
fn formula(
&mut self,
Typesetting {
tex,
painted,
code,
matrix_row_spacing_percent,
}: Typesetting,
colour: Rgb,
canvas: Canvas,
width: u16,
placement: Placement,
) -> Option<(Arc<KittyImage>, usize)> {
let key = (painted.to_string(), colour, matrix_row_spacing_percent);
let formula = self
.formulas
.entry(key.clone())
.or_insert_with(|| {
let typeset =
typeset::svg_colored(tex, painted, colour, matrix_row_spacing_percent).ok()?;
let tree = Tree::from_str(&typeset.svg, &Options::default()).ok()?;
Some(Arc::new(Formula {
tree,
breaks: typeset.breaks,
baseline: typeset.baseline,
}))
})
.clone()?;
let raster = Raster::fitted(&formula, canvas, width, placement)?;
let baseline = raster.baseline_row();
let image_key = (
key,
raster.cells.width,
canvas.cell,
code.to_string(),
placement,
);
if let Some(image) = self.images.get(&image_key) {
return image.clone().map(|image| (image, baseline));
}
self.last_id = self.last_id.next();
let image = raster
.png(&formula.tree)
.and_then(|png| KittyImage::new(self.last_id, raster.cells, png).ok())
.map(Arc::new);
self.unsent.extend(image.clone());
if image.is_some() {
self.sources.insert(self.last_id, code.to_string());
}
self.images.insert(image_key, image.clone());
image.map(|image| (image, baseline))
}
}
struct Raster {
scale: f32,
top: f32,
offset: f32,
rows: Vec<Range<f32>>,
row_cells: u16,
slice_baseline: u16,
cells: Size,
cell: Size,
}
impl Raster {
fn fitted(formula: &Formula, canvas: Canvas, width: u16, placement: Placement) -> Option<Self> {
let tree = &formula.tree;
let bounds = tree.root().abs_stroke_bounding_box();
let top = bounds.top() - PADDING;
let height = bounds.height() + 2.0 * PADDING;
let cell_width = f32::from(canvas.cell.width);
let cell_height = f32::from(canvas.cell.height);
let natural = cell_height * UI.typeset.em_per_cell_height / EM;
let room = f32::from(width) * cell_width / natural;
let across = tree.size().width();
let breaks = formula
.breaks
.iter()
.map(|cut| cut.across(across))
.collect::<Vec<_>>();
let rows = wrapped(across, &breaks, room);
let widest = rows
.iter()
.map(|row| row.end - row.start)
.fold(0.0, f32::max);
let shrink = (room / widest).min(1.0);
if shrink < UI.typeset.min_scale {
return None;
}
let scale = natural * shrink;
let cover = |extent: f32, cell: f32| ((extent * scale / cell).ceil() as u16).max(1);
let above = (formula.baseline * tree.size().height() - top) * scale;
let below = (height * scale - above).max(0.0);
let padding = PADDING * scale;
let text_baseline = UI.typeset.baseline_per_cell_height * cell_height;
let rows_past = |reach: f32, room: f32| {
((reach - 2.0 * padding - room) / cell_height)
.ceil()
.max(0.0) as u16
};
let covered = cover(height, cell_height);
let (row_cells, slice_baseline) = match placement {
_ if covered == 1 => (1, 0),
Placement::Alone => (covered, (covered - 1) / 2),
Placement::AmongText => {
let rows_above = rows_past(above, text_baseline);
let rows_below = rows_past(below, cell_height - text_baseline);
(rows_above + 1 + rows_below, rows_above)
}
};
let row_height = f32::from(row_cells) * cell_height;
let offset = if placement == Placement::Alone && covered > 1 {
(row_height - height * scale) / 2.0
} else {
(f32::from(slice_baseline) * cell_height + text_baseline)
.min(row_height - below)
.max(above - padding)
- above
};
Some(Self {
scale,
top,
offset,
cells: Size::new(
cover(widest, cell_width).min(width),
row_cells * rows.len() as u16,
),
rows,
row_cells,
slice_baseline,
cell: canvas.cell,
})
}
fn baseline_row(&self) -> usize {
match self.rows.len() {
1 => usize::from(self.slice_baseline),
_ => usize::from(self.cells.height.saturating_sub(1) / 2),
}
}
fn png(&self, tree: &Tree) -> Option<Vec<u8>> {
let width = u32::from(self.cells.width) * u32::from(self.cell.width);
let row_height = u32::from(self.row_cells) * u32::from(self.cell.height);
let mut pixmap = Pixmap::new(width, row_height * self.rows.len() as u32)?;
for (index, row) in self.rows.iter().enumerate() {
let slice = (((row.end - row.start) * self.scale).ceil() as u32).clamp(1, width);
let mut strip = Pixmap::new(slice, row_height)?;
let transform = Transform::from_scale(self.scale, self.scale)
.post_translate(-row.start * self.scale, self.offset - self.top * self.scale);
resvg::render(tree, transform, &mut strip.as_mut());
pixmap.draw_pixmap(
0,
(row_height * index as u32) as i32,
strip.as_ref(),
&PixmapPaint::default(),
Transform::identity(),
None,
);
}
pixmap.encode_png().ok()
}
}
fn wrapped(width: f32, breaks: &[Break], room: f32) -> Vec<Range<f32>> {
let mut rows = Vec::new();
let mut start = 0.0;
while width - start > room {
let ahead = || breaks.iter().filter(|cut| cut.start > start);
let Some(cut) = ahead()
.take_while(|cut| cut.end - start <= room)
.last()
.or_else(|| ahead().next())
else {
break;
};
rows.push(start..cut.end);
start = cut.start;
}
rows.push(start..width);
rows
}
#[cfg(test)]
mod tests {
use resvg::tiny_skia::PremultipliedColorU8;
use super::*;
const CANVAS: Canvas = Canvas {
cell: Size::new(10, 20),
passthrough: Passthrough::Direct,
};
#[test]
fn terminals_are_named_without_versions_or_families() {
assert_eq!(printable_name("ghostty 1.3.1").as_deref(), Some("ghostty"));
assert_eq!(printable_name("kitty(0.36.4)").as_deref(), Some("kitty"));
assert_eq!(printable_name("xterm-ghostty").as_deref(), Some("ghostty"));
assert_eq!(printable_name("WezTerm").as_deref(), Some("wezterm"));
assert_eq!(printable_name("xterm-256color"), None);
assert_eq!(printable_name(""), None);
}
#[test]
fn a_type_annotation_of_words_and_rings_is_set() {
let annotation = r"\textrm{Matrix}\ R^{1}\ \textrm{<--}\ \mathbb{Q}[x,y]^{2}";
assert!(typeset::svg(annotation, Rgb([255, 255, 255])).is_ok());
}
fn formula(tex: &str) -> Formula {
let typeset = typeset::svg_colored(
tex,
tex,
Rgb([255, 255, 255]),
UI.matrix_row_spacing_percent,
)
.unwrap();
Formula {
tree: Tree::from_str(&typeset.svg, &Options::default()).unwrap(),
breaks: typeset.breaks,
baseline: typeset.baseline,
}
}
#[test]
fn ordinary_delimiters_do_not_add_a_terminal_row() {
for tex in [
"1",
"(1)",
"[1]",
r"\left(1\right)",
r"\left[1\right]",
r"\left\{1\right\}",
] {
let formula = formula(tex);
let raster = Raster::fitted(&formula, CANVAS, 80, Placement::AmongText).unwrap();
assert_eq!(
raster.cells.height,
1,
"{tex}: SVG height {}, scale {}",
formula.tree.size().height(),
raster.scale
);
}
}
#[test]
fn one_row_formulas_rest_their_ink_on_the_text_baseline() {
let cell = Size::new(20, 44);
let canvas = Canvas { cell, ..CANVAS };
let baseline = UI.typeset.baseline_per_cell_height * f32::from(cell.height);
for tex in ["a", "c", "ab", "ac", "1", "x", r"a^{2}", r"a^{2}b"] {
let formula = formula(tex);
let raster = Raster::fitted(&formula, canvas, 80, Placement::AmongText).unwrap();
assert_eq!(raster.cells.height, 1, "{tex}");
let pixmap = Pixmap::decode_png(&raster.png(&formula.tree).unwrap()).unwrap();
let last_inked_row = pixmap
.pixels()
.chunks(pixmap.width() as usize)
.rposition(|row| row.iter().any(|pixel| pixel.alpha() > u8::MAX / 2))
.unwrap();
let below_baseline = last_inked_row as f32 + 1.0 - baseline;
assert!(below_baseline.abs() <= 1.5, "{tex}: {below_baseline}");
}
}
#[test]
fn a_tall_formula_beside_text_stands_around_the_row_of_its_baseline() {
let cell = Size::new(20, 44);
let canvas = Canvas { cell, ..CANVAS };
let formula = formula(r"\frac{1}{2}");
let raster = Raster::fitted(&formula, canvas, 80, Placement::AmongText).unwrap();
let pixmap = Pixmap::decode_png(&raster.png(&formula.tree).unwrap()).unwrap();
let inked = |row: &[PremultipliedColorU8]| row.iter().any(|pixel| pixel.alpha() > 0);
let row_of = |pixel_row: usize| pixel_row / usize::from(cell.height);
let mut rows = pixmap.pixels().chunks(pixmap.width() as usize);
let first = rows.clone().position(inked).unwrap();
let last = rows.rposition(inked).unwrap();
let baseline = raster.baseline_row();
assert!(
row_of(first) < baseline,
"numerator above: {first}, {baseline}"
);
assert!(
row_of(last) > baseline,
"denominator below: {last}, {baseline}"
);
assert_eq!(usize::from(raster.cells.height), baseline * 2 + 1);
let alone = Raster::fitted(&formula, canvas, 80, Placement::Alone).unwrap();
assert!(alone.cells.height < raster.cells.height);
}
#[test]
fn a_picture_covers_whole_cells_and_fills_them_exactly() {
let formula = formula(r"\frac{x+y}{x-z}");
let raster = Raster::fitted(&formula, CANVAS, 80, Placement::AmongText).unwrap();
let png = raster.png(&formula.tree).unwrap();
let pixmap = Pixmap::decode_png(&png).unwrap();
assert_eq!(pixmap.width(), u32::from(raster.cells.width) * 10);
assert_eq!(pixmap.height(), u32::from(raster.cells.height) * 20);
assert!(
raster.cells.height > 1,
"a fraction stands taller than a row"
);
let enlarged = Canvas {
cell: Size::new(CANVAS.cell.width * 2, CANVAS.cell.height * 2),
..CANVAS
};
let zoomed = Raster::fitted(&formula, enlarged, 80, Placement::AmongText).unwrap();
let zoomed = Pixmap::decode_png(&zoomed.png(&formula.tree).unwrap()).unwrap();
assert_eq!(zoomed.width(), pixmap.width() * 2);
assert_eq!(zoomed.height(), pixmap.height() * 2);
}
#[test]
fn a_wide_formula_shrinks_to_the_width_until_it_would_be_unreadable() {
let formula = formula(r"1\,267\,650\,600\,228\,229\,401\,496\,703\,205\,376");
let natural = Raster::fitted(&formula, CANVAS, u16::MAX, Placement::AmongText)
.unwrap()
.cells
.width;
let shrunk = Raster::fitted(&formula, CANVAS, natural - 1, Placement::AmongText).unwrap();
let refused = Raster::fitted(&formula, CANVAS, 1, Placement::AmongText);
assert!(shrunk.cells.width < natural);
assert!(refused.is_none());
}
#[test]
fn a_long_list_wraps_after_its_commas_at_full_size() {
let entries = (1..=60).map(|n| n.to_string()).collect::<Vec<_>>();
let formula = formula(&format!(r"\left\{{{}\right\}}", entries.join(r",\,")));
let unbroken = Raster::fitted(&formula, CANVAS, u16::MAX, Placement::AmongText).unwrap();
let width = unbroken.cells.width / 3;
let wrapped = Raster::fitted(&formula, CANVAS, width, Placement::AmongText).unwrap();
assert_eq!(unbroken.rows.len(), 1);
assert!(wrapped.rows.len() >= 3, "{} rows", wrapped.rows.len());
assert_eq!(wrapped.scale, unbroken.scale, "wrapping never shrinks");
assert!(wrapped.cells.width <= width);
assert_eq!(
wrapped.cells.height,
unbroken.cells.height * wrapped.rows.len() as u16
);
}
#[test]
fn a_fraction_bar_is_never_cut() {
let formula = formula(r"\frac{1,\,2,\,3,\,4,\,5,\,6,\,7,\,8}{9}");
assert!(formula.breaks.is_empty());
}
#[test]
fn another_matrix_row_spacing_makes_another_picture() {
let mut gallery = GALLERY.lock().unwrap();
let tex = r"\begin{array}{c}x\\y\end{array}";
let formula = |matrix_row_spacing_percent| Typesetting {
tex,
painted: tex,
code: tex,
matrix_row_spacing_percent,
};
let ink = Rgb([1, 2, 3]);
let placement = Placement::Alone;
let tight = gallery.formula(formula(100), ink, CANVAS, 80, placement);
let spaced = gallery.formula(formula(200), ink, CANVAS, 80, placement);
assert!(!Arc::ptr_eq(&tight.unwrap().0, &spaced.unwrap().0));
}
#[test]
fn a_formula_is_made_once_and_sent_once_whatever_the_wider_width() {
let mut gallery = GALLERY.lock().unwrap();
gallery.unsent.clear();
let ink = Rgb([1, 2, 3]);
let square = Typesetting {
tex: "x^2",
painted: r"\textcolor{red}{x^2}",
code: "x^2",
matrix_row_spacing_percent: UI.matrix_row_spacing_percent,
};
let placement = Placement::AmongText;
let first = gallery.formula(square, ink, CANVAS, 80, placement);
let again = gallery.formula(square, ink, CANVAS, 120, placement);
assert!(Arc::ptr_eq(&first.unwrap().0, &again.unwrap().0));
assert_eq!(gallery.unsent.len(), 1);
assert_eq!(
gallery.sources.get(&gallery.last_id).map(String::as_str),
Some(square.code)
);
let enlarged = Canvas {
cell: Size::new(CANVAS.cell.width * 2, CANVAS.cell.height * 2),
..CANVAS
};
let (original, _) = gallery.formula(square, ink, CANVAS, 80, placement).unwrap();
let (zoomed, _) = gallery
.formula(square, ink, enlarged, 80, placement)
.unwrap();
assert!(!Arc::ptr_eq(&original, &zoomed));
assert_eq!(original.lines().len(), zoomed.lines().len());
assert_eq!(original.lines()[0].width(), zoomed.lines()[0].width());
assert_eq!(gallery.unsent.len(), 2);
let (restored, _) = gallery.formula(square, ink, CANVAS, 80, placement).unwrap();
assert!(Arc::ptr_eq(&original, &restored));
assert_eq!(gallery.unsent.len(), 2);
}
}