Skip to content

Latest commit

 

History

History

fastframe-theme

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 
 
 

README.md

fastframe-theme

Colour palettes for egui apps: JSON palette files, a catalogue loaded off the interface thread, the palettes the apps share, a light and a dark palette that follow the system's appearance, and following the Omarchy desktop's theme live.

The app keeps its own palette type (its colours and its dark and light defaults) and its mapping onto egui::Visuals and its widgets. This crate owns what is the same in every app: reading palette files, listing them, the shared palettes, and Omarchy.

Palettes

impl fastframe_theme::Palette for Palette {
    fn base(base: fastframe_theme::Base) -> Self {
        match base {
            fastframe_theme::Base::Dark => Palette::dark(),
            fastframe_theme::Base::Light => Palette::light(),
        }
    }

    fn set(&mut self, name: &str, color: Color32) -> bool {
        match name {
            "window" => self.window = color,
            "accent" => self.accent = color,
            // ... every colour the app has ...
            _ => return false,
        }
        true
    }

    // Optional: fill app-only colours from the base ones a file set.
    fn derive(&mut self, given: &BTreeSet<&str>) {
        if given.contains("window") && !given.contains("chat") {
            self.chat = self.window;
        }
    }
}

A palette file names a base (dark, the default, or light) and the colours it overrides, as #RRGGBB or #RRGGBBAA:

{ "base": "light", "colors": { "accent": "#907aa9", "danger": "#b4637a" } }

BASE_COLORS lists the sixteen names every app reads (window, panel, surface, surface_hover, surface_active, outline, text, secondary, dim, accent, accent_hover, on_accent, danger, warning, overlay, shadow); an app may accept more. Unknown names and fields make a file invalid, so a typo is reported rather than ignored.

A file in the themes folder may also be a link to a palette kept elsewhere, such as one pywal, wallust or matugen writes on every wallpaper change. The file it leads to is checked like any other: a regular file of at most 64 KiB.

parse_palette::<P>(text) reads one; CustomTheme<P> pairs it with its filename and serializes as { "filename": .., "palette": .. }; read_cached_theme reads a cached one from settings, treating a damaged cache as absent.

The catalogue

// App state:
custom_themes: fastframe_theme::Catalog<Palette>,

// Normal launches (not demos):
app.custom_themes.enable_desktop_themes(fastframe_theme::DesktopThemes {
    slug: "zapfast",
    omarchy_template: include_str!("../contrib/omarchy/zapfast.json.tpl"),
    // Templates earlier versions shipped, so an untouched copy is upgraded.
    omarchy_previous_templates: &[],
    presets: true,
});

// At start, on "reload-themes", and when needs_reload() says so:
let waker = fastframe_theme::Waker::new(move || app_waker.wake());
app.custom_themes.start(config_dir.join("themes"), settings.custom_theme.clone(), &waker);

// Each frame (or on wake-up):
if app.custom_themes.needs_reload() { app.load_custom_themes(); }
if app.custom_themes.poll() { /* re-resolve the selected palette */ }
  • The themes directory is listed on a background thread: at most 512 entries, 128 palettes, 64 KiB per file, regular files only (no links, no paths out of the directory). The selected file is read first, so a large directory cannot push it out.
  • One scan runs and one request waits; a burst of reloads collapses into the last, and a superseded result is never published, so colours do not flash back.
  • picker_themes() lists the live Omarchy palette first when it is followed; status(selected) says what to show under the setting (Loading, SelectedUnavailable, or a Problem), which the app words and translates.
  • Catalog::preview(themes, follows_omarchy) builds one for demos and tests without touching the desktop.

Shared palettes

presets embeds eight palettes in the sixteen base colours: Catppuccin, Catppuccin Latte, Nord, Ristretto, Rosé Pine, Rosé Pine Moon, Rosé Pine Dawn and Tokyo Night. With presets: true they are listed alongside the user's files; a user file with the same name overrides one.

With presets on, the catalogue also installs them into the app's themes directory (for example ~/.config/<app>/themes) the first time it scans, as ordinary palette files people can read, change, copy or delete. From then on they are the user's: an installed palette is read from the folder like any other and never rewritten, so an edit lasts and a deleted one stays deleted. .installed-palettes in the folder records which were installed, so a palette added in a later version is installed once when it arrives, and never over a file that already has its name. Writes go through a temporary file and a rename, never through a symbolic link. presets::install does this; the embedded copies still fill in for any palette that could not be written. The examples folder 0.1.4 kept is removed when nobody changed it.

display_name shows a file under its name without .json ("Nord", "Rose Pine Dawn"), and the Omarchy palette as "Omarchy".

Light and dark pairs

Pair holds a palette for light mode and one for dark mode, so "follow system" can switch between, say, Catppuccin Latte and Catppuccin, rather than only between the app's own light and dark palettes. An empty slot is the app's own palette for that base, so Pair::default() is the usual follow system and settings without a pair keep their look.

The palette type says which base it was built on:

