Skip to content

42. Look and Themes

amadeOS looks like CDE: grey-brown panels with 3D bevels, bold buttons that sink in when pressed, and colours from a palette the user picks in Settings. Every program looks the same and changes its colours together with the desktop, without a line of code of its own. This chapter covers the colour themes, where custom themes come from, how a running program follows the desktop, the Swing look that paints it all, the text size, and what a program does for colours and fonts beyond the standard ones.

Colour schemes: DesktopTheme

Package theme · introduced in 10. Pad · source: DesktopTheme

What it is for. A DesktopTheme is one palette: a base colour, the shades of the bevels, the title bar colours, text colours, the colours of the Front Panel's frame and of the four workspaces, and the name of the icon theme. The window manager, the Front Panel and every program use the same five built-in themes:

Constant Id Look
WARM_MOTIF warm-motif The default: warm grey with a slate-blue title bar
CDE_BROCADE cde-brocade Teal and blue
CDE_DESERT cde-desert Sand
CLASSIC_GRAY classic-gray Neutral grey
DARK_SLATE dark-slate Light text on a dark base

When to choose it. Read the current theme with AmadeUI.theme() while painting, or receive it in a styler. Never keep a theme in a field: the user may pick another one at any moment.

How it works. Only the base colour is required. Every missing colour is derived from it, as CDE derived its palettes: the highlight is white, the shadow 68 % of the base, the dark shadow 35 %, a sunken area 88 %.

amade-commons · theme/DesktopTheme.java (derived shades)
    private static Color deriveShadow(Color c) {
        return new Color(
                (int) (c.getRed() * 0.68),
                (int) (c.getGreen() * 0.68),
                (int) (c.getBlue() * 0.68)
        );
    }

Four colours are not in CDE's palettes but are needed by every program with a document, a list or a selection. They are computed from the others, so a custom theme gets them too:

amade-commons · theme/DesktopTheme.java
    /**
     * Background of editable and list content (text fields, tables, trees, message bodies): white in light
     * themes, a dark tone in dark themes so that the light {@link #textActive()} stays readable.
     */
    public Color fieldBackground() {
        return isDark() ? mix(darkShadow, base, 0.45) : Color.WHITE;
    }

    /** Text on {@link #fieldBackground()}. */
    public Color fieldText() {
        return isDark() ? textActive : new Color(0x11, 0x11, 0x11);
    }

    /** Selection in lists, tables and text: the active title bar colour, as in CDE. */
    public Color selectionBackground() {
        return activeTitleBg;
    }

    /** Text on {@link #selectionBackground()}, black or white whichever contrasts more. */
    public Color selectionText() {
        return isDark(activeTitleBg) ? Color.WHITE : Color.BLACK;
    }
Method Colour
base(), highlight(), softHighlight(), shadow(), darkShadow() The panel and its bevels
sunkenBg() A pressed button, a scroll bar's track, a check box
activeTitleBg(), inactiveTitleBg() Window title bars; the active one is also the selection
textActive(), textInactive() Text, and text of disabled components
fieldBackground(), fieldText() Text fields, lists, tables, documents
selectionBackground(), selectionText() Selected text, rows and cells
workspaceColors(), workspaceColor(i) The four workspaces; the fifth repeats the first
panelFrameBg(), panelFrameHighlight(), panelFrameShadow() The Front Panel's frame
iconTheme(), withIconTheme(name) The freedesktop icon theme, NsCDE unless set
isDark(), isDark(color) True for light text on a dark base, by luminance

Two themes are equal when their id and icon theme are equal, so a store can tell whether anything changed.

Custom themes: ThemeRegistry

Package theme · introduced in 10. Pad · source: ThemeRegistry

What it is for. The themes the user can choose: the five built-in ones and any theme found in a directory of its own, /etc/amadeos/themes/<id>/theme.properties for the whole system and ~/.config/amadeos/themes/<id>/theme.properties for one user. A theme file needs a single line:

~/.config/amadeos/themes/midnight/theme.properties
name=Midnight
base=#2B3A55
activeTitleBg=#C08040
workspace3=#4992A7
iconTheme=NsCDE

