This commit is contained in:
Gregor Lohaus
2026-09-08 10:32:15 +02:00
parent 349f38b31c
commit 51a497cb04
28 changed files with 5135 additions and 68 deletions
+1
View File
@@ -1,3 +1,4 @@
/target/
/.tools/
/result
/.worktrees
+20 -3
View File
@@ -7,9 +7,26 @@ font_size = 16
scrollback = 10000
window_width = 1100
window_height = 720
background = "#17191c"
foreground = "#e2e4e8"
accent = "#58c4ad"
# Any theme from `slerm --list-themes`: "slerm" plus every theme Helix ships.
theme = "slerm"
# Shorthand for the [colors] entries of the same name.
# background = "#17191c"
# foreground = "#e2e4e8"
# accent = "#58c4ad"
# Colors overriding the theme, all optional. Alongside the three above:
# cursor, selection_background, selection_foreground, border,
# chrome_background, chrome_foreground, chrome_active_background,
# popup_background,
# and the sixteen ANSI colors:
# black, red, green, yellow, blue, magenta, cyan, white,
# and each of those again as bright_black ... bright_white.
# [colors]
# cursor = "#58c4ad"
# red = "#cc6666"
# bright_red = "#d54e53"
[kitty]
image_storage_mb = 64
+699
View File
@@ -0,0 +1,699 @@
//! Converts the themes Helix ships in `runtime/themes` into `src/themes.rs`.
//!
//! Helix themes describe an editor: they name syntax scopes, not the sixteen
//! ANSI colors a terminal needs. The conversion therefore reads the UI scopes
//! directly (`ui.background`, `ui.statusline`, ...) and *infers* the ANSI
//! palette by scoring every color the theme uses against the hue each slot is
//! expected to hold, preferring the scopes that conventionally carry it.
//!
//! cargo run --example gen_themes -- <helix>/runtime/themes src/themes.rs
use std::{collections::BTreeMap, fmt::Write as _, path::Path};
use toml::Value;
type Rgb = [u8; 3];
/// A color as written in a theme: its RGB value, plus the ANSI slot it named if
/// it was written as one of Helix's terminal color names rather than as hex.
#[derive(Clone, Copy, PartialEq, Eq)]
struct Color {
rgb: Rgb,
ansi: Option<u8>,
}
/// Helix's terminal color names and the palette slots they select. `gray` is
/// the dim one and `light-gray` the bright one, matching how Helix hands them
/// to crossterm (`Gray` -> dark grey, `LightGray` -> grey).
const NAMED: &[(&str, u8)] = &[
("black", 0),
("red", 1),
("green", 2),
("yellow", 3),
("blue", 4),
("magenta", 5),
("cyan", 6),
("light-gray", 7),
("gray", 8),
("light-red", 9),
("light-green", 10),
("light-yellow", 11),
("light-blue", 12),
("light-magenta", 13),
("light-cyan", 14),
("white", 15),
];
/// The xterm defaults, used both for themes that name terminal colors outright
/// and as the last resort for a slot nothing in a theme fits.
const XTERM: [Rgb; 16] = [
[0x00, 0x00, 0x00],
[0xcd, 0x00, 0x00],
[0x00, 0xcd, 0x00],
[0xcd, 0xcd, 0x00],
[0x00, 0x00, 0xee],
[0xcd, 0x00, 0xcd],
[0x00, 0xcd, 0xcd],
[0xe5, 0xe5, 0xe5],
[0x7f, 0x7f, 0x7f],
[0xff, 0x00, 0x00],
[0x00, 0xff, 0x00],
[0xff, 0xff, 0x00],
[0x5c, 0x5c, 0xff],
[0xff, 0x00, 0xff],
[0x00, 0xff, 0xff],
[0xff, 0xff, 0xff],
];
/// The hue each chromatic slot aims for, and the scopes that conventionally
/// carry it. Earlier scopes are preferred, but only as a tie-break: a theme
/// whose `function` is teal still gets a teal blue rather than a stray hue.
const SLOTS: [(u8, f32, &[&str]); 6] = [
(
1,
0.0,
&[
"error",
"diagnostic.error",
"diff.minus",
"keyword.control.exception",
"markup.list.unchecked",
"variable.builtin",
],
),
(
2,
120.0,
&[
"string",
"diff.plus",
"markup.raw",
"hint",
"diagnostic.hint",
"variable.other.member",
],
),
(
3,
48.0,
&[
"warning",
"diagnostic.warning",
"constant.numeric",
"attribute",
"type",
"markup.heading",
],
),
(
4,
230.0,
&[
"function",
"info",
"diagnostic.info",
"constructor",
"markup.link.url",
"diff.delta",
],
),
(
5,
300.0,
&[
"keyword",
"constant",
"special",
"namespace",
"markup.link.text",
"type.enum.variant",
],
),
(
6,
185.0,
&[
"operator",
"constant.character.escape",
"markup.quote",
"string.special",
"punctuation.special",
"label",
],
),
];
/// One theme file, before inheritance is resolved.
#[derive(Default)]
struct Raw {
inherits: Option<String>,
palette: BTreeMap<String, String>,
/// Scope -> (fg, bg), each already flattened out of Helix's several
/// spellings (bare string, `{ fg, bg }`, `{ underline = { color } }`).
scopes: BTreeMap<String, (Option<String>, Option<String>)>,
}
fn read(path: &Path) -> Raw {
let text = std::fs::read_to_string(path).expect("cannot read theme file");
let table: toml::Table = toml::from_str(&text).expect("theme is not valid TOML");
let mut raw = Raw::default();
for (key, value) in table {
match (key.as_str(), value) {
("inherits", Value::String(name)) => raw.inherits = Some(name),
("palette", Value::Table(entries)) => {
for (name, value) in entries {
if let Value::String(color) = value {
raw.palette.insert(name, color);
}
}
}
(_, Value::String(fg)) => {
raw.scopes.insert(key, (Some(fg), None));
}
(_, Value::Table(style)) => {
let text = |v: Option<&Value>| v.and_then(Value::as_str).map(str::to_owned);
let fg = text(style.get("fg")).or_else(|| {
style
.get("underline")
.and_then(Value::as_table)
.and_then(|u| text(u.get("color")))
});
raw.scopes.insert(key, (fg, text(style.get("bg"))));
}
_ => {}
}
}
raw
}
/// A theme with its `inherits` chain folded in: the child's palette entries and
/// whole scope entries win over the parent's, exactly as Helix merges them.
struct Theme {
palette: BTreeMap<String, String>,
scopes: BTreeMap<String, (Option<String>, Option<String>)>,
}
fn flatten(name: &str, raws: &BTreeMap<String, Raw>) -> Theme {
let mut chain = vec![];
let mut current = Some(name.to_owned());
while let Some(name) = current {
if chain.contains(&name) {
break;
}
current = raws.get(&name).and_then(|r| r.inherits.clone());
chain.push(name);
}
let mut theme = Theme {
palette: BTreeMap::new(),
scopes: BTreeMap::new(),
};
// Ancestors first so that descendants overwrite them.
for name in chain.iter().rev() {
let Some(raw) = raws.get(name) else { continue };
theme.palette.extend(raw.palette.clone());
theme.scopes.extend(raw.scopes.clone());
}
theme
}
impl Theme {
fn color(&self, text: &str) -> Option<Color> {
let text = self.palette.get(text).map(String::as_str).unwrap_or(text);
if let Some((_, index)) = NAMED.iter().find(|(name, _)| *name == text) {
return Some(Color {
rgb: XTERM[*index as usize],
ansi: Some(*index),
});
}
let digits = text.strip_prefix('#')?;
let expand = |c: u8| (c << 4) | c;
let nibble = |i: usize| u8::from_str_radix(&digits[i..i + 1], 16).ok();
let byte = |i: usize| u8::from_str_radix(&digits[i..i + 2], 16).ok();
let rgb = match digits.len() {
3 => [expand(nibble(0)?), expand(nibble(1)?), expand(nibble(2)?)],
6 | 8 => [byte(0)?, byte(2)?, byte(4)?],
_ => return None,
};
Some(Color { rgb, ansi: None })
}
fn fg(&self, scope: &str) -> Option<Color> {
self.color(self.scopes.get(scope)?.0.as_deref()?)
}
fn bg(&self, scope: &str) -> Option<Color> {
self.color(self.scopes.get(scope)?.1.as_deref()?)
}
/// Every color the theme mentions anywhere, with the number of scopes that
/// use it. This is the pool the ANSI slots are chosen from; the count
/// separates a theme's working colors from one-off accents such as the
/// color of a jump label.
fn all(&self) -> Vec<(Color, usize)> {
let mut counts: BTreeMap<Rgb, (Color, usize)> = BTreeMap::new();
let named = self.palette.values().map(String::as_str);
// `ui.*` carries backgrounds, greys and one-off markers such as the
// color of a jump label, none of which belong in a terminal palette.
let scoped = self
.scopes
.iter()
.filter(|(scope, _)| !scope.starts_with("ui."))
.flat_map(|(_, (fg, bg))| [fg, bg])
.filter_map(|c| c.as_deref());
for color in named.chain(scoped).filter_map(|c| self.color(c)) {
counts.entry(color.rgb).or_insert((color, 0)).1 += 1;
}
counts.into_values().collect()
}
}
fn luma(c: Rgb) -> f32 {
let channel = |v: u8| {
let v = v as f32 / 255.0;
if v <= 0.04045 {
v / 12.92
} else {
((v + 0.055) / 1.055).powf(2.4)
}
};
0.2126 * channel(c[0]) + 0.7152 * channel(c[1]) + 0.0722 * channel(c[2])
}
fn contrast(a: Rgb, b: Rgb) -> f32 {
let (a, b) = (luma(a) + 0.05, luma(b) + 0.05);
if a > b { a / b } else { b / a }
}
/// Hue in degrees, saturation and lightness in 0..1.
fn hsl(c: Rgb) -> (f32, f32, f32) {
let [r, g, b] = [
c[0] as f32 / 255.0,
c[1] as f32 / 255.0,
c[2] as f32 / 255.0,
];
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let (span, sum) = (max - min, max + min);
let lightness = sum / 2.0;
if span < f32::EPSILON {
return (0.0, 0.0, lightness);
}
let saturation = span / (1.0 - (sum - 1.0).abs());
let hue = if max == r {
60.0 * (((g - b) / span) % 6.0)
} else if max == g {
60.0 * ((b - r) / span + 2.0)
} else {
60.0 * ((r - g) / span + 4.0)
};
(hue.rem_euclid(360.0), saturation, lightness)
}
fn hue_distance(a: f32, b: f32) -> f32 {
let d = (a - b).abs() % 360.0;
d.min(360.0 - d)
}
fn mix(a: Rgb, b: Rgb, t: f32) -> Rgb {
let channel = |i: usize| (a[i] as f32 + (b[i] as f32 - a[i] as f32) * t).round() as u8;
[channel(0), channel(1), channel(2)]
}
const BLACK: Rgb = [0, 0, 0];
const WHITE: Rgb = [255, 255, 255];
/// The converted theme, mirroring the struct in `src/theme.rs`.
struct Out {
dark: bool,
background: Rgb,
foreground: Rgb,
cursor: Rgb,
selection_bg: Rgb,
selection_fg: Rgb,
accent: Rgb,
chrome_bg: Rgb,
chrome_fg: Rgb,
chrome_active_bg: Rgb,
border: Rgb,
popup_bg: Rgb,
ansi: [Rgb; 16],
}
fn convert(theme: &Theme) -> Out {
let text = theme
.fg("ui.text")
.or_else(|| theme.fg("ui.background"))
.or_else(|| theme.fg("ui.statusline"));
let background = theme
.bg("ui.background")
.or_else(|| theme.bg("ui.popup"))
.or_else(|| theme.bg("ui.statusline"));
// A theme that never states a background (the `term16` family leaves it to
// the terminal) is placed by the brightness of its text instead.
let dark = match (background, text) {
(Some(bg), _) => luma(bg.rgb) < 0.22,
(None, Some(fg)) => luma(fg.rgb) > 0.22,
(None, None) => true,
};
let background = background.map_or(if dark { BLACK } else { WHITE }, |c| c.rgb);
let mut foreground = text.map_or(if dark { WHITE } else { BLACK }, |c| c.rgb);
// A theme that states neither (peachpuff leaves both to the terminal) can
// resolve to text the same shade as its background, which renders nothing.
if contrast(foreground, background) < 2.0 {
foreground = if dark { WHITE } else { BLACK };
}
let far = if dark { WHITE } else { BLACK };
let ansi = palette(theme, background, foreground, dark);
let cursor = theme
.bg("ui.cursor.primary")
.or_else(|| theme.bg("ui.cursor"))
.or_else(|| theme.fg("ui.cursor.primary"))
.or_else(|| theme.fg("ui.cursor"))
.map_or(foreground, |c| c.rgb);
let selection_bg = theme
.bg("ui.selection.primary")
.or_else(|| theme.bg("ui.selection"))
.map_or(mix(background, far, 0.22), |c| c.rgb);
let selection_fg = theme
.fg("ui.selection.primary")
.or_else(|| theme.fg("ui.selection"))
.map(|c| c.rgb)
.filter(|c| contrast(*c, selection_bg) >= 2.5)
.unwrap_or_else(|| {
if contrast(foreground, selection_bg) >= 2.5 {
foreground
} else {
mix(
selection_bg,
if luma(selection_bg) > 0.3 {
BLACK
} else {
WHITE
},
0.85,
)
}
});
let chrome_bg = theme
.bg("ui.statusline")
.or_else(|| theme.bg("ui.popup"))
.map(|c| c.rgb)
.filter(|c| *c != background)
.unwrap_or_else(|| mix(background, far, 0.09));
let chrome_fg = theme
.fg("ui.statusline")
.map(|c| c.rgb)
.filter(|c| contrast(*c, chrome_bg) >= 2.0)
.unwrap_or_else(|| mix(chrome_bg, far, 0.7));
// The accent marks the focused pane: it is drawn both on the title bar and
// as a border on the terminal background, so it has to read on both.
let readable = |c: Rgb| contrast(c, chrome_bg) >= 2.5 && contrast(c, background) >= 2.5;
let vivid = [theme.fg("ui.cursor.match").map(|c| c.rgb), Some(cursor)]
.into_iter()
.flatten()
.find(|c| readable(*c) && hsl(*c).1 > 0.30);
let accent = vivid
// A theme whose cursor is a plain grey still deserves a colored focus
// ring, so fall back to its own blue, cyan or magenta.
.or_else(|| {
[4, 6, 5, 3]
.into_iter()
.map(|i| ansi[i])
.find(|c| readable(*c))
})
.or(Some(cursor).filter(|c| readable(*c)))
.unwrap_or(foreground);
Out {
dark,
background,
foreground,
cursor,
selection_bg,
selection_fg,
accent,
chrome_bg,
chrome_fg,
// The focused pane's title bar steps away from the unfocused ones,
// whichever direction that is for this theme's chrome.
chrome_active_bg: mix(
chrome_bg,
if luma(chrome_bg) > 0.18 { BLACK } else { WHITE },
0.14,
),
// Unfocused borders are a hint, not a frame, so a theme with a loud
// window color is pulled back towards its background.
border: theme
.fg("ui.window")
.or_else(|| theme.fg("ui.virtual.indent-guide"))
.map_or(mix(background, far, 0.28), |c| mix(background, c.rgb, 0.65)),
popup_bg: theme.bg("ui.popup").map_or(chrome_bg, |c| c.rgb),
ansi,
}
}
/// Picks the sixteen ANSI colors. The six chromatic slots score every color the
/// theme uses against the slot's hue; the greys are derived from the background
/// and foreground so that they always stay legible against the theme's own
/// background.
fn palette(theme: &Theme, background: Rgb, foreground: Rgb, dark: bool) -> [Rgb; 16] {
let pool = theme.all();
let mut ansi = XTERM;
// A theme whose palette names terminal colors outright is already
// describing a palette; those entries are taken as written.
let named: BTreeMap<u8, Rgb> = theme
.palette
.keys()
.filter_map(|name| Some((slot_named(name)?, theme.color(name)?.rgb)))
.collect();
let mut taken: Vec<Rgb> = vec![];
let mut assigned = [false; 7];
for (index, rgb) in &named {
ansi[*index as usize] = *rgb;
if let Some(slot) = assigned.get_mut(*index as usize) {
*slot = true;
taken.push(*rgb);
}
}
// Score every slot against every color the theme uses, then hand each color
// to the slot that wants it most. Assigning greedily one slot at a time
// would let an early slot take a color a later slot needed far more, which
// is how palettes with no true blue end up with two cyans.
let mut pairs: Vec<(f32, usize, Rgb)> = vec![];
for (index, hue, scopes) in SLOTS {
if assigned[index as usize] {
continue;
}
let preferred: Vec<Rgb> = scopes
.iter()
.filter_map(|s| Some(theme.fg(s)?.rgb))
.collect();
for (candidate, uses) in &pool {
let (h, s, l) = hsl(candidate.rgb);
// A grey, an unreadable color, the plain text color and a color the
// theme only spelled as a terminal name (which says nothing about
// what that terminal color should be) are never palette entries.
if s < 0.10
|| contrast(candidate.rgb, background) < 1.8
|| candidate.ansi.is_some()
|| candidate.rgb == foreground
|| candidate.rgb == background
{
continue;
}
let rank = preferred
.iter()
.position(|p| *p == candidate.rgb)
.map_or(20.0, |r| r as f32 * 2.5);
// Whichever slot's hue this color sits closest to has the better
// claim on it, so that a theme's red is not spent on magenta just
// because it happens to be the color of `keyword`.
let closest = SLOTS
.iter()
.map(|(_, target, _)| *target)
.min_by(|a, b| hue_distance(h, *a).total_cmp(&hue_distance(h, *b)));
let score = hue_distance(h, hue) * 1.5
+ rank
+ (1.0 - s) * 38.0
+ if closest == Some(hue) { 0.0 } else { 45.0 }
// A color used once is a one-off accent, not a working color.
+ match uses {
0 | 1 => 22.0,
2 => 10.0,
_ => 0.0,
}
+ if l > 0.85 { 40.0 } else { 0.0 };
pairs.push((score, index as usize, candidate.rgb));
}
}
pairs.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)));
for (_, index, rgb) in &pairs {
if !assigned[*index] && !taken.contains(rgb) {
assigned[*index] = true;
ansi[*index] = *rgb;
taken.push(*rgb);
}
}
// A theme with fewer than six usable colors leaves slots over; those share
// whichever color came closest rather than falling back to a stock xterm
// primary that belongs to no theme at all.
for (_, index, rgb) in &pairs {
if !assigned[*index] {
assigned[*index] = true;
ansi[*index] = *rgb;
}
}
for (index, _, _) in SLOTS {
if !assigned[index as usize] {
ansi[index as usize] = mix(XTERM[index as usize], foreground, 0.4);
}
}
// The bright variant is the theme's own lighter shade of the same color
// where it has one, and a lightened copy otherwise. Matching the hue is not
// enough: a pale grey-blue is a poor bright blue, so the candidate has to
// keep most of the base color's chroma, and it cannot be a color another
// slot already owns.
let chroma = |c: Rgb| (*c.iter().max().unwrap() - *c.iter().min().unwrap()) as f32;
for (index, _, _) in SLOTS {
if named.contains_key(&(index + 8)) {
continue;
}
let base = ansi[index as usize];
let (base_hue, _, lightness) = hsl(base);
let brighter = pool
.iter()
.map(|(c, _)| c)
.filter(|c| {
let (h, _, l) = hsl(c.rgb);
hue_distance(h, base_hue) <= 30.0
&& chroma(c.rgb) >= chroma(base) * 0.7
&& (0.05..=0.40).contains(&(l - lightness))
&& contrast(c.rgb, background) >= 2.0
&& !taken.contains(&c.rgb)
})
.min_by(|a, b| hsl(a.rgb).2.total_cmp(&hsl(b.rgb).2));
ansi[index as usize + 8] = brighter.map_or_else(
|| mix(base, WHITE, if lightness > 0.7 { 0.12 } else { 0.26 }),
|c| c.rgb,
);
}
// Greys: black through bright white always span from the theme's darker end
// to its lighter end, so ANSI text stays readable in light themes too.
let comment = theme
.fg("comment")
.map(|c| c.rgb)
.filter(|c| contrast(*c, background) >= 2.0);
let greys = if dark {
[
mix(background, WHITE, 0.13),
foreground,
comment.unwrap_or_else(|| mix(background, WHITE, 0.42)),
mix(foreground, WHITE, 0.35),
]
} else {
[
mix(foreground, BLACK, 0.25),
mix(background, BLACK, 0.13),
comment.unwrap_or_else(|| mix(foreground, background, 0.45)),
background,
]
};
for (slot, grey) in [0, 7, 8, 15].into_iter().zip(greys) {
ansi[slot as usize] = *named.get(&slot).unwrap_or(&grey);
}
ansi
}
/// The palette slot a `[palette]` key names outright, so that a theme defining
/// `red`/`bright-blue` and friends keeps its own values.
fn slot_named(name: &str) -> Option<u8> {
let name = name.to_ascii_lowercase().replace('_', "-");
let (bright, base) = match name
.strip_prefix("bright-")
.or_else(|| name.strip_prefix("light-"))
{
Some(base) => (8, base.to_owned()),
None => (0, name),
};
let index = match base.as_str() {
"black" => 0,
"red" => 1,
"green" => 2,
"yellow" => 3,
"blue" => 4,
"magenta" => 5,
"cyan" => 6,
"white" => 7,
_ => return None,
};
Some(index + bright)
}
fn main() {
let mut args = std::env::args().skip(1);
let dir = args
.next()
.expect("usage: gen_themes <themes dir> <out.rs>");
let out = args
.next()
.expect("usage: gen_themes <themes dir> <out.rs>");
let mut raws = BTreeMap::new();
for entry in std::fs::read_dir(&dir).expect("cannot read themes directory") {
let path = entry.expect("cannot read theme").path();
if path.extension().is_some_and(|e| e == "toml") {
let name = path.file_stem().unwrap().to_string_lossy().into_owned();
raws.insert(name, read(&path));
}
}
let mut text = String::from(
"//! The themes Helix ships with, converted by `cargo run --example gen_themes`.\n\
//! Generated file: edit the generator, not this.\n\
//!\n\
//! Upstream: <https://github.com/helix-editor/helix/tree/master/runtime/themes>.\n\
//! The individual themes keep their own authors and licenses; see `themes/LICENSES`.\n\n\
use crate::theme::Theme;\n\n\
/// Every built-in theme, sorted by name so that lookup can binary search.\n\
pub static THEMES: &[(&str, Theme)] = &[\n",
);
for name in raws.keys() {
let out = convert(&flatten(name, &raws));
let hex = |c: Rgb| format!("0x{:02x}{:02x}{:02x}", c[0], c[1], c[2]);
let ansi = out
.ansi
.iter()
.map(|c| hex(*c))
.collect::<Vec<_>>()
.join(", ");
writeln!(
text,
" (\n {name:?},\n Theme {{\n \
dark: {dark},\n background: {bg},\n foreground: {fg},\n \
cursor: {cursor},\n \
selection_bg: {sel_bg},\n selection_fg: {sel_fg},\n \
accent: {accent},\n chrome_bg: {chrome_bg},\n \
chrome_fg: {chrome_fg},\n chrome_active_bg: {chrome_active},\n \
border: {border},\n popup_bg: {popup},\n \
ansi: [\n {ansi},\n ],\n }},\n ),",
dark = out.dark,
bg = hex(out.background),
fg = hex(out.foreground),
cursor = hex(out.cursor),
sel_bg = hex(out.selection_bg),
sel_fg = hex(out.selection_fg),
accent = hex(out.accent),
chrome_bg = hex(out.chrome_bg),
chrome_fg = hex(out.chrome_fg),
chrome_active = hex(out.chrome_active_bg),
border = hex(out.border),
popup = hex(out.popup_bg),
)
.unwrap();
}
text.push_str("];\n");
std::fs::write(&out, text).expect("cannot write output");
eprintln!("wrote {} themes to {out}", raws.len());
}
+67 -16
View File
@@ -3,10 +3,10 @@ use crate::{
input,
layout::{Axis, Direction, Layout, PaneId, Tab},
pane::{self, Pane},
render::color,
theme::{self, Theme},
};
use anyhow::{Result, ensure};
use egui::{Color32, CornerRadius, Rect, Stroke, vec2};
use egui::{CornerRadius, Rect, Stroke, vec2};
use std::{
collections::{BTreeMap, HashSet},
path::PathBuf,
@@ -14,6 +14,7 @@ use std::{
pub struct App {
config: Config,
theme: Theme,
config_path: PathBuf,
panes: BTreeMap<PaneId, Pane>,
tabs: Vec<Tab>,
@@ -76,11 +77,13 @@ impl App {
config: Config,
config_path: PathBuf,
) -> Result<Self> {
cc.egui_ctx.set_visuals(egui::Visuals::dark());
let theme = config.theme()?;
cc.egui_ctx.set_visuals(theme.visuals());
cc.egui_ctx.set_fonts(fonts(&config)?);
let pane = Pane::new(&config, &cc.egui_ctx, None)?;
let pane = Pane::new(&config, &theme, &cc.egui_ctx, None)?;
Ok(Self {
config,
theme,
config_path,
panes: BTreeMap::from([(1, pane)]),
tabs: vec![Tab::new(1, 1)],
@@ -102,13 +105,27 @@ impl App {
.get(self.current)
.and_then(|tab| self.panes.get(&tab.active))
.and_then(Pane::cwd);
let pane = Pane::new(&self.config, ctx, cwd.as_deref())?;
let pane = Pane::new(&self.config, &self.theme, ctx, cwd.as_deref())?;
let id = self.next_id;
self.next_id += 1;
self.panes.insert(id, pane);
Ok(id)
}
/// Switches theme for this session. Every open pane is retinted, since a
/// terminal keeps its palette rather than asking for it each frame.
fn set_theme(&mut self, name: &str, ctx: &egui::Context) -> Result<()> {
let previous = std::mem::replace(&mut self.config.theme, name.to_owned());
self.theme = self.config.theme().inspect_err(|_| {
self.config.theme = previous;
})?;
for pane in self.panes.values_mut() {
pane::apply_config(&mut pane.terminal, &self.config, &self.theme)?;
}
ctx.set_visuals(self.theme.visuals());
Ok(())
}
fn action(&mut self, action: Action, ctx: &egui::Context) -> Result<()> {
let active = self.tabs[self.current].active;
match action {
@@ -216,10 +233,12 @@ impl App {
Action::ReloadConfig => {
let config = Config::load(&self.config_path)?;
let fonts = fonts(&config)?;
self.theme = config.theme()?;
for pane in self.panes.values_mut() {
pane::apply_config(&mut pane.terminal, &config)?;
pane::apply_config(&mut pane.terminal, &config, &self.theme)?;
}
ctx.set_fonts(fonts);
ctx.set_visuals(self.theme.visuals());
self.config = config;
}
Action::Copy => {
@@ -382,6 +401,7 @@ impl eframe::App for App {
return;
}
let mut action = None;
let mut picked = None;
egui::TopBottomPanel::top("tabs").show(ctx, |ui| {
ui.horizontal_wrapped(|ui| {
if cfg!(target_os = "macos") {
@@ -429,6 +449,20 @@ impl eframe::App for App {
action = Some(Action::ReloadConfig);
ui.close();
}
ui.menu_button("Theme", |ui| {
ui.label("Applies now; set `theme` in the config to keep it.");
egui::ScrollArea::vertical()
.max_height(420.0)
.show(ui, |ui| {
for name in theme::names() {
let current = self.config.theme == name;
if ui.selectable_label(current, name).clicked() && !current {
picked = Some(name);
ui.close();
}
}
});
});
});
});
if cfg!(target_os = "macos") {
@@ -438,14 +472,14 @@ impl eframe::App for App {
if let Some(error) = self.error.clone() {
egui::TopBottomPanel::bottom("error").show(ctx, |ui| {
ui.horizontal_wrapped(|ui| {
ui.colored_label(Color32::LIGHT_RED, error);
ui.colored_label(theme::color(self.theme.ansi[9]), error);
if ui.button("Dismiss").clicked() {
self.error = None;
}
});
});
}
let bg = color(config::rgb(&self.config.background).unwrap());
let bg = theme::color(self.theme.background);
let tab = &mut self.tabs[self.current];
let mut tiled = vec![];
let mut workspace = Rect::NOTHING;
@@ -464,7 +498,7 @@ impl eframe::App for App {
if !tab.zoomed {
pane.last_rect = *rect;
}
if show_pane(ui, *id, pane, *rect, &self.config, active) {
if show_pane(ui, *id, pane, *rect, &self.config, &self.theme, active) {
tab.active = *id;
}
}
@@ -487,7 +521,7 @@ impl eframe::App for App {
let rect = ui.available_rect_before_wrap();
ui.allocate_rect(rect, egui::Sense::hover());
pane.last_rect = rect;
if show_pane(ui, id, pane, rect, &self.config, active) {
if show_pane(ui, id, pane, rect, &self.config, &self.theme, active) {
tab.active = id;
}
});
@@ -510,6 +544,11 @@ impl eframe::App for App {
pane.error = Some(e.to_string());
}
}
if let Some(name) = picked
&& let Err(e) = self.set_theme(name, ctx)
{
self.error = Some(e.to_string());
}
if !self.tabs.is_empty()
&& let Some(action) = action
&& let Err(e) = self.action(action, ctx)
@@ -552,9 +591,10 @@ fn show_pane(
pane: &mut Pane,
rect: Rect,
config: &Config,
theme: &Theme,
active: bool,
) -> bool {
let accent = color(config::rgb(&config.accent).unwrap());
let accent = theme::color(theme.accent);
let title_rect = Rect::from_min_size(rect.min, vec2(rect.width(), 25.0_f32.min(rect.height())));
// The title bar only rounds off the top of the pane, unless the pane is so
// short that the bar is all there is of it.
@@ -571,7 +611,11 @@ fn show_pane(
sw: bottom,
se: bottom,
},
Color32::from_gray(if active { 43 } else { 31 }),
theme::color(if active {
theme.chrome_active_bg
} else {
theme.chrome_bg
}),
);
ui.painter()
.with_clip_rect(title_rect.shrink(3.0).intersect(ui.clip_rect()))
@@ -580,7 +624,11 @@ fn show_pane(
egui::Align2::LEFT_TOP,
pane_title(id, pane),
egui::FontId::proportional(13.0),
if active { accent } else { Color32::GRAY },
if active {
accent
} else {
theme::color(theme.chrome_fg)
},
);
// Click-only leaves dragging to the floating window's existing move handler.
let header = ui.interact(
@@ -594,7 +642,7 @@ fn show_pane(
// The pane rectangle is already allocated; a layout scope would grow its parent.
let mut content_ui = ui.new_child(egui::UiBuilder::new().id_salt(id).max_rect(content));
content_ui.set_clip_rect(content.intersect(ui.clip_rect()));
match pane.show(&mut content_ui, content, config, active) {
match pane.show(&mut content_ui, content, config, theme, active) {
Ok(clicked) => focused |= clicked,
Err(e) => pane.error = Some(e.to_string()),
}
@@ -606,7 +654,7 @@ fn show_pane(
if active {
accent
} else {
Color32::from_gray(54)
theme::color(theme.border)
},
),
egui::StrokeKind::Inside,
@@ -649,9 +697,11 @@ mod tests {
shell: vec!["/bin/sh".into()],
..Default::default()
};
let pane = Pane::new(&config, ctx, None).unwrap();
let theme = config.theme().unwrap();
let pane = Pane::new(&config, &theme, ctx, None).unwrap();
App {
config,
theme,
config_path: PathBuf::new(),
panes: BTreeMap::from([(1, pane)]),
tabs: vec![Tab::new(1, 1)],
@@ -687,6 +737,7 @@ mod tests {
app.panes.get_mut(&1).unwrap(),
rect,
&app.config,
&app.theme,
true,
);
})
+40 -9
View File
@@ -1,3 +1,4 @@
use crate::theme::{self, Theme};
use anyhow::{Context, Result, bail, ensure};
use serde::Deserialize;
use std::{
@@ -17,9 +18,13 @@ pub struct Config {
pub scrollback: usize,
pub window_width: f32,
pub window_height: f32,
pub background: String,
pub foreground: String,
pub accent: String,
pub theme: String,
/// Shorthand for the `[colors]` entries of the same name.
pub background: Option<String>,
pub foreground: Option<String>,
pub accent: Option<String>,
/// Colors overriding the theme, keyed by the names in `theme::FIELDS`.
pub colors: BTreeMap<String, String>,
pub kitty: Kitty,
pub multiplexer: Multiplexer,
pub keys: BTreeMap<String, Action>,
@@ -102,9 +107,11 @@ impl Default for Config {
scrollback: 10000,
window_width: 1100.0,
window_height: 720.0,
background: "#17191c".into(),
foreground: "#e2e4e8".into(),
accent: "#58c4ad".into(),
theme: theme::DEFAULT_NAME.into(),
background: None,
foreground: None,
accent: None,
colors: BTreeMap::new(),
kitty: Kitty::default(),
multiplexer: Multiplexer::default(),
keys: [
@@ -196,14 +203,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> {
+9 -1
View File
@@ -7,6 +7,8 @@ mod pty;
mod render;
#[cfg(test)]
mod tests;
mod theme;
mod themes;
use anyhow::{Context, Result, bail};
@@ -20,13 +22,19 @@ fn main() -> Result<()> {
"--config" => path = args.next().context("--config needs a path")?.into(),
"--check-config" => check = true,
"--init-config" => init = true,
"--list-themes" => {
for name in theme::names() {
println!("{name}");
}
return Ok(());
}
"--version" => {
println!("slerm {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
"--help" | "-h" => {
println!(
"slerm [--config PATH] [--check-config | --init-config]\n\nDefault config: {}",
"slerm [--config PATH] [--check-config | --init-config | --list-themes]\n\nDefault config: {}",
path.display()
);
return Ok(());
+37 -14
View File
@@ -1,8 +1,9 @@
use crate::{
config::{Config, rgb},
config::Config,
input,
pty::Pty,
render::{Renderer, cell_size},
render::{Grid, Renderer, cell_size},
theme::{self, Theme},
};
use anyhow::{Result, ensure};
use egui::{FontId, Rect, Sense, vec2};
@@ -91,14 +92,14 @@ fn percent_decode(path: &str) -> String {
String::from_utf8_lossy(&out).into_owned()
}
pub fn terminal(config: &Config) -> Result<Terminal<'static, 'static>> {
pub fn terminal(config: &Config, theme: &Theme) -> Result<Terminal<'static, 'static>> {
let mut term = Terminal::new(TerminalOptions {
cols: 80,
rows: 24,
max_scrollback: config.scrollback,
})?;
term.resize(80, 24, 8, 16)?;
apply_config(&mut term, config)?;
apply_config(&mut term, config, theme)?;
term.on_xtversion(|_| Some(concat!("slerm ", env!("CARGO_PKG_VERSION"))))?;
term.on_device_attributes(|_| {
Some(terminal::DeviceAttributes {
@@ -114,14 +115,21 @@ pub fn terminal(config: &Config) -> Result<Terminal<'static, 'static>> {
tertiary: Default::default(),
})
})?;
term.on_color_scheme(|_| Some(terminal::ColorScheme::Dark))?;
let scheme = if theme.dark {
terminal::ColorScheme::Dark
} else {
terminal::ColorScheme::Light
};
term.on_color_scheme(move |_| Some(scheme))?;
Ok(term)
}
pub fn apply_config(term: &mut Terminal<'_, '_>, config: &Config) -> Result<()> {
term.set_default_fg_color(Some(rgb(&config.foreground)?))?
.set_default_bg_color(Some(rgb(&config.background)?))?
.set_default_cursor_color(Some(rgb(&config.accent)?))?
pub fn apply_config(term: &mut Terminal<'_, '_>, config: &Config, theme: &Theme) -> Result<()> {
let palette = theme.palette(term.default_color_palette()?);
term.set_default_fg_color(Some(theme::rgb_color(theme.foreground)))?
.set_default_bg_color(Some(theme::rgb_color(theme.background)))?
.set_default_cursor_color(Some(theme::rgb_color(theme.cursor)))?
.set_default_color_palette(Some(palette))?
.set_kitty_image_storage_limit(config.kitty.image_storage_mb * 1024 * 1024)?
.set_kitty_image_from_file_allowed(config.kitty.allow_file)?
.set_kitty_image_from_temp_file_allowed(config.kitty.allow_temp_file)?
@@ -130,8 +138,13 @@ pub fn apply_config(term: &mut Terminal<'_, '_>, config: &Config) -> Result<()>
}
impl Pane {
pub fn new(config: &Config, ctx: &egui::Context, cwd: Option<&Path>) -> Result<Self> {
let mut terminal = terminal(config)?;
pub fn new(
config: &Config,
theme: &Theme,
ctx: &egui::Context,
cwd: Option<&Path>,
) -> Result<Self> {
let mut terminal = terminal(config, theme)?;
let renderer = Renderer::new()?;
let mouse_encoder = mouse::Encoder::new()?;
let mouse_event = mouse::Event::new()?;
@@ -261,6 +274,7 @@ impl Pane {
ui: &mut egui::Ui,
rect: Rect,
config: &Config,
theme: &Theme,
active: bool,
) -> Result<bool> {
let font = FontId::monospace(config.font_size);
@@ -289,15 +303,24 @@ impl Pane {
}
let response = ui.interact(grid, ui.id().with("terminal"), Sense::click_and_drag());
self.pointer(ui, &response, grid, cell)?;
self.renderer
.paint(&self.terminal, ui.painter(), grid, cell, &font, active)?;
self.renderer.paint(
&self.terminal,
ui.painter(),
&Grid {
rect: grid,
cell,
font: &font,
theme,
active,
},
)?;
if let Some(error) = &self.error {
ui.painter().text(
grid.left_bottom() - vec2(0.0, ch),
egui::Align2::LEFT_TOP,
error,
FontId::proportional(12.0),
egui::Color32::LIGHT_RED,
theme::color(theme.ansi[9]),
);
}
Ok(response.clicked()
+62 -13
View File
@@ -1,3 +1,4 @@
use crate::theme::{self, Theme};
use anyhow::{Result, ensure};
use egui::{
Color32, ColorImage, FontId, Painter, Rect, Stroke, TextureHandle, TextureOptions, pos2, vec2,
@@ -15,6 +16,17 @@ use std::{
sync::Arc,
};
/// Where a pane's terminal is drawn this frame, and the colors that are slerm's
/// own rather than the terminal's.
#[derive(Clone, Copy)]
pub struct Grid<'a> {
pub rect: Rect,
pub cell: egui::Vec2,
pub font: &'a FontId,
pub theme: &'a Theme,
pub active: bool,
}
pub fn color(c: RgbColor) -> Color32 {
Color32::from_rgb(c.r, c.g, c.b)
}
@@ -145,6 +157,7 @@ pub struct Renderer {
glyphs: HashMap<String, Arc<egui::Galley>>,
font_marker: Option<Arc<egui::Galley>>,
cached_colors: Option<Colors>,
cached_selection: (Color32, Color32),
cached_cell: egui::Vec2,
cached_cols: u16,
#[cfg(test)]
@@ -163,6 +176,7 @@ impl Renderer {
glyphs: HashMap::new(),
font_marker: None,
cached_colors: None,
cached_selection: (Color32::PLACEHOLDER, Color32::PLACEHOLDER),
cached_cell: egui::Vec2::ZERO,
cached_cols: 0,
#[cfg(test)]
@@ -174,11 +188,15 @@ 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.
@@ -199,12 +217,18 @@ impl Renderer {
let colors = snapshot.colors()?;
let cols = snapshot.cols()?;
let row_count = snapshot.rows()? as usize;
let selected_colors = (
theme::color(theme.selection_bg),
theme::color(theme.selection_fg),
);
let rebuild_all = snapshot.dirty()? == Dirty::Full
|| self.cached_colors.as_ref() != Some(&colors)
|| self.cached_selection != selected_colors
|| self.cached_cols != cols
|| self.row_cache.len() != row_count;
self.row_cache.resize_with(row_count, CachedRow::default);
self.cached_colors = Some(colors.clone());
self.cached_selection = selected_colors;
self.cached_cols = cols;
painter.rect_filled(rect, 0, color(colors.background));
let mut rows = self.rows.update(&snapshot)?;
@@ -237,9 +261,14 @@ impl Renderer {
let explicit_bg = c.bg_color()?;
let mut bg = color(explicit_bg.unwrap_or(colors.background));
let selected = c.is_selected()?;
if style.inverse ^ selected {
if style.inverse {
std::mem::swap(&mut fg, &mut bg);
}
// A selection takes the theme's own colors rather than inverting
// the cell, which would be invisible over inverted text.
if selected {
(bg, fg) = selected_colors;
}
if explicit_bg.is_some() || style.inverse || selected {
backgrounds.push(egui::Shape::rect_filled(cell_rect, 0, bg));
}
@@ -461,7 +490,17 @@ mod tests {
ctx.run(egui::RawInput::default(), |ctx| {
let painter = ctx.layer_painter(egui::LayerId::background());
renderer
.paint(term, &painter, rect, cell_size(&painter, font), font, true)
.paint(
term,
&painter,
&Grid {
rect,
cell: cell_size(&painter, font),
font,
theme: &theme::DEFAULT,
active: true,
},
)
.unwrap();
})
.shapes
@@ -475,7 +514,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 +589,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 +638,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 +672,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();
},
+15 -12
View File
@@ -1,4 +1,4 @@
use crate::{config::Config, input, pane, render};
use crate::{config::Config, input, pane, render, theme};
use base64::Engine;
use egui::{Event, Key, Modifiers};
use libghostty_vt::{
@@ -30,7 +30,7 @@ fn key(key: Key, modifiers: Modifiers) -> Event {
#[test]
fn terminal_preserves_unicode_colors_and_application_input_modes() {
let mut term = pane::terminal(&Config::default()).unwrap();
let mut term = pane::terminal(&Config::default(), &theme::DEFAULT).unwrap();
term.vt_write("hello \x1b[31mred\x1b[0m \u{754c}e\u{301}\r\n".as_bytes());
assert!(plain(&term).contains("hello red \u{754c}e\u{301}"));
let events = [
@@ -59,7 +59,7 @@ fn terminal_preserves_unicode_colors_and_application_input_modes() {
#[test]
fn queries_return_to_the_pty_and_alternate_screen_restores() {
let mut term = pane::terminal(&Config::default()).unwrap();
let mut term = pane::terminal(&Config::default(), &theme::DEFAULT).unwrap();
let response = Rc::new(RefCell::new(vec![]));
let writer = response.clone();
term.on_pty_write(move |_, bytes| writer.borrow_mut().extend_from_slice(bytes))
@@ -74,7 +74,7 @@ fn queries_return_to_the_pty_and_alternate_screen_restores() {
#[test]
fn kitty_images_decode_render_reuse_and_delete() {
graphics::set_png_decoder(Some(Box::new(render::PngDecoder))).unwrap();
let mut term = pane::terminal(&Config::default()).unwrap();
let mut term = pane::terminal(&Config::default(), &theme::DEFAULT).unwrap();
let replies = Rc::new(RefCell::new(vec![]));
let output = replies.clone();
term.on_pty_write(move |_, data| output.borrow_mut().extend_from_slice(data))
@@ -137,10 +137,13 @@ fn kitty_images_decode_render_reuse_and_delete() {
.paint(
&term,
&painter,
rect,
egui::vec2(8.0, 16.0),
&egui::FontId::monospace(12.0),
true,
&render::Grid {
rect,
cell: egui::vec2(8.0, 16.0),
font: &egui::FontId::monospace(12.0),
theme: &theme::DEFAULT,
active: true,
},
)
.unwrap();
})
@@ -173,7 +176,7 @@ fn pty_executes_input_and_bracketed_paste_is_sanitized() {
..Default::default()
};
let ctx = egui::Context::default();
let mut pane = pane::Pane::new(&config, &ctx, None).unwrap();
let mut pane = pane::Pane::new(&config, &theme::DEFAULT, &ctx, None).unwrap();
pane.send(b"printf '\\033[31mpty-%s\\033[0m\\n' 'success'\r".to_vec())
.unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
@@ -216,7 +219,7 @@ fn new_panes_start_in_the_directory_of_the_pane_they_came_from() {
let dir = std::env::temp_dir().join(format!("slerm cwd {}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let dir = dir.canonicalize().unwrap();
let mut pane = pane::Pane::new(&config, &ctx, None).unwrap();
let mut pane = pane::Pane::new(&config, &theme::DEFAULT, &ctx, None).unwrap();
pane.send(format!("cd '{}'\r", dir.display()).into_bytes())
.unwrap();
// /bin/sh reports no OSC 7, so the directory has to come from the process.
@@ -227,7 +230,7 @@ fn new_panes_start_in_the_directory_of_the_pane_they_came_from() {
}
assert_eq!(pane.cwd().as_deref(), Some(dir.as_path()));
let mut child = pane::Pane::new(&config, &ctx, pane.cwd().as_deref()).unwrap();
let mut child = pane::Pane::new(&config, &theme::DEFAULT, &ctx, pane.cwd().as_deref()).unwrap();
child
.send(b"printf 'at:%s\\n' \"$PWD\"\r".to_vec())
.unwrap();
@@ -251,7 +254,7 @@ fn osc7_wins_over_the_process_directory_but_only_for_this_machine() {
let reported = std::env::temp_dir().join(format!("slerm osc7 {}", std::process::id()));
std::fs::create_dir_all(&reported).unwrap();
let reported = reported.canonicalize().unwrap();
let mut pane = pane::Pane::new(&config, &ctx, None).unwrap();
let mut pane = pane::Pane::new(&config, &theme::DEFAULT, &ctx, None).unwrap();
let process_cwd = pane.cwd().unwrap();
assert_ne!(process_cwd, reported);
+261
View File
@@ -0,0 +1,261 @@
use crate::themes::THEMES;
use anyhow::{Result, bail};
use egui::Color32;
use libghostty_vt::style::{Palette, PaletteIndex, RgbColor};
/// A packed `0xRRGGBB` color. Themes are compiled in, so they hold plain
/// integers rather than anything that needs allocating or parsing at startup.
pub type Rgb = u32;
/// Every color slerm draws with. A theme covers the terminal grid, the sixteen
/// ANSI colors programs address by index, and the chrome around them, so that
/// picking a light theme does not leave a dark tab bar behind.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Theme {
/// Whether the chrome should be drawn as a dark or a light widget set.
pub dark: bool,
pub background: Rgb,
pub foreground: Rgb,
pub cursor: Rgb,
pub selection_bg: Rgb,
pub selection_fg: Rgb,
/// The focused pane: its border, its title and the cursor.
pub accent: Rgb,
/// The tab bar, menus and pane title bars.
pub chrome_bg: Rgb,
pub chrome_fg: Rgb,
/// The title bar of the focused pane.
pub chrome_active_bg: Rgb,
/// The border of an unfocused pane.
pub border: Rgb,
pub popup_bg: Rgb,
/// ANSI 0..=15: black, red, green, yellow, blue, magenta, cyan, white, then
/// the same eight again in their bright form.
pub ansi: [Rgb; 16],
}
/// The name of slerm's own theme, which is what a config without a `theme` gets.
pub const DEFAULT_NAME: &str = "slerm";
/// slerm's own colors. The palette is libghostty's built-in one, so the default
/// look is exactly what the terminal renders with no theme applied at all.
pub const DEFAULT: Theme = Theme {
dark: true,
background: 0x17191c,
foreground: 0xe2e4e8,
cursor: 0x58c4ad,
selection_bg: 0x2f3d44,
selection_fg: 0xe2e4e8,
accent: 0x58c4ad,
chrome_bg: 0x1f2124,
chrome_fg: 0xa8adb5,
chrome_active_bg: 0x2b2e33,
border: 0x363636,
popup_bg: 0x1f2124,
ansi: [
0x1d1f21, 0xcc6666, 0xb5bd68, 0xf0c674, 0x81a2be, 0xb294bb, 0x8abeb7, 0xc5c8c6, 0x666666,
0xd54e53, 0xb9ca4a, 0xe7c547, 0x7aa6da, 0xc397d8, 0x70c0b1, 0xeaeaea,
],
};
/// The theme with this name, if there is one. slerm's own comes first; the rest
/// are the Helix themes, which `THEMES` keeps sorted for the binary search.
pub fn find(name: &str) -> Option<Theme> {
if name == DEFAULT_NAME {
return Some(DEFAULT);
}
let index = THEMES.binary_search_by_key(&name, |(name, _)| name).ok()?;
Some(THEMES[index].1)
}
/// Every theme name, in the order a picker should list them.
pub fn names() -> impl Iterator<Item = &'static str> {
std::iter::once(DEFAULT_NAME).chain(THEMES.iter().map(|(name, _)| *name))
}
pub fn color(rgb: Rgb) -> Color32 {
Color32::from_rgb((rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8)
}
pub fn rgb_color(rgb: Rgb) -> RgbColor {
RgbColor {
r: (rgb >> 16) as u8,
g: (rgb >> 8) as u8,
b: rgb as u8,
}
}
/// Where one of the names a config may override writes to.
type Field = fn(&mut Theme) -> &mut Rgb;
/// The names a config may override, paired with the field each one writes.
/// Ordered so that `[colors]` reads the way a palette is usually written.
const FIELDS: [(&str, Field); 27] = [
("background", |t| &mut t.background),
("foreground", |t| &mut t.foreground),
("cursor", |t| &mut t.cursor),
("selection_background", |t| &mut t.selection_bg),
("selection_foreground", |t| &mut t.selection_fg),
("accent", |t| &mut t.accent),
("chrome_background", |t| &mut t.chrome_bg),
("chrome_foreground", |t| &mut t.chrome_fg),
("chrome_active_background", |t| &mut t.chrome_active_bg),
("border", |t| &mut t.border),
("popup_background", |t| &mut t.popup_bg),
("black", |t| &mut t.ansi[0]),
("red", |t| &mut t.ansi[1]),
("green", |t| &mut t.ansi[2]),
("yellow", |t| &mut t.ansi[3]),
("blue", |t| &mut t.ansi[4]),
("magenta", |t| &mut t.ansi[5]),
("cyan", |t| &mut t.ansi[6]),
("white", |t| &mut t.ansi[7]),
("bright_black", |t| &mut t.ansi[8]),
("bright_red", |t| &mut t.ansi[9]),
("bright_green", |t| &mut t.ansi[10]),
("bright_yellow", |t| &mut t.ansi[11]),
("bright_blue", |t| &mut t.ansi[12]),
("bright_magenta", |t| &mut t.ansi[13]),
("bright_cyan", |t| &mut t.ansi[14]),
("bright_white", |t| &mut t.ansi[15]),
];
impl Theme {
/// Overrides one color by the name a config file uses for it.
pub fn set(&mut self, name: &str, value: Rgb) -> Result<()> {
let Some((_, field)) = FIELDS.iter().find(|(known, _)| *known == name) else {
bail!("unknown color: {name}");
};
*field(self) = value;
Ok(())
}
/// The 256-color palette a terminal renders indexed colors through. Only
/// the first sixteen are themed; the color cube and greyscale ramp above
/// them are fixed by the protocol, so they keep libghostty's values.
pub fn palette(&self, default: Palette) -> Palette {
const SLOTS: [PaletteIndex; 16] = [
PaletteIndex::BLACK,
PaletteIndex::RED,
PaletteIndex::GREEN,
PaletteIndex::YELLOW,
PaletteIndex::BLUE,
PaletteIndex::MAGENTA,
PaletteIndex::CYAN,
PaletteIndex::WHITE,
PaletteIndex::BRIGHT_BLACK,
PaletteIndex::BRIGHT_RED,
PaletteIndex::BRIGHT_GREEN,
PaletteIndex::BRIGHT_YELLOW,
PaletteIndex::BRIGHT_BLUE,
PaletteIndex::BRIGHT_MAGENTA,
PaletteIndex::BRIGHT_CYAN,
PaletteIndex::BRIGHT_WHITE,
];
let mut palette = default;
for (slot, rgb) in SLOTS.into_iter().zip(self.ansi) {
palette.set(slot, rgb_color(rgb));
}
palette
}
/// egui's widget colors, so that the tab bar, menus and buttons follow the
/// theme rather than staying on egui's own dark palette.
pub fn visuals(&self) -> egui::Visuals {
let mut visuals = if self.dark {
egui::Visuals::dark()
} else {
egui::Visuals::light()
};
let (chrome, accent, text) = (
color(self.chrome_bg),
color(self.accent),
color(self.chrome_fg),
);
let widgets = &mut visuals.widgets;
widgets.noninteractive.bg_fill = chrome;
widgets.noninteractive.weak_bg_fill = chrome;
widgets.noninteractive.fg_stroke = egui::Stroke::new(1.0_f32, text);
widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0_f32, color(self.border));
for widget in [
&mut widgets.inactive,
&mut widgets.hovered,
&mut widgets.active,
&mut widgets.open,
] {
widget.bg_fill = color(self.chrome_active_bg);
widget.weak_bg_fill = color(self.chrome_active_bg);
widget.fg_stroke = egui::Stroke::new(1.0_f32, color(self.foreground));
widget.bg_stroke = egui::Stroke::new(1.0_f32, color(self.border));
}
widgets.hovered.weak_bg_fill = color(self.selection_bg);
widgets.active.weak_bg_fill = color(self.selection_bg);
widgets.active.fg_stroke = egui::Stroke::new(1.0_f32, accent);
visuals.panel_fill = chrome;
visuals.window_fill = color(self.popup_bg);
visuals.extreme_bg_color = color(self.background);
visuals.selection.bg_fill = color(self.selection_bg);
visuals.selection.stroke = egui::Stroke::new(1.0_f32, accent);
visuals.hyperlink_color = accent;
visuals.window_stroke = egui::Stroke::new(1.0_f32, color(self.border));
visuals
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_helix_theme_is_present_sorted_and_legible() {
assert!(THEMES.len() > 150, "expected the full Helix theme set");
assert!(
THEMES.windows(2).all(|w| w[0].0 < w[1].0),
"THEMES must be sorted for find() to binary search it"
);
for name in ["catppuccin_mocha", "gruvbox", "nord", "onedark", "dracula"] {
assert!(find(name).is_some(), "missing {name}");
}
assert_eq!(find(DEFAULT_NAME), Some(DEFAULT));
assert_eq!(find("no such theme"), None);
assert_eq!(names().count(), THEMES.len() + 1);
// A theme whose text matches its background, or whose palette collapses
// onto one color, would render an unusable terminal.
for (name, theme) in THEMES {
let luma = |rgb: Rgb| {
let channel = |shift: u32| ((rgb >> shift) & 0xff) as f32 / 255.0;
0.2126 * channel(16) + 0.7152 * channel(8) + 0.0722 * channel(0)
};
assert!(
(luma(theme.foreground) - luma(theme.background)).abs() > 0.15,
"{name}: text is not readable on its background"
);
assert_eq!(theme.dark, luma(theme.background) < luma(theme.foreground));
// Themes built on three or four colors (acme, vesper) reuse them
// across slots; anything flatter than that has lost its palette.
let distinct: std::collections::BTreeSet<_> = theme.ansi.iter().collect();
assert!(distinct.len() >= 9, "{name}: palette is too flat");
}
}
#[test]
fn overrides_are_named_and_reach_the_palette() {
let mut theme = DEFAULT;
theme.set("red", 0x123456).unwrap();
theme.set("bright_white", 0xfedcba).unwrap();
theme.set("accent", 0x00ff00).unwrap();
assert_eq!(theme.ansi[1], 0x123456);
assert_eq!(theme.ansi[15], 0xfedcba);
assert_eq!(theme.accent, 0x00ff00);
for unknown in ["dark", "purple", "ansi", ""] {
assert!(theme.set(unknown, 0).is_err(), "accepted {unknown}");
}
let palette = theme.palette(Palette([RgbColor { r: 0, g: 0, b: 0 }; 256]));
assert_eq!(palette.get(PaletteIndex::RED), rgb_color(0x123456));
assert_eq!(palette.get(PaletteIndex::BRIGHT_WHITE), rgb_color(0xfedcba));
// Everything above the sixteen named slots is left as it was.
assert_eq!(palette.0[16], RgbColor { r: 0, g: 0, b: 0 });
}
}
+3623
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2025 Daniel Fichtinger
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 James Simpson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2016 CloudCannon
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Aaron Young
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+23
View File
@@ -0,0 +1,23 @@
MIT License
Copyright (c) 2025 NTBBloodbath
This theme has been ported by spentbliss <zyhkekrz.hatbox475@passinbox.com>.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 sainnhe
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Strash
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+23
View File
@@ -0,0 +1,23 @@
MIT License
Copyright (c) 2025 Alex Barnett
This theme has been ported by spentbliss <zyhkekrz.hatbox475@passinbox.com>.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+5
View File
@@ -0,0 +1,5 @@
Copyright (C) 2020-2023 Free Software Foundation, Inc.
Permission is granted to copy, distribute and/or modify this document under the terms of the GNU Free Documentation License, Version 1.3 or any later version published by the Free Software Foundation; with no Invariant Sections, with the Front-Cover Texts being “A GNU Manual,” and with the Back-Cover Texts as in (a) below. A copy of the license is included in the section entitled “GNU Free Documentation License.”
(a) The FSF’s Back-Cover Text is: “You have the freedom to copy and modify this GNU manual.”
@@ -0,0 +1,5 @@
Copyright (C) 2020-2023 Free Software Foundation, Inc.
Permission is granted to copy, distribute and/or modify this document under the terms of the GNU Free Documentation License, Version 1.3 or any later version published by the Free Software Foundation; with no Invariant Sections, with the Front-Cover Texts being “A GNU Manual,” and with the Back-Cover Texts as in (a) below. A copy of the license is included in the section entitled “GNU Free Documentation License.”
(a) The FSF’s Back-Cover Text is: “You have the freedom to copy and modify this GNU manual.”
@@ -0,0 +1,5 @@
Copyright (C) 2020-2023 Free Software Foundation, Inc.
Permission is granted to copy, distribute and/or modify this document under the terms of the GNU Free Documentation License, Version 1.3 or any later version published by the Free Software Foundation; with no Invariant Sections, with the Front-Cover Texts being “A GNU Manual,” and with the Back-Cover Texts as in (a) below. A copy of the license is included in the section entitled “GNU Free Documentation License.”
(a) The FSF’s Back-Cover Text is: “You have the freedom to copy and modify this GNU manual.”
@@ -0,0 +1,5 @@
Copyright (C) 2020-2023 Free Software Foundation, Inc.
Permission is granted to copy, distribute and/or modify this document under the terms of the GNU Free Documentation License, Version 1.3 or any later version published by the Free Software Foundation; with no Invariant Sections, with the Front-Cover Texts being “A GNU Manual,” and with the Back-Cover Texts as in (a) below. A copy of the license is included in the section entitled “GNU Free Documentation License.”
(a) The FSF’s Back-Cover Text is: “You have the freedom to copy and modify this GNU manual.”
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 drcmda
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018 arturoalviar
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 sainnhe
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+24
View File
@@ -0,0 +1,24 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <https://unlicense.org>
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Rauno Freiberg
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.