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