mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-27 20:06:53 -06:00
# Conflicts: # crates/ui-egui/src/idle_tests.rs # docs/mcp.md
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()}))
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" = "インターフェースの言語"
|
||||
|
||||
@@ -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" = "인터페이스 언어"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" = "界面语言"
|
||||
|
||||
@@ -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!({}));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user