diff --git a/Source/DotNET/Engine.Specs/for_FreeformSlotArrangementEvaluator/when_evaluating_against_the_shared_fixture_corpus.cs b/Source/DotNET/Engine.Specs/for_FreeformSlotArrangementEvaluator/when_evaluating_against_the_shared_fixture_corpus.cs new file mode 100644 index 0000000..110ef78 --- /dev/null +++ b/Source/DotNET/Engine.Specs/for_FreeformSlotArrangementEvaluator/when_evaluating_against_the_shared_fixture_corpus.cs @@ -0,0 +1,72 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text.Json; +using Cratis.Scene.Engine.Layouts; +using Cratis.Scene.Model.Layouts; +using Cratis.Scene.Model.SizeClasses; + +namespace Cratis.Scene.Engine.for_FreeformSlotArrangementEvaluator; + +public class when_evaluating_against_the_shared_fixture_corpus : Specification +{ + record FixtureCase(string Name, FreeformSlotArrangement Arrangement, SizeClass SizeClass, string? ExpectedTag); + + List _cases = null!; + List<(FixtureCase Case, string? ActualTag)> _results = null!; + + void Establish() + { + var manifestPath = Path.Combine(FindRepositoryRoot(), "layout-evaluation-fixtures.json"); + using var document = JsonDocument.Parse(File.ReadAllText(manifestPath)); + + _cases = document.RootElement.GetProperty("freeformCases").EnumerateArray().Select(ToFixtureCase).ToList(); + } + + void Because() => _results = [.. _cases.Select(fixtureCase => (fixtureCase, TagOf(FreeformSlotArrangementEvaluator.Evaluate(fixtureCase.Arrangement, fixtureCase.SizeClass))))]; + + [Fact] + void should_match_the_expected_tag_for_every_case() + { + foreach (var (fixtureCase, actualTag) in _results) + { + (fixtureCase.Name, actualTag).ShouldEqual((fixtureCase.Name, fixtureCase.ExpectedTag)); + } + } + + static FixtureCase ToFixtureCase(JsonElement element) + { + var name = element.GetProperty("name").GetString()!; + + var variants = element.GetProperty("variants").EnumerateArray() + .Select(variantElement => new FreeformSlotVariant( + new SizeClass( + Enum.Parse(variantElement.GetProperty("width").GetString()!), + Enum.Parse(variantElement.GetProperty("height").GetString()!)), + [new SlotPlacement(variantElement.GetProperty("tag").GetString()!, 0, 0, 0, 0)])) + .ToList(); + + var sizeClassElement = element.GetProperty("sizeClass"); + var sizeClass = new SizeClass( + Enum.Parse(sizeClassElement.GetProperty("width").GetString()!), + Enum.Parse(sizeClassElement.GetProperty("height").GetString()!)); + + var expectedTagElement = element.GetProperty("expectedTag"); + var expectedTag = expectedTagElement.ValueKind == JsonValueKind.Null ? null : expectedTagElement.GetString(); + + return new(name, new FreeformSlotArrangement(variants), sizeClass, expectedTag); + } + + static string? TagOf(FreeformSlotVariant? variant) => variant?.Placements.Single().SlotName; + + static string FindRepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "Scene.slnx"))) + { + directory = directory.Parent; + } + + return directory?.FullName ?? throw new DirectoryNotFoundException("Could not locate the repository root (Scene.slnx) above " + AppContext.BaseDirectory); + } +} diff --git a/Source/DotNET/Engine/Layouts/FreeformSlotArrangementEvaluator.cs b/Source/DotNET/Engine/Layouts/FreeformSlotArrangementEvaluator.cs new file mode 100644 index 0000000..8c37cab --- /dev/null +++ b/Source/DotNET/Engine/Layouts/FreeformSlotArrangementEvaluator.cs @@ -0,0 +1,29 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Scene.Model.Layouts; +using Cratis.Scene.Model.SizeClasses; + +namespace Cratis.Scene.Engine.Layouts; + +/// +/// Evaluates a for a given - the counterpart +/// to for a 's own macro +/// , which places the layout's slots themselves rather than the content of +/// one slot. +/// +public static class FreeformSlotArrangementEvaluator +{ + /// + /// Selects the that targets a given . + /// + /// The to evaluate. + /// The current . + /// + /// The variant whose exactly matches, or + /// when nothing targets it. There is deliberately no fallback here - a size class with no matching + /// variant is a design-time/build-time warning elsewhere, never a silently picked variant. + /// + public static FreeformSlotVariant? Evaluate(FreeformSlotArrangement arrangement, SizeClass sizeClass) => + arrangement.Variants.FirstOrDefault(variant => variant.SizeClass == sizeClass); +} diff --git a/Source/DotNET/Model/Layouts/Arrangement.cs b/Source/DotNET/Model/Layouts/Arrangement.cs index 1c7f478..2bfb068 100644 --- a/Source/DotNET/Model/Layouts/Arrangement.cs +++ b/Source/DotNET/Model/Layouts/Arrangement.cs @@ -4,7 +4,12 @@ namespace Cratis.Scene.Model.Layouts; /// -/// How a 's content is arranged: (reflowing, computed per -/// size class) or (one placement variant per size class). +/// How content is arranged: (reflowing, computed per size class) or +/// / (one placement variant per size +/// class). Used at two levels - a 's own positions its +/// named s relative to each other (leaves reference a slot by name: / +/// ), while a 's own positions +/// that slot's own filled content elements (leaves carry the element itself: / +/// ). /// public abstract record Arrangement; diff --git a/Source/DotNET/Model/Layouts/FlowSlotLeaf.cs b/Source/DotNET/Model/Layouts/FlowSlotLeaf.cs new file mode 100644 index 0000000..4c2dc68 --- /dev/null +++ b/Source/DotNET/Model/Layouts/FlowSlotLeaf.cs @@ -0,0 +1,12 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Scene.Model.Layouts; + +/// +/// A leaf that positions one of a 's own named s +/// within its macro tree - the counterpart to , which +/// positions an element within a single slot's own content instead of positioning a slot within the layout. +/// +/// The name of the being positioned. +public record FlowSlotLeaf(string SlotName) : FlowNode; diff --git a/Source/DotNET/Model/Layouts/FreeformSlotArrangement.cs b/Source/DotNET/Model/Layouts/FreeformSlotArrangement.cs new file mode 100644 index 0000000..3a60b6a --- /dev/null +++ b/Source/DotNET/Model/Layouts/FreeformSlotArrangement.cs @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Scene.Model.Layouts; + +/// +/// Arranges a 's own named s with one placement variant per size +/// class - the Xcode-storyboard model applied to the layout's slots themselves, rather than to the content +/// of one slot (see ). The same slot set is shared across every variant; +/// only placement differs. +/// +/// The placement variants, one per targeted size class. +public record FreeformSlotArrangement(IReadOnlyList Variants) : Arrangement; diff --git a/Source/DotNET/Model/Layouts/FreeformSlotVariant.cs b/Source/DotNET/Model/Layouts/FreeformSlotVariant.cs new file mode 100644 index 0000000..c33eb30 --- /dev/null +++ b/Source/DotNET/Model/Layouts/FreeformSlotVariant.cs @@ -0,0 +1,16 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Scene.Model.SizeClasses; + +namespace Cratis.Scene.Model.Layouts; + +/// +/// One placement variant of a , targeting a specific size class - the +/// counterpart to , which places elements within a single slot instead of +/// placing a layout's own slots. A size class with no matching variant is a compiler/engine warning, never +/// a silent fallback, matching . +/// +/// The size class this variant targets. +/// Where each of the layout's slots is placed for this variant. +public record FreeformSlotVariant(SizeClass SizeClass, IReadOnlyList Placements); diff --git a/Source/DotNET/Model/Layouts/Layout.cs b/Source/DotNET/Model/Layouts/Layout.cs index 781c342..ad44a57 100644 --- a/Source/DotNET/Model/Layouts/Layout.cs +++ b/Source/DotNET/Model/Layouts/Layout.cs @@ -10,4 +10,11 @@ namespace Cratis.Scene.Model.Layouts; /// /// The layout's name. /// The slots the layout declares, in declaration order. -public record Layout(string Name, IReadOnlyList Slots); +/// +/// How the layout's own position relative to each other - a +/// (leaves are ) or , or +/// for the slots' declaration order with no further positioning information. Distinct from each +/// 's own , which positions that one slot's filled +/// content instead of positioning the slots themselves. +/// +public record Layout(string Name, IReadOnlyList Slots, Arrangement? Arrangement = null); diff --git a/Source/DotNET/Model/Layouts/SlotPlacement.cs b/Source/DotNET/Model/Layouts/SlotPlacement.cs new file mode 100644 index 0000000..295691d --- /dev/null +++ b/Source/DotNET/Model/Layouts/SlotPlacement.cs @@ -0,0 +1,16 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Scene.Model.Layouts; + +/// +/// The absolute position and size of one of a 's own named s within a +/// - the counterpart to , which places an +/// element within a single slot's own content instead of placing a slot within the layout. +/// +/// The name of the being placed. +/// The horizontal offset. +/// The vertical offset. +/// The width. +/// The height. +public record SlotPlacement(string SlotName, double X, double Y, double Width, double Height); diff --git a/Source/JavaScript/engine/evaluateFreeformSlotArrangement.ts b/Source/JavaScript/engine/evaluateFreeformSlotArrangement.ts new file mode 100644 index 0000000..8b2ddaf --- /dev/null +++ b/Source/JavaScript/engine/evaluateFreeformSlotArrangement.ts @@ -0,0 +1,17 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { FreeformSlotArrangement, FreeformSlotVariant, SizeClass } from '@cratis/scene.model'; + +/** + * Selects the {@link FreeformSlotVariant} that targets a given {@link SizeClass} - the counterpart to + * {@link evaluateFreeformArrangement} for a `Layout`'s own macro `arrangement`, which places the layout's + * slots themselves rather than the content of one slot. + * + * @param arrangement The {@link FreeformSlotArrangement} to evaluate. + * @param sizeClass The current {@link SizeClass}. + * @returns The variant whose size class exactly matches, or `undefined` when nothing targets it. There is deliberately no fallback here - a size class with no matching variant is a design-time/build-time warning elsewhere, never a silently picked variant. + */ +export function evaluateFreeformSlotArrangement(arrangement: FreeformSlotArrangement, sizeClass: SizeClass): FreeformSlotVariant | undefined { + return arrangement.variants.find(variant => variant.sizeClass.width === sizeClass.width && variant.sizeClass.height === sizeClass.height); +} diff --git a/Source/JavaScript/engine/for_evaluateFreeformSlotArrangement/when_evaluating_against_the_shared_fixture_corpus.ts b/Source/JavaScript/engine/for_evaluateFreeformSlotArrangement/when_evaluating_against_the_shared_fixture_corpus.ts new file mode 100644 index 0000000..ea8ab02 --- /dev/null +++ b/Source/JavaScript/engine/for_evaluateFreeformSlotArrangement/when_evaluating_against_the_shared_fixture_corpus.ts @@ -0,0 +1,47 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { FreeformSlotArrangement, HeightSizeClass, SizeClass, WidthSizeClass } from '@cratis/scene.model'; +import { evaluateFreeformSlotArrangement } from '../index'; + +interface FixtureVariant { + width: WidthSizeClass; + height: HeightSizeClass; + tag: string; +} + +interface FixtureCase { + name: string; + variants: FixtureVariant[]; + sizeClass: SizeClass; + expectedTag: string | null; +} + +interface FixtureCorpus { + freeformCases: FixtureCase[]; +} + +const manifestPath = join(import.meta.dirname, '..', '..', '..', '..', 'layout-evaluation-fixtures.json'); +const corpus = JSON.parse(readFileSync(manifestPath, 'utf-8')) as FixtureCorpus; + +describe('when evaluating against the shared fixture corpus', () => { + for (const fixtureCase of corpus.freeformCases) { + it(`should match the expected tag for "${fixtureCase.name}"`, () => { + const arrangement: FreeformSlotArrangement = { + variants: fixtureCase.variants.map(variant => ({ + sizeClass: { width: variant.width, height: variant.height }, + placements: [{ slotName: variant.tag, x: 0, y: 0, width: 0, height: 0 }], + })), + }; + + const result = evaluateFreeformSlotArrangement(arrangement, fixtureCase.sizeClass); + if (fixtureCase.expectedTag === null) { + (result === undefined).should.be.true; + } else { + result!.placements[0].slotName.should.equal(fixtureCase.expectedTag); + } + }); + } +}); diff --git a/Source/JavaScript/engine/index.ts b/Source/JavaScript/engine/index.ts index 0add27c..11e19be 100644 --- a/Source/JavaScript/engine/index.ts +++ b/Source/JavaScript/engine/index.ts @@ -10,6 +10,7 @@ export * from './resolveComponentName'; export * from './computeSizeClass'; export * from './evaluateFlowArrangement'; export * from './evaluateFreeformArrangement'; +export * from './evaluateFreeformSlotArrangement'; export * from './aggregateContributions'; export * from './themeCompatibility'; export * from './buildStarterProfile'; diff --git a/Source/JavaScript/model/layouts/Arrangement.ts b/Source/JavaScript/model/layouts/Arrangement.ts index abd8352..ebd71f8 100644 --- a/Source/JavaScript/model/layouts/Arrangement.ts +++ b/Source/JavaScript/model/layouts/Arrangement.ts @@ -2,7 +2,11 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. /** - * How a {@link Slot}'s content is arranged: {@link FlowArrangement} (reflowing, computed per size class) - * or {@link FreeformArrangement} (one placement variant per size class). + * How content is arranged: {@link FlowArrangement} (reflowing, computed per size class) or + * {@link FreeformArrangement}/{@link FreeformSlotArrangement} (one placement variant per size class). Used + * at two levels - a {@link Layout}'s own `arrangement` positions its named {@link Slot}s relative to each + * other (leaves reference a slot by name: {@link FlowSlotLeaf}/{@link SlotPlacement}), while a + * {@link Slot}'s own `arrangement` positions that slot's own filled content elements (leaves carry the + * element itself: {@link FlowLeaf}/{@link ElementPlacement}). */ export interface Arrangement {} diff --git a/Source/JavaScript/model/layouts/FlowSlotLeaf.ts b/Source/JavaScript/model/layouts/FlowSlotLeaf.ts new file mode 100644 index 0000000..3c8bcae --- /dev/null +++ b/Source/JavaScript/model/layouts/FlowSlotLeaf.ts @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { FlowNode } from './FlowNode'; + +/** + * A {@link FlowNode} leaf that positions one of a {@link Layout}'s own named {@link Slot}s within its + * macro `arrangement` tree - the counterpart to {@link FlowLeaf}, which positions an element within a + * single slot's own content instead of positioning a slot within the layout. + */ +export interface FlowSlotLeaf extends FlowNode { + slotName: string; +} + +export const FlowSlotLeafPropertyNames: (keyof FlowSlotLeaf)[] = ['slotName']; diff --git a/Source/JavaScript/model/layouts/FreeformSlotArrangement.ts b/Source/JavaScript/model/layouts/FreeformSlotArrangement.ts new file mode 100644 index 0000000..f515c29 --- /dev/null +++ b/Source/JavaScript/model/layouts/FreeformSlotArrangement.ts @@ -0,0 +1,17 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { Arrangement } from './Arrangement'; +import { FreeformSlotVariant } from './FreeformSlotVariant'; + +/** + * Arranges a {@link Layout}'s own named {@link Slot}s with one placement variant per size class - the + * Xcode-storyboard model applied to the layout's slots themselves, rather than to the content of one slot + * (see {@link FreeformArrangement}). The same slot set is shared across every variant; only placement + * differs. + */ +export interface FreeformSlotArrangement extends Arrangement { + variants: FreeformSlotVariant[]; +} + +export const FreeformSlotArrangementPropertyNames: (keyof FreeformSlotArrangement)[] = ['variants']; diff --git a/Source/JavaScript/model/layouts/FreeformSlotVariant.ts b/Source/JavaScript/model/layouts/FreeformSlotVariant.ts new file mode 100644 index 0000000..79d03db --- /dev/null +++ b/Source/JavaScript/model/layouts/FreeformSlotVariant.ts @@ -0,0 +1,18 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { SizeClass } from '../sizeClasses'; +import { SlotPlacement } from './SlotPlacement'; + +/** + * One placement variant of a {@link FreeformSlotArrangement}, targeting a specific size class - the + * counterpart to {@link FreeformVariant}, which places elements within a single slot instead of placing a + * layout's own slots. A size class with no matching variant is a compiler/engine warning, never a silent + * fallback, matching {@link FreeformVariant}. + */ +export interface FreeformSlotVariant { + sizeClass: SizeClass; + placements: SlotPlacement[]; +} + +export const FreeformSlotVariantPropertyNames: (keyof FreeformSlotVariant)[] = ['sizeClass', 'placements']; diff --git a/Source/JavaScript/model/layouts/Layout.ts b/Source/JavaScript/model/layouts/Layout.ts index 1d8dae6..409e0c1 100644 --- a/Source/JavaScript/model/layouts/Layout.ts +++ b/Source/JavaScript/model/layouts/Layout.ts @@ -1,6 +1,7 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { Arrangement } from './Arrangement'; import { Slot } from './Slot'; /** @@ -11,6 +12,15 @@ import { Slot } from './Slot'; export interface Layout { name: string; slots: Slot[]; + + /** + * How the layout's own `slots` position relative to each other - a {@link FlowArrangement} (leaves + * are {@link FlowSlotLeaf}) or {@link FreeformSlotArrangement}, or `undefined` for the slots' + * declaration order with no further positioning information. Distinct from each {@link Slot}'s own + * `arrangement`, which positions that one slot's filled content instead of positioning the slots + * themselves. + */ + arrangement?: Arrangement; } -export const LayoutPropertyNames: (keyof Layout)[] = ['name', 'slots']; +export const LayoutPropertyNames: (keyof Layout)[] = ['name', 'slots', 'arrangement']; diff --git a/Source/JavaScript/model/layouts/SlotPlacement.ts b/Source/JavaScript/model/layouts/SlotPlacement.ts new file mode 100644 index 0000000..c04e88e --- /dev/null +++ b/Source/JavaScript/model/layouts/SlotPlacement.ts @@ -0,0 +1,17 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * The absolute position and size of one of a {@link Layout}'s own named {@link Slot}s within a + * {@link FreeformSlotVariant} - the counterpart to {@link ElementPlacement}, which places an element + * within a single slot's own content instead of placing a slot within the layout. + */ +export interface SlotPlacement { + slotName: string; + x: number; + y: number; + width: number; + height: number; +} + +export const SlotPlacementPropertyNames: (keyof SlotPlacement)[] = ['slotName', 'x', 'y', 'width', 'height']; diff --git a/Source/JavaScript/model/layouts/index.ts b/Source/JavaScript/model/layouts/index.ts index 911f55c..206308f 100644 --- a/Source/JavaScript/model/layouts/index.ts +++ b/Source/JavaScript/model/layouts/index.ts @@ -12,6 +12,10 @@ export * from './FlowRow'; export * from './FlowColumn'; export * from './FlowGrid'; export * from './FlowLeaf'; +export * from './FlowSlotLeaf'; export * from './FreeformArrangement'; export * from './FreeformVariant'; export * from './ElementPlacement'; +export * from './FreeformSlotArrangement'; +export * from './FreeformSlotVariant'; +export * from './SlotPlacement'; diff --git a/layout-evaluation-fixtures.json b/layout-evaluation-fixtures.json index 8477a34..cd0d338 100644 --- a/layout-evaluation-fixtures.json +++ b/layout-evaluation-fixtures.json @@ -1,5 +1,5 @@ { - "description": "Shared behavior corpus for Cratis.Scene.Engine.Layouts.FlowArrangementEvaluator/FreeformArrangementEvaluator (C#) and evaluateFlowArrangement/evaluateFreeformArrangement (TypeScript, @cratis/scene.engine) - both sides assert every case here independently, so the two implementations of Cratis/Scene#4's selection algorithm cannot drift apart. Trees and placements are reduced to a distinguishing 'tag' string, since the evaluators only select which one wins - they never descend into or transform the tree itself.", + "description": "Shared behavior corpus for Cratis.Scene.Engine.Layouts.FlowArrangementEvaluator/FreeformArrangementEvaluator (C#) and evaluateFlowArrangement/evaluateFreeformArrangement (TypeScript, @cratis/scene.engine) - both sides assert every case here independently, so the two implementations of Cratis/Scene#4's selection algorithm cannot drift apart. Trees and placements are reduced to a distinguishing 'tag' string, since the evaluators only select which one wins - they never descend into or transform the tree itself. 'freeformCases' is reused verbatim (the 'tag' read as a slot name instead of an element id) by FreeformSlotArrangementEvaluator/evaluateFreeformSlotArrangement - the slot-vs-element leaf content is exactly what those evaluators don't look at, so one corpus proves both.", "flowCases": [ { "name": "no overrides - the base root always wins", diff --git a/scene-model-shape.json b/scene-model-shape.json index 9fc6017..e69ebc5 100644 --- a/scene-model-shape.json +++ b/scene-model-shape.json @@ -14,7 +14,7 @@ "ItemsControl": ["itemsSource", "itemTemplate"], "ContentControl": ["content"], "ExternalComponent": ["componentName", "slots"], - "Layout": ["name", "slots"], + "Layout": ["name", "slots", "arrangement"], "Slot": ["name", "arrangement"], "Arrangement": [], "FlowArrangement": ["root", "overrides"], @@ -25,9 +25,13 @@ "FlowColumn": [], "FlowGrid": ["columns", "rows"], "FlowLeaf": ["content"], + "FlowSlotLeaf": ["slotName"], "FreeformArrangement": ["variants"], "FreeformVariant": ["sizeClass", "placements"], "ElementPlacement": ["element", "x", "y", "width", "height"], + "FreeformSlotArrangement": ["variants"], + "FreeformSlotVariant": ["sizeClass", "placements"], + "SlotPlacement": ["slotName", "x", "y", "width", "height"], "PopulateSource": [], "PopulateViaQuery": ["queryName", "parameterBindings"], "PopulateFromItem": ["itemBinding"],