diff --git a/crates/pf-console-ui/src/skia_overlay.rs b/crates/pf-console-ui/src/skia_overlay.rs index a0133f5a..33b6060e 100644 --- a/crates/pf-console-ui/src/skia_overlay.rs +++ b/crates/pf-console-ui/src/skia_overlay.rs @@ -48,6 +48,10 @@ struct Drawn { height: u32, stats: Option, hint: Option, + /// The UI scale this was drawn at, in percent — part of the damage key so dragging the window + /// to a differently-scaled monitor re-renders the chrome at the new size instead of keeping + /// the stale one (the text is identical, so nothing else here would notice). + scale_pct: u16, /// The start banner's alpha, quantized — a fade step is a redraw, steady is not. banner_step: u8, /// The resize scrim's spinner phase, quantized — a nonzero, ever-changing step while a @@ -56,6 +60,26 @@ struct Drawn { resize_step: u16, } +/// The stream chrome's base metrics, in pixels at 100 % scale (96 dpi). Everything here is +/// multiplied by `FrameCtx::scale` before it is drawn: the overlay composites into the swapchain +/// 1:1 in PHYSICAL pixels, so on a 4K panel at 200 % an unscaled 14 px OSD renders at half its +/// intended physical size — legible on a 1080p monitor, a squint on a HiDPI laptop. +mod base { + /// The monospace OSD/hint/label size. Also the size the shared `Font` is built at, so the + /// scale factor below is exactly the multiplier applied to it. + pub const FONT_PX: f32 = 14.0; + /// Top-left inset of the stats panel. + pub const OSD_MARGIN: f32 = 12.0; + /// Stats-panel inner padding and corner radius. + pub const OSD_PAD_X: f32 = 10.0; + pub const OSD_PAD_Y: f32 = 8.0; + pub const OSD_RADIUS: f32 = 8.0; + /// Hint/banner pill padding and its gap from the bottom edge. + pub const PILL_PAD_X: f32 = 14.0; + pub const PILL_PAD_Y: f32 = 8.0; + pub const PILL_BOTTOM: f32 = 24.0; +} + /// Where the console starts (the session binary's `--browse` forms). pub enum ConsoleEntry { /// The host list (bare `--browse`). @@ -221,7 +245,7 @@ impl Overlay for SkiaOverlay { skia_safe::FontStyle::normal(), ) .context("no monospace typeface (fontconfig alias or system family)")?; - self.font = Some(Font::new(typeface, 14.0)); + self.font = Some(Font::new(typeface, base::FONT_PX)); self.fonts = Some(crate::theme::build_fonts()?); self.gpu = Some(Gpu { @@ -360,11 +384,15 @@ impl Overlay for SkiaOverlay { self.drawn = Drawn::default(); // forget content so re-show re-renders return Ok(None); } + // 1 % granularity: fine enough that no real display scale is rounded into another, coarse + // enough that float noise on the same monitor can't churn the damage gate every frame. + let scale = ctx.scale.clamp(0.5, 4.0); let want = Drawn { width: ctx.width, height: ctx.height, stats: ctx.stats.map(str::to_owned), hint: ctx.hint.map(str::to_owned), + scale_pct: (scale * 100.0).round() as u16, banner_step, resize_step, }; @@ -387,16 +415,20 @@ impl Overlay for SkiaOverlay { let canvas = slot.surface.canvas(); canvas.clear(Color4f::new(0.0, 0.0, 0.0, 0.0)); + // Each drawer re-derives the face at its own (fit-clamped) size rather than the canvas + // being transformed: Skia hints and rasterizes glyphs at the requested size, so this + // stays crisp where a magnified 14 px bitmap would be mush. Only on a damage redraw — + // a steady stream re-renders nothing at all. let font = self.font.as_ref().expect("init ran"); // The resize scrim sits UNDER the OSD/hint so those stay legible over it. if let Some(phase) = resize_phase { - draw_resize_scrim(canvas, font, ctx.width, ctx.height, phase); + draw_resize_scrim(canvas, font, ctx.width, ctx.height, phase, scale); } if let Some(stats) = &want.stats { - draw_osd_panel(canvas, font, stats, 12.0, 12.0); + draw_osd_panel(canvas, font, stats, ctx.width, scale); } if let Some(hint) = &want.hint { - draw_hint_pill(canvas, font, hint, ctx.width, ctx.height, 1.0); + draw_hint_pill(canvas, font, hint, ctx.width, ctx.height, 1.0, scale); } else if banner_step > 0 { // The start banner: the leave/stats shortcuts, fading out on its own — // discoverable without the stats overlay, gone before it annoys. @@ -408,6 +440,7 @@ impl Overlay for SkiaOverlay { ctx.width, ctx.height, banner_alpha as f32, + scale, ); } } @@ -543,25 +576,61 @@ impl SkiaOverlay { } } -/// The stats OSD: a translucent rounded panel, one text line per `\n` (the GTK OSD's -/// look, minus the toolkit). -fn draw_osd_panel(canvas: &Canvas, font: &Font, text: &str, x: f32, y: f32) { +/// The chrome face at `scale`. `with_size` only fails on a nonsensical size (the caller clamps), +/// in which case the unscaled face is still better than no text. +fn chrome_font(font: &Font, scale: f32) -> Font { + font.with_size(base::FONT_PX * scale) + .unwrap_or_else(|| font.clone()) +} + +/// Shrink `scale` until a box of `width_at_scale` (which must be linear in the scale — every +/// chrome metric is) fits in `budget`. Scaling text up by the display's DPI is only an +/// improvement while the result still fits the window: the capture hint is a ~150-character line +/// that already spans most of a 1280 px window at 100 %, so at 200 % it would run off both edges +/// and lose its ends. Fitting keeps it whole, just smaller than the nominal scale. +fn fit_scale(scale: f32, width_at_scale: f32, budget: f32) -> f32 { + if width_at_scale > budget && width_at_scale > 0.0 { + (scale * budget / width_at_scale).max(0.1) + } else { + scale + } +} + +/// The stats OSD: a translucent rounded panel in the top-left, one text line per `\n` (the GTK +/// OSD's look, minus the toolkit), sized for the display's UI `scale`. +fn draw_osd_panel(canvas: &Canvas, base_font: &Font, text: &str, width: u32, scale: f32) { + let lines: Vec<&str> = text.lines().collect(); + // Panel width is linear in the scale, so measuring once at the requested scale is enough to + // solve for the scale that keeps the Detailed tier's long lines inside the window instead of + // running them past the right edge on a HiDPI display. + let width_at = |s: f32| { + let font = chrome_font(base_font, s); + let widest = lines + .iter() + .map(|l| font.measure_str(l, None).0) + .fold(0.0f32, f32::max); + widest + 2.0 * (base::OSD_PAD_X + base::OSD_MARGIN) * s + }; + let scale = fit_scale(scale, width_at(scale), width as f32); + let font = chrome_font(base_font, scale); + let (_, metrics) = font.metrics(); let line_h = metrics.descent - metrics.ascent + metrics.leading; - let lines: Vec<&str> = text.lines().collect(); let widest = lines .iter() .map(|l| font.measure_str(l, None).0) .fold(0.0f32, f32::max); - let (pad_x, pad_y) = (10.0, 8.0); + let (pad_x, pad_y) = (base::OSD_PAD_X * scale, base::OSD_PAD_Y * scale); + let (x, y) = (base::OSD_MARGIN * scale, base::OSD_MARGIN * scale); let panel = Rect::from_xywh( x, y, widest + 2.0 * pad_x, line_h * lines.len() as f32 + 2.0 * pad_y, ); + let radius = base::OSD_RADIUS * scale; canvas.draw_rrect( - RRect::new_rect_xy(panel, 8.0, 8.0), + RRect::new_rect_xy(panel, radius, radius), &Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.62), None), ); let text_paint = Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.92), None); @@ -569,7 +638,7 @@ fn draw_osd_panel(canvas: &Canvas, font: &Font, text: &str, x: f32, y: f32) { canvas.draw_str( line, Point::new(x + pad_x, y + pad_y - metrics.ascent + line_h * i as f32), - font, + &font, &text_paint, ); } @@ -581,7 +650,16 @@ fn draw_osd_panel(canvas: &Canvas, font: &Font, text: &str, x: f32, y: f32) { /// window. This is the presenter's analog of the Apple client's blur overlay: the overlay /// composites its own RGBA quad and cannot sample the video to blur it, so an opaque scrim /// hides the stretched in-between frame instead (same intent, one draw). -fn draw_resize_scrim(canvas: &Canvas, font: &Font, width: u32, height: u32, phase: f64) { +fn draw_resize_scrim( + canvas: &Canvas, + base_font: &Font, + width: u32, + height: u32, + phase: f64, + scale: f32, +) { + // Short, centered label — it always fits, so it just takes the display scale as-is. + let font = &chrome_font(base_font, scale); let (wf, hf) = (width as f32, height as f32); canvas.draw_rect( Rect::from_wh(wf, hf), @@ -602,16 +680,33 @@ fn draw_resize_scrim(canvas: &Canvas, font: &Font, width: u32, height: u32, phas ); } -/// The capture hint / start banner: a centered pill near the bottom edge. -fn draw_hint_pill(canvas: &Canvas, font: &Font, text: &str, width: u32, height: u32, alpha: f32) { +/// The capture hint / start banner: a centered pill near the bottom edge. `scale` = the display's +/// UI scale (the text size already rides in `font`). +fn draw_hint_pill( + canvas: &Canvas, + base_font: &Font, + text: &str, + width: u32, + height: u32, + alpha: f32, + scale: f32, +) { + // The capture hint is one long line that already fills most of a 1280 px window at 100 %; + // scaled by a 2× display it would overrun both edges, so fit it to the window (a 4 % gutter + // keeps it off the very edge). + let pill_w = + |s: f32| chrome_font(base_font, s).measure_str(text, None).0 + 2.0 * base::PILL_PAD_X * s; + let scale = fit_scale(scale, pill_w(scale), width as f32 * 0.96); + let font = &chrome_font(base_font, scale); + let (_, metrics) = font.metrics(); let line_h = metrics.descent - metrics.ascent; let text_w = font.measure_str(text, None).0; - let (pad_x, pad_y) = (14.0, 8.0); + let (pad_x, pad_y) = (base::PILL_PAD_X * scale, base::PILL_PAD_Y * scale); let w = text_w + 2.0 * pad_x; let h = line_h + 2.0 * pad_y; let x = (width as f32 - w) / 2.0; - let y = height as f32 - h - 24.0; + let y = height as f32 - h - base::PILL_BOTTOM * scale; canvas.draw_rrect( RRect::new_rect_xy(Rect::from_xywh(x, y, w, h), h / 2.0, h / 2.0), &Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.62 * alpha), None), diff --git a/crates/pf-presenter/src/overlay.rs b/crates/pf-presenter/src/overlay.rs index 2dbf759d..e023aa7e 100644 --- a/crates/pf-presenter/src/overlay.rs +++ b/crates/pf-presenter/src/overlay.rs @@ -33,6 +33,12 @@ pub struct FrameCtx<'a> { /// Swapchain size in pixels — the overlay renders 1:1. pub width: u32, pub height: u32, + /// UI scale for the stream chrome: the window's display scale (DPI × the display's content + /// scale — `1.0` at 96 dpi / 100 %), times the `PUNKTFUNK_OSD_SCALE` preference. Because the + /// overlay renders in *physical* pixels, a fixed-pixel OSD shrinks as panel density rises — + /// unreadable at 14 px on a 4K laptop at 200 %. Every chrome metric is multiplied by this. + /// Sanitized and clamped by the run loop (`overlay_scale`), so it is always finite and > 0. + pub scale: f32, /// Multi-line stats OSD (top-left panel); `None` = hidden. pub stats: Option<&'a str>, /// The capture hint (bottom-center pill, "click to capture…"); `None` = hidden. diff --git a/crates/pf-presenter/src/run.rs b/crates/pf-presenter/src/run.rs index 9d961bbd..35d28295 100644 --- a/crates/pf-presenter/src/run.rs +++ b/crates/pf-presenter/src/run.rs @@ -420,6 +420,15 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result println!("{{\"ready\":true}}"); } + // Operator preference on top of the display's own DPI scale — for a TV across the room, or to + // shrink chrome that a compositor reports an aggressive scale for. Read once (a preference, + // not session state); the DPI part is re-read per frame. + let osd_scale_pref = std::env::var("PUNKTFUNK_OSD_SCALE") + .ok() + .and_then(|s| s.trim().parse::().ok()) + .filter(|v| v.is_finite() && *v > 0.0) + .unwrap_or(1.0); + let mut overlay = opts.overlay.take(); if let Some(o) = overlay.as_mut() { if let Err(e) = o.init(&presenter.shared_device()) { @@ -1148,6 +1157,10 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result let ctx = FrameCtx { width: pw, height: ph, + // Re-read per frame, not once at startup: dragging the window to a second monitor + // with a different scale (or changing the scale setting live) updates this, and the + // overlay's damage gate picks the new value up on the next redraw. + scale: overlay_scale(window.display_scale(), osd_scale_pref), stats, hint, resizing, @@ -1810,6 +1823,27 @@ fn content_to_window( (lx as f32, ly as f32) } +/// The overlay chrome's UI scale (`FrameCtx::scale`): SDL's window display scale — DPI × the +/// display's content scale, `1.0` at 96 dpi / 100 % — times the `PUNKTFUNK_OSD_SCALE` preference. +/// +/// Sanitizing is not paranoia: `SDL_GetWindowDisplayScale` returns `0.0` when it cannot resolve the +/// window's display (a headless/offscreen driver, or racing a monitor hotplug), and a 0 multiplier +/// would collapse the OSD to an invisible zero-size panel — worse than the un-scaled chrome it +/// replaces. The 4× ceiling keeps a bogus scale from covering the stream. +fn overlay_scale(display_scale: f32, pref: f32) -> f32 { + let base = if display_scale.is_finite() && display_scale > 0.0 { + display_scale + } else { + 1.0 + }; + let pref = if pref.is_finite() && pref > 0.0 { + pref + } else { + 1.0 + }; + (base * pref).clamp(0.5, 4.0) +} + /// The presenter's share of the unified stats window — folded into each printed line. #[derive(Default)] struct PresentedWindow { @@ -1907,6 +1941,28 @@ fn stats_text( mod tests { use super::*; + #[test] + fn overlay_scale_follows_dpi_and_survives_a_bogus_display() { + // 100 % / 96 dpi is the identity — the chrome keeps the size it always had. + assert_eq!(overlay_scale(1.0, 1.0), 1.0); + // The common HiDPI settings pass straight through. + assert_eq!(overlay_scale(1.5, 1.0), 1.5); + assert_eq!(overlay_scale(2.0, 1.0), 2.0); + // PUNKTFUNK_OSD_SCALE multiplies the display's own scale, it doesn't replace it. + assert_eq!(overlay_scale(2.0, 1.25), 2.5); + // SDL reports 0.0 when it can't resolve the window's display (offscreen driver, or + // racing a hotplug) — that must NOT collapse the panel to nothing. + assert_eq!(overlay_scale(0.0, 1.0), 1.0); + assert_eq!(overlay_scale(f32::NAN, 1.0), 1.0); + assert_eq!(overlay_scale(-2.0, 1.0), 1.0); + // A garbage preference degrades to "just the DPI", never to zero. + assert_eq!(overlay_scale(1.5, 0.0), 1.5); + assert_eq!(overlay_scale(1.5, f32::NAN), 1.5); + // Clamped both ways so nothing can hide the OSD or bury the stream under it. + assert_eq!(overlay_scale(1.0, 100.0), 4.0); + assert_eq!(overlay_scale(1.0, 0.01), 0.5); + } + #[test] fn content_to_window_inverts_the_letterbox() { // 1920×1080 video letterboxed in a 1600×1200 (4:3) window at 2× HiDPI: pillarless diff --git a/docs-site/content/docs/configuration.md b/docs-site/content/docs/configuration.md index ba0387d4..e24ecd46 100644 --- a/docs-site/content/docs/configuration.md +++ b/docs-site/content/docs/configuration.md @@ -167,6 +167,7 @@ A few knobs are read by the native **clients**, not the host: | Setting | Values | Meaning | |---|---|---| | `PUNKTFUNK_DECODER` | `software` · `vaapi` · `vulkan` (Linux) · `d3d11va` (Windows) | Force the decode path. Default auto-selects hardware per vendor (Linux: VAAPI on Intel/AMD, Vulkan Video on NVIDIA and the Steam Deck; Windows: Vulkan Video on NVIDIA/AMD, D3D11VA on Intel and others) with a software fallback. | +| `PUNKTFUNK_OSD_SCALE` | multiplier, e.g. `1.5` *(default `1`)* | Size of the in-stream overlay — the stats OSD, the capture hint and the start banner. They already follow your display's scaling setting (200 % display → twice the pixels), so set this only to nudge that: bigger for a TV across the room, smaller if your compositor reports an aggressive scale. Clamped to 0.5×–4×, and a line that would run off the screen is shrunk to fit. | ## Bitrate