add a system theme option that follows the os light or dark setting, and make it the default for new installs
What changed, and why it matters
This commit adds a new 'System' theme option to the Sparrow Wallet desktop app that automatically follows the operating system's light or dark mode setting, and makes it the default for new installations. It also updates various UI components to use the active theme (which may be derived from the system setting) when choosing icons and stylesheets. There is no security-relevant change here.
No security action required. This is a cosmetic/user-experience feature. Normal code review and QA for theme consistency on supported platforms is sufficient.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The change introduces a Theme.SYSTEM enum value and a system theme monitor (AppServices.monitorSystemTheme) that reads Platform.Preferences.getColorScheme() and posts ThemeChangedEvents when the OS color scheme changes. Multiple UI controllers and custom controls are refactored from checking Config.get().getTheme() == Theme.DARK to calling AppServices.isDarkTheme()/getActiveTheme(), which resolves SYSTEM to the actual OS preference. Several dialogs are given explicit owners (initOwner) and the About dialog is refreshed on re-show for macOS. These are all UI/UX improvements with no cryptographic, network, or privilege-related modifications.
Changed components
Theme selection UI (app.fxml, AppController.setTheme)System theme monitor (AppServices.monitorSystemTheme, AppServices.getActiveTheme, AppServices.isDarkTheme)About dialog (AboutController.refreshTheme)Dialog and image controls (DialogImage, WalletIcon, WalletModelImage, QREncoding, MempoolSizeFeeRatesChart, TransactionDiagram, PaymentController)DevicePane range input dialog ownershipInspect captured patch +90 / −32
### src/main/java/com/sparrowwallet/sparrow/AboutController.java
@@ -1,5 +1,6 @@
package com.sparrowwallet.sparrow;
+import com.sparrowwallet.sparrow.control.DialogImage;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.scene.control.Label;
@@ -11,10 +12,26 @@ public class AboutController {
@FXML
private Label title;
+ @FXML
+ private DialogImage dialogImage;
+
public void initializeView() {
title.setText(SparrowWallet.APP_NAME + " " + SparrowWallet.APP_VERSION + SparrowWallet.APP_VERSION_SUFFIX);
}
+ public void refreshTheme() {
+ String darkCss = AppServices.class.getResource("darktheme.css").toExternalForm();
+ if(AppServices.isDarkTheme()) {
+ if(!stage.getScene().getStylesheets().contains(darkCss)) {
+ stage.getScene().getStylesheets().add(darkCss);
+ }
+ } else {
+ stage.getScene().getStylesheets().remove(darkCss);
+ }
+
+ dialogImage.refresh();
+ }
+
public void setStage(Stage stage) {
this.stage = stage;
}
### src/main/java/com/sparrowwallet/sparrow/AppController.java
@@ -388,8 +388,8 @@ void initializeView() {
Theme configTheme = Config.get().getTheme();
if(configTheme == null) {
- configTheme = Theme.LIGHT;
- Config.get().setTheme(Theme.LIGHT);
+ configTheme = Theme.SYSTEM;
+ Config.get().setTheme(Theme.SYSTEM);
}
final Theme selectedTheme = configTheme;
Optional<Toggle> selectedThemeToggle = theme.getToggles().stream().filter(toggle -> selectedTheme.equals(toggle.getUserData())).findFirst();
@@ -578,6 +578,8 @@ private Stage getAboutStage() {
controller.initializeView();
setStageIcon(stage);
stage.setOnShowing(event -> {
+ //The macOS application menu reuses a single About stage, so the theme may have changed since it was created
+ controller.refreshTheme();
AppServices.moveToActiveWindowScreen(stage, 600, 460);
});
@@ -1374,6 +1376,7 @@ private void addImportedWallet(Wallet wallet) {
File walletFile = Storage.getExistingWallet(wallet.getName());
if(walletFile != null) {
Alert alert = new Alert(Alert.AlertType.CONFIRMATION);
+ alert.initOwner(rootStack.getScene().getWindow());
AppServices.setStageIcon(alert.getDialogPane().getScene().getWindow());
alert.setTitle("Existing wallet found");
alert.setHeaderText("Replace existing wallet?");
@@ -2643,7 +2646,7 @@ public void setTheme(ActionEvent event) {
Config.get().setTheme(selectedTheme);
}
- EventManager.get().post(new ThemeChangedEvent(selectedTheme));
+ EventManager.get().post(new ThemeChangedEvent(AppServices.getActiveTheme()));
}
private void serverToggleStartAnimation() {
@@ -2747,13 +2750,24 @@ private void setTorIcon() {
@Subscribe
public void themeChanged(ThemeChangedEvent event) {
+ //Owned dialogs follow the main window stylesheets, but these non-modal dialogs have no owner
+ List<Scene> scenes = new ArrayList<>(List.of(tabs.getScene()));
+ if(sendToManyDialog != null) {
+ scenes.add(sendToManyDialog.getDialogPane().getScene());
+ }
+ if(searchWalletDialog != null) {
+ scenes.add(searchWalletDialog.getDialogPane().getScene());
+ }
+
String darkCss = getClass().getResource("darktheme.css").toExternalForm();
- if(event.getTheme() == Theme.DARK) {
- if(!tabs.getScene().getStylesheets().contains(darkCss)) {
- tabs.getScene().getStylesheets().add(darkCss);
+ for(Scene scene : scenes) {
+ if(event.getTheme() == Theme.DARK) {
+ if(!scene.getStylesheets().contains(darkCss)) {
+ scene.getStylesheets().add(darkCss);
+ }
+ } else {
+ scene.getStylesheets().remove(darkCss);
}
- } else {
- tabs.getScene().getStylesheets().remove(darkCss);
}
for(Tab tab : tabs.getTabs()) {
### src/main/java/com/sparrowwallet/sparrow/AppServices.java
@@ -30,6 +30,7 @@
import io.reactivex.rxjavafx.schedulers.JavaFxScheduler;
import io.reactivex.subjects.PublishSubject;
import javafx.application.Application;
+import javafx.application.ColorScheme;
import javafx.application.Platform;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
@@ -132,6 +133,8 @@ public class AppServices {
private static volatile ChainTip announcedTip;
+ private static volatile boolean systemDarkTheme;
+
private static final Map<Integer, BlockSummary> blockSummaries = new ConcurrentHashMap<>();
private static Map<Integer, Double> targetBlockFeeRates;
@@ -956,13 +959,42 @@ public static Optional<ButtonType> showAlertDialog(String title, String content,
return getInteractionServices().showAlert(title, content, alertType, graphic, buttons);
}
+ public static void monitorSystemTheme() {
+ try {
+ Platform.Preferences preferences = Platform.getPreferences();
+ systemDarkTheme = preferences.getColorScheme() == ColorScheme.DARK;
+ preferences.colorSchemeProperty().addListener((observable, oldValue, colorScheme) -> {
+ systemDarkTheme = colorScheme == ColorScheme.DARK;
+ if(Config.get().getTheme() == null || Config.get().getTheme() == Theme.SYSTEM) {
+ EventManager.get().post(new ThemeChangedEvent(getActiveTheme()));
+ }
+ });
+ } catch(Exception e) {
+ log.warn("Could not read the system color scheme", e);
+ }
+ }
+
+ public static Theme getActiveTheme() {
+ Theme theme = Config.get().getTheme();
+ if(theme == null || theme == Theme.SYSTEM) {
+ return systemDarkTheme ? Theme.DARK : Theme.LIGHT;
+ }
+
+ return theme;
+ }
+
+ public static boolean isDarkTheme() {
+ return getActiveTheme() == Theme.DARK;
+ }
+
public static void setStageIcon(Window window) {
Stage stage = (Stage)window;
stage.getIcons().add(getWindowIcon());
if(stage.getScene() != null) {
- if(Config.get().getTheme() == Theme.DARK) {
- stage.getScene().getStylesheets().add(AppServices.class.getResource("darktheme.css").toExternalForm());
+ String darkCss = AppServices.class.getResource("darktheme.css").toExternalForm();
+ if(isDarkTheme() && !stage.getScene().getStylesheets().contains(darkCss)) {
+ stage.getScene().getStylesheets().add(darkCss);
}
if(Config.get().isChunkAddresses()) {
stage.getScene().getRoot().getStyleClass().add("chunk-addresses");
### src/main/java/com/sparrowwallet/sparrow/SparrowDesktop.java
@@ -46,6 +46,7 @@ public void start(Stage stage) throws Exception {
URL.setURLStreamHandlerFactory(protocol -> WalletIcon.PROTOCOL.equals(protocol) ? new WalletIcon.WalletIconStreamHandler() : null);
AppServices.initialize(this);
+ AppServices.monitorSystemTheme();
boolean createNewWallet = false;
Mode mode = Config.get().getMode();
### src/main/java/com/sparrowwallet/sparrow/Theme.java
@@ -1,5 +1,5 @@
package com.sparrowwallet.sparrow;
public enum Theme {
- LIGHT, DARK
+ LIGHT, DARK, SYSTEM
}
### src/main/java/com/sparrowwallet/sparrow/control/DevicePane.java
@@ -993,6 +993,7 @@ private void discoverWallet() {
List<Wallet> wallets = new ArrayList<>();
RangeInputDialog rangeInputDialog = new RangeInputDialog(StandardAccount.ACCOUNT_0.getAccountNumber(), StandardAccount.ACCOUNT_30.getAccountNumber(), StandardAccount.ACCOUNT_10.getAccountNumber());
+ rangeInputDialog.initOwner(this.getScene().getWindow());
rangeInputDialog.setTitle("Choose number of accounts");
rangeInputDialog.setHeaderText("Enter the number of additional accounts to scan for existing funds.\n\nThis may take a few minutes depending on how many accounts are selected.");
Optional<Integer> optRange = rangeInputDialog.showAndWait();
### src/main/java/com/sparrowwallet/sparrow/control/DialogImage.java
@@ -1,8 +1,6 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.AppServices;
-import com.sparrowwallet.sparrow.Theme;
-import com.sparrowwallet.sparrow.io.Config;
import javafx.beans.NamedArg;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
@@ -43,7 +41,7 @@ public void refresh() {
protected void refresh(Type type) {
SVGImage svgImage;
- if(Config.get().getTheme() == Theme.DARK) {
+ if(AppServices.isDarkTheme()) {
svgImage = loadSVGImage("/image/dialog/" + type.name().toLowerCase(Locale.ROOT) + "-invert.svg");
} else {
svgImage = loadSVGImage("/image/dialog/" + type.name().toLowerCase(Locale.ROOT) + ".svg");
### src/main/java/com/sparrowwallet/sparrow/control/MempoolSizeFeeRatesChart.java
@@ -2,9 +2,7 @@
import com.sparrowwallet.drongo.OsType;
import com.sparrowwallet.sparrow.AppServices;
-import com.sparrowwallet.sparrow.Theme;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
-import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.net.MempoolRateSize;
import javafx.application.Platform;
import javafx.beans.NamedArg;
@@ -63,7 +61,7 @@ public void handle(MouseEvent event) {
}
scenePane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
- if(Config.get().getTheme() == Theme.DARK) {
+ if(AppServices.isDarkTheme()) {
scenePane.getStylesheets().add(AppServices.class.getResource("darktheme.css").toExternalForm());
}
scenePane.getStylesheets().add(AppServices.class.getResource("wallet/wallet.css").toExternalForm());
### src/main/java/com/sparrowwallet/sparrow/control/QREncoding.java
@@ -1,9 +1,7 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.AppServices;
-import com.sparrowwallet.sparrow.Theme;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
-import com.sparrowwallet.sparrow.io.Config;
import javafx.geometry.Insets;
import javafx.scene.Node;
import org.controlsfx.glyphfont.Glyph;
@@ -31,7 +29,7 @@ public String getName() {
public Node getSVGImage() {
try {
- URL url = AppServices.class.getResource("/image/qrencoding/" + getName().toLowerCase(Locale.ROOT) + "-icon" + (Config.get().getTheme() == Theme.DARK ? "-invert" : "") + ".svg");
+ URL url = AppServices.class.getResource("/image/qrencoding/" + getName().toLowerCase(Locale.ROOT) + "-icon" + (AppServices.isDarkTheme() ? "-invert" : "") + ".svg");
if(url != null) {
return SVGLoader.load(url);
} else {
### src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java
@@ -98,7 +98,7 @@ public void handle(MouseEvent event) {
}
scenePane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
- if(Config.get().getTheme() == Theme.DARK) {
+ if(AppServices.isDarkTheme()) {
scenePane.getStylesheets().add(AppServices.class.getResource("darktheme.css").toExternalForm());
}
scenePane.getStylesheets().add(AppServices.class.getResource("wallet/wallet.css").toExternalForm());
@@ -998,7 +998,7 @@ private void saveAsImage() {
transactionDiagram.setFinal(true);
transactionDiagram.setExpanded(isExpanded());
transactionDiagram.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, null, null)));
- transactionDiagram.setStyle("-fx-text-background-color: " + (Config.get().getTheme() == Theme.DARK ? "#ffffff" : "#000000"));
+ transactionDiagram.setStyle("-fx-text-background-color: " + (AppServices.isDarkTheme() ? "#ffffff" : "#000000"));
updateDerivedDiagram(transactionDiagram);
Scene scene = new Scene(transactionDiagram);
scene.setFill(Color.TRANSPARENT);
### src/main/java/com/sparrowwallet/sparrow/control/WalletIcon.java
@@ -2,17 +2,14 @@
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices;
-import com.sparrowwallet.sparrow.Theme;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
-import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.ImageUtils;
import com.sparrowwallet.sparrow.io.Storage;
import javafx.application.Platform;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.geometry.Pos;
import javafx.scene.image.Image;
-import javafx.scene.image.ImageView;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.ImagePattern;
import javafx.scene.shape.Circle;
@@ -73,7 +70,7 @@ public void refresh() {
WalletModel walletModel = keystore.getWalletModel();
SVGImage svgImage;
- if(Config.get().getTheme() == Theme.DARK) {
+ if(AppServices.isDarkTheme()) {
svgImage = loadSVGImage("/image/walletmodel/" + walletModel.getType() + "-icon-invert.svg");
} else {
svgImage = loadSVGImage("/image/walletmodel/" + walletModel.getType() + "-icon.svg");
### src/main/java/com/sparrowwallet/sparrow/control/WalletModelImage.java
@@ -2,12 +2,9 @@
import com.sparrowwallet.drongo.wallet.WalletModel;
import com.sparrowwallet.sparrow.AppServices;
-import com.sparrowwallet.sparrow.Theme;
-import com.sparrowwallet.sparrow.io.Config;
import javafx.beans.NamedArg;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
-import javafx.scene.image.Image;
import javafx.scene.layout.StackPane;
import org.girod.javafx.svgimage.SVGImage;
import org.girod.javafx.svgimage.SVGLoader;
@@ -51,7 +48,7 @@ public void refresh() {
protected void refresh(WalletModel walletModel) {
SVGImage svgImage;
- if(Config.get().getTheme() == Theme.DARK) {
+ if(AppServices.isDarkTheme()) {
svgImage = loadSVGImage("/image/walletmodel/" + walletModel.getType() + "-invert.svg");
} else {
svgImage = loadSVGImage("/image/walletmodel/" + walletModel.getType() + ".svg");
### src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java
@@ -903,7 +903,7 @@ public static Glyph getPayNymGlyph() {
public static Node getBitcoinCharacter() {
try {
URL url;
- if(Config.get().getTheme() == Theme.DARK) {
+ if(AppServices.isDarkTheme()) {
url = AppServices.class.getResource("/image/bitcoin-character-invert.svg");
} else {
url = AppServices.class.getResource("/image/bitcoin-character.svg");
### src/main/resources/com/sparrowwallet/sparrow/about.fxml
@@ -14,7 +14,7 @@
<Label fx:id="title" text="Sparrow" styleClass="title-label" />
</HBox>
<Region HBox.hgrow="ALWAYS"/>
- <DialogImage type="SPARROW" AnchorPane.rightAnchor="0"/>
+ <DialogImage fx:id="dialogImage" type="SPARROW" AnchorPane.rightAnchor="0"/>
</HBox>
<VBox spacing="10" styleClass="content-area">
<Label text="Sparrow is a Bitcoin wallet with the goal of providing greater transparency and usability on the path to full financial self-sovereignty. It attempts to provide all of the detail about your wallet setup, transactions and UTXOs so that you can transact with a full understanding of your money." wrapText="true" />
### src/main/resources/com/sparrowwallet/sparrow/app.fxml
@@ -107,6 +107,11 @@
</Menu>
<Menu mnemonicParsing="false" text="Theme">
<items>
+ <RadioMenuItem mnemonicParsing="false" text="System" toggleGroup="$theme" onAction="#setTheme">
+ <userData>
+ <Theme fx:constant="SYSTEM" />
+ </userData>
+ </RadioMenuItem>
<RadioMenuItem mnemonicParsing="false" text="Light" toggleGroup="$theme" onAction="#setTheme">
<userData>
<Theme fx:constant="LIGHT" />Why this scored 15/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.