Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,5 @@ regex = "1.12.4"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
crossterm = "0.28"
ttf-parser = { version = "0.25.1", default-features = false, features = ["std"] }
dialoguer = { version = "0.11", default-features = false }
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ Colors accept ANSI-256 (`9`), hex (`#ff0000`), or rgb (`255,0,0`).

### Screenshots

Screenshots render a snapshot of the session in the current terminal by default, but can render an SVG using the `-o` output flag
Screenshots render a snapshot of the session in the current terminal by default, but can render an SVG using the `-o` output flag. Nerd Font icons are embedded as vector paths, so SVGs remain self-contained without changing the font stack for regular text.

<p align="center">
<img alt="full-color SVG screenshot of a TUI rendered by shell-use" src="static/screen.svg" width="400">
Expand Down
21 changes: 21 additions & 0 deletions assets/nerd-fonts/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
The MIT License (MIT)

Copyright (c) 2014 Ryan L McIntyre

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Binary file added assets/nerd-fonts/SymbolsNerdFontMono-Regular.ttf
Binary file not shown.
1 change: 1 addition & 0 deletions src/render/mod.rs
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
mod nerd_font;
pub mod svg;
278 changes: 278 additions & 0 deletions src/render/nerd_font.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write;

use ttf_parser::{Face, OutlineBuilder};

use crate::terminal::emu::EmuCell;

// Nerd Fonts Symbols v3.4.0 is MIT-licensed; see the adjacent LICENSE.
const FONT_DATA: &[u8] = include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/assets/nerd-fonts/SymbolsNerdFontMono-Regular.ttf"
));

struct Glyph {
path: String,
advance: u16,
bounds: ttf_parser::Rect,
}

#[derive(Default)]
struct SvgPathBuilder {
path: String,
}

impl OutlineBuilder for SvgPathBuilder {
fn move_to(&mut self, x: f32, y: f32) {
let _ = write!(self.path, "M{x:.1},{y:.1}");
}

fn line_to(&mut self, x: f32, y: f32) {
let _ = write!(self.path, "L{x:.1},{y:.1}");
}

fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
let _ = write!(self.path, "Q{x1:.1},{y1:.1},{x:.1},{y:.1}");
}

fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
let _ = write!(self.path, "C{x1:.1},{y1:.1},{x2:.1},{y2:.1},{x:.1},{y:.1}");
}

fn close(&mut self) {
self.path.push('Z');
}
}

struct GlyphTransform {
x: f32,
y: f32,
scale_x: f32,
scale_y: f32,
}

pub(super) struct NerdFont {
glyphs: BTreeMap<char, Glyph>,
scale: f32,
}

