From 0baaa7d45f254c5822b1336e61c7d26afe6fdfc7 Mon Sep 17 00:00:00 2001 From: Lukas Wallmen <49093091+SooStrator1136@users.noreply.github.com> Date: Tue, 16 Jun 2026 17:41:18 +0200 Subject: [PATCH] Apply window scaling at runtime --- .../coley/recaf/RecafApplication.java | 12 ++-- .../java/software/coley/recaf/UIMain.java | 14 ----- .../recaf/services/window/WindowManager.java | 30 ++++++++- .../recaf/ui/config/WindowScaleConfig.java | 62 ++++++++++++++++--- .../coley/recaf/ui/pane/ScalePane.java | 55 ++++++++++++++++ 5 files changed, 143 insertions(+), 30 deletions(-) create mode 100644 recaf-ui/src/main/java/software/coley/recaf/ui/pane/ScalePane.java diff --git a/recaf-ui/src/main/java/software/coley/recaf/RecafApplication.java b/recaf-ui/src/main/java/software/coley/recaf/RecafApplication.java index c202110b1..16b7533ec 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/RecafApplication.java +++ b/recaf-ui/src/main/java/software/coley/recaf/RecafApplication.java @@ -11,7 +11,6 @@ import software.coley.recaf.services.workspace.WorkspaceManager; import software.coley.recaf.ui.RecafTheme; import software.coley.recaf.ui.config.KeybindingConfig; -import software.coley.recaf.ui.config.WindowScaleConfig; import software.coley.recaf.ui.menubar.MainMenu; import software.coley.recaf.ui.pane.LoggingPane; import software.coley.recaf.ui.docking.DockingManager; @@ -57,7 +56,6 @@ public void start(Stage stage) { wrapper.getStyleClass().addAll("padded", "bg-inset"); // Display - WindowScaleConfig scaleConfig = recaf.get(WindowScaleConfig.class); Scene scene = new RecafScene(wrapper); scene.addEventFilter(KeyEvent.KEY_PRESSED, (KeyEvent event) -> { // Global keybind handling @@ -69,18 +67,18 @@ public void start(Stage stage) { recaf.get(PathExportingManager.class).export(workspaceManager.getCurrent()); } }); - stage.setMinWidth(450 / scaleConfig.getScale()); - stage.setMinHeight(200 / scaleConfig.getScale()); - stage.setWidth(900 / scaleConfig.getScale()); - stage.setHeight(620 / scaleConfig.getScale()); + stage.setMinWidth(450); + stage.setMinHeight(200); + stage.setWidth(900); + stage.setHeight(620); stage.setScene(scene); stage.getIcons().add(Icons.getImage(Icons.LOGO)); stage.setTitle("Recaf"); stage.setOnCloseRequest(e -> ExitDebugLoggingHook.exit(0)); - stage.show(); // Register main window windowManager.register(WindowManager.WIN_MAIN, stage); + stage.show(); // Publish UI init event recaf.getContainer().getBeanContainer().getEvent().fire(new UiInitializationEvent()); diff --git a/recaf-ui/src/main/java/software/coley/recaf/UIMain.java b/recaf-ui/src/main/java/software/coley/recaf/UIMain.java index 1bcd98f3e..36cceece3 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/UIMain.java +++ b/recaf-ui/src/main/java/software/coley/recaf/UIMain.java @@ -7,7 +7,6 @@ import software.coley.recaf.launch.LaunchCommand; import software.coley.recaf.services.plugin.PluginContainer; import software.coley.recaf.services.plugin.PluginManager; -import software.coley.recaf.ui.config.WindowScaleConfig; import software.coley.recaf.util.JFXValidation; import software.coley.recaf.util.Lang; @@ -77,22 +76,9 @@ private static void initialize(@Nonnull Recaf recaf, launchBootstrap.initPlugins(); initPluginTranslations(recaf); launchBootstrap.fireInitEvent(); - initScale(recaf); // Needs to init after the init-event so config is loaded RecafApplication.launch(RecafApplication.class, launchArgs.getArgs()); } - /** - * Assigns UI scaling properties based on the window scale config. - */ - private static void initScale(@Nonnull Recaf recaf) { - WindowScaleConfig scaleConfig = recaf.get(WindowScaleConfig.class); - - double scale = scaleConfig.getScale(); - System.setProperty("sun.java2d.uiScale", String.format("%.0f%%", 100 * scale)); - System.setProperty("glass.win.uiScale", String.valueOf(scale)); - System.setProperty("glass.gtk.uiScale", String.valueOf(scale)); - } - /** * Configure the JavaFX access logging agent. * The logging is only active when the agent is passed as a launch argument to Recaf. diff --git a/recaf-ui/src/main/java/software/coley/recaf/services/window/WindowManager.java b/recaf-ui/src/main/java/software/coley/recaf/services/window/WindowManager.java index 34d6ee3de..f675309ae 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/services/window/WindowManager.java +++ b/recaf-ui/src/main/java/software/coley/recaf/services/window/WindowManager.java @@ -5,8 +5,10 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.inject.Instance; import jakarta.inject.Inject; +import javafx.beans.property.DoubleProperty; import javafx.geometry.Dimension2D; import javafx.geometry.Rectangle2D; +import javafx.scene.Scene; import javafx.stage.Screen; import javafx.stage.Stage; import javafx.stage.WindowEvent; @@ -14,6 +16,8 @@ import software.coley.collections.observable.ObservableList; import software.coley.recaf.analytics.logging.Logging; import software.coley.recaf.services.Service; +import software.coley.recaf.ui.config.WindowScaleConfig; +import software.coley.recaf.ui.pane.ScalePane; import software.coley.recaf.ui.window.IdentifiableStage; import software.coley.recaf.util.NodeEvents; @@ -48,6 +52,7 @@ public class WindowManager implements Service { // Manager instance data private final WindowStyling windowStyling; private final WindowManagerConfig config; + private final WindowScaleConfig scaleConfig; private final ObservableList activeWindows = new ObservableList<>(); private final Map windowMappings = new HashMap<>(); private final Map lastStageScreen = new IdentityHashMap<>(); @@ -55,9 +60,10 @@ public class WindowManager implements Service { @Inject public WindowManager(@Nonnull WindowStyling windowStyling, @Nonnull WindowManagerConfig config, - @Nonnull Instance stages) { + @Nonnull WindowScaleConfig scaleConfig, @Nonnull Instance stages) { this.windowStyling = windowStyling; this.config = config; + this.scaleConfig = scaleConfig; // Register identifiable stages. // These will be @Dependent scoped, so we need to be careful with their instances. @@ -99,6 +105,8 @@ public void register(@Nonnull String id, @Nonnull Stage stage) { if (windowMappings.containsKey(id)) throw new IllegalStateException("The stage ID was already registered: " + id); + applyScale(stage); + // Add custom stylesheets if any are registered. if (!windowStyling.getStylesheetUris().isEmpty()) NodeEvents.runOnceIfPresentOrOnChange(stage.sceneProperty(), @@ -166,6 +174,26 @@ public void register(@Nonnull String id, @Nonnull Stage stage) { logger.trace("Register stage: {}", id); } + /** + * Wraps the stage's scene root in a {@link ScalePane} + */ + private void applyScale(@Nonnull Stage stage) { + var scale = scaleConfig.scaleProperty(); + stage.sceneProperty().addListener((_, _, scene) -> wrapSceneRoot(scene, scale)); + wrapSceneRoot(stage.getScene(), scale); + } + + private static void wrapSceneRoot(@Nullable Scene scene, @Nonnull DoubleProperty scale) { + if (scene == null) + return; + + var root = scene.getRoot(); + if (root == null || root instanceof ScalePane) + return; + + scene.setRoot(new ScalePane(root, scale)); + } + /** * Do not use this list to iterate over if within your loop you will be closing/creating windows. * This will cause a {@link ConcurrentModificationException}. Wrap this result in a new collection diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/config/WindowScaleConfig.java b/recaf-ui/src/main/java/software/coley/recaf/ui/config/WindowScaleConfig.java index 0397099c9..3a05437d4 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/ui/config/WindowScaleConfig.java +++ b/recaf-ui/src/main/java/software/coley/recaf/ui/config/WindowScaleConfig.java @@ -1,14 +1,22 @@ package software.coley.recaf.ui.config; +import atlantafx.base.theme.Styles; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; -import javafx.scene.Node; +import javafx.beans.binding.Bindings; +import javafx.beans.property.DoubleProperty; +import javafx.beans.property.SimpleDoubleProperty; +import javafx.geometry.Pos; +import javafx.scene.control.Label; import javafx.scene.control.Slider; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; import software.coley.observables.ObservableDouble; import software.coley.recaf.config.*; import software.coley.recaf.services.config.ConfigComponentManager; -import software.coley.recaf.services.config.KeyedConfigComponentFactory; +import software.coley.recaf.util.FxThreadUtil; /** * Config for window scaling. @@ -18,7 +26,10 @@ @ApplicationScoped public class WindowScaleConfig extends BasicConfigContainer { public static final String ID = "window-scale"; + private static final double MIN = 0.5; + private static final double MAX = 2.0; private final ObservableDouble scale = new ObservableDouble(1); + private final DoubleProperty scaleProperty = new SimpleDoubleProperty(1); private Slider slider; @Inject @@ -26,22 +37,57 @@ public WindowScaleConfig(@Nonnull ConfigComponentManager componentManager) { super(ConfigGroups.SERVICE_UI, ID + CONFIG_SUFFIX); addValue(new BasicConfigValue<>("scale", double.class, scale)); + scaleProperty.set(getScale()); + scale.addChangeListener((_, _, cur) -> FxThreadUtil.run(() -> scaleProperty.set(Math.clamp(cur, MIN, MAX)))); + componentManager.register(this, "scale", false, (container, value) -> { - slider = new Slider(0.5, 2.0, getScale()); + slider = new Slider(MIN, MAX, getScale()); slider.setSnapToTicks(true); slider.setShowTickLabels(true); - slider.setBlockIncrement(0.5); + slider.setBlockIncrement(0.25); slider.setMajorTickUnit(0.5); - slider.setMinorTickCount(5); - slider.valueProperty().addListener((ob, old, cur) -> scale.setValue(cur.doubleValue())); - return slider; + slider.setMinorTickCount(1); + + //Live readout of the current setting + var readout = new Label(); + readout.textProperty().bind(Bindings.createStringBinding(() -> Math.round(slider.getValue() * 100) + "%", slider.valueProperty())); + readout.getStyleClass().add(Styles.TEXT_SUBTLE); + readout.setMinWidth(Region.USE_PREF_SIZE); + readout.setPrefWidth(45); + readout.setAlignment(Pos.CENTER_RIGHT); + + //Commit only when not dragging + slider.valueProperty().addListener((_, _, cur) -> { + if (!slider.isValueChanging()) + commitScale(cur.doubleValue()); + }); + slider.valueChangingProperty().addListener((_, _, isChanging) -> { + if (!isChanging) + commitScale(slider.getValue()); + }); + + var box = new HBox(10, slider, readout); + box.setAlignment(Pos.CENTER_LEFT); + HBox.setHgrow(slider, Priority.ALWAYS); + return box; }); } + private void commitScale(double value) { + var clamped = Math.clamp(value, MIN, MAX); + if (Double.compare(clamped, scale.getValue()) != 0) + scale.setValue(clamped); + } + + @Nonnull + public DoubleProperty scaleProperty() { + return scaleProperty; + } + /** * @return Window scale. */ public double getScale() { - return Math.clamp(scale.getValue(), 0.5, 2); + return Math.clamp(scale.getValue(), MIN, MAX); } } diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/pane/ScalePane.java b/recaf-ui/src/main/java/software/coley/recaf/ui/pane/ScalePane.java new file mode 100644 index 000000000..3fec91816 --- /dev/null +++ b/recaf-ui/src/main/java/software/coley/recaf/ui/pane/ScalePane.java @@ -0,0 +1,55 @@ +package software.coley.recaf.ui.pane; + +import jakarta.annotation.Nonnull; +import javafx.beans.property.DoubleProperty; +import javafx.scene.Node; +import javafx.scene.layout.Pane; +import javafx.scene.transform.Scale; + +/** + * Wraps a child node and scales it by {@code scale} while keeping it sized to fill this pane. + */ +public class ScalePane extends Pane { + private final DoubleProperty scale; + private final Node content; + + /** + * @param content + * Original scene root to wrap (must have no parent!). + * @param scale + * Factor to scale by. + */ + public ScalePane(@Nonnull Node content, @Nonnull DoubleProperty scale) { + this.scale = scale; + this.content = content; + + //Pivot so the child grows down/right to fill the window + var transform = new Scale(); + transform.xProperty().bind(scale); + transform.yProperty().bind(scale); + content.getTransforms().add(transform); + + //Reparent the orphaned root into this pane + getChildren().add(content); + + scale.addListener(o -> requestLayout()); + } + + /** + * @return The wrapped original root. + */ + @Nonnull + public Node getContent() { + return content; + } + + @Override + protected void layoutChildren() { + var scale = this.scale.get(); + if (scale <= 0) + scale = 1; + + content.resize(getWidth() / scale, getHeight() / scale); + content.relocate(0, 0); + } +}