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/// Refresh a surface configuration against capabilities re-queried after a
88/// display-topology change.
89///
90/// The stored configuration was negotiated against the OLD topology. Every
91/// field the fresh capabilities still support is kept as-is — a gratuitous
92/// format change would invalidate the render pipelines — and only what the new
93/// topology dropped is re-picked, mirroring the selection order of
94/// `CellRenderer::new` (first non-sRGB format; Fifo; PreMultiplied >
95/// PostMultiplied > Auto). The extent is re-clamped from the live window size.
96pub fn refresh_config_after_display_change(
97    config: &mut wgpu::SurfaceConfiguration,
98    formats: &[wgpu::TextureFormat],
99    present_modes: &[wgpu::PresentMode],
100    alpha_modes: &[wgpu::CompositeAlphaMode],
101    width: u32,
102    height: u32,
103    max_dimension: u32,
104) {
105    if !formats.contains(&config.format) {
106        let fallback = formats
107            .iter()
108            .copied()
109            .find(|f| !f.is_srgb())
110            .or_else(|| formats.first().copied());
111        if let Some(fallback) = fallback {
112            log::warn!(
113                "Surface format {:?} no longer supported after display change; switching to {:?} (render pipelines were built for the old format)",
114                config.format,
115                fallback
116            );
117            config.format = fallback;
118        }
119    }
120
121    if !present_modes.contains(&config.present_mode) {
122        let fallback = if present_modes.contains(&wgpu::PresentMode::Fifo) {
123            Some(wgpu::PresentMode::Fifo)
124        } else {
125            present_modes.first().copied()
126        };
127        if let Some(fallback) = fallback {
128            log::warn!(
129                "Present mode {:?} no longer supported after display change; falling back to {:?}",
130                config.present_mode,
131                fallback
132            );
133            config.present_mode = fallback;
134        }
135    }
136
137    if !alpha_modes.contains(&config.alpha_mode) {
138        let fallback = [
139            wgpu::CompositeAlphaMode::PreMultiplied,
140            wgpu::CompositeAlphaMode::PostMultiplied,
141            wgpu::CompositeAlphaMode::Auto,
142        ]
143        .into_iter()
144        .find(|m| alpha_modes.contains(m))
145        .or_else(|| alpha_modes.first().copied());
146        if let Some(fallback) = fallback {
147            log::warn!(
148                "Alpha mode {:?} no longer supported after display change; falling back to {:?}",
149                config.alpha_mode,
150                fallback
151            );
152            config.alpha_mode = fallback;
153        }
154    }
155
156    let (w, h) = clamp_surface_extent(width, height, max_dimension);
157    config.width = w;
158    config.height = h;
159}
160
161impl CellRenderer {
162    pub fn reconfigure_surface(&mut self) {
163        // Every configure path must be attributable from the debug log: a
164        // silent same-config configure is exactly how the wgpu-30 strobe
165        // hid (the Moved handler stormed here invisibly at default level).
166        log::info!(
167            "Plain surface reconfigure (same config, {}x{}, {:?})",
168            self.config.width,
169            self.config.height,
170            self.config.present_mode
171        );
172        self.surface.configure(&self.device, &self.config);
173    }
174
175    /// Reconfigure the surface after a display-topology change (monitor
176    /// attach/detach, resolution or color-space change), healing the
177    /// brightness-strobe state such a change can leave behind.
178    ///
179    /// A plain [`CellRenderer::reconfigure_surface`] reuses the stored config
180    /// and does not heal that state (measured 2026-09-21: a vsync toggle heals
181    /// in either direction, a same-config configure does not). This variant
182    /// therefore
183    ///
184    /// 1. re-queries surface capabilities — the stored ones were negotiated
185    ///    against the old topology — and refreshes `config` and
186    ///    `supported_present_modes`, and
187    /// 2. cycles the present mode through an alternate supported mode and
188    ///    back, reproducing the toggle's drawable-pool rebuild without
189    ///    changing the user's vsync setting.
190    pub fn reconfigure_after_display_change(&mut self, width: u32, height: u32) {
191        let caps = self.surface.get_capabilities(&self.adapter);
192        let mut refreshed = self.config.clone();
193        refresh_config_after_display_change(
194            &mut refreshed,
195            &caps.formats,
196            &caps.present_modes,
197            &caps.alpha_modes,
198            width,
199            height,
200            self.device.limits().max_texture_dimension_2d,
201        );
202        self.supported_present_modes = caps.present_modes.clone();
203
204        let target = refreshed.present_mode;
205        match alternate_present_mode(target, &self.supported_present_modes) {
206            Some(alternate) => {
207                log::info!(
208                    "Display-change heal: cycling present mode {:?} -> {:?}, config {} by fresh capabilities",
209                    target,
210                    alternate,
211                    if refreshed == self.config {
212                        "unchanged"
213                    } else {
214                        "changed"
215                    }
216                );
217                self.config = refreshed;
218                self.config.present_mode = alternate;
219                self.surface.configure(&self.device, &self.config);
220                self.config.present_mode = target;
221                self.surface.configure(&self.device, &self.config);
222            }
223            None if refreshed == self.config => {
224                // Nothing to transition to: a same-config configure heals
225                // nothing, and wgpu 30 treats configure as an expensive
226                // state transition, not idempotent maintenance.
227                log::warn!(
228                    "Display-change heal: no alternate present mode and fresh capabilities unchanged; skipping configure"
229                );
230            }
231            None => {
232                log::info!(
233                    "Display-change heal: no alternate present mode; applying refreshed config"
234                );
235                self.config = refreshed;
236                self.surface.configure(&self.device, &self.config);
237            }
238        }
239    }
240
241    /// Get the list of supported present modes for this surface
242    pub fn supported_present_modes(&self) -> &[wgpu::PresentMode] {
243        &self.supported_present_modes
244    }
245
246    /// Check if a vsync mode is supported
247    pub fn is_vsync_mode_supported(&self, mode: par_term_config::VsyncMode) -> bool {
248        self.supported_present_modes
249            .contains(&mode.to_present_mode())
250    }
251
252    /// Update the vsync mode. Returns the actual mode applied (may differ if requested mode unsupported).
253    /// Also returns whether the mode was changed.
254    pub fn update_vsync_mode(
255        &mut self,
256        mode: par_term_config::VsyncMode,
257    ) -> (par_term_config::VsyncMode, bool) {
258        let requested = mode.to_present_mode();
259        let current = self.config.present_mode;
260
261        // Determine the actual mode to use
262        let actual = if self.supported_present_modes.contains(&requested) {
263            requested
264        } else {
265            log::warn!(
266                "Requested present mode {:?} not supported, falling back to Fifo",
267                requested
268            );
269            wgpu::PresentMode::Fifo
270        };
271
272        // Only reconfigure if the mode actually changed
273        if actual != current {
274            self.config.present_mode = actual;
275            self.surface.configure(&self.device, &self.config);
276            log::info!("VSync mode changed to {:?}", actual);
277        }
278
279        // Convert back to VsyncMode for return
280        let actual_vsync = match actual {
281            wgpu::PresentMode::Immediate => par_term_config::VsyncMode::Immediate,
282            wgpu::PresentMode::Mailbox => par_term_config::VsyncMode::Mailbox,
283            wgpu::PresentMode::Fifo | wgpu::PresentMode::FifoRelaxed => {
284                par_term_config::VsyncMode::Fifo
285            }
286            _ => par_term_config::VsyncMode::Fifo,
287        };
288
289        (actual_vsync, actual != current)
290    }
291
292    /// Get the current vsync mode
293    pub fn current_vsync_mode(&self) -> par_term_config::VsyncMode {
294        match self.config.present_mode {
295            wgpu::PresentMode::Immediate => par_term_config::VsyncMode::Immediate,
296            wgpu::PresentMode::Mailbox => par_term_config::VsyncMode::Mailbox,
297            wgpu::PresentMode::Fifo | wgpu::PresentMode::FifoRelaxed => {
298                par_term_config::VsyncMode::Fifo
299            }
300            _ => par_term_config::VsyncMode::Fifo,
301        }
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    /// The crash of 2026-08-21: a full-screen tile spanning two 5K displays,
310    /// 10240×2822, configured against a device capped at 8192.
311    #[test]
312    fn oversize_extent_is_clamped_to_the_device_limit() {
313        assert_eq!(clamp_surface_extent(10240, 2822, 8192), (8192, 2822));
314    }
315
316    #[test]
317    fn extent_within_the_limit_is_unchanged() {
318        assert_eq!(clamp_surface_extent(3840, 2160, 8192), (3840, 2160));
319    }
320
321    #[test]
322    fn extent_at_the_limit_is_unchanged() {
323        assert_eq!(clamp_surface_extent(8192, 8192, 8192), (8192, 8192));
324    }
325
326    /// `configure` rejects a zero extent as well, so the clamp floors at one
327    /// and a call site cannot trade one validation failure for another.
328    #[test]
329    fn zero_extent_floors_to_one() {
330        assert_eq!(clamp_surface_extent(0, 0, 8192), (1, 1));
331    }
332
333    #[test]
334    fn texture_limits_raises_only_the_2d_dimension() {
335        assert_eq!(
336            texture_limits(16384),
337            wgpu::Limits {
338                max_texture_dimension_2d: 16384,
339                ..wgpu::Limits::default()
340            }
341        );
342    }
343
344    #[test]
345    fn uncaptured_errors_log_the_first_then_every_1000th() {
346        assert!(should_log_uncaptured_error(1));
347        assert!(!should_log_uncaptured_error(2));
348        assert!(!should_log_uncaptured_error(999));
349        assert!(should_log_uncaptured_error(1000));
350        assert!(!should_log_uncaptured_error(1001));
351    }
352
353    fn surface_config(present_mode: wgpu::PresentMode) -> wgpu::SurfaceConfiguration {
354        wgpu::SurfaceConfiguration {
355            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
356            format: wgpu::TextureFormat::Bgra8Unorm,
357            color_space: wgpu::SurfaceColorSpace::Auto,
358            width: 2560,
359            height: 1440,
360            present_mode,
361            alpha_mode: wgpu::CompositeAlphaMode::PreMultiplied,
362            view_formats: vec![],
363            desired_maximum_frame_latency: 2,
364        }
365    }
366
367    #[test]
368    fn alternate_mode_prefers_the_other_vsync_family() {
369        // The two halves of the measured heal: toggling vsync either way
370        // recovers, so the cycle must be able to leave the target's family.
371        assert_eq!(
372            alternate_present_mode(
373                wgpu::PresentMode::Fifo,
374                &[wgpu::PresentMode::Fifo, wgpu::PresentMode::Immediate],
375            ),
376            Some(wgpu::PresentMode::Immediate)
377        );
378        assert_eq!(
379            alternate_present_mode(
380                wgpu::PresentMode::Immediate,
381                &[wgpu::PresentMode::Fifo, wgpu::PresentMode::Immediate],
382            ),
383            Some(wgpu::PresentMode::Fifo)
384        );
385    }
386
387    #[test]
388    fn alternate_mode_falls_back_to_any_other_supported_mode() {
389        // Metal offers no Mailbox; FifoRelaxed is the only other option.
390        assert_eq!(
391            alternate_present_mode(
392                wgpu::PresentMode::Fifo,
393                &[wgpu::PresentMode::Fifo, wgpu::PresentMode::FifoRelaxed],
394            ),
395            Some(wgpu::PresentMode::FifoRelaxed)
396        );
397    }
398
399    #[test]
400    fn alternate_mode_is_none_when_target_is_the_only_mode() {
401        assert_eq!(
402            alternate_present_mode(wgpu::PresentMode::Fifo, &[wgpu::PresentMode::Fifo]),
403            None
404        );
405    }
406
407    #[test]
408    fn refresh_keeps_fields_the_new_topology_still_supports() {
409        let mut config = surface_config(wgpu::PresentMode::Fifo);
410        refresh_config_after_display_change(
411            &mut config,
412            &[
413                wgpu::TextureFormat::Bgra8Unorm,
414                wgpu::TextureFormat::Rgba8UnormSrgb,
415            ],
416            &[wgpu::PresentMode::Fifo, wgpu::PresentMode::Immediate],
417            &[
418                wgpu::CompositeAlphaMode::PreMultiplied,
419                wgpu::CompositeAlphaMode::Auto,
420            ],
421            1920,
422            1080,
423            8192,
424        );
425        assert_eq!(config.format, wgpu::TextureFormat::Bgra8Unorm);
426        assert_eq!(config.present_mode, wgpu::PresentMode::Fifo);
427        assert_eq!(config.alpha_mode, wgpu::CompositeAlphaMode::PreMultiplied);
428        assert_eq!((config.width, config.height), (1920, 1080));
429    }
430
431    #[test]
432    fn refresh_replaces_what_the_new_topology_dropped() {
433        let mut config = surface_config(wgpu::PresentMode::Mailbox);
434        refresh_config_after_display_change(
435            &mut config,
436            &[wgpu::TextureFormat::Rgba8Unorm],
437            &[wgpu::PresentMode::Fifo],
438            &[wgpu::CompositeAlphaMode::Opaque],
439            0,
440            0,
441            8192,
442        );
443        // Re-picked by the CellRenderer::new preference order.
444        assert_eq!(config.format, wgpu::TextureFormat::Rgba8Unorm);
445        assert_eq!(config.present_mode, wgpu::PresentMode::Fifo);
446        assert_eq!(config.alpha_mode, wgpu::CompositeAlphaMode::Opaque);
447        // The extent still passes through the configure-rejection clamp.
448        assert_eq!((config.width, config.height), (1, 1));
449    }
450
451    #[test]
452    fn refresh_clamps_oversize_extent() {
453        let mut config = surface_config(wgpu::PresentMode::Fifo);
454        let (format, present_mode, alpha_mode) =
455            (config.format, config.present_mode, config.alpha_mode);
456        refresh_config_after_display_change(
457            &mut config,
458            &[format],
459            &[present_mode],
460            &[alpha_mode],
461            10240,
462            2822,
463            8192,
464        );
465        assert_eq!((config.width, config.height), (8192, 2822));
466    }
467}