From 59626d2434230cf11fb68af520f67baee59cd0b5 Mon Sep 17 00:00:00 2001 From: piexel Date: Mon, 19 Oct 2026 23:01:31 +0200 Subject: [PATCH] Add light theme Follows the system by default. --- .../com/volmit/iris/core/IrisSettings.java | 1 + .../com/volmit/iris/core/gui/GuiHost.java | 5 + .../com/volmit/iris/core/gui/MapCanvas.java | 11 +- .../com/volmit/iris/core/gui/MapWindow.java | 1 + .../volmit/iris/core/gui/ObjectRenderer.java | 9 +- .../volmit/iris/core/gui/ObjectViewport.java | 7 + .../iris/core/gui/PaletteNavigator.java | 9 +- .../iris/core/gui/StickyFolderHeaders.java | 2 +- .../com/volmit/iris/core/gui/StudioTheme.java | 202 ++++++++++++++---- .../volmit/iris/core/gui/StudioToolbar.java | 32 ++- .../iris/core/gui/SystemAppearance.java | 110 ++++++++++ .../com/volmit/iris/core/gui/ThemeColor.java | 55 +++++ 12 files changed, 394 insertions(+), 50 deletions(-) create mode 100644 core/src/main/java/com/volmit/iris/core/gui/SystemAppearance.java create mode 100644 core/src/main/java/com/volmit/iris/core/gui/ThemeColor.java diff --git a/core/src/main/java/com/volmit/iris/core/IrisSettings.java b/core/src/main/java/com/volmit/iris/core/IrisSettings.java index 1ab105d43..647d24c04 100644 --- a/core/src/main/java/com/volmit/iris/core/IrisSettings.java +++ b/core/src/main/java/com/volmit/iris/core/IrisSettings.java @@ -266,6 +266,7 @@ public class IrisSettings { public boolean useServerLaunchedGuis = true; public boolean maximumPregenGuiFPS = false; public boolean colorMode = true; + public String theme = "auto"; } @Data diff --git a/core/src/main/java/com/volmit/iris/core/gui/GuiHost.java b/core/src/main/java/com/volmit/iris/core/gui/GuiHost.java index 0de92faf5..730766f68 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/GuiHost.java +++ b/core/src/main/java/com/volmit/iris/core/gui/GuiHost.java @@ -73,6 +73,11 @@ public final class GuiHost { public void windowClosed(WindowEvent event) { MANAGED_FRAMES.remove(frame); } + + @Override + public void windowActivated(WindowEvent event) { + StudioTheme.followSystem(); + } }); installMacQuitGuard(); return frame; diff --git a/core/src/main/java/com/volmit/iris/core/gui/MapCanvas.java b/core/src/main/java/com/volmit/iris/core/gui/MapCanvas.java index b48d08968..3acee1ce5 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/MapCanvas.java +++ b/core/src/main/java/com/volmit/iris/core/gui/MapCanvas.java @@ -66,8 +66,8 @@ final class MapCanvas extends JPanel { private static final Color MOB_COLOR = StudioTheme.ERROR; private static final Color GRID_MINOR = new Color(255, 255, 255, 18); private static final Color GRID_MAJOR = new Color(255, 255, 255, 38); - private static final Color TILE_EMPTY_A = new Color(0x232427); - private static final Color TILE_EMPTY_B = new Color(0x26272B); + private static final Color TILE_EMPTY_A = new ThemeColor(0xF0F1F3, 0x232427); + private static final Color TILE_EMPTY_B = new ThemeColor(0xEBECEF, 0x26272B); private static final int CARD_PADDING = 12; private static final int NOTIFICATION_MILLIS = 2_500; private static final double DEFAULT_BLOCKS_PER_PIXEL = 4D; @@ -585,6 +585,13 @@ final class MapCanvas extends JPanel { canvas.drawImage(currentTileLayer(), 0, 0, null); } + // The theme changed: the cached layer holds the old theme's background. + @Override + public void updateUI() { + super.updateUI(); + tileLayerStale = true; + } + private BufferedImage currentTileLayer() { int width = Math.max(1, getWidth()); int height = Math.max(1, getHeight()); diff --git a/core/src/main/java/com/volmit/iris/core/gui/MapWindow.java b/core/src/main/java/com/volmit/iris/core/gui/MapWindow.java index bd3bb9ef6..dcf244610 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/MapWindow.java +++ b/core/src/main/java/com/volmit/iris/core/gui/MapWindow.java @@ -178,6 +178,7 @@ public final class MapWindow implements MapCanvas.Listener { explorerControls.forEach(toolbar::add); toolbar.add(Box.createHorizontalGlue()); + toolbar.add(StudioToolbar.themeButton()); JComponent help = StudioToolbar.button("Shortcuts", "Show every mouse and key shortcut (/)", canvas::toggleHelp); mapControls.add(help); toolbar.add(help); diff --git a/core/src/main/java/com/volmit/iris/core/gui/ObjectRenderer.java b/core/src/main/java/com/volmit/iris/core/gui/ObjectRenderer.java index 227b2f3ce..f5830df29 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/ObjectRenderer.java +++ b/core/src/main/java/com/volmit/iris/core/gui/ObjectRenderer.java @@ -22,6 +22,7 @@ import com.volmit.iris.core.gui.BlockModel.Face; import com.volmit.iris.core.gui.BlockModel.TextureMapping; import com.volmit.iris.core.gui.BlockTextures.BlockTexture; +import java.awt.Color; import java.awt.image.BufferedImage; import java.awt.image.DataBufferInt; import java.util.ArrayList; @@ -36,8 +37,7 @@ import java.util.List; * what is already drawn. */ final class ObjectRenderer { - private static final int BACKGROUND = StudioTheme.EDITOR_BACKGROUND.getRGB() & 0xFFFFFF; - private static final int GRID_COLOR = 0x34373C; + private static final Color GRID = new ThemeColor(0xE3E4E8, 0x34373C); private static final int GRID_LINES_PER_SIDE = 25; private static final double GRID_REACH = 0.7D; private static final double GRID_BELOW_OBJECT = 0.035D; @@ -54,6 +54,7 @@ final class ObjectRenderer { private final double[] depths; private final boolean[] showsObject; private final Projection projection; + private final int gridColor = GRID.getRGB() & 0xFFFFFF; private ObjectRenderer(ObjectMesh mesh, ObjectCamera camera, BufferedImage image, int coveredLeftWidth) { this.width = image.getWidth(); @@ -71,7 +72,7 @@ final class ObjectRenderer { static BufferedImage render(ObjectMesh mesh, ObjectCamera camera, int width, int height, int coveredLeftWidth) { BufferedImage image = new BufferedImage(Math.max(1, width), Math.max(1, height), BufferedImage.TYPE_INT_RGB); ObjectRenderer renderer = new ObjectRenderer(mesh, camera, image, Math.max(0, Math.min(width - 1, coveredLeftWidth))); - Arrays.fill(renderer.pixels, BACKGROUND); + Arrays.fill(renderer.pixels, StudioTheme.EDITOR_BACKGROUND.getRGB() & 0xFFFFFF); Arrays.fill(renderer.depths, Double.NEGATIVE_INFINITY); renderer.drawGrid(mesh.bounds()); for (ObjectMesh.Quad quad : mesh.solidQuads()) { @@ -131,7 +132,7 @@ final class ObjectRenderer { double depth = startDepth + (endDepth - startDepth) * progress; if (depth > depths[index]) { depths[index] = depth; - pixels[index] = GRID_COLOR; + pixels[index] = gridColor; } } } diff --git a/core/src/main/java/com/volmit/iris/core/gui/ObjectViewport.java b/core/src/main/java/com/volmit/iris/core/gui/ObjectViewport.java index 56d71b637..c1ee7087c 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/ObjectViewport.java +++ b/core/src/main/java/com/volmit/iris/core/gui/ObjectViewport.java @@ -77,6 +77,13 @@ final class ObjectViewport extends JComponent { }); } + // The theme changed, and the last frame was drawn on the old theme's background. + @Override + public void updateUI() { + super.updateUI(); + requestFrame(); + } + void show(ObjectMesh nextMesh) { mesh = nextMesh; camera = ObjectCamera.START; diff --git a/core/src/main/java/com/volmit/iris/core/gui/PaletteNavigator.java b/core/src/main/java/com/volmit/iris/core/gui/PaletteNavigator.java index 546c24abc..cfc542988 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/PaletteNavigator.java +++ b/core/src/main/java/com/volmit/iris/core/gui/PaletteNavigator.java @@ -57,7 +57,6 @@ final class PaletteNavigator extends JPanel { private static final long serialVersionUID = -6402313718430917284L; private static final String HERE_CARD = "here"; private static final String OVERVIEW_CARD = "overview"; - private static final String DETAIL_COLOR = StudioTheme.SECONDARY_TEXT_HTML; private static final int MESSAGE_WRAP_WIDTH_PIXELS = 190; private final Listener listener; @@ -438,13 +437,13 @@ final class PaletteNavigator extends JPanel { private static String biomeText(BiomeEntry biome, String note) { String noteText = note == null ? "" : " ยท " + escape(note); - return "" + escape(biome.name()) + noteText + " " + escape(biome.key()) + return "" + escape(biome.name()) + noteText + " " + escape(biome.key()) + ""; } private static String labeled(String title, String name, String key) { - return "" + title + "  " + escape(name) - + " " + escape(key) + ""; + return "" + title + "  " + escape(name) + + " " + escape(key) + ""; } private static String escape(String text) { @@ -477,7 +476,7 @@ final class PaletteNavigator extends JPanel { if (detail == null) { return escape(label); } - return "" + escape(label) + " " + escape(detail) + ""; + return "" + escape(label) + " " + escape(detail) + ""; } } diff --git a/core/src/main/java/com/volmit/iris/core/gui/StickyFolderHeaders.java b/core/src/main/java/com/volmit/iris/core/gui/StickyFolderHeaders.java index fdf2a82bd..f73962317 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/StickyFolderHeaders.java +++ b/core/src/main/java/com/volmit/iris/core/gui/StickyFolderHeaders.java @@ -51,7 +51,7 @@ import java.util.List; final class StickyFolderHeaders extends LayerUI { private static final long serialVersionUID = -2205474187531254372L; private static final int SHADOW_HEIGHT = 4; - private static final Color SHADOW = new Color(0, 0, 0, 90); + private static final Color SHADOW = new ThemeColor(new Color(0, 0, 0, 28), new Color(0, 0, 0, 90)); private static final Color NO_SHADOW = new Color(0, 0, 0, 0); // The folder handle sits one indent to the left of the folder's name, as the tree draws it. private static final int HANDLE_NUDGE = 1; diff --git a/core/src/main/java/com/volmit/iris/core/gui/StudioTheme.java b/core/src/main/java/com/volmit/iris/core/gui/StudioTheme.java index 9d877bfd5..5e17cd171 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/StudioTheme.java +++ b/core/src/main/java/com/volmit/iris/core/gui/StudioTheme.java @@ -20,62 +20,64 @@ package com.volmit.iris.core.gui; import com.formdev.flatlaf.FlatDarkLaf; import com.formdev.flatlaf.FlatLaf; +import com.formdev.flatlaf.FlatLightLaf; import com.formdev.flatlaf.util.UIScale; import com.volmit.iris.Iris; +import com.volmit.iris.core.IrisSettings; import javax.swing.BorderFactory; import javax.swing.UIManager; import javax.swing.border.Border; import java.awt.Color; import java.awt.Dimension; +import java.awt.EventQueue; import java.awt.Font; +import java.util.HashMap; +import java.util.Locale; import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicBoolean; /** - * The colours of IntelliJ's dark "New UI", shared by FlatLaf's controls and everything the studio windows draw themselves. + * The colours of IntelliJ's "New UI", light and dark, shared by FlatLaf's controls and everything the studio windows + * draw themselves. The theme follows the system unless the user picked light or dark. */ final class StudioTheme { - static final Color EDITOR_BACKGROUND = new Color(0x1E1F22); - static final Color PANEL_BACKGROUND = new Color(0x2B2D30); - static final Color HOVER_BACKGROUND = new Color(0x393B40); - static final Color INACTIVE_SELECTION = new Color(0x43454A); - static final Color SELECTION = new Color(0x2E436E); - static final Color CONTROL_BORDER = new Color(0x4E5157); - static final Color SEPARATOR = new Color(0x1E1F22); - static final Color TEXT = new Color(0xDFE1E5); - static final Color SECONDARY_TEXT = new Color(0x9DA0A8); - static final Color MUTED_TEXT = new Color(0x6F737A); + static final Color EDITOR_BACKGROUND = new ThemeColor(0xFFFFFF, 0x1E1F22); + static final Color PANEL_BACKGROUND = new ThemeColor(0xF7F8FA, 0x2B2D30); + static final Color HOVER_BACKGROUND = new ThemeColor(0xDFE1E5, 0x393B40); + static final Color INACTIVE_SELECTION = new ThemeColor(0xDFE1E5, 0x43454A); + static final Color SELECTION = new ThemeColor(0xD4E2FF, 0x2E436E); + static final Color CONTROL_BORDER = new ThemeColor(0xC9CCD6, 0x4E5157); + static final Color SEPARATOR = new ThemeColor(0xEBECF0, 0x1E1F22); + static final Color TEXT = new ThemeColor(0x000000, 0xDFE1E5); + static final ThemeColor SECONDARY_TEXT = new ThemeColor(0x6C707E, 0x9DA0A8); + static final Color MUTED_TEXT = new ThemeColor(0x818594, 0x6F737A); static final Color ACCENT = new Color(0x3574F0); - static final Color LINK = new Color(0x548AF7); - static final Color ERROR = new Color(0xF75464); - static final Color WARNING = new Color(0xF2C55C); - static final Color SUCCESS = new Color(0x5FB865); - static final Color OVERLAY_BACKGROUND = new Color(0x2B, 0x2D, 0x30, 236); - static final Color OVERLAY_BORDER = new Color(0x43454A); - static final String SECONDARY_TEXT_HTML = "#9DA0A8"; + static final Color LINK = new ThemeColor(0x315FBD, 0x548AF7); + static final Color ERROR = new ThemeColor(0xDB3B4B, 0xF75464); + static final Color WARNING = new ThemeColor(0xC27D04, 0xF2C55C); + static final Color SUCCESS = new ThemeColor(0x208A3C, 0x5FB865); + static final Color OVERLAY_BACKGROUND = new ThemeColor(new Color(0xFF, 0xFF, 0xFF, 240), new Color(0x2B, 0x2D, 0x30, 236)); + static final Color OVERLAY_BORDER = new ThemeColor(0xDFE1E5, 0x43454A); static final int OVERLAY_ARC = 10; + private static final boolean DARK_WITHOUT_SYSTEM_PREFERENCE = true; private static final AtomicBoolean INSTALLED = new AtomicBoolean(); + private static final ExecutorService SYSTEM_CHECK = Executors.newSingleThreadExecutor(runnable -> { + Thread thread = new Thread(runnable, "Iris Theme Check"); + thread.setDaemon(true); + return thread; + }); + private static volatile boolean dark = DARK_WITHOUT_SYSTEM_PREFERENCE; private static volatile float pixelScale = 1F; - private static final Map INTELLIJ_NEW_UI_DEFAULTS = Map.ofEntries( - Map.entry("@background", "#2B2D30"), - Map.entry("@foreground", "#DFE1E5"), - Map.entry("@accentColor", "#3574F0"), - Map.entry("@selectionBackground", "#2E436E"), - Map.entry("@selectionInactiveBackground", "#43454A"), - Map.entry("@disabledForeground", "#6F737A"), - Map.entry("Component.borderColor", "#4E5157"), - Map.entry("Component.focusedBorderColor", "#3574F0"), + private static final Map SHARED_DEFAULTS = Map.ofEntries( Map.entry("Component.focusWidth", "1"), Map.entry("Component.innerFocusWidth", "0"), Map.entry("Component.arc", "8"), Map.entry("Button.arc", "8"), Map.entry("TextComponent.arc", "8"), - Map.entry("Button.toolbar.hoverBackground", "#393B40"), - Map.entry("Button.toolbar.pressedBackground", "#43454A"), - Map.entry("Button.toolbar.selectedBackground", "#43454A"), - Map.entry("ToggleButton.toolbar.selectedBackground", "#43454A"), Map.entry("List.selectionArc", "6"), Map.entry("List.selectionInsets", "0,6,0,6"), Map.entry("Tree.selectionArc", "6"), @@ -85,16 +87,49 @@ final class StudioTheme { Map.entry("ScrollBar.thumbArc", "999"), Map.entry("ScrollBar.thumbInsets", "2,2,2,2"), Map.entry("ScrollBar.showButtons", "false"), - Map.entry("ScrollBar.track", "#2B2D30"), Map.entry("ScrollPane.border", "0,0,0,0"), Map.entry("SplitPane.dividerSize", "1"), Map.entry("SplitPaneDivider.style", "plain"), - Map.entry("SplitPane.background", "#1E1F22"), - Map.entry("Separator.foreground", "#1E1F22"), - Map.entry("ToolTip.background", "#393B40"), Map.entry("ProgressBar.arc", "999"), Map.entry("ProgressBar.horizontalSize", "120,4") ); + private static final Map DARK_DEFAULTS = withShared(Map.ofEntries( + Map.entry("@background", "#2B2D30"), + Map.entry("@foreground", "#DFE1E5"), + Map.entry("@accentColor", "#3574F0"), + Map.entry("@selectionBackground", "#2E436E"), + Map.entry("@selectionInactiveBackground", "#43454A"), + Map.entry("@disabledForeground", "#6F737A"), + Map.entry("Component.borderColor", "#4E5157"), + Map.entry("Component.focusedBorderColor", "#3574F0"), + Map.entry("Button.toolbar.hoverBackground", "#393B40"), + Map.entry("Button.toolbar.pressedBackground", "#43454A"), + Map.entry("Button.toolbar.selectedBackground", "#43454A"), + Map.entry("ToggleButton.toolbar.selectedBackground", "#43454A"), + Map.entry("ScrollBar.track", "#2B2D30"), + Map.entry("SplitPane.background", "#1E1F22"), + Map.entry("Separator.foreground", "#1E1F22"), + Map.entry("ToolTip.background", "#393B40") + )); + private static final Map LIGHT_DEFAULTS = withShared(Map.ofEntries( + Map.entry("@background", "#F7F8FA"), + Map.entry("@foreground", "#000000"), + Map.entry("@accentColor", "#3574F0"), + Map.entry("@selectionBackground", "#D4E2FF"), + Map.entry("@selectionForeground", "#000000"), + Map.entry("@selectionInactiveBackground", "#DFE1E5"), + Map.entry("@selectionInactiveForeground", "#000000"), + Map.entry("@disabledForeground", "#A8ADBD"), + Map.entry("Component.borderColor", "#C9CCD6"), + Map.entry("Component.focusedBorderColor", "#3574F0"), + Map.entry("Button.toolbar.hoverBackground", "#DFE1E5"), + Map.entry("Button.toolbar.pressedBackground", "#C9CCD6"), + Map.entry("Button.toolbar.selectedBackground", "#DFE1E5"), + Map.entry("ToggleButton.toolbar.selectedBackground", "#DFE1E5"), + Map.entry("ScrollBar.track", "#F7F8FA"), + Map.entry("SplitPane.background", "#EBECF0"), + Map.entry("Separator.foreground", "#EBECF0") + )); private StudioTheme() { } @@ -104,8 +139,7 @@ final class StudioTheme { return; } try { - FlatLaf.setGlobalExtraDefaults(INTELLIJ_NEW_UI_DEFAULTS); - FlatDarkLaf.setup(); + applyLookAndFeel(darkFor(mode())); pixelScale = UIScale.getUserScaleFactor(); } catch (Throwable error) { Iris.reportError(error); @@ -113,6 +147,71 @@ final class StudioTheme { } } + static boolean isDark() { + return dark; + } + + static Mode mode() { + return Mode.fromSetting(IrisSettings.get().getGui().getTheme()); + } + + /** + * Remembers the user's choice and switches every studio window to it. + */ + static void choose(Mode next) { + IrisSettings settings = IrisSettings.get(); + settings.getGui().setTheme(next.settingName()); + settings.forceSave(); + if (next == Mode.AUTO) { + followSystem(); + return; + } + switchTo(next == Mode.DARK); + } + + // Studio windows call this when they come to the front, so a change made in the system's settings shows up. + static void followSystem() { + if (!INSTALLED.get() || mode() != Mode.AUTO) { + return; + } + SYSTEM_CHECK.execute(() -> { + boolean systemDark = SystemAppearance.prefersDark().orElse(DARK_WITHOUT_SYSTEM_PREFERENCE); + EventQueue.invokeLater(() -> switchTo(systemDark)); + }); + } + + private static boolean darkFor(Mode chosen) { + return switch (chosen) { + case LIGHT -> false; + case DARK -> true; + case AUTO -> SystemAppearance.prefersDark().orElse(DARK_WITHOUT_SYSTEM_PREFERENCE); + }; + } + + private static void switchTo(boolean nextDark) { + if (nextDark == dark) { + return; + } + applyLookAndFeel(nextDark); + FlatLaf.updateUI(); + } + + private static void applyLookAndFeel(boolean nextDark) { + dark = nextDark; + FlatLaf.setGlobalExtraDefaults(nextDark ? DARK_DEFAULTS : LIGHT_DEFAULTS); + if (nextDark) { + FlatDarkLaf.setup(); + } else { + FlatLightLaf.setup(); + } + } + + private static Map withShared(Map themeDefaults) { + Map merged = new HashMap<>(SHARED_DEFAULTS); + merged.putAll(themeDefaults); + return Map.copyOf(merged); + } + /** * FlatLaf grows fonts and its own controls to the screen's DPI; every size the studio sets itself has to grow with them. */ @@ -160,4 +259,33 @@ final class StudioTheme { Font font = UIManager.getFont(key); return font != null ? font : new Font(Font.SANS_SERIF, fallbackStyle, fallbackSize); } + + enum Mode { + AUTO("Match system"), + LIGHT("Light"), + DARK("Dark"); + + private final String label; + + Mode(String label) { + this.label = label; + } + + String label() { + return label; + } + + String settingName() { + return name().toLowerCase(Locale.ROOT); + } + + static Mode fromSetting(String setting) { + for (Mode mode : values()) { + if (mode.settingName().equalsIgnoreCase(setting)) { + return mode; + } + } + return AUTO; + } + } } diff --git a/core/src/main/java/com/volmit/iris/core/gui/StudioToolbar.java b/core/src/main/java/com/volmit/iris/core/gui/StudioToolbar.java index 9fb9bfc49..4abd92186 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/StudioToolbar.java +++ b/core/src/main/java/com/volmit/iris/core/gui/StudioToolbar.java @@ -22,10 +22,13 @@ import com.formdev.flatlaf.FlatClientProperties; import com.formdev.flatlaf.icons.FlatSearchIcon; import javax.swing.BorderFactory; +import javax.swing.ButtonGroup; import javax.swing.JButton; import javax.swing.JComponent; import javax.swing.JLabel; import javax.swing.JPanel; +import javax.swing.JPopupMenu; +import javax.swing.JRadioButtonMenuItem; import javax.swing.JScrollPane; import javax.swing.JTextField; import javax.swing.JToggleButton; @@ -63,12 +66,39 @@ final class StudioToolbar { } static JButton button(String text, String tooltip, Runnable action) { + JButton button = toolbarButton(text, tooltip); + button.addActionListener(event -> action.run()); + return button; + } + + /** + * Opens a menu to pick light, dark or the system's theme. + */ + static JButton themeButton() { + JButton button = toolbarButton("Theme", "Light, dark, or whatever the system uses"); + button.addActionListener(event -> themeMenu().show(button, 0, button.getHeight())); + return button; + } + + private static JPopupMenu themeMenu() { + JPopupMenu menu = new JPopupMenu(); + ButtonGroup choices = new ButtonGroup(); + StudioTheme.Mode current = StudioTheme.mode(); + for (StudioTheme.Mode mode : StudioTheme.Mode.values()) { + JRadioButtonMenuItem item = new JRadioButtonMenuItem(mode.label(), mode == current); + item.addActionListener(event -> StudioTheme.choose(mode)); + choices.add(item); + menu.add(item); + } + return menu; + } + + private static JButton toolbarButton(String text, String tooltip) { JButton button = new JButton(text); button.setToolTipText(tooltip); button.setFocusable(false); button.setMargin(new Insets(4, 8, 4, 8)); button.putClientProperty(FlatClientProperties.BUTTON_TYPE, FlatClientProperties.BUTTON_TYPE_TOOLBAR_BUTTON); - button.addActionListener(event -> action.run()); return button; } diff --git a/core/src/main/java/com/volmit/iris/core/gui/SystemAppearance.java b/core/src/main/java/com/volmit/iris/core/gui/SystemAppearance.java new file mode 100644 index 000000000..643680fe7 --- /dev/null +++ b/core/src/main/java/com/volmit/iris/core/gui/SystemAppearance.java @@ -0,0 +1,110 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.volmit.iris.core.gui; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +/** + * Asks the operating system whether its user prefers dark or light apps, through the same settings desktop apps + * follow: the freedesktop portal or GNOME's setting on Linux, the apps theme on Windows, the appearance on macOS. + */ +final class SystemAppearance { + private static final long COMMAND_TIMEOUT_MILLIS = 2000L; + private static final String PORTAL_DARK = "uint32 1"; + private static final String PORTAL_LIGHT = "uint32 2"; + + private SystemAppearance() { + } + + /** + * Empty when the system has no preference or could not be asked. + */ + static Optional prefersDark() { + String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT); + if (os.contains("win")) { + return windowsPrefersDark(); + } + if (os.contains("mac")) { + return macPrefersDark(); + } + return linuxPrefersDark(); + } + + private static Optional linuxPrefersDark() { + Optional portal = output(List.of("gdbus", "call", "--session", "--dest", "org.freedesktop.portal.Desktop", + "--object-path", "/org/freedesktop/portal/desktop", "--method", "org.freedesktop.portal.Settings.ReadOne", + "org.freedesktop.appearance", "color-scheme")); + if (portal.filter(answer -> answer.contains(PORTAL_DARK)).isPresent()) { + return Optional.of(true); + } + if (portal.filter(answer -> answer.contains(PORTAL_LIGHT)).isPresent()) { + return Optional.of(false); + } + Optional gnome = output(List.of("gsettings", "get", "org.gnome.desktop.interface", "color-scheme")); + if (gnome.filter(scheme -> scheme.contains("prefer-dark")).isPresent()) { + return Optional.of(true); + } + if (gnome.filter(scheme -> scheme.contains("prefer-light")).isPresent()) { + return Optional.of(false); + } + return output(List.of("gsettings", "get", "org.gnome.desktop.interface", "gtk-theme")) + .map(theme -> theme.toLowerCase(Locale.ROOT).contains("dark")); + } + + private static Optional windowsPrefersDark() { + return output(List.of("reg", "query", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + "/v", "AppsUseLightTheme")) + .filter(answer -> answer.contains("AppsUseLightTheme")) + .map(answer -> answer.trim().endsWith("0x0")); + } + + // The setting only exists while macOS is dark, so a failed read means light. + private static Optional macPrefersDark() { + return Optional.of(output(List.of("defaults", "read", "-g", "AppleInterfaceStyle")) + .filter(style -> style.trim().equalsIgnoreCase("dark")) + .isPresent()); + } + + private static Optional output(List command) { + try { + Process process = new ProcessBuilder(command).redirectErrorStream(true).start(); + if (!process.waitFor(COMMAND_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)) { + process.destroyForcibly(); + return Optional.empty(); + } + if (process.exitValue() != 0) { + return Optional.empty(); + } + try (InputStream stream = process.getInputStream()) { + return Optional.of(new String(stream.readAllBytes(), StandardCharsets.UTF_8)); + } + } catch (IOException missingCommand) { + return Optional.empty(); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return Optional.empty(); + } + } +} diff --git a/core/src/main/java/com/volmit/iris/core/gui/ThemeColor.java b/core/src/main/java/com/volmit/iris/core/gui/ThemeColor.java new file mode 100644 index 000000000..116acd26e --- /dev/null +++ b/core/src/main/java/com/volmit/iris/core/gui/ThemeColor.java @@ -0,0 +1,55 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.volmit.iris.core.gui; + +import java.awt.Color; + +/** + * A colour with a light and a dark version that always answers with the one of the current theme, like IntelliJ's + * JBColor. Components keep the same colour object across a theme switch and simply paint in the other version. + */ +final class ThemeColor extends Color { + private static final long serialVersionUID = 2286435620386470207L; + private final Color light; + private final Color dark; + + ThemeColor(int lightRgb, int darkRgb) { + this(new Color(lightRgb), new Color(darkRgb)); + } + + ThemeColor(Color light, Color dark) { + super(dark.getRGB(), true); + this.light = light; + this.dark = dark; + } + + // Every other colour query of Color, and Java2D's painting, go through this one. + @Override + public int getRGB() { + return current().getRGB(); + } + + String toHtml() { + return String.format("#%06X", getRGB() & 0xFFFFFF); + } + + private Color current() { + return StudioTheme.isDark() ? dark : light; + } +}