theming
This commit is contained in:
+67
-16
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user