When to choose it. A program never needs it directly: ThemeStore.system() builds the registry with both directories. Settings lists registry().all(); a theme designer writes a file, and the next registry that is built lists it.

How it works. discover registers every sub-directory with a theme.properties, in alphabetical order, and later directories win, so a user's theme can replace the system's of the same id. A broken file is logged and skipped: one bad theme never takes the others down.

amade-commons · theme/ThemeRegistry.java
    /** Registers every {@code <id>/theme.properties} below {@code directory}. */
    public void discover(Path directory) {
        if (!Files.isDirectory(directory)) return;
        List<Path> candidates = new ArrayList<>();
        try (DirectoryStream<Path> stream = Files.newDirectoryStream(directory, Files::isDirectory)) {
            stream.forEach(candidates::add);
        } catch (IOException e) {
            LOG.warn("Cannot list themes in {}", directory, e);
            return;
        }
        candidates.sort(null);
        for (Path themeDirectory : candidates) {
            Path file = themeDirectory.resolve("theme.properties");
            if (!Files.isRegularFile(file)) continue;
            try {
                register(load(themeDirectory.getFileName().toString(), file));
            } catch (IOException | RuntimeException e) {
                LOG.warn("Ignoring invalid theme {}: {}", file, e.getMessage());
            }
        }
    }

The keys are the method names of DesktopTheme: highlight, shadow, activeTitleBg, workspace1 to workspace4, panelFrameBg and so on, each #RRGGBB.

Test: a custom theme with derived colours, a broken one skipped
amade-commons · test/theme/ThemeTest.java
    @Test
    void registryLoadsCustomThemesAndDerivesMissingColours() throws Exception {
        Path midnight = Files.createDirectories(dir.resolve("midnight"));
        Files.writeString(midnight.resolve("theme.properties"), "name=Midnight\nbase=#2B3A55\nworkspace3=#C08040\n");
        Path broken = Files.createDirectories(dir.resolve("broken"));
        Files.writeString(broken.resolve("theme.properties"), "base=not-a-colour\n");

        ThemeRegistry registry = ThemeRegistry.withCustomThemes(List.of(dir, dir.resolve("missing")));
        DesktopTheme theme = registry.find("midnight").orElseThrow();
        assertEquals("Midnight", theme.name());
        assertEquals(new Color(0x2B, 0x3A, 0x55), theme.base());
        assertNotNull(theme.shadow());
        assertEquals(DesktopTheme.DEFAULT_ICON_THEME, theme.iconTheme());
        assertEquals(new Color(0xC0, 0x80, 0x40), theme.workspaceColor(2), "given workspace colour");
        assertEquals(theme.base(), theme.workspaceColor(1), "derived workspace colour");
        assertEquals(theme.workspaceColors(), theme.withIconTheme("Other").workspaceColors());
        assertTrue(registry.find("broken").isEmpty());
        assertEquals(6, registry.all().size());
    }

Following the desktop: ThemeStore

Package theme · introduced in 10. Pad · source: ThemeStore

What it is for. The theme the user chose in Settings, and news when they choose another. Settings writes the theme's id into ~/.config/amadeos/theme.conf, the icon theme into icon_theme.conf and the text size, 1 to 7, into font.conf; ThemeStore reads the three into one DesktopTheme and, once watch() was called, follows them. The text size is part of the theme value (fontSize(), withFontSize(int)), so a new size reaches a program the same way a new palette does.

When to choose it. AmadeApplication.launch creates it and passes it to AmadeUI.follow, and CommonsModule binds it. A program asks for it only to react to a theme change beyond colours, for example to load other icons when iconTheme() changes.

How it works. Reading never fails: a missing file, an unknown id or an unreadable file mean Warm Motif, so a program starts even with a broken setting.

amade-commons · theme/ThemeStore.java
    private DesktopTheme read() {
        DesktopTheme theme = readLine(THEME_FILE).flatMap(registry::find).orElse(DesktopTheme.WARM_MOTIF);
        String icons = readLine(ICON_THEME_FILE).orElse(null);
        if (icons != null) theme = theme.withIconTheme(icons);
        return theme.withFontSize(readLine(FONT_FILE).map(ThemeStore::fontSize).orElse(DesktopTheme.DEFAULT_FONT_SIZE));
    }

