Merge fix/537

This commit is contained in:
bflatastic
2026-10-17 20:23:42 +07:00
23 changed files with 595 additions and 34 deletions
+92 -1
View File
@@ -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,
+4
View File
@@ -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"
+4
View File
@@ -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"
+5 -1
View File
@@ -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"
"⌥ drag" = "⌥ drag"
+4
View File
@@ -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"
+4
View File
@@ -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"
+4
View File
@@ -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"
+4
View File
@@ -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" = "プロパティ"
+4
View File
@@ -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" = "속성"
+4
View File
@@ -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"
+4
View File
@@ -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" = "Свойства"
+4
View File
@@ -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"
+4
View File
@@ -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"
+4
View File
@@ -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" = "属性"
+1 -1
View File
@@ -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);
+16 -3
View File
@@ -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<present::Presenter>,
/// A presentation kept alive while its design tab or Home is in front.
pub paused_present: Option<present::Presenter>,
/// The file that owns the presentation tab.
pub present_uid: Option<u64>,
/// 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,
+290 -13
View File
@@ -270,6 +270,8 @@ pub struct Presenter {
anim_tex: Option<egui::TextureHandle>,
temp: Option<Temp>,
textures: HashMap<NodeId, FrameTex>,
/// 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<String> {
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<BarAction> {
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");
+27 -5
View File
@@ -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");
+2 -2
View File
@@ -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.
+70 -5
View File
@@ -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<bool> {
}
/// 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
+18 -1
View File
@@ -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]
+7 -2
View File
@@ -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);
+19
View File
@@ -45,6 +45,25 @@ fn names(app: &FigApp) -> Vec<String> {
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();