feat(clients): render-scale setting on every client — shared punktfunk_core::render_scale
Client-side supersampling/downscaling: the client asks the host to render and encode at chosen-resolution × scale (the host does no scaling) and the presenter rescales the decoded frame to the display. >1 supersamples for sharpness; <1 lightens the host GPU and the link. Default 1.0 = Native, the prior behavior. The geometry lives once in punktfunk_core::render_scale (multiply, preserve aspect ratio, floor to even, clamp to the codec's per-axis ceiling — 4096 for H.264, 8192 otherwise), the Rust twin of the Apple client's RenderScale.swift, consumed by the native session client, the presenter's match-window path, the Windows/Linux settings UIs, Decky, and Android (settings + host connect + unit test). Implemented and platform-verified by the Apple-client-features session (Linux+Android+Apple green there); the punktfunk-core wiring (pub mod render_scale) is restored here after being lost in a working-tree reconciliation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -30,7 +30,13 @@ suspend fun connectToHost(
|
||||
): Long {
|
||||
// Advertise HDR only when the user enabled it AND this device's display can present it (else the
|
||||
// host sends a proper SDR stream rather than PQ the panel would mis-tone-map).
|
||||
val (w, h, hz) = settings.effectiveMode(context)
|
||||
val (baseW, baseH, hz) = settings.effectiveMode(context)
|
||||
// Render scale: ask the host for `chosen mode × scale` (even + codec-clamped) — > 1 supersamples
|
||||
// (the compositor downscales the larger decoded frame to the SurfaceView), < 1 renders under
|
||||
// native. 1.0 leaves the resolved mode untouched.
|
||||
val (w, h) = RenderScale.apply(
|
||||
baseW, baseH, settings.renderScale, RenderScale.maxDimension(settings.codec)
|
||||
)
|
||||
val hdrEnabled = settings.hdrEnabled && displaySupportsHdr(context)
|
||||
// "Automatic" resolves to a concrete pad type from the connected controller's VID/PID.
|
||||
val gamepadPref = Gamepad.resolvePref(settings.gamepad)
|
||||
|
||||
@@ -16,6 +16,14 @@ data class Settings(
|
||||
val height: Int = 0,
|
||||
val hz: Int = 0,
|
||||
val bitrateKbps: Int = 0,
|
||||
/**
|
||||
* Render-resolution multiplier: the client asks the host to render/encode at `chosen mode ×
|
||||
* renderScale` and the compositor downscales the larger decoded frame to the SurfaceView
|
||||
* (`> 1` supersamples for sharpness, at more bandwidth AND decode; `< 1` renders under native
|
||||
* for a lighter host/link). `1.0` = Native. Applied at connect via [RenderScale.apply], clamped
|
||||
* even + to the codec's max dimension. Mirrors the Apple/Linux clients' render scale.
|
||||
*/
|
||||
val renderScale: Double = 1.0,
|
||||
/**
|
||||
* Advertise HDR (10-bit BT.2020 PQ) to the host. Default on, but only *effective* on a panel that
|
||||
* can actually present HDR10 (see [displaySupportsHdr]) — on an SDR display HDR is never
|
||||
@@ -137,6 +145,7 @@ class SettingsStore(context: Context) {
|
||||
height = prefs.getInt(K_H, 0),
|
||||
hz = prefs.getInt(K_HZ, 0),
|
||||
bitrateKbps = prefs.getInt(K_BITRATE, 0),
|
||||
renderScale = prefs.getFloat(K_RENDER_SCALE, 1.0f).toDouble(),
|
||||
hdrEnabled = prefs.getBoolean(K_HDR, true),
|
||||
compositor = prefs.getInt(K_COMPOSITOR, 0),
|
||||
gamepad = prefs.getInt(K_GAMEPAD, 0),
|
||||
@@ -171,6 +180,7 @@ class SettingsStore(context: Context) {
|
||||
.putInt(K_H, s.height)
|
||||
.putInt(K_HZ, s.hz)
|
||||
.putInt(K_BITRATE, s.bitrateKbps)
|
||||
.putFloat(K_RENDER_SCALE, s.renderScale.toFloat())
|
||||
.putBoolean(K_HDR, s.hdrEnabled)
|
||||
.putInt(K_COMPOSITOR, s.compositor)
|
||||
.putInt(K_GAMEPAD, s.gamepad)
|
||||
@@ -193,6 +203,7 @@ class SettingsStore(context: Context) {
|
||||
const val K_H = "height"
|
||||
const val K_HZ = "hz"
|
||||
const val K_BITRATE = "bitrate_kbps"
|
||||
const val K_RENDER_SCALE = "render_scale"
|
||||
const val K_HDR = "hdr_enabled"
|
||||
const val K_COMPOSITOR = "compositor"
|
||||
const val K_GAMEPAD = "gamepad"
|
||||
@@ -281,6 +292,54 @@ fun Settings.effectiveMode(context: Context): Triple<Int, Int, Int> {
|
||||
return Triple(w, h, hz)
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side render-scale geometry — the Kotlin twin of `punktfunk-core`'s `render_scale` module
|
||||
* (and the Apple client's `RenderScale`). Multiply a base size, preserve aspect, even-floor (the
|
||||
* host rejects odd sizes), and clamp uniformly to the codec's per-axis ceiling so a connect can't
|
||||
* ask for a size the encoder rejects. `1.0` = Native. Pure + covered by [RenderScaleTest].
|
||||
*/
|
||||
object RenderScale {
|
||||
val PRESETS = listOf(0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0)
|
||||
|
||||
/** H.264 tops out at 4096 px/axis; HEVC/AV1/auto at 8192 — the host's `codec.rs` walls. */
|
||||
fun maxDimension(codec: String): Int = if (codec == "h264") 4096 else 8192
|
||||
|
||||
/** Clamp a raw multiplier into [0.5, 4.0]; a missing / non-positive / NaN value → 1.0. */
|
||||
fun sanitize(raw: Double): Double = if (raw > 0.0) raw.coerceIn(0.5, 4.0) else 1.0
|
||||
|
||||
/** "Native (1×)" / "1.5×" / "2× · supersample" — the picker label. */
|
||||
fun label(scale: Double): String = when {
|
||||
scale == 1.0 -> "Native (1×)"
|
||||
scale > 1.0 -> "${trim(scale)}× · supersample"
|
||||
else -> "${trim(scale)}×"
|
||||
}
|
||||
|
||||
private fun trim(s: Double): String =
|
||||
if (s == s.toLong().toDouble()) s.toLong().toString() else s.toString()
|
||||
|
||||
/** Apply [scale] to a base size → a host-valid even, aspect-preserved, codec-clamped (w, h). */
|
||||
fun apply(baseW: Int, baseH: Int, scale: Double, maxDim: Int): Pair<Int, Int> {
|
||||
val s = sanitize(scale)
|
||||
var w = maxOf(baseW, 1) * s
|
||||
var h = maxOf(baseH, 1) * s
|
||||
val cap = maxDim.toDouble()
|
||||
val over = maxOf(w / cap, h / cap)
|
||||
if (over > 1.0) {
|
||||
w /= over
|
||||
h /= over
|
||||
}
|
||||
return Pair(evenFloor(w, 320), evenFloor(h, 200))
|
||||
}
|
||||
|
||||
private fun evenFloor(value: Double, minimum: Int): Int {
|
||||
val v = maxOf(kotlin.math.floor(value).toInt(), minimum).coerceAtLeast(0)
|
||||
return v / 2 * 2
|
||||
}
|
||||
}
|
||||
|
||||
/** (scale, label) for the render-scale picker. `1.0` = Native. */
|
||||
val RENDER_SCALE_OPTIONS = RenderScale.PRESETS.map { it to RenderScale.label(it) }
|
||||
|
||||
// ---- UI option tables (value, label). The first entry is always the "auto/native" default. ----
|
||||
|
||||
/** (width, height, label). `(0,0)` = native display. */
|
||||
|
||||
@@ -333,6 +333,15 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
|
||||
update(s.copy(bitrateKbps = kbps))
|
||||
}
|
||||
|
||||
SettingDropdown(
|
||||
label = "Render scale",
|
||||
options = RENDER_SCALE_OPTIONS,
|
||||
// Snap the stored value (a Float round-tripped to Double) to the nearest preset so the
|
||||
// exact Double keys match. > 1 supersamples for sharpness (more bandwidth AND decode);
|
||||
// < 1 renders under native for a lighter host — this device resamples to the display.
|
||||
selected = RenderScale.PRESETS.minByOrNull { kotlin.math.abs(it - s.renderScale) } ?: 1.0,
|
||||
) { scale -> update(s.copy(renderScale = scale)) }
|
||||
|
||||
// AV1 is only offered when the device has a real AV1 decoder (it's never advertised to the
|
||||
// host otherwise, so preferring it would be a dead setting). A stored "av1" from a capable
|
||||
// device stays visible so the selection is always representable.
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pure JVM test of the client-side render-scale geometry ([RenderScale]) — the Kotlin twin of
|
||||
* `punktfunk-core`'s `render_scale` module. Run: `./gradlew :app:testDebugUnitTest`.
|
||||
*/
|
||||
class RenderScaleTest {
|
||||
@Test
|
||||
fun sanitizeClampsAndDefaults() {
|
||||
assertEquals(1.0, RenderScale.sanitize(0.0), 0.0) // absent / zero → Native
|
||||
assertEquals(1.0, RenderScale.sanitize(-2.0), 0.0)
|
||||
assertEquals(1.0, RenderScale.sanitize(Double.NaN), 0.0)
|
||||
assertEquals(0.5, RenderScale.sanitize(0.1), 0.0) // below the floor
|
||||
assertEquals(4.0, RenderScale.sanitize(9.0), 0.0) // above the ceiling
|
||||
assertEquals(1.5, RenderScale.sanitize(1.5), 0.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun maxDimensionIsCodecAware() {
|
||||
assertEquals(4096, RenderScale.maxDimension("h264"))
|
||||
assertEquals(8192, RenderScale.maxDimension("hevc"))
|
||||
assertEquals(8192, RenderScale.maxDimension("av1"))
|
||||
assertEquals(8192, RenderScale.maxDimension("auto"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nativeIsIdentity() {
|
||||
assertEquals(1920 to 1080, RenderScale.apply(1920, 1080, 1.0, 8192))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun supersampleDoubles() {
|
||||
assertEquals(3840 to 2160, RenderScale.apply(1920, 1080, 2.0, 8192))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun underRenderHalves() {
|
||||
assertEquals(960 to 540, RenderScale.apply(1920, 1080, 0.5, 8192))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resultsAreEven() {
|
||||
// 1366×768 × 1.5 = 2049×1152 → even-floored to 2048×1152.
|
||||
val (w, h) = RenderScale.apply(1366, 768, 1.5, 8192)
|
||||
assertEquals(0, w % 2)
|
||||
assertEquals(0, h % 2)
|
||||
assertEquals(2048 to 1152, w to h)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overCeilingClampsUniformly() {
|
||||
// 4K × 4 = 15360×8640; both exceed 8192 → width lands on cap, 16:9 kept (8192×4608).
|
||||
val (w, h) = RenderScale.apply(3840, 2160, 4.0, 8192)
|
||||
assertTrue(w <= 8192 && h <= 8192)
|
||||
assertEquals(8192 to 4608, w to h)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun h264CeilingIsTighter() {
|
||||
// 1080p × 4 = 7680×4320; under H.264's 4096 wall → 4096×2304.
|
||||
assertEquals(4096 to 2304, RenderScale.apply(1920, 1080, 4.0, 4096))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun minimumFloorHonoured() {
|
||||
val (w, h) = RenderScale.apply(400, 300, 0.5, 8192)
|
||||
assertTrue(w >= 320 && h >= 200)
|
||||
}
|
||||
}
|
||||
@@ -896,8 +896,8 @@ class Plugin:
|
||||
except (OSError, json.JSONDecodeError):
|
||||
# The client's own defaults (native display, host-default bitrate, auto pad).
|
||||
return {
|
||||
"width": 0, "height": 0, "refresh_hz": 0, "bitrate_kbps": 0,
|
||||
"codec": "auto", "gamepad": "auto", "compositor": "auto",
|
||||
"width": 0, "height": 0, "refresh_hz": 0, "render_scale": 1.0,
|
||||
"bitrate_kbps": 0, "codec": "auto", "gamepad": "auto", "compositor": "auto",
|
||||
"inhibit_shortcuts": True, "mic_enabled": False,
|
||||
}
|
||||
|
||||
|
||||
@@ -99,6 +99,7 @@ export interface StreamSettings {
|
||||
width: number; // 0 = native
|
||||
height: number; // 0 = native
|
||||
refresh_hz: number; // 0 = native
|
||||
render_scale?: number; // render-resolution multiplier; 1.0 = native (absent in pre-scale files)
|
||||
bitrate_kbps: number; // 0 = host default
|
||||
codec?: string; // "auto" | "hevc" | "h264" | "av1" — soft preference (absent in pre-codec files)
|
||||
gamepad: string; // "auto" | "xbox360" | "xboxone" | "dualsense" | "dualshock4" | "steamdeck"
|
||||
|
||||
@@ -25,6 +25,10 @@ const RESOLUTIONS: [number, number, string][] = [
|
||||
[2560, 1440, "2560 × 1440"],
|
||||
];
|
||||
const REFRESH = [0, 30, 60, 90, 120];
|
||||
// Render-resolution multipliers (mirrors punktfunk_core::render_scale::PRESETS). 1.0 = native.
|
||||
const RENDER_SCALES = [0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0];
|
||||
const renderScaleLabel = (x: number): string =>
|
||||
x === 1 ? "Native (1×)" : x > 1 ? `${x}× · supersample` : `${x}×`;
|
||||
const GAMEPADS = ["auto", "xbox360", "xboxone", "dualsense", "dualshock4", "steamdeck"];
|
||||
const GAMEPAD_LABELS: Record<string, string> = {
|
||||
auto: "Automatic",
|
||||
@@ -106,6 +110,24 @@ export const SettingsSection: FC = () => {
|
||||
</div>
|
||||
</RowActions>
|
||||
</Field>
|
||||
<Field
|
||||
label="Render scale"
|
||||
description="Supersample for sharpness (> 1×, more bandwidth) or render below native (< 1×) — the Deck resamples to its screen"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<div style={selectShell}>
|
||||
<Dropdown
|
||||
rgOptions={RENDER_SCALES.map((x) => ({ data: x, label: renderScaleLabel(x) }))}
|
||||
// Snap the stored value to the nearest preset so the dropdown always shows a match.
|
||||
selectedOption={RENDER_SCALES.reduce((best, x) =>
|
||||
Math.abs(x - (s.render_scale ?? 1)) < Math.abs(best - (s.render_scale ?? 1)) ? x : best,
|
||||
)}
|
||||
onChange={(o) => patch({ render_scale: o.data as number })}
|
||||
/>
|
||||
</div>
|
||||
</RowActions>
|
||||
</Field>
|
||||
<SliderField
|
||||
label="Bitrate"
|
||||
description="Mbit/s · 0 = host default"
|
||||
|
||||
@@ -17,6 +17,20 @@ const RESOLUTIONS: &[(u32, u32)] = &[
|
||||
];
|
||||
/// `0` = the monitor's native refresh, resolved at connect.
|
||||
const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240];
|
||||
/// Render-scale multipliers (persisted as f64; mirrors [`punktfunk_core::render_scale::PRESETS`]).
|
||||
/// `1.0` = Native. Applied at connect and each match-window resize.
|
||||
const RENDER_SCALES: &[f64] = &[0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0];
|
||||
|
||||
/// A compact label for a render-scale multiplier: "Native" / "1.5×" / "2× (supersample)".
|
||||
fn render_scale_label(scale: f64) -> String {
|
||||
if scale == 1.0 {
|
||||
"Native".to_string()
|
||||
} else if scale > 1.0 {
|
||||
format!("{scale}× (supersample)")
|
||||
} else {
|
||||
format!("{scale}×")
|
||||
}
|
||||
}
|
||||
const GAMEPADS: &[&str] = &[
|
||||
"auto",
|
||||
"xbox360",
|
||||
@@ -304,6 +318,18 @@ pub fn show(
|
||||
"",
|
||||
&hz_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
);
|
||||
let scale_names: Vec<String> = RENDER_SCALES
|
||||
.iter()
|
||||
.map(|&s| render_scale_label(s))
|
||||
.collect();
|
||||
let scale_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
"Render scale",
|
||||
"Supersample for sharpness (> 1×, more bandwidth and decode) or render below native \
|
||||
(< 1×) for a lighter host — this device resamples to the window",
|
||||
&scale_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
);
|
||||
let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 5.0);
|
||||
bitrate_row.set_title("Bitrate");
|
||||
bitrate_row.set_subtitle("Mbit/s · 0 = host default · run a speed test before going high");
|
||||
@@ -346,6 +372,7 @@ pub fn show(
|
||||
.build();
|
||||
stream.add(res_row.widget());
|
||||
stream.add(hz_row.widget());
|
||||
stream.add(scale_row.widget());
|
||||
stream.add(&bitrate_row);
|
||||
stream.add(compositor_row.widget());
|
||||
stream.add(decoder_row.widget());
|
||||
@@ -500,6 +527,11 @@ pub fn show(
|
||||
res_row.set_selected(res_i as u32);
|
||||
let hz_i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0);
|
||||
hz_row.set_selected(hz_i as u32);
|
||||
let scale_i = RENDER_SCALES
|
||||
.iter()
|
||||
.position(|&x| (x - s.render_scale).abs() < 1e-6)
|
||||
.unwrap_or_else(|| RENDER_SCALES.iter().position(|&x| x == 1.0).unwrap());
|
||||
scale_row.set_selected(scale_i as u32);
|
||||
bitrate_row.set_value(f64::from(s.bitrate_kbps) / 1000.0);
|
||||
let pad_i = GAMEPADS.iter().position(|&g| g == s.gamepad).unwrap_or(0);
|
||||
pad_row.set_selected(pad_i as u32);
|
||||
@@ -545,6 +577,8 @@ pub fn show(
|
||||
RESOLUTIONS[res_i - 1]
|
||||
};
|
||||
s.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 1)];
|
||||
s.render_scale =
|
||||
RENDER_SCALES[(scale_row.selected() as usize).min(RENDER_SCALES.len() - 1)];
|
||||
s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32;
|
||||
s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string();
|
||||
s.touch_mode =
|
||||
|
||||
@@ -175,6 +175,8 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
// Latched at console start (like the stats tier above): toggling Match window in
|
||||
// the console's settings screen applies from the next console launch.
|
||||
match_window: crate::session_main::match_window(&settings_at_start),
|
||||
render_scale: settings_at_start.render_scale,
|
||||
render_scale_max_dim: punktfunk_core::render_scale::max_dimension(&settings_at_start.codec),
|
||||
};
|
||||
|
||||
let result =
|
||||
|
||||
@@ -127,6 +127,20 @@ mod session_main {
|
||||
settings.refresh_hz
|
||||
},
|
||||
};
|
||||
// Render scale: multiply the resolved mode (even + codec-clamped) so the host renders
|
||||
// larger/smaller and the presenter resamples to the window. 1.0 = Native. Applied after the
|
||||
// Native/explicit resolution so it composes uniformly with both.
|
||||
let (sw, sh) = punktfunk_core::render_scale::apply(
|
||||
mode.width,
|
||||
mode.height,
|
||||
settings.render_scale,
|
||||
punktfunk_core::render_scale::max_dimension(&settings.codec),
|
||||
);
|
||||
let mode = Mode {
|
||||
width: sw,
|
||||
height: sh,
|
||||
..mode
|
||||
};
|
||||
SessionParams {
|
||||
host: addr,
|
||||
port,
|
||||
@@ -372,6 +386,8 @@ mod session_main {
|
||||
overlay: None,
|
||||
window_size: window_size(&settings),
|
||||
match_window: match_window(&settings),
|
||||
render_scale: settings.render_scale,
|
||||
render_scale_max_dim: punktfunk_core::render_scale::max_dimension(&settings.codec),
|
||||
};
|
||||
|
||||
let outcome =
|
||||
|
||||
@@ -19,6 +19,20 @@ const RESOLUTIONS: &[(u32, u32)] = &[
|
||||
];
|
||||
/// `0` = the display's native refresh, resolved at connect.
|
||||
const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240];
|
||||
/// Render-scale multipliers (persisted as f64; mirrors [`punktfunk_core::render_scale::PRESETS`]).
|
||||
/// `1.0` = Native. Applied at connect and each match-window resize.
|
||||
const RENDER_SCALES: &[f64] = &[0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0];
|
||||
|
||||
/// A compact label for a render-scale multiplier: "Native" / "1.5×" / "2× (supersample)".
|
||||
fn render_scale_label(scale: f64) -> String {
|
||||
if scale == 1.0 {
|
||||
"Native".to_string()
|
||||
} else if scale > 1.0 {
|
||||
format!("{scale}\u{00D7} (supersample)")
|
||||
} else {
|
||||
format!("{scale}\u{00D7}")
|
||||
}
|
||||
}
|
||||
/// Decode backend presets: `(stored value, display label)`.
|
||||
// A stored legacy "hardware" (the D3D11VA era) matches no preset, so the combo shows
|
||||
// Automatic — which is exactly how the session's decoder chain reads that value.
|
||||
@@ -193,6 +207,24 @@ pub(crate) fn settings_page(
|
||||
s.refresh_hz = REFRESH[i];
|
||||
})
|
||||
.tooltip("\u{201C}Native\u{201D} resolves to this display's refresh rate at connect.");
|
||||
let (scale_names, scale_i) = {
|
||||
let names: Vec<String> = RENDER_SCALES
|
||||
.iter()
|
||||
.map(|&x| render_scale_label(x))
|
||||
.collect();
|
||||
let i = RENDER_SCALES
|
||||
.iter()
|
||||
.position(|&x| (x - s.render_scale).abs() < 1e-6)
|
||||
.unwrap_or_else(|| RENDER_SCALES.iter().position(|&x| x == 1.0).unwrap());
|
||||
(names, i)
|
||||
};
|
||||
let scale_combo = setting_combo(ctx, "Render scale", scale_names, scale_i, |s, i| {
|
||||
s.render_scale = RENDER_SCALES[i];
|
||||
})
|
||||
.tooltip(
|
||||
"Supersample for sharpness (above 1\u{00D7}, more bandwidth and decode) or render below \
|
||||
native (below 1\u{00D7}) for a lighter host \u{2014} this device resamples to the window.",
|
||||
);
|
||||
let (comp_names, comp_i) = presets(COMPOSITORS, |v| *v == s.compositor);
|
||||
let comp_combo = setting_combo(ctx, "Host compositor", comp_names, comp_i, |s, i| {
|
||||
s.compositor = COMPOSITORS[i].0.to_string();
|
||||
@@ -441,6 +473,7 @@ pub(crate) fn settings_page(
|
||||
settings_card(vec![
|
||||
res_combo.into(),
|
||||
hz_combo.into(),
|
||||
scale_combo.into(),
|
||||
fullscreen_toggle.into(),
|
||||
comp_combo.into(),
|
||||
]),
|
||||
|
||||
Reference in New Issue
Block a user