watch() starts a ConfigWatcher on the three files. On a change it calls reload(), which reads them again and tells the listeners only if the theme really changed:

amade-commons · theme/ThemeStore.java
    /** Reads the files again and notifies the listeners if the theme changed. */
    public synchronized boolean reload() {
        DesktopTheme theme = read();
        if (theme.equals(current)) return false;
        current = theme;
        LOG.info("Desktop theme changed to {} (icons: {}, font size {})", theme.id(), theme.iconTheme(),
                theme.fontSize());
        for (Consumer<DesktopTheme> listener : listeners) {
            try {
                listener.accept(theme);
            } catch (RuntimeException e) {
                LOG.warn("Theme listener failed", e);
            }
        }
        return true;
    }
Method What it does
ThemeStore.system() The store of the running user, with custom themes from both directories
new ThemeStore(dir, registry) A store on any directory, for tests
current(), registry() The theme now, the themes to choose from
addListener, removeListener Called with the new theme, on the watching thread
watch(), close() Start and stop following the files

Programs read, the desktop writes

ThemeStore has no method to write a theme. Only Settings does, and every program follows. A program with a theme setting of its own would make the desktop look different in every window.

Test: the store follows a change to the file
amade-commons · test/theme/ThemeTest.java
    @Test
    void storeFollowsFileChanges() throws Exception {
        try (ThemeStore store = new ThemeStore(dir, new ThemeRegistry())) {
            List<DesktopTheme> seen = new java.util.concurrent.CopyOnWriteArrayList<>();
            store.addListener(seen::add);
            store.watch();
            Files.writeString(dir.resolve(ThemeStore.THEME_FILE), "dark-slate");
            long deadline = System.currentTimeMillis() + 15_000;
            while (seen.isEmpty() && System.currentTimeMillis() < deadline) Thread.sleep(50);
            assertEquals("dark-slate", store.current().id());
            assertEquals(1, seen.size());
        }
    }

The CDE look for Swing: AmadeUI

Package swing · introduced in 10. Pad · source: AmadeUI

What it is for. The look of every amadeOS program: Swing's cross-platform look and feel with its colour table filled from the theme, Motif bevels around fields, scroll panes and menus, and button UIs of its own: bold labels, lighter while the mouse is over them, sunken and shifted by a pixel while pressed. Check boxes and radio buttons are the Motif ones, drawn in the theme's colours.

When to choose it. launch installs it. A program calls it directly only for the helpers below, or in a test that draws a component in a given theme.

How it works. install(theme) sets Swing's colour table: Panel.background, TextField.background, Table.selectionBackground and about a hundred others, plus the borders and UI classes. Every colour is a ColorUIResource, Swing's mark for "comes from the look". apply(theme) installs a new theme and updates every open window:

amade-commons · swing/AmadeUI.java
    /**
     * Installs a new theme and updates every open window; call on the EDT. Components registered with
     * {@link #style(JComponent, BiConsumer)} get their extra colours again.
     */
    public static void apply(DesktopTheme newTheme) {
        install(newTheme);
        for (Window window : Window.getWindows()) {
            SwingUtilities.updateComponentTreeUI(window);
            restyle(window);
            window.repaint();
        }
    }

updateComponentTreeUI replaces only colours that are still UIResources; a colour a program set itself stays. That is the reason for the next section. follow connects the store to apply, switching to the EDT on the way:

amade-commons · swing/AmadeUI.java
    /** Follows theme changes of the desktop: watches the theme files and applies changes on the EDT. */
    public static void follow(ThemeStore store) {
        store.addListener(changed -> SwingUtilities.invokeLater(() -> apply(changed)));
        store.watch();
    }
