Skip to content

Photon fr

Edgar Mesquita edited this page Oct 14, 2026 · 1 revision

Photon : le moteur GPU natif

🌐 Cette page en : English · Português · Español · Français · Deutsch

Photon est le moteur de rendu GPU propre à eQuantic.UI pour les cibles natives : un renderer écrit de zéro, inspiré d'Impeller, qui dessine l'arbre de composants pixel par pixel sur le GPU (Metal sur macOS et iOS, Vulkan sur Android), sans étage Skia et sans WebView. C'est la deuxième cible de réalisation de l'architecture de composants à écriture unique : les mêmes composants C# qui s'abaissent en DOM et CSS sur le web sont rastérisés nativement par Photon.

La piste native est expérimentale en 1.0

1.0 promet la piste web : une app écrite contre elle continue de compiler et de répondre pareil tout au long de la ligne 1.x. La piste native n'est pas encore promise (Depuis 0.2.0-preview.64). Ses assemblages, chaque eQuantic.UI.Native.*, portent le [Experimental("EQX0001")] de .NET, et leur API peut changer dans une version mineure (#324).

Une app construite avec eQuantic.UI.Sdk.Native n'écrit rien pour cela. Choisir le SDK natif c'est choisir la piste, donc le SDK supprime EQX0001, et une publication le dit une fois. Un projet qui n'est pas une app Photon et utilise un assemblage natif casse sa compilation avec EQX0001 jusqu'à ce qu'il dise, dans son propre projet, qu'il le veut :

<NoWarn>$(NoWarn);EQX0001</NoWarn>

Les composants qu'une app Photon place sont ceux du contrat, les mêmes que le web dessine. Ce que la promesse ne couvre pas encore, c'est la façon dont la piste native les dessine, et l'API propre de la piste.

Pourquoi propriétaire, pourquoi pas de Skia