impl fastframe_theme::PaletteBase for Palette {
    fn palette_base(&self) -> fastframe_theme::Base {
        fastframe_theme::Base::from_dark(self.dark)
    }
}

Then, with the pair in the app's settings:

// Settings: serializes as { "light": { "filename", "palette" }, "dark": .. },
// leaving out empty slots; a damaged slot reads as empty.
#[serde(default)]
theme_pair: fastframe_theme::Pair<Palette>,

// Read the chosen files first, with any single selection:
catalog.start(dir, settings.custom_theme.clone().into_iter().chain(settings.theme_pair.filenames()), &waker);

// When a scan finishes, take edited colours (and save when it says so):
if catalog.poll() && settings.theme_pair.refresh(&catalog) { save(); }

// Each frame, while following the system:
let wanted = settings.theme_pair.palette(fastframe_theme::Base::from_dark(dark));

// Each slot's picker, with the app's own palette first:
for theme in catalog.pair_themes(fastframe_theme::Base::Light) { /* .. */ }
settings.theme_pair.set(fastframe_theme::Base::Light, Some(theme));
  • A slot takes only palettes of its own base (pair_themes(base), and set refuses the others): light mode shows a light palette and dark mode a dark one, so the widgets' light or dark style, which egui takes from the system, always matches the colours. The live Omarchy palette is never offered; it follows the desktop on its own.
  • Each slot keeps the colours last read from its file, so the first frame after a restart is right without a scan, and a file that disappears, or is edited to name the other base, keeps its last look. status(base, &catalog) then says SelectedUnavailable, as Catalog::status does for a single palette. Without those colours (a damaged cache) the slot shows the app's own palette for its base.
  • Show the switch with Transition as any change of colours: when the system's appearance changes, palette(base) changes, and the app's usual begin / holding / apply sequence reveals it.

Omarchy

On Linux, when ~/.config/omarchy and ~/.local/state/omarchy/current exist, the catalogue follows the desktop:

  1. It reads the palette Omarchy rendered for the app (current/theme/<slug>.json); without one, or when the user's template is newer than that rendering, it renders the user's template (~/.config/omarchy/themed/<slug>.json.tpl) or the app's own, from omarchy-theme-color --all. Source and portable builds follow too.
  2. It lists that palette as omarchy.json ("Omarchy" in the picker) and offers it as system_theme().
  3. It watches the themes directory and Omarchy's current theme with filesystem notifications; needs_reload() turns true on a change, with no repaint timer.

A native package may also ship share/<slug>/omarchy/<slug>.json.tpl and the hook share/<slug>/omarchy/<slug>-theme. The first scan then copies them to ~/.config/omarchy/themed/ and ~/.config/omarchy/hooks/theme-set.d/ and seeds themes/omarchy.json, never replacing an existing file (or even a broken link). Later scans upgrade the installed template and hook when the package ships a new one and the user never changed theirs: the installed file still reads as the app last installed it (recorded in themes/.omarchy-template and themes/.omarchy-hook), or as one of omarchy_previous_templates for installs from before that record. An edited file stays as it is. Omarchy then renders the palette itself on every theme change, and the hook asks a running app to reload through <slug> reload-themes, which the app answers over its single-instance channel.

Packaging files the apps ship

These stay in each app and are installed by its packages:

File Installed to What it is
contrib/omarchy/<slug>.json.tpl share/<slug>/omarchy/ The Omarchy template. BASE_TEMPLATE is the sixteen-colour one (Spotifast's); ZapFast's adds chat, bubble_in, bubble_out, link and read.
contrib/omarchy/<slug>-theme share/<slug>/omarchy/ The theme-set hook. omarchy::hook_script(slug) returns its exact text; test the shipped file against it so they cannot drift.

The hook honours <SLUG>_OMARCHY_THEME_DIR and <SLUG>_THEMES_DIR so it can be tested without touching the desktop.

On macOS and Windows the catalogue lists files and presets only; follows_omarchy() is false and needs_reload() never fires.

Revealing a change of colours

Transition reveals new colours from the middle of the window outwards, as Omarchy does when its theme changes. Keep one per window. When the palette is about to change, call begin, and keep drawing the old palette while holding says so; when it stops holding, apply the new one. Call paint last in every frame.

if app.applied != app.wanted {
    app.transition.begin(ctx);
    if !app.transition.holding(ctx) {
        app.apply(app.wanted);
    }
}
// ... draw the interface, then:
app.transition.paint(ctx);

begin asks the window for a screenshot with the old colours. When it arrives, paint lays it over the new colours with an opening cut out of its middle that widens until the old colours are gone. The default, Reveal::Band, is Omarchy's own theme change, taken from its background shell: a band leaning slightly to the right (a slant of -0.18 of the window's height) opens from the middle towards both sides over 0.42 seconds, easing in and out, with a sharp anti-aliased edge. Transition::new(Reveal::Circle) grows a soft-edged circle from the middle past the corners over 0.6 seconds instead. A window that sends no screenshot (hidden, or a renderer without them) gets its new colours after a quarter of a second, without the reveal. Skip begin for the first palette a window draws.