From 97334996ff04c795730f01cce1d9c8a514198804 Mon Sep 17 00:00:00 2001 From: Hornfisk <30924992+Hornfisk@users.noreply.github.com> Date: Wed, 27 May 2026 16:17:15 +0200 Subject: [PATCH] fix(ui): make UI scale work in DAWs (live zoom + window resize) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UI-scale badge only wrote `ui_scale.txt`, which is read solely by the standalone launcher (`niner-launch.sh --dpi-scale`). In a DAW nothing read it and `Editor::set_scale_factor` was never driven from it, so clicking the badge was a no-op in every host (not distro/WM-specific). Resizing the window also did nothing — the editor never participated in host resize. Drive both egui's content zoom and a matching programmatic window resize from a single scale value, every frame: ctx.set_zoom_factor(scale); if editor_state.size() != BASE * scale { editor_state.set_requested_size(..) } With baseview's native scale pinned to 1.0 (DAW hosts default to it; the standalone launcher now passes `--dpi-scale 1.0`), effective ppp == scale: the host window grows to BASE × scale physical pixels while the layout keeps drawing into a fixed 680×444 logical space. Uniform scale, no reflow — knob rows never move relative to each other. - src/ui/editor.rs: per-frame zoom + resize reconcile; honest badge tooltip/log. - src/params.rs: open pre-scaled (new BASE_WINDOW_SIZE const) to avoid a first-frame resize flash. - tools/niner-launch.sh: pin --dpi-scale 1.0; plugin owns scaling now. - Cargo.toml: repoint nih_plug/nih_plug_egui to Hornfisk/nih-plug@8b51e93, which exposes EguiState::set_requested_size (private upstream). Co-Authored-By: Claude Opus 4.7 --- Cargo.lock | 6 +++--- Cargo.toml | 10 +++++++--- src/params.rs | 23 ++++++++++++++++------- src/ui/editor.rs | 41 ++++++++++++++++++++++++++++------------- tools/niner-launch.sh | 18 ++++++++---------- 5 files changed, 62 insertions(+), 36 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 19a6d30..686cc29 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2033,7 +2033,7 @@ dependencies = [ [[package]] name = "nih_plug" version = "0.0.0" -source = "git+https://github.com/robbert-vdh/nih-plug?rev=28b149ec4d62757d0b448809148a0c3ca6e09a95#28b149ec4d62757d0b448809148a0c3ca6e09a95" +source = "git+https://github.com/Hornfisk/nih-plug?rev=8b51e93a707cb6226721b56825047322e8a8e623#8b51e93a707cb6226721b56825047322e8a8e623" dependencies = [ "anyhow", "anymap3", @@ -2070,7 +2070,7 @@ dependencies = [ [[package]] name = "nih_plug_derive" version = "0.1.0" -source = "git+https://github.com/robbert-vdh/nih-plug?rev=28b149ec4d62757d0b448809148a0c3ca6e09a95#28b149ec4d62757d0b448809148a0c3ca6e09a95" +source = "git+https://github.com/Hornfisk/nih-plug?rev=8b51e93a707cb6226721b56825047322e8a8e623#8b51e93a707cb6226721b56825047322e8a8e623" dependencies = [ "proc-macro2", "quote", @@ -2080,7 +2080,7 @@ dependencies = [ [[package]] name = "nih_plug_egui" version = "0.0.0" -source = "git+https://github.com/robbert-vdh/nih-plug?rev=28b149ec4d62757d0b448809148a0c3ca6e09a95#28b149ec4d62757d0b448809148a0c3ca6e09a95" +source = "git+https://github.com/Hornfisk/nih-plug?rev=8b51e93a707cb6226721b56825047322e8a8e623#8b51e93a707cb6226721b56825047322e8a8e623" dependencies = [ "baseview", "crossbeam", diff --git a/Cargo.toml b/Cargo.toml index 17e33b8..98eacc9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,9 +36,13 @@ name = "niner-standalone" path = "src/main.rs" [dependencies] -# Plugin framework — pinned to a specific commit for reproducible builds -nih_plug = { git = "https://github.com/robbert-vdh/nih-plug", rev = "28b149ec4d62757d0b448809148a0c3ca6e09a95", features = ["assert_process_allocs", "standalone"] } -nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug", rev = "28b149ec4d62757d0b448809148a0c3ca6e09a95" } +# Plugin framework — pinned to a specific commit for reproducible builds. +# Hornfisk/nih-plug tracks upstream robbert-vdh@28b149e plus a single-line +# change exposing `EguiState::set_requested_size` (private upstream) so the +# UI-scale control can drive a programmatic window resize in-DAW. See +# https://github.com/Hornfisk/nih-plug/tree/niner/pub-set-requested-size +nih_plug = { git = "https://github.com/Hornfisk/nih-plug", rev = "8b51e93a707cb6226721b56825047322e8a8e623", features = ["assert_process_allocs", "standalone"] } +nih_plug_egui = { git = "https://github.com/Hornfisk/nih-plug", rev = "8b51e93a707cb6226721b56825047322e8a8e623" } # Real-time safety rtrb = "0.3" diff --git a/src/params.rs b/src/params.rs index 55098b0..2cd79b9 100644 --- a/src/params.rs +++ b/src/params.rs @@ -370,18 +370,27 @@ pub fn collect_kick_params(p: &NinerParams) -> KickParams { } } +/// Base editor size in logical pixels at 1x. The UI-scale control multiplies +/// this (see `ui::editor`); the layout always draws into this fixed space and +/// egui's zoom factor renders it larger, so nothing reflows. +pub const BASE_WINDOW_SIZE: (u32, u32) = (680, 444); + impl Default for NinerParams { fn default() -> Self { - // EguiState size is the LOGICAL window size. Actual on-screen scaling - // is applied by baseview's WindowScalePolicy — for standalone the - // launcher passes `--dpi-scale N` (read from `ui_scale.txt`); DAW - // hosts call `Editor::set_scale_factor`. Multiplying the logical - // size by the scale here would double-scale (window grows, content - // doesn't). Mirrors the squelchbox approach. + // The window opens at the saved UI scale. Baseview's native scale is + // pinned to 1.0 (DAW hosts default to it; the standalone launcher + // passes `--dpi-scale 1.0`), so the editor drives all scaling itself + // via egui's zoom factor plus a matching window resize (see + // `ui::editor`). Opening pre-scaled here avoids a first-frame resize + // flash; the editor keeps the window in sync when the badge changes. let ui_scale = crate::util::paths::load_ui_scale(); + let (bw, bh) = BASE_WINDOW_SIZE; Self { - editor_state: EguiState::from_size(680, 444), + editor_state: EguiState::from_size( + (bw as f32 * ui_scale).round() as u32, + (bh as f32 * ui_scale).round() as u32, + ), seq_steps: Arc::new(Mutex::new(DEFAULT_STEP_BITS)), seq_accents: Arc::new(Mutex::new(DEFAULT_ACCENT_BITS)), diff --git a/src/ui/editor.rs b/src/ui/editor.rs index bf225bc..86999f8 100644 --- a/src/ui/editor.rs +++ b/src/ui/editor.rs @@ -282,12 +282,32 @@ pub fn create( crate::ui::layout_overrides::init(ctx); }, move |ctx, setter, _state| { - // Scaling is handled outside this callback: baseview applies the - // window scale factor (standalone via `--dpi-scale`, DAW via - // `Editor::set_scale_factor`), and egui's `pixels_per_point` - // follows. We do NOT call `ctx.set_pixels_per_point()` here — - // that fights baseview and double-scales the layout. - let _ = &editor_state_clone; + // UI scale (1.0 / 1.5 / 2.0). We drive both egui's content zoom + // and a matching programmatic window resize from a single value, + // so the scale badge works identically in a DAW and standalone. + // + // Baseview's native scale is pinned to 1.0 (DAW hosts default to + // it; the standalone launcher passes `--dpi-scale 1.0`), so the + // effective pixels-per-point is exactly `scale`: the host window + // grows to BASE × scale physical pixels while the layout keeps + // drawing into a fixed BASE logical space. Uniform scale, no + // reflow — knob rows never move relative to each other. + // + // `set_zoom_factor` is cheap and idempotent per frame; the resize + // request only fires when the stored size actually differs, so a + // settled scale costs one comparison. + { + let scale = (*params.ui_scale.lock()).clamp(1.0, 2.0); + ctx.set_zoom_factor(scale); + let (bw, bh) = crate::params::BASE_WINDOW_SIZE; + let want = ( + (bw as f32 * scale).round() as u32, + (bh as f32 * scale).round() as u32, + ); + if editor_state_clone.size() != want { + editor_state_clone.set_requested_size(want); + } + } // Invalidate texture caches when the egui::Context changes. The // `Arc>>`s captured above outlive any @@ -1059,10 +1079,7 @@ pub fn create( let ui_resp = ui .interact(hit, egui::Id::new("ui_scale_btn"), egui::Sense::click()) .on_hover_cursor(egui::CursorIcon::PointingHand) - .on_hover_text( - "UI scale — click to cycle (1x / 1.5x / 2x).\n\ - Reopen the plugin to apply.", - ); + .on_hover_text("UI scale — click to cycle (1x / 1.5x / 2x)."); let color = if ui_resp.hovered() { theme::WHITE } else { @@ -1084,9 +1101,7 @@ pub fn create( }; *lock = next; crate::util::paths::save_ui_scale(next); - tracing::info!( - "[ui_scale] cycled → {next}x (saved; reopen plugin to apply)" - ); + tracing::info!("[ui_scale] cycled → {next}x (applied live)"); } } } diff --git a/tools/niner-launch.sh b/tools/niner-launch.sh index 9f04fe0..b9297ab 100755 --- a/tools/niner-launch.sh +++ b/tools/niner-launch.sh @@ -2,9 +2,10 @@ # niner-launch — desktop-launcher shim for the standalone. # # What it does: -# 1. Reads the persisted UI scale from `$XDG_DATA_HOME/niner/ui_scale.txt` -# (the in-GUI scale badge writes it; the standalone only honours the -# `--dpi-scale` CLI flag) and forwards it. +# 1. Pins baseview's native scale to 1.0 (`--dpi-scale 1.0`). The plugin +# drives UI scaling itself (egui zoom factor + a matching window resize), +# reading the saved factor from `$XDG_DATA_HOME/niner/ui_scale.txt` at +# startup. The launcher must NOT also scale or the two would compound. # 2. Picks an audio backend, sets up MIDI auto-routing, and execs # niner-standalone. Default is `--backend alsa` because: # * PipeWire's ALSA bridge auto-routes niner's audio to the default @@ -45,9 +46,6 @@ set -u BIN_DIR="$(cd "$(dirname "$0")" && pwd)" -SCALE_FILE="${XDG_DATA_HOME:-$HOME/.local/share}/niner/ui_scale.txt" -SCALE=$(tr -d ' \n' < "$SCALE_FILE" 2>/dev/null || true) -SCALE="${SCALE:-1.0}" STANDALONE="$BIN_DIR/niner-standalone" WRAPPER="$BIN_DIR/nih-standalone-wrapper" @@ -106,9 +104,9 @@ if [ "${NINER_FORCE_BACKEND:-alsa}" != "jack" ] && command -v aconnect >/dev/nul PERIOD="${NINER_PERIOD_SIZE:-${PW_QUANTUM:-512}}" ALSA_ARGS=(--backend alsa --midi-input "$MIDI_INPUT" --sample-rate "$PW_RATE" --period-size "$PERIOD") if [ -x "$WRAPPER" ]; then - exec "$WRAPPER" "$STANDALONE" --dpi-scale "$SCALE" "${ALSA_ARGS[@]}" "$@" + exec "$WRAPPER" "$STANDALONE" --dpi-scale 1.0 "${ALSA_ARGS[@]}" "$@" fi - exec "$STANDALONE" --dpi-scale "$SCALE" "${ALSA_ARGS[@]}" "$@" + exec "$STANDALONE" --dpi-scale 1.0 "${ALSA_ARGS[@]}" "$@" fi # No BeatStep found — fall through to JACK below (works without MIDI). fi @@ -139,6 +137,6 @@ if command -v pw-link >/dev/null 2>&1; then fi if [ -x "$WRAPPER" ]; then - exec "$WRAPPER" "$STANDALONE" --dpi-scale "$SCALE" --backend jack "$@" + exec "$WRAPPER" "$STANDALONE" --dpi-scale 1.0 --backend jack "$@" fi -exec "$STANDALONE" --dpi-scale "$SCALE" --backend jack "$@" +exec "$STANDALONE" --dpi-scale 1.0 --backend jack "$@"