feat(macos): add Canvas A2UI renderer

This commit is contained in:
Peter Steinberger
2025-12-17 11:35:06 +01:00
parent 1cdebb68a0
commit cdb5ddb2da
408 changed files with 73598 additions and 32 deletions

View File

@@ -0,0 +1,36 @@
/*
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { Binding, InjectionToken, Type } from '@angular/core';
import { DynamicComponent } from './dynamic-component';
import { Types } from '@a2ui/lit/0.8';
export type CatalogLoader = () =>
| Promise<Type<DynamicComponent<any>>>
| Type<DynamicComponent<any>>;
export type CatalogEntry<T extends Types.AnyComponentNode> =
| CatalogLoader
| {
type: CatalogLoader;
bindings: (data: T) => Binding[];
};
export interface Catalog {
[key: string]: CatalogEntry<Types.AnyComponentNode>;
}
export const Catalog = new InjectionToken<Catalog>('Catalog');

View File

@@ -0,0 +1,100 @@
/*
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { Types, Primitives } from '@a2ui/lit/0.8';
import { Directive, inject, input } from '@angular/core';
import { MessageProcessor } from '../data';
import { Theme } from './theming';
let idCounter = 0;
@Directive({
host: {
'[style.--weight]': 'weight()',
},
})
export abstract class DynamicComponent<T extends Types.AnyComponentNode = Types.AnyComponentNode> {
protected readonly processor = inject(MessageProcessor);
protected readonly theme = inject(Theme);
readonly surfaceId = input.required<Types.SurfaceID | null>();
readonly component = input.required<T>();
readonly weight = input.required<string | number>();
protected sendAction(action: Types.Action): Promise<Types.ServerToClientMessage[]> {
const component = this.component();
const surfaceId = this.surfaceId() ?? undefined;
const context: Record<string, unknown> = {};
if (action.context) {
for (const item of action.context) {
if (item.value.literalBoolean) {
context[item.key] = item.value.literalBoolean;
} else if (item.value.literalNumber) {
context[item.key] = item.value.literalNumber;
} else if (item.value.literalString) {
context[item.key] = item.value.literalString;
} else if (item.value.path) {
const path = this.processor.resolvePath(item.value.path, component.dataContextPath);
const value = this.processor.getData(component, path, surfaceId);
context[item.key] = value;
}
}
}
const message: Types.A2UIClientEventMessage = {
userAction: {
name: action.name,
sourceComponentId: component.id,
surfaceId: surfaceId!,
timestamp: new Date().toISOString(),
context,
},
};
return this.processor.dispatch(message);
}
protected resolvePrimitive(value: Primitives.StringValue | null): string | null;
protected resolvePrimitive(value: Primitives.BooleanValue | null): boolean | null;
protected resolvePrimitive(value: Primitives.NumberValue | null): number | null;
protected resolvePrimitive(
value: Primitives.StringValue | Primitives.BooleanValue | Primitives.NumberValue | null,
) {
const component = this.component();
const surfaceId = this.surfaceId();
if (!value || typeof value !== 'object') {
return null;
} else if (value.literal != null) {
return value.literal;
} else if (value.path) {
return this.processor.getData(component, value.path, surfaceId ?? undefined);
} else if ('literalString' in value) {
return value.literalString;
} else if ('literalNumber' in value) {
return value.literalNumber;
} else if ('literalBoolean' in value) {
return value.literalBoolean;
}
return null;
}
protected getUniqueId(prefix: string) {
return `${prefix}-${idCounter++}`;
}
}

View File

@@ -0,0 +1,20 @@
/*
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export * from './catalog';
export * from './dynamic-component';
export * from './renderer';
export * from './theming';

View File

@@ -0,0 +1,109 @@
/*
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {
Binding,
ComponentRef,
Directive,
DOCUMENT,
effect,
inject,
input,
inputBinding,
OnDestroy,
PLATFORM_ID,
Type,
untracked,
ViewContainerRef,
} from '@angular/core';
import { Types, Styles } from '@a2ui/lit/0.8';
import { Catalog } from './catalog';
import { isPlatformBrowser } from '@angular/common';
@Directive({
selector: 'ng-container[a2ui-renderer]',
})
export class Renderer implements OnDestroy {
private viewContainerRef = inject(ViewContainerRef);
private catalog = inject(Catalog);
private static hasInsertedStyles = false;
private currentRef: ComponentRef<unknown> | null = null;
private isDestroyed = false;
readonly surfaceId = input.required<Types.SurfaceID>();
readonly component = input.required<Types.AnyComponentNode>();
constructor() {
effect(() => {
const surfaceId = this.surfaceId();
const component = this.component();
untracked(() => this.render(surfaceId, component));
});
const platformId = inject(PLATFORM_ID);
const document = inject(DOCUMENT);
if (!Renderer.hasInsertedStyles && isPlatformBrowser(platformId)) {
const styles = document.createElement('style');
styles.textContent = Styles.structuralStyles;
document.head.appendChild(styles);
Renderer.hasInsertedStyles = true;
}
}
ngOnDestroy(): void {
this.isDestroyed = true;
this.clear();
}
private async render(surfaceId: Types.SurfaceID, component: Types.AnyComponentNode) {
const config = this.catalog[component.type];
let newComponent: Type<unknown> | null = null;
let componentBindings: Binding[] | null = null;
if (typeof config === 'function') {
newComponent = await config();
} else if (typeof config === 'object') {
newComponent = await config.type();
componentBindings = config.bindings(component as any);
}
this.clear();
if (newComponent && !this.isDestroyed) {
const bindings = [
inputBinding('surfaceId', () => surfaceId),
inputBinding('component', () => component),
inputBinding('weight', () => component.weight ?? 'initial'),
];
if (componentBindings) {
bindings.push(...componentBindings);
}
this.currentRef = this.viewContainerRef.createComponent(newComponent, {
bindings,
injector: this.viewContainerRef.injector,
});
}
}
private clear() {
this.currentRef?.destroy();
this.currentRef = null;
}
}

View File

@@ -0,0 +1,22 @@
/*
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { Types } from '@a2ui/lit/0.8';
import { InjectionToken } from '@angular/core';
export const Theme = new InjectionToken<Theme>('Theme');
export type Theme = Types.Theme;