Posséder un étage natif veut dire posséder toute la pile de rendu : des backends Metal et Vulkan directs avec des shaders précompilés, pas une enveloppe sur Skia. Cela achète :

  • Des performances prévisibles : registre fixe d'états de pipeline, zéro compilation de shaders à l'exécution (la source de saccades qu'Impeller a été construit pour tuer). Les caches de pipeline persistent entre les lancements sur les deux backends.
  • Une surface minuscule : rendre une UI a besoin d'une poignée de primitives, pas d'une bibliothèque 2D générale.
  • Un seul modèle normatif : les mêmes mathématiques, vérifiables au pixel, sur la référence CPU et sur chaque backend GPU.

L'architecture d'un coup d'œil

C# components (write-once, eQuantic.UI.Primitives vocabulary)
        │  Build(ComponentContext)          // pure, token-based, mode-free
        ▼
eQuantic.UI.Native.Framework                // C# flex layout engine (spec A2)
        ▼
eQuantic.UI.Native.Components               // PhotonRealizer: abstract tree → display list
        ▼
eQuantic.UI.Native.Engine                   // geometry, color model, Sdf.cs (NORMATIVE), DisplayList
        ▼
IRenderBackend
   ├── eQuantic.UI.Native.Engine.Reference  // scalar CPU rasterizer (golden ground truth, never shipped)
   ├── eQuantic.UI.Native.Engine.Metal      // Apple GPUs (macOS, iOS)
   └── eQuantic.UI.Native.Engine.Vulkan     // Android

eQuantic.UI.Native.Shell.MacOS / .iOS / .Android / .Windows   // the window/host per platform

Le cœur normatif

  • Sdf.cs est une spécification, pas une bibliothèque : distance signée de rectangle arrondi par coin, trait centré (|d| − w/2), rampe de couverture d'anti-aliasing d'un pixel. Chaque rastériseur, la référence CPU et les shaders de fragment du GPU pareillement, implémente exactement ces formules.
  • Modèle de couleur : écriture en sRGB → mélange linéaire prémultiplié → stockage en sRGB, en miroir du comportement du matériel GPU avec des cibles de rendu sRGB.
  • Listes d'affichage : enregistrements DrawCommand plats et sans tas, neuf sortes : Clear, FillRRect, StrokeRRect, ShadowRRect (l'ombre analytique de §05), BeginLayer/EndLayer (opacité de groupe), Texture (couverture A8 ou images RGBA8), BackdropBlur (verre dépoli) et FillAnnularSector (la tranche d'anneau, depuis 0.2.0-preview.45) ; peintures unies, linéaires à deux arrêts et radiales elliptiques ; transformations 2D cuites et découpes en espace écran.

Le harnais d'images golden

Chaque scène se rend par un backend dans une surface, relit les pixels sRGB et les compare à des PNG golden commités (codec sans dépendance, flux de régénération avec EQ_UPDATE_GOLDENS=1, tolérance de ±2, artefacts d'échec avec des différences amplifiées). Le catalogue de scènes est partagé : le backend de Référence épingle les golden, les backends GPU lancent le même catalogue pour la parité, et la parité GPU↔CPU tient sur tout le catalogue partagé dans la tolérance golden, sur Metal et sur Vulkan.

Les backends GPU sont pilotés par des liaisons typées et minces d'objc_msgSend et de P-Invoke : aucun framework de liaison, aucun shim C, zéro paquet tiers.

Rendu des composants

Le réalisateur natif dessine du vrai habillage de composant (remplissages résolus par token, bordures à l'intérieur, rayons par coin) :

Sous-système État
Formes, bordures, dégradés, transformations, mélange, découpe ✅ Complet, testé au golden
Mise en page (flex + wrap, grille, piles, adaptatif, troncature) ✅ Moteur en C#
Ombres (élévation §05) ✅ ShadowCoverage analytique, les deux backends
Texte ✅ RÉEL. Primitive Texture (couverture A8 × teinte) + ITextRasterizer de plateforme ; macOS = CoreText (frameworks du système seulement, pas de HarfBuzz ni de FreeType là où la plateforme a un moteur de texte). Un seul moteur mesure ET rastérise, donc les coupures de ligne s'accordent par construction. Deux fontes : la proportionnelle du système et sa monospace (TypeStyle.Mono). Aucun glyphe n'est jamais coupé : le bitmap est dimensionné depuis la boîte d'ENCRE de la ligne (CTLineGetImageBounds, pas les métriques déclarées de la police, puisque la queue d'un g profond passe au-delà) et rapporte de combien au-dessus de la boîte de ligne il a dû aller, donc le réalisateur relève le dessin d'exactement cela et la boîte de ligne atterrit quand même là où la mise en page l'a mise
Icônes ✅ RÉEL. Analyseur de tracés SVG en C# pur (jeu complet de commandes, arcs par F.6.5) + rastériseur CoreGraphics, la même primitive Texture
Opacité de groupe / transformations ✅ Couches du moteur
Compositeur de défilement ✅ Décalages indexés par chemin, épinglage Pinned réel (vertical)
Mouvement ✅ Mouvement en boucle, entrée et sortie de Presence avec relecture d'instantané de commandes, et chaque canal de style glisse, voir plus bas
Dessin personnalisé ✅ Canvas : l'app peint les formes propres du moteur dans sa boîte, et le pointeur arrive dans ses coordonnées
Gestes ✅ Chaîne de survol, pression avec annulation par jeu, glisser pour écarter (suivre + glisser)
Surcouches ancrées ✅ Couches de surcouche synthétiques positionnées depuis des limites d'ancre absolues
Images ✅ RÉEL. Décodées par la plateforme (CoreGraphicsImageLoader sur les shells Apple) en textures RGBA8 en sRGB direct, dessinées par la même primitive Texture que le texte et les icônes

Une phrase se dessine suite par suite

Depuis 0.2.0-preview.28

Un paragraphe riche (Text.Spans) se rastérisait autrefois comme UN bloc dans le style de base, donc le mot en gras, la suite de code en ligne, le terme en italique et le lien au milieu de la phrase se dissolvaient tous en prose plate, la plus large divergence d'écriture unique qui restait, puisque le web émet un span par suite depuis le jour où les suites ont existé. L'unité est désormais un FRAGMENT, une suite sur une ligne, parce que c'est la seule pièce avec un rectangle et que les deux moitiés en ont besoin : chaque fragment se rastérise dans son propre style (graisse, mono, italique, sa propre taille), et le rectangle d'un fragment lié est une région de contact depuis laquelle le shell navigue, donc les liens en ligne sont pressables sur Photon, ce qui ferme le point de feuille de route qui les nommait.

Un paragraphe aligne ses lignes

Text.Align était honoré par le réalisateur web et IGNORÉ par Photon, découvert sur un véritable écran d'accueil : un paragraphe centré de deux lignes se rendait collé à gauche, sous un titre qui n'avait l'air correct que parce qu'UNE ligne se centre par son conteneur.

Ce n'était pas un simple passage, parce que chaque service de texte coupe son raster au plus près de la ligne la plus longue et qu'il n'y a pas de boîte dans laquelle aligner. Décaler le raster fini de (boîte − raster) / 2 centre le BLOC, et pour un paragraphe dont la ligne la plus longue remplit déjà sa boîte, c'est un décalage d'à peu près zéro tandis que les lignes courtes restent à gauche, exactement le cas qui fait mal. L'alignement est par LIGNE. Le rastériseur le prend, les trois services placent chaque ligne d'après lui, et les barres de remplacement sans affichage placent leurs rectangles par la même arithmétique, et c'est ce qui le rend assertable sans aucun service de texte de plateforme dans la boucle.

Deux conséquences à connaître quand vous l'utilisez :

  • Un Text de Photon prend la largeur que son parent décide, comme le fait un bloc sur le web et comme le fait celui de Flutter sous une contrainte serrée : une Box avec une largeur, une Row ou une Column qui étire ses enfants, la page elle-même. Ses lignes s'alignent sur cette largeur, la plus longue comprise. Là où rien n'en décide une (le long d'une Row, dans un bouton), le texte est la largeur de son contenu, donc le bloc dans lequel une ligne se centre est la ligne la plus longue du paragraphe, et le paragraphe dans son ensemble est placé par son conteneur. Centrer le bloc et centrer ses lignes sont deux travaux différents, et une étiquette de deux lignes dans une forme (les nœuds de Mermaid, par exemple) veut les deux.
  • Un paragraphe riche s'aligne aussi, et ses pièces bougent avec la ligne plutôt qu'avec le raster, donc une suite liée reste pressable là où elle est dessinée. Ce sont deux calculs séparés dans le code et ils passent par une seule fonction exprès : un lien qui bouge à l'écran et pas dans le test de contact est un lien qui cesse de fonctionner.

L'interaction est branchée : PhotonHost tient une racine retenue, SetState invalide, les touches se répartissent vers des régions de contact : la boîte dessinée sous le point gagne, et la cible qu'un contrôle garde au-delà de sa boîte (≥48dp sous un doigt, au moins 24dp sous un pointeur, voir Densité) ne répond que là où aucune boîte à côté d'elle n'est dessinée, ou devant la boîte dans laquelle elle se tient, donc une liste de rangées courtes donne à chaque rangée ses propres pressions ; un contrôle désactivé l'avale. Une boîte transformée prend le pointeur là où elle est dessinée, une boîte tournée dans sa forme, et une pression sur un champ, une surface de code, une feuille ou un canevas dessiné à l'échelle ou tourné atterrit sur le caret, la cellule ou le point sous elle ; une vue de défilement dessinée à l'échelle ou tournée révèle un contrôle focalisé et un caret aussi loin qu'elle le ferait sans transformation. Un champ de texte, une surface de code ou une feuille ne prend des pressions que là où elle est à l'écran dans la vue de défilement qui la montre. Le Counter natif fait touche → SetState → reconstruction de bout en bout dans les tests. PhotonHost.FocusedPath rapporte ce qui tient le clavier, y compris un champ en cours d'édition (le caret est l'indicateur de focus propre de ce champ), parce qu'un pont d'accessibilité de plateforme a besoin d'une réponse honnête, pas de la moitié de l'anneau.

Les doigts ne survolent jamais, et le survol survit à la reconstruction

Depuis 0.2.0-preview.24

Les événements de pointeur portent un PointerKind, par ÉVÉNEMENT et non par hôte, parce que la capacité de survol appartient à l'appareil qui est dans la main (un iPad gagne un pavé tactile, un Android gagne une souris). Un PointerMove étiqueté Touch pilote les glissers, les panoramiques et les sélections exactement comme une souris et ne saute que la résolution du survol ; un PressUp/PointerCancel tactile efface le survol qu'une session d'entrée mixte a laissé derrière, tandis qu'une souris garde son survol à travers un clic. Le shell macOS installe une NSTrackingArea et route MouseExited vers PointerLeave(), donc franchir le bord de la fenêtre efface le survol au lieu de le verrouiller.

L'identité du survol est le CHEMIN de mise en page, pas la référence au nœud, et la pression l'a appris d'abord : une reconstruction de composant remplace chaque instance, et un survol qui ne connaissait que l'ancienne référence peignait exactement une image. C'est aussi une CHAÎNE de chaque région sous le pointeur (le :hover de CSS correspond à chaque ancêtre, et le réalisateur natif répond à la même question que le jumeau web), et c'est ce qui laisse un Anchored ouvert par survol rester ouvert pendant que vous survolez le déclencheur qui est dedans. Pressé gagne toujours sur survolé sur la boîte qui a consommé l'échange.

La fenêtre apprend la langue de la machine

Depuis 0.2.0-preview.24

Un processus graphique lancé hors d'un terminal n'a ni LANG ni LC_*, donc .NET démarre invariant même sur une machine pt-BR. Les shells résolvent maintenant la vérité de la locale de la plateforme et l'appliquent avant la première image : AppleLocale lit NSLocale, où preferredLanguages[0] est la culture d'UI (ressources) et currentLocale la culture de format (dates, séparateurs), nativement la même paire que .NET modélise en CurrentUICulture/CurrentCulture, et AndroidLocale alimente les deux statiques depuis Locale.getDefault(). PhotonCultureController possède l'écriture des statiques (fil par défaut ET fil courant, pour que chaque fil ultérieur s'accorde) et repeint sur Apply, la forme du contrôleur de thème ; enregistré en TryAdd, donc une app qui force une culture gagne. Observer la locale de l'OS changer à l'exécution est une barrière par shell.

Mise en page : les règles CSS que le moteur vous doit

Le moteur natif est jugé contre le navigateur, parce que le jumeau web rend le même arbre là-bas :

  • Fill hérite de l'indétermination : sur un axe où le parent se dimensionne par le contenu, un enfant Fill n'a rien à remplir et passe la question ; le reste n'est pas distribué sur un tel axe.
  • Sémantique de bloc sur la largeur, et une hauteur DÉCIDÉE se transmet : une boîte dont la largeur est décidée étire un enfant CONTENEUR dimensionné automatiquement sur toute sa largeur (l'enfant div d'un div fait toute la largeur), tandis qu'un bouton, un lien ou un input épouse là : ils sont inline-block, et seul un étirement FLEX les traverse (StretchKind). La moitié de la hauteur est plus bas.

Depuis 0.2.0-preview.28 Une boîte dont la hauteur est DÉCIDÉE (fixe, remplie ou bornée) la transmet à son unique enfant, sur les deux cibles. C'est la règle qui a retiré la famille récurrente de la « barre d'outils collée en haut de sa barre à hauteur fixe », où chaque Row dans une Box de 64dp avait besoin d'un Height = Fill manuel pour atteindre le bas de sa propre barre.

  • flex-shrink avec un plancher de min-content : une ligne qui ne tient pas rétrécit ses éléments au lieu de déborder, et jamais sous le min-content de chaque élément (le mot le plus long d'un texte, la somme des enfants d'une rangée). Le plancher compte doublement parce que la découpe garde les pixels ET le pointeur : une région de pression découpée ne peut pas être pressée.
  • Reflux par Min/Max : quand une borne change l'étendue d'une boîte, son enfant se remesure contre la taille finale.

Les shells : Photon dans une vraie fenêtre

macOS (eQuantic.UI.Native.Shell.MacOS) : une NSWindow hébergeant une CAMetalLayer, zéro paquet tiers, juste des liaisons AppKit typées et minces par objc_msgSend. Le backend Metal encode chaque image directement dans le drawable de la couche (cache de pipeline par format de pixel, BGRA8_sRGB pour les fenêtres) ; PhotonHost.RenderScale rastérise au backingScaleFactor tandis que la mise en page et l'entrée restent en dp (retina). Les événements souris, défilement et clavier de l'OS sont routés dans la chaîne d'entrée ordinaire, donc les visuels de pression, les diffs de survol et les menus ancrés se comportent exactement comme dans les tests. Du vrai texte San Francisco et de vraies icônes de pack se rendent à l'écran. Essayez : dotnet run --project samples/PhotonDesktop. Deux modes sans affichage n'ont besoin d'aucune fenêtre : --Photon:MaxFrames 120 présente autant d'images et sort, et --Photon:ScreenshotPath out.png (avec --Photon:Mode Dark pour l'autre palette) rend UNE image stabilisée par le backend de référence avec les mêmes métriques CoreText. C'est l'étape de capture de la CI, et la façon de vérifier une passe de fidélité contre une remise de design. Appeler Run() hors du fil principal le dit en termes .NET au lieu de mourir dans AppKit. Redimensionnez la fenêtre librement : l'hôte adopte le viewport avec tout l'état intact.

Aucun des deux modes sans affichage ne vous dit que l'app a fonctionné, et cela vaut d'être compris avant de construire une vérification sur l'un des deux. Un composant qui lève est CONTENU : son sous-arbre est remplacé par une petite surface rouge et l'app continue, ce qu'une app livrée devrait faire. Chaque signal automatique se range du côté de la contention : les images sont présentées, le code de sortie est zéro, et le compte d'accessibilité MONTE, parce que la surface de contention a du texte à elle. Un consommateur avait noté « images présentées et sortie 0 » comme la vérification qui attrape une fenêtre noire, et a découvert une app dont la barre de titre entière levait à chaque image en passant tout cela.

Alors le résumé d'image nomme ce qui a été contenu :

[photon] frames presented: 6 — CONTAINED: TitleBar

Des noms, pas un compte : un composant qui lève lève à chaque image, donc un compte rapporte la fréquence d'images et enterre le seul fait qui valait. Il s'imprime dans les deux modes sans affichage, et --Photon:StrictRender true fait sortir le processus avec un code non nul tant que quelque chose est contenu : désactivé par défaut, parce que la contention est juste dans une app livrée et qu'un code de sortie n'est pas là où un utilisateur l'apprend, et activé pour un auto-test ou une barrière de CI, où c'est l'inverse qui est vrai.

iOS (eQuantic.UI.Native.Shell.iOS) : PhotonApp.Run est le point d'entrée ; le même moteur présente par Metal.

L'app tourne sous le cycle de vie des scènes, sans lequel une app construite avec le SDK iOS 27 ne peut pas se lancer, et sur Xcode 27 chaque app est construite avec ce SDK, quel que soit le pack .NET qui se résout. Le SDK déclare le manifeste de scènes dans l'Info.plist de l'app et le PhotonSceneDelegate du shell possède la fenêtre, donc l'app n'écrit ni délégué ni clé de plist. L'écran vit pour le processus plutôt que pour la scène : quand le système déconnecte une scène en arrière-plan et en connecte une nouvelle, l'app revient sur la page qu'elle a quittée, avec son état. En arrière-plan le shell ne dessine rien, et la première image au retour est neuve.

Android (eQuantic.UI.Native.Shell.Android) : l'app fournit CreateApp (pas de Main) ; la présentation passe par Vulkan (le backend de Référence est le repli).

L'hôte vit avec l'ACTIVITÉ, pas avec sa surface. Android détruit la surface chaque fois que l'app quitte l'écran (Accueil, une autre app devant, l'écran qui s'éteint), et le shell ne libère alors que ce qui lui présentait : la swapchain, la fenêtre, le backend. L'app revient sur l'écran où elle était, avec son état. Une rotation, un passage en mode sombre, un clavier branché ou un changement de densité ne recréent pas l'activité non plus : le lanceur généré les déclare comme les siennes, et l'hôte suit la nouvelle taille, palette et échelle. La swapchain est rebâtie à la nouvelle taille de la fenêtre et déclare ses images droites, donc le compositeur les tourne vers la dalle : un téléphone tourné avec l'app devant montre le même écran, dessiné dans le bon sens et remplissant la fenêtre.

Le retour du système

Le retour de la plateforme ferme ce qui est ouvert. Chaque couche qui s'écarte lie déjà Échap (un Dialog écartable, un BottomSheet, un Drawer, un menu ou un sélecteur ouvert, le détail compact d'un ListDetail). PhotonHost.Back() lance cette liaison, et PhotonHost.CanGoBack dit s'il y en a une. Un champ dans une couche part avec elle. Une édition en cours ne prend aucun retour, ni un anneau de focus seul, comme un TextField focalisé ne tient aucun retour dans Flutter : sur un téléphone le clavier a déjà dépensé le premier retour en descendant, en gardant le caret, et un retour qui ne prendrait alors que le caret ou un anneau se lirait comme une pression avalée.

Une app qui déclare le retour prédictif est interrogée AVANT la fin du geste : le système dessine l'écran d'accueil sous le doigt quand l'app ne s'est pas enregistrée pour le retour. Donc le shell ne s'enregistre que pendant que CanGoBack est vrai, en le relisant après chaque image ; avec rien d'ouvert, le retour est celui du système et quitte l'app, comme dans chaque app Android. L'activité du lanceur généré déclare le retour prédictif, donc l'app n'écrit rien pour cela, et Android 14 et plus lisent cette déclaration. Android 12 et avant délivrent la TOUCHE retour à la place, et Android 13 aussi, qui ne lit la déclaration que depuis l'application, là où .NET pour Android n'a aucun attribut pour l'écrire. Le shell route la touche vers le même Back(), donc une pression n'est jamais traitée deux fois.

Le clavier du téléphone

Toucher un TextEntry (un TextInput, un SearchField), une surface de code ou une feuille sur un téléphone ouvre le clavier de la plateforme, et la fin de l'édition le ferme. L'app n'écrit rien pour cela : le champ qu'elle a déjà est ce que le clavier édite.

Le clavier d'un téléphone édite un DOCUMENT plutôt que d'envoyer des touches : il lit le texte autour du caret, remplace le mot qu'il a autocorrigé, marque le mot pour lequel il suggère, prend un balayage ou une dictée. Donc les shells remettent à l'hôte l'état nouveau entier du champ, un TextEditingValue (le texte, la sélection et la composition, les noms de Flutter), par PhotonHost.UpdateEditingValue, et l'app entend le nouveau texte une fois, par OnChanged, comme quand on tape. Le mot en cours de composition est DANS la valeur et dessiné souligné, donc une recherche filtre pendant que le mot est tapé. Une app qui réécrit la valeur (un masque, un plafond de longueur) termine la composition, et le clavier en est informé. TextInputSession garde les deux au pas : après chaque image il dit si le clavier s'ouvre, repart sur un autre champ, se ferme, ou prend une valeur que l'app a changée, et il ne renvoie jamais au clavier une édition que le clavier a faite. Une surface de code et une feuille gardent leur texte pour elles et prennent les touches et le texte validé du clavier, comme sur un bureau, donc le code n'est jamais autocorrigé.

La zone du clavier est PhotonHost.ViewInsets, le MediaQuery.viewInsets de Flutter. L'arbre se met en page au-dessus d'elle, la zone sûre qu'elle couvre compte une fois, et une image mise en page plus courte que la précédente (le clavier qui monte, une rotation, un écran partagé) fait défiler le champ en cours d'édition dans ce qui reste. Là où rien ne défile (un formulaire qui ne tient pas dans ce que le clavier laisse, un téléphone sur le côté), l'écran est soulevé jusqu'à ce que le champ se voie, comme Android le fait pour une fenêtre sans rien à faire défiler, et redescend avec le clavier. Un champ dans un ScrollView est à la vue de défilement de l'amener en vue, et un que la personne fait défiler sous le clavier y reste, comme dans Flutter. Mettez un formulaire dans un ScrollView quand son haut doit rester atteignable pendant qu'un champ est édité.

La touche d'action du clavier sur une seule ligne (Terminé) soumet le champ, comme Entrée le fait, puis range le clavier avec l'édition.

  • Android : la vue de surface répond au clavier avec une connexion d'entrée sur un miroir du champ, décrit pour lui : un champ qui cache ce qui est tapé n'est ni appris ni suggéré, une seule ligne finit sur la touche Terminé du clavier, et une surface de code ou une feuille n'est pas un document. Le premier retour cache le clavier et le champ garde son caret, et une touche dessus ramène le clavier. Le retour avec le clavier baissé ferme une couche ouverte ou quitte l'app, comme il le fait depuis un champ natif (Le retour du système).
  • iOS : une vue de texte invisible au caret prend le clavier, donc UIKit fait tourner l'autocorrection, les suggestions, la dictée et la composition contre elle comme il le fait pour ses propres champs, et la barre de candidats apparaît au champ.
  • Un clavier matériel branché à un téléphone ou une tablette atteint l'hôte comme sur un bureau : les flèches, Tab, Échap et les raccourcis de l'app, et l'accent d'une touche morte se combine avec la lettre suivante (´ puis a donne á), ce qu'Android laisse à l'éditeur et que le shell fait. Ses copier, couper et coller passent par le presse-papiers du système, le même ITextClipboard que demande le bouton Copier propre d'une page (Capacités).

Un mouvement qui veut dire ce qu'il dit

Un BoxStyle.Transition était autrefois un glissement réservé au web : le natif SAUTAIT à chaque nouvelle valeur, et le disait dans une barrière. Plus maintenant. Les couleurs, l'opacité, les composantes de la transformation, l'ombre d'élévation et une taille fixe interpolent toutes sous la spécification propre de la boîte, sa durée, son délai, sa courbe, et un canal que l'auteur a laissé hors de Channels saute, par le même appel.

new Box(new BoxStyle
{
    Background = hovered ? theme.SurfaceHover : theme.Surface,
    Elevation = hovered ? 3 : 1,
    Transition = TransitionSpec.Of(StyleChannels.Colors | StyleChannels.Shadow, Motion.State),
})

Deux conséquences à énoncer, parce qu'aucune n'est évidente :

  • L'ombre grandit au lieu de se substituer. Une carte qui se lève de l'élévation 1 à 3 interpole le décalage, le flou, l'étalement et la couleur ; une qui descend à 0 S'ESTOMPE, ce que if (Elevation > 0) ne pourrait jamais dessiner. Le niveau 0 est une vraie spécification, géométrie zéro, couleur transparente, donc il n'y a pas de cas particulier.
  • Une taille qui glisse est mise en page à la valeur interpolée, donc tout ce qui est en aval suit : le retour à la ligne de l'enfant, la part des frères dans la rangée. C'est ce que fait transition: width dans un navigateur, et c'est pourquoi le canal Size vit dans la mise en page et non dans la peinture.

Les courbes sont réelles maintenant aussi. Curve était quatre points de contrôle sans évaluateur, donc le magasin natif substituait un smoothstep pour chacune d'elles : Motion.Press produisait le cubic-bezier(.2,0,0,1) de CSS sur le web et autre chose sur Photon, une intention d'auteur, deux mouvements. Le solveur fait ce que font les navigateurs, donc une courbe nommée veut dire la même chose sur les deux cibles. Le solveur, CurveEvaluator, est celui de l'hôte : sur le web une transition est une fonction de temps CSS, donc le runtime n'en livre aucun jumeau, et un composant qui le nomme casse la compilation avec EQ2010 (depuis 0.2.0-preview.61).

Depuis 0.2.0-preview.46

Dessiner vos propres pixels : Canvas

Tout le reste du vocabulaire est composé de nœuds, ce qui est juste pour une interface utilisateur et faux pour une visualisation : un sunburst d'un million de fichiers, un ensemble de bulles mené par la physique, un graphique dont la géométrie est recalculée depuis des données qui n'arrêtent pas. Ce sont de l'ARITHMÉTIQUE sur une boîte, et les exprimer comme des milliers de nœuds retenus paie une passe de réconciliateur pour dire ce qu'une boucle pourrait dire directement.

Canvas(painter =>
{
    foreach (var slice in slices)
        painter.FillAnnularSector(cx, cy, slice.Inner, slice.Outer, slice.Start, slice.End, slice.Ink);
},
onPointerMove: p => Highlight(SliceAt(p.AngleFrom(cx, cy), p.DistanceFrom(cx, cy))),
label: "Disk usage by folder")

Le pointeur arrive dans les coordonnées du CANEVAS, et c'est ce qui fait que le test de contact est celui de l'app : polaire pour un sunburst, par particule pour une simulation. Le moteur remet le point et ne prétend pas savoir ce qui a été dessiné là. Une pression capture, donc un glisser qui quitte la boîte appartient encore au canevas ; un canevas sans gestionnaires n'enregistre aucune région, donc l'ornement pur n'avale jamais la pression en dessous.

Le vocabulaire du peintre est celui du MOTEUR, rectangles arrondis, cercles, secteurs annulaires, traits, et il n'y a pas de tracés arbitraires, parce que Photon est un rastériseur SDF et qu'un moteur de tracés est une barrière de v2. Cette contrainte est ce qui achète une seconde cible gratuitement : les mêmes appels deviennent du SVG en ligne sur le web, donc une visualisation s'écrit une fois. Une entrée dégénérée ne dessine rien par les règles propres du moteur (un rayon extérieur non positif, un angle de fin au début ou avant, un rayon intérieur qui rejoint l'extérieur), et les deux cibles sont épinglées contre ces règles plutôt que l'une contre l'autre.