impl NerdFont {
pub fn new(rows: &[Vec<EmuCell>], font_size: f32) -> Self {
let chars: BTreeSet<char> = rows
.iter()
.flatten()
.flat_map(|cell| cell.ch.chars())
.filter(|c| is_private_use(*c))
.collect();
if chars.is_empty() {
return Self {
glyphs: BTreeMap::new(),
scale: 1.0,
};
}

let face = Face::parse(FONT_DATA, 0).expect("bundled Nerd Font must be valid");
let mut glyphs = BTreeMap::new();
for c in chars {
let Some(glyph_id) = face.glyph_index(c) else {
continue;
};
let mut builder = SvgPathBuilder::default();
let Some(bounds) = face.outline_glyph(glyph_id, &mut builder) else {
continue;
};
if builder.path.is_empty() {
continue;
}
glyphs.insert(
c,
Glyph {
path: builder.path,
advance: face
.glyph_hor_advance(glyph_id)
.unwrap_or(face.units_per_em()),
bounds,
},
);
}

Self {
glyphs,
scale: font_size / face.units_per_em() as f32,
}
}

pub fn write_defs(&self, out: &mut String) {
if self.glyphs.is_empty() {
return;
}
out.push_str("<defs>");
for (c, glyph) in &self.glyphs {
let codepoint = *c as u32;
let _ = write!(out, r#"<path id="nf-{codepoint:x}" d="{}"/>"#, glyph.path);
}
out.push_str("</defs>");
}

pub fn prepare_run(&self, text: &str, width: f32, cell_width: f32) -> (String, f32) {
let mut masked = String::with_capacity(text.len());
let mut extra_width = 0.0;
for c in text.chars() {
if let Some(glyph) = self.glyphs.get(&c) {
masked.push(' ');
if !is_powerline_separator(c) {
extra_width += glyph.advance as f32 * self.scale - cell_width;
}
} else {
masked.push(c);
}
}
let natural_width = width + extra_width;
let x_adjust = if natural_width > 0.0 {
width / natural_width
} else {
1.0
};
(masked, x_adjust)
}

pub fn write_use(
&self,
out: &mut String,
c: char,
origin: (f32, f32),
size: (f32, f32),
run_x_adjust: f32,
fill: &str,
) {
let Some(transform) = self.transform(c, origin, size, run_x_adjust) else {
return;
};
let codepoint = c as u32;
let _ = write!(
out,
r##"<use href="#nf-{codepoint:x}" transform="translate({x:.2} {y:.2}) scale({scale_x:.6} -{scale_y:.6})" fill="{fill}"/>"##,
x = transform.x,
y = transform.y,
scale_x = transform.scale_x,
scale_y = transform.scale_y,
);
}

fn transform(
&self,
c: char,
(cell_x, cell_y): (f32, f32),
(cell_width, cell_height): (f32, f32),
run_x_adjust: f32,
) -> Option<GlyphTransform> {
let glyph = self.glyphs.get(&c)?;
let bounds_width = (glyph.bounds.x_max - glyph.bounds.x_min) as f32;
let bounds_height = (glyph.bounds.y_max - glyph.bounds.y_min) as f32;
if is_powerline_separator(c) {
let scale_x = cell_width / bounds_width;
let scale_y = cell_height / bounds_height;
Some(GlyphTransform {
x: cell_x - glyph.bounds.x_min as f32 * scale_x,
y: cell_y + glyph.bounds.y_max as f32 * scale_y,
scale_x,
scale_y,
})
} else {
let scale_x = self.scale * run_x_adjust;
let rendered_advance = glyph.advance as f32 * scale_x;
let rendered_height = bounds_height * self.scale;
Some(GlyphTransform {
x: cell_x + (cell_width - rendered_advance) / 2.0,
y: cell_y
+ (cell_height - rendered_height) / 2.0
+ glyph.bounds.y_max as f32 * self.scale,
scale_x,
scale_y: self.scale,
})
}
}
}

fn is_private_use(c: char) -> bool {
matches!(
c as u32,
0xe000..=0xf8ff | 0xf0000..=0xffffd | 0x100000..=0x10fffd
)
}

fn is_powerline_separator(c: char) -> bool {
matches!(c as u32, 0xe0b0..=0xe0d4)
}

#[cfg(test)]
mod tests {
use super::*;
use crate::terminal::emu::Color;

fn cell(ch: &str) -> EmuCell {
EmuCell {
ch: ch.to_string(),
fg: Color::Default,
bg: Color::Default,
bold: false,
dim: false,
italic: false,
underline: false,
inverse: false,
invisible: false,
strike: false,
}
}

#[test]
fn powerline_glyphs_fill_the_terminal_cell() {
let rows = vec![vec![cell("\u{e0b0}")]];
let font = NerdFont::new(&rows, 17.0);
let transform = font
.transform('\u{e0b0}', (15.0, 38.0), (10.0, 21.0), 1.0)
.unwrap();
let glyph = &font.glyphs[&'\u{e0b0}'];

let left = transform.x + glyph.bounds.x_min as f32 * transform.scale_x;
let right = transform.x + glyph.bounds.x_max as f32 * transform.scale_x;
let top = transform.y - glyph.bounds.y_max as f32 * transform.scale_y;
let bottom = transform.y - glyph.bounds.y_min as f32 * transform.scale_y;
assert!((left - 15.0).abs() < 0.001);
assert!((right - 25.0).abs() < 0.001);
assert!((top - 38.0).abs() < 0.001);
assert!((bottom - 59.0).abs() < 0.001);
}

#[test]
fn regular_glyphs_are_vertically_centered() {
let rows = vec![vec![cell("\u{f115}")]];
let font = NerdFont::new(&rows, 17.0);
let transform = font
.transform('\u{f115}', (15.0, 38.0), (10.0, 21.0), 1.0)
.unwrap();
let glyph = &font.glyphs[&'\u{f115}'];

let top = transform.y - glyph.bounds.y_max as f32 * transform.scale_y;
let bottom = transform.y - glyph.bounds.y_min as f32 * transform.scale_y;
assert!(((top - 38.0) - (59.0 - bottom)).abs() < 0.001);
}

#[test]
fn powerline_glyphs_do_not_adjust_run_width() {
let rows = vec![vec![cell("\u{e0b0}")]];
let font = NerdFont::new(&rows, 17.0);
let (_, x_adjust) = font.prepare_run("\u{e0b0}", 10.0, 10.0);
assert_eq!(x_adjust, 1.0);
}

#[test]
fn bundled_font_includes_its_mit_license() {
let license = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/assets/nerd-fonts/LICENSE"
));
assert!(license.starts_with("The MIT License (MIT)"));
assert!(license.contains("Copyright (c) 2014 Ryan L McIntyre"));
}
}
Loading
Loading