eQuantic.UIeQuantic.UI
Docs
Playground
GitHub
DocsWrite-once
Photon: o motor GPU nativo
Editar esta página
50 min de leitura
O Photon é o motor de renderização GPU proprietário do eQuantic.UI para alvos nativos: um renderizador escrito do zero, inspirado no Impeller, que desenha a árvore de componentes pixel a pixel na GPU (Metal no macOS/iOS, Vulkan no Android), sem camada Skia e sem WebView. Ele é o segundo alvo de realização da arquitetura de componentes write-once: os mesmos componentes C# que rebaixam para DOM+CSS no web são rasterizados nativamente pelo Photon.
Por que proprietário, por que sem Skia
Ter uma camada nativa própria significa ter a pilha de renderização inteira: backends Metal/Vulkan diretos com shaders pré-compilados, não um wrapper sobre o Skia. Isso compra:
•
Performance previsível: registro fixo de estados de pipeline, zero compilação de shader em tempo de execução (a fonte de travadas que o Impeller foi construído para matar). Os caches de pipeline persistem entre lançamentos nos dois backends.
•
Uma superfície minúscula: renderizar interface precisa de um punhado de primitivas, não de uma biblioteca 2D geral.
•
Um único modelo normativo: a mesma matemática, testável até o pixel, entre a referência em CPU e todos os backends de GPU.
A arquitetura de relance
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Componentes C# (write-once, vocabulário do eQuantic.UI.Primitives)
│ Build(ComponentContext) // puro, baseado em tokens, sem modos
▼
eQuantic.UI.Native.Framework // motor de layout flex em C# (spec A2)
▼
eQuantic.UI.Native.Components // PhotonRealizer: árvore abstrata → display list
▼
eQuantic.UI.Native.Engine // geometria, modelo de cor, Sdf.cs (NORMATIVO), DisplayList
▼
IRenderBackend
├── eQuantic.UI.Native.Engine.Reference // rasterizador escalar em CPU (verdade das goldens, nunca publicado)
├── eQuantic.UI.Native.Engine.Metal // GPUs da Apple (macOS, iOS)
└── eQuantic.UI.Native.Engine.Vulkan // Android
eQuantic.UI.Native.Shell.MacOS / .iOS / .Android / .Windows // a janela/host de cada plataforma
O núcleo normativo
•
Sdf.cs é uma especificação, não uma biblioteca: distância com sinal de retângulo arredondado por canto, traço centrado (|d| − w/2), rampa de cobertura de antialiasing de 1 pixel. Todo rasterizador, tanto a referência em CPU quanto os fragment shaders da GPU, implementa exatamente essas fórmulas.
•
Modelo de cor: autoria em sRGB → mistura linear pré-multiplicada → armazenamento em sRGB, espelhando o comportamento do hardware GPU com render targets sRGB.
•
Display lists: registros DrawCommand planos e sem heap — nove tipos: Clear, FillRRect, StrokeRRect, ShadowRRect (a sombra analítica do §05), BeginLayer/EndLayer (opacidade de grupo), Texture (cobertura A8 ou imagens RGBA8), BackdropBlur (vidro fosco) e FillAnnularSector (a fatia de anel, desde a 0.2.0-preview.45); tintas sólidas, gradiente linear de duas paradas e radial elíptico; transforms 2D já assadas e clips em espaço de dispositivo.
Arnês de imagens golden
Toda cena renderiza por um backend numa superfície, lê os pixels sRGB de volta e compara contra PNGs golden comitados (codec sem dependências, fluxo de regeneração com EQ_UPDATE_GOLDENS=1, tolerância ±2, artefatos de falha com diffs amplificados). O catálogo de cenas é compartilhado: o backend Reference pina as goldens, os backends de GPU rodam o mesmo catálogo para paridade, e a paridade GPU↔CPU se sustenta em todo o catálogo compartilhado dentro da tolerância das goldens, no Metal e no Vulkan.
Os backends de GPU são dirigidos por bindings finos e tipados de objc_msgSend/P-Invoke: sem framework de binding, sem shims em C, zero pacotes de terceiros.
Renderização de componentes
O realizador nativo desenha chrome de componente de verdade (preenchimentos resolvidos por token, bordas internas, raios por canto):
Subsistema
Estado
Formas, bordas, gradientes, transforms, mistura, clip
✅ Completo, testado por golden
Layout (flex + wrap, grid, stacks, adaptativo, truncamento)
✅ Motor em C#
Sombras (elevação §05)
✅ ShadowCoverage analítico, nos dois backends
Texto
✅ REAL. Primitiva Texture (cobertura A8 × tinta) + ITextRasterizer da plataforma; no macOS = CoreText (só frameworks do sistema, sem HarfBuzz/FreeType onde a plataforma tem um motor de texto). Um motor mede E rasteriza, então as quebras de linha concordam por construção. Duas faces: a proporcional do sistema e a monoespaçada dele (TypeStyle.Mono). Nenhum glifo é jamais cortado: o bitmap é dimensionado pela caixa de TINTA da linha (CTLineGetImageBounds, não as métricas declaradas da fonte, já que a cauda profunda de um g passa além delas) e reporta o quanto acima da caixa de linha ele precisou alcançar, então o realizador levanta o desenho exatamente por isso e a caixa de linha continua caindo onde o layout a colocou
Ícones
✅ REAL. Parser de caminho SVG em C# puro (conjunto completo de comandos, arcos via F.6.5) + rasterizador CoreGraphics, a mesma primitiva Texture
Opacidade/transforms de grupo
✅ Camadas do motor
Compositor de rolagem
✅ Deslocamentos por caminho, fixação Pinned de verdade (vertical)
Movimento
✅ Movimento em laço, entrada/saída de Presence com replay de snapshot, e todo canal de estilo desliza — ver abaixo
Desenho próprio
✅ Canvas: a app pinta as formas do próprio engine na sua caixa, e o ponteiro chega nas coordenadas dela
Gestos
✅ Pipeline de hover, toque com cancelamento por folga, arrastar-para-dispensar (seguir + deslizar)
Overlays ancorados
✅ Camadas de overlay sintéticas posicionadas a partir dos limites absolutos da âncora
Imagens
✅ REAL. Decodificadas pela plataforma (CoreGraphicsImageLoader nos shells Apple) em texturas RGBA8 sRGB straight-alpha, desenhadas pela mesma primitiva Texture de texto e ícones
Uma frase desenha run a run
Desde 0.2.0-preview.28
Um parágrafo rico (Text.Spans) rasterizava como UM bloco no estilo base, então a palavra em negrito, o span de código inline, o termo em itálico e o link no meio da frase se dissolviam todos em prosa chapada, a divergência write-once mais larga que restava, já que o web emite um span por run desde o dia em que runs existem. A unidade agora é um FRAGMENTO, um run numa linha, porque essa é a única peça com um retângulo e as duas metades precisam de um: cada fragmento rasteriza no próprio estilo (peso, mono, itálico, o tamanho próprio), e o retângulo de um fragmento com link é uma região de hit de onde o shell navega, então links inline são tocáveis no Photon, fechando o item do Roadmap que os nomeava.
Um parágrafo alinha as suas linhas
O Text.Align era honrado pelo realizador web e IGNORADO pelo Photon, vindo de um ecrã de onboarding real: um parágrafo centrado de duas linhas saía encostado à esquerda, debaixo de um título que só parecia certo porque UMA linha centra pelo seu container.
Não era repasse, porque todo serviço de texto corta o raster justo à linha mais comprida e não sobra caixa nenhuma onde alinhar. Deslocar o raster pronto por (caixa − raster) / 2 centra o BLOCO, e num parágrafo cuja linha mais comprida já preenche a caixa isso é um deslocamento de quase zero enquanto as linhas curtas ficam à esquerda — exatamente o caso que dói. O alinhamento é por LINHA. O rasterizador recebe-o, os três serviços colocam cada linha por ele, e as barras de marcador de posição do modo headless colocam os seus retângulos pela mesma aritmética, que é o que o torna afirmável sem nenhum serviço de texto de plataforma no meio.
Duas consequências que interessa conhecer ao usar:
•
Um Text no Photon toma a largura que o pai decide, como um bloco na web e como o do Flutter sob uma restrição justa: uma Box com largura, uma Row ou uma Column que estica os filhos, a própria página. As linhas dele alinham-se nessa largura, a mais comprida incluída. Onde nada decide uma (ao longo de uma Row, dentro de um botão), o texto tem a largura do conteúdo, portanto o bloco onde uma linha centra é a linha mais comprida do parágrafo, e o parágrafo inteiro é colocado pelo seu container. Centrar o bloco e centrar as linhas dele são dois trabalhos diferentes, e um rótulo de duas linhas dentro de uma forma (os nós do Mermaid, por exemplo) quer os dois.
•
Um parágrafo rico também alinha, e as suas peças movem-se com a linha e não com o raster, por isso um run com link continua clicável onde é desenhado. São dois cálculos separados no código e passam por uma função só de propósito: um link que se move no ecrã e não no teste de acerto é um link que deixa de funcionar.
A interação está ligada: o PhotonHost guarda uma raiz retida, o SetState invalida, os toques despacham para as regiões de hit: vence a caixa desenhada sob o ponto, e o alvo que um controle guarda além da caixa (≥48dp sob um dedo, pelo menos 24dp sob um ponteiro, veja Densidade) só responde onde nenhuma caixa ao lado está desenhada, ou na frente da caixa em que ele está, então uma lista de linhas baixas dá a cada linha os seus próprios toques; um controle desabilitado engole. Uma caixa transformada recebe o ponteiro onde é desenhada, uma rodada na sua forma, e um toque num campo, numa superfície de código, numa planilha ou num canvas desenhado em escala ou girado cai no cursor, na célula ou no ponto sob ele; uma scroll view desenhada em escala ou girada revela um controle focado e um cursor tão longe quanto revelaria sem transformação. Um campo de texto, uma superfície de código ou uma planilha só recebe toques onde está na tela dentro da scroll view que a mostra. O Counter nativo roda toque → SetState → reconstrução de ponta a ponta nos testes. O PhotonHost.FocusedPath reporta o que segura o teclado, incluindo um campo em edição (o cursor é o indicador de foco daquele campo), porque uma ponte de acessibilidade da plataforma precisa de uma resposta honesta, não de metade dela.
Dedos nunca fazem hover, e o hover sobrevive à reconstrução
Desde 0.2.0-preview.24
Os eventos de ponteiro carregam um PointerKind, por EVENTO e não por host, porque a capacidade de hover pertence ao dispositivo na mão (um iPad ganha um trackpad, um Android ganha um mouse). Um PointerMove marcado como Touch guia arrastos, panorâmicas e seleções exatamente como um mouse e pula apenas a resolução de hover; um PressUp/PointerCancel de toque limpa qualquer hover que uma sessão de entrada mista tenha deixado para trás, enquanto um mouse mantém o hover dele durante um clique. O shell do macOS instala uma NSTrackingArea e roteia MouseExited para PointerLeave(), então cruzar a borda da janela limpa o hover em vez de travá-lo.
A identidade do hover é o CAMINHO de layout, não a referência do nó, e o toque aprendeu isso primeiro: uma reconstrução de componente substitui cada instância, e um hover que só conhecia a referência antiga pintava exatamente um frame. Ele também é uma CADEIA de toda região sob o ponteiro (o :hover do CSS casa com todo ancestral, e o realizador nativo responde a mesma pergunta que o gêmeo web responde), que é o que permite a um Anchored aberto por hover ficar aberto enquanto você faz hover no gatilho dentro dele. O pressionado continua vencendo o hover na caixa que consumiu a troca.
A janela aprende o idioma da máquina
Desde 0.2.0-preview.24
Um processo gráfico lançado fora de um terminal não tem LANG/LC_*, então o .NET começa invariante mesmo numa máquina pt-BR. Os shells agora resolvem a verdade de locale da plataforma e a aplicam antes do primeiro frame: o AppleLocale lê o NSLocale, onde preferredLanguages[0] é a cultura de interface (recursos) e currentLocale a cultura de formato (datas, separadores), nativamente o mesmo par que o .NET modela como CurrentUICulture/CurrentCulture, e o AndroidLocale alimenta os dois statics a partir de Locale.getDefault(). O PhotonCultureController é dono da escrita dos statics (thread padrão E thread atual, para que toda thread posterior concorde) e repinta no Apply, a forma do controller de tema; registrado com TryAdd, então um app que força uma cultura vence. Observar o locale do SO mudando em tempo de execução é uma cerca por shell.
Layout: as regras de CSS que o motor deve a você
O motor nativo é julgado contra o browser, porque o gêmeo web renderiza a mesma árvore lá:
•
O Fill herda a indeterminação: num eixo em que o pai se dimensiona pelo conteúdo, um filho Fill não tem o que preencher e passa a pergunta adiante; o que sobra não é distribuído nesse eixo.
•
Semântica de bloco na largura, e uma altura DECIDIDA se entrega ao filho: uma caixa cuja largura foi decidida estica um filho CONTÊINER de tamanho automático por ela (o filho div de uma div ocupa a largura toda), enquanto um botão, link ou input se aperta ali: eles são inline-block, e só um esticamento de FLEX atravessa por eles (StretchKind). A metade da altura está logo abaixo.
Desde 0.2.0-preview.28 Uma caixa cuja altura foi DECIDIDA (fixa, preenchida ou limitada) a entrega ao filho único, nos dois alvos. Esta é a regra que aposentou a família recorrente da "toolbar colada no topo da barra de altura fixa", em que toda Row dentro de um Box de 64dp precisava de um Height = Fill manual para alcançar o fundo da própria barra.
•
flex-shrink com piso de min-content: uma linha que não cabe encolhe os itens em vez de transbordar, e nunca abaixo do min-content de cada item (a palavra mais longa de um texto, a soma dos filhos de uma linha). O piso importa em dobro porque o clip guarda pixels E ponteiro: uma região de toque cortada não pode ser tocada.
•
Refluxo de Min/Max: quando um limite muda a extensão de uma caixa, o filho dela se remede contra o tamanho final.
Os shells: o Photon numa janela de verdade
macOS (eQuantic.UI.Native.Shell.MacOS): uma NSWindow hospedando uma CAMetalLayer, zero pacotes de terceiros, apenas bindings finos e tipados de objc_msgSend para o AppKit. O backend Metal codifica cada frame direto no drawable da camada (cache de pipeline por formato de pixel, BGRA8_sRGB para janelas); o PhotonHost.RenderScale rasteriza em backingScaleFactor enquanto layout/entrada ficam em dp (retina). Eventos de mouse/rolagem/teclado do SO entram no pipeline de entrada comum, então os visuais de toque, os diffs de hover e os menus ancorados se comportam exatamente como nos testes. Texto San Francisco de verdade e ícones de pacote de verdade renderizam na tela. Experimente: dotnet run --project samples/PhotonDesktop. Dois modos sem tela não precisam de janela nenhuma: --Photon:MaxFrames 120 apresenta essa quantidade de frames e sai, e --Photon:ScreenshotPath out.png (com --Photon:Mode Dark para a outra paleta) renderiza UM frame assentado pelo backend de referência com as mesmas métricas do CoreText. Esse é o passo de screenshot do CI, e o jeito de conferir uma passada de fidelidade contra um handoff de design. Chamar Run() fora da thread principal diz isso em termos de .NET, em vez de morrer dentro do AppKit. Redimensione a janela à vontade: o host adota o viewport com todo o estado intacto.
Nenhum dos dois modos sem tela diz que a app funcionou, e vale entender isso antes de montar uma verificação em cima de um deles. Um componente que rebenta é CONTIDO — a subárvore dele é substituída por uma superfície vermelha pequena e a app segue, que é o que uma app publicada deve fazer. Todo sinal automatizado fica do lado da contenção: os frames são apresentados, o código de saída é zero, e a contagem de acessibilidade SOBE, porque a superfície de contenção tem texto próprio. Um consumidor tinha "frames apresentados e exit 0" escrito como a verificação que apanha uma janela preta, e descobriu uma app cuja barra de título inteira rebentava em todo frame a passar em tudo isso.
Por isso o resumo de frames nomeia o que foi contido:
[photon] frames presented: 6 — CONTAINED: TitleBar
Nomes, não contagem: um componente que rebenta rebenta em todo frame, portanto uma contagem reporta a taxa de frames e enterra o único facto que interessa. Sai nos dois modos sem tela, e o --Photon:StrictRender true faz o processo sair diferente de zero enquanto algo estiver contido — desligado por omissão, porque conter é o certo numa app publicada e o código de saída não é onde um utilizador fica a saber disso, e ligado para um self-test ou um portão de CI, onde é o contrário.
iOS (eQuantic.UI.Native.Shell.iOS): o PhotonApp.Run é o ponto de entrada; o mesmo motor apresenta pelo Metal.
O app roda sob o ciclo de vida de cenas, sem o qual um app compilado com o SDK do iOS 27 não abre, e no Xcode 27 todo app é compilado com esse SDK, seja qual for o pack do .NET resolvido. O SDK declara o manifesto de cenas no Info.plist do app e o PhotonSceneDelegate do shell é dono da janela, então o app não escreve delegate nem chave de plist. A tela vive com o processo, não com a cena: quando o sistema desconecta uma cena em segundo plano e conecta outra, o app volta na página em que estava, com o seu estado. Em segundo plano o shell não desenha nada, e o primeiro quadro na volta é novo.
Android (eQuantic.UI.Native.Shell.Android): o app fornece o CreateApp (sem Main); a apresentação vai pelo Vulkan (o backend Reference é o fallback).
O host vive com a ACTIVITY, não com a superfície dela. O Android destrói a superfície toda vez que o app sai da tela (Home, outro app na frente, a tela apagando), e o shell então libera só o que apresentava nela: a swapchain, a janela, o backend. O app volta na tela em que estava, com o seu estado. Uma rotação, uma troca para o modo escuro, um teclado conectado ou uma mudança de densidade também não recriam a activity: o launcher gerado as declara como suas, e o host segue o novo tamanho, a nova paleta e a nova escala. A swapchain é refeita no novo tamanho da janela e declara as suas imagens em pé, para que o compositor as gire até o painel: um telefone girado com o app na frente mostra a mesma tela, desenhada do lado certo e preenchendo a janela.
O voltar do sistema
O voltar da plataforma fecha o que está aberto. Toda camada que se dispensa já liga o Escape (um Dialog dispensável, um BottomSheet, um Drawer, um menu ou seletor aberto, o detalhe compacto de um ListDetail). O PhotonHost.Back() executa essa ligação, e o PhotonHost.CanGoBack diz se existe uma. Um campo dentro de uma camada sai junto com ela. Uma edição em andamento não toma o voltar, e um anel de foco sozinho também não, como um TextField focado não segura o voltar no Flutter: num telefone o teclado já gastou o primeiro voltar para descer, mantendo o cursor, e um voltar que depois só tirasse o cursor ou o anel pareceria um toque engolido.
Um app que declara o voltar preditivo é consultado ANTES de o gesto terminar: o sistema desenha a tela inicial sob o dedo quando o app não se registrou para o voltar. Por isso o shell se registra só enquanto o CanGoBack é verdadeiro, relendo-o depois de cada quadro; sem nada aberto, o voltar é do sistema e sai do app, como em todo app Android. A activity de launcher gerada declara o voltar preditivo, então o app não escreve nada para isso, e o Android 14 em diante lê essa declaração. O Android 12 e anteriores entregam a TECLA de voltar, e o Android 13 também, porque só lê a declaração na application, onde o .NET for Android não tem atributo para escrevê-la. O shell leva a tecla ao mesmo Back(), de modo que um toque nunca é tratado duas vezes.
O teclado do telefone
Tocar num TextEntry (um TextInput, um SearchField), numa superfície de código ou numa planilha num telefone abre o teclado da plataforma, e o fim da edição o fecha. O app não escreve nada para isso: o campo que ele já tem é o que o teclado edita.
O teclado de um telefone edita um DOCUMENTO em vez de mandar teclas: ele lê o texto em volta do cursor, troca a palavra que autocorrigiu, marca a palavra para a qual está sugerindo, recebe um deslize ou um ditado. Por isso os shells entregam ao host o novo estado inteiro do campo, um TextEditingValue (o texto, a seleção e a composição, com os nomes do Flutter), pelo PhotonHost.UpdateEditingValue, e o app ouve o texto novo uma vez, pelo OnChanged, como na digitação. A palavra sendo composta está NO valor e é desenhada sublinhada, então uma busca filtra enquanto a palavra é digitada. Um app que reescreve o valor (uma máscara, um limite de tamanho) encerra a composição, e o teclado é avisado. O TextInputSession mantém os dois em sincronia: depois de cada quadro ele diz se o teclado abre, recomeça em outro campo, fecha ou recebe um valor que o app mudou, e nunca devolve ao teclado uma edição que o próprio teclado fez. Uma superfície de código e uma planilha guardam o texto para si e recebem as teclas e o texto confirmado do teclado, como num desktop, então código nunca é autocorrigido.
A área do teclado é o PhotonHost.ViewInsets, o MediaQuery.viewInsets do Flutter. A árvore é disposta acima dela, a área segura que ela cobre conta uma vez só, e um quadro disposto mais baixo que o anterior (o teclado subindo, uma rotação, uma tela dividida) rola o campo sendo editado para o que sobrou. Onde nada rola (um formulário que não cabe no que o teclado deixa, um telefone deitado), a tela sobe até o campo aparecer, como o Android faz com uma janela sem nada para rolar, e volta quando o teclado desce. Um campo dentro de um ScrollView é o ScrollView que traz à vista, e um campo que a pessoa rola para baixo do teclado fica lá, como no Flutter. Coloque um formulário num ScrollView quando o topo dele precisar continuar ao alcance durante a edição de um campo.
A tecla de ação do teclado numa linha única (Concluído) envia o campo, como o Enter, e depois guarda o teclado junto com a edição.
•
Android: a surface view responde ao teclado com uma conexão de entrada sobre um espelho do campo, descrito a ele: um campo que esconde o que é digitado não é aprendido nem sugerido, uma linha única termina na tecla Concluído do teclado, e uma superfície de código ou uma planilha não é um documento. O primeiro voltar esconde o teclado e o campo mantém o cursor, e um toque nele traz o teclado de volta. O voltar com o teclado abaixado fecha uma camada aberta ou sai do app, como faz a partir de um campo nativo (O voltar do sistema).
•
iOS: uma text view invisível no cursor recebe o teclado, então o UIKit roda a autocorreção, as sugestões, o ditado e a composição sobre ela como faz com os próprios campos, e a barra de candidatos aparece no campo.
•
Um teclado físico ligado a um telefone ou a um tablet chega ao host como num desktop: setas, Tab, Escape e os atalhos do app, e o acento de uma tecla morta se junta à letra seguinte (´ e depois a dá á), o que o Android deixa para o editor e o shell faz. Copiar, recortar e colar dele passam pela área de transferência do sistema, o mesmo ITextClipboard que o botão de copiar de uma página pede (Capacidades).
Movimento que quer dizer o que diz
Um BoxStyle.Transition era um deslize só-web: o nativo SALTAVA para cada valor novo, e dizia isso numa cerca. Já não salta. Cores, opacidade, os componentes do transform, a sombra da elevação e um tamanho fixo interpolam sob a spec da própria caixa — a duração dela, o atraso dela, a curva dela — e um canal que o autor deixou de fora de Channels salta, pela mesma chamada.
1
2
3
4
5
6
new Box(new BoxStyle
{
Background = hovered ? theme.SurfaceHover : theme.Surface,
Elevation = hovered ? 3 : 1,
Transition = TransitionSpec.Of(StyleChannels.Colors | StyleChannels.Shadow, Motion.State),
})
Duas consequências que vale enunciar, porque nenhuma é óbvia:
•
A sombra CRESCE em vez de trocar. Um cartão que sobe da elevação 1 para 3 interpola o deslocamento, o blur, o spread e a cor; um que cai para 0 DESVANECE, o que um if (Elevation > 0) nunca conseguiria desenhar. O nível 0 é uma spec real — geometria zero, cor transparente — então não há caso especial nenhum.
•
Um tamanho a deslizar é MEDIDO no valor interpolado, então tudo o que depende dele segue: a quebra de linha do filho, a fatia dos irmãos na linha. É o que o transition: width faz num navegador, e é por isso que o canal Size vive no layout e não na pintura.
As curvas agora são reais também. O Curve eram quatro pontos de controlo sem avaliador, então o store nativo usava um smoothstep para todas: o Motion.Press produzia o cubic-bezier(.2,0,0,1) do CSS na web e outra coisa no Photon — uma intenção autoral, dois movimentos. O solucionador faz o que os navegadores fazem, então uma curva nomeada significa o mesmo nos dois alvos. O solucionador, o CurveEvaluator, é do host: na web uma transição é uma timing function do CSS, então o runtime não traz gêmeo dele, e um componente que o nomeie falha o build com EQ2010 (desde 0.2.0-preview.61).
Desde 0.2.0-preview.46
Desenhar os próprios pixels: Canvas
Todo o resto do vocabulário compõe-se de nós, o que está certo para uma interface e errado para uma visualização: um sunburst de um milhão de ficheiros, um conjunto de bolhas movidas por física, um gráfico cuja geometria é recalculada a partir de dados que não param. Isso é ARITMÉTICA sobre uma caixa, e exprimi-lo como milhares de nós retidos paga uma passagem do reconciliador para dizer o que um laço diria diretamente.
1
2
3
4
5
6
7
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: "Uso de disco por pasta")
O ponteiro chega nas coordenadas do CANVAS, que é o que torna o hit test da app: polar para um sunburst, por partícula para uma simulação. O engine entrega o ponto e não finge saber o que foi desenhado ali. Uma pressão captura, então um arrasto que sai da caixa continua a pertencer à tela; e uma tela sem handlers não regista região nenhuma, então ornamento puro nunca engole a pressão do que está por baixo.
O vocabulário do painter é o do ENGINE — retângulos arredondados, círculos, setores anulares, traços — e não há caminhos arbitrários, porque o Photon é um rasterizador SDF e um motor de caminhos é cerca de v2. Essa restrição é o que compra um segundo alvo de graça: as mesmas chamadas viram SVG inline na web, então uma visualização escreve-se uma vez. Entrada degenerada não desenha nada, pelas regras do próprio engine (raio externo não positivo, ângulo final igual ou anterior ao inicial, raio interno a alcançar o externo), e os dois alvos são fixados contra essas regras e não um contra o outro.
Desde 0.2.0-preview.46
Publicar um app macOS: o bundle .app
Since 0.2.0-preview.45
Um head desktop construído com eQuantic.UI.Sdk.Native no macOS sai como APLICAÇÃO, não como um processo com uma janela: depois do Build, o SDK escreve <ApplicationTitle>.app ao lado da saída do build — Info.plist a partir do projeto (ApplicationTitle, Version, o bundle id de EQuanticBundleId, padrão com.equantic.<assemblyname>), o ícone do mesmo Assets/AppIcon que alimenta os telefones, o manifesto de capacidades lido do assembly — e assina ad hoc sob esse identificador. Desligue com <EQuanticMacAppBundle>false</EQuanticMacAppBundle>.
Assinar um app que dá para publicar
Ad hoc está certo para desenvolvimento e é honesto sobre o que é: não prova nada sobre a origem. Um build PUBLICADO diz quem é, e no momento em que diz, o macOS aplica o hardened runtime — que proíbe coisas por omissão e não as recusa com educação. Código que precisa de uma delas não é negado, o processo é morto.
1
<EQuanticSigningIdentity>Developer ID Application: Sua Org (TEAMID)</EQuanticSigningIdentity>
Essa propriedade é o interruptor inteiro: substitui a assinatura ad hoc e liga o hardened runtime junto (o EQuanticHardenedRuntime sobrepõe-se a esse padrão nos dois sentidos).
O que o SEU código precisa, ele diz em C#, no builder, ao lado de tudo o mais que declara:
1
2
builder.Entitlements.RequireNetworkClient();
builder.Entitlements.RequireUserSelectedFiles();
O que o RUNTIME .NET precisa, o SDK declara sozinho, e esta parte vale ler duas vezes porque foi medida e não suposta: um app .NET framework-dependent com hardened runtime não arranca de todo. A validação de bibliotecas recusa o próprio libhostfxr.dylib da Microsoft antes de uma linha do seu código correr — "mapping process and mapped file (non-platform) have different Team IDs" — e enviar o runtime dentro do bundle não ajuda, o que é a suposição razoável e a errada. Então o SDK acrescenta disable-library-validation e allow-jit quando o endurecimento está ligado e o app não é AOT completo, regista no log o que acrescentou, e deixa você a declarar só o que é seu.
O PhotonEntitlements nomeia as chaves que interessam, cada uma dizendo QUANDO se precisa dela, e o Require(chave) aceita qualquer outra — a lista é da Apple e cresce. O EQ3004 recusa uma chave construída em runtime, porque entitlements são assinados no build.
Se você embute o wasmtime, a chave que declara é o RequireUnsignedExecutableMemory(), não o RequireJit(). Os nomes enganam: o allow-jit cobre páginas mapeadas à maneira da Apple (MAP_JIT), que é o que o JIT do próprio .NET usa — e é por isso que o SDK já a põe num bundle endurecido não-AOT por você. O wasmtime escreve memória executável simples, portanto aquela chave é o requisito inteiro, e declarar o RequireJit() ao lado é um no-op inofensivo. O que decide é como o motor mapeia as suas páginas, e não o facto de correr WebAssembly: outro runtime WASM pode bem usar MAP_JIT, e aí o SDK já o cobriu.
Medido com um certificado real, as três linhas: só com JIT o processo leva SIGKILL no primeiro uso, com "namespace":"CODESIGNING" e "indicator":"Invalid Page" no relatório de falha; JIT mais unsigned passa; e unsigned SOZINHO também passa, que foi o que provou que a declaração do JIT nunca foi a variável. Sob PublishAot o SDK não declara nada por você, portanto uma app AOT resolve a sua própria receita — essa ninguém mediu.
Vale a pena saber porque nada sem certificado de assinatura exercita o hardened runtime, portanto isto sobrevive a uma suite inteiramente verde e aparece no seu primeiro release notarizado.
Desde 0.2.0-preview.46
Três regras mantêm a assinatura honesta, cada uma aprendida com um app real:
•
O bundle é o payload. Contents/MacOS é reconstruído a partir da saída do build a cada vez, nunca acumulado — um arquivo que a saída deixou de produzir não fica esquecido dentro do app.
•
runtimes/ é uma allowlist (osx*, unix*). Só entram RIDs que um processo macOS consegue carregar; um pacote que também traz assets nativos browser-wasm ou linux-x64 (o Microsoft.Data.Sqlite traz) já não faz o codesign --deep recusar o bundle inteiro.
•
Falha de assinatura falha o build. Era um warning, e o app ficava com a assinatura ANTERIOR — uma identidade que muda entre builds é como um grant de Acesso Completo ao Disco evapora em silêncio, porque o TCC amarra os grants à identidade de assinatura.
A app que você entrega vem do dotnet publish
Até à 0.2.0-preview.47 não vinha. O .app era montado a partir da saída do BUILD e o publish/ tinha ficheiros soltos sem bundle nenhum, portanto toda decisão de publish parava à porta do bundle: trimming, self-contained, single-file, AOT. Medido no sample de desktop deste repositório com o trimming ligado — publish 23 MB, bundle 172 MB. A app que você entregaria era sete vezes a app que pediu, e nada do trimming lá chegava.
Agora o bundle aterra DENTRO do diretório de publish, ao lado dos ficheiros soltos, que é onde o MAUI põe o dele e portanto onde as ferramentas já procuram. Nada para configurar: o dotnet publish produz-o.
1
2
dotnet publish -c Release -r osx-arm64 --self-contained
# → bin/Release/net10.0/osx-arm64/publish/A Minha App.app
O trimming funciona, e teve de ser feito funcionar. Quatro defeitos, todos eles o trimmer a ler a MESMA evidência em que este desenho assenta e a chegar à conclusão oposta: uma app nunca nomeia o seu shell em código, então o trimmer removeu-o e a app morria no arranque a dizer que não havia shell referenciado. O SDK agora enraíza o que o SDK acrescentou, anota as costuras por onde o contentor alcança, e liga o gerador de binding de configuração no assembly que faz o bind — sem esse último, uma app abria a janela e ignorava todas as settings, em silêncio. Medido depois: 86 MB → 23 MB, zero avisos de trim, os mesmos 110 elementos de acessibilidade.
Desde 0.2.0-preview.47
Dizer o que o sistema lê sobre a sua app
O Info.plist tinha um conjunto fixo de chaves, portanto tudo o resto era indizível. Agora é C#, no mesmo builder de tudo o resto:
1
2
3
4
5
6
7
builder.Bundle
.Copyright("© 2026 Acme")
.Category(AppCategory.Utilities)
.MinimumSystemVersion("13.0")
.Agent() // app de barra de menus: sem ícone no Dock (a Apple chama-lhe LSUIElement)
.UrlScheme("acme") // acme://… abre esta app
.Key("NSAlgumaChaveNovaDaApple", "valor"); // a escotilha, ainda em C#
Uma declaração GANHA à do framework para a mesma chave, e nenhuma chave é escrita duas vezes. O Category é um enum e não public.app-category.utilities, porque essa string está a um erro de digitação de ser ignorada em silêncio e ninguém devia ter de a procurar.
Uma coisa que o SDK corrige sem perguntar: a versão da Apple são um a três inteiros, e uma versão de pré-lançamento do .NET não é. O 0.2.0-preview.46 ia inteiro para o plist e produzia um bundle que a notarização recusa, num vocabulário que não nomeia nada que você escreveu. Passa a 0.2.0, e o build diz isso uma vez.
Desde 0.2.0-preview.47
Notarização e uma imagem de disco
A Apple a examinar a app assinada e a emitir um bilhete, sem o qual uma app descarregada é recusada com "não pode ser aberta porque o programador não pode ser verificado". São sempre os mesmos três passos, portanto o SDK fá-los: zipar como o notarytool espera, submeter e ESPERAR, e depois grampear o bilhete dentro do bundle para que ele verifique sem rede nenhuma.
1
2
3
<EQuanticSigningIdentity>Developer ID Application: Acme (TEAMID)</EQuanticSigningIdentity>
<EQuanticNotaryProfile>acme-notary</EQuanticNotaryProfile>
<EQuanticPackageFormat>Dmg</EQuanticPackageFormat>
As credenciais nunca vivem no projeto: ou um perfil de keychain do notarytool, ou EQuanticNotaryAppleId + EQuanticNotaryTeamId + EQuanticNotaryPassword vindos dos segredos de um CI — um portátil tem keychain e um agente de build não tem. Cada um deles chega ao notarytool como um argumento à parte, sem nenhum shell a lê-lo, e o build não ecoa a linha de comando que carrega a senha (Desde 0.2.0-preview.62). Um log de diagnóstico ou um log binário ainda regista os parâmetros de toda task, por isso o de uma publicação notarizada não é ficheiro para partilhar. A imagem de disco é construída a partir do bundle JÁ GRAMPEADO, portanto o bilhete viaja lá dentro e a app verifica offline no momento em que é arrastada para fora.
Ainda não provado de ponta a ponta. Os passos correm pela ordem certa e falham alto perante uma credencial que não existe, e nenhum bundle foi de facto notarizado por ninguém aqui — falta um certificado Developer ID Application, que é o que a última linha disto precisa e não mais código. Dito assim porque uma página que descreve uma funcionalidade que ninguém correu é uma página que o devia dizer.
Desde 0.2.0-preview.47
O cursor, e o que uma seleção tem que mostrar
Uma seleção tem duas pontas e só uma delas se move. Qual é não é algo que uma pessoa consiga deduzir da faixa, então o cursor é desenhado JUNTO com a seleção, não no lugar dela: a faixa diz quais caracteres estão segurados, o cursor diz qual PONTA você está segurando, a que a seta com ⇧ vai mover, a partir da qual o próximo caractere substitui.
Duas regras vêm junto, e as duas são sobre estar visível quando importa:
•
A fase do piscar começa no último MOVIMENTO do cursor, não na época. Numa fase de rodagem livre, um cursor que acabou de chegar em algum lugar pode estar na metade APAGADA por meio segundo: você aperta ⇧→ e olha para nada. Toda escrita no cursor reinicia o ciclo, que é por que o host roteia todas elas por uma única propriedade.
•
Enquanto um arrasto está desenhando uma seleção o cursor não pisca de jeito nenhum. A ponta que segue o seu ponteiro é a única coisa que você está olhando.
No web o campo é um <input> de verdade e o browser é dono do cursor dele; o que está descrito aqui é o que o Photon pinta, e o que a superfície de editor (que os dois alvos pintam eles mesmos) faz.
O piscar é RITMADO, não preso
Um cursor é movimento de 2Hz, não movimento de vsync, então um cursor em edição AGENDA os frames dele em vez de manter o laço de render quente:
•
o RenderFrame deixa NeedsRender como falso quando o cursor é a única razão para um próximo frame, e registra quando a próxima TRANSIÇÃO do piscar é devida;
•
os ticks ociosos dos shells (o timer de 120Hz do macOS, os callbacks de vsync dos telefones) perguntam host.IsFrameDue(nowMs) junto com NeedsRender: uma apresentação por meio período, caindo na transição, ~2 por segundo em vez da taxa de atualização do display.
Movimento de verdade (um spinner, um deslize, uma transição) continua mantendo o laço quente, e o agendamento existe só para o cursor. Enquanto um arrasto desenha uma seleção nada é agendado: o cursor é forçado sólido e o movimento do próprio ponteiro guia os frames. Dez segundos simulados de um campo focado: 20 apresentações, cada uma virando o piscar, afirmado em CaretPacingTests, contagem incluída.
O web não precisa de nada disso: o browser é dono do cursor do <input>, e a div de cursor da superfície de editor pode piscar no compositor.
Hot reload: dotnet watch run, e o app acompanha
Rode qualquer app Photon sob dotnet watch run, edite um corpo de Build, salve, e a janela redesenha com o novo código e TODO o estado intacto, num tique. Sem comando de SDK, sem configuração: o encanamento vem em eQuantic.UI.Native.Components e todo host se inscreve na construção.
A arquitetura tem formato de hot reload por construção:
•
a árvore é RE-EXPANDIDA a partir da raiz retida a cada frame (o Build roda durante a medição), então um corpo de método remendado simplesmente passa a valer no frame seguinte;
•
o estado do componente vive num armazenamento com chave de CAMINHO, não nos objetos que uma edição substitui, então nada precisa ser migrado. Três cliques de estado de contador sobrevivem a um reload porque nada os tocou.
O PhotonHotReload (o [MetadataUpdateHandler]) fornece a peça que falta, o próprio frame seguinte, porque uma janela ociosa não apresenta nada e uma atualização de metadados chega sem nenhum evento de entrada anexado. Ele faz exatamente duas coisas:
1.
acorda todo host vivo por um bool que os laços de render já consultam (macOS em até 50ms, os telefones no próximo vsync deles), e
2.
incrementa uma GERAÇÃO que o host compara na própria thread de render dele para descartar os caches de conteúdo uma vez (rasterizações de texto, ícones, imagens). As chaves são conteúdo, então a maioria das edições passaria longe delas, mas um rasterizador remendado ou um caminho de ícone editado é exatamente a edição que uma pessoa faz enquanto ajusta o visual. Nada é mutado pela thread do handler.
Edições grosseiras (tipos novos, assinaturas mudadas) cabem ao runtime recusar. O dotnet watch responde a essas com um reinício, o que é correto e não precisa de nada do framework.
Mobile: o mesmo registro serve os hosts de iOS e Android (os ticks de vsync deles consultam o mesmo bool), então o hot reload funciona no SIMULADOR. Um dispositivo físico precisa que o dotnet watch alcance o processo, o que é uma questão de canal de deploy, não de framework.
Correr num simulador ou emulador hoje
Só o loop de desktop é um comando hoje (dotnet run, dotnet watch run); os telefones são uma receita manual até o W7 do Trilho W a dobrar para dentro do SDK como dotnet build -t:RunIos / -t:RunAndroid. O que funciona agora, e as armadilhas que custaram uma sessão cada:
•
A verificação de versão do Xcode vem desligada. O .NET para iOS recusa o build (E0191) a menos que o MAJOR.MINOR do Xcode instalado seja igual ao que o pack do seu SDK recomenda — uma comparação de versão, não uma verificação de capacidade. Instalar o Xcode "certo" também não resolve: QUAL pack resolve é decidido pelo manifesto do workload, não pelo Xcode que você tem, e uma máquina com o Xcode 27.0 e os packs 26.0, 26.5 e 27.0 ainda resolve o 26.5 e é mandada buscar o Xcode 26.6. O SDK define ValidateXcodeVersion=false para o TFM iOS, e só quando você não definiu: ponha <ValidateXcodeVersion>true</ValidateXcodeVersion> no seu csproj para ter a comparação de volta. Uma toolchain que genuinamente não consegue construir continua a falhar, com o erro da coisa que falhou.
•
Simulador iOS: xcrun simctl boot <udid>, depois dotnet build -f net10.0-ios -r iossimulator-arm64 -c Release (um build Debug instala um .app sem assemblies e crasha ao abrir), xcrun simctl install booted <caminho>.app, xcrun simctl launch booted <bundle id>. Console.WriteLine NÃO VAI A LADO NENHUM no iOS — leia o log unificado (`xcrun simctl spawn booted log show --last 45s --predicate 'processImagePath CONTAINS "<App>"'). Argumentos de launch nunca chegam ao Main`; a configuração viaja por ambiente (SIMCTL_CHILD_Photon__MaxFrames=90 xcrun simctl launch …). Um crash "AOT module … out of date" depois de reconstruir um shell é obj/ e bin/ velhos do TFM iOS — apague-os.
•
Emulador Android: arranque com -gpu host — com swiftshader_indirect não há Vulkan, o Photon cai no backend Reference (software), a thread de UI fica a 100% e todo pedido de acessibilidade expira. Construa -c Debug -p:EmbedAssembliesIntoApk=true (senão o APK não leva assemblies), depois adb install -r e adb shell am start. A configuração viaja por um ficheiro @(AndroidEnvironment) (Photon__MaxFrames=90); setprop debug.mono.env não funciona. O que o teclado da tela e o shell dizem um ao outro vai para o log depois de adb shell setprop log.tag.PhotonInput DEBUG, a partir da próxima edição (adb logcat -s PhotonInput). Um campo que esconde o que se digita mostra ali só o tamanho do texto.
Shaders: UMA fonte Slang normativa
O Sdf.slang (transliterando o Sdf.cs) compila offline pela toolchain slangc pinada em MSL + SPIR-V comitados: o fragmento SDF mais o fragmento texturizado (Load de texel, o mais próximo por definição, porque as rasterizações são em escala de dispositivo, com paridade exata com a Reference). Os desenvolvedores de app nunca rodam a toolchain de shaders; eles consomem os artefatos comitados.
Performance: as promessas ganham números
O PerfHarnessTests mede o que a definição de pronto promete ("120 Hz, zero alocações em regime, orçamentos cumpridos") em vez de assumir. Numa cena em formato de dashboard (24 cartões + uma faixa com movimento em laço), por frame em regime (baseline série M): 146 comandos de desenho, realize p50 0,23 ms / p95 0,32 ms contra o orçamento de 8,33 ms, e alocação em regime abaixo de 74 KB por frame com reciclagem de frames ligada.
O que mantém o número baixo:
•
um cache de strings de caminho: um caminho é identidade, então o host empresta um dicionário que sobrevive aos frames e o regime para de reconcatená-las;
•
uma tabela de caminhos de camada: o caminho raiz de cada camada de overlay é construído uma vez e mantido entre frames, em vez de uma string nova por camada a cada frame;
•
um DisplayListBuilder reutilizado: o Reset() mantém a capacidade do buffer; os shells rodam um builder por laço;
•
reciclagem de frames (PhotonHost.RecycleFrames, opcional, desligado por padrão): o RealizeResult carrega uma história explícita de propriedade. Com a reciclagem ligada, o host é DONO de todo frame que ele substitui, a árvore substituída alimenta um LayoutNodePool, e o próximo frame é construído a partir dele. Os shells de produção (macOS/iOS/Android) ligam isso porque nada retém os frames deles; testes e ferramentas que seguram RealizeResults deixam desligado e ficam com árvores imutáveis.
Os tetos são pinados como CATRACAS de regressão com uma margem acima dos números medidos (152 KB no perfil padrão / 74 KB com pool, 200 comandos, alarme de 33 ms, com o tempo deliberadamente frouxo para que um CI compartilhado nunca fique instável). A metade de GPU do frame segue coberta por goldens de paridade, não por cronometragem no CI.
Uma tela com camadas abertas tem uma régua própria. A cena densa não tem overlay, então o teto com pool dela não diz nada sobre um menu, uma sheet e um tooltip abertos; a régua é o que UMA camada acrescenta, a diferença entre uma cena com 24 camadas e outra com 32, dividida pelas oito entre elas. A parte densa são os mesmos bytes nas duas, e entre 24 e 32 as listas por frame do realizador mantêm uma capacidade só, então o que sobra é o custo de uma camada e não o de uma lista. O teto é de 528 bytes por camada, o que deixa uma folga menor que o menor objeto que o .NET aloca: um objeto a mais por camada falha. O harness roda sozinho (PerfHarnessCollection, com a paralelização desligada), porque uma régua de bytes por frame medida ao lado do resto da suíte mede um pouco dela também.
IME: a composição é o texto da plataforma
Enquanto um campo segura o cursor, o shell do macOS entrega os eventos de tecla ao contexto de entrada da PLATAFORMA (NSTextInputClient: a view conforma, registrada em tempo de execução como toda outra sobreposição):
•
Uma tecla morta ou um método CJK compõe ao longo de vários toques como texto marcado: segurado pelo host (SetMarkedText), renderizado INLINE no cursor com um sublinhado, enquanto o VALOR do campo fica intocado. O commit (CommitText) entra no campo pela mesma porta que todo toque de tecla usa; o cancelamento deixa o campo exatamente como estava. Compor sobre uma seleção a substitui, a mesma regra que digitar tem. Um campo obscurecido compõe às cegas (ecoar a composição ecoaria o segredo).
•
As teclas de edição VOLTAM do contexto de entrada como seletores (doCommandBySelector:) e reentram pela mesma porta OnKey, re-soletradas para nomes do DOM: Backspace, setas (com variantes Shift/Alt), Home/End, Escape. Acordes de Command/Control nunca entram no contexto: ⌘A/⌘C/⌘Z pertencem ao app.
•
O firstRectForCharacterRange ancora a janela de candidatos no CURSOR (host CaretRect() → inversão de y → view → janela → tela), então o seletor CJK aparece sob a composição.
•
Prova: ImeCompositionTests no host (sequência de tecla morta, cancelamento, composição sobre seleção, acompanhamento do cursor, intervalos, perda de foco descarta a composição), e o autoteste da janela dirige os seletores registrados DE VERDADE (setMarkedText: → insertText:) contra um campo vivo. Digite option+e e em qualquer campo para ver o é compor.
•
Escopo: superfícies de código compõem às cegas. O commit funciona, mas a face do editor não renderiza trecho marcado inline.
Num telefone a composição não fica à parte: o teclado a mantém dentro do texto do campo, e o host só a marca (O teclado do telefone).
Acessibilidade: a árvore de semântica
Desde 0.2.0-preview.29
O Photon desenha os próprios pixels, então o SO vê uma view opaca, e sem uma árvore de semântica o app é INVISÍVEL para um leitor de tela. A árvore é um artefato de primeira classe do frame:
•
SemanticsTree.Collect(frame) (compartilhado, neutro em relação ao alvo) deriva a semântica em ordem de leitura a partir do layout realizado, tanto da página QUANTO das camadas de overlay, então diálogos são vistos. Papéis: StaticText, Button, Link, TextField, CodeField, Slider (um Adjustable), Image (Icon rotulado), Checkbox, Switch, GridCell, ProgressIndicator, Group, um para cada papel de pressionável, e a barra de abas, o grupo de radios, o combobox e os diálogos (ambos abaixo). O texto interno de um controle é o nome acessível dele (a regra <button>texto</button> do web); um Icon rotulado anuncia, um sem rótulo é decoração; o TextEntry.Label nomeia um campo. A identidade é o CAMINHO de layout, a mesma identidade que toques, foco e rolagem usam, porque a árvore é reconstruída a cada frame e referências envelhecem.
•
Conteúdo rolado para fora é incluído, exatamente como o FocusStop e deliberadamente ao contrário das regiões de hit de ponteiro: a navegação linear alcança abaixo da dobra; o corte guarda o ponteiro, não o leitor.
•
PhotonHost.Semantics() expõe a árvore; PhotonHost.ActivatePath(path) aciona um controle do jeito que um toque aciona (resolvido a partir das regiões do frame atual, recusando desabilitados); elementos ajustáveis respondem a ações de incremento/decremento.
•
Ponte do macOS (PhotonAccessibility): a view de conteúdo responde accessibilityChildren com EQAXElements (um NSAccessibilityElement que pode ser pressionado): papel e subrole, rótulo, valor, habilitação, accessibilityFrameInParentSpace com y invertido, o caminho como accessibilityIdentifier, e o accessibilityPerformPress roteado por ActivatePath. Os elementos são reconstruídos a cada consulta; o lote anterior é liberado.
•
Ponte do iOS (PhotonAccessibility): a PhotonView é um CONTÊINER de acessibilidade cujos elementos são construídos da árvore em ordem de leitura: papel mapeado em UIAccessibilityTraits, frames convertidos para o espaço da tela, o caminho como accessibilityIdentifier, e o toque duplo e o gesto de ajuste exportados como accessibilityActivate / accessibilityIncrement / accessibilityDecrement, roteados por ActivatePath e AdjustPath. Uma marcação é um botão cujo VALOR é "1"/"0", o contrato do próprio UIKit para um alternador, e por isso o estado é anunciado no idioma do usuário sem o framework publicar nenhuma dessas palavras. A divulgação anda no accessibilityExpandedStatus do iOS 17. Os elementos são reconstruídos quando a árvore MUDA, e não a cada consulta (o VoiceOver pede a lista enquanto caminha por ela), e o LayoutChanged só é postado enquanto o VoiceOver está ligado.
•
Ponte do Android (PhotonAccessibility, um AccessibilityNodeProvider): a PhotonSurfaceView responde por uma hierarquia VIRTUAL: um filho por nó de semântica, o papel como o nome de classe da plataforma (android.widget.Button, CheckBox, RadioButton, Switch, SeekBar, EditText, ImageView, TextView), limites em pixels de tela, ACTION_CLICK por ActivatePath, rolagem para frente/trás por AdjustPath, e a exploração por toque respondida no dispatchHoverEvent. Lá a divulgação é uma AÇÃO em vez de um sinalizador: oferecer expandir é como um nó diz que está fechado. A view precisa se declarar IMPORTANT_FOR_ACCESSIBILITY_YES, já que ela é a única view da janela, e sem isso a janela fica sem raiz acessível nenhuma.
•
Duas cercas, uma em cada telefone: nenhuma das plataformas tem um terceiro estado de marcação, então um checkbox mixed é anunciado sem estado em vez de alegar que está desmarcado; e o Android não tem classe de link, então um link é anunciado como aquilo que ele faz.
•
Prova: os SemanticsTests pinam ordem, nomeação, visibilidade de diálogo, exposição de desabilitados e a ação de ativar, mais a PORTEIRA DE PARIDADE SemanticsMatchFocusStops, que impede o percurso de semântica e o percurso de entrada de se afastarem um do outro. O autoteste de cada shell então pergunta à PLATAFORMA, não à árvore: a janela do macOS imprime seus accessibilityChildren vivos, o controlador do iOS envia os seletores Objective-C de verdade, e no Android o uiautomator dump lê o app de fora pelo mesmo caminho de serviço que o TalkBack usa. A mesma tela responde com os mesmos 26 elementos nos três.
•
A paridade no web é nativa do realizador web (<button>/aria-label/placeholder de verdade), então nada é devido lá. A árvore é neutra em relação ao alvo e agora todo shell tem a sua ponte.
Cada papel de pressionável, nas palavras de cada plataforma
Desde 0.2.0-preview.60
Um radio, uma aba, um item de menu, uma opção e um destino de navegação chegam à árvore cada um com o seu próprio papel, com o estado onde o web o coloca, e cada ponte o diz nas palavras da própria plataforma. Até esta versão, os cinco eram anunciados como botão.
PressableRole
macOS (AppKit)
Classe no Android
iOS (UIKit)
Estado
Radio
AXRadioButton
RadioButton
botão, selecionado quando escolhido
marcado, como aria-checked
Tab
AXRadioButton, subrole AXTabButton
View
botão
selecionado, como aria-selected
MenuItem
AXMenuItem
MenuItem
botão
nenhum
Option
AXMenuItem
View
botão
selecionado, como aria-selected
Destination
AXButton
Button
botão
atual, como aria-current
As palavras do AppKit e do Android seguem o Core-AAM do W3C para o mesmo papel ARIA, que é o que o Safari e o Chrome expõem para a metade web do mesmo componente, com dois desvios. Uma opção é AXMenuItem, a palavra de uma escolha nos popups do próprio macOS, onde o AXStaticText do Core-AAM a leria como um parágrafo. Um destino mantém as palavras de um botão nas três, como o <button aria-current="page"> do web, porque um ListItem só é destino enquanto é a linha atual. O Android não tem classe para uma aba, uma opção ou um item de menu sozinhos, então o TalkBack lê as palavras do item e o estado dele, do jeito que soam os menus do próprio Android. O UIKit não tem papel para nenhum dos cinco, e diz um radio escolhido do jeito que os segmented controls dele dizem, com o trait Selected, em vez do "1" ou "0" de um alternador.
Onde são ouvidos: os itens de um menu, as opções de um select e de um seletor de horário, os destinos de uma barra de navegação e de um rail, a linha atual de uma lista, e as abas e os radios de um Tabs, de um RadioGroup e de um SegmentedControl, cada um lido sozinho dentro do seu grupo (abaixo). Nenhum item diz ainda onde fica no seu conjunto (#502).
Barras de abas, grupos de radios, comboboxes e diálogos
Desde 0.2.0-preview.60
Uma faixa de abas, um grupo de radios, o campo de um select e um diálogo aberto chegam à árvore como o que são, com as palavras que o web dá a eles: tablist, radiogroup, combobox, dialog e alertdialog. Até esta versão, um Tabs e um RadioGroup eram um único slider sem nome cujas abas e radios nunca eram lidos (#500), o campo de um select era um botão que expande, e um diálogo era um grupo comum com nome (#501).
SemanticRole
macOS (AppKit)
Classe no Android
iOS (UIKit)
Onde
TabBar
AXTabGroup
TabWidget
um grupo
a faixa de um Tabs
RadioGroup
AXRadioGroup
RadioGroup
um grupo
um RadioGroup, um SegmentedControl
ComboBox
AXPopUpButton
Spinner
botão, expandido ou recolhido
o campo de um Select, o de um TimePicker
Dialog
AXGroup, subrole AXApplicationDialog
Dialog
um grupo
um Overlay modal aberto, o calendário de um DatePicker
AlertDialog
AXGroup, subrole AXApplicationAlertDialog
AlertDialog
um grupo
um Dialog cuja ação é destrutiva
•
Uma barra de abas e um grupo de radios são contêineres. Um leitor para na barra ou no grupo, ouve o nome dele quando o autor deu um (o rótulo de um RadioGroup), e depois cada aba ou radio por vez, com o fato de estar escolhido ou marcado; ativar um deles roda o mesmo handler que um toque roda. O teclado não mudou: a barra continua sendo uma única parada de Tab, e as setas dela continuam mudando a escolha. O grupo em si não aceita o gesto de ajuste em nenhuma plataforma, assim como os grupos de abas do macOS e os grupos de radios do Android não aceitam.
•
O combobox é o campo de onde uma lista abre: o pressionável de onde pende um painel de listbox, como no web, dizendo se a lista está aberta. O gatilho de um menu continua sendo um botão. Todo combobox que a biblioteca constrói é só de seleção, então ele usa as palavras do dropdown de cada plataforma, o AXPopUpButton do NSPopUpButton e o Spinner do Android, que o TalkBack chama de "lista suspensa", em vez das de um combobox editável que o Core-AAM dá.
•
Um diálogo é uma camada modal aberta, nomeada pelo seu rótulo; uma confirmação destrutiva é um diálogo de alerta. O próprio AppKit descreve o AXGroup do diálogo como "group", então é o subrole que o marca como diálogo. A camada que um menu, um select ou um tooltip abertos abrem no Photon não é diálogo nem parada: o web não tem camada ali. O calendário de um seletor de data é um diálogo, como é no web.
•
O UIKit não tem trait para nenhum dos quatro contêineres. Ele diz uma barra de abas e uma view modal com uma propriedade de um CONTÊINER que não é ele mesmo um elemento, que a ponte do iOS não constrói, então ele os ouve como um grupo que carrega o nome.
Status
A suíte nativa pina o comportamento do motor com 91 imagens golden comitadas (pares de componentes claro+escuro, estados de folha no meio do arrasto, cobertura de textura) ao lado das suítes de unidade e de host. A janela roda o showroom write-once com texto, ícones, menus e gestos de verdade nos shells de macOS, iOS e Android. Cerca de escopo atual: decodificação/upload de imagem não está incluída, então o Image renderiza a superfície de placeholder dele.