Depuis 0.2.0-preview.46

Livrer une app macOS : le bundle .app

Depuis 0.2.0-preview.45

Une tête de bureau construite avec eQuantic.UI.Sdk.Native sur macOS sort comme une APPLICATION, pas comme un processus avec une fenêtre : après Build, le SDK écrit <ApplicationTitle>.app à côté de la sortie de compilation, l'Info.plist depuis le projet (ApplicationTitle, Version, l'id de bundle depuis EQuanticBundleId, par défaut com.equantic.<assemblyname>), l'icône depuis le même Assets/AppIcon qui alimente les téléphones, le manifeste de capacités lu de l'assemblage, et la signe en ad hoc sous cet identifiant. Retirez-vous avec <EQuanticMacAppBundle>false</EQuanticMacAppBundle>.

Signer une app que vous pouvez vraiment livrer

L'ad hoc est juste pour le développement et honnête sur ce qu'il est : il ne prouve rien sur l'origine. Une compilation PUBLIÉE dit qui elle est, et au moment où elle le fait, macOS applique le runtime durci, qui interdit des choses par défaut et ne les refuse pas poliment. Le code qui a besoin de l'une d'elles n'est pas refusé, le processus est tué.

<EQuanticSigningIdentity>Developer ID Application: Your Org (TEAMID)</EQuanticSigningIdentity>

