From e99d1752a40c539866fbc6928532fead16fdb98f Mon Sep 17 00:00:00 2001 From: stawan15 Date: Mon, 28 Sep 2026 09:16:03 +0700 Subject: [PATCH] macOS: hidden windows free their pixel buffers, idle spare surfaces are purgeable At a 1680x992 Retina window every copy of the screen is ~27 MB: the framebuffer plus up to three IOSurfaces, in every native tab (each is a window), visible or not. - A window that is hidden (background native tab, minimised, covered) frees its framebuffer and surfaces, and repaints from scratch when it shows again. Titles and OSC 52 still apply meanwhile. - A visible window marks surfaces it isn't showing as purgeable once the compositor lets go of them, so macOS doesn't count them and may take them back; they are rewritten if it does. - Tests no longer read the user's config file. Footprint (1680x992 window, idle shell): 1 tab 100-126 MB -> 77 MB, 3 tabs 255 MB -> 84 MB. --- src/config.rs | 4 ++++ src/main.rs | 60 ++++++++++++++++++++++++++++++++++++++++++++++++-- src/present.rs | 53 ++++++++++++++++++++++++++++++++++++++++++-- src/render.rs | 17 ++++++++++++++ 4 files changed, 130 insertions(+), 4 deletions(-) diff --git a/src/config.rs b/src/config.rs index 18abbbd..9cf1b6d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -120,6 +120,10 @@ fn keybind(v: &str) -> Option { } fn path() -> Option { + // Tests use the defaults, whatever the person running them has configured. + if cfg!(test) { + return None; + } let base = std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?; Some(base.join("litty/config")) } diff --git a/src/main.rs b/src/main.rs index 9461b57..aca8d6e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -362,6 +362,10 @@ struct Win { find: Option, /// A short message in the corner ("saved ~/Desktop/…cast") and when it goes away. flash: Option<(String, Instant)>, + /// Hidden (a background native tab, minimised, covered): pixel buffers are freed meanwhile. + occluded: bool, + /// When to let macOS reclaim the spare surfaces of a window that stopped drawing. + trim_at: Option, /// Several lines waiting for Enter before they are pasted into a shell: (bytes, lines). pending_paste: Option<(Vec, usize)>, /// The link under the mouse when the right-click menu opened. @@ -388,6 +392,8 @@ const PAD_PT: f32 = 10.0; /// Minimum time between frames. Drawing holds the grid lock, so capping it keeps the parser /// thread fed during floods like `cat bigfile`. const FRAME: Duration = Duration::from_millis(15); +/// A window that stopped drawing for this long gives its spare surfaces back (macOS). +const TRIM_AFTER: Duration = Duration::from_millis(250); const BLINK: Duration = Duration::from_millis(530); const DOUBLE_CLICK: Duration = Duration::from_millis(400); /// Longest a synchronized update (mode 2026) may hold the screen back if the program never ends it. @@ -947,7 +953,11 @@ impl Win { /// Rebuild fonts for the current point size / display scale, then re-layout. fn rebuild(&mut self) { let pad = config::get().padding.unwrap_or(PAD_PT); - self.renderer = Some(Renderer::new(self.font_pt * self.scale, (pad * self.scale) as usize)); + let mut renderer = Renderer::new(self.font_pt * self.scale, (pad * self.scale) as usize); + if self.occluded { + renderer.release(); + } + self.renderer = Some(renderer); if let (Some(size), Some(r), Some(p)) = (self.window.as_ref().map(|w| w.inner_size()), &mut self.renderer, &mut self.presenter) { p.resize(size.width as usize, size.height as usize); r.resize(size.width as usize, size.height as usize); @@ -956,6 +966,26 @@ impl Win { } fn redraw(&mut self) { + if self.occluded { + // Nothing to paint, but the (native) tab title and OSC 52 copies still apply. + let (mut title, mut clip) = (None, None); + let tab = &self.tabs[self.active]; + for pane in &tab.panes { + pane.pending.store(false, Ordering::SeqCst); + let mut t = pane.term.lock().unwrap(); + clip = clip.or(t.grid.clip.take()); + if pane.id == tab.active { + title = t.grid.title.take(); + } + } + if let (Some(title), Some(w)) = (title, &self.window) { + w.set_title(&title); + } + if let Some(clip) = clip { + clipboard_set(&clip); + } + return; + } // Titles for the tab bar, taken before locking any pane (never hold two locks). let titles: Vec = if self.tabs.len() > 1 { self.tabs @@ -1006,6 +1036,9 @@ impl Win { } let damage = r.take_damage(); presenter.present(&r.fb, damage); + if cfg!(target_os = "macos") { + self.trim_at = Some(Instant::now() + TRIM_AFTER); + } if let Some(title) = title { win.set_title(&title); } @@ -2288,6 +2321,8 @@ impl Win { flash: None, pending_paste: None, link: None, + occluded: false, + trim_at: None, #[cfg(not(target_os = "macos"))] menu: None, }; @@ -2314,7 +2349,12 @@ impl Win { /// Blink the cursor, and return when this window next needs waking (a postponed frame or a blink). fn tick(&mut self, now: Instant) -> Option { - let mut deadline = self.frame_deadline; + if let (Some(at), Some(p)) = (self.trim_at, &mut self.presenter) + && now >= at + { + self.trim_at = p.trim().then_some(now + TRIM_AFTER); + } + let mut deadline = self.frame_deadline.into_iter().chain(self.trim_at).min(); if let Some((_, until)) = self.flash { if now >= until { self.flash = None; @@ -2356,6 +2396,22 @@ impl Win { self.redraw(); } } + WindowEvent::Occluded(hidden) if hidden != self.occluded => { + self.occluded = hidden; + if let (Some(p), Some(r)) = (&mut self.presenter, &mut self.renderer) { + if hidden { + p.release(); + r.release(); + self.trim_at = None; + } else { + r.restore(); + for pane in self.tabs.iter().flat_map(|t| &t.panes) { + pane.term.lock().unwrap().grid.dirty.fill(true); + } + self.redraw_soon(); + } + } + } WindowEvent::ModifiersChanged(m) => self.mods = m.state(), WindowEvent::Focused(f) => { self.focused = f; diff --git a/src/present.rs b/src/present.rs index cbbc57c..173d3e7 100644 --- a/src/present.rs +++ b/src/present.rs @@ -31,6 +31,13 @@ mod other { } } + /// Nothing to free: softbuffer keeps one buffer. + pub fn release(&mut self) {} + + pub fn trim(&mut self) -> bool { + false + } + pub fn present(&mut self, fb: &[u32], damage: Option<(usize, usize)>) { if damage.is_none() { return; @@ -52,7 +59,7 @@ mod mac { use objc2::runtime::{AnyObject, Bool}; use objc2_core_foundation::{CFDictionary, CFRetained, CGPoint, CGRect, CGSize}; use objc2_foundation::{NSDictionary, NSNumber, NSString, ns_string}; - use objc2_io_surface::{IOSurfaceLockOptions, IOSurfaceRef}; + use objc2_io_surface::{IOSurfaceLockOptions, IOSurfacePurgeabilityState, IOSurfaceRef}; use objc2_quartz_core::{CALayer, CATransaction, kCAGravityTopLeft}; use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle}; @@ -67,6 +74,9 @@ mod mac { surfaces: Vec>, /// Per surface: rows that changed since it was last written. stale: Vec>, + /// Per surface: marked purgeable while not needed, so macOS may take its memory back + /// (and doesn't count it against litty). + volatile: Vec, shown: usize, size: (usize, usize), /// False while the background is see-through (config `background-opacity`). @@ -86,18 +96,47 @@ mod mac { layer.setOpaque(true); layer.setContentsGravity(unsafe { kCAGravityTopLeft }); root.addSublayer(&layer); - Presenter { window: window.clone(), layer, surfaces: Vec::new(), stale: Vec::new(), shown: 0, size: (0, 0), opaque: true } + Presenter { window: window.clone(), layer, surfaces: Vec::new(), stale: Vec::new(), volatile: Vec::new(), shown: 0, size: (0, 0), opaque: true } } pub fn resize(&mut self, w: usize, h: usize) { self.size = (w, h); self.surfaces.clear(); self.stale.clear(); + self.volatile.clear(); let scale = self.window.scale_factor(); self.layer.setContentsScale(scale); self.layer.setBounds(CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(w as f64 / scale, h as f64 / scale))); } + /// The window is hidden: drop every surface (the next frame makes new ones). + pub fn release(&mut self) { + self.surfaces.clear(); + self.stale.clear(); + self.volatile.clear(); + // SAFETY: no contents is a valid value. + unsafe { self.layer.setContents(None) }; + } + + /// Mark surfaces that aren't shown and that the compositor let go of as purgeable. Returns + /// true while one is still in use, so the caller looks again shortly. + pub fn trim(&mut self) -> bool { + let mut busy = false; + for (i, s) in self.surfaces.iter().enumerate() { + if i == self.shown || self.volatile[i] { + continue; + } + if s.is_in_use() { + busy = true; + } else { + // SAFETY: a valid surface; the old state isn't needed. + unsafe { s.set_purgeable(IOSurfacePurgeabilityState::PurgeableVolatile.0, std::ptr::null_mut()) }; + self.volatile[i] = true; + } + } + busy + } + fn new_surface(&self) -> Option> { let num = |n: usize| NSNumber::new_usize(n); let keys = [ns_string!("IOSurfaceWidth"), ns_string!("IOSurfaceHeight"), ns_string!("IOSurfaceBytesPerElement"), ns_string!("IOSurfacePixelFormat")]; @@ -126,11 +165,21 @@ mod mac { let Some(s) = self.new_surface() else { return }; self.surfaces.push(s); self.stale.push(Some((0, self.size.1))); + self.volatile.push(false); n } None => (self.shown + 1) % n, }; let s = &self.surfaces[i]; + if std::mem::take(&mut self.volatile[i]) { + let mut old = 0; + // SAFETY: a valid surface and a place for its previous state. + unsafe { s.set_purgeable(IOSurfacePurgeabilityState::PurgeableNonVolatile.0, &mut old) }; + if old == IOSurfacePurgeabilityState::PurgeableEmpty.0 { + // macOS took the pixels back: write the whole frame. + self.stale[i] = Some((0, self.size.1)); + } + } let (w, stride) = (self.size.0, s.bytes_per_row() / 4); let (r0, r1) = self.stale[i].take().unwrap_or((0, 0)); // Below full opacity the background colour becomes see-through (premultiplied alpha). diff --git a/src/render.rs b/src/render.rs index 896d0c1..8cda4b2 100644 --- a/src/render.rs +++ b/src/render.rs @@ -185,6 +185,8 @@ pub struct Renderer { tmp: Vec, /// Rows (first, last + 1) whose pixels changed since the last `take_damage`. damage: Option<(usize, usize)>, + /// The window is hidden and its framebuffer freed (see `release`). + released: bool, /// Glyphs are only drawn inside this (x0, y0, x1, y1): one that overhangs its cell would leave /// pixels behind that no later repaint of the row erases. clip: [usize; 4], @@ -201,6 +203,7 @@ impl Renderer { bar_h: 0, tmp: Vec::new(), damage: None, + released: false, clip: [0; 4], } } @@ -213,11 +216,25 @@ impl Renderer { pub fn resize(&mut self, w: usize, h: usize) { self.w = w; self.h = h; + if self.released { + return; + } self.fb = vec![def_bg(); w * h]; self.damage = Some((0, h)); self.clip = [0, 0, w, h]; } + /// The window is hidden: free the framebuffer (a Retina window's is tens of MB). Nothing may + /// be drawn until `restore`, which starts over with a blank one. + pub fn release(&mut self) { + (self.released, self.fb) = (true, Vec::new()); + } + + pub fn restore(&mut self) { + self.released = false; + self.resize(self.w, self.h); + } + /// The area panes are laid out in: the window minus padding and the tab bar. pub fn area(&self) -> Rect { Rect {