diff --git a/crates/doc/src/proto.rs b/crates/doc/src/proto.rs index 91d2e8eb..a844774c 100644 --- a/crates/doc/src/proto.rs +++ b/crates/doc/src/proto.rs @@ -860,7 +860,35 @@ pub struct Device { impl PrototypeSettings { /// The devices to pick from ("None" presents the frame at its own size). - pub const DEVICES: [Device; 6] = [ + pub const DEVICES: [Device; 19] = [ + Device { + name: "iPhone 17", + width: 402.0, + height: 874.0, + rotates: true, + colors: &[c("Black", 0x38, 0x39, 0x3D), c("White", 0xE9, 0xE9, 0xE5)], + }, + Device { + name: "iPhone 17 Pro", + width: 402.0, + height: 874.0, + rotates: true, + colors: &[c("Black", 0x38, 0x39, 0x3D), c("Silver", 0xD7, 0xD7, 0xD5)], + }, + Device { + name: "iPhone 17 Pro Max", + width: 440.0, + height: 956.0, + rotates: true, + colors: &[c("Black", 0x38, 0x39, 0x3D), c("Silver", 0xD7, 0xD7, 0xD5)], + }, + Device { + name: "iPhone Air", + width: 420.0, + height: 912.0, + rotates: true, + colors: &[c("Black", 0x38, 0x39, 0x3D), c("Silver", 0xD7, 0xD7, 0xD5)], + }, Device { name: "iPhone 16", width: 393.0, @@ -874,6 +902,13 @@ impl PrototypeSettings { c("Blue", 0x72, 0x84, 0xD4), ], }, + Device { + name: "iPhone 16 Pro", + width: 402.0, + height: 874.0, + rotates: true, + colors: &[c("Black", 0x3B, 0x3A, 0x38), c("White", 0xEC, 0xEB, 0xE6)], + }, Device { name: "iPhone 16 Pro Max", width: 440.0, @@ -881,6 +916,55 @@ impl PrototypeSettings { rotates: true, colors: &[c("Black", 0x3B, 0x3A, 0x38), c("White", 0xEC, 0xEB, 0xE6), c("Natural", 0xBF, 0xB9, 0xAF), c("Sand", 0xC6, 0xAC, 0x8F)], }, + Device { + name: "iPhone 16 Plus", + width: 430.0, + height: 932.0, + rotates: true, + colors: &[c("Black", 0x3A, 0x3B, 0x3F), c("White", 0xEE, 0xED, 0xE8)], + }, + Device { + name: "iPhone 15 Pro", + width: 393.0, + height: 852.0, + rotates: true, + colors: &[c("Black", 0x3B, 0x3A, 0x38), c("Silver", 0xD7, 0xD7, 0xD5)], + }, + Device { + name: "iPhone 15 Pro Max", + width: 430.0, + height: 932.0, + rotates: true, + colors: &[c("Black", 0x3B, 0x3A, 0x38), c("Silver", 0xD7, 0xD7, 0xD5)], + }, + Device { + name: "iPhone 15 Plus", + width: 430.0, + height: 932.0, + rotates: true, + colors: &[c("Black", 0x3A, 0x3B, 0x3F), c("White", 0xEE, 0xED, 0xE8)], + }, + Device { + name: "iPhone 14 Pro", + width: 393.0, + height: 852.0, + rotates: true, + colors: &[c("Black", 0x3B, 0x3A, 0x38), c("Silver", 0xD7, 0xD7, 0xD5)], + }, + Device { + name: "iPhone 14 Pro Max", + width: 430.0, + height: 932.0, + rotates: true, + colors: &[c("Black", 0x3B, 0x3A, 0x38), c("Silver", 0xD7, 0xD7, 0xD5)], + }, + Device { + name: "iPhone 14", + width: 390.0, + height: 844.0, + rotates: true, + colors: &[c("Black", 0x3A, 0x3B, 0x3F), c("White", 0xEE, 0xED, 0xE8)], + }, Device { name: "Android Compact", width: 412.0, @@ -888,6 +972,13 @@ impl PrototypeSettings { rotates: true, colors: &[c("Black", 0x2E, 0x2F, 0x33), c("Silver", 0xD6, 0xD8, 0xDB), c("Green", 0xA6, 0xC0, 0xA3)], }, + Device { + name: "Android Medium", + width: 700.0, + height: 840.0, + rotates: true, + colors: &[c("Black", 0x2E, 0x2F, 0x33), c("Silver", 0xD6, 0xD8, 0xDB)], + }, Device { name: "iPad mini", width: 744.0, diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index 12e403c6..48deda39 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -333,6 +333,7 @@ status = "draft — needs native review" "Current version" = "Aktuální verze" "Cursor chat" = "Chat u kurzoru" "Custom" = "Vlastní" +"Custom size" = "Vlastní velikost" "Cut" = "Vyjmout" "Dark" = "Tmavý" "Darken" = "Ztmavit" @@ -796,6 +797,7 @@ status = "draft — needs native review" "Nested instance" = "Vnořená instance" "New design file" = "Nový soubor návrhu" "Newest first" = "Od nejnovějších" +"Next frame" = "Další rámec" "Next or previous control in the focused region" = "Další nebo předchozí ovládací prvek v zaměřené oblasti" "Next ready for dev" = "Další připravené pro vývoj" "Next size option" = "Další možnost velikosti" @@ -813,6 +815,7 @@ status = "draft — needs native review" "No components yet. Select layers and press {keys}." = "Zatím žádné komponenty. Vyberte vrstvy a stiskněte {keys}." "No conflicts." = "Žádné konflikty." "No contributor data was built into this copy." = "Toto sestavení neobsahuje údaje o přispěvatelích." +"No device" = "Bez zařízení" "No fonts match" = "Žádná odpovídající písma" "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Kód pro připojení není dostupný (server kódy nepodporuje nebo tuto adresu nelze do kódu vložit): sdílejte odkaz." "No layer changes to show." = "Žádné změny vrstev k zobrazení." @@ -996,6 +999,7 @@ status = "draft — needs native review" "Preview" = "Náhled" "Preview this flow" = "Náhled tohoto toku" "Preview this frame" = "Náhled tohoto rámce" +"Previous frame" = "Předchozí rámec" "Previous ready for dev" = "Předchozí připravené pro vývoj" "Progressive" = "Postupné" "Properties" = "Vlastnosti" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index e05690e3..2e0ce547 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -329,6 +329,7 @@ status = "draft — needs native review" "Current version" = "Aktuelle Version" "Cursor chat" = "Cursor-Chat" "Custom" = "Benutzerdefiniert" +"Custom size" = "Benutzerdefinierte Größe" "Cut" = "Ausschneiden" "Dark" = "Dunkel" "Darken" = "Abdunkeln" @@ -769,6 +770,7 @@ status = "draft — needs native review" "Navigate to" = "Navigieren zu" "Nested instance" = "Verschachtelte Instanz" "New design file" = "Neue Designdatei" +"Next frame" = "Nächster Rahmen" "Next or previous control in the focused region" = "Nächstes oder vorheriges Steuerelement im fokussierten Bereich" "Next ready for dev" = "Nächstes bereit für Entwicklung" "Next size option" = "Nächste Größenoption" @@ -786,6 +788,7 @@ status = "draft — needs native review" "No components yet. Select layers and press {keys}." = "Noch keine Komponenten. Wählen Sie Ebenen aus und drücken Sie {keys}." "No conflicts." = "Keine Konflikte." "No contributor data was built into this copy." = "Diese Kopie enthält keine Daten zu Mitwirkenden." +"No device" = "Kein Gerät" "No fonts match" = "Keine passenden Schriften" "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Hier gibt es keinen Beitrittscode (der Server ist älter als Beitrittscodes, oder diese Adresse passt in keinen): Teilen Sie stattdessen den Link." "No layer changes to show." = "Keine Ebenenänderungen vorhanden." @@ -962,6 +965,7 @@ status = "draft — needs native review" "Preview" = "Vorschau" "Preview this flow" = "Vorschau dieses Ablaufs" "Preview this frame" = "Vorschau dieses Rahmens" +"Previous frame" = "Vorheriger Rahmen" "Previous ready for dev" = "Vorheriges bereit für Entwicklung" "Progressive" = "Progressiv" "Properties" = "Eigenschaften" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 15c095f7..524ebc47 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -341,6 +341,7 @@ name = "English" "Current version" = "Current version" "Cursor chat" = "Cursor chat" "Custom" = "Custom" +"Custom size" = "Custom size" "Cut" = "Cut" "Dark" = "Dark" "Darken" = "Darken" @@ -810,6 +811,7 @@ name = "English" "New design file" = "New design file" "Newest first" = "Newest first" "Next or previous control in the focused region" = "Next or previous control in the focused region" +"Next frame" = "Next frame" "Next ready for dev" = "Next ready for dev" "Next size option" = "Next size option" "No assets: no export settings, icons or images. Add export settings in the Design tab." = "No assets: no export settings, icons or images. Add export settings in the Design tab." @@ -826,6 +828,7 @@ name = "English" "No components yet. Select layers and press {keys}." = "No components yet. Select layers and press {keys}." "No conflicts." = "No conflicts." "No contributor data was built into this copy." = "No contributor data was built into this copy." +"No device" = "No device" "No fonts match" = "No fonts match" "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." "No layer changes to show." = "No layer changes to show." @@ -1015,6 +1018,7 @@ name = "English" "Preview" = "Preview" "Preview this flow" = "Preview this flow" "Preview this frame" = "Preview this frame" +"Previous frame" = "Previous frame" "Previous ready for dev" = "Previous ready for dev" "Progressive" = "Progressive" "Properties" = "Properties" @@ -1691,4 +1695,4 @@ name = "English" "⌘ arrows" = "⌘ arrows" "⌘ click" = "⌘ click" "⌘ scroll" = "⌘ scroll" -"⌥ drag" = "⌥ drag" \ No newline at end of file +"⌥ drag" = "⌥ drag" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index fa7b9006..872a3ab7 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -342,6 +342,7 @@ status = "draft — needs native review" "Current version" = "Versión actual" "Cursor chat" = "Chat del cursor" "Custom" = "Personalizado" +"Custom size" = "Tamaño personalizado" "Cut" = "Cortar" "Dark" = "Oscuro" "Darken" = "Oscurecer" @@ -811,6 +812,7 @@ status = "draft — needs native review" "New design file" = "Nuevo archivo de diseño" "Newest first" = "Más recientes primero" "Next or previous control in the focused region" = "Control siguiente o anterior de la región enfocada" +"Next frame" = "Marco siguiente" "Next ready for dev" = "Siguiente listo para desarrollo" "Next size option" = "Siguiente opción de tamaño" "No assets: no export settings, icons or images. Add export settings in the Design tab." = "No hay recursos: ni ajustes de exportación, ni iconos, ni imágenes. Añade ajustes de exportación en la pestaña Diseño." @@ -827,6 +829,7 @@ status = "draft — needs native review" "No components yet. Select layers and press {keys}." = "Aún no hay componentes. Selecciona capas y pulsa {keys}." "No conflicts." = "Sin conflictos." "No contributor data was built into this copy." = "Esta copia no incluye datos de colaboradores." +"No device" = "Sin dispositivo" "No fonts match" = "Ninguna fuente coincide" "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Aquí no hay código para unirse (el servidor es anterior a los códigos, o esta dirección no cabe en uno): comparte el enlace." "No layer changes to show." = "No hay cambios de capas que mostrar." @@ -1016,6 +1019,7 @@ status = "draft — needs native review" "Preview" = "Vista previa" "Preview this flow" = "Vista previa de este flujo" "Preview this frame" = "Vista previa de este marco" +"Previous frame" = "Marco anterior" "Previous ready for dev" = "Anterior listo para desarrollo" "Progressive" = "Progresivo" "Properties" = "Propiedades" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index d07ad1da..e9afd7e5 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -328,6 +328,7 @@ status = "draft — needs native review" "Current version" = "Version actuelle" "Cursor chat" = "Chat au curseur" "Custom" = "Personnalisé" +"Custom size" = "Taille personnalisée" "Cut" = "Couper" "Dark" = "Sombre" "Darken" = "Assombrir" @@ -765,6 +766,7 @@ status = "draft — needs native review" "Navigate to" = "Naviguer vers" "Nested instance" = "Instance imbriquée" "New design file" = "Nouveau fichier de design" +"Next frame" = "Cadre suivant" "Next or previous control in the focused region" = "Contrôle suivant ou précédent dans la région active" "Next ready for dev" = "Prêt pour le dev suivant" "Next size option" = "Option de taille suivante" @@ -782,6 +784,7 @@ status = "draft — needs native review" "No components yet. Select layers and press {keys}." = "Aucun composant pour l’instant. Sélectionnez des calques et appuyez sur {keys}." "No conflicts." = "Aucun conflit." "No contributor data was built into this copy." = "Cette copie ne contient aucune donnée de contributeurs." +"No device" = "Aucun appareil" "No fonts match" = "Aucune police ne correspond" "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Pas de code d’accès ici (le serveur est antérieur aux codes d’accès, ou cette adresse ne tient pas dans un code) : partagez plutôt le lien." "No layer changes to show." = "Aucune modification de calque à afficher." @@ -956,6 +959,7 @@ status = "draft — needs native review" "Preview" = "Aperçu" "Preview this flow" = "Prévisualiser ce flux" "Preview this frame" = "Prévisualiser ce cadre" +"Previous frame" = "Cadre précédent" "Previous ready for dev" = "Prêt pour le dev précédent" "Progressive" = "Progressif" "Properties" = "Propriétés" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index 4262eae2..a19c1587 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -337,6 +337,7 @@ status = "draft — needs native review" "Current version" = "Versi saat ini" "Cursor chat" = "Chat kursor" "Custom" = "Kustom" +"Custom size" = "Ukuran kustom" "Cut" = "Potong" "Dark" = "Gelap" "Darken" = "Gelapkan" @@ -800,6 +801,7 @@ status = "draft — needs native review" "Nested instance" = "Instance bersarang" "New design file" = "File desain baru" "Newest first" = "Terbaru dulu" +"Next frame" = "Frame berikutnya" "Next or previous control in the focused region" = "Kontrol berikutnya atau sebelumnya di wilayah yang difokuskan" "Next ready for dev" = "Siap dev berikutnya" "Next size option" = "Opsi ukuran berikutnya" @@ -817,6 +819,7 @@ status = "draft — needs native review" "No components yet. Select layers and press {keys}." = "Belum ada component. Pilih layer lalu tekan {keys}." "No conflicts." = "Tidak ada konflik." "No contributor data was built into this copy." = "Tidak ada data kontributor yang disertakan dalam salinan ini." +"No device" = "Tanpa perangkat" "No fonts match" = "Tidak ada font yang cocok" "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Tidak ada kode gabung di sini (versi server lebih lama dari fitur kode gabung, atau alamat ini tidak dapat dimasukkan ke kode): bagikan link-nya saja." "No layer changes to show." = "Tidak ada perubahan layer untuk ditampilkan." @@ -1000,6 +1003,7 @@ status = "draft — needs native review" "Preview" = "Pratinjau" "Preview this flow" = "Pratinjau alur ini" "Preview this frame" = "Pratinjau frame ini" +"Previous frame" = "Frame sebelumnya" "Previous ready for dev" = "Versi siap dev sebelumnya" "Progressive" = "Progresif" "Properties" = "Properti" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 59190fd7..fb3a95e5 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -340,6 +340,7 @@ status = "draft — needs native review" "Current version" = "現在のバージョン" "Cursor chat" = "カーソルチャット" "Custom" = "カスタム" +"Custom size" = "カスタムサイズ" "Cut" = "カット" "Dark" = "ダーク" "Darken" = "比較(暗)" @@ -805,6 +806,7 @@ status = "draft — needs native review" "Nested instance" = "ネストされたインスタンス" "New design file" = "新規デザインファイル" "Newest first" = "新しい順" +"Next frame" = "次のフレーム" "Next or previous control in the focused region" = "フォーカス中の領域で次または前のコントロール" "Next ready for dev" = "次の開発準備完了" "Next size option" = "次のサイズオプション" @@ -822,6 +824,7 @@ status = "draft — needs native review" "No components yet. Select layers and press {keys}." = "コンポーネントはまだない。レイヤーを選択して {keys} を押す。" "No conflicts." = "競合なし。" "No contributor data was built into this copy." = "このコピーには貢献者データが含まれていない。" +"No device" = "デバイスなし" "No fonts match" = "一致するフォントなし" "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "参加コードなし(サーバーが参加コードより古いか、このアドレスはコードにできない):代わりにリンクを共有すること。" "No layer changes to show." = "表示するレイヤーの変更なし。" @@ -1005,6 +1008,7 @@ status = "draft — needs native review" "Preview" = "プレビュー" "Preview this flow" = "このフローをプレビュー" "Preview this frame" = "このフレームをプレビュー" +"Previous frame" = "前のフレーム" "Previous ready for dev" = "前の開発準備完了" "Progressive" = "プログレッシブ" "Properties" = "プロパティ" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 37ad8548..7cc9bab5 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -328,6 +328,7 @@ status = "draft — needs native review" "Current version" = "현재 버전" "Cursor chat" = "커서 채팅" "Custom" = "사용자 지정" +"Custom size" = "사용자 지정 크기" "Cut" = "잘라내기" "Dark" = "다크" "Darken" = "어둡게 하기" @@ -765,6 +766,7 @@ status = "draft — needs native review" "Navigate to" = "이동 대상" "Nested instance" = "중첩된 인스턴스" "New design file" = "새 디자인 파일" +"Next frame" = "다음 프레임" "Next or previous control in the focused region" = "포커스된 영역의 다음 또는 이전 컨트롤" "Next ready for dev" = "다음 개발 준비 완료" "Next size option" = "다음 크기 옵션" @@ -782,6 +784,7 @@ status = "draft — needs native review" "No components yet. Select layers and press {keys}." = "아직 컴포넌트가 없음. 레이어를 선택하고 {keys}를 누르세요." "No conflicts." = "충돌 없음." "No contributor data was built into this copy." = "이 사본에는 기여자 데이터가 포함되지 않음." +"No device" = "기기 없음" "No fonts match" = "일치하는 글꼴 없음" "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "참여 코드 없음(서버가 참여 코드 이전 버전이거나 이 주소를 코드에 담을 수 없음): 대신 링크를 공유하세요." "No layer changes to show." = "표시할 레이어 변경 사항 없음." @@ -956,6 +959,7 @@ status = "draft — needs native review" "Preview" = "미리 보기" "Preview this flow" = "이 플로우 미리 보기" "Preview this frame" = "이 프레임 미리 보기" +"Previous frame" = "이전 프레임" "Previous ready for dev" = "이전 개발 준비 완료" "Progressive" = "점진적" "Properties" = "속성" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 18d947c2..30a3a186 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -327,6 +327,7 @@ status = "draft — needs native review" "Current version" = "Versão atual" "Cursor chat" = "Chat do cursor" "Custom" = "Personalizado" +"Custom size" = "Tamanho personalizado" "Cut" = "Recortar" "Dark" = "Escuro" "Darken" = "Escurecer" @@ -764,6 +765,7 @@ status = "draft — needs native review" "Navigate to" = "Navegar para" "Nested instance" = "Instância aninhada" "New design file" = "Novo arquivo de design" +"Next frame" = "Próximo quadro" "Next or previous control in the focused region" = "Controle seguinte ou anterior na região em foco" "Next ready for dev" = "Próximo pronto para dev" "Next size option" = "Próxima opção de tamanho" @@ -781,6 +783,7 @@ status = "draft — needs native review" "No components yet. Select layers and press {keys}." = "Ainda não há componentes. Selecione camadas e pressione {keys}." "No conflicts." = "Nenhum conflito." "No contributor data was built into this copy." = "Esta cópia não inclui dados de colaboradores." +"No device" = "Nenhum dispositivo" "No fonts match" = "Nenhuma fonte corresponde" "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Não há código de acesso aqui (o servidor é anterior aos códigos de acesso, ou este endereço não cabe em um): compartilhe o link." "No layer changes to show." = "Nenhuma alteração de camada para mostrar." @@ -955,6 +958,7 @@ status = "draft — needs native review" "Preview" = "Visualização" "Preview this flow" = "Visualizar este fluxo" "Preview this frame" = "Visualizar este quadro" +"Previous frame" = "Quadro anterior" "Previous ready for dev" = "Anterior pronto para dev" "Progressive" = "Progressivo" "Properties" = "Propriedades" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index ac349add..d039a14f 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -334,6 +334,7 @@ name = "Русский" "Current version" = "Текущая версия" "Cursor chat" = "Чат у курсора" "Custom" = "Пользовательский" +"Custom size" = "Пользовательский размер" "Cut" = "Вырезать" "Dark" = "Тёмная" "Darken" = "Затемнение" @@ -797,6 +798,7 @@ name = "Русский" "Nested instance" = "Вложенный экземпляр" "New design file" = "Новый файл дизайна" "Newest first" = "Сначала новые" +"Next frame" = "Следующий Frame" "Next or previous control in the focused region" = "Следующий или предыдущий элемент в активной области" "Next ready for dev" = "Следующий готовый к разработке" "Next size option" = "Следующий вариант размера" @@ -814,6 +816,7 @@ name = "Русский" "No components yet. Select layers and press {keys}." = "Компонентов пока нет. Выделите слои и нажмите {keys}." "No conflicts." = "Конфликтов нет." "No contributor data was built into this copy." = "В этой сборке нет данных об участниках." +"No device" = "Без устройства" "No fonts match" = "Подходящих шрифтов нет" "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Код подключения недоступен (сервер не поддерживает коды или адрес нельзя включить в код): поделитесь ссылкой." "No layer changes to show." = "Нет изменений слоёв для показа." @@ -998,6 +1001,7 @@ name = "Русский" "Preview" = "Предпросмотр" "Preview this flow" = "Предпросмотр этого сценария" "Preview this frame" = "Предпросмотр этого Frame" +"Previous frame" = "Предыдущий Frame" "Previous ready for dev" = "Предыдущий готовый к разработке" "Progressive" = "Прогрессивный" "Properties" = "Свойства" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index 0c4792af..7ebc07c0 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -339,6 +339,7 @@ name = "Svenska" "Current version" = "Aktuell version" "Cursor chat" = "Markörchatt" "Custom" = "Anpassad" +"Custom size" = "Anpassad storlek" "Cut" = "Klipp ut" "Dark" = "Mörk" "Darken" = "Mörkare" @@ -807,6 +808,7 @@ name = "Svenska" "Nested instance" = "Kapslad instans" "New design file" = "Ny designfil" "Newest first" = "Nyaste först" +"Next frame" = "Nästa ram" "Next or previous control in the focused region" = "Nästa eller föregående kontroll i den fokuserade regionen" "Next ready for dev" = "Nästa redo för utveckling" "Next size option" = "Nästa storleksalternativ" @@ -824,6 +826,7 @@ name = "Svenska" "No components yet. Select layers and press {keys}." = "Inga komponenter än. Markera lager och tryck på {keys}." "No conflicts." = "Inga konflikter." "No contributor data was built into this copy." = "Inga uppgifter om bidragsgivare finns inbyggda i den här kopian." +"No device" = "Ingen enhet" "No fonts match" = "Inga typsnitt matchar" "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Ingen anslutningskod här (servern är äldre än anslutningskoder, eller så kan den här adressen inte göras om till en): dela länken i stället." "No layer changes to show." = "Inga lagerändringar att visa." @@ -1009,6 +1012,7 @@ name = "Svenska" "Preview" = "Förhandsgranskning" "Preview this flow" = "Förhandsgranska det här flödet" "Preview this frame" = "Förhandsgranska den här ramen" +"Previous frame" = "Föregående ram" "Previous ready for dev" = "Föregående redo för utveckling" "Progressive" = "Progressiv" "Properties" = "Egenskaper" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index 80d85d70..94475f3b 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -337,6 +337,7 @@ name = "Türkçe" "Current version" = "Geçerli sürüm" "Cursor chat" = "İmleç sohbeti" "Custom" = "Özel" +"Custom size" = "Özel boyut" "Cut" = "Kes" "Dark" = "Koyu" "Darken" = "Koyulaştır" @@ -800,6 +801,7 @@ name = "Türkçe" "Nested instance" = "İç içe örnek" "New design file" = "Yeni tasarım dosyası" "Newest first" = "Önce en yeni" +"Next frame" = "Sonraki çerçeve" "Next or previous control in the focused region" = "Odaklanılan bölgedeki sonraki veya önceki denetim" "Next ready for dev" = "Sonraki geliştirmeye hazır" "Next size option" = "Sonraki boyut seçeneği" @@ -817,6 +819,7 @@ name = "Türkçe" "No components yet. Select layers and press {keys}." = "Henüz bileşen yok. Katmanları seçip {keys} tuşlarına basın." "No conflicts." = "Çakışma yok." "No contributor data was built into this copy." = "Bu kopyaya katkıda bulunan verisi eklenmemiş." +"No device" = "Cihaz yok" "No fonts match" = "Eşleşen yazı tipi yok" "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Burada katılma kodu yok (sunucu katılma kodlarından eski veya bu adres bir koda sığmıyor): bunun yerine bağlantıyı paylaşın." "No layer changes to show." = "Gösterilecek katman değişikliği yok." @@ -1000,6 +1003,7 @@ name = "Türkçe" "Preview" = "Önizleme" "Preview this flow" = "Bu akışı önizle" "Preview this frame" = "Bu çerçeveyi önizle" +"Previous frame" = "Önceki çerçeve" "Previous ready for dev" = "Önceki geliştirmeye hazır" "Progressive" = "Aşamalı" "Properties" = "Özellikler" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 9ea4cc96..9883a2c2 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -329,6 +329,7 @@ status = "draft — needs native review" "Current version" = "当前版本" "Cursor chat" = "光标聊天" "Custom" = "自定义" +"Custom size" = "自定义大小" "Cut" = "剪切" "Dark" = "深色" "Darken" = "变暗" @@ -766,6 +767,7 @@ status = "draft — needs native review" "Navigate to" = "导航到" "Nested instance" = "嵌套实例" "New design file" = "新建设计文件" +"Next frame" = "下一个画框" "Next or previous control in the focused region" = "聚焦区域中的下一个或上一个控件" "Next ready for dev" = "下一个可供开发" "Next size option" = "下一个尺寸选项" @@ -783,6 +785,7 @@ status = "draft — needs native review" "No components yet. Select layers and press {keys}." = "还没有组件。选择图层并按 {keys}。" "No conflicts." = "没有冲突。" "No contributor data was built into this copy." = "此副本未内置贡献者数据。" +"No device" = "无设备" "No fonts match" = "没有匹配的字体" "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "此处没有加入代码(服务器版本早于加入代码,或此地址无法写入代码):请改为分享链接。" "No layer changes to show." = "没有可显示的图层更改。" @@ -957,6 +960,7 @@ status = "draft — needs native review" "Preview" = "预览" "Preview this flow" = "预览此流程" "Preview this frame" = "预览此画框" +"Previous frame" = "上一个画框" "Previous ready for dev" = "上一个可供开发" "Progressive" = "渐进" "Properties" = "属性" diff --git a/crates/ui-egui/src/comments_tests.rs b/crates/ui-egui/src/comments_tests.rs index bf30d5e5..696ff322 100644 --- a/crates/ui-egui/src/comments_tests.rs +++ b/crates/ui-egui/src/comments_tests.rs @@ -213,7 +213,7 @@ fn the_presenter_shows_the_frames_comments() { key(&mut u, Key::C, Modifiers::SHIFT); assert_eq!(u.app.inspect()["presenter"]["comments"], true); // The frame is presented at 100% (Fit never scales up), centred under the bar. - let screen = egui::Rect::from_min_size(pos2(0.0, 40.0), vec2(1440.0, 860.0)); + let screen = egui::Rect::from_min_size(pos2(0.0, 80.0), vec2(1440.0, 820.0)); let frame_min = screen.center() - vec2(200.0, 150.0); let pin = frame_min + vec2(100.0, 100.0) + vec2(14.0, -14.0); click(&mut u, pin); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b2f27760..955f2602 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -466,6 +466,10 @@ pub struct UiState { /// A message at the bottom of the window and how long it has been showing. pub toast: Option<(String, ToastClock)>, pub present: Option, + /// A presentation kept alive while its design tab or Home is in front. + pub paused_present: Option, + /// The file that owns the presentation tab. + pub present_uid: Option, /// The full-window presenter's options (how it sizes the prototype), kept between /// presentations. pub present_view: present::PresentView, @@ -605,6 +609,8 @@ impl Default for UiState { icon_gallery: false, toast: None, present: None, + paused_present: None, + present_uid: None, present_view: present::PresentView::default(), preview: None, picker: None, @@ -915,6 +921,9 @@ impl FigApp { let mut p = present::Presenter::new(start); p.view = self.ui.present_view; self.ui.present = Some(p); + self.ui.paused_present = None; + self.ui.present_uid = self.session.docs.get(self.session.active).map(|d| d.uid); + self.ui.home = false; } } "present.options" => { @@ -1408,14 +1417,19 @@ impl FigApp { // The Inspect tab measures on hover without Alt. self.session.tool.redlines = self.ui.right_tab == RightTab::Inspect; self.end_pen_outside_design(); + tabs::tab_bar(self, ui); if self.ui.present.is_some() { + // A close request can arrive while the presenter fills the window. Let its unsaved + // changes dialog take Escape and draw above the player just as it does in the editor. + dialogs::keys(self, &ctx); present::show(self, ui); + dialogs::show(self, &ctx); // What went wrong while presenting (a widget's plugin failing) shows over it. self.toast_ui(&ctx); + windows::requests(self, &ctx); return; } if !self.has_files() || self.ui.home { - tabs::tab_bar(self, ui); home::show(self, ui); dialogs::keys(self, &ctx); dialogs::show(self, &ctx); @@ -1424,7 +1438,6 @@ impl FigApp { return; } let hidden = self.session.ds().map(|d| d.view.ui_hidden).unwrap_or(false); - tabs::tab_bar(self, ui); if !hidden { egui::Panel::left("left_rail") .exact_size(56.0) @@ -1587,7 +1600,7 @@ impl FigApp { "focus": {"region": self.ui.nav.region.id(), "keyboard": self.ui.nav.keyboard}, "reduceMotion": self.reduce_motion(), "presenting": self.ui.present.is_some(), - "presenter": self.ui.present.as_ref().map(present::Presenter::inspect), + "presenter": self.ui.present.as_ref().or(self.ui.paused_present.as_ref()).map(present::Presenter::inspect), "presentOptions": {"scale": self.ui.present_view.scale.id(), "deviceFrame": self.ui.present_view.device_frame, "comments": self.ui.present_view.comments, "hotspotHints": self.ui.present_view.hotspot_hints, "inspect": self.ui.present_view.inspect}, "preview": self.ui.preview.as_ref().map(|p| p.player.inspect()), "mainMenu": self.ui.main_menu, diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs index b5f177af..863b540f 100644 --- a/crates/ui-egui/src/present.rs +++ b/crates/ui-egui/src/present.rs @@ -270,6 +270,8 @@ pub struct Presenter { anim_tex: Option, temp: Option, textures: HashMap, + /// Small current-screen preview in the full-window bar, cached until its content changes. + bar_thumb: Option<(NodeId, usize, egui::TextureHandle)>, arrived: f64, delays_fired: HashSet<(NodeId, usize)>, /// Mouse triggers waiting out their delay. @@ -348,6 +350,7 @@ impl Presenter { anim_tex: None, temp: None, textures: HashMap::new(), + bar_thumb: None, arrived: -1.0, delays_fired: HashSet::new(), waiting: Vec::new(), @@ -390,6 +393,18 @@ impl Presenter { self.stop_scrolling(); } + /// A new device changes the viewport and may relayout every frame. Start at its top instead + /// of carrying scroll offsets or a drag from the old screen into the new one. + fn reset_for_device(&mut self) { + self.pan = None; + self.scroll.clear(); + self.scroll_anim = None; + self.press = None; + self.dragged = false; + self.stop_scrolling(); + self.rev += 1; + } + /// The current offset of `t` (a frame's scroll, else the view's pan). fn offset(&self, t: Scroller) -> (f64, f64) { match t { @@ -1651,6 +1666,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| { action = player(&mut pres, ui, ui.max_rect(), now, Chrome { keys: !typing, ..FULL }).or(action); }); + let picked_device = navigation_bar(&mut pres, &ctx, now); // Keys the prototype left alone: C shows the comments, F fills the screen. if !typing { if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::C)) { @@ -1679,12 +1695,132 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { if let Some(BarAction::Responsive(on)) = action { app.run("proto.settings", json!({"responsive": on})); } + if let Some(device) = picked_device { + choose_device(app, &mut pres, &device); + } if action == Some(BarAction::Close) || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + app.ui.present_uid = None; return; } app.ui.present = Some(pres); } +/// Device choices in the presenter use the screen's real dimensions and reflow a copy of the +/// design through constraints and auto layout. "No device" returns to the original frame size. +fn choose_device(app: &mut FigApp, p: &mut Presenter, device: &str) { + let responsive = device != "None"; + if app.run("proto.settings", json!({"device": device, "responsive": responsive})).is_some() { + p.reset_for_device(); + } +} + +/// Controls that stay in reach while viewing a prototype, including screen stepping, device and +/// scale. The selected device is returned to the caller so it can go through the normal document +/// command (and undo/save) rather than changing only the player's copy. +fn navigation_bar(p: &mut Presenter, ctx: &egui::Context, now: f64) -> Option { + let t = Tokens::get(ctx); + let doc = p.doc.as_ref()?; + let frames = doc.top_frames(doc.page_of(p.current).unwrap_or(p.current)); + let index = frames.iter().position(|id| *id == p.current); + let settings = page_settings(doc, p.current); + let mut navigate = None; + let mut device_choice = None; + egui::Area::new(Id::new("present_navigation")).anchor(Align2::CENTER_BOTTOM, vec2(0.0, -18.0)).order(egui::Order::Foreground).show(ctx, |ui| { + egui::Frame::NONE + .fill(t.panel) + .stroke(Stroke::new(1.0, t.border)) + .corner_radius(CornerRadius::same(9)) + .inner_margin(egui::Margin::symmetric(8, 5)) + .show(ui, |ui| { + ui.horizontal(|ui| { + let prev = index.and_then(|i| i.checked_sub(1)).and_then(|i| frames.get(i)).copied(); + let next = index.and_then(|i| frames.get(i + 1)).copied(); + let previous = + ui.add_enabled(prev.is_some(), egui::Button::new("").min_size(vec2(26.0, 24.0))).on_hover_text(tr("Previous frame")); + icons::paint( + ui.painter(), + Rect::from_center_size(previous.rect.center(), vec2(21.0, 21.0)), + Icon::ChevronLeft, + if prev.is_some() { t.icon } else { t.icon_secondary }, + ); + if previous.clicked() { + navigate = prev; + } + ui.label(egui::RichText::new(format!("{} / {}", index.map_or(0, |i| i + 1), frames.len())).color(t.text)); + let following = ui.add_enabled(next.is_some(), egui::Button::new("").min_size(vec2(26.0, 24.0))).on_hover_text(tr("Next frame")); + icons::paint( + ui.painter(), + Rect::from_center_size(following.rect.center(), vec2(21.0, 21.0)), + Icon::ChevronRight, + if next.is_some() { t.icon } else { t.icon_secondary }, + ); + if following.clicked() { + navigate = next; + } + ui.separator(); + let model = if settings.device.is_empty() || settings.device == "None" { tr("No device") } else { &settings.device }; + let device_menu = ui.menu_button(format!("{model} "), |ui| { + navigation_menu(ui, |ui| { + egui::ScrollArea::vertical().max_height(410.0).show(ui, |ui| { + if ui.selectable_label(settings.device.is_empty() || settings.device == "None", tr("No device")).clicked() { + device_choice = Some("None".to_owned()); + ui.close(); + } + ui.separator(); + for device in PrototypeSettings::DEVICES { + let label = format!("{} {}×{}", device.name, device.width as u32, device.height as u32); + if ui.selectable_label(settings.device == device.name, label).clicked() { + device_choice = Some(device.name.to_owned()); + ui.close(); + } + } + ui.separator(); + if ui.selectable_label(settings.device == PrototypeSettings::CUSTOM_DEVICE, tr("Custom size")).clicked() { + device_choice = Some(PrototypeSettings::CUSTOM_DEVICE.to_owned()); + ui.close(); + } + }); + }); + }); + paint_menu_chevron(ui.painter(), device_menu.response.rect, t.icon_secondary); + let scale_menu = ui.menu_button(format!("{} ", tr(p.view.scale.label(settings.device().is_some()))), |ui| { + navigation_menu(ui, |ui| { + for scale in PresentScale::options(settings.device().is_some()) { + let label = egui::RichText::new(tr(scale.label(settings.device().is_some()))).color(t.menu_text); + if ui.selectable_label(p.view.scale == *scale, label).clicked() { + p.set_scale(*scale); + ui.close(); + } + } + }); + }); + paint_menu_chevron(ui.painter(), scale_menu.response.rect, t.icon_secondary); + }); + }); + }); + if let Some(id) = navigate { + p.go(id, Transition::Instant, 0.0, Easing::Linear, now, true, false); + } + device_choice +} + +fn paint_menu_chevron(painter: &egui::Painter, button: Rect, color: Color32) { + let icon = Rect::from_center_size(pos2(button.right() - 11.0, button.center().y), vec2(17.0, 17.0)); + icons::paint(painter, icon, Icon::Chevron, color); +} + +fn navigation_menu(ui: &mut Ui, add: impl FnOnce(&mut Ui)) { + let t = Tokens::get(ui.ctx()); + ui.visuals_mut().override_text_color = Some(t.menu_text); + egui::Frame::NONE.fill(t.menu).stroke(Stroke::new(1.0, t.border)).corner_radius(CornerRadius::same(7)).inner_margin(egui::Margin::same(6)).show( + ui, + |ui| { + ui.set_min_width(212.0); + add(ui); + }, + ); +} + /// How often the player polls the controllers while its prototype has gamepad triggers (seconds). const PAD_POLL_SECS: f64 = 1.0 / 30.0; @@ -2045,16 +2181,17 @@ fn inspect_hover(p: &mut Presenter, painter: &egui::Painter, ctx: &egui::Context /// the right (text on the dark full-window bar, icons in the pane, which follows the theme). fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome) -> Option { let t = Tokens::get(ui.ctx()); - let (fill, text, dim, hover) = if chrome.pane { - (t.panel, t.text, t.text_secondary, t.hover) - } else { - (Color32::from_rgb(0x2C, 0x2C, 0x2C), Color32::WHITE, Color32::from_gray(140), Color32::from_white_alpha(28)) - }; + let (fill, text, dim, hover) = (t.panel, t.text, t.text_secondary, t.hover); let r = chrome.round; ui.painter().rect_filled(bar, CornerRadius { nw: r, ne: r, sw: 0, se: 0 }, fill); if chrome.pane { ui.painter().line_segment([bar.left_bottom(), bar.right_bottom()], Stroke::new(1.0, t.border)); } + let source_key = p.source.as_ref().map_or(0, |s| Arc::as_ptr(s) as usize); + if !chrome.pane && p.bar_thumb.as_ref().is_none_or(|(id, source, _)| *id != p.current || *source != source_key) { + p.bar_thumb = + p.source.as_ref().and_then(|d| crate::assets_panel::thumbnail(ui.ctx(), d, p.current, 38.0)).map(|tex| (p.current, source_key, tex)); + } let doc = p.doc.as_ref()?; let name = doc.node(p.current).map(|n| n.name.clone()).unwrap_or_default(); let flows = match doc.page_of(p.start).and_then(|pg| doc.node(pg)).map(|n| &n.kind) { @@ -2117,19 +2254,33 @@ fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome) // Left: the flow picker and the frame's name, clipped before the buttons. let left = ui.painter().with_clip_rect(Rect::from_min_max(bar.min, pos2(x, bar.max.y))); let mut lx = bar.min.x + if chrome.pane { 8.0 } else { 12.0 }; + if !chrome.pane { + let thumb = Rect::from_center_size(pos2(lx + 15.0, bar.center().y), vec2(28.0, 28.0)); + left.rect_filled(thumb, CornerRadius::same(4), t.field); + if let Some((_, _, tex)) = &p.bar_thumb { + let size = tex.size_vec2(); + if size.x > 0.0 && size.y > 0.0 { + let scaled = size * (26.0 / size.x.max(size.y)); + let dst = Rect::from_center_size(thumb.center(), scaled); + left.image(tex.id(), dst, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + } + lx += 38.0; + } // The flow playing: its name on the picker, its description on hover; the menu lists each // flow with its description. let playing = flows.iter().find(|f| f.start == p.start); if !flows.is_empty() { - let label = format!("{} ▾", playing.map_or(tr("Flows"), |f| f.name.as_str())); + let label = playing.map_or(tr("Flows"), |f| f.name.as_str()).to_owned(); let tip = playing.map(|f| f.description.trim()).filter(|d| !d.is_empty()).unwrap_or(tr("Flows")).to_string(); let galley = left.layout_no_wrap(label, semibold(11.5), text); - let fr = Rect::from_min_size(pos2(lx, bar.center().y - 12.0), vec2(galley.size().x + 16.0, 24.0)); + let fr = Rect::from_min_size(pos2(lx, bar.center().y - 12.0), vec2(galley.size().x + 30.0, 24.0)); let resp = ui.interact(fr.intersect(left.clip_rect()), ui.id().with("pflows"), Sense::click()).tip(tip); if resp.hovered() { left.rect_filled(fr, CornerRadius::same(5), hover); } left.galley(pos2(fr.min.x + 8.0, fr.center().y - galley.size().y / 2.0), galley, text); + paint_menu_chevron(&left, fr, t.icon_secondary); crate::right::menu_popup(&resp).width(240.0).show(|ui| { let t = Tokens::get(ui.ctx()); for f in &flows { @@ -2149,7 +2300,7 @@ fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome) left.text(pos2(lx, bar.center().y), Align2::LEFT_CENTER, "/", regular(11.5), dim); lx += 14.0; } - left.text(pos2(lx, bar.center().y), Align2::LEFT_CENTER, name, regular(11.5), if chrome.pane { text } else { Color32::from_gray(220) }); + left.text(pos2(lx, bar.center().y), Align2::LEFT_CENTER, name, regular(11.5), text); action } @@ -2418,14 +2569,21 @@ fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_area: bool, ke } return; } - // Otherwise the drag scrolls the innermost frame under it that scrolls, else pans a view - // bigger than the window. + // A drag begun inside a mobile screen moves its content, even if the pointer leaves the + // screen. The device body stays in place even when the drag begins outside its screen. + // Panning the whole view remains available only without a mobile device. if let Some(base) = p.doc.clone() { - let target = scrollers_at(p, &base, doc, root, &place, start) - .first() + let horizontal = d.x.abs() >= d.y.abs(); + let scrolls = scrollers_at(p, &base, doc, root, &place, start); + let on_axis = |range: (f64, f64)| if horizontal { range.0 > 0.0 } else { range.1 > 0.0 }; + let mobile = page_settings(&base, root).device().is_some_and(|device| device.rotates); + let target = scrolls + .iter() .copied() + .find(|f| on_axis(p.range(&base, *f))) + .or_else(|| scrolls.first().copied()) .map(Scroller::Frame) - .or((p.pan_range != (0.0, 0.0)).then_some(Scroller::View)); + .or((!mobile && p.pan_range != (0.0, 0.0)).then_some(Scroller::View)); if let Some(t) = target { p.fling = None; p.drag_scroll = Some((t, clamp_scroll(p.offset(t), p.limits(&base, t)))); @@ -2685,6 +2843,125 @@ mod tests { use super::*; use figcraft_doc::NodeKind; + use crate::present_tests::{Ui, id}; + + #[test] + fn device_picker_reflows_without_resizing_the_saved_frame_and_can_revert() { + let mut session = figcraft_engine::Session::new(); + let frame = id(&session.execute("node.create", &json!({"type": "frame", "width": 894, "height": 844})).unwrap()); + let right = id(&session + .execute("node.create", &json!({"type": "rectangle", "parent": frame, "x": 804, "y": 20, "width": 80, "height": 40})) + .unwrap()); + session.execute("node.set", &json!({"id": right, "props": {"constraints": {"horizontal": "right", "vertical": "top"}}})).unwrap(); + let mut ui = Ui::new(session); + ui.run("proto.present", json!({"start": frame})); + + let mut presenter = ui.app.ui.present.take().unwrap(); + presenter.pan = Some((100.0, 200.0)); + presenter.scroll.insert(frame, (75.0, 50.0)); + choose_device(&mut ui.app, &mut presenter, "iPhone 17"); + assert!(presenter.pan.is_none() && presenter.scroll.is_empty()); + ui.app.ui.present = Some(presenter); + ui.frame(vec![]); + + let shown = &ui.app.ui.present.as_ref().unwrap().doc.as_ref().unwrap(); + assert_eq!((shown.node(frame).unwrap().width, shown.node(frame).unwrap().height), (402.0, 874.0)); + assert_eq!(shown.world_bounds(right).unwrap().x0, 312.0, "right constraint follows the phone edge"); + assert_eq!(ui.app.inspect()["presenter"]["viewport"], json!([402.0, 874.0])); + assert_eq!(ui.app.inspect()["presenter"]["scrollRange"], json!([0.0, 0.0])); + assert_eq!(ui.app.session.doc().unwrap().node(frame).unwrap().width, 894.0, "the saved design keeps its geometry"); + + let area = Rect::from_min_max(pos2(0.0, 40.0), pos2(1440.0, 900.0)); + let screen_before = layout(ui.app.ui.present.as_mut().unwrap(), area, FULL).unwrap().screen; + ui.button(pos2(720.0, 440.0), true); + ui.frame(vec![egui::Event::PointerMoved(pos2(300.0, 300.0))]); + ui.button(pos2(300.0, 300.0), false); + assert_eq!(ui.app.inspect()["presenter"]["scroll"], json!([0.0, 0.0]), "fitted content does not move"); + assert_eq!(layout(ui.app.ui.present.as_mut().unwrap(), area, FULL).unwrap().screen, screen_before); + + let mut presenter = ui.app.ui.present.take().unwrap(); + choose_device(&mut ui.app, &mut presenter, "None"); + ui.app.ui.present = Some(presenter); + ui.frame(vec![]); + assert_eq!(ui.app.inspect()["presenter"]["frameSize"], json!([894.0, 844.0])); + assert_eq!(ui.app.inspect()["presenter"]["responsive"], Value::Null); + } + + #[test] + fn device_presets_show_their_screen_at_the_real_resolution_in_their_body() { + let mut session = figcraft_engine::Session::new(); + let frame = id(&session.execute("node.create", &json!({"type": "frame", "width": 894, "height": 844})).unwrap()); + let mut ui = Ui::new(session); + ui.run("proto.present", json!({"start": frame})); + let area = Rect::from_min_max(pos2(0.0, 40.0), pos2(1440.0, 860.0)); + + for (name, width, height) in + [("iPad mini", 744.0, 1133.0), ("MacBook Air", 1280.0, 832.0), ("Desktop", 1440.0, 1024.0), ("iPhone 17", 402.0, 874.0)] + { + let mut presenter = ui.app.ui.present.take().unwrap(); + choose_device(&mut ui.app, &mut presenter, name); + ui.app.ui.present = Some(presenter); + ui.frame(vec![]); + + let presenter = ui.app.ui.present.as_mut().unwrap(); + let laid = layout(presenter, area, FULL).unwrap(); + assert!(laid.color.is_some(), "{name}: drawn in its body"); + assert!((f64::from(laid.screen.width()) / laid.scale - width).abs() < 0.1, "{name}: width"); + assert!((f64::from(laid.screen.height()) / laid.scale - height).abs() < 0.1, "{name}: height"); + assert_eq!(ui.app.session.doc().unwrap().node(frame).unwrap().width, 894.0); + } + } + + /// A phone shows a frame's content past its screen only as far as the frame's overflow lets it + /// scroll: with "No scrolling" it's clipped, like on every other device. + #[test] + fn a_phone_scrolls_only_what_the_frames_overflow_allows() { + let on_phone = |overflow: &str| { + let mut session = figcraft_engine::Session::new(); + let frame = id(&session.execute("node.create", &json!({"type": "frame", "width": 894, "height": 844})).unwrap()); + session.execute("node.create", &json!({"type": "rectangle", "parent": frame, "x": 650, "y": 1050, "width": 80, "height": 80})).unwrap(); + session.execute("node.set", &json!({"id": frame, "props": {"overflow": overflow}})).unwrap(); + let mut ui = Ui::new(session); + ui.run("proto.present", json!({"start": frame})); + let mut presenter = ui.app.ui.present.take().unwrap(); + choose_device(&mut ui.app, &mut presenter, "iPhone 17"); + ui.app.ui.present = Some(presenter); + ui.frame(vec![]); + (ui, frame) + }; + let start = pos2(720.0, 440.0); + let drag = |ui: &mut Ui, to: Pos2| { + ui.button(start, true); + ui.frame(vec![egui::Event::PointerMoved(to)]); + ui.button(to, false); + ui.app.inspect()["presenter"].clone() + }; + + let (mut ui, frame) = on_phone("none"); + assert_eq!(ui.app.inspect()["presenter"]["scrollRange"], json!([0.0, 0.0])); + assert_eq!(drag(&mut ui, start - vec2(100.0, 120.0))["scroll"], json!([0.0, 0.0]), "no scrolling stays clipped"); + assert_eq!(ui.app.session.doc().unwrap().node(frame).unwrap().width, 894.0); + + let (mut ui, frame) = on_phone("both"); + let range = &ui.app.inspect()["presenter"]["scrollRange"]; + assert!(range[0].as_f64().unwrap() > 0.0 && range[1].as_f64().unwrap() > 0.0, "{range}"); + let scroll = drag(&mut ui, start - vec2(100.0, 120.0))["scroll"].clone(); + assert!(scroll[0].as_f64().unwrap() > 0.0 && scroll[1].as_f64().unwrap() > 0.0, "{scroll}"); + assert_eq!(ui.app.session.doc().unwrap().node(frame).unwrap().width, 894.0); + + // The content keeps following a drag after the pointer leaves the screen, while the + // device itself stays fixed on the stage. + ui.frames(60, Vec::new); + let area = Rect::from_min_max(pos2(0.0, 40.0), pos2(1440.0, 900.0)); + let screen_before = layout(ui.app.ui.present.as_mut().unwrap(), area, FULL).unwrap().screen; + ui.button(start, true); + ui.frame(vec![egui::Event::PointerMoved(pos2(300.0, 300.0))]); + let during = ui.app.inspect()["presenter"].clone(); + assert!(during["scroll"][0].as_f64().unwrap() > 0.0, "{during}"); + assert_eq!(layout(ui.app.ui.present.as_mut().unwrap(), area, FULL).unwrap().screen, screen_before); + ui.button(pos2(300.0, 300.0), false); + } + fn doc_with_two_frames() -> (Document, NodeId, NodeId) { let mut d = Document::new("t"); let page = d.add_page("Page 1"); diff --git a/crates/ui-egui/src/present_view_tests.rs b/crates/ui-egui/src/present_view_tests.rs index 3571b2ad..8fdd5eb0 100644 --- a/crates/ui-egui/src/present_view_tests.rs +++ b/crates/ui-egui/src/present_view_tests.rs @@ -95,6 +95,28 @@ fn scale_options_match_the_command_and_cycle() { assert!(!PresentScale::options(true).contains(&PresentScale::FitWidth)); } +#[test] +fn presentation_tab_keeps_the_current_screen_when_returning_to_the_editor() { + let (mut u, a, b, _) = setup(); + u.run("proto.present", json!({"start": a})); + u.key(pos2(720.0, 470.0), Key::ArrowRight); + assert_eq!(presenter(&u)["current"], json!(b)); + + let design_tab = pos2(100.0, 20.0); + u.button(design_tab, true); + u.button(design_tab, false); + assert!(u.app.ui.present.is_none()); + assert_eq!(u.app.ui.paused_present.as_ref().map(|p| p.current), Some(b)); + + let name_len = u.app.session.doc().unwrap().name.chars().count() as f32; + let design_w = (12.0 * 0.6 * name_len + 64.0).clamp(120.0, 220.0); + let preview_tab = pos2(8.0 + 44.0 + design_w + 40.0, 20.0); + u.button(preview_tab, true); + u.button(preview_tab, false); + assert!(u.app.ui.paused_present.is_none()); + assert_eq!(presenter(&u)["current"], json!(b)); +} + /// No device: a big screen (2000×3000) and a small one, presenting the big one. fn big() -> (Ui, NodeId) { let mut s = Session::new(); @@ -117,10 +139,10 @@ fn num(v: &Value) -> f64 { fn presenter_scales_by_its_options_and_pans_what_overflows() { let (mut u, big) = big(); let at = pos2(720.0, 470.0); - // Fit: scaled down to the 1376×796 inside the padding; nothing pans. + // Fit: the tab strip leaves 1376×756 inside the presenter's padding; nothing pans. let p = presenter(&u); assert_eq!(p["scale"], "fit"); - assert!((num(&p["zoom"]) - 796.0 / 3000.0).abs() < 1e-3, "{p}"); + assert!((num(&p["zoom"]) - 756.0 / 3000.0).abs() < 1e-3, "{p}"); assert_eq!(p["panRange"], json!([0.0, 0.0])); // Z: fit width (the whole 1440 width; the rest of the height pans). u.key(at, Key::Z); @@ -128,14 +150,14 @@ fn presenter_scales_by_its_options_and_pans_what_overflows() { assert_eq!(p["scale"], "fitWidth"); assert!((num(&p["zoom"]) - 0.72).abs() < 1e-3, "{p}"); assert_eq!(p["panRange"][0], 0.0); - assert!((num(&p["panRange"][1]) - (2160.0 - 860.0)).abs() < 1.0, "{p}"); + assert!((num(&p["panRange"][1]) - (2160.0 - 820.0)).abs() < 1.0, "{p}"); // Z: fill (here the width decides too), then 100%: both ways overflow, centred across at the top. u.key(at, Key::Z); assert_eq!(presenter(&u)["scale"], "fill"); u.key(at, Key::Z); let p = presenter(&u); assert_eq!((p["scale"].as_str(), num(&p["zoom"])), (Some("actual"), 1.0)); - assert_eq!(p["panRange"], json!([624.0, 2204.0])); + assert_eq!(p["panRange"], json!([624.0, 2244.0])); assert_eq!(p["pan"], json!([312.0, 0.0])); // The wheel pans down; a drag pans with the pointer. let wheel = move || { @@ -192,7 +214,7 @@ fn devices_turn_and_their_frame_can_hide() { let with_body = num(&presenter(&u)["zoom"]); u.run("proto.presentOptions", json!({"deviceFrame": false})); let bare = num(&presenter(&u)["zoom"]); - assert!(bare > with_body && (bare - 796.0 / 852.0).abs() < 1e-3, "{with_body} {bare}"); + assert!(bare > with_body && (bare - 756.0 / 852.0).abs() < 1e-3, "{with_body} {bare}"); // A device offers no fit width: Z goes fit → fill → 100%. u.key(pos2(720.0, 470.0), Key::Z); assert_eq!(presenter(&u)["scale"], "fill"); diff --git a/crates/ui-egui/src/proto_responsive_tests.rs b/crates/ui-egui/src/proto_responsive_tests.rs index fc9ccda2..f0ead8e9 100644 --- a/crates/ui-egui/src/proto_responsive_tests.rs +++ b/crates/ui-egui/src/proto_responsive_tests.rs @@ -69,8 +69,8 @@ fn without_a_device_a_responsive_frame_fills_the_window_at_100_percent() { let mut u = Ui::new(s); u.run("proto.present", json!({"start": w})); let p = presenter(&u); - // The window is 1440×900; the bar takes 40. - assert_eq!(size(&p["frameSize"]), (1440.0, 860.0), "{p}"); + // The window is 1440×900; the title and tab bars take 80. + assert_eq!(size(&p["frameSize"]), (1440.0, 820.0), "{p}"); assert_eq!(p["zoom"], json!(1.0)); assert_eq!(size(&p["panRange"]), (0.0, 0.0)); // Z has nothing to cycle: the size options don't apply. diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index 27ff085c..381b09dd 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -28,12 +28,12 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max)); } } - let buttons_w = if app.ui.title_bar == TitleBar::Custom { window_buttons(ui, rect) } else { 0.0 }; + let buttons_w = if app.ui.title_bar == TitleBar::Custom { window_buttons(app, ui, rect) } else { 0.0 }; let mut x = rect.min.x + if app.ui.title_bar == TitleBar::Inset { 76.0 } else { 8.0 }; // Home. let hr = Rect::from_min_size(pos2(x, rect.min.y), vec2(44.0, HEIGHT)); let hresp = ui.interact(hr, ui.id().with("home"), Sense::click()).tip(tr("Home")); - let home_active = app.ui.home || !app.has_files(); + let home_active = (app.ui.home || !app.has_files()) && app.ui.present.is_none(); crate::a11y::tab(&hresp, tr("Home"), home_active); if home_active { ui.painter().rect_filled(hr, CornerRadius::ZERO, t.tab_active); @@ -42,6 +42,9 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { } icons::paint(ui.painter(), Rect::from_center_size(hr.center(), vec2(16.0, 16.0)), Icon::Home, t.icon); if hresp.clicked() { + if let Some(p) = app.ui.present.take() { + app.ui.paused_present = Some(p); + } app.ui.home = true; } x += 44.0; @@ -52,7 +55,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { let mut detach = None; let several = !ui.ctx().embed_viewports(); for (i, d) in app.session.docs.iter().enumerate().filter(|(_, d)| app.shows(d.uid)) { - let active = i == app.session.active && !app.ui.home; + let active = i == app.session.active && !app.ui.home && app.ui.present.is_none(); let name = d.doc.name.clone(); // In a live session: a mark after the name says whether others get in. let live = live_mark(app, d.uid); @@ -132,6 +135,41 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { x += w; ui.painter().line_segment([pos2(x, rect.min.y + 10.0), pos2(x, rect.max.y - 10.0)], Stroke::new(1.0, t.border)); } + // Presentations live in their own tab. Switching back to the design keeps the player + // state (current screen, navigation history and scroll position) for the return trip. + if app.ui.present.is_some() || app.ui.paused_present.is_some() { + let w = 128.0; + let r = Rect::from_min_size(pos2(x, rect.min.y), vec2(w, HEIGHT)); + let resp = ui.interact(r, ui.id().with("presentation_tab"), Sense::click()).tip(tr("Present")); + let active = app.ui.present.is_some(); + crate::a11y::tab(&resp, tr("Present"), active); + if active { + ui.painter().rect_filled(r, CornerRadius::ZERO, t.tab_active); + } else if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover); + } + icons::paint(ui.painter(), Rect::from_center_size(pos2(x + 19.0, r.center().y), vec2(14.0, 14.0)), Icon::Play, t.icon); + ui.painter().text(pos2(x + 34.0, r.center().y), Align2::LEFT_CENTER, tr("Present"), regular(12.0), t.text); + let cr = Rect::from_center_size(pos2(r.max.x - 16.0, r.center().y), vec2(18.0, 18.0)); + if resp.hovered() || active { + let close_resp = ui.interact(cr, ui.id().with("presentation_close"), Sense::click()).tip(tr("Close preview")); + if close_resp.hovered() { + ui.painter().rect_filled(cr, CornerRadius::same(4), t.pressed); + } + icons::paint(ui.painter(), cr.shrink(4.0), Icon::Close, t.icon_secondary); + if close_resp.clicked() { + app.ui.present = None; + app.ui.paused_present = None; + app.ui.present_uid = None; + } else if resp.clicked() && !active { + resume_presentation(app); + } + } else if resp.clicked() && !active { + resume_presentation(app); + } + x += w; + ui.painter().line_segment([pos2(x, rect.min.y + 10.0), pos2(x, rect.max.y - 10.0)], Stroke::new(1.0, t.border)); + } // New tab. let nr = Rect::from_center_size(pos2(x + 20.0, rect.center().y), vec2(28.0, 28.0)); let nresp = ui.interact(nr, ui.id().with("newtab"), Sense::click()).tip(crate::shortcuts::tip(tr("New design file"), "file.new")); @@ -140,6 +178,9 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { } icons::paint(ui.painter(), nr.shrink(7.0), Icon::Plus, t.icon); if nresp.clicked() { + if let Some(p) = app.ui.present.take() { + app.ui.paused_present = Some(p); + } app.run("file.new", json!({})); app.ui.home = false; } @@ -155,6 +196,9 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { app.run(cmd, json!({})); } if let Some(i) = switch { + if let Some(p) = app.ui.present.take() { + app.ui.paused_present = Some(p); + } app.run("doc.switch", json!({"index": i})); app.ui.home = false; } @@ -180,12 +224,29 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { } else { app.run("file.close", json!({"index": i})); } + if app.ui.present_uid.is_some_and(|uid| !app.session.docs.iter().any(|d| d.uid == uid)) { + app.ui.present = None; + app.ui.paused_present = None; + app.ui.present_uid = None; + } } let _ = Color32::TRANSPARENT; let _ = Align2::LEFT_TOP; }); } +fn resume_presentation(app: &mut FigApp) { + let Some(uid) = app.ui.present_uid else { return }; + let Some(index) = app.session.docs.iter().position(|d| d.uid == uid) else { + app.ui.paused_present = None; + app.ui.present_uid = None; + return; + }; + app.run("doc.switch", json!({"index": index})); + app.ui.home = false; + app.ui.present = app.ui.paused_present.take(); +} + /// What the tab of document `uid` says about its live session: others get in (`true`: a session /// this app hosts or joined, a file shared online), only you do (`false`: a file of yours on a /// server, not shared now), or it has none. @@ -195,7 +256,7 @@ pub fn live_mark(app: &FigApp, uid: u64) -> Option { } /// Minimise, maximise/restore and close at the right end of the title bar; returns their width. -fn window_buttons(ui: &mut Ui, bar: Rect) -> f32 { +fn window_buttons(app: &mut FigApp, ui: &mut Ui, bar: Rect) -> f32 { const W: f32 = 46.0; let t = Tokens::get(ui.ctx()); let max = ui.input(|i| i.viewport().maximized.unwrap_or(false)); @@ -223,7 +284,11 @@ fn window_buttons(ui: &mut Ui, bar: Rect) -> f32 { } icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), icon, col); if resp.clicked() { - ui.ctx().send_viewport_cmd(cmd); + if close { + crate::windows::request_close(app, ui.ctx()); + } else { + ui.ctx().send_viewport_cmd(cmd); + } } } W * 3.0 diff --git a/crates/ui-egui/src/titlebar_tests.rs b/crates/ui-egui/src/titlebar_tests.rs index 796cb670..e9224404 100644 --- a/crates/ui-egui/src/titlebar_tests.rs +++ b/crates/ui-egui/src/titlebar_tests.rs @@ -4,6 +4,7 @@ use egui::{Event, PointerButton, Pos2, RawInput, Rect, ResizeDirection, ViewportCommand, ViewportId, pos2, vec2}; use figcraft_engine::Session; +use serde_json::json; use crate::{FigApp, NoServices, TitleBar}; @@ -76,7 +77,23 @@ fn window_buttons_minimise_maximise_and_close() { let mut u = Ui::new(TitleBar::Custom); assert!(u.click(window_button(0)).contains(&ViewportCommand::Minimized(true))); assert!(u.click(window_button(1)).contains(&ViewportCommand::Maximized(true))); - assert!(u.click(window_button(2)).contains(&ViewportCommand::Close)); + u.click(window_button(2)); + assert!(u.frame(vec![]).contains(&ViewportCommand::Close)); +} + +#[test] +fn titlebar_close_asks_over_the_presenter_when_a_file_is_unsaved() { + let mut u = Ui::new(TitleBar::Custom); + let frame = u.app.session.execute("node.create", &json!({"type": "frame", "width": 402, "height": 874})).unwrap()["id"].clone(); + u.app.session.execute("proto.present", &json!({"start": frame})).unwrap(); + u.app.handle_ui_requests(); + u.frame(vec![]); + assert!(u.app.ui.present.is_some()); + + let commands = u.click(window_button(2)); + assert!(!commands.contains(&ViewportCommand::Close)); + assert!(u.app.ui.confirm_close); + assert!(u.ctx.memory(|memory| memory.area_rect(egui::Id::new(Some(crate::i18n::tr("Unsaved changes")))).is_some())); } #[test] diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index b6a21cda..451d87d7 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -302,7 +302,12 @@ pub fn on_close_request(app: &mut FigApp, ctx: &Context) { if app.windows.current == 0 && (unsaved || !app.windows.list.is_empty()) { ctx.send_viewport_cmd(ViewportCommand::CancelClose); } - if unsaved { + request_close(app, ctx); +} + +/// The custom title bar's Close button uses the same unsaved-file check as a system close event. +pub(crate) fn request_close(app: &mut FigApp, ctx: &Context) { + if !app.unsaved().is_empty() { app.ui.confirm_close = true; ctx.request_repaint(); } else { @@ -327,7 +332,7 @@ pub(crate) fn confirm_close(app: &mut FigApp, ctx: &Context) { } let t = crate::theme::Tokens::get(ctx); let (mut open, mut discard, mut cancel) = (true, false, false); - crate::dialogs::window(tr("Unsaved changes"), ctx).open(&mut open).show(ctx, |ui| { + crate::dialogs::window(tr("Unsaved changes"), ctx).order(egui::Order::Foreground).open(&mut open).show(ctx, |ui| { ui.set_width(340.0); crate::widgets::label(ui, &trf("Unsaved changes in {names}. Close the window and lose them?", &[("names", &names.join(", "))]), t.text); ui.add_space(12.0); diff --git a/crates/ui-egui/src/windows_tests.rs b/crates/ui-egui/src/windows_tests.rs index 1a6e91bb..06e0a6a1 100644 --- a/crates/ui-egui/src/windows_tests.rs +++ b/crates/ui-egui/src/windows_tests.rs @@ -45,6 +45,25 @@ fn names(app: &FigApp) -> Vec { app.session.docs.iter().map(|d| d.doc.name.clone()).collect() } +#[test] +fn closing_a_window_in_the_presenter_shows_the_unsaved_changes_dialog() { + let mut session = Session::new(); + let frame_id = session.execute("node.create", &json!({"type": "frame", "width": 402, "height": 874})).unwrap()["id"].clone(); + let ctx = egui::Context::default(); + let mut app = FigApp::new(session, Box::new(NoServices)); + frame(&ctx, &mut app, false); + app.session.execute("proto.present", &json!({"start": frame_id})).unwrap(); + app.handle_ui_requests(); + frame(&ctx, &mut app, false); + assert!(app.ui.present.is_some()); + + frame(&ctx, &mut app, true); + assert!(app.ui.confirm_close); + assert!(ctx.memory(|memory| memory.area_rect(egui::Id::new(Some(crate::i18n::tr("Unsaved changes")))).is_some()), "dialog must be on screen"); + frame(&ctx, &mut app, false); + assert!(app.ui.present.is_some(), "asking to close does not dismiss the preview"); +} + #[test] fn windows_hold_tabs_and_close_with_their_files() { let mut s = Session::empty();