Cette seule propriété est tout l'interrupteur : elle remplace la signature ad hoc et allume le runtime durci avec elle (EQuanticHardenedRuntime écrase ce défaut dans un sens comme dans l'autre).

Ce dont votre propre code a besoin, il le dit en C#, sur le builder, à côté de tout le reste qu'il déclare :

builder.Entitlements.RequireNetworkClient();
builder.Entitlements.RequireUserSelectedFiles();

Ce dont le RUNTIME .NET a besoin, le SDK le déclare lui-même, et c'est la partie qui vaut d'être lue deux fois parce qu'elle est mesurée et non supposée : une app .NET dépendante du framework sous le runtime durci ne peut pas se lancer du tout. La validation de bibliothèques refuse le propre libhostfxr.dylib de Microsoft avant qu'une ligne de votre code ne tourne, « mapping process and mapped file (non-platform) have different Team IDs », et livrer le runtime dans le bundle n'aide pas, ce qui est la supposition raisonnable et la mauvaise. Donc le SDK ajoute disable-library-validation et allow-jit quand le durcissement est allumé et que l'app n'est pas entièrement AOT, journalise ce qu'il a ajouté, et vous laisse déclarer seulement vos propres besoins.

PhotonEntitlements nomme les clés qui comptent, chacune disant QUAND vous en avez besoin, et Require(key) prend n'importe quelle autre : la liste est celle d'Apple et elle grandit. EQ3004 refuse une clé construite à l'exécution, parce que les autorisations sont signées à la compilation.