Method What it does
install(theme) Fills Swing's colour table; before the first component, on the EDT
apply(theme) install, then updates and restyles every window
follow(store) Applies every change of the store on the EDT
theme() The theme installed last; read it while painting
style(component, styler), restyle(root) Colours of your own (below)
sunken(), raised(), paintBevel(...), colorDot(color, size) Helpers (below)

Text size: fontScale

Package swing · introduced in 62. Settings · source: AmadeUI

What it is for. The text size the user picks on the Font page of Settings, seven steps from Smallest to Largest. At the default size 4 nothing changes; size 7 makes every font of the look half as big again.

When to choose it. A program gets it for free: install scales the fonts of the look, and follow installs again when font.conf changes, so labels, menus, buttons, fields, lists and tables change size while the program runs. A program needs scaled only where it picks a font size of its own, such as a heading or the small print of a status line:

AmadeUI.style(title, (l, t) -> l.setFont(UIManager.getFont("Label.font").deriveFont(Font.BOLD, AmadeUI.scaled(17f))));

Put such a line into a styler, not into the constructor, so the size follows a change like the colours do.

How it works. The scale factors are a table, 0.75 to 1.5. install scales every font of the look and feel's own defaults, the keys ending in font, and puts the result into Swing's table:

amade-commons · swing/AmadeUI.java
    /** A font size of the application's own, scaled by {@link #fontScale()}. */
    public static float scaled(float size) {
        return Math.round(size * fontScale() * 2) / 2f;
    }

    // Starts from the look and feel's own fonts every time, so that sizes never compound.
    private static void installFonts(float scale) {
        UIDefaults defaults = UIManager.getLookAndFeelDefaults();
        for (Object key : java.util.List.copyOf(defaults.keySet())) {
            if (!(key instanceof String name) || !name.endsWith("font")) continue;
            if (defaults.get(key) instanceof Font font) {
                put(name, new FontUIResource(font.deriveFont((float) Math.round(font.getSize2D() * scale))));
            }
        }
    }

It always starts from UIManager.getLookAndFeelDefaults(), never from the fonts it installed before, so installing a theme twice at size 7 gives the same sizes as once, and going back to 4 gives the original fonts.

Method What it does
fontScale() The factor of the installed theme's size, 1 at size 4
fontScale(size) The factor of any size, for a preview
scaled(size) A size of your own times the factor, rounded to half a point

Pitfall: fixed sizes in pixels

A layout that gives a text a fixed height in pixels, a setPreferredSize on a label or a table row height, cuts the text off at size 7. Let the layout manager size text components, and multiply the row heights you need with fontScale().

Test: sizes scale without compounding
amade-commons · test/swing/AmadeUITest.java
    @Test
    void fontSizeScalesTheLookAndFeelFontsWithoutCompounding() {
        AmadeUI.install(DesktopTheme.WARM_MOTIF);
        float normal = UIManager.getFont("Label.font").getSize2D();
        assertEquals(1f, AmadeUI.fontScale());

        AmadeUI.install(DesktopTheme.WARM_MOTIF.withFontSize(7));
        assertEquals(Math.round(normal * 1.5f), UIManager.getFont("Label.font").getSize2D());
        AmadeUI.install(DesktopTheme.WARM_MOTIF.withFontSize(7));
        assertEquals(Math.round(normal * 1.5f), UIManager.getFont("Label.font").getSize2D(), "installing twice");
        assertEquals(18f, AmadeUI.scaled(12f));

        AmadeUI.install(DesktopTheme.WARM_MOTIF);
        assertEquals(normal, UIManager.getFont("Label.font").getSize2D());
    }

Colours of your own: style

Package swing · introduced in 10. Pad · source: AmadeUI

What it is for. Components that need a colour Swing's table does not give them: a calendar's day grid in the field colour, a path bar in the selection colour, a highlighted status line. A styler is a function from theme to colours; style runs it now and again after every theme change.

amade-commons · swing/AmadeUI.java
    /**
     * Gives a component colours beyond the standard ones, e.g. a white message pane or a highlighted bar.
     * The styler runs now and after every theme change.
     */
    public static <C extends JComponent> C style(C component, BiConsumer<? super C, DesktopTheme> styler) {
        @SuppressWarnings("unchecked")
        BiConsumer<JComponent, DesktopTheme> stored = (BiConsumer<JComponent, DesktopTheme>) (BiConsumer<?, ?>) styler;
        component.putClientProperty(STYLER, stored);
        stored.accept(component, theme);
        return component;
    }
