Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// 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.Elements;
using Cratis.Scene.Model.Layouts;
using Cratis.Scene.Model.SizeClasses;

namespace Cratis.Scene.Engine.for_FlowArrangementEvaluator;

public class when_evaluating_against_the_shared_fixture_corpus : Specification
{
record FixtureCase(string Name, FlowArrangement Arrangement, SizeClass SizeClass, string ExpectedTag);

List<FixtureCase> _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("flowCases").EnumerateArray().Select(ToFixtureCase).ToList();
}

void Because() => _results = [.. _cases.Select(fixtureCase => (fixtureCase, TagOf(FlowArrangementEvaluator.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 root = Leaf(element.GetProperty("rootTag").GetString()!);

var overrides = element.GetProperty("overrides").EnumerateArray()
.Select(overrideElement => new FlowOverride(
ParseWidth(overrideElement),
ParseHeight(overrideElement),
Leaf(overrideElement.GetProperty("tag").GetString()!)))
.ToList();

var sizeClassElement = element.GetProperty("sizeClass");
var sizeClass = new SizeClass(
Enum.Parse<WidthSizeClass>(sizeClassElement.GetProperty("width").GetString()!),
Enum.Parse<HeightSizeClass>(sizeClassElement.GetProperty("height").GetString()!));

var expectedTag = element.GetProperty("expectedTag").GetString()!;

return new(name, new FlowArrangement(root, overrides), sizeClass, expectedTag);
}

static WidthSizeClass? ParseWidth(JsonElement overrideElement)
{
var widthElement = overrideElement.GetProperty("width");
return widthElement.ValueKind == JsonValueKind.Null ? null : Enum.Parse<WidthSizeClass>(widthElement.GetString()!);
}

static HeightSizeClass? ParseHeight(JsonElement overrideElement)
{
var heightElement = overrideElement.GetProperty("height");
return heightElement.ValueKind == JsonValueKind.Null ? null : Enum.Parse<HeightSizeClass>(heightElement.GetString()!);
}

static FlowLeaf Leaf(string tag) => new(new ExternalComponent { Id = tag, Name = tag, ComponentName = "core:text" });

static string TagOf(FlowNode node) => ((FlowLeaf)node).Content.Id;

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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// 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.Elements;
using Cratis.Scene.Model.Layouts;
using Cratis.Scene.Model.SizeClasses;

namespace Cratis.Scene.Engine.for_FreeformArrangementEvaluator;

public class when_evaluating_against_the_shared_fixture_corpus : Specification
{
record FixtureCase(string Name, FreeformArrangement Arrangement, SizeClass SizeClass, string? ExpectedTag);

List<FixtureCase> _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(FreeformArrangementEvaluator.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 FreeformVariant(
new SizeClass(
Enum.Parse<WidthSizeClass>(variantElement.GetProperty("width").GetString()!),
Enum.Parse<HeightSizeClass>(variantElement.GetProperty("height").GetString()!)),
[Placement(variantElement.GetProperty("tag").GetString()!)]))
.ToList();

var sizeClassElement = element.GetProperty("sizeClass");
var sizeClass = new SizeClass(
Enum.Parse<WidthSizeClass>(sizeClassElement.GetProperty("width").GetString()!),
Enum.Parse<HeightSizeClass>(sizeClassElement.GetProperty("height").GetString()!));

var expectedTagElement = element.GetProperty("expectedTag");
var expectedTag = expectedTagElement.ValueKind == JsonValueKind.Null ? null : expectedTagElement.GetString();

return new(name, new FreeformArrangement(variants), sizeClass, expectedTag);
}

static ElementPlacement Placement(string tag) => new(new ExternalComponent { Id = tag, Name = tag, ComponentName = "core:text" }, 0, 0, 0, 0);

static string? TagOf(FreeformVariant? variant) => variant?.Placements.Single().Element.Id;

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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// 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.Engine.Layouts;
using Cratis.Scene.Model.SizeClasses;

namespace Cratis.Scene.Engine.for_SizeClassCalculator;

public class when_computing_the_size_class : Specification
{
[Fact] void should_be_compact_by_compact_below_both_breakpoints() =>
SizeClassCalculator.Compute(320, 480).ShouldEqual(new SizeClass(WidthSizeClass.Compact, HeightSizeClass.Compact));

[Fact] void should_be_regular_width_below_height_breakpoint_only() =>
SizeClassCalculator.Compute(1024, 480).ShouldEqual(new SizeClass(WidthSizeClass.Regular, HeightSizeClass.Compact));

[Fact] void should_be_regular_by_regular_at_exactly_both_breakpoints() =>
SizeClassCalculator.Compute(SizeClassCalculator.DefaultWidthBreakpoint, SizeClassCalculator.DefaultHeightBreakpoint)
.ShouldEqual(new SizeClass(WidthSizeClass.Regular, HeightSizeClass.Regular));

[Fact] void should_honor_a_custom_breakpoint() =>
SizeClassCalculator.Compute(500, 500, widthBreakpoint: 400).Width.ShouldEqual(WidthSizeClass.Regular);
}
49 changes: 49 additions & 0 deletions Source/DotNET/Engine/Layouts/FlowArrangementEvaluator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// 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;

/// <summary>
/// Evaluates a <see cref="FlowArrangement"/> for a given <see cref="SizeClass"/> - part of Cratis/Scene#4.
/// </summary>
public static class FlowArrangementEvaluator
{
/// <summary>
/// Selects the <see cref="FlowNode"/> tree that applies for a given <see cref="SizeClass"/>.
/// </summary>
/// <param name="arrangement">The <see cref="FlowArrangement"/> to evaluate.</param>
/// <param name="sizeClass">The current <see cref="SizeClass"/>.</param>
/// <returns>
/// The most specific matching <see cref="FlowOverride.Root"/> (both dimensions targeted beats one; the
/// last declared wins among equally specific matches), or <see cref="FlowArrangement.Root"/> when no
/// override matches.
/// </returns>
public static FlowNode Evaluate(FlowArrangement arrangement, SizeClass sizeClass)
{
FlowOverride? best = null;
foreach (var candidate in arrangement.Overrides ?? [])
{
if (!Matches(candidate, sizeClass))
{
continue;
}

if (best is null || Specificity(candidate) >= Specificity(best))
{
best = candidate;
}
}

return best?.Root ?? arrangement.Root;
}

static bool Matches(FlowOverride @override, SizeClass sizeClass) =>
(@override.Width is null || @override.Width == sizeClass.Width) &&
(@override.Height is null || @override.Height == sizeClass.Height);

static int Specificity(FlowOverride @override) =>
(@override.Width is not null ? 1 : 0) + (@override.Height is not null ? 1 : 0);
}
26 changes: 26 additions & 0 deletions Source/DotNET/Engine/Layouts/FreeformArrangementEvaluator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// 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;

/// <summary>
/// Evaluates a <see cref="FreeformArrangement"/> for a given <see cref="SizeClass"/> - part of Cratis/Scene#4.
/// </summary>
public static class FreeformArrangementEvaluator
{
/// <summary>
/// Selects the <see cref="FreeformVariant"/> that targets a given <see cref="SizeClass"/>.
/// </summary>
/// <param name="arrangement">The <see cref="FreeformArrangement"/> to evaluate.</param>
/// <param name="sizeClass">The current <see cref="SizeClass"/>.</param>
/// <returns>
/// The variant whose <see cref="FreeformVariant.SizeClass"/> exactly matches, or <see langword="null"/>
/// 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.
/// </returns>
public static FreeformVariant? Evaluate(FreeformArrangement arrangement, SizeClass sizeClass) =>
arrangement.Variants.FirstOrDefault(variant => variant.SizeClass == sizeClass);
}
40 changes: 40 additions & 0 deletions Source/DotNET/Engine/Layouts/SizeClassCalculator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// 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.Engine.Layouts;

/// <summary>
/// Computes the current <see cref="SizeClass"/> from actual available dimensions - shared by every
/// renderer (<c>Scene.React</c>, any future native renderer, Studio's preview surface) so they all agree
/// on when a class boundary is crossed. Reactive (recompute on resize) vs. fixed-per-launch (mobile,
/// where orientation change is the only runtime variable) are both just "call this again when the
/// dimensions you have available change" - the API shape is the same either way, only the caller's
/// triggering mechanism differs.
/// </summary>
public static class SizeClassCalculator
{
/// <summary>
/// The default width, in device-independent pixels, at or above which <see cref="WidthSizeClass.Regular"/> applies.
/// </summary>
public const double DefaultWidthBreakpoint = 600;

/// <summary>
/// The default height, in device-independent pixels, at or above which <see cref="HeightSizeClass.Regular"/> applies.
/// </summary>
public const double DefaultHeightBreakpoint = 600;

/// <summary>
/// Computes the <see cref="SizeClass"/> for a given available width and height.
/// </summary>
/// <param name="width">The available width, in device-independent pixels.</param>
/// <param name="height">The available height, in device-independent pixels.</param>
/// <param name="widthBreakpoint">The width at or above which <see cref="WidthSizeClass.Regular"/> applies. Defaults to <see cref="DefaultWidthBreakpoint"/>.</param>
/// <param name="heightBreakpoint">The height at or above which <see cref="HeightSizeClass.Regular"/> applies. Defaults to <see cref="DefaultHeightBreakpoint"/>.</param>
/// <returns>The computed <see cref="SizeClass"/>.</returns>
public static SizeClass Compute(double width, double height, double widthBreakpoint = DefaultWidthBreakpoint, double heightBreakpoint = DefaultHeightBreakpoint) =>
new(
width >= widthBreakpoint ? WidthSizeClass.Regular : WidthSizeClass.Compact,
height >= heightBreakpoint ? HeightSizeClass.Regular : HeightSizeClass.Compact);
}
7 changes: 6 additions & 1 deletion Source/DotNET/Model/Layouts/FlowArrangement.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,9 @@ namespace Cratis.Scene.Model.Layouts;
/// in a future native renderer).
/// </summary>
/// <param name="Root">The root of the flow tree.</param>
public record FlowArrangement(FlowNode Root) : Arrangement;
/// <param name="Overrides">
/// Replacements for <paramref name="Root"/> targeting specific width/height size classes - the most
/// specific match wins (both dimensions targeted beats one), and the last declared wins among equally
/// specific matches. <see langword="null"/> or empty when the tree never varies by size class.
/// </param>
public record FlowArrangement(FlowNode Root, IReadOnlyList<FlowOverride>? Overrides = null) : Arrangement;
14 changes: 14 additions & 0 deletions Source/DotNET/Model/Layouts/FlowOverride.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// 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;

/// <summary>
/// Replaces a <see cref="FlowArrangement"/>'s root tree for a targeted width and/or height size class.
/// </summary>
/// <param name="Width">The width size class this override targets, or <see langword="null"/> to target any width.</param>
/// <param name="Height">The height size class this override targets, or <see langword="null"/> to target any height.</param>
/// <param name="Root">The replacement tree.</param>
public record FlowOverride(WidthSizeClass? Width, HeightSizeClass? Height, FlowNode Root);
30 changes: 30 additions & 0 deletions Source/JavaScript/engine/computeSizeClass.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

import { HeightSizeClass, SizeClass, WidthSizeClass } from '@cratis/scene.model';

/** The default width, in device-independent pixels, at or above which {@link WidthSizeClass.Regular} applies. */
export const defaultWidthBreakpoint = 600;

/** The default height, in device-independent pixels, at or above which {@link HeightSizeClass.Regular} applies. */
export const defaultHeightBreakpoint = 600;

/**
* Computes the current {@link SizeClass} from actual available dimensions - shared by every renderer
* (`Scene.React`, any future native renderer, Studio's preview surface) so they all agree on when a class
* boundary is crossed. Reactive (recompute on resize) vs. fixed-per-launch (mobile, where orientation
* change is the only runtime variable) are both just "call this again when the dimensions you have
* available change" - the shape is the same either way, only the caller's triggering mechanism differs.
*
* @param width The available width, in device-independent pixels.
* @param height The available height, in device-independent pixels.
* @param widthBreakpoint The width at or above which {@link WidthSizeClass.Regular} applies. Defaults to {@link defaultWidthBreakpoint}.
* @param heightBreakpoint The height at or above which {@link HeightSizeClass.Regular} applies. Defaults to {@link defaultHeightBreakpoint}.
* @returns The computed {@link SizeClass}.
*/
export function computeSizeClass(width: number, height: number, widthBreakpoint = defaultWidthBreakpoint, heightBreakpoint = defaultHeightBreakpoint): SizeClass {
return {
width: width >= widthBreakpoint ? WidthSizeClass.Regular : WidthSizeClass.Compact,
height: height >= heightBreakpoint ? HeightSizeClass.Regular : HeightSizeClass.Compact,
};
}
35 changes: 35 additions & 0 deletions Source/JavaScript/engine/evaluateFlowArrangement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

import { FlowArrangement, FlowNode, FlowOverride, SizeClass } from '@cratis/scene.model';

/**
* Selects the {@link FlowNode} tree that applies for a given {@link SizeClass} - part of Cratis/Scene#4.
*
* @param arrangement The {@link FlowArrangement} to evaluate.
* @param sizeClass The current {@link SizeClass}.
* @returns The most specific matching override's root (both dimensions targeted beats one; the last declared wins among equally specific matches), or `arrangement.root` when no override matches.
*/
export function evaluateFlowArrangement(arrangement: FlowArrangement, sizeClass: SizeClass): FlowNode {
let best: FlowOverride | undefined;
for (const candidate of arrangement.overrides ?? []) {
if (!matches(candidate, sizeClass)) {
continue;
}

if (!best || specificity(candidate) >= specificity(best)) {
best = candidate;
}
}

return best?.root ?? arrangement.root;
}

function matches(override: FlowOverride, sizeClass: SizeClass): boolean {
return (override.width === undefined || override.width === sizeClass.width) &&
(override.height === undefined || override.height === sizeClass.height);
}

function specificity(override: FlowOverride): number {
return (override.width !== undefined ? 1 : 0) + (override.height !== undefined ? 1 : 0);
}
Loading
Loading