Si vous embarquez wasmtime, la clé que vous déclarez est RequireUnsignedExecutableMemory(), pas RequireJit(). Les noms trompent : allow-jit couvre les pages mappées à la façon d'Apple (MAP_JIT), ce qu'utilise le propre JIT de .NET, et c'est pourquoi le SDK la met déjà dans un bundle durci non AOT pour vous. wasmtime écrit de la mémoire exécutable ordinaire à la place, donc cette seule clé est toute l'exigence, et déclarer RequireJit() à côté est une opération nulle inoffensive. Ce qui décide, c'est la façon dont le moteur mappe ses pages, pas qu'il exécute du WebAssembly : un autre runtime WASM peut très bien utiliser MAP_JIT, et alors le SDK l'a déjà couvert.

Mesuré avec un vrai certificat, les trois lignes : JIT seul est SIGKILLé au premier usage, avec "namespace":"CODESIGNING" et "indicator":"Invalid Page" dans le rapport de plantage ; JIT plus unsigned passe ; et unsigned SEUL passe aussi, ce qui a prouvé que la déclaration de JIT n'a jamais été la variable. Sous PublishAot le SDK ne déclare rien pour vous, donc une app AOT trouve sa propre recette : personne n'a mesuré celle-là.

À connaître parce que rien sans certificat de signature n'exerce le runtime durci du tout, donc ceci survit à une suite entièrement verte et apparaît à votre première livraison notarisée.

Depuis 0.2.0-preview.46

Trois règles gardent la signature honnête, chacune apprise d'une vraie app :

  • Le bundle est la charge. Contents/MacOS est rebâti depuis la sortie de compilation chaque fois, jamais accumulé : un fichier que la sortie a cessé de produire ne traîne pas dans l'app.
  • runtimes/ est une liste blanche (osx*, unix*). Seuls les RID qu'un processus macOS peut charger y entrent ; un paquet qui livre aussi des actifs natifs browser-wasm ou linux-x64 (Microsoft.Data.Sqlite le fait) ne fait plus refuser tout le bundle par codesign --deep.
  • Un échec de signature casse la compilation. C'était un avertissement, et l'app gardait la signature PRÉCÉDENTE : une identité qui bascule entre compilations est la façon dont une autorisation d'Accès complet au disque s'évapore en silence, parce que TCC indexe ses autorisations sur l'identité de signature.

L'app que vous livrez vient de dotnet publish

Jusqu'à 0.2.0-preview.47 ce n'était pas le cas. Le .app était assemblé depuis la sortie de COMPILATION et publish/ contenait des fichiers en vrac sans bundle, donc chaque décision de publication s'arrêtait à la porte du bundle : découpe, autonome, fichier unique, AOT. Mesuré sur le sample de bureau propre de ce dépôt avec la découpe allumée : publish 23 Mo, bundle 172 Mo. L'app que vous auriez livrée était sept fois l'app que vous avez demandée, et rien de la découpe ne l'atteignait.

Maintenant le bundle atterrit DANS le répertoire de publication, à côté des fichiers en vrac, là où MAUI met le sien et donc là où l'outillage regarde déjà. Rien à configurer : dotnet publish le produit.

dotnet publish -c Release -r osx-arm64 --self-contained
# → bin/Release/net10.0/osx-arm64/publish/My App.app

La découpe fonctionne, et il a fallu l'y amener. Quatre défauts, chacun d'eux le découpeur lisant la même preuve sur laquelle ce design s'appuie et arrivant à la conclusion opposée : une app ne nomme jamais son shell dans le code, donc le découpeur l'enlevait et l'app mourait au lancement en disant qu'aucun shell n'était référencé. Le SDK enracine maintenant ce que le SDK a ajouté, annote les coutures par lesquelles le conteneur atteint, et allume le générateur de liaison de configuration dans l'assemblage qui lie : sans ce dernier une app ouvrait sa fenêtre et ignorait chaque réglage, en silence. Mesuré après : 86 Mo → 23 Mo, zéro avertissement de découpe, les mêmes 110 éléments d'accessibilité.

Depuis 0.2.0-preview.47

Dire ce que le système lit de votre app

L'Info.plist avait un jeu fixe de clés, donc tout le reste était indicible. C'est du C# maintenant, sur le même builder que tout le reste :

builder.Bundle
       .Copyright("© 2026 Acme")
       .Category(AppCategory.Utilities)
       .MinimumSystemVersion("13.0")
       .Agent()                       // une app de barre de menus : pas de tuile dans le Dock (Apple l'appelle LSUIElement)
       .UrlScheme("acme")             // acme://… ouvre cette app
       .Key("NSSomeNewAppleKey", "value");   // la sortie de secours, toujours en C#

Une déclaration GAGNE sur la valeur propre du framework pour la même clé, et aucune clé n'est écrite deux fois. Category est un enum plutôt que public.app-category.utilities, parce que cette chaîne est à une faute de frappe d'être ignorée en silence et que personne ne devrait avoir à la chercher.

Une chose que le SDK corrige sans demander : la version d'Apple fait un à trois entiers, et une version de préversion .NET n'en fait pas. 0.2.0-preview.46 allait dans le plist tel quel et produisait un bundle que la notarisation refuse, dans un vocabulaire qui ne nomme rien de ce que vous avez écrit. Il devient 0.2.0, et la compilation le dit une fois.

Depuis 0.2.0-preview.47

La notarisation et une image disque

Apple scannant l'app signée et émettant un ticket, sans lequel une app téléchargée est refusée avec « impossible de l'ouvrir car le développeur ne peut pas être vérifié ». Ce sont toujours les trois mêmes étapes, donc le SDK les fait : compresser comme notarytool l'attend, soumettre et ATTENDRE, puis agrafer le ticket dans le bundle pour qu'il se vérifie sans aucun réseau.

<EQuanticSigningIdentity>Developer ID Application: Acme (TEAMID)</EQuanticSigningIdentity>
<EQuanticNotaryProfile>acme-notary</EQuanticNotaryProfile>
<EQuanticPackageFormat>Dmg</EQuanticPackageFormat>

Les identifiants ne vivent jamais dans le projet : soit un profil de porte-clés notarytool, soit EQuanticNotaryAppleId + EQuanticNotaryTeamId + EQuanticNotaryPassword depuis les secrets d'une CI : un portable a un porte-clés et un agent de build n'en a pas. Chacun d'eux atteint notarytool comme un argument à lui, sans aucun shell qui le lise, et la compilation ne répète pas la ligne de commande qui porte le mot de passe (Depuis 0.2.0-preview.62). Un diagnostic ou un journal binaire enregistre toujours les paramètres de chaque tâche, donc celui d'une publication notarisée n'est pas un fichier à partager. L'image disque est bâtie depuis le bundle AGRAFÉ, donc le ticket voyage dedans et l'app se vérifie hors ligne dès qu'elle en est tirée.

Pas encore prouvé de bout en bout. Les étapes tournent dans l'ordre et échouent haut et fort sur un identifiant qui n'existe pas, et aucun bundle n'a réellement été notarisé par personne ici : personne n'a encore de certificat Developer ID Application, et c'est ce dont la dernière ligne de ceci a besoin plutôt que de plus de code. Dit clairement parce qu'une page décrivant une fonctionnalité que personne n'a lancée est une page qui devrait le dire.