AmadeUI.style(scroll.getViewport(), (viewport, theme) -> viewport.setBackground(theme.fieldBackground()));

When to choose it. For a component whose colours you set with setBackground, setForeground or a border. A component that paints its own pixels needs no styler: it reads AmadeUI.theme() in paintComponent, and the repaint after a theme change does the rest. Pad's line number margin and Audio's racks and faders work that way.

How it works. The styler is kept in a client property of the component. apply walks every window and runs the stylers it finds, menus' pop-ups included.

Pitfall: setBackground(AmadeUI.theme().softHighlight())

A colour set once stays forever. After switching to Dark Slate, one bright bar remains in an otherwise dark window. If the colour comes from the theme, it belongs in a styler.

Test: stylers run again after a theme change
amade-commons · test/swing/AmadeUITest.java
    @Test
    void stylersRunAgainAfterAThemeChange() {
        AmadeUI.install(DesktopTheme.WARM_MOTIF);
        JPanel root = new JPanel();
        JLabel label = AmadeUI.style(new JLabel("x"), (l, t) -> l.setForeground(t.activeTitleBg()));
        root.add(label);
        assertEquals(DesktopTheme.WARM_MOTIF.activeTitleBg(), label.getForeground());

        AmadeUI.install(DesktopTheme.CDE_BROCADE);
        AmadeUI.restyle(root);
        assertEquals(DesktopTheme.CDE_BROCADE.activeTitleBg(), label.getForeground());
        AmadeUI.install(DesktopTheme.WARM_MOTIF);
    }

Bevels and dots

Package swing · introduced in 10. Pad · source: AmadeUI

What it is for. The small pieces of the look that programs need in their own components:

  • AmadeUI.sunken() and raised() are Motif bevel borders, 2 pixels, in the current theme's colours. A sunken bevel goes around content (a preview, a cover), a raised one around a group of controls.
  • paintBevel(g, x, y, width, height, raised, thickness) paints the same bevel in a component's own paintComponent, as Audio's faders and cover, WritePad's ruler and Imagescan's picture view do.
  • colorDot(color, size) is a round icon outlined in the dark shadow, for Mail's account colours in the folder tree, the message list and the options.

How it works. The borders paint with theme() each time they are drawn, so they follow a theme change without a styler. A raised bevel is light at the top left and dark at the bottom right; a sunken one the other way round.

Picking a date: DateChooser

Package swing · introduced in 21. Calendar · source: DateChooser

What it is for. A small dialog with one month as a grid of days, buttons for the previous and next month and year, Today, OK and Cancel. The arrow keys move the selection, Enter and a double-click accept. button makes the small calendar button that goes next to a date field:

amade-commons · swing/DateChooser.java
    /**
     * A small button with a calendar icon that opens the chooser on the current date of a field and
     * hands the chosen date back.
     */
    public static JButton button(Supplier<LocalDate> current, Consumer<LocalDate> chosen, Supplier<Locale> locale) {
        JButton button = new JButton(new CalendarIcon());
        button.setMargin(new Insets(1, 3, 1, 3));
        button.setFocusable(false);
        button.setToolTipText(Messages.commons(locale.get()).get("date.choose"));
        button.addActionListener(e -> {
            LocalDate start = current.get();
            choose(button, start == null ? LocalDate.now() : start, locale.get()).ifPresent(chosen);
        });
        return button;
    }

When to choose it. Next to every field where a date is typed. Typing stays the fast way; the dialog is for "the second Tuesday of next month".

How it works. The first day of the week and the names of days and months come from the locale, so a German user sees weeks from Monday and an American from Sunday. The dialog's own texts (date.today, date.previous-month, ...) are in amade-commons' texts in all five languages. choose(parent, initial, locale) returns the date, or an empty Optional if the user cancelled.