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 %.
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:
/**
* 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:
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.
/** 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
@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.
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:
/** 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
@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:
/**
* 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:
/** 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:
/** 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
@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.
/**
* 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
@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()andraised()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 ownpaintComponent, 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:
/**
* 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.