From 573dd521bb3e53ba04a98b49112c60280b97e26a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 13 Oct 2026 01:42:21 +0700 Subject: [PATCH] M11: starter templates: Templates tab, first-run templates and Getting started card on the home screen --- apps/figcraft/src/main.rs | 5 +- crates/ui-egui/locales/de.toml | 1 - crates/ui-egui/locales/en.toml | 41 ++- crates/ui-egui/locales/es.toml | 42 ++- crates/ui-egui/locales/fr.toml | 1 - crates/ui-egui/locales/ja.toml | 1 - crates/ui-egui/locales/ko.toml | 1 - crates/ui-egui/locales/pt-BR.toml | 1 - crates/ui-egui/locales/zh-Hans.toml | 1 - crates/ui-egui/src/control.rs | 4 +- crates/ui-egui/src/home.rs | 546 +++++++++++++++++++++------- crates/ui-egui/src/home_tests.rs | 282 ++++++++++++++ crates/ui-egui/src/i18n_tests.rs | 2 + crates/ui-egui/src/lib.rs | 15 + crates/ui-egui/src/online.rs | 4 +- crates/ui-egui/src/toolbar.rs | 7 +- 16 files changed, 811 insertions(+), 143 deletions(-) create mode 100644 crates/ui-egui/src/home_tests.rs diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index 1fa9879..8ef814c 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -150,7 +150,10 @@ fn main() -> eframe::Result { // default, never a build from source unless asked). session.live.links = figcraft_collab_net::links::system(); session.invite_links_startup(); - if session.docs.is_empty() { + // A first run (no settings kept yet, nothing to open) starts on the home screen, with its + // tips and templates; later runs start in a new file. + let first_run = svc.prefs.is_none() && args.join.is_none(); + if session.docs.is_empty() && !first_run { let _ = session.execute("file.new", &json!({})); } let mut fig = FigApp::new(session, Box::new(svc)); diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index f8c7580..3a10dac 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -257,7 +257,6 @@ status = "draft — needs native review" "Comment" = "Kommentar" "Comments" = "Kommentare" "Commits" = "Commits" -"Community" = "Community" "Company" = "Unternehmen" "Compare changes" = "Änderungen vergleichen" "Completed" = "Abgeschlossen" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 23bd3fa..b68b8a4 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -16,7 +16,9 @@ name = "English" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "A collection of this file with these modes and variables: override their values per mode and add variables of its own" +"A responsive product page: hero, features, figures, a quote and a footer" = "A responsive product page: hero, features, figures, a quote and a footer" "A spring plays until it settles: its mass, stiffness and damping set how long" = "A spring plays until it settles: its mass, stiffness and damping set how long" +"A title, an agenda, a big figure, a chart and a closing slide" = "A title, an agenda, a big figure, a chart and a closing slide" "About" = "About" "About FigCraft" = "About FigCraft" "About this server" = "About this server" @@ -88,6 +90,7 @@ name = "English" "All variables" = "All variables" "Alpha" = "Alpha" "Alt+drag" = "Alt+drag" +"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "An analytics screen on a grid: figures, charts drawn from vectors and a table" "An invite link that opens FigCraft and joins" = "An invite link that opens FigCraft and joins" "Anchor" = "Anchor" "Android" = "Android" @@ -106,6 +109,7 @@ name = "English" "Apply variable" = "Apply variable" "Apply variable to text" = "Apply variable to text" "Apply variable to visibility" = "Apply variable to visibility" +"Apps" = "Apps" "Arc" = "Arc" "Arc ratio" = "Arc ratio" "Arc start" = "Arc start" @@ -189,6 +193,7 @@ name = "English" "Clear from this list" = "Clear from this list" "Code or invite link" = "Code or invite link" "Color profile: {name}" = "Color profile: {name}" +"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Colour and text styles, Light and Dark variables, buttons, inputs and cards" "CSS custom properties…" = "CSS custom properties…" "Can edit" = "Can edit" "Can't read this library: {e}" = "Can't read this library: {e}" @@ -256,7 +261,6 @@ name = "English" "Comment" = "Comment" "Comments" = "Comments" "Commits" = "Commits" -"Community" = "Community" "Company" = "Company" "Compare changes" = "Compare changes" "Completed" = "Completed" @@ -327,6 +331,7 @@ name = "English" "Dark" = "Dark" "Darken" = "Darken" "Dash" = "Dash" +"Dashboard" = "Dashboard" "Decoration style" = "Decoration style" "Decoration" = "Decoration" "Deep select" = "Deep select" @@ -353,6 +358,7 @@ name = "English" "Description" = "Description" "Description (optional)" = "Description (optional)" "Design" = "Design" +"Design system starter" = "Design system starter" "Desktop" = "Desktop" "Destination" = "Destination" "Detach alias" = "Detach alias" @@ -388,6 +394,7 @@ name = "English" "Drafts" = "Drafts" "Drag the stroke to add a width point" = "Drag the stroke to add a width point" "Draw" = "Draw" +"Draw a frame" = "Draw a frame" "Drawing tools" = "Drawing tools" "Drop shadow" = "Drop shadow" "Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." @@ -436,6 +443,7 @@ name = "English" "Expand" = "Expand" "Export" = "Export" "Export all" = "Export all" +"Export frames and layers as PNG, JPG, SVG or PDF" = "Export frames and layers as PNG, JPG, SVG or PDF" "Export page as PNG" = "Export page as PNG" "Export settings" = "Export settings" "Export variables" = "Export variables" @@ -504,6 +512,7 @@ name = "English" "Font size" = "Font size" "Font style" = "Font style" "Forget on this computer" = "Forget on this computer" +"Four screens of a trail app with components and a prototype flow" = "Four screens of a trail app with components and a prototype flow" "Fractions" = "Fractions" "Frame" = "Frame" "Frame selection" = "Frame selection" @@ -517,6 +526,7 @@ name = "English" "Gap between columns" = "Gap between columns" "Gap between items" = "Gap between items" "Gap between rows" = "Gap between rows" +"Getting started" = "Getting started" "Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." "Glass" = "Glass" "Go to main component" = "Go to main component" @@ -525,6 +535,7 @@ name = "English" "Gradient" = "Gradient" "Gray" = "Gray" "Green" = "Green" +"Greyscale components and two example screens to sketch a flow with" = "Greyscale components and two example screens to sketch a flow with" "Grid" = "Grid" "Grid ({size})" = "Grid ({size})" "Grid styles" = "Grid styles" @@ -546,6 +557,7 @@ name = "English" "Hide contents" = "Hide contents" "Hide from publishing" = "Hide from publishing" "Hide section contents" = "Hide section contents" +"Hide these tips (Getting started in the Help menu shows them again)" = "Hide these tips (Getting started in the Help menu shows them again)" "Hide when publishing" = "Hide when publishing" "Highlight layers on hover" = "Highlight layers on hover" "Highlights" = "Highlights" @@ -568,6 +580,8 @@ name = "English" "Hue" = "Hue" "Hug" = "Hug" "Hug contents" = "Hug contents" +"Icon components on a 24 px grid with keylines and a size preview" = "Icon components on a 24 px grid with keylines and a size preview" +"Icon set starter" = "Icon set starter" "If" = "If" "If {condition}" = "If {condition}" "Ignore auto layout (absolute position)" = "Ignore auto layout (absolute position)" @@ -631,6 +645,8 @@ name = "English" "Key/Gamepad" = "Key/Gamepad" "Key/Gamepad {key}" = "Key/Gamepad {key}" "Keyboard shortcuts" = "Keyboard shortcuts" +"Kits" = "Kits" +"Landing page" = "Landing page" "Language" = "Language" "Lasso" = "Lasso" "Last" = "Last" @@ -674,6 +690,7 @@ name = "English" "Link" = "Link" "Link copied" = "Link copied" "Link docs, code or tickets for developers." = "Link docs, code or tickets for developers." +"Link frames on the Prototype tab, then present the flow" = "Link frames on the Prototype tab, then present the flow" "Link this component to its code component: instances then show your real snippet with their values." = "Link this component to its code component: instances then show your real snippet with their values." "List spacing" = "List spacing" "List style" = "List style" @@ -691,6 +708,7 @@ name = "English" "Main" = "Main" "Main component deleted" = "Main component deleted" "Main menu" = "Main menu" +"Make a component" = "Make a component" "Make slot" = "Make slot" "Margin" = "Margin" "Mark all as read" = "Mark all as read" @@ -725,6 +743,7 @@ name = "English" "Mixed" = "Mixed" "Mixed fonts" = "Mixed fonts" "Mixed interactions: click + to replace them" = "Mixed interactions: click + to replace them" +"Mobile app" = "Mobile app" "Mode" = "Mode" "Model" = "Model" "Models" = "Models" @@ -796,6 +815,7 @@ name = "English" "No string variables yet. Create them in Local variables." = "No string variables yet. Create them in Local variables." "No styles yet." = "No styles yet." "No styles yet. Click + to create one from the selection." = "No styles yet. Click + to create one from the selection." +"No templates match your search" = "No templates match your search" "No text styles yet. Click + to create one from the selected text." = "No text styles yet. Click + to create one from the selected text." "No variables fit here." = "No variables fit here." "No variables yet. Create them in Local variables." = "No variables yet. Create them in Local variables." @@ -830,6 +850,7 @@ name = "English" "On click" = "On click" "On drag" = "On drag" "On this page" = "On this page" +"One announcement in five common sizes: square, portrait, story, link and banner" = "One announcement in five common sizes: square, portrait, story, link and banner" "Online" = "Online" "Online files" = "Online files" "Online sharing is off: files are shared on the local network only." = "Online sharing is off: files are shared on the local network only." @@ -902,6 +923,7 @@ name = "English" "Pick color" = "Pick color" "Pick color (I)" = "Pick color (I)" "Pick one font family first" = "Pick one font family first" +"Pick the Frame tool, then drag on the canvas or choose a device size" = "Pick the Frame tool, then drag on the canvas or choose a device size" "Pin a property" = "Pin a property" "Pinch" = "Pinch" "Pixel grid" = "Pixel grid" @@ -927,6 +949,7 @@ name = "English" "Present or preview" = "Present or preview" "Present this flow" = "Present this flow" "Presentation" = "Presentation" +"Presentation slides" = "Presentation slides" "Presenting: comments" = "Presenting: comments" "Presenting: cycle the size options" = "Presenting: cycle the size options" "Presenting: full screen" = "Presenting: full screen" @@ -1065,6 +1088,9 @@ name = "English" "Rulers" = "Rulers" "Run" = "Run" "Run {label}" = "Run {label}" +"Search templates" = "Search templates" +"See all templates" = "See all templates" +"Select layers and they space themselves as their content changes" = "Select layers and they space themselves as their content changes" "SHA-256: {sha}…" = "SHA-256: {sha}…" "Same as layer" = "Same as layer" "Saturation" = "Saturation" @@ -1124,6 +1150,7 @@ name = "English" "Shape tools" = "Shape tools" "Share" = "Share" "Share this file" = "Share this file" +"Share with a code" = "Share with a code" "Share: live session, export" = "Share: live session, export" "Shared" = "Shared" "Shared: copy its code from the file's menu" = "Shared: copy its code from the file's menu" @@ -1133,6 +1160,7 @@ name = "English" "Sharing online · {people}" = "Sharing online · {people}" "Sharing stopped: the file stays on the server, open only to you" = "Sharing stopped: the file stays on the server, open only to you" "Show" = "Show" +"Show all templates" = "Show all templates" "Show as grid" = "Show as grid" "Show as list" = "Show as list" "Show autosaves" = "Show autosaves" @@ -1165,7 +1193,9 @@ name = "English" "Small caps" = "Small caps" "Smart animate" = "Smart animate" "Snap to pixel grid" = "Snap to pixel grid" +"Social and slides" = "Social and slides" "Social media" = "Social media" +"Social post sizes" = "Social post sizes" "Soft light" = "Soft light" "Solid" = "Solid" "Some variants have the same property values: give each one a unique combination" = "Some variants have the same property values: give each one a unique combination" @@ -1190,9 +1220,11 @@ name = "English" "Start" = "Start" "Start ascending from / stop descending at" = "Start ascending from / stop descending at" "Start from a blank canvas" = "Start from a blank canvas" +"Start from a template" = "Start from a template" "Start on path" = "Start on path" "Start point" = "Start point" "Start sharing" = "Start sharing" +"Start sharing with the Share button, then pass on the code it gives" = "Start sharing with the Share button, then pass on the code it gives" "Starting to share…" = "Starting to share…" "Status" = "Status" "Sticky" = "Sticky" @@ -1241,6 +1273,7 @@ name = "English" "Tell the server's operator what is wrong with it" = "Tell the server's operator what is wrong with it" "Temperature" = "Temperature" "Template" = "Template" +"Templates" = "Templates" "Terms" = "Terms" "Text" = "Text" "Text align" = "Text align" @@ -1298,6 +1331,8 @@ name = "English" "Truncate with an ellipsis" = "Truncate with an ellipsis" "Try again" = "Try again" "Try another search or filter." = "Try another search or filter." +"Try another word, or look at every kind." = "Try another word, or look at every kind." +"Turn a layer into a component, reuse it, and change every copy at once" = "Turn a layer into a component, reuse it, and change every copy at once" "Two digits" = "Two digits" "Type" = "Type" "Type settings" = "Type settings" @@ -1368,6 +1403,7 @@ name = "English" "Watch" = "Watch" "Wavy" = "Wavy" "Web" = "Web" +"Websites" = "Websites" "WebSocket URL" = "WebSocket URL" "WebSocket URL copied" = "WebSocket URL copied" "Wedge" = "Wedge" @@ -1385,6 +1421,7 @@ name = "English" "Width" = "Width" "Width point" = "Width point" "Width profile" = "Width profile" +"Wireframe kit" = "Wireframe kit" "With the layers' export settings" = "With the layers' export settings" "Wrap" = "Wrap" "Wrap in new section" = "Wrap in new section" @@ -1511,4 +1548,4 @@ name = "English" "⌘ arrows" = "⌘ arrows" "⌘ click" = "⌘ click" "⌘ scroll" = "⌘ scroll" -"⌥ drag" = "⌥ drag" +"⌥ drag" = "⌥ drag" \ No newline at end of file diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 95b6c68..52d93b7 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -17,7 +17,9 @@ status = "draft — needs native review" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PR, {commits} commits, +{added} / −{deleted} líneas (Δ {delta}), +{bin_added} / −{bin_deleted} recursos binarios, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Una colección de este archivo con estos modos y variables: modifica sus valores por modo y añade variables propias" +"A responsive product page: hero, features, figures, a quote and a footer" = "Una página de producto adaptable: cabecera, funciones, cifras, una cita y un pie" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Un muelle se reproduce hasta que se asienta: su masa, rigidez y amortiguación fijan cuánto dura" +"A title, an agenda, a big figure, a chart and a closing slide" = "Un título, una agenda, una gran cifra, un gráfico y una diapositiva de cierre" "About" = "Acerca de" "About FigCraft" = "Acerca de FigCraft" "About this server" = "Acerca de este servidor" @@ -89,6 +91,7 @@ status = "draft — needs native review" "All variables" = "Todas las variables" "Alpha" = "Alfa" "Alt+drag" = "Alt+arrastrar" +"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Una pantalla de analítica en cuadrícula: cifras, gráficos dibujados con vectores y una tabla" "An invite link that opens FigCraft and joins" = "Un enlace de invitación que abre FigCraft y se une" "Anchor" = "Ancla" "Android" = "Android" @@ -107,6 +110,7 @@ status = "draft — needs native review" "Apply variable" = "Aplicar variable" "Apply variable to text" = "Aplicar variable al texto" "Apply variable to visibility" = "Aplicar variable a la visibilidad" +"Apps" = "Aplicaciones" "Arc" = "Arco" "Arc ratio" = "Proporción del arco" "Arc start" = "Inicio del arco" @@ -190,6 +194,7 @@ status = "draft — needs native review" "Clear from this list" = "Borrar de esta lista" "Code or invite link" = "Código o enlace de invitación" "Color profile: {name}" = "Perfil de color: {name}" +"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Estilos de color y texto, variables Claro y Oscuro, botones, campos y tarjetas" "CSS custom properties…" = "Propiedades personalizadas CSS…" "Can edit" = "Puede editar" "Can't read this library: {e}" = "No se puede leer esta biblioteca: {e}" @@ -257,7 +262,6 @@ status = "draft — needs native review" "Comment" = "Comentario" "Comments" = "Comentarios" "Commits" = "Commits" -"Community" = "Comunidad" "Company" = "Empresa" "Compare changes" = "Comparar cambios" "Completed" = "Completado" @@ -328,6 +332,7 @@ status = "draft — needs native review" "Dark" = "Oscuro" "Darken" = "Oscurecer" "Dash" = "Discontinuo" +"Dashboard" = "Panel de control" "Decoration style" = "Estilo de decoración" "Decoration" = "Decoración" "Deep select" = "Selección profunda" @@ -354,6 +359,7 @@ status = "draft — needs native review" "Description" = "Descripción" "Description (optional)" = "Descripción (opcional)" "Design" = "Diseño" +"Design system starter" = "Base de sistema de diseño" "Desktop" = "Escritorio" "Destination" = "Destino" "Detach alias" = "Separar alias" @@ -389,6 +395,7 @@ status = "draft — needs native review" "Drafts" = "Borradores" "Drag the stroke to add a width point" = "Arrastra el trazo para añadir un punto de ancho" "Draw" = "Dibujar" +"Draw a frame" = "Dibuja un marco" "Drawing tools" = "Herramientas de dibujo" "Drop shadow" = "Sombra paralela" "Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "Con doble licencia MIT OR Apache-2.0. Hecho por el equipo y la comunidad de ArtCraft." @@ -437,6 +444,7 @@ status = "draft — needs native review" "Expand" = "Expandir" "Export" = "Exportar" "Export all" = "Exportar todo" +"Export frames and layers as PNG, JPG, SVG or PDF" = "Exporta marcos y capas como PNG, JPG, SVG o PDF" "Export page as PNG" = "Exportar página como PNG" "Export settings" = "Ajustes de exportación" "Export variables" = "Exportar variables" @@ -505,6 +513,7 @@ status = "draft — needs native review" "Font size" = "Tamaño de fuente" "Font style" = "Estilo de fuente" "Forget on this computer" = "Olvidar en este ordenador" +"Four screens of a trail app with components and a prototype flow" = "Cuatro pantallas de una app de senderos con componentes y un flujo de prototipo" "Fractions" = "Fracciones" "Frame" = "Marco" "Frame selection" = "Enmarcar selección" @@ -518,6 +527,7 @@ status = "draft — needs native review" "Gap between columns" = "Espacio entre columnas" "Gap between items" = "Espacio entre elementos" "Gap between rows" = "Espacio entre filas" +"Getting started" = "Primeros pasos" "Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "Indica la dirección de un servidor abierto al público, o la dirección de un servidor con su clave (`figcraft-server key` la muestra)." "Glass" = "Vidrio" "Go to main component" = "Ir al componente principal" @@ -526,6 +536,7 @@ status = "draft — needs native review" "Gradient" = "Degradado" "Gray" = "Gris" "Green" = "Verde" +"Greyscale components and two example screens to sketch a flow with" = "Componentes en escala de grises y dos pantallas de ejemplo para esbozar un flujo" "Grid" = "Cuadrícula" "Grid ({size})" = "Cuadrícula ({size})" "Grid styles" = "Estilos de cuadrícula" @@ -547,6 +558,7 @@ status = "draft — needs native review" "Hide contents" = "Ocultar contenido" "Hide from publishing" = "Ocultar al publicar" "Hide section contents" = "Ocultar contenido de la sección" +"Hide these tips (Getting started in the Help menu shows them again)" = "Ocultar estos consejos (Primeros pasos en el menú Ayuda los vuelve a mostrar)" "Hide when publishing" = "Ocultar al publicar" "Highlight layers on hover" = "Resaltar capas al pasar el cursor" "Highlights" = "Luces" @@ -569,6 +581,8 @@ status = "draft — needs native review" "Hue" = "Tono" "Hug" = "Ajustar" "Hug contents" = "Ajustar al contenido" +"Icon components on a 24 px grid with keylines and a size preview" = "Componentes de icono en una cuadrícula de 24 px con líneas guía y una vista de tamaños" +"Icon set starter" = "Base de conjunto de iconos" "If" = "Si" "If {condition}" = "Si {condition}" "Ignore auto layout (absolute position)" = "Ignorar diseño automático (posición absoluta)" @@ -632,6 +646,8 @@ status = "draft — needs native review" "Key/Gamepad" = "Tecla/Mando" "Key/Gamepad {key}" = "Tecla/Mando {key}" "Keyboard shortcuts" = "Atajos de teclado" +"Kits" = "Kits" +"Landing page" = "Página de inicio" "Language" = "Idioma" "Lasso" = "Lazo" "Last" = "Último" @@ -675,6 +691,7 @@ status = "draft — needs native review" "Link" = "Enlace" "Link copied" = "Enlace copiado" "Link docs, code or tickets for developers." = "Enlaza documentación, código o tickets para desarrolladores." +"Link frames on the Prototype tab, then present the flow" = "Enlaza marcos en la pestaña Prototipo y presenta el flujo" "Link this component to its code component: instances then show your real snippet with their values." = "Vincula este componente a su componente de código: así las instancias muestran tu fragmento real con sus valores." "List spacing" = "Espaciado de lista" "List style" = "Estilo de lista" @@ -692,6 +709,7 @@ status = "draft — needs native review" "Main" = "Principal" "Main component deleted" = "Componente principal eliminado" "Main menu" = "Menú principal" +"Make a component" = "Crea un componente" "Make slot" = "Convertir en ranura" "Margin" = "Margen" "Mark all as read" = "Marcar todo como leído" @@ -726,6 +744,7 @@ status = "draft — needs native review" "Mixed" = "Mixto" "Mixed fonts" = "Fuentes mixtas" "Mixed interactions: click + to replace them" = "Interacciones mixtas: haz clic en + para reemplazarlas" +"Mobile app" = "Aplicación móvil" "Mode" = "Modo" "Model" = "Modelo" "Models" = "Modelos" @@ -797,6 +816,7 @@ status = "draft — needs native review" "No string variables yet. Create them in Local variables." = "Aún no hay variables de cadena. Créalas en Variables locales." "No styles yet." = "Aún no hay estilos." "No styles yet. Click + to create one from the selection." = "Aún no hay estilos. Haz clic en + para crear uno a partir de la selección." +"No templates match your search" = "Ninguna plantilla coincide con tu búsqueda" "No text styles yet. Click + to create one from the selected text." = "Aún no hay estilos de texto. Haz clic en + para crear uno a partir del texto seleccionado." "No variables fit here." = "Ninguna variable encaja aquí." "No variables yet. Create them in Local variables." = "Aún no hay variables. Créalas en Variables locales." @@ -831,6 +851,7 @@ status = "draft — needs native review" "On click" = "Al hacer clic" "On drag" = "Al arrastrar" "On this page" = "En esta página" +"One announcement in five common sizes: square, portrait, story, link and banner" = "Un anuncio en cinco tamaños habituales: cuadrado, vertical, historia, enlace y banner" "Online" = "En línea" "Online files" = "Archivos en línea" "Online sharing is off: files are shared on the local network only." = "Compartir en línea está desactivado: los archivos solo se comparten en la red local." @@ -903,6 +924,7 @@ status = "draft — needs native review" "Pick color" = "Elegir color" "Pick color (I)" = "Tomar color (I)" "Pick one font family first" = "Elige primero una familia de fuentes" +"Pick the Frame tool, then drag on the canvas or choose a device size" = "Elige la herramienta Marco y arrastra en el lienzo o escoge un tamaño de dispositivo" "Pin a property" = "Fijar una propiedad" "Pinch" = "Pellizco" "Pixel grid" = "Cuadrícula de píxeles" @@ -928,6 +950,7 @@ status = "draft — needs native review" "Present or preview" = "Presentar o previsualizar" "Present this flow" = "Presentar este flujo" "Presentation" = "Presentación" +"Presentation slides" = "Diapositivas de presentación" "Presenting: comments" = "Presentando: comentarios" "Presenting: cycle the size options" = "Presentando: alternar las opciones de tamaño" "Presenting: full screen" = "Presentando: pantalla completa" @@ -1066,6 +1089,9 @@ status = "draft — needs native review" "Rulers" = "Reglas" "Run" = "Ejecutar" "Run {label}" = "Ejecutar {label}" +"Search templates" = "Buscar plantillas" +"See all templates" = "Ver todas las plantillas" +"Select layers and they space themselves as their content changes" = "Selecciona capas y se espaciarán solas cuando cambie su contenido" "SHA-256: {sha}…" = "SHA-256: {sha}…" "Same as layer" = "Igual que la capa" "Saturation" = "Saturación" @@ -1125,6 +1151,7 @@ status = "draft — needs native review" "Shape tools" = "Herramientas de forma" "Share" = "Compartir" "Share this file" = "Compartir este archivo" +"Share with a code" = "Comparte con un código" "Share: live session, export" = "Compartir: sesión en vivo, exportar" "Shared" = "Compartido" "Shared: copy its code from the file's menu" = "Compartido: copia su código desde el menú del archivo" @@ -1134,6 +1161,7 @@ status = "draft — needs native review" "Sharing online · {people}" = "Compartiendo en línea · {people}" "Sharing stopped: the file stays on the server, open only to you" = "Se dejó de compartir: el archivo se queda en el servidor, abierto solo para ti" "Show" = "Mostrar" +"Show all templates" = "Mostrar todas las plantillas" "Show as grid" = "Mostrar como cuadrícula" "Show as list" = "Mostrar como lista" "Show autosaves" = "Mostrar autoguardados" @@ -1166,7 +1194,9 @@ status = "draft — needs native review" "Small caps" = "Versalitas" "Smart animate" = "Animación inteligente" "Snap to pixel grid" = "Ajustar a la cuadrícula de píxeles" +"Social and slides" = "Redes y diapositivas" "Social media" = "Redes sociales" +"Social post sizes" = "Tamaños para redes sociales" "Soft light" = "Luz suave" "Solid" = "Sólido" "Some variants have the same property values: give each one a unique combination" = "Algunas variantes tienen los mismos valores de propiedad: da a cada una una combinación única" @@ -1191,9 +1221,11 @@ status = "draft — needs native review" "Start" = "Inicio" "Start ascending from / stop descending at" = "Contar hacia arriba desde / hacia abajo hasta" "Start from a blank canvas" = "Empieza con un lienzo en blanco" +"Start from a template" = "Empieza con una plantilla" "Start on path" = "Inicio en el trazado" "Start point" = "Punto inicial" "Start sharing" = "Empezar a compartir" +"Start sharing with the Share button, then pass on the code it gives" = "Empieza a compartir con el botón Compartir y pasa el código que te da" "Starting to share…" = "Empezando a compartir…" "Status" = "Estado" "Sticky" = "Adherido" @@ -1242,6 +1274,7 @@ status = "draft — needs native review" "Tell the server's operator what is wrong with it" = "Explica al operador del servidor qué tiene de malo" "Temperature" = "Temperatura" "Template" = "Plantilla" +"Templates" = "Plantillas" "Terms" = "Condiciones" "Text" = "Texto" "Text align" = "Alineación del texto" @@ -1299,6 +1332,8 @@ status = "draft — needs native review" "Truncate with an ellipsis" = "Truncar con puntos suspensivos" "Try again" = "Reintentar" "Try another search or filter." = "Prueba otra búsqueda u otro filtro." +"Try another word, or look at every kind." = "Prueba con otra palabra o mira todos los tipos." +"Turn a layer into a component, reuse it, and change every copy at once" = "Convierte una capa en componente, reutilízalo y cambia todas las copias a la vez" "Two digits" = "Dos dígitos" "Type" = "Tipo" "Type settings" = "Ajustes de texto" @@ -1369,6 +1404,7 @@ status = "draft — needs native review" "Watch" = "Reloj" "Wavy" = "Ondulado" "Web" = "Web" +"Websites" = "Sitios web" "WebSocket URL" = "URL de WebSocket" "WebSocket URL copied" = "URL de WebSocket copiada" "Wedge" = "Cuña" @@ -1386,6 +1422,7 @@ status = "draft — needs native review" "Width" = "Ancho" "Width point" = "Punto de ancho" "Width profile" = "Perfil de ancho" +"Wireframe kit" = "Kit de wireframes" "With the layers' export settings" = "Con los ajustes de exportación de las capas" "Wrap" = "Ajustar" "Wrap in new section" = "Envolver en una sección nueva" @@ -1661,6 +1698,7 @@ status = "draft — needs native review" "file.exportSvg" = "Exportar selección como SVG" "file.import" = "Importar" "file.new" = "Nuevo archivo de diseño" +"file.newFromTemplate" = "Nuevo desde plantilla" "file.open" = "Abrir" "file.openRecent" = "Abrir reciente" "file.placeImage" = "Colocar imagen" @@ -1687,6 +1725,7 @@ status = "draft — needs native review" "guide.move" = "Mover guía" "guide.remove" = "Quitar guía" "guide.select" = "Seleccionar guía" +"help.gettingStarted" = "Primeros pasos" "help.about" = "Acerca de FigCraft" "help.page" = "Página de ayuda" "help.shortcuts" = "Atajos de teclado" @@ -2064,6 +2103,7 @@ status = "draft — needs native review" "widget.list" = "Listar widgets" "window.focus" = "Traer la ventana al frente" "window.new" = "Nueva ventana" +"template.list" = "Listar plantillas" [menu] "bool.exclude" = "Excluir" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 3e220b6..06b4c10 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -257,7 +257,6 @@ status = "draft — needs native review" "Comment" = "Commentaire" "Comments" = "Commentaires" "Commits" = "Commits" -"Community" = "Communauté" "Company" = "Entreprise" "Compare changes" = "Comparer les modifications" "Completed" = "Terminé" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 246a7fa..65c2994 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -259,7 +259,6 @@ status = "draft — needs native review" "Comment" = "コメント" "Comments" = "コメント" "Commits" = "コミット" -"Community" = "コミュニティ" "Company" = "会社" "Compare changes" = "変更を比較" "Completed" = "完了" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 5ae2014..b1c401f 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -258,7 +258,6 @@ status = "draft — needs native review" "Comment" = "댓글" "Comments" = "댓글" "Commits" = "커밋" -"Community" = "커뮤니티" "Company" = "회사" "Compare changes" = "변경 사항 비교" "Completed" = "완료됨" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 7ca8d81..3f8d5c3 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -256,7 +256,6 @@ status = "draft — needs native review" "Comment" = "Comentário" "Comments" = "Comentários" "Commits" = "Commits" -"Community" = "Comunidade" "Company" = "Empresa" "Compare changes" = "Comparar alterações" "Completed" = "Concluído" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 639b800..1c0d559 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -259,7 +259,6 @@ status = "draft — needs native review" "Comment" = "评论" "Comments" = "评论" "Commits" = "提交" -"Community" = "社区" "Company" = "公司" "Compare changes" = "比较更改" "Completed" = "已完成" diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 0316a71..5b935a6 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -344,8 +344,8 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul if let Some(h) = p.get("home").and_then(Value::as_bool) { app.ui.home = h; } - if let Some(s) = p.get("homeSection").and_then(Value::as_str) { - app.ui.online.open = s == "online"; + if let Some(section) = p.get("homeSection").and_then(Value::as_str).and_then(crate::home::Section::parse) { + app.ui.home_view.section = section; } if let Some(h) = p.get("palette").and_then(Value::as_bool) { app.ui.palette = if h { Some(String::new()) } else { None }; diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 7d45963..4598ba3 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -1,6 +1,12 @@ -//! The home screen: new file, open, join with a code, recent files, and your online files. +//! The home screen: Recents (new file, open, join with a code, the Getting started tips, open and +//! recent files, and templates to start from on a first run), Online (your online files: +//! [`crate::online`]) and Templates (the starter templates as cards with thumbnails rendered from +//! the documents themselves, searched and filtered by kind). -use egui::{Align2, Color32, CornerRadius, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use std::collections::BTreeMap; + +use egui::{Align2, Color32, CornerRadius, Id, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use figcraft_engine::templates::{Kind, TEMPLATES, Template}; use serde_json::json; use crate::FigApp; @@ -10,6 +16,64 @@ use crate::icons::{self, Icon}; use crate::theme::{ACCENT, Tokens, regular, semibold}; use crate::widgets; +/// The section the home screen shows. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Section { + #[default] + Recents, + Online, + Templates, +} + +impl Section { + /// The sidebar, top to bottom. + const ALL: [(Section, Icon, &'static str); 3] = [ + (Section::Recents, Icon::Home, tr_noop("Recents")), + (Section::Online, Icon::Online, tr_noop("Online")), + (Section::Templates, Icon::Assets, tr_noop("Templates")), + ]; + + /// The id `ui.set {homeSection}` and `ui.inspect` use. + pub fn id(self) -> &'static str { + match self { + Section::Recents => "recents", + Section::Online => "online", + Section::Templates => "templates", + } + } + + pub fn parse(id: &str) -> Option
{ + Section::ALL.into_iter().map(|(section, ..)| section).find(|section| section.id() == id) + } +} + +/// The home screen's own state. +#[derive(Default)] +pub struct Home { + pub section: Section, + /// The Templates search text and kind filter. + pub query: String, + pub kind: Option, +} + +const SIDEBAR: f32 = 240.0; +const CARD_W: f32 = 248.0; +const THUMB_H: f32 = 148.0; +const CARD_H: f32 = THUMB_H + 86.0; +const GAP: f32 = 20.0; +/// Templates offered on a first run. +const FIRST_RUN: usize = 4; + +/// The Getting started tips: (title, what to do, the command whose shortcut shows). +const TIPS: [(&str, &str, &str); 6] = [ + (tr_noop("Draw a frame"), tr_noop("Pick the Frame tool, then drag on the canvas or choose a device size"), "tool.frame"), + (tr_noop("Add auto layout"), tr_noop("Select layers and they space themselves as their content changes"), "layout.add"), + (tr_noop("Make a component"), tr_noop("Turn a layer into a component, reuse it, and change every copy at once"), "component.create"), + (tr_noop("Prototype"), tr_noop("Link frames on the Prototype tab, then present the flow"), "proto.present"), + (tr_noop("Share with a code"), tr_noop("Start sharing with the Share button, then pass on the code it gives"), "collab.share"), + (tr_noop("Export"), tr_noop("Export frames and layers as PNG, JPG, SVG or PDF"), "file.export"), +]; + /// A row of the home screen's file lists. pub(crate) struct FileRow<'a> { /// The mark before the name, and its colour. @@ -35,153 +99,185 @@ pub(crate) fn file_row(ui: &Ui, t: &Tokens, r: Rect, resp: &Response, row: &File ui.painter().text(pos2(r.min.x + 32.0, r.center().y), Align2::LEFT_CENTER, name, regular(12.0), row.ink); } +/// A row of Recents' own lists: laid out in place, named for screen readers, ringed with the +/// keyboard on it; true when chosen. +fn listed(ui: &mut Ui, t: &Tokens, id: Id, height: f32, row: &FileRow) -> bool { + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width().min(640.0), height), Sense::hover()); + let resp = ui.interact(r, id, Sense::click()); + a11y::button(&resp, row.name); + file_row(ui, t, r, &resp, row); + focus_ring(ui, t, r, &resp, 5); + resp.clicked() +} + pub fn show(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.panel)).show(ui, |ui| { let rect = ui.max_rect(); - let left = Rect::from_min_size(rect.min, vec2(240.0, rect.height())); + let left = Rect::from_min_size(rect.min, vec2(SIDEBAR, rect.height())); ui.painter().line_segment([left.right_top(), left.right_bottom()], Stroke::new(1.0, t.border)); - // Sidebar. - let mut y = left.min.y + 20.0; - icons::paint(ui.painter(), Rect::from_min_size(pos2(left.min.x + 16.0, y), vec2(20.0, 20.0)), Icon::Logo, ACCENT); - ui.painter().text(pos2(left.min.x + 44.0, y + 10.0), Align2::LEFT_CENTER, "FigCraft", semibold(14.0), t.text); - y += 48.0; - // Recents and Online are the sections there are; the others are placeholders. - for (icon, label, section) in [ - (Icon::Home, tr_noop("Recents"), Some(false)), - (Icon::Online, tr_noop("Online"), Some(true)), - (Icon::Page, tr_noop("Drafts"), None), - (Icon::Assets, tr_noop("Community"), None), - ] { - let r = Rect::from_min_size(pos2(left.min.x + 8.0, y), vec2(224.0, 32.0)); - let selected = section == Some(app.ui.online.open); - if let Some(online) = section { - let resp = ui.interact(r, ui.id().with(("home_section", label)), Sense::click()); - a11y::tab(&resp, tr(label), selected); - if resp.clicked() { - app.ui.online.open = online; - } - if resp.hovered() && !selected { - ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); - } - } - if selected { - ui.painter().rect_filled(r, CornerRadius::same(5), t.selected_row); - } - icons::paint(ui.painter(), Rect::from_min_size(pos2(r.min.x + 10.0, r.center().y - 8.0), vec2(16.0, 16.0)), icon, t.icon); - ui.painter().text(pos2(r.min.x + 36.0, r.center().y), Align2::LEFT_CENTER, tr(label), regular(12.0), t.text); - y += 34.0; - } - let main = Rect::from_min_max(pos2(left.max.x + 40.0, rect.min.y + 24.0), rect.max - vec2(40.0, 24.0)); - if app.ui.online.open { - crate::online::section(app, ui, main, &t); + sidebar(app, ui, &t, left); + if app.ui.home_view.section == Section::Online { + // Your online files lay themselves out in the area (and scroll their own list). + let area = Rect::from_min_max(pos2(left.max.x + 40.0, rect.min.y + 24.0), rect.max - vec2(40.0, 24.0)); + crate::online::section(app, ui, area, &t); opened_join(app); return; } - ui.painter().text(main.min, Align2::LEFT_TOP, tr("Recents"), semibold(18.0), t.text); - // Action cards. - let mut x = main.min.x; - let cy = main.min.y + 48.0; - let mut new_file = false; - let mut open = false; - for (icon, title, sub) in [ - (Icon::Plus, tr_noop("New design file"), tr_noop("Start from a blank canvas")), - (Icon::Page, tr_noop("Open file"), tr_noop("Open .figcraft, .svg or images")), - ] { - let r = Rect::from_min_size(pos2(x, cy), vec2(260.0, 72.0)); - let mut resp = ui.interact(r, ui.id().with(("card", title)), Sense::click()); - // One line each inside the card; a long translation ends with an ellipsis, in full on hover. - let text_w = r.width() - 72.0; - let (title_shown, sub_shown) = (widgets::elide_font(ui, tr(title), text_w, semibold(12.0)), widgets::elide(ui, tr(sub), text_w)); - if title_shown != tr(title) || sub_shown != tr(sub) { - resp = resp.on_hover_text(format!("{}\n{}", tr(title), tr(sub))); - } - ui.painter().rect_filled(r, CornerRadius::same(8), if resp.hovered() { t.hover } else { t.panel }); - ui.painter().rect_stroke(r, CornerRadius::same(8), Stroke::new(1.0, t.border), StrokeKind::Inside); - let ir = Rect::from_min_size(pos2(r.min.x + 16.0, r.center().y - 16.0), vec2(32.0, 32.0)); - ui.painter().rect_filled(ir, CornerRadius::same(6), ACCENT); - icons::paint(ui.painter(), ir.shrink(8.0), icon, egui::Color32::WHITE); - ui.painter().text(pos2(r.min.x + 60.0, r.center().y - 8.0), Align2::LEFT_CENTER, title_shown, semibold(12.0), t.text); - ui.painter().text(pos2(r.min.x + 60.0, r.center().y + 9.0), Align2::LEFT_CENTER, sub_shown, regular(11.0), t.text_secondary); - if resp.clicked() { - if title.starts_with("New") { - new_file = true; - } else { - open = true; - } - } - x += 280.0; - } - if new_file { - app.run("file.new", json!({})); - app.ui.home = false; - } - if open { - app.open_dialog(); - } - // Join a file someone shared: the join field, and what the join is doing. - let join = Rect::from_min_size(pos2(main.min.x, cy + 96.0), vec2(main.width().min(540.0), 74.0)); - ui.painter().text(join.min, Align2::LEFT_TOP, tr("Join with a code"), semibold(12.0), t.text_secondary); - let field = Rect::from_min_max(pos2(join.min.x, join.min.y + 24.0), join.max); - ui.scope_builder(egui::UiBuilder::new().max_rect(field).layout(egui::Layout::top_down(egui::Align::Min)), |ui| { - if let Some(session) = crate::live::join_row(app, ui, 260.0) { - app.ui.live.home_join = Some(session); - } - if let Some(session) = app.ui.live.home_join { - crate::live::join_status(app, ui, &t, session); - } + let main = Rect::from_min_max(pos2(left.max.x, rect.min.y), rect.max); + ui.scope_builder(egui::UiBuilder::new().max_rect(main), |ui| { + egui::ScrollArea::vertical().id_salt("home").auto_shrink([false, false]).show(ui, |ui| { + egui::Frame::NONE.inner_margin(egui::Margin::symmetric(40, 24)).show(ui, |ui| { + ui.spacing_mut().item_spacing = vec2(GAP, 0.0); + match app.ui.home_view.section { + Section::Templates => templates(app, ui, &t), + _ => recents(app, ui, &t), + } + }); + }); }); - opened_join(app); - // Open documents and recent files. - let mut y = join.max.y + 22.0; - ui.painter().text(pos2(main.min.x, y), Align2::LEFT_TOP, tr("Open files"), semibold(12.0), t.text_secondary); - y += 28.0; + }); +} + +fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) { + let mut y = left.min.y + 20.0; + icons::paint(ui.painter(), Rect::from_min_size(pos2(left.min.x + 16.0, y), vec2(20.0, 20.0)), Icon::Logo, ACCENT); + ui.painter().text(pos2(left.min.x + 44.0, y + 10.0), Align2::LEFT_CENTER, "FigCraft", semibold(14.0), t.text); + y += 48.0; + for (section, icon, label) in Section::ALL { + let r = Rect::from_min_size(pos2(left.min.x + 8.0, y), vec2(SIDEBAR - 16.0, 32.0)); + let resp = ui.interact(r, ui.id().with(("home_section", label)), Sense::click()); + let current = app.ui.home_view.section == section; + a11y::tab(&resp, tr(label), current); + if current || resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(5), if current { t.selected_row } else { t.hover }); + } + focus_ring(ui, t, r, &resp, 5); + icons::paint(ui.painter(), Rect::from_min_size(pos2(r.min.x + 10.0, r.center().y - 8.0), vec2(16.0, 16.0)), icon, t.icon); + ui.painter().text(pos2(r.min.x + 36.0, r.center().y), Align2::LEFT_CENTER, tr(label), regular(12.0), t.text); + if resp.clicked() { + app.ui.home_view.section = section; + } + y += 34.0; + } +} + +/// A ring around a card or row that has the keyboard. +fn focus_ring(ui: &Ui, t: &Tokens, rect: Rect, resp: &Response, radius: u8) { + if resp.has_focus() { + ui.painter().rect_stroke(rect, CornerRadius::same(radius), Stroke::new(2.0, t.focus), StrokeKind::Outside); + } +} + +/// A card's background and border (lit on hover, ringed with the keyboard on it). +fn card_frame(ui: &Ui, t: &Tokens, rect: Rect, resp: &Response) { + ui.painter().rect_filled(rect, CornerRadius::same(8), if resp.hovered() { t.hover } else { t.panel }); + ui.painter().rect_stroke(rect, CornerRadius::same(8), Stroke::new(1.0, t.border), StrokeKind::Inside); + focus_ring(ui, t, rect, resp, 8); +} + +fn heading(ui: &mut Ui, t: &Tokens, text: &str) { + ui.label(egui::RichText::new(text).font(semibold(18.0)).color(t.text)); + ui.add_space(24.0); +} + +fn subheading(ui: &mut Ui, t: &Tokens, text: &str) { + ui.add_space(28.0); + ui.label(egui::RichText::new(text).font(semibold(12.0)).color(t.text_secondary)); + ui.add_space(12.0); +} + +/// A card with an icon tile, a title and a line under it; true when chosen. +fn action_card(ui: &mut Ui, t: &Tokens, icon: Icon, title: &str, sub: &str) -> bool { + let (r, mut resp) = ui.allocate_exact_size(vec2(260.0, 72.0), Sense::click()); + a11y::button(&resp, title); + // One line each inside the card; a long translation ends with an ellipsis, in full on hover. + let text_w = r.width() - 72.0; + let (title_shown, sub_shown) = (widgets::elide_font(ui, title, text_w, semibold(12.0)), widgets::elide(ui, sub, text_w)); + if title_shown != title || sub_shown != sub { + resp = resp.on_hover_text(format!("{title}\n{sub}")); + } + card_frame(ui, t, r, &resp); + let ir = Rect::from_min_size(pos2(r.min.x + 16.0, r.center().y - 16.0), vec2(32.0, 32.0)); + ui.painter().rect_filled(ir, CornerRadius::same(6), ACCENT); + icons::paint(ui.painter(), ir.shrink(8.0), icon, Color32::WHITE); + ui.painter().text(pos2(r.min.x + 60.0, r.center().y - 8.0), Align2::LEFT_CENTER, title_shown, semibold(12.0), t.text); + ui.painter().text(pos2(r.min.x + 60.0, r.center().y + 9.0), Align2::LEFT_CENTER, sub_shown, regular(11.0), t.text_secondary); + resp.clicked() +} + +fn recents(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { + heading(ui, t, tr("Recents")); + let (mut new_file, mut open) = (false, false); + ui.horizontal(|ui| { + new_file = action_card(ui, t, Icon::Plus, tr("New design file"), tr("Start from a blank canvas")); + open = action_card(ui, t, Icon::Page, tr("Open file"), tr("Open .figcraft, .svg or images")); + }); + if new_file { + app.run("file.new", json!({})); + app.ui.home = false; + } + if open { + app.open_dialog(); + } + // Join a file someone shared: the join field, and what the join is doing. + subheading(ui, t, tr("Join with a code")); + ui.scope(|ui| { + ui.set_max_width(540.0); + if let Some(session) = crate::live::join_row(app, ui, 260.0) { + app.ui.live.home_join = Some(session); + } + if let Some(session) = app.ui.live.home_join { + crate::live::join_status(app, ui, t, session); + } + }); + opened_join(app); + if !app.session.prefs.getting_started_dismissed { + ui.add_space(28.0); + getting_started(app, ui, t); + } + if !app.session.docs.is_empty() { + subheading(ui, t, tr("Open files")); let mut switch = None; for (i, d) in app.session.docs.iter().enumerate() { - let r = Rect::from_min_size(pos2(main.min.x, y), vec2(main.width().min(640.0), 36.0)); - let resp = ui.interact(r, ui.id().with(("open", d.uid)), Sense::click()); let detail = d.path.as_deref().unwrap_or_default(); let row = FileRow { mark: Some((Icon::Logo, ACCENT)), name: &d.doc.name, ink: t.text, detail, detail_size: 11.0, detail_ink: t.text_secondary }; - file_row(ui, &t, r, &resp, &row); - if resp.clicked() { + if listed(ui, t, ui.id().with(("open", d.uid)), 36.0, &row) { switch = Some(i); } - y += 38.0; } if let Some(i) = switch { app.run("doc.switch", json!({"index": i})); app.ui.home = false; } - y += 16.0; - let recents = app.session.prefs.recent_files.clone(); - if !recents.is_empty() { - ui.painter().text(pos2(main.min.x, y), Align2::LEFT_TOP, tr("Recent files"), semibold(12.0), t.text_secondary); - y += 28.0; - let mut open_path = None; - for p in recents.iter().take(12) { - let r = Rect::from_min_size(pos2(main.min.x, y), vec2(main.width().min(640.0), 32.0)); - let resp = ui.interact(r, ui.id().with(("recent", p.as_str())), Sense::click()); - let name = std::path::Path::new(p).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| p.clone()); - file_row( - ui, - &t, - r, - &resp, - &FileRow { mark: None, name: &name, ink: t.text, detail: p, detail_size: 10.0, detail_ink: t.text_tertiary }, - ); - if resp.clicked() { - open_path = Some(p.clone()); - } - y += 34.0; - } - if let Some(p) = open_path - && app.run("file.open", json!({"path": p})).is_some() - { - app.ui.home = false; - } + } + let recents = app.session.prefs.recent_files.clone(); + if recents.is_empty() { + // A first run: nothing to reopen yet, so offer something to start from. + subheading(ui, t, tr("Start from a template")); + let shown: Vec<&Template> = TEMPLATES.iter().take(FIRST_RUN).collect(); + template_grid(app, ui, t, &shown); + ui.add_space(12.0); + if widgets::button(ui, tr("See all templates"), false, true).clicked() { + app.ui.home_view.section = Section::Templates; } - }); + return; + } + subheading(ui, t, tr("Recent files")); + let mut open_path = None; + for p in recents.iter().take(12) { + let name = std::path::Path::new(p).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| p.clone()); + let row = FileRow { mark: None, name: &name, ink: t.text, detail: p, detail_size: 10.0, detail_ink: t.text_tertiary }; + if listed(ui, t, ui.id().with(("recent", p.as_str())), 32.0, &row) { + open_path = Some(p.clone()); + } + } + if let Some(p) = open_path + && app.run("file.open", json!({"path": p})).is_some() + { + app.ui.home = false; + } } /// The file joined from the home screen (a code, or one of your online files) opened in a tab of @@ -196,3 +292,199 @@ fn opened_join(app: &mut FigApp) { _ => {} } } + +/// The Getting started card: short tips with their shortcuts as the keymap has them now, and a +/// button that puts the card away (Help ▸ Getting started brings it back). +fn getting_started(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { + let width = ui.available_width().min(3.0 * CARD_W + 2.0 * GAP); + let columns = if width >= 720.0 { 3 } else { 1 }; + let (cell_w, cell_h) = ((width - 32.0) / columns as f32, 62.0); + let rows = TIPS.len().div_ceil(columns); + let (rect, _) = ui.allocate_exact_size(vec2(width, 52.0 + rows as f32 * cell_h + 8.0), Sense::hover()); + a11y::container_bounds(ui, rect); + ui.painter().rect_filled(rect, CornerRadius::same(8), t.field); + ui.painter().text(rect.min + vec2(16.0, 24.0), Align2::LEFT_CENTER, tr("Getting started"), semibold(13.0), t.text); + let close = Rect::from_min_size(pos2(rect.max.x - 36.0, rect.min.y + 12.0), vec2(24.0, 24.0)); + if widgets::icon_button_at( + ui, + ui.id().with("tips_close"), + close, + Icon::Close, + tr("Hide these tips (Getting started in the Help menu shows them again)"), + ) { + app.run("help.gettingStarted", json!({"show": false})); + app.services.save_prefs(&app.session.prefs); + } + for (i, (title, line, command)) in TIPS.into_iter().enumerate() { + let cell = Rect::from_min_size( + rect.min + vec2(16.0 + (i % columns) as f32 * cell_w, 48.0 + (i / columns) as f32 * cell_h), + vec2(cell_w - 16.0, cell_h), + ); + let badge = Rect::from_min_size(cell.min + vec2(0.0, 2.0), vec2(20.0, 20.0)); + ui.painter().rect_filled(badge, CornerRadius::same(10), t.panel); + ui.painter().text(badge.center(), Align2::CENTER_CENTER, (i + 1).to_string(), semibold(10.0), t.text_secondary); + let x = cell.min.x + 30.0; + let title_end = ui.painter().text(pos2(x, cell.min.y + 12.0), Align2::LEFT_CENTER, tr(title), semibold(12.0), t.text).max.x; + if let Some(key) = shortcut(command) { + ui.painter().text(pos2(title_end + 8.0, cell.min.y + 12.0), Align2::LEFT_CENTER, key, regular(11.0), t.text_tertiary); + } + let galley = lines(ui, tr(line), regular(11.0), t.text_secondary, cell.max.x - x, 2); + ui.painter().galley(pos2(x, cell.min.y + 24.0), galley, t.text_secondary); + } +} + +/// A command's shortcut as this platform writes it, as the keymap has it now. +pub(crate) fn shortcut(command: &str) -> Option { + crate::shortcuts::shortcut(command).map(|key| figcraft_engine::keymap::display(key, crate::shortcuts::platform())) +} + +/// `text` wrapped at `width`, at most `rows` lines (the last one ends with an ellipsis). +fn lines(ui: &Ui, text: &str, font: egui::FontId, color: Color32, width: f32, rows: usize) -> std::sync::Arc { + let mut job = egui::text::LayoutJob::simple(text.to_string(), font, color, width.max(1.0)); + job.wrap.max_rows = rows; + ui.fonts_mut(|f| f.layout_job(job)) +} + +fn matches(template: &Template, query: &str, kind: Option) -> bool { + let q = query.trim().to_lowercase(); + let has = |s: &str| s.to_lowercase().contains(&q); + kind.is_none_or(|k| k == template.kind) + && (q.is_empty() + || [template.name, template.description, template.kind.label()].into_iter().any(|s| has(s) || has(tr(s))) + || has(template.size)) +} + +fn templates(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { + heading(ui, t, tr("Templates")); + ui.horizontal(|ui| { + let search = widgets::text_input(ui, Id::new("home_template_search"), &mut app.ui.home_view.query, 260.0, tr("Search templates")); + a11y::text(&search, tr("Search templates"), &app.ui.home_view.query); + let labels: Vec<&str> = std::iter::once(tr("All")).chain(Kind::ALL.iter().map(|k| tr(k.label()))).collect(); + let active = app.ui.home_view.kind.and_then(|k| Kind::ALL.iter().position(|x| *x == k)).map_or(0, |i| i + 1); + ui.scope(|ui| { + ui.set_max_width((labels.len() as f32 * 124.0).min(ui.available_width())); + if let Some(i) = widgets::segmented_text(ui, &labels, active) { + app.ui.home_view.kind = i.checked_sub(1).and_then(|i| Kind::ALL.get(i).copied()); + } + }); + }); + ui.add_space(24.0); + let (query, kind) = (app.ui.home_view.query.clone(), app.ui.home_view.kind); + let shown: Vec<&Template> = TEMPLATES.iter().filter(|x| matches(x, &query, kind)).collect(); + if shown.is_empty() { + ui.add_space(24.0); + ui.label(egui::RichText::new(tr("No templates match your search")).font(semibold(13.0)).color(t.text)); + ui.add_space(6.0); + ui.label(egui::RichText::new(tr("Try another word, or look at every kind.")).font(regular(12.0)).color(t.text_secondary)); + ui.add_space(14.0); + if widgets::button(ui, tr("Show all templates"), false, true).clicked() { + app.ui.home_view.query.clear(); + app.ui.home_view.kind = None; + } + return; + } + template_grid(app, ui, t, &shown); +} + +/// Template cards in as many columns as fit; choosing one opens a new document made from it. +fn template_grid(app: &mut FigApp, ui: &mut Ui, t: &Tokens, list: &[&Template]) { + let columns = (((ui.available_width() + GAP) / (CARD_W + GAP)).floor() as usize).max(1); + let dark = ui.visuals().dark_mode; + let mut thumbs: Thumbs = ui.data(|d| d.get_temp(Id::new("template_thumbs"))).unwrap_or_default(); + let mut chosen = None; + let mut rendered = false; + for row in list.chunks(columns) { + ui.horizontal(|ui| { + for template in row { + let key = (template.id, dark); + if !thumbs.0.contains_key(&key) && ui.is_rect_visible(Rect::from_min_size(ui.cursor().min, vec2(CARD_W, CARD_H))) { + if rendered { + // One a frame keeps the screen moving; the rest follow. + ui.ctx().request_repaint(); + } else { + thumbs.0.insert(key, thumbnail(ui.ctx(), template, app.session.new_page_background())); + rendered = true; + } + } + if template_card(ui, t, template, thumbs.0.get(&key).and_then(Option::as_ref)) { + chosen = Some(*template); + } + } + }); + ui.add_space(GAP); + } + ui.data_mut(|d| d.insert_temp(Id::new("template_thumbs"), thumbs)); + if let Some(template) = chosen + && app.run("file.newFromTemplate", json!({"template": template.id, "name": tr(template.name)})).is_some() + { + app.ui.home = false; + } +} + +fn template_card(ui: &mut Ui, t: &Tokens, template: &Template, thumb: Option<&Thumb>) -> bool { + let (r, resp) = ui.allocate_exact_size(vec2(CARD_W, CARD_H), Sense::click()); + let (name, description) = (tr(template.name), tr(template.description)); + a11y::button(&resp, &format!("{name}: {description}")); + card_frame(ui, t, r, &resp); + let picture = Rect::from_min_size(r.min, vec2(CARD_W, THUMB_H)).shrink(1.0); + let top = CornerRadius { nw: 7, ne: 7, sw: 0, se: 0 }; + match thumb { + Some(thumb) => { + ui.painter().rect_filled(picture, top, thumb.page); + let size = thumb.texture.size_vec2(); + let inner = picture.shrink(10.0); + let fit = (inner.width() / size.x).min(inner.height() / size.y); + let image = Rect::from_center_size(inner.center(), size * fit); + ui.painter().image(thumb.texture.id(), image, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + None => { + ui.painter().rect_filled(picture, top, t.field); + icons::paint(ui.painter(), Rect::from_center_size(picture.center(), vec2(20.0, 20.0)), Icon::Frame, t.icon_secondary); + } + } + ui.painter().line_segment([picture.left_bottom(), picture.right_bottom()], Stroke::new(1.0, t.border)); + let (x, w) = (r.min.x + 14.0, CARD_W - 28.0); + let y = picture.max.y + 20.0; + ui.painter().text(pos2(x, y), Align2::LEFT_CENTER, widgets::elide_font(ui, name, w, semibold(12.0)), semibold(12.0), t.text); + let galley = lines(ui, description, regular(11.0), t.text_secondary, w, 2); + ui.painter().galley(pos2(x, y + 11.0), galley, t.text_secondary); + let tag = format!("{} · {}", tr(template.kind.label()), template.size); + ui.painter().text(pos2(x, r.max.y - 15.0), Align2::LEFT_CENTER, widgets::elide_font(ui, &tag, w, regular(10.0)), regular(10.0), t.text_tertiary); + resp.clicked() +} + +/// A template's picture: its first page's frames, and the page colour they sit on. +#[derive(Clone)] +struct Thumb { + texture: egui::TextureHandle, + page: Color32, +} + +/// Pictures by template and theme (the page colour follows the theme); `None`: nothing to show. +#[derive(Clone, Default)] +struct Thumbs(BTreeMap<(&'static str, bool), Option>); + +/// Build `template` and render the top of its first page to fit a card. +fn thumbnail(ctx: &egui::Context, template: &Template, background: figcraft_color::Color) -> Option { + let doc = template.build(background).ok()?; + let frames = doc.node(doc.first_page()?)?.children.clone(); + let bounds = frames.iter().filter_map(|id| figcraft_render::export_bounds(&doc, *id)).reduce(|a, b| a.union(b))?; + if !(bounds.width() > 0.0 && bounds.height() > 0.0) { + return None; + } + // A page much taller than a card shows its top, as wide as the card. + let (w, h) = (f64::from(CARD_W - 22.0), f64::from(THUMB_H - 22.0)); + let area = figcraft_geom::Rect::new(bounds.x0, bounds.y0, bounds.x1, bounds.y1.min(bounds.y0 + bounds.width() * h / w * 1.5)); + let scale = (w / area.width()).min(h / area.height()) * f64::from(ctx.pixels_per_point()); + let pix = figcraft_render::render_area(&doc, &frames, area, scale.min(1.0))?; + let image = crate::render_worker::screen_image(&pix, doc.color_profile); + let texture = ctx.load_texture(format!("template-thumb-{}", template.id), image, egui::TextureOptions::LINEAR); + Some(Thumb { texture, page: crate::theme::c32(background) }) +} + +/// Tests: the templates whose pictures are cached for the light or dark theme. +#[cfg(test)] +pub(crate) fn cached_thumbnails(ctx: &egui::Context, dark: bool) -> Vec<&'static str> { + let thumbs: Thumbs = ctx.data(|d| d.get_temp(Id::new("template_thumbs"))).unwrap_or_default(); + thumbs.0.iter().filter(|((_, d), thumb)| *d == dark && thumb.is_some()).map(|((id, _), _)| *id).collect() +} diff --git a/crates/ui-egui/src/home_tests.rs b/crates/ui-egui/src/home_tests.rs new file mode 100644 index 0000000..67d2eac --- /dev/null +++ b/crates/ui-egui/src/home_tests.rs @@ -0,0 +1,282 @@ +//! The home screen, drawn headless: the Templates grid (opened by click and by keyboard, searched, +//! filtered, its empty state, its names for screen readers, thumbnails cached per theme and no +//! frames once they are in), the first-run row (only without recent files) and the Getting +//! started card (closed, kept closed, and brought back from the Help menu). + +use egui::accesskit::Role; +use egui::{Event, Key, Modifiers, PointerButton, Pos2, Shape}; +use figcraft_engine::Session; +use figcraft_engine::templates::TEMPLATES; +use serde_json::json; + +use crate::home::{Section, cached_thumbnails}; +use crate::layers_drag_tests::Ui; +use crate::live_tests::{flat, has_text, text_center}; + +/// The app as a first run finds it: no documents, no recent files. +fn first_run() -> Ui { + Ui::new(Session::empty()) +} + +fn shapes(u: &mut Ui) -> Vec { + flat(&u.frame_output(vec![]).shapes) +} + +/// Enough frames for every thumbnail in view (they render one a frame) and what follows a click. +fn settle(u: &mut Ui) -> Vec { + for _ in 0..12 { + u.frame(vec![]); + } + shapes(u) +} + +fn click(u: &mut Ui, p: Pos2) { + for pressed in [true, false] { + u.frame(vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]); + } + u.frame(vec![]); +} + +fn press(u: &mut Ui, key: Key) { + for pressed in [true, false] { + u.frame(vec![Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }]); + } +} + +fn templates_tab(u: &mut Ui) -> Vec { + let s = shapes(u); + click(u, text_center(&s, "Templates")); + assert_eq!(u.app.ui.home_view.section, Section::Templates); + settle(u) +} + +#[test] +fn the_sidebar_has_recents_online_and_templates_and_no_placeholders() { + let mut u = first_run(); + let s = shapes(&mut u); + assert!(has_text(&s, "Recents") && has_text(&s, "Online") && has_text(&s, "Templates")); + assert!(!has_text(&s, "Drafts") && !has_text(&s, "Community")); + u.ctx.enable_accesskit(); + u.frame(vec![]); + let nodes: Vec<_> = u.frame_output(vec![]).platform_output.accesskit_update.unwrap().nodes.into_iter().map(|(_, n)| n).collect(); + let tab = |label: &str| nodes.iter().find(|n| n.role() == Role::Tab && n.label() == Some(label)).unwrap_or_else(|| panic!("no tab {label}")); + assert_eq!((tab("Recents").is_selected(), tab("Online").is_selected(), tab("Templates").is_selected()), (Some(true), Some(false), Some(false))); + // In reading order: Recents, Online, Templates. + let top = |label: &str| tab(label).bounds().unwrap().y0; + assert!(top("Recents") < top("Online") && top("Online") < top("Templates")); + // Each is clicked into, and `ui.inspect` and `ui.set {homeSection}` name them the same way. + for (label, section) in [("Online", Section::Online), ("Templates", Section::Templates), ("Recents", Section::Recents)] { + let s = shapes(&mut u); + click(&mut u, text_center(&s, label)); + assert_eq!(u.app.ui.home_view.section, section); + let inspect = u.app.inspect(); + assert_eq!(inspect["homeSection"], section.id()); + assert_eq!(inspect["online"]["open"], section == Section::Online); + assert_eq!(Section::parse(section.id()), Some(section)); + } + assert_eq!(Section::parse("drafts"), None); + // The Online section is the online files' own. + u.app.ui.home_view.section = Section::Online; + let s = settle(&mut u); + assert!(has_text(&s, "Online files") && !has_text(&s, "Getting started") && !has_text(&s, "Start from a template")); +} + +#[test] +fn the_templates_grid_shows_every_template_and_a_click_opens_a_new_document() { + let mut u = first_run(); + let s = templates_tab(&mut u); + for t in TEMPLATES { + assert!(has_text(&s, t.name), "{}", t.name); + } + assert!(has_text(&s, "Apps · 390 × 844"), "the kind and size tag"); + assert_eq!(cached_thumbnails(&u.ctx, false).len(), TEMPLATES.len(), "every card has its picture"); + click(&mut u, text_center(&s, "Dashboard")); + assert_eq!(u.app.session.docs.len(), 1); + let ds = u.app.session.ds().unwrap(); + assert_eq!(ds.doc.name, "Dashboard"); + assert!(ds.path.is_none() && !ds.is_dirty(), "a new, untitled-on-disk document"); + assert!(!u.app.ui.home, "and the editor shows it"); + // Again: another document, the first untouched. + let before = u.app.session.docs[0].doc.clone(); + u.app.ui.home = true; + let s = settle(&mut u); + // (Its tag: the name is also on the open document's tab now.) + click(&mut u, text_center(&s, "Apps · 1440 × 960")); + assert_eq!(u.app.session.docs.len(), 2); + assert!(std::sync::Arc::ptr_eq(&u.app.session.docs[0].doc, &before)); +} + +#[test] +fn cards_are_reached_with_tab_named_for_screen_readers_and_opened_with_enter() { + let mut u = first_run(); + templates_tab(&mut u); + u.ctx.enable_accesskit(); + u.frame(vec![]); + let nodes: Vec<_> = u.frame_output(vec![]).platform_output.accesskit_update.unwrap().nodes.into_iter().map(|(_, n)| n).collect(); + for t in TEMPLATES { + let name = format!("{}: {}", t.name, t.description); + assert!(nodes.iter().any(|n| n.role() == Role::Button && n.label() == Some(name.as_str())), "no button named {name:?}"); + } + assert!(nodes.iter().any(|n| n.label() == Some("Search templates"))); + // Tab walks the controls; the first card it reaches opens with Enter. + let mut reached = false; + for _ in 0..40 { + press(&mut u, Key::Tab); + if crate::a11y::focused_name(&u.ctx).starts_with("Mobile app: ") { + reached = true; + break; + } + } + assert!(reached, "Tab never reached the first card"); + press(&mut u, Key::Enter); + u.frame(vec![]); + assert_eq!(u.app.session.doc().unwrap().name, "Mobile app"); + assert!(!u.app.ui.home); +} + +#[test] +fn search_and_kind_filter_the_grid_and_nothing_found_offers_a_way_back() { + let mut u = first_run(); + templates_tab(&mut u); + u.app.ui.home_view.query = "icon".into(); + let s = settle(&mut u); + assert!(has_text(&s, "Icon set starter") && !has_text(&s, "Dashboard")); + // Descriptions and kinds match too, whatever the case. + u.app.ui.home_view.query = " PROTOTYPE ".into(); + let s = settle(&mut u); + assert!(has_text(&s, "Mobile app") && !has_text(&s, "Landing page")); + u.app.ui.home_view.query.clear(); + let s = settle(&mut u); + click(&mut u, text_center(&s, "Websites")); + let s = settle(&mut u); + assert!(has_text(&s, "Landing page") && !has_text(&s, "Mobile app") && !has_text(&s, "Icon set starter")); + u.app.ui.home_view.query = "zzz".into(); + let s = settle(&mut u); + assert!(has_text(&s, "No templates match your search")); + assert!(TEMPLATES.iter().all(|t| !has_text(&s, t.name))); + click(&mut u, text_center(&s, "Show all templates")); + let s = settle(&mut u); + assert!(u.app.ui.home_view.query.is_empty() && u.app.ui.home_view.kind.is_none()); + assert!(TEMPLATES.iter().all(|t| has_text(&s, t.name))); +} + +#[test] +fn typing_in_the_search_field_filters() { + let mut u = first_run(); + let s = templates_tab(&mut u); + let field = text_center(&s, "Search templates"); + click(&mut u, field); + u.frame(vec![Event::Text("slides".into())]); + let s = settle(&mut u); + assert_eq!(u.app.ui.home_view.query, "slides"); + assert!(has_text(&s, "Presentation slides") && !has_text(&s, "Dashboard")); + assert!(u.app.session.docs.is_empty(), "typing opens nothing"); +} + +#[test] +fn thumbnails_are_cached_per_theme() { + let mut u = first_run(); + templates_tab(&mut u); + assert_eq!(cached_thumbnails(&u.ctx, false).len(), TEMPLATES.len()); + assert!(cached_thumbnails(&u.ctx, true).is_empty()); + u.app.session.prefs.theme = "dark".into(); + settle(&mut u); + assert_eq!(cached_thumbnails(&u.ctx, true).len(), TEMPLATES.len(), "the dark theme's pictures"); + assert_eq!(cached_thumbnails(&u.ctx, false).len(), TEMPLATES.len(), "the light ones are kept"); +} + +#[test] +fn the_home_screen_draws_no_frames_once_its_thumbnails_are_in() { + let (mut h, _) = crate::live_tests::Harness::new(); + h.app.ui.home = true; + for tab in [Section::Templates, Section::Online, Section::Recents] { + h.app.ui.home_view.section = tab; + h.frames(60); + let (_, delay) = h.frame(vec![]); + assert!(delay >= std::time::Duration::from_secs(1), "{tab:?} asks for a frame in {delay:?}: {:?}", h.ctx.repaint_causes()); + } + assert_eq!(cached_thumbnails(&h.ctx, false).len(), TEMPLATES.len()); +} + +#[test] +fn a_first_run_offers_templates_on_recents_and_recent_files_replace_them() { + let mut u = first_run(); + let s = settle(&mut u); + assert!(has_text(&s, "Start from a template")); + let offered = TEMPLATES.iter().filter(|t| has_text(&s, t.name)).count(); + assert_eq!(offered, 4); + assert!(!has_text(&s, "Recent files") && !has_text(&s, "Open files")); + click(&mut u, text_center(&s, "See all templates")); + assert_eq!(u.app.ui.home_view.section, Section::Templates); + // From the row: a click opens it. + u.app.ui.home_view.section = Section::Recents; + let s = settle(&mut u); + click(&mut u, text_center(&s, "Landing page")); + assert_eq!(u.app.session.doc().unwrap().name, "Landing page"); + // With a recent file, the list shows in the row's place. + u.app.ui.home = true; + u.app.session.prefs.recent_files = vec!["/tmp/ridgeline.figcraft".into()]; + let s = settle(&mut u); + assert!(has_text(&s, "Recent files") && has_text(&s, "ridgeline.figcraft")); + assert!(!has_text(&s, "Start from a template") && !has_text(&s, "See all templates")); + assert!(has_text(&s, "Open files"), "the document opened from the template is listed"); +} + +#[test] +fn the_getting_started_card_closes_stays_closed_and_comes_back_from_help() { + let mut u = first_run(); + let s = settle(&mut u); + assert!(has_text(&s, "Getting started")); + for tip in ["Draw a frame", "Add auto layout", "Make a component", "Prototype", "Share with a code", "Export"] { + assert!(has_text(&s, tip), "{tip}"); + } + // Shortcuts come from the keymap. + let key = crate::home::shortcut("layout.add").unwrap(); + assert!(has_text(&s, &key), "the auto layout shortcut {key:?}"); + // The close button is named, and closes the card. + u.ctx.enable_accesskit(); + u.frame(vec![]); + let nodes = u.frame_output(vec![]).platform_output.accesskit_update.unwrap().nodes; + let close = nodes.iter().map(|(_, n)| n).find(|n| n.label().is_some_and(|l| l.starts_with("Hide these tips"))).expect("a named close button"); + let b = close.bounds().unwrap(); + click(&mut u, Pos2::new(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32)); + assert!(u.app.session.prefs.getting_started_dismissed); + let s = settle(&mut u); + assert!(!has_text(&s, "Getting started") && !has_text(&s, "Draw a frame")); + assert!(has_text(&s, "Start from a template"), "the rest of the screen stays"); + // The preference is kept with the others, so it stays closed next time. + let kept: figcraft_engine::Prefs = serde_json::from_value(serde_json::to_value(&u.app.session.prefs).unwrap()).unwrap(); + assert!(kept.getting_started_dismissed); + // Help ▸ Getting started: back on the home screen, from wherever. + u.app.run("file.new", json!({})); + u.app.ui.home = false; + u.app.ui.home_view.section = Section::Templates; + crate::menus::invoke(&mut u.app, "help.gettingStarted"); + assert!(u.app.ui.home && u.app.ui.home_view.section == Section::Recents && !u.app.session.prefs.getting_started_dismissed); + assert!(has_text(&settle(&mut u), "Getting started")); +} + +#[test] +fn the_file_menu_item_shows_the_templates() { + let mut u = Ui::new(Session::new()); + u.app.ui.home = false; + crate::menus::invoke(&mut u.app, "file.newFromTemplate"); + assert!(u.app.ui.home && u.app.ui.home_view.section == Section::Templates); + assert_eq!(u.app.session.docs.len(), 1, "nothing opens until one is chosen"); + assert!(has_text(&settle(&mut u), "Wireframe kit")); +} + +#[test] +fn tab_walks_the_sidebar_in_order_before_a_sections_controls() { + let mut u = first_run(); + let mut names = Vec::new(); + for _ in 0..40 { + press(&mut u, Key::Tab); + let name = crate::a11y::focused_name(&u.ctx); + if !name.is_empty() && names.last() != Some(&name) { + names.push(name); + } + } + let at = |name: &str| names.iter().position(|n| n == name).unwrap_or_else(|| panic!("Tab never reached {name:?}: {names:?}")); + assert!(at("Recents") < at("Online") && at("Online") < at("Templates") && at("Templates") < at("Open file"), "{names:?}"); +} diff --git a/crates/ui-egui/src/i18n_tests.rs b/crates/ui-egui/src/i18n_tests.rs index 398097a..768434e 100644 --- a/crates/ui-egui/src/i18n_tests.rs +++ b/crates/ui-egui/src/i18n_tests.rs @@ -135,6 +135,8 @@ fn dynamic_labels() -> Vec<&'static str> { v.extend(Group::ALL.iter().map(|g| g.label())); v.extend(GESTURES.iter().flat_map(|(_, label, mac, other)| [*label, *mac, *other])); v.extend(DATA_LABELS); + // The starter templates' names, descriptions and kinds. + v.extend(figcraft_engine::templates::TEMPLATES.iter().flat_map(|t| [t.name, t.description, t.kind.label()])); v } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 908c56b..9d85210 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -81,6 +81,8 @@ mod field_tests; #[cfg(test)] mod frame_label_tests; #[cfg(test)] +mod home_tests; +#[cfg(test)] mod idle_tests; #[cfg(test)] mod layers_behaviour_tests; @@ -289,6 +291,8 @@ pub struct UiState { /// An opacity digit just typed and when (a second one soon after makes an exact percentage). pub opacity_digit: Option<(u32, f64)>, pub home: bool, + /// The home screen's section, and the Templates search and filter. + pub home_view: home::Home, /// How the window's title bar is drawn (the desktop app sets it when it opens the window). pub title_bar: TitleBar, /// Every icon on one screen, for reviewing the set (control channel `ui.set {iconGallery}`). @@ -402,6 +406,7 @@ impl Default for UiState { shortcuts_query: String::new(), opacity_digit: None, home: false, + home_view: home::Home::default(), title_bar: TitleBar::Native, icon_gallery: false, toast: None, @@ -657,6 +662,15 @@ impl FigApp { self.ui.libraries = true; } "home" => self.ui.home = true, + "home.templates" => { + self.ui.home = true; + self.ui.home_view.section = home::Section::Templates; + } + "home.gettingStarted" => { + self.ui.home = true; + self.ui.home_view.section = home::Section::Recents; + self.services.save_prefs(&self.session.prefs); + } "dialog.saveAs" => self.save_as_dialog(), "dialog.colorProfile" => self.ui.color_profile = self.session.doc().ok().map(|d| d.color_profile), "present" => { @@ -1116,6 +1130,7 @@ impl FigApp { "home": self.ui.home, "online": online::inspect(self), "tabs": self.session.docs.iter().map(|d| json!({"name": d.doc.name, "live": tabs::live_mark(self, d.uid).map(|shared| if shared { "shared" } else { "private" })})).collect::>(), + "homeSection": self.ui.home_view.section.id(), "dark": self.ui.dark, "focus": {"region": self.ui.nav.region.id(), "keyboard": self.ui.nav.keyboard}, "reduceMotion": self.reduce_motion(), diff --git a/crates/ui-egui/src/online.rs b/crates/ui-egui/src/online.rs index 0580118..261424c 100644 --- a/crates/ui-egui/src/online.rs +++ b/crates/ui-egui/src/online.rs @@ -28,8 +28,6 @@ const ROW: f32 = 36.0; /// UI-only state of the Online section. #[derive(Default)] pub struct OnlineUi { - /// The Online section shows on the home screen (else Recents). - pub open: bool, /// The frame the section last drew in: it asks the servers when it shows again after a gap. drawn: Option, /// Remove from server… was chosen for this file (server, id, name): its confirmation shows. @@ -318,7 +316,7 @@ pub fn inspect(app: &FigApp) -> Value { .map(|f| json!({"file": f.file, "name": f.name, "detail": detail(f), "shared": f.shared.is_some(), "state": f.state, "hasOwnerToken": true})) .collect(); json!({ - "open": app.ui.online.open, + "open": app.ui.home_view.section == crate::home::Section::Online, "files": files, "checking": app.session.waiting_for(FILES), "problems": app.session.live.servers.len(), diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index ee0b150..1e6e38a 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -190,7 +190,12 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { ui.painter().circle_filled(hr.center(), 16.0, t.panel); ui.painter().text(hr.center(), Align2::CENTER_CENTER, "?", crate::theme::regular(15.0), t.text); crate::right::menu_popup(&hresp).show(|ui| { - for (l, c) in [(tr("Keyboard shortcuts"), "help.shortcuts"), (tr("Help page"), "help.page"), (tr("About FigCraft"), "help.about")] { + for (l, c) in [ + (tr("Getting started"), "help.gettingStarted"), + (tr("Keyboard shortcuts"), "help.shortcuts"), + (tr("Help page"), "help.page"), + (tr("About FigCraft"), "help.about"), + ] { if crate::right::menu_item(ui, l, crate::shortcuts::shortcut(c).unwrap_or("")) { app.run(c, json!({})); ui.close();