Repository navigation
Photon fr
🌐 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.
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.
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.
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
-
Sdf.csest 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
DrawCommandplats 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.
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.
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 |
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.
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
Textde 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 : uneBoxavec une largeur, uneRowou uneColumnqui é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'uneRow, 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 deMermaid, 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.
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.
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.
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
Filln'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.
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 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.
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
ITextClipboardque demande le bouton Copier propre d'une page (Capacités).
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: widthdans 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
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
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>.
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/MacOSest 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 natifsbrowser-wasmoulinux-x64(Microsoft.Data.Sqlite le fait) ne fait plus refuser tout le bundle parcodesign --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.
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.appLa 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
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
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
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).
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 :
-
RenderFramelaisseNeedsRenderà 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é deNeedsRender: 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.
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 (
Buildtourne 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 :
- 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
- 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.
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.MINORdu 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 metValidateXcodeVersion=falsepour 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>, puisdotnet build -f net10.0-ios -r iossimulator-arm64 -c Release(une compilation en Debug installe un.appsans assemblages et plante au lancement),xcrun simctl install booted <path>.app,xcrun simctl launch booted <bundle id>.Console.WriteLinene 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 jamaisMain; 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 desobj/etbin/périmés pour le TFM iOS : supprimez-les. -
Émulateur Android : démarrez-le avec
-gpu host: avecswiftshader_indirectil 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), puisadb install -retadb shell am start. La configuration voyage par un fichier@(AndroidEnvironment)(Photon__MaxFrames=90) ;setprop debug.mono.envnon. Ce que le clavier à l'écran et le shell se disent est journalisé aprèsadb 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.
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.
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
DisplayListBuilderré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) :RealizeResultporte une histoire de propriété explicite. Avec le recyclage allumé, l'hôte POSSÈDE chaque image qu'il remplace, l'arbre remplacé alimente unLayoutNodePool, 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 desRealizeResultle 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.
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 porteOnKey, 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. -
firstRectForCharacterRangeancre 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. Tapezoption+e edans 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).
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) ; uneIconétiquetée annonce, une sans étiquette est décorative ;TextEntry.Labelnomme 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
FocusStopet 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 àaccessibilityChildrenavec desEQAXElement(unNSAccessibilityElementqui peut être pressé) : rôle et sous-rôle, étiquette, valeur, activé,accessibilityFrameInParentSpaceinversé en y, le chemin commeaccessibilityIdentifier, etaccessibilityPerformPressrouté parActivatePath. Les éléments sont rebâtis par requête ; le lot précédent est relâché. -
Pont iOS (
PhotonAccessibility) : laPhotonViewest un CONTENEUR d'accessibilité dont les éléments sont bâtis depuis l'arbre en ordre de lecture : le rôle mappé enUIAccessibilityTraits, les cadres convertis en espace écran, le chemin commeaccessibilityIdentifier, et le double tap et le balayage d'ajustement exportés enaccessibilityActivate/accessibilityIncrement/accessibilityDecrement, routés parActivatePathetAdjustPath. 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'accessibilityExpandedStatusd'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), etLayoutChangedn'est posté que pendant que VoiceOver tourne. -
Pont Android (
PhotonAccessibility, unAccessibilityNodeProvider) : laPhotonSurfaceViewré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_CLICKparActivatePath, le défilement avant et arrière parAdjustPath, et l'exploration au toucher répondue depuisdispatchHoverEvent. 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éclarerIMPORTANT_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
mixedest 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 sesaccessibilityChildrenvivants, le contrôleur iOS envoie les vrais sélecteurs Objective-C, et sur Androiduiautomator dumplit 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.
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).
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'
AXPopUpButtonde NSPopUpButton et leSpinnerd'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'
AXGroupdu 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.
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.
🏁 Commencer ici
🏗️ Architecture
- Vue d'ensemble de l'architecture
- Composants write-once
- Surface déclarative
- Architecture des paquets
- Composants
- Styles
- Localisation
- Analytics et GTM
📱 Write-once
- Moteur Photon
- Design System
- Capacités
- Stockage
- Formulaires
- Éditeur de code
- Markdown
- Mermaid
- Rendu des e-mails
⚙️ Compilation
- Compilateur
- Fonctionnalités C# prises en charge
- Résolution des types externes
- Flux de build
- Diagnostics
⚡ Runtime
🔌 Serveur
🎨 Écosystème
- Image · Graphiques · Icônes · Lottie
🚀 Développement