Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98453329a1 | ||
|
|
a5ac986375 | ||
|
|
9b9fc8517d | ||
|
|
8d49a993e9 | ||
|
|
c21472b2f2 | ||
|
|
ca30e9a0ee | ||
|
|
c5e4e5fb85 | ||
|
|
d74b0acc83 | ||
|
|
567ffd76b2 | ||
|
|
2a4945b363 | ||
|
|
dd1622350d | ||
|
|
50e1319b92 | ||
|
|
fa8057989e | ||
|
|
51a497cb04 | ||
|
|
349f38b31c | ||
|
|
d8bfbf0294 | ||
|
|
3b04fe4f8b |
@@ -1,2 +1,5 @@
|
||||
/target/
|
||||
/.tools/
|
||||
/.worktrees
|
||||
/sdk/node_modules/
|
||||
/sdk/dist/
|
||||
|
||||
Generated
+728
-2
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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"
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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} \
|
||||
|
||||
+133
@@ -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/).
|
||||
@@ -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 }) }],
|
||||
} });
|
||||
}
|
||||
},
|
||||
});
|
||||
Generated
+515
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
} });
|
||||
})();
|
||||
@@ -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]>;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
@@ -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;
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": { "noEmit": true, "rootDir": "." },
|
||||
"include": ["examples/**/*.ts", "test/types.ts"]
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+105
-10
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.”
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user