Skip to main content

max / shop

Tell the grid when the renderer has forgotten everything A shop window went permanently blank if the compositor changed the scale after the first frame and the program on the pty then went quiet. Reproduced on a HiDPI COSMIC session with `--exec 'cat file; sleep'`, and it predates the width work — the pre-change binary does it too. scale_factor_changed rebuilds the TextRenderer, whose per-row glyph cache is what the window actually draws from, and the new one is empty. It then re-queues a resize to make the surface reconfigure, and that is the part that does not work: the logical dimensions have not changed, so Grid::resize returns early, nothing is marked dirty, and damage — which is a diff — correctly reports that nothing changed. Both ends are right and the window stays empty until the shell happens to print. A shell prints on the next keystroke, which is why this survived daily use; a command that writes once and waits does not. Grid::invalidate_render is the renderer saying it forgot rather than the grid noticing anything, so it lives on the grid as a way to answer that rather than as a redraw request.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-09 15:35 UTC
Signed with PGP, not checked
Commit: 097f524aca5adc972fdbd0620f2e1c8d9478cf4f
Parent: 72854df
2 files changed, +42 insertions, -0 deletions
@@ -689,6 +689,24 @@
689 689 }
690 690 }
691 691
692 + /// Report every row as damaged, for a renderer that lost its own cache.
693 + ///
694 + /// Damage is a diff, and a diff only works while both ends agree on what
695 + /// the other has. The renderer keeps a per-row glyph cache and rebuilds
696 + /// only the rows damage names, so anything that throws that cache away —
697 + /// rebuilding the renderer on a scale change is the live case — leaves the
698 + /// two ends disagreeing: the grid has nothing new to report, the renderer
699 + /// has nothing at all, and the window stays empty until the program on the
700 + /// pty happens to print. A shell prints on the next keystroke and hides
701 + /// this; a command that writes once and waits does not.
702 + ///
703 + /// So this is not a redraw request but the renderer saying it forgot, and
704 + /// the grid answering with the whole screen.
705 + pub fn invalidate_render(&mut self) {
706 + self.mark_all_rows_dirty();
707 + self.view_dirty = true;
708 + }
709 +
692 710 pub fn cursor_shape(&self) -> CursorShape {
693 711 self.cursor_shape
694 712 }
@@ -3533,4 +3551,23 @@
3533 3551 let b = a;
3534 3552 assert_eq!(a, b);
3535 3553 }
3554 +
3555 + #[test]
3556 + fn invalidate_render_reports_every_row_however_quiet_the_grid_is() {
3557 + // The renderer rebuilt itself and lost its cache. Nothing about the
3558 + // grid changed, which is exactly why it cannot be left to report the
3559 + // difference: there isn't one, and the window would stay empty until
3560 + // the program on the pty happened to print.
3561 + let mut g = Grid::new(8, 4);
3562 + feed(&mut g, b"one\r\ntwo");
3563 + let _ = g.take_damage();
3564 + assert!(
3565 + g.take_damage().dirty_rows.is_empty(),
3566 + "a quiet grid still had damage to report"
3567 + );
3568 + g.invalidate_render();
3569 + let d = g.take_damage();
3570 + assert_eq!(d.dirty_rows.len(), 4, "not every row came back");
3571 + assert!(d.view_moved, "the viewport was not invalidated with them");
3572 + }
3536 3573 }
@@ -1041,6 +1041,11 @@
1041 1041 return;
1042 1042 }
1043 1043 }
1044 + // The new renderer has an empty per-row cache and the grid has not
1045 + // changed, so without this nothing would ever name a row to rebuild.
1046 + // The resize queued below does not cover it: the logical dimensions are
1047 + // the same ones, and `Grid::resize` returns early when they are.
1048 + self.grid.invalidate_render();
1044 1049 // Re-queue a resize with the current logical dims so the surface
1045 1050 // reconfigures at the new physical resolution and set_buffer_scale
1046 1051 // fires.