mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-28 20:36:55 -06:00
M11: plugin API 4: menu bar and Design panel section, panel sliders and pictures, timers and typing in the UI and the presenter, catalogs
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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" = "新しいウィンドウ"
|
||||
|
||||
|
||||
@@ -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" = "새 창"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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" = "新建窗口"
|
||||
|
||||
|
||||
@@ -891,6 +891,33 @@ pub fn number(resp: &Response, label: &str, value: Option<f64>, suffix: &str, em
|
||||
});
|
||||
}
|
||||
|
||||
/// A slider at `value` (none: mixed) within `range`.
|
||||
pub fn slider(resp: &Response, label: &str, value: Option<f64>, 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| {
|
||||
|
||||
@@ -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())) {
|
||||
|
||||
@@ -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<Event>) -> Duration {
|
||||
pub(crate) fn frame(&mut self, events: Vec<Event>) -> 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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<String, Value>, t: &Tokens) -> Option<Used> {
|
||||
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<String, Value>,
|
||||
caption_w: f32,
|
||||
first_button: &mut bool,
|
||||
t: &Tokens,
|
||||
) -> Option<Used> {
|
||||
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<String, Valu
|
||||
}
|
||||
ParamKind::Button => {
|
||||
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<egui::TextureHandle>)>(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<egui::ColorImage> {
|
||||
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<f64>) -> 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<Value> {
|
||||
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<f64>)>(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<f64>)>(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<Used> {
|
||||
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<Used> {
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
+120
-20
@@ -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<NodeId>,
|
||||
widget_tip: Option<String>,
|
||||
/// 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<String> {
|
||||
/// 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<String> {
|
||||
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::<Vec<_>>());
|
||||
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"]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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<f64>, width: f32) -> Option<(Option<f64>, 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) {
|
||||
|
||||
@@ -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<MenuItem>)> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<u8> {
|
||||
let lit = |s: &str| s.bytes().map(|b| format!("\\{b:02x}")).collect::<String>();
|
||||
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<Event> {
|
||||
[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<Node> {
|
||||
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::<Vec<_>>();
|
||||
// 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::<String>();
|
||||
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");
|
||||
}
|
||||
@@ -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<R>(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<R>(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<f64>,
|
||||
value: Option<f64>,
|
||||
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));
|
||||
|
||||
Reference in New Issue
Block a user