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
+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