From ba55ff7f5850110000fb6b7aed70aaa1c1b072d6 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 13 Oct 2026 10:22:19 +0700 Subject: [PATCH] M11: plugin API 4: menu bar and Design panel section, panel sliders and pictures, timers and typing in the UI and the presenter, catalogs --- crates/ui-egui/locales/de.toml | 6 + crates/ui-egui/locales/en.toml | 2 + crates/ui-egui/locales/es.toml | 6 + crates/ui-egui/locales/fr.toml | 6 + crates/ui-egui/locales/ja.toml | 6 + crates/ui-egui/locales/ko.toml | 6 + crates/ui-egui/locales/pt-BR.toml | 6 + crates/ui-egui/locales/zh-Hans.toml | 6 + crates/ui-egui/src/a11y.rs | 27 ++ crates/ui-egui/src/canvas.rs | 2 + crates/ui-egui/src/idle_tests.rs | 8 +- crates/ui-egui/src/lib.rs | 5 + crates/ui-egui/src/plugins_ui.rs | 197 +++++++++- crates/ui-egui/src/present.rs | 140 ++++++- crates/ui-egui/src/present_widgets_tests.rs | 63 ++++ crates/ui-egui/src/right.rs | 3 + crates/ui-egui/src/typography.rs | 29 +- crates/ui-egui/src/widget_ui.rs | 219 +++++++++++ crates/ui-egui/src/widget_ui_tests.rs | 387 ++++++++++++++++++++ crates/ui-egui/src/widgets.rs | 93 ++++- 20 files changed, 1137 insertions(+), 80 deletions(-) create mode 100644 crates/ui-egui/src/present_widgets_tests.rs create mode 100644 crates/ui-egui/src/widget_ui.rs create mode 100644 crates/ui-egui/src/widget_ui_tests.rs diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index c707c15c..f8b3a34b 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -1274,6 +1274,7 @@ status = "draft — needs native review" "This file is on {server}, open only to you. Share it to let others in with a new code." = "Diese Datei liegt auf {server} und ist nur für Sie zugänglich. Teilen Sie sie, um andere mit einem neuen Code hereinzulassen." "This frame is new on the branch." = "Dieser Rahmen ist im Branch neu." "This frame isn't on the branch." = "Dieser Rahmen ist nicht im Branch." +"This picture can't be shown" = "Dieses Bild kann nicht angezeigt werden" "This server is open to the public: no key needed." = "Dieser Server ist öffentlich zugänglich: kein Schlüssel nötig." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Dieser Server braucht seinen Schlüssel: Fügen Sie der Adresse ?key=… hinzu (`figcraft-server key` gibt ihn auf dem Rechner des Servers aus)." "This server takes files only with its key: see Advanced." = "Dieser Server nimmt Dateien nur mit seinem Schlüssel an: siehe Erweitert." @@ -1383,6 +1384,7 @@ status = "draft — needs native review" "White" = "Weiß" "Who runs it, its terms and how long it keeps files" = "Wer ihn betreibt, seine Bedingungen und wie lange er Dateien aufbewahrt" "Why it changed" = "Warum es sich geändert hat" +"Widget" = "Widget" "Widgets" = "Widgets" "Width" = "Breite" "Width point" = "Breitenpunkt" @@ -2060,8 +2062,12 @@ status = "draft — needs native review" "view.zoomOut" = "Verkleinern" "view.zoomSelection" = "Auf Auswahl zoomen" "widget.click" = "Widget anklicken" +"widget.fireTimer" = "Widget-Timer auslösen" "widget.insert" = "Widget einfügen" "widget.list" = "Widgets auflisten" +"widget.menu" = "Eigenschaftenmenü des Widgets" +"widget.menuAction" = "Menüeintrag des Widgets verwenden" +"widget.setText" = "Widget-Text bearbeiten" "window.focus" = "Fenster in den Vordergrund holen" "window.new" = "Neues Fenster" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 954c4208..bac03326 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1310,6 +1310,7 @@ name = "English" "This file is on {server}, open only to you. Share it to let others in with a new code." = "This file is on {server}, open only to you. Share it to let others in with a new code." "This frame is new on the branch." = "This frame is new on the branch." "This frame isn't on the branch." = "This frame isn't on the branch." +"This picture can't be shown" = "This picture can't be shown" "This server is open to the public: no key needed." = "This server is open to the public: no key needed." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." "This server takes files only with its key: see Advanced." = "This server takes files only with its key: see Advanced." @@ -1424,6 +1425,7 @@ name = "English" "White" = "White" "Who runs it, its terms and how long it keeps files" = "Who runs it, its terms and how long it keeps files" "Why it changed" = "Why it changed" +"Widget" = "Widget" "Widgets" = "Widgets" "Width" = "Width" "Width point" = "Width point" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 4e5574e0..87b758cf 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -1311,6 +1311,7 @@ status = "draft — needs native review" "This file is on {server}, open only to you. Share it to let others in with a new code." = "Este archivo está en {server}, abierto solo para ti. Compártelo para dejar entrar a otros con un código nuevo." "This frame is new on the branch." = "Este marco es nuevo en la rama." "This frame isn't on the branch." = "Este marco no está en la rama." +"This picture can't be shown" = "No se puede mostrar esta imagen" "This server is open to the public: no key needed." = "Este servidor está abierto al público: no hace falta clave." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Este servidor necesita su clave: añade ?key=… a la dirección (`figcraft-server key` la muestra en el equipo del servidor)." "This server takes files only with its key: see Advanced." = "Este servidor solo acepta archivos con su clave: mira Avanzado." @@ -1425,6 +1426,7 @@ status = "draft — needs native review" "White" = "Blanco" "Who runs it, its terms and how long it keeps files" = "Quién lo gestiona, sus condiciones y cuánto tiempo conserva los archivos" "Why it changed" = "Por qué cambió" +"Widget" = "Widget" "Widgets" = "Widgets" "Width" = "Ancho" "Width point" = "Punto de ancho" @@ -2113,8 +2115,12 @@ status = "draft — needs native review" "view.zoomOut" = "Alejar" "view.zoomSelection" = "Zoom a la selección" "widget.click" = "Pulsar widget" +"widget.fireTimer" = "Disparar temporizador del widget" "widget.insert" = "Insertar widget" "widget.list" = "Listar widgets" +"widget.menu" = "Menú de propiedades del widget" +"widget.menuAction" = "Usar elemento del menú del widget" +"widget.setText" = "Editar texto del widget" "window.focus" = "Traer la ventana al frente" "window.new" = "Nueva ventana" "template.list" = "Listar plantillas" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index b1788b19..5aaa15a3 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -1274,6 +1274,7 @@ status = "draft — needs native review" "This file is on {server}, open only to you. Share it to let others in with a new code." = "Ce fichier est sur {server}, accessible à vous seul. Partagez-le pour laisser entrer d’autres personnes avec un nouveau code." "This frame is new on the branch." = "Ce cadre est nouveau dans la branche." "This frame isn't on the branch." = "Ce cadre n’est pas dans la branche." +"This picture can't be shown" = "Impossible d’afficher cette image" "This server is open to the public: no key needed." = "Ce serveur est ouvert au public : aucune clé nécessaire." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Ce serveur exige sa clé : ajoutez ?key=… à l’adresse (`figcraft-server key` l’affiche sur la machine du serveur)." "This server takes files only with its key: see Advanced." = "Ce serveur n’accepte les fichiers qu’avec sa clé : voir Avancé." @@ -1383,6 +1384,7 @@ status = "draft — needs native review" "White" = "Blanc" "Who runs it, its terms and how long it keeps files" = "Qui le gère, ses conditions et combien de temps il conserve les fichiers" "Why it changed" = "Pourquoi cela a changé" +"Widget" = "Widget" "Widgets" = "Widgets" "Width" = "Largeur" "Width point" = "Point de largeur" @@ -2060,8 +2062,12 @@ status = "draft — needs native review" "view.zoomOut" = "Zoom arrière" "view.zoomSelection" = "Zoom sur la sélection" "widget.click" = "Cliquer sur le widget" +"widget.fireTimer" = "Déclencher le minuteur du widget" "widget.insert" = "Insérer un widget" "widget.list" = "Lister les widgets" +"widget.menu" = "Menu de propriétés du widget" +"widget.menuAction" = "Utiliser un élément du menu du widget" +"widget.setText" = "Modifier le texte du widget" "window.focus" = "Mettre la fenêtre au premier plan" "window.new" = "Nouvelle fenêtre" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 22f5cf92..3bed2fcc 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -1276,6 +1276,7 @@ status = "draft — needs native review" "This file is on {server}, open only to you. Share it to let others in with a new code." = "このファイルは {server} にあり、開けるのはあなただけ。共有すると、新しいコードで他の人が参加可能。" "This frame is new on the branch." = "このフレームはブランチで新規作成。" "This frame isn't on the branch." = "このフレームはブランチにない。" +"This picture can't be shown" = "この画像は表示できません" "This server is open to the public: no key needed." = "このサーバーは一般公開されている:キーは不要。" "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "このサーバーにはキーが必要:アドレスに ?key=… を追加(サーバーのマシンで `figcraft-server key` を実行すると表示)。" "This server takes files only with its key: see Advanced." = "このサーバーはキーがないとファイルを受け付けない:「詳細設定」を参照。" @@ -1385,6 +1386,7 @@ status = "draft — needs native review" "White" = "白" "Who runs it, its terms and how long it keeps files" = "運営者、利用規約、ファイルの保管期間" "Why it changed" = "変更理由" +"Widget" = "ウィジェット" "Widgets" = "ウィジェット" "Width" = "幅" "Width point" = "幅のポイント" @@ -2062,8 +2064,12 @@ status = "draft — needs native review" "view.zoomOut" = "ズームアウト" "view.zoomSelection" = "選択範囲にズーム" "widget.click" = "ウィジェットをクリック" +"widget.fireTimer" = "ウィジェットのタイマーを発火" "widget.insert" = "ウィジェットを挿入" "widget.list" = "ウィジェットを一覧表示" +"widget.menu" = "ウィジェットのプロパティメニュー" +"widget.menuAction" = "ウィジェットのメニュー項目を使用" +"widget.setText" = "ウィジェットのテキストを編集" "window.focus" = "ウィンドウを最前面に表示" "window.new" = "新しいウィンドウ" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 194d2c33..996be842 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -1275,6 +1275,7 @@ status = "draft — needs native review" "This file is on {server}, open only to you. Share it to let others in with a new code." = "이 파일은 {server}에 있으며 나만 열 수 있음. 공유하면 다른 사람들이 새 코드로 참여할 수 있음." "This frame is new on the branch." = "이 프레임은 브랜치에서 새로 만들어졌음." "This frame isn't on the branch." = "이 프레임은 브랜치에 없음." +"This picture can't be shown" = "이 그림을 표시할 수 없음" "This server is open to the public: no key needed." = "이 서버는 누구에게나 열려 있음: 키가 필요 없음." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "이 서버는 키가 필요함: 주소에 ?key=…를 추가하세요(서버 컴퓨터에서 `figcraft-server key`로 확인)." "This server takes files only with its key: see Advanced." = "이 서버는 키가 있어야만 파일을 받음: 고급을 참고하세요." @@ -1384,6 +1385,7 @@ status = "draft — needs native review" "White" = "흰색" "Who runs it, its terms and how long it keeps files" = "운영자, 약관, 파일 보관 기간" "Why it changed" = "변경 이유" +"Widget" = "위젯" "Widgets" = "위젯" "Width" = "너비" "Width point" = "너비 점" @@ -2061,8 +2063,12 @@ status = "draft — needs native review" "view.zoomOut" = "축소" "view.zoomSelection" = "선택 항목으로 확대/축소" "widget.click" = "위젯 클릭" +"widget.fireTimer" = "위젯 타이머 실행" "widget.insert" = "위젯 삽입" "widget.list" = "위젯 목록" +"widget.menu" = "위젯 속성 메뉴" +"widget.menuAction" = "위젯 메뉴 항목 사용" +"widget.setText" = "위젯 텍스트 편집" "window.focus" = "창을 맨 앞으로 가져오기" "window.new" = "새 창" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 702cd921..b187afed 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -1273,6 +1273,7 @@ status = "draft — needs native review" "This file is on {server}, open only to you. Share it to let others in with a new code." = "Este arquivo está em {server}, aberto somente para você. Compartilhe-o para deixar outras pessoas entrarem com um novo código." "This frame is new on the branch." = "Este quadro é novo na branch." "This frame isn't on the branch." = "Este quadro não está na branch." +"This picture can't be shown" = "Não é possível mostrar esta imagem" "This server is open to the public: no key needed." = "Este servidor é aberto ao público: não precisa de chave." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Este servidor precisa da própria chave: adicione ?key=… ao endereço (`figcraft-server key` a mostra na máquina do servidor)." "This server takes files only with its key: see Advanced." = "Este servidor só aceita arquivos com a própria chave: veja Avançado." @@ -1382,6 +1383,7 @@ status = "draft — needs native review" "White" = "Branco" "Who runs it, its terms and how long it keeps files" = "Quem o mantém, os termos dele e por quanto tempo guarda os arquivos" "Why it changed" = "Por que mudou" +"Widget" = "Widget" "Widgets" = "Widgets" "Width" = "Largura" "Width point" = "Ponto de largura" @@ -2059,8 +2061,12 @@ status = "draft — needs native review" "view.zoomOut" = "Diminuir zoom" "view.zoomSelection" = "Zoom na seleção" "widget.click" = "Clicar no widget" +"widget.fireTimer" = "Disparar temporizador do widget" "widget.insert" = "Inserir widget" "widget.list" = "Listar widgets" +"widget.menu" = "Menu de propriedades do widget" +"widget.menuAction" = "Usar item do menu do widget" +"widget.setText" = "Editar texto do widget" "window.focus" = "Trazer a janela para a frente" "window.new" = "Nova janela" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index e8c506d1..499a3f49 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -1276,6 +1276,7 @@ status = "draft — needs native review" "This file is on {server}, open only to you. Share it to let others in with a new code." = "此文件在 {server} 上,只有你能打开。分享后,其他人可以用新代码加入。" "This frame is new on the branch." = "此画框是分支上新增的。" "This frame isn't on the branch." = "此画框不在分支上。" +"This picture can't be shown" = "无法显示此图片" "This server is open to the public: no key needed." = "此服务器对公众开放:无需密钥。" "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "此服务器需要密钥:请在地址后加上 ?key=…(在服务器所在的机器上运行 `figcraft-server key` 可显示)。" "This server takes files only with its key: see Advanced." = "此服务器只接受带密钥的文件:请查看“高级”。" @@ -1385,6 +1386,7 @@ status = "draft — needs native review" "White" = "白" "Who runs it, its terms and how long it keeps files" = "谁在运营、其条款以及文件保留多久" "Why it changed" = "更改原因" +"Widget" = "小组件" "Widgets" = "小组件" "Width" = "宽度" "Width point" = "宽度点" @@ -2062,8 +2064,12 @@ status = "draft — needs native review" "view.zoomOut" = "缩小" "view.zoomSelection" = "缩放至所选项" "widget.click" = "点击小组件" +"widget.fireTimer" = "触发小组件计时器" "widget.insert" = "插入小组件" "widget.list" = "列出小组件" +"widget.menu" = "小组件属性菜单" +"widget.menuAction" = "使用小组件菜单项" +"widget.setText" = "编辑小组件文本" "window.focus" = "将窗口置于前台" "window.new" = "新建窗口" diff --git a/crates/ui-egui/src/a11y.rs b/crates/ui-egui/src/a11y.rs index fc634243..bc060734 100644 --- a/crates/ui-egui/src/a11y.rs +++ b/crates/ui-egui/src/a11y.rs @@ -891,6 +891,33 @@ pub fn number(resp: &Response, label: &str, value: Option, suffix: &str, em }); } +/// A slider at `value` (none: mixed) within `range`. +pub fn slider(resp: &Response, label: &str, value: Option, range: (f64, f64)) { + node(resp, label, |n| { + n.set_role(Role::Slider); + n.set_min_numeric_value(range.0); + n.set_max_numeric_value(range.1); + if let Some(v) = value { + n.set_numeric_value(v); + } + }); +} + +/// A progress bar filled to `fraction` (0–1). +pub fn progress(resp: &Response, label: &str, fraction: f64) { + node(resp, label, |n| { + n.set_role(Role::ProgressIndicator); + n.set_min_numeric_value(0.0); + n.set_max_numeric_value(1.0); + n.set_numeric_value(fraction); + }); +} + +/// A picture, named by what it shows. +pub fn image(resp: &Response, label: &str) { + node(resp, label, |n| n.set_role(Role::Image)); +} + /// A text field showing `value`. pub fn text(resp: &Response, label: &str, value: &str) { node(resp, label, |n| { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index fdedfea2..39594670 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -225,6 +225,8 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { crate::versions::preview_banner(app, &ctx, rect); crate::branch_ui::banner(app, &ctx, rect); crate::dev_mode::focus_bar(app, &ctx, rect); + crate::widget_ui::menu_bar(app, ui, rect); + crate::widget_ui::pointer(app, &resp); // The cursor: the tool's, unless something on the canvas with its own has the pointer. app.canvas.cursor = None; if let (true, Some(at)) = (own_cursor && resp.hovered(), ctx.input(|i| i.pointer.hover_pos())) { diff --git a/crates/ui-egui/src/idle_tests.rs b/crates/ui-egui/src/idle_tests.rs index 917ab3f8..5ea8ddeb 100644 --- a/crates/ui-egui/src/idle_tests.rs +++ b/crates/ui-egui/src/idle_tests.rs @@ -20,7 +20,7 @@ const IDLE: Duration = Duration::from_secs(1); pub(crate) struct Harness { ctx: egui::Context, - app: FigApp, + pub(crate) app: FigApp, time: f64, } @@ -30,7 +30,7 @@ impl Harness { } /// One frame with `events`, 1/60 s after the last; returns the repaint delay it asked for. - fn frame(&mut self, events: Vec) -> Duration { + pub(crate) fn frame(&mut self, events: Vec) -> Duration { self.time += 1.0 / 60.0; let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), events, ..Default::default() }; @@ -43,7 +43,7 @@ impl Harness { /// Draw frames with no input until the UI is idle: no render in flight and three frames in a /// row asking for no repaint sooner than [`IDLE`]. Returns the last frame's delay; fails with /// what kept asking for frames. - fn settle(&mut self) -> Duration { + pub(crate) fn settle(&mut self) -> Duration { let mut quiet = 0; for _ in 0..2000 { let delay = self.frame(vec![]); @@ -56,7 +56,7 @@ impl Harness { panic!("still asking for frames after 2000 with no input: {:?}", self.ctx.repaint_causes()); } - fn run(&mut self, cmd: &str, params: Value) -> Value { + pub(crate) fn run(&mut self, cmd: &str, params: Value) -> Value { let r = self.app.session.execute(cmd, ¶ms).unwrap(); self.app.handle_ui_requests(); r diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a5d6d380..cec12376 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -60,6 +60,7 @@ pub mod variable_ui; pub mod variables_panel; pub mod vector_panel; pub mod versions; +pub mod widget_ui; pub mod widgets; pub mod windows; @@ -148,6 +149,8 @@ mod titlebar_tests; #[cfg(test)] mod vector_tail_ui_tests; #[cfg(test)] +mod widget_ui_tests; +#[cfg(test)] mod windows_qa_tests; #[cfg(test)] mod windows_tests; @@ -947,6 +950,8 @@ impl FigApp { } self.background_done(&ctx); } + // Widget timers that are due call their plugins back; the next one wakes the window. + widget_ui::tick(self, &ctx); windows::begin(self, &ctx); let (files, prefs) = (self.session.docs.len(), self.session.prefs.clone()); self.frame(ui); diff --git a/crates/ui-egui/src/plugins_ui.rs b/crates/ui-egui/src/plugins_ui.rs index 0d4da40c..6ce45cf7 100644 --- a/crates/ui-egui/src/plugins_ui.rs +++ b/crates/ui-egui/src/plugins_ui.rs @@ -6,7 +6,7 @@ use egui::{Align2, Context, Id, RichText, Ui}; use figcraft_engine::plugin_directory::{PluginEntry, installed_version, newer_version}; -use figcraft_engine::plugins::{Access, InstalledPlugin, Param, ParamKind}; +use figcraft_engine::plugins::{Access, InstalledPlugin, Param, ParamKind, WidgetFit}; use serde_json::{Map, Value, json}; use crate::FigApp; @@ -56,6 +56,13 @@ pub struct PluginsWindow { /// Width of the plugin windows' fields. const FIELD_W: f32 = 220.0; +/// While a slider is dragged its plugin hears of it at most this often (seconds), and once more +/// when the drag ends. +const SLIDER_EVERY: f64 = 0.1; +/// Width of the number field beside a slider. +const SLIDER_FIELD_W: f32 = 52.0; +/// A panel's picture is decoded only up to this many pixels on a side. +const MAX_IMAGE_SIDE: u32 = 2048; /// How far apart plugin panels open, side by side (a panel and a gap). const PANEL_STEP: f32 = 372.0; @@ -552,21 +559,87 @@ fn form_window(app: &mut FigApp, ctx: &Context) { } } +/// Whether a control is drawn across the panel, not as a row of the label and field columns. +fn full_width(kind: ParamKind) -> bool { + matches!(kind, ParamKind::Section | ParamKind::Divider | ParamKind::Image | ParamKind::Progress) +} + +/// The label in the left column: checkboxes and buttons carry their own, a note has none. +fn caption(p: &Param) -> &str { + if matches!(p.kind, ParamKind::Boolean | ParamKind::Button | ParamKind::Note) { "" } else { p.title() } +} + /// Parameters or panel controls in two columns (label, field); buttons in a row share one line, -/// the first button filled. Returns what the user did to which control. +/// the first button filled. Dividers, pictures and progress bars span the panel, and a section's +/// heading folds the controls after it, up to the next section or divider. Returns what the user +/// did to which control. fn controls(ui: &mut Ui, salt: &str, params: &[Param], values: &Map, t: &Tokens) -> Option { let mut changed = None; let mut first_button = true; - egui::Grid::new(("plugin_controls", salt)).num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { - for row in params.chunk_by(|a, b| a.kind == ParamKind::Button && b.kind == ParamKind::Button) { + // The label column is as wide as its widest label in every block of fields, so they line up. + let label_w = |p: &Param| ui.painter().layout_no_wrap(caption(p).to_string(), regular(11.0), t.text_secondary).size().x; + let caption_w = params.iter().filter(|p| !full_width(p.kind)).map(label_w).fold(0.0, f32::max); + let mut open = true; + for (i, block) in params.chunk_by(|a, b| !full_width(a.kind) && !full_width(b.kind)).enumerate() { + let Some(p) = block.first() else { continue }; + match p.kind { + ParamKind::Section => { + let id = Id::new(("plugin_section", salt, i)); + let starts_open = p.default.as_ref().and_then(Value::as_bool).unwrap_or(true); + open = ui.data(|d| d.get_temp(id)).unwrap_or(starts_open); + if widgets::disclosure(ui, &p.label, open) { + open = !open; + ui.data_mut(|d| d.insert_temp(id, open)); + } + } + // A divider ends a section: it and what follows always show. + ParamKind::Divider => { + open = true; + ui.add_space(2.0); + widgets::separator(ui); + ui.add_space(2.0); + } + _ if !open => {} + ParamKind::Image => picture(ui, Id::new(("plugin_image", salt, i)), p, t), + ParamKind::Progress => { + if !p.label.is_empty() { + widgets::label(ui, &p.label, t.text_secondary); + } + widgets::progress_bar(ui, p.title(), p.default.as_ref().and_then(Value::as_f64).unwrap_or(0.0), ui.available_width()); + } + _ => { + let hit = fields(ui, (salt, i), block, values, caption_w, &mut first_button, t); + if hit.is_some() { + changed = hit; + } + } + } + ui.add_space(if open || p.kind == ParamKind::Section { 8.0 } else { 0.0 }); + } + changed +} + +/// A block of fields, notes and buttons in two columns. A disabled control is greyed and can't be +/// used; a field's validation message shows under it. +fn fields( + ui: &mut Ui, + salt: (&str, usize), + block: &[Param], + values: &Map, + caption_w: f32, + first_button: &mut bool, + t: &Tokens, +) -> Option { + let mut changed = None; + egui::Grid::new(("plugin_controls", salt)).num_columns(2).min_col_width(caption_w).spacing([12.0, 8.0]).show(ui, |ui| { + for row in block.chunk_by(|a, b| a.kind == ParamKind::Button && b.kind == ParamKind::Button) { let Some(p) = row.first() else { continue }; - // Checkboxes and buttons carry their own label; a list's sits by its first item. - let caption = if matches!(p.kind, ParamKind::Boolean | ParamKind::Button | ParamKind::Note) { "" } else { p.title() }; - let caption = RichText::new(caption).font(regular(11.0)).color(t.text_secondary); + let label = RichText::new(caption(p)).font(regular(11.0)).color(t.text_secondary); + // A list's label sits by its first item. if p.kind == ParamKind::List { - ui.with_layout(egui::Layout::top_down(egui::Align::Min), |ui| ui.label(caption)); + ui.with_layout(egui::Layout::top_down(egui::Align::Min), |ui| ui.label(label)); } else { - ui.label(caption); + ui.label(label); } let hit = match p.kind { ParamKind::Note => { @@ -578,34 +651,131 @@ fn controls(ui: &mut Ui, salt: &str, params: &[Param], values: &Map { ui.horizontal(|ui| { - let pressed = row.iter().filter(|b| widgets::button(ui, b.title(), std::mem::take(&mut first_button), true).clicked()); + let pressed = row.iter().filter(|b| widgets::button(ui, b.title(), std::mem::take(first_button), !b.disabled).clicked()); pressed.last().map(|b| Used::Set(b.name.clone(), Value::Null)) }) .inner } _ => { let current = values.get(&p.name).cloned().unwrap_or_else(|| p.default_value()); - field(ui, Id::new(("plugin_control", salt, &p.name)), p, ¤t) + let id = Id::new(("plugin_control", salt, &p.name)); + ui.add_enabled_ui(!p.disabled, |ui| field(ui, id, p, ¤t)).inner } }; if hit.is_some() { changed = hit; } ui.end_row(); + if !p.error.is_empty() { + ui.label(""); + ui.vertical(|ui| { + ui.set_max_width(FIELD_W); + ui.add(egui::Label::new(RichText::new(&p.error).font(regular(11.0)).color(t.danger)).wrap()); + }); + ui.end_row(); + } } }); changed } +/// A panel's picture, fitted in the panel's width at its height and named for screen readers. It +/// is decoded once, and again only when the plugin shows another. +fn picture(ui: &mut Ui, id: Id, p: &Param, t: &Tokens) { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + p.data.hash(&mut hasher); + let key = hasher.finish(); + let kept = ui.data(|d| d.get_temp::<(u64, Option)>(id)).filter(|(k, _)| *k == key); + let (_, texture) = kept.unwrap_or_else(|| { + let fresh = (key, decode(&p.data).map(|img| ui.ctx().load_texture("plugin-image", img, egui::TextureOptions::LINEAR))); + ui.data_mut(|d| d.insert_temp(id, fresh.clone())); + fresh + }); + let (rect, resp) = ui.allocate_exact_size(egui::vec2(ui.available_width(), p.height.unwrap_or(120.0) as f32), egui::Sense::hover()); + crate::a11y::image(&resp, p.title()); + let Some(texture) = texture else { + ui.painter().rect_filled(rect, egui::CornerRadius::same(4), t.field); + ui.painter().text(rect.center(), Align2::CENTER_CENTER, tr("This picture can't be shown"), regular(11.0), t.text_secondary); + return; + }; + // `fit` shows all of it, `fill` stretches it to the box; `crop` and `tile` cover the box. + let (size, mut uv) = (texture.size_vec2(), egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0))); + let scale = |cover: bool| { + let (sx, sy) = (rect.width() / size.x.max(1.0), rect.height() / size.y.max(1.0)); + if cover { sx.max(sy) } else { sx.min(sy) } + }; + let shown = match p.fit { + None | Some(WidgetFit::Fit) => egui::Rect::from_center_size(rect.center(), size * scale(false)), + Some(WidgetFit::Fill) => rect, + Some(WidgetFit::Crop | WidgetFit::Tile) => { + let covered = size * scale(true); + uv = egui::Rect::from_center_size(egui::pos2(0.5, 0.5), egui::vec2(rect.width() / covered.x, rect.height() / covered.y)); + rect + } + }; + ui.painter().with_clip_rect(rect).image(texture.id(), shown, uv, egui::Color32::WHITE); +} + +/// A picture's pixels from its file's bytes as base64 (PNG, JPEG, BMP…), if it is one of at most +/// [`MAX_IMAGE_SIDE`] pixels on a side. +fn decode(data: &str) -> Option { + use base64::Engine as _; + let bytes = base64::engine::general_purpose::STANDARD.decode(data.trim()).ok()?; + let reader = || image::ImageReader::new(std::io::Cursor::new(&bytes)).with_guessed_format().ok(); + let (w, h) = reader()?.into_dimensions().ok()?; + if w == 0 || h == 0 || w > MAX_IMAGE_SIDE || h > MAX_IMAGE_SIDE { + return None; + } + let rgba = reader()?.decode().ok()?.to_rgba8(); + Some(egui::ColorImage::from_rgba_unmultiplied([w as usize, h as usize], &rgba)) +} + +/// Whether a slider that moved tells its plugin now: when the drag ends, and while it goes on at +/// most every [`SLIDER_EVERY`] seconds (`last`: when the plugin last heard of this drag). +pub(crate) fn slider_due(done: bool, now: f64, last: Option) -> bool { + done || last.is_none_or(|l| now - l >= SLIDER_EVERY) +} + +/// A slider with its value in a number field beside it (which also takes the keyboard). While it +/// is dragged it shows the dragged value at once and tells the plugin now and then. +fn slider(ui: &mut Ui, id: Id, p: &Param, current: &Value) -> Option { + let name = p.title(); + // (the value being dragged, when the plugin last heard of the drag) + let live = ui.data(|d| d.get_temp::<(f64, Option)>(id)); + let shown = live.map(|l| l.0).or(current.as_f64()); + let range = (p.min.unwrap_or(0.0), p.max.unwrap_or(100.0)); + ui.horizontal(|ui| { + let (_, moved) = widgets::slider(ui, id, name, range, None, shown, FIELD_W - SLIDER_FIELD_W - 8.0); + let typed = match widgets::number_field(ui, id.with("value"), Lead::Named(name), shown, "", SLIDER_FIELD_W, p.step.unwrap_or(1.0)) { + Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) => p.coerce(&json!(v)).ok(), + _ => None, + }; + let Some((v, done)) = moved else { return typed }; + let value = p.coerce(&json!(v)).ok()?; + let (now, last) = (ui.input(|i| i.time), live.and_then(|l| l.1)); + // Nothing to tell when the plugin already has this value. + let due = slider_due(done, now, last) && value.as_f64() != current.as_f64(); + let told = if due { Some(now) } else { last }; + ui.data_mut(|d| match done { + true => d.remove::<(f64, Option)>(id), + false => drop(d.insert_temp(id, (value.as_f64().unwrap_or(v), told))), + }); + due.then_some(value) + }) + .inner +} + /// One parameter's field, named for screen readers: its new value when it changed. fn field(ui: &mut Ui, id: Id, p: &Param, current: &Value) -> Option { let name = p.title(); let v = match p.kind { ParamKind::Text => widgets::text_field_named(ui, id, current.as_str().unwrap_or_default(), FIELD_W, name).map(Value::String), - ParamKind::Number => match widgets::number_field(ui, id, Lead::Named(name), current.as_f64(), "", FIELD_W, 1.0)? { + ParamKind::Number => match widgets::number_field(ui, id, Lead::Named(name), current.as_f64(), "", FIELD_W, p.step.unwrap_or(1.0))? { FieldEdit::Set(v) | FieldEdit::Scrub(v, true) => p.coerce(&json!(v)).ok(), FieldEdit::Scrub(_, false) => None, }, + ParamKind::Slider => slider(ui, id, p, current), ParamKind::Boolean => { let on = current.as_bool().unwrap_or(false); widgets::checkbox(ui, on, name).then_some(Value::Bool(!on)) @@ -651,7 +821,8 @@ fn field(ui: &mut Ui, id: Id, p: &Param, current: &Value) -> Option { } typed } - ParamKind::Button | ParamKind::Note => None, + // Buttons, notes and the controls that span the panel aren't fields. + _ => None, }; v.map(|v| Used::Set(p.name.clone(), v)) } diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs index b5f177af..46f236e6 100644 --- a/crates/ui-egui/src/present.rs +++ b/crates/ui-egui/src/present.rs @@ -28,6 +28,7 @@ use figcraft_doc::proto::{ Action, Device, DeviceColor, Direction, Easing, KeyMods, Overflow, OverlayPosition, PrototypeSettings, ScrollPosition, Transition, Trigger, }; use figcraft_doc::{Document, HConstraint, Node, NodeId, NodeKind, VConstraint}; +use figcraft_engine::widgets::Part; use figcraft_geom::{Affine, Point}; use serde_json::{Value, json}; @@ -311,8 +312,16 @@ pub struct Presenter { uses_pads: bool, /// The controller buttons held at the last poll (bit i: standard layout index i). pads_held: u32, - /// Clicks on widgets' clickable parts (widget, target), for [`Presenter::click_widgets`]. - widget_clicks: Vec<(NodeId, String)>, + /// What happened to widgets (a `widget.*` command and its parameters), for + /// [`Presenter::run_widgets`]. + widget_events: Vec<(&'static str, Value)>, + /// The widgets' pending timers on the player's clock: (widget, when it is due). + widget_timers: Vec<(NodeId, f64)>, + /// The widget part shown in its hover style, and the tooltip of the part under the pointer. + widget_hover: Option, + widget_tip: Option, + /// The widget text being typed into (its layer), and whether typing just started. + widget_input: Option<(NodeId, bool)>, } impl Presenter { @@ -372,7 +381,11 @@ impl Presenter { pads: Vec::new(), uses_pads: false, pads_held: 0, - widget_clicks: Vec::new(), + widget_events: Vec::new(), + widget_timers: Vec::new(), + widget_hover: None, + widget_tip: None, + widget_input: None, } } @@ -452,6 +465,7 @@ impl Presenter { let copy = Document::clone(doc); self.original = Some(copy.clone()); self.design = Some(copy); + self.forget_widgets(); self.relayout(); if first { self.arrived = now; @@ -490,6 +504,10 @@ impl Presenter { } figcraft_engine::proto_run::play_interactions(d); raise_fixed(d); + // The widget part under the pointer shows its hover style. + if let Some(look) = self.widget_hover.and_then(|part| figcraft_engine::widgets::hovered(d, part)) { + *d = look; + } } self.uses_pads = d.as_ref().is_some_and(figcraft_doc::proto::uses_gamepad); self.doc = d; @@ -673,10 +691,18 @@ impl Presenter { self.anim = None; self.temp = None; self.design = self.original.clone(); + self.forget_widgets(); self.relayout(); self.arrive(now); } + /// The prototype's copy of the file starts over: its widgets' timers, hover and typing go. + fn forget_widgets(&mut self) { + self.widget_events.clear(); + self.widget_timers.clear(); + (self.widget_hover, self.widget_tip, self.widget_input) = (None, None, None); + } + fn revert(&mut self, now: f64) { let Some(t) = self.temp.take() else { return }; self.begin(t.transition.reversed(), t.dur, t.easing, now); @@ -739,15 +765,24 @@ impl Presenter { did } - /// Run the widget clicks the player took: each calls its plugin back on the player's copy (the - /// prototype's own state of the widget), as on the canvas but without touching the file. - /// Returns what went wrong, for a toast. - pub fn click_widgets(&mut self, s: &figcraft_engine::Session) -> Option { + /// Run what happened to widgets in the player (clicks, text typed into them) and their timers + /// that are due at `now` on the player's clock: each calls its plugin back on the player's + /// copy (the prototype's own state of the widget), as on the canvas but without touching the + /// file. Returns what went wrong, for a toast. + pub fn run_widgets(&mut self, s: &figcraft_engine::Session, now: f64) -> Option { + let due = self.widget_timers.iter().filter(|t| t.1 <= now).map(|t| ("widget.fireTimer", json!({"id": t.0}))); + self.widget_events.extend(due.collect::>()); + self.widget_timers.retain(|t| t.1 > now); let mut error = None; - for (id, target) in std::mem::take(&mut self.widget_clicks) { + for (cmd, params) in std::mem::take(&mut self.widget_events) { let Some(doc) = self.design.clone() else { break }; - match figcraft_engine::widgets::click_on(s, doc, id, &target) { - Ok((d, _)) => { + match figcraft_engine::widgets::run_on(s, doc, cmd, ¶ms) { + Ok((d, out)) => { + // The callback started or stopped the widget's timer: the player keeps the clock. + if let (Some(w), true) = (out["id"].as_u64().map(NodeId), out["timerChanged"] == json!(true)) { + self.widget_timers.retain(|t| t.0 != w); + self.widget_timers.extend(out["timer"].as_f64().map(|ms| (w, now + ms / 1000.0))); + } self.design = Some(d); self.relayout(); } @@ -1631,9 +1666,10 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { let now = ctx.input(|i| i.time); let Some(mut pres) = app.ui.present.take() else { return }; if let Ok(ds) = app.session.ds() { - pres.sync(&ds.doc, now); + pres.sync(ds.without_widget_hover(), now); } poll_pads(app, &mut pres, &ctx); + let in_widget_text = pres.widget_input.is_some(); let mut action = None; // Typing a reply to a comment keeps the keys from the prototype; Shift+C shows the comments. let typing = ctx.egui_wants_keyboard_input(); @@ -1679,7 +1715,8 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { if let Some(BarAction::Responsive(on)) = action { app.run("proto.settings", json!({"responsive": on})); } - if action == Some(BarAction::Close) || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + // Escape while typing into a widget's text ends the typing, not the presentation. + if action == Some(BarAction::Close) || (!in_widget_text && ctx.input(|i| i.key_pressed(egui::Key::Escape))) { return; } app.ui.present = Some(pres); @@ -1709,7 +1746,7 @@ fn finish(app: &mut FigApp, p: &mut Presenter) { for u in p.urls.drain(..) { app.services.open_url(&u); } - if let Some(e) = p.click_widgets(&app.session) { + if let Some(e) = p.run_widgets(&app.session, p.clock) { app.toast(e); } } @@ -1787,7 +1824,7 @@ pub fn show_pane(app: &mut FigApp, ctx: &egui::Context) { return; } let now = ctx.input(|i| i.time); - let (doc, selected) = (ds.doc.clone(), selected_frame(ds)); + let (doc, selected) = (ds.without_widget_hover().clone(), selected_frame(ds)); pane.player.sync(&doc, now); // Deselecting and coming back to the same frame keeps the preview where it got to. if selected.is_some() && selected != pane.selected { @@ -2006,6 +2043,7 @@ fn player(p: &mut Presenter, ui: &mut Ui, rect: Rect, now: f64, chrome: Chrome) painter.rect_filled(l.screen, CornerRadius::ZERO, bg); } draw(p, &painter, &ctx, &l, now); + widget_extras(p, ui, &resp, &l); if p.view.inspect && !chrome.pane { inspect_hover(p, &painter, &ctx, &l, area); } @@ -2023,9 +2061,45 @@ fn player(p: &mut Presenter, ui: &mut Ui, rect: Rect, now: f64, chrome: Chrome) if let Some(due) = p.waiting.iter().map(|w| w.due - now).reduce(f64::min) { crate::repaint_in(&ctx, due.clamp(0.0, 60.0)); } + // Widget timers run on the player's clock: wake for the next one (it fires after the frame). + if let Some(due) = p.widget_timers.iter().map(|t| t.1).reduce(f64::min) { + crate::repaint_in(&ctx, due - now); + } action } +/// What widgets show over the player: the tooltip of the part under the pointer, and the field +/// over a text being typed into (Enter or a click elsewhere commits it to the widget's plugin, +/// Escape leaves it as it was). +// shortcut: one line, and an emptied text is left as it was (the shared rename field); the +// canvas editor takes several lines. Upgrade if prototypes need multiline widget text. +fn widget_extras(p: &mut Presenter, ui: &mut Ui, area: &egui::Response, l: &Layout) { + if let Some(tip) = p.widget_tip.as_deref().filter(|_| area.hovered()) { + area.clone().on_hover_text_at_pointer(tip); + } + let Some((layer, start)) = p.widget_input else { return }; + // The field opens the frame after the click that asked for it: a field takes a click that + // ends elsewhere in its first frame as one that takes the keyboard away from it. + if start && ui.input(|i| i.pointer.any_released() || i.pointer.any_down()) { + ui.ctx().request_repaint(); + return; + } + let (_, place, _) = l.active(p.current); + let found = + l.disp.world_bounds(layer).zip(l.disp.node(layer).and_then(|n| n.kind.text())).zip(figcraft_engine::widgets::input_of(&l.disp, layer)); + let Some(((bounds, text), input)) = found else { + p.widget_input = None; + return; + }; + let rect = place.rect(bounds); + let font = regular((rect.height() * 0.7).clamp(9.0, 32.0)); + let (typed, open) = widgets::rename_field(ui, Id::new(("present_widget_input", layer)), &text.characters, rect, font, start); + if let Some(typed) = typed { + p.widget_events.push(("widget.setText", json!({"id": layer, "input": input.name, "text": typed}))); + } + p.widget_input = open.then_some((layer, false)); +} + /// Inspecting: the layer under the pointer becomes the inspected one; it is outlined. fn inspect_hover(p: &mut Presenter, painter: &egui::Painter, ctx: &egui::Context, l: &Layout, area: Rect) { let (root, place, clip) = l.active(p.current); @@ -2331,6 +2405,23 @@ fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_area: bool, ke if !hits.is_empty() { ctx.set_cursor_icon(egui::CursorIcon::PointingHand); } + // Widgets: the part under the pointer shows its hover style and its tooltip, with a hand over + // what can be clicked and an I-beam over a text to type into. Their plugins aren't called. + { + use figcraft_engine::widgets::{hover_at, part_at, tooltip_at}; + let under = pos.filter(|_| over_area).and_then(|q| Some((layer_at(doc, root, &place, q)?, place.page(q)))); + match under.and_then(|(hit, at)| part_at(doc, hit, at)) { + Some((_, Part::Target(_))) => ctx.set_cursor_icon(egui::CursorIcon::PointingHand), + Some((_, Part::Input(_))) => ctx.set_cursor_icon(egui::CursorIcon::Text), + None => {} + } + p.widget_tip = under.and_then(|(hit, at)| tooltip_at(doc, hit, at)); + let hover = under.and_then(|(hit, at)| hover_at(doc, hit, at)); + if hover != p.widget_hover { + p.widget_hover = hover; + p.relayout(); + } + } // First (top-most) hit that has a matching trigger. let pick = |want: &dyn Fn(&Trigger) -> bool| -> Option<(NodeId, usize, Rect)> { hits.iter().find_map(|(id, r)| find_trigger(doc, *id, want).map(|k| (*id, k, *r))) @@ -2461,12 +2552,18 @@ fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_area: bool, ke { return; } - // A widget's clickable part calls its plugin back (after the frame, with the session). - if let Some(q) = pos - && let Some(t) = layer_at(doc, root, &place, q).and_then(|hit| figcraft_engine::widgets::target_at(doc, hit, place.page(q))) - { - p.widget_clicks.push(t); - return; + // A widget's clickable part calls its plugin back (after the frame, with the session); + // a text of it that can be edited is typed into in place. + match pos.and_then(|q| layer_at(doc, root, &place, q).and_then(|hit| figcraft_engine::widgets::part_at(doc, hit, place.page(q)))) { + Some((w, Part::Target(target))) => { + p.widget_events.push(("widget.click", json!({"id": w, "target": target}))); + return; + } + Some((_, Part::Input(text))) => { + p.widget_input = Some((text, true)); + return; + } + None => {} } // Clicking where nothing reacts flashes the hotspots. if p.view.hotspot_hints { @@ -2675,6 +2772,9 @@ mod behaviour_tests; #[cfg(test)] #[path = "present_tail_tests.rs"] mod tail_tests; +#[cfg(test)] +#[path = "present_widgets_tests.rs"] +mod widgets_tests; #[cfg(test)] #[path = "present_delay_tests.rs"] diff --git a/crates/ui-egui/src/present_widgets_tests.rs b/crates/ui-egui/src/present_widgets_tests.rs new file mode 100644 index 00000000..efd14eab --- /dev/null +++ b/crates/ui-egui/src/present_widgets_tests.rs @@ -0,0 +1,63 @@ +//! Widgets in a presented prototype (plugin API 4): the part under the pointer shows its hover +//! style, a timer runs on the player's clock, and text is typed into a field over the widget's +//! text, all on the prototype's own copy: the file, its history and its timers stay as they are. + +use egui::{Event, Key, PointerButton, Pos2}; +use figcraft_doc::NodeId; +use serde_json::json; + +use crate::present_tests::Ui as Player; +use crate::widget_ui_tests::{NOTE, fill, key, part, session, state}; + +#[test] +fn a_presented_widget_takes_hover_typing_and_timers_on_its_own_copy() { + let mut s = session(); + let a = NodeId(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 300, "name": "A"})).unwrap()["id"].as_u64().unwrap()); + let w = NodeId(s.execute("widget.insert", &json!({"plugin": NOTE, "x": 40, "y": 40})).unwrap()["id"].as_u64().unwrap()); + s.execute("node.reparent", &json!({"ids": [w], "parent": a})).unwrap(); + let mut u = Player::new(s); + u.run("proto.present", json!({"start": a})); + u.frames(2, Vec::new); + let shown = |u: &Player, name: &str| { + let p = u.app.ui.present.as_ref().unwrap(); + let (doc, (_, place, _)) = (p.doc.as_ref().unwrap(), p.shown.unwrap()); + (part(doc, w, name), place.rect(doc.world_bounds(part(doc, w, name)).unwrap())) + }; + let player = |u: &Player| state(u.app.ui.present.as_ref().unwrap().doc.as_ref().unwrap(), w); + let press = |u: &mut Player, name: &str| { + let at: Pos2 = shown(u, name).1.center(); + let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + u.frame(vec![Event::PointerMoved(at)]); + u.frame(vec![b(true)]); + u.frame(vec![b(false)]); + u.frames(2, Vec::new); + }; + // Hover: the part shows its style in the prototype; nobody is called. + let (go, at) = shown(&u, "Go"); + u.frame(vec![Event::PointerMoved(at.center())]); + u.frame(vec![]); + assert_eq!(fill(u.app.ui.present.as_ref().unwrap().doc.as_ref().unwrap(), go), "#00FF00"); + assert_eq!(player(&u), r#"{"n":1}"#); + u.frame(vec![Event::PointerMoved(at.center() + egui::vec2(0.0, 200.0))]); + u.frame(vec![]); + assert_eq!(fill(u.app.ui.present.as_ref().unwrap().doc.as_ref().unwrap(), go), "#0D99FF"); + // A timer runs on the player's clock: asked for in 100 ms, it fires a few frames later. + press(&mut u, "Soon"); + assert_eq!(player(&u), r#"{"n":5}"#); + u.frames(12, Vec::new); + assert_eq!(player(&u), r#"{"n":3}"#, "the timer fired in the prototype"); + // Typing: a click on the text opens a field over it; Enter commits to the plugin. + press(&mut u, "Title"); + u.frame(vec![Event::Text("Hey".into())]); + u.frame(key(Key::Enter)); + u.frames(3, Vec::new); + assert_eq!(player(&u), r#"{"n":4}"#, "the typed text reached the plugin"); + // The file, its history and its timers are as they were, and restarting starts over. + let ds = u.app.session.ds().unwrap(); + assert_eq!((state(&ds.doc, w), ds.history.undo.len()), (r#"{"n":1}"#.to_string(), 3)); + assert!(figcraft_engine::widgets::next_timer(&u.app.session).is_none() && u.app.ui.toast.is_none(), "{:?}", u.app.ui.toast); + press(&mut u, "Soon"); + u.frame(key(Key::R)); + u.frames(12, Vec::new); + assert_eq!(player(&u), r#"{"n":1}"#, "restarting forgets the prototype's timers"); +} diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index 8cc6f064..9e9e0438 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -342,6 +342,9 @@ fn design(app: &mut FigApp, ui: &mut Ui) { } header(app, ui, &doc, &sel); widgets::separator(ui); + if crate::widget_ui::design_section(app, ui, &doc, &sel) { + widgets::separator(ui); + } // Editing a vector with points selected, the points' fields stand in for the layer's position // and dimensions. let points_selected = vector_edit.as_ref().is_some_and(|ve| !ve.vertices.is_empty() || !ve.segments.is_empty()); diff --git a/crates/ui-egui/src/typography.rs b/crates/ui-egui/src/typography.rs index 873ac0f0..7672e654 100644 --- a/crates/ui-egui/src/typography.rs +++ b/crates/ui-egui/src/typography.rs @@ -768,36 +768,11 @@ fn round_axis(axis: &figcraft_text::Axis, v: f64) -> f64 { /// Returns the new value (None = double-click: back to the style's value) and whether the /// gesture ended. fn axis_slider(ui: &mut Ui, id: Id, axis: &figcraft_text::Axis, value: Option, width: f32) -> Option<(Option, bool)> { - let t = Tokens::get(ui.ctx()); - let (rect, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover()); - let resp = ui.interact(rect, id, Sense::click_and_drag()); - let track = Rect::from_center_size(rect.center(), vec2(width - 12.0, 4.0)); - let span = (axis.max - axis.min).max(1e-9); - let x_of = |v: f64| track.min.x + ((v - axis.min) / span).clamp(0.0, 1.0) as f32 * track.width(); - let p = ui.painter(); - p.rect_filled(track, CornerRadius::same(2), t.border); - let dx = x_of(axis.default); - p.line_segment([pos2(dx, track.min.y - 3.0), pos2(dx, track.max.y + 3.0)], Stroke::new(1.0, t.border_strong)); - if let Some(v) = value { - let x = x_of(v); - p.rect_filled(Rect::from_min_max(track.min, pos2(x, track.max.y)), CornerRadius::same(2), t.accent); - p.circle_filled(pos2(x, track.center().y), 6.0, Color32::WHITE); - let ring = if resp.hovered() || resp.dragged() { t.accent } else { t.border_strong }; - p.circle_stroke(pos2(x, track.center().y), 6.0, Stroke::new(1.0, ring)); - } + let (resp, moved) = widgets::slider(ui, id, &axis.name, (axis.min, axis.max), Some(axis.default), value, width); if resp.double_clicked() { return Some((None, true)); } - if (resp.dragged() || resp.clicked() || resp.drag_started()) - && let Some(pos) = resp.interact_pointer_pos() - { - let f = f64::from(((pos.x - track.min.x) / track.width().max(1.0)).clamp(0.0, 1.0)); - return Some((Some(axis.min + f * span), resp.drag_stopped() || resp.clicked())); - } - if resp.drag_stopped() { - return value.map(|v| (Some(v), true)); - } - None + moved.map(|(v, done)| (Some(v), done)) } fn caption(ui: &mut Ui, s: &str) { diff --git a/crates/ui-egui/src/widget_ui.rs b/crates/ui-egui/src/widget_ui.rs new file mode 100644 index 00000000..6bb4e45e --- /dev/null +++ b/crates/ui-egui/src/widget_ui.rs @@ -0,0 +1,219 @@ +//! Widgets (the layers plugins draw) in the UI: the property menu of the selected widget, as a +//! bar beside it on the canvas and as the same items in the Design panel; the tooltip of the part +//! under the pointer; and the widgets' timers, which wake the window when the next one is due and +//! at no other time. Everything acts through the engine's `widget.*` commands. + +use egui::{Context, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use figcraft_color::{Color, Paint}; +use figcraft_doc::{Document, NodeId}; +use figcraft_engine::widgets::{self as engine, MenuItem, MenuKind}; +use serde_json::{Value, json}; + +use crate::FigApp; +use crate::i18n::tr; +use crate::right::{menu_item, menu_popup}; +use crate::theme::{ACCENT, Tokens}; +use crate::widgets::{self, FIELD_H, PAD}; + +/// How far the bar sits from the widget, and how much room the size label under a selection takes. +const GAP: f32 = 12.0; +const SIZE_LABEL: f32 = 24.0; +/// Width of a dropdown in the menu, and the side of a colour. +const DROPDOWN_W: f32 = 96.0; +const SWATCH: f32 = 18.0; + +/// Fire the widget timers that are due (their plugins are called back) and ask for the frame the +/// next one is due at. Nothing here draws a frame sooner. +pub fn tick(app: &mut FigApp, ctx: &Context) { + // While a prototype is presented the file's timers wait: a callback would change the file, + // and the prototype (which plays its own copy, with its own timers) would start over. + if app.ui.present.is_some() || engine::next_timer(&app.session).is_none() { + return; + } + let errors = engine::run_timers(&mut app.session); + app.handle_ui_requests(); + if let Some(e) = errors.into_iter().next_back() { + app.toast(e); + } + if let Some(secs) = engine::next_timer(&app.session) { + crate::repaint_in(ctx, secs); + } +} + +/// The one selected widget and its property menu, when it has one. +fn selected(app: &FigApp) -> Option<(NodeId, Vec)> { + let ds = app.session.ds().ok()?; + let [w] = ds.selection.as_slice() else { return None }; + let menu = engine::menu_of(ds.document_itself(), *w); + (!menu.is_empty()).then_some((*w, menu)) +} + +/// Choose item `name` of widget `w`'s menu: its plugin is called back; a link opens. +fn choose(app: &mut FigApp, ctx: &Context, w: NodeId, name: &str, value: Value) { + let Some(out) = app.run("widget.menuAction", json!({"id": w, "item": name, "value": value})) else { return }; + // Opened only on this click, and only what the engine checked is a web address. + if let Some(url) = out["url"].as_str() { + ctx.open_url(egui::OpenUrl::new_tab(url)); + } +} + +/// The menu's items in a row, each named for screen readers. Returns the item used and its new +/// value (null for an action or a link). +fn items(ui: &mut Ui, menu: &[MenuItem], t: &Tokens) -> Option<(String, Value)> { + let mut used = None; + for item in menu { + let label = item.title(); + let value = item.value.as_ref(); + let picked = match item.kind { + MenuKind::Action => widgets::button(ui, label, false, true).clicked().then_some(Value::Null), + MenuKind::Link => { + let resp = widgets::button(ui, &format!("{label} ↗"), false, true); + crate::a11y::link(&resp, label); + resp.on_hover_text(item.url.as_str()).clicked().then_some(Value::Null) + } + MenuKind::Toggle => { + let on = value.and_then(Value::as_bool).unwrap_or(false); + widgets::checkbox(ui, on, label).then_some(Value::Bool(!on)) + } + MenuKind::Dropdown => { + let shown = value.and_then(Value::as_str).unwrap_or_default(); + widgets::label(ui, label, t.text_secondary); + let resp = widgets::dropdown(ui, shown, DROPDOWN_W, None); + crate::a11y::combo(&resp, label, shown, crate::a11y::menu_open(&resp)); + let mut picked = None; + menu_popup(&resp).show(|ui| { + for o in &item.options { + if menu_item(ui, o, "") { + picked = Some(json!(o)); + ui.close(); + } + } + }); + picked + } + MenuKind::Color => { + widgets::label(ui, label, t.text_secondary); + let mut picked = None; + for hex in &item.options { + let resp = widgets::swatch(ui, &Paint::solid(Color::from_hex(hex).unwrap_or(Color::BLACK)), SWATCH); + crate::a11y::radio(&resp, &format!("{label}: {hex}"), value.and_then(Value::as_str) == Some(hex)); + if value.and_then(Value::as_str) == Some(hex) { + ui.painter().rect_stroke(resp.rect.expand(2.0), CornerRadius::same(4), Stroke::new(1.5, ACCENT), StrokeKind::Outside); + } + if resp.on_hover_text(hex.as_str()).clicked() { + picked = Some(json!(hex)); + } + } + picked + } + MenuKind::Separator => { + let (r, _) = ui.allocate_exact_size(vec2(1.0, FIELD_H), Sense::hover()); + ui.painter().line_segment([r.center_top(), r.center_bottom()], Stroke::new(1.0, t.border)); + None + } + }; + if let Some(v) = picked { + used = Some((item.name.clone(), v)); + } + } + used +} + +/// The selected widget's property menu as a bar on the canvas (`ui` is the canvas's: windows and +/// popups lie over the bar): centred above the widget, or under it when there is no room above, +/// and kept inside the canvas. Hidden while the widget is dragged or its text is typed. +pub fn menu_bar(app: &mut FigApp, ui: &mut Ui, canvas: Rect) { + let Some((w, menu)) = selected(app) else { return }; + let Ok(ds) = app.session.ds() else { return }; + if ds.interaction.is_some() || ds.text_edit.is_some() || ds.preview.is_some() { + return; + } + let Some(b) = ds.doc.world_bounds(w) else { return }; + let on_screen = Rect::from_two_pos(app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1)); + if !canvas.intersects(on_screen) { + return; + } + // Placed by the size it had in the last frame (drawn again when that changes). + let id = Id::new("widget_menu"); + let size: egui::Vec2 = ui.data(|d| d.get_temp(id)).unwrap_or(vec2(320.0, FIELD_H + PAD)); + let within = canvas.shrink(8.0); + let above = on_screen.min.y - GAP - size.y >= within.min.y; + let y = if above { on_screen.min.y - GAP - size.y } else { on_screen.max.y + GAP + SIZE_LABEL }; + let x = (on_screen.center().x - size.x / 2.0).min(within.max.x - size.x).max(within.min.x); + let at = Rect::from_min_size(pos2(x, y), size); + // The bar takes the pointer: a press on it isn't one on the canvas under it. + ui.interact(at, id.with("bar"), Sense::click_and_drag()); + let t = Tokens::get(ui.ctx()); + let room = Rect::from_min_max(at.min, pos2(within.max.x.max(at.max.x), at.max.y)); + let drawn = ui.scope_builder(egui::UiBuilder::new().id_salt(id).max_rect(room).layout(egui::Layout::left_to_right(egui::Align::Min)), |ui| { + crate::a11y::container(ui, egui::accesskit::Role::Toolbar, tr("Widget")); + widgets::bar(ui, |ui| items(ui, &menu, &t)) + }); + if drawn.response.rect.size() != size { + ui.data_mut(|d| d.insert_temp(id, drawn.response.rect.size())); + ui.ctx().request_repaint(); + } + if let Some((name, value)) = drawn.inner { + let ctx = ui.ctx().clone(); + choose(app, &ctx, w, &name, value); + } +} + +/// The selected widget's property menu in the Design panel (the same items as the bar's). Returns +/// whether it drew anything. +pub fn design_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) -> bool { + let [w] = sel else { return false }; + let menu = engine::menu_of(doc, *w); + if menu.is_empty() { + return false; + } + let t = Tokens::get(ui.ctx()); + widgets::section_header(ui, tr("Widget"), &[], false); + // Buttons and toggles share rows (wrapping); a dropdown or a row of colours has its own, + // with its label. + let plain = |i: &MenuItem| matches!(i.kind, MenuKind::Action | MenuKind::Toggle | MenuKind::Link); + let mut used = None; + for group in menu.chunk_by(|a, b| plain(a) && plain(b)) { + let hit = match group.first().map(|i| i.kind) { + None | Some(MenuKind::Separator) => None, + Some(MenuKind::Dropdown | MenuKind::Color) => { + let mut hit = None; + crate::right::row(ui, widgets::ROW_H, |ui| hit = items(ui, group, &t)); + hit + } + Some(_) => { + let inset = ui.available_rect_before_wrap().shrink2(vec2(PAD, 0.0)); + let wrapped = ui.scope_builder(egui::UiBuilder::new().max_rect(inset), |ui| { + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(8.0, 8.0); + items(ui, group, &t) + }) + .inner + }); + ui.add_space(4.0); + wrapped.inner + } + }; + used = hit.or(used); + } + ui.add_space(8.0); + if let Some((name, value)) = used { + let ctx = ui.ctx().clone(); + choose(app, &ctx, *w, &name, value); + } + true +} + +/// What the pointer over the canvas (its response) means for widgets: the tooltip of the part +/// under it; and once it has left the canvas (for a panel, a window over it, the menu bar), no +/// part is under it, so none keeps its hover style. +pub fn pointer(app: &mut FigApp, canvas: &egui::Response) { + if canvas.hovered() { + if let Some(tip) = app.session.tool.overlay.widget_tip.as_deref() { + canvas.clone().on_hover_text_at_pointer(tip); + } + } else if !canvas.ctx.input(|i| i.pointer.any_down()) { + engine::set_hover(&mut app.session, None); + app.session.tool.overlay.widget_tip = None; + } +} diff --git a/crates/ui-egui/src/widget_ui_tests.rs b/crates/ui-egui/src/widget_ui_tests.rs new file mode 100644 index 00000000..a71c74c8 --- /dev/null +++ b/crates/ui-egui/src/widget_ui_tests.rs @@ -0,0 +1,387 @@ +//! Plugin API 4 in the UI, driven headless: a widget's text is typed in place on the canvas and +//! committed to its plugin; the property menu sits by the selected widget and in the Design panel, +//! named for screen readers and reachable from the keyboard; a part's hover style and tooltip show +//! without a callback; a pending timer wakes the window when it is due and at no other time; and a +//! panel's slider tells its plugin now and then while dragged (a presented prototype's side is in +//! `present_widgets_tests`). The test plugin is WAT: it draws what its input asks. + +use std::time::Duration; + +use base64::Engine as _; +use egui::accesskit::{Node, Role}; +use egui::{Event, Key, Modifiers, Pos2, Rect}; +use figcraft_doc::{Document, NodeId}; +use figcraft_engine::Session; +use serde_json::{Value, json}; + +use crate::a11y::Region; +use crate::idle_tests::Harness; +use crate::layers_drag_tests::Ui; + +pub(crate) const NOTE: &str = "org.test.note"; + +/// A module that, for the first of `cases` whose needle its input contains (an empty needle +/// always does), draws the look (unless it is null) and asks for a timer (unless it is negative). +fn module(imports: &str, cases: &[(&str, Value, i32)], call: &str) -> Vec { + let lit = |s: &str| s.bytes().map(|b| format!("\\{b:02x}")).collect::(); + let (mut data, mut at, mut body) = (String::new(), 1000usize, String::new()); + let mut put = |text: &str| { + let a = at; + data += &format!("(data (i32.const {a}) \"{}\")\n", lit(text)); + at += text.len(); + (a, text.len()) + }; + for (needle, look, timer) in cases { + let ((n, nl), (l, ll)) = (put(needle), put(&look.to_string())); + let draw = if look.is_null() { String::new() } else { format!("(drop (call $draw (i32.const {l}) (i32.const {ll})))") }; + let timer = if *timer < 0 { String::new() } else { format!("(drop (call $timer (i32.const {timer})))") }; + body += &format!("(if (call $has (i32.const {n}) (i32.const {nl}) (local.get $n)) (then {draw} {timer} (return (i32.const 0))))\n"); + } + let wat = format!( + r#"(module + (import "figcraft" "reply_read" (func $reply_read (param i32 i32) (result i32))) + {imports} + (memory (export "memory") 4) + {data} + (func $has (param $p i32) (param $l i32) (param $n i32) (result i32) (local $i i32) (local $j i32) + (block $no (loop $outer + (br_if $no (i32.gt_s (i32.add (local.get $i) (local.get $l)) (local.get $n))) + (local.set $j (i32.const 0)) + (block $miss (loop $inner + (if (i32.ge_s (local.get $j) (local.get $l)) (then (return (i32.const 1)))) + (br_if $miss (i32.ne (i32.load8_u (i32.add (i32.const 100000) (i32.add (local.get $i) (local.get $j)))) + (i32.load8_u (i32.add (local.get $p) (local.get $j))))) + (local.set $j (i32.add (local.get $j) (i32.const 1))) + (br $inner))) + (local.set $i (i32.add (local.get $i) (i32.const 1))) + (br $outer))) + (i32.const 0)) + (func (export "figcraft_run") (param $n i32) (result i32) + (drop (call $reply_read (i32.const 100000) (local.get $n))) + {call} + {body} + (i32.const 0)))"# + ); + wat::parse_str(&wat).unwrap_or_else(|e| panic!("{e}\n{wat}")) +} + +/// The note widget in state `n`: a title to edit, a part with a hover style and a tooltip, and a +/// property menu. +fn look(n: i64) -> Value { + json!({"state": {"n": n}, "menu": [ + {"type": "action", "name": "reset", "label": "Start over"}, + {"type": "toggle", "name": "pinned", "label": "Pinned note"}, + {"type": "dropdown", "name": "size", "label": "Note size", "options": ["S", "M"], "value": "M"}, + {"type": "color", "name": "tint", "label": "Note tint", "options": ["#FF0000", "#00FF00"], "value": "#FF0000"}, + {"type": "separator"}, + {"type": "link", "name": "docs", "label": "Note docs", "url": "https://example.org/docs"} + ], "root": {"type": "frame", "name": "Note", "layout": "vertical", "gap": 8, "padding": 12, "fill": "#FFFFFF", "align": "min", "children": [ + {"type": "text", "name": "Title", "text": format!("Title {n}"), "input": "title", "maxLength": 12, "width": 160, "size": 14}, + {"type": "rectangle", "name": "Go", "target": "go", "width": 80, "height": 30, "fill": "#0D99FF", "hover": {"fill": "#00FF00"}, "tooltip": "Go on"}, + {"type": "rectangle", "name": "Soon", "target": "soon", "width": 80, "height": 30, "fill": "#F24822"} + ]}}) +} + +/// A session with the note widget's plugin: inserted it is in state 1; `go` draws 2 and asks for +/// a timer in a minute, `soon` draws 5 and asks for one in 100 ms, a timer draws 3, text typed +/// into the title draws 4, the menu's `reset` draws 0 and its other items draw nothing. +pub(crate) fn session() -> Session { + let imports = r#"(import "figcraft" "widget_render" (func $draw (param i32 i32) (result i32))) + (import "figcraft" "widget_timer" (func $timer (param i32) (result i32)))"#; + let cases = [ + (r#""timer":true"#, look(3), -1), + (r#""target":"go""#, look(2), 60_000), + (r#""target":"soon""#, look(5), 100), + (r#""input":"title""#, look(4), -1), + (r#""menu":"reset""#, look(0), -1), + (r#""menu":""#, Value::Null, -1), + ("", look(1), -1), + ]; + let manifest = + json!({"id": NOTE, "name": "Note", "version": "1.0.0", "main": "w.wasm", "api": 4, "widget": true, "permissions": {"access": "read"}}); + let mut s = Session::new(); + let wasm = base64::engine::general_purpose::STANDARD.encode(module(imports, &cases, "")); + s.execute("plugin.import", &json!({"manifest": manifest, "wasm": wasm})).unwrap(); + s +} + +fn insert(s: &mut Session) -> NodeId { + NodeId(s.execute("widget.insert", &json!({"plugin": NOTE, "x": 0, "y": 0})).unwrap()["id"].as_u64().unwrap()) +} + +pub(crate) fn state(doc: &Document, w: NodeId) -> String { + doc.node(w).and_then(|n| n.plugin_data.get("figcraft.widget.state").cloned()).unwrap_or_default() +} + +/// The layer called `name` inside widget `w`. +pub(crate) fn part(doc: &Document, w: NodeId, name: &str) -> NodeId { + doc.subtree(w).into_iter().find(|i| doc.node(*i).is_some_and(|n| n.name == name)).unwrap_or_else(|| panic!("no {name}")) +} + +/// Where the layer called `name` of widget `w` shows on the canvas. +fn on_canvas(app: &crate::FigApp, w: NodeId, name: &str) -> Rect { + let doc = app.session.doc().unwrap(); + let b = doc.world_bounds(if name.is_empty() { w } else { part(doc, w, name) }).unwrap(); + Rect::from_two_pos(app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1)) +} + +pub(crate) fn fill(doc: &Document, id: NodeId) -> String { + doc.node(id).unwrap().fills[0].solid_color().unwrap().to_css_hex() +} + +pub(crate) fn key(k: Key) -> Vec { + [true, false].map(|pressed| Event::Key { key: k, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }).to_vec() +} + +fn click(u: &mut Ui, at: Pos2) { + u.button(at, true); + u.button(at, false); +} + +/// The AccessKit tree a screen reader would get now. +fn tree(u: &mut Ui) -> Vec { + u.ctx.enable_accesskit(); + u.frame(vec![]); + u.frame_output(vec![]).platform_output.accesskit_update.expect("an AccessKit update").nodes.into_iter().map(|(_, n)| n).collect() +} + +fn bounds(n: &Node) -> Rect { + let b = n.bounds().unwrap(); + Rect::from_min_max(Pos2::new(b.x0 as f32, b.y0 as f32), Pos2::new(b.x1 as f32, b.y1 as f32)) +} + +#[test] +fn a_widgets_text_is_typed_in_place_on_the_canvas_and_committed_to_its_plugin() { + let mut s = session(); + let w = insert(&mut s); + s.execute("select.set", &json!({"ids": []})).unwrap(); + let mut u = Ui::new(s); + let undo = u.app.session.ds().unwrap().history.undo.len(); + let title = part(u.app.session.doc().unwrap(), w, "Title"); + let at = on_canvas(&u.app, w, "Title").center(); + click(&mut u, at); + assert_eq!(u.app.session.ds().unwrap().text_edit.as_ref().map(|t| t.id), Some(title), "a click starts editing the text in place"); + // What is typed replaces the selected text and shows at once, up to the text's maximum. + u.frame(vec![Event::Text("Hello, wide world".into())]); + let shown = |u: &Ui| u.app.session.doc().unwrap().node(title).and_then(|n| n.kind.text()).map(|t| t.characters.clone()); + assert_eq!(shown(&u).as_deref(), Some("Hello, wide ")); + assert_eq!(state(u.app.session.doc().unwrap(), w), r#"{"n":1}"#, "the plugin hears nothing while it's typed"); + // Enter commits: the plugin is called back and redraws, as one undo step. + u.frame(key(Key::Enter)); + u.frame(vec![]); + let ds = u.app.session.ds().unwrap(); + assert!(ds.text_edit.is_none()); + assert_eq!((state(&ds.doc, w), ds.history.undo.len(), ds.selection.clone()), (r#"{"n":4}"#.to_string(), undo + 1, vec![w])); +} + +#[test] +fn the_property_menu_sits_by_the_selected_widget_and_in_the_design_panel() { + let mut s = session(); + let w = insert(&mut s); + let mut u = Ui::new(s); + let nodes = tree(&mut u); + // The panel keeps its place beside the canvas (a row that overflows would push it out). + assert!((u.app.canvas.rect.max.x - 1200.0).abs() < 1.0, "{:?}", u.app.canvas.rect); + let (canvas, widget) = (u.app.canvas.rect, on_canvas(&u.app, w, "")); + // Every item is there twice, named: in the bar over the canvas and in the Design panel. + let named = |role: Role, label: &str| nodes.iter().filter(|n| n.role() == role && n.label() == Some(label)).map(bounds).collect::>(); + // The bar sits just above the widget, or just under it when the canvas has no room above. + let above = widget.min.y - 68.0 >= canvas.min.y; + for (role, label) in [ + (Role::Button, "Start over"), + (Role::CheckBox, "Pinned note"), + (Role::ComboBox, "Note size"), + (Role::RadioButton, "Note tint: #FF0000"), + (Role::Link, "Note docs"), + ] { + let at = named(role, label); + let (bar, panel): (Vec<&Rect>, Vec<&Rect>) = at.iter().partition(|r| canvas.contains_rect(**r)); + assert_eq!((bar.len(), panel.len()), (1, 1), "{label}: one over the canvas, one in the panel: {at:?} (canvas {canvas:?})"); + let gap = if above { widget.min.y - bar[0].max.y } else { bar[0].min.y - widget.max.y }; + assert!((0.0..60.0).contains(&gap), "{label} at {:?}, the widget at {widget:?} (above: {above})", bar[0]); + assert!(panel[0].min.x >= canvas.max.x, "{label} in the Design panel: {:?}", panel[0]); + } + assert!(nodes.iter().any(|n| n.role() == Role::Toolbar && n.label() == Some("Widget"))); + let bar_reset = named(Role::Button, "Start over").into_iter().find(|r| canvas.contains_rect(*r)).unwrap(); + assert!((bar_reset.center().x - widget.center().x).abs() < 300.0, "over the widget's middle (or as near as the canvas allows)"); + // Using an item calls the plugin back; a toggle shows its new state. + click(&mut u, bar_reset.center()); + assert_eq!(state(u.app.session.doc().unwrap(), w), r#"{"n":0}"#); + let pinned = nodes.iter().filter(|n| n.label() == Some("Pinned note")).map(bounds).find(|r| !canvas.contains_rect(*r)).unwrap(); + click(&mut u, pinned.center()); + let menu = u.app.session.execute("widget.menu", &json!({"id": w})).unwrap(); + assert_eq!(menu["items"][1]["value"], json!(true), "the Design panel's toggle"); + // The Design panel's items are reached and used with the keyboard: F6 to the panel, then Tab, + // and Enter on the action. + u.app.session.execute("widget.click", &json!({"id": w, "target": "go"})).unwrap(); + for _ in 0..4 { + if u.app.ui.nav.region == Region::Properties { + break; + } + u.frame(key(Key::F6)); + } + assert_eq!(u.app.ui.nav.region, Region::Properties); + let mut seen = Vec::new(); + for _ in 0..60 { + seen.push(crate::a11y::focused_name(&u.ctx)); + if seen.last().is_some_and(|n| n == "Start over") { + u.frame(key(Key::Enter)); + u.frame(vec![]); + assert_eq!(state(u.app.session.doc().unwrap(), w), r#"{"n":0}"#, "used from the keyboard"); + } + u.frame(key(Key::Tab)); + } + for want in ["Start over", "Pinned note", "Note size", "Note docs"] { + assert!(seen.iter().any(|n| n == want), "{want} not reached by Tab: {seen:?}"); + } + // Deselected, there is no menu anywhere. + u.app.session.execute("select.set", &json!({"ids": []})).unwrap(); + assert!(!tree(&mut u).iter().any(|n| n.label() == Some("Start over"))); +} + +#[test] +fn a_parts_hover_style_and_tooltip_show_without_a_callback_or_more_frames() { + let mut s = session(); + let w = insert(&mut s); + s.execute("select.set", &json!({"ids": []})).unwrap(); + let mut h = Harness::new(s); + h.settle(); + let go = part(h.app.session.doc().unwrap(), w, "Go"); + let at = on_canvas(&h.app, w, "Go").center(); + h.frame(vec![Event::PointerMoved(at)]); + h.frame(vec![]); + let ds = h.app.session.ds().unwrap(); + assert_eq!((fill(&ds.doc, go), fill(ds.document_itself(), go)), ("#00FF00".to_string(), "#0D99FF".to_string()), "shown, not in the document"); + assert_eq!((h.app.session.tool.overlay.widget_tip.as_deref(), ds.history.undo.len()), (Some("Go on"), 1)); + assert_eq!(state(&ds.doc, w), r#"{"n":1}"#, "its plugin wasn't called"); + // Resting there draws nothing more. + h.assert_idle("a hovered widget part"); + assert_eq!(fill(&h.app.session.ds().unwrap().doc, go), "#00FF00"); + // Moving off puts it back, on the canvas and when the pointer leaves it for a panel. + h.frame(vec![Event::PointerMoved(at + egui::vec2(0.0, 300.0))]); + let ds = h.app.session.ds().unwrap(); + assert!(fill(&ds.doc, go) == "#0D99FF" && ds.text_preview.is_none() && h.app.session.tool.overlay.widget_tip.is_none()); + h.frame(vec![Event::PointerMoved(at)]); + h.frame(vec![]); + assert_eq!(fill(&h.app.session.ds().unwrap().doc, go), "#00FF00"); + h.frame(vec![Event::PointerMoved(egui::pos2(1300.0, 500.0))]); + h.frame(vec![]); + let ds = h.app.session.ds().unwrap(); + assert!(fill(&ds.doc, go) == "#0D99FF" && ds.text_preview.is_none() && h.app.session.tool.overlay.widget_tip.is_none(), "over the Design panel"); +} + +#[test] +fn a_pending_timer_wakes_the_window_when_it_is_due_and_at_no_other_time() { + let mut s = session(); + let w = insert(&mut s); + let mut h = Harness::new(s); + // No timer: the next frame is the autosave's, minutes away. + assert!(h.settle() > Duration::from_secs(60)); + // A timer a minute off: the window sleeps until then, and draws nothing meanwhile. + h.run("widget.click", json!({"id": w, "target": "go"})); + let wake = h.settle(); + assert!(wake > Duration::from_secs(30) && wake <= Duration::from_secs(60), "{wake:?}"); + h.assert_idle("a widget with a timer pending"); + assert_eq!(state(h.app.session.doc().unwrap(), w), r#"{"n":2}"#); + // One due in 100 ms fires in the frame after that: its plugin is called back. + h.run("widget.click", json!({"id": w, "target": "soon"})); + assert!(h.frame(vec![]) <= Duration::from_millis(100)); + assert_eq!(state(h.app.session.doc().unwrap(), w), r#"{"n":5}"#, "not before it is due"); + std::thread::sleep(Duration::from_millis(130)); + h.frame(vec![]); + assert_eq!(state(h.app.session.doc().unwrap(), w), r#"{"n":3}"#); + // It asked for no other: the window is idle again, with nothing to wake for. + assert!(h.settle() > Duration::from_secs(60)); + // While a prototype is presented the file's timers wait (the prototype plays its own copy). + let a = h.run("frame.create", json!({"x": 400, "y": 0, "width": 300, "height": 300}))["id"].clone(); + h.run("widget.click", json!({"id": w, "target": "soon"})); + h.run("proto.present", json!({"start": a})); + std::thread::sleep(Duration::from_millis(130)); + h.frame(vec![]); + assert_eq!(state(h.app.session.doc().unwrap(), w), r#"{"n":5}"#); + h.app.ui.present = None; + h.frame(vec![]); + assert_eq!(state(h.app.session.doc().unwrap(), w), r#"{"n":3}"#, "it fires once the presentation ends"); +} + +#[test] +fn a_panels_slider_tells_its_plugin_now_and_then_while_it_is_dragged() { + use crate::plugins_ui::slider_due; + assert!(slider_due(false, 5.0, None) && slider_due(true, 5.0, Some(4.99)), "the first move and the last always tell"); + assert!(!slider_due(false, 5.05, Some(5.0)) && slider_due(false, 5.2, Some(5.0))); + // A panel with a slider, a picture, a progress bar and a section; each event makes a rectangle. + let spec = json!({"title": "Dots", "controls": [ + {"type": "slider", "name": "amount", "label": "Amount", "min": 0, "max": 100, "step": 1, "value": 0}, + {"type": "image", "label": "Preview", "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", "height": 40}, + {"type": "progress", "label": "Half done", "value": 0.5}, + {"type": "divider"}, + {"type": "section", "label": "Fine tuning", "value": false}, + {"type": "text", "name": "hidden", "label": "Hidden", "error": "Needs a name"}, + {"type": "button", "name": "go", "label": "Go", "disabled": true}, + {"type": "divider"}, + {"type": "note", "label": "Always shown"} + ]}); + // Its strings: the command, its parameters, the needle that tells an event from the first run, + // and the panel. + let hex = |s: &str| s.bytes().map(|b| format!("\\{b:02x}")).collect::(); + let (cmd, rect, needle, text) = ("shape.rectangle", r#"{"x":0,"y":0,"width":5,"height":5}"#, r#""event""#, spec.to_string()); + let imports = format!( + r#"(import "figcraft" "ui_show" (func $show (param i32 i32) (result i32))) + (import "figcraft" "call" (func $call (param i32 i32 i32 i32) (result i32))) + (data (i32.const 200) "{}") (data (i32.const 300) "{}") (data (i32.const 400) "{}") (data (i32.const 50000) "{}")"#, + hex(cmd), + hex(rect), + hex(needle), + hex(&text) + ); + let call = format!( + "(if (i32.eqz (call $has (i32.const 400) (i32.const {}) (local.get $n))) (then (return (call $show (i32.const 50000) (i32.const {}))))) + (drop (call $call (i32.const 200) (i32.const {}) (i32.const 300) (i32.const {})))", + needle.len(), + text.len(), + cmd.len(), + rect.len() + ); + let manifest = json!({"id": "org.test.dots", "name": "Dots", "main": "p.wasm", "api": 4, "permissions": {"access": "write"}}); + let mut s = Session::new(); + let wasm = base64::engine::general_purpose::STANDARD.encode(module(&imports, &[], &call)); + s.execute("plugin.import", &json!({"manifest": manifest, "wasm": wasm})).unwrap(); + s.execute("plugin.run", &json!({"id": "org.test.dots"})).unwrap(); + let mut u = Ui::new(s); + let nodes = tree(&mut u); + let find = |role: Role, label: &str| nodes.iter().find(|n| n.role() == role && n.label() == Some(label)).map(bounds); + let track = find(Role::Slider, "Amount").expect("the slider is named"); + assert!( + find(Role::Image, "Preview").is_some() && find(Role::ProgressIndicator, "Half done").is_some(), + "the picture and the progress bar are named" + ); + assert!(nodes.iter().all(|n| n.label() != Some("Hidden") && n.label() != Some("Go")), "a closed section hides its controls"); + assert!(nodes.iter().any(|n| n.label().or(n.value()) == Some("Always shown")), "a divider ends the section"); + let layers = |u: &Ui| { + let ds = u.app.session.ds().unwrap(); + ds.doc.node(ds.page).unwrap().children.len() + }; + // Dragged across in 40 frames (two thirds of a second): the plugin hears a handful of times, + // not forty, the slider follows the pointer all the way, and the last value is told. + let (from, to) = (Pos2::new(track.min.x + 8.0, track.center().y), Pos2::new(track.max.x - 8.0, track.center().y)); + u.button(from, true); + for k in 1..=40 { + u.frame(vec![Event::PointerMoved(from + (to - from) * (k as f32 / 40.0))]); + } + let during = layers(&u); + assert!((3..=9).contains(&during), "told {during} times while dragging"); + u.button(to, false); + u.frame(vec![]); + let value = u.app.session.plugins.panel("org.test.dots").unwrap().values["amount"].as_f64().unwrap(); + assert!(value >= 95.0 && layers(&u) <= during + 1, "the plugin has the value the drag ended on: {value}"); + // Opening the section shows its controls: the validation message, and a button that can't be used. + let more = nodes.iter().find(|n| n.label() == Some("Fine tuning")).map(bounds).expect("the section's heading"); + click(&mut u, more.center()); + let nodes = tree(&mut u); + let labels: Vec<&str> = nodes.iter().filter_map(|n| n.label().or(n.value())).collect(); + assert!(labels.contains(&"Hidden") && labels.contains(&"Needs a name"), "the field and its message: {labels:?}"); + let go = nodes.iter().find(|n| n.label() == Some("Go")).map(bounds).unwrap(); + let before = layers(&u); + click(&mut u, go.center()); + assert_eq!(layers(&u), before, "a disabled button calls nobody"); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 508e6fc4..6531957d 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -750,30 +750,91 @@ pub fn icon_button(ui: &mut Ui, icon: Icon, tip: &str) -> bool { /// A bar floating at the top centre of the `canvas` area (a version preview, the focus view): /// `add` lays out its contents in a row. pub fn canvas_banner(ctx: &egui::Context, id: Id, canvas: Rect, add: impl FnOnce(&mut Ui) -> R) -> R { - let t = Tokens::get(ctx); egui::Area::new(id) .order(egui::Order::Foreground) .pivot(Align2::CENTER_TOP) .fixed_pos(pos2(canvas.center().x, canvas.min.y + 12.0)) - .show(ctx, |ui| { - egui::Frame::NONE - .fill(t.panel) - .stroke(Stroke::new(1.0, t.border)) - .corner_radius(CornerRadius::same(8)) - .shadow(egui::epaint::Shadow { offset: [0, 2], blur: 12, spread: 0, color: t.shadow }) - .inner_margin(egui::Margin { left: 14, right: 8, top: 8, bottom: 8 }) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 8.0; - add(ui) - }) - .inner - }) - .inner + .show(ctx, |ui| bar(ui, add)) + .inner +} + +/// A bar's box (the panel's fill, a border, a shadow) around `add`'s contents, laid out in a row. +pub fn bar(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R { + let t = Tokens::get(ui.ctx()); + egui::Frame::NONE + .fill(t.panel) + .stroke(Stroke::new(1.0, t.border)) + .corner_radius(CornerRadius::same(8)) + .shadow(egui::epaint::Shadow { offset: [0, 2], blur: 12, spread: 0, color: t.shadow }) + .inner_margin(egui::Margin { left: 14, right: 8, top: 8, bottom: 8 }) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 8.0; + add(ui) + }) + .inner }) .inner } +/// A horizontal slider over `range`, named `name` for screen readers, with a mark at `tick` (a +/// notable value). `value` None = mixed: no knob. Returns its response and, when it was moved, +/// the new value and whether the gesture ended. +pub fn slider( + ui: &mut Ui, + id: Id, + name: &str, + range: (f64, f64), + tick: Option, + value: Option, + width: f32, +) -> (Response, Option<(f64, bool)>) { + let t = Tokens::get(ui.ctx()); + let (min, max) = range; + let (rect, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover()); + let resp = ui.interact(rect, id, Sense::click_and_drag()); + a11y::slider(&resp, name, value, range); + let track = Rect::from_center_size(rect.center(), vec2(width - 12.0, 4.0)); + let span = (max - min).max(1e-9); + let x_of = |v: f64| track.min.x + ((v - min) / span).clamp(0.0, 1.0) as f32 * track.width(); + let p = ui.painter(); + p.rect_filled(track, CornerRadius::same(2), t.border); + if let Some(tick) = tick { + let x = x_of(tick); + p.line_segment([pos2(x, track.min.y - 3.0), pos2(x, track.max.y + 3.0)], Stroke::new(1.0, t.border_strong)); + } + if let Some(v) = value { + let x = x_of(v); + p.rect_filled(Rect::from_min_max(track.min, pos2(x, track.max.y)), CornerRadius::same(2), t.accent); + p.circle_filled(pos2(x, track.center().y), 6.0, Color32::WHITE); + let ring = if resp.hovered() || resp.dragged() { t.accent } else { t.border_strong }; + p.circle_stroke(pos2(x, track.center().y), 6.0, Stroke::new(1.0, ring)); + } + let moved = if (resp.dragged() || resp.clicked() || resp.drag_started()) + && let Some(pos) = resp.interact_pointer_pos() + { + let f = f64::from(((pos.x - track.min.x) / track.width().max(1.0)).clamp(0.0, 1.0)); + Some((min + f * span, resp.drag_stopped() || resp.clicked())) + } else if resp.drag_stopped() { + value.map(|v| (v, true)) + } else { + None + }; + (resp, moved) +} + +/// A progress bar `width` wide filled to `fraction` (0–1), named `name` for screen readers. +pub fn progress_bar(ui: &mut Ui, name: &str, fraction: f64, width: f32) { + let t = Tokens::get(ui.ctx()); + let fraction = if fraction.is_finite() { fraction.clamp(0.0, 1.0) } else { 0.0 }; + let (rect, resp) = ui.allocate_exact_size(vec2(width, 12.0), Sense::hover()); + a11y::progress(&resp, name, fraction); + let track = Rect::from_center_size(rect.center(), vec2(width, 4.0)); + ui.painter().rect_filled(track, CornerRadius::same(2), t.border); + let done = Rect::from_min_max(track.min, pos2(track.min.x + track.width() * fraction as f32, track.max.y)); + ui.painter().rect_filled(done, CornerRadius::same(2), t.accent); +} + /// Where a dragged row will land: an accent line across `x0..x1` at `y`. pub fn insertion_line(painter: &egui::Painter, x0: f32, x1: f32, y: f32) { painter.line_segment([pos2(x0, y), pos2(x1, y)], Stroke::new(2.0, crate::theme::ACCENT));