Skip to main content

par_term_render/cell_renderer/
surface.rs

1use super::CellRenderer;
2use crate::wgpu_conversions::VsyncModeWgpu;
3use std::sync::atomic::{AtomicU64, Ordering};
4
5/// Clamp a surface extent so `Surface::configure` cannot reject it.
6///
7/// `configure` is a validation boundary: an extent beyond the device's
8/// `max_texture_dimension_2d` is an error, and an uncaptured wgpu error
9/// aborts the process (see [`install_nonfatal_error_handler`]). A window
10/// spanning multiple high-DPI displays can exceed the 8192 default — the
11/// crash of 2026-08-21 was a full-screen tile at 10240×2822 against an 8192
12/// limit. Clamping trades a slightly soft, compositor-upscaled frame on
13/// adapters that genuinely cap at 8192 for not losing every tab.
14pub fn clamp_surface_extent(width: u32, height: u32, max_dimension: u32) -> (u32, u32) {
15    (
16        width.min(max_dimension).max(1),
17        height.min(max_dimension).max(1),
18    )
19}
20
21/// Device limits with `max_texture_dimension_2d` raised to the adapter's real
22/// maximum instead of the 8192 of [`wgpu::Limits::default`].
23pub fn texture_limits(max_texture_dimension_2d: u32) -> wgpu::Limits {
24    wgpu::Limits {
25        max_texture_dimension_2d,
26        ..wgpu::Limits::default()
27    }
28}
29
30/// Install a non-fatal handler for uncaptured wgpu errors on `device`.
31///
32/// wgpu's default turns any error not caught by an error scope into a panic.
33/// par-term's wgpu calls happen inside AppKit/SkyLight callbacks — resize and
34/// redraw arrive as Objective-C notifications — where a Rust panic cannot
35/// unwind and aborts the process. Logging instead keeps the app alive with a
36/// degraded frame.
37pub fn install_nonfatal_error_handler(device: &wgpu::Device) {
38    let seen = AtomicU64::new(0);
39    device.on_uncaptured_error(std::sync::Arc::new(move |err: wgpu::Error| {
40        let count = seen.fetch_add(1, Ordering::Relaxed) + 1;
41        if should_log_uncaptured_error(count) {
42            log::error!("wgpu uncaptured error #{count}: {err}");
43        }
44    }));
45}
46
47/// Whether occurrence number `count` (1-based) reaches the log: the first,
48/// then every 1000th, so a per-frame error cannot grow the log unbounded.
49fn should_log_uncaptured_error(count: u64) -> bool {
50    count == 1 || count.is_multiple_of(1_000)
51}
52
53/// Whether `mode` throttles presents to the display refresh (the Fifo family).
54fn is_vsync_throttled(mode: wgpu::PresentMode) -> bool {
55    matches!(
56        mode,
57        wgpu::PresentMode::Fifo | wgpu::PresentMode::FifoRelaxed
58    )
59}
60
61/// Pick an alternate supported present mode to configure through before
62/// settling back on `target`.
63///
64/// A display-topology change can leave the CAMetalLayer in a brightness-strobe
65/// state that a same-config `Surface::configure` does not heal, while a vsync
66/// toggle — a configure with a *different* present mode — heals in either
67/// direction (measured 2026-09-21). Prefers a mode from the other vsync family
68/// (the drawable-count difference perturbs the layer harder), then any other
69/// supported mode. `None` when the surface supports only `target` and there is
70/// nothing to cycle through.
71pub fn alternate_present_mode(
72    target: wgpu::PresentMode,
73    supported: &[wgpu::PresentMode],
74) -> Option<wgpu::PresentMode> {
75    let preferred: &[wgpu::PresentMode] = if is_vsync_throttled(target) {
76        &[wgpu::PresentMode::Immediate, wgpu::PresentMode::Mailbox]
77    } else {
78        &[wgpu::PresentMode::Fifo, wgpu::PresentMode::FifoRelaxed]
79    };
80    preferred
81        .iter()
82        .copied()
83        .find(|m| *m != target && supported.contains(m))
84        .or_else(|| supported.iter().copied().find(|m| *m != target))
85}
86
87/// Whether a window at `window_opacity` needs a non-opaque (compositing) surface.
88///
89/// Matches the gate on `CellRenderer::render_opaque_alpha`, which stamps every
90/// pixel to alpha 1.0 at this opacity, so nothing is lost by telling the
91/// compositor the layer is opaque.
92pub fn surface_needs_alpha(window_opacity: f32) -> bool {
93    window_opacity < 1.0
94}
95
96/// Pick the composite alpha mode for the surface.
97///
98/// A fully opaque window gets `Opaque`, which makes wgpu set
99/// `CAMetalLayer.opaque = true` on every configure; macOS then draws the layer
100/// directly instead of blending it with whatever sits behind it. A translucent
101/// window keeps the compositing order PreMultiplied > PostMultiplied > Auto.
102/// Falls back to the first advertised mode when none of the preferred modes is
103/// available.
104pub fn select_alpha_mode(
105    needs_alpha: bool,
106    available: &[wgpu::CompositeAlphaMode],
107) -> Option<wgpu::CompositeAlphaMode> {
108    let preferred: &[wgpu::CompositeAlphaMode] = if needs_alpha {
109        &[
110            wgpu::CompositeAlphaMode::PreMultiplied,
111            wgpu::CompositeAlphaMode::PostMultiplied,
112            wgpu::CompositeAlphaMode::Auto,
113        ]
114    } else {
115        &[
116            wgpu::CompositeAlphaMode::Opaque,
117            wgpu::CompositeAlphaMode::PreMultiplied,
118            wgpu::CompositeAlphaMode::PostMultiplied,
119            wgpu::CompositeAlphaMode::Auto,
120        ]
121    };
122    preferred
123        .iter()
124        .copied()
125        .find(|m| available.contains(m))
126        .or_else(|| available.first().copied())
127}
128
129/// Refresh a surface configuration against capabilities re-queried after a
130/// display-topology change.
131///
132/// The stored configuration was negotiated against the OLD topology. Every
133/// field the fresh capabilities still support is kept as-is — a gratuitous
134/// format change would invalidate the render pipelines — and only what the new
135/// topology dropped is re-picked, mirroring the selection order of
136/// `CellRenderer::new` (first non-sRGB format; Fifo; PreMultiplied >
137/// PostMultiplied > Auto). The extent is re-clamped from the live window size.
138pub fn refresh_config_after_display_change(
139    config: &mut wgpu::SurfaceConfiguration,
140    formats: &[wgpu::TextureFormat],
141    present_modes: &[wgpu::PresentMode],
142    alpha_modes: &[wgpu::CompositeAlphaMode],
143    width: u32,
144    height: u32,
145    max_dimension: u32,
146) {
147    if !formats.contains(&config.format) {
148        let fallback = formats
149            .iter()
150            .copied()
151            .find(|f| !f.is_srgb())
152            .or_else(|| formats.first().copied());
153        if let Some(fallback) = fallback {
154            log::warn!(
155                "Surface format {:?} no longer supported after display change; switching to {:?} (render pipelines were built for the old format)",
156                config.format,
157                fallback
158            );
159            config.format = fallback;
160        }
161    }
162
163    if !present_modes.contains(&config.present_mode) {
164        let fallback = if present_modes.contains(&wgpu::PresentMode::Fifo) {
165            Some(wgpu::PresentMode::Fifo)
166        } else {
167            present_modes.first().copied()
168        };
169        if let Some(fallback) = fallback {
170            log::warn!(
171                "Present mode {:?} no longer supported after display change; falling back to {:?}",
172                config.present_mode,
173                fallback
174            );
175            config.present_mode = fallback;
176        }
177    }
178
179    if !alpha_modes.contains(&config.alpha_mode) {
180        let needs_alpha = config.alpha_mode != wgpu::CompositeAlphaMode::Opaque;
181        if let Some(fallback) = select_alpha_mode(needs_alpha, alpha_modes) {
182            log::warn!(
183                "Alpha mode {:?} no longer supported after display change; falling back to {:?}",
184                config.alpha_mode,
185                fallback
186            );
187            config.alpha_mode = fallback;
188        }
189    }
190
191    let (w, h) = clamp_surface_extent(width, height, max_dimension);
192    config.width = w;
193    config.height = h;
194}
195
196impl CellRenderer {
197    /// The single entry point for `Surface::configure`.
198    ///
199    /// Every configure path goes through here so anything that must hold after
200    /// a configure (wgpu rewrites the CAMetalLayer's properties on each call)
201    /// is applied in one place.
202    pub(crate) fn configure_surface(&self) {
203        self.surface.configure(&self.device, &self.config);
204        #[cfg(target_os = "macos")]
205        self.tag_metal_layer_colorspace();
206    }
207
208    /// Tag the CAMetalLayer as sRGB and log its post-configure state.
209    ///
210    /// wgpu 30 resolves `SurfaceColorSpace::Auto` to `Srgb` and then writes a
211    /// nil colorspace to the layer on every configure (wgpu 29 never touched
212    /// it). With no tag, macOS color-matches the frame against each display's
213    /// profile its own way; an explicit sRGB tag gives every display the same
214    /// conversion. Must run after each configure, which resets the property.
215    #[cfg(target_os = "macos")]
216    fn tag_metal_layer_colorspace(&self) {
217        use objc2_core_graphics::{CGColorSpace, kCGColorSpaceSRGB};
218
219        // SAFETY: `as_hal` borrows the live hal surface of this `wgpu::Surface`
220        // for the scope of the guard; no other access to the surface happens
221        // while it is held. The render layer is the CAMetalLayer wgpu-hal
222        // configured, and its mutex is the same one hal locks in `configure`.
223        let Some(hal_surface) = (unsafe { self.surface.as_hal::<wgpu::hal::api::Metal>() }) else {
224            log::warn!("Surface is not a Metal surface; CAMetalLayer colorspace left untagged");
225            return;
226        };
227        let layer = hal_surface.render_layer().lock();
228        // SAFETY: kCGColorSpaceSRGB is an immutable CoreGraphics constant.
229        let srgb = CGColorSpace::with_name(Some(unsafe { kCGColorSpaceSRGB }));
230        layer.setColorspace(srgb.as_deref());
231        log::info!(
232            "CAMetalLayer after configure: opaque={} displaySync={} maxDrawables={} colorspace={}",
233            layer.isOpaque(),
234            layer.displaySyncEnabled(),
235            layer.maximumDrawableCount(),
236            if layer.colorspace().is_some() {
237                "sRGB"
238            } else {
239                "none"
240            }
241        );
242    }
243
244    /// Move the surface between `Opaque` and a compositing alpha mode when
245    /// the window's opacity crosses 1.0, reconfiguring only on a real change.
246    pub(crate) fn sync_alpha_mode_to_opacity(&mut self) {
247        let Some(alpha_mode) = select_alpha_mode(
248            surface_needs_alpha(self.window_opacity),
249            &self.supported_alpha_modes,
250        ) else {
251            return;
252        };
253        if alpha_mode != self.config.alpha_mode {
254            log::info!(
255                "Surface alpha mode {:?} -> {:?} (window_opacity={})",
256                self.config.alpha_mode,
257                alpha_mode,
258                self.window_opacity
259            );
260            self.config.alpha_mode = alpha_mode;
261            self.configure_surface();
262        }
263    }
264
265    pub fn reconfigure_surface(&mut self) {
266        // Every configure path must be attributable from the debug log: a
267        // silent same-config configure is exactly how the wgpu-30 strobe
268        // hid (the Moved handler stormed here invisibly at default level).
269        log::info!(
270            "Plain surface reconfigure (same config, {}x{}, {:?})",
271            self.config.width,
272            self.config.height,
273            self.config.present_mode
274        );
275        self.configure_surface();
276    }
277
278    /// Reconfigure the surface after a display-topology change (monitor
279    /// attach/detach, resolution or color-space change), healing the
280    /// brightness-strobe state such a change can leave behind.
281    ///
282    /// A plain [`CellRenderer::reconfigure_surface`] reuses the stored config
283    /// and does not heal that state (measured 2026-09-21: a vsync toggle heals
284    /// in either direction, a same-config configure does not). This variant
285    /// therefore
286    ///
287    /// 1. re-queries surface capabilities — the stored ones were negotiated
288    ///    against the old topology — and refreshes `config` and
289    ///    `supported_present_modes`, and
290    /// 2. cycles the present mode through an alternate supported mode and
291    ///    back, reproducing the toggle's drawable-pool rebuild without
292    ///    changing the user's vsync setting.
293    pub fn reconfigure_after_display_change(&mut self, width: u32, height: u32) {
294        let caps = self.surface.get_capabilities(&self.adapter);
295        let mut refreshed = self.config.clone();
296        refresh_config_after_display_change(
297            &mut refreshed,
298            &caps.formats,
299            &caps.present_modes,
300            &caps.alpha_modes,
301            width,
302            height,
303            self.device.limits().max_texture_dimension_2d,
304        );
305        self.supported_present_modes = caps.present_modes.clone();
306        self.supported_alpha_modes = caps.alpha_modes.clone();
307
308        let target = refreshed.present_mode;
309        match alternate_present_mode(target, &self.supported_present_modes) {
310            Some(alternate) => {
311                log::info!(
312                    "Display-change heal: cycling present mode {:?} -> {:?}, config {} by fresh capabilities",
313                    target,
314                    alternate,
315                    if refreshed == self.config {
316                        "unchanged"
317                    } else {
318                        "changed"
319                    }
320                );
321                self.config = refreshed;
322                self.config.present_mode = alternate;
323                self.configure_surface();
324                self.config.present_mode = target;
325                self.configure_surface();
326            }
327            None if refreshed == self.config => {
328                // Nothing to transition to: a same-config configure heals
329                // nothing, and wgpu 30 treats configure as an expensive
330                // state transition, not idempotent maintenance.
331                log::warn!(
332                    "Display-change heal: no alternate present mode and fresh capabilities unchanged; skipping configure"
333                );
334            }
335            None => {
336                log::info!(
337                    "Display-change heal: no alternate present mode; applying refreshed config"
338                );
339                self.config = refreshed;
340                self.configure_surface();
341            }
342        }
343    }
344
345    /// Get the list of supported present modes for this surface
346    pub fn supported_present_modes(&self) -> &[wgpu::PresentMode] {
347        &self.supported_present_modes
348    }
349
350    /// Check if a vsync mode is supported
351    pub fn is_vsync_mode_supported(&self, mode: par_term_config::VsyncMode) -> bool {
352        self.supported_present_modes
353            .contains(&mode.to_present_mode())
354    }
355
356    /// Update the vsync mode. Returns the actual mode applied (may differ if requested mode unsupported).
357    /// Also returns whether the mode was changed.
358    pub fn update_vsync_mode(
359        &mut self,
360        mode: par_term_config::VsyncMode,
361    ) -> (par_term_config::VsyncMode, bool) {
362        let requested = mode.to_present_mode();
363        let current = self.config.present_mode;
364
365        // Determine the actual mode to use
366        let actual = if self.supported_present_modes.contains(&requested) {
367            requested
368        } else {
369            log::warn!(
370                "Requested present mode {:?} not supported, falling back to Fifo",
371                requested
372            );
373            wgpu::PresentMode::Fifo
374        };
375
376        // Only reconfigure if the mode actually changed
377        if actual != current {
378            self.config.present_mode = actual;
379            self.configure_surface();
380            log::info!("VSync mode changed to {:?}", actual);
381        }
382
383        // Convert back to VsyncMode for return
384        let actual_vsync = match actual {
385            wgpu::PresentMode::Immediate => par_term_config::VsyncMode::Immediate,
386            wgpu::PresentMode::Mailbox => par_term_config::VsyncMode::Mailbox,
387            wgpu::PresentMode::Fifo | wgpu::PresentMode::FifoRelaxed => {
388                par_term_config::VsyncMode::Fifo
389            }
390            _ => par_term_config::VsyncMode::Fifo,
391        };
392
393        (actual_vsync, actual != current)
394    }
395
396    /// Get the current vsync mode
397    pub fn current_vsync_mode(&self) -> par_term_config::VsyncMode {
398        match self.config.present_mode {
399            wgpu::PresentMode::Immediate => par_term_config::VsyncMode::Immediate,
400            wgpu::PresentMode::Mailbox => par_term_config::VsyncMode::Mailbox,
401            wgpu::PresentMode::Fifo | wgpu::PresentMode::FifoRelaxed => {
402                par_term_config::VsyncMode::Fifo
403            }
404            _ => par_term_config::VsyncMode::Fifo,
405        }
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412
413    /// The crash of 2026-08-21: a full-screen tile spanning two 5K displays,
414    /// 10240×2822, configured against a device capped at 8192.
415    #[test]
416    fn oversize_extent_is_clamped_to_the_device_limit() {
417        assert_eq!(clamp_surface_extent(10240, 2822, 8192), (8192, 2822));
418    }
419
420    #[test]
421    fn extent_within_the_limit_is_unchanged() {
422        assert_eq!(clamp_surface_extent(3840, 2160, 8192), (3840, 2160));
423    }
424
425    #[test]
426    fn extent_at_the_limit_is_unchanged() {
427        assert_eq!(clamp_surface_extent(8192, 8192, 8192), (8192, 8192));
428    }
429
430    /// `configure` rejects a zero extent as well, so the clamp floors at one
431    /// and a call site cannot trade one validation failure for another.
432    #[test]
433    fn zero_extent_floors_to_one() {
434        assert_eq!(clamp_surface_extent(0, 0, 8192), (1, 1));
435    }
436
437    #[test]
438    fn texture_limits_raises_only_the_2d_dimension() {
439        assert_eq!(
440            texture_limits(16384),
441            wgpu::Limits {
442                max_texture_dimension_2d: 16384,
443                ..wgpu::Limits::default()
444            }
445        );
446    }
447
448    #[test]
449    fn uncaptured_errors_log_the_first_then_every_1000th() {
450        assert!(should_log_uncaptured_error(1));
451        assert!(!should_log_uncaptured_error(2));
452        assert!(!should_log_uncaptured_error(999));
453        assert!(should_log_uncaptured_error(1000));
454        assert!(!should_log_uncaptured_error(1001));
455    }
456
457    fn surface_config(present_mode: wgpu::PresentMode) -> wgpu::SurfaceConfiguration {
458        wgpu::SurfaceConfiguration {
459            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
460            format: wgpu::TextureFormat::Bgra8Unorm,
461            color_space: wgpu::SurfaceColorSpace::Auto,
462            width: 2560,
463            height: 1440,
464            present_mode,
465            alpha_mode: wgpu::CompositeAlphaMode::PreMultiplied,
466            view_formats: vec![],
467            desired_maximum_frame_latency: 2,
468        }
469    }
470
471    #[test]
472    fn alternate_mode_prefers_the_other_vsync_family() {
473        // The two halves of the measured heal: toggling vsync either way
474        // recovers, so the cycle must be able to leave the target's family.
475        assert_eq!(
476            alternate_present_mode(
477                wgpu::PresentMode::Fifo,
478                &[wgpu::PresentMode::Fifo, wgpu::PresentMode::Immediate],
479            ),
480            Some(wgpu::PresentMode::Immediate)
481        );
482        assert_eq!(
483            alternate_present_mode(
484                wgpu::PresentMode::Immediate,
485                &[wgpu::PresentMode::Fifo, wgpu::PresentMode::Immediate],
486            ),
487            Some(wgpu::PresentMode::Fifo)
488        );
489    }
490
491    #[test]
492    fn alternate_mode_falls_back_to_any_other_supported_mode() {
493        // Metal offers no Mailbox; FifoRelaxed is the only other option.
494        assert_eq!(
495            alternate_present_mode(
496                wgpu::PresentMode::Fifo,
497                &[wgpu::PresentMode::Fifo, wgpu::PresentMode::FifoRelaxed],
498            ),
499            Some(wgpu::PresentMode::FifoRelaxed)
500        );
501    }
502
503    #[test]
504    fn alternate_mode_is_none_when_target_is_the_only_mode() {
505        assert_eq!(
506            alternate_present_mode(wgpu::PresentMode::Fifo, &[wgpu::PresentMode::Fifo]),
507            None
508        );
509    }
510
511    #[test]
512    fn refresh_keeps_fields_the_new_topology_still_supports() {
513        let mut config = surface_config(wgpu::PresentMode::Fifo);
514        refresh_config_after_display_change(
515            &mut config,
516            &[
517                wgpu::TextureFormat::Bgra8Unorm,
518                wgpu::TextureFormat::Rgba8UnormSrgb,
519            ],
520            &[wgpu::PresentMode::Fifo, wgpu::PresentMode::Immediate],
521            &[
522                wgpu::CompositeAlphaMode::PreMultiplied,
523                wgpu::CompositeAlphaMode::Auto,
524            ],
525            1920,
526            1080,
527            8192,
528        );
529        assert_eq!(config.format, wgpu::TextureFormat::Bgra8Unorm);
530        assert_eq!(config.present_mode, wgpu::PresentMode::Fifo);
531        assert_eq!(config.alpha_mode, wgpu::CompositeAlphaMode::PreMultiplied);
532        assert_eq!((config.width, config.height), (1920, 1080));
533    }
534
535    #[test]
536    fn refresh_replaces_what_the_new_topology_dropped() {
537        let mut config = surface_config(wgpu::PresentMode::Mailbox);
538        refresh_config_after_display_change(
539            &mut config,
540            &[wgpu::TextureFormat::Rgba8Unorm],
541            &[wgpu::PresentMode::Fifo],
542            &[wgpu::CompositeAlphaMode::Opaque],
543            0,
544            0,
545            8192,
546        );
547        // Re-picked by the CellRenderer::new preference order.
548        assert_eq!(config.format, wgpu::TextureFormat::Rgba8Unorm);
549        assert_eq!(config.present_mode, wgpu::PresentMode::Fifo);
550        assert_eq!(config.alpha_mode, wgpu::CompositeAlphaMode::Opaque);
551        // The extent still passes through the configure-rejection clamp.
552        assert_eq!((config.width, config.height), (1, 1));
553    }
554
555    #[test]
556    fn refresh_clamps_oversize_extent() {
557        let mut config = surface_config(wgpu::PresentMode::Fifo);
558        let (format, present_mode, alpha_mode) =
559            (config.format, config.present_mode, config.alpha_mode);
560        refresh_config_after_display_change(
561            &mut config,
562            &[format],
563            &[present_mode],
564            &[alpha_mode],
565            10240,
566            2822,
567            8192,
568        );
569        assert_eq!((config.width, config.height), (8192, 2822));
570    }
571
572    #[test]
573    fn opaque_window_selects_opaque_alpha_mode() {
574        use wgpu::CompositeAlphaMode::*;
575        let available = [Opaque, PostMultiplied];
576        assert_eq!(
577            select_alpha_mode(surface_needs_alpha(1.0), &available),
578            Some(Opaque)
579        );
580    }
581
582    #[test]
583    fn translucent_window_selects_compositing_alpha_mode() {
584        use wgpu::CompositeAlphaMode::*;
585        assert_eq!(
586            select_alpha_mode(surface_needs_alpha(0.9), &[Opaque, PostMultiplied]),
587            Some(PostMultiplied)
588        );
589        assert_eq!(
590            select_alpha_mode(true, &[Opaque, PostMultiplied, PreMultiplied]),
591            Some(PreMultiplied)
592        );
593    }
594
595    #[test]
596    fn alpha_mode_falls_back_to_first_advertised() {
597        use wgpu::CompositeAlphaMode::*;
598        assert_eq!(select_alpha_mode(true, &[Opaque]), Some(Opaque));
599        assert_eq!(select_alpha_mode(false, &[Inherit]), Some(Inherit));
600        assert_eq!(select_alpha_mode(false, &[]), None);
601    }
602}