git.lucas.co / cce-lock
session locker (ext-session-lock + PAM)
git clone https://git.lucas.co/cce-lock.git

commitb316e1020568ee0956aa19f4679b001432a7ec55
parent702aa7c6b3
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-05 21:53
perf: paint the lock screen when it changes, not continuously

The main loop dispatched with a 50 ms timeout and then repainted every
output on every pass, unconditionally: scene rebuild, tessellation, a
cosmic-text reshape of every label, and a full-damage present. Each
present makes the compositor answer (buffer release, frame events), and
those wake the dispatch at once, so the loop never waited out its 50 ms
-- it painted as fast as frames would present, for as long as the
screen stayed locked. Nothing on the lock screen moves.

Now a `dirty` flag is set by whatever changes the screen (configure,
scale, keys, Caps Lock, an auth result or PAM message, the deferred-
unlock status), the loop paints once per batch of events while it is
set, and dispatch blocks with no timeout: the Wayland source, the auth
channel and the keyboard repeat timer are all calloop sources, and the
Wayland source flushes before sleeping. A frame that fails to present
is retried on a 50 ms timeout until it does.

Measured in a scale-2 shadow (headless, so presents are unthrottled):
  before: 11532 frames in 5 s idle; 3984 frames while typing 3 chars
  after:  0 frames in 5 s idle; 3 frames for 3 chars
Prompt, dots, Escape and the empty-Return status all repaint (shots).
No password was submitted: a wrong one counts against faillock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 src/main.rs | 65 +++++++++++++++++++++++++++++++++++++++++--------------------
 1 file changed, 44 insertions(+), 21 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index 7c1dbf2..97a02b2 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -129,6 +129,11 @@ struct AppState {
     /// before exiting.
     unlocked: bool,
     exit: bool,
+    /// Something on screen changed since the last paint. The main loop paints
+    /// only then — until 2026-10-05 it repainted every output on every pass of
+    /// a 50 ms dispatch, ~20 full frames a second for as long as the screen
+    /// stayed locked, with nothing on it moving.
+    dirty: bool,
 
     font_system: FontSystem,
     swash_cache: SwashCache,
@@ -194,6 +199,7 @@ impl AppState {
             self.phase = Phase::Prompt;
             self.status = Some("Locking, one moment…".to_string());
             self.unlock_when_locked = true;
+            self.dirty = true;
             return;
         }
         let Some(lock) = self.lock.take() else {
@@ -234,18 +240,30 @@ impl AppState {
         );
     }
 
+    /// Ask for a repaint of every output; the main loop does it once the
+    /// events in hand are handled.
     fn draw_all(&mut self, _qh: &QueueHandle<Self>) {
+        self.dirty = true;
+    }
+
+    /// Paint every output now. False when a frame did not present (swapchain
+    /// out of date) and the paint must be retried.
+    fn paint_all(&mut self) -> bool {
         let ids: Vec<u32> = self.outputs.keys().copied().collect();
+        let mut ok = true;
         for id in ids {
-            self.draw(id);
+            ok &= self.draw(id);
         }
+        ok
     }
 
-    /// Paint one output.
-    fn draw(&mut self, id: u32) {
-        let Some(out) = self.outputs.get(&id) else { return };
+    /// Paint one output. False only when a frame was drawn and did not
+    /// present; an output with nothing to paint on yet (no configure, no
+    /// renderer) is not a failure — its configure asks for a paint.
+    fn draw(&mut self, id: u32) -> bool {
+        let Some(out) = self.outputs.get(&id) else { return true };
         if !out.configured || out.width <= 0.0 || out.height <= 0.0 {
-            return;
+            return true;
         }
         let (w, h, scale) = (out.width, out.height, out.scale);
 
@@ -274,8 +292,8 @@ impl AppState {
         // Split the borrow: the renderer lives in the map, the font system on
         // self, and prepare_text needs both at once.
         let Self { outputs, font_system, swash_cache, .. } = self;
-        let Some(out) = outputs.get_mut(&id) else { return };
-        let Some(renderer) = out.renderer.as_mut() else { return };
+        let Some(out) = outputs.get_mut(&id) else { return true };
+        let Some(renderer) = out.renderer.as_mut() else { return true };
         renderer.prepare_text(font_system, swash_cache, &spans);
         renderer.draw_frame_2d(Frame2D {
             verts: &verts,
@@ -286,7 +304,7 @@ impl AppState {
             clear_color: [0.0, 0.0, 0.0, 1.0],
             // Always a full frame: the lock screen repaints whole.
             damage: None,
-        });
+        })
     }
 
     /// The lock screen itself: an opaque ground, a centred card, the password
@@ -518,7 +536,7 @@ impl Dispatch<ExtSessionLockSurfaceV1, u32> for AppState {
                     }
                 }
             }
-            state.draw(*id);
+            state.dirty = true;
         }
     }
 }
@@ -544,7 +562,7 @@ impl CompositorHandler for AppState {
                 r.resize((out.width * out.scale) as u32, (out.height * out.scale) as u32);
             }
         }
-        self.draw(id);
+        self.dirty = true;
     }
 
     fn transform_changed(
@@ -930,6 +948,7 @@ fn main() {
         unlock_when_locked: false,
         unlocked: false,
         exit: false,
+        dirty: false,
         font_system: cce_ui::create_font_system(),
         swash_cache: SwashCache::new(),
         auth_tx,
@@ -954,9 +973,11 @@ fn main() {
                 auth::AuthEvent::Failure { msg } => {
                     state.phase = Phase::Prompt;
                     state.status = Some(msg);
+                    state.dirty = true;
                 }
                 auth::AuthEvent::Info { msg } => {
                     state.status = Some(msg);
+                    state.dirty = true;
                 }
             }
         })
@@ -966,20 +987,22 @@ fn main() {
         .insert(event_loop.handle())
         .expect("wayland source");
 
+    // Blocks until something happens: every wake-up is an event source on
+    // this loop — the Wayland connection, the auth channel, the keyboard's
+    // repeat timer. The one exception is a frame that failed to present,
+    // which is retried on a short timeout until it does.
+    let mut retry_paint = false;
     while !state.exit {
-        if event_loop
-            .dispatch(std::time::Duration::from_millis(50), &mut state)
-            .is_err()
-        {
+        let timeout = retry_paint.then(|| std::time::Duration::from_millis(50));
+        if event_loop.dispatch(timeout, &mut state).is_err() {
             break;
         }
-        // Redraw outside the event handlers: an auth result arrives on the
-        // calloop channel with no qh in scope.
-        if !matches!(state.phase, Phase::Unlocking) {
-            let ids: Vec<u32> = state.outputs.keys().copied().collect();
-            for id in ids {
-                state.draw(id);
-            }
+        // Paint outside the event handlers, once per batch of events: an auth
+        // result arrives on the calloop channel with no qh in scope, and a
+        // burst of keys is one frame, not one each.
+        if (state.dirty || retry_paint) && !matches!(state.phase, Phase::Unlocking) {
+            state.dirty = false;
+            retry_paint = !state.paint_all();
         }
     }