Depuis 0.2.0-preview.47

Le caret, et ce qu'une sélection doit montrer

Une sélection a deux bouts et un seul d'eux bouge. Lequel n'est pas quelque chose qu'une personne peut déduire de la bande, donc le caret est dessiné AVEC la sélection, pas à sa place : la bande dit quels caractères sont tenus, le caret dit quel BOUT vous tenez, celui que ⇧-flèche bougera, celui depuis lequel le caractère suivant remplace.

Deux règles vont avec, et les deux portent sur le fait d'être visible quand cela compte :

  • La phase du clignotement démarre au dernier DÉPLACEMENT du caret, pas à l'époque. Sur une phase qui court librement, un caret qui vient d'arriver quelque part peut être dans sa moitié ÉTEINTE pendant une demi-seconde : vous appuyez sur ⇧→ et vous regardez rien. Chaque écriture au caret redémarre le cycle, et c'est pourquoi l'hôte les route toutes par une seule propriété.
  • Pendant qu'un glisser dessine une sélection le caret ne clignote pas du tout. Le bout qui suit votre pointeur est la seule chose que vous regardez.

Sur le web le champ est un vrai <input> et le navigateur possède son caret ; ce qui est décrit ici est ce que Photon peint, et ce que fait la surface d'éditeur (que les deux cibles peignent elles-mêmes).

Le clignotement est CADENCÉ, pas épinglé

Un caret est un mouvement à 2 Hz, pas un mouvement à la vsync, donc un caret en édition PLANIFIE ses images au lieu de garder la boucle de rendu chaude :

  • RenderFrame laisse NeedsRender à faux quand le caret est la seule raison d'une image suivante, et note quand la prochaine TRANSITION du clignotement est due ;
  • les ticks d'inactivité des shells (le minuteur 120 Hz de macOS, les callbacks de vsync des téléphones) demandent host.IsFrameDue(nowMs) à côté de NeedsRender : une présentation par demi-période, atterrissant sur la transition, ~2 par seconde au lieu de la fréquence de rafraîchissement de l'écran.

Le vrai mouvement (un spinner, un glissement, une transition) garde toujours la boucle chaude, et la planification n'existe que pour le caret. Pendant qu'un glisser dessine une sélection rien n'est planifié du tout : le caret est forcé solide et le mouvement propre du pointeur mène les images. Dix secondes simulées d'un champ focalisé : 20 présentations, chacune inversant le clignotement, assertées dans CaretPacingTests, le compte compris.

Le web n'a besoin de rien de tout cela : le navigateur possède le caret de l'<input>, et le div du caret de la surface d'éditeur peut clignoter sur le compositeur.

Rechargement à chaud : dotnet watch run, et l'app suit

Lancez n'importe quelle app Photon sous dotnet watch run, modifiez un corps de Build, enregistrez, et la fenêtre se redessine avec le nouveau code et TOUT l'état intact, en un tick. Aucune commande de SDK, aucune configuration : la plomberie est livrée dans eQuantic.UI.Native.Components et chaque hôte s'inscrit à sa construction.

L'architecture a la forme du rechargement à chaud par construction :

  • l'arbre est RÉ-ÉTENDU depuis la racine retenue à chaque image (Build tourne pendant la mesure), donc un corps de méthode corrigé prend simplement effet à l'image suivante ;
  • l'état du composant vit dans un magasin indexé par CHEMIN, pas dans les objets qu'une modification remplace, donc rien n'a à être migré. Trois clics d'état de compteur survivent à un rechargement parce que rien ne les a touchés.

PhotonHotReload (le [MetadataUpdateHandler]) fournit la pièce manquante, l'image suivante elle-même, parce qu'une fenêtre inactive ne présente rien et qu'une mise à jour de métadonnées arrive sans aucun événement d'entrée attaché. Il fait exactement deux choses :

  1. il réveille chaque hôte vivant par un bool que les boucles de rendu consultent déjà (macOS en moins de 50 ms, les téléphones à leur prochaine vsync), et
  2. il incrémente une GÉNÉRATION que l'hôte compare sur son propre fil de rendu pour lâcher les caches de contenu une fois (rasters de texte, icônes, images). Les clés sont le contenu, donc la plupart des modifications les manqueraient, mais un rastériseur corrigé ou un tracé d'icône modifié est exactement la modification qu'une personne fait en réglant des visuels. Rien n'est muté depuis le fil du gestionnaire.

Les modifications grossières (types nouveaux, signatures changées) sont au runtime de les refuser. dotnet watch répond à celles-là par un redémarrage, ce qui est correct et ne demande rien au framework.

Mobile : le même registre sert les hôtes iOS et Android (leurs ticks de vsync consultent le même bool), donc le rechargement à chaud fonctionne dans le SIMULATEUR. Un appareil physique demande que dotnet watch atteigne le processus, ce qui est une question de canal de déploiement, pas de framework.

Tourner sur un simulateur ou un émulateur aujourd'hui

Seule la boucle de bureau est une commande aujourd'hui (dotnet run, dotnet watch run) ; les téléphones sont une recette à la main jusqu'à ce que le W7 de la piste W la plie dans le SDK en dotnet build -t:RunIos / -t:RunAndroid. Ce qui marche maintenant, et les pièges qui ont coûté une session chacun :

  • La vérification de version de Xcode est désactivée par défaut. .NET pour iOS refuse la compilation (E0191) à moins que le MAJOR.MINOR du Xcode installé n'égale celui que son pack de SDK recommande, une comparaison de versions, pas une vérification de capacité. Installer le « bon » Xcode ne règle pas la chose non plus : QUEL pack se résout est décidé par le manifeste de workload, pas par le Xcode que vous avez, donc une machine portant Xcode 27.0 et les packs 26.0, 26.5 et 27.0 résout quand même 26.5 et se fait dire d'aller chercher Xcode 26.6. Le SDK met ValidateXcodeVersion=false pour le target framework iOS, et seulement quand vous ne l'avez pas fait : mettez <ValidateXcodeVersion>true</ValidateXcodeVersion> dans votre csproj pour récupérer la comparaison. Une chaîne d'outils qui ne peut vraiment pas compiler échoue quand même, avec l'erreur de ce qui a échoué.
  • Simulateur iOS : xcrun simctl boot <udid>, puis dotnet build -f net10.0-ios -r iossimulator-arm64 -c Release (une compilation en Debug installe un .app sans assemblages et plante au lancement), xcrun simctl install booted <path>.app, xcrun simctl launch booted <bundle id>. Console.WriteLine ne va NULLE PART sur iOS : lisez le journal unifié (xcrun simctl spawn booted log show --last 45s --predicate 'processImagePath CONTAINS "<App>"'). Les arguments de lancement n'atteignent jamais Main ; la configuration voyage en environnement (SIMCTL_CHILD_Photon__MaxFrames=90 xcrun simctl launch …). Un plantage « AOT module … out of date » après avoir recompilé un shell signifie des obj/ et bin/ périmés pour le TFM iOS : supprimez-les.
  • Émulateur Android : démarrez-le avec -gpu host : avec swiftshader_indirect il n'y a pas de Vulkan, Photon retombe sur le backend de Référence logiciel, le fil d'UI se cloue à 100 % et chaque requête d'accessibilité expire. Compilez -c Debug -p:EmbedAssembliesIntoApk=true (sinon l'APK ne porte aucun assemblage), puis adb install -r et adb shell am start. La configuration voyage par un fichier @(AndroidEnvironment) (Photon__MaxFrames=90) ; setprop debug.mono.env non. Ce que le clavier à l'écran et le shell se disent est journalisé après adb shell setprop log.tag.PhotonInput DEBUG, à partir de la modification suivante (adb logcat -s PhotonInput). Un champ qui cache ce qui est tapé montre seulement la longueur de son texte.

Shaders : UNE source normative en Slang

