From 9e815546ba1f51782bf2ef788e725f111a49c745 Mon Sep 17 00:00:00 2001 From: Pavel Ivanov Date: Wed, 11 Nov 2026 15:05:04 +0100 Subject: [PATCH] M8: switching back to a tab shows its page at once (#2075) * **No blank canvas** when switching back to a document tab or to a page in a file: the image it had when last shown is drawn from the first frame while it renders again * Images of up to four recently shown files and pages are kept; closing a tab frees its image right away Refs: #2074 --- crates/ui-egui/src/app.rs | 2 + crates/ui-egui/src/canvas.rs | 49 ++++++ crates/ui-egui/src/canvas/tab_switch_tests.rs | 159 ++++++++++++++++++ 3 files changed, 210 insertions(+) create mode 100644 crates/ui-egui/src/canvas/tab_switch_tests.rs diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 0267dd769..45dc80f09 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -268,6 +268,8 @@ impl FigApp { return; } if !self.has_files() || self.ui.home { + let docs = &self.session.docs; + self.canvas.drop_closed(|uid| docs.iter().any(|d| d.uid == uid)); home::show(self, ui); // The shortcuts panel belongs to the canvas; it doesn't hang over the home screen // (cleared after the home widgets, which may have just asked for it). diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 251ff25e6..02d0415ff 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -57,6 +57,10 @@ pub struct CanvasState { pub tex_coarse: bool, /// The request the texture answers ([`crate::render_worker::Done::seq`]). tex_seq: u64, + /// Refined images of the files and pages shown before, newest first: switching back to one + /// shows its image at once while it renders again, instead of a blank canvas (and, where + /// renders are slow, a coarse preview). + kept: Vec, submitted: Option, /// The view of the last render asked for. want_view: Option, @@ -98,6 +102,7 @@ impl Default for CanvasState { tex_of: None, tex_coarse: false, tex_seq: 0, + kept: Vec::new(), submitted: None, want_view: None, render_work: None, @@ -119,6 +124,45 @@ impl Default for CanvasState { } } +/// At most this many images of files and pages not shown are kept (each is a canvas-sized texture). +const KEPT: usize = 4; + +/// A refined canvas image of a file and page not shown now ([`CanvasState::kept`]). +struct Kept { + texture: egui::TextureHandle, + view: figcraft_render::View, + of: (u64, NodeId), + seq: u64, +} + +impl CanvasState { + /// When the file or page shown is no longer the one the texture is of, set the texture aside + /// (if refined) and bring back the image kept for `shown`, if any. Images of files that are no + /// longer open (`open` says which are) are dropped every frame, not only on a switch: a + /// background tab can close while the file shown stays the same. + fn switch_texture(&mut self, shown: Option<(u64, NodeId)>, open: impl Fn(u64) -> bool) { + if shown.is_some() && self.tex_of != shown { + let back = self.kept.iter().position(|k| Some(k.of) == shown).map(|i| self.kept.remove(i)); + let (texture, view, of) = (self.texture.take(), self.tex_view.take(), self.tex_of.take()); + if let (Some(texture), Some(view), Some(of), false) = (texture, view, of, self.tex_coarse) { + self.kept.insert(0, Kept { texture, view, of, seq: self.tex_seq }); + } + self.tex_coarse = false; + if let Some(k) = back { + (self.texture, self.tex_view, self.tex_of, self.tex_seq) = (Some(k.texture), Some(k.view), Some(k.of), k.seq); + } + } + self.drop_closed(open); + self.kept.truncate(KEPT); + } + + /// Drop the kept images of files that are no longer open (`open` says which are). Home calls + /// this too: the canvas isn't shown there, and closing the last file leads to it. + pub(crate) fn drop_closed(&mut self, open: impl Fn(u64) -> bool) { + self.kept.retain(|k| open(k.of.0)); + } +} + /// What the canvas shows while an IME composition is in progress: the document with the /// composition inserted at the caret (the real document only gets it on commit). pub struct PreeditView { @@ -264,6 +308,8 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { // Every finished render is shown, even when a newer one is already on its way: renders finish // in order, and an edit every frame (a drag) would otherwise never show until it stopped. let shown = app.session.ds().map(|ds| (ds.uid, ds.page)).ok(); + let docs = &app.session.docs; + app.canvas.switch_texture(shown, |uid| docs.iter().any(|d| d.uid == uid)); if let Some(done) = worker.poll() && shows(&app.canvas, &done, shown) { @@ -408,3 +454,6 @@ mod wheel_zoom_tests; #[cfg(test)] mod zoom_preview_tests; + +#[cfg(test)] +mod tab_switch_tests; diff --git a/crates/ui-egui/src/canvas/tab_switch_tests.rs b/crates/ui-egui/src/canvas/tab_switch_tests.rs new file mode 100644 index 000000000..f5629d3ef --- /dev/null +++ b/crates/ui-egui/src/canvas/tab_switch_tests.rs @@ -0,0 +1,159 @@ +//! Switching tabs: the canvas shows the image a file had when it was last on screen from the first +//! frame back, instead of a blank page (and, where renders are slow, a coarse preview) while the +//! file renders again. + +use egui::{Pos2, RawInput, Rect, vec2}; +use figcraft_engine::Session; +use serde_json::json; + +use crate::idle_tests::wait_for_render; +use crate::{FigApp, NoServices}; + +/// The whole UI, driven headless, with files of their own names open. +struct Harness { + ctx: egui::Context, + app: FigApp, + time: f64, +} + +impl Harness { + /// The UI with a file named after each of `names` (each with a rectangle); the last is shown. + fn new(names: &[&str]) -> Self { + figcraft_text::scan_system_fonts(); + let mut s = Session::empty(); + for n in names { + s.execute("file.new", &json!({"name": n})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 200, "height": 120})).unwrap(); + } + Self { ctx: egui::Context::default(), app: FigApp::new(s, Box::new(NoServices)), time: 0.0 } + } + + /// One frame with no input, 1/60 s after the last. + fn frame(&mut self) { + self.time += 1.0 / 60.0; + self.app.clock.set_test_now(Some(self.time)); + let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), ..Default::default() }; + let app = &mut self.app; + let mut out = self.ctx.run_ui(input, |ui| app.ui(ui)); + out.textures_delta.clear(); + } + + /// Frames until the canvas shows a refined image of the file and page on screen. + fn settle(&mut self) { + for _ in 0..200 { + self.frame(); + wait_for_render(&self.app); + let ds = self.app.session.ds().unwrap(); + let c = &self.app.canvas; + if c.tex_of == Some((ds.uid, ds.page)) && !c.tex_coarse && c.texture.is_some() { + return; + } + } + panic!("the canvas never showed a refined image"); + } + + /// Show the file named `name`, as clicking its tab does. + fn switch(&mut self, name: &str) { + let i = self.app.session.docs.iter().position(|d| d.doc.name == name).unwrap(); + self.app.run("doc.switch", json!({"index": i})); + } + + /// The file and page on screen. + fn shown(&self) -> (u64, figcraft_doc::NodeId) { + let ds = self.app.session.ds().unwrap(); + (ds.uid, ds.page) + } +} + +#[test] +fn switching_back_to_a_tab_shows_its_image_on_the_first_frame() { + let mut h = Harness::new(&["A", "B"]); + h.switch("A"); + h.settle(); + let a = (h.shown(), h.app.canvas.tex_seq, h.app.canvas.texture.as_ref().map(egui::TextureHandle::id)); + h.switch("B"); + h.settle(); + let b = (h.shown(), h.app.canvas.tex_seq); + assert_ne!(h.app.canvas.tex_of, Some(a.0)); + + // The image brought back is the one A had when it was last shown. Looked at without a frame: + // in one, a render of A asked for in it could already have come back and replaced it. + let c = &mut h.app.canvas; + c.switch_texture(Some(a.0), |_| true); + assert_eq!((c.tex_of, c.tex_seq, c.texture.as_ref().map(egui::TextureHandle::id)), (Some(a.0), a.1, a.2)); + c.switch_texture(Some(b.0), |_| true); + assert_eq!((c.tex_of, c.tex_seq), (Some(b.0), b.1), "B's image was kept in turn"); + + h.switch("A"); + h.frame(); + let c = &h.app.canvas; + assert_eq!(c.tex_of, Some(a.0), "A's image is on screen at once"); + assert!(!c.tex_coarse, "and it is the refined one, not a preview"); + assert!(c.kept.iter().any(|k| k.of == b.0), "B's image is kept in turn"); + h.settle(); +} + +#[test] +fn switching_back_to_a_page_shows_its_image_on_the_first_frame() { + let mut h = Harness::new(&["A"]); + h.settle(); + let first = (h.shown(), h.app.canvas.tex_seq, h.app.canvas.texture.as_ref().map(egui::TextureHandle::id)); + h.app.run("page.add", json!({})); + h.app.run("shape.ellipse", json!({"x": 40, "y": 40, "width": 120, "height": 120})); + h.settle(); + let second = (h.shown(), h.app.canvas.tex_seq); + assert_ne!(first.0, second.0); + + // Looked at without a frame, as for tabs: a render asked for in one could replace the image. + let c = &mut h.app.canvas; + c.switch_texture(Some(first.0), |_| true); + assert_eq!((c.tex_of, c.tex_seq, c.texture.as_ref().map(egui::TextureHandle::id)), (Some(first.0), first.1, first.2)); + c.switch_texture(Some(second.0), |_| true); + assert_eq!((c.tex_of, c.tex_seq), (Some(second.0), second.1), "the second page's image was kept in turn"); + + h.app.run("page.select", json!({"index": 0})); + h.frame(); + let c = &h.app.canvas; + assert_eq!(c.tex_of, Some(first.0), "the first page's image is on screen at once"); + assert!(!c.tex_coarse); + assert!(c.kept.iter().any(|k| k.of == second.0), "the second page's image is kept in turn"); + h.settle(); +} + +#[test] +fn kept_images_are_bounded_and_dropped_with_their_files() { + let names = ["A", "B", "C", "D", "E", "F", "G"]; + let mut h = Harness::new(&names); + for n in names { + h.switch(n); + h.settle(); + } + assert_eq!(h.app.canvas.kept.len(), super::KEPT); + + let f = h.app.session.docs.iter().position(|d| d.doc.name == "F").unwrap(); + let f_uid = h.app.session.docs[f].uid; + assert!(h.app.canvas.kept.iter().any(|k| k.of.0 == f_uid)); + // A tab closed in the background: the file shown stays the same. + let before = h.shown(); + h.app.run("file.close", json!({"index": f, "force": true})); + h.frame(); + assert_eq!(h.shown(), before); + assert!(h.app.canvas.kept.iter().all(|k| k.of.0 != f_uid), "a closed file's image goes"); +} + +#[test] +fn closing_the_last_file_drops_its_kept_images() { + let mut h = Harness::new(&["A", "B"]); + h.switch("A"); + h.settle(); + h.switch("B"); + h.settle(); + assert_eq!(h.app.canvas.kept.len(), 1, "A's image is kept"); + // Both close before the next frame, which then shows Home, not the canvas. + let a = h.app.session.docs.iter().position(|d| d.doc.name == "A").unwrap(); + h.app.run("file.close", json!({"index": a, "force": true})); + h.app.run("file.close", json!({"index": 0, "force": true})); + assert!(h.app.session.docs.is_empty()); + h.frame(); + assert!(h.app.canvas.kept.is_empty(), "no closed file's image stays behind Home"); +}