Compare commits

...
Author SHA1 Message Date
Gregor Lohaus 98453329a1 ts-plugin-runtime 2026-09-24 15:21:12 +02:00
Gregor Lohaus a5ac986375 plugin sdk
squash
2026-09-17 14:27:57 +02:00
Gregor Lohaus 9b9fc8517d floating position fixes 2026-09-16 13:57:44 +02:00
Gregor Lohaus 8d49a993e9 better floating nac 2026-09-14 13:35:38 +02:00
Gregor Lohaus c21472b2f2 scrolling 2026-09-10 10:24:16 +02:00
Gregor Lohaus ca30e9a0ee explicit scrollback editor command 2026-09-09 12:49:47 +02:00
Gregor Lohaus c5e4e5fb85 smaller cursor corner radius 2026-09-09 11:41:06 +02:00
Gregor Lohaus d74b0acc83 fix horizontal gaps 2026-09-08 18:39:10 +02:00
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
49 changed files with 9603 additions and 209 deletions
+3
View File
@@ -1,2 +1,5 @@
/target/
/.tools/
/.worktrees
/sdk/node_modules/
/sdk/dist/
Generated
+728 -2
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -8,13 +8,19 @@ license = "MIT"
[dependencies]
anyhow = "1"
ab_glyph = "0.2"
boa_engine = { version = "0.22", default-features = false }
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"] }
png = "0.18"
portable-pty = "0.9"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
shell-words = "1"
toml = "0.9"
ts-rs = "12"
wgpu = { version = "27", default-features = false, features = ["std", "vulkan", "gles", "metal", "wgsl"] }
[dev-dependencies]
+50 -4
View File
@@ -2,14 +2,48 @@
# 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
# Command for opening scrollback; omit to use $EDITOR, or vi if unset.
# Supports quoted arguments; the scrollback file path is appended automatically.
# Runs directly without shell expansion. The editor should stay in the foreground.
# scrollback_editor = "nvim -R"
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
@@ -19,6 +53,8 @@ allow_shared_memory = true
[multiplexer]
max_panes = 32
# Floating panes follow the centered cascade and configured size until manually
# moved or resized. Automatic panes also follow window and configuration changes.
floating_width = 0.65
floating_height = 0.65
# Fractions of the workspace, capped at these many points on large displays.
@@ -30,6 +66,7 @@ floating_max_height = 640
"Alt+F" = "toggle_floats"
"Alt+M" = "new_pane"
"Alt+H" = "focus_left"
# With floating panes shown, J/K cycle forward/backward through them.
"Alt+J" = "focus_down"
"Alt+K" = "focus_up"
"Alt+L" = "focus_right"
@@ -37,7 +74,16 @@ floating_max_height = 640
"Alt+Shift+H" = "previous_tab"
"Alt+Shift+L" = "next_tab"
"Alt+X" = "close_pane"
# Open scrollback in scrollback_editor (or $EDITOR/vi), returning on exit.
"Alt+S" = "open_scrollback"
"Ctrl+Shift+Z" = "zoom_pane"
"Ctrl+Shift+R" = "reload_config"
"Ctrl+Shift+C" = "copy"
"Ctrl+Shift+V" = "paste"
# TypeScript plugins compile to JavaScript bundles and run in embedded Boa.
# See sdk/README.md. Relative paths resolve beside this configuration file.
# Restart Slerm after changing a plugin bundle or these entries.
# [[plugins]]
# id = "workspace"
# path = "plugins/workspace.js"
+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());
}
+37
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}";
@@ -148,6 +177,9 @@
./Cargo.lock
./config.example.toml
./src
# Embedded runtime and the bindings checked by Rust tests.
./sdk/runtime.js
./sdk/src/generated.ts
];
};
cargoLock.lockFile = ./Cargo.lock;
@@ -178,11 +210,16 @@
checkFlags = [
"--test-threads=1"
"--skip=app::tests::"
"--skip=pane::scrollback_tests::"
"--skip=tests::pty_executes_input_and_bracketed_paste_is_sanitized"
"--skip=tests::new_panes_start_in_the_directory_of_the_pane_they_came_from"
"--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
+133
View File
@@ -0,0 +1,133 @@
# Slerm TypeScript plugin SDK
Slerm runs JavaScript bundles in **embedded Boa**, with a separate worker thread
and context for each plugin. End users do not need Node.js or Bun. Plugin authors
use TypeScript and a bundler during development.
## Try the example
From the Slerm repository root:
```sh
npm ci --prefix sdk
npm run example --prefix sdk
```
Add this to your Slerm configuration, replacing the absolute path:
```toml
[[plugins]]
id = "workspace"
path = "/absolute/path/to/slerm/sdk/dist/workspace.js"
```
Start Slerm. The example opens **Workspace tools**, with controls to create a
floating pane, run a shell command, and inspect terminal text. Reopen the panel
from **Plugins → workspace → Show workspace tools**.
Bundle paths can also be relative to the configuration file's directory. Plugins
load at startup; restart Slerm after changing a bundle or the plugin configuration.
## Write a plugin
Build the SDK with `npm run build --prefix sdk`. In a separate plugin project,
install the local package with `npm install /absolute/path/to/slerm/sdk` and add
TypeScript and esbuild as development dependencies. The package has not been
published to a registry.
```ts
import { definePlugin } from "@slerm/plugin-sdk";
definePlugin({
async activate(slerm) {
await slerm.registerCommand({
command: { id: "scratch", title: "New scratch pane" },
});
},
async onEvent(event, slerm) {
if (event.event === "command" && event.command === "scratch") {
await slerm.execute({ action: "new_floating" });
}
},
});
```
Type-check your entry point, then bundle it:
```sh
npx tsc --noEmit --strict --target es2022 --module nodenext plugin.ts
npx esbuild plugin.ts --bundle --format=iife --platform=neutral --target=es2022 --outfile=plugin.js
```
Point a `[[plugins]]` entry at `plugin.js`. Each bundle calls `definePlugin` once.
All API calls return promises that resolve after Slerm applies the operation.
Events invoke `onEvent(event, slerm)`; callbacks can be async.
## Generated API
The Rust definitions in [`src/plugin_api.rs`](../src/plugin_api.rs) are the source
of truth. A Rust method list declares parameters and results; `ts-rs` generates
`src/generated.ts`, and TypeScript derives the client method signatures from those
types. Actions come from Slerm's existing Rust action enum. State, UI elements,
and events also use the same Rust types for serialization and type generation.
The generic runtime bridge does not duplicate a method list or handwritten types.
Regenerate after changing the Rust API:
```sh
nix develop -c cargo run -- --generate-plugin-sdk
npm test --prefix sdk
nix develop -c cargo test
nix develop -c cargo test bundled_typescript_example_runs_in_boa -- --ignored
```
Without Nix, run Cargo in an environment containing the project's Rust and Zig
build dependencies. Commit `sdk/src/generated.ts` with API changes. A Rust test
fails if the checked-in bindings differ from the schema. Bump `API_VERSION` for
incompatible changes; Slerm rejects bundles built for another API version.
| Method | Purpose |
| --- | --- |
| `getState()` | Read tabs, panes, titles, focus, floating state, and zoom |
| `execute({ action })` | Execute a built-in pane, tab, clipboard, or settings action |
| `focusPane({ pane })`, `focusTab({ tab })` | Focus by ID from `getState()` |
| `readTerminal({ pane })` | Read a plain-text screen and scrollback snapshot, up to 128 KiB |
| `writeTerminal({ pane, text })` | Send raw PTY input; include `\r` to submit a shell command |
| `registerCommand({ command })`, `unregisterCommand({ command })` | Manage entries in the Plugins menu |
| `setPanel({ panel })`, `removePanel({ panel })` | Create, replace, or remove a native panel |
| `reportError({ message })` | Display an error in Slerm |
Command and panel IDs are scoped to the plugin. Control IDs must be unique within
a panel. Pane and tab IDs come from Slerm and should be treated as opaque numbers.
Events are discriminated by `event.event`:
- `command`: `event.command` is the selected command ID.
- `state`: `event.state` is a snapshot when workspace metadata changes. Terminal
output changes alone do not produce this event; use `readTerminal` for snapshots.
- `ui`: `event.data` contains the panel/control IDs and either a `click`, or a
`change` with the new input `value`.
## Native UI
`setPanel` renders an egui floating window. Its `elements` accept `text`, `button`,
`input`, and `separator` variants. Inputs retain edits until replaced by another
`setPanel` call. UI focus receives typing without forwarding it to the terminal.
See [`examples/workspace.ts`](examples/workspace.ts) for a complete example.
## Runtime boundaries
The runtime supports ECMAScript and promises. This initial host does not expose
Node.js APIs, a DOM, filesystem/network APIs, or timers. Bundle imports ahead of
time and use libraries compatible with this environment. Report diagnostics with
`reportError`; `console` is not installed.
Plugins are trusted local extensions: terminal writes can run shell commands.
Boa contexts run in the same OS process as Slerm; worker threads and execution
limits are not an OS security sandbox. Slerm bounds loops, recursion, promise jobs,
request waits, bundle/message sizes, and UI counts. An uncaught callback error
stops that plugin and removes its commands and panels. Host-operation errors
reject the API promise and can be handled by the plugin.
Implementation references: [Boa](https://boajs.dev/docs/intro) and
[ts-rs](https://docs.rs/ts-rs/latest/ts_rs/).
+40
View File
@@ -0,0 +1,40 @@
import { definePlugin, type Slerm } from "../src/index.js";
let command = "pwd";
const show = (slerm: Slerm) => slerm.setPanel({ panel: {
id: "workspace", title: "Workspace tools", elements: [
{ type: "text", text: "Create a scratch terminal or run a command in the active pane." },
{ type: "button", id: "scratch", label: "New floating pane" },
{ type: "separator" },
{ type: "input", id: "command", label: "Shell command", value: command },
{ type: "button", id: "run", label: "Run in active pane" },
{ type: "button", id: "read", label: "Show terminal text" },
{ type: "button", id: "close", label: "Close tools" },
],
} });
definePlugin({
async activate(slerm) {
await slerm.registerCommand({ command: { id: "show-tools", title: "Show workspace tools" } });
await show(slerm);
},
async onEvent(event, slerm) {
if (event.event === "command" && event.command === "show-tools") await show(slerm);
if (event.event !== "ui" || event.data.panel !== "workspace") return;
const control = event.data;
if (control.kind === "change" && control.control === "command") command = control.value;
if (control.kind !== "click") return;
if (control.control === "scratch") await slerm.execute({ action: "new_floating" });
if (control.control === "close") await slerm.removePanel({ panel: "workspace" });
if (control.control === "run" || control.control === "read") {
const state = await slerm.getState();
const pane = state.tabs.find(tab => tab.id === state.activeTab)?.activePane;
if (pane === undefined) return;
if (control.control === "run") await slerm.writeTerminal({ pane, text: command + "\r" });
else await slerm.setPanel({ panel: {
id: "output", title: "Terminal snapshot",
elements: [{ type: "text", text: await slerm.readTerminal({ pane }) }],
} });
}
},
});
+515
View File
@@ -0,0 +1,515 @@
{
"name": "@slerm/plugin-sdk",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@slerm/plugin-sdk",
"version": "0.1.0",
"license": "MIT",
"devDependencies": {
"esbuild": "^0.27.0",
"typescript": "^6.0.3"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/esbuild": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.7",
"@esbuild/android-arm": "0.27.7",
"@esbuild/android-arm64": "0.27.7",
"@esbuild/android-x64": "0.27.7",
"@esbuild/darwin-arm64": "0.27.7",
"@esbuild/darwin-x64": "0.27.7",
"@esbuild/freebsd-arm64": "0.27.7",
"@esbuild/freebsd-x64": "0.27.7",
"@esbuild/linux-arm": "0.27.7",
"@esbuild/linux-arm64": "0.27.7",
"@esbuild/linux-ia32": "0.27.7",
"@esbuild/linux-loong64": "0.27.7",
"@esbuild/linux-mips64el": "0.27.7",
"@esbuild/linux-ppc64": "0.27.7",
"@esbuild/linux-riscv64": "0.27.7",
"@esbuild/linux-s390x": "0.27.7",
"@esbuild/linux-x64": "0.27.7",
"@esbuild/netbsd-arm64": "0.27.7",
"@esbuild/netbsd-x64": "0.27.7",
"@esbuild/openbsd-arm64": "0.27.7",
"@esbuild/openbsd-x64": "0.27.7",
"@esbuild/openharmony-arm64": "0.27.7",
"@esbuild/sunos-x64": "0.27.7",
"@esbuild/win32-arm64": "0.27.7",
"@esbuild/win32-ia32": "0.27.7",
"@esbuild/win32-x64": "0.27.7"
}
},
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@slerm/plugin-sdk",
"version": "0.1.0",
"description": "Generated TypeScript API for Slerm's embedded Boa plugins",
"license": "MIT",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist",
"README.md"
],
"scripts": {
"generate": "cargo run --manifest-path ../Cargo.toml -- --generate-plugin-sdk",
"build": "tsc -p tsconfig.json",
"check": "npm run build && tsc -p tsconfig.examples.json",
"example": "npm run check && esbuild examples/workspace.ts --bundle --format=iife --platform=neutral --target=es2022 --outfile=dist/workspace.js",
"test": "npm run example && node --test test/*.test.mjs",
"prepack": "npm run build"
},
"devDependencies": {
"esbuild": "^0.27.0",
"typescript": "^6.0.3"
}
}
+28
View File
@@ -0,0 +1,28 @@
// Generic Boa bridge. API methods and their types come from the Rust schema.
(() => {
const call = __slerm_call;
const fail = __slerm_error;
let plugin;
const api = new Proxy(Object.create(null), {
get: (_, method) => method === "then" ? undefined : async (params = {}) => {
const reply = JSON.parse(call(JSON.stringify({ ...params, method })));
if (reply.error !== undefined) throw new Error(reply.error);
return reply.result;
},
});
const invoke = (callback, ...args) => {
if (callback) Promise.resolve().then(() => callback(...args)).catch(error => fail(String(error)));
};
Object.defineProperty(globalThis, "__slerm", { value: Object.freeze({
activate(definition, version) {
if (version !== __slerm_version) throw new Error(`Unsupported Slerm API version ${version}`);
if (plugin) throw new Error("A bundle must define exactly one plugin");
plugin = definition;
invoke(plugin.activate, api);
},
}) });
Object.defineProperty(globalThis, "__slerm_dispatch", { value: event => {
if (!plugin) throw new Error("Bundle did not call definePlugin");
invoke(plugin.onEvent, event, api);
} });
})();
+30
View File
@@ -0,0 +1,30 @@
// Generated by cargo run -- --generate-plugin-sdk. Do not edit.
export const apiVersion = 1 as const;
export type Action = "split_right" | "split_down" | "new_floating" | "new_pane" | "toggle_floating" | "toggle_floats" | "next_pane" | "previous_pane" | "focus_left" | "focus_down" | "focus_up" | "focus_right" | "close_pane" | "zoom_pane" | "new_tab" | "next_tab" | "previous_tab" | "reload_config" | "copy" | "paste" | "open_scrollback";
export type Operation = { "method": "getState", } | { "method": "execute", action: Action, } | { "method": "focusPane", pane: number, } | { "method": "focusTab", tab: number, } | { "method": "readTerminal", pane: number, } | { "method": "writeTerminal", pane: number, text: string, } | { "method": "registerCommand", command: PluginCommand, } | { "method": "unregisterCommand", command: string, } | { "method": "setPanel", panel: Panel, } | { "method": "removePanel", panel: string, } | { "method": "reportError", message: string, };
export type Results = { getState: State, execute: null, focusPane: null, focusTab: null, readTerminal: string, writeTerminal: null, registerCommand: null, unregisterCommand: null, setPanel: null, removePanel: null, reportError: null, };
export type State = { activeTab: number | null, tabs: Array<TabState>, };
export type TabState = { id: number, activePane: number, floatsVisible: boolean, zoomed: boolean, panes: Array<PaneState>, };
export type PaneState = { id: number, title: string, floating: boolean, exited: boolean, };
export type PluginCommand = { id: string, title: string, };
export type Panel = { id: string, title: string, elements: Array<Element>, };
export type Element = { "type": "text", text: string, } | { "type": "button", id: string, label: string, } | { "type": "input", id: string, label: string, value: string, } | { "type": "separator" };
export type UIEvent = { "kind": "click", panel: string, control: string, } | { "kind": "change", panel: string, control: string, value: string, };
export type Event = { "event": "command", command: string, } | { "event": "state", state: State, } | { "event": "ui", data: UIEvent, };
type Params<M extends Operation["method"]> = Omit<Extract<Operation, { method: M }>, "method">;
export type Slerm = {
[M in Operation["method"]]: (...args: keyof Params<M> extends never ? [] : [params: Params<M>]) => Promise<Results[M] extends null ? void : Results[M]>;
};
+15
View File
@@ -0,0 +1,15 @@
import { apiVersion } from "./generated.js";
import type { Event, Slerm } from "./generated.js";
export * from "./generated.js";
export interface Plugin {
activate?(slerm: Slerm): void | Promise<void>;
onEvent?(event: Event, slerm: Slerm): void | Promise<void>;
}
declare const __slerm: { activate(plugin: Plugin, version: number): void };
/** Register one plugin in its embedded Boa runtime. Bundle this entry point. */
export function definePlugin(plugin: Plugin): void {
__slerm.activate(plugin, apiVersion);
}
+67
View File
@@ -0,0 +1,67 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { runInNewContext, runInContext } from "node:vm";
import test from "node:test";
const bridge = readFileSync(new URL("../runtime.js", import.meta.url), "utf8");
const example = readFileSync(new URL("../dist/workspace.js", import.meta.url), "utf8");
const settle = () => new Promise(resolve => setImmediate(resolve));
function start(reply = () => ({ result: null })) {
const operations = [];
const errors = [];
const context = {
__slerm_version: 1,
__slerm_call: text => {
const operation = JSON.parse(text);
operations.push(operation);
return JSON.stringify(reply(operation));
},
__slerm_error: error => errors.push(error),
};
runInNewContext(bridge, context);
return { context, operations, errors };
}
test("bundled TypeScript example uses generated methods and handles UI and command events", async () => {
const host = start(operation => ({ result: operation.method === "getState"
? { activeTab: 1, tabs: [{ id: 1, activePane: 2 }] }
: operation.method === "readTerminal" ? "hello from terminal" : null }));
runInContext(example, host.context);
await settle();
assert.deepEqual(host.operations.map(op => op.method), ["registerCommand", "setPanel"]);
assert.equal(host.operations[1].panel.elements[3].type, "input");
const ui = data => host.context.__slerm_dispatch({ event: "ui", data: { panel: "workspace", ...data } });
ui({ kind: "change", control: "command", value: "echo hello" });
await settle();
ui({ kind: "click", control: "run" });
await settle();
assert.deepEqual(host.operations.at(-1), { method: "writeTerminal", pane: 2, text: "echo hello\r" });
ui({ kind: "click", control: "scratch" });
await settle();
assert.deepEqual(host.operations.at(-1), { method: "execute", action: "new_floating" });
ui({ kind: "click", control: "read" });
await settle();
assert.equal(host.operations.at(-1).panel.elements[0].text, "hello from terminal");
ui({ kind: "click", control: "close" });
await settle();
assert.equal(host.operations.at(-1).method, "removePanel");
host.context.__slerm_dispatch({ event: "command", command: "show-tools" });
await settle();
assert.equal(host.operations.at(-1).method, "setPanel");
assert.deepEqual(host.errors, []);
});
test("API mismatch and duplicate registration fail before activation", () => {
const { context } = start();
assert.throws(() => context.__slerm.activate({}, 2), /Unsupported Slerm API/);
context.__slerm.activate({}, 1);
assert.throws(() => context.__slerm.activate({}, 1), /exactly one plugin/);
});
test("host errors reject generated calls and surface callback failures", async () => {
const host = start(() => ({ error: "unknown pane" }));
runInContext(example, host.context);
await settle();
assert.deepEqual(host.errors, ["Error: unknown pane"]);
});
+19
View File
@@ -0,0 +1,19 @@
import type { Slerm, State } from "../src/index.js";
declare const api: Slerm;
const state: Promise<State> = api.getState();
const text: Promise<string> = api.readTerminal({ pane: 1 });
void state; void text;
// @ts-expect-error Generated methods reject unknown actions.
api.execute({ action: "missing" });
// @ts-expect-error Pane IDs are numbers in the JSON API.
api.focusPane({ pane: "1" });
// @ts-expect-error A terminal write requires text.
api.writeTerminal({ pane: 1 });
// @ts-expect-error Parameterless methods take no parameters.
api.getState({});
// @ts-expect-error Panel elements are generated from the Rust enum.
api.setPanel({ panel: { id: "test", title: "Test", elements: [{ type: "html", html: "" }] } });
// @ts-expect-error Return types come from the Rust method list.
const invalid: Promise<number> = api.readTerminal({ pane: 1 });
void invalid;
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "./tsconfig.json",
"compilerOptions": { "noEmit": true, "rootDir": "." },
"include": ["examples/**/*.ts", "test/types.ts"]
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"types": [],
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"verbatimModuleSyntax": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}
+797 -49
View File
File diff suppressed because it is too large Load Diff
+105 -10
View File
@@ -1,3 +1,4 @@
use crate::theme::{self, Theme};
use anyhow::{Context, Result, bail, ensure};
use serde::Deserialize;
use std::{
@@ -14,15 +15,34 @@ 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 scrollback_editor: Option<String>,
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>,
pub plugins: Vec<Plugin>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Plugin {
pub id: String,
/// JavaScript bundle path, relative to the config file.
pub path: PathBuf,
}
#[derive(Clone, Debug, Deserialize)]
@@ -44,7 +64,7 @@ pub struct Multiplexer {
pub floating_max_height: f32,
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, ts_rs::TS)]
#[serde(rename_all = "snake_case")]
pub enum Action {
SplitRight,
@@ -67,6 +87,7 @@ pub enum Action {
ReloadConfig,
Copy,
Paste,
OpenScrollback,
}
impl Default for Kitty {
@@ -99,14 +120,23 @@ 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,
scrollback_editor: None,
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(),
plugins: Vec::new(),
keys: [
("Alt+F", Action::ToggleFloats),
("Alt+M", Action::NewPane),
@@ -118,6 +148,7 @@ impl Default for Config {
("Alt+Shift+H", Action::PreviousTab),
("Alt+Shift+L", Action::NextTab),
("Alt+X", Action::ClosePane),
("Alt+S", Action::OpenScrollback),
("Ctrl+Shift+Z", Action::ZoomPane),
("Ctrl+Shift+R", Action::ReloadConfig),
("Ctrl+Shift+C", Action::Copy),
@@ -153,10 +184,42 @@ impl Config {
pub fn parse(text: &str) -> Result<Self> {
let config: Self = toml::from_str(text)?;
let mut plugin_ids = std::collections::HashSet::new();
for plugin in &config.plugins {
ensure!(
crate::plugins::valid_id(&plugin.id) && plugin_ids.insert(&plugin.id),
"plugin ids must be unique and contain only letters, digits, '.', '-' or '_'"
);
ensure!(
!plugin.path.as_os_str().is_empty(),
"plugin {} needs a bundle path",
plugin.id
);
}
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"
);
if let Some(editor) = &config.scrollback_editor {
let command =
shell_words::split(editor).context("Invalid scrollback_editor command")?;
ensure!(
!command.is_empty() && !command[0].is_empty(),
"scrollback_editor must contain an executable"
);
}
ensure!(
(8.0..=48.0).contains(&config.font_size),
"font_size must be 8..48"
@@ -196,14 +259,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,7 +337,14 @@ 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 = []",
"scrollback_editor = ''",
"scrollback_editor = ' '",
"scrollback_editor = \"''\"",
"scrollback_editor = \"'\"",
"background = '#xx0000'",
"font_sze = 16",
"[keys]\n'Hyper+Q' = 'close_pane'",
@@ -274,6 +368,7 @@ mod tests {
("Alt+Shift+H", Action::PreviousTab),
("Alt+Shift+L", Action::NextTab),
("Alt+X", Action::ClosePane),
("Alt+S", Action::OpenScrollback),
] {
assert_eq!(config.keys.get(binding), Some(&action));
assert!(shortcut(binding).unwrap().modifiers.alt);
+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);
}
}
}
+21 -2
View File
@@ -1,6 +1,25 @@
use anyhow::Result;
use libghostty_vt::{Terminal, key};
/// Avoid libghostty-vt 0.2.1's encode_to_vec capacity calculation, which can
/// reserve too little when the destination already has some spare capacity.
pub fn append_encoded(
out: &mut Vec<u8>,
mut encode: impl FnMut(&mut [u8]) -> libghostty_vt::error::Result<usize>,
) -> Result<()> {
let mut buffer = [0; 128];
match encode(&mut buffer) {
Ok(written) => out.extend_from_slice(&buffer[..written]),
Err(libghostty_vt::Error::OutOfSpace { required }) => {
let mut buffer = vec![0; required];
let written = encode(&mut buffer)?;
out.extend_from_slice(&buffer[..written]);
}
Err(error) => return Err(error.into()),
}
Ok(())
}
pub fn mods(m: egui::Modifiers) -> key::Mods {
let mut out = key::Mods::empty();
if m.ctrl {
@@ -71,7 +90,7 @@ pub fn encode(terminal: &Terminal<'_, '_>, events: &[egui::Event]) -> Result<Vec
})
.set_unshifted_codepoint(ucp)
.set_utf8(text);
encoder.encode_to_vec(&event, &mut out)?;
append_encoded(&mut out, |buffer| encoder.encode(&event, buffer))?;
}
egui::Event::Text(text) if !used_text.contains(&index) => {
event
@@ -81,7 +100,7 @@ pub fn encode(terminal: &Terminal<'_, '_>, events: &[egui::Event]) -> Result<Vec
.set_consumed_mods(key::Mods::empty())
.set_unshifted_codepoint('\0')
.set_utf8(Some(text.as_str()));
encoder.encode_to_vec(&event, &mut out)?;
append_encoded(&mut out, |buffer| encoder.encode(&event, buffer))?;
}
egui::Event::Ime(egui::ImeEvent::Commit(text)) => {
out.extend_from_slice(text.as_bytes())
+145 -6
View File
@@ -152,6 +152,8 @@ pub struct Tab {
pub floats: Vec<PaneId>,
pub active: PaneId,
pub floats_visible: bool,
// Remember both focus and zoom while floating panes are hidden.
last_active_float: Option<(PaneId, bool)>,
pub zoomed: bool,
}
@@ -163,6 +165,7 @@ impl Tab {
floats: vec![],
active: pane,
floats_visible: false,
last_active_float: None,
zoomed: false,
}
}
@@ -177,6 +180,9 @@ impl Tab {
pub fn detach(&mut self, id: PaneId) {
self.root = self.root.take().and_then(|r| r.remove(id));
self.floats.retain(|p| *p != id);
if self.last_active_float.is_some_and(|(pane, _)| pane == id) {
self.last_active_float = None;
}
if self.floats.is_empty() {
self.floats_visible = false;
}
@@ -199,19 +205,32 @@ impl Tab {
pub fn toggle_floats(&mut self) {
self.floats_visible = !self.floats_visible;
self.zoomed = false;
let was_zoomed = std::mem::take(&mut self.zoomed);
if self.floats_visible {
if let Some(id) = self.floats.last() {
self.active = *id;
}
} else if self.floats.contains(&self.active)
&& let Some(root) = &self.root
if let Some((id, zoomed)) = self
.last_active_float
.filter(|(id, _)| self.floats.contains(id))
.or_else(|| self.floats.last().map(|id| (*id, false)))
{
self.active = id;
self.zoomed = zoomed;
}
} else if self.floats.contains(&self.active) {
self.last_active_float = Some((self.active, was_zoomed));
if let Some(root) = &self.root {
self.active = root.first();
}
}
}
pub fn focus_direction(&mut self, direction: Direction, rects: &[(PaneId, Rect)]) {
if self.floats_visible
&& !self.floats.is_empty()
&& matches!(direction, Direction::Down | Direction::Up)
{
self.cycle(matches!(direction, Direction::Up));
return;
}
let Some((_, source)) = rects
.iter()
.find(|(id, _)| *id == self.active && self.is_visible(*id))
@@ -322,6 +341,100 @@ mod tests {
assert!(tab.is_visible(2));
}
#[test]
fn showing_floats_restores_the_pane_active_when_hidden() {
let mut tab = Tab::new(1, 1);
tab.root.as_mut().unwrap().split(1, 2, Axis::Horizontal);
tab.floats = vec![3, 4, 5];
tab.toggle_floats();
assert_eq!(tab.active, 5);
tab.cycle(true);
assert_eq!(tab.active, 4);
tab.toggle_floats();
assert_eq!(tab.active, 1);
tab.cycle(false);
assert_eq!(tab.active, 2);
tab.toggle_floats();
assert_eq!(tab.active, 4);
tab.cycle(true);
tab.toggle_floats();
tab.toggle_floats();
assert_eq!(tab.active, 3);
tab.root = None;
tab.toggle_floats();
tab.toggle_floats();
assert_eq!(tab.active, 3);
}
#[test]
fn showing_floats_falls_back_when_remembered_pane_is_detached() {
for dock in [false, true] {
let mut tab = Tab::new(1, 1);
tab.floats = vec![2, 3];
tab.floats_visible = true;
tab.active = 2;
tab.zoomed = true;
tab.toggle_floats();
tab.detach(2);
if dock {
tab.root.as_mut().unwrap().split(1, 2, Axis::Horizontal);
}
tab.toggle_floats();
assert_eq!(tab.active, 3);
assert!(!tab.zoomed);
}
}
#[test]
fn showing_floats_restores_zoom_without_zooming_tiles() {
let mut tab = Tab::new(1, 1);
tab.root.as_mut().unwrap().split(1, 2, Axis::Horizontal);
tab.floats = vec![3, 4];
tab.floats_visible = true;
tab.active = 3;
tab.zoomed = true;
for _ in 0..2 {
tab.toggle_floats();
assert_eq!(tab.active, 1);
assert!(!tab.zoomed);
assert!(!tab.is_visible(3));
tab.cycle(false);
assert_eq!(tab.active, 2);
tab.toggle_floats();
assert_eq!(tab.active, 3);
assert!(tab.zoomed);
}
// Unzooming the float replaces its remembered zoom, even if tiles zoom.
tab.zoomed = false;
tab.toggle_floats();
tab.zoomed = true;
tab.toggle_floats();
assert_eq!(tab.active, 3);
assert!(!tab.zoomed);
}
#[test]
fn floating_zoom_survives_hiding_without_tiles() {
let mut tab = Tab::new(1, 1);
tab.root = None;
tab.floats = vec![1];
tab.floats_visible = true;
tab.zoomed = true;
tab.toggle_floats();
assert!(!tab.is_visible(1));
assert!(!tab.zoomed);
tab.toggle_floats();
assert_eq!(tab.active, 1);
assert!(tab.is_visible(1));
assert!(tab.zoomed);
}
#[test]
fn directional_focus_uses_geometry_and_ignores_hidden_or_foreign_panes() {
let mut tab = Tab::new(1, 1);
@@ -375,6 +488,30 @@ mod tests {
);
}
#[test]
fn up_and_down_cycle_visible_floats_without_geometry() {
let mut tab = Tab::new(1, 1);
tab.floats = vec![2, 3, 4];
tab.floats_visible = true;
for (direction, expected) in [
(Direction::Down, 2),
(Direction::Down, 3),
(Direction::Down, 4),
(Direction::Down, 2),
(Direction::Up, 4),
(Direction::Up, 3),
(Direction::Up, 2),
] {
tab.focus_direction(direction, &[]);
assert_eq!(tab.active, expected);
}
tab.toggle_floats();
assert_eq!(tab.active, 1);
tab.toggle_floats();
assert_eq!(tab.active, 2);
}
#[test]
fn visible_floats_constrain_directional_and_cyclic_navigation() {
let mut tab = Tab::new(1, 1);
@@ -395,6 +532,8 @@ mod tests {
(Direction::Right, 4),
(Direction::Down, 5),
(Direction::Left, 6),
(Direction::Up, 5),
(Direction::Up, 4),
(Direction::Up, 3),
] {
tab.focus_direction(direction, &rects);
+31 -4
View File
@@ -1,12 +1,17 @@
mod app;
mod config;
mod fonts;
mod input;
mod layout;
mod pane;
mod plugin_api;
mod plugins;
mod pty;
mod render;
#[cfg(test)]
mod tests;
mod theme;
mod themes;
use anyhow::{Context, Result, bail};
@@ -20,13 +25,26 @@ 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(());
}
"--generate-plugin-sdk" => {
let target =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("sdk/src/generated.ts");
std::fs::write(&target, plugin_api::typescript())?;
println!("Generated {}", target.display());
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 | --generate-plugin-sdk]\n\nDefault config: {}",
path.display()
);
return Ok(());
@@ -67,12 +85,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()
};
+332 -30
View File
@@ -1,10 +1,11 @@
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 anyhow::{Context, Result, ensure};
use egui::{FontId, Rect, Sense, vec2};
use libghostty_vt::{
Terminal, TerminalOptions, mouse,
@@ -30,8 +31,193 @@ pub struct Pane {
callback_error: Rc<RefCell<Option<String>>>,
mouse_encoder: mouse::Encoder<'static>,
mouse_event: mouse::Event<'static>,
scrollback_scroll: ScrollAccumulator,
mouse_scroll: ScrollAccumulator,
selection_start: Option<PointCoordinate>,
focused: bool,
saved_session: Option<(Box<Pane>, ScrollbackFile)>,
}
struct ScrollbackFile(PathBuf);
#[derive(Default)]
struct ScrollAccumulator(f32);
impl ScrollAccumulator {
fn rows(&mut self, delta: f32) -> isize {
self.0 += delta;
let rows = self.0.trunc();
self.0 -= rows;
rows as isize
}
fn wheel(&mut self, unit: egui::MouseWheelUnit, delta: f32, cell: f32, page: u16) -> isize {
self.rows(match unit {
egui::MouseWheelUnit::Point => delta / cell,
egui::MouseWheelUnit::Line => delta * 3.0,
egui::MouseWheelUnit::Page => delta * f32::from(page),
})
}
}
impl ScrollbackFile {
fn new(bytes: &[u8]) -> Result<Self> {
use std::{
fs::OpenOptions,
io::Write,
os::unix::fs::OpenOptionsExt,
sync::atomic::{AtomicU64, Ordering},
};
static NEXT: AtomicU64 = AtomicU64::new(0);
loop {
let path = std::env::temp_dir().join(format!(
"slerm-scrollback-{}-{}.txt",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
match OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&path)
{
Ok(mut file) => {
let temporary = Self(path);
file.write_all(bytes)?;
return Ok(temporary);
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(error.into()),
}
}
}
}
#[cfg(test)]
mod scroll_tests {
use super::*;
#[test]
fn precise_scrolling_preserves_distance_across_frames() {
let mut scroll = ScrollAccumulator::default();
// Sub-pixel events from a slow gesture must eventually move a row.
let rows: isize = (0..128).map(|_| scroll.rows(0.25 / 16.0)).sum();
assert_eq!(rows, 2);
assert_eq!(scroll.0, 0.0);
let mut batched = ScrollAccumulator::default();
assert_eq!(batched.rows(32.0 / 16.0), rows);
assert_eq!(scroll.rows(1.75), 1);
assert_eq!(scroll.rows(0.25), 1);
assert_eq!(scroll.rows(-0.75), 0);
assert_eq!(scroll.rows(-0.25), -1);
assert_eq!(scroll.rows(0.75), 0);
assert_eq!(scroll.rows(-0.75), 0);
}
#[test]
fn mouse_tracking_respects_precise_distance_and_wheel_units() {
use egui::MouseWheelUnit::{Line, Page, Point};
let mut scroll = ScrollAccumulator::default();
assert_eq!(scroll.wheel(Point, 0.0, 16.0, 24), 0);
assert_eq!(scroll.wheel(Point, 4.0, 16.0, 24), 0);
assert_eq!(scroll.wheel(Point, 12.0, 16.0, 24), 1);
assert_eq!(scroll.wheel(Point, -32.0, 16.0, 24), -2);
assert_eq!(scroll.wheel(Line, 1.0, 16.0, 24), 3);
assert_eq!(scroll.wheel(Page, -1.0, 16.0, 24), -24);
}
}
#[cfg(test)]
mod scrollback_tests {
use super::*;
use std::time::{Duration, Instant};
#[test]
fn editor_receives_history_and_restores_original_session_on_exit() {
let ctx = egui::Context::default();
let config = Config {
shell: vec![
"/bin/sh".into(),
"-c".into(),
"read answer; printf ORIGINAL_ALIVE; read answer".into(),
],
scrollback_editor: Config::parse(
"scrollback_editor = \"/bin/sh -c 'printf EDITOR_READY; read answer' editor\"",
)
.unwrap()
.scrollback_editor,
..Default::default()
};
let mut pane = Pane::new(&config, &theme::DEFAULT, &ctx, None).unwrap();
pane.terminal.resize(12, 2, 8, 16).unwrap();
pane.terminal
.vt_write(b"history\r\n\x1b[31mred\x1b[0m\r\nlong line that wraps\r\nlast");
pane.open_scrollback(&config, &theme::DEFAULT, &ctx)
.unwrap();
let (original, file) = pane.saved_session.as_ref().unwrap();
assert!(!original.dead);
let path = file.0.clone();
let contents = std::fs::read_to_string(&path).unwrap();
assert!(
contents.contains("history\nred\nlong line that wraps\nlast"),
"{contents:?}"
);
assert!(!contents.contains('\x1b'));
pane.send(b"done\n".to_vec()).unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
while pane.saved_session.is_some() && Instant::now() < deadline {
pane.drain(&ctx);
std::thread::sleep(Duration::from_millis(10));
}
assert!(pane.saved_session.is_none());
assert!(!pane.dead);
assert!(!path.exists());
pane.send(b"continue\n".to_vec()).unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
loop {
pane.drain(&ctx);
let text = libghostty_vt::fmt::Formatter::new(
&pane.terminal,
libghostty_vt::fmt::FormatterOptions::new().with_unwrap(true),
)
.unwrap()
.format_alloc(None)
.unwrap();
if String::from_utf8_lossy(&text).contains("ORIGINAL_ALIVE") {
break;
}
assert!(
Instant::now() < deadline,
"original process did not resume input"
);
std::thread::sleep(Duration::from_millis(10));
}
}
#[test]
fn invalid_editor_leaves_the_original_pane_intact() {
let ctx = egui::Context::default();
let config = Config {
shell: vec!["/bin/sh".into()],
..Default::default()
};
let mut pane = Pane::new(&config, &theme::DEFAULT, &ctx, None).unwrap();
for editor in ["", "'", "/no/such/slerm-editor"] {
assert!(
pane.open_scrollback_with_editor(&config, &theme::DEFAULT, &ctx, editor)
.is_err()
);
assert!(pane.saved_session.is_none());
assert!(!pane.dead);
}
}
}
impl Drop for ScrollbackFile {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
/// OSC 7 carries a `file://host/path` URL. Only a directory on this machine can be
@@ -46,13 +232,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 +277,16 @@ 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.set_default_cursor_style(Some(terminal::CursorStyle::BlockHollow))?;
term.vt_write(b"\x1b[0 q");
term.on_xtversion(|_| Some(concat!("slerm ", env!("CARGO_PKG_VERSION"))))?;
term.on_device_attributes(|_| {
Some(terminal::DeviceAttributes {
@@ -98,14 +302,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 +325,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()?;
@@ -148,12 +364,18 @@ impl Pane {
callback_error,
mouse_encoder,
mouse_event,
scrollback_scroll: ScrollAccumulator::default(),
mouse_scroll: ScrollAccumulator::default(),
selection_start: None,
focused: false,
saved_session: None,
})
}
pub fn drain(&mut self, ctx: &egui::Context) {
if let Some((session, _)) = &mut self.saved_session {
session.drain(ctx);
}
// Bound work per frame as well as queued bytes, so one busy PTY cannot starve others.
let mut drained = 0;
for _ in 0..64 {
@@ -179,6 +401,66 @@ impl Pane {
if drained == 64 {
ctx.request_repaint();
}
if self.dead
&& let Some((session, _file)) = self.saved_session.take()
{
*self = *session;
ctx.request_repaint();
}
}
pub fn open_scrollback(
&mut self,
config: &Config,
theme: &Theme,
ctx: &egui::Context,
) -> Result<()> {
if self.saved_session.is_some() {
return Ok(());
}
let editor = config
.scrollback_editor
.clone()
.unwrap_or_else(|| std::env::var("EDITOR").unwrap_or_else(|_| "vi".into()));
self.open_scrollback_with_editor(config, theme, ctx, &editor)
}
fn open_scrollback_with_editor(
&mut self,
config: &Config,
theme: &Theme,
ctx: &egui::Context,
editor: &str,
) -> Result<()> {
use libghostty_vt::fmt::{Formatter, FormatterOptions};
let mut command =
shell_words::split(editor).context("Invalid scrollback editor command")?;
ensure!(
!command.is_empty() && !command[0].is_empty(),
"Scrollback editor command is empty"
);
self.drain(ctx);
let bytes = Formatter::new(
&self.terminal,
FormatterOptions::new().with_trim(true).with_unwrap(true),
)?
.format_alloc(None)?;
let file = ScrollbackFile::new(&bytes).context("Cannot save scrollback")?;
command.push(
file.0
.to_str()
.context("Scrollback path is not valid UTF-8")?
.into(),
);
let mut editor_config = config.clone();
editor_config.shell = command;
let mut editor_pane = Self::new(&editor_config, theme, ctx, self.cwd().as_deref())
.context("Cannot open scrollback editor")?;
editor_pane.last_rect = self.last_rect;
let session = std::mem::replace(self, editor_pane);
self.saved_session = Some((Box::new(session), file));
ctx.request_repaint();
Ok(())
}
pub fn send(&mut self, bytes: Vec<u8>) -> Result<()> {
@@ -191,6 +473,7 @@ impl Pane {
.try_send(bytes)
.map_err(|e| anyhow::anyhow!("PTY input queue: {e}"))?;
self.terminal.scroll_viewport(ScrollViewport::Bottom);
self.scrollback_scroll = ScrollAccumulator::default();
Ok(())
}
@@ -245,6 +528,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 +557,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()
@@ -298,6 +591,12 @@ impl Pane {
) -> Result<()> {
let modifiers = ui.input(|i| i.modifiers);
let tracking = self.terminal.is_mouse_tracking()? && !modifiers.shift;
if !response.hovered() || tracking {
self.scrollback_scroll = ScrollAccumulator::default();
}
if !response.hovered() || !tracking {
self.mouse_scroll = ScrollAccumulator::default();
}
let pos = response
.interact_pointer_pos()
.or_else(|| ui.input(|i| i.pointer.hover_pos()));
@@ -346,8 +645,9 @@ impl Pane {
mouse::Action::Release
})
.set_button(Some(button));
self.mouse_encoder
.encode_to_vec(&self.mouse_event, &mut data)?;
input::append_encoded(&mut data, |buffer| {
self.mouse_encoder.encode(&self.mouse_event, buffer)
})?;
}
egui::Event::PointerMoved(_) => {
let button = ui.input(|i| {
@@ -362,20 +662,23 @@ impl Pane {
self.mouse_event
.set_action(mouse::Action::Motion)
.set_button(button);
self.mouse_encoder
.encode_to_vec(&self.mouse_event, &mut data)?;
input::append_encoded(&mut data, |buffer| {
self.mouse_encoder.encode(&self.mouse_event, buffer)
})?;
}
egui::Event::MouseWheel { delta, .. } => {
egui::Event::MouseWheel { unit, delta, .. } => {
let rows = self.mouse_scroll.wheel(unit, delta.y, cell.y, self.size.1);
self.mouse_event
.set_action(mouse::Action::Press)
.set_button(Some(if delta.y > 0.0 {
.set_button(Some(if rows > 0 {
mouse::Button::Four
} else {
mouse::Button::Five
}));
for _ in 0..3 {
self.mouse_encoder
.encode_to_vec(&self.mouse_event, &mut data)?;
for _ in 0..rows.unsigned_abs() {
input::append_encoded(&mut data, |buffer| {
self.mouse_encoder.encode(&self.mouse_event, buffer)
})?;
}
}
_ => {}
@@ -408,10 +711,9 @@ impl Pane {
}
if response.hovered() {
let scroll = ui.input(|i| i.raw_scroll_delta.y);
if scroll.abs() >= 1.0 {
self.terminal.scroll_viewport(ScrollViewport::Delta(
-(scroll / cell.y).round() as isize,
));
let rows = self.scrollback_scroll.rows(scroll / cell.y);
if rows != 0 {
self.terminal.scroll_viewport(ScrollViewport::Delta(-rows));
}
}
}
+163
View File
@@ -0,0 +1,163 @@
//! The source of truth for the plugin API and its generated TypeScript bindings.
use crate::config::Action;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
pub const API_VERSION: u32 = 1;
// Declare each method, its parameters and its result once. The enum drives Rust
// dispatch; the generated result map drives the TypeScript client's signatures.
macro_rules! plugin_api {
($($variant:ident => $method:ident { $($(#[$attr:meta])* $arg:ident: $ty:ty),* } -> $result:ty;)+) => {
#[derive(Debug, Deserialize, TS)]
#[serde(tag = "method", rename_all = "camelCase", deny_unknown_fields)]
pub enum Operation {
$($variant { $($(#[$attr])* $arg: $ty),* },)+
}
#[derive(TS)]
#[allow(dead_code, non_snake_case)]
pub struct Results { $(pub $method: $result,)+ }
};
}
plugin_api! {
GetState => getState {} -> State;
Execute => execute { action: Action } -> ();
FocusPane => focusPane { #[ts(type = "number")] pane: u64 } -> ();
FocusTab => focusTab { #[ts(type = "number")] tab: u64 } -> ();
ReadTerminal => readTerminal { #[ts(type = "number")] pane: u64 } -> String;
WriteTerminal => writeTerminal { #[ts(type = "number")] pane: u64, text: String } -> ();
RegisterCommand => registerCommand { command: PluginCommand } -> ();
UnregisterCommand => unregisterCommand { command: String } -> ();
SetPanel => setPanel { panel: Panel } -> ();
RemovePanel => removePanel { panel: String } -> ();
ReportError => reportError { message: String } -> ();
}
#[derive(Clone, Debug, Serialize, TS)]
#[serde(rename_all = "camelCase")]
pub struct State {
#[ts(type = "number | null")]
pub active_tab: Option<u64>,
pub tabs: Vec<TabState>,
}
#[derive(Clone, Debug, Serialize, TS)]
#[serde(rename_all = "camelCase")]
pub struct TabState {
#[ts(type = "number")]
pub id: u64,
#[ts(type = "number")]
pub active_pane: u64,
pub floats_visible: bool,
pub zoomed: bool,
pub panes: Vec<PaneState>,
}
#[derive(Clone, Debug, Serialize, TS)]
pub struct PaneState {
#[ts(type = "number")]
pub id: u64,
pub title: String,
pub floating: bool,
pub exited: bool,
}
#[derive(Clone, Debug, Deserialize, TS)]
#[serde(deny_unknown_fields)]
pub struct PluginCommand {
pub id: String,
pub title: String,
}
#[derive(Clone, Debug, Deserialize, TS)]
#[serde(deny_unknown_fields)]
pub struct Panel {
pub id: String,
pub title: String,
pub elements: Vec<Element>,
}
#[derive(Clone, Debug, Deserialize, TS)]
#[serde(tag = "type", rename_all = "camelCase", deny_unknown_fields)]
pub enum Element {
Text {
text: String,
},
Button {
id: String,
label: String,
},
Input {
id: String,
label: String,
value: String,
},
Separator,
}
#[derive(Clone, Debug, Serialize, TS)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum UIEvent {
Click {
panel: String,
control: String,
},
Change {
panel: String,
control: String,
value: String,
},
}
#[derive(Clone, Debug, Serialize, TS)]
#[serde(tag = "event", rename_all = "camelCase")]
pub enum Event {
Command { command: String },
State { state: State },
Ui { data: UIEvent },
}
pub fn typescript() -> String {
let mut output =
String::from("// Generated by cargo run -- --generate-plugin-sdk. Do not edit.\n\n");
output.push_str(&format!(
"export const apiVersion = {API_VERSION} as const;\n\n"
));
let config = ts_rs::Config::default();
for declaration in [
Action::decl(&config),
Operation::decl(&config),
Results::decl(&config),
State::decl(&config),
TabState::decl(&config),
PaneState::decl(&config),
PluginCommand::decl(&config),
Panel::decl(&config),
Element::decl(&config),
UIEvent::decl(&config),
Event::decl(&config),
] {
output.push_str("export ");
output.push_str(&declaration);
output.push_str("\n\n");
}
output.push_str(r#"type Params<M extends Operation["method"]> = Omit<Extract<Operation, { method: M }>, "method">;
export type Slerm = {
[M in Operation["method"]]: (...args: keyof Params<M> extends never ? [] : [params: Params<M>]) => Promise<Results[M] extends null ? void : Results[M]>;
};
"#);
output
}
#[cfg(test)]
mod tests {
#[test]
fn generated_sdk_matches_rust_api() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("sdk/src/generated.ts");
assert_eq!(
std::fs::read_to_string(path).unwrap(),
super::typescript(),
"Regenerate bindings with cargo run -- --generate-plugin-sdk"
);
}
}
+457
View File
@@ -0,0 +1,457 @@
//! Embedded Boa runtimes, one worker thread per plugin. UI mutations stay on the UI thread.
use crate::{
config::Plugin,
plugin_api::{API_VERSION, Element, Event, Operation, Panel, PluginCommand, UIEvent},
};
use anyhow::{Context as _, Result, ensure};
use boa_engine::{
Context, JsNativeError, JsResult, JsValue, NativeFunction, Source,
job::{Job, JobExecutor},
js_string,
property::Attribute,
};
use serde_json::Value;
use std::{
cell::RefCell,
collections::{BTreeMap, VecDeque},
path::Path,
rc::Rc,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
mpsc::{self, Receiver, SyncSender, TryRecvError},
},
thread,
time::{Duration, Instant},
};
const MAX_MESSAGE: usize = 1024 * 1024;
pub fn valid_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 80
&& id
.bytes()
.all(|c| c.is_ascii_alphanumeric() || b"._-".contains(&c))
}
pub struct Pending {
pub operation: Operation,
pub reply: SyncSender<Value>,
}
struct Bridge {
requests: SyncSender<Result<Pending, String>>,
repaint: egui::Context,
stopped: Arc<AtomicBool>,
}
fn host_call(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let text = args
.first()
.unwrap_or(&JsValue::undefined())
.to_string(ctx)?
.to_std_string_escaped();
if text.len() > MAX_MESSAGE {
return Err(JsNativeError::range()
.with_message("plugin request exceeds 1 MiB")
.into());
}
let operation = serde_json::from_str(&text)
.map_err(|e| JsNativeError::typ().with_message(e.to_string()))?;
let bridge = ctx.get_data::<Bridge>().unwrap();
let (reply, response) = mpsc::sync_channel(1);
bridge
.requests
.try_send(Ok(Pending { operation, reply }))
.map_err(|_| JsNativeError::error().with_message("plugin host is unavailable"))?;
bridge.repaint.request_repaint();
let deadline = Instant::now() + Duration::from_secs(10);
loop {
if bridge.stopped.load(Ordering::Relaxed) || Instant::now() >= deadline {
return Err(JsNativeError::error()
.with_message("plugin stopped or host request timed out")
.into());
}
match response.recv_timeout(Duration::from_millis(50)) {
Ok(value) => return Ok(js_string!(serde_json::to_string(&value).unwrap()).into()),
Err(mpsc::RecvTimeoutError::Timeout) => {}
Err(_) => {
return Err(JsNativeError::error()
.with_message("plugin host disconnected")
.into());
}
}
}
}
fn host_error(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let text = args
.first()
.unwrap_or(&JsValue::undefined())
.to_string(ctx)?
.to_std_string_escaped();
let bridge = ctx.get_data::<Bridge>().unwrap();
let _ = bridge.requests.try_send(Err(text));
bridge.repaint.request_repaint();
Ok(JsValue::undefined())
}
// This runtime exposes promises, but no timers or native async jobs. Bound each
// promise checkpoint as well as JS loops, so runaway callbacks can be stopped.
struct Jobs {
queue: RefCell<VecDeque<Job>>,
stopped: Arc<AtomicBool>,
}
impl JobExecutor for Jobs {
fn enqueue_job(self: Rc<Self>, job: Job, _: &mut Context) {
self.queue.borrow_mut().push_back(job);
}
fn run_jobs(self: Rc<Self>, ctx: &mut Context) -> JsResult<()> {
for _ in 0..10_000 {
if self.stopped.load(Ordering::Relaxed) {
return Err(JsNativeError::error().with_message("plugin stopped").into());
}
let Some(job) = self.queue.borrow_mut().pop_front() else {
return Ok(());
};
match job {
Job::PromiseJob(job) => {
job.call(ctx)?;
}
Job::GenericJob(job) => {
job.call(ctx)?;
}
Job::FinalizationRegistryCleanupJob(_) => {}
_ => {
return Err(JsNativeError::error()
.with_message("unsupported plugin job")
.into());
}
}
}
Err(JsNativeError::error()
.with_message("plugin promise job limit exceeded")
.into())
}
}
fn run(source: String, events: Receiver<Event>, bridge: Bridge) -> Result<()> {
let jobs = Rc::new(Jobs {
queue: RefCell::new(VecDeque::new()),
stopped: bridge.stopped.clone(),
});
let mut ctx = Context::builder()
.job_executor(jobs)
.build()
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
ctx.runtime_limits_mut().set_loop_iteration_limit(1_000_000);
ctx.runtime_limits_mut().set_recursion_limit(128);
ctx.insert_data(bridge);
let execute = |ctx: &mut Context, source: &str| -> Result<()> {
ctx.eval(Source::from_bytes(source))
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
ctx.run_jobs().map_err(|e| anyhow::anyhow!(e.to_string()))?;
Ok(())
};
ctx.register_global_builtin_callable(
js_string!("__slerm_call"),
1,
NativeFunction::from_fn_ptr(host_call),
)
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
ctx.register_global_builtin_callable(
js_string!("__slerm_error"),
1,
NativeFunction::from_fn_ptr(host_error),
)
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
ctx.register_global_property(
js_string!("__slerm_version"),
API_VERSION,
Attribute::READONLY,
)
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
execute(&mut ctx, include_str!("../sdk/runtime.js"))?;
execute(&mut ctx, &source)?;
while let Ok(event) = events.recv() {
if ctx
.get_data::<Bridge>()
.unwrap()
.stopped
.load(Ordering::Relaxed)
{
break;
}
execute(
&mut ctx,
&format!("__slerm_dispatch({});", serde_json::to_string(&event)?),
)?;
}
Ok(())
}
pub struct Runtime {
pub id: String,
pub commands: BTreeMap<String, PluginCommand>,
pub panels: BTreeMap<String, Panel>,
pub last_state: Option<Value>,
outgoing: Option<SyncSender<Event>>,
incoming: Option<Receiver<Result<Pending, String>>>,
stopped: Arc<AtomicBool>,
}
impl Runtime {
pub fn spawn(config: &Plugin, config_dir: &Path, ctx: &egui::Context) -> Result<Self> {
let path = config_dir.join(&config.path);
let source = std::fs::read_to_string(&path)
.with_context(|| format!("Cannot read plugin {} at {}", config.id, path.display()))?;
ensure!(
source.len() <= 4 * MAX_MESSAGE,
"plugin bundle exceeds 4 MiB"
);
let (outgoing, events) = mpsc::sync_channel(64);
let (requests, incoming) = mpsc::sync_channel(64);
let stopped = Arc::new(AtomicBool::new(false));
let bridge = Bridge {
requests: requests.clone(),
repaint: ctx.clone(),
stopped: stopped.clone(),
};
let repaint = ctx.clone();
thread::Builder::new()
.name(format!("plugin-{}", config.id))
.spawn(move || {
if let Err(error) = run(source, events, bridge) {
let _ = requests.try_send(Err(error.to_string()));
repaint.request_repaint();
}
})?;
Ok(Self {
id: config.id.clone(),
commands: BTreeMap::new(),
panels: BTreeMap::new(),
last_state: None,
outgoing: Some(outgoing),
incoming: Some(incoming),
stopped,
})
}
pub fn poll(&mut self) -> Option<Result<Pending, String>> {
match self.incoming.as_ref()?.try_recv() {
Ok(request) => Some(request),
Err(TryRecvError::Empty) => None,
Err(TryRecvError::Disconnected) => Some(Err("plugin runtime stopped".into())),
}
}
pub fn send(&self, event: Event) -> Result<()> {
let outgoing = self
.outgoing
.as_ref()
.ok_or_else(|| anyhow::anyhow!("plugin is stopped"))?;
ensure!(
serde_json::to_vec(&event)?.len() < MAX_MESSAGE,
"plugin event exceeds 1 MiB"
);
outgoing
.try_send(event)
.context("plugin event queue is full or disconnected")
}
pub fn is_running(&self) -> bool {
!self.stopped.load(Ordering::Relaxed)
}
pub fn stop(&mut self) {
self.stopped.store(true, Ordering::Relaxed);
self.commands.clear();
self.panels.clear();
self.outgoing.take();
self.incoming.take();
}
}
impl Drop for Runtime {
fn drop(&mut self) {
self.stop();
}
}
impl Panel {
pub fn validate(&self) -> Result<()> {
ensure!(valid_id(&self.id), "invalid panel id");
ensure!(
!self.title.trim().is_empty() && self.title.len() <= 200,
"invalid panel title"
);
ensure!(
self.elements.len() <= 128,
"a panel supports at most 128 elements"
);
let mut ids = std::collections::HashSet::new();
for element in &self.elements {
let id = match element {
Element::Button { id, .. } | Element::Input { id, .. } => Some(id),
_ => None,
};
if let Some(id) = id {
ensure!(
valid_id(id) && ids.insert(id),
"invalid or duplicate control id"
);
}
}
Ok(())
}
}
#[derive(Default)]
pub struct Host {
pub runtimes: Vec<Runtime>,
}
impl Host {
pub fn start(
configs: &[Plugin],
config_dir: &Path,
ctx: &egui::Context,
) -> (Self, Vec<String>) {
let mut host = Self::default();
let mut errors = Vec::new();
for config in configs {
match Runtime::spawn(config, config_dir, ctx) {
Ok(process) => host.runtimes.push(process),
Err(e) => errors.push(e.to_string()),
}
}
(host, errors)
}
pub fn invoke(&self, index: usize, command: &str) -> Result<()> {
let plugin = &self.runtimes[index];
ensure!(
plugin.commands.contains_key(command),
"unknown plugin command"
);
plugin.send(Event::Command {
command: command.into(),
})
}
pub fn show_panels(&mut self, ctx: &egui::Context) -> Vec<String> {
let mut errors = Vec::new();
for plugin in &mut self.runtimes {
let mut events = Vec::new();
for panel in plugin.panels.values_mut() {
egui::Window::new(&panel.title)
.id(egui::Id::new(("plugin panel", &plugin.id, &panel.id)))
.order(egui::Order::Foreground)
.default_width(280.0)
.show(ctx, |ui| {
egui::ScrollArea::vertical()
.max_height(500.0)
.show(ui, |ui| {
for element in &mut panel.elements {
match element {
Element::Text { text } => {
ui.label(text.as_str());
}
Element::Separator => {
ui.separator();
}
Element::Button { id, label } => {
if ui
.push_id(id.as_str(), |ui| {
ui.button(label.as_str())
})
.inner
.clicked()
{
events.push(Event::Ui {
data: UIEvent::Click {
panel: panel.id.clone(),
control: id.clone(),
},
});
}
}
Element::Input { id, label, value } => {
ui.label(label.as_str());
if ui
.add(
egui::TextEdit::singleline(value)
.id_source(id.as_str()),
)
.changed()
{
events.push(Event::Ui {
data: UIEvent::Change {
panel: panel.id.clone(),
control: id.clone(),
value: value.clone(),
},
});
}
}
}
}
});
});
}
for event in events {
if let Err(e) = plugin.send(event) {
errors.push(format!("{}: {e}", plugin.id));
}
}
}
errors
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn rejects_invalid_configuration_and_ui() {
assert!(crate::config::Config::parse("[[plugins]]\nid='x'\npath=''\n").is_err());
assert!(
crate::config::Config::parse(
"[[plugins]]\nid='x'\npath='a.js'\n[[plugins]]\nid='x'\npath='b.js'\n"
)
.is_err()
);
assert!(serde_json::from_str::<Operation>(r#"{"method":"unknown"}"#).is_err());
let panel: Panel =
serde_json::from_value(json!({"id":"panel", "title":"Panel", "elements":[
{"type":"button", "id":"same", "label":"One"},
{"type":"input", "id":"same", "label":"Two", "value":""}
]}))
.unwrap();
assert!(panel.validate().is_err());
}
#[test]
fn boa_reports_script_errors_and_limits_runaway_loops_and_promises() {
for source in [
"throw Error('broken plugin')",
"while (true) {}",
"function again() { Promise.resolve().then(again); } again();",
"__slerm.activate({}, 999)",
"process.exit(0)",
] {
let (_, events) = mpsc::sync_channel(1);
let (requests, _) = mpsc::sync_channel(1);
let bridge = Bridge {
requests,
repaint: egui::Context::default(),
stopped: Arc::new(AtomicBool::new(false)),
};
assert!(
run(source.into(), events, bridge).is_err(),
"source should fail: {source}"
);
}
}
}
+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(());
+480 -46
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,24 +8,43 @@ use libghostty_vt::{
alloc::{Allocator, Bytes},
kitty::graphics::{DecodePng, DecodedImage, ImageFormat, Layer, PlacementIterator},
render::{CellIterator, Colors, CursorVisualStyle, Dirty, RowIterator},
style::{RgbColor, Underline},
screen::CellWide,
style::{RgbColor, Style, Underline},
terminal::Mode,
};
use std::{
collections::{HashMap, HashSet},
io::Cursor,
sync::Arc,
time::{Duration, Instant},
};
/// 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)
}
pub fn cell_size(painter: &Painter, font: &FontId) -> egui::Vec2 {
let pixels_per_point = painter.ctx().pixels_per_point();
painter.ctx().fonts_mut(|fonts| {
vec2(
fonts.glyph_width(font, 'M').ceil().max(1.0),
fonts.row_height(font).ceil().max(1.0),
)
// Cached glyphs are rasterized at x=0 and egui snaps their positions to
// physical pixels. Use a whole-pixel column pitch so that snapping
// cannot periodically insert an extra pixel between columns. Round
// down to avoid spacing wider than the font's advance.
let width_px = (fonts.glyph_width(font, 'M') * pixels_per_point)
.floor()
.max(1.0);
vec2(width_px / pixels_per_point, fonts.row_height(font).max(1.0))
})
}
@@ -125,16 +145,71 @@ struct CachedRow {
backgrounds: Vec<egui::Shape>,
foregrounds: Vec<egui::Shape>,
selection: Option<(u16, u16)>,
cells: Vec<CachedCell>,
}
#[derive(PartialEq)]
struct CellAppearance {
text: String,
style: Style,
fg: Color32,
bg: Color32,
background: bool,
wide: CellWide,
}
struct CachedCell {
appearance: CellAppearance,
backgrounds: std::ops::Range<usize>,
foregrounds: std::ops::Range<usize>,
}
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,13 +217,19 @@ 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,
frame: Vec<egui::epaint::ClippedShape>,
sync_started: Option<Instant>,
#[cfg(test)]
rebuilt_rows: usize,
#[cfg(test)]
rebuilt_cells: usize,
}
impl Renderer {
@@ -162,11 +243,17 @@ 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,
frame: Vec::new(),
sync_started: None,
#[cfg(test)]
rebuilt_rows: 0,
#[cfg(test)]
rebuilt_cells: 0,
})
}
@@ -174,39 +261,95 @@ 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.frame.clear();
}
self.font_marker = Some(marker);
self.cached_cell = cell;
// Editors may clear and redraw in separate PTY reads. Keep presenting
// the last complete frame until DEC synchronized output ends. Bound
// the wait so a crashed application cannot leave the display frozen.
if term.mode(Mode::SYNC_OUTPUT)? {
let elapsed = self.sync_started.get_or_insert_with(Instant::now).elapsed();
let timeout = Duration::from_secs(1);
if elapsed < timeout && !self.frame.is_empty() {
painter.ctx().request_repaint_after(timeout - elapsed);
self.present(&painter, rect);
return Ok(());
}
} else {
self.sync_started = None;
}
let screen_rect = rect;
let rect = Rect::from_min_size(egui::Pos2::ZERO, rect.size());
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 shapes = vec![egui::Shape::rect_filled(rect, 0, color(colors.background))];
let mut cursor_shapes = Vec::new();
let mut rows = self.rows.update(&snapshot)?;
let mut text = String::new();
let mut y = 0;
@@ -237,36 +380,91 @@ 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);
}
if explicit_bg.is_some() || style.inverse || selected {
backgrounds.push(egui::Shape::rect_filled(cell_rect, 0, 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 style.faint {
fg = fg.gamma_multiply(0.6);
}
if !style.invisible && c.graphemes_len()? > 0 {
let wide = c.raw_cell()?.wide()?;
text.clear();
c.graphemes_utf8(&mut text)?;
let appearance = CellAppearance {
text: text.clone(),
style,
fg,
bg,
background: explicit_bg.is_some() || style.inverse || selected,
wide,
};
// Dirty flags are row-granular. Compare cells so edits in one
// split don't rebuild its unchanged neighbor on the same row.
let background_start = backgrounds.len();
let foreground_start = foregrounds.len();
if let Some(old) = self.row_cache[y].cells.get(x)
&& old.appearance == appearance
{
backgrounds
.extend_from_slice(&self.row_cache[y].backgrounds[old.backgrounds.clone()]);
foregrounds
.extend_from_slice(&self.row_cache[y].foregrounds[old.foregrounds.clone()]);
} else {
#[cfg(test)]
{
self.rebuilt_cells += 1;
}
if appearance.background {
backgrounds.push(egui::Shape::rect_filled(cell_rect, 0, bg));
}
if !style.invisible
&& !matches!(wide, CellWide::SpacerHead | CellWide::SpacerTail)
&& c.graphemes_len()? > 0
{
// 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 [
@@ -281,6 +479,12 @@ impl Renderer {
));
}
}
}
cached.cells.push(CachedCell {
appearance,
backgrounds: background_start..backgrounds.len(),
foregrounds: foreground_start..foregrounds.len(),
});
x += 1;
}
self.row_cache[y] = cached;
@@ -291,7 +495,7 @@ impl Renderer {
let mut seen = HashSet::new();
for layer in [Layer::BelowBg, Layer::BelowText, Layer::AboveText] {
if layer == Layer::BelowText {
painter.extend(
shapes.extend(
self.row_cache
.iter()
.flat_map(|row| row.backgrounds.iter())
@@ -303,7 +507,7 @@ impl Renderer {
);
}
if layer == Layer::AboveText {
painter.extend(
shapes.extend(
self.row_cache
.iter()
.flat_map(|row| row.foregrounds.iter())
@@ -323,6 +527,7 @@ impl Renderer {
&mut self.placements,
&mut self.textures,
&mut seen,
&mut shapes,
)?;
}
self.textures.retain(|id, _| seen.contains(id));
@@ -337,25 +542,74 @@ impl Renderer {
match snapshot.cursor_visual_style()? {
CursorVisualStyle::Bar if active => {
cursor_rect.max.x = cursor_rect.min.x + 2.0;
painter.rect_filled(cursor_rect, 0, c);
shapes.push(egui::Shape::rect_filled(cursor_rect, 0, c));
}
CursorVisualStyle::Underline if active => {
cursor_rect.min.y = cursor_rect.max.y - 2.0;
painter.rect_filled(cursor_rect, 0, c);
shapes.push(egui::Shape::rect_filled(cursor_rect, 0, c));
}
CursorVisualStyle::Block if active => {
shapes.push(egui::Shape::rect_filled(cursor_rect, 0, c));
// Redraw the covered glyph in the background color so an
// application's solid block cursor leaves text readable.
let ink = color(colors.background);
cursor_shapes.extend(
self.row_cache[cursor.y as usize]
.foregrounds
.iter()
.cloned()
.map(|mut shape| {
shape.translate(rect.min.to_vec2());
egui::epaint::shape_transform::adjust_colors(
&mut shape,
move |color| {
if *color != Color32::TRANSPARENT {
*color = ink;
}
},
);
egui::epaint::ClippedShape {
clip_rect: cursor_rect.intersect(rect),
shape,
}
}),
);
}
_ => {
painter.rect_stroke(
shapes.push(egui::Shape::rect_stroke(
cursor_rect,
0,
Stroke::new(if active { 1.5_f32 } else { 0.75_f32 }, c),
theme::CURSOR_CORNER_RADIUS,
Stroke::new(theme::BORDER_WIDTH, c),
egui::StrokeKind::Inside,
);
));
}
}
}
snapshot.set_dirty(Dirty::Clean)?;
self.frame = shapes
.into_iter()
.map(|shape| egui::epaint::ClippedShape {
clip_rect: rect,
shape,
})
.chain(cursor_shapes)
.collect();
self.present(&painter, screen_rect);
Ok(())
}
fn present(&self, painter: &Painter, rect: Rect) {
for cached in &self.frame {
let mut shape = cached.shape.clone();
shape.translate(rect.min.to_vec2());
let clip = cached
.clip_rect
.translate(rect.min.to_vec2())
.intersect(rect)
.intersect(painter.clip_rect());
painter.with_clip_rect(clip).add(shape);
}
}
}
#[allow(clippy::too_many_arguments)]
@@ -368,6 +622,7 @@ fn paint_images(
iterator: &mut PlacementIterator<'_>,
textures: &mut HashMap<u32, (u64, TextureHandle)>,
seen: &mut HashSet<u32>,
shapes: &mut Vec<egui::Shape>,
) -> Result<()> {
let graphics = term.kitty_graphics()?;
let mut placements = iterator.update(&graphics)?;
@@ -442,7 +697,12 @@ fn paint_images(
}
images.sort_by_key(|(z, id, pid, _, _)| (*z, *id, *pid));
for (_, id, _, dest, uv) in images {
painter.image(textures[&id].1.id(), dest, uv, Color32::WHITE);
shapes.push(egui::Shape::image(
textures[&id].1.id(),
dest,
uv,
Color32::WHITE,
));
}
Ok(())
}
@@ -451,6 +711,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,12 +797,85 @@ 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
}
#[test]
fn scrolling_one_editor_split_reuses_unchanged_cells() {
let ctx = egui::Context::default();
let mut term =
crate::pane::terminal(&crate::config::Config::default(), &theme::DEFAULT).unwrap();
term.resize(20, 5, 8, 16).unwrap();
term.vt_write(b"\x1b[?25l");
for row in 1..=5 {
term.vt_write(format!("\x1b[{row};1Hleft {row} right {row}").as_bytes());
}
let rect = Rect::from_min_size(pos2(12.0, 24.0), vec2(200.0, 120.0));
let font = FontId::monospace(16.0);
let mut renderer = Renderer::new().unwrap();
frame(&ctx, &mut renderer, &term, rect, &font);
assert_eq!(renderer.rebuilt_cells, 100);
// Simulate an editor repainting entire rows while only its left split changes.
for row in 1..=5 {
term.vt_write(format!("\x1b[{row};1Hleft {} right {row}", row + 1).as_bytes());
}
let actual = frame(&ctx, &mut renderer, &term, rect, &font);
assert_eq!(renderer.rebuilt_cells, 105, "only five digits changed");
assert_eq!(
actual,
frame(&ctx, &mut Renderer::new().unwrap(), &term, rect, &font)
);
// Rewriting identical text should reuse every cell, even on dirty rows.
term.vt_write(b"\x1b[1;1Hleft 2 right 1");
frame(&ctx, &mut renderer, &term, rect, &font);
assert_eq!(renderer.rebuilt_cells, 105);
}
#[test]
fn synchronized_updates_keep_the_last_complete_frame() {
let ctx = egui::Context::default();
let mut term =
crate::pane::terminal(&crate::config::Config::default(), &theme::DEFAULT).unwrap();
term.resize(20, 5, 8, 16).unwrap();
term.vt_write(b"\x1b[?25lleft buffer\r\nright buffer");
let rect = Rect::from_min_size(pos2(12.0, 24.0), vec2(200.0, 120.0));
let font = FontId::monospace(16.0);
let mut renderer = Renderer::new().unwrap();
let initial = frame(&ctx, &mut renderer, &term, rect, &font);
let cells = renderer.rebuilt_cells;
term.vt_write(b"\x1b[?2026h\x1b[2J\x1b[Hpartial");
assert!(term.mode(Mode::SYNC_OUTPUT).unwrap());
assert_eq!(initial, frame(&ctx, &mut renderer, &term, rect, &font));
assert_eq!(renderer.rebuilt_cells, cells);
term.vt_write(b"\rcomplete\r\nright buffer\x1b[?2026l");
let completed = frame(&ctx, &mut renderer, &term, rect, &font);
assert_ne!(initial, completed);
assert_eq!(
completed,
frame(&ctx, &mut Renderer::new().unwrap(), &term, rect, &font)
);
// A missing end marker must not freeze the terminal indefinitely.
term.vt_write(b"\x1b[?2026h\x1b[Htimeout");
assert_eq!(completed, frame(&ctx, &mut renderer, &term, rect, &font));
renderer.sync_started = Some(Instant::now() - Duration::from_secs(2));
assert_ne!(completed, frame(&ctx, &mut renderer, &term, rect, &font));
}
#[test]
fn cached_rows_match_fresh_rendering_after_terminal_changes() {
use libghostty_vt::{
@@ -475,7 +884,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 +959,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 +1008,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 +1042,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();
},
@@ -680,15 +1099,30 @@ mod tests {
ctx.set_fonts(crate::app::fonts(&config).unwrap());
let _ = ctx.run(egui::RawInput::default(), |ctx| {
let painter = ctx.layer_painter(egui::LayerId::background());
for size in [12.0, 16.0, 20.0, 24.0] {
for size in [12.0, 13.0, 14.5, 16.0, 17.0, 20.0, 24.0] {
let font = FontId::monospace(size);
let cell = cell_size(&painter, &font);
let line_height = ctx.fonts_mut(|fonts| fonts.row_height(&font));
assert_eq!(
cell.y,
line_height.ceil(),
"font size {size}, scale {scale}"
assert_eq!(cell.y, line_height, "font size {size}, scale {scale}");
// The pitch must fit within the glyph advance and differ
// by less than one physical pixel.
let row = painter.layout_no_wrap("M".repeat(80), font.clone(), Color32::WHITE);
let advance: f32 = row.rows[0].glyphs.iter().map(|g| g.advance_width).sum();
assert!(
advance - 80.0 * cell.x >= -0.01 && advance - 80.0 * cell.x < 80.0 / scale,
"grid width {} vs glyph advances {advance}, size {size}, scale {scale}",
80.0 * cell.x
);
// Check the positions egui will actually use, including a
// pane origin that falls between physical pixels.
let pitch = cell.x * scale;
for origin in [0.0, 0.37, 12.25] {
for column in 0..80 {
let left = ((origin + column as f32 * cell.x) * scale).round();
let right = ((origin + (column + 1) as f32 * cell.x) * scale).round();
assert!((right - left - pitch).abs() < 0.001);
}
}
let text = painter.layout_no_wrap("NOR Android".into(), font, Color32::WHITE);
assert!(
(cell.y - text.size().y).abs() <= 1.0,
+85 -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::{
@@ -28,9 +28,32 @@ fn key(key: Key, modifiers: Modifiers) -> Event {
}
}
#[test]
fn cursor_defaults_to_hollow_and_respects_application_styles() {
use libghostty_vt::{RenderState, render::CursorVisualStyle};
let mut term = pane::terminal(&Config::default(), &theme::DEFAULT).unwrap();
let mut state = RenderState::new().unwrap();
let snapshot = state.update(&term).unwrap();
assert_eq!(
snapshot.cursor_visual_style().unwrap(),
CursorVisualStyle::BlockHollow
);
for (sequence, expected) in [
(b"\x1b[2 q", CursorVisualStyle::Block),
(b"\x1b[4 q", CursorVisualStyle::Underline),
(b"\x1b[6 q", CursorVisualStyle::Bar),
(b"\x1b[0 q", CursorVisualStyle::BlockHollow),
] {
term.vt_write(sequence);
let snapshot = state.update(&term).unwrap();
assert_eq!(snapshot.cursor_visual_style().unwrap(), expected);
}
}
#[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 = [
@@ -57,9 +80,57 @@ fn terminal_preserves_unicode_colors_and_application_input_modes() {
assert!(plain(&term).contains("hello red"));
}
#[test]
fn input_encoding_grows_buffers_without_losing_events() {
let term = pane::terminal(&Config::default(), &theme::DEFAULT).unwrap();
let mut events = vec![Event::Text("a".into()), Event::Text("123456789".into())];
let long_text = "界".repeat(200);
events.push(Event::Text(long_text.clone()));
for _ in 0..100 {
events.push(key(Key::ArrowUp, Modifiers::NONE));
}
let expected = format!("a123456789{long_text}{}", "\x1b[A".repeat(100));
assert_eq!(input::encode(&term, &events).unwrap(), expected.as_bytes());
}
#[test]
fn mouse_encoding_appends_when_destination_has_insufficient_spare_capacity() {
use libghostty_vt::mouse;
let mut term = pane::terminal(&Config::default(), &theme::DEFAULT).unwrap();
term.vt_write(b"\x1b[?1000h\x1b[?1006h");
let mut encoder = mouse::Encoder::new().unwrap();
encoder
.set_options_from_terminal(&term)
.set_size(mouse::EncoderSize {
screen_width: 800,
screen_height: 600,
cell_width: 8,
cell_height: 16,
padding_top: 0,
padding_bottom: 0,
padding_left: 0,
padding_right: 0,
});
let mut event = mouse::Event::new().unwrap();
event
.set_action(mouse::Action::Press)
.set_button(Some(mouse::Button::Left))
.set_position(mouse::Position { x: 0.0, y: 0.0 });
let mut out = Vec::with_capacity(16);
out.resize(9, b'x');
for _ in 0..100 {
input::append_encoded(&mut out, |buffer| encoder.encode(&event, buffer)).unwrap();
}
assert_eq!(
out,
format!("xxxxxxxxx{}", "\x1b[<0;1;1M".repeat(100)).as_bytes()
);
}
#[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 +145,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 +208,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 +247,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 +290,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 +301,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 +325,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);
+266
View File
@@ -0,0 +1,266 @@
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;
/// Shared stroke width for pane borders and the default hollow cursor.
pub const BORDER_WIDTH: f32 = 1.0;
pub const CORNER_RADIUS: u8 = 5;
pub const CURSOR_CORNER_RADIUS: u8 = 2;
/// 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.