Sdf.slang (translittérant Sdf.cs) compile hors ligne par la chaîne slangc épinglée en MSL + SPIR-V commités : le fragment SDF plus le fragment texturé (Load de texel, au plus proche par définition, parce que les rasters sont à l'échelle de l'appareil, parité exacte avec la Référence). Les développeurs d'apps ne lancent jamais la chaîne de shaders ; ils consomment les artefacts commités.

Performances : les promesses reçoivent des nombres

PerfHarnessTests mesure ce que la définition de fini promet (« 120 Hz, zéro allocation en régime établi, budgets tenus ») au lieu de le supposer. Sur une scène en forme de tableau de bord (24 cartes + une bande de mouvement en boucle), par image en régime établi (base série M) : 146 commandes de dessin, realize p50 0,23 ms / p95 0,32 ms contre le budget de 8,33 ms, et allocation en régime établi sous 74 Ko par image avec le recyclage d'images allumé.

Ce qui garde le nombre bas :

  • un cache de chaînes de chemin : un chemin est une identité, donc l'hôte prête un dictionnaire qui survit aux images et le régime établi cesse de les reconcaténer ;
  • une table de chemins de couche : le chemin racine de chaque couche de surcouche est bâti une fois et gardé d'une image à l'autre, au lieu d'une chaîne nouvelle par couche par image ;
  • un DisplayListBuilder réutilisé : Reset() garde la capacité du tampon ; les shells font tourner un builder par boucle ;
  • le recyclage d'images (PhotonHost.RecycleFrames, à demander, désactivé par défaut) : RealizeResult porte une histoire de propriété explicite. Avec le recyclage allumé, l'hôte POSSÈDE chaque image qu'il remplace, l'arbre remplacé alimente un LayoutNodePool, et l'image suivante est bâtie depuis lui. Les shells de production (macOS, iOS, Android) l'allument parce que rien ne retient leurs images ; les tests et l'outillage qui tiennent des RealizeResult le laissent éteint et gardent des arbres immuables.

