Merge #305: M1: save files automatically (#296) (@AganzeFelicite)

# Conflicts:
#	crates/ui-egui/src/idle_tests.rs
#	docs/mcp.md
This commit is contained in:
bflatastic
2026-10-15 19:53:52 +07:00
16 changed files with 279 additions and 9 deletions
+49
View File
@@ -73,3 +73,52 @@ fn autosaves_keep_their_flag_in_the_file() {
assert!(!autosave(&mut t));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn files_that_save_themselves_still_get_checkpoints() {
let dir = std::env::temp_dir().join(format!("figcraft-autosave-on-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("auto.figcraft").to_string_lossy().to_string();
let mut s = Session::new();
s.execute("pref.autoSave", &json!({"on": true})).unwrap();
rect(&mut s, 0.0);
s.execute("file.save", &json!({"path": path})).unwrap();
assert!(autosave(&mut s), "saved, but changed since the last version");
assert!(!autosave(&mut s), "no change since the checkpoint");
// Just opened: nothing to checkpoint.
let mut t = Session::empty();
t.execute("pref.autoSave", &json!({"on": true})).unwrap();
t.execute("file.open", &json!({"path": path})).unwrap();
assert!(!autosave(&mut t));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_file_encoded_elsewhere_is_saved_as_of_its_snapshot() {
use crate::cmd::file::FileSnapshot;
let dir = std::env::temp_dir().join(format!("figcraft-snapshot-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("snap.figcraft").to_string_lossy().to_string();
let mut s = Session::new();
s.execute("file.save", &json!({"path": path})).unwrap();
rect(&mut s, 0.0);
let file = FileSnapshot::of(s.ds().unwrap());
let bytes = file.encode().unwrap();
rect(&mut s, 50.0);
assert!(file.save(&mut s, &bytes).unwrap());
assert!(s.ds().unwrap().is_dirty(), "the edit since the snapshot is still unsaved");
let mut t = Session::empty();
t.execute("file.open", &json!({"path": path})).unwrap();
assert_eq!(t.ds().unwrap().doc.page_content_bounds(t.ds().unwrap().page).unwrap().x1, 10.0, "the file is the snapshot");
// Saved meanwhile: the older bytes never overwrite the newer file.
let file = FileSnapshot::of(s.ds().unwrap());
let bytes = file.encode().unwrap();
rect(&mut s, 100.0);
s.execute("file.save", &json!({})).unwrap();
assert!(!file.save(&mut s, &bytes).unwrap());
assert!(!s.ds().unwrap().is_dirty());
let mut t = Session::empty();
t.execute("file.open", &json!({"path": path})).unwrap();
assert_eq!(t.ds().unwrap().doc.page_content_bounds(t.ds().unwrap().page).unwrap().x1, 110.0);
let _ = std::fs::remove_dir_all(&dir);
}
+71 -9
View File
@@ -195,7 +195,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"version.autosave",
"Autosave version",
"{} save an automatic checkpoint when the document has unsaved changes since the last version (the app does this every 30 minutes); keeps the newest 20 autosaves → {saved, version?}",
"{} save an automatic checkpoint when the document has unsaved changes since the last version (with `pref.autoSave` on: changes made since it was opened); the app does this every 30 minutes; keeps the newest 20 autosaves → {saved, version?}",
false,
autosave
),
@@ -257,8 +257,11 @@ pub const MAX_AUTOSAVES: usize = 20;
/// `version.autosave`: a checkpoint of a document with unsaved changes since the last version.
fn autosave(s: &mut Session, _: &Value) -> Result<Value> {
let auto_save = s.prefs.auto_save;
let ds = s.ds_mut()?;
if ds.preview.is_some() || !ds.is_dirty() || !ds.changed_since_checkpoint() {
// A file that saves itself is rarely unsaved: edits since it was opened count then.
let edited = ds.is_dirty() || (auto_save && !ds.history.undo.is_empty());
if ds.preview.is_some() || !edited || !ds.changed_since_checkpoint() {
return Ok(json!({"saved": false}));
}
Ok(match add_version(ds, AUTOSAVE, "", true) {
@@ -693,10 +696,71 @@ pub fn thumbnail(doc: &Document) -> Option<Vec<u8>> {
/// The `.figcraft` file for a document: content, thumbnail and version history.
pub fn file_bytes(ds: &DocState) -> Result<Vec<u8>> {
let mut doc = (*ds.doc).clone();
doc.current_page = Some(ds.page);
let thumb = thumbnail(&doc);
figcraft_format::save_bytes_with_history(&doc, thumb.as_deref(), &ds.versions, &ds.branches).map_err(|e| EngineError::Other(e.to_string()))
FileSnapshot::of(ds).encode()
}
/// A document's file as it is at one moment, so the slow part of saving it (the thumbnail and the
/// encoding) can run on another thread while editing goes on: [`encode`](Self::encode) there, then
/// [`save`](Self::save) the bytes back on the session's thread.
pub struct FileSnapshot {
uid: u64,
path: Option<String>,
revision: u64,
doc: Arc<Document>,
/// The document as last saved when the snapshot was taken: another save since makes it stale.
saved: Arc<Document>,
page: NodeId,
versions: Vec<Version>,
branches: Vec<figcraft_format::Branch>,
}
impl FileSnapshot {
pub fn of(ds: &DocState) -> Self {
Self {
uid: ds.uid,
path: ds.path.clone(),
revision: ds.revision,
doc: ds.doc.clone(),
saved: ds.saved_doc.clone(),
page: ds.page,
versions: ds.versions.clone(),
branches: ds.branches.clone(),
}
}
/// The file's bytes: content, thumbnail and version history.
pub fn encode(&self) -> Result<Vec<u8>> {
let mut doc = (*self.doc).clone();
doc.current_page = Some(self.page);
let thumb = thumbnail(&doc);
figcraft_format::save_bytes_with_history(&doc, thumb.as_deref(), &self.versions, &self.branches)
.map_err(|e| EngineError::Other(e.to_string()))
}
/// Write `bytes` (this snapshot's [`encode`](Self::encode)) to the document's file: it is saved
/// as of the snapshot, and edits since stay unsaved. False (nothing written) when the document
/// was closed, saved, reverted or given another path since: the bytes are out of date.
pub fn save(self, s: &mut Session, bytes: &[u8]) -> Result<bool> {
let Some(ds) = s.docs.iter_mut().find(|d| d.uid == self.uid && d.path == self.path && Arc::ptr_eq(&d.saved_doc, &self.saved)) else {
return Ok(false);
};
write(self.path.as_deref().ok_or(EngineError::NoDocument)?, bytes)?;
// The history or branches may have changed since: then they still count as unsaved.
if ds.revision == self.revision {
ds.versions_changed = false;
}
ds.saved_doc = self.doc;
ds.revision += 1;
branch_tabs_saved(s, self.uid);
Ok(true)
}
}
/// The branch tabs of the file in tab `uid` were saved with it.
fn branch_tabs_saved(s: &mut Session, uid: u64) {
for tab in s.docs.iter_mut().filter(|d| d.branch_of.as_ref().is_some_and(|(m, _)| *m == uid)) {
tab.mark_saved();
}
}
fn color_profile(s: &mut Session, p: &Value) -> Result<Value> {
@@ -721,9 +785,7 @@ fn save_to(s: &mut Session, path: String) -> Result<Value> {
ds.mark_saved();
ds.revision += 1;
let uid = ds.uid;
for tab in s.docs.iter_mut().filter(|d| d.branch_of.as_ref().is_some_and(|(m, _)| *m == uid)) {
tab.mark_saved();
}
branch_tabs_saved(s, uid);
remember(s, &path);
Ok(json!({"path": path, "bytes": bytes.len()}))
}
+1
View File
@@ -334,6 +334,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("pref.showDimensions", "Show dimensions on objects", "{on?}", false, |s, p| pref(s, p, |x| &mut x.show_dimensions)),
spec!("pref.numberKeysOpacity", "Use number keys for opacity", "{on?}", false, |s, p| pref(s, p, |x| &mut x.number_keys_opacity)),
spec!("pref.reduceMotion", "Reduce motion", "{on?}", false, |s, p| pref(s, p, |x| &mut x.reduce_motion)),
spec!("pref.autoSave", "Save files automatically", "{on?}", false, |s, p| pref(s, p, |x| &mut x.auto_save)),
spec!("pref.nudge", "Nudge amount", "{small?, big?}", false, |s, p| {
s.prefs.nudge_small = num(p, "small").unwrap_or(s.prefs.nudge_small).clamp(0.01, 1000.0);
s.prefs.nudge_big = num(p, "big").unwrap_or(s.prefs.nudge_big).clamp(0.01, 10000.0);
+4
View File
@@ -573,6 +573,9 @@ pub struct Prefs {
/// Reduce motion: the interface's own animations are off (the system's setting turns them off
/// too). Prototype transitions still play.
pub reduce_motion: bool,
/// Files that have a path are saved by themselves a few seconds after they change (the app's
/// timer; off: only File ▸ Save writes them).
pub auto_save: bool,
/// `figcraft://` invite links open this app (`collab.registerLinks`); `None`: on for installed
/// builds, off for development builds.
pub invite_links: Option<bool>,
@@ -625,6 +628,7 @@ impl Default for Prefs {
language: "system".into(),
user_name: cmd::comment::DEFAULT_AUTHOR.into(),
reduce_motion: false,
auto_save: false,
invite_links: None,
plugins: Vec::new(),
plugin_settings: BTreeMap::new(),
+3
View File
@@ -302,6 +302,7 @@ status = "draft — needs native review"
"Corner radius" = "Eckenradius"
"Corner smoothing" = "Eckenglättung"
"Couldn't reach {server}: showing what this computer remembers." = "{server} war nicht erreichbar: Es wird angezeigt, was dieser Computer gespeichert hat."
"Couldn't save automatically: {error}" = "Automatisches Speichern fehlgeschlagen: {error}"
"Count" = "Anzahl"
"Create" = "Erstellen"
"Create a variable collection first" = "Erstellen Sie zuerst eine Variablensammlung"
@@ -1071,6 +1072,7 @@ status = "draft — needs native review"
"Save" = "Speichern"
"Save as" = "Speichern unter"
"Save as preset…" = "Als Vorgabe speichern…"
"Save files automatically" = "Dateien automatisch speichern"
"Save version {key}" = "Version speichern {key}"
"Saved" = "Gespeichert"
"Saved {name}" = "{name} gespeichert"
@@ -1785,6 +1787,7 @@ status = "draft — needs native review"
"plugin.ui" = "Plugin-Bereich"
"plugins.manage" = "Plugins verwalten…"
"plugins.runLast" = "Letztes Plugin ausführen"
"pref.autoSave" = "Dateien automatisch speichern"
"pref.highlightHover" = "Ebenen beim Überfahren hervorheben"
"pref.keepTool" = "Werkzeug nach Verwendung ausgewählt lassen"
"pref.language" = "Sprache der Benutzeroberfläche"
+2
View File
@@ -311,6 +311,7 @@ name = "English"
"Corner radius" = "Corner radius"
"Corner smoothing" = "Corner smoothing"
"Couldn't reach {server}: showing what this computer remembers." = "Couldn't reach {server}: showing what this computer remembers."
"Couldn't save automatically: {error}" = "Couldn't save automatically: {error}"
"Count" = "Count"
"Create" = "Create"
"Create a variable collection first" = "Create a variable collection first"
@@ -1113,6 +1114,7 @@ name = "English"
"Rulers" = "Rulers"
"Run" = "Run"
"Run {label}" = "Run {label}"
"Save files automatically" = "Save files automatically"
"Search templates" = "Search templates"
"See all templates" = "See all templates"
"Select layers and they space themselves as their content changes" = "Select layers and they space themselves as their content changes"
+3
View File
@@ -312,6 +312,7 @@ status = "draft — needs native review"
"Corner radius" = "Radio de esquina"
"Corner smoothing" = "Suavizado de esquinas"
"Couldn't reach {server}: showing what this computer remembers." = "No se pudo contactar con {server}: se muestra lo que este ordenador recuerda."
"Couldn't save automatically: {error}" = "No se pudo guardar automáticamente: {error}"
"Count" = "Número"
"Create" = "Crear"
"Create a variable collection first" = "Crea primero una colección de variables"
@@ -1114,6 +1115,7 @@ status = "draft — needs native review"
"Rulers" = "Reglas"
"Run" = "Ejecutar"
"Run {label}" = "Ejecutar {label}"
"Save files automatically" = "Guardar archivos automáticamente"
"Search templates" = "Buscar plantillas"
"See all templates" = "Ver todas las plantillas"
"Select layers and they space themselves as their content changes" = "Selecciona capas y se espaciarán solas cuando cambie su contenido"
@@ -1866,6 +1868,7 @@ status = "draft — needs native review"
"plugin.ui" = "Panel del plugin"
"plugins.manage" = "Gestionar plugins…"
"plugins.runLast" = "Ejecutar el último plugin"
"pref.autoSave" = "Guardar archivos automáticamente"
"pref.highlightHover" = "Resaltar capas al pasar el cursor"
"pref.keepTool" = "Mantener la herramienta tras usarla"
"pref.language" = "Idioma de la interfaz"
+3
View File
@@ -302,6 +302,7 @@ status = "draft — needs native review"
"Corner radius" = "Rayon d’angle"
"Corner smoothing" = "Lissage des angles"
"Couldn't reach {server}: showing what this computer remembers." = "Impossible de joindre {server} : affichage de ce dont cet ordinateur se souvient."
"Couldn't save automatically: {error}" = "Impossible d’enregistrer automatiquement : {error}"
"Count" = "Nombre"
"Create" = "Créer"
"Create a variable collection first" = "Créez d’abord une collection de variables"
@@ -1071,6 +1072,7 @@ status = "draft — needs native review"
"Save" = "Enregistrer"
"Save as" = "Enregistrer sous"
"Save as preset…" = "Enregistrer comme préréglage…"
"Save files automatically" = "Enregistrer les fichiers automatiquement"
"Save version {key}" = "Enregistrer une version {key}"
"Saved" = "Enregistré"
"Saved {name}" = "{name} enregistré"
@@ -1785,6 +1787,7 @@ status = "draft — needs native review"
"plugin.ui" = "Panneau du plugin"
"plugins.manage" = "Gérer les plugins…"
"plugins.runLast" = "Exécuter le dernier plugin"
"pref.autoSave" = "Enregistrer les fichiers automatiquement"
"pref.highlightHover" = "Mettre en évidence les calques au survol"
"pref.keepTool" = "Garder l’outil sélectionné après usage"
"pref.language" = "Langue de l’interface"
+3
View File
@@ -314,6 +314,7 @@ status = "draft — needs native review"
"Corner radius" = "角の半径"
"Corner smoothing" = "角のスムージング"
"Couldn't reach {server}: showing what this computer remembers." = "{server} に接続できない:このコンピューターが覚えている内容を表示中。"
"Couldn't save automatically: {error}" = "自動保存できませんでした:{error}"
"Count" = "数"
"Create" = "作成"
"Create a variable collection first" = "先に変数コレクションを作成"
@@ -1122,6 +1123,7 @@ status = "draft — needs native review"
"Save" = "保存"
"Save as" = "名前を付けて保存"
"Save as preset…" = "プリセットとして保存…"
"Save files automatically" = "ファイルを自動保存"
"Save version {key}" = "バージョンを保存 {key}"
"Saved" = "保存済み"
"Saved {name}" = "{name} を保存済み"
@@ -1868,6 +1870,7 @@ status = "draft — needs native review"
"plugin.ui" = "プラグインパネル"
"plugins.manage" = "プラグインを管理…"
"plugins.runLast" = "最後のプラグインを実行"
"pref.autoSave" = "ファイルを自動保存"
"pref.highlightHover" = "ホバー時にレイヤーをハイライト"
"pref.keepTool" = "使用後もツールを選択したままにする"
"pref.language" = "インターフェースの言語"
+3
View File
@@ -303,6 +303,7 @@ status = "draft — needs native review"
"Corner radius" = "모서리 반경"
"Corner smoothing" = "모서리 스무딩"
"Couldn't reach {server}: showing what this computer remembers." = "{server}에 연결할 수 없음: 이 컴퓨터가 기억하는 내용을 표시함."
"Couldn't save automatically: {error}" = "자동으로 저장할 수 없음: {error}"
"Count" = "개수"
"Create" = "만들기"
"Create a variable collection first" = "먼저 변수 컬렉션을 만드세요"
@@ -1072,6 +1073,7 @@ status = "draft — needs native review"
"Save" = "저장"
"Save as" = "다른 이름으로 저장"
"Save as preset…" = "프리셋으로 저장…"
"Save files automatically" = "파일 자동 저장"
"Save version {key}" = "버전 저장 {key}"
"Saved" = "저장됨"
"Saved {name}" = "{name} 저장됨"
@@ -1786,6 +1788,7 @@ status = "draft — needs native review"
"plugin.ui" = "플러그인 패널"
"plugins.manage" = "플러그인 관리…"
"plugins.runLast" = "마지막 플러그인 실행"
"pref.autoSave" = "파일 자동 저장"
"pref.highlightHover" = "마우스를 올리면 레이어 강조"
"pref.keepTool" = "사용 후에도 도구 선택 유지"
"pref.language" = "인터페이스 언어"
+3
View File
@@ -301,6 +301,7 @@ status = "draft — needs native review"
"Corner radius" = "Raio do canto"
"Corner smoothing" = "Suavização de cantos"
"Couldn't reach {server}: showing what this computer remembers." = "Não foi possível alcançar {server}: mostrando o que este computador lembra."
"Couldn't save automatically: {error}" = "Não foi possível salvar automaticamente: {error}"
"Count" = "Quantidade"
"Create" = "Criar"
"Create a variable collection first" = "Crie primeiro uma coleção de variáveis"
@@ -1070,6 +1071,7 @@ status = "draft — needs native review"
"Save" = "Salvar"
"Save as" = "Salvar como"
"Save as preset…" = "Salvar como predefinição…"
"Save files automatically" = "Salvar arquivos automaticamente"
"Save version {key}" = "Salvar versão {key}"
"Saved" = "Salvo"
"Saved {name}" = "{name} salvo"
@@ -1784,6 +1786,7 @@ status = "draft — needs native review"
"plugin.ui" = "Painel do plugin"
"plugins.manage" = "Gerenciar plugins…"
"plugins.runLast" = "Executar o último plugin"
"pref.autoSave" = "Salvar arquivos automaticamente"
"pref.highlightHover" = "Destacar camadas ao passar o cursor"
"pref.keepTool" = "Manter a ferramenta selecionada após o uso"
"pref.language" = "Idioma da interface"
+3
View File
@@ -304,6 +304,7 @@ status = "draft — needs native review"
"Corner radius" = "圆角半径"
"Corner smoothing" = "圆角平滑"
"Couldn't reach {server}: showing what this computer remembers." = "无法连接 {server}:显示的是这台电脑记录的内容。"
"Couldn't save automatically: {error}" = "无法自动保存:{error}"
"Count" = "数量"
"Create" = "创建"
"Create a variable collection first" = "请先创建变量集合"
@@ -1073,6 +1074,7 @@ status = "draft — needs native review"
"Save" = "保存"
"Save as" = "另存为"
"Save as preset…" = "存储为预设…"
"Save files automatically" = "自动保存文件"
"Save version {key}" = "保存版本 {key}"
"Saved" = "已保存"
"Saved {name}" = "已保存 {name}"
@@ -1787,6 +1789,7 @@ status = "draft — needs native review"
"plugin.ui" = "插件面板"
"plugins.manage" = "管理插件…"
"plugins.runLast" = "运行上一个插件"
"pref.autoSave" = "自动保存文件"
"pref.highlightHover" = "悬停时高亮图层"
"pref.keepTool" = "使用后保持工具选中"
"pref.language" = "界面语言"
+4
View File
@@ -163,6 +163,10 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
if app.services.reduce_motion() {
crate::widgets::label(ui, tr("Your system asks for reduced motion: animations stay off."), t.text_secondary);
}
// The browser has no files to save to.
if !cfg!(target_arch = "wasm32") && crate::widgets::checkbox(ui, prefs.auto_save, tr("Save files automatically")) {
toggles.push("pref.autoSave");
}
for c in toggles {
app.run(c, json!({}));
}
+36
View File
@@ -324,3 +324,39 @@ fn a_hidden_window_answers_requests_that_need_no_frame() {
assert_eq!(rx.try_recv().unwrap().id, json!(2));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn auto_save_writes_a_changed_file_then_rests() {
let dir = std::env::temp_dir().join(format!("figcraft-auto-save-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("auto.figcraft").to_string_lossy().to_string();
let (mut h, _) = document();
h.run("file.save", json!({"path": path}));
h.run("pref.autoSave", json!({"on": true}));
h.assert_idle("a saved file");
h.run("shape.rectangle", json!({"x": 500, "y": 300, "width": 40, "height": 40}));
assert!(h.frame(vec![]) <= Duration::from_secs_f64(crate::AUTO_SAVE_SECS), "wakes up to save it");
assert!(h.app.session.ds().unwrap().is_dirty(), "not saved at once");
// Another change restarts the wait.
h.time += crate::AUTO_SAVE_SECS - 0.5;
h.run("shape.rectangle", json!({"x": 600, "y": 300, "width": 40, "height": 40}));
h.frame(vec![]);
h.time += 1.0;
h.frame(vec![]);
assert!(h.app.ui.auto_saving.is_empty() && h.app.session.ds().unwrap().is_dirty(), "waits for a pause in the changes");
h.time += crate::AUTO_SAVE_SECS;
// The file is encoded on a thread of its own, then written.
let start = Instant::now();
while h.app.session.ds().unwrap().is_dirty() {
assert!(start.elapsed() < Duration::from_secs(20), "saved");
let delay = h.frame(vec![]);
assert!(h.app.ui.auto_saving.is_empty() || delay <= Duration::from_millis(100), "looks again for the file being encoded");
std::thread::sleep(Duration::from_millis(5));
}
let mut t = Session::empty();
t.execute("file.open", &json!({"path": path})).unwrap();
let ds = t.ds().unwrap();
assert!(ds.doc.page_content_bounds(ds.page).unwrap().x1 >= 640.0, "the new rectangles are in the file");
h.assert_idle("an auto-saved file");
let _ = std::fs::remove_dir_all(&dir);
}
+89
View File
@@ -305,6 +305,14 @@ pub enum TitleBar {
/// How often open documents get an autosave checkpoint (seconds).
pub const AUTOSAVE_SECS: f64 = 30.0 * 60.0;
/// With auto-save on: how long after a file last changed it is saved, and how long a failed save
/// waits before trying again (seconds).
pub const AUTO_SAVE_SECS: f64 = 3.0;
const AUTO_SAVE_RETRY_SECS: f64 = 60.0;
/// A file being encoded for auto-save: its snapshot and, once done, its bytes.
pub type AutoSaveEncoding = std::thread::JoinHandle<(figcraft_engine::cmd::file::FileSnapshot, figcraft_engine::Result<Vec<u8>>)>;
/// How long a toast shows (seconds).
const TOAST_SECS: f64 = 3.0;
@@ -440,6 +448,11 @@ pub struct UiState {
pub last_copy: Option<String>,
/// When (UI clock, seconds) open documents next get an autosave checkpoint.
pub next_autosave: f64,
/// With auto-save on: when (UI clock, seconds) files with unsaved changes are next saved, and
/// the revisions of those files it waits on (a change restarts the wait).
pub auto_save_at: Option<(f64, u64)>,
/// Files being encoded for auto-save on threads of their own (tab `uid`).
pub auto_saving: Vec<(u64, AutoSaveEncoding)>,
/// The Assets panel shows the libraries view (publish this file, enabled libraries).
pub libraries: bool,
/// Keyboard navigation: the region with the keyboard, keyboard mode (F6, Tab).
@@ -524,6 +537,8 @@ impl Default for UiState {
last_copy: None,
next_autosave: 0.0,
auto_save_at: None,
auto_saving: Vec::new(),
libraries: false,
nav: a11y::Nav::default(),
plugins_window: false,
@@ -1066,6 +1081,79 @@ impl FigApp {
repaint_in(ctx, AUTOSAVE_SECS);
}
/// With auto-save on, files that have a path are saved [`AUTO_SAVE_SECS`] after they last
/// changed (not during a gesture, a live preview or while a version is shown). The file is
/// encoded on a thread of its own, so a big one doesn't hold up the window, and written here.
fn auto_save_tick(&mut self, ctx: &egui::Context) {
let now = ctx.input(|i| i.time);
let mut failed = self.auto_save_done();
// The browser has no files to save to.
let on = !cfg!(target_arch = "wasm32") && self.session.prefs.auto_save;
let saving = &self.ui.auto_saving;
let due = |d: &&figcraft_engine::DocState| {
d.path.is_some() && d.branch_of.is_none() && d.preview.is_none() && d.is_dirty() && !saving.iter().any(|(uid, _)| *uid == d.uid)
};
let changes = if on { self.session.docs.iter().filter(due).map(|d| d.revision).reduce(u64::wrapping_add) } else { None };
match changes {
None => self.ui.auto_save_at = None,
Some(changes) => {
let at = match self.ui.auto_save_at {
Some((at, c)) if c == changes && failed.is_none() => at,
_ => now + if failed.is_some() { AUTO_SAVE_RETRY_SECS } else { AUTO_SAVE_SECS },
};
self.ui.auto_save_at = Some((at, changes));
if now < at || self.session.docs.iter().any(|d| d.interaction.is_some() || d.text_preview.is_some()) {
// Due but busy: look again in a second.
repaint_in(ctx, if now < at { at - now } else { 1.0 });
} else {
self.ui.auto_save_at = None;
let files: Vec<_> =
self.session.docs.iter().filter(due).map(|d| (d.uid, figcraft_engine::cmd::file::FileSnapshot::of(d))).collect();
for (uid, file) in files {
let encode = move || {
let bytes = file.encode();
(file, bytes)
};
match std::thread::Builder::new().name("figcraft-save".into()).spawn(encode) {
Ok(h) => self.ui.auto_saving.push((uid, h)),
Err(e) => {
failed.get_or_insert(e.to_string());
self.ui.auto_save_at = Some((now + AUTO_SAVE_RETRY_SECS, changes));
}
}
}
}
}
}
if !self.ui.auto_saving.is_empty() {
// Look again shortly for the files being encoded.
repaint_in(ctx, 0.1);
}
if let Some(e) = failed {
self.toast(i18n::trf("Couldn't save automatically: {error}", &[("error", &e)]));
}
}
/// Write the files whose auto-save encoding finished; the first error, if one failed.
fn auto_save_done(&mut self) -> Option<String> {
if self.ui.auto_saving.is_empty() {
return None;
}
let mut failed = None;
let done: Vec<_> = self.ui.auto_saving.extract_if(.., |(_, h)| h.is_finished()).collect();
for (_, h) in done {
let saved = match h.join() {
Ok((snapshot, Ok(bytes))) => snapshot.save(&mut self.session, &bytes).map(|_| ()).map_err(|e| e.to_string()),
Ok((_, Err(e))) => Err(e.to_string()),
Err(_) => Err("the file couldn't be encoded".to_string()),
};
if let Err(e) = saved {
failed.get_or_insert(e);
}
}
failed
}
/// Draw the whole UI: the main window (the other windows draw themselves).
pub fn ui(&mut self, ui: &mut egui::Ui) {
#[cfg(not(target_arch = "wasm32"))]
@@ -1076,6 +1164,7 @@ impl FigApp {
let first = !self.fonts_installed;
self.logic(&ctx);
self.autosave_tick(&ctx);
self.auto_save_tick(&ctx);
if first {
// New fonts apply from the next frame; draw nothing until then.
ctx.request_repaint();
+2
View File
@@ -349,6 +349,8 @@ on; `key` resolves keys the way the canvas does. Commands behind keys:
nested layers), Escape or a click drops it.
- `pref.reduceMotion {on?}`: reduced motion (the interface's own animations off; the system's
accessibility setting turns it on too). Prototype transitions still play.
- `pref.autoSave {on?}`: files that have a path save themselves a few seconds after they change
(off by default; the desktop app's timer does it, so scripts still call `file.save`).
- `pref.language {language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…}`: the app's interface language, applied live ("system":
the OS or browser language; a language without a catalog shows English). Command ids, labels and
every result stay English.