mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
Issue #345: ask before quitting while tasks run
Quitting while an import, an export or another task that can be cancelled is running asks first: Quit Anyway cancels them (then any unsaved changes still get their own question), Cancel keeps working. Tasks without a cross (the face scan, AI Denoise) pick up again on the next launch and don't ask. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
af9b1063ca
commit
98f65ff0d7
24 files changed
+135
-31
No files matched your search
@@ -225,5 +225,7 @@
|
||||
"{done} of {total}": "{done} von {total}",
|
||||
"{done} of {total} MB": "{done} von {total} MB",
|
||||
"{pct} %": "{pct} %",
|
||||
"+{n} more": "+{n} weitere"
|
||||
"+{n} more": "+{n} weitere",
|
||||
"1 task is still running: {names}. Quitting stops it.": "1 Aufgabe läuft noch: {names}. Beenden bricht sie ab.",
|
||||
"{n} tasks are still running: {names}. Quitting stops them.": "{n} Aufgaben laufen noch: {names}. Beenden bricht sie ab."
|
||||
}
|
||||
@@ -2017,5 +2017,6 @@
|
||||
"Not saved": "Nicht gespeichert",
|
||||
"A folder is being synchronized": "Ein Ordner wird gerade synchronisiert",
|
||||
"Wait for the import to finish": "Warten Sie, bis der Import abgeschlossen ist",
|
||||
"Show less": "Weniger anzeigen"
|
||||
"Show less": "Weniger anzeigen",
|
||||
"Quit while tasks are running?": "Beenden, während Aufgaben laufen?"
|
||||
}
|
||||
@@ -225,5 +225,7 @@
|
||||
"{done} of {total}": "{done} de {total}",
|
||||
"{done} of {total} MB": "{done} de {total} MB",
|
||||
"{pct} %": "{pct} %",
|
||||
"+{n} more": "+{n} más"
|
||||
"+{n} more": "+{n} más",
|
||||
"1 task is still running: {names}. Quitting stops it.": "1 tarea sigue en curso: {names}. Salir la detiene.",
|
||||
"{n} tasks are still running: {names}. Quitting stops them.": "{n} tareas siguen en curso: {names}. Salir las detiene."
|
||||
}
|
||||
@@ -1862,5 +1862,6 @@
|
||||
"Not saved": "Sin guardar",
|
||||
"A folder is being synchronized": "Se está sincronizando una carpeta",
|
||||
"Wait for the import to finish": "Espera a que termine la importación",
|
||||
"Show less": "Mostrar menos"
|
||||
"Show less": "Mostrar menos",
|
||||
"Quit while tasks are running?": "¿Salir con tareas en curso?"
|
||||
}
|
||||
@@ -225,5 +225,7 @@
|
||||
"{done} of {total}": "{done} sur {total}",
|
||||
"{done} of {total} MB": "{done} sur {total} Mo",
|
||||
"{pct} %": "{pct} %",
|
||||
"+{n} more": "+{n} autres"
|
||||
"+{n} more": "+{n} autres",
|
||||
"1 task is still running: {names}. Quitting stops it.": "1 tâche est encore en cours : {names}. Quitter l'arrête.",
|
||||
"{n} tasks are still running: {names}. Quitting stops them.": "{n} tâches sont encore en cours : {names}. Quitter les arrête."
|
||||
}
|
||||
@@ -1862,5 +1862,6 @@
|
||||
"Not saved": "Non enregistré",
|
||||
"A folder is being synchronized": "Un dossier est en cours de synchronisation",
|
||||
"Wait for the import to finish": "Attendez la fin de l'importation",
|
||||
"Show less": "Afficher moins"
|
||||
"Show less": "Afficher moins",
|
||||
"Quit while tasks are running?": "Quitter pendant que des tâches sont en cours ?"
|
||||
}
|
||||
@@ -225,5 +225,7 @@
|
||||
"{done} of {total}": "{total}件中{done}件",
|
||||
"{done} of {total} MB": "{done} / {total} MB",
|
||||
"{pct} %": "{pct}%",
|
||||
"+{n} more": "ほか{n}件"
|
||||
"+{n} more": "ほか{n}件",
|
||||
"1 task is still running: {names}. Quitting stops it.": "1件のタスクが実行中です:{names}。終了すると停止します。",
|
||||
"{n} tasks are still running: {names}. Quitting stops them.": "{n}件のタスクが実行中です:{names}。終了すると停止します。"
|
||||
}
|
||||
@@ -1864,5 +1864,6 @@
|
||||
"Not saved": "保存されていません",
|
||||
"A folder is being synchronized": "フォルダを同期中です",
|
||||
"Wait for the import to finish": "読み込みが終わるまでお待ちください",
|
||||
"Show less": "表示を減らす"
|
||||
"Show less": "表示を減らす",
|
||||
"Quit while tasks are running?": "タスクの実行中に終了しますか?"
|
||||
}
|
||||
@@ -225,5 +225,7 @@
|
||||
"{done} of {total}": "{done} de {total}",
|
||||
"{done} of {total} MB": "{done} de {total} MB",
|
||||
"{pct} %": "{pct}%",
|
||||
"+{n} more": "+{n} mais"
|
||||
"+{n} more": "+{n} mais",
|
||||
"1 task is still running: {names}. Quitting stops it.": "1 tarefa ainda está em andamento: {names}. Sair a interrompe.",
|
||||
"{n} tasks are still running: {names}. Quitting stops them.": "{n} tarefas ainda estão em andamento: {names}. Sair as interrompe."
|
||||
}
|
||||
@@ -1862,5 +1862,6 @@
|
||||
"Not saved": "Não salvo",
|
||||
"A folder is being synchronized": "Uma pasta está sendo sincronizada",
|
||||
"Wait for the import to finish": "Aguarde a importação terminar",
|
||||
"Show less": "Mostrar menos"
|
||||
"Show less": "Mostrar menos",
|
||||
"Quit while tasks are running?": "Sair com tarefas em andamento?"
|
||||
}
|
||||
@@ -225,5 +225,7 @@
|
||||
"{done} of {total}": "{done} из {total}",
|
||||
"{done} of {total} MB": "{done} из {total} МБ",
|
||||
"{pct} %": "{pct} %",
|
||||
"+{n} more": "ещё {n}"
|
||||
"+{n} more": "ещё {n}",
|
||||
"1 task is still running: {names}. Quitting stops it.": "Ещё выполняется задача: {names}. Выход прервёт её.",
|
||||
"{n} tasks are still running: {names}. Quitting stops them.": "Ещё выполняется задач: {n} — {names}. Выход прервёт их."
|
||||
}
|
||||
@@ -1575,5 +1575,6 @@
|
||||
"Not saved": "Не сохранено",
|
||||
"A folder is being synchronized": "Идёт синхронизация папки",
|
||||
"Wait for the import to finish": "Дождитесь окончания импорта",
|
||||
"Show less": "Свернуть"
|
||||
"Show less": "Свернуть",
|
||||
"Quit while tasks are running?": "Выйти, пока выполняются задачи?"
|
||||
}
|
||||
@@ -225,5 +225,7 @@
|
||||
"{done} of {total}": "{done} з {total}",
|
||||
"{done} of {total} MB": "{done} з {total} МБ",
|
||||
"{pct} %": "{pct} %",
|
||||
"+{n} more": "+{n} ще"
|
||||
"+{n} more": "+{n} ще",
|
||||
"1 task is still running: {names}. Quitting stops it.": "1 завдання ще виконується: {names}. Вихід зупинить його.",
|
||||
"{n} tasks are still running: {names}. Quitting stops them.": "Ще виконуються завдання ({n}): {names}. Вихід зупинить їх."
|
||||
}
|
||||
@@ -2056,5 +2056,6 @@
|
||||
"Not saved": "Не збережено",
|
||||
"A folder is being synchronized": "Тека синхронізується",
|
||||
"Wait for the import to finish": "Дочекайтеся завершення імпорту",
|
||||
"Show less": "Показати менше"
|
||||
"Show less": "Показати менше",
|
||||
"Quit while tasks are running?": "Вийти, поки виконуються завдання?"
|
||||
}
|
||||
@@ -225,5 +225,7 @@
|
||||
"{done} of {total}": "{done}/{total}",
|
||||
"{done} of {total} MB": "{done}/{total} MB",
|
||||
"{pct} %": "{pct}%",
|
||||
"+{n} more": "还有 {n} 项"
|
||||
"+{n} more": "还有 {n} 项",
|
||||
"1 task is still running: {names}. Quitting stops it.": "仍有 1 个任务在运行:{names}。退出将停止它。",
|
||||
"{n} tasks are still running: {names}. Quitting stops them.": "仍有 {n} 个任务在运行:{names}。退出将停止它们。"
|
||||
}
|
||||
@@ -1862,5 +1862,6 @@
|
||||
"Not saved": "未保存",
|
||||
"A folder is being synchronized": "正在同步文件夹",
|
||||
"Wait for the import to finish": "请等待导入完成",
|
||||
"Show less": "收起"
|
||||
"Show less": "收起",
|
||||
"Quit while tasks are running?": "要在任务运行时退出吗?"
|
||||
}
|
||||
@@ -225,5 +225,7 @@
|
||||
"{done} of {total}": "{done}/{total}",
|
||||
"{done} of {total} MB": "{done}/{total} MB",
|
||||
"{pct} %": "{pct}%",
|
||||
"+{n} more": "還有 {n} 項"
|
||||
"+{n} more": "還有 {n} 項",
|
||||
"1 task is still running: {names}. Quitting stops it.": "仍有 1 個工作在執行:{names}。結束將停止它。",
|
||||
"{n} tasks are still running: {names}. Quitting stops them.": "仍有 {n} 個工作在執行:{names}。結束將停止它們。"
|
||||
}
|
||||
@@ -1862,5 +1862,6 @@
|
||||
"Not saved": "未儲存",
|
||||
"A folder is being synchronized": "正在同步資料夾",
|
||||
"Wait for the import to finish": "請等待匯入完成",
|
||||
"Show less": "收合"
|
||||
"Show less": "收合",
|
||||
"Quit while tasks are running?": "要在工作執行時結束嗎?"
|
||||
}
|
||||
@@ -105,7 +105,7 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value {
|
||||
"status": app.ui.status,
|
||||
"copied": app.copied,
|
||||
"notices": app.notices,
|
||||
"quitPrompt": app.quit_prompt,
|
||||
"quitPrompt": app.quit_prompt.as_ref().map(crate::QuitPrompt::text),
|
||||
// how hard the background face scan may work right now, and why it is judged so (see `panels::faces::scan_pace`)
|
||||
"faceScan": {"pace": app.caches.faces_pace, "focused": app.caches.faces_in_front, "pending": app.caches.faces_pending, "indexed": app.caches.faces_indexed},
|
||||
"unsaved": app.session.unsaved().map(|(n, e)| json!({"ops": n, "error": e})),
|
||||
|
||||
@@ -191,6 +191,24 @@ pub struct Perf {
|
||||
pub fps: f64,
|
||||
}
|
||||
|
||||
/// Why quitting stopped to ask first (`panels::notices`).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum QuitPrompt {
|
||||
/// Changes couldn't be written to disk (issue #103).
|
||||
Unsaved(String),
|
||||
/// Tasks that quitting would cut short are running: an import, an export… (issue #345).
|
||||
Tasks(String),
|
||||
}
|
||||
|
||||
impl QuitPrompt {
|
||||
/// The prompt's message.
|
||||
pub fn text(&self) -> &str {
|
||||
match self {
|
||||
QuitPrompt::Unsaved(text) | QuitPrompt::Tasks(text) => text,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LightcraftApp {
|
||||
pub(crate) model_setup: model_setup::Pending,
|
||||
/// Per-catalog-revision caches of library-wide results the panels show every frame
|
||||
@@ -215,8 +233,8 @@ pub struct LightcraftApp {
|
||||
pub headless_host: bool,
|
||||
/// Warnings to show one at a time (damaged settings files…, issue #103).
|
||||
pub notices: Vec<String>,
|
||||
/// Quitting was stopped because changes couldn't be saved: the prompt's text.
|
||||
pub quit_prompt: Option<String>,
|
||||
/// Quitting was stopped to ask first: changes couldn't be saved, or tasks are running.
|
||||
pub quit_prompt: Option<QuitPrompt>,
|
||||
/// Quit Anyway was chosen: the window may close with unsaved changes.
|
||||
pub quit_confirmed: bool,
|
||||
control_rx: Option<Receiver<ControlRequest>>,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
//! Things the user must not miss (issue #103): settings files that were damaged or unreadable when
|
||||
//! the library opened (and host warnings such as a damaged `ui.json`), and quitting while changes
|
||||
//! are only in memory.
|
||||
//! are only in memory or while tasks that would be cut short are running (issue #345).
|
||||
|
||||
use egui::{Align2, RichText, vec2};
|
||||
|
||||
use crate::LightcraftApp;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::register;
|
||||
use crate::{LightcraftApp, QuitPrompt};
|
||||
|
||||
/// Collect the library's settings-file warnings (each is shown once).
|
||||
pub fn logic(app: &mut LightcraftApp) {
|
||||
@@ -14,10 +14,25 @@ pub fn logic(app: &mut LightcraftApp) {
|
||||
app.notices.extend(new);
|
||||
}
|
||||
|
||||
/// The window may close now (`true`), or the quit prompt is shown (`false`): changes that
|
||||
/// couldn't be written are retried first — the log, then a snapshot of everything in memory.
|
||||
/// The window may close now (`true`), or the quit prompt is shown (`false`). Running tasks that
|
||||
/// can be cancelled (an import, an export…) are asked about first; then changes that couldn't be
|
||||
/// written are retried — the log, then a snapshot of everything in memory.
|
||||
pub fn may_close(app: &mut LightcraftApp) -> bool {
|
||||
if app.quit_confirmed || app.session.unsaved().is_none() {
|
||||
if app.quit_confirmed {
|
||||
return true;
|
||||
}
|
||||
// before the library is touched: a Cancel here keeps working with it
|
||||
let running = app.session.activity.running_cancellable();
|
||||
if !running.is_empty() {
|
||||
let names = running.iter().map(|t| crate::i18n::tr(&t.label)).collect::<Vec<_>>().join(", ");
|
||||
app.quit_prompt = Some(QuitPrompt::Tasks(if running.len() == 1 {
|
||||
crate::i18n::tr_format!("1 task is still running: {names}. Quitting stops it.", names = names)
|
||||
} else {
|
||||
crate::i18n::tr_format!("{n} tasks are still running: {names}. Quitting stops them.", n = running.len(), names = names)
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
if app.session.unsaved().is_none() {
|
||||
return true;
|
||||
}
|
||||
let _ = app.session.persist();
|
||||
@@ -29,13 +44,13 @@ pub fn may_close(app: &mut LightcraftApp) -> bool {
|
||||
match app.session.unsaved() {
|
||||
None => true,
|
||||
Some((n, e)) => {
|
||||
app.quit_prompt = Some(crate::i18n::tr_format!(
|
||||
app.quit_prompt = Some(QuitPrompt::Unsaved(crate::i18n::tr_format!(
|
||||
"{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.",
|
||||
if n == 1 { "" } else { "s" },
|
||||
if n == 1 { "it is" } else { "they are" },
|
||||
n = n,
|
||||
e = e
|
||||
));
|
||||
)));
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -85,7 +100,20 @@ fn window(ctx: &egui::Context, id: &str, title: &str, text: &str, buttons: &[(&s
|
||||
}
|
||||
|
||||
pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
if let Some(text) = app.quit_prompt.clone() {
|
||||
if let Some(QuitPrompt::Tasks(text)) = app.quit_prompt.clone() {
|
||||
let buttons = [("quitAnyway", "Quit Anyway", Choice::QuitAnyway), ("quitCancel", "Cancel", Choice::Cancel)];
|
||||
let Some(i) = window(ctx, "quit-tasks", "Quit while tasks are running?", &text, &buttons) else { return };
|
||||
app.quit_prompt = None;
|
||||
if matches!(buttons[i].2, Choice::QuitAnyway) {
|
||||
app.session.activity.cancel_all();
|
||||
// unsaved changes still get their own question
|
||||
if may_close(app) {
|
||||
app.ui.quit = true;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(QuitPrompt::Unsaved(text)) = app.quit_prompt.clone() {
|
||||
let buttons = [
|
||||
("quitRetry", "Try Saving Again", Choice::Retry),
|
||||
("quitAnyway", "Quit Anyway", Choice::QuitAnyway),
|
||||
|
||||
@@ -130,3 +130,34 @@ fn long_detail_stays_inside_the_stack() {
|
||||
let row = rect(&h, &format!("activity:row:{}", g.id()));
|
||||
assert!(row.width() <= WIDTH + 1.0, "{row:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quit_with_a_running_task_asks_and_quit_anyway_cancels() {
|
||||
let mut h = demo();
|
||||
let g = h.app.session.activity.start("export", "Exporting", Cancel::Yes);
|
||||
wait_visible(&mut h);
|
||||
assert!(!crate::panels::notices::may_close(&mut h.app));
|
||||
assert!(matches!(h.app.quit_prompt, Some(crate::QuitPrompt::Tasks(_))), "{:?}", h.app.quit_prompt);
|
||||
h.step();
|
||||
assert!(!has(&h, "button:quitRetry"));
|
||||
click(&mut h, "button:quitAnyway");
|
||||
assert!(g.is_cancelled() && h.quit_requested());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quit_cancel_keeps_the_task_running() {
|
||||
let mut h = demo();
|
||||
let g = h.app.session.activity.start("import", "Importing", Cancel::Yes);
|
||||
assert!(!crate::panels::notices::may_close(&mut h.app));
|
||||
click(&mut h, "button:quitCancel");
|
||||
assert!(!g.is_cancelled() && h.app.quit_prompt.is_none() && !h.app.quit_confirmed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_task_does_not_block_quit() {
|
||||
let mut h = demo();
|
||||
let g = h.app.session.activity.start("export", "Exporting", Cancel::Yes);
|
||||
h.app.session.activity.cancel(g.id()).unwrap();
|
||||
let _f = h.app.session.activity.start("faces", "Finding faces", Cancel::No);
|
||||
assert!(crate::panels::notices::may_close(&mut h.app));
|
||||
}
|
||||
@@ -79,7 +79,7 @@ fn quitting_with_unsaved_changes_asks_first() {
|
||||
assert_eq!(r["ok"], false, "{r}");
|
||||
assert!(!crate::panels::notices::may_close(&mut h.app), "retried, still failing: ask");
|
||||
h.step();
|
||||
let prompt = h.app.quit_prompt.clone().unwrap();
|
||||
let prompt = h.app.quit_prompt.clone().unwrap().text().to_string();
|
||||
assert!(prompt.contains("1 change couldn't be written to disk") && prompt.contains("drive unplugged"), "{prompt}");
|
||||
for b in ["button:quitRetry", "button:quitAnyway", "button:quitCancel"] {
|
||||
assert!(has(&h, b), "{b}");
|
||||
|
||||
@@ -30,7 +30,7 @@ mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation:
|
||||
| `engine.commands` | — | Engine + UI commands: id, label, menu, shortcut, params doc, enabled |
|
||||
| `ui.menu.list` | — | Menu entries (flat: id, label, menu path, shortcut, enabled) |
|
||||
| `ui.menu.tree` | — | The menu bar as shown (File … Help): items `{id, params?, label, shortcut?, enabled, checked?}`, separators, submenus — the model behind the native macOS menu bar and the in-window menus |
|
||||
| `ui.inspect` | — | UI state, window, `loupe` (`source`, and `region: {full, window, pending}` when a zoomed view also renders the window on screen at no more than 100 % — `full` is the frame it is cut from, `window` its `[x, y, w, h]` — and `regionBefore` for the Before side of a Before/After view), canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why — see `docs/gpu-pipeline.md`), status, memory (bytes per cache, see `library.memory`; plus stage caches — `budgetBytes`, `trimmed`, `sharedSourceBytes` (the one device copy of a big original) — and textures), `export: {running: {total, done, current} \| null, last}`, `notices` (warnings waiting to be shown, e.g. a damaged settings file; OK = `button:noticeOk`), `quitPrompt` (why quitting was stopped: unsaved changes; `button:quitRetry` / `button:quitAnyway` / `button:quitCancel`), `import: {done, total, imported, cancelled} \| null` (an import runs on a worker thread), `tasks` (other background work: `Find Missing Photos`, `Auto Import`), `activity` (every long-running task in flight as the activity stack in the top-left corner shows it, same shape as `activity.list`; a row appears once its task is 0.5 s old, ids `activity:row:<id>`, ✕ = `activity:cancel:<id>`, `activity:more` expands past 3 rows), `fileDialogs` (commands waiting on a native file dialog, which runs off the UI thread; they run again with the answer when it closes), `copied` (the text the UI last put on the clipboard, `null` until something is copied) |
|
||||
| `ui.inspect` | — | UI state, window, `loupe` (`source`, and `region: {full, window, pending}` when a zoomed view also renders the window on screen at no more than 100 % — `full` is the frame it is cut from, `window` its `[x, y, w, h]` — and `regionBefore` for the Before side of a Before/After view), canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why — see `docs/gpu-pipeline.md`), status, memory (bytes per cache, see `library.memory`; plus stage caches — `budgetBytes`, `trimmed`, `sharedSourceBytes` (the one device copy of a big original) — and textures), `export: {running: {total, done, current} \| null, last}`, `notices` (warnings waiting to be shown, e.g. a damaged settings file; OK = `button:noticeOk`), `quitPrompt` (why quitting was stopped: tasks that would be cut short are running — `button:quitAnyway` cancels them and quits, `button:quitCancel` keeps working — or unsaved changes: `button:quitRetry` / `button:quitAnyway` / `button:quitCancel`), `import: {done, total, imported, cancelled} \| null` (an import runs on a worker thread), `tasks` (other background work: `Find Missing Photos`, `Auto Import`), `activity` (every long-running task in flight as the activity stack in the top-left corner shows it, same shape as `activity.list`; a row appears once its task is 0.5 s old, ids `activity:row:<id>`, ✕ = `activity:cancel:<id>`, `activity:more` expands past 3 rows), `fileDialogs` (commands waiting on a native file dialog, which runs off the UI thread; they run again with the answer when it closes), `copied` (the text the UI last put on the clipboard, `null` until something is copied) |
|
||||
| `ui.widgets` | `{filter?}` | On-screen widgets `{id, rect: [x, y, w, h]}` (screen points) |
|
||||
| `ui.clickWidget` / `ui.dragWidget` / `ui.hoverWidget` | `{id, count?, button?, fx?, fy?}` (`button: "right"` right-clicks) / `{id, toX?, toY?, dx?, dy?, steps?}` / `{id, fx?, fy?}` | Real egui input on a widget (hover: the pointer rests on it, e.g. for preset/profile previews) |
|
||||
| `ui.move` / `ui.click` / `ui.drag` | `{x, y, count?, button?}` / `{x, y, toX, toY, steps?}` | Raw pointer input, screen points |
|
||||
|
||||
Reference in new issue
Block a user