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
This commit is contained in:
Pavel Ivanov
2026-11-11 15:05:04 +01:00
committed by GitHub
parent 401f449729
commit 9e815546ba
3 changed files with 210 additions and 0 deletions
+2
View File
@@ -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).
+49
View File
@@ -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<Kept>,
submitted: Option<JobKey>,
/// The view of the last render asked for.
want_view: Option<figcraft_render::View>,
@@ -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;
@@ -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");
}