Compare commits

..
9 Commits
Author SHA1 Message Date
Gregor Lohaus 567ffd76b2 pass theme 2026-09-08 11:00:13 +02:00
Gregor Lohaus 2a4945b363 Merge branch 'font-rendering-fix' 2026-09-08 10:44:03 +02:00
Gregor Lohaus dd1622350d remove result 2026-09-08 10:43:58 +02:00
Gregor Lohaus 50e1319b92 remove result from ignore 2026-09-08 10:41:51 +02:00
Gregor Lohaus fa8057989e font family 2026-09-08 10:40:09 +02:00
Gregor Lohaus 51a497cb04 theming 2026-09-08 10:32:15 +02:00
Gregor Lohaus 349f38b31c rounded panes, cwd fix mac 2026-09-08 04:18:01 +02:00
Gregor Lohaus d8bfbf0294 app bundle 2026-09-08 03:49:37 +02:00
Gregor Lohaus 3b04fe4f8b remove result 2026-09-08 02:28:57 +02:00
34 changed files with 5874 additions and 107 deletions
+1
View File
@@ -1,2 +1,3 @@
/target/
/.tools/
/.worktrees
Generated
+58
View File
@@ -404,6 +404,15 @@ dependencies = [
"libc",
]
[[package]]
name = "core_maths"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30"
dependencies = [
"libm",
]
[[package]]
name = "crc32fast"
version = "1.5.1"
@@ -719,6 +728,29 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "fontconfig-parser"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
dependencies = [
"roxmltree",
]
[[package]]
name = "fontdb"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905"
dependencies = [
"fontconfig-parser",
"log",
"memmap2",
"slotmap",
"tinyvec",
"ttf-parser",
]
[[package]]
name = "foreign-types"
version = "0.5.0"
@@ -2087,6 +2119,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
[[package]]
name = "roxmltree"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
[[package]]
name = "rustc-hash"
version = "1.1.0"
@@ -2271,10 +2309,12 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
name = "slerm"
version = "0.1.0"
dependencies = [
"ab_glyph",
"anyhow",
"base64",
"eframe",
"egui",
"fontdb",
"libghostty-vt",
"png",
"portable-pty",
@@ -2488,6 +2528,21 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "toml"
version = "0.9.12+spec-1.1.0"
@@ -2570,6 +2625,9 @@ name = "ttf-parser"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
dependencies = [
"core_maths",
]
[[package]]
name = "type-map"
+2
View File
@@ -8,6 +8,8 @@ license = "MIT"
[dependencies]
anyhow = "1"
ab_glyph = "0.2"
fontdb = "0.23"
eframe = { version = "0.33", default-features = false, features = ["default_fonts", "wgpu", "wayland", "x11"] }
egui = "0.33"
libghostty-vt = { version = "=0.2.1", features = ["png"] }
+34 -4
View File
@@ -2,14 +2,44 @@
# Omit shell to use $SHELL. Arguments are separate array entries.
# shell = ["/bin/bash", "--login"]
# working_directory = "/home/you"
font_path = "/usr/local/share/fonts/MononokiNerdFontMono-Regular.ttf"
# Select an installed family; regular, bold, italic and bold italic are matched
# automatically. Install a patched Nerd Font to get its icons.
# font_family = "GeistMono Nerd Font Mono"
# Alternatively load a file; matching style faces are discovered beside it.
# font_path = "/usr/local/share/fonts/MononokiNerdFontMono-Regular.ttf"
# Optional overrides for individual faces:
# font_bold_path = "/path/to/Bold.ttf"
# font_italic_path = "/path/to/Italic.ttf"
# font_bold_italic_path = "/path/to/BoldItalic.ttf"
# Ordered installed fallback families, used for missing glyphs in every style.
# font_fallbacks = ["Symbols Nerd Font Mono"]
# Symbols Nerd Font Mono (or Symbols Nerd Font) is also used automatically when
# installed. Missing styles use the closest available face in the family;
# regular-only fonts retain synthetic bold/italic emphasis.
font_size = 16
scrollback = 10000
window_width = 1100
window_height = 720
background = "#17191c"
foreground = "#e2e4e8"
accent = "#58c4ad"
# Any theme from `slerm --list-themes`: "slerm" plus every theme Helix ships.
theme = "slerm"
# Shorthand for the [colors] entries of the same name.
# background = "#17191c"
# foreground = "#e2e4e8"
# accent = "#58c4ad"
# Colors overriding the theme, all optional. Alongside the three above:
# cursor, selection_background, selection_foreground, border,
# chrome_background, chrome_foreground, chrome_active_background,
# popup_background,
# and the sixteen ANSI colors:
# black, red, green, yellow, blue, magenta, cyan, white,
# and each of those again as bright_black ... bright_white.
# [colors]
# cursor = "#58c4ad"
# red = "#cc6666"
# bright_red = "#d54e53"
[kitty]
image_storage_mb = 64
+699
View File
@@ -0,0 +1,699 @@
//! Converts the themes Helix ships in `runtime/themes` into `src/themes.rs`.
//!
//! Helix themes describe an editor: they name syntax scopes, not the sixteen
//! ANSI colors a terminal needs. The conversion therefore reads the UI scopes
//! directly (`ui.background`, `ui.statusline`, ...) and *infers* the ANSI
//! palette by scoring every color the theme uses against the hue each slot is
//! expected to hold, preferring the scopes that conventionally carry it.
//!
//! cargo run --example gen_themes -- <helix>/runtime/themes src/themes.rs
use std::{collections::BTreeMap, fmt::Write as _, path::Path};
use toml::Value;
type Rgb = [u8; 3];
/// A color as written in a theme: its RGB value, plus the ANSI slot it named if
/// it was written as one of Helix's terminal color names rather than as hex.
#[derive(Clone, Copy, PartialEq, Eq)]
struct Color {
rgb: Rgb,
ansi: Option<u8>,
}
/// Helix's terminal color names and the palette slots they select. `gray` is
/// the dim one and `light-gray` the bright one, matching how Helix hands them
/// to crossterm (`Gray` -> dark grey, `LightGray` -> grey).
const NAMED: &[(&str, u8)] = &[
("black", 0),
("red", 1),
("green", 2),
("yellow", 3),
("blue", 4),
("magenta", 5),
("cyan", 6),
("light-gray", 7),
("gray", 8),
("light-red", 9),
("light-green", 10),
("light-yellow", 11),
("light-blue", 12),
("light-magenta", 13),
("light-cyan", 14),
("white", 15),
];
/// The xterm defaults, used both for themes that name terminal colors outright
/// and as the last resort for a slot nothing in a theme fits.
const XTERM: [Rgb; 16] = [
[0x00, 0x00, 0x00],
[0xcd, 0x00, 0x00],
[0x00, 0xcd, 0x00],
[0xcd, 0xcd, 0x00],
[0x00, 0x00, 0xee],
[0xcd, 0x00, 0xcd],
[0x00, 0xcd, 0xcd],
[0xe5, 0xe5, 0xe5],
[0x7f, 0x7f, 0x7f],
[0xff, 0x00, 0x00],
[0x00, 0xff, 0x00],
[0xff, 0xff, 0x00],
[0x5c, 0x5c, 0xff],
[0xff, 0x00, 0xff],
[0x00, 0xff, 0xff],
[0xff, 0xff, 0xff],
];
/// The hue each chromatic slot aims for, and the scopes that conventionally
/// carry it. Earlier scopes are preferred, but only as a tie-break: a theme
/// whose `function` is teal still gets a teal blue rather than a stray hue.
const SLOTS: [(u8, f32, &[&str]); 6] = [
(
1,
0.0,
&[
"error",
"diagnostic.error",
"diff.minus",
"keyword.control.exception",
"markup.list.unchecked",
"variable.builtin",
],
),
(
2,
120.0,
&[
"string",
"diff.plus",
"markup.raw",
"hint",
"diagnostic.hint",
"variable.other.member",
],
),
(
3,
48.0,
&[
"warning",
"diagnostic.warning",
"constant.numeric",
"attribute",
"type",
"markup.heading",
],
),
(
4,
230.0,
&[
"function",
"info",
"diagnostic.info",
"constructor",
"markup.link.url",
"diff.delta",
],
),
(
5,
300.0,
&[
"keyword",
"constant",
"special",
"namespace",
"markup.link.text",
"type.enum.variant",
],
),
(
6,
185.0,
&[
"operator",
"constant.character.escape",
"markup.quote",
"string.special",
"punctuation.special",
"label",
],
),
];
/// One theme file, before inheritance is resolved.
#[derive(Default)]
struct Raw {
inherits: Option<String>,
palette: BTreeMap<String, String>,
/// Scope -> (fg, bg), each already flattened out of Helix's several
/// spellings (bare string, `{ fg, bg }`, `{ underline = { color } }`).
scopes: BTreeMap<String, (Option<String>, Option<String>)>,
}
fn read(path: &Path) -> Raw {
let text = std::fs::read_to_string(path).expect("cannot read theme file");
let table: toml::Table = toml::from_str(&text).expect("theme is not valid TOML");
let mut raw = Raw::default();
for (key, value) in table {
match (key.as_str(), value) {
("inherits", Value::String(name)) => raw.inherits = Some(name),
("palette", Value::Table(entries)) => {
for (name, value) in entries {
if let Value::String(color) = value {
raw.palette.insert(name, color);
}
}
}
(_, Value::String(fg)) => {
raw.scopes.insert(key, (Some(fg), None));
}
(_, Value::Table(style)) => {
let text = |v: Option<&Value>| v.and_then(Value::as_str).map(str::to_owned);
let fg = text(style.get("fg")).or_else(|| {
style
.get("underline")
.and_then(Value::as_table)
.and_then(|u| text(u.get("color")))
});
raw.scopes.insert(key, (fg, text(style.get("bg"))));
}
_ => {}
}
}
raw
}
/// A theme with its `inherits` chain folded in: the child's palette entries and
/// whole scope entries win over the parent's, exactly as Helix merges them.
struct Theme {
palette: BTreeMap<String, String>,
scopes: BTreeMap<String, (Option<String>, Option<String>)>,
}
fn flatten(name: &str, raws: &BTreeMap<String, Raw>) -> Theme {
let mut chain = vec![];
let mut current = Some(name.to_owned());
while let Some(name) = current {
if chain.contains(&name) {
break;
}
current = raws.get(&name).and_then(|r| r.inherits.clone());
chain.push(name);
}
let mut theme = Theme {
palette: BTreeMap::new(),
scopes: BTreeMap::new(),
};
// Ancestors first so that descendants overwrite them.
for name in chain.iter().rev() {
let Some(raw) = raws.get(name) else { continue };
theme.palette.extend(raw.palette.clone());
theme.scopes.extend(raw.scopes.clone());
}
theme
}
impl Theme {
fn color(&self, text: &str) -> Option<Color> {
let text = self.palette.get(text).map(String::as_str).unwrap_or(text);
if let Some((_, index)) = NAMED.iter().find(|(name, _)| *name == text) {
return Some(Color {
rgb: XTERM[*index as usize],
ansi: Some(*index),
});
}
let digits = text.strip_prefix('#')?;
let expand = |c: u8| (c << 4) | c;
let nibble = |i: usize| u8::from_str_radix(&digits[i..i + 1], 16).ok();
let byte = |i: usize| u8::from_str_radix(&digits[i..i + 2], 16).ok();
let rgb = match digits.len() {
3 => [expand(nibble(0)?), expand(nibble(1)?), expand(nibble(2)?)],
6 | 8 => [byte(0)?, byte(2)?, byte(4)?],
_ => return None,
};
Some(Color { rgb, ansi: None })
}
fn fg(&self, scope: &str) -> Option<Color> {
self.color(self.scopes.get(scope)?.0.as_deref()?)
}
fn bg(&self, scope: &str) -> Option<Color> {
self.color(self.scopes.get(scope)?.1.as_deref()?)
}
/// Every color the theme mentions anywhere, with the number of scopes that
/// use it. This is the pool the ANSI slots are chosen from; the count
/// separates a theme's working colors from one-off accents such as the
/// color of a jump label.
fn all(&self) -> Vec<(Color, usize)> {
let mut counts: BTreeMap<Rgb, (Color, usize)> = BTreeMap::new();
let named = self.palette.values().map(String::as_str);
// `ui.*` carries backgrounds, greys and one-off markers such as the
// color of a jump label, none of which belong in a terminal palette.
let scoped = self
.scopes
.iter()
.filter(|(scope, _)| !scope.starts_with("ui."))
.flat_map(|(_, (fg, bg))| [fg, bg])
.filter_map(|c| c.as_deref());
for color in named.chain(scoped).filter_map(|c| self.color(c)) {
counts.entry(color.rgb).or_insert((color, 0)).1 += 1;
}
counts.into_values().collect()
}
}
fn luma(c: Rgb) -> f32 {
let channel = |v: u8| {
let v = v as f32 / 255.0;
if v <= 0.04045 {
v / 12.92
} else {
((v + 0.055) / 1.055).powf(2.4)
}
};
0.2126 * channel(c[0]) + 0.7152 * channel(c[1]) + 0.0722 * channel(c[2])
}
fn contrast(a: Rgb, b: Rgb) -> f32 {
let (a, b) = (luma(a) + 0.05, luma(b) + 0.05);
if a > b { a / b } else { b / a }
}
/// Hue in degrees, saturation and lightness in 0..1.
fn hsl(c: Rgb) -> (f32, f32, f32) {
let [r, g, b] = [
c[0] as f32 / 255.0,
c[1] as f32 / 255.0,
c[2] as f32 / 255.0,
];
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let (span, sum) = (max - min, max + min);
let lightness = sum / 2.0;
if span < f32::EPSILON {
return (0.0, 0.0, lightness);
}
let saturation = span / (1.0 - (sum - 1.0).abs());
let hue = if max == r {
60.0 * (((g - b) / span) % 6.0)
} else if max == g {
60.0 * ((b - r) / span + 2.0)
} else {
60.0 * ((r - g) / span + 4.0)
};
(hue.rem_euclid(360.0), saturation, lightness)
}
fn hue_distance(a: f32, b: f32) -> f32 {
let d = (a - b).abs() % 360.0;
d.min(360.0 - d)
}
fn mix(a: Rgb, b: Rgb, t: f32) -> Rgb {
let channel = |i: usize| (a[i] as f32 + (b[i] as f32 - a[i] as f32) * t).round() as u8;
[channel(0), channel(1), channel(2)]
}
const BLACK: Rgb = [0, 0, 0];
const WHITE: Rgb = [255, 255, 255];
/// The converted theme, mirroring the struct in `src/theme.rs`.
struct Out {
dark: bool,
background: Rgb,
foreground: Rgb,
cursor: Rgb,
selection_bg: Rgb,
selection_fg: Rgb,
accent: Rgb,
chrome_bg: Rgb,
chrome_fg: Rgb,
chrome_active_bg: Rgb,
border: Rgb,
popup_bg: Rgb,
ansi: [Rgb; 16],
}
fn convert(theme: &Theme) -> Out {
let text = theme
.fg("ui.text")
.or_else(|| theme.fg("ui.background"))
.or_else(|| theme.fg("ui.statusline"));
let background = theme
.bg("ui.background")
.or_else(|| theme.bg("ui.popup"))
.or_else(|| theme.bg("ui.statusline"));
// A theme that never states a background (the `term16` family leaves it to
// the terminal) is placed by the brightness of its text instead.
let dark = match (background, text) {
(Some(bg), _) => luma(bg.rgb) < 0.22,
(None, Some(fg)) => luma(fg.rgb) > 0.22,
(None, None) => true,
};
let background = background.map_or(if dark { BLACK } else { WHITE }, |c| c.rgb);
let mut foreground = text.map_or(if dark { WHITE } else { BLACK }, |c| c.rgb);
// A theme that states neither (peachpuff leaves both to the terminal) can
// resolve to text the same shade as its background, which renders nothing.
if contrast(foreground, background) < 2.0 {
foreground = if dark { WHITE } else { BLACK };
}
let far = if dark { WHITE } else { BLACK };
let ansi = palette(theme, background, foreground, dark);
let cursor = theme
.bg("ui.cursor.primary")
.or_else(|| theme.bg("ui.cursor"))
.or_else(|| theme.fg("ui.cursor.primary"))
.or_else(|| theme.fg("ui.cursor"))
.map_or(foreground, |c| c.rgb);
let selection_bg = theme
.bg("ui.selection.primary")
.or_else(|| theme.bg("ui.selection"))
.map_or(mix(background, far, 0.22), |c| c.rgb);
let selection_fg = theme
.fg("ui.selection.primary")
.or_else(|| theme.fg("ui.selection"))
.map(|c| c.rgb)
.filter(|c| contrast(*c, selection_bg) >= 2.5)
.unwrap_or_else(|| {
if contrast(foreground, selection_bg) >= 2.5 {
foreground
} else {
mix(
selection_bg,
if luma(selection_bg) > 0.3 {
BLACK
} else {
WHITE
},
0.85,
)
}
});
let chrome_bg = theme
.bg("ui.statusline")
.or_else(|| theme.bg("ui.popup"))
.map(|c| c.rgb)
.filter(|c| *c != background)
.unwrap_or_else(|| mix(background, far, 0.09));
let chrome_fg = theme
.fg("ui.statusline")
.map(|c| c.rgb)
.filter(|c| contrast(*c, chrome_bg) >= 2.0)
.unwrap_or_else(|| mix(chrome_bg, far, 0.7));
// The accent marks the focused pane: it is drawn both on the title bar and
// as a border on the terminal background, so it has to read on both.
let readable = |c: Rgb| contrast(c, chrome_bg) >= 2.5 && contrast(c, background) >= 2.5;
let vivid = [theme.fg("ui.cursor.match").map(|c| c.rgb), Some(cursor)]
.into_iter()
.flatten()
.find(|c| readable(*c) && hsl(*c).1 > 0.30);
let accent = vivid
// A theme whose cursor is a plain grey still deserves a colored focus
// ring, so fall back to its own blue, cyan or magenta.
.or_else(|| {
[4, 6, 5, 3]
.into_iter()
.map(|i| ansi[i])
.find(|c| readable(*c))
})
.or(Some(cursor).filter(|c| readable(*c)))
.unwrap_or(foreground);
Out {
dark,
background,
foreground,
cursor,
selection_bg,
selection_fg,
accent,
chrome_bg,
chrome_fg,
// The focused pane's title bar steps away from the unfocused ones,
// whichever direction that is for this theme's chrome.
chrome_active_bg: mix(
chrome_bg,
if luma(chrome_bg) > 0.18 { BLACK } else { WHITE },
0.14,
),
// Unfocused borders are a hint, not a frame, so a theme with a loud
// window color is pulled back towards its background.
border: theme
.fg("ui.window")
.or_else(|| theme.fg("ui.virtual.indent-guide"))
.map_or(mix(background, far, 0.28), |c| mix(background, c.rgb, 0.65)),
popup_bg: theme.bg("ui.popup").map_or(chrome_bg, |c| c.rgb),
ansi,
}
}
/// Picks the sixteen ANSI colors. The six chromatic slots score every color the
/// theme uses against the slot's hue; the greys are derived from the background
/// and foreground so that they always stay legible against the theme's own
/// background.
fn palette(theme: &Theme, background: Rgb, foreground: Rgb, dark: bool) -> [Rgb; 16] {
let pool = theme.all();
let mut ansi = XTERM;
// A theme whose palette names terminal colors outright is already
// describing a palette; those entries are taken as written.
let named: BTreeMap<u8, Rgb> = theme
.palette
.keys()
.filter_map(|name| Some((slot_named(name)?, theme.color(name)?.rgb)))
.collect();
let mut taken: Vec<Rgb> = vec![];
let mut assigned = [false; 7];
for (index, rgb) in &named {
ansi[*index as usize] = *rgb;
if let Some(slot) = assigned.get_mut(*index as usize) {
*slot = true;
taken.push(*rgb);
}
}
// Score every slot against every color the theme uses, then hand each color
// to the slot that wants it most. Assigning greedily one slot at a time
// would let an early slot take a color a later slot needed far more, which
// is how palettes with no true blue end up with two cyans.
let mut pairs: Vec<(f32, usize, Rgb)> = vec![];
for (index, hue, scopes) in SLOTS {
if assigned[index as usize] {
continue;
}
let preferred: Vec<Rgb> = scopes
.iter()
.filter_map(|s| Some(theme.fg(s)?.rgb))
.collect();
for (candidate, uses) in &pool {
let (h, s, l) = hsl(candidate.rgb);
// A grey, an unreadable color, the plain text color and a color the
// theme only spelled as a terminal name (which says nothing about
// what that terminal color should be) are never palette entries.
if s < 0.10
|| contrast(candidate.rgb, background) < 1.8
|| candidate.ansi.is_some()
|| candidate.rgb == foreground
|| candidate.rgb == background
{
continue;
}
let rank = preferred
.iter()
.position(|p| *p == candidate.rgb)
.map_or(20.0, |r| r as f32 * 2.5);
// Whichever slot's hue this color sits closest to has the better
// claim on it, so that a theme's red is not spent on magenta just
// because it happens to be the color of `keyword`.
let closest = SLOTS
.iter()
.map(|(_, target, _)| *target)
.min_by(|a, b| hue_distance(h, *a).total_cmp(&hue_distance(h, *b)));
let score = hue_distance(h, hue) * 1.5
+ rank
+ (1.0 - s) * 38.0
+ if closest == Some(hue) { 0.0 } else { 45.0 }
// A color used once is a one-off accent, not a working color.
+ match uses {
0 | 1 => 22.0,
2 => 10.0,
_ => 0.0,
}
+ if l > 0.85 { 40.0 } else { 0.0 };
pairs.push((score, index as usize, candidate.rgb));
}
}
pairs.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)));
for (_, index, rgb) in &pairs {
if !assigned[*index] && !taken.contains(rgb) {
assigned[*index] = true;
ansi[*index] = *rgb;
taken.push(*rgb);
}
}
// A theme with fewer than six usable colors leaves slots over; those share
// whichever color came closest rather than falling back to a stock xterm
// primary that belongs to no theme at all.
for (_, index, rgb) in &pairs {
if !assigned[*index] {
assigned[*index] = true;
ansi[*index] = *rgb;
}
}
for (index, _, _) in SLOTS {
if !assigned[index as usize] {
ansi[index as usize] = mix(XTERM[index as usize], foreground, 0.4);
}
}
// The bright variant is the theme's own lighter shade of the same color
// where it has one, and a lightened copy otherwise. Matching the hue is not
// enough: a pale grey-blue is a poor bright blue, so the candidate has to
// keep most of the base color's chroma, and it cannot be a color another
// slot already owns.
let chroma = |c: Rgb| (*c.iter().max().unwrap() - *c.iter().min().unwrap()) as f32;
for (index, _, _) in SLOTS {
if named.contains_key(&(index + 8)) {
continue;
}
let base = ansi[index as usize];
let (base_hue, _, lightness) = hsl(base);
let brighter = pool
.iter()
.map(|(c, _)| c)
.filter(|c| {
let (h, _, l) = hsl(c.rgb);
hue_distance(h, base_hue) <= 30.0
&& chroma(c.rgb) >= chroma(base) * 0.7
&& (0.05..=0.40).contains(&(l - lightness))
&& contrast(c.rgb, background) >= 2.0
&& !taken.contains(&c.rgb)
})
.min_by(|a, b| hsl(a.rgb).2.total_cmp(&hsl(b.rgb).2));
ansi[index as usize + 8] = brighter.map_or_else(
|| mix(base, WHITE, if lightness > 0.7 { 0.12 } else { 0.26 }),
|c| c.rgb,
);
}
// Greys: black through bright white always span from the theme's darker end
// to its lighter end, so ANSI text stays readable in light themes too.
let comment = theme
.fg("comment")
.map(|c| c.rgb)
.filter(|c| contrast(*c, background) >= 2.0);
let greys = if dark {
[
mix(background, WHITE, 0.13),
foreground,
comment.unwrap_or_else(|| mix(background, WHITE, 0.42)),
mix(foreground, WHITE, 0.35),
]
} else {
[
mix(foreground, BLACK, 0.25),
mix(background, BLACK, 0.13),
comment.unwrap_or_else(|| mix(foreground, background, 0.45)),
background,
]
};
for (slot, grey) in [0, 7, 8, 15].into_iter().zip(greys) {
ansi[slot as usize] = *named.get(&slot).unwrap_or(&grey);
}
ansi
}
/// The palette slot a `[palette]` key names outright, so that a theme defining
/// `red`/`bright-blue` and friends keeps its own values.
fn slot_named(name: &str) -> Option<u8> {
let name = name.to_ascii_lowercase().replace('_', "-");
let (bright, base) = match name
.strip_prefix("bright-")
.or_else(|| name.strip_prefix("light-"))
{
Some(base) => (8, base.to_owned()),
None => (0, name),
};
let index = match base.as_str() {
"black" => 0,
"red" => 1,
"green" => 2,
"yellow" => 3,
"blue" => 4,
"magenta" => 5,
"cyan" => 6,
"white" => 7,
_ => return None,
};
Some(index + bright)
}
fn main() {
let mut args = std::env::args().skip(1);
let dir = args
.next()
.expect("usage: gen_themes <themes dir> <out.rs>");
let out = args
.next()
.expect("usage: gen_themes <themes dir> <out.rs>");
let mut raws = BTreeMap::new();
for entry in std::fs::read_dir(&dir).expect("cannot read themes directory") {
let path = entry.expect("cannot read theme").path();
if path.extension().is_some_and(|e| e == "toml") {
let name = path.file_stem().unwrap().to_string_lossy().into_owned();
raws.insert(name, read(&path));
}
}
let mut text = String::from(
"//! The themes Helix ships with, converted by `cargo run --example gen_themes`.\n\
//! Generated file: edit the generator, not this.\n\
//!\n\
//! Upstream: <https://github.com/helix-editor/helix/tree/master/runtime/themes>.\n\
//! The individual themes keep their own authors and licenses; see `themes/LICENSES`.\n\n\
use crate::theme::Theme;\n\n\
/// Every built-in theme, sorted by name so that lookup can binary search.\n\
pub static THEMES: &[(&str, Theme)] = &[\n",
);
for name in raws.keys() {
let out = convert(&flatten(name, &raws));
let hex = |c: Rgb| format!("0x{:02x}{:02x}{:02x}", c[0], c[1], c[2]);
let ansi = out
.ansi
.iter()
.map(|c| hex(*c))
.collect::<Vec<_>>()
.join(", ");
writeln!(
text,
" (\n {name:?},\n Theme {{\n \
dark: {dark},\n background: {bg},\n foreground: {fg},\n \
cursor: {cursor},\n \
selection_bg: {sel_bg},\n selection_fg: {sel_fg},\n \
accent: {accent},\n chrome_bg: {chrome_bg},\n \
chrome_fg: {chrome_fg},\n chrome_active_bg: {chrome_active},\n \
border: {border},\n popup_bg: {popup},\n \
ansi: [\n {ansi},\n ],\n }},\n ),",
dark = out.dark,
bg = hex(out.background),
fg = hex(out.foreground),
cursor = hex(out.cursor),
sel_bg = hex(out.selection_bg),
sel_fg = hex(out.selection_fg),
accent = hex(out.accent),
chrome_bg = hex(out.chrome_bg),
chrome_fg = hex(out.chrome_fg),
chrome_active = hex(out.chrome_active_bg),
border = hex(out.border),
popup = hex(out.popup_bg),
)
.unwrap();
}
text.push_str("];\n");
std::fs::write(&out, text).expect("cannot write output");
eprintln!("wrote {} themes to {out}", raws.len());
}
+33
View File
@@ -132,6 +132,35 @@
unset slermCache slermStale slermTmp slermDir slermGlob slermLib
fi
'';
# macOS only gives a process a dock icon, a menu bar and a real
# application name when it runs from a bundle, so ship one and keep
# bin/slerm as a symlink into it for terminal use.
appBundle = version: ''
app="$out/Applications/slerm.app"
mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources"
cat > "$app/Contents/Info.plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key><string>slerm</string>
<key>CFBundleDisplayName</key><string>slerm</string>
<key>CFBundleExecutable</key><string>slerm</string>
<key>CFBundleIdentifier</key><string>org.slerm.slerm</string>
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>${version}</string>
<key>CFBundleVersion</key><string>${version}</string>
<key>LSApplicationCategoryType</key><string>public.app-category.developer-tools</string>
<key>LSMinimumSystemVersion</key><string>11.0</string>
<key>NSHighResolutionCapable</key><true/>
<key>NSSupportsAutomaticGraphicsSwitching</key><true/>
</dict>
</plist>
EOF
mv "$out/bin/slerm" "$app/Contents/MacOS/slerm"
ln -s "$app/Contents/MacOS/slerm" "$out/bin/slerm"
'';
ghosttyEnv = {
GHOSTTY_SOURCE_DIR = "${ghostty-src}";
GHOSTTY_ZIG_SYSTEM_DIR = "${ghosttyDeps}";
@@ -183,6 +212,10 @@
"--skip=tests::osc7_wins_over_the_process_directory_but_only_for_this_machine"
];
postInstall = lib.optionalString pkgs.stdenv.hostPlatform.isDarwin (
appBundle manifest.package.version
);
postFixup = lib.optionalString pkgs.stdenv.hostPlatform.isLinux ''
wrapProgram "$out/bin/slerm" \
--suffix LD_LIBRARY_PATH : ${lib.makeLibraryPath runtimeLibraries} \
-1
View File
@@ -1 +0,0 @@
/nix/store/f3ssbg9n2ykvy4n6wslfgl25gr8pzkbl-slerm-0.1.0
+123 -31
View File
@@ -3,10 +3,10 @@ use crate::{
input,
layout::{Axis, Direction, Layout, PaneId, Tab},
pane::{self, Pane},
render::color,
theme::{self, Theme},
};
use anyhow::{Result, ensure};
use egui::{Color32, Rect, Stroke, vec2};
use egui::{CornerRadius, Rect, Stroke, vec2};
use std::{
collections::{BTreeMap, HashSet},
path::PathBuf,
@@ -14,6 +14,7 @@ use std::{
pub struct App {
config: Config,
theme: Theme,
config_path: PathBuf,
panes: BTreeMap<PaneId, Pane>,
tabs: Vec<Tab>,
@@ -23,20 +24,39 @@ pub struct App {
suppressed: HashSet<egui::Key>,
}
pub fn fonts(config: &Config) -> Result<egui::FontDefinitions> {
let mut fonts = egui::FontDefinitions::default();
if let Some(path) = &config.font_path {
let data = std::fs::read(path)?;
fonts
.font_data
.insert("terminal".into(), egui::FontData::from_owned(data).into());
fonts
.families
.get_mut(&egui::FontFamily::Monospace)
.unwrap()
.insert(0, "terminal".into());
/// Width kept free at the left of the tab bar for the macOS traffic lights,
/// which float over the content once the titlebar is hidden.
const TITLEBAR_INSET: f32 = 70.0;
/// Height the tab bar needs so the traffic lights fit inside it.
const TITLEBAR_HEIGHT: f32 = 28.0;
/// With the titlebar hidden the tab bar takes its place, so let the empty space
/// beside the tabs drag the window and double-click to zoom, like a titlebar.
fn drag_window(ui: &mut egui::Ui) {
let used = ui.min_rect();
let free = Rect::from_min_max(
egui::pos2(used.right(), used.top()),
egui::pos2(ui.max_rect().right(), used.bottom()),
);
if free.width() <= 0.0 {
return;
}
Ok(fonts)
let response = ui.interact(
free,
ui.id().with("titlebar drag"),
egui::Sense::click_and_drag(),
);
if response.double_clicked() {
let maximized = ui.input(|i| i.viewport().maximized.unwrap_or(false));
ui.ctx()
.send_viewport_cmd(egui::ViewportCommand::Maximized(!maximized));
} else if response.drag_started_by(egui::PointerButton::Primary) {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
}
}
pub fn fonts(config: &Config) -> Result<egui::FontDefinitions> {
crate::fonts::load(config)
}
impl App {
@@ -45,11 +65,13 @@ impl App {
config: Config,
config_path: PathBuf,
) -> Result<Self> {
cc.egui_ctx.set_visuals(egui::Visuals::dark());
let theme = config.theme()?;
cc.egui_ctx.set_visuals(theme.visuals());
cc.egui_ctx.set_fonts(fonts(&config)?);
let pane = Pane::new(&config, &cc.egui_ctx, None)?;
let pane = Pane::new(&config, &theme, &cc.egui_ctx, None)?;
Ok(Self {
config,
theme,
config_path,
panes: BTreeMap::from([(1, pane)]),
tabs: vec![Tab::new(1, 1)],
@@ -71,13 +93,27 @@ impl App {
.get(self.current)
.and_then(|tab| self.panes.get(&tab.active))
.and_then(Pane::cwd);
let pane = Pane::new(&self.config, ctx, cwd.as_deref())?;
let pane = Pane::new(&self.config, &self.theme, ctx, cwd.as_deref())?;
let id = self.next_id;
self.next_id += 1;
self.panes.insert(id, pane);
Ok(id)
}
/// Switches theme for this session. Every open pane is retinted, since a
/// terminal keeps its palette rather than asking for it each frame.
fn set_theme(&mut self, name: &str, ctx: &egui::Context) -> Result<()> {
let previous = std::mem::replace(&mut self.config.theme, name.to_owned());
self.theme = self.config.theme().inspect_err(|_| {
self.config.theme = previous;
})?;
for pane in self.panes.values_mut() {
pane::apply_config(&mut pane.terminal, &self.config, &self.theme)?;
}
ctx.set_visuals(self.theme.visuals());
Ok(())
}
fn action(&mut self, action: Action, ctx: &egui::Context) -> Result<()> {
let active = self.tabs[self.current].active;
match action {
@@ -185,10 +221,12 @@ impl App {
Action::ReloadConfig => {
let config = Config::load(&self.config_path)?;
let fonts = fonts(&config)?;
self.theme = config.theme()?;
for pane in self.panes.values_mut() {
pane::apply_config(&mut pane.terminal, &config)?;
pane::apply_config(&mut pane.terminal, &config, &self.theme)?;
}
ctx.set_fonts(fonts);
ctx.set_visuals(self.theme.visuals());
self.config = config;
}
Action::Copy => {
@@ -351,8 +389,13 @@ impl eframe::App for App {
return;
}
let mut action = None;
let mut picked = None;
egui::TopBottomPanel::top("tabs").show(ctx, |ui| {
ui.horizontal_wrapped(|ui| {
if cfg!(target_os = "macos") {
ui.add_space(TITLEBAR_INSET);
ui.set_min_height(TITLEBAR_HEIGHT);
}
for (index, tab) in self.tabs.iter().enumerate() {
let title = self.panes[&tab.active].terminal.title().unwrap_or("");
let label = if title.is_empty() {
@@ -394,20 +437,37 @@ impl eframe::App for App {
action = Some(Action::ReloadConfig);
ui.close();
}
ui.menu_button("Theme", |ui| {
ui.label("Applies now; set `theme` in the config to keep it.");
egui::ScrollArea::vertical()
.max_height(420.0)
.show(ui, |ui| {
for name in theme::names() {
let current = self.config.theme == name;
if ui.selectable_label(current, name).clicked() && !current {
picked = Some(name);
ui.close();
}
}
});
});
});
});
if cfg!(target_os = "macos") {
drag_window(ui);
}
});
if let Some(error) = self.error.clone() {
egui::TopBottomPanel::bottom("error").show(ctx, |ui| {
ui.horizontal_wrapped(|ui| {
ui.colored_label(Color32::LIGHT_RED, error);
ui.colored_label(theme::color(self.theme.ansi[9]), error);
if ui.button("Dismiss").clicked() {
self.error = None;
}
});
});
}
let bg = color(config::rgb(&self.config.background).unwrap());
let bg = theme::color(self.theme.background);
let tab = &mut self.tabs[self.current];
let mut tiled = vec![];
let mut workspace = Rect::NOTHING;
@@ -426,7 +486,7 @@ impl eframe::App for App {
if !tab.zoomed {
pane.last_rect = *rect;
}
if show_pane(ui, *id, pane, *rect, &self.config, active) {
if show_pane(ui, *id, pane, *rect, &self.config, &self.theme, active) {
tab.active = *id;
}
}
@@ -449,7 +509,7 @@ impl eframe::App for App {
let rect = ui.available_rect_before_wrap();
ui.allocate_rect(rect, egui::Sense::hover());
pane.last_rect = rect;
if show_pane(ui, id, pane, rect, &self.config, active) {
if show_pane(ui, id, pane, rect, &self.config, &self.theme, active) {
tab.active = id;
}
});
@@ -472,6 +532,11 @@ impl eframe::App for App {
pane.error = Some(e.to_string());
}
}
if let Some(name) = picked
&& let Err(e) = self.set_theme(name, ctx)
{
self.error = Some(e.to_string());
}
if !self.tabs.is_empty()
&& let Some(action) = action
&& let Err(e) = self.action(action, ctx)
@@ -505,20 +570,40 @@ fn grab_keys(ctx: &egui::Context) {
});
}
/// Corner rounding shared by a pane's border and its title bar.
const CORNER: u8 = 5;
fn show_pane(
ui: &mut egui::Ui,
id: PaneId,
pane: &mut Pane,
rect: Rect,
config: &Config,
theme: &Theme,
active: bool,
) -> bool {
let accent = color(config::rgb(&config.accent).unwrap());
let accent = theme::color(theme.accent);
let title_rect = Rect::from_min_size(rect.min, vec2(rect.width(), 25.0_f32.min(rect.height())));
// The title bar only rounds off the top of the pane, unless the pane is so
// short that the bar is all there is of it.
let bottom = if title_rect.height() < rect.height() {
0
} else {
CORNER
};
ui.painter().rect_filled(
title_rect,
0,
Color32::from_gray(if active { 43 } else { 31 }),
CornerRadius {
nw: CORNER,
ne: CORNER,
sw: bottom,
se: bottom,
},
theme::color(if active {
theme.chrome_active_bg
} else {
theme.chrome_bg
}),
);
ui.painter()
.with_clip_rect(title_rect.shrink(3.0).intersect(ui.clip_rect()))
@@ -527,7 +612,11 @@ fn show_pane(
egui::Align2::LEFT_TOP,
pane_title(id, pane),
egui::FontId::proportional(13.0),
if active { accent } else { Color32::GRAY },
if active {
accent
} else {
theme::color(theme.chrome_fg)
},
);
// Click-only leaves dragging to the floating window's existing move handler.
let header = ui.interact(
@@ -541,19 +630,19 @@ fn show_pane(
// The pane rectangle is already allocated; a layout scope would grow its parent.
let mut content_ui = ui.new_child(egui::UiBuilder::new().id_salt(id).max_rect(content));
content_ui.set_clip_rect(content.intersect(ui.clip_rect()));
match pane.show(&mut content_ui, content, config, active) {
match pane.show(&mut content_ui, content, config, theme, active) {
Ok(clicked) => focused |= clicked,
Err(e) => pane.error = Some(e.to_string()),
}
ui.painter().rect_stroke(
rect,
0,
CORNER,
Stroke::new(
1.0_f32,
if active {
accent
} else {
Color32::from_gray(54)
theme::color(theme.border)
},
),
egui::StrokeKind::Inside,
@@ -596,9 +685,11 @@ mod tests {
shell: vec!["/bin/sh".into()],
..Default::default()
};
let pane = Pane::new(&config, ctx, None).unwrap();
let theme = config.theme().unwrap();
let pane = Pane::new(&config, &theme, ctx, None).unwrap();
App {
config,
theme,
config_path: PathBuf::new(),
panes: BTreeMap::from([(1, pane)]),
tabs: vec![Tab::new(1, 1)],
@@ -634,6 +725,7 @@ mod tests {
app.panes.get_mut(&1).unwrap(),
rect,
&app.config,
&app.theme,
true,
);
})
+65 -9
View File
@@ -1,3 +1,4 @@
use crate::theme::{self, Theme};
use anyhow::{Context, Result, bail, ensure};
use serde::Deserialize;
use std::{
@@ -14,12 +15,21 @@ pub struct Config {
pub working_directory: Option<PathBuf>,
pub font_size: f32,
pub font_path: Option<PathBuf>,
pub font_family: Option<String>,
pub font_bold_path: Option<PathBuf>,
pub font_italic_path: Option<PathBuf>,
pub font_bold_italic_path: Option<PathBuf>,
pub font_fallbacks: Vec<String>,
pub scrollback: usize,
pub window_width: f32,
pub window_height: f32,
pub background: String,
pub foreground: String,
pub accent: String,
pub theme: String,
/// Shorthand for the `[colors]` entries of the same name.
pub background: Option<String>,
pub foreground: Option<String>,
pub accent: Option<String>,
/// Colors overriding the theme, keyed by the names in `theme::FIELDS`.
pub colors: BTreeMap<String, String>,
pub kitty: Kitty,
pub multiplexer: Multiplexer,
pub keys: BTreeMap<String, Action>,
@@ -99,12 +109,19 @@ impl Default for Config {
working_directory: None,
font_size: 16.0,
font_path: None,
font_family: None,
font_bold_path: None,
font_italic_path: None,
font_bold_italic_path: None,
font_fallbacks: Vec::new(),
scrollback: 10000,
window_width: 1100.0,
window_height: 720.0,
background: "#17191c".into(),
foreground: "#e2e4e8".into(),
accent: "#58c4ad".into(),
theme: theme::DEFAULT_NAME.into(),
background: None,
foreground: None,
accent: None,
colors: BTreeMap::new(),
kitty: Kitty::default(),
multiplexer: Multiplexer::default(),
keys: [
@@ -153,6 +170,18 @@ impl Config {
pub fn parse(text: &str) -> Result<Self> {
let config: Self = toml::from_str(text)?;
ensure!(
config.font_path.is_none() || config.font_family.is_none(),
"set either font_family or font_path, not both"
);
ensure!(
config
.font_family
.iter()
.chain(&config.font_fallbacks)
.all(|name| !name.trim().is_empty()),
"font family names must not be empty"
);
ensure!(
!config.shell.is_empty() && !config.shell[0].is_empty(),
"shell must contain an executable"
@@ -196,14 +225,38 @@ impl Config {
"floating maximums must be 160..16384 points"
);
}
for color in [&config.background, &config.foreground, &config.accent] {
rgb(color)?;
}
config.theme()?;
for key in config.keys.keys() {
shortcut(key)?;
}
Ok(config)
}
/// The configured theme with the `[colors]` overrides applied. Validated by
/// `parse`, so a config that loaded is a config whose theme resolves.
pub fn theme(&self) -> Result<Theme> {
let mut theme = theme::find(&self.theme)
.with_context(|| format!("unknown theme: {} (try --list-themes)", self.theme))?;
let shorthand = [
("background", &self.background),
("foreground", &self.foreground),
("accent", &self.accent),
];
let named = shorthand
.iter()
.filter_map(|(name, value)| Some((*name, value.as_ref()?)))
.chain(self.colors.iter().map(|(k, v)| (k.as_str(), v)));
for (name, value) in named {
theme.set(name, packed(value)?)?;
}
Ok(theme)
}
}
/// A `#RRGGBB` string as the packed integer a theme stores.
pub fn packed(text: &str) -> Result<theme::Rgb> {
let color = rgb(text)?;
Ok((color.r as u32) << 16 | (color.g as u32) << 8 | color.b as u32)
}
pub fn rgb(text: &str) -> Result<libghostty_vt::style::RgbColor> {
@@ -250,6 +303,9 @@ mod tests {
assert_eq!(example.keys, Config::default().keys);
for text in [
"font_size = nan",
"font_family = ''",
"font_fallbacks = [' ']",
"font_family = 'Test'\nfont_path = '/font.ttf'",
"shell = []",
"background = '#xx0000'",
"font_sze = 16",
+268
View File
@@ -0,0 +1,268 @@
use crate::config::Config;
use anyhow::{Context, Result, ensure};
use egui::{FontData, FontDefinitions, FontFamily};
use fontdb::{Database, Family, ID, Query, Style, Weight};
use std::{collections::HashMap, path::Path};
pub fn family(bold: bool, italic: bool) -> FontFamily {
match (bold, italic) {
(false, false) => FontFamily::Monospace,
(true, false) => FontFamily::Name("terminal-bold".into()),
(false, true) => FontFamily::Name("terminal-italic".into()),
(true, true) => FontFamily::Name("terminal-bold-italic".into()),
}
}
fn query(db: &Database, name: &str, bold: bool, italic: bool) -> Option<ID> {
db.query(&Query {
families: &[Family::Name(name)],
weight: if bold { Weight::BOLD } else { Weight::NORMAL },
style: if italic { Style::Italic } else { Style::Normal },
..Default::default()
})
}
fn load_file(db: &mut Database, path: &Path) -> Result<ID> {
let bytes =
std::fs::read(path).with_context(|| format!("Cannot read font {}", path.display()))?;
db.load_font_source(fontdb::Source::Binary(std::sync::Arc::new(bytes)))
.first()
.copied()
.with_context(|| format!("No font faces in {}", path.display()))
}
fn register(
db: &Database,
fonts: &mut FontDefinitions,
loaded: &mut HashMap<ID, String>,
id: ID,
) -> Result<String> {
if let Some(name) = loaded.get(&id) {
return Ok(name.clone());
}
let face = db.face(id).context("Font face disappeared")?;
let data = db
.with_face_data(id, |bytes, index| {
// Validate with the renderer's parser too, so a bad font becomes a
// reload error rather than a panic inside egui on the next frame.
ab_glyph::FontRef::try_from_slice_and_index(bytes, index)
.with_context(|| format!("Unsupported font: {}", face.post_script_name))?;
let mut data = FontData::from_owned(bytes.to_vec());
data.index = index;
Ok::<_, anyhow::Error>(data)
})
.context("Cannot read font face data")??;
let name = format!("terminal-face-{}", loaded.len());
fonts.font_data.insert(name.clone(), data.into());
loaded.insert(id, name.clone());
Ok(name)
}
pub fn load(config: &Config) -> Result<FontDefinitions> {
let mut db = Database::new();
// Preserve the built-in default when no family is configured. System
// fonts also supply optional Nerd Font symbols and named fallbacks.
db.load_system_fonts();
load_from_database(config, db)
}
fn load_from_database(config: &Config, mut db: Database) -> Result<FontDefinitions> {
let mut primary = config.font_family.clone();
let regular = if let Some(path) = &config.font_path {
let id = load_file(&mut db, path)?;
primary = db
.face(id)
.and_then(|face| face.families.first())
.map(|f| f.0.clone());
// Inspect sibling metadata, not filename suffixes: foundries use
// different style names, and collections contain multiple faces.
let parent = path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or(Path::new("."));
for entry in std::fs::read_dir(parent)?.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()).is_some_and(|e| {
["ttf", "otf", "ttc", "otc"]
.iter()
.any(|ext| e.eq_ignore_ascii_case(ext))
}) {
let _ = db.load_font_file(path);
}
}
Some(id)
} else if let Some(name) = &primary {
Some(
query(&db, name, false, false)
.with_context(|| format!("Font family not found: {name}"))?,
)
} else {
None
};
for name in &config.font_fallbacks {
ensure!(
query(&db, name, false, false).is_some(),
"Fallback font family not found: {name}"
);
}
let mut fallbacks = config.font_fallbacks.clone();
for name in ["Symbols Nerd Font Mono", "Symbols Nerd Font"] {
if query(&db, name, false, false).is_some() && !fallbacks.iter().any(|f| f == name) {
fallbacks.push(name.into());
break;
}
}
let mut fonts = FontDefinitions::default();
let builtin = fonts.families[&FontFamily::Monospace].clone();
let mut loaded = HashMap::new();
for (bold, italic, path) in [
(false, false, None),
(true, false, config.font_bold_path.as_deref()),
(false, true, config.font_italic_path.as_deref()),
(true, true, config.font_bold_italic_path.as_deref()),
] {
let id = if let Some(path) = path {
Some(load_file(&mut db, path)?)
} else if !bold && !italic {
regular
} else {
primary
.as_ref()
.and_then(|name| query(&db, name, bold, italic))
.or(regular)
};
let mut chain = Vec::new();
if let Some(id) = id {
chain.push(register(&db, &mut fonts, &mut loaded, id)?);
} else {
// A symbols-only fallback must not set the terminal's line metrics.
chain.push(builtin[0].clone());
}
// A style face may lack symbols present in its regular face.
if let Some(id) = regular {
let name = register(&db, &mut fonts, &mut loaded, id)?;
if !chain.contains(&name) {
chain.push(name);
}
}
for name in &fallbacks {
for id in [
query(&db, name, bold, italic),
query(&db, name, false, false),
]
.into_iter()
.flatten()
{
let name = register(&db, &mut fonts, &mut loaded, id)?;
if !chain.contains(&name) {
chain.push(name);
}
}
}
chain.extend(builtin.iter().cloned());
fonts.families.insert(family(bold, italic), chain);
}
Ok(fonts)
}
#[cfg(test)]
mod tests {
use super::*;
fn database() -> Database {
let mut db = Database::new();
let fonts = FontDefinitions::default();
let data = &fonts.font_data[&fonts.families[&FontFamily::Monospace][0]];
db.load_font_data(data.font.to_vec());
let template = db.faces().next().unwrap().clone();
for (bold, italic) in [(false, false), (true, false), (false, true), (true, true)] {
let mut face = template.clone();
face.families[0].0 = "Test Family".into();
face.weight = if bold { Weight::BOLD } else { Weight::NORMAL };
face.style = if italic { Style::Italic } else { Style::Normal };
db.push_face_info(face);
}
db
}
#[test]
fn matches_styles_and_keeps_regular_as_fallback() {
let config = Config {
font_family: Some("Test Family".into()),
..Default::default()
};
let db = database();
let mut ids = std::collections::HashSet::new();
for (bold, italic) in [(false, false), (true, false), (false, true), (true, true)] {
let id = query(&db, "Test Family", bold, italic).unwrap();
assert!(ids.insert(id), "styles must select distinct faces");
let face = db.face(id).unwrap();
assert_eq!(face.weight == Weight::BOLD, bold);
assert_eq!(face.style == Style::Italic, italic);
}
let fonts = load_from_database(&config, db).unwrap();
let regular = &fonts.families[&family(false, false)][0];
for (bold, italic) in [(true, false), (false, true), (true, true)] {
let chain = &fonts.families[&family(bold, italic)];
assert_ne!(&chain[0], regular);
assert_eq!(&chain[1], regular);
}
}
#[test]
fn rejects_missing_families_and_accepts_regular_only_fallback() {
let mut config = Config {
font_family: Some("Not Installed".into()),
..Default::default()
};
assert!(load_from_database(&config, database()).is_err());
config.font_family = None;
config.font_fallbacks = vec!["Not Installed".into()];
assert!(load_from_database(&config, database()).is_err());
let db = database();
let name = db.faces().next().unwrap().families[0].0.clone();
config.font_fallbacks = vec![name];
let fonts = load_from_database(&config, db).unwrap();
assert_eq!(
fonts.families[&family(false, false)],
fonts.families[&family(true, true)]
);
}
#[test]
#[ignore = "requires SLERM_TEST_FONT pointing to a Nerd Font regular face with sibling styles"]
fn installed_nerd_font_family_and_sibling_styles() {
use ab_glyph::Font;
let path = std::env::var_os("SLERM_TEST_FONT").expect("set SLERM_TEST_FONT");
let config = Config {
font_path: Some(path.into()),
..Default::default()
};
let fonts = load(&config).unwrap();
let mut faces = std::collections::HashSet::new();
for (bold, italic) in [(false, false), (true, false), (false, true), (true, true)] {
let name = &fonts.families[&family(bold, italic)][0];
assert!(faces.insert(name.clone()));
let data = &fonts.font_data[name];
let face = ab_glyph::FontRef::try_from_slice_and_index(&data.font, data.index).unwrap();
for c in ['M', '\u{f121}', '\u{e0a0}', '\u{f0001}'] {
assert_ne!(face.glyph_id(c).0, 0, "missing {c:?} in {name}");
}
}
let mut db = Database::new();
let id = load_file(&mut db, config.font_path.as_ref().unwrap()).unwrap();
let name = db.face(id).unwrap().families[0].0.clone();
eprintln!("Verified installed family: {name}");
let named = load(&Config {
font_family: Some(name),
..Default::default()
})
.unwrap();
for (bold, italic) in [(false, false), (true, false), (false, true), (true, true)] {
let a = &fonts.font_data[&fonts.families[&family(bold, italic)][0]];
let b = &named.font_data[&named.families[&family(bold, italic)][0]];
assert_eq!(a.font, b.font);
assert_eq!(a.index, b.index);
}
}
}
+22 -4
View File
@@ -1,5 +1,6 @@
mod app;
mod config;
mod fonts;
mod input;
mod layout;
mod pane;
@@ -7,6 +8,8 @@ mod pty;
mod render;
#[cfg(test)]
mod tests;
mod theme;
mod themes;
use anyhow::{Context, Result, bail};
@@ -20,13 +23,19 @@ fn main() -> Result<()> {
"--config" => path = args.next().context("--config needs a path")?.into(),
"--check-config" => check = true,
"--init-config" => init = true,
"--list-themes" => {
for name in theme::names() {
println!("{name}");
}
return Ok(());
}
"--version" => {
println!("slerm {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
"--help" | "-h" => {
println!(
"slerm [--config PATH] [--check-config | --init-config]\n\nDefault config: {}",
"slerm [--config PATH] [--check-config | --init-config | --list-themes]\n\nDefault config: {}",
path.display()
);
return Ok(());
@@ -67,12 +76,21 @@ fn main() -> Result<()> {
return Ok(());
}
libghostty_vt::kitty::graphics::set_png_decoder(Some(Box::new(render::PngDecoder)))?;
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
let mut viewport = egui::ViewportBuilder::default()
.with_title("slerm")
.with_inner_size([config.window_width, config.window_height])
.with_min_inner_size([320.0, 200.0])
.with_app_id("slerm"),
.with_app_id("slerm");
if cfg!(target_os = "macos") {
// Hide the titlebar and draw under it; the traffic lights stay on top of
// the tab bar, which reserves room for them (see app::TITLEBAR_INSET).
viewport = viewport
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false);
}
let options = eframe::NativeOptions {
viewport,
renderer: eframe::Renderer::Wgpu,
..Default::default()
};
+55 -16
View File
@@ -1,8 +1,9 @@
use crate::{
config::{Config, rgb},
config::Config,
input,
pty::Pty,
render::{Renderer, cell_size},
render::{Grid, Renderer, cell_size},
theme::{self, Theme},
};
use anyhow::{Result, ensure};
use egui::{FontId, Rect, Sense, vec2};
@@ -46,13 +47,29 @@ fn local_pwd(pwd: &str) -> Option<PathBuf> {
return pwd.starts_with('/').then(|| PathBuf::from(pwd));
};
let (host, path) = rest.split_once('/')?;
// A shell may report either the short name or the FQDN, so compare the leading
// label of each.
let short = |name: &str| name.split('.').next().unwrap_or(name).to_ascii_lowercase();
let local = host.is_empty()
|| host.eq_ignore_ascii_case("localhost")
|| std::fs::read_to_string("/proc/sys/kernel/hostname")
.is_ok_and(|name| name.trim().eq_ignore_ascii_case(host));
|| hostname().is_some_and(|name| short(&name) == short(host));
local.then(|| PathBuf::from(percent_decode(&format!("/{path}"))))
}
fn hostname() -> Option<String> {
unsafe extern "C" {
fn gethostname(name: *mut std::ffi::c_char, len: usize) -> std::ffi::c_int;
}
let mut buf = [0u8; 256];
// SAFETY: at most `len` bytes are written, leaving the final byte of `buf` as
// the terminator even when the name is truncated.
if unsafe { gethostname(buf.as_mut_ptr().cast(), buf.len() - 1) } != 0 {
return None;
}
let end = buf.iter().position(|&b| b == 0)?;
String::from_utf8(buf[..end].to_vec()).ok()
}
fn percent_decode(path: &str) -> String {
let mut out = Vec::with_capacity(path.len());
let mut bytes = path.bytes();
@@ -75,14 +92,14 @@ fn percent_decode(path: &str) -> String {
String::from_utf8_lossy(&out).into_owned()
}
pub fn terminal(config: &Config) -> Result<Terminal<'static, 'static>> {
pub fn terminal(config: &Config, theme: &Theme) -> Result<Terminal<'static, 'static>> {
let mut term = Terminal::new(TerminalOptions {
cols: 80,
rows: 24,
max_scrollback: config.scrollback,
})?;
term.resize(80, 24, 8, 16)?;
apply_config(&mut term, config)?;
apply_config(&mut term, config, theme)?;
term.on_xtversion(|_| Some(concat!("slerm ", env!("CARGO_PKG_VERSION"))))?;
term.on_device_attributes(|_| {
Some(terminal::DeviceAttributes {
@@ -98,14 +115,21 @@ pub fn terminal(config: &Config) -> Result<Terminal<'static, 'static>> {
tertiary: Default::default(),
})
})?;
term.on_color_scheme(|_| Some(terminal::ColorScheme::Dark))?;
let scheme = if theme.dark {
terminal::ColorScheme::Dark
} else {
terminal::ColorScheme::Light
};
term.on_color_scheme(move |_| Some(scheme))?;
Ok(term)
}
pub fn apply_config(term: &mut Terminal<'_, '_>, config: &Config) -> Result<()> {
term.set_default_fg_color(Some(rgb(&config.foreground)?))?
.set_default_bg_color(Some(rgb(&config.background)?))?
.set_default_cursor_color(Some(rgb(&config.accent)?))?
pub fn apply_config(term: &mut Terminal<'_, '_>, config: &Config, theme: &Theme) -> Result<()> {
let palette = theme.palette(term.default_color_palette()?);
term.set_default_fg_color(Some(theme::rgb_color(theme.foreground)))?
.set_default_bg_color(Some(theme::rgb_color(theme.background)))?
.set_default_cursor_color(Some(theme::rgb_color(theme.cursor)))?
.set_default_color_palette(Some(palette))?
.set_kitty_image_storage_limit(config.kitty.image_storage_mb * 1024 * 1024)?
.set_kitty_image_from_file_allowed(config.kitty.allow_file)?
.set_kitty_image_from_temp_file_allowed(config.kitty.allow_temp_file)?
@@ -114,8 +138,13 @@ pub fn apply_config(term: &mut Terminal<'_, '_>, config: &Config) -> Result<()>
}
impl Pane {
pub fn new(config: &Config, ctx: &egui::Context, cwd: Option<&Path>) -> Result<Self> {
let mut terminal = terminal(config)?;
pub fn new(
config: &Config,
theme: &Theme,
ctx: &egui::Context,
cwd: Option<&Path>,
) -> Result<Self> {
let mut terminal = terminal(config, theme)?;
let renderer = Renderer::new()?;
let mouse_encoder = mouse::Encoder::new()?;
let mouse_event = mouse::Event::new()?;
@@ -245,6 +274,7 @@ impl Pane {
ui: &mut egui::Ui,
rect: Rect,
config: &Config,
theme: &Theme,
active: bool,
) -> Result<bool> {
let font = FontId::monospace(config.font_size);
@@ -273,15 +303,24 @@ impl Pane {
}
let response = ui.interact(grid, ui.id().with("terminal"), Sense::click_and_drag());
self.pointer(ui, &response, grid, cell)?;
self.renderer
.paint(&self.terminal, ui.painter(), grid, cell, &font, active)?;
self.renderer.paint(
&self.terminal,
ui.painter(),
&Grid {
rect: grid,
cell,
font: &font,
theme,
active,
},
)?;
if let Some(error) = &self.error {
ui.painter().text(
grid.left_bottom() - vec2(0.0, ch),
egui::Align2::LEFT_TOP,
error,
FontId::proportional(12.0),
egui::Color32::LIGHT_RED,
theme::color(theme.ansi[9]),
);
}
Ok(response.clicked()
+69 -4
View File
@@ -111,11 +111,10 @@ impl Pty {
})
}
/// The working directory of the foreground process group, via /proc. Shells
/// that emit OSC 7 are reported by the terminal instead; this covers the rest.
/// The working directory of the foreground process group. Shells that emit
/// OSC 7 are reported by the terminal instead; this covers the rest.
pub fn cwd(&self) -> Option<PathBuf> {
let pid = self.master.process_group_leader()?;
std::fs::read_link(format!("/proc/{pid}/cwd")).ok()
process_cwd(self.master.process_group_leader()?)
}
pub fn resize(&self, cols: u16, rows: u16, cw: u32, ch: u32) -> Result<()> {
@@ -129,6 +128,72 @@ impl Pty {
}
}
#[cfg(target_os = "linux")]
fn process_cwd(pid: i32) -> Option<PathBuf> {
std::fs::read_link(format!("/proc/{pid}/cwd")).ok()
}
/// macOS has no /proc, so the current directory comes from `proc_pidinfo`.
#[cfg(target_os = "macos")]
fn process_cwd(pid: i32) -> Option<PathBuf> {
use std::{ffi::CStr, os::unix::ffi::OsStrExt};
// <sys/proc_info.h>. `vinfo_stat` is spelled as its 136 bytes of padding
// because only the path that follows it is of interest here.
#[repr(C)]
struct VnodeInfo {
stat: [u64; 17],
vtype: i32,
pad: i32,
fsid: [i32; 2],
}
#[repr(C)]
struct VnodeInfoPath {
info: VnodeInfo,
path: [std::ffi::c_char; 1024],
}
#[repr(C)]
struct VnodePathInfo {
cdir: VnodeInfoPath,
rdir: VnodeInfoPath,
}
const PROC_PIDVNODEPATHINFO: i32 = 9;
unsafe extern "C" {
fn proc_pidinfo(
pid: i32,
flavor: i32,
arg: u64,
buffer: *mut std::ffi::c_void,
buffersize: i32,
) -> i32;
}
let size = size_of::<VnodePathInfo>();
// SAFETY: the call writes at most `size` bytes into `info`, and reports how
// many it wrote. A short write leaves the path unset, so it is rejected.
unsafe {
let mut info = std::mem::zeroed::<VnodePathInfo>();
let written = proc_pidinfo(
pid,
PROC_PIDVNODEPATHINFO,
0,
(&raw mut info).cast(),
size as i32,
);
if written < size as i32 {
return None;
}
let path = CStr::from_ptr(info.cdir.path.as_ptr());
(!path.is_empty()).then(|| PathBuf::from(std::ffi::OsStr::from_bytes(path.to_bytes())))
}
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn process_cwd(_pid: i32) -> Option<PathBuf> {
None
}
impl Drop for Pty {
fn drop(&mut self) {
let _ = self.shutdown.send(());
+243 -24
View File
@@ -1,3 +1,4 @@
use crate::theme::{self, Theme};
use anyhow::{Result, ensure};
use egui::{
Color32, ColorImage, FontId, Painter, Rect, Stroke, TextureHandle, TextureOptions, pos2, vec2,
@@ -7,6 +8,7 @@ use libghostty_vt::{
alloc::{Allocator, Bytes},
kitty::graphics::{DecodePng, DecodedImage, ImageFormat, Layer, PlacementIterator},
render::{CellIterator, Colors, CursorVisualStyle, Dirty, RowIterator},
screen::CellWide,
style::{RgbColor, Underline},
};
use std::{
@@ -15,6 +17,17 @@ use std::{
sync::Arc,
};
/// Where a pane's terminal is drawn this frame, and the colors that are slerm's
/// own rather than the terminal's.
#[derive(Clone, Copy)]
pub struct Grid<'a> {
pub rect: Rect,
pub cell: egui::Vec2,
pub font: &'a FontId,
pub theme: &'a Theme,
pub active: bool,
}
pub fn color(c: RgbColor) -> Color32 {
Color32::from_rgb(c.r, c.g, c.b)
}
@@ -128,13 +141,51 @@ struct CachedRow {
}
fn layout_glyph(painter: &Painter, text: String, font: &FontId, height: f32) -> Arc<egui::Galley> {
layout_styled_glyph(painter, text, font, height, false)
}
fn layout_styled_glyph(
painter: &Painter,
text: String,
font: &FontId,
height: f32,
italics: bool,
) -> Arc<egui::Galley> {
let mut job =
egui::text::LayoutJob::simple(text, font.clone(), Color32::PLACEHOLDER, f32::INFINITY);
job.sections[0].format.line_height = Some(height);
job.sections[0].format.valign = egui::Align::Center;
job.sections[0].format.italics = italics;
painter.layout_job(job)
}
fn nerd_icon(text: &str) -> bool {
// Includes the supplementary private-use plane used by Material Design.
text.chars()
.next()
.is_some_and(|c| matches!(c as u32, 0xe000..=0xf8ff | 0xf0000..=0xffffd))
}
fn icon_shape(galley: Arc<egui::Galley>, rect: Rect, color: Color32) -> egui::Shape {
// Use ink bounds: patched fonts can have bearings or outlines that extend
// well beyond their nominal advance. Fit uniformly and center in the cell
// allocation so icons cannot cover neighboring text or rows.
let bounds = galley.mesh_bounds;
let mut shape = egui::epaint::TextShape::new(egui::Pos2::ZERO, galley, color);
if bounds.is_positive() {
let scale = (rect.width() / bounds.width())
.min(rect.height() / bounds.height())
.min(1.0);
shape.transform(egui::emath::TSTransform {
scaling: scale,
translation: rect.center().to_vec2() - bounds.center().to_vec2() * scale,
});
} else {
shape.pos = rect.min;
}
shape.into()
}
pub struct Renderer {
state: RenderState<'static>,
rows: RowIterator<'static>,
@@ -142,9 +193,11 @@ pub struct Renderer {
placements: PlacementIterator<'static>,
textures: HashMap<u32, (u64, TextureHandle)>,
row_cache: Vec<CachedRow>,
glyphs: HashMap<String, Arc<egui::Galley>>,
glyphs: HashMap<(String, bool, bool), Arc<egui::Galley>>,
font_marker: Option<Arc<egui::Galley>>,
synthetic_styles: [(bool, bool); 4],
cached_colors: Option<Colors>,
cached_selection: (Color32, Color32),
cached_cell: egui::Vec2,
cached_cols: u16,
#[cfg(test)]
@@ -162,7 +215,9 @@ impl Renderer {
row_cache: Vec::new(),
glyphs: HashMap::new(),
font_marker: None,
synthetic_styles: [(false, false); 4],
cached_colors: None,
cached_selection: (Color32::PLACEHOLDER, Color32::PLACEHOLDER),
cached_cell: egui::Vec2::ZERO,
cached_cols: 0,
#[cfg(test)]
@@ -174,37 +229,75 @@ impl Renderer {
&mut self,
term: &Terminal<'static, '_>,
painter: &Painter,
rect: Rect,
cell: egui::Vec2,
font: &FontId,
active: bool,
grid: &Grid<'_>,
) -> Result<()> {
let &Grid {
rect,
cell,
font,
theme,
active,
} = grid;
let painter = painter.with_clip_rect(rect.intersect(painter.clip_rect()));
// Keep one egui galley alive in its cache each frame. A new identity means
// fonts/DPI/the atlas were reset, invalidating every cached glyph's UVs.
let marker = layout_glyph(&painter, "M".into(), font, cell.y);
if self
let fonts_changed = self
.font_marker
.as_ref()
.is_none_or(|old| !Arc::ptr_eq(old, &marker))
|| self.cached_cell != cell
|| self.glyphs.len() > 4096
{
|| self.glyphs.len() > 4096;
if fonts_changed {
self.row_cache.clear();
self.glyphs.clear();
}
self.font_marker = Some(marker);
self.cached_cell = cell;
let style_fonts = painter.ctx().fonts(|fonts| {
[(false, false), (true, false), (false, true), (true, true)].map(|(bold, italic)| {
let family = crate::fonts::family(bold, italic);
if font.family == egui::FontFamily::Monospace
&& fonts.definitions().families.contains_key(&family)
{
FontId::new(font.size, family)
} else {
font.clone()
}
})
});
if fonts_changed {
self.synthetic_styles = painter.ctx().fonts(|fonts| {
let first_face = |index: usize| {
fonts.definitions().families[&style_fonts[index].family]
.first()
.map(|name| &fonts.definitions().font_data[name])
};
std::array::from_fn::<_, 4, _>(|index| {
(
index & 1 != 0 && first_face(index) == first_face(index & !1),
index & 2 != 0 && first_face(index) == first_face(index & !2),
)
})
});
}
let synthetic = self.synthetic_styles;
let snapshot = self.state.update(term)?;
let colors = snapshot.colors()?;
let cols = snapshot.cols()?;
let row_count = snapshot.rows()? as usize;
let selected_colors = (
theme::color(theme.selection_bg),
theme::color(theme.selection_fg),
);
let rebuild_all = snapshot.dirty()? == Dirty::Full
|| self.cached_colors.as_ref() != Some(&colors)
|| self.cached_selection != selected_colors
|| self.cached_cols != cols
|| self.row_cache.len() != row_count;
self.row_cache.resize_with(row_count, CachedRow::default);
self.cached_colors = Some(colors.clone());
self.cached_selection = selected_colors;
self.cached_cols = cols;
painter.rect_filled(rect, 0, color(colors.background));
let mut rows = self.rows.update(&snapshot)?;
@@ -237,36 +330,66 @@ impl Renderer {
let explicit_bg = c.bg_color()?;
let mut bg = color(explicit_bg.unwrap_or(colors.background));
let selected = c.is_selected()?;
if style.inverse ^ selected {
if style.inverse {
std::mem::swap(&mut fg, &mut bg);
}
// A selection takes the theme's own colors rather than inverting
// the cell, which would be invisible over inverted text.
if selected {
(bg, fg) = selected_colors;
}
if explicit_bg.is_some() || style.inverse || selected {
backgrounds.push(egui::Shape::rect_filled(cell_rect, 0, bg));
}
if style.faint {
fg = fg.gamma_multiply(0.6);
}
if !style.invisible && c.graphemes_len()? > 0 {
let wide = c.raw_cell()?.wide()?;
if !style.invisible
&& !matches!(wide, CellWide::SpacerHead | CellWide::SpacerTail)
&& c.graphemes_len()? > 0
{
c.graphemes_utf8(&mut text)?;
// Placeholder cells are reserved by the Kitty protocol, not printable text.
if let Some(separator) = powerline_shape(&text, cell_rect, fg) {
foregrounds.push(separator);
} else if text != " " && !text.starts_with('\u{10eeee}') {
let galley = if let Some(galley) = self.glyphs.get(&text) {
let index = usize::from(style.bold) + 2 * usize::from(style.italic);
let icon = nerd_icon(&text);
let key = (text.clone(), style.bold, style.italic);
let galley = if let Some(galley) = self.glyphs.get(&key) {
galley.clone()
} else {
let galley = layout_glyph(&painter, text.clone(), font, cell.y);
self.glyphs.insert(text.clone(), galley.clone());
let font = &style_fonts[index];
let galley = layout_styled_glyph(
&painter,
text.clone(),
font,
cell.y,
synthetic[index].1 && !icon,
);
self.glyphs.insert(key, galley.clone());
galley
};
foregrounds.push(egui::Shape::galley(pos, galley.clone(), fg));
if style.bold {
// Keep style emphasis for regular-only fonts; real style
// faces are drawn once. Never distort symbol outlines.
if synthetic[index].0 && !icon {
foregrounds.push(egui::Shape::galley(
pos + vec2(0.45, 0.0),
galley,
galley.clone(),
fg,
));
}
foregrounds.push(if icon {
let width = if wide == CellWide::Wide { 2.0 } else { 1.0 };
icon_shape(
galley,
Rect::from_min_size(pos, vec2(cell.x * width, cell.y)),
fg,
)
} else {
egui::Shape::galley(pos, galley, fg)
});
}
}
for (enabled, height) in [
@@ -451,6 +574,82 @@ fn paint_images(
mod tests {
use super::*;
#[test]
fn sgr_styles_use_distinct_cached_faces_without_double_drawing() {
let ctx = egui::Context::default();
let config = crate::config::Config {
font_path: std::env::var_os("SLERM_TEST_FONT").map(Into::into),
..Default::default()
};
ctx.set_fonts(crate::fonts::load(&config).unwrap());
let mut term = crate::pane::terminal(&config, &theme::DEFAULT).unwrap();
term.resize(20, 2, 8, 16).unwrap();
term.vt_write(b"M\x1b[1mM\x1b[22;3mM\x1b[1mM\x1b[0mM");
let mut renderer = Renderer::new().unwrap();
let rect = Rect::from_min_size(egui::Pos2::ZERO, vec2(200.0, 100.0));
let font = FontId::monospace(16.0);
frame(&ctx, &mut renderer, &term, rect, &font);
assert_eq!(renderer.glyphs.len(), 4);
assert_eq!(
renderer.row_cache[0].foregrounds.len(),
if config.font_path.is_some() { 5 } else { 7 }
);
for (bold, italic) in [(false, false), (true, false), (false, true), (true, true)] {
let galley = &renderer.glyphs[&("M".into(), bold, italic)];
assert_eq!(
galley.job.sections[0].format.font_id.family,
crate::fonts::family(bold, italic)
);
}
let before = renderer.glyphs[&("M".into(), true, true)].clone();
let mut fonts = crate::fonts::load(&config).unwrap();
fonts
.families
.get_mut(&crate::fonts::family(true, true))
.unwrap()
.reverse();
ctx.set_fonts(fonts);
frame(&ctx, &mut renderer, &term, rect, &font);
assert!(!Arc::ptr_eq(
&before,
&renderer.glyphs[&("M".into(), true, true)]
));
}
#[test]
fn icons_fit_ink_bounds_at_different_scales_and_cell_widths() {
let config = crate::config::Config {
font_path: std::env::var_os("SLERM_TEST_FONT").map(Into::into),
..Default::default()
};
let fonts = crate::fonts::load(&config).unwrap();
for scale in [1.0, 1.5, 2.0] {
let ctx = egui::Context::default();
ctx.set_fonts(fonts.clone());
ctx.set_pixels_per_point(scale);
let _ = ctx.run(egui::RawInput::default(), |ctx| {
let painter = ctx.layer_painter(egui::LayerId::background());
for text in ["\u{e0a0}", "\u{f121}", "\u{f0001}"] {
assert!(nerd_icon(text));
for width in [4.0, 9.0, 18.0] {
let rect = Rect::from_min_size(pos2(23.0, 41.0), vec2(width, 18.0));
let galley =
layout_glyph(&painter, text.into(), &FontId::monospace(20.0), 18.0);
let shape = icon_shape(galley, rect, Color32::WHITE);
let bounds = shape.visual_bounding_rect();
assert!(
rect.expand(0.001).contains_rect(bounds),
"{text:?}: {bounds:?} outside {rect:?}"
);
assert!(bounds.center().distance(rect.center()) < 0.001);
}
}
});
}
assert!(!nerd_icon("M"));
assert!(!nerd_icon("\u{10eeee}"));
}
fn frame(
ctx: &egui::Context,
renderer: &mut Renderer,
@@ -461,7 +660,17 @@ mod tests {
ctx.run(egui::RawInput::default(), |ctx| {
let painter = ctx.layer_painter(egui::LayerId::background());
renderer
.paint(term, &painter, rect, cell_size(&painter, font), font, true)
.paint(
term,
&painter,
&Grid {
rect,
cell: cell_size(&painter, font),
font,
theme: &theme::DEFAULT,
active: true,
},
)
.unwrap();
})
.shapes
@@ -475,7 +684,9 @@ mod tests {
};
let ctx = egui::Context::default();
let mut term = crate::pane::terminal(&crate::config::Config::default()).unwrap();
let mut term =
crate::pane::terminal(&crate::config::Config::default(), &crate::theme::DEFAULT)
.unwrap();
term.resize(20, 5, 8, 16).unwrap();
term.vt_write("\x1b[31mNOR\u{e0b4}\x1b[0m Android\r\nsecond line".as_bytes());
let rect = Rect::from_min_size(pos2(12.0, 24.0), vec2(200.0, 120.0));
@@ -548,7 +759,9 @@ mod tests {
#[test]
fn cached_glyphs_rebuild_after_font_or_scale_changes() {
let ctx = egui::Context::default();
let mut term = crate::pane::terminal(&crate::config::Config::default()).unwrap();
let mut term =
crate::pane::terminal(&crate::config::Config::default(), &crate::theme::DEFAULT)
.unwrap();
term.resize(20, 5, 8, 16).unwrap();
term.vt_write(b"font cache");
let rect = Rect::from_min_size(egui::Pos2::ZERO, vec2(200.0, 120.0));
@@ -595,7 +808,7 @@ mod tests {
};
let ctx = egui::Context::default();
ctx.set_fonts(crate::app::fonts(&config).unwrap());
let mut term = crate::pane::terminal(&config).unwrap();
let mut term = crate::pane::terminal(&config, &theme::DEFAULT).unwrap();
term.resize(160, 50, 9, 18).unwrap();
let screen = (1..=50)
.map(|row| {
@@ -629,10 +842,16 @@ mod tests {
.paint(
&term,
&ctx.layer_painter(egui::LayerId::background()),
Rect::from_min_size(egui::Pos2::ZERO, vec2(1440.0, 900.0)),
vec2(9.0, 18.0),
&FontId::monospace(16.0),
true,
&Grid {
rect: Rect::from_min_size(
egui::Pos2::ZERO,
vec2(1440.0, 900.0),
),
cell: vec2(9.0, 18.0),
font: &FontId::monospace(16.0),
theme: &theme::DEFAULT,
active: true,
},
)
.unwrap();
},
+14 -11
View File
@@ -1,4 +1,4 @@
use crate::{config::Config, input, pane, render};
use crate::{config::Config, input, pane, render, theme};
use base64::Engine;
use egui::{Event, Key, Modifiers};
use libghostty_vt::{
@@ -30,7 +30,7 @@ fn key(key: Key, modifiers: Modifiers) -> Event {
#[test]
fn terminal_preserves_unicode_colors_and_application_input_modes() {
let mut term = pane::terminal(&Config::default()).unwrap();
let mut term = pane::terminal(&Config::default(), &theme::DEFAULT).unwrap();
term.vt_write("hello \x1b[31mred\x1b[0m \u{754c}e\u{301}\r\n".as_bytes());
assert!(plain(&term).contains("hello red \u{754c}e\u{301}"));
let events = [
@@ -59,7 +59,7 @@ fn terminal_preserves_unicode_colors_and_application_input_modes() {
#[test]
fn queries_return_to_the_pty_and_alternate_screen_restores() {
let mut term = pane::terminal(&Config::default()).unwrap();
let mut term = pane::terminal(&Config::default(), &theme::DEFAULT).unwrap();
let response = Rc::new(RefCell::new(vec![]));
let writer = response.clone();
term.on_pty_write(move |_, bytes| writer.borrow_mut().extend_from_slice(bytes))
@@ -74,7 +74,7 @@ fn queries_return_to_the_pty_and_alternate_screen_restores() {
#[test]
fn kitty_images_decode_render_reuse_and_delete() {
graphics::set_png_decoder(Some(Box::new(render::PngDecoder))).unwrap();
let mut term = pane::terminal(&Config::default()).unwrap();
let mut term = pane::terminal(&Config::default(), &theme::DEFAULT).unwrap();
let replies = Rc::new(RefCell::new(vec![]));
let output = replies.clone();
term.on_pty_write(move |_, data| output.borrow_mut().extend_from_slice(data))
@@ -137,10 +137,13 @@ fn kitty_images_decode_render_reuse_and_delete() {
.paint(
&term,
&painter,
&render::Grid {
rect,
egui::vec2(8.0, 16.0),
&egui::FontId::monospace(12.0),
true,
cell: egui::vec2(8.0, 16.0),
font: &egui::FontId::monospace(12.0),
theme: &theme::DEFAULT,
active: true,
},
)
.unwrap();
})
@@ -173,7 +176,7 @@ fn pty_executes_input_and_bracketed_paste_is_sanitized() {
..Default::default()
};
let ctx = egui::Context::default();
let mut pane = pane::Pane::new(&config, &ctx, None).unwrap();
let mut pane = pane::Pane::new(&config, &theme::DEFAULT, &ctx, None).unwrap();
pane.send(b"printf '\\033[31mpty-%s\\033[0m\\n' 'success'\r".to_vec())
.unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
@@ -216,7 +219,7 @@ fn new_panes_start_in_the_directory_of_the_pane_they_came_from() {
let dir = std::env::temp_dir().join(format!("slerm cwd {}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let dir = dir.canonicalize().unwrap();
let mut pane = pane::Pane::new(&config, &ctx, None).unwrap();
let mut pane = pane::Pane::new(&config, &theme::DEFAULT, &ctx, None).unwrap();
pane.send(format!("cd '{}'\r", dir.display()).into_bytes())
.unwrap();
// /bin/sh reports no OSC 7, so the directory has to come from the process.
@@ -227,7 +230,7 @@ fn new_panes_start_in_the_directory_of_the_pane_they_came_from() {
}
assert_eq!(pane.cwd().as_deref(), Some(dir.as_path()));
let mut child = pane::Pane::new(&config, &ctx, pane.cwd().as_deref()).unwrap();
let mut child = pane::Pane::new(&config, &theme::DEFAULT, &ctx, pane.cwd().as_deref()).unwrap();
child
.send(b"printf 'at:%s\\n' \"$PWD\"\r".to_vec())
.unwrap();
@@ -251,7 +254,7 @@ fn osc7_wins_over_the_process_directory_but_only_for_this_machine() {
let reported = std::env::temp_dir().join(format!("slerm osc7 {}", std::process::id()));
std::fs::create_dir_all(&reported).unwrap();
let reported = reported.canonicalize().unwrap();
let mut pane = pane::Pane::new(&config, &ctx, None).unwrap();
let mut pane = pane::Pane::new(&config, &theme::DEFAULT, &ctx, None).unwrap();
let process_cwd = pane.cwd().unwrap();
assert_ne!(process_cwd, reported);
+261
View File
@@ -0,0 +1,261 @@
use crate::themes::THEMES;
use anyhow::{Result, bail};
use egui::Color32;
use libghostty_vt::style::{Palette, PaletteIndex, RgbColor};
/// A packed `0xRRGGBB` color. Themes are compiled in, so they hold plain
/// integers rather than anything that needs allocating or parsing at startup.
pub type Rgb = u32;
/// Every color slerm draws with. A theme covers the terminal grid, the sixteen
/// ANSI colors programs address by index, and the chrome around them, so that
/// picking a light theme does not leave a dark tab bar behind.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Theme {
/// Whether the chrome should be drawn as a dark or a light widget set.
pub dark: bool,
pub background: Rgb,
pub foreground: Rgb,
pub cursor: Rgb,
pub selection_bg: Rgb,
pub selection_fg: Rgb,
/// The focused pane: its border, its title and the cursor.
pub accent: Rgb,
/// The tab bar, menus and pane title bars.
pub chrome_bg: Rgb,
pub chrome_fg: Rgb,
/// The title bar of the focused pane.
pub chrome_active_bg: Rgb,
/// The border of an unfocused pane.
pub border: Rgb,
pub popup_bg: Rgb,
/// ANSI 0..=15: black, red, green, yellow, blue, magenta, cyan, white, then
/// the same eight again in their bright form.
pub ansi: [Rgb; 16],
}
/// The name of slerm's own theme, which is what a config without a `theme` gets.
pub const DEFAULT_NAME: &str = "slerm";
/// slerm's own colors. The palette is libghostty's built-in one, so the default
/// look is exactly what the terminal renders with no theme applied at all.
pub const DEFAULT: Theme = Theme {
dark: true,
background: 0x17191c,
foreground: 0xe2e4e8,
cursor: 0x58c4ad,
selection_bg: 0x2f3d44,
selection_fg: 0xe2e4e8,
accent: 0x58c4ad,
chrome_bg: 0x1f2124,
chrome_fg: 0xa8adb5,
chrome_active_bg: 0x2b2e33,
border: 0x363636,
popup_bg: 0x1f2124,
ansi: [
0x1d1f21, 0xcc6666, 0xb5bd68, 0xf0c674, 0x81a2be, 0xb294bb, 0x8abeb7, 0xc5c8c6, 0x666666,
0xd54e53, 0xb9ca4a, 0xe7c547, 0x7aa6da, 0xc397d8, 0x70c0b1, 0xeaeaea,
],
};
/// The theme with this name, if there is one. slerm's own comes first; the rest
/// are the Helix themes, which `THEMES` keeps sorted for the binary search.
pub fn find(name: &str) -> Option<Theme> {
if name == DEFAULT_NAME {
return Some(DEFAULT);
}
let index = THEMES.binary_search_by_key(&name, |(name, _)| name).ok()?;
Some(THEMES[index].1)
}
/// Every theme name, in the order a picker should list them.
pub fn names() -> impl Iterator<Item = &'static str> {
std::iter::once(DEFAULT_NAME).chain(THEMES.iter().map(|(name, _)| *name))
}
pub fn color(rgb: Rgb) -> Color32 {
Color32::from_rgb((rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8)
}
pub fn rgb_color(rgb: Rgb) -> RgbColor {
RgbColor {
r: (rgb >> 16) as u8,
g: (rgb >> 8) as u8,
b: rgb as u8,
}
}
/// Where one of the names a config may override writes to.
type Field = fn(&mut Theme) -> &mut Rgb;
/// The names a config may override, paired with the field each one writes.
/// Ordered so that `[colors]` reads the way a palette is usually written.
const FIELDS: [(&str, Field); 27] = [
("background", |t| &mut t.background),
("foreground", |t| &mut t.foreground),
("cursor", |t| &mut t.cursor),
("selection_background", |t| &mut t.selection_bg),
("selection_foreground", |t| &mut t.selection_fg),
("accent", |t| &mut t.accent),
("chrome_background", |t| &mut t.chrome_bg),
("chrome_foreground", |t| &mut t.chrome_fg),
("chrome_active_background", |t| &mut t.chrome_active_bg),
("border", |t| &mut t.border),
("popup_background", |t| &mut t.popup_bg),
("black", |t| &mut t.ansi[0]),
("red", |t| &mut t.ansi[1]),
("green", |t| &mut t.ansi[2]),
("yellow", |t| &mut t.ansi[3]),
("blue", |t| &mut t.ansi[4]),
("magenta", |t| &mut t.ansi[5]),
("cyan", |t| &mut t.ansi[6]),
("white", |t| &mut t.ansi[7]),
("bright_black", |t| &mut t.ansi[8]),
("bright_red", |t| &mut t.ansi[9]),
("bright_green", |t| &mut t.ansi[10]),
("bright_yellow", |t| &mut t.ansi[11]),
("bright_blue", |t| &mut t.ansi[12]),
("bright_magenta", |t| &mut t.ansi[13]),
("bright_cyan", |t| &mut t.ansi[14]),
("bright_white", |t| &mut t.ansi[15]),
];
impl Theme {
/// Overrides one color by the name a config file uses for it.
pub fn set(&mut self, name: &str, value: Rgb) -> Result<()> {
let Some((_, field)) = FIELDS.iter().find(|(known, _)| *known == name) else {
bail!("unknown color: {name}");
};
*field(self) = value;
Ok(())
}
/// The 256-color palette a terminal renders indexed colors through. Only
/// the first sixteen are themed; the color cube and greyscale ramp above
/// them are fixed by the protocol, so they keep libghostty's values.
pub fn palette(&self, default: Palette) -> Palette {
const SLOTS: [PaletteIndex; 16] = [
PaletteIndex::BLACK,
PaletteIndex::RED,
PaletteIndex::GREEN,
PaletteIndex::YELLOW,
PaletteIndex::BLUE,
PaletteIndex::MAGENTA,
PaletteIndex::CYAN,
PaletteIndex::WHITE,
PaletteIndex::BRIGHT_BLACK,
PaletteIndex::BRIGHT_RED,
PaletteIndex::BRIGHT_GREEN,
PaletteIndex::BRIGHT_YELLOW,
PaletteIndex::BRIGHT_BLUE,
PaletteIndex::BRIGHT_MAGENTA,
PaletteIndex::BRIGHT_CYAN,
PaletteIndex::BRIGHT_WHITE,
];
let mut palette = default;
for (slot, rgb) in SLOTS.into_iter().zip(self.ansi) {
palette.set(slot, rgb_color(rgb));
}
palette
}
/// egui's widget colors, so that the tab bar, menus and buttons follow the
/// theme rather than staying on egui's own dark palette.
pub fn visuals(&self) -> egui::Visuals {
let mut visuals = if self.dark {
egui::Visuals::dark()
} else {
egui::Visuals::light()
};
let (chrome, accent, text) = (
color(self.chrome_bg),
color(self.accent),
color(self.chrome_fg),
);
let widgets = &mut visuals.widgets;
widgets.noninteractive.bg_fill = chrome;
widgets.noninteractive.weak_bg_fill = chrome;
widgets.noninteractive.fg_stroke = egui::Stroke::new(1.0_f32, text);
widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0_f32, color(self.border));
for widget in [
&mut widgets.inactive,
&mut widgets.hovered,
&mut widgets.active,
&mut widgets.open,
] {
widget.bg_fill = color(self.chrome_active_bg);
widget.weak_bg_fill = color(self.chrome_active_bg);
widget.fg_stroke = egui::Stroke::new(1.0_f32, color(self.foreground));
widget.bg_stroke = egui::Stroke::new(1.0_f32, color(self.border));
}
widgets.hovered.weak_bg_fill = color(self.selection_bg);
widgets.active.weak_bg_fill = color(self.selection_bg);
widgets.active.fg_stroke = egui::Stroke::new(1.0_f32, accent);
visuals.panel_fill = chrome;
visuals.window_fill = color(self.popup_bg);
visuals.extreme_bg_color = color(self.background);
visuals.selection.bg_fill = color(self.selection_bg);
visuals.selection.stroke = egui::Stroke::new(1.0_f32, accent);
visuals.hyperlink_color = accent;
visuals.window_stroke = egui::Stroke::new(1.0_f32, color(self.border));
visuals
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_helix_theme_is_present_sorted_and_legible() {
assert!(THEMES.len() > 150, "expected the full Helix theme set");
assert!(
THEMES.windows(2).all(|w| w[0].0 < w[1].0),
"THEMES must be sorted for find() to binary search it"
);
for name in ["catppuccin_mocha", "gruvbox", "nord", "onedark", "dracula"] {
assert!(find(name).is_some(), "missing {name}");
}
assert_eq!(find(DEFAULT_NAME), Some(DEFAULT));
assert_eq!(find("no such theme"), None);
assert_eq!(names().count(), THEMES.len() + 1);
// A theme whose text matches its background, or whose palette collapses
// onto one color, would render an unusable terminal.
for (name, theme) in THEMES {
let luma = |rgb: Rgb| {
let channel = |shift: u32| ((rgb >> shift) & 0xff) as f32 / 255.0;
0.2126 * channel(16) + 0.7152 * channel(8) + 0.0722 * channel(0)
};
assert!(
(luma(theme.foreground) - luma(theme.background)).abs() > 0.15,
"{name}: text is not readable on its background"
);
assert_eq!(theme.dark, luma(theme.background) < luma(theme.foreground));
// Themes built on three or four colors (acme, vesper) reuse them
// across slots; anything flatter than that has lost its palette.
let distinct: std::collections::BTreeSet<_> = theme.ansi.iter().collect();
assert!(distinct.len() >= 9, "{name}: palette is too flat");
}
}
#[test]
fn overrides_are_named_and_reach_the_palette() {
let mut theme = DEFAULT;
theme.set("red", 0x123456).unwrap();
theme.set("bright_white", 0xfedcba).unwrap();
theme.set("accent", 0x00ff00).unwrap();
assert_eq!(theme.ansi[1], 0x123456);
assert_eq!(theme.ansi[15], 0xfedcba);
assert_eq!(theme.accent, 0x00ff00);
for unknown in ["dark", "purple", "ansi", ""] {
assert!(theme.set(unknown, 0).is_err(), "accepted {unknown}");
}
let palette = theme.palette(Palette([RgbColor { r: 0, g: 0, b: 0 }; 256]));
assert_eq!(palette.get(PaletteIndex::RED), rgb_color(0x123456));
assert_eq!(palette.get(PaletteIndex::BRIGHT_WHITE), rgb_color(0xfedcba));
// Everything above the sixteen named slots is left as it was.
assert_eq!(palette.0[16], RgbColor { r: 0, g: 0, b: 0 });
}
}
+3623
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2025 Daniel Fichtinger
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.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 James Simpson
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.
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2016 CloudCannon
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.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Aaron Young
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.
+23
View File
@@ -0,0 +1,23 @@
MIT License
Copyright (c) 2025 NTBBloodbath
This theme has been ported by spentbliss <zyhkekrz.hatbox475@passinbox.com>.
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.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 sainnhe
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.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Strash
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.
+23
View File
@@ -0,0 +1,23 @@
MIT License
Copyright (c) 2025 Alex Barnett
This theme has been ported by spentbliss <zyhkekrz.hatbox475@passinbox.com>.
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.
+5
View File
@@ -0,0 +1,5 @@
Copyright (C) 2020-2023 Free Software Foundation, Inc.
Permission is granted to copy, distribute and/or modify this document under the terms of the GNU Free Documentation License, Version 1.3 or any later version published by the Free Software Foundation; with no Invariant Sections, with the Front-Cover Texts being “A GNU Manual,” and with the Back-Cover Texts as in (a) below. A copy of the license is included in the section entitled “GNU Free Documentation License.”
(a) The FSF’s Back-Cover Text is: “You have the freedom to copy and modify this GNU manual.”
@@ -0,0 +1,5 @@
Copyright (C) 2020-2023 Free Software Foundation, Inc.
Permission is granted to copy, distribute and/or modify this document under the terms of the GNU Free Documentation License, Version 1.3 or any later version published by the Free Software Foundation; with no Invariant Sections, with the Front-Cover Texts being “A GNU Manual,” and with the Back-Cover Texts as in (a) below. A copy of the license is included in the section entitled “GNU Free Documentation License.”
(a) The FSF’s Back-Cover Text is: “You have the freedom to copy and modify this GNU manual.”
@@ -0,0 +1,5 @@
Copyright (C) 2020-2023 Free Software Foundation, Inc.
Permission is granted to copy, distribute and/or modify this document under the terms of the GNU Free Documentation License, Version 1.3 or any later version published by the Free Software Foundation; with no Invariant Sections, with the Front-Cover Texts being “A GNU Manual,” and with the Back-Cover Texts as in (a) below. A copy of the license is included in the section entitled “GNU Free Documentation License.”
(a) The FSF’s Back-Cover Text is: “You have the freedom to copy and modify this GNU manual.”
@@ -0,0 +1,5 @@
Copyright (C) 2020-2023 Free Software Foundation, Inc.
Permission is granted to copy, distribute and/or modify this document under the terms of the GNU Free Documentation License, Version 1.3 or any later version published by the Free Software Foundation; with no Invariant Sections, with the Front-Cover Texts being “A GNU Manual,” and with the Back-Cover Texts as in (a) below. A copy of the license is included in the section entitled “GNU Free Documentation License.”
(a) The FSF’s Back-Cover Text is: “You have the freedom to copy and modify this GNU manual.”
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 drcmda
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.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018 arturoalviar
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.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 sainnhe
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.
+24
View File
@@ -0,0 +1,24 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
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 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.
For more information, please refer to <https://unlicense.org>
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Rauno Freiberg
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.