Les plafonds sont épinglés comme des CLIQUETS de régression une marge au-dessus des nombres mesurés (152 Ko en profil par défaut / 74 Ko avec pool, 200 commandes, alarme de 33 ms, le temps délibérément lâche pour qu'une CI partagée ne devienne jamais instable). La moitié GPU de l'image reste couverte par des golden de parité, pas par des temps de CI.

Un écran avec des couches ouvertes a sa propre règle. La scène dense n'a pas de surcouche, donc son plafond avec pool ne dit rien d'un menu, d'une feuille et d'une infobulle ouverts ; la règle est ce qu'UNE couche ajoute, la différence entre une scène à 24 couches et une à 32, sur les huit qui les séparent. La partie dense fait les mêmes octets dans les deux, et entre 24 et 32 les listes par image du réalisateur gardent une capacité, donc ce qui reste est le coût d'une couche et non d'une liste. Le plafond est de 528 octets par couche, ce qui laisse moins de marge que le plus petit objet que .NET alloue : un objet de plus par couche échoue. Le harnais tourne seul (PerfHarnessCollection, parallélisation éteinte), parce qu'une règle d'octets par image mesurée à côté du reste de la suite en mesure une partie aussi.

IME : la composition est le texte de la plateforme

Pendant qu'un champ tient le caret, le shell macOS remet les événements de touche au contexte d'entrée de la PLATEFORME (NSTextInputClient : la vue s'y conforme, enregistrée à l'exécution comme chaque autre surcharge) :

  • Une touche morte ou une méthode CJK compose sur plusieurs frappes en texte marqué : tenu par l'hôte (SetMarkedText), rendu EN LIGNE au caret avec un souligné, tandis que la VALEUR du champ reste intacte. La validation (CommitText) entre dans le champ par la même porte que chaque frappe ; annuler laisse le champ exactement comme il était. Composer sur une sélection la remplace, la même règle que la saisie. Un champ masqué compose à l'aveugle (faire écho à la composition ferait écho au secret).
  • Les touches d'édition reviennent DU contexte d'entrée comme des sélecteurs (doCommandBySelector:) et rentrent par la même porte OnKey, réécrites en noms du DOM : Backspace, flèches (avec les variantes Shift et Alt), Home/End, Échap. Les accords Command et Control n'entrent jamais dans le contexte : ⌘A/⌘C/⌘Z appartiennent à l'app.
  • firstRectForCharacterRange ancre la fenêtre de candidats au CARET (CaretRect() de l'hôte → inversion en y → vue → fenêtre → écran), donc le sélecteur CJK apparaît sous la composition.
  • Preuve : ImeCompositionTests à l'hôte (séquence de touche morte, annulation, composition sur sélection, suivi du caret, plages, la perte de focus lâche la composition), et l'auto-test de fenêtre pilote les sélecteurs RÉELS enregistrés (setMarkedText: → insertText:) contre un champ vivant. Tapez option+e e dans n'importe quel champ pour voir é se composer.
  • Portée : les surfaces de code composent à l'aveugle. La validation marche, mais la face de l'éditeur ne rend aucune suite marquée en ligne.

Sur un téléphone la composition n'est pas tenue à part : le clavier la garde dans le texte du champ, et l'hôte ne fait que la marquer (Le clavier du téléphone).

Accessibilité : l'arbre de sémantique

Depuis 0.2.0-preview.29

Photon dessine ses propres pixels, donc l'OS voit une seule vue opaque, et sans arbre de sémantique l'app est INVISIBLE pour un lecteur d'écran. L'arbre est un artefact d'image de première classe :

  • SemanticsTree.Collect(frame) (partagé, neutre vis-à-vis de la cible) dérive la sémantique en ordre de lecture depuis la mise en page réalisée, la page ET les couches de surcouche les deux, donc les dialogues sont vus. Rôles : StaticText, Button, Link, TextField, CodeField, Slider (un Adjustable), Image (Icône étiquetée), Checkbox, Switch, GridCell, ProgressIndicator, Group, un pour chaque rôle pressable, et la barre d'onglets, le groupe de radios, le combobox et les dialogues (les deux plus bas). Le texte intérieur d'un contrôle est son nom accessible (la règle <button>text</button> du web) ; une Icon étiquetée annonce, une sans étiquette est décorative ; TextEntry.Label nomme un champ. L'identité est le CHEMIN de mise en page, la même identité qu'utilisent les pressions, le focus et le défilement, parce que l'arbre est rebâti à chaque image et que les références périment.
  • Le contenu défilé hors vue est inclus, exactement comme FocusStop et délibérément à l'inverse des régions de contact du pointeur : la navigation linéaire atteint sous le pli ; la découpe garde le pointeur, pas le lecteur.
  • PhotonHost.Semantics() expose l'arbre ; PhotonHost.ActivatePath(path) lance un contrôle comme une touche le fait (résolu depuis les régions de l'image courante, en refusant les désactivés) ; les éléments ajustables répondent aux actions d'incrément et de décrément.
  • Pont macOS (PhotonAccessibility) : la vue de contenu répond à accessibilityChildren avec des EQAXElement (un NSAccessibilityElement qui peut être pressé) : rôle et sous-rôle, étiquette, valeur, activé, accessibilityFrameInParentSpace inversé en y, le chemin comme accessibilityIdentifier, et accessibilityPerformPress routé par ActivatePath. Les éléments sont rebâtis par requête ; le lot précédent est relâché.
  • Pont iOS (PhotonAccessibility) : la PhotonView est un CONTENEUR d'accessibilité dont les éléments sont bâtis depuis l'arbre en ordre de lecture : le rôle mappé en UIAccessibilityTraits, les cadres convertis en espace écran, le chemin comme accessibilityIdentifier, et le double tap et le balayage d'ajustement exportés en accessibilityActivate / accessibilityIncrement / accessibilityDecrement, routés par ActivatePath et AdjustPath. Une case est un bouton dont la VALEUR est "1"/"0", le contrat propre d'UIKit pour une bascule, et c'est pourquoi l'état est annoncé dans la langue de l'utilisateur sans que le framework ne livre aucun des deux mots. Le dépliage chevauche l'accessibilityExpandedStatus d'iOS 17. Les éléments sont rebâtis quand l'arbre CHANGE plutôt que par requête (VoiceOver demande la liste en la parcourant), et LayoutChanged n'est posté que pendant que VoiceOver tourne.
  • Pont Android (PhotonAccessibility, un AccessibilityNodeProvider) : la PhotonSurfaceView répond pour une hiérarchie VIRTUELLE : un enfant par nœud de sémantique, le rôle comme nom de classe de la plateforme (android.widget.Button, CheckBox, RadioButton, Switch, SeekBar, EditText, ImageView, TextView), des limites en pixels écran, ACTION_CLICK par ActivatePath, le défilement avant et arrière par AdjustPath, et l'exploration au toucher répondue depuis dispatchHoverEvent. Le dépliage est une ACTION là plutôt qu'un drapeau : offrir de déplier est la façon dont un nœud dit qu'il est fermé. La vue doit se déclarer IMPORTANT_FOR_ACCESSIBILITY_YES, puisqu'elle est la seule vue de la fenêtre, et sans cela la fenêtre n'a aucune racine accessible.
  • Deux barrières, une sur chaque téléphone : aucune plateforme n'a de troisième état de case, donc une case mixed est annoncée sans état plutôt qu'en prétendant être décochée ; et Android n'a pas de classe de lien, donc un lien est annoncé comme ce qu'il fait.
  • Preuve : SemanticsTests épingle l'ordre, le nommage, la visibilité des dialogues, l'affleurement des désactivés et l'action d'activation, plus la BARRIÈRE DE PARITÉ SemanticsMatchFocusStops, qui empêche la marche de sémantique et celle d'entrée de jamais diverger. L'auto-test de chaque shell interroge ensuite la PLATEFORME, pas l'arbre : la fenêtre macOS imprime ses accessibilityChildren vivants, le contrôleur iOS envoie les vrais sélecteurs Objective-C, et sur Android uiautomator dump lit l'app depuis l'extérieur par la même chaîne de service que TalkBack. Le même écran répond avec les mêmes 26 éléments sur les trois.
  • La parité web est native au réalisateur web (de vrais <button>/aria-label/placeholder), donc rien n'est dû là. L'arbre est neutre vis-à-vis de la cible et chaque shell a désormais son pont.

Chaque rôle pressable, dans les mots de chaque plateforme

Depuis 0.2.0-preview.60

Un radio, un onglet, un élément de menu, une option et une destination de navigation atteignent chacun l'arbre comme leur propre rôle, avec leur état là où le web le met, et chaque pont le dit dans les mots propres de la plateforme. Jusqu'à cette version les cinq étaient annoncés comme un bouton.

PressableRole macOS (AppKit) Classe Android iOS (UIKit) État
Radio AXRadioButton RadioButton bouton, sélectionné quand choisi checked, comme aria-checked
Tab AXRadioButton, sous-rôle AXTabButton View bouton selected, comme aria-selected
MenuItem AXMenuItem MenuItem bouton aucun
Option AXMenuItem View bouton selected, comme aria-selected
Destination AXButton Button bouton current, comme aria-current

Les mots d'AppKit et d'Android suivent le Core-AAM du W3C pour le même rôle ARIA, ce que Safari et Chrome exposent pour la moitié web du même composant, avec deux écarts. Une option est AXMenuItem, le mot pour un choix dans les popups propres de macOS, là où l'AXStaticText de Core-AAM la lirait comme un paragraphe. Une destination garde les mots d'un bouton sur les trois, comme le fait le <button aria-current="page"> du web, parce qu'un ListItem n'est une destination que pendant qu'il est la rangée courante. Android n'a pas de classe pour un seul onglet, une option ou un élément de menu, donc TalkBack lit les mots de l'élément et son état, comme sonnent les menus propres d'Android. UIKit n'a de rôle pour aucun des cinq, et dit un radio choisi comme le font ses contrôles segmentés, avec le trait Selected, plutôt qu'avec le « 1 » ou le « 0 » d'une bascule.

Où on les entend : les éléments d'un menu, les options d'un select et d'un sélecteur d'heure, les destinations d'une barre de navigation et d'un rail, la rangée courante d'une liste, et les onglets et les radios d'un Tabs, d'un RadioGroup et d'un SegmentedControl, chacun lu pour lui-même dans son groupe (plus bas). Aucun élément ne dit encore où il se situe dans son ensemble (#502).

Barres d'onglets, groupes de radios, comboboxes et dialogues

Depuis 0.2.0-preview.60

Une bande d'onglets, un groupe de radios, le champ d'un select et un dialogue ouvert atteignent l'arbre comme ce qu'ils sont, avec les mots que le web leur donne : tablist, radiogroup, combobox, dialog et alertdialog. Jusqu'à cette version un Tabs et un RadioGroup étaient un seul slider sans nom dont les onglets et les radios n'étaient jamais lus (#500), le champ d'un select était un bouton qui s'étend, et un dialogue un groupe ordinaire avec un nom (#501).

SemanticRole macOS (AppKit) Classe Android iOS (UIKit) Où
TabBar AXTabGroup TabWidget un groupe une bande de Tabs
RadioGroup AXRadioGroup RadioGroup un groupe un RadioGroup, un SegmentedControl
ComboBox AXPopUpButton Spinner bouton, étendu ou replié le champ d'un Select, celui d'un TimePicker
Dialog AXGroup, sous-rôle AXApplicationDialog Dialog un groupe un Overlay modal ouvert, le calendrier d'un DatePicker
AlertDialog AXGroup, sous-rôle AXApplicationAlertDialog AlertDialog un groupe un Dialog dont l'action est destructive
  • Une barre d'onglets et un groupe de radios sont des conteneurs. Un lecteur atterrit sur la barre ou sur le groupe, entend son nom quand l'auteur en a donné un (l'étiquette d'un RadioGroup), puis chaque onglet ou radio à son tour, avec s'il est choisi ou coché ; en activer un lance le gestionnaire qu'une touche lance. Le clavier n'a pas changé : la barre est toujours un seul arrêt de Tab, et ses flèches déplacent toujours le choix. Le groupe lui-même ne prend aucun geste d'ajustement sur aucune plateforme, comme les groupes d'onglets de macOS et les groupes de radios d'Android n'en prennent aucun.
  • Le combobox est le champ depuis lequel une liste s'ouvre : le pressable auquel un panneau de listbox est accroché, comme sur le web, en disant si sa liste est ouverte. Le déclencheur d'un menu reste un bouton. Chaque combobox que la bibliothèque bâtit est en sélection seule, donc il prend les mots propres de chaque plateforme pour un déroulant, l'AXPopUpButton de NSPopUpButton et le Spinner d'Android, que TalkBack appelle une « liste déroulante », plutôt que ceux du combo box éditable que donne Core-AAM.
  • Un dialogue est une couche modale ouverte, nommée par son étiquette ; une confirmation destructive est un dialogue d'alerte. AppKit lui-même décrit l'AXGroup du dialogue comme « group », donc le sous-rôle est ce qui le marque comme dialogue. La couche qu'un menu, un select ou une infobulle ouverte ouvre sur Photon n'est ni un dialogue ni un arrêt : le web n'a pas de couche là. Le calendrier d'un sélecteur de date est un dialogue, comme il l'est sur le web.
  • UIKit n'a de trait pour aucun des quatre conteneurs. Il dit une barre d'onglets et une vue modale avec une propriété d'un CONTENEUR qui n'est pas lui-même un élément, que le pont iOS ne bâtit pas, donc il les entend comme un groupe portant le nom.

État

La suite native épingle le comportement du moteur avec 91 images golden commitées (paires de composant clair+sombre, états de feuille à mi-glisser, couverture de texture) à côté des suites d'unité et d'hôte. La fenêtre fait tourner le showroom à écriture unique avec du vrai texte, de vraies icônes, de vrais menus et de vrais gestes sur les shells macOS, iOS et Android. Barrière de portée actuelle : le décodage et le téléversement d'images ne sont pas inclus, donc Image rend sa surface de remplacement.

Clone this wiki locally