From 12d0335a3217596d11cea311d14fb0efbe39b9b5 Mon Sep 17 00:00:00 2001 From: token <61819790+239573049@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:14:55 -0700 Subject: [PATCH 1/6] feat(foundation): TKCode.Arc.Foundation, an MIT base package for tokens, themes and localization TKCode.Arc.Foundation depends only on Avalonia and provides what Arc took from AtomUI: token declarations and their source generator (token kinds, markup extensions, reflection-free writers and override parsers), the theme runtime (ArcThemeManager, ArcThemeState, scoped themes, presets), lazy control-theme loading, package registration (AppBuilder.UseArc), and localization (xlf catalogs, live language switching, coverage checks). Diagnostics ARCF001-027 report token, theme and translation errors at build time. NativeAOT and trimming safe; tools/dev/FoundationAotSample checks it. Written clean-room from Avalonia's public API and Arc's own design docs. Refs #25 Co-Authored-By: Claude Opus 5.5 --- .../ArcFoundationGenerator.cs | 271 +++++++++++++ .../Diagnostics.cs | 70 ++++ .../EquatableArray.cs | 101 +++++ .../LocalizationDiagnostics.cs | 50 +++ .../LocalizationEmitter.cs | 356 ++++++++++++++++++ .../LocalizationModel.cs | 324 ++++++++++++++++ .../TKCode.Arc.Foundation.Generators.csproj | 34 ++ .../ThemeEmitter.cs | 118 ++++++ .../ThemeModel.cs | 199 ++++++++++ .../TokenEmitter.cs | 230 +++++++++++ .../TokenModel.cs | 267 +++++++++++++ .../Localization/ArcLanguageCatalog.cs | 141 +++++++ .../Localization/ArcLanguageManager.cs | 224 +++++++++++ .../Localization/LanguageTag.cs | 180 +++++++++ .../Motion/ArcMotionLevel.cs | 17 + .../Properties/AssemblyInfo.cs | 4 + .../Registration/ArcBuilder.cs | 254 +++++++++++++ .../Registration/ArcRuntime.cs | 223 +++++++++++ .../TKCode.Arc.Foundation.csproj | 27 ++ src/TKCode.Arc.Foundation/Theme/ArcTheme.cs | 20 + .../Theme/ArcThemeManager.cs | 207 ++++++++++ .../Theme/ArcThemePreset.cs | 226 +++++++++++ .../Theme/ArcThemeProvider.cs | 199 ++++++++++ .../Theme/ArcThemeScope.cs | 160 ++++++++ .../Theme/ArcThemeState.cs | 201 ++++++++++ .../Theme/ArcTokenCalculator.cs | 195 ++++++++++ .../Tokens/AbstractControlDesignToken.cs | 25 ++ .../Tokens/ArcResourceReference.cs | 39 ++ .../Tokens/ArcSeedToken.cs | 35 ++ .../Tokens/ArcTokenDescriptor.cs | 77 ++++ .../Tokens/ArcTokenValueParser.cs | 209 ++++++++++ .../Tokens/ControlDesignTokenAttribute.cs | 28 ++ .../TKCode.Arc.Foundation.targets | 21 ++ tools/dev/FoundationAotSample/.gitignore | 2 + tools/dev/FoundationAotSample/AssemblyInfo.cs | 4 + .../FoundationAotSample.csproj | 29 ++ tools/dev/FoundationAotSample/Program.cs | 194 ++++++++++ .../dev/FoundationAotSample/generate_scale.py | 163 ++++++++ 38 files changed, 5124 insertions(+) create mode 100644 src/TKCode.Arc.Foundation.Generators/ArcFoundationGenerator.cs create mode 100644 src/TKCode.Arc.Foundation.Generators/Diagnostics.cs create mode 100644 src/TKCode.Arc.Foundation.Generators/EquatableArray.cs create mode 100644 src/TKCode.Arc.Foundation.Generators/LocalizationDiagnostics.cs create mode 100644 src/TKCode.Arc.Foundation.Generators/LocalizationEmitter.cs create mode 100644 src/TKCode.Arc.Foundation.Generators/LocalizationModel.cs create mode 100644 src/TKCode.Arc.Foundation.Generators/TKCode.Arc.Foundation.Generators.csproj create mode 100644 src/TKCode.Arc.Foundation.Generators/ThemeEmitter.cs create mode 100644 src/TKCode.Arc.Foundation.Generators/ThemeModel.cs create mode 100644 src/TKCode.Arc.Foundation.Generators/TokenEmitter.cs create mode 100644 src/TKCode.Arc.Foundation.Generators/TokenModel.cs create mode 100644 src/TKCode.Arc.Foundation/Localization/ArcLanguageCatalog.cs create mode 100644 src/TKCode.Arc.Foundation/Localization/ArcLanguageManager.cs create mode 100644 src/TKCode.Arc.Foundation/Localization/LanguageTag.cs create mode 100644 src/TKCode.Arc.Foundation/Motion/ArcMotionLevel.cs create mode 100644 src/TKCode.Arc.Foundation/Properties/AssemblyInfo.cs create mode 100644 src/TKCode.Arc.Foundation/Registration/ArcBuilder.cs create mode 100644 src/TKCode.Arc.Foundation/Registration/ArcRuntime.cs create mode 100644 src/TKCode.Arc.Foundation/TKCode.Arc.Foundation.csproj create mode 100644 src/TKCode.Arc.Foundation/Theme/ArcTheme.cs create mode 100644 src/TKCode.Arc.Foundation/Theme/ArcThemeManager.cs create mode 100644 src/TKCode.Arc.Foundation/Theme/ArcThemePreset.cs create mode 100644 src/TKCode.Arc.Foundation/Theme/ArcThemeProvider.cs create mode 100644 src/TKCode.Arc.Foundation/Theme/ArcThemeScope.cs create mode 100644 src/TKCode.Arc.Foundation/Theme/ArcThemeState.cs create mode 100644 src/TKCode.Arc.Foundation/Theme/ArcTokenCalculator.cs create mode 100644 src/TKCode.Arc.Foundation/Tokens/AbstractControlDesignToken.cs create mode 100644 src/TKCode.Arc.Foundation/Tokens/ArcResourceReference.cs create mode 100644 src/TKCode.Arc.Foundation/Tokens/ArcSeedToken.cs create mode 100644 src/TKCode.Arc.Foundation/Tokens/ArcTokenDescriptor.cs create mode 100644 src/TKCode.Arc.Foundation/Tokens/ArcTokenValueParser.cs create mode 100644 src/TKCode.Arc.Foundation/Tokens/ControlDesignTokenAttribute.cs create mode 100644 src/TKCode.Arc.Foundation/buildTransitive/TKCode.Arc.Foundation.targets create mode 100644 tools/dev/FoundationAotSample/.gitignore create mode 100644 tools/dev/FoundationAotSample/AssemblyInfo.cs create mode 100644 tools/dev/FoundationAotSample/FoundationAotSample.csproj create mode 100644 tools/dev/FoundationAotSample/Program.cs create mode 100644 tools/dev/FoundationAotSample/generate_scale.py diff --git a/src/TKCode.Arc.Foundation.Generators/ArcFoundationGenerator.cs b/src/TKCode.Arc.Foundation.Generators/ArcFoundationGenerator.cs new file mode 100644 index 00000000..8358caf7 --- /dev/null +++ b/src/TKCode.Arc.Foundation.Generators/ArcFoundationGenerator.cs @@ -0,0 +1,271 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace TKCode.Arc.Foundation.Generators +{ + /// + /// Generates, for every [ControlDesignToken] class, its token kinds, keys, markup extension, writer and + /// override parser; for every Themes/*Theme.axaml file, the dictionary class that instantiates it; and per + /// assembly the ArcGeneratedAssets table a package registers (design/architecture/foundation.md). + /// + [Generator(LanguageNames.CSharp)] + public sealed class ArcFoundationGenerator : IIncrementalGenerator + { + private const string AttributeName = "TKCode.Arc.ControlDesignTokenAttribute"; + private const string FoundationMarker = "TKCode.Arc.ArcTokenDescriptor"; + private const string CatalogAttributeName = "TKCode.Arc.Localization.LanguageCatalogAttribute"; + + /// + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var tokens = context.SyntaxProvider.ForAttributeWithMetadataName( + AttributeName, + static (node, _) => node is ClassDeclarationSyntax, + static (ctx, _) => TokenModel.Create((INamedTypeSymbol)ctx.TargetSymbol, ctx.Attributes[0])) + .Collect(); + + var projectDirectory = context.AnalyzerConfigOptionsProvider.Select(static (options, _) => + { + options.GlobalOptions.TryGetValue("build_property.projectdir", out var directory); + options.GlobalOptions.TryGetValue("build_property.rootnamespace", out var rootNamespace); + return (Directory: directory, RootNamespace: rootNamespace); + }); + + var themes = context.AdditionalTextsProvider + .Where(static file => ThemeFile.IsThemeFile(file.Path)) + .Combine(projectDirectory) + .Select(static (pair, cancellation) => ThemeFile.Parse(pair.Left.Path, + ThemeFile.MakeRelative(pair.Left.Path, pair.Right.Directory), pair.Left.GetText(cancellation))) + .Collect(); + + // Language catalogs: [LanguageCatalog] enums and Localization/*.xlf (LocalizationEmitter). + var catalogs = context.SyntaxProvider.ForAttributeWithMetadataName( + CatalogAttributeName, + static (node, _) => node is EnumDeclarationSyntax, + static (ctx, _) => CatalogModel.Create((INamedTypeSymbol)ctx.TargetSymbol)) + .Collect(); + var languageFiles = context.AdditionalTextsProvider + .Where(static file => XlfFile.IsLanguageFile(file.Path)) + .Combine(projectDirectory) + .Select(static (pair, cancellation) => XlfFile.Parse(pair.Left.Path, + ThemeFile.MakeRelative(pair.Left.Path, pair.Right.Directory), pair.Left.GetText(cancellation))) + .Collect(); + var languages = catalogs.Combine(languageFiles); + + var input = tokens.Combine(themes).Combine(projectDirectory).Combine(languages).Combine(context.CompilationProvider); + context.RegisterSourceOutput(input, static (spc, source) => + { + var ((((tokenModels, themeFiles), project), (catalogModels, xlfFiles)), compilation) = source; + Execute(spc, tokenModels, themeFiles, catalogModels, xlfFiles, project.RootNamespace, compilation); + }); + } + + private static void Execute(SourceProductionContext context, ImmutableArray tokenModels, + ImmutableArray themeFiles, ImmutableArray catalogModels, + ImmutableArray xlfFiles, string? rootNamespace, Compilation compilation) + { + if (compilation.GetTypeByMetadataName(FoundationMarker) is null) + { + // The project does not reference TKCode.Arc.Foundation: nothing to generate against. + return; + } + + // Tokens. + var validTokens = new List(); + var identities = new Dictionary(StringComparer.Ordinal); + TokenModel? global = null; + foreach (var model in tokenModels.OrderBy(m => m.FullClassName, StringComparer.Ordinal)) + { + foreach (var diagnostic in model.Diagnostics) + { + context.ReportDiagnostic(diagnostic.ToDiagnostic()); + } + if (!model.IsValid) + { + continue; + } + if (identities.TryGetValue(model.Identity, out var other)) + { + context.ReportDiagnostic(model.CreateDiagnostic(Diagnostics.DuplicateIdentity, + model.Identity, other.FullClassName, model.FullClassName)); + continue; + } + identities.Add(model.Identity, model); + if (model.IsGlobal) + { + if (global is not null) + { + context.ReportDiagnostic(model.CreateDiagnostic(Diagnostics.MultipleGlobalIdentities, + global.FullClassName, model.FullClassName)); + } + else + { + global = model; + } + } + validTokens.Add(model); + context.AddSource(model.Stem + "Token.g.cs", SourceText.From(TokenEmitter.Emit(model), Encoding.UTF8)); + } + + // Themes. + var xmlns = XmlnsMap.Build(compilation); + var assets = new List<(ThemeFile File, string ClassName, List Keys)>(); + var themeKeys = new Dictionary(StringComparer.Ordinal); + foreach (var file in themeFiles.OrderBy(f => f.RelativePath, StringComparer.Ordinal)) + { + if (file.Error is not null) + { + context.ReportDiagnostic(Diagnostic.Create(Diagnostics.InvalidThemeFile, + Location.Create(file.Path, default, default), file.RelativePath, file.Error)); + continue; + } + if (file.ClassName is null) + { + context.ReportDiagnostic(Diagnostic.Create(Diagnostics.ThemeWithoutClass, + Location.Create(file.Path, default, default), file.RelativePath, + ThemeFile.SuggestClassName(file.RelativePath, rootNamespace))); + continue; + } + var keys = new List(); + foreach (var key in file.Keys) + { + string expression; + string identity; + if (key.IsType) + { + var type = xmlns.Resolve(compilation, key.XmlNamespace!, key.TypeName!); + if (type is null) + { + context.ReportDiagnostic(Diagnostic.Create(Diagnostics.UnresolvedThemeKey, + Location.Create(file.Path, default, file.LineSpan(key)), file.RelativePath, key.Text, key.TypeName!)); + continue; + } + var typeName = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + expression = "typeof(" + typeName + ")"; + identity = typeName; + } + else + { + expression = "\"" + key.Text.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; + identity = "\"" + key.Text + "\""; + } + if (themeKeys.TryGetValue(identity, out var owner)) + { + context.ReportDiagnostic(Diagnostic.Create(Diagnostics.DuplicateThemeKey, + Location.Create(file.Path, default, file.LineSpan(key)), key.Text, owner, file.RelativePath)); + continue; + } + themeKeys.Add(identity, file.RelativePath); + keys.Add(expression); + } + if (file.Keys.Count == 0) + { + context.ReportDiagnostic(Diagnostic.Create(Diagnostics.EmptyThemeFile, + Location.Create(file.Path, default, default), file.RelativePath)); + } + var className = file.ClassName; + // A dictionary class the project declares itself (with its own constructor) is used as is. + if (compilation.GetTypeByMetadataName(className) is null) + { + context.AddSource(SafeFileName(className) + ".g.cs", + SourceText.From(ThemeEmitter.EmitDictionaryClass(file, className), Encoding.UTF8)); + } + assets.Add((file, className, keys)); + } + + // Language catalogs. + var catalogs = LocalizationEmitter.Execute(context, catalogModels, xlfFiles); + + if (validTokens.Count == 0 && assets.Count == 0 && catalogs.Count == 0) + { + return; + } + context.AddSource("ArcGeneratedAssets.g.cs", + SourceText.From(ThemeEmitter.EmitAssets(compilation.AssemblyName ?? "Assembly", validTokens, assets, catalogs), Encoding.UTF8)); + } + + private static string SafeFileName(string name) + { + var builder = new StringBuilder(name.Length); + foreach (var c in name) + { + builder.Append(char.IsLetterOrDigit(c) || c == '.' || c == '_' ? c : '_'); + } + return builder.ToString(); + } + } + + /// XML namespace → CLR namespaces, from the [XmlnsDefinition] attributes of the compilation and its references. + internal sealed class XmlnsMap + { + private readonly Dictionary> _map = new Dictionary>(StringComparer.Ordinal); + + public static XmlnsMap Build(Compilation compilation) + { + var map = new XmlnsMap(); + map.Collect(compilation.Assembly); + foreach (var assembly in compilation.SourceModule.ReferencedAssemblySymbols) + { + map.Collect(assembly); + } + return map; + } + + private void Collect(IAssemblySymbol assembly) + { + foreach (var attribute in assembly.GetAttributes()) + { + if (attribute.AttributeClass?.ToDisplayString() != "Avalonia.Metadata.XmlnsDefinitionAttribute" + || attribute.ConstructorArguments.Length < 2 + || attribute.ConstructorArguments[0].Value is not string xml + || attribute.ConstructorArguments[1].Value is not string clr) + { + continue; + } + if (!_map.TryGetValue(xml, out var list)) + { + list = new List(); + _map.Add(xml, list); + } + if (!list.Contains(clr)) + { + list.Add(clr); + } + } + } + + public INamedTypeSymbol? Resolve(Compilation compilation, string xmlNamespace, string typeName) + { + IEnumerable namespaces; + if (xmlNamespace.StartsWith("clr-namespace:", StringComparison.Ordinal) || xmlNamespace.StartsWith("using:", StringComparison.Ordinal)) + { + var value = xmlNamespace.Substring(xmlNamespace.IndexOf(':') + 1); + var semicolon = value.IndexOf(';'); + namespaces = new[] { semicolon >= 0 ? value.Substring(0, semicolon) : value }; + } + else if (_map.TryGetValue(xmlNamespace, out var list)) + { + namespaces = list; + } + else + { + return null; + } + foreach (var ns in namespaces) + { + var type = compilation.GetTypeByMetadataName(ns + "." + typeName); + if (type is not null && compilation.IsSymbolAccessibleWithin(type, compilation.Assembly)) + { + return type; + } + } + return null; + } + } +} diff --git a/src/TKCode.Arc.Foundation.Generators/Diagnostics.cs b/src/TKCode.Arc.Foundation.Generators/Diagnostics.cs new file mode 100644 index 00000000..208ec404 --- /dev/null +++ b/src/TKCode.Arc.Foundation.Generators/Diagnostics.cs @@ -0,0 +1,70 @@ +using Microsoft.CodeAnalysis; + +namespace TKCode.Arc.Foundation.Generators +{ + /// Diagnostics of the Foundation generator (ids ARCF0xx; design/architecture/foundation.md section 6). + internal static class Diagnostics + { + private const string Category = "TKCode.Arc.Foundation"; + + public static readonly DiagnosticDescriptor UnsupportedTokenType = new DiagnosticDescriptor( + "ARCF001", "Unsupported token property type", + "Token '{0}.{1}' has type '{2}', which string overrides cannot parse; use Color, double, int, bool, string, Thickness, CornerRadius, BoxShadows, FontFamily, FontWeight, TimeSpan, Easing or an enum", + Category, DiagnosticSeverity.Error, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor NotATokenClass = new DiagnosticDescriptor( + "ARCF002", "Token class must derive from AbstractControlDesignToken", + "'{0}' is marked [ControlDesignToken] but does not derive from TKCode.Arc.AbstractControlDesignToken", + Category, DiagnosticSeverity.Error, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor EmptyTokenClass = new DiagnosticDescriptor( + "ARCF003", "Token class declares no tokens", + "'{0}' declares no public settable properties; give the control at least one token or remove the class", + Category, DiagnosticSeverity.Warning, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor DuplicateIdentity = new DiagnosticDescriptor( + "ARCF004", "Duplicate token identity", + "Token identity '{0}' is declared by both '{1}' and '{2}'", + Category, DiagnosticSeverity.Error, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor MultipleGlobalIdentities = new DiagnosticDescriptor( + "ARCF005", "More than one global token identity", + "'{0}' and '{1}' are both marked IsGlobal; an application has a single global token identity", + Category, DiagnosticSeverity.Error, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor InvalidTokenClassShape = new DiagnosticDescriptor( + "ARCF006", "Token class must be a concrete top-level class", + "'{0}' must be a non-abstract, non-generic, top-level class with a parameterless constructor", + Category, DiagnosticSeverity.Error, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor TokenClassName = new DiagnosticDescriptor( + "ARCF007", "Token class name should end with Token", + "'{0}' does not end with 'Token'; its identity is the whole class name", + Category, DiagnosticSeverity.Warning, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor ThemeWithoutClass = new DiagnosticDescriptor( + "ARCF010", "Theme file needs x:Class", + "Theme file '{0}' has no x:Class; add x:Class=\"{1}\" so the dictionary can be created without loading XAML by URI (which is not AOT safe)", + Category, DiagnosticSeverity.Error, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor InvalidThemeFile = new DiagnosticDescriptor( + "ARCF011", "Theme file cannot be read", + "Theme file '{0}' cannot be read: {1}", + Category, DiagnosticSeverity.Error, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor UnresolvedThemeKey = new DiagnosticDescriptor( + "ARCF012", "Theme key type not found", + "Theme file '{0}' keys a resource by '{1}', but no accessible type '{2}' was found; the resource will not be loaded on demand", + Category, DiagnosticSeverity.Warning, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor DuplicateThemeKey = new DiagnosticDescriptor( + "ARCF013", "Theme key defined twice", + "Resource key '{0}' is defined by both '{1}' and '{2}'", + Category, DiagnosticSeverity.Error, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor EmptyThemeFile = new DiagnosticDescriptor( + "ARCF014", "Theme file defines no resources", + "Theme file '{0}' has no keyed top-level resources, so nothing can ever load it", + Category, DiagnosticSeverity.Warning, isEnabledByDefault: true); + } +} diff --git a/src/TKCode.Arc.Foundation.Generators/EquatableArray.cs b/src/TKCode.Arc.Foundation.Generators/EquatableArray.cs new file mode 100644 index 00000000..82b551e3 --- /dev/null +++ b/src/TKCode.Arc.Foundation.Generators/EquatableArray.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace TKCode.Arc.Foundation.Generators +{ + /// An immutable array with value equality, so incremental pipeline steps cache. + internal readonly struct EquatableArray : IEquatable>, IEnumerable + where T : IEquatable + { + private readonly ImmutableArray _items; + + public EquatableArray(ImmutableArray items) + { + _items = items; + } + + public EquatableArray(IEnumerable items) + { + _items = items.ToImmutableArray(); + } + + public static readonly EquatableArray Empty = new EquatableArray(ImmutableArray.Empty); + + public ImmutableArray Items => _items.IsDefault ? ImmutableArray.Empty : _items; + + public int Count => Items.Length; + + public T this[int index] => Items[index]; + + public bool Equals(EquatableArray other) => Items.SequenceEqual(other.Items); + + public override bool Equals(object? obj) => obj is EquatableArray other && Equals(other); + + public override int GetHashCode() + { + var hash = 17; + foreach (var item in Items) + { + hash = unchecked(hash * 31 + (item?.GetHashCode() ?? 0)); + } + return hash; + } + + public ImmutableArray.Enumerator GetEnumerator() => Items.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)Items).GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)Items).GetEnumerator(); + } + + /// A diagnostic that can live in a cached pipeline model (no Location or symbol references). + internal sealed class DiagnosticInfo : IEquatable + { + public DiagnosticInfo(DiagnosticDescriptor descriptor, string? filePath, TextSpan span, LinePositionSpan lineSpan, params string[] arguments) + { + Descriptor = descriptor; + FilePath = filePath; + Span = span; + LineSpan = lineSpan; + Arguments = arguments; + } + + public DiagnosticDescriptor Descriptor { get; } + public string? FilePath { get; } + public TextSpan Span { get; } + public LinePositionSpan LineSpan { get; } + public string[] Arguments { get; } + + public static DiagnosticInfo Create(DiagnosticDescriptor descriptor, Location? location, params string[] arguments) + { + if (location is null || location.SourceTree is null) + { + return new DiagnosticInfo(descriptor, null, default, default, arguments); + } + var line = location.GetLineSpan(); + return new DiagnosticInfo(descriptor, line.Path, location.SourceSpan, line.Span, arguments); + } + + public Diagnostic ToDiagnostic() + { + var location = FilePath is null ? Location.None : Location.Create(FilePath, Span, LineSpan); + return Diagnostic.Create(Descriptor, location, Arguments); + } + + public bool Equals(DiagnosticInfo? other) + => other is not null + && Descriptor.Id == other.Descriptor.Id + && FilePath == other.FilePath + && Span.Equals(other.Span) + && Arguments.SequenceEqual(other.Arguments); + + public override bool Equals(object? obj) => Equals(obj as DiagnosticInfo); + + public override int GetHashCode() => unchecked(Descriptor.Id.GetHashCode() * 31 + Span.GetHashCode()); + } +} diff --git a/src/TKCode.Arc.Foundation.Generators/LocalizationDiagnostics.cs b/src/TKCode.Arc.Foundation.Generators/LocalizationDiagnostics.cs new file mode 100644 index 00000000..eff33819 --- /dev/null +++ b/src/TKCode.Arc.Foundation.Generators/LocalizationDiagnostics.cs @@ -0,0 +1,50 @@ +using Microsoft.CodeAnalysis; + +namespace TKCode.Arc.Foundation.Generators +{ + /// Diagnostics of the localization part of the Foundation generator (ids ARCF020–ARCF029). + internal static class LocalizationDiagnostics + { + private const string Category = "TKCode.Arc.Foundation.Localization"; + + public static readonly DiagnosticDescriptor InvalidLanguageFile = new DiagnosticDescriptor( + "ARCF020", "Language file cannot be read", + "Language file '{0}' cannot be read: {1}", + Category, DiagnosticSeverity.Error, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor MissingText = new DiagnosticDescriptor( + "ARCF021", "Text missing in a language", + "'{0}' has no {1} text for '{2}.{3}'", + Category, DiagnosticSeverity.Error, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor UnknownUnit = new DiagnosticDescriptor( + "ARCF022", "Text for a member the catalog does not declare", + "'{0}' has text for '{1}', but '{2}' declares no such member; the text is ignored", + Category, DiagnosticSeverity.Warning, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor Duplicate = new DiagnosticDescriptor( + "ARCF023", "Text defined twice", + "{0}", + Category, DiagnosticSeverity.Error, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor MissingSource = new DiagnosticDescriptor( + "ARCF024", "Catalog has no source-language file", + "Language catalog '{0}' has no source-language file (a Localization/.xlf without trgLang whose is '{0}')", + Category, DiagnosticSeverity.Error, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor PlaceholderMismatch = new DiagnosticDescriptor( + "ARCF025", "Placeholders differ from the source text", + "'{0}': the {1} text of '{2}.{3}' uses placeholders {4} but the source text uses {5}", + Category, DiagnosticSeverity.Error, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor InvalidCatalog = new DiagnosticDescriptor( + "ARCF026", "Language catalog must be a top-level enum with distinct values", + "Language catalog '{0}' {1}", + Category, DiagnosticSeverity.Error, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor UnknownCatalog = new DiagnosticDescriptor( + "ARCF027", "Language file names no catalog", + "'{0}' has text for '{1}', which is not a [LanguageCatalog] enum of this assembly; the text is ignored", + Category, DiagnosticSeverity.Warning, isEnabledByDefault: true); + } +} diff --git a/src/TKCode.Arc.Foundation.Generators/LocalizationEmitter.cs b/src/TKCode.Arc.Foundation.Generators/LocalizationEmitter.cs new file mode 100644 index 00000000..657a4c74 --- /dev/null +++ b/src/TKCode.Arc.Foundation.Generators/LocalizationEmitter.cs @@ -0,0 +1,356 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Text; + +namespace TKCode.Arc.Foundation.Generators +{ + /// + /// Checks the [LanguageCatalog] enums against their Localization/*.xlf files and generates, per catalog, + /// XxxLangResources (the reflection-free text tables and C# lookup) and XxxLangResourceExtension. + /// + internal static class LocalizationEmitter + { + private const string Localization = "global::TKCode.Arc.Localization."; + + /// Reports diagnostics, adds the catalog sources and returns the generated resources classes (global names). + public static List Execute(SourceProductionContext context, ImmutableArray catalogModels, + ImmutableArray files) + { + var generated = new List(); + if (catalogModels.IsDefaultOrEmpty && files.IsDefaultOrEmpty) + { + return generated; + } + + var catalogs = new Dictionary(StringComparer.Ordinal); + foreach (var catalog in catalogModels.OrderBy(c => c.FullName, StringComparer.Ordinal)) + { + if (catalog.IsNested) + { + context.ReportDiagnostic(catalog.CreateDiagnostic(LocalizationDiagnostics.InvalidCatalog, catalog.FullName, + "is nested in a type; declare it at namespace level")); + continue; + } + var byValue = new Dictionary(StringComparer.Ordinal); + string? clash = null; + for (var i = 0; i < catalog.Members.Count && clash is null; i++) + { + if (byValue.TryGetValue(catalog.Values[i], out var other)) + { + clash = $"gives '{other}' and '{catalog.Members[i]}' the same value; each member is a resource key and needs its own value"; + } + else + { + byValue.Add(catalog.Values[i], catalog.Members[i]); + } + } + if (clash is not null) + { + context.ReportDiagnostic(catalog.CreateDiagnostic(LocalizationDiagnostics.InvalidCatalog, catalog.FullName, clash)); + continue; + } + catalogs[catalog.FullName] = catalog; + } + + // Catalog → language → the file section holding that language's text. + var sections = new Dictionary>(StringComparer.Ordinal); + foreach (var file in files.OrderBy(f => f.RelativePath, StringComparer.Ordinal)) + { + if (file.Error is not null) + { + context.ReportDiagnostic(Diagnostic.Create(LocalizationDiagnostics.InvalidLanguageFile, + file.LocationAt(file.ErrorLine), file.RelativePath, file.Error)); + continue; + } + foreach (var section in file.Sections) + { + if (!catalogs.ContainsKey(section.Id)) + { + context.ReportDiagnostic(Diagnostic.Create(LocalizationDiagnostics.UnknownCatalog, + file.LocationAt(section.Line), file.RelativePath, section.Id)); + continue; + } + if (!sections.TryGetValue(section.Id, out var languages)) + { + languages = new SortedDictionary(StringComparer.Ordinal); + sections.Add(section.Id, languages); + } + if (languages.TryGetValue(file.Language!, out var existing)) + { + context.ReportDiagnostic(Diagnostic.Create(LocalizationDiagnostics.Duplicate, file.LocationAt(section.Line), + $"'{existing.File.RelativePath}' and '{file.RelativePath}' both hold the {file.Language} text of '{section.Id}'")); + continue; + } + languages.Add(file.Language!, (file, section)); + } + } + + foreach (var catalog in catalogs.Values) + { + sections.TryGetValue(catalog.FullName, out var languages); + languages ??= new SortedDictionary(StringComparer.Ordinal); + var tables = Check(context, catalog, languages, out var sourceLanguage); + var className = (catalog.Namespace is null ? "global::" : "global::" + catalog.Namespace + ".") + catalog.ResourcesName; + context.AddSource(catalog.FullName + ".Localization.g.cs", + SourceText.From(EmitCatalog(catalog, sourceLanguage, tables), Encoding.UTF8)); + generated.Add(className); + } + return generated; + } + + private static List<(string Language, string?[] Texts)> Check( + SourceProductionContext context, CatalogModel catalog, + SortedDictionary languages, out string sourceLanguageResult) + { + var index = new Dictionary(StringComparer.Ordinal); + for (var i = 0; i < catalog.Members.Count; i++) + { + index[catalog.Members[i]] = i; + } + + var source = languages.Values.Where(e => e.File.IsSource).Select(e => e.File).FirstOrDefault(); + if (source is null) + { + context.ReportDiagnostic(catalog.CreateDiagnostic(LocalizationDiagnostics.MissingSource, catalog.FullName)); + } + var sourceLanguage = source?.Language ?? languages.Values.Select(e => e.File.SourceLanguage).FirstOrDefault() ?? "en-US"; + sourceLanguageResult = sourceLanguage; + + var tables = new List<(string Language, string?[] Texts)>(); + var lines = new Dictionary(StringComparer.Ordinal); + // The source language first, then the others in ordinal order. + foreach (var pair in languages.OrderBy(p => p.Key == sourceLanguage ? 0 : 1).ThenBy(p => p.Key, StringComparer.Ordinal)) + { + var file = pair.Value.File; + var section = pair.Value.Section; + if (file.SourceLanguage != sourceLanguage) + { + context.ReportDiagnostic(Diagnostic.Create(LocalizationDiagnostics.InvalidLanguageFile, file.LocationAt(1), + file.RelativePath, $"srcLang '{file.SourceLanguage}' differs from the source language '{sourceLanguage}' of '{catalog.FullName}'")); + } + var texts = new string?[catalog.Members.Count]; + var unitLines = new int[catalog.Members.Count]; + var seen = new HashSet(StringComparer.Ordinal); + foreach (var unit in section.Units) + { + if (!seen.Add(unit.Id)) + { + context.ReportDiagnostic(Diagnostic.Create(LocalizationDiagnostics.Duplicate, file.LocationAt(unit.Line), + $"'{file.RelativePath}' has text for '{catalog.Name}.{unit.Id}' twice")); + continue; + } + if (!index.TryGetValue(unit.Id, out var i)) + { + context.ReportDiagnostic(Diagnostic.Create(LocalizationDiagnostics.UnknownUnit, file.LocationAt(unit.Line), + file.RelativePath, unit.Id, catalog.FullName)); + continue; + } + var text = file.IsSource ? unit.Source : unit.Target; + texts[i] = string.IsNullOrEmpty(text) ? null : text; + unitLines[i] = unit.Line; + } + for (var i = 0; i < texts.Length; i++) + { + if (texts[i] is null) + { + context.ReportDiagnostic(Diagnostic.Create(LocalizationDiagnostics.MissingText, + file.LocationAt(unitLines[i] > 0 ? unitLines[i] : section.Line), + file.RelativePath, pair.Key, catalog.Name, catalog.Members[i])); + } + } + tables.Add((pair.Key, texts)); + lines[pair.Key] = unitLines; + } + + var sourceTexts = tables.FirstOrDefault(t => t.Language == sourceLanguage).Texts; + if (sourceTexts is not null) + { + foreach (var (language, texts) in tables) + { + if (language == sourceLanguage) + { + continue; + } + var file = languages[language].File; + for (var i = 0; i < texts.Length; i++) + { + if (texts[i] is null || sourceTexts[i] is null) + { + continue; + } + var expected = Placeholders(sourceTexts[i]!); + var actual = Placeholders(texts[i]!); + if (expected != actual) + { + context.ReportDiagnostic(Diagnostic.Create(LocalizationDiagnostics.PlaceholderMismatch, + file.LocationAt(lines[language][i]), file.RelativePath, language, catalog.Name, catalog.Members[i], + actual, expected)); + } + } + } + } + return tables; + } + + /// The distinct composite-format indexes of , for example {0}, {1}, or none. + internal static string Placeholders(string text) + { + var indexes = new SortedSet(); + for (var i = 0; i < text.Length; i++) + { + if (text[i] != '{') + { + continue; + } + if (i + 1 < text.Length && text[i + 1] == '{') + { + i++; + continue; + } + var j = i + 1; + var value = 0; + while (j < text.Length && char.IsDigit(text[j])) + { + value = value * 10 + (text[j] - '0'); + j++; + } + if (j > i + 1 && j < text.Length && (text[j] == '}' || text[j] == ',' || text[j] == ':')) + { + indexes.Add(value); + } + } + return indexes.Count == 0 ? "none" : string.Join(", ", indexes.Select(n => "{" + n + "}")); + } + + private static string EmitCatalog(CatalogModel catalog, string sourceLanguage, List<(string Language, string?[] Texts)> tables) + { + var kind = (catalog.Namespace is null ? "global::" : "global::" + catalog.Namespace + ".") + catalog.Name; + var visibility = catalog.IsPublic ? "public" : "internal"; + var resources = catalog.ResourcesName; + var extension = catalog.ExtensionName; + + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + var indent = ""; + if (catalog.Namespace is not null) + { + sb.Append("namespace ").AppendLine(catalog.Namespace); + sb.AppendLine("{"); + indent = " "; + } + + // Tables and C# lookup. + sb.Append(indent).Append("/// The text of in every language (Localization/*.xlf)."); + sb.Append(indent).Append(visibility).Append(" static class ").AppendLine(resources); + sb.Append(indent).AppendLine("{"); + sb.Append(indent).AppendLine(" private static readonly object[] s_keys = new object[]"); + sb.Append(indent).AppendLine(" {"); + foreach (var member in catalog.Members) + { + sb.Append(indent).Append(" ").Append(kind).Append('.').Append(Identifier(member)).AppendLine(","); + } + sb.Append(indent).AppendLine(" };"); + sb.AppendLine(); + sb.Append(indent).AppendLine(" /// The catalog: resource keys, member names and the text of each language."); + sb.Append(indent).Append(" public static ").Append(Localization).Append("ArcLanguageCatalog Catalog { get; } = new ") + .Append(Localization).AppendLine("ArcLanguageCatalog("); + sb.Append(indent).Append(" ").Append(Literal(catalog.FullName)).AppendLine(","); + sb.Append(indent).Append(" ").Append(Localization).Append("LanguageTag.Parse(").Append(Literal(sourceLanguage)).AppendLine("),"); + sb.Append(indent).AppendLine(" s_keys,"); + sb.Append(indent).Append(" new string[] { ").Append(string.Join(", ", catalog.Members.Select(Literal))).AppendLine(" },"); + sb.Append(indent).Append(" new ").Append(Localization).AppendLine("ArcLanguageTable[]"); + sb.Append(indent).AppendLine(" {"); + foreach (var (language, texts) in tables) + { + sb.Append(indent).Append(" new ").Append(Localization).Append("ArcLanguageTable(").Append(Localization) + .Append("LanguageTag.Parse(").Append(Literal(language)).AppendLine("), new string?[]"); + sb.Append(indent).AppendLine(" {"); + foreach (var text in texts) + { + sb.Append(indent).Append(" ").Append(text is null ? "null" : Literal(text)).AppendLine(","); + } + sb.Append(indent).AppendLine(" }),"); + } + sb.Append(indent).AppendLine(" });"); + sb.AppendLine(); + sb.Append(indent).AppendLine(" /// The resource key of : the boxed member, cached."); + sb.Append(indent).Append(" public static object Key(").Append(kind).AppendLine(" kind)"); + sb.Append(indent).AppendLine(" {"); + sb.Append(indent).AppendLine(" var index = Index(kind);"); + sb.Append(indent).AppendLine(" return index < 0 ? kind : s_keys[index];"); + sb.Append(indent).AppendLine(" }"); + sb.AppendLine(); + sb.Append(indent).AppendLine(" /// The text of in the current language."); + sb.Append(indent).Append(" public static string Get(").Append(kind).AppendLine(" kind)"); + sb.Append(indent).AppendLine(" {"); + sb.Append(indent).AppendLine(" var index = Index(kind);"); + sb.Append(indent).Append(" return index < 0 ? kind.ToString() : ").Append(Localization).AppendLine("ArcLanguage.Get(Catalog, index);"); + sb.Append(indent).AppendLine(" }"); + sb.AppendLine(); + sb.Append(indent).AppendLine(" /// The text of with its {0} placeholders filled, in the current language's culture."); + sb.Append(indent).Append(" public static string Format(").Append(kind).AppendLine(" kind, params object?[] arguments)"); + sb.Append(indent).AppendLine(" {"); + sb.Append(indent).AppendLine(" var index = Index(kind);"); + sb.Append(indent).Append(" return index < 0 ? kind.ToString() : ").Append(Localization).AppendLine("ArcLanguage.Format(Catalog, index, arguments);"); + sb.Append(indent).AppendLine(" }"); + sb.AppendLine(); + sb.Append(indent).AppendLine(" /// The declaration index of , or -1 for a value the enum does not declare."); + sb.Append(indent).Append(" public static int Index(").Append(kind).AppendLine(" kind)"); + sb.Append(indent).AppendLine(" {"); + sb.Append(indent).AppendLine(" switch (kind)"); + sb.Append(indent).AppendLine(" {"); + for (var i = 0; i < catalog.Members.Count; i++) + { + sb.Append(indent).Append(" case ").Append(kind).Append('.').Append(Identifier(catalog.Members[i])).Append(": return ") + .Append(i).AppendLine(";"); + } + sb.Append(indent).AppendLine(" default: return -1;"); + sb.Append(indent).AppendLine(" }"); + sb.Append(indent).AppendLine(" }"); + sb.Append(indent).AppendLine("}"); + sb.AppendLine(); + + // Markup extension. + sb.Append(indent).Append("/// Dynamic resource reference to a text, written {arc:") + .Append(catalog.BaseName).AppendLine(" Member}; it follows the language."); + sb.Append(indent).Append(visibility).Append(" sealed class ").AppendLine(extension); + sb.Append(indent).AppendLine("{"); + sb.Append(indent).AppendLine(" /// Creates the extension; set ."); + sb.Append(indent).Append(" public ").Append(extension).AppendLine("()"); + sb.Append(indent).AppendLine(" {"); + sb.Append(indent).AppendLine(" }"); + sb.AppendLine(); + sb.Append(indent).AppendLine(" /// Creates the extension for ."); + sb.Append(indent).Append(" public ").Append(extension).Append('(').Append(kind).AppendLine(" kind)"); + sb.Append(indent).AppendLine(" {"); + sb.Append(indent).AppendLine(" Kind = kind;"); + sb.Append(indent).AppendLine(" }"); + sb.AppendLine(); + sb.Append(indent).AppendLine(" /// The text."); + sb.Append(indent).Append(" public ").Append(kind).AppendLine(" Kind { get; set; }"); + sb.AppendLine(); + sb.Append(indent).AppendLine(" /// Returns a dynamic resource binding to the text's key (local priority, see ArcResourceReference)."); + sb.Append(indent).AppendLine(" public global::Avalonia.Data.BindingBase ProvideValue(global::System.IServiceProvider serviceProvider)"); + sb.Append(indent).Append(" => global::TKCode.Arc.ArcResourceReference.Create(").Append(resources) + .AppendLine(".Key(Kind), serviceProvider);"); + sb.Append(indent).AppendLine("}"); + if (catalog.Namespace is not null) + { + sb.AppendLine("}"); + } + return sb.ToString(); + } + + private static string Literal(string text) => SymbolDisplay.FormatLiteral(text, quote: true); + + private static string Identifier(string name) + => SyntaxFacts.GetKeywordKind(name) != SyntaxKind.None ? "@" + name : name; + } +} diff --git a/src/TKCode.Arc.Foundation.Generators/LocalizationModel.cs b/src/TKCode.Arc.Foundation.Generators/LocalizationModel.cs new file mode 100644 index 00000000..84188c74 --- /dev/null +++ b/src/TKCode.Arc.Foundation.Generators/LocalizationModel.cs @@ -0,0 +1,324 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Xml; +using System.Xml.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace TKCode.Arc.Foundation.Generators +{ + /// A [LanguageCatalog] enum. + internal sealed class CatalogModel : IEquatable + { + private CatalogModel(string fullName, string? ns, string name, bool isPublic, bool isNested, + EquatableArray members, EquatableArray values, string? filePath, TextSpan span, + LinePositionSpan lineSpan) + { + FullName = fullName; + Namespace = ns; + Name = name; + IsPublic = isPublic; + IsNested = isNested; + Members = members; + Values = values; + FilePath = filePath; + Span = span; + LineSpan = lineSpan; + } + + /// Metadata-style full name (Ns.Name), the xlf file id. + public string FullName { get; } + + public string? Namespace { get; } + public string Name { get; } + public bool IsPublic { get; } + public bool IsNested { get; } + + /// Member names in declaration order. + public EquatableArray Members { get; } + + /// The members' constant values, aligned with . + public EquatableArray Values { get; } + + public string? FilePath { get; } + public TextSpan Span { get; } + public LinePositionSpan LineSpan { get; } + + /// DataGridLangResourceKind → DataGridLangResource. + public string BaseName => Name.EndsWith("Kind", StringComparison.Ordinal) && Name.Length > 4 + ? Name.Substring(0, Name.Length - 4) + : Name; + + public string ExtensionName => BaseName + "Extension"; + + public string ResourcesName => BaseName + "s"; + + public static CatalogModel Create(INamedTypeSymbol symbol) + { + var ns = symbol.ContainingNamespace is { IsGlobalNamespace: false } containing ? containing.ToDisplayString() : null; + var fields = symbol.GetMembers().OfType().Where(f => f.HasConstantValue).ToList(); + var isPublic = symbol.DeclaredAccessibility == Accessibility.Public; + var location = symbol.Locations.FirstOrDefault(); + var lineSpan = location?.GetLineSpan(); + return new CatalogModel((ns is null ? "" : ns + ".") + symbol.Name, ns, symbol.Name, isPublic, + symbol.ContainingType is not null, + new EquatableArray(fields.Select(f => f.Name)), + new EquatableArray(fields.Select(f => Convert.ToString(f.ConstantValue, System.Globalization.CultureInfo.InvariantCulture) ?? "")), + location?.SourceTree is null ? null : lineSpan?.Path, location?.SourceSpan ?? default, lineSpan?.Span ?? default); + } + + public Diagnostic CreateDiagnostic(DiagnosticDescriptor descriptor, params object[] arguments) + { + var location = FilePath is null ? Location.None : Location.Create(FilePath, Span, LineSpan); + return Diagnostic.Create(descriptor, location, arguments); + } + + public bool Equals(CatalogModel? other) + => other is not null && FullName == other.FullName && IsPublic == other.IsPublic && IsNested == other.IsNested + && Members.Equals(other.Members) && Values.Equals(other.Values) && FilePath == other.FilePath + && Span.Equals(other.Span); + + public override bool Equals(object? obj) => Equals(obj as CatalogModel); + + public override int GetHashCode() => FullName.GetHashCode(); + } + + /// One <unit> of an xlf file. + internal sealed class XlfUnit + { + public XlfUnit(string id, string? source, string? target, int line) + { + Id = id; + Source = source; + Target = target; + Line = line; + } + + public string Id { get; } + public string? Source { get; } + public string? Target { get; } + + /// One-based line of the unit. + public int Line { get; } + } + + /// One <file> of an xlf file (the text of one catalog). + internal sealed class XlfSection + { + public XlfSection(string id, int line, List units) + { + Id = id; + Line = line; + Units = units; + } + + public string Id { get; } + public int Line { get; } + public List Units { get; } + } + + /// + /// A parsed Localization/<language>.xlf file (XLIFF 2). The file name is its language. A file without + /// trgLang (or with trgLang = srcLang) is a source file: its text is the <source>; + /// any other file's text is the <target>. + /// + internal sealed class XlfFile : IEquatable + { + private const string Xliff2Namespace = "urn:oasis:names:tc:xliff:document:2.0"; + + private XlfFile(string path, string relativePath, string text) + { + Path = path; + RelativePath = relativePath; + Text = text; + } + + public string Path { get; } + public string RelativePath { get; } + + /// The raw text; equality compares it, so an edit re-runs the generator and nothing else does. + public string Text { get; } + + /// The canonical language from the file name. + public string? Language { get; private set; } + + public string? SourceLanguage { get; private set; } + + public bool IsSource { get; private set; } + + /// Why the file cannot be used, or null. + public string? Error { get; private set; } + + public int ErrorLine { get; private set; } = 1; + + public List Sections { get; } = new List(); + + /// Whether is an xlf file directly inside a Localization folder. + public static bool IsLanguageFile(string path) + { + if (!path.EndsWith(".xlf", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + var directory = System.IO.Path.GetFileName(System.IO.Path.GetDirectoryName(path) ?? ""); + return string.Equals(directory, "Localization", StringComparison.Ordinal); + } + + public static XlfFile Parse(string path, string relativePath, SourceText? text) + { + var file = new XlfFile(path, relativePath, text?.ToString() ?? ""); + file.Read(); + return file; + } + + private void Read() + { + var name = System.IO.Path.GetFileNameWithoutExtension(Path); + Language = CanonicalTag(name); + if (Language is null) + { + Error = $"the file name '{name}' is not a language tag (name it after its language, for example zh-CN.xlf)"; + return; + } + XDocument document; + try + { + document = XDocument.Parse(Text, LoadOptions.SetLineInfo); + } + catch (XmlException e) + { + Error = e.Message; + ErrorLine = Math.Max(1, e.LineNumber); + return; + } + var root = document.Root!; + XNamespace ns = Xliff2Namespace; + var version = (string?)root.Attribute("version"); + if (root.Name != ns + "xliff" || version is null || !version.StartsWith("2.", StringComparison.Ordinal)) + { + Error = $"the root element must be "; + return; + } + var source = CanonicalTag((string?)root.Attribute("srcLang")); + if (source is null) + { + Error = " needs a srcLang that is a language tag"; + return; + } + SourceLanguage = source; + var targetText = (string?)root.Attribute("trgLang"); + var target = targetText is null ? null : CanonicalTag(targetText); + if (targetText is not null && target is null) + { + Error = $"trgLang '{targetText}' is not a language tag"; + return; + } + IsSource = target is null || target == source; + var declared = target ?? source; + if (declared != Language) + { + Error = $"the file is named '{name}' but declares the language '{declared}' ({(target is null ? "srcLang" : "trgLang")})"; + return; + } + foreach (var fileElement in root.Elements(ns + "file")) + { + var id = (string?)fileElement.Attribute("id"); + if (string.IsNullOrEmpty(id)) + { + Error = "a has no id (the catalog enum's full name)"; + ErrorLine = LineOf(fileElement); + return; + } + var units = new List(); + foreach (var unit in fileElement.Descendants(ns + "unit")) + { + var unitId = (string?)unit.Attribute("id"); + if (string.IsNullOrEmpty(unitId)) + { + Error = "a has no id (the catalog member name)"; + ErrorLine = LineOf(unit); + return; + } + var segments = unit.Elements(ns + "segment").ToList(); + units.Add(new XlfUnit(unitId!, Join(segments, ns + "source"), Join(segments, ns + "target"), LineOf(unit))); + } + Sections.Add(new XlfSection(id!, LineOf(fileElement), units)); + } + } + + private static string? Join(List segments, XName part) + { + StringBuilder? text = null; + foreach (var segment in segments) + { + var element = segment.Element(part); + if (element is null) + { + continue; + } + text ??= new StringBuilder(); + text.Append(element.Value); + } + return text?.ToString(); + } + + private static int LineOf(XObject node) => node is IXmlLineInfo info && info.HasLineInfo() ? info.LineNumber : 1; + + /// The canonical form of a BCP 47 tag (the runtime's LanguageTag rules), or null. + public static string? CanonicalTag(string? tag) + { + if (tag is null || tag.Trim().Length == 0) + { + return null; + } + var parts = tag.Trim().Split('-', '_'); + var builder = new StringBuilder(); + for (var i = 0; i < parts.Length; i++) + { + var part = parts[i]; + if (part.Length == 0 || part.Length > 8 || !part.All(c => c < 128 && char.IsLetterOrDigit(c))) + { + return null; + } + if (i == 0) + { + if (part.Length < 2 || !part.All(char.IsLetter)) + { + return null; + } + builder.Append(part.ToLowerInvariant()); + continue; + } + builder.Append('-'); + if (i == 1 && part.Length == 4 && part.All(char.IsLetter)) + { + builder.Append(char.ToUpperInvariant(part[0])).Append(part.Substring(1).ToLowerInvariant()); + } + else if ((part.Length == 2 && part.All(char.IsLetter)) || (part.Length == 3 && part.All(char.IsDigit))) + { + builder.Append(part.ToUpperInvariant()); + } + else + { + builder.Append(part.ToLowerInvariant()); + } + } + return builder.ToString(); + } + + public Location LocationAt(int line) + { + var position = new LinePosition(Math.Max(0, line - 1), 0); + return Location.Create(Path, default, new LinePositionSpan(position, position)); + } + + public bool Equals(XlfFile? other) => other is not null && Path == other.Path && RelativePath == other.RelativePath && Text == other.Text; + + public override bool Equals(object? obj) => Equals(obj as XlfFile); + + public override int GetHashCode() => Path.GetHashCode(); + } +} diff --git a/src/TKCode.Arc.Foundation.Generators/TKCode.Arc.Foundation.Generators.csproj b/src/TKCode.Arc.Foundation.Generators/TKCode.Arc.Foundation.Generators.csproj new file mode 100644 index 00000000..88a49cda --- /dev/null +++ b/src/TKCode.Arc.Foundation.Generators/TKCode.Arc.Foundation.Generators.csproj @@ -0,0 +1,34 @@ + + + + + netstandard2.0 + false + false + false + false + + false + false + false + false + false + TKCode.Arc.Foundation.Generators + true + true + disable + + $(NoWarn);RS2008 + + + + + + + + + diff --git a/src/TKCode.Arc.Foundation.Generators/ThemeEmitter.cs b/src/TKCode.Arc.Foundation.Generators/ThemeEmitter.cs new file mode 100644 index 00000000..13b4c243 --- /dev/null +++ b/src/TKCode.Arc.Foundation.Generators/ThemeEmitter.cs @@ -0,0 +1,118 @@ +using System.Collections.Generic; +using System.Text; + +namespace TKCode.Arc.Foundation.Generators +{ + /// Writes theme dictionary classes and the per-assembly asset table. + internal static class ThemeEmitter + { + public static string EmitDictionaryClass(ThemeFile file, string className) + { + var dot = className.LastIndexOf('.'); + var ns = dot > 0 ? className.Substring(0, dot) : null; + var name = dot > 0 ? className.Substring(dot + 1) : className; + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + var indent = string.Empty; + if (ns is not null) + { + sb.Append("namespace ").AppendLine(ns); + sb.AppendLine("{"); + indent = " "; + } + sb.Append(indent).Append("/// The compiled dictionary of ").Append(file.RelativePath).AppendLine("."); + sb.Append(indent).Append(file.IsPublic ? "public" : "internal").Append(" partial class ").Append(name) + .AppendLine(" : global::Avalonia.Controls.ResourceDictionary"); + sb.Append(indent).AppendLine("{"); + sb.Append(indent).AppendLine(" /// Builds the dictionary from its compiled XAML (no parsing, no URI lookup)."); + sb.Append(indent).Append(" public ").Append(name).AppendLine("()"); + sb.Append(indent).AppendLine(" {"); + // Avalonia's XAML compiler rewrites this call into the compiled populate method of the x:Class. + sb.Append(indent).AppendLine(" global::Avalonia.Markup.Xaml.AvaloniaXamlLoader.Load(this);"); + sb.Append(indent).AppendLine(" }"); + sb.Append(indent).AppendLine("}"); + if (ns is not null) + { + sb.AppendLine("}"); + } + return sb.ToString(); + } + + public static string EmitAssets(string assemblyName, List tokens, + List<(ThemeFile File, string ClassName, List Keys)> assets, + List catalogs) + { + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + sb.Append("namespace TKCode.Arc.Generated.").AppendLine(Identifier(assemblyName)); + sb.AppendLine("{"); + sb.Append(" /// Token identities and control-theme files of ").Append(assemblyName).AppendLine(", for the package registration."); + sb.AppendLine(" internal static class ArcGeneratedAssets"); + sb.AppendLine(" {"); + sb.AppendLine(" /// The assembly name, the default package id."); + sb.Append(" public const string PackageId = \"").Append(assemblyName).AppendLine("\";"); + sb.AppendLine(); + sb.AppendLine(" /// The token identities declared in this assembly."); + sb.AppendLine(" public static readonly global::TKCode.Arc.ArcTokenDescriptor[] Tokens = new global::TKCode.Arc.ArcTokenDescriptor[]"); + sb.AppendLine(" {"); + foreach (var token in tokens) + { + sb.Append(" global::").Append(token.TokensNamespace).Append('.').Append(token.Stem).AppendLine("Tokens.Descriptor,"); + } + sb.AppendLine(" };"); + sb.AppendLine(); + sb.AppendLine(" /// The control-theme files of this assembly; each is instantiated on first lookup of one of its keys."); + sb.AppendLine(" public static readonly global::TKCode.Arc.ArcThemeAsset[] Themes = new global::TKCode.Arc.ArcThemeAsset[]"); + sb.AppendLine(" {"); + foreach (var (file, className, keys) in assets) + { + sb.Append(" new global::TKCode.Arc.ArcThemeAsset(\"").Append(assemblyName).Append(':').Append(file.RelativePath) + .Append("\", new object[] { ").Append(string.Join(", ", keys)).Append(" }, static () => new global::") + .Append(className).AppendLine("()),"); + } + sb.AppendLine(" };"); + sb.AppendLine(); + // Language catalogs (only when there are any, so assemblies without catalogs generate what they did before). + if (catalogs.Count > 0) + { + sb.AppendLine(" /// The language catalogs of this assembly."); + sb.AppendLine(" public static readonly global::TKCode.Arc.Localization.ArcLanguageCatalog[] Languages = new global::TKCode.Arc.Localization.ArcLanguageCatalog[]"); + sb.AppendLine(" {"); + foreach (var catalog in catalogs) + { + sb.Append(" ").Append(catalog).AppendLine(".Catalog,"); + } + sb.AppendLine(" };"); + sb.AppendLine(); + } + sb.Append(" /// Adds this assembly's tokens").Append(catalogs.Count > 0 ? ", theme files and language catalogs" : " and theme files") + .AppendLine(" to ."); + sb.AppendLine(" public static global::TKCode.Arc.ArcPackageBuilder AddTo(global::TKCode.Arc.ArcPackageBuilder package)"); + sb.Append(" => package.AddTokens(Tokens).AddThemes(Themes)").Append(catalogs.Count > 0 ? ".AddLanguages(Languages)" : "").AppendLine(";"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static string Identifier(string assemblyName) + { + var sb = new StringBuilder(); + foreach (var c in assemblyName) + { + if (char.IsLetterOrDigit(c) || c == '_') + { + sb.Append(c); + } + } + if (sb.Length == 0 || char.IsDigit(sb[0])) + { + sb.Insert(0, '_'); + } + return sb.ToString(); + } + } +} diff --git a/src/TKCode.Arc.Foundation.Generators/ThemeModel.cs b/src/TKCode.Arc.Foundation.Generators/ThemeModel.cs new file mode 100644 index 00000000..3be068fa --- /dev/null +++ b/src/TKCode.Arc.Foundation.Generators/ThemeModel.cs @@ -0,0 +1,199 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Xml; +using System.Xml.Linq; +using Microsoft.CodeAnalysis.Text; + +namespace TKCode.Arc.Foundation.Generators +{ + /// A top-level key of a theme file: {x:Type prefix:Name} or a plain string. + internal sealed class ThemeKey : IEquatable + { + public ThemeKey(string text, string? xmlNamespace, string? typeName, int line, int column) + { + Text = text; + XmlNamespace = xmlNamespace; + TypeName = typeName; + Line = line; + Column = column; + } + + /// The x:Key text as written. + public string Text { get; } + + /// For a type key, the XML namespace of its prefix; null for a string key. + public string? XmlNamespace { get; } + + /// For a type key, the type name without prefix. + public string? TypeName { get; } + + public int Line { get; } + public int Column { get; } + + public bool IsType => TypeName is not null; + + public bool Equals(ThemeKey? other) + => other is not null && Text == other.Text && XmlNamespace == other.XmlNamespace && Line == other.Line; + + public override bool Equals(object? obj) => Equals(obj as ThemeKey); + + public override int GetHashCode() => Text.GetHashCode(); + } + + /// A parsed Themes/*Theme.axaml file. + internal sealed class ThemeFile : IEquatable + { + public ThemeFile(string path, string relativePath, string? className, bool isPublic, string? error, EquatableArray keys) + { + Path = path; + RelativePath = relativePath; + ClassName = className; + IsPublic = isPublic; + Error = error; + Keys = keys; + } + + public string Path { get; } + + /// Path relative to the project directory, with forward slashes. + public string RelativePath { get; } + + /// The x:Class value (full name), or null. + public string? ClassName { get; } + + public bool IsPublic { get; } + + /// Why the file could not be read, or null. + public string? Error { get; } + + public EquatableArray Keys { get; } + + public bool Equals(ThemeFile? other) + => other is not null && Path == other.Path && RelativePath == other.RelativePath && ClassName == other.ClassName + && IsPublic == other.IsPublic && Error == other.Error && Keys.Equals(other.Keys); + + public override bool Equals(object? obj) => Equals(obj as ThemeFile); + + public override int GetHashCode() => Path.GetHashCode(); + + /// True for …/Themes/XxxTheme.axaml. + public static bool IsThemeFile(string path) + { + var normalized = path.Replace('\\', '/'); + var name = System.IO.Path.GetFileName(normalized); + var directory = System.IO.Path.GetFileName(System.IO.Path.GetDirectoryName(normalized) ?? string.Empty); + return directory == "Themes" && name.EndsWith("Theme.axaml", StringComparison.Ordinal); + } + + private const string XamlNamespace = "http://schemas.microsoft.com/winfx/2006/xaml"; + + public static ThemeFile Parse(string path, string relativePath, SourceText? text) + { + if (text is null) + { + return new ThemeFile(path, relativePath, null, false, "the file could not be read", EquatableArray.Empty); + } + XDocument document; + try + { + document = XDocument.Parse(text.ToString(), LoadOptions.SetLineInfo); + } + catch (XmlException exception) + { + return new ThemeFile(path, relativePath, null, false, exception.Message, EquatableArray.Empty); + } + var root = document.Root!; + if (root.Name.LocalName != "ResourceDictionary") + { + return new ThemeFile(path, relativePath, null, false, + $"the root element is '{root.Name.LocalName}', not ResourceDictionary", EquatableArray.Empty); + } + var className = (string?)root.Attribute(XName.Get("Class", XamlNamespace)); + var modifier = (string?)root.Attribute(XName.Get("ClassModifier", XamlNamespace)); + var isPublic = modifier is null || string.Equals(modifier, "public", StringComparison.OrdinalIgnoreCase); + + var keys = new List(); + foreach (var element in root.Elements()) + { + // Property elements (ResourceDictionary.MergedDictionaries, .ThemeDictionaries) are not resources. + if (element.Name.LocalName.IndexOf('.') >= 0) + { + continue; + } + var key = element.Attribute(XName.Get("Key", XamlNamespace)); + if (key is null) + { + continue; + } + var info = (IXmlLineInfo)key; + keys.Add(ParseKey(key.Value, element, info.LineNumber, info.LinePosition)); + } + return new ThemeFile(path, relativePath, className, isPublic, null, new EquatableArray(keys)); + } + + private static ThemeKey ParseKey(string value, XElement element, int line, int column) + { + var trimmed = value.Trim(); + if (trimmed.StartsWith("{", StringComparison.Ordinal) && trimmed.EndsWith("}", StringComparison.Ordinal)) + { + var inner = trimmed.Substring(1, trimmed.Length - 2).Trim(); + var space = inner.IndexOf(' '); + if (space > 0) + { + var extension = inner.Substring(0, space); + var argument = inner.Substring(space + 1).Trim(); + if (argument.StartsWith("TypeName=", StringComparison.Ordinal)) + { + argument = argument.Substring("TypeName=".Length).Trim(); + } + if (extension is "x:Type" or "x:TypeExtension") + { + var colon = argument.IndexOf(':'); + var prefix = colon > 0 ? argument.Substring(0, colon) : string.Empty; + var name = colon > 0 ? argument.Substring(colon + 1) : argument; + var ns = prefix.Length == 0 ? element.GetDefaultNamespace() : element.GetNamespaceOfPrefix(prefix); + return new ThemeKey(trimmed, ns?.NamespaceName ?? string.Empty, name, line, column); + } + } + } + return new ThemeKey(value, null, null, line, column); + } + + public static string MakeRelative(string path, string? projectDirectory) + { + var normalized = path.Replace('\\', '/'); + if (!string.IsNullOrEmpty(projectDirectory)) + { + var directory = projectDirectory!.Replace('\\', '/'); + if (!directory.EndsWith("/", StringComparison.Ordinal)) + { + directory += "/"; + } + if (normalized.StartsWith(directory, StringComparison.OrdinalIgnoreCase)) + { + return normalized.Substring(directory.Length); + } + } + return System.IO.Path.GetFileName(normalized); + } + + public LinePositionSpan LineSpan(ThemeKey key) + { + var start = new LinePosition(Math.Max(0, key.Line - 1), Math.Max(0, key.Column - 1)); + return new LinePositionSpan(start, start); + } + + /// + /// The class a theme file should name: <RootNamespace>.<FileName>, flat like every Arc type + /// (Buttons/Button/Themes/ButtonTheme.axaml → TKCode.Arc.ButtonTheme). Two theme files of one project + /// with the same file name need different names; tools/dev/migrate_atomui.py prefixes the folder names then. + /// + public static string SuggestClassName(string relativePath, string? rootNamespace) + { + var name = System.IO.Path.GetFileNameWithoutExtension(relativePath.Split('/').Last(p => p.Length > 0)); + return string.IsNullOrEmpty(rootNamespace) ? name : rootNamespace + "." + name; + } + } +} diff --git a/src/TKCode.Arc.Foundation.Generators/TokenEmitter.cs b/src/TKCode.Arc.Foundation.Generators/TokenEmitter.cs new file mode 100644 index 00000000..0b199594 --- /dev/null +++ b/src/TKCode.Arc.Foundation.Generators/TokenEmitter.cs @@ -0,0 +1,230 @@ +using System.Text; + +namespace TKCode.Arc.Foundation.Generators +{ + /// Writes the code generated for one token class. + internal static class TokenEmitter + { + private const string Foundation = "global::TKCode.Arc."; + private const string Parser = "global::TKCode.Arc.ArcTokenValueParser."; + + public static string Emit(TokenModel model) + { + var id = model.Stem; + var kind = id + "TokenKind"; + var tokens = id + "Tokens"; + var descriptor = id + "TokenDescriptor"; + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + sb.Append("namespace ").AppendLine(model.TokensNamespace); + sb.AppendLine("{"); + + // Kind enum. + sb.Append(" /// Token names of the ").Append(id).AppendLine(" identity. A boxed value is the token's resource key."); + sb.Append(" public enum ").AppendLine(kind); + sb.AppendLine(" {"); + for (var i = 0; i < model.Properties.Count; i++) + { + var property = model.Properties[i]; + sb.Append(" /// Token ").Append(property.Name).Append(" (").Append(Escape(property.DisplayType)).AppendLine(")."); + sb.Append(" ").Append(property.Name).Append(" = ").Append(i).AppendLine(","); + } + sb.AppendLine(" }"); + sb.AppendLine(); + + // Keys and identity. + sb.Append(" /// Identity and resource keys of the ").Append(id).AppendLine(" tokens."); + sb.Append(" public static class ").AppendLine(tokens); + sb.AppendLine(" {"); + sb.AppendLine(" /// The identity name, used by token overrides."); + sb.Append(" public const string Identity = \"").Append(model.Identity).AppendLine("\";"); + sb.AppendLine(); + sb.Append(" internal static readonly object[] KeyTable = new object[] { "); + for (var i = 0; i < model.Properties.Count; i++) + { + if (i > 0) + { + sb.Append(", "); + } + sb.Append(kind).Append('.').Append(model.Properties[i].Name); + } + sb.AppendLine(" };"); + sb.AppendLine(); + sb.AppendLine(" /// The resource key of : the boxed kind (equal to any other boxing of it), cached."); + sb.Append(" public static object Key(").Append(kind).AppendLine(" kind) => KeyTable[(int)kind];"); + sb.AppendLine(); + sb.AppendLine(" /// The generated descriptor (writer and override parser)."); + sb.Append(" public static ").Append(Foundation).Append("ArcTokenDescriptor Descriptor => ").Append(descriptor).AppendLine(".Instance;"); + sb.AppendLine(" }"); + sb.AppendLine(); + + // Markup extension. + sb.Append(" /// Dynamic resource reference to a ").Append(id).Append(" token, written {arc:").Append(id).AppendLine("TokenResource Name}."); + sb.Append(" public sealed class ").Append(id).AppendLine("TokenResourceExtension"); + sb.AppendLine(" {"); + sb.AppendLine(" /// Creates the extension; set ."); + sb.Append(" public ").Append(id).AppendLine("TokenResourceExtension()"); + sb.AppendLine(" {"); + sb.AppendLine(" }"); + sb.AppendLine(); + sb.AppendLine(" /// Creates the extension for ."); + sb.Append(" public ").Append(id).Append("TokenResourceExtension(").Append(kind).AppendLine(" kind)"); + sb.AppendLine(" {"); + sb.AppendLine(" Kind = kind;"); + sb.AppendLine(" }"); + sb.AppendLine(); + sb.AppendLine(" /// The token."); + sb.Append(" public ").Append(kind).AppendLine(" Kind { get; set; }"); + sb.AppendLine(); + sb.AppendLine(" /// Returns a dynamic resource binding to the token's key (local priority, see ArcResourceReference)."); + sb.AppendLine(" public global::Avalonia.Data.BindingBase ProvideValue(global::System.IServiceProvider serviceProvider)"); + sb.Append(" => global::TKCode.Arc.ArcResourceReference.Create(").Append(tokens).AppendLine(".Key(Kind), serviceProvider);"); + sb.AppendLine(" }"); + sb.AppendLine(); + + // Descriptor. + sb.Append(" internal sealed class ").Append(descriptor).Append(" : ").Append(Foundation).AppendLine("ArcTokenDescriptor"); + sb.AppendLine(" {"); + sb.Append(" public static readonly ").Append(descriptor).Append(" Instance = new ").Append(descriptor).AppendLine("();"); + sb.AppendLine(); + sb.Append(" private ").Append(descriptor).AppendLine("()"); + sb.Append(" : base(").Append(tokens).Append(".Identity, ").Append(model.IsGlobal ? "true" : "false").Append(", new string[] { "); + for (var i = 0; i < model.Properties.Count; i++) + { + if (i > 0) + { + sb.Append(", "); + } + sb.Append('"').Append(model.Properties[i].Name).Append('"'); + } + sb.Append(" }, ").Append(tokens).AppendLine(".KeyTable)"); + sb.AppendLine(" {"); + sb.AppendLine(" }"); + sb.AppendLine(); + sb.Append(" public override ").Append(Foundation).Append("AbstractControlDesignToken CreateToken() => new ").Append(model.FullClassName).AppendLine("();"); + sb.AppendLine(); + sb.Append(" public override void Write(").Append(Foundation).AppendLine("AbstractControlDesignToken token, object?[] values)"); + sb.AppendLine(" {"); + if (model.Properties.Count > 0) + { + sb.Append(" var t = (").Append(model.FullClassName).AppendLine(")token;"); + } + for (var i = 0; i < model.Properties.Count; i++) + { + var property = model.Properties[i]; + sb.Append(" values[").Append(i).Append("] = "); + if (property.Kind == TokenValueKind.Color) + { + sb.Append(Parser).Append("Brush(t.").Append(property.Name).AppendLine(");"); + } + else + { + sb.Append("t.").Append(property.Name).AppendLine(";"); + } + } + sb.AppendLine(" }"); + sb.AppendLine(); + sb.Append(" public override bool TrySetToken(").Append(Foundation).AppendLine("AbstractControlDesignToken token, string name, string value, out string? error)"); + sb.AppendLine(" {"); + if (model.Properties.Count > 0) + { + sb.Append(" var t = (").Append(model.FullClassName).AppendLine(")token;"); + sb.AppendLine(" switch (name)"); + sb.AppendLine(" {"); + foreach (var property in model.Properties) + { + sb.Append(" case \"").Append(property.Name).AppendLine("\":"); + sb.AppendLine(" {"); + if (property.Kind == TokenValueKind.String) + { + sb.Append(" t.").Append(property.Name).AppendLine(" = value;"); + sb.AppendLine(" error = null;"); + sb.AppendLine(" return true;"); + } + else + { + sb.Append(" if (").Append(ParseCall(property)).Append("(value, out ").Append(property.TypeName).AppendLine(" parsed))"); + sb.AppendLine(" {"); + sb.Append(" t.").Append(property.Name).AppendLine(" = parsed;"); + sb.AppendLine(" error = null;"); + sb.AppendLine(" return true;"); + sb.AppendLine(" }"); + sb.Append(" error = InvalidValue(name, \"").Append(Escape(TypeDescription(property))).AppendLine("\", value);"); + sb.AppendLine(" return false;"); + } + sb.AppendLine(" }"); + } + sb.AppendLine(" }"); + } + sb.AppendLine(" error = UnknownToken(name);"); + sb.AppendLine(" return false;"); + sb.AppendLine(" }"); + + // Enum parsers: generated switches, no Enum.Parse. + var emitted = new System.Collections.Generic.HashSet(); + foreach (var property in model.Properties) + { + if (property.Kind != TokenValueKind.Enum || !emitted.Add(property.TypeName)) + { + continue; + } + sb.AppendLine(); + sb.Append(" private static bool ").Append(EnumParserName(property)).Append("(string text, out ").Append(property.TypeName).AppendLine(" value)"); + sb.AppendLine(" {"); + sb.AppendLine(" switch (text.Trim().ToUpperInvariant())"); + sb.AppendLine(" {"); + foreach (var member in property.EnumMembers) + { + sb.Append(" case \"").Append(member.ToUpperInvariant()).AppendLine("\":"); + sb.Append(" value = ").Append(property.TypeName).Append('.').Append(member).AppendLine(";"); + sb.AppendLine(" return true;"); + } + sb.AppendLine(" }"); + sb.AppendLine(" value = default;"); + sb.AppendLine(" return false;"); + sb.AppendLine(" }"); + } + + sb.AppendLine(" }"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static string ParseCall(TokenProperty property) => property.Kind switch + { + TokenValueKind.Color => Parser + "TryParseColor", + TokenValueKind.Double => Parser + "TryParseDouble", + TokenValueKind.Int32 => Parser + "TryParseInt32", + TokenValueKind.Boolean => Parser + "TryParseBoolean", + TokenValueKind.Thickness => Parser + "TryParseThickness", + TokenValueKind.CornerRadius => Parser + "TryParseCornerRadius", + TokenValueKind.BoxShadows => Parser + "TryParseBoxShadows", + TokenValueKind.FontFamily => Parser + "TryParseFontFamily", + TokenValueKind.TimeSpan => Parser + "TryParseTimeSpan", + TokenValueKind.Easing => Parser + "TryParseEasing", + _ => EnumParserName(property) + }; + + private static string TypeDescription(TokenProperty property) => property.Kind switch + { + TokenValueKind.Color => "color (#RRGGBB, #AARRGGBB or a name)", + TokenValueKind.Double => "number", + TokenValueKind.Int32 => "integer", + TokenValueKind.Boolean => "boolean (true or false)", + TokenValueKind.Thickness => "thickness (1, 2 or 4 numbers)", + TokenValueKind.CornerRadius => "corner radius (1 or 4 numbers)", + TokenValueKind.BoxShadows => "box shadow list", + TokenValueKind.FontFamily => "font family", + TokenValueKind.TimeSpan => "duration (120ms, 0.2s or 00:00:00.120)", + TokenValueKind.Easing => "easing (x1,y1,x2,y2 or an easing name)", + _ => property.DisplayType + " (" + string.Join(", ", property.EnumMembers) + ")" + }; + + private static string EnumParserName(TokenProperty property) + => "TryParse" + property.TypeName.Replace("global::", string.Empty).Replace(".", "_"); + + private static string Escape(string text) => text.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("<", "<").Replace(">", ">"); + } +} diff --git a/src/TKCode.Arc.Foundation.Generators/TokenModel.cs b/src/TKCode.Arc.Foundation.Generators/TokenModel.cs new file mode 100644 index 00000000..74d57d5d --- /dev/null +++ b/src/TKCode.Arc.Foundation.Generators/TokenModel.cs @@ -0,0 +1,267 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.CodeAnalysis; + +namespace TKCode.Arc.Foundation.Generators +{ + /// How a token property is written and parsed. + internal enum TokenValueKind + { + Color, + Double, + Int32, + Boolean, + String, + Thickness, + CornerRadius, + BoxShadows, + FontFamily, + TimeSpan, + Easing, + Enum + } + + internal sealed class TokenProperty : IEquatable + { + public TokenProperty(string name, TokenValueKind kind, string typeName, string displayType, EquatableArray enumMembers) + { + Name = name; + Kind = kind; + TypeName = typeName; + DisplayType = displayType; + EnumMembers = enumMembers; + } + + public string Name { get; } + public TokenValueKind Kind { get; } + + /// Fully qualified type name (global::…). + public string TypeName { get; } + + /// Type as shown in messages. + public string DisplayType { get; } + + public EquatableArray EnumMembers { get; } + + public bool Equals(TokenProperty? other) + => other is not null && Name == other.Name && Kind == other.Kind && TypeName == other.TypeName + && EnumMembers.Equals(other.EnumMembers); + + public override bool Equals(object? obj) => Equals(obj as TokenProperty); + + public override int GetHashCode() => unchecked(Name.GetHashCode() * 31 + (int)Kind); + } + + /// A [ControlDesignToken] class, as the emitter needs it. + internal sealed class TokenModel : IEquatable + { + public TokenModel(string className, string fullClassName, string ns, string stem, string identity, bool isGlobal, bool isValid, + EquatableArray properties, EquatableArray diagnostics, DiagnosticInfo location) + { + Location = location; + ClassName = className; + FullClassName = fullClassName; + Namespace = ns; + Stem = stem; + Identity = identity; + IsGlobal = isGlobal; + IsValid = isValid; + Properties = properties; + Diagnostics = diagnostics; + } + + public string ClassName { get; } + + /// global::Ns.ClassName + public string FullClassName { get; } + + public string Namespace { get; } + + /// The class name without Token: the prefix of the generated type names. + public string Stem { get; } + + /// The identity overrides name: unless the attribute sets Identity. + public string Identity { get; } + public bool IsGlobal { get; } + + /// False when the class cannot be emitted (diagnostics explain why). + public bool IsValid { get; } + + public EquatableArray Properties { get; } + public EquatableArray Diagnostics { get; } + + /// Where the class is declared, as a diagnostic template (for the assembly-level checks). + public DiagnosticInfo Location { get; } + + public Diagnostic CreateDiagnostic(DiagnosticDescriptor descriptor, params string[] arguments) + => new DiagnosticInfo(descriptor, Location.FilePath, Location.Span, Location.LineSpan, arguments).ToDiagnostic(); + + /// Namespace of the generated public types. + public string TokensNamespace => Namespace.Length == 0 ? "DesignTokens" : Namespace + ".DesignTokens"; + + public bool Equals(TokenModel? other) + => other is not null && FullClassName == other.FullClassName && Stem == other.Stem && Identity == other.Identity + && IsGlobal == other.IsGlobal && IsValid == other.IsValid + && Properties.Equals(other.Properties) && Diagnostics.Equals(other.Diagnostics) && Location.Equals(other.Location); + + public override bool Equals(object? obj) => Equals(obj as TokenModel); + + public override int GetHashCode() => FullClassName.GetHashCode(); + + private const string AbstractTokenName = "TKCode.Arc.AbstractControlDesignToken"; + + public static TokenModel Create(INamedTypeSymbol type, AttributeData attribute) + { + var diagnostics = new List(); + var location = type.Locations.FirstOrDefault(); + var className = type.Name; + var ns = type.ContainingNamespace is { IsGlobalNamespace: false } containing ? containing.ToDisplayString() : string.Empty; + var fullName = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var isGlobal = false; + string? explicitIdentity = null; + foreach (var named in attribute.NamedArguments) + { + if (named.Key == "IsGlobal" && named.Value.Value is bool flag) + { + isGlobal = flag; + } + else if (named.Key == "Identity" && named.Value.Value is string text && text.Length > 0) + { + explicitIdentity = text; + } + } + + var stem = className; + if (className.EndsWith("Token", StringComparison.Ordinal) && className.Length > "Token".Length) + { + stem = className.Substring(0, className.Length - "Token".Length); + } + else + { + diagnostics.Add(DiagnosticInfo.Create(Generators.Diagnostics.TokenClassName, location, className)); + } + var identity = explicitIdentity ?? stem; + + var isValid = true; + if (!DerivesFrom(type, AbstractTokenName)) + { + diagnostics.Add(DiagnosticInfo.Create(Generators.Diagnostics.NotATokenClass, location, className)); + isValid = false; + } + var hasDefaultCtor = type.InstanceConstructors.Any(c => c.Parameters.Length == 0 && c.DeclaredAccessibility >= Accessibility.Internal); + if (type.IsAbstract || type.IsGenericType || type.ContainingType is not null || !hasDefaultCtor) + { + diagnostics.Add(DiagnosticInfo.Create(Generators.Diagnostics.InvalidTokenClassShape, location, className)); + isValid = false; + } + + var properties = new List(); + if (isValid) + { + foreach (var property in CollectProperties(type)) + { + var token = Classify(property); + if (token is null) + { + diagnostics.Add(DiagnosticInfo.Create(Generators.Diagnostics.UnsupportedTokenType, + property.Locations.FirstOrDefault(), identity, property.Name, + property.Type.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat))); + continue; + } + properties.Add(token); + } + if (properties.Count == 0 && !diagnostics.Any(d => d.Descriptor == Generators.Diagnostics.UnsupportedTokenType)) + { + diagnostics.Add(DiagnosticInfo.Create(Generators.Diagnostics.EmptyTokenClass, location, className)); + } + } + + return new TokenModel(className, fullName, ns, stem, identity, isGlobal, isValid, + new EquatableArray(properties), new EquatableArray(diagnostics), + DiagnosticInfo.Create(Generators.Diagnostics.DuplicateIdentity, location)); + } + + private static bool DerivesFrom(INamedTypeSymbol type, string baseName) + { + for (var current = type.BaseType; current is not null; current = current.BaseType) + { + if (current.ToDisplayString() == baseName) + { + return true; + } + } + return false; + } + + /// Public instance properties with public get and set, base classes first, up to AbstractControlDesignToken. + private static IEnumerable CollectProperties(INamedTypeSymbol type) + { + var chain = new List(); + for (var current = type; current is not null && current.ToDisplayString() != AbstractTokenName; current = current.BaseType) + { + chain.Add(current); + } + chain.Reverse(); + var seen = new HashSet(StringComparer.Ordinal); + foreach (var declaring in chain) + { + foreach (var member in declaring.GetMembers()) + { + if (member is IPropertySymbol { IsStatic: false, IsIndexer: false, DeclaredAccessibility: Accessibility.Public } property + && property.GetMethod is { DeclaredAccessibility: Accessibility.Public } + && property.SetMethod is { DeclaredAccessibility: Accessibility.Public, IsInitOnly: false } + && seen.Add(property.Name)) + { + yield return property; + } + } + } + } + + private static TokenProperty? Classify(IPropertySymbol property) + { + var type = property.Type; + var full = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var display = type.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat); + TokenValueKind? kind = type.SpecialType switch + { + SpecialType.System_Double => TokenValueKind.Double, + SpecialType.System_Int32 => TokenValueKind.Int32, + SpecialType.System_Boolean => TokenValueKind.Boolean, + SpecialType.System_String => TokenValueKind.String, + _ => null + }; + if (kind is null) + { + kind = type.WithNullableAnnotation(NullableAnnotation.NotAnnotated).ToDisplayString() switch + { + "Avalonia.Media.Color" => TokenValueKind.Color, + "Avalonia.Thickness" => TokenValueKind.Thickness, + "Avalonia.CornerRadius" => TokenValueKind.CornerRadius, + "Avalonia.Media.BoxShadows" => TokenValueKind.BoxShadows, + "Avalonia.Media.FontFamily" => TokenValueKind.FontFamily, + "System.TimeSpan" => TokenValueKind.TimeSpan, + "Avalonia.Animation.Easings.Easing" => TokenValueKind.Easing, + _ => (TokenValueKind?)null + }; + } + var members = EquatableArray.Empty; + if (kind is null && type.TypeKind == TypeKind.Enum && type is INamedTypeSymbol enumType) + { + kind = TokenValueKind.Enum; + members = new EquatableArray(enumType.GetMembers().OfType() + .Where(f => f.IsConst && f.HasConstantValue).Select(f => f.Name)); + } + if (kind is null) + { + return null; + } + if (kind is TokenValueKind.String or TokenValueKind.FontFamily or TokenValueKind.Easing) + { + full = type.WithNullableAnnotation(NullableAnnotation.NotAnnotated).ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + } + return new TokenProperty(property.Name, kind.Value, full, display, members); + } + } +} diff --git a/src/TKCode.Arc.Foundation/Localization/ArcLanguageCatalog.cs b/src/TKCode.Arc.Foundation/Localization/ArcLanguageCatalog.cs new file mode 100644 index 00000000..a9585d4f --- /dev/null +++ b/src/TKCode.Arc.Foundation/Localization/ArcLanguageCatalog.cs @@ -0,0 +1,141 @@ +namespace TKCode.Arc.Localization; + +/// +/// Marks an enum as a language catalog: its members are text ids, and the Localization/<language>.xlf files of +/// the project (XLIFF 2, <file id> = the enum's full name, <unit id> = a member name) hold the text. +/// The Foundation generator turns it into XxxLangResources (C# lookup) and XxxLangResourceExtension +/// ({arc:XxxLangResource Member} in XAML), with XxxLangResourceKind → XxxLangResource. +/// +[AttributeUsage(AttributeTargets.Enum, Inherited = false)] +public sealed class LanguageCatalogAttribute : Attribute +{ +} + +/// The text of one language of a catalog, aligned with (null = missing). +public sealed class ArcLanguageTable +{ + /// Creates a table. + /// The language. + /// The text of every member in declaration order; null where the language has none. + public ArcLanguageTable(LanguageTag language, IReadOnlyList texts) + { + Language = language; + Texts = texts; + } + + /// The language. + public LanguageTag Language { get; } + + /// The text of every member in declaration order; null where the language has none. + public IReadOnlyList Texts { get; } +} + +/// +/// One [LanguageCatalog] enum and its text in every language, as generated from its xlf files (the generated +/// XxxLangResources.Catalog). A package registers its catalogs with . +/// +public sealed class ArcLanguageCatalog +{ + private readonly ArcLanguageTable? _source; + + /// Creates a catalog. + /// The enum's full name (the xlf file id). + /// The language the text was written in (the xlf srcLang). + /// The resource keys: the boxed enum members in declaration order. + /// The member names, aligned with . + /// The text of each language. + public ArcLanguageCatalog(string id, LanguageTag sourceLanguage, IReadOnlyList keys, IReadOnlyList names, + IReadOnlyList tables) + { + if (names.Count != keys.Count) + { + throw new ArgumentException($"Catalog '{id}' has {keys.Count} keys but {names.Count} names.", nameof(names)); + } + foreach (var table in tables) + { + if (table.Texts.Count != keys.Count) + { + throw new ArgumentException( + $"Catalog '{id}' has {keys.Count} keys but its '{table.Language}' table has {table.Texts.Count} entries.", nameof(tables)); + } + } + Id = id; + SourceLanguage = sourceLanguage; + Keys = keys; + Names = names; + Tables = tables; + Languages = tables.Select(t => t.Language).ToArray(); + _source = FindTable(sourceLanguage); + } + + /// The enum's full name. + public string Id { get; } + + /// The language the text was written in; the last fallback. + public LanguageTag SourceLanguage { get; } + + /// The resource keys: the boxed enum members in declaration order. + public IReadOnlyList Keys { get; } + + /// The member names, aligned with . + public IReadOnlyList Names { get; } + + /// The text of each language. + public IReadOnlyList Tables { get; } + + /// The languages that have a table. + public IReadOnlyList Languages { get; } + + /// The table of exactly , or null. + /// The language. + public ArcLanguageTable? FindTable(LanguageTag language) + { + foreach (var table in Tables) + { + if (table.Language == language) + { + return table; + } + } + return null; + } + + /// + /// The text of entry in : that language, then its parents + /// (zh-Hant-TW → zh-Hant → zh), then and its parents, then the source + /// language, then the member name. + /// + /// The member's declaration index. + /// The language. + /// The application's default language, or null. + public string Resolve(int index, LanguageTag language, LanguageTag? fallback = null) + { + if (index < 0 || index >= Keys.Count) + { + throw new ArgumentOutOfRangeException(nameof(index)); + } + return Lookup(index, language) ?? (fallback is null ? null : Lookup(index, fallback)) ?? _source?.Texts[index] ?? Names[index]; + } + + /// The entry text in or one of its parents, without other fallbacks. + /// The member's declaration index. + /// The language. + public string? Lookup(int index, LanguageTag language) + { + for (var tag = language; tag is not null; tag = tag.Parent) + { + if (FindTable(tag)?.Texts[index] is { } text) + { + return text; + } + } + return null; + } + + /// The source-language text of entry (the member name when it has none). + /// The member's declaration index. + public string SourceText(int index) => _source?.Texts[index] ?? Names[index]; + + /// + public override string ToString() => Id; +} diff --git a/src/TKCode.Arc.Foundation/Localization/ArcLanguageManager.cs b/src/TKCode.Arc.Foundation/Localization/ArcLanguageManager.cs new file mode 100644 index 00000000..1831c7d1 --- /dev/null +++ b/src/TKCode.Arc.Foundation/Localization/ArcLanguageManager.cs @@ -0,0 +1,224 @@ +using System.Globalization; +using Avalonia.Controls; + +namespace TKCode.Arc.Localization; + +/// Arguments of . +public sealed class LanguageChangedEventArgs : EventArgs +{ + /// Creates the arguments. + /// The language before the change. + /// The language after the change. + public LanguageChangedEventArgs(LanguageTag oldLanguage, LanguageTag newLanguage) + { + OldLanguage = oldLanguage; + NewLanguage = newLanguage; + } + + /// The language before the change. + public LanguageTag OldLanguage { get; } + + /// The language after the change. + public LanguageTag NewLanguage { get; } +} + +/// +/// The language of one application: which languages it offers, the current one, and the text of the enabled packages' +/// catalogs in it. The text is published in (merged into the application's resources) under the +/// boxed catalog enum members, so {arc:XxxLangResource Member} and GetResourceObservable(kind) follow a +/// language change by themselves; C# reads it with the generated XxxLangResources.Get(kind). +/// +public sealed class ArcLanguageManager +{ + private readonly Dictionary _resolved = new(); + private readonly Dictionary _values = new(); + + internal ArcLanguageManager(LanguageTag defaultLanguage, IReadOnlyList supportedLanguages, + IReadOnlyList catalogs) + { + DefaultLanguage = defaultLanguage; + SupportedLanguages = supportedLanguages; + Catalogs = catalogs; + Language = defaultLanguage; + Culture = defaultLanguage.ToCulture(); + Publish(); + } + + /// The manager of , or null before UseArc(...) set it up. + public static ArcLanguageManager? Current => ArcRuntime.Current?.Languages; + + /// The language the application starts in and the first fallback for missing text. + public LanguageTag DefaultLanguage { get; } + + /// The languages the application offers (UseLanguages); every enabled catalog covers each of them. + public IReadOnlyList SupportedLanguages { get; } + + /// The catalogs of the enabled packages, dependencies first. + public IReadOnlyList Catalogs { get; } + + /// The current language. + public LanguageTag Language { get; private set; } + + /// The culture of the current language, for formatting numbers and dates. + public CultureInfo Culture { get; private set; } + + /// The text of every enabled catalog in the current language, keyed by the boxed enum members. + public ResourceDictionary Resources { get; } = new(); + + /// Raised on the UI thread after the text of a new language was published. + public event EventHandler? Changed; + + /// + /// Switches to : publishes the text that differs in one resource change, then raises + /// . Setting the current language does nothing. Call it on the UI thread. + /// + /// One of . + /// The language is not one of . + public void Set(LanguageTag language) + { + if (!SupportedLanguages.Contains(language)) + { + throw new ArgumentException( + $"'{language}' is not one of the application's languages ({string.Join(", ", SupportedLanguages.Select(t => t.Value))}); add it to UseLanguages.", + nameof(language)); + } + if (language == Language) + { + return; + } + var old = Language; + Language = language; + Culture = language.ToCulture(); + _resolved.Clear(); + Publish(); + var args = new LanguageChangedEventArgs(old, language); + Changed?.Invoke(this, args); + ArcLanguage.RaiseChanged(this, args); + } + + /// The text of entry of in the current language. + /// The catalog; one that no enabled package registered is resolved the same way. + /// The member's declaration index. + public string Get(ArcLanguageCatalog catalog, int index) + { + if (!_resolved.TryGetValue(catalog, out var texts)) + { + texts = Resolve(catalog); + _resolved.Add(catalog, texts); + } + return texts[index]; + } + + /// + /// What is missing for an application that offers with + /// enabled: one message per catalog and language that lacks a table or some entries. A language is covered by its + /// own table and its parents' (zh-Hant covers zh-Hant-TW), never by the fallback languages. + /// + /// The application's languages. + /// The enabled catalogs with the package that registered each. + public static IReadOnlyList FindCoverageGaps(IReadOnlyList supported, + IEnumerable<(string Package, ArcLanguageCatalog Catalog)> catalogs) + { + var gaps = new List(); + foreach (var (package, catalog) in catalogs) + { + foreach (var language in supported) + { + var hasTable = false; + for (var tag = language; tag is not null && !hasTable; tag = tag.Parent) + { + hasTable = catalog.FindTable(tag) is not null; + } + if (!hasTable) + { + gaps.Add($"Package '{package}' has no '{language}' text for '{catalog.Id}' (add Localization/{language}.xlf)."); + continue; + } + var missing = new List(); + for (var i = 0; i < catalog.Keys.Count; i++) + { + if (catalog.Lookup(i, language) is null) + { + missing.Add(catalog.Names[i]); + } + } + if (missing.Count > 0) + { + gaps.Add($"Package '{package}' is missing {missing.Count} '{language}' entries of '{catalog.Id}': {string.Join(", ", missing)}."); + } + } + } + return gaps; + } + + private string[] Resolve(ArcLanguageCatalog catalog) + { + var texts = new string[catalog.Keys.Count]; + for (var i = 0; i < texts.Length; i++) + { + texts[i] = catalog.Resolve(i, Language, DefaultLanguage); + } + return texts; + } + + private void Publish() + { + _values.Clear(); + foreach (var catalog in Catalogs) + { + var texts = Resolve(catalog); + _resolved[catalog] = texts; + for (var i = 0; i < texts.Length; i++) + { + _values[catalog.Keys[i]] = texts[i]; + } + } + // Only the entries whose text differs are rewritten, in one SetItems: one ResourcesChanged for the whole tree, + // and bindings to text that two languages share do not change. + ArcTokenCalculator.Publish(Resources, _values); + } +} + +/// +/// The language of the current application: ArcLanguage.Set(LanguageTags.ZhCN). Before UseArc(...) set +/// the application up (design time, plain unit tests) text comes from the catalogs' source language. +/// +public static class ArcLanguage +{ + /// The current application's language manager, or null. + public static ArcLanguageManager? Manager => ArcLanguageManager.Current; + + /// The current language (en-US before the application is set up). + public static LanguageTag Current => Manager?.Language ?? LanguageTags.EnUS; + + /// The culture of the current language (the current culture before the application is set up). + public static CultureInfo Culture => Manager?.Culture ?? CultureInfo.CurrentCulture; + + /// Raised on the UI thread after any application's language changed (the sender is its manager). + public static event EventHandler? Changed; + + /// Switches the current application to . + /// One of the application's languages. + /// Arc is not set up in the current application. + public static void Set(LanguageTag language) + => (Manager ?? throw new InvalidOperationException("Arc is not set up in the current application (UseArc).")).Set(language); + + /// Switches the current application to the language . + /// A language tag, for example zh-CN. + public static void Set(string tag) => Set(LanguageTag.Parse(tag)); + + /// The text of entry of in the current language. + /// The catalog. + /// The member's declaration index. + public static string Get(ArcLanguageCatalog catalog, int index) + => Manager is { } manager ? manager.Get(catalog, index) : catalog.SourceText(index); + + /// The text of an entry with its {0} placeholders filled, formatted in . + /// The catalog. + /// The member's declaration index. + /// The placeholder values. + public static string Format(ArcLanguageCatalog catalog, int index, object?[] arguments) + => string.Format(Culture, Get(catalog, index), arguments); + + internal static void RaiseChanged(ArcLanguageManager sender, LanguageChangedEventArgs args) => Changed?.Invoke(sender, args); +} diff --git a/src/TKCode.Arc.Foundation/Localization/LanguageTag.cs b/src/TKCode.Arc.Foundation/Localization/LanguageTag.cs new file mode 100644 index 00000000..50bd4b57 --- /dev/null +++ b/src/TKCode.Arc.Foundation/Localization/LanguageTag.cs @@ -0,0 +1,180 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; + +namespace TKCode.Arc.Localization; + +/// +/// A BCP 47 language tag such as en-US or zh-Hant-TW, in canonical case (language lower case, script title +/// case, region upper case). Two tags are equal when their canonical values are. +/// +public sealed class LanguageTag : IEquatable +{ + private LanguageTag(string value) + { + Value = value; + } + + /// The canonical tag, for example zh-CN. + public string Value { get; } + + /// + /// The tag with its last subtag removed (zh-Hant-TW → zh-Hant → zh), or null for a bare language. + /// Text missing for a language is looked up in its parents before the fallback languages. + /// + public LanguageTag? Parent + { + get + { + var at = Value.LastIndexOf('-'); + return at < 0 ? null : new LanguageTag(Value.Substring(0, at)); + } + } + + /// Parses ; hyphens or underscores separate subtags and case does not matter. + /// The tag, for example zh-cn or zh_CN. + /// The text is not a language tag. + public static LanguageTag Parse(string tag) + { + if (!TryParse(tag, out var result)) + { + throw new FormatException($"'{tag}' is not a language tag (for example en-US or zh-Hant-TW)."); + } + return result; + } + + /// Parses , or returns false when it is not a language tag. + /// The tag. + /// The parsed tag. + public static bool TryParse([NotNullWhen(true)] string? tag, [NotNullWhen(true)] out LanguageTag? result) + { + result = null; + if (string.IsNullOrWhiteSpace(tag)) + { + return false; + } + var parts = tag.Trim().Split('-', '_'); + var builder = new System.Text.StringBuilder(tag.Length); + for (var i = 0; i < parts.Length; i++) + { + var part = parts[i]; + if (part.Length is 0 or > 8 || !IsAlphanumeric(part)) + { + return false; + } + if (i == 0) + { + if (part.Length < 2 || !IsLetters(part)) + { + return false; + } + builder.Append(part.ToLowerInvariant()); + continue; + } + builder.Append('-'); + if (i == 1 && part.Length == 4 && IsLetters(part)) + { + // Script: Hant, Hans, Latn. + builder.Append(char.ToUpperInvariant(part[0])).Append(part.Substring(1).ToLowerInvariant()); + } + else if ((part.Length == 2 && IsLetters(part)) || (part.Length == 3 && IsDigits(part))) + { + // Region: US, TW, 419. + builder.Append(part.ToUpperInvariant()); + } + else + { + builder.Append(part.ToLowerInvariant()); + } + } + result = new LanguageTag(builder.ToString()); + return true; + } + + /// The .NET culture of this tag (formatting of numbers and dates), or the invariant culture when .NET has none. + public CultureInfo ToCulture() + { + try + { + return CultureInfo.GetCultureInfo(Value); + } + catch (CultureNotFoundException) + { + return CultureInfo.InvariantCulture; + } + } + + /// Whether this tag is or one of its parents (zh includes zh-CN). + /// The tag to test. + public bool Includes(LanguageTag other) + => other.Value.Length == Value.Length + ? other.Value == Value + : other.Value.Length > Value.Length && other.Value[Value.Length] == '-' + && other.Value.StartsWith(Value, StringComparison.Ordinal); + + /// + public bool Equals(LanguageTag? other) => other is not null && other.Value == Value; + + /// + public override bool Equals(object? obj) => Equals(obj as LanguageTag); + + /// + public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Value); + + /// The canonical tag. + public override string ToString() => Value; + + /// Value equality. + public static bool operator ==(LanguageTag? left, LanguageTag? right) => left is null ? right is null : left.Equals(right); + + /// Value inequality. + public static bool operator !=(LanguageTag? left, LanguageTag? right) => !(left == right); + + private static bool IsAlphanumeric(string text) + { + foreach (var c in text) + { + if (!(c is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9')) + { + return false; + } + } + return true; + } + + private static bool IsLetters(string text) + { + foreach (var c in text) + { + if (!(c is >= 'a' and <= 'z' or >= 'A' and <= 'Z')) + { + return false; + } + } + return true; + } + + private static bool IsDigits(string text) + { + foreach (var c in text) + { + if (c is < '0' or > '9') + { + return false; + } + } + return true; + } +} + +/// The languages Arc ships text for. +public static class LanguageTags +{ + /// English (United States), the source language of Arc's catalogs. + public static LanguageTag EnUS { get; } = LanguageTag.Parse("en-US"); + + /// Chinese (Simplified, China). + public static LanguageTag ZhCN { get; } = LanguageTag.Parse("zh-CN"); + + /// Chinese (Traditional, Taiwan). + public static LanguageTag ZhTW { get; } = LanguageTag.Parse("zh-TW"); +} diff --git a/src/TKCode.Arc.Foundation/Motion/ArcMotionLevel.cs b/src/TKCode.Arc.Foundation/Motion/ArcMotionLevel.cs new file mode 100644 index 00000000..2c860849 --- /dev/null +++ b/src/TKCode.Arc.Foundation/Motion/ArcMotionLevel.cs @@ -0,0 +1,17 @@ +namespace TKCode.Arc.Motion; + +// Same name, namespace and values as TKCode.Arc.Core's enum: at the F3 switch-over the Core copy is deleted and Core uses +// this one, so no consumer changes. + +/// How much motion the theme allows. +public enum ArcMotionLevel +{ + /// No transitions or animations. + Off = 0, + + /// Opacity and color changes only; springs jump to their end values. + Reduced = 1, + + /// All motion. + Full = 2 +} diff --git a/src/TKCode.Arc.Foundation/Properties/AssemblyInfo.cs b/src/TKCode.Arc.Foundation/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..87b1dbe1 --- /dev/null +++ b/src/TKCode.Arc.Foundation/Properties/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using Avalonia.Metadata; + +[assembly: XmlnsDefinition("https://github.com/AIDotNet/TKCode.Arc", "TKCode.Arc")] +[assembly: XmlnsPrefix("https://github.com/AIDotNet/TKCode.Arc", "arc")] diff --git a/src/TKCode.Arc.Foundation/Registration/ArcBuilder.cs b/src/TKCode.Arc.Foundation/Registration/ArcBuilder.cs new file mode 100644 index 00000000..30262a30 --- /dev/null +++ b/src/TKCode.Arc.Foundation/Registration/ArcBuilder.cs @@ -0,0 +1,254 @@ +using Avalonia; +using TKCode.Arc.Localization; + +namespace TKCode.Arc; + +/// How control-theme files are registered. +public enum ArcThemeLoading +{ + /// A theme file is instantiated the first time one of its keys is looked up (default). + Lazy = 0, + + /// Every theme file is instantiated at startup. + Eager = 1 +} + +/// +/// Collects the packages and the initial theme of an application. Given to AppBuilder.UseArc(...); packages add +/// themselves with extension methods (arc.UseArcBlocks()) that call . +/// +/// The configuration callbacks run before the application exists: they must not touch Avalonia platform objects. +public sealed class ArcBuilder +{ + private readonly List _packages = new(); + private readonly Dictionary _byId = new(StringComparer.Ordinal); + private readonly List _defining = new(); + private readonly List> _theme = new(); + private readonly Dictionary _presets = new(StringComparer.Ordinal); + + /// The enabled packages, dependencies before dependents. + public IReadOnlyList Packages => _packages; + + /// The theme presets the enabled packages registered, by . + public IReadOnlyDictionary Presets => _presets; + + /// How theme files are registered. + public ArcThemeLoading ThemeLoading { get; private set; } + + internal IReadOnlyList> ThemeConfigurations => _theme; + + /// The language the application starts in and the first fallback for missing text (en-US by default). + public LanguageTag DefaultLanguage { get; private set; } = LanguageTags.EnUS; + + /// The languages the application offers (en-US only by default); every enabled catalog must cover each. + public IReadOnlyList SupportedLanguages { get; private set; } = [LanguageTags.EnUS]; + + /// + /// Chooses the application's languages. At startup every enabled package's catalogs must cover each of + /// completely, or UseArc throws and lists what is missing. + /// + /// The language to start in; one of . + /// The languages may switch to. + /// is not one of . + public ArcBuilder UseLanguages(LanguageTag defaultLanguage, IEnumerable supportedLanguages) + { + var supported = supportedLanguages.Distinct().ToArray(); + if (!supported.Contains(defaultLanguage)) + { + throw new ArgumentException( + $"'{defaultLanguage}' is not one of the supported languages ({string.Join(", ", supported.Select(t => t.Value))}).", nameof(defaultLanguage)); + } + DefaultLanguage = defaultLanguage; + SupportedLanguages = supported; + return this; + } + + /// Whether the package is enabled. + /// The package id (by convention the assembly name). + public bool IsEnabled(string id) => _byId.ContainsKey(id); + + /// + /// Enables a package. Idempotent: a package that is already enabled is not defined again, so every package can + /// enable its dependencies itself. A dependency cycle throws. + /// + /// The package id (by convention the assembly name). + /// Declares the package: dependencies, generated assets, startup callbacks. + public ArcBuilder UsePackage(string id, Action define) + { + if (_byId.ContainsKey(id)) + { + return this; + } + var cycleStart = _defining.IndexOf(id); + if (cycleStart >= 0) + { + throw new InvalidOperationException( + $"Arc packages depend on each other in a cycle: {string.Join(" → ", _defining.Skip(cycleStart))} → {id}."); + } + _defining.Add(id); + var package = new ArcPackageBuilder(this, id); + try + { + define(package); + } + finally + { + _defining.RemoveAt(_defining.Count - 1); + } + foreach (var preset in package.Presets) + { + if (_presets.TryGetValue(preset.Name, out var other) && !ReferenceEquals(other, preset)) + { + throw new InvalidOperationException( + $"Theme preset '{preset.Name}' is registered by both '{PresetOwner(preset.Name)}' and '{id}'."); + } + _presets[preset.Name] = preset; + } + // Dependencies enabled by DependsOn were appended while define ran, so this order is a topological one. + _byId.Add(id, package); + _packages.Add(package); + return this; + } + + private string PresetOwner(string name) + => _packages.FirstOrDefault(p => p.Presets.Any(preset => preset.Name == name))?.Id ?? "?"; + + /// Configures the initial theme; several calls are applied in order. + /// Changes to the default state. + public ArcBuilder WithTheme(Action configure) + { + _theme.Add(configure); + return this; + } + + /// Chooses how theme files are registered (lazy by default). + /// Lazy or eager. + public ArcBuilder WithThemeLoading(ArcThemeLoading loading) + { + ThemeLoading = loading; + return this; + } +} + +/// Declares one package for . +public sealed class ArcPackageBuilder +{ + private readonly ArcBuilder _owner; + private readonly List _tokens = new(); + private readonly List _themes = new(); + private readonly List _languages = new(); + private readonly List _presets = new(); + private readonly List> _onCommit = new(); + private readonly List> _onFirstUse = new(); + + internal ArcPackageBuilder(ArcBuilder owner, string id) + { + _owner = owner; + Id = id; + } + + /// The package id. + public string Id { get; } + + internal IReadOnlyList Tokens => _tokens; + internal IReadOnlyList Themes => _themes; + internal IReadOnlyList Languages => _languages; + internal IReadOnlyList Presets => _presets; + internal IReadOnlyList> CommitCallbacks => _onCommit; + internal IReadOnlyList> FirstUseCallbacks => _onFirstUse; + + /// Enables the packages this one needs (they are committed first). + /// Enables the dependencies, for example b => b.UseArcCore(). + public ArcPackageBuilder DependsOn(Action use) + { + use(_owner); + return this; + } + + /// Adds token identities (the generated ArcGeneratedAssets.Tokens). + /// The descriptors. + public ArcPackageBuilder AddTokens(IEnumerable tokens) + { + _tokens.AddRange(tokens); + return this; + } + + /// Adds control-theme files (the generated ArcGeneratedAssets.Themes). + /// The theme files. + public ArcPackageBuilder AddThemes(IEnumerable themes) + { + _themes.AddRange(themes); + return this; + } + + /// Adds language catalogs (the generated ArcGeneratedAssets.Languages). + /// The catalogs. + public ArcPackageBuilder AddLanguages(IEnumerable catalogs) + { + _languages.AddRange(catalogs); + return this; + } + + /// + /// Registers theme presets the package ships (for example the Pixel style), so apps and tools can list them through + /// . Registering does not apply a preset: the app adds it with + /// WithTheme(t => t.Preset(preset)) or ArcTheme.Apply(t => t.Preset(preset)). Names must be unique. + /// + /// The presets. + public ArcPackageBuilder AddPresets(IEnumerable presets) + { + _presets.AddRange(presets); + return this; + } + + /// + /// Runs once at startup on the UI thread, after the application initialized and the theme was published. Keep it + /// light (register fonts, services); give disposable objects to . + /// + /// The callback. + public ArcPackageBuilder OnCommit(Action callback) + { + _onCommit.Add(callback); + return this; + } + + /// + /// Runs once, the first time one of the package's theme files is loaded (a control of the package is about to be + /// styled). In eager mode it runs at startup. + /// + /// The callback. + public ArcPackageBuilder OnFirstUse(Action callback) + { + _onFirstUse.Add(callback); + return this; + } +} + +/// What a package callback can reach. +public sealed class ArcPackageContext +{ + private readonly ArcRuntime _runtime; + + internal ArcPackageContext(ArcRuntime runtime, string packageId) + { + _runtime = runtime; + PackageId = packageId; + } + + /// The package id. + public string PackageId { get; } + + /// The application. + public Application Application => _runtime.Application; + + /// The theme manager. + public ArcThemeManager Theme => _runtime.Theme; + + /// Disposes when the application shuts down. + /// The object to dispose. + public T Own(T disposable) where T : IDisposable + { + _runtime.Own(disposable); + return disposable; + } +} diff --git a/src/TKCode.Arc.Foundation/Registration/ArcRuntime.cs b/src/TKCode.Arc.Foundation/Registration/ArcRuntime.cs new file mode 100644 index 00000000..a698acd8 --- /dev/null +++ b/src/TKCode.Arc.Foundation/Registration/ArcRuntime.cs @@ -0,0 +1,223 @@ +using System.Runtime.CompilerServices; +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using TKCode.Arc.Localization; + +namespace TKCode.Arc; + +/// Arc's state in one application: packages, theme manager and theme provider. +public sealed class ArcRuntime +{ + private static readonly ConditionalWeakTable s_runtimes = new(); + private readonly List _owned = new(); + + private ArcRuntime(Application application, ArcThemeManager theme, ArcThemeProvider themes, ArcLanguageManager languages, + IReadOnlyList packages, IReadOnlyDictionary presets) + { + Application = application; + Theme = theme; + Themes = themes; + Languages = languages; + Packages = packages; + Presets = presets; + } + + /// The application. + public Application Application { get; } + + /// The theme manager. + public ArcThemeManager Theme { get; } + + /// The control-theme provider (lazy loading). + public ArcThemeProvider Themes { get; } + + /// The language manager: current language and the text of the enabled packages' catalogs. + public ArcLanguageManager Languages { get; } + + /// The enabled packages, dependencies first. + public IReadOnlyList Packages { get; } + + /// The theme presets the enabled packages registered, by name. + public IReadOnlyDictionary Presets { get; } + + /// The runtime of , or null before UseArc(...) set it up. + public static ArcRuntime? Current => Application.Current is { } application ? Find(application) : null; + + /// The runtime of , or null. + /// The application. + public static ArcRuntime? Find(Application application) + => s_runtimes.TryGetValue(application, out var runtime) ? runtime : null; + + /// + /// Sets Arc up in : registers token identities and theme files, publishes the initial + /// theme and runs the packages' commit callbacks. UseArc(...) calls it after the application initialized; + /// call it directly only for an application that was not built with . + /// + /// The application. + /// The configured builder. + public static ArcRuntime Install(Application application, ArcBuilder builder) + { + if (Find(application) is not null) + { + throw new InvalidOperationException("Arc is already set up for this application."); + } + + var descriptors = new List(); + var identities = new Dictionary(StringComparer.Ordinal); + ArcTokenDescriptor? global = null; + foreach (var package in builder.Packages) + { + foreach (var descriptor in package.Tokens) + { + if (!identities.TryAdd(descriptor.Identity, package.Id)) + { + throw new InvalidOperationException( + $"Token identity '{descriptor.Identity}' is registered by both '{identities[descriptor.Identity]}' and '{package.Id}'."); + } + if (descriptor.IsGlobal) + { + if (global is not null) + { + throw new InvalidOperationException( + $"Only one global token identity is allowed: '{global.Identity}' and '{descriptor.Identity}'."); + } + global = descriptor; + descriptors.Insert(0, descriptor); + } + else + { + descriptors.Add(descriptor); + } + } + } + + var languages = CreateLanguages(builder); + var theme = new ArcThemeManager(application, descriptors); + var themes = new ArcThemeProvider(); + var runtime = new ArcRuntime(application, theme, themes, languages, builder.Packages.Select(p => p.Id).ToArray(), + new Dictionary(builder.Presets, StringComparer.Ordinal)); + s_runtimes.AddOrUpdate(application, runtime); + + foreach (var package in builder.Packages) + { + var onFirstUse = package.FirstUseCallbacks.Count == 0 ? null : runtime.FirstUse(package); + foreach (var asset in package.Themes) + { + themes.Add(asset, onFirstUse); + } + } + + var initial = ArcThemeState.Default; + foreach (var configure in builder.ThemeConfigurations) + { + initial = initial.With(configure); + } + theme.Apply(initial); + + // Tokens before themes in lookup order does not matter (their keys are disjoint); the application's own + // resources stay above both. + application.Resources.MergedDictionaries.Add(theme.Resources); + application.Resources.MergedDictionaries.Add(themes); + application.Resources.MergedDictionaries.Add(languages.Resources); + + if (builder.ThemeLoading == ArcThemeLoading.Eager) + { + themes.LoadAll(); + } + + foreach (var package in builder.Packages) + { + var context = new ArcPackageContext(runtime, package.Id); + foreach (var callback in package.CommitCallbacks) + { + callback(context); + } + } + + if (application.ApplicationLifetime is IControlledApplicationLifetime lifetime) + { + lifetime.Exit += (_, _) => runtime.Shutdown(); + } + return runtime; + } + + internal void Own(IDisposable disposable) => _owned.Add(disposable); + + /// + /// Collects the enabled packages' language catalogs and checks, before anything is installed, that each covers + /// every language the application offers. + /// + private static ArcLanguageManager CreateLanguages(ArcBuilder builder) + { + var catalogs = new List<(string Package, ArcLanguageCatalog Catalog)>(); + var owners = new Dictionary(StringComparer.Ordinal); + foreach (var package in builder.Packages) + { + foreach (var catalog in package.Languages) + { + if (!owners.TryAdd(catalog.Id, package.Id)) + { + throw new InvalidOperationException( + $"Language catalog '{catalog.Id}' is registered by both '{owners[catalog.Id]}' and '{package.Id}'."); + } + catalogs.Add((package.Id, catalog)); + } + } + var gaps = ArcLanguageManager.FindCoverageGaps(builder.SupportedLanguages, catalogs); + if (gaps.Count > 0) + { + throw new InvalidOperationException( + "The application's languages are not covered by every enabled package:\n- " + string.Join("\n- ", gaps)); + } + return new ArcLanguageManager(builder.DefaultLanguage, builder.SupportedLanguages, catalogs.Select(c => c.Catalog).ToArray()); + } + + private Action FirstUse(ArcPackageBuilder package) + { + var done = false; + return () => + { + if (done) + { + return; + } + done = true; + var context = new ArcPackageContext(this, package.Id); + foreach (var callback in package.FirstUseCallbacks) + { + callback(context); + } + }; + } + + private void Shutdown() + { + Theme.Detach(); + for (var i = _owned.Count - 1; i >= 0; i--) + { + _owned[i].Dispose(); + } + _owned.Clear(); + } +} + +/// The Arc entry point on . +public static class ArcAppBuilderExtensions +{ + /// + /// Enables Arc: packages are declared in (arc => arc.UseArcBlocks()) and set up + /// after the application initialized. No partial Application hook is needed. + /// + /// The app builder. + /// Enables packages and sets the initial theme. + public static AppBuilder UseArc(this AppBuilder builder, Action? configure = null) + { + var arc = new ArcBuilder(); + configure?.Invoke(arc); + return builder.AfterSetup(b => + { + var application = b.Instance ?? throw new InvalidOperationException("The application was not created."); + ArcRuntime.Install(application, arc); + }); + } +} diff --git a/src/TKCode.Arc.Foundation/TKCode.Arc.Foundation.csproj b/src/TKCode.Arc.Foundation/TKCode.Arc.Foundation.csproj new file mode 100644 index 00000000..68494e6d --- /dev/null +++ b/src/TKCode.Arc.Foundation/TKCode.Arc.Foundation.csproj @@ -0,0 +1,27 @@ + + + + $(ArcTargetFrameworks) + TKCode.Arc.Foundation + TKCode.Arc + TKCode.Arc.Foundation + Theme and token runtime for TKCode.Arc: design tokens, light and dark publishing, lazy control themes and package registration. Depends on Avalonia only. + avalonia;arc;theme;tokens + + + + + + + + + + + + + + diff --git a/src/TKCode.Arc.Foundation/Theme/ArcTheme.cs b/src/TKCode.Arc.Foundation/Theme/ArcTheme.cs new file mode 100644 index 00000000..1df3b541 --- /dev/null +++ b/src/TKCode.Arc.Foundation/Theme/ArcTheme.cs @@ -0,0 +1,20 @@ +namespace TKCode.Arc; + +/// Shortcuts to the theme manager of the current application. +public static class ArcTheme +{ + /// The theme manager of the current application. Throws before UseArc(...) set it up. + public static ArcThemeManager Manager + => ArcThemeManager.Current + ?? throw new InvalidOperationException("Arc is not set up for the current application: call AppBuilder.UseArc(...)."); + + /// The applied state. + public static ArcThemeState State => Manager.State; + + /// + /// Applies a state changed from the current one; what does not set is kept, for example + /// ArcTheme.Apply(t => t.Appearance(ArcAppearance.Dark)). + /// + /// Changes to make. + public static void Apply(Action configure) => Manager.Apply(configure); +} diff --git a/src/TKCode.Arc.Foundation/Theme/ArcThemeManager.cs b/src/TKCode.Arc.Foundation/Theme/ArcThemeManager.cs new file mode 100644 index 00000000..52cb820a --- /dev/null +++ b/src/TKCode.Arc.Foundation/Theme/ArcThemeManager.cs @@ -0,0 +1,207 @@ +using System.Diagnostics; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Platform; +using Avalonia.Styling; +using Avalonia.Threading; + +namespace TKCode.Arc; + +/// Event data of . +public sealed class ArcThemeChangedEventArgs : EventArgs +{ + internal ArcThemeChangedEventArgs(ArcThemeState oldState, ArcThemeState newState, bool wasDark, bool isDark) + { + OldState = oldState; + NewState = newState; + WasDark = wasDark; + IsDark = isDark; + } + + /// The state before the change. + public ArcThemeState OldState { get; } + + /// The state now applied. + public ArcThemeState NewState { get; } + + /// Whether the previous state resolved to dark. + public bool WasDark { get; } + + /// Whether the new state resolves to dark. + public bool IsDark { get; } +} + +/// +/// Owns the theme of one application: the current , the token dictionary published in +/// , and the switch between states. Created by UseArc(...). +/// +public sealed class ArcThemeManager +{ + private readonly Application _application; + private readonly ArcTokenDescriptor[] _descriptors; + private readonly int _tokenCount; + private IPlatformSettings? _platformSettings; + + internal ArcThemeManager(Application application, IReadOnlyList descriptors) + { + _application = application; + _descriptors = descriptors.ToArray(); + foreach (var descriptor in _descriptors) + { + _tokenCount += descriptor.Keys.Count; + } + State = ArcThemeState.Default; + } + + /// The theme manager of , or null before UseArc(...) set it up. + public static ArcThemeManager? Current + => Application.Current is { } application ? ArcRuntime.Find(application)?.Theme : null; + + /// The applied state. + public ArcThemeState State { get; private set; } + + /// Whether the applied state resolves to dark. + public bool IsDark { get; private set; } + + /// The registered token identities, global identity first. + public IReadOnlyList Descriptors => _descriptors; + + /// Total number of published tokens. + public int TokenCount => _tokenCount; + + /// + /// The dictionary the tokens are published into. It sits in ' merged + /// dictionaries for the whole lifetime of the application; a state change rewrites the values that changed. + /// + public ResourceDictionary Resources { get; } = new(); + + /// Time the last spent computing tokens. + public TimeSpan LastComputeDuration { get; private set; } + + /// Time the last spent writing them (including the change notification). + public TimeSpan LastPublishDuration { get; private set; } + + /// Number of keys the last apply rewrote. + public int LastPublishedCount { get; private set; } + + /// Raised on the UI thread after a new state was published. + public event EventHandler? StateChanged; + + /// Whether the platform currently reports a dark appearance. + public bool SystemIsDark + => _application.PlatformSettings?.GetColorValues().ThemeVariant == PlatformThemeVariant.Dark; + + /// Applies a state changed from the current one; what does not set is kept. + /// Changes to make. + public void Apply(Action configure) => Apply(State.With(configure)); + + /// + /// Computes and publishes . All or nothing: a bad override throws + /// and the current theme stays. Must run on the UI thread. + /// + /// The state to apply. + public void Apply(ArcThemeState state) + { + Dispatcher.UIThread.VerifyAccess(); + var isDark = state.ResolveIsDark(SystemIsDark); + var watch = Stopwatch.StartNew(); + var values = Compute(state, isDark); + LastComputeDuration = watch.Elapsed; + Commit(state, isDark, values); + } + + /// + /// Like , but computes the tokens on a worker thread and only publishes + /// on the UI thread. Token calculations must then be thread safe (they are pure functions of the state). + /// + /// + /// Await the returned task; never block on it on the UI thread (.Wait(), .Result, + /// .GetAwaiter().GetResult()): the publish step needs the UI thread, so blocking it deadlocks. Code that must + /// finish synchronously calls , which takes a few milliseconds for Arc's + /// full token set. + /// + /// Changes to make. + public async Task ApplyAsync(Action configure) + { + Dispatcher.UIThread.VerifyAccess(); + var state = State.With(configure); + var isDark = state.ResolveIsDark(SystemIsDark); + var watch = Stopwatch.StartNew(); + var values = await Task.Run(() => Compute(state, isDark)); + LastComputeDuration = watch.Elapsed; + Commit(state, isDark, values); + } + + /// + /// Computes the token values of for one appearance without publishing them: resource key → + /// value (colors as brushes). Used by scopes and by token snapshot exports. + /// + /// The state. + /// The appearance to compute. + public Dictionary Compute(ArcThemeState state, bool isDark) + => ArcTokenCalculator.Compute(_descriptors, _tokenCount, state, isDark); + + /// The values of the applied state for one appearance, computed once per state and appearance. + internal Dictionary GetCachedValues(bool isDark) + { + var slot = isDark ? 1 : 0; + if (_cacheStates[slot] != State || _cacheValues[slot] is null) + { + _cacheValues[slot] = Compute(State, isDark); + _cacheStates[slot] = State; + } + return _cacheValues[slot]!; + } + + private readonly ArcThemeState?[] _cacheStates = new ArcThemeState?[2]; + private readonly Dictionary?[] _cacheValues = new Dictionary?[2]; + + private void Commit(ArcThemeState state, bool isDark, Dictionary values) + { + var slot = isDark ? 1 : 0; + _cacheStates[slot] = state; + _cacheValues[slot] = values; + var watch = Stopwatch.StartNew(); + var oldState = State; + var wasDark = IsDark; + State = state; + IsDark = isDark; + // The variant first: built-in theme dictionaries keyed by variant and ActualThemeVariant readers agree with the + // tokens by the time the resource notification goes out. + _application.RequestedThemeVariant = isDark ? ThemeVariant.Dark : ThemeVariant.Light; + LastPublishedCount = ArcTokenCalculator.Publish(Resources, values); + LastPublishDuration = watch.Elapsed; + FollowSystem(state.Appearance == ArcAppearance.System); + StateChanged?.Invoke(this, new ArcThemeChangedEventArgs(oldState, state, wasDark, isDark)); + } + + private void FollowSystem(bool follow) + { + if (follow && _platformSettings is null) + { + _platformSettings = _application.PlatformSettings; + if (_platformSettings is not null) + { + _platformSettings.ColorValuesChanged += OnColorValuesChanged; + } + } + else if (!follow && _platformSettings is not null) + { + _platformSettings.ColorValuesChanged -= OnColorValuesChanged; + _platformSettings = null; + } + } + + private void OnColorValuesChanged(object? sender, PlatformColorValues e) + { + Dispatcher.UIThread.Post(() => + { + if (State.Appearance == ArcAppearance.System && State.ResolveIsDark(SystemIsDark) != IsDark) + { + Apply(State); + } + }); + } + + internal void Detach() => FollowSystem(false); +} diff --git a/src/TKCode.Arc.Foundation/Theme/ArcThemePreset.cs b/src/TKCode.Arc.Foundation/Theme/ArcThemePreset.cs new file mode 100644 index 00000000..bc4b1dd9 --- /dev/null +++ b/src/TKCode.Arc.Foundation/Theme/ArcThemePreset.cs @@ -0,0 +1,226 @@ +using System.Collections.Immutable; + +namespace TKCode.Arc; + +/// +/// A named, immutable bundle of token overrides that a theme package ships (the Pixel style, a corner style): overrides +/// for both appearances plus overrides for light or dark only. Add it to a state with +/// ArcThemeBuilder.Preset; packages make theirs discoverable with ArcPackageBuilder.AddPresets. +/// +/// +/// Overrides are final values, like ArcThemeBuilder.Token. When a state is +/// computed, the presets are applied in order after the token calculation, then the state's own overrides, so the app +/// always wins over a preset and a later preset wins over an earlier one. +/// Unlike the state's own overrides, a preset's overrides of an identity that is not registered are skipped: a preset +/// may cover every Arc package, and an app that did not enable one of them simply does not get those overrides. A token +/// name the registered identity does not have is still an error. +/// This file depends on nothing but the base class library so that tests can compile it on its own. +/// +public sealed class ArcThemePreset +{ + /// The identity name under which overrides of the global identity are stored when they name no identity. + public const string GlobalIdentity = ""; + + private ArcThemePreset(string name, string slot, ArcPresetOverrides light, ArcPresetOverrides dark) + { + Name = name; + Slot = slot; + LightTokens = light; + DarkTokens = dark; + } + + /// The unique name, for example ArcPixel or ArcCornersSquare. + public string Name { get; } + + /// + /// What the preset replaces: a state holds at most one preset per slot, so adding a corner style replaces the previous + /// corner style while a Pixel preset in another slot stays. Defaults to . + /// + public string Slot { get; } + + /// The overrides used when the state resolves to light: identity → (token name → value text). + public IReadOnlyDictionary> LightTokens { get; } + + /// The overrides used when the state resolves to dark: identity → (token name → value text). + public IReadOnlyDictionary> DarkTokens { get; } + + /// The overrides for one appearance. + /// Whether the state resolves to dark. + public IReadOnlyDictionary> TokensFor(bool isDark) + => isDark ? DarkTokens : LightTokens; + + /// Whether the preset overrides nothing (for example the default corner style). + public bool IsEmpty => LightTokens.Count == 0 && DarkTokens.Count == 0; + + /// Defines a preset. + /// The unique name. + /// Adds the overrides. + /// What the preset replaces in a state; null uses . + public static ArcThemePreset Create(string name, Action define, string? slot = null) + { + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentException("A preset needs a name.", nameof(name)); + } + if (slot is { Length: 0 }) + { + throw new ArgumentException("A preset slot cannot be empty.", nameof(slot)); + } + var builder = new ArcThemePresetBuilder(); + define(builder); + return new ArcThemePreset(name, slot ?? name, builder.Light.ToImmutable(), builder.Dark.ToImmutable()); + } + + /// + public override string ToString() => Slot == Name ? Name : $"{Name} ({Slot})"; +} + +/// Collects the overrides of an . Later writes of the same token win. +public sealed class ArcThemePresetBuilder +{ + private readonly bool _light; + private readonly bool _dark; + + internal ArcThemePresetBuilder() + : this(new ArcPresetOverrides.Builder(), new ArcPresetOverrides.Builder(), true, true) + { + } + + private ArcThemePresetBuilder(ArcPresetOverrides.Builder light, ArcPresetOverrides.Builder dark, bool toLight, bool toDark) + { + Light = light; + Dark = dark; + _light = toLight; + _dark = toDark; + } + + internal ArcPresetOverrides.Builder Light { get; } + + internal ArcPresetOverrides.Builder Dark { get; } + + /// Overrides a token of the global identity (Arc's ArcDesign). + /// The token name. + /// The value text, parsed by the token's type when the theme is computed. + public ArcThemePresetBuilder Token(string name, string value) => Token(ArcThemePreset.GlobalIdentity, name, value); + + /// Overrides a token of . + /// The identity, for example Button. + /// The token name. + /// The value text, parsed by the token's type when the theme is computed. + public ArcThemePresetBuilder Token(string identity, string name, string value) + { + if (_light) + { + Light.Set(identity, name, value); + } + if (_dark) + { + Dark.Set(identity, name, value); + } + return this; + } + + /// Adds overrides that apply only when the state resolves to light. + /// Adds the overrides. + public ArcThemePresetBuilder ForLight(Action define) + { + if (_light) + { + define(new ArcThemePresetBuilder(Light, Dark, true, false)); + } + return this; + } + + /// Adds overrides that apply only when the state resolves to dark. + /// Adds the overrides. + public ArcThemePresetBuilder ForDark(Action define) + { + if (_dark) + { + define(new ArcThemePresetBuilder(Light, Dark, false, true)); + } + return this; + } + + /// + /// Copies the overrides of (its light overrides into light, its dark ones into dark), for + /// example the Pixel style including the square corner style. + /// + /// The preset to include. + public ArcThemePresetBuilder Include(ArcThemePreset preset) + { + if (_light) + { + Light.SetAll(preset.LightTokens); + } + if (_dark) + { + Dark.SetAll(preset.DarkTokens); + } + return this; + } +} + +/// The override table of one appearance of a preset. +internal sealed class ArcPresetOverrides : IReadOnlyDictionary> +{ + private readonly ImmutableDictionary> _items; + + private ArcPresetOverrides(ImmutableDictionary> items) => _items = items; + + public ImmutableDictionary this[string key] => _items[key]; + + public IEnumerable Keys => _items.Keys; + + public IEnumerable> Values => _items.Values; + + public int Count => _items.Count; + + public bool ContainsKey(string key) => _items.ContainsKey(key); + + public bool TryGetValue(string key, out ImmutableDictionary value) + => _items.TryGetValue(key, out value!); + + public IEnumerator>> GetEnumerator() => _items.GetEnumerator(); + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + + internal sealed class Builder + { + private readonly Dictionary.Builder> _items = new(StringComparer.Ordinal); + + public void Set(string identity, string name, string value) + { + ArgumentNullException.ThrowIfNull(identity); + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentNullException.ThrowIfNull(value); + if (!_items.TryGetValue(identity, out var tokens)) + { + tokens = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); + _items.Add(identity, tokens); + } + tokens[name] = value; + } + + public void SetAll(IReadOnlyDictionary> overrides) + { + foreach (var (identity, tokens) in overrides) + { + foreach (var (name, value) in tokens) + { + Set(identity, name, value); + } + } + } + + public ArcPresetOverrides ToImmutable() + { + var result = ImmutableDictionary.CreateBuilder>(StringComparer.Ordinal); + foreach (var (identity, tokens) in _items) + { + result.Add(identity, tokens.ToImmutable()); + } + return new ArcPresetOverrides(result.ToImmutable()); + } + } +} diff --git a/src/TKCode.Arc.Foundation/Theme/ArcThemeProvider.cs b/src/TKCode.Arc.Foundation/Theme/ArcThemeProvider.cs new file mode 100644 index 00000000..e76ad5e0 --- /dev/null +++ b/src/TKCode.Arc.Foundation/Theme/ArcThemeProvider.cs @@ -0,0 +1,199 @@ +using System.ComponentModel; +using System.Diagnostics; +using Avalonia.Controls; +using Avalonia.Styling; + +namespace TKCode.Arc; + +/// +/// One theme file (Themes/XxxTheme.axaml) as the generator describes it: an id, the keys it defines, and a factory +/// that instantiates the compiled dictionary (no XAML parsing or URI loading at run time). +/// +[EditorBrowsable(EditorBrowsableState.Never)] +public sealed class ArcThemeAsset +{ + /// Creates the description. Called by generated code. + /// The asset id: {assembly}:{path in the project}. + /// The keys of the dictionary's top-level resources ( for {x:Type …}, or strings). + /// Creates the compiled dictionary. + public ArcThemeAsset(string id, object[] keys, Func load) + { + Id = id; + Keys = keys; + Load = load; + } + + /// The asset id: {assembly}:{path in the project}. + public string Id { get; } + + /// The keys of the dictionary's top-level resources. + public IReadOnlyList Keys { get; } + + /// Creates the compiled dictionary. + public Func Load { get; } +} + +/// Event data of . +public sealed class ArcThemeAssetLoadedEventArgs : EventArgs +{ + internal ArcThemeAssetLoadedEventArgs(ArcThemeAsset asset, object? trigger, TimeSpan duration) + { + Asset = asset; + Trigger = trigger; + Duration = duration; + } + + /// The asset that was loaded. + public ArcThemeAsset Asset { get; } + + /// The resource key whose lookup loaded it (null for ). + public object? Trigger { get; } + + /// Time spent creating the dictionary. + public TimeSpan Duration { get; } +} + +/// +/// Resource provider that loads control-theme dictionaries on demand: it knows every key of every registered theme file +/// (from the generated table) and instantiates a file's dictionary the first time one of its keys is looked up, for +/// example when the first control of that type looks for its implicit . +/// +/// +/// +/// Sits in ' merged dictionaries. is true as soon +/// as one asset is registered, so Avalonia always queries it; a lookup that does not hit a registered key is one +/// dictionary probe. +/// +/// +/// Loading does not raise ResourcesChanged: before the load the key was answered by this provider all the same (the +/// value is created, not changed), so nothing that resolved earlier can be stale. The loaded dictionaries get no owner +/// for the same reason (adding an owner notifies the whole tree). +/// +/// +public sealed class ArcThemeProvider : ResourceProvider +{ + private readonly Dictionary _index = new(); + private readonly List _entries = new(); + private int _loadedCount; + + /// Number of registered theme files. + public int AssetCount => _entries.Count; + + /// Number of theme files loaded so far. + public int LoadedCount => _loadedCount; + + /// Total time spent loading theme files. + public TimeSpan TotalLoadDuration { get; private set; } + + /// + public override bool HasResources => _entries.Count > 0; + + /// Raised after a theme file was loaded. + public event EventHandler? AssetLoaded; + + /// Whether the theme file has been loaded. + /// The asset id. + public bool IsLoaded(string assetId) + { + foreach (var entry in _entries) + { + if (entry.Asset.Id == assetId) + { + return entry.Dictionary is not null; + } + } + return false; + } + + /// Whether a registered theme file defines . + /// The resource key. + public bool Defines(object key) => _index.ContainsKey(key); + + /// Loads every registered theme file now (eager mode, and for measurements). + public void LoadAll() + { + foreach (var entry in _entries) + { + GetOrLoad(entry, null); + } + } + + /// + public override bool TryGetResource(object key, ThemeVariant? theme, out object? value) + { + if (_index.TryGetValue(key, out var entry)) + { + var dictionary = GetOrLoad(entry, key); + if (dictionary is not null) + { + return dictionary.TryGetResource(key, theme, out value); + } + } + value = null; + return false; + } + + internal void Add(ArcThemeAsset asset, Action? onFirstLoad) + { + var entry = new Entry(asset, onFirstLoad); + foreach (var key in asset.Keys) + { + if (_index.TryGetValue(key, out var other)) + { + throw new InvalidOperationException( + $"Theme key '{key}' is defined by both '{other.Asset.Id}' and '{asset.Id}'."); + } + _index.Add(key, entry); + } + _entries.Add(entry); + if (_entries.Count == 1) + { + // HasResources just turned true. + RaiseResourcesChanged(); + } + } + + private ResourceDictionary? GetOrLoad(Entry entry, object? trigger) + { + if (entry.Dictionary is not null) + { + return entry.Dictionary; + } + if (entry.IsLoading) + { + // A resource of the file is looked up while the file itself is being built (for example a StaticResource + // that Avalonia resolves upwards before the file's own dictionary is complete); let the lookup continue. + return null; + } + entry.IsLoading = true; + var watch = Stopwatch.StartNew(); + try + { + entry.Dictionary = entry.Asset.Load(); + } + finally + { + entry.IsLoading = false; + } + var duration = watch.Elapsed; + TotalLoadDuration += duration; + _loadedCount++; + entry.OnFirstLoad?.Invoke(); + AssetLoaded?.Invoke(this, new ArcThemeAssetLoadedEventArgs(entry.Asset, trigger, duration)); + return entry.Dictionary; + } + + private sealed class Entry + { + public Entry(ArcThemeAsset asset, Action? onFirstLoad) + { + Asset = asset; + OnFirstLoad = onFirstLoad; + } + + public ArcThemeAsset Asset { get; } + public Action? OnFirstLoad { get; } + public ResourceDictionary? Dictionary { get; set; } + public bool IsLoading { get; set; } + } +} diff --git a/src/TKCode.Arc.Foundation/Theme/ArcThemeScope.cs b/src/TKCode.Arc.Foundation/Theme/ArcThemeScope.cs new file mode 100644 index 00000000..5e860c69 --- /dev/null +++ b/src/TKCode.Arc.Foundation/Theme/ArcThemeScope.cs @@ -0,0 +1,160 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.LogicalTree; +using Avalonia.Styling; + +namespace TKCode.Arc; + +/// +/// Gives a subtree its own theme: the tokens are computed for the scope's state and published into the element's +/// , which resource lookups inside the subtree reach before the application's. +/// Used for side-by-side light and dark previews and for screenshots. +/// +/// +/// keeps following the application's state (accent, overrides, motion) and only fixes +/// light or dark. fixes the whole state. When the element is a +/// or a , its requested theme variant is set to match. +/// +public static class ArcThemeScope +{ + /// Fixes light or dark for the subtree; everything else follows the application's theme. + public static readonly AttachedProperty AppearanceProperty = + AvaloniaProperty.RegisterAttached("Appearance", typeof(ArcThemeScope)); + + /// Fixes the whole theme state of the subtree. + public static readonly AttachedProperty StateProperty = + AvaloniaProperty.RegisterAttached("State", typeof(ArcThemeScope)); + + private static readonly AttachedProperty HostProperty = + AvaloniaProperty.RegisterAttached("Host", typeof(ArcThemeScope)); + + static ArcThemeScope() + { + AppearanceProperty.Changed.AddClassHandler((element, _) => Update(element)); + StateProperty.Changed.AddClassHandler((element, _) => Update(element)); + } + + /// Gets the fixed appearance of 's scope. + public static ArcAppearance? GetAppearance(StyledElement element) => element.GetValue(AppearanceProperty); + + /// Fixes light or dark for 's subtree; null removes the scope. + public static void SetAppearance(StyledElement element, ArcAppearance? value) => element.SetValue(AppearanceProperty, value); + + /// Gets the fixed state of 's scope. + public static ArcThemeState? GetState(StyledElement element) => element.GetValue(StateProperty); + + /// Fixes the whole theme state of 's subtree; null removes the scope. + public static void SetState(StyledElement element, ArcThemeState? value) => element.SetValue(StateProperty, value); + + private static void Update(StyledElement element) + { + var host = element.GetValue(HostProperty); + if (GetAppearance(element) is null && GetState(element) is null) + { + if (host is not null) + { + host.Dispose(); + element.ClearValue(HostProperty); + } + return; + } + if (host is null) + { + host = new ScopeHost(element); + element.SetValue(HostProperty, host); + } + host.Refresh(); + } + + private sealed class ScopeHost : IDisposable + { + private readonly StyledElement _element; + private readonly ResourceDictionary _tokens = new(); + private ArcThemeManager? _manager; + + public ScopeHost(StyledElement element) + { + _element = element; + element.Resources.MergedDictionaries.Add(_tokens); + element.AttachedToLogicalTree += OnAttached; + element.DetachedFromLogicalTree += OnDetached; + if (((ILogical)element).IsAttachedToLogicalTree) + { + Subscribe(); + } + } + + public void Refresh() + { + var manager = ArcThemeManager.Current; + if (manager is null) + { + // Not set up yet: refreshed when the element joins the tree. + return; + } + var fixedState = GetState(_element); + var appearance = GetAppearance(_element); + var state = fixedState ?? manager.State; + if (appearance is { } fixedAppearance) + { + state = state.With(t => t.Appearance(fixedAppearance)); + } + var isDark = state.ResolveIsDark(manager.SystemIsDark); + // Without a fixed state the scope differs from the application only in light or dark, so it shares the + // manager's cached values for that appearance. + var values = fixedState is null ? manager.GetCachedValues(isDark) : manager.Compute(state, isDark); + ArcTokenCalculator.Publish(_tokens, values); + var variant = isDark ? ThemeVariant.Dark : ThemeVariant.Light; + if (_element is ThemeVariantScope scope) + { + scope.RequestedThemeVariant = variant; + } + else if (_element is TopLevel topLevel) + { + topLevel.RequestedThemeVariant = variant; + } + } + + public void Dispose() + { + Unsubscribe(); + _element.AttachedToLogicalTree -= OnAttached; + _element.DetachedFromLogicalTree -= OnDetached; + _element.Resources.MergedDictionaries.Remove(_tokens); + } + + private void OnAttached(object? sender, LogicalTreeAttachmentEventArgs e) + { + Subscribe(); + Refresh(); + } + + private void OnDetached(object? sender, LogicalTreeAttachmentEventArgs e) => Unsubscribe(); + + private void Subscribe() + { + if (_manager is null && ArcThemeManager.Current is { } manager) + { + _manager = manager; + manager.StateChanged += OnStateChanged; + } + } + + private void Unsubscribe() + { + if (_manager is not null) + { + _manager.StateChanged -= OnStateChanged; + _manager = null; + } + } + + private void OnStateChanged(object? sender, ArcThemeChangedEventArgs e) + { + if (GetState(_element) is null || GetState(_element)!.Appearance == ArcAppearance.System) + { + Refresh(); + } + } + } +} diff --git a/src/TKCode.Arc.Foundation/Theme/ArcThemeState.cs b/src/TKCode.Arc.Foundation/Theme/ArcThemeState.cs new file mode 100644 index 00000000..73b63ff9 --- /dev/null +++ b/src/TKCode.Arc.Foundation/Theme/ArcThemeState.cs @@ -0,0 +1,201 @@ +using System.Collections.Immutable; +using Avalonia.Media; +using TKCode.Arc.Motion; + +namespace TKCode.Arc; + +/// Light, dark, or following the operating system. +public enum ArcAppearance +{ + /// The light appearance. + Light = 0, + + /// The dark appearance. + Dark = 1, + + /// Follows the platform's light or dark setting and updates when it changes. + System = 2 +} + +/// +/// Everything a theme is computed from: appearance, accent seed, motion level, theme presets and string token overrides. Immutable; +/// derive a changed copy with . +/// +public sealed class ArcThemeState +{ + /// The identity name under which overrides of the global identity are stored when they name no identity. + public const string GlobalIdentity = ""; + + private static readonly ImmutableDictionary> s_noOverrides = + ImmutableDictionary.Create>(StringComparer.Ordinal); + + /// Light appearance, default accent, full motion, no overrides. + public static ArcThemeState Default { get; } = new(ArcAppearance.Light, null, ArcMotionLevel.Full, s_noOverrides, + ImmutableArray.Empty); + + internal ArcThemeState(ArcAppearance appearance, Color? accent, ArcMotionLevel motionLevel, + ImmutableDictionary> overrides, + ImmutableArray presets) + { + Appearance = appearance; + Accent = accent; + MotionLevel = motionLevel; + Overrides = overrides; + Presets = presets; + } + + /// The requested appearance. + public ArcAppearance Appearance { get; } + + /// The accent seed, or null for . + public Color? Accent { get; } + + /// The motion level. + public ArcMotionLevel MotionLevel { get; } + + /// + /// Token overrides: identity → (token name → value text). Overrides of the global identity that name no identity are + /// stored under . Overrides are applied after the calculation, as final values. + /// + public IReadOnlyDictionary> TokenOverrides => Overrides; + + internal ImmutableDictionary> Overrides { get; } + + /// + /// The theme presets, at most one per , applied in this order after the calculation + /// and before . + /// + public ImmutableArray Presets { get; } + + /// Returns a copy changed by ; the parts it does not touch are kept. + /// Changes to make. + public ArcThemeState With(Action configure) + { + var builder = new ArcThemeBuilder(this); + configure(builder); + return builder.Build(); + } + + /// Resolves the appearance to light or dark. + /// Whether the platform is currently dark (used for ). + public bool ResolveIsDark(bool systemIsDark) => Appearance switch + { + ArcAppearance.Dark => true, + ArcAppearance.System => systemIsDark, + _ => false + }; +} + +/// +/// Builds an from an existing one: what is not set keeps the value of the starting state. +/// +public sealed class ArcThemeBuilder +{ + private ArcAppearance _appearance; + private Color? _accent; + private ArcMotionLevel _motionLevel; + private ImmutableDictionary> _overrides; + private ImmutableArray _presets; + + /// Starts from . + /// The state whose values are kept unless changed. + public ArcThemeBuilder(ArcThemeState from) + { + _appearance = from.Appearance; + _accent = from.Accent; + _motionLevel = from.MotionLevel; + _overrides = from.Overrides; + _presets = from.Presets; + } + + /// Sets the appearance. + public ArcThemeBuilder Appearance(ArcAppearance appearance) + { + _appearance = appearance; + return this; + } + + /// Uses as accent seed (the global identity derives its accent ramp from it). + public ArcThemeBuilder Accent(Color seed) + { + _accent = seed; + return this; + } + + /// Goes back to the default accent seed. + public ArcThemeBuilder DefaultAccent() + { + _accent = null; + return this; + } + + /// Sets the motion level. + public ArcThemeBuilder MotionLevel(ArcMotionLevel level) + { + _motionLevel = level; + return this; + } + + /// Overrides the final value of a token of the global identity (Arc's ArcDesign). + /// The token name, for example RadiusControl. + /// The value text, parsed by the token's type when the theme is applied. + public ArcThemeBuilder Token(string name, string value) => Token(ArcThemeState.GlobalIdentity, name, value); + + /// Overrides the final value of a token of . + /// The identity, for example Button. + /// The token name. + /// The value text, parsed by the token's type when the theme is applied. + public ArcThemeBuilder Token(string identity, string name, string value) + { + var tokens = _overrides.TryGetValue(identity, out var existing) + ? existing + : ImmutableDictionary.Create(StringComparer.Ordinal); + _overrides = _overrides.SetItem(identity, tokens.SetItem(name, value)); + return this; + } + + /// Removes every token override. + public ArcThemeBuilder ClearTokens() + { + _overrides = _overrides.Clear(); + return this; + } + + /// + /// Adds a theme preset. A preset already in the state with the same is replaced in + /// place (a new corner style replaces the old one); otherwise the preset goes last, so it wins over earlier ones. + /// + /// The preset, for example ArcPixelPresets.Pixel. + public ArcThemeBuilder Preset(ArcThemePreset preset) + { + ArgumentNullException.ThrowIfNull(preset); + for (var i = 0; i < _presets.Length; i++) + { + if (string.Equals(_presets[i].Slot, preset.Slot, StringComparison.Ordinal)) + { + _presets = _presets.SetItem(i, preset); + return this; + } + } + _presets = _presets.Add(preset); + return this; + } + + /// Removes the preset in , if any. + /// The preset slot, for example ArcCornerPresets.Slot. + public ArcThemeBuilder RemovePreset(string slot) + { + _presets = _presets.RemoveAll(p => string.Equals(p.Slot, slot, StringComparison.Ordinal)); + return this; + } + + /// Removes every preset. + public ArcThemeBuilder ClearPresets() + { + _presets = ImmutableArray.Empty; + return this; + } + + /// Builds the state. + public ArcThemeState Build() => new(_appearance, _accent, _motionLevel, _overrides, _presets); +} diff --git a/src/TKCode.Arc.Foundation/Theme/ArcTokenCalculator.cs b/src/TKCode.Arc.Foundation/Theme/ArcTokenCalculator.cs new file mode 100644 index 00000000..69b250fd --- /dev/null +++ b/src/TKCode.Arc.Foundation/Theme/ArcTokenCalculator.cs @@ -0,0 +1,195 @@ +using Avalonia.Animation.Easings; +using Avalonia.Controls; + +namespace TKCode.Arc; + +/// Raised when a theme state cannot be applied; the previous theme stays in place. +public sealed class ArcThemeException : Exception +{ + /// Creates the exception from the individual problems. + /// One message per rejected override. + public ArcThemeException(IReadOnlyList errors) + : base("The theme was not applied:" + Environment.NewLine + " " + string.Join(Environment.NewLine + " ", errors)) + { + Errors = errors; + } + + /// One message per rejected override. + public IReadOnlyList Errors { get; } +} + +/// Computes token values (calculation, then overrides) and publishes them into a resource dictionary. +internal static class ArcTokenCalculator +{ + /// + /// Computes every token of for . Pure: touches no Avalonia + /// object tree, so it can run on any thread. Throws listing every bad override. + /// + public static Dictionary Compute(IReadOnlyList descriptors, int tokenCount, + ArcThemeState state, bool isDark) + { + var seed = new ArcSeedToken(state.Accent ?? ArcSeedToken.DefaultAccent, state.MotionLevel); + List? errors = null; + var used = state.Overrides.Count == 0 ? null : new HashSet(StringComparer.Ordinal); + var result = new Dictionary(tokenCount); + var buffer = new object?[16]; + + foreach (var descriptor in descriptors) + { + var token = descriptor.CreateToken(); + token.EffectiveGlobalToken = seed; + token.CalculateTokenValues(isDark); + + // Presets first (in state order), then the state's own overrides, so the app wins over every preset. + foreach (var preset in state.Presets) + { + ApplyPreset(descriptor, token, preset, isDark, ref errors); + } + + if (used is not null) + { + if (descriptor.IsGlobal) + { + ApplyOverrides(descriptor, token, state, ArcThemeState.GlobalIdentity, used, ref errors); + } + ApplyOverrides(descriptor, token, state, descriptor.Identity, used, ref errors); + } + + var count = descriptor.Keys.Count; + if (buffer.Length < count) + { + buffer = new object?[count]; + } + descriptor.Write(token, buffer); + var keys = descriptor.Keys; + for (var i = 0; i < count; i++) + { + result[keys[i]] = buffer[i]; + } + } + + if (used is not null) + { + foreach (var identity in state.Overrides.Keys) + { + if (!used.Contains(identity)) + { + errors ??= new List(); + errors.Add(identity == ArcThemeState.GlobalIdentity + ? "Token overrides name no identity, but no global token identity is registered." + : $"No token identity named '{identity}' is registered (is its package enabled in UseArc?)."); + } + } + } + + if (errors is not null) + { + throw new ArcThemeException(errors); + } + return result; + } + + /// + /// Applies a preset's overrides for one identity. Unlike the state's overrides, a preset naming an identity that is not + /// registered is not an error (a preset covers packages the app may not enable), so nothing is recorded as used. + /// + private static void ApplyPreset(ArcTokenDescriptor descriptor, AbstractControlDesignToken token, ArcThemePreset preset, + bool isDark, ref List? errors) + { + var overrides = preset.TokensFor(isDark); + if (overrides.Count == 0) + { + return; + } + if (descriptor.IsGlobal && overrides.TryGetValue(ArcThemePreset.GlobalIdentity, out var global)) + { + SetTokens(descriptor, token, preset, global, ref errors); + } + if (overrides.TryGetValue(descriptor.Identity, out var own)) + { + SetTokens(descriptor, token, preset, own, ref errors); + } + } + + private static void SetTokens(ArcTokenDescriptor descriptor, AbstractControlDesignToken token, ArcThemePreset preset, + IReadOnlyDictionary overrides, ref List? errors) + { + foreach (var (name, value) in overrides) + { + if (!descriptor.TrySetToken(token, name, value, out var error)) + { + errors ??= new List(); + errors.Add($"Preset '{preset.Name}': " + (error ?? $"Token '{descriptor.Identity}.{name}' rejected '{value}'.")); + } + } + } + + private static void ApplyOverrides(ArcTokenDescriptor descriptor, AbstractControlDesignToken token, ArcThemeState state, + string identity, HashSet used, ref List? errors) + { + if (!state.Overrides.TryGetValue(identity, out var overrides)) + { + return; + } + used.Add(identity); + foreach (var (name, value) in overrides) + { + if (!descriptor.TrySetToken(token, name, value, out var error)) + { + errors ??= new List(); + errors.Add(error ?? $"Token '{descriptor.Identity}.{name}' rejected '{value}'."); + } + } + } + + /// + /// Writes into , touching only keys whose value changed, in one + /// call (one ResourcesChanged notification). Unchanged keys keep their + /// instances, so targets bound to them see no property change. + /// + /// The number of keys written. + public static int Publish(ResourceDictionary target, Dictionary values) + { + List>? changed = null; + var empty = target.Count == 0; + foreach (var pair in values) + { + if (!empty && target.TryGetValue(pair.Key, out var current) && SameValue(current, pair.Value)) + { + continue; + } + changed ??= new List>(empty ? values.Count : 64); + changed.Add(pair); + } + if (changed is null) + { + return 0; + } + if (empty) + { + target.EnsureCapacity(values.Count); + } + target.SetItems(changed); + return changed.Count; + } + + /// + /// Value equality of published values. Brushes, shadows, fonts and structs implement it; easings do not, and a token + /// calculation creates new ones every time, so they are compared by type and control points. + /// + private static bool SameValue(object? current, object? next) + { + if (Equals(current, next)) + { + return true; + } + return (current, next) switch + { + (SplineEasing a, SplineEasing b) => a.X1 == b.X1 && a.Y1 == b.Y1 && a.X2 == b.X2 && a.Y2 == b.Y2, + // Avalonia's other built-in easings carry no state; a custom easing class might. + (Easing a, Easing b) => a.GetType() == b.GetType() && a is not SplineEasing + && a.GetType().Assembly == typeof(Easing).Assembly, + _ => false + }; + } +} diff --git a/src/TKCode.Arc.Foundation/Tokens/AbstractControlDesignToken.cs b/src/TKCode.Arc.Foundation/Tokens/AbstractControlDesignToken.cs new file mode 100644 index 00000000..2626a03d --- /dev/null +++ b/src/TKCode.Arc.Foundation/Tokens/AbstractControlDesignToken.cs @@ -0,0 +1,25 @@ +namespace TKCode.Arc; + +/// +/// Base class of every token class. A token class holds the values of one identity (a control, or the global +/// ArcDesign) as settable properties and computes them in ; the theme manager +/// then applies the string overrides of the theme state and publishes the properties as resources. +/// +public abstract class AbstractControlDesignToken +{ + /// + /// The theme-wide inputs of the calculation: accent seed and motion level. Set by the theme manager before + /// runs. + /// + public ArcSeedToken EffectiveGlobalToken { get; internal set; } = ArcSeedToken.Default; + + /// + /// Computes every token value for the given appearance. Overrides call base first. Must depend only on + /// and : the manager may run it off the UI thread and + /// cache the result. + /// + /// True for the dark appearance. + public virtual void CalculateTokenValues(bool isDarkMode) + { + } +} diff --git a/src/TKCode.Arc.Foundation/Tokens/ArcResourceReference.cs b/src/TKCode.Arc.Foundation/Tokens/ArcResourceReference.cs new file mode 100644 index 00000000..5091fc04 --- /dev/null +++ b/src/TKCode.Arc.Foundation/Tokens/ArcResourceReference.cs @@ -0,0 +1,39 @@ +using System.ComponentModel; +using Avalonia.Data; +using Avalonia.Markup.Xaml.MarkupExtensions; +using Avalonia.Markup.Xaml.XamlIl.Runtime; + +namespace TKCode.Arc; + +/// +/// The dynamic resource reference behind the generated {arc:XxxTokenResource} and {arc:XxxLangResource} +/// markup extensions. Used by generated code. +/// +/// +/// Avalonia's {DynamicResource} applies a value written on an element inside a control template at +/// , below the style triggers of that element's own theme (for example a +/// ^[Size=Small] setter of the part's theme). Arc's themes were written for local priority: a token written on a +/// template part is the part's value whatever its theme's triggers say, and the templated control's own +/// /template/ styles still win over it only through their own triggers. So the reference is created as if outside a +/// template; everything else (the anchor for non-element targets, the theme variant of a theme dictionary) is unchanged. +/// +[EditorBrowsable(EditorBrowsableState.Never)] +public static class ArcResourceReference +{ + /// A dynamic resource reference to at local priority. + /// The resource key (a boxed token or language kind). + /// The XAML service provider given to the markup extension. + public static BindingBase Create(object key, IServiceProvider serviceProvider) + => new DynamicResourceExtension(key).ProvideValue(new OutsideTemplate(serviceProvider)); + + /// Answers every service like the XAML context, except that it is not inside a control template. + private sealed class OutsideTemplate : IServiceProvider + { + private readonly IServiceProvider _inner; + + public OutsideTemplate(IServiceProvider inner) => _inner = inner; + + public object? GetService(Type serviceType) + => serviceType == typeof(IAvaloniaXamlIlControlTemplateProvider) ? null : _inner.GetService(serviceType); + } +} diff --git a/src/TKCode.Arc.Foundation/Tokens/ArcSeedToken.cs b/src/TKCode.Arc.Foundation/Tokens/ArcSeedToken.cs new file mode 100644 index 00000000..ebf04f4e --- /dev/null +++ b/src/TKCode.Arc.Foundation/Tokens/ArcSeedToken.cs @@ -0,0 +1,35 @@ +using Avalonia.Media; +using TKCode.Arc.Motion; + +namespace TKCode.Arc; + +/// +/// The theme-wide inputs every token calculation can read: the accent seed and the motion level. It replaces the +/// 255 Ant global tokens of AtomUI; Arc only ever read and . +/// +public sealed class ArcSeedToken +{ + /// The accent seed used when the theme state names none (#1677FF, AtomUI's default primary). + public static readonly Color DefaultAccent = Color.FromRgb(0x16, 0x77, 0xFF); + + /// Seed values of the default state. + public static readonly ArcSeedToken Default = new(DefaultAccent, ArcMotionLevel.Full); + + /// Creates seed values. + /// The accent seed. + /// The motion level. + public ArcSeedToken(Color colorPrimary, ArcMotionLevel motionLevel) + { + ColorPrimary = colorPrimary; + MotionLevel = motionLevel; + } + + /// The accent seed (the brand color the global token derives its accent ramp from). + public Color ColorPrimary { get; } + + /// The motion level of the theme state. + public ArcMotionLevel MotionLevel { get; } + + /// False when the motion level is . + public bool EnableMotion => MotionLevel != ArcMotionLevel.Off; +} diff --git a/src/TKCode.Arc.Foundation/Tokens/ArcTokenDescriptor.cs b/src/TKCode.Arc.Foundation/Tokens/ArcTokenDescriptor.cs new file mode 100644 index 00000000..8161b4d8 --- /dev/null +++ b/src/TKCode.Arc.Foundation/Tokens/ArcTokenDescriptor.cs @@ -0,0 +1,77 @@ +using System.ComponentModel; + +namespace TKCode.Arc; + +/// +/// Generated description of one token class: its identity, token names, resource keys, and reflection-free code that +/// creates the token, writes its values and applies string overrides. One instance per [ControlDesignToken] class, +/// emitted by the Foundation generator; applications do not derive from it. +/// +[EditorBrowsable(EditorBrowsableState.Never)] +public abstract class ArcTokenDescriptor +{ + private readonly string[] _tokenNames; + private readonly object[] _keys; + + /// Creates a descriptor. Called by generated code only. + /// The identity (token class name without the Token suffix). + /// Whether this is the global identity. + /// The token (property) names, in XxxTokenKind order. + /// The resource keys (boxed XxxTokenKind values), in the same order. + protected ArcTokenDescriptor(string identity, bool isGlobal, string[] tokenNames, object[] keys) + { + if (tokenNames.Length != keys.Length) + { + throw new ArgumentException("Every token needs exactly one key.", nameof(keys)); + } + Identity = identity; + IsGlobal = isGlobal; + _tokenNames = tokenNames; + _keys = keys; + } + + /// The identity: the token class name without the Token suffix. + public string Identity { get; } + + /// Whether this is the global identity (receives overrides that name no identity). + public bool IsGlobal { get; } + + /// The token names, in XxxTokenKind order. + public IReadOnlyList TokenNames => _tokenNames; + + /// The resource keys, in XxxTokenKind order. A key is the boxed XxxTokenKind value. + public IReadOnlyList Keys => _keys; + + /// Creates a fresh token instance. + public abstract AbstractControlDesignToken CreateToken(); + + /// + /// Writes the token's values into (same order as ). Colors are written as + /// immutable solid color brushes. + /// + /// A token created by . + /// The destination, at least long. + public abstract void Write(AbstractControlDesignToken token, object?[] values); + + /// + /// Parses by the type of token and assigns it. + /// + /// A token created by . + /// The token name. + /// The value text. + /// Why the override was rejected, when the method returns false. + /// True when the value was parsed and assigned. + public abstract bool TrySetToken(AbstractControlDesignToken token, string name, string value, out string? error); + + /// Message for an unknown token name. Used by generated code. + /// The token name that was not found. + protected string UnknownToken(string name) + => $"'{Identity}' has no token named '{name}'."; + + /// Message for a value that does not parse. Used by generated code. + /// The token name. + /// The token's type as shown to the user. + /// The value text. + protected string InvalidValue(string name, string typeName, string value) + => $"'{value}' is not a valid {typeName} for token '{Identity}.{name}'."; +} diff --git a/src/TKCode.Arc.Foundation/Tokens/ArcTokenValueParser.cs b/src/TKCode.Arc.Foundation/Tokens/ArcTokenValueParser.cs new file mode 100644 index 00000000..d779522b --- /dev/null +++ b/src/TKCode.Arc.Foundation/Tokens/ArcTokenValueParser.cs @@ -0,0 +1,209 @@ +using System.ComponentModel; +using System.Globalization; +using Avalonia; +using Avalonia.Animation; +using Avalonia.Animation.Easings; +using Avalonia.Media; +using Avalonia.Media.Immutable; + +namespace TKCode.Arc; + +/// +/// Parsers and value conversions used by the generated token writers and override parsers. Every parser is culture +/// invariant, never throws, and uses no reflection. Enums are parsed by generated switches, not here. +/// +[EditorBrowsable(EditorBrowsableState.Never)] +public static class ArcTokenValueParser +{ + /// Converts a color token to the resource value it is published as (an immutable solid color brush). + /// The color. + public static object Brush(Color color) => new ImmutableSolidColorBrush(color); + + /// Parses #RGB, #ARGB, #RRGGBB, #AARRGGBB or a known color name. + public static bool TryParseColor(string text, out Color value) => Color.TryParse(text.Trim(), out value); + + /// Parses an invariant-culture number. + public static bool TryParseDouble(string text, out double value) + => double.TryParse(text.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out value); + + /// Parses an invariant-culture integer. + public static bool TryParseInt32(string text, out int value) + => int.TryParse(text.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out value); + + /// Parses true or false (any case). + public static bool TryParseBoolean(string text, out bool value) => bool.TryParse(text.Trim(), out value); + + /// Parses a thickness: one, two or four numbers (8, 12,0, 1,2,3,4). + public static bool TryParseThickness(string text, out Thickness value) + { + try + { + value = Thickness.Parse(text.Trim()); + return true; + } + catch (FormatException) + { + } + catch (ArgumentException) + { + } + value = default; + return false; + } + + /// Parses a corner radius: one or four numbers. + public static bool TryParseCornerRadius(string text, out CornerRadius value) + { + try + { + value = CornerRadius.Parse(text.Trim()); + return true; + } + catch (FormatException) + { + } + catch (ArgumentException) + { + } + value = default; + return false; + } + + /// Parses box shadows in Avalonia syntax (0 4 12 0 #20000000, inset 0 1 0 0 #10FFFFFF), or none. + public static bool TryParseBoxShadows(string text, out BoxShadows value) + { + var trimmed = text.Trim(); + if (trimmed.Length == 0 || string.Equals(trimmed, "none", StringComparison.OrdinalIgnoreCase)) + { + value = default; + return true; + } + try + { + value = BoxShadows.Parse(trimmed); + return true; + } + catch (FormatException) + { + } + catch (ArgumentException) + { + } + catch (IndexOutOfRangeException) + { + } + value = default; + return false; + } + + /// Parses a font family list (Inter, Segoe UI) or a resource font (avares://…#Name). + public static bool TryParseFontFamily(string text, out FontFamily value) + { + var trimmed = text.Trim(); + if (trimmed.Length == 0) + { + value = FontFamily.Default; + return false; + } + try + { + value = FontFamily.Parse(trimmed); + return true; + } + catch (FormatException) + { + } + catch (ArgumentException) + { + } + value = FontFamily.Default; + return false; + } + + /// + /// Parses a duration: 120ms, 0.2s, a bare number of milliseconds (120), or a + /// in invariant format (00:00:00.120). + /// + public static bool TryParseTimeSpan(string text, out TimeSpan value) + { + var trimmed = text.Trim(); + if (trimmed.EndsWith("ms", StringComparison.OrdinalIgnoreCase)) + { + if (TryParseDouble(trimmed.Substring(0, trimmed.Length - 2), out var ms) && ms >= 0) + { + value = TimeSpan.FromMilliseconds(ms); + return true; + } + } + else if (trimmed.EndsWith("s", StringComparison.OrdinalIgnoreCase)) + { + if (TryParseDouble(trimmed.Substring(0, trimmed.Length - 1), out var s) && s >= 0) + { + value = TimeSpan.FromSeconds(s); + return true; + } + } + else if (trimmed.IndexOf(':') < 0) + { + if (TryParseDouble(trimmed, out var bare) && bare >= 0) + { + value = TimeSpan.FromMilliseconds(bare); + return true; + } + } + else if (TimeSpan.TryParse(trimmed, CultureInfo.InvariantCulture, out value)) + { + return true; + } + value = default; + return false; + } + + /// + /// Parses an easing: a cubic bezier x1,y1,x2,y2 (CSS cubic-bezier numbers) or the name of an Avalonia + /// easing (Linear, CubicEaseOut, … with or without the Easing suffix). + /// + public static bool TryParseEasing(string text, out Easing value) + { + var trimmed = text.Trim(); + if (trimmed.IndexOf(',') >= 0) + { + var parts = trimmed.Split(','); + if (parts.Length == 4 + && TryParseDouble(parts[0], out var x1) && TryParseDouble(parts[1], out var y1) + && TryParseDouble(parts[2], out var x2) && TryParseDouble(parts[3], out var y2) + && x1 is >= 0 and <= 1 && x2 is >= 0 and <= 1) + { + value = new SplineEasing(new KeySpline(x1, y1, x2, y2)); + return true; + } + value = new LinearEasing(); + return false; + } + var name = trimmed.EndsWith("Easing", StringComparison.OrdinalIgnoreCase) + ? trimmed.Substring(0, trimmed.Length - "Easing".Length) + : trimmed; + Easing? easing = name.ToUpperInvariant() switch + { + "LINEAR" => new LinearEasing(), + "QUADRATICEASEIN" => new QuadraticEaseIn(), + "QUADRATICEASEOUT" => new QuadraticEaseOut(), + "QUADRATICEASEINOUT" => new QuadraticEaseInOut(), + "CUBICEASEIN" => new CubicEaseIn(), + "CUBICEASEOUT" => new CubicEaseOut(), + "CUBICEASEINOUT" => new CubicEaseInOut(), + "QUARTICEASEIN" => new QuarticEaseIn(), + "QUARTICEASEOUT" => new QuarticEaseOut(), + "QUARTICEASEINOUT" => new QuarticEaseInOut(), + "SINEEASEIN" => new SineEaseIn(), + "SINEEASEOUT" => new SineEaseOut(), + "SINEEASEINOUT" => new SineEaseInOut(), + "EXPONENTIALEASEIN" => new ExponentialEaseIn(), + "EXPONENTIALEASEOUT" => new ExponentialEaseOut(), + "EXPONENTIALEASEINOUT" => new ExponentialEaseInOut(), + _ => null + }; + value = easing ?? new LinearEasing(); + return easing is not null; + } +} diff --git a/src/TKCode.Arc.Foundation/Tokens/ControlDesignTokenAttribute.cs b/src/TKCode.Arc.Foundation/Tokens/ControlDesignTokenAttribute.cs new file mode 100644 index 00000000..92b4e6ad --- /dev/null +++ b/src/TKCode.Arc.Foundation/Tokens/ControlDesignTokenAttribute.cs @@ -0,0 +1,28 @@ +namespace TKCode.Arc; + +/// +/// Marks a token class (XxxToken : AbstractControlDesignToken) for the Foundation generator. The generator emits +/// XxxTokenKind, XxxTokens (keys and identity), the XxxTokenResource markup extension, a writer that +/// publishes the properties into a resource dictionary and a parser for string overrides, all without reflection. +/// +/// +/// The identity of the tokens is the class name without the Token suffix (ButtonToken is Button). +/// Every public settable property is a token; its type must be one the generator can parse (see +/// design/architecture/foundation.md). +/// +[AttributeUsage(AttributeTargets.Class, Inherited = false)] +public sealed class ControlDesignTokenAttribute : Attribute +{ + /// + /// Whether this is the global token identity (Arc's ArcDesign): it receives the overrides that name no identity + /// (). An application has at most one global identity. + /// + public bool IsGlobal { get; set; } + + /// + /// The identity overrides name, when it must differ from the class name without Token (two packages that an + /// application enables together declare a token class of the same name, such as Mobile's ButtonToken next to + /// the desktop one). The generated types keep the class-name stem (ButtonTokenKind, {arcm:ButtonTokenResource}). + /// + public string? Identity { get; set; } +} diff --git a/src/TKCode.Arc.Foundation/buildTransitive/TKCode.Arc.Foundation.targets b/src/TKCode.Arc.Foundation/buildTransitive/TKCode.Arc.Foundation.targets new file mode 100644 index 00000000..d743838a --- /dev/null +++ b/src/TKCode.Arc.Foundation/buildTransitive/TKCode.Arc.Foundation.targets @@ -0,0 +1,21 @@ + + + + <_ArcFoundationThemeXaml Include="**/Themes/*Theme.axaml" Exclude="$(DefaultItemExcludes);$(DefaultExcludesInProjectFolder)" /> + + + + + + <_ArcFoundationLanguageFile Include="**/Localization/*.xlf" Exclude="$(DefaultItemExcludes);$(DefaultExcludesInProjectFolder)" /> + + + + diff --git a/tools/dev/FoundationAotSample/.gitignore b/tools/dev/FoundationAotSample/.gitignore new file mode 100644 index 00000000..d3c46f5f --- /dev/null +++ b/tools/dev/FoundationAotSample/.gitignore @@ -0,0 +1,2 @@ +# Written by generate_scale.py (run it before building). +Scale/ diff --git a/tools/dev/FoundationAotSample/AssemblyInfo.cs b/tools/dev/FoundationAotSample/AssemblyInfo.cs new file mode 100644 index 00000000..5f046487 --- /dev/null +++ b/tools/dev/FoundationAotSample/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using Avalonia.Metadata; + +[assembly: XmlnsDefinition("https://github.com/AIDotNet/TKCode.Arc", "TKCode.Arc.FoundationAotSample.Scale")] +[assembly: XmlnsDefinition("https://github.com/AIDotNet/TKCode.Arc", "TKCode.Arc.FoundationAotSample.Scale.DesignTokens")] diff --git a/tools/dev/FoundationAotSample/FoundationAotSample.csproj b/tools/dev/FoundationAotSample/FoundationAotSample.csproj new file mode 100644 index 00000000..0ba9369d --- /dev/null +++ b/tools/dev/FoundationAotSample/FoundationAotSample.csproj @@ -0,0 +1,29 @@ + + + + Exe + net10.0 + TKCode.Arc.FoundationAotSample + true + true + false + false + + false + + + + + + + + + + + diff --git a/tools/dev/FoundationAotSample/Program.cs b/tools/dev/FoundationAotSample/Program.cs new file mode 100644 index 00000000..0c097019 --- /dev/null +++ b/tools/dev/FoundationAotSample/Program.cs @@ -0,0 +1,194 @@ +using System.Diagnostics; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Presenters; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Templates; +using Avalonia.Headless; +using Avalonia.Media; +using Avalonia.Styling; +using Avalonia.Threading; +using TKCode.Arc; +using TKCode.Arc.FoundationAotSample.Scale; +using TKCode.Arc.FoundationAotSample.Scale.DesignTokens; +using TKCode.Arc.Generated.FoundationAotSample; + +namespace TKCode.Arc.FoundationAotSample; + +/// +/// Starts Avalonia headless with Foundation and an Arc-sized token and theme set, checks that tokens resolve, switch and +/// parse, and prints timings. Usage: FoundationAotSample [lazy|eager]. Exit code 0 when every check passed. +/// +public static class Program +{ + private static int s_failures; + + public static int Main(string[] args) + { + var mode = args.Length > 0 && args[0] == "eager" ? ArcThemeLoading.Eager : ArcThemeLoading.Lazy; + var total = Stopwatch.StartNew(); + var install = new Stopwatch(); + AppBuilder.Configure() + .UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = true }) + .AfterSetup(_ => install.Start()) + .UseArc(arc => arc.UsePackage(ArcGeneratedAssets.PackageId, static p => ArcGeneratedAssets.AddTo(p)) + .WithThemeLoading(mode)) + .AfterSetup(_ => install.Stop()) + .SetupWithoutStarting(); + var setup = total.Elapsed; + + var runtime = ArcRuntime.Current!; + var theme = runtime.Theme; + Console.WriteLine($"mode {mode}"); + Console.WriteLine($"runtime {(System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeCompiled ? "JIT" : "Native AOT")}"); + Console.WriteLine($"identities / tokens {theme.Descriptors.Count} / {theme.TokenCount}"); + Console.WriteLine($"theme files {runtime.Themes.AssetCount}"); + Console.WriteLine($"avalonia setup total {Ms(setup)}"); + Console.WriteLine($" UseArc install {Ms(install.Elapsed)}"); + Console.WriteLine($" first compute {Ms(theme.LastComputeDuration)}"); + Console.WriteLine($" first publish {Ms(theme.LastPublishDuration)}"); + Console.WriteLine($" theme files loaded {runtime.Themes.LoadedCount} in {Ms(runtime.Themes.TotalLoadDuration)}"); + + // Tokens resolve through the boxed kind (what ArcTokens.Bind does). + Check("global token resolves", + Application.Current!.TryGetResource(ScaleDesignTokenKind.Color0, null, out var accent) + && accent is ISolidColorBrush { Color: var c } && c == ArcSeedToken.DefaultAccent); + + // First use of 50 control types, then the same 50 types again. + var types = Enumerable.Range(0, 50).Select(CreateControl).ToArray(); + var loadsBefore = runtime.Themes.TotalLoadDuration; + var watch = Stopwatch.StartNew(); + var window = new Window { Width = 1200, Height = 900, Content = new WrapPanel() }; + window.Show(); + foreach (var control in types) + { + ((WrapPanel)window.Content!).Children.Add(control); + } + Dispatcher.UIThread.RunJobs(); + var firstUse = watch.Elapsed; + Console.WriteLine($"first use of 50 controls {Ms(firstUse)} (theme file loads {Ms(runtime.Themes.TotalLoadDuration - loadsBefore)}, {runtime.Themes.LoadedCount} files loaded)"); + + watch.Restart(); + var again = new WrapPanel(); + foreach (var index in Enumerable.Range(0, 50)) + { + again.Children.Add(CreateControl(index)); + } + window.Content = again; + Dispatcher.UIThread.RunJobs(); + Console.WriteLine($"same 50 controls again {Ms(watch.Elapsed)}"); + + var probe = (TemplatedControl)again.Children[3]; + Check("control theme applies token", probe.Background is ISolidColorBrush { Color: var probeColor } && probeColor == ExpectedColor(3, false)); + + // All 348 controls on screen, then switch light/dark. + var all = new WrapPanel(); + foreach (var index in Enumerable.Range(0, runtime.Themes.AssetCount)) + { + all.Children.Add(CreateControl(index)); + } + watch.Restart(); + window.Content = all; + Dispatcher.UIThread.RunJobs(); + Console.WriteLine($"show all 348 controls {Ms(watch.Elapsed)} ({runtime.Themes.LoadedCount} files loaded, {Ms(runtime.Themes.TotalLoadDuration)} in loads)"); + + var switches = new List<(TimeSpan Compute, TimeSpan Publish, TimeSpan Total)>(); + for (var i = 0; i < 10; i++) + { + watch.Restart(); + theme.Apply(t => t.Appearance(i % 2 == 0 ? ArcAppearance.Dark : ArcAppearance.Light)); + Dispatcher.UIThread.RunJobs(); + switches.Add((theme.LastComputeDuration, theme.LastPublishDuration, watch.Elapsed)); + } + Console.WriteLine($"light/dark switch x10 compute {Avg(switches, s => s.Compute)}, publish+refresh {Avg(switches, s => s.Publish)}, total {Avg(switches, s => s.Total)} (avg; {theme.LastPublishedCount} keys rewritten)"); + var probeAll = (TemplatedControl)all.Children[3]; + Check("light/dark switch updates control", probeAll.Background is ISolidColorBrush { Color: var light } && light == ExpectedColor(3, false)); + + watch.Restart(); + theme.Apply(t => t.Accent(Color.Parse("#7747FF"))); + Dispatcher.UIThread.RunJobs(); + Console.WriteLine($"accent change compute {Ms(theme.LastComputeDuration)}, publish+refresh {Ms(theme.LastPublishDuration)}, total {Ms(watch.Elapsed)} ({theme.LastPublishedCount} keys rewritten)"); + Check("accent recomputes global token", + Application.Current.TryGetResource(ScaleDesignTokenKind.Color0, null, out var violet) + && violet is ISolidColorBrush { Color: var v } && v == Color.Parse("#7747FF")); + + // String overrides through the generated parsers. + theme.Apply(t => t.Token("Radius0", "12").Token("Scale003", "Inset0", "4,2").Token("Duration0", "90ms")); + Check("overrides parse", + Application.Current.TryGetResource(ScaleDesignTokenKind.Radius0, null, out var radius) && radius is CornerRadius { TopLeft: 12 } + && Application.Current.TryGetResource(Scale003TokenKind.Inset0, null, out var inset) && inset is Thickness { Left: 4, Top: 2 }); + try + { + theme.Apply(t => t.Token("Radius0", "round")); + Check("bad override rejected", false); + } + catch (ArcThemeException error) + { + Check("bad override rejected", error.Errors.Count == 1 && error.Errors[0].Contains("'round' is not a valid corner radius")); + } + + // Scoped dark subtree. + var scoped = CreateControl(3); + var scope = new ThemeVariantScope { Child = scoped }; + ArcThemeScope.SetAppearance(scope, ArcAppearance.Dark); + window.Content = scope; + Dispatcher.UIThread.RunJobs(); + Check("scoped dark subtree", scoped.Background is ISolidColorBrush { Color: var dark } && dark == ExpectedColor(3, true)); + + if (mode == ArcThemeLoading.Lazy) + { + var remaining = runtime.Themes.TotalLoadDuration; + runtime.Themes.LoadAll(); + Console.WriteLine($"eager-load remaining {Ms(runtime.Themes.TotalLoadDuration - remaining)} ({runtime.Themes.LoadedCount} files now)"); + } + Console.WriteLine($"total {Ms(total.Elapsed)}"); + Console.WriteLine($"working set {Environment.WorkingSet / (1024 * 1024)} MB"); + Console.WriteLine(s_failures == 0 ? "ALL CHECKS PASSED" : $"{s_failures} CHECK(S) FAILED"); + return s_failures == 0 ? 0 : 1; + } + + // Matches generate_scale.py: control i uses global Color{i % 120} as background. + private static Color ExpectedColor(int control, bool dark) + { + var i = control % 120; + if (i == 0) + { + return ArcSeedToken.DefaultAccent; + } + var b = (i * 37) % 200 + (dark ? 40 : 0); + return Color.FromRgb((byte)b, (byte)((b * 3) % 256), (byte)((b * 7) % 256)); + } + + private static TemplatedControl CreateControl(int index) => ScaleControls.Create(index); + + private static void Check(string name, bool ok) + { + Console.WriteLine($"check {(ok ? "ok " : "FAIL")} {name}"); + if (!ok) + { + s_failures++; + } + } + + private static string Ms(TimeSpan span) => $"{span.TotalMilliseconds:0.00} ms"; + + private static string Avg(List items, Func select) + => Ms(TimeSpan.FromTicks((long)items.Average(i => select(i).Ticks))); +} + +/// Headless host without Fluent: the window gets a minimal template. +public sealed class App : Application +{ + /// + public override void Initialize() + { + var windowTheme = new ControlTheme(typeof(Window)); + windowTheme.Setters.Add(new Setter(TemplatedControl.TemplateProperty, + new FuncControlTemplate((window, _) => new ContentPresenter + { + Name = "PART_ContentPresenter", + [!ContentPresenter.ContentProperty] = window[!ContentControl.ContentProperty] + }))); + Resources[typeof(Window)] = windowTheme; + } +} diff --git a/tools/dev/FoundationAotSample/generate_scale.py b/tools/dev/FoundationAotSample/generate_scale.py new file mode 100644 index 00000000..135d72c5 --- /dev/null +++ b/tools/dev/FoundationAotSample/generate_scale.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Writes the Arc-sized token and theme set of the Foundation AOT sample (design/architecture/foundation.md section 8). + +Shape copied from Arc 0.3 (tests/Baselines/tokens): one global identity with 169 tokens and 250 control identities with +18 tokens each (4,669 tokens; Arc has 4,843), types in Arc's proportions (double > Thickness > CornerRadius > Color > +TimeSpan), and 348 theme files with about 30 token references each (Arc: 348 files, ~12,400 references). + +Run from this directory: python3 generate_scale.py +""" +import os + +HERE = os.path.dirname(os.path.abspath(__file__)) +CONTROL_IDENTITIES = 250 +THEME_FILES = 348 + +GLOBAL = ( + [("Color", f"Color{i}") for i in range(120)] + + [("double", f"Size{i}") for i in range(20)] + + [("CornerRadius", f"Radius{i}") for i in range(10)] + + [("BoxShadows", f"Shadow{i}") for i in range(8)] + + [("FontFamily", f"Font{i}") for i in range(5)] + + [("FontWeight", f"Weight{i}") for i in range(2)] + + [("TimeSpan", f"Duration{i}") for i in range(4)] +) +assert len(GLOBAL) == 169 + +CONTROL = ( + [("double", f"Size{i}") for i in range(10)] + + [("Thickness", f"Inset{i}") for i in range(5)] + + [("CornerRadius", "Radius0"), ("Color", "Tint0"), ("TimeSpan", "Duration0")] +) +assert len(CONTROL) == 18 + + +def value(kind, i, dark): + if kind == "Color": + base = (i * 37) % 200 + (40 if dark else 0) + return f"Color.FromRgb({base}, {(base * 3) % 256}, {(base * 7) % 256})" + if kind == "double": + return f"{i + (2 if dark else 1)}" + if kind == "Thickness": + return f"new Thickness({i % 8 + 4}, {i % 4})" + if kind == "CornerRadius": + return f"new CornerRadius({i % 12 + 4})" + if kind == "BoxShadows": + return f'BoxShadows.Parse("0 {i % 4 + 1} {i % 8 + 4} 0 #{"66" if dark else "1A"}000000")' + if kind == "FontFamily": + return 'FontFamily.Default' + if kind == "FontWeight": + return "FontWeight.Medium" + if kind == "TimeSpan": + return f"TimeSpan.FromMilliseconds({120 + i * 40})" + raise ValueError(kind) + + +def token_class(name, props, is_global): + lines = [f"[ControlDesignToken{'(IsGlobal = true)' if is_global else ''}]", + f"internal sealed class {name} : AbstractControlDesignToken", "{"] + for kind, prop in props: + default = " = FontFamily.Default;" if kind == "FontFamily" else "" + lines.append(f" public {kind} {prop} {{ get; set; }}{default}") + lines.append("") + lines.append(" public override void CalculateTokenValues(bool isDarkMode)") + lines.append(" {") + lines.append(" base.CalculateTokenValues(isDarkMode);") + if is_global: + lines.append(" var seed = EffectiveGlobalToken.ColorPrimary;") + lines.append(" if (isDarkMode)") + lines.append(" {") + for i, (kind, prop) in enumerate(props): + lines.append(f" {prop} = {value(kind, i, True)};") + lines.append(" }") + lines.append(" else") + lines.append(" {") + for i, (kind, prop) in enumerate(props): + lines.append(f" {prop} = {value(kind, i, False)};") + lines.append(" }") + if is_global: + lines.append(" Color0 = seed;") + lines.append(" }") + lines.append("}") + return "\n".join(lines) + + +def write_tokens(): + parts = ["// Generated by generate_scale.py; do not edit.", + "using Avalonia;", "using Avalonia.Controls.Primitives;", "using Avalonia.Media;", "", + "namespace TKCode.Arc.FoundationAotSample.Scale;", "", + token_class("ScaleDesignToken", GLOBAL, True), ""] + for i in range(CONTROL_IDENTITIES): + parts.append(token_class(f"Scale{i:03}Token", CONTROL, False)) + parts.append("") + for i in range(THEME_FILES): + parts.append(f"/// Scale control {i}.") + parts.append(f"public class ScaleControl{i:03} : TemplatedControl") + parts.append("{") + parts.append("}") + parts.append("") + parts.append("/// Creates scale controls by index without reflection.") + parts.append("public static class ScaleControls") + parts.append("{") + parts.append(" /// Creates control .") + parts.append(" public static TemplatedControl Create(int index) => index switch") + parts.append(" {") + for i in range(THEME_FILES): + parts.append(f" {i} => new ScaleControl{i:03}(),") + parts.append(" _ => throw new System.ArgumentOutOfRangeException(nameof(index))") + parts.append(" };") + parts.append("}") + parts.append("") + with open(os.path.join(HERE, "Scale", "ScaleTokens.cs"), "w") as f: + f.write("\n".join(parts)) + + +def theme(i): + t = i % CONTROL_IDENTITIES + own = f"Scale{t:03}TokenResource" + g = "ScaleDesignTokenResource" + borders = [] + for b in range(6): + borders.append( + f' ') + return f""" + + + + + + + + + +{chr(10).join(borders)} + + + + + + +""" + + +def write_themes(): + root = os.path.join(HERE, "Scale") + for i in range(THEME_FILES): + directory = os.path.join(root, f"ScaleControl{i:03}", "Themes") + os.makedirs(directory, exist_ok=True) + with open(os.path.join(directory, f"ScaleControl{i:03}Theme.axaml"), "w") as f: + f.write(theme(i)) + + +if __name__ == "__main__": + os.makedirs(os.path.join(HERE, "Scale"), exist_ok=True) + write_tokens() + write_themes() From df29e56892c540b66a13b02428e42c02b742986e Mon Sep 17 00:00:00 2001 From: token <61819790+239573049@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:15:19 -0700 Subject: [PATCH 2/6] feat!: replace AtomUI with TKCode.Arc.Foundation Every TKCode.Arc package now depends only on Avalonia and the MIT TKCode.Arc.Foundation; AtomUI (LGPL-3.0) is gone from the dependency tree, so closed-source NativeAOT single-file apps are no longer constrained by it. Switch-over (tools/dev/migrate_atomui.py does the mechanical part): - Token classes and XAML token references are unchanged; theme files get a flat x:Class; packages register through ArcBuilder extensions. - Startup is AppBuilder.UseArc(arc => arc.UseArcControls()...); themes are applied with ArcTheme.Apply / ArcThemeManager; Pixel and Corners are presets. - Localization moves to TKCode.Arc.Localization with the same catalogs. - Mobile token identities become MobileButton, MobileCheckbox, MobileDialog, MobileSwitch and MobileToast. Replaced AtomUI pieces: - ArcPopup derives from Avalonia's Popup (ArcPopupFrame, ArcPopupMotion). - ArcImage and TKCode.Arc.ImageSource replace AsyncImage (IsImageLoaded). - ArcOverlayHost replaces the scoped overlay layer. - The palette generator follows @ant-design/colors (MIT). - ArcBaseTheme supplies ScrollViewer/ScrollBar (hidden until hover, like ScrollArea), ItemsControl, PathIcon, TextSelectionHandle, popup hosts, EmbeddableControlRoot and Window; it gives way to Fluent and Simple. Also: - The gallery desktop window draws its own title bar again. - Fixes: toast stack re-entrancy while pausing, the DataGrid filter and EventLog search boxes had no template, the browser root missed its base theme, the first ThemeSwitcher reveal in a plain Window, MarkdownHost's outline before the template, and pool-thread posts now go to the owning control's dispatcher. - Tests: token values are checked against a baseline recorded under AtomUI (30 variants, no differences); test isolation fixes for GC tests. BREAKING CHANGE: startup, theme and localization APIs changed; see the 0.3 to 0.4 migration guide in the changelog. Refs #25 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 +- Directory.Packages.props | 8 +- Dockerfile | 2 +- TKCode.Arc.slnx | 4 + build/Arc.Repository.targets | 10 + build/PackageMetadata.props | 4 +- build/Versions.props | 1 - gallery/TKCode.Arc.Gallery.Browser/App.cs | 2 - .../EmbeddableRootTemplate.cs | 46 - gallery/TKCode.Arc.Gallery.Browser/Program.cs | 14 +- .../TKCode.Arc.Gallery.Browser.csproj | 26 +- gallery/TKCode.Arc.Gallery.Desktop/Program.cs | 14 +- .../TKCode.Arc.Gallery.Desktop.csproj | 5 +- gallery/TKCode.Arc.Gallery.Llms/LlmsSite.cs | 5 +- .../TKCode.Arc.Gallery.Llms.csproj | 1 - .../Demos/Blocks/CommandPalette/Preview.axaml | 5 +- .../Demos/Blocks/EnvVariables/EventLog.axaml | 2 +- .../InvitePeople/InvitePeopleDemoData.cs | 1 - .../NotificationCenter/ReadState.axaml.cs | 4 +- .../Blocks/ProfilePage/ProfilePageDemoData.cs | 1 - .../TeamDirectory/TeamDirectoryDemoData.cs | 1 - .../Blocks/TeamMembers/TeamMembersDemoData.cs | 1 - .../Demos/Content/Markdown/Document.axaml.cs | 2 +- .../Cursor/GravityWell/GravityWellDemoData.cs | 1 - .../Demos/Cursor/GravityWell/Preview.axaml | 5 +- .../Media/CardStack/CardStackDemoData.cs | 1 - .../Demos/Media/CardStack/Photos.axaml | 3 +- .../Demos/Media/Carousel/CarouselDemoData.cs | 1 - .../Demos/Media/Carousel/Photos.axaml | 3 +- .../Demos/Overlays/Dialog/ShortForm.axaml | 3 +- .../Demos/Overlays/Drawer/Sides.axaml | 3 +- .../Demos/Overlays/Popover/SmallForm.axaml | 3 +- .../GalleryBuilderExtensions.cs | 33 +- .../Localization/GalleryLanguages.cs | 2 +- .../GalleryShellLangResourceKind.cs | 8 +- .../Localization/GalleryText.cs | 27 +- .../TKCode.Arc.Gallery/Localization/en-US.xlf | 30 + .../TKCode.Arc.Gallery/Localization/zh-CN.xlf | 36 + .../TKCode.Arc.Gallery/Localization/zh-TW.xlf | 36 + .../TKCode.Arc.Gallery/Shell/GalleryIcons.cs | 11 +- .../Shell/GalleryPageView.cs | 4 +- .../Shell/GallerySearchBox.cs | 73 + .../Shell/GallerySidebar.cs | 4 +- .../TKCode.Arc.Gallery/Shell/GalleryTheme.cs | 47 +- .../Shell/GalleryTitleBar.cs | 279 + .../TKCode.Arc.Gallery/Shell/GalleryWindow.cs | 87 +- .../TKCode.Arc.Gallery.csproj | 4 +- .../AdvancedChartsBuilderExtensions.cs | 13 +- .../DataGrid/DataGrid.Chrome.cs | 4 +- .../DataGrid/DataGrid.cs | 11 +- .../DataGrid/DataGridToken.cs | 1 - .../Localization/DataGridLangResourceKind.cs | 1 - .../DataGrid/Localization/DataGridText.cs | 24 +- .../DataGrid/Themes/DataGridTheme.axaml | 3 +- .../TKCode.Arc.AdvancedCharts.csproj | 4 +- .../Blocks/Ai/AgentMemory/AgentMemoryToken.cs | 1 - .../AgentMemory/Themes/AgentMemoryTheme.axaml | 3 +- .../Blocks/Ai/AiAnswer/AiAnswerToken.cs | 1 - .../Ai/AiAnswer/Themes/AiAnswerTheme.axaml | 3 +- .../Blocks/Ai/AiChat/AiChatToken.cs | 1 - .../Blocks/Ai/AiChat/Themes/AiChatTheme.axaml | 3 +- .../Blocks/Ai/AiComposer/AiComposerToken.cs | 1 - .../AiComposer/Themes/AiComposerTheme.axaml | 3 +- .../Blocks/Ai/AiSidePanel/AiSidePanelToken.cs | 1 - .../AiSidePanel/Themes/AiSidePanelTheme.axaml | 3 +- .../DocumentExtraction.Run.cs | 2 +- .../DocumentExtractionToken.cs | 1 - .../Themes/DocumentExtractionTheme.axaml | 3 +- .../Blocks/Ai/ModelArena/ModelArenaToken.cs | 1 - .../ModelArena/Themes/ModelArenaTheme.axaml | 3 +- .../Blocks/Ai/ModelMenu/ModelMenuPresence.cs | 31 +- .../Blocks/Ai/ModelMenu/ModelMenuToken.cs | 1 - .../Ai/ModelMenu/Themes/ModelMenuTheme.axaml | 3 +- .../Blocks/Ai/ModelPicker/ModelPickerToken.cs | 1 - .../ModelPicker/Themes/ModelPickerTheme.axaml | 3 +- .../Blocks/Ai/ModelPill/ModelPillToken.cs | 1 - .../Ai/ModelPill/Themes/ModelPillTheme.axaml | 3 +- .../Ai/ModelSummary/ModelSummaryToken.cs | 1 - .../Themes/ModelSummaryTheme.axaml | 3 +- .../Ai/PromptStudio/PromptStudioToken.cs | 1 - .../Themes/PromptStudioTheme.axaml | 3 +- .../_Shared/AiAnswerActions/AiActionsPopup.cs | 14 +- .../Themes/AiAnswerActionsTheme.axaml | 3 +- .../Themes/AiAnswerCitationTheme.axaml | 3 +- .../Themes/AiAnswerCodeBlockTheme.axaml | 3 +- .../Themes/AiAnswerDocumentTheme.axaml | 3 +- .../Themes/AiAnswerTableTheme.axaml | 3 +- .../Themes/AiAttachmentChipTheme.axaml | 3 +- .../Themes/AiAttachmentTrayTheme.axaml | 3 +- .../Themes/AiChatEmptyStateTheme.axaml | 3 +- .../Themes/AiChatSuggestionListTheme.axaml | 3 +- .../AiConversationMenuButtonTheme.axaml | 3 +- .../AiCounter/Themes/AiCounterTheme.axaml | 3 +- .../AiDiffTable/Themes/AiDiffTableTheme.axaml | 3 +- .../AiDiffText/Themes/AiDiffTextTheme.axaml | 3 +- .../Themes/AiFeedbackReasonPickerTheme.axaml | 3 +- .../Themes/AiFollowScrollerTheme.axaml | 3 +- .../Themes/AiMessageActionsTheme.axaml | 3 +- .../Themes/AiMetricLineTheme.axaml | 3 +- .../AiModelLine/Themes/AiModelLineTheme.axaml | 3 +- .../AiPanelDock/Themes/AiPanelDockTheme.axaml | 3 +- .../Themes/AiPanelPageViewTheme.axaml | 3 +- .../Themes/AiReasoningDisclosureTheme.axaml | 3 +- .../Themes/AiRegenerateMenuTheme.axaml | 3 +- .../Themes/AiRelatedQuestionsTheme.axaml | 3 +- .../Themes/AiCitationCardTheme.axaml | 3 +- .../AiReplyText/Themes/AiReplyTextTheme.axaml | 3 +- .../AiRunMeta/Themes/AiRunMetaTheme.axaml | 3 +- .../Themes/AiRunStatusLineTheme.axaml | 3 +- .../Themes/AiSendButtonTheme.axaml | 3 +- .../Themes/AiSourceStackTheme.axaml | 3 +- .../Themes/AiSourcesRowTheme.axaml | 3 +- .../Themes/AiStatusNoteTheme.axaml | 3 +- .../Themes/AiStatusPillTheme.axaml | 3 +- .../AiSuggestionPop/AiSuggestionPopup.cs | 25 +- .../Themes/AiSuggestionPopTheme.axaml | 3 +- .../AiToolChip/Themes/AiToolChipTheme.axaml | 3 +- .../Themes/AiToolDetailTheme.axaml | 3 +- .../Themes/AiToolToggleTheme.axaml | 3 +- .../AiUndoPill/Themes/AiUndoPillTheme.axaml | 3 +- .../Themes/AiUserBubbleTheme.axaml | 3 +- .../Themes/AiVersionPagerTheme.axaml | 3 +- .../Themes/ArenaAnswerViewTheme.axaml | 3 +- .../Themes/ArenaIdentityTheme.axaml | 3 +- .../ArenaPane/Themes/ArenaPaneTheme.axaml | 3 +- .../Themes/ArenaPaneTabsTheme.axaml | 3 +- .../Themes/ArenaPromptFormTheme.axaml | 3 +- .../Themes/ArenaStandingsTheme.axaml | 3 +- .../Themes/ArenaVoteBarTheme.axaml | 3 +- .../Themes/ConfidenceBarsTheme.axaml | 3 +- .../Themes/ConfidenceIndicatorTheme.axaml | 3 +- .../Themes/DocumentPageTheme.axaml | 3 +- .../Themes/DocumentViewerTheme.axaml | 3 +- .../Themes/ExtractionFieldRowTheme.axaml | 3 +- .../Themes/ExtractionInputTheme.axaml | 3 +- .../Themes/ExtractionJsonViewTheme.axaml | 3 +- .../Themes/ExtractionTableGridTheme.axaml | 3 +- .../Themes/HighlightedTextTheme.axaml | 3 +- .../Themes/MemoryCapturedPillTheme.axaml | 3 +- .../MemoryMeter/Themes/MemoryMeterTheme.axaml | 3 +- .../Themes/PillFilterBarTheme.axaml | 3 +- .../Themes/PromptHeaderPillTheme.axaml | 3 +- .../PromptKbd/Themes/PromptKbdTheme.axaml | 3 +- .../Themes/PromptModelMenuTheme.axaml | 3 +- .../Themes/PromptModelPillTheme.axaml | 3 +- .../PromptPopover/PromptPopoverPresence.cs | 11 +- .../Themes/PromptPopoverTheme.axaml | 3 +- .../Themes/PromptRunButtonTheme.axaml | 3 +- .../Themes/PromptRunPanelTheme.axaml | 3 +- .../Themes/PromptTemplateEditorTheme.axaml | 3 +- .../Themes/PromptTestCasesTheme.axaml | 3 +- .../Themes/PromptTokenCountTheme.axaml | 3 +- .../Themes/PromptVariableFieldsTheme.axaml | 3 +- .../Themes/PromptVersionMenuTheme.axaml | 3 +- .../Themes/PromptVersionPillTheme.axaml | 3 +- .../Themes/RankedBarListTheme.axaml | 3 +- .../RowEditor/Themes/RowEditorTheme.axaml | 3 +- .../Themes/SourceLinkOverlayTheme.axaml | 3 +- .../Themes/UndoCountdownButtonTheme.axaml | 3 +- .../ChoroplethExplorerToken.cs | 1 - .../Themes/ChoroplethExplorerTheme.axaml | 3 +- .../CohortRetention/CohortRetentionToken.cs | 1 - .../Themes/CohortRetentionTheme.axaml | 3 +- .../ConnectedScatter/ConnectedScatterToken.cs | 1 - .../Themes/ConnectedScatterTheme.axaml | 3 +- .../HorizonChart/HorizonChartToken.cs | 1 - .../Themes/HorizonChartTheme.axaml | 3 +- .../Analytics/JourneyFlow/JourneyFlowToken.cs | 1 - .../JourneyFlow/Themes/JourneyFlowTheme.axaml | 3 +- .../KpiDrilldown/KpiDrilldownToken.cs | 1 - .../Themes/KpiDrilldownTheme.axaml | 3 +- .../MetricExplorer/MetricExplorerToken.cs | 1 - .../Themes/MetricExplorerTheme.axaml | 3 +- .../MetricsDashboard/MetricsDashboardToken.cs | 1 - .../Themes/MetricsDashboardTheme.axaml | 3 +- .../MrrWaterfall/MrrWaterfallToken.cs | 1 - .../Themes/MrrWaterfallTheme.axaml | 3 +- .../RevenueExplorer/RevenueExplorerToken.cs | 1 - .../Themes/RevenueExplorerTheme.axaml | 3 +- .../Themes/UptimeStatusTheme.axaml | 3 +- .../UptimeStatus/UptimeStatusToken.cs | 1 - .../Themes/UsageForecastTheme.axaml | 3 +- .../UsageForecast/UsageForecastToken.cs | 1 - .../_Shared/Themes/AnalyticsDeltaTheme.axaml | 3 +- .../Themes/AnalyticsSegmentButtonTheme.axaml | 3 +- .../CommandPaletteDialogToken.cs | 1 - .../CommandPalette/CommandPaletteToken.cs | 1 - .../Themes/CommandPaletteDialogTheme.axaml | 3 +- .../Themes/CommandPaletteTheme.axaml | 3 +- .../AppShell/EmptyStates/EmptyStatesToken.cs | 1 - .../EmptyStates/Themes/EmptyStatesTheme.axaml | 5 +- .../NotificationCenterPopup.cs | 20 +- .../NotificationCenterRow.cs | 1 - .../NotificationCenterToken.cs | 1 - .../NotificationCenter/NotificationItem.cs | 1 - .../Themes/NotificationCenterTheme.axaml | 3 +- .../AppShell/PageHeader/PageHeaderTabToken.cs | 1 - .../AppShell/PageHeader/PageHeaderToken.cs | 1 - .../Themes/PageHeaderTabTheme.axaml | 3 +- .../PageHeader/Themes/PageHeaderTheme.axaml | 3 +- .../SearchResults/SearchResultItem.cs | 1 - .../SearchResults/SearchResultsToken.cs | 1 - .../Themes/SearchResultsTheme.axaml | 3 +- .../Blocks/AppShell/Sidebar/SidebarToken.cs | 1 - .../Sidebar/Themes/SidebarTheme.axaml | 3 +- .../Auth/LoginCentered/LoginCenteredToken.cs | 1 - .../Themes/LoginCenteredTheme.axaml | 3 +- .../LoginImmersive/LoginImmersiveToken.cs | 1 - .../Themes/LoginImmersiveTheme.axaml | 3 +- .../Blocks/Auth/LoginSplit/LoginSplitParts.cs | 1 - .../Blocks/Auth/LoginSplit/LoginSplitToken.cs | 1 - .../Blocks/Auth/LoginSplit/LoginSplitTypes.cs | 1 - .../LoginSplit/Themes/LoginSplitTheme.axaml | 3 +- .../Auth/MultiStepForm/MultiStepFormToken.cs | 1 - .../Auth/MultiStepForm/MultiStepSwap.cs | 2 +- .../Themes/MultiStepFormTheme.axaml | 3 +- .../OnboardingIllustrationToken.cs | 1 - .../Themes/OnboardingIllustrationTheme.axaml | 5 +- .../Blocks/Auth/OtpInput/OtpInputToken.cs | 1 - .../Auth/OtpInput/Themes/OtpInputTheme.axaml | 3 +- .../Blocks/Auth/SignIn/SignInToken.cs | 1 - .../Auth/SignIn/Themes/SignInTheme.axaml | 3 +- .../Blocks/Auth/SignupForm/SignupFormToken.cs | 1 - .../SignupForm/Themes/SignupFormTheme.axaml | 3 +- .../Blocks/Developer/ApiKeys/ApiKeysToken.cs | 1 - .../ApiKeys/Themes/ApiKeysTheme.axaml | 3 +- .../EnvVariables/EnvVariablesToken.cs | 1 - .../Themes/EnvVariablesTheme.axaml | 3 +- .../Integrations/IntegrationsToken.cs | 1 - .../Themes/IntegrationsTheme.axaml | 3 +- .../Webhooks/Themes/WebhooksTheme.axaml | 3 +- .../Developer/Webhooks/WebhooksToken.cs | 1 - .../Themes/DeveloperMorphPanelTheme.axaml | 3 +- .../Themes/DeveloperSecretFieldTheme.axaml | 3 +- .../BudgetVariance/BudgetVarianceToken.cs | 1 - .../BudgetVariance/BudgetVarianceTypes.cs | 1 - .../Themes/BudgetVarianceTheme.axaml | 3 +- .../MarketTerminal/MarketTerminalToken.cs | 1 - .../Themes/MarketTerminalTheme.axaml | 3 +- .../Themes/FinanceCountingTextTheme.axaml | 3 +- .../CtaSection/CtaSectionToken.cs | 1 - .../CtaSection/Themes/CtaSectionTheme.axaml | 3 +- .../NewsletterSignup/NewsletterSignupToken.cs | 1 - .../Themes/NewsletterSignupTheme.axaml | 3 +- .../SiteFooter/SiteFooterToken.cs | 1 - .../SiteFooter/Themes/SiteFooterTheme.axaml | 3 +- .../SiteHeader/SiteHeaderToken.cs | 1 - .../SiteHeader/Themes/SiteHeaderTheme.axaml | 5 +- .../Blocks/HeadersFooters/_Shared/SiteSwap.cs | 2 +- .../LineCharts/LineReplay/LineReplayToken.cs | 1 - .../LineReplay/Themes/LineReplayTheme.axaml | 3 +- .../SmallMultiples/SmallMultiplesToken.cs | 1 - .../Themes/SmallMultiplesTheme.axaml | 3 +- .../Blocks/Media/FileUpload/FileUploadSwap.cs | 2 +- .../Media/FileUpload/FileUploadToken.cs | 1 - .../FileUpload/Themes/FileUploadTheme.axaml | 3 +- .../Media/MediaPlayer/MediaPlayerToken.cs | 1 - .../MediaPlayer/Themes/MediaPlayerTheme.axaml | 3 +- .../People/InvitePeople/InvitePeopleParts.cs | 1 - .../People/InvitePeople/InvitePeopleToken.cs | 1 - .../People/InvitePeople/InvitePeopleTypes.cs | 1 - .../Themes/InvitePeopleTheme.axaml | 3 +- .../Blocks/People/ProfilePage/ProfilePage.cs | 9 +- .../People/ProfilePage/ProfilePageToken.cs | 1 - .../People/ProfilePage/ProfilePageTypes.cs | 1 - .../People/ProfilePage/ProfilePageViews.cs | 4 +- .../ProfilePage/Themes/ProfilePageTheme.axaml | 6 +- .../RolesPermissions/RolesPermissions.Save.cs | 2 +- .../RolesPermissions/RolesPermissionsGrid.cs | 4 +- .../RolesPermissions/RolesPermissionsToken.cs | 1 - .../Themes/RolesPermissionsTheme.axaml | 3 +- .../TeamDirectory/TeamDirectoryPerson.cs | 1 - .../TeamDirectory/TeamDirectoryToken.cs | 1 - .../Themes/TeamDirectoryTheme.axaml | 3 +- .../People/TeamMembers/TeamMembersToken.cs | 1 - .../People/TeamMembers/TeamMembersTypes.cs | 1 - .../TeamMembers/Themes/TeamMembersTheme.axaml | 3 +- .../AvailabilityPickerToken.cs | 1 - .../Themes/AvailabilityPickerTheme.axaml | 3 +- .../Projects/KanbanBoard/KanbanBoardToken.cs | 1 - .../KanbanBoard/Themes/KanbanBoardTheme.axaml | 3 +- .../ProjectBoard/ProjectBoardToken.cs | 1 - .../Themes/ProjectBoardTheme.axaml | 3 +- .../ReleaseReadiness/ReleaseReadinessToken.cs | 1 - .../Themes/ReleaseReadinessTheme.axaml | 3 +- .../Themes/WeekCalendarTheme.axaml | 3 +- .../WeekCalendar/WeekCalendarToken.cs | 1 - .../Themes/ProjectsProgressLineTheme.axaml | 3 +- .../BillingOverview/BillingOverviewToken.cs | 1 - .../Themes/BillingOverviewTheme.axaml | 3 +- .../Settings/CancelFlow/CancelFlowToken.cs | 1 - .../CancelFlow/Themes/CancelFlowTheme.axaml | 3 +- .../SecuritySettings/SecuritySettingsToken.cs | 1 - .../Themes/SecuritySettingsTheme.axaml | 3 +- .../SettingsCommand/SettingsCommandToken.cs | 1 - .../Themes/SettingsCommandTheme.axaml | 3 +- .../SettingsPage/SettingsPageNavItemToken.cs | 1 - .../SettingsPage/SettingsPageToken.cs | 1 - .../SettingsPage/SettingsSectionToken.cs | 1 - .../Themes/SettingsPageTheme.axaml | 3 +- .../Themes/UsageBillingTheme.axaml | 3 +- .../UsageBilling/UsageBillingToken.cs | 1 - .../Settings/_Shared/SettingsGroupToken.cs | 1 - .../Settings/_Shared/SettingsRowToken.cs | 1 - .../_Shared/Themes/SettingsGroupTheme.axaml | 3 +- .../_Shared/Themes/SettingsRowTheme.axaml | 3 +- .../ActivityTerrain/ActivityTerrainToken.cs | 1 - .../Themes/ActivityTerrainTheme.axaml | 3 +- .../CustomerGalaxy/CustomerGalaxyToken.cs | 1 - .../Themes/CustomerGalaxyTheme.axaml | 3 +- .../ExplodedView/ExplodedViewToken.cs | 1 - .../Themes/ExplodedViewTheme.axaml | 3 +- .../LayoutMorph/LayoutMorphToken.cs | 1 - .../LayoutMorph/Themes/LayoutMorphTheme.axaml | 3 +- .../RevenueGlobe/RevenueGlobeToken.cs | 1 - .../Themes/RevenueGlobeTheme.axaml | 3 +- .../SemanticZoom/SemanticZoomToken.cs | 1 - .../Themes/SemanticZoomPanelTheme.axaml | 3 +- .../Themes/SemanticZoomTheme.axaml | 3 +- .../_Shared/ShowpieceHoverCardToken.cs | 1 - .../Showpieces/_Shared/ShowpieceNumber.cs | 2 +- .../_Shared/ShowpieceZoomControlsToken.cs | 1 - .../Themes/ShowpieceHoverCardTheme.axaml | 3 +- .../Themes/ShowpieceZoomControlsTheme.axaml | 3 +- .../BlocksBuilderExtensions.cs | 13 +- .../TKCode.Arc.Blocks.csproj | 4 +- src/TKCode.Arc.Core/Motion/ArcMotion.cs | 8 +- .../Primitives/ArcContentSwap.cs | 4 +- .../Primitives/Images/ArcImage.cs | 345 ++ .../Primitives/Images/ArcImageCache.cs | 281 + .../Primitives/Images/ArcImageLoader.cs | 100 + .../Primitives/Images/ImageSource.cs | 178 + .../Primitives/Modal/ArcOverlayHost.cs | 116 + .../Primitives/Modal/ModalPresence.cs | 18 +- .../Primitives/Popups/ArcPopup.cs | 792 ++- .../Primitives/Popups/ArcPopupFrame.cs | 111 + .../Primitives/Popups/ArcPopupMotions.cs | 119 +- .../Primitives/Popups/ArcZoom.cs | 86 - .../Primitives/RollingDigits.cs | 2 +- src/TKCode.Arc.Core/TKCode.Arc.Core.csproj | 6 +- src/TKCode.Arc.Core/Theme/ArcDesign.cs | 4 +- src/TKCode.Arc.Core/Theme/ArcDesignToken.cs | 13 +- src/TKCode.Arc.Core/Theme/ArcPalette.cs | 13 +- .../Theme/ArcPaletteGenerator.cs | 205 + src/TKCode.Arc.Core/Theme/ArcThemeConfig.cs | 101 +- src/TKCode.Arc.Core/Theme/ArcTokens.cs | 31 +- .../Theme/Base/ArcBaseTheme.cs | 154 + .../Theme/Base/ItemsControlBaseTheme.axaml | 19 + .../Theme/Base/PathIconBaseTheme.axaml | 24 + .../Theme/Base/PopupHostBaseTheme.axaml | 79 + .../Theme/Base/ScrollViewerAutoHide.cs | 218 + .../Theme/Base/ScrollViewerBaseTheme.axaml | 184 + .../Base/TextSelectionHandleBaseTheme.axaml | 51 + .../Theme/Base/WindowBaseTheme.axaml | 38 + .../ThemeManagerBuilderExtensions.cs | 16 +- src/TKCode.Arc.Fonts/ArcFontCollections.cs | 2 +- .../Inputs/Button/ButtonToken.cs | 4 +- .../Inputs/Button/Themes/ButtonTheme.axaml | 3 +- .../Inputs/Checkbox/CheckboxToken.cs | 4 +- .../Checkbox/Themes/CheckboxTheme.axaml | 3 +- .../Inputs/IconButton/IconButtonToken.cs | 1 - .../IconButton/Themes/IconButtonTheme.axaml | 3 +- .../Inputs/Radio/RadioToken.cs | 1 - .../Inputs/Radio/Themes/RadioTheme.axaml | 3 +- .../Inputs/Segmented/SegmentedItemToken.cs | 1 - .../Inputs/Segmented/SegmentedToken.cs | 1 - .../Segmented/Themes/SegmentedTheme.axaml | 3 +- .../Inputs/Switch/SwitchToken.cs | 4 +- .../Inputs/Switch/Themes/SwitchTheme.axaml | 3 +- .../Inputs/TextField/TextFieldToken.cs | 1 - .../TextField/Themes/TextFieldTheme.axaml | 3 +- .../Lists/ListGroup/ListGroupToken.cs | 1 - .../ListGroup/Themes/ListGroupTheme.axaml | 3 +- .../Lists/ListItem/ListItemToken.cs | 1 - .../Lists/ListItem/Themes/ListItemTheme.axaml | 3 +- .../Lists/PullToRefresh/PullToRefreshToken.cs | 1 - .../Themes/PullToRefreshTheme.axaml | 3 +- .../MobileBuilderExtensions.cs | 11 +- .../ActionSheet/ActionSheetItemToken.cs | 1 - .../Overlays/ActionSheet/ActionSheetToken.cs | 1 - .../Themes/ActionSheetItemTheme.axaml | 3 +- .../ActionSheet/Themes/ActionSheetTheme.axaml | 3 +- .../Overlays/Dialog/Dialog.cs | 2 +- .../Overlays/Dialog/DialogToken.cs | 4 +- .../Overlays/Dialog/Themes/DialogTheme.axaml | 3 +- src/TKCode.Arc.Mobile/Overlays/Sheet/Sheet.cs | 2 +- .../Overlays/Sheet/SheetToken.cs | 1 - .../Overlays/Sheet/Themes/SheetTheme.axaml | 3 +- .../Overlays/Toast/Themes/ToastTheme.axaml | 3 +- src/TKCode.Arc.Mobile/Overlays/Toast/Toast.cs | 2 +- .../Overlays/Toast/ToastToken.cs | 4 +- .../Shell/Navbar/NavbarToken.cs | 1 - .../Shell/Navbar/Themes/NavbarTheme.axaml | 3 +- .../NavigationStack/NavigationStackToken.cs | 1 - .../Themes/NavigationStackTheme.axaml | 3 +- src/TKCode.Arc.Mobile/Shell/Page/PageToken.cs | 1 - .../Shell/Page/Themes/PageTheme.axaml | 3 +- .../Shell/Tabbar/TabbarItemToken.cs | 1 - .../Shell/Tabbar/TabbarToken.cs | 1 - .../Shell/Tabbar/Themes/TabbarTheme.axaml | 3 +- .../Shell/Toolbar/Themes/ToolbarTheme.axaml | 3 +- .../Shell/Toolbar/ToolbarToken.cs | 1 - .../TKCode.Arc.Mobile.csproj | 4 +- .../Ai/AgentPlan/AgentPlanRow.cs | 2 +- .../Ai/AgentPlan/AgentPlanToken.cs | 1 - .../Ai/AgentPlan/Themes/AgentPlanTheme.axaml | 3 +- .../Ai/AgentStatus/AgentStatusToken.cs | 1 - .../AgentStatus/Themes/AgentStatusTheme.axaml | 3 +- .../Ai/AgentTimeline/AgentTimelineToken.cs | 1 - .../Themes/AgentTimelineTheme.axaml | 3 +- .../Ai/ContextWindow/ContextWindowToken.cs | 1 - .../Themes/ContextWindowTheme.axaml | 3 +- .../ToolCall/Themes/ToolCallGroupTheme.axaml | 3 +- .../Ai/ToolCall/Themes/ToolCallTheme.axaml | 3 +- .../Ai/ToolCall/ToolCallBody.cs | 2 +- .../Ai/ToolCall/ToolCallGroupToken.cs | 1 - .../Ai/ToolCall/ToolCallHeadPanel.cs | 2 +- .../Ai/ToolCall/ToolCallStatusGlyph.cs | 2 +- .../Ai/ToolCall/ToolCallText.cs | 2 +- .../Ai/ToolCall/ToolCallToken.cs | 1 - .../Cursor/DotGrid/DotGridToken.cs | 1 - .../Cursor/DotGrid/Themes/DotGridTheme.axaml | 3 +- .../Cursor/GravityWell/GravityWellToken.cs | 1 - .../GravityWell/Themes/GravityWellTheme.axaml | 3 +- .../Cursor/InkFluid/InkFluidToken.cs | 1 - .../InkFluid/Themes/InkFluidTheme.axaml | 3 +- .../Illustrations/AgentLoop/AgentLoopToken.cs | 1 - .../AgentLoop/Themes/AgentLoopTheme.axaml | 3 +- .../MixtureOfExperts/MixtureOfExpertsToken.cs | 1 - .../Themes/MixtureOfExpertsTheme.axaml | 3 +- .../PromptCache/PromptCacheToken.cs | 1 - .../PromptCache/Themes/PromptCacheTheme.axaml | 3 +- .../ReasoningTree/ReasoningTreeToken.cs | 1 - .../Themes/ReasoningTreeTheme.axaml | 3 +- .../SpeculativeDecodingToken.cs | 1 - .../Themes/SpeculativeDecodingTheme.axaml | 3 +- .../SpecialBuilderExtensions.cs | 13 +- .../TKCode.Arc.Special.csproj | 4 +- .../ArcCornerPresets.cs | 71 + .../ArcCornerScope.cs | 30 - .../ArcCornersBuilderExtensions.cs | 24 + .../ArcCornersExtensions.cs | 83 - src/TKCode.Arc.Themes.Corners/CornerValues.cs | 35 + .../ArcPixelBuilderExtensions.cs | 24 +- .../ArcPixelPresets.cs | 261 + src/TKCode.Arc.Themes.Pixel/ArcPixelScope.cs | 27 - src/TKCode.Arc.Themes.Pixel/ArcPixelTheme.cs | 281 - .../TKCode.Arc.Themes.Pixel.csproj | 2 - .../Themes/ArcPixel.xml | 113 - .../Themes/ArcPixelDark.xml | 113 - .../TKCode.Arc.Workflow.csproj | 4 +- .../WorkflowBuilderExtensions.cs | 13 +- .../Localization/WorkflowLangResourceKind.cs | 1 - .../Localization/WorkflowText.cs | 81 +- .../Themes/WorkflowCanvasTheme.axaml | 3 +- .../Themes/WorkflowNodeCardTheme.axaml | 3 +- .../WorkflowCanvas/WorkflowCanvasToken.cs | 1 - .../WorkflowCanvas/WorkflowNodeCard.cs | 8 +- .../WorkflowCanvas/WorkflowNodeCardToken.cs | 1 - .../Themes/WorkflowMinimapTheme.axaml | 3 +- .../WorkflowMinimap/WorkflowMinimapToken.cs | 1 - .../Themes/WorkflowZoomControlsTheme.axaml | 3 +- .../WorkflowZoomControls.cs | 7 +- .../WorkflowZoomControlsToken.cs | 1 - .../Activity/ChatThread/ChatComposer.cs | 5 +- .../Activity/ChatThread/ChatComposerToken.cs | 1 - .../Activity/ChatThread/ChatThreadToken.cs | 1 - .../Activity/ChatThread/ChatThreadTypes.cs | 1 - .../Activity/ChatThread/ChatThreadViews.cs | 7 +- .../ChatThread/Themes/ChatComposerTheme.axaml | 3 +- .../ChatThread/Themes/ChatThreadTheme.axaml | 3 +- .../CommentThread/CommentThreadToken.cs | 1 - .../Themes/CommentThreadTheme.axaml | 3 +- .../Timeline/Themes/TimelineTheme.axaml | 3 +- .../Activity/Timeline/TimelineToken.cs | 1 - .../Activity/_Shared/ActivityAvatar.cs | 1 - .../_Shared/ActivityReactionChipToken.cs | 1 - .../Themes/ActivityReactionChipTheme.axaml | 3 +- src/TKCode.Arc/Avatars/Avatar/Avatar.cs | 14 +- src/TKCode.Arc/Avatars/Avatar/AvatarToken.cs | 1 - .../Avatars/Avatar/Themes/AvatarTheme.axaml | 20 +- .../Avatars/AvatarGroup/AvatarGroupMember.cs | 1 - .../Avatars/AvatarGroup/AvatarGroupToken.cs | 1 - .../AvatarGroup/Themes/AvatarGroupTheme.axaml | 3 +- src/TKCode.Arc/Avatars/Badge/BadgeToken.cs | 1 - .../Avatars/Badge/Themes/BadgeTheme.axaml | 3 +- .../Buttons/ActionButton/ActionButtonToken.cs | 1 - .../Themes/ActionButtonTheme.axaml | 3 +- .../Buttons/ActionSwap/ActionSwapToken.cs | 1 - .../ActionSwap/Themes/ActionSwapTheme.axaml | 3 +- src/TKCode.Arc/Buttons/Button/ButtonToken.cs | 1 - .../Buttons/Button/Themes/ButtonTheme.axaml | 3 +- .../ButtonGroup/ButtonGroupItemToken.cs | 1 - .../Buttons/ButtonGroup/ButtonGroupToken.cs | 1 - .../Themes/ButtonGroupItemTheme.axaml | 3 +- .../ButtonGroup/Themes/ButtonGroupTheme.axaml | 3 +- .../Buttons/ConfirmMorph/ConfirmMorphToken.cs | 1 - .../Themes/ConfirmMorphTheme.axaml | 3 +- .../Buttons/CopyButton/CopyButtonToken.cs | 1 - .../CopyButton/Themes/CopyButtonTheme.axaml | 3 +- .../ExpandingButtonGroupItemToken.cs | 1 - .../ExpandingButtonGroupToken.cs | 1 - .../ExpandingButtonGroupItemTheme.axaml | 3 +- .../Themes/ExpandingButtonGroupTheme.axaml | 3 +- .../FloatingButtonGroupItemToken.cs | 1 - .../FloatingButtonGroupSeparatorToken.cs | 1 - .../FloatingButtonGroupToken.cs | 1 - .../Themes/FloatingButtonGroupItemTheme.axaml | 3 +- .../FloatingButtonGroupSeparatorTheme.axaml | 3 +- .../Themes/FloatingButtonGroupTheme.axaml | 3 +- .../Buttons/IconMorph/IconMorphToken.cs | 1 - .../IconMorph/Themes/IconMorphTheme.axaml | 3 +- .../Buttons/SplitButton/SplitButtonToken.cs | 1 - .../SplitButton/Themes/SplitButtonTheme.axaml | 3 +- .../Buttons/ThemeSwitcher/ThemeSwitcher.cs | 7 +- .../ThemeSwitcher/ThemeSwitcherToken.cs | 1 - .../ThemeSwitcher/ThemeSwitcherTransition.cs | 89 +- .../Themes/ThemeSwitcherTheme.axaml | 3 +- src/TKCode.Arc/Cards/Card/CardToken.cs | 1 - .../Cards/Card/Themes/CardTheme.axaml | 3 +- .../Cards/EmptyState/EmptyStateToken.cs | 1 - .../EmptyState/Themes/EmptyStateTheme.axaml | 5 +- .../Cards/MetricCard/MetricCardToken.cs | 1 - .../MetricCard/Themes/MetricCardTheme.axaml | 5 +- .../ActivityHeatmap/ActivityHeatmapToken.cs | 1 - .../Themes/ActivityHeatmapTheme.axaml | 3 +- .../AnimatedCounter/AnimatedCounterToken.cs | 1 - .../Themes/AnimatedCounterTheme.axaml | 3 +- .../Charts/BarChart/BarChartToken.cs | 1 - .../BarChart/Themes/BarChartTheme.axaml | 3 +- .../Charts/BrushChart/BrushChartToken.cs | 1 - .../BrushChart/Themes/BrushChartTheme.axaml | 3 +- .../Charts/DonutChart/DonutChartToken.cs | 1 - .../DonutChart/Themes/DonutChartTheme.axaml | 3 +- src/TKCode.Arc/Charts/Gauge/GaugeToken.cs | 1 - .../Charts/Gauge/Themes/GaugeTheme.axaml | 3 +- .../Charts/LineChart/LineChartToken.cs | 1 - .../LineChart/Themes/LineChartTheme.axaml | 3 +- .../Charts/Ridgeline/RidgelineToken.cs | 1 - .../Ridgeline/Themes/RidgelineTheme.axaml | 3 +- .../Charts/SlopeChart/SlopeChartToken.cs | 1 - .../SlopeChart/Themes/SlopeChartTheme.axaml | 3 +- .../Charts/Sparkline/SparklineToken.cs | 1 - .../Sparkline/Themes/SparklineTheme.axaml | 3 +- .../Charts/Streamgraph/StreamgraphToken.cs | 1 - .../Streamgraph/Themes/StreamgraphTheme.axaml | 3 +- .../Charts/Treemap/Themes/TreemapTheme.axaml | 3 +- .../Charts/Treemap/TreemapCrumbs.cs | 2 +- src/TKCode.Arc/Charts/Treemap/TreemapToken.cs | 1 - .../WaffleChart/Themes/WaffleChartTheme.axaml | 3 +- .../Charts/WaffleChart/WaffleChartToken.cs | 1 - src/TKCode.Arc/Content/Markdown/Markdown.cs | 13 +- .../Content/Markdown/MarkdownToken.cs | 1 - .../Markdown/Themes/MarkdownTheme.axaml | 3 +- .../Editors/FileDropzone/FileDropzoneToken.cs | 1 - .../Themes/FileDropzoneTheme.axaml | 3 +- .../RichTextEditor/RichTextEditorToken.cs | 1 - .../Themes/RichTextEditorTheme.axaml | 3 +- .../Editors/SignaturePad/SignaturePadToken.cs | 1 - .../Themes/SignaturePadTheme.axaml | 3 +- .../Expand/Accordion/AccordionItemToken.cs | 1 - .../Expand/Accordion/AccordionToken.cs | 1 - .../Accordion/Themes/AccordionItemTheme.axaml | 3 +- .../Accordion/Themes/AccordionTheme.axaml | 3 +- .../ExpandableCard/ExpandableCardToken.cs | 1 - .../Themes/ExpandableCardTheme.axaml | 3 +- .../ExpandableText/ExpandableTextToken.cs | 1 - .../Themes/ExpandableTextTheme.axaml | 3 +- .../ResizablePanels/ResizablePanelToken.cs | 1 - .../ResizablePanels/ResizablePanelsToken.cs | 1 - .../Themes/ResizablePanelTheme.axaml | 3 +- .../Themes/ResizablePanelsTheme.axaml | 3 +- .../Expand/ScrollArea/ScrollAreaToken.cs | 1 - .../ScrollArea/Themes/ScrollAreaTheme.axaml | 3 +- .../HoldToConfirm/HoldToConfirmToken.cs | 1 - .../Themes/HoldToConfirmTheme.axaml | 3 +- .../Gestures/Reactions/ReactionsToken.cs | 1 - .../Reactions/Themes/ReactionsTheme.axaml | 3 +- .../SwipeActions/SwipeActionsRowToken.cs | 1 - .../SwipeActions/SwipeActionsToken.cs | 1 - .../Themes/SwipeActionsRowTheme.axaml | 3 +- .../Themes/SwipeActionsTheme.axaml | 3 +- .../Illustrations/DataFlow/DataFlowToken.cs | 1 - .../DataFlow/Themes/DataFlowTheme.axaml | 3 +- .../MultiRegionFailoverToken.cs | 1 - .../Themes/MultiRegionFailoverTheme.axaml | 3 +- .../PaymentFlow/PaymentFlowToken.cs | 1 - .../PaymentFlow/Themes/PaymentFlowTheme.axaml | 3 +- .../Themes/VectorSearchTheme.axaml | 3 +- .../VectorSearch/VectorSearchToken.cs | 1 - .../Themes/IllustrationPlayButtonTheme.axaml | 3 +- .../Media/CardStack/CardStackToken.cs | 1 - .../CardStack/Themes/CardStackTheme.axaml | 3 +- .../Media/Carousel/CarouselToken.cs | 1 - .../Media/Carousel/Themes/CarouselTheme.axaml | 3 +- .../Media/ImageCompare/ImageCompare.cs | 28 +- .../Media/ImageCompare/ImageCompareToken.cs | 1 - .../Themes/ImageCompareTheme.axaml | 32 +- .../Media/_Shared/MediaButtonToken.cs | 1 - .../_Shared/Themes/MediaButtonTheme.axaml | 3 +- .../Menus/ContextMenu/ContextMenuToken.cs | 1 - .../ContextMenu/Themes/ContextMenuTheme.axaml | 3 +- .../Menus/DropdownMenu/DropdownMenuToken.cs | 1 - .../Themes/DropdownMenuTheme.axaml | 3 +- .../Menus/UserMenu/Themes/UserMenuTheme.axaml | 3 +- src/TKCode.Arc/Menus/UserMenu/UserMenu.cs | 1 - .../Menus/UserMenu/UserMenuIdentity.cs | 1 - .../Menus/UserMenu/UserMenuToken.cs | 1 - .../Menus/_Shared/MenuEntryLabelToken.cs | 1 - .../Menus/_Shared/MenuEntrySeparatorToken.cs | 1 - .../Menus/_Shared/MenuEntryToken.cs | 1 - .../Menus/_Shared/MenuSurfacePopup.cs | 142 +- .../Menus/_Shared/MenuSurfaceToken.cs | 1 - .../_Shared/Themes/MenuEntryLabelTheme.axaml | 3 +- .../Themes/MenuEntrySeparatorTheme.axaml | 3 +- .../Menus/_Shared/Themes/MenuEntryTheme.axaml | 3 +- .../_Shared/Themes/MenuSurfaceTheme.axaml | 3 +- src/TKCode.Arc/Messages/Alert/AlertToken.cs | 1 - .../Messages/Alert/Themes/AlertTheme.axaml | 5 +- .../AnnouncementBar/AnnouncementBarToken.cs | 1 - .../AnnouncementBarViewport.cs | 2 +- .../Themes/AnnouncementBarTheme.axaml | 9 +- .../CookieConsent/CookieConsentSurface.cs | 4 +- .../CookieConsent/CookieConsentToken.cs | 1 - .../Themes/CookieConsentTheme.axaml | 7 +- .../Messages/Toast/Themes/ToastTheme.axaml | 5 +- src/TKCode.Arc/Messages/Toast/Toast.cs | 2 +- src/TKCode.Arc/Messages/Toast/ToastToken.cs | 1 - .../ToastStack/Themes/ToastStackTheme.axaml | 3 +- .../Messages/ToastStack/ToastStack.cs | 6 +- .../Messages/ToastStack/ToastStackToken.cs | 1 - .../Messages/_Shared/MessagesGlyphSwap.cs | 2 +- .../Messages/_Shared/MessagesPresenceFrame.cs | 2 +- .../_Shared/MessagesTaskExtensions.cs | 12 +- .../Messages/_Shared/MessagesTextSwap.cs | 2 +- .../Breadcrumb/BreadcrumbItemToken.cs | 1 - .../Navigation/Breadcrumb/BreadcrumbToken.cs | 1 - .../Themes/BreadcrumbItemTheme.axaml | 3 +- .../Breadcrumb/Themes/BreadcrumbTheme.axaml | 3 +- .../Navigation/Pagination/PaginationToken.cs | 1 - .../Pagination/Themes/PaginationTheme.axaml | 3 +- .../Navigation/Tabs/TabItemToken.cs | 1 - src/TKCode.Arc/Navigation/Tabs/TabsToken.cs | 1 - .../Navigation/Tabs/Themes/TabItemTheme.axaml | 3 +- .../Navigation/Tabs/Themes/TabsTheme.axaml | 3 +- .../BottomSheet/BottomSheetSurfaceToken.cs | 1 - .../Overlays/BottomSheet/BottomSheetToken.cs | 1 - .../Themes/BottomSheetSurfaceTheme.axaml | 3 +- .../BottomSheet/Themes/BottomSheetTheme.axaml | 3 +- .../Overlays/Dialog/DialogSurfaceToken.cs | 1 - src/TKCode.Arc/Overlays/Dialog/DialogToken.cs | 1 - .../Dialog/Themes/DialogSurfaceTheme.axaml | 3 +- .../Overlays/Dialog/Themes/DialogTheme.axaml | 3 +- .../Overlays/Drawer/DrawerSurfaceToken.cs | 1 - src/TKCode.Arc/Overlays/Drawer/DrawerToken.cs | 1 - .../Drawer/Themes/DrawerSurfaceTheme.axaml | 3 +- .../Overlays/Drawer/Themes/DrawerTheme.axaml | 3 +- .../Overlays/HoverCard/HoverCardProfile.cs | 1 - .../HoverCard/HoverCardProfileToken.cs | 1 - .../Overlays/HoverCard/HoverCardToken.cs | 1 - .../Themes/HoverCardProfileTheme.axaml | 3 +- .../HoverCard/Themes/HoverCardTheme.axaml | 3 +- .../Overlays/Popover/PopoverSurfaceToken.cs | 1 - .../Overlays/Popover/PopoverToken.cs | 1 - .../Popover/Themes/PopoverSurfaceTheme.axaml | 3 +- .../Popover/Themes/PopoverTheme.axaml | 3 +- .../Tooltip/Themes/TooltipTheme.axaml | 3 +- .../Overlays/Tooltip/TooltipToken.cs | 1 - .../_Shared/OverlayAnchoredPresence.cs | 22 +- .../Pickers/Calendar/CalendarToken.cs | 1 - .../Calendar/Themes/CalendarTheme.axaml | 3 +- .../ColorPicker/ColorPickerSwatchRow.cs | 2 +- .../Pickers/ColorPicker/ColorPickerToken.cs | 1 - .../ColorPicker/Themes/ColorPickerTheme.axaml | 3 +- .../Pickers/DatePicker/DatePickerToken.cs | 1 - .../DatePicker/Themes/DatePickerTheme.axaml | 3 +- .../DateRangePickerMonthsView.cs | 2 +- .../DateRangePicker/DateRangePickerToken.cs | 1 - .../Themes/DateRangePickerTheme.axaml | 3 +- .../TimePicker/Themes/TimePickerTheme.axaml | 3 +- .../Pickers/TimePicker/TimePickerToken.cs | 1 - .../Pickers/_Shared/PickerMorphPopup.cs | 3 +- .../Pickers/_Shared/PickerRollText.cs | 4 +- .../Progress/Countdown/CountdownToken.cs | 1 - .../Countdown/Themes/CountdownTheme.axaml | 3 +- .../Progress/Progress/ProgressToken.cs | 1 - .../Progress/Themes/ProgressTheme.axaml | 3 +- .../Progress/Skeleton/SkeletonToken.cs | 1 - .../Skeleton/Themes/SkeletonTheme.axaml | 3 +- .../Progress/Stepper/StepperMarker.cs | 2 +- .../Progress/Stepper/StepperToken.cs | 1 - .../Stepper/Themes/StepperTheme.axaml | 3 +- .../UsageMeter/Themes/UsageMeterTheme.axaml | 3 +- .../Progress/UsageMeter/UsageMeterToken.cs | 1 - .../Progress/_Shared/ProgressSwapText.cs | 2 +- .../Selects/Cascader/CascaderToken.cs | 1 - .../Cascader/Themes/CascaderTheme.axaml | 3 +- .../Selects/ChipGroup/ChipGroupToken.cs | 1 - .../ChipGroup/Themes/ChipGroupTheme.axaml | 3 +- .../Selects/Combobox/ComboboxToken.cs | 1 - .../Combobox/Themes/ComboboxTheme.axaml | 3 +- .../Selects/MorphSelect/MorphSelect.cs | 2 +- .../Selects/MorphSelect/MorphSelectSurface.cs | 2 +- .../Selects/MorphSelect/MorphSelectToken.cs | 1 - .../MorphSelect/Themes/MorphSelectTheme.axaml | 3 +- .../Selects/MultiSelect/MultiSelectChipRow.cs | 2 +- .../Selects/MultiSelect/MultiSelectToken.cs | 1 - .../MultiSelect/Themes/MultiSelectTheme.axaml | 3 +- src/TKCode.Arc/Selects/Select/SelectToken.cs | 1 - .../Selects/Select/Themes/SelectTheme.axaml | 3 +- .../Selects/SelectItem/SelectItemToken.cs | 1 - .../Selects/_Shared/SelectValueRoll.cs | 2 +- .../ElasticSlider/ElasticSliderToken.cs | 1 - .../Themes/ElasticSliderTheme.axaml | 3 +- src/TKCode.Arc/Sliders/Slider/SliderToken.cs | 1 - .../Sliders/Slider/Themes/SliderTheme.axaml | 3 +- .../MentionInput/MentionInputSuggestions.cs | 20 +- .../MentionInput/MentionInputToken.cs | 1 - .../Themes/MentionInputTheme.axaml | 3 +- .../MoneyInput/MoneyInputCurrencyMenu.cs | 6 +- .../MoneyInput/MoneyInputToken.cs | 1 - .../MoneyInput/Themes/MoneyInputTheme.axaml | 3 +- .../NumberField/NumberFieldParts.cs | 4 +- .../NumberField/NumberFieldToken.cs | 1 - .../NumberField/Themes/NumberFieldTheme.axaml | 3 +- .../PhoneInput/PhoneInput.Picker.cs | 2 +- .../PhoneInput/PhoneInputFace.cs | 2 +- .../PhoneInput/PhoneInputPresence.cs | 2 +- .../PhoneInput/PhoneInputSurface.cs | 2 +- .../PhoneInput/PhoneInputToken.cs | 1 - .../PhoneInput/Themes/PhoneInputTheme.axaml | 3 +- .../ScrubInput/ScrubInputToken.cs | 1 - .../ScrubInput/Themes/ScrubInputTheme.axaml | 3 +- .../ShortcutRecorder/ShortcutRecorderToken.cs | 1 - .../Themes/ShortcutRecorderTheme.axaml | 3 +- .../SpecialInputs/TagInput/TagInputToken.cs | 1 - .../TagInput/Themes/TagInputTheme.axaml | 3 +- .../Themes/TokenTextEditorTheme.axaml | 3 +- .../TokenTextEditor/TokenTextEditorToken.cs | 1 - src/TKCode.Arc/TKCode.Arc.csproj | 4 +- .../Tables/CodeBlock/CodeBlockText.cs | 3 +- .../Tables/CodeBlock/CodeBlockToken.cs | 1 - .../CodeBlock/Themes/CodeBlockTheme.axaml | 3 +- .../Tables/FilterToolbar/FilterMenuToken.cs | 1 - .../FilterToolbar/FilterToolbarToken.cs | 1 - .../Themes/FilterMenuTheme.axaml | 3 +- .../Themes/FilterToolbarTheme.axaml | 3 +- .../Tables/JsonViewer/JsonViewerToken.cs | 1 - .../JsonViewer/Themes/JsonViewerTheme.axaml | 3 +- .../ReorderableList/ReorderableListToken.cs | 1 - .../Themes/ReorderableListTheme.axaml | 3 +- src/TKCode.Arc/Tables/Table/TableToken.cs | 1 - .../Tables/Table/Themes/TableTheme.axaml | 3 +- .../TreeTable/Themes/TreeTableTheme.axaml | 3 +- .../Tables/TreeTable/TreeTableToken.cs | 1 - .../TreeView/Themes/TreeViewTheme.axaml | 3 +- .../Tables/TreeView/TreeViewToken.cs | 1 - .../InViewTitle/InViewTitleToken.cs | 1 - .../InViewTitle/Themes/InViewTitleTheme.axaml | 3 +- .../TextEffects/TextMorph/TextMorph.cs | 2 +- .../TextEffects/TextMorph/TextMorphToken.cs | 1 - .../TextMorph/Themes/TextMorphTheme.axaml | 3 +- .../TextEffects/TextReveal/TextRevealToken.cs | 1 - .../TextReveal/Themes/TextRevealTheme.axaml | 3 +- .../TextEffects/TextShimmer/TextShimmer.cs | 2 +- .../TextShimmer/TextShimmerToken.cs | 1 - .../TextShimmer/Themes/TextShimmerTheme.axaml | 3 +- .../ExpandingSearch/ExpandingSearchToken.cs | 1 - .../Themes/ExpandingSearchTheme.axaml | 3 +- .../TextFields/InlineEdit/InlineEditToken.cs | 1 - .../InlineEdit/Themes/InlineEditTheme.axaml | 3 +- src/TKCode.Arc/TextFields/Input/InputToken.cs | 1 - .../TextFields/Input/Themes/InputTheme.axaml | 3 +- .../PasswordField/PasswordFieldToken.cs | 1 - .../Themes/PasswordFieldTheme.axaml | 3 +- .../PasswordStrength/PasswordStrengthToken.cs | 1 - .../Themes/PasswordStrengthTheme.axaml | 3 +- .../SearchField/SearchFieldToken.cs | 1 - .../SearchField/Themes/SearchFieldTheme.axaml | 3 +- .../TextFields/Textarea/TextareaToken.cs | 1 - .../Textarea/Themes/TextareaTheme.axaml | 3 +- .../TextFields/_Shared/FieldFrameToken.cs | 1 - .../_Shared/FieldIconButtonToken.cs | 1 - .../TextFields/_Shared/FieldMessageToken.cs | 1 - .../TextFields/_Shared/FieldTextBoxToken.cs | 1 - .../TextFields/_Shared/FieldToken.cs | 1 - .../_Shared/Themes/FieldFrameTheme.axaml | 3 +- .../_Shared/Themes/FieldIconButtonTheme.axaml | 3 +- .../_Shared/Themes/FieldMessageTheme.axaml | 3 +- .../_Shared/Themes/FieldTheme.axaml | 3 +- .../ThemeManagerBuilderExtensions.cs | 15 +- .../BillingToggle/BillingPriceToken.cs | 1 - .../BillingToggle/BillingToggleToken.cs | 1 - .../Themes/BillingPriceTheme.axaml | 3 +- .../Themes/BillingToggleTheme.axaml | 3 +- .../Toggles/Checkbox/CheckboxToken.cs | 1 - .../Checkbox/Themes/CheckboxTheme.axaml | 3 +- .../Toggles/RadioCards/RadioCardsToken.cs | 1 - .../RadioCards/Themes/RadioCardsTheme.axaml | 3 +- .../Toggles/RadioGroup/RadioGroupToken.cs | 1 - .../RadioGroup/Themes/RadioGroupTheme.axaml | 3 +- .../SegmentedControl/SegmentedControlToken.cs | 1 - .../Themes/SegmentedControlTheme.axaml | 3 +- src/TKCode.Arc/Toggles/Switch/SwitchToken.cs | 1 - .../Toggles/Switch/Themes/SwitchTheme.axaml | 3 +- .../Baselines/presets/corners-scale-0.5.json | 1211 ++++ tests/Baselines/presets/corners-scale-2.json | 1211 ++++ tests/Baselines/presets/corners-square.json | 1211 ++++ tests/Baselines/presets/pixel.json | 1573 +++++ tests/Baselines/tokens/dark-amber.json | 5350 +++++++++++++++++ tests/Baselines/tokens/dark-blue.json | 5350 +++++++++++++++++ tests/Baselines/tokens/dark-coral.json | 5350 +++++++++++++++++ .../Baselines/tokens/dark-corners-square.json | 5350 +++++++++++++++++ tests/Baselines/tokens/dark-default.json | 5350 +++++++++++++++++ tests/Baselines/tokens/dark-green.json | 5350 +++++++++++++++++ tests/Baselines/tokens/dark-neutral.json | 5350 +++++++++++++++++ tests/Baselines/tokens/dark-orange.json | 5350 +++++++++++++++++ tests/Baselines/tokens/dark-pixel.json | 5350 +++++++++++++++++ tests/Baselines/tokens/dark-rose.json | 5350 +++++++++++++++++ tests/Baselines/tokens/dark-seed-0562EF.json | 5350 +++++++++++++++++ tests/Baselines/tokens/dark-seed-E5484D.json | 5350 +++++++++++++++++ tests/Baselines/tokens/dark-violet.json | 5350 +++++++++++++++++ tests/Baselines/tokens/light-amber.json | 5350 +++++++++++++++++ tests/Baselines/tokens/light-blue.json | 5350 +++++++++++++++++ tests/Baselines/tokens/light-coral.json | 5350 +++++++++++++++++ .../tokens/light-corners-scale-0.5.json | 5350 +++++++++++++++++ .../tokens/light-corners-scale-2.json | 5350 +++++++++++++++++ .../tokens/light-corners-square.json | 5350 +++++++++++++++++ .../tokens/light-default-motion-off.json | 5350 +++++++++++++++++ .../tokens/light-default-motion-reduced.json | 5350 +++++++++++++++++ tests/Baselines/tokens/light-default.json | 5350 +++++++++++++++++ tests/Baselines/tokens/light-green.json | 5350 +++++++++++++++++ tests/Baselines/tokens/light-neutral.json | 5350 +++++++++++++++++ tests/Baselines/tokens/light-orange.json | 5350 +++++++++++++++++ tests/Baselines/tokens/light-pixel.json | 5350 +++++++++++++++++ tests/Baselines/tokens/light-rose.json | 5350 +++++++++++++++++ tests/Baselines/tokens/light-seed-0562EF.json | 5350 +++++++++++++++++ tests/Baselines/tokens/light-seed-E5484D.json | 5350 +++++++++++++++++ tests/Baselines/tokens/light-violet.json | 5350 +++++++++++++++++ .../ArcBaseThemeTests.cs | 694 +++ .../AvaloniaTestApp.cs | 44 + .../TKCode.Arc.BaseTheme.Tests.csproj | 21 + .../Theme/PaletteTests.cs | 38 +- .../AvaloniaTestApp.cs | 46 + .../GeneratorTests.cs | 282 + .../LocalizationGeneratorTests.cs | 233 + .../LocalizationRuntimeTests.cs | 243 + .../OverrideTests.cs | 122 + .../Properties/AssemblyInfo.cs | 5 + .../Localization/SampleLangResourceKind.cs | 13 + .../Localization/SampleLocalizedView.axaml | 9 + .../Localization/SampleLocalizedView.axaml.cs | 12 + .../Samples/Localization/en-US.xlf | 25 + .../Samples/Localization/zh-CN.xlf | 29 + .../Samples/Localization/zh-TW.xlf | 29 + .../SampleBadge/Themes/SampleBadgeTheme.axaml | 16 + .../Themes/SampleButtonTheme.axaml | 37 + .../Samples/SampleControls.cs | 106 + .../Samples/SampleDesignToken.cs | 82 + .../Themes/SampleSwitchTheme.axaml | 32 + .../ScopeAndLazyThemeTests.cs | 160 + .../ArcGeneratedAssets.Languages.g.cs.txt | 32 + .../Snapshots/ArcGeneratedAssets.g.cs.txt | 29 + ...DemoLangResourceKind.Localization.g.cs.txt | 93 + ...o.Controls.Themes.DemoButtonTheme.g.cs.txt | 15 + .../Snapshots/DemoButtonToken.g.cs.txt | 329 + .../Snapshots/DemoDesignToken.g.cs.txt | 97 + .../Snapshots/Diagnostics.txt | 14 + .../Snapshots/LocalizationDiagnostics.txt | 14 + .../TKCode.Arc.Foundation.Tests.csproj | 43 + .../TestHelpers.cs | 30 + .../ThemePresetPortTests.cs | 110 + .../ThemePresetTests.cs | 134 + .../ThemeRuntimeTests.cs | 175 + .../AvaloniaTestApp.cs | 16 +- .../DataGrid/DataGridBehaviorTests.cs | 2 +- .../DataGrid/DataGridChromeTests.cs | 42 +- tests/TKCode.Arc.Tests/AvaloniaTestApp.cs | 18 +- .../Blocks/Ai/AiAttachmentChipTests.cs | 6 +- .../Blocks/Ai/AiComposerTests.cs | 6 +- .../Blocks/Ai/AiRelatedQuestionsTests.cs | 6 +- .../Blocks/Ai/AiSourceStackTests.cs | 6 +- .../Blocks/Ai/AiSourcesRowTests.cs | 6 +- .../Blocks/Ai/AiSuggestionPopupTests.cs | 6 +- .../Blocks/Ai/AiUserBubbleTests.cs | 2 +- .../Blocks/Ai/DocumentPageTests.cs | 6 +- .../Blocks/Ai/DocumentViewerTests.cs | 6 +- .../Blocks/Ai/ModelMenuDataTests.cs | 7 +- .../Blocks/Ai/ModelMenuInlineEffortTests.cs | 5 +- .../Blocks/Ai/ModelMenuMotionTests.cs | 6 +- .../Blocks/Ai/PromptModelMenuTests.cs | 2 +- .../Blocks/Ai/PromptVersionMenuTests.cs | 2 +- .../Blocks/Auth/LoginCenteredTests.cs | 3 +- .../Blocks/Auth/MultiStepFormTests.cs | 4 +- .../Blocks/EmptyStatesTests.cs | 7 +- .../Blocks/HeadersFooters/SiteFooterTests.cs | 7 +- .../Blocks/Media/MediaPlayerTests.cs | 6 +- .../Blocks/NotificationCenterTests.cs | 7 +- .../Blocks/PageHeaderTests.cs | 7 +- .../Blocks/Showpieces/ActivityTerrainTests.cs | 6 +- .../Showpieces/CustomerGalaxyDemosTests.cs | 4 +- .../Blocks/Showpieces/CustomerGalaxyTests.cs | 11 +- .../Showpieces/ExplodedViewDemosTests.cs | 4 +- .../Showpieces/LayoutMorphRenderingTests.cs | 10 +- .../Blocks/Showpieces/LayoutMorphTests.cs | 28 +- .../Blocks/Showpieces/RevenueGlobeTests.cs | 4 +- .../Showpieces/SemanticZoomDemosTests.cs | 4 +- .../Blocks/Showpieces/SemanticZoomTests.cs | 11 +- .../Blocks/Showpieces/ShowpieceStageTests.cs | 4 +- .../Showpieces/ShowpiecesLifecycleTests.cs | 15 +- .../Buttons/ButtonsMotionTests.cs | 6 +- .../Buttons/ConfirmMorphTests.cs | 4 +- .../Buttons/ThemeSwitcherTests.cs | 67 +- .../TKCode.Arc.Tests/Content/MarkdownTests.cs | 7 +- .../CorePrimitives/ArcImageTests.cs | 348 ++ .../Gallery/GalleryDemoAppTests.cs | 17 +- .../Gallery/GalleryDemosTests.cs | 11 +- .../Gallery/GalleryDocsTests.cs | 4 +- .../Gallery/GalleryEventLogDemoTests.cs | 41 + .../Gallery/GalleryGuidesTests.cs | 8 +- .../Gallery/GalleryLocalizationTests.cs | 3 +- .../Gallery/GallerySmokeTests.cs | 4 +- .../Gallery/GalleryTextSourceTests.cs | 5 +- .../Gallery/GalleryTitleBarTests.cs | 225 + .../Menus/MenuSurfacePointerTests.cs | 7 +- .../Menus/MenuSurfaceSheetTests.cs | 11 +- .../Messages/ToastStackTests.cs | 25 + .../Overlays/HoverCardKeyboardTests.cs | 5 +- .../Overlays/ModalPresenceTests.cs | 50 +- .../TKCode.Arc.Tests/Overlays/PopoverTests.cs | 3 +- .../Overlays/PopupZoomTests.cs | 10 +- .../Special/Ai/AgentStatusTests.cs | 7 +- .../Special/PromptCacheTests.cs | 6 +- .../Special/SpeculativeDecodingTests.cs | 6 +- tests/TKCode.Arc.Tests/TestHelpers.cs | 50 +- tests/TKCode.Arc.Tests/TestHelpersTests.cs | 39 + .../Theme/ArcDesignTokenTests.cs | 16 +- .../Theme/BaseThemeInventoryTests.cs | 203 + .../Theme/CornerStyleTests.cs | 7 +- .../TKCode.Arc.Tests/Theme/PixelThemeTests.cs | 39 +- .../TokenBaseline/TokenBaselineResolver.cs | 68 + .../Theme/TokenBaseline/TokenBaselineTests.cs | 275 + .../TokenBaseline/TokenValueFormatter.cs | 66 + .../Workflow/WorkflowLeakTests.cs | 10 +- tools/dev/avalonia_builtins.py | 463 ++ tools/dev/migrate_atomui.py | 321 + tools/docshots/DocShots.csproj | 1 - tools/docshots/PageShot.cs | 5 +- tools/docshots/Program.cs | 21 +- tools/docshots/Shooter.cs | 26 +- tools/pixel/generate_pixel_themes.py | 36 - 951 files changed, 176546 insertions(+), 2852 deletions(-) delete mode 100644 gallery/TKCode.Arc.Gallery.Browser/EmbeddableRootTemplate.cs create mode 100644 gallery/TKCode.Arc.Gallery/Shell/GallerySearchBox.cs create mode 100644 gallery/TKCode.Arc.Gallery/Shell/GalleryTitleBar.cs create mode 100644 src/TKCode.Arc.Core/Primitives/Images/ArcImage.cs create mode 100644 src/TKCode.Arc.Core/Primitives/Images/ArcImageCache.cs create mode 100644 src/TKCode.Arc.Core/Primitives/Images/ArcImageLoader.cs create mode 100644 src/TKCode.Arc.Core/Primitives/Images/ImageSource.cs create mode 100644 src/TKCode.Arc.Core/Primitives/Modal/ArcOverlayHost.cs create mode 100644 src/TKCode.Arc.Core/Primitives/Popups/ArcPopupFrame.cs create mode 100644 src/TKCode.Arc.Core/Theme/ArcPaletteGenerator.cs create mode 100644 src/TKCode.Arc.Core/Theme/Base/ArcBaseTheme.cs create mode 100644 src/TKCode.Arc.Core/Theme/Base/ItemsControlBaseTheme.axaml create mode 100644 src/TKCode.Arc.Core/Theme/Base/PathIconBaseTheme.axaml create mode 100644 src/TKCode.Arc.Core/Theme/Base/PopupHostBaseTheme.axaml create mode 100644 src/TKCode.Arc.Core/Theme/Base/ScrollViewerAutoHide.cs create mode 100644 src/TKCode.Arc.Core/Theme/Base/ScrollViewerBaseTheme.axaml create mode 100644 src/TKCode.Arc.Core/Theme/Base/TextSelectionHandleBaseTheme.axaml create mode 100644 src/TKCode.Arc.Core/Theme/Base/WindowBaseTheme.axaml create mode 100644 src/TKCode.Arc.Themes.Corners/ArcCornerPresets.cs delete mode 100644 src/TKCode.Arc.Themes.Corners/ArcCornerScope.cs create mode 100644 src/TKCode.Arc.Themes.Corners/ArcCornersBuilderExtensions.cs delete mode 100644 src/TKCode.Arc.Themes.Corners/ArcCornersExtensions.cs create mode 100644 src/TKCode.Arc.Themes.Corners/CornerValues.cs create mode 100644 src/TKCode.Arc.Themes.Pixel/ArcPixelPresets.cs delete mode 100644 src/TKCode.Arc.Themes.Pixel/ArcPixelScope.cs delete mode 100644 src/TKCode.Arc.Themes.Pixel/ArcPixelTheme.cs delete mode 100644 src/TKCode.Arc.Themes.Pixel/Themes/ArcPixel.xml delete mode 100644 src/TKCode.Arc.Themes.Pixel/Themes/ArcPixelDark.xml create mode 100644 tests/Baselines/presets/corners-scale-0.5.json create mode 100644 tests/Baselines/presets/corners-scale-2.json create mode 100644 tests/Baselines/presets/corners-square.json create mode 100644 tests/Baselines/presets/pixel.json create mode 100644 tests/Baselines/tokens/dark-amber.json create mode 100644 tests/Baselines/tokens/dark-blue.json create mode 100644 tests/Baselines/tokens/dark-coral.json create mode 100644 tests/Baselines/tokens/dark-corners-square.json create mode 100644 tests/Baselines/tokens/dark-default.json create mode 100644 tests/Baselines/tokens/dark-green.json create mode 100644 tests/Baselines/tokens/dark-neutral.json create mode 100644 tests/Baselines/tokens/dark-orange.json create mode 100644 tests/Baselines/tokens/dark-pixel.json create mode 100644 tests/Baselines/tokens/dark-rose.json create mode 100644 tests/Baselines/tokens/dark-seed-0562EF.json create mode 100644 tests/Baselines/tokens/dark-seed-E5484D.json create mode 100644 tests/Baselines/tokens/dark-violet.json create mode 100644 tests/Baselines/tokens/light-amber.json create mode 100644 tests/Baselines/tokens/light-blue.json create mode 100644 tests/Baselines/tokens/light-coral.json create mode 100644 tests/Baselines/tokens/light-corners-scale-0.5.json create mode 100644 tests/Baselines/tokens/light-corners-scale-2.json create mode 100644 tests/Baselines/tokens/light-corners-square.json create mode 100644 tests/Baselines/tokens/light-default-motion-off.json create mode 100644 tests/Baselines/tokens/light-default-motion-reduced.json create mode 100644 tests/Baselines/tokens/light-default.json create mode 100644 tests/Baselines/tokens/light-green.json create mode 100644 tests/Baselines/tokens/light-neutral.json create mode 100644 tests/Baselines/tokens/light-orange.json create mode 100644 tests/Baselines/tokens/light-pixel.json create mode 100644 tests/Baselines/tokens/light-rose.json create mode 100644 tests/Baselines/tokens/light-seed-0562EF.json create mode 100644 tests/Baselines/tokens/light-seed-E5484D.json create mode 100644 tests/Baselines/tokens/light-violet.json create mode 100644 tests/TKCode.Arc.BaseTheme.Tests/ArcBaseThemeTests.cs create mode 100644 tests/TKCode.Arc.BaseTheme.Tests/AvaloniaTestApp.cs create mode 100644 tests/TKCode.Arc.BaseTheme.Tests/TKCode.Arc.BaseTheme.Tests.csproj create mode 100644 tests/TKCode.Arc.Foundation.Tests/AvaloniaTestApp.cs create mode 100644 tests/TKCode.Arc.Foundation.Tests/GeneratorTests.cs create mode 100644 tests/TKCode.Arc.Foundation.Tests/LocalizationGeneratorTests.cs create mode 100644 tests/TKCode.Arc.Foundation.Tests/LocalizationRuntimeTests.cs create mode 100644 tests/TKCode.Arc.Foundation.Tests/OverrideTests.cs create mode 100644 tests/TKCode.Arc.Foundation.Tests/Properties/AssemblyInfo.cs create mode 100644 tests/TKCode.Arc.Foundation.Tests/Samples/Localization/SampleLangResourceKind.cs create mode 100644 tests/TKCode.Arc.Foundation.Tests/Samples/Localization/SampleLocalizedView.axaml create mode 100644 tests/TKCode.Arc.Foundation.Tests/Samples/Localization/SampleLocalizedView.axaml.cs create mode 100644 tests/TKCode.Arc.Foundation.Tests/Samples/Localization/en-US.xlf create mode 100644 tests/TKCode.Arc.Foundation.Tests/Samples/Localization/zh-CN.xlf create mode 100644 tests/TKCode.Arc.Foundation.Tests/Samples/Localization/zh-TW.xlf create mode 100644 tests/TKCode.Arc.Foundation.Tests/Samples/SampleBadge/Themes/SampleBadgeTheme.axaml create mode 100644 tests/TKCode.Arc.Foundation.Tests/Samples/SampleButton/Themes/SampleButtonTheme.axaml create mode 100644 tests/TKCode.Arc.Foundation.Tests/Samples/SampleControls.cs create mode 100644 tests/TKCode.Arc.Foundation.Tests/Samples/SampleDesignToken.cs create mode 100644 tests/TKCode.Arc.Foundation.Tests/Samples/SampleSwitch/Themes/SampleSwitchTheme.axaml create mode 100644 tests/TKCode.Arc.Foundation.Tests/ScopeAndLazyThemeTests.cs create mode 100644 tests/TKCode.Arc.Foundation.Tests/Snapshots/ArcGeneratedAssets.Languages.g.cs.txt create mode 100644 tests/TKCode.Arc.Foundation.Tests/Snapshots/ArcGeneratedAssets.g.cs.txt create mode 100644 tests/TKCode.Arc.Foundation.Tests/Snapshots/Demo.Controls.Localization.DemoLangResourceKind.Localization.g.cs.txt create mode 100644 tests/TKCode.Arc.Foundation.Tests/Snapshots/Demo.Controls.Themes.DemoButtonTheme.g.cs.txt create mode 100644 tests/TKCode.Arc.Foundation.Tests/Snapshots/DemoButtonToken.g.cs.txt create mode 100644 tests/TKCode.Arc.Foundation.Tests/Snapshots/DemoDesignToken.g.cs.txt create mode 100644 tests/TKCode.Arc.Foundation.Tests/Snapshots/Diagnostics.txt create mode 100644 tests/TKCode.Arc.Foundation.Tests/Snapshots/LocalizationDiagnostics.txt create mode 100644 tests/TKCode.Arc.Foundation.Tests/TKCode.Arc.Foundation.Tests.csproj create mode 100644 tests/TKCode.Arc.Foundation.Tests/TestHelpers.cs create mode 100644 tests/TKCode.Arc.Foundation.Tests/ThemePresetPortTests.cs create mode 100644 tests/TKCode.Arc.Foundation.Tests/ThemePresetTests.cs create mode 100644 tests/TKCode.Arc.Foundation.Tests/ThemeRuntimeTests.cs create mode 100644 tests/TKCode.Arc.Tests/CorePrimitives/ArcImageTests.cs create mode 100644 tests/TKCode.Arc.Tests/Gallery/GalleryEventLogDemoTests.cs create mode 100644 tests/TKCode.Arc.Tests/Gallery/GalleryTitleBarTests.cs create mode 100644 tests/TKCode.Arc.Tests/TestHelpersTests.cs create mode 100644 tests/TKCode.Arc.Tests/Theme/BaseThemeInventoryTests.cs create mode 100644 tests/TKCode.Arc.Tests/Theme/TokenBaseline/TokenBaselineResolver.cs create mode 100644 tests/TKCode.Arc.Tests/Theme/TokenBaseline/TokenBaselineTests.cs create mode 100644 tests/TKCode.Arc.Tests/Theme/TokenBaseline/TokenValueFormatter.cs create mode 100644 tools/dev/avalonia_builtins.py create mode 100644 tools/dev/migrate_atomui.py delete mode 100644 tools/pixel/generate_pixel_themes.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ca73a22..efa8bf28 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,7 +60,7 @@ jobs: - uses: actions/setup-dotnet@v6 with: - # global.json pins the .NET 11 SDK; the AtomUI build tasks and the test projects run on .NET 10. + # global.json pins the .NET 11 SDK; the test projects run on .NET 10. global-json-file: global.json dotnet-version: 10.0.x diff --git a/Directory.Packages.props b/Directory.Packages.props index 57977281..3bf5f409 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,11 +6,6 @@ 0.1.1 - - - - - @@ -31,5 +26,8 @@ + + + diff --git a/Dockerfile b/Dockerfile index 0bb7b52e..7bc1641e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,7 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends python3 brotli gzip \ && rm -rf /var/lib/apt/lists/* WORKDIR /src -# The SDK is the one global.json pins (11.0.100-rc.2). The .NET 10 runtime is needed too: AtomUI's build worker is net10. +# The SDK is the one global.json pins (11.0.100-rc.2). The .NET 10 runtime is needed too: gallery/TKCode.Arc.Gallery.Llms (run below) targets net10.0. COPY global.json . RUN curl -sSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh \ && bash /tmp/dotnet-install.sh --jsonfile global.json --install-dir /opt/dotnet \ diff --git a/TKCode.Arc.slnx b/TKCode.Arc.slnx index 5faaaf44..5a4859bf 100644 --- a/TKCode.Arc.slnx +++ b/TKCode.Arc.slnx @@ -1,5 +1,7 @@ + + @@ -17,7 +19,9 @@ + + diff --git a/build/Arc.Repository.targets b/build/Arc.Repository.targets index ad1da497..132dcbe4 100644 --- a/build/Arc.Repository.targets +++ b/build/Arc.Repository.targets @@ -7,4 +7,14 @@ + + + + + diff --git a/build/PackageMetadata.props b/build/PackageMetadata.props index 337b5308..57d48851 100644 --- a/build/PackageMetadata.props +++ b/build/PackageMetadata.props @@ -3,8 +3,8 @@ $(ArcVersion) TKCode.Arc contributors TKCode.Arc - Arc-style component library for Avalonia, built on AtomUI. Ports the free components of Arc (uiarc.dev) with their visual language and spring motion. - avalonia;atomui;arc;ui;controls;motion;desktop + Arc-style component library for Avalonia. Ports the free components of Arc (uiarc.dev) with their visual language and spring motion. + avalonia;arc;ui;controls;motion;desktop MIT README.md Copyright (c) 2026 TKCode.Arc contributors diff --git a/build/Versions.props b/build/Versions.props index 03ea0847..b51b9495 100644 --- a/build/Versions.props +++ b/build/Versions.props @@ -1,7 +1,6 @@ 0.3.1-preview.6 - 6.2.8 12.1.4 diff --git a/gallery/TKCode.Arc.Gallery.Browser/App.cs b/gallery/TKCode.Arc.Gallery.Browser/App.cs index e12e883b..dd171206 100644 --- a/gallery/TKCode.Arc.Gallery.Browser/App.cs +++ b/gallery/TKCode.Arc.Gallery.Browser/App.cs @@ -8,8 +8,6 @@ public partial class App : Application { public override void OnFrameworkInitializationCompleted() { - // Popups need the root's overlay layer, which AtomUI's browser root template does not enable. - Styles.Add(EmbeddableRootTemplate.Create()); // The browser has one view and no windows, so the shell is the page itself. if (ApplicationLifetime is ISingleViewApplicationLifetime singleView) { diff --git a/gallery/TKCode.Arc.Gallery.Browser/EmbeddableRootTemplate.cs b/gallery/TKCode.Arc.Gallery.Browser/EmbeddableRootTemplate.cs deleted file mode 100644 index 54ccc58a..00000000 --- a/gallery/TKCode.Arc.Gallery.Browser/EmbeddableRootTemplate.cs +++ /dev/null @@ -1,46 +0,0 @@ -using Avalonia; -using Avalonia.Controls; -using Avalonia.Controls.Embedding; -using Avalonia.Controls.Presenters; -using Avalonia.Controls.Primitives; -using Avalonia.Controls.Templates; -using Avalonia.Layout; -using Avalonia.Styling; - -namespace TKCode.Arc.Gallery.Browser; - -/// -/// The browser has no popup windows, so every popup (Popover, Tooltip, Select, pickers) is hosted in the root's overlay -/// layer. Avalonia 12 enables that layer on the the root template names -/// PART_VisualLayerManager. AtomUI 6.2.6's template has the manager but leaves it -/// unnamed, so opening a popup throws "no overlay layer is found for the target control" and nothing appears. This style -/// swaps in the same template with the part named; a style outranks the theme, which keeps its other setters (background, -/// foreground, font). Drop it when AtomUI names the part itself. -/// -internal static class EmbeddableRootTemplate -{ - public static Style Create() => new(static x => x.OfType()) - { - Setters = { new Setter(TemplatedControl.TemplateProperty, new FuncControlTemplate(Build)) } - }; - - private static Control Build(EmbeddableControlRoot root, INameScope scope) - { - var fallback = new Border { IsHitTestVisible = false }; - scope.Register("PART_TransparencyFallback", fallback); - - var presenter = new ContentPresenter(); - presenter.Bind(ContentPresenter.ContentProperty, root.GetObservable(ContentControl.ContentProperty)); - presenter.Bind(ContentPresenter.ContentTemplateProperty, root.GetObservable(ContentControl.ContentTemplateProperty)); - presenter.Bind(Layoutable.MarginProperty, root.GetObservable(TemplatedControl.PaddingProperty)); - scope.Register("PART_ContentPresenter", presenter); - - var layers = new VisualLayerManager { Child = presenter }; - scope.Register("PART_VisualLayerManager", layers); - - var background = new Border { Child = layers }; - background.Bind(Border.BackgroundProperty, root.GetObservable(TemplatedControl.BackgroundProperty)); - - return new Panel { Children = { fallback, background } }; - } -} diff --git a/gallery/TKCode.Arc.Gallery.Browser/Program.cs b/gallery/TKCode.Arc.Gallery.Browser/Program.cs index 855f7b5b..d002c56c 100644 --- a/gallery/TKCode.Arc.Gallery.Browser/Program.cs +++ b/gallery/TKCode.Arc.Gallery.Browser/Program.cs @@ -1,6 +1,4 @@ using System.Threading.Tasks; -using AtomUI; -using AtomUI.Desktop.Controls; using Avalonia; using Avalonia.Browser; using TKCode.Arc; @@ -10,6 +8,7 @@ using TKCode.Arc.Gallery.Docs; using TKCode.Arc.Gallery.Localization; using TKCode.Arc.Gallery.Shell; +using TKCode.Arc.Localization; namespace TKCode.Arc.Gallery.Browser; @@ -51,11 +50,8 @@ public static AppBuilder BuildAvaloniaApp() => .WithArcFonts() .WithArcPixelFonts() .WithCjkFont() - .UseAtomUI(builder => - { - builder.UseLanguages(GalleryLanguages.Initial(BrowserLanguageStore.Preferred()), GalleryLanguages.Tags); - builder.UseDesktopControls(); - builder.UseArc(); - builder.UseArcGallery(); - }); + .UseArc(arc => arc + .UseLanguages(GalleryLanguages.Initial(BrowserLanguageStore.Preferred()), GalleryLanguages.Tags) + .UseArcControls() + .UseArcGallery()); } diff --git a/gallery/TKCode.Arc.Gallery.Browser/TKCode.Arc.Gallery.Browser.csproj b/gallery/TKCode.Arc.Gallery.Browser/TKCode.Arc.Gallery.Browser.csproj index 4019d0d2..e6f045e7 100644 --- a/gallery/TKCode.Arc.Gallery.Browser/TKCode.Arc.Gallery.Browser.csproj +++ b/gallery/TKCode.Arc.Gallery.Browser/TKCode.Arc.Gallery.Browser.csproj @@ -7,9 +7,8 @@ true true - + true + downloaded at startup. These come with the Avalonia package and are never loaded in the browser. Avalonia.OpenGL, + .Metal and .Vulkan are NOT in this list: Avalonia.Skia loads them. Pass -p:ArcWasmPrune=false to publish with + everything. --> - - - - - + @@ -61,7 +55,7 @@ @@ -74,15 +68,13 @@ - - - - + Assets/AppIcon.ico + + false - diff --git a/gallery/TKCode.Arc.Gallery.Llms/LlmsSite.cs b/gallery/TKCode.Arc.Gallery.Llms/LlmsSite.cs index 2694a4ba..eba41483 100644 --- a/gallery/TKCode.Arc.Gallery.Llms/LlmsSite.cs +++ b/gallery/TKCode.Arc.Gallery.Llms/LlmsSite.cs @@ -41,7 +41,7 @@ public static partial class LlmsSite /// The builder call that registers each package's controls. private static readonly Dictionary s_registration = new() { - ["TKCode.Arc"] = "UseArc()", + ["TKCode.Arc"] = "UseArcControls()", ["TKCode.Arc.Blocks"] = "UseArcBlocks()", ["TKCode.Arc.Mobile"] = "UseArcMobile()", ["TKCode.Arc.AdvancedCharts"] = "UseArcAdvancedCharts()", @@ -156,7 +156,7 @@ private static string Groups(IEnumerable<(string Title, Page[] Pages)> groups, F markdown.Append("> ").Append(doc.Summary).Append("\n\n"); markdown.Append("- ").Append(words.Package).Append(s_onNuGet.Contains(package) ? string.Format(words.OnNuGet, $"`{package}`", $"`dotnet add package {package} --version {version}`") - : string.Format(words.NotOnNuGet, $"`{package}`", Repository, $"`{s_registration.GetValueOrDefault(package, "UseArc()")}`")).Append('\n'); + : string.Format(words.NotOnNuGet, $"`{package}`", Repository, $"`{s_registration.GetValueOrDefault(package, "UseArcControls()")}`")).Append('\n'); markdown.Append("- ").Append(words.Namespace).Append("`xmlns:arc=\"https://github.com/AIDotNet/TKCode.Arc\"`\n"); markdown.Append("- ").Append(words.LivePage).Append(baseUrl).Append(page.Address).Append("\n\n"); markdown.Append(Shots(baseUrl, doc.CopyMarkdown, $"Docs/{GalleryDocs.FolderOf(className)}")); @@ -186,7 +186,6 @@ private static Dictionary Values(string baseUrl) ["skill-name"] = SkillName, ["repository"] = Repository, ["version"] = metadata["ArcVersion"], - ["atomui"] = metadata["AtomUIVersion"], ["avalonia"] = metadata["AvaloniaVersion"], }; } diff --git a/gallery/TKCode.Arc.Gallery.Llms/TKCode.Arc.Gallery.Llms.csproj b/gallery/TKCode.Arc.Gallery.Llms/TKCode.Arc.Gallery.Llms.csproj index 9c6d92ac..2837b2f1 100644 --- a/gallery/TKCode.Arc.Gallery.Llms/TKCode.Arc.Gallery.Llms.csproj +++ b/gallery/TKCode.Arc.Gallery.Llms/TKCode.Arc.Gallery.Llms.csproj @@ -20,7 +20,6 @@ - diff --git a/gallery/TKCode.Arc.Gallery/Demos/Blocks/CommandPalette/Preview.axaml b/gallery/TKCode.Arc.Gallery/Demos/Blocks/CommandPalette/Preview.axaml index 16e56e2b..4e227601 100644 --- a/gallery/TKCode.Arc.Gallery/Demos/Blocks/CommandPalette/Preview.axaml +++ b/gallery/TKCode.Arc.Gallery/Demos/Blocks/CommandPalette/Preview.axaml @@ -1,10 +1,9 @@ - + - + diff --git a/gallery/TKCode.Arc.Gallery/Demos/Blocks/EnvVariables/EventLog.axaml b/gallery/TKCode.Arc.Gallery/Demos/Blocks/EnvVariables/EventLog.axaml index 1a73b07e..30c8b9e1 100644 --- a/gallery/TKCode.Arc.Gallery/Demos/Blocks/EnvVariables/EventLog.axaml +++ b/gallery/TKCode.Arc.Gallery/Demos/Blocks/EnvVariables/EventLog.axaml @@ -6,7 +6,7 @@ - + diff --git a/gallery/TKCode.Arc.Gallery/Demos/Blocks/InvitePeople/InvitePeopleDemoData.cs b/gallery/TKCode.Arc.Gallery/Demos/Blocks/InvitePeople/InvitePeopleDemoData.cs index 81391043..9c24e50e 100644 --- a/gallery/TKCode.Arc.Gallery/Demos/Blocks/InvitePeople/InvitePeopleDemoData.cs +++ b/gallery/TKCode.Arc.Gallery/Demos/Blocks/InvitePeople/InvitePeopleDemoData.cs @@ -1,4 +1,3 @@ -using AtomUI.Controls; namespace TKCode.Arc.Gallery.Demos; diff --git a/gallery/TKCode.Arc.Gallery/Demos/Blocks/NotificationCenter/ReadState.axaml.cs b/gallery/TKCode.Arc.Gallery/Demos/Blocks/NotificationCenter/ReadState.axaml.cs index 7517f608..eca85789 100644 --- a/gallery/TKCode.Arc.Gallery/Demos/Blocks/NotificationCenter/ReadState.axaml.cs +++ b/gallery/TKCode.Arc.Gallery/Demos/Blocks/NotificationCenter/ReadState.axaml.cs @@ -29,8 +29,8 @@ private void Reset() UpdateSummary(); } - private static AtomUI.Controls.ImageSource? ImageSourceOf(string url) => - AtomUI.Controls.ImageSource.TryParse(url, out var source) ? source : null; + private static ImageSource? ImageSourceOf(string url) => + ImageSource.TryParse(url, out var source) ? source : null; // Your data store learns about every change from the events; the items' IsRead is already updated. private void OnReadChanged(object? sender, NotificationReadChangedEventArgs e) diff --git a/gallery/TKCode.Arc.Gallery/Demos/Blocks/ProfilePage/ProfilePageDemoData.cs b/gallery/TKCode.Arc.Gallery/Demos/Blocks/ProfilePage/ProfilePageDemoData.cs index 7a794588..a616c8c1 100644 --- a/gallery/TKCode.Arc.Gallery/Demos/Blocks/ProfilePage/ProfilePageDemoData.cs +++ b/gallery/TKCode.Arc.Gallery/Demos/Blocks/ProfilePage/ProfilePageDemoData.cs @@ -1,4 +1,3 @@ -using AtomUI.Controls; using Avalonia; using Avalonia.Media; diff --git a/gallery/TKCode.Arc.Gallery/Demos/Blocks/TeamDirectory/TeamDirectoryDemoData.cs b/gallery/TKCode.Arc.Gallery/Demos/Blocks/TeamDirectory/TeamDirectoryDemoData.cs index 60cf44bf..c117a17c 100644 --- a/gallery/TKCode.Arc.Gallery/Demos/Blocks/TeamDirectory/TeamDirectoryDemoData.cs +++ b/gallery/TKCode.Arc.Gallery/Demos/Blocks/TeamDirectory/TeamDirectoryDemoData.cs @@ -1,4 +1,3 @@ -using AtomUI.Controls; namespace TKCode.Arc.Gallery.Demos; diff --git a/gallery/TKCode.Arc.Gallery/Demos/Blocks/TeamMembers/TeamMembersDemoData.cs b/gallery/TKCode.Arc.Gallery/Demos/Blocks/TeamMembers/TeamMembersDemoData.cs index 7fd393f2..8914fc2e 100644 --- a/gallery/TKCode.Arc.Gallery/Demos/Blocks/TeamMembers/TeamMembersDemoData.cs +++ b/gallery/TKCode.Arc.Gallery/Demos/Blocks/TeamMembers/TeamMembersDemoData.cs @@ -1,4 +1,3 @@ -using AtomUI.Controls; namespace TKCode.Arc.Gallery.Demos; diff --git a/gallery/TKCode.Arc.Gallery/Demos/Content/Markdown/Document.axaml.cs b/gallery/TKCode.Arc.Gallery/Demos/Content/Markdown/Document.axaml.cs index 21a8f8b2..a6a3f531 100644 --- a/gallery/TKCode.Arc.Gallery/Demos/Content/Markdown/Document.axaml.cs +++ b/gallery/TKCode.Arc.Gallery/Demos/Content/Markdown/Document.axaml.cs @@ -22,7 +22,7 @@ public MarkdownDocumentDemo() - Light, dark and the accent switch live. 1. Add the package. - 2. Call `UseArc()` in `UseAtomUI(...)`. + 2. Call `UseArcControls()` in `UseArc(...)`. 3. Drop `` into a view. - [x] Static documents diff --git a/gallery/TKCode.Arc.Gallery/Demos/Cursor/GravityWell/GravityWellDemoData.cs b/gallery/TKCode.Arc.Gallery/Demos/Cursor/GravityWell/GravityWellDemoData.cs index d16652fb..c56d4f96 100644 --- a/gallery/TKCode.Arc.Gallery/Demos/Cursor/GravityWell/GravityWellDemoData.cs +++ b/gallery/TKCode.Arc.Gallery/Demos/Cursor/GravityWell/GravityWellDemoData.cs @@ -1,4 +1,3 @@ -using AtomUI.Controls; using Avalonia.Media; namespace TKCode.Arc.Gallery.Demos; diff --git a/gallery/TKCode.Arc.Gallery/Demos/Cursor/GravityWell/Preview.axaml b/gallery/TKCode.Arc.Gallery/Demos/Cursor/GravityWell/Preview.axaml index f3e8b853..08723b68 100644 --- a/gallery/TKCode.Arc.Gallery/Demos/Cursor/GravityWell/Preview.axaml +++ b/gallery/TKCode.Arc.Gallery/Demos/Cursor/GravityWell/Preview.axaml @@ -1,7 +1,6 @@ @@ -17,8 +16,8 @@ Cursor="Cross" ItemsSource="{x:Static demos:GravityWellDemoData.Photos}"> - - + + diff --git a/gallery/TKCode.Arc.Gallery/Demos/Media/CardStack/CardStackDemoData.cs b/gallery/TKCode.Arc.Gallery/Demos/Media/CardStack/CardStackDemoData.cs index 561af079..cf275f24 100644 --- a/gallery/TKCode.Arc.Gallery/Demos/Media/CardStack/CardStackDemoData.cs +++ b/gallery/TKCode.Arc.Gallery/Demos/Media/CardStack/CardStackDemoData.cs @@ -1,6 +1,5 @@ using Avalonia.Controls; using Avalonia.Data.Converters; -using AtomUI.Controls; using TKCode.Arc.Gallery.Pages; namespace TKCode.Arc.Gallery.Demos; diff --git a/gallery/TKCode.Arc.Gallery/Demos/Media/CardStack/Photos.axaml b/gallery/TKCode.Arc.Gallery/Demos/Media/CardStack/Photos.axaml index 722c122a..ea994a60 100644 --- a/gallery/TKCode.Arc.Gallery/Demos/Media/CardStack/Photos.axaml +++ b/gallery/TKCode.Arc.Gallery/Demos/Media/CardStack/Photos.axaml @@ -1,7 +1,6 @@ - + - + @@ -12,7 +11,7 @@ Title="Rename project" Description="Choose a short name your team will recognize." Closed="OnClosed"> - + diff --git a/gallery/TKCode.Arc.Gallery/Demos/Overlays/Drawer/Sides.axaml b/gallery/TKCode.Arc.Gallery/Demos/Overlays/Drawer/Sides.axaml index 3b042c40..30493902 100644 --- a/gallery/TKCode.Arc.Gallery/Demos/Overlays/Drawer/Sides.axaml +++ b/gallery/TKCode.Arc.Gallery/Demos/Overlays/Drawer/Sides.axaml @@ -1,7 +1,6 @@ @@ -39,7 +38,7 @@ FontSize="14" TextWrapping="Wrap" Foreground="{arc:ArcDesignTokenResource TextSecondary}" /> - + diff --git a/gallery/TKCode.Arc.Gallery/Demos/Overlays/Popover/SmallForm.axaml b/gallery/TKCode.Arc.Gallery/Demos/Overlays/Popover/SmallForm.axaml index 35d8ae34..8bd0413c 100644 --- a/gallery/TKCode.Arc.Gallery/Demos/Overlays/Popover/SmallForm.axaml +++ b/gallery/TKCode.Arc.Gallery/Demos/Overlays/Popover/SmallForm.axaml @@ -1,7 +1,6 @@ @@ -13,7 +12,7 @@ FontSize="14" TextWrapping="Wrap" Foreground="{arc:ArcDesignTokenResource TextSecondary}" /> - + diff --git a/gallery/TKCode.Arc.Gallery/GalleryBuilderExtensions.cs b/gallery/TKCode.Arc.Gallery/GalleryBuilderExtensions.cs index 99b15f55..fc1b7c29 100644 --- a/gallery/TKCode.Arc.Gallery/GalleryBuilderExtensions.cs +++ b/gallery/TKCode.Arc.Gallery/GalleryBuilderExtensions.cs @@ -1,6 +1,6 @@ -using AtomUI; -using AtomUI.Generated.TKCodeArcGallery; +using TKCode.Arc.Generated.TKCodeArcGallery; using TKCode.Arc.Mobile; +using TKCode.Arc.Themes.Corners; using TKCode.Arc.Themes.Pixel; namespace TKCode.Arc.Gallery; @@ -8,19 +8,20 @@ namespace TKCode.Arc.Gallery; public static class GalleryBuilderExtensions { /// - /// Registers the gallery's shell text (Localization/*.xlf) with AtomUI, and the blocks, advanced charts and special - /// components its pages show (UseArcBlocks(), the mobile components (UseArcMobile()), the Pixel theme the Appearance panel offers (UseArcPixelTheme()), UseArcAdvancedCharts(), UseArcWorkflow(), UseArcSpecial()). Call it next to UseArc() in every host that shows a - /// ; - /// the host's UseLanguages decides which of the translations are used. + /// Registers the gallery's shell text (Localization/*.xlf) and enables everything the gallery's pages show: the + /// blocks (UseArcBlocks()), the mobile components (UseArcMobile()), the Pixel and corner styles the + /// Appearance panel offers (UseArcPixelTheme(), UseArcCorners()), UseArcAdvancedCharts(), + /// UseArcWorkflow() and UseArcSpecial(). Call it inside UseArc(...) in every host that shows a + /// ; the host's UseLanguages decides which of the translations are used. /// - public static IAtomUIBuilder UseArcGallery(this IAtomUIBuilder builder) - => GeneratedControlPackageRegistration.Definition.Register(builder, static package => - { - package.DependsOn(static dependency => dependency.UseArc()); - package.DependsOn(static dependency => dependency.UseArcBlocks()); - package.DependsOn(static dependency => dependency.UseArcAdvancedCharts()); - package.DependsOn(static dependency => dependency.UseArcWorkflow()); - package.DependsOn(static dependency => dependency.UseArcSpecial()); - package.DependsOn(static dependency => dependency.UseArcMobile()); - }).UseArcPixelTheme(); + public static ArcBuilder UseArcGallery(this ArcBuilder builder) + => builder.UsePackage(ArcGeneratedAssets.PackageId, static package => ArcGeneratedAssets.AddTo(package + .DependsOn(static b => b.UseArcControls()) + .DependsOn(static b => b.UseArcBlocks()) + .DependsOn(static b => b.UseArcAdvancedCharts()) + .DependsOn(static b => b.UseArcWorkflow()) + .DependsOn(static b => b.UseArcSpecial()) + .DependsOn(static b => b.UseArcMobile()) + .DependsOn(static b => b.UseArcPixelTheme()) + .DependsOn(static b => b.UseArcCorners()))); } diff --git a/gallery/TKCode.Arc.Gallery/Localization/GalleryLanguages.cs b/gallery/TKCode.Arc.Gallery/Localization/GalleryLanguages.cs index bce3bbef..25351dc9 100644 --- a/gallery/TKCode.Arc.Gallery/Localization/GalleryLanguages.cs +++ b/gallery/TKCode.Arc.Gallery/Localization/GalleryLanguages.cs @@ -1,5 +1,5 @@ -using AtomUI.Localization; using TKCode.Arc.Gallery.Docs; +using TKCode.Arc.Localization; namespace TKCode.Arc.Gallery.Localization; diff --git a/gallery/TKCode.Arc.Gallery/Localization/GalleryShellLangResourceKind.cs b/gallery/TKCode.Arc.Gallery/Localization/GalleryShellLangResourceKind.cs index ddf9077e..a60515cb 100644 --- a/gallery/TKCode.Arc.Gallery/Localization/GalleryShellLangResourceKind.cs +++ b/gallery/TKCode.Arc.Gallery/Localization/GalleryShellLangResourceKind.cs @@ -1,4 +1,4 @@ -using AtomUI.Localization; +using TKCode.Arc.Localization; namespace TKCode.Arc.Gallery.Localization; @@ -171,4 +171,10 @@ public enum GalleryShellLangResourceKind PhoneDeviceTablet, UpdateAvailable, UpdateDownload, + WindowPin, + WindowUnpin, + WindowMinimize, + WindowMaximize, + WindowRestore, + WindowClose, } diff --git a/gallery/TKCode.Arc.Gallery/Localization/GalleryText.cs b/gallery/TKCode.Arc.Gallery/Localization/GalleryText.cs index e5a228dc..7170ea48 100644 --- a/gallery/TKCode.Arc.Gallery/Localization/GalleryText.cs +++ b/gallery/TKCode.Arc.Gallery/Localization/GalleryText.cs @@ -1,35 +1,34 @@ using System.Globalization; -using AtomUI; -using AtomUI.Localization; using Avalonia; using Avalonia.Controls; using Avalonia.Threading; using TKCode.Arc.Gallery.Docs; +using TKCode.Arc.Localization; namespace TKCode.Arc.Gallery.Localization; /// -/// The gallery shell's text in the current AtomUI language. Strings live in Localization/*.xlf -/// (); AtomUI publishes them as application resources keyed by the enum +/// The gallery shell's text in the current Arc language. Strings live in Localization/*.xlf +/// (); Arc publishes them as application resources keyed by the enum /// value, so follows language changes by itself. Text that is composed from state (for example the /// "no match" message) is recomputed from . /// public static class GalleryText { - /// AtomUI's language manager, or null before the application has started. - public static ILanguageManager? Languages => Application.Current?.GetLanguageManager(); + /// The Arc language manager, or null before the application has started. + public static ArcLanguageManager? Languages => ArcLanguageManager.Current; /// The current language tag (en-US when there is no language manager). - public static string CurrentLanguage => Languages?.Current.CurrentLanguage.Value ?? GalleryDocs.SourceLanguage; + public static string CurrentLanguage => Languages?.Language.Value ?? GalleryDocs.SourceLanguage; /// Switches the whole application, the Code tab included, to . - public static void SetLanguage(string languageTag) => Languages?.ChangeLanguage(LanguageTag.Parse(languageTag)); + public static void SetLanguage(string languageTag) => Languages?.Set(LanguageTag.Parse(languageTag)); public static string Get(GalleryShellLangResourceKind kind) - => Application.Current?.GetLocalizer()?.Get(kind) ?? kind.ToString(); + => GalleryShellLangResources.Get(kind); public static string Format(GalleryShellLangResourceKind kind, params object?[] arguments) - => Application.Current?.GetLocalizer()?.Format(kind, arguments) ?? kind.ToString(); + => GalleryShellLangResources.Format(kind, arguments); /// Binds of to a string; it changes with the language. public static IDisposable Bind(StyledElement target, AvaloniaProperty property, GalleryShellLangResourceKind kind) @@ -39,7 +38,7 @@ public static IDisposable Bind(StyledElement target, AvaloniaProperty property, public static IDisposable BindFormat(StyledElement target, AvaloniaProperty property, GalleryShellLangResourceKind kind, params object?[] arguments) => target.Bind(property, target.GetResourceObservable(kind, pattern => pattern is string text - ? string.Format(Languages?.Current.FormattingCulture ?? CultureInfo.CurrentCulture, text, arguments) + ? string.Format(ArcLanguage.Culture, text, arguments) : pattern)); /// @@ -48,7 +47,7 @@ public static IDisposable BindFormat(StyledElement target, AvaloniaProperty prop /// public static void Track(Visual host, Action refresh) { - ILanguageManager? subscribed = null; + ArcLanguageManager? subscribed = null; EventHandler changed = (_, _) => { if (Dispatcher.UIThread.CheckAccess()) @@ -65,7 +64,7 @@ public static void Track(Visual host, Action refresh) subscribed = Languages; if (subscribed is not null) { - subscribed.LanguageChanged += changed; + subscribed.Changed += changed; } refresh(); }; @@ -73,7 +72,7 @@ public static void Track(Visual host, Action refresh) { if (subscribed is not null) { - subscribed.LanguageChanged -= changed; + subscribed.Changed -= changed; subscribed = null; } }; diff --git a/gallery/TKCode.Arc.Gallery/Localization/en-US.xlf b/gallery/TKCode.Arc.Gallery/Localization/en-US.xlf index 3ce5e75a..7b14dd93 100644 --- a/gallery/TKCode.Arc.Gallery/Localization/en-US.xlf +++ b/gallery/TKCode.Arc.Gallery/Localization/en-US.xlf @@ -826,5 +826,35 @@ Download + + + Keep on top + + + + + Stop keeping on top + + + + + Minimize + + + + + Maximize + + + + + Restore + + + + + Close + + diff --git a/gallery/TKCode.Arc.Gallery/Localization/zh-CN.xlf b/gallery/TKCode.Arc.Gallery/Localization/zh-CN.xlf index a0d54af4..8c3de4f8 100644 --- a/gallery/TKCode.Arc.Gallery/Localization/zh-CN.xlf +++ b/gallery/TKCode.Arc.Gallery/Localization/zh-CN.xlf @@ -991,5 +991,41 @@ 下载 + + + Keep on top + 窗口置顶 + + + + + Stop keeping on top + 取消置顶 + + + + + Minimize + 最小化 + + + + + Maximize + 最大化 + + + + + Restore + 还原 + + + + + Close + 关闭 + + diff --git a/gallery/TKCode.Arc.Gallery/Localization/zh-TW.xlf b/gallery/TKCode.Arc.Gallery/Localization/zh-TW.xlf index dec24cdb..ff06cea7 100644 --- a/gallery/TKCode.Arc.Gallery/Localization/zh-TW.xlf +++ b/gallery/TKCode.Arc.Gallery/Localization/zh-TW.xlf @@ -991,5 +991,41 @@ 下載 + + + Keep on top + 視窗置頂 + + + + + Stop keeping on top + 取消置頂 + + + + + Minimize + 最小化 + + + + + Maximize + 最大化 + + + + + Restore + 還原 + + + + + Close + 關閉 + + diff --git a/gallery/TKCode.Arc.Gallery/Shell/GalleryIcons.cs b/gallery/TKCode.Arc.Gallery/Shell/GalleryIcons.cs index 3206335a..897eec2c 100644 --- a/gallery/TKCode.Arc.Gallery/Shell/GalleryIcons.cs +++ b/gallery/TKCode.Arc.Gallery/Shell/GalleryIcons.cs @@ -5,7 +5,8 @@ namespace TKCode.Arc.Gallery.Shell; /// /// Lucide glyphs the gallery chrome needs whose data draws wrongly: the generator joins an /// icon's shapes into one path and leaves each later shape's leading m relative, so it starts from the end of -/// the previous shape instead of the origin. These are the same Lucide shapes with absolute starts. +/// the previous shape instead of the origin. These are the same Lucide shapes with absolute starts. It also holds the few +/// Lucide glyphs the chrome needs that the Core subset lacks (the title bar's pin). /// internal static class GalleryIcons { @@ -14,4 +15,12 @@ internal static class GalleryIcons public static Geometry Code { get; } = ArcIcons.Code; public static Geometry X { get; } = ArcIcons.X; + + /// Lucide pin (not in the Core subset): the title bar's keep-on-top button. + public static Geometry Pin { get; } = ArcIcons.Parse( + "M12 17v5 M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"); + + /// Lucide pin-off: the keep-on-top button while the window is pinned. + public static Geometry PinOff { get; } = ArcIcons.Parse( + "M12 17v5 M15 9.34V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H7.89 M2 2l20 20 M9 9v1.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h11"); } diff --git a/gallery/TKCode.Arc.Gallery/Shell/GalleryPageView.cs b/gallery/TKCode.Arc.Gallery/Shell/GalleryPageView.cs index db04ceb9..d70ac0bb 100644 --- a/gallery/TKCode.Arc.Gallery/Shell/GalleryPageView.cs +++ b/gallery/TKCode.Arc.Gallery/Shell/GalleryPageView.cs @@ -22,7 +22,7 @@ namespace TKCode.Arc.Gallery.Shell; /// One component page, laid out like a uiarc.dev docs page: breadcrumb, title, summary, install command and status, the /// page content, and a sticky "On this page" list that follows the scroll position. The content is the component's docs /// page () with its live demos (): "On this page" lists the page's -/// sections and "Copy page" copies it, in the current AtomUI language. A component without demos (a planned one) shows a +/// sections and "Copy page" copies it, in the current Arc language. A component without demos (a planned one) shows a /// plain body instead, such as its placeholder. /// public sealed class GalleryPageView : Grid @@ -338,7 +338,7 @@ private static Control Labeled(GalleryShellLangResourceKind text, Geometry glyph } /// - /// Shows the summary and the docs page in the current AtomUI language. A page that is not translated falls back to + /// Shows the summary and the docs page in the current Arc language. A page that is not translated falls back to /// en-US by itself, and a component without a docs page keeps its catalog description. /// private void RefreshLanguage() diff --git a/gallery/TKCode.Arc.Gallery/Shell/GallerySearchBox.cs b/gallery/TKCode.Arc.Gallery/Shell/GallerySearchBox.cs new file mode 100644 index 00000000..4cfde50e --- /dev/null +++ b/gallery/TKCode.Arc.Gallery/Shell/GallerySearchBox.cs @@ -0,0 +1,73 @@ +using TKCode.Arc.DesignTokens; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Presenters; +using Avalonia.Controls.Templates; +using Avalonia.Data; +using Avalonia.Data.Converters; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Styling; + +namespace TKCode.Arc.Gallery.Shell; + +/// +/// The sidebar's borderless search input: a bare text box (placeholder and text presenter only) whose frame, icon and +/// focus border belong to the sidebar. It carries its own template so it does not depend on a theme for TextBox. +/// +internal sealed class GallerySearchBox : TextBox +{ + private static readonly ControlTheme s_theme = BuildTheme(); + + protected override Type StyleKeyOverride => typeof(TextBox); + + public GallerySearchBox() + { + Theme = s_theme; + ArcTokens.Bind(this, CaretBrushProperty, ArcDesignTokenKind.Foreground); + ArcTokens.Bind(this, SelectionBrushProperty, ArcDesignTokenKind.AccentSubtle); + } + + private static ControlTheme BuildTheme() + { + var theme = new ControlTheme(typeof(TextBox)); + theme.Setters.Add(new Setter(FocusAdornerProperty, null)); + theme.Setters.Add(new Setter(CursorProperty, new Cursor(StandardCursorType.Ibeam))); + theme.Setters.Add(new Setter(AcceptsReturnProperty, false)); + theme.Setters.Add(new Setter(TemplateProperty, new FuncControlTemplate(static (box, scope) => + { + var placeholder = new TextBlock + { + Name = "PART_Watermark", + VerticalAlignment = VerticalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis, + IsHitTestVisible = false, + [!TextBlock.TextProperty] = box[!PlaceholderTextProperty], + [!IsVisibleProperty] = new TemplateBinding(TextProperty) { Converter = StringConverters.IsNullOrEmpty } + }.RegisterInNameScope(scope); + ArcTokens.Bind(placeholder, TextBlock.ForegroundProperty, ArcDesignTokenKind.TextMuted); + var presenter = new TextPresenter + { + Name = "PART_TextPresenter", + VerticalAlignment = VerticalAlignment.Center, + TextWrapping = TextWrapping.NoWrap, + [!TextPresenter.TextProperty] = new TemplateBinding(TextProperty) { Mode = BindingMode.TwoWay }, + [!TextPresenter.CaretIndexProperty] = box[!CaretIndexProperty], + [!TextPresenter.SelectionStartProperty] = box[!SelectionStartProperty], + [!TextPresenter.SelectionEndProperty] = box[!SelectionEndProperty], + [!TextPresenter.TextAlignmentProperty] = box[!TextAlignmentProperty], + [!TextPresenter.SelectionBrushProperty] = box[!SelectionBrushProperty], + [!TextPresenter.SelectionForegroundBrushProperty] = box[!SelectionForegroundBrushProperty], + [!TextPresenter.CaretBrushProperty] = box[!CaretBrushProperty] + }.RegisterInNameScope(scope); + return new Panel + { + Background = Brushes.Transparent, + [!MarginProperty] = box[!PaddingProperty], + Children = { placeholder, presenter } + }; + }))); + return theme; + } +} diff --git a/gallery/TKCode.Arc.Gallery/Shell/GallerySidebar.cs b/gallery/TKCode.Arc.Gallery/Shell/GallerySidebar.cs index 1ae59886..293267e5 100644 --- a/gallery/TKCode.Arc.Gallery/Shell/GallerySidebar.cs +++ b/gallery/TKCode.Arc.Gallery/Shell/GallerySidebar.cs @@ -12,7 +12,6 @@ using Avalonia.Input; using Avalonia.Layout; using Avalonia.Media; -using AtomTextBox = AtomUI.Desktop.Controls.TextBox; namespace TKCode.Arc.Gallery.Shell; @@ -74,7 +73,7 @@ private sealed class Item private readonly Dictionary _byClass = new(); private readonly Panel _content = new(); private readonly GlidingIndicator _indicator = new() { IsHitTestVisible = false, Spring = ArcSpring.Snappy }; - private readonly AtomTextBox _search = new(); + private readonly GallerySearchBox _search = new(); private readonly Border _searchBox = new(); private readonly GallerySegmented _filter = new(GalleryShellLangResourceKind.FilterAll, GalleryShellLangResourceKind.FilterReady, GalleryShellLangResourceKind.FilterPlanned); private readonly List
_sections = new(); @@ -197,7 +196,6 @@ public GallerySidebar() private Control BuildSearchBox() { GalleryText.Bind(_search, TextBox.PlaceholderTextProperty, GalleryShellLangResourceKind.SearchComponents); - _search.StyleVariant = AtomUI.Controls.InputControlStyleVariant.Borderless; _search.BorderThickness = new Thickness(0); _search.Padding = new Thickness(0); _search.MinHeight = 0; diff --git a/gallery/TKCode.Arc.Gallery/Shell/GalleryTheme.cs b/gallery/TKCode.Arc.Gallery/Shell/GalleryTheme.cs index 2f69b762..5460ed37 100644 --- a/gallery/TKCode.Arc.Gallery/Shell/GalleryTheme.cs +++ b/gallery/TKCode.Arc.Gallery/Shell/GalleryTheme.cs @@ -1,16 +1,11 @@ -using AtomUI; using TKCode.Arc.Motion; -using AtomUI.Theme; -using AtomUI.Theme.Algorithms; -using AtomUI.Theme.Configuration; using TKCode.Arc.Themes.Corners; using TKCode.Arc.Themes.Pixel; -using Avalonia; using Avalonia.Controls; namespace TKCode.Arc.Gallery.Shell; -/// Holds the gallery's appearance, accent and motion level, and applies them through AtomUI. +/// Holds the gallery's appearance, accent and motion level, and applies them through the Arc theme manager. public sealed class GalleryTheme { public bool IsDark { get; private set; } @@ -63,30 +58,34 @@ public void SetMotion(Control owner, ArcMotionLevel level) Changed?.Invoke(); } - private async Task ApplyAsync() + // ArcThemeManager.Apply, not ApplyAsync: computing every token takes a few milliseconds, and callers may wait on these + // tasks on the UI thread, which ApplyAsync's UI-thread publish cannot complete under (see ArcThemeManager.ApplyAsync). + private Task ApplyAsync() { - var manager = Application.Current?.GetThemeManager(); + var manager = ArcThemeManager.Current; if (manager is null) { - return; + return Task.CompletedTask; } - if (IsPixel) - { - // The pixel style is a compiled theme pair; its config already carries light/dark and the palette. - await manager.ApplyThemeAsync(ArcPixelTheme.CreateRequest(IsDark, scope: ArcPixelScope.All)); - ArcPixelRendering.ApplyToApplication(true); - Changed?.Invoke(); - return; - } - ArcPixelRendering.ApplyToApplication(false); - var builder = new ThemeConfigBuilder(); - builder = IsDark - ? builder.WithAlgorithms(ThemeAlgorithm.Default, ThemeAlgorithm.Dark) - : builder.WithAlgorithms(ThemeAlgorithm.Default); + var dark = IsDark; + var pixel = IsPixel; var accent = Accent; var corners = CornerStyle; - builder = builder.WithArc(arc => arc.Accent(accent).Corners(corners, ArcCornerScope.All)); - await manager.ApplyThemeAsync(new ThemeRequest(IThemeManager.DEFAULT_THEME_ID, builder.Build(), ThemeTransitionReason.UserRequest)); + manager.Apply(t => + { + t.Appearance(dark ? ArcAppearance.Dark : ArcAppearance.Light); + if (pixel) + { + // The Pixel style brings its own palette and square corners. + t.DefaultAccent().RemovePreset(ArcCornerPresets.Slot).Pixel(); + } + else + { + t.Accent(accent).RemovePreset(ArcPixelPresets.Slot).Corners(corners); + } + }); + ArcPixelRendering.ApplyToApplication(pixel); Changed?.Invoke(); + return Task.CompletedTask; } } diff --git a/gallery/TKCode.Arc.Gallery/Shell/GalleryTitleBar.cs b/gallery/TKCode.Arc.Gallery/Shell/GalleryTitleBar.cs new file mode 100644 index 00000000..a16a5dc0 --- /dev/null +++ b/gallery/TKCode.Arc.Gallery/Shell/GalleryTitleBar.cs @@ -0,0 +1,279 @@ +using TKCode.Arc.DesignTokens; +using TKCode.Arc.Gallery.Localization; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Chrome; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; + +namespace TKCode.Arc.Gallery.Shell; + +/// +/// The desktop window's own title bar, drawn in the client area ( extends it into the +/// decorations): the logo and the window title, then the pin that keeps the window on top and, on Windows and Linux, the +/// minimize, maximize / restore and close buttons. On macOS the system's traffic lights stay and the bar leaves room for +/// them on the left. Pressing the bar's empty part moves the window and a double click maximizes or restores it. +/// +public sealed class GalleryTitleBar : Border +{ + /// The bar's height; the window asks the system for a title bar of the same height (macOS centres its lights in it). + public const double BarHeight = 40; + + /// The room the macOS traffic lights take at the left of the bar. + private const double TrafficLightsWidth = 78; + + private const double ButtonSize = 30; + + private readonly Window _window; + private readonly Border _leading = new(); + private readonly TextBlock _title = new() { VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis }; + private readonly Button _pin; + private readonly Button? _maximize; + private IDisposable? _pinLabel; + private IDisposable? _maximizeLabel; + private IDisposable? _titleColor; + + /// The window the bar moves, pins, minimizes, maximizes and closes. + /// + /// True on Windows and Linux: the bar draws minimize, maximize and close. False on macOS, whose traffic lights stay. + /// + public GalleryTitleBar(Window window, bool drawsCaptionButtons) + { + _window = window; + DrawsCaptionButtons = drawsCaptionButtons; + Height = BarHeight; + BorderThickness = new Thickness(0, 0, 0, 1); + ArcTokens.Bind(this, BackgroundProperty, ArcDesignTokenKind.Background); + ArcTokens.Bind(this, BorderBrushProperty, ArcDesignTokenKind.BorderSubtle); + + var logo = new Image { Source = GalleryBrand.CreateLogo(), Width = 18, Height = 18, VerticalAlignment = VerticalAlignment.Center }; + _title.FontSize = 13; + ArcTokens.Bind(_title, TextBlock.FontWeightProperty, ArcDesignTokenKind.WeightMedium); + _title.Bind(TextBlock.TextProperty, window.GetObservable(Window.TitleProperty)); + var brand = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 8, + VerticalAlignment = VerticalAlignment.Center, + Children = { logo, _title } + }; + Brand = brand; + + _pin = CaptionButton(GalleryIcons.Pin); + _pin.Click += (_, _) => window.Topmost = !window.Topmost; + + var buttons = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 4, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 6, 0) }; + buttons.Children.Add(_pin); + if (drawsCaptionButtons) + { + // A hairline between the pin and the window's own buttons. + var divider = new Border { Width = 1, Height = 16, Margin = new Thickness(4, 0), VerticalAlignment = VerticalAlignment.Center }; + ArcTokens.Bind(divider, BackgroundProperty, ArcDesignTokenKind.BorderSubtle); + buttons.Children.Add(divider); + + var minimize = MinimizeButton = CaptionButton(ArcIcons.Minus); + Label(minimize, GalleryShellLangResourceKind.WindowMinimize); + minimize.Click += (_, _) => window.WindowState = WindowState.Minimized; + + _maximize = CaptionButton(ArcIcons.Square); + _maximize.Click += (_, _) => ToggleMaximize(); + + var close = CloseButton = CaptionButton(ArcIcons.X); + Label(close, GalleryShellLangResourceKind.WindowClose); + close.Click += (_, _) => window.Close(); + UseDangerHover(close); + + buttons.Children.Add(minimize); + buttons.Children.Add(_maximize); + buttons.Children.Add(close); + } + Buttons = buttons; + + var row = new Grid { ColumnDefinitions = new ColumnDefinitions("Auto,Auto,*,Auto") }; + brand.Margin = new Thickness(drawsCaptionButtons ? 14 : 4, 0, 12, 0); + Grid.SetColumn(brand, 1); + Grid.SetColumn(buttons, 3); + row.Children.Add(_leading); + row.Children.Add(brand); + row.Children.Add(buttons); + Child = row; + + window.PropertyChanged += OnWindowPropertyChanged; + SyncPin(); + SyncState(); + SyncActive(); + } + + /// True when the bar draws minimize, maximize and close (Windows and Linux); false on macOS. + public bool DrawsCaptionButtons { get; } + + /// The logo and the window title. + public StackPanel Brand { get; } + + /// The buttons at the end of the bar: the pin, then minimize, maximize and close when the bar draws them. + public StackPanel Buttons { get; } + + /// Keeps the window above other windows () or lets it go. + public Button PinButton => _pin; + + /// Minimizes the window; null on macOS. + public Button? MinimizeButton { get; } + + /// Maximizes or restores the window; null on macOS. + public Button? MaximizeButton => _maximize; + + /// Closes the window; null on macOS. + public Button? CloseButton { get; } + + /// The width kept free at the left for the macOS traffic lights (0 elsewhere and in full screen). + public double LeadingInset => _leading.Width is var width && double.IsFinite(width) ? width : 0; + + /// Maximizes a normal window and restores a maximized one. + public void ToggleMaximize() + { + if (!_window.CanResize || !_window.CanMaximize) + { + return; + } + _window.WindowState = _window.WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized; + } + + /// + /// The bar's empty part (and the brand) is the window's handle: a press moves the window, a double click maximizes or + /// restores it. Buttons take their own presses, so a press on one never reaches the bar. + /// + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + if (e.Handled || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + { + return; + } + if (e.ClickCount == 2) + { + ToggleMaximize(); + } + else if (_window.WindowState != WindowState.FullScreen) + { + _window.BeginMoveDrag(e); + } + e.Handled = true; + } + + /// A square ghost Arc button with a glyph; it takes input even where the system would see a title bar. + private static Button CaptionButton(Geometry glyph) + { + var button = new Button + { + Variant = ButtonVariant.Ghost, + Size = ControlSize.Small, + IsPressScaleEnabled = false, + Padding = new Thickness(0), + Width = ButtonSize, + Height = ButtonSize, + MinWidth = ButtonSize, + MinHeight = ButtonSize, + Focusable = false, + Content = new ArcGlyph { Data = glyph, Size = 15 } + }; + WindowDecorationProperties.SetElementRole(button, WindowDecorationsElementRole.User); + return button; + } + + /// Gives a button its tooltip and its accessible name, both following the language. + private static IDisposable Label(Button button, GalleryShellLangResourceKind text) + { + var tip = GalleryText.Bind(button, Tooltip.TipProperty, text); + var name = GalleryText.Bind(button, Avalonia.Automation.AutomationProperties.NameProperty, text); + return new CompositeLabel(tip, name); + } + + private sealed class CompositeLabel(IDisposable tip, IDisposable name) : IDisposable + { + public void Dispose() + { + tip.Dispose(); + name.Dispose(); + } + } + + /// Close turns red under the pointer: a danger-tinted fill and a danger glyph, as Arc marks destructive actions. + private static void UseDangerHover(Button button) + { + var bindings = new List(); + button.PropertyChanged += (_, e) => + { + if (e.Property != IsPointerOverProperty) + { + return; + } + foreach (var binding in bindings) + { + binding.Dispose(); + } + bindings.Clear(); + if (button.IsPointerOver) + { + bindings.Add(ArcTokens.Bind(button, Avalonia.Controls.Primitives.TemplatedControl.BackgroundProperty, ArcDesignTokenKind.DangerSubtle, BindingPriority.LocalValue)); + bindings.Add(ArcTokens.Bind(button, Avalonia.Controls.Primitives.TemplatedControl.ForegroundProperty, ArcDesignTokenKind.Danger, BindingPriority.LocalValue)); + } + else + { + button.ClearValue(Avalonia.Controls.Primitives.TemplatedControl.BackgroundProperty); + button.ClearValue(Avalonia.Controls.Primitives.TemplatedControl.ForegroundProperty); + } + }; + } + + private void OnWindowPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) + { + if (e.Property == Window.TopmostProperty) + { + SyncPin(); + } + else if (e.Property == Window.WindowStateProperty) + { + SyncState(); + } + else if (e.Property == WindowBase.IsActiveProperty) + { + SyncActive(); + } + } + + /// A pinned window shows the crossed-out pin on a secondary (filled) button; the tooltip says what a press does next. + private void SyncPin() + { + var pinned = _window.Topmost; + _pin.Content = new ArcGlyph { Data = pinned ? GalleryIcons.PinOff : GalleryIcons.Pin, Size = 15 }; + _pin.Variant = pinned ? ButtonVariant.Secondary : ButtonVariant.Ghost; + _pinLabel?.Dispose(); + _pinLabel = Label(_pin, pinned ? GalleryShellLangResourceKind.WindowUnpin : GalleryShellLangResourceKind.WindowPin); + } + + private void SyncState() + { + var state = _window.WindowState; + // The traffic lights hide in full screen, so their room goes too. + _leading.Width = !DrawsCaptionButtons && state != WindowState.FullScreen ? TrafficLightsWidth : 0; + if (_maximize is null) + { + return; + } + var maximized = state == WindowState.Maximized; + _maximize.Content = new ArcGlyph { Data = maximized ? ArcIcons.Copy : ArcIcons.Square, Size = maximized ? 14 : 13 }; + _maximize.IsEnabled = _window.CanResize && _window.CanMaximize; + _maximizeLabel?.Dispose(); + _maximizeLabel = Label(_maximize, maximized ? GalleryShellLangResourceKind.WindowRestore : GalleryShellLangResourceKind.WindowMaximize); + } + + /// The title dims while another window is active, as system title bars do. + private void SyncActive() + { + _titleColor?.Dispose(); + _titleColor = ArcTokens.Bind(_title, TextBlock.ForegroundProperty, + _window.IsActive ? ArcDesignTokenKind.Foreground : ArcDesignTokenKind.TextMuted); + } +} diff --git a/gallery/TKCode.Arc.Gallery/Shell/GalleryWindow.cs b/gallery/TKCode.Arc.Gallery/Shell/GalleryWindow.cs index e78314f3..3066e94c 100644 --- a/gallery/TKCode.Arc.Gallery/Shell/GalleryWindow.cs +++ b/gallery/TKCode.Arc.Gallery/Shell/GalleryWindow.cs @@ -1,15 +1,37 @@ -using AtomWindow = AtomUI.Desktop.Controls.Window; +using TKCode.Arc.DesignTokens; using TKCode.Arc.Gallery.Catalog; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Chrome; +using Avalonia.Controls.Presenters; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Templates; using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Styling; +using Avalonia.Threading; namespace TKCode.Arc.Gallery.Shell; -/// The desktop window around a ; the browser host shows the shell as its single view. -public sealed class GalleryWindow : AtomWindow +/// +/// The desktop window around a ; the browser host shows the shell as its single view. The client +/// area extends into the decorations and the window draws its own above the shell (on macOS +/// the system's traffic lights stay in it). Its template hosts bar and shell in a named visual layer manager, so popups, +/// toasts, tooltips and focus adorners have their overlay and adorner layers, and its text defaults follow ArcDesign. +/// +public sealed class GalleryWindow : Window { private readonly GalleryShell _shell = new(); + private Control? _frame; + /// A window for this system: caption buttons drawn on Windows and Linux, the traffic lights kept on macOS. public GalleryWindow() + : this(!OperatingSystem.IsMacOS()) + { + } + + /// Whether the title bar draws minimize, maximize and close (see ). + public GalleryWindow(bool drawsCaptionButtons) { Title = "TKCode.Arc Gallery"; Icon = GalleryBrand.CreateWindowIcon(); @@ -17,6 +39,17 @@ public GalleryWindow() Height = 860; MinWidth = 900; MinHeight = 600; + TitleBar = new GalleryTitleBar(this, drawsCaptionButtons); + ExtendClientAreaToDecorationsHint = true; + ExtendClientAreaTitleBarHeightHint = GalleryTitleBar.BarHeight; + // Where the system leaves the decorations to the app (Windows, Linux), Avalonia draws a title bar from the + // decorations theme; an empty theme draws nothing, so the gallery's bar is the only one. Resize grips stay. + WindowDecorationsTheme = new ControlTheme(typeof(WindowDrawnDecorations)); + Template = new FuncControlTemplate(BuildTemplate); + ArcTokens.Bind(this, BackgroundProperty, ArcDesignTokenKind.Background); + ArcTokens.Bind(this, ForegroundProperty, ArcDesignTokenKind.Foreground); + ArcTokens.Bind(this, FontFamilyProperty, ArcDesignTokenKind.BodyFontFamily); + ArcTokens.Bind(this, FontSizeProperty, ArcDesignTokenKind.TypeSM); Content = _shell; } @@ -26,6 +59,9 @@ public GalleryWindow() public GalleryTopBar TopBar => _shell.TopBar; + /// The window's own title bar: logo, title, the keep-on-top pin and (not on macOS) the caption buttons. + public GalleryTitleBar TitleBar { get; } + public GalleryShell Shell => _shell; protected override void OnKeyDown(KeyEventArgs e) @@ -40,4 +76,49 @@ protected override void OnKeyDown(KeyEventArgs e) /// Opens 's page and reveals it in the sidebar. public void Select(GalleryEntry entry) => _shell.Select(entry); + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == WindowStateProperty) + { + // Windows keeps the frame of a maximized window off screen; the next layout knows the margin. + Dispatcher.UIThread.Post(SyncOffScreenMargin); + } + } + + private void SyncOffScreenMargin() + { + if (_frame is not null) + { + _frame.Margin = WindowState == WindowState.Maximized ? OffScreenMargin : default; + } + } + + /// + /// Background, then the title bar over the content inside a visual layer manager. Avalonia 12 gives the manager named + /// PART_VisualLayerManager its overlay layer, which hosts Arc's popups, tooltips and toasts. + /// + private static Control BuildTemplate(GalleryWindow window, INameScope scope) + { + var presenter = new ContentPresenter(); + presenter.Bind(ContentPresenter.ContentProperty, window.GetObservable(ContentProperty)); + presenter.Bind(ContentPresenter.ContentTemplateProperty, window.GetObservable(ContentTemplateProperty)); + presenter.Bind(Layoutable.MarginProperty, window.GetObservable(PaddingProperty)); + scope.Register("PART_ContentPresenter", presenter); + + var frame = new DockPanel(); + DockPanel.SetDock(window.TitleBar, Dock.Top); + frame.Children.Add(window.TitleBar); + frame.Children.Add(presenter); + + var layers = new VisualLayerManager { Child = frame }; + scope.Register("PART_VisualLayerManager", layers); + + var background = new Border { Child = layers }; + background.Bind(Border.BackgroundProperty, window.GetObservable(BackgroundProperty)); + window._frame = background; + window.SyncOffScreenMargin(); + return background; + } } diff --git a/gallery/TKCode.Arc.Gallery/TKCode.Arc.Gallery.csproj b/gallery/TKCode.Arc.Gallery/TKCode.Arc.Gallery.csproj index f1d3fc6f..f17e4485 100644 --- a/gallery/TKCode.Arc.Gallery/TKCode.Arc.Gallery.csproj +++ b/gallery/TKCode.Arc.Gallery/TKCode.Arc.Gallery.csproj @@ -4,9 +4,9 @@ TKCode.Arc.Gallery false $(NoWarn);CS0436 + true - @@ -38,7 +38,7 @@ + The API translations stay out of Localization/ folders, which the Foundation generator reads as language catalogs. --> diff --git a/src/TKCode.Arc.AdvancedCharts/AdvancedChartsBuilderExtensions.cs b/src/TKCode.Arc.AdvancedCharts/AdvancedChartsBuilderExtensions.cs index 4918bb46..6af3fbf3 100644 --- a/src/TKCode.Arc.AdvancedCharts/AdvancedChartsBuilderExtensions.cs +++ b/src/TKCode.Arc.AdvancedCharts/AdvancedChartsBuilderExtensions.cs @@ -1,5 +1,4 @@ -using AtomUI; -using AtomUI.Generated.TKCodeArcAdvancedCharts; +using TKCode.Arc.Generated.TKCodeArcAdvancedCharts; namespace TKCode.Arc; @@ -7,11 +6,9 @@ public static class ArcAdvancedChartsBuilderExtensions { /// /// Enables the Arc advanced charts () and their built-in text in en-US, zh-CN and zh-TW. - /// Depends on . + /// Depends on . /// - public static IAtomUIBuilder UseArcAdvancedCharts(this IAtomUIBuilder builder) - => GeneratedControlPackageRegistration.Definition.Register(builder, static package => - { - package.DependsOn(static dependency => dependency.UseArc()); - }); + public static ArcBuilder UseArcAdvancedCharts(this ArcBuilder builder) + => builder.UsePackage(ArcGeneratedAssets.PackageId, static package => ArcGeneratedAssets.AddTo(package + .DependsOn(static b => b.UseArcControls()))); } diff --git a/src/TKCode.Arc.AdvancedCharts/DataGrid/DataGrid.Chrome.cs b/src/TKCode.Arc.AdvancedCharts/DataGrid/DataGrid.Chrome.cs index a7ee84df..b2b3b1e9 100644 --- a/src/TKCode.Arc.AdvancedCharts/DataGrid/DataGrid.Chrome.cs +++ b/src/TKCode.Arc.AdvancedCharts/DataGrid/DataGrid.Chrome.cs @@ -288,10 +288,12 @@ internal void OpenSelectFilter(Control anchor, int accessor) { var column = ColumnOf(accessor); var options = FilterOptionsOf(accessor); - var search = new TextBox + // The filter row's input (DataGridTheme gives it its template; a plain TextBox has none of its own). + var search = new DataGridFilterBox { PlaceholderText = DataGridText.Get(DataGridLangResourceKind.SearchValues), IsVisible = options.Count > 7, + Height = 30, Margin = new Thickness(4, 4, 4, 6) }; AutomationProperties.SetName(search, DataGridText.Get(DataGridLangResourceKind.SearchValues)); diff --git a/src/TKCode.Arc.AdvancedCharts/DataGrid/DataGrid.cs b/src/TKCode.Arc.AdvancedCharts/DataGrid/DataGrid.cs index 243faef5..132bc94d 100644 --- a/src/TKCode.Arc.AdvancedCharts/DataGrid/DataGrid.cs +++ b/src/TKCode.Arc.AdvancedCharts/DataGrid/DataGrid.cs @@ -13,7 +13,6 @@ using Avalonia.Media; using Avalonia.Metadata; using Avalonia.Threading; -using AtomUI.Localization; using TKCode.Arc.Localization; namespace TKCode.Arc; @@ -178,7 +177,7 @@ public partial class DataGrid : TemplatedControl public static readonly StyledProperty EmptyMessageProperty = AvaloniaProperty.Register(nameof(EmptyMessage)); - /// Culture for formatting and parsing values; null follows the AtomUI language. + /// Culture for formatting and parsing values; null follows the Arc language. public static readonly StyledProperty CultureProperty = AvaloniaProperty.Register(nameof(Culture)); @@ -542,7 +541,7 @@ public event EventHandler? Exporting private DataGridToolbar? _toolbar; private DataGridStatusBar? _statusBar; private TextBlock? _announcer; - private ILanguageManager? _languages; + private ArcLanguageManager? _languages; internal DataGridPalette Palette { get; } = new(); @@ -577,7 +576,7 @@ public DataGrid() _store.RowChanged += OnStoreRowChanged; } - /// The effective formatting culture: , else the AtomUI language's formatting culture. + /// The effective formatting culture: , else the Arc language's culture. internal CultureInfo EffectiveCulture => Culture ?? DataGridText.FormattingCulture; internal bool HasDataSource => DataSource is not null; @@ -631,7 +630,7 @@ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) _languages = DataGridText.Languages; if (_languages is not null) { - _languages.LanguageChanged += OnLanguageChanged; + _languages.Changed += OnLanguageChanged; } // Whether a save dialog exists depends on the top level. _toolbar?.UpdateState(); @@ -642,7 +641,7 @@ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e base.OnDetachedFromVisualTree(e); if (_languages is not null) { - _languages.LanguageChanged -= OnLanguageChanged; + _languages.Changed -= OnLanguageChanged; _languages = null; } CancelBlockRequests(); diff --git a/src/TKCode.Arc.AdvancedCharts/DataGrid/DataGridToken.cs b/src/TKCode.Arc.AdvancedCharts/DataGrid/DataGridToken.cs index 16557c1f..bd1ba6ab 100644 --- a/src/TKCode.Arc.AdvancedCharts/DataGrid/DataGridToken.cs +++ b/src/TKCode.Arc.AdvancedCharts/DataGrid/DataGridToken.cs @@ -1,4 +1,3 @@ -using AtomUI.Theme.DesignTokens; using Avalonia; namespace TKCode.Arc; diff --git a/src/TKCode.Arc.AdvancedCharts/DataGrid/Localization/DataGridLangResourceKind.cs b/src/TKCode.Arc.AdvancedCharts/DataGrid/Localization/DataGridLangResourceKind.cs index 378223d9..f871d00e 100644 --- a/src/TKCode.Arc.AdvancedCharts/DataGrid/Localization/DataGridLangResourceKind.cs +++ b/src/TKCode.Arc.AdvancedCharts/DataGrid/Localization/DataGridLangResourceKind.cs @@ -1,4 +1,3 @@ -using AtomUI.Localization; namespace TKCode.Arc.Localization; diff --git a/src/TKCode.Arc.AdvancedCharts/DataGrid/Localization/DataGridText.cs b/src/TKCode.Arc.AdvancedCharts/DataGrid/Localization/DataGridText.cs index 9eeaf210..5707e219 100644 --- a/src/TKCode.Arc.AdvancedCharts/DataGrid/Localization/DataGridText.cs +++ b/src/TKCode.Arc.AdvancedCharts/DataGrid/Localization/DataGridText.cs @@ -1,30 +1,20 @@ using System.Globalization; -using AtomUI; -using AtomUI.Localization; -using Avalonia; using TKCode.Arc.Localization; namespace TKCode.Arc; -/// The grid's built-in text in the current AtomUI language (Localization/*.xlf). +/// The grid's built-in text in the current Arc language (Localization/*.xlf). internal static class DataGridText { - public static ILanguageManager? Languages => Application.Current?.GetLanguageManager(); + public static ArcLanguageManager? Languages => ArcLanguageManager.Current; - /// The AtomUI formatting culture, or the current culture before the application starts. - public static CultureInfo FormattingCulture => Languages?.Current.FormattingCulture ?? CultureInfo.CurrentCulture; + /// The culture of the current Arc language (the current culture before the application starts). + public static CultureInfo FormattingCulture => ArcLanguage.Culture; - public static string Get(DataGridLangResourceKind kind) => - Application.Current?.GetLocalizer()?.Get(kind) ?? kind.ToString(); + public static string Get(DataGridLangResourceKind kind) => DataGridLangResources.Get(kind); - public static string Format(DataGridLangResourceKind kind, params object?[] arguments) - { - if (Application.Current?.GetLocalizer() is { } localizer) - { - return localizer.Format(kind, arguments); - } - return kind + " " + string.Join(' ', arguments); - } + public static string Format(DataGridLangResourceKind kind, params object?[] arguments) => + DataGridLangResources.Format(kind, arguments); /// Picks the singular or plural entry for and formats it. public static string Count(DataGridLangResourceKind one, DataGridLangResourceKind other, int count, CultureInfo culture) => diff --git a/src/TKCode.Arc.AdvancedCharts/DataGrid/Themes/DataGridTheme.axaml b/src/TKCode.Arc.AdvancedCharts/DataGrid/Themes/DataGridTheme.axaml index c8a4f38f..94efe1e2 100644 --- a/src/TKCode.Arc.AdvancedCharts/DataGrid/Themes/DataGridTheme.axaml +++ b/src/TKCode.Arc.AdvancedCharts/DataGrid/Themes/DataGridTheme.axaml @@ -1,7 +1,8 @@ + x:ClassModifier="internal" + x:Class="TKCode.Arc.DataGridTheme"> diff --git a/src/TKCode.Arc.AdvancedCharts/TKCode.Arc.AdvancedCharts.csproj b/src/TKCode.Arc.AdvancedCharts/TKCode.Arc.AdvancedCharts.csproj index da188b20..8d781e22 100644 --- a/src/TKCode.Arc.AdvancedCharts/TKCode.Arc.AdvancedCharts.csproj +++ b/src/TKCode.Arc.AdvancedCharts/TKCode.Arc.AdvancedCharts.csproj @@ -5,13 +5,11 @@ TKCode.Arc TKCode.Arc.AdvancedCharts Advanced charts for TKCode.Arc: DataGrid, a spreadsheet grid with range selection, inline editing, a fill handle, Excel and Sheets clipboard, undo, sorting, filtering, grouping, totals and export. - $(AssemblyName) - TKCode.Arc.AdvancedCharts $(NoWarn);CS0436 + true - diff --git a/src/TKCode.Arc.Blocks/Blocks/Ai/AgentMemory/AgentMemoryToken.cs b/src/TKCode.Arc.Blocks/Blocks/Ai/AgentMemory/AgentMemoryToken.cs index 1e99ab89..39ed7413 100644 --- a/src/TKCode.Arc.Blocks/Blocks/Ai/AgentMemory/AgentMemoryToken.cs +++ b/src/TKCode.Arc.Blocks/Blocks/Ai/AgentMemory/AgentMemoryToken.cs @@ -1,4 +1,3 @@ -using AtomUI.Theme.DesignTokens; using Avalonia; using Avalonia.Media; diff --git a/src/TKCode.Arc.Blocks/Blocks/Ai/AgentMemory/Themes/AgentMemoryTheme.axaml b/src/TKCode.Arc.Blocks/Blocks/Ai/AgentMemory/Themes/AgentMemoryTheme.axaml index 498928b4..219b76f0 100644 --- a/src/TKCode.Arc.Blocks/Blocks/Ai/AgentMemory/Themes/AgentMemoryTheme.axaml +++ b/src/TKCode.Arc.Blocks/Blocks/Ai/AgentMemory/Themes/AgentMemoryTheme.axaml @@ -1,7 +1,8 @@ + x:ClassModifier="internal" + x:Class="TKCode.Arc.AgentMemoryTheme"> diff --git a/src/TKCode.Arc.Blocks/Blocks/Ai/ModelPicker/ModelPickerToken.cs b/src/TKCode.Arc.Blocks/Blocks/Ai/ModelPicker/ModelPickerToken.cs index f481a2a7..1272f601 100644 --- a/src/TKCode.Arc.Blocks/Blocks/Ai/ModelPicker/ModelPickerToken.cs +++ b/src/TKCode.Arc.Blocks/Blocks/Ai/ModelPicker/ModelPickerToken.cs @@ -1,4 +1,3 @@ -using AtomUI.Theme.DesignTokens; namespace TKCode.Arc; diff --git a/src/TKCode.Arc.Blocks/Blocks/Ai/ModelPicker/Themes/ModelPickerTheme.axaml b/src/TKCode.Arc.Blocks/Blocks/Ai/ModelPicker/Themes/ModelPickerTheme.axaml index a0a9f8df..22e54d31 100644 --- a/src/TKCode.Arc.Blocks/Blocks/Ai/ModelPicker/Themes/ModelPickerTheme.axaml +++ b/src/TKCode.Arc.Blocks/Blocks/Ai/ModelPicker/Themes/ModelPickerTheme.axaml @@ -1,7 +1,8 @@ + x:ClassModifier="internal" + x:Class="TKCode.Arc.ModelPickerTheme"> diff --git a/src/TKCode.Arc.Blocks/Blocks/Ai/ModelPill/ModelPillToken.cs b/src/TKCode.Arc.Blocks/Blocks/Ai/ModelPill/ModelPillToken.cs index c91e2d96..a4b3a966 100644 --- a/src/TKCode.Arc.Blocks/Blocks/Ai/ModelPill/ModelPillToken.cs +++ b/src/TKCode.Arc.Blocks/Blocks/Ai/ModelPill/ModelPillToken.cs @@ -1,4 +1,3 @@ -using AtomUI.Theme.DesignTokens; using Avalonia; using Avalonia.Media; diff --git a/src/TKCode.Arc.Blocks/Blocks/Ai/ModelPill/Themes/ModelPillTheme.axaml b/src/TKCode.Arc.Blocks/Blocks/Ai/ModelPill/Themes/ModelPillTheme.axaml index 64d0d836..ac34c0f7 100644 --- a/src/TKCode.Arc.Blocks/Blocks/Ai/ModelPill/Themes/ModelPillTheme.axaml +++ b/src/TKCode.Arc.Blocks/Blocks/Ai/ModelPill/Themes/ModelPillTheme.axaml @@ -1,7 +1,8 @@ + x:ClassModifier="internal" + x:Class="TKCode.Arc.ModelPillTheme"> diff --git a/src/TKCode.Arc.Blocks/Blocks/Ai/PromptStudio/PromptStudioToken.cs b/src/TKCode.Arc.Blocks/Blocks/Ai/PromptStudio/PromptStudioToken.cs index 2b0b192c..96314c16 100644 --- a/src/TKCode.Arc.Blocks/Blocks/Ai/PromptStudio/PromptStudioToken.cs +++ b/src/TKCode.Arc.Blocks/Blocks/Ai/PromptStudio/PromptStudioToken.cs @@ -1,4 +1,3 @@ -using AtomUI.Theme.DesignTokens; using Avalonia; namespace TKCode.Arc; diff --git a/src/TKCode.Arc.Blocks/Blocks/Ai/PromptStudio/Themes/PromptStudioTheme.axaml b/src/TKCode.Arc.Blocks/Blocks/Ai/PromptStudio/Themes/PromptStudioTheme.axaml index 496900d0..d9fc753e 100644 --- a/src/TKCode.Arc.Blocks/Blocks/Ai/PromptStudio/Themes/PromptStudioTheme.axaml +++ b/src/TKCode.Arc.Blocks/Blocks/Ai/PromptStudio/Themes/PromptStudioTheme.axaml @@ -1,7 +1,8 @@ + x:ClassModifier="internal" + x:Class="TKCode.Arc.PromptStudioTheme"> diff --git a/src/TKCode.Arc.Blocks/Blocks/Ai/_Shared/AiConversationMenuButton/Themes/AiConversationMenuButtonTheme.axaml b/src/TKCode.Arc.Blocks/Blocks/Ai/_Shared/AiConversationMenuButton/Themes/AiConversationMenuButtonTheme.axaml index 397010a2..a9cc16e7 100644 --- a/src/TKCode.Arc.Blocks/Blocks/Ai/_Shared/AiConversationMenuButton/Themes/AiConversationMenuButtonTheme.axaml +++ b/src/TKCode.Arc.Blocks/Blocks/Ai/_Shared/AiConversationMenuButton/Themes/AiConversationMenuButtonTheme.axaml @@ -1,7 +1,8 @@ + x:ClassModifier="internal" + x:Class="TKCode.Arc.AiConversationMenuButtonTheme"> diff --git a/src/TKCode.Arc.Blocks/Blocks/Ai/_Shared/PromptVersionMenu/Themes/PromptVersionMenuTheme.axaml b/src/TKCode.Arc.Blocks/Blocks/Ai/_Shared/PromptVersionMenu/Themes/PromptVersionMenuTheme.axaml index 8a85f820..62a61348 100644 --- a/src/TKCode.Arc.Blocks/Blocks/Ai/_Shared/PromptVersionMenu/Themes/PromptVersionMenuTheme.axaml +++ b/src/TKCode.Arc.Blocks/Blocks/Ai/_Shared/PromptVersionMenu/Themes/PromptVersionMenuTheme.axaml @@ -1,7 +1,8 @@ + x:ClassModifier="internal" + x:Class="TKCode.Arc.PromptVersionMenuTheme"> diff --git a/src/TKCode.Arc.Blocks/Blocks/AppShell/CommandPalette/Themes/CommandPaletteTheme.axaml b/src/TKCode.Arc.Blocks/Blocks/AppShell/CommandPalette/Themes/CommandPaletteTheme.axaml index 1a7890e8..768894d7 100644 --- a/src/TKCode.Arc.Blocks/Blocks/AppShell/CommandPalette/Themes/CommandPaletteTheme.axaml +++ b/src/TKCode.Arc.Blocks/Blocks/AppShell/CommandPalette/Themes/CommandPaletteTheme.axaml @@ -2,7 +2,8 @@ + x:ClassModifier="internal" + x:Class="TKCode.Arc.CommandPaletteTheme"> diff --git a/src/TKCode.Arc.Blocks/Blocks/AppShell/EmptyStates/EmptyStatesToken.cs b/src/TKCode.Arc.Blocks/Blocks/AppShell/EmptyStates/EmptyStatesToken.cs index 576b996d..95497b11 100644 --- a/src/TKCode.Arc.Blocks/Blocks/AppShell/EmptyStates/EmptyStatesToken.cs +++ b/src/TKCode.Arc.Blocks/Blocks/AppShell/EmptyStates/EmptyStatesToken.cs @@ -1,4 +1,3 @@ -using AtomUI.Theme.DesignTokens; using Avalonia; namespace TKCode.Arc; diff --git a/src/TKCode.Arc.Blocks/Blocks/AppShell/EmptyStates/Themes/EmptyStatesTheme.axaml b/src/TKCode.Arc.Blocks/Blocks/AppShell/EmptyStates/Themes/EmptyStatesTheme.axaml index 980d67d3..ae3a2c08 100644 --- a/src/TKCode.Arc.Blocks/Blocks/AppShell/EmptyStates/Themes/EmptyStatesTheme.axaml +++ b/src/TKCode.Arc.Blocks/Blocks/AppShell/EmptyStates/Themes/EmptyStatesTheme.axaml @@ -1,7 +1,8 @@ + x:ClassModifier="internal" + x:Class="TKCode.Arc.EmptyStatesTheme"> - + diff --git a/src/TKCode.Arc.Blocks/Blocks/AppShell/NotificationCenter/NotificationCenterPopup.cs b/src/TKCode.Arc.Blocks/Blocks/AppShell/NotificationCenter/NotificationCenterPopup.cs index 4c11e703..4d52f275 100644 --- a/src/TKCode.Arc.Blocks/Blocks/AppShell/NotificationCenter/NotificationCenterPopup.cs +++ b/src/TKCode.Arc.Blocks/Blocks/AppShell/NotificationCenter/NotificationCenterPopup.cs @@ -1,7 +1,6 @@ // Ported from Arc's free notification-center block (MIT, Copyright (c) 2026 Elia Kuratli). using TKCode.Arc.Motion; using TKCode.Arc.Primitives; -using AtomUI.MotionScene; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Primitives.PopupPositioning; @@ -11,7 +10,6 @@ using Avalonia.LogicalTree; using Avalonia.Media; using Avalonia.VisualTree; -using AtomPopup = AtomUI.Desktop.Controls.Popup; namespace TKCode.Arc; @@ -32,7 +30,7 @@ protected override void OnPlacementResolved(CustomPopupPlacement placement, Thic { return; } - var width = placement.PopupSize.Width - placement.Deflate.Left - placement.Deflate.Right; + var width = placement.PopupSize.Width - shadow.Left - shadow.Right; var client = topLevel.ClientSize.Width; if (width <= 0 || width + 2 * CollisionPadding > client) { @@ -54,7 +52,7 @@ protected override void OnPlacementResolved(CustomPopupPlacement placement, Thic /// played on the popup's motion actor so the floating shadow moves with the panel: it grows out of the bell from 6px /// toward it at scale 0.97 while fading in (standard 240ms, enter curve), and leaves in 120ms at half the travel and /// scale 0.98. The origin is the panel edge nearest the bell, following a flip to the top. An open and a close -/// reverse each other from where the panel is. Code-driven because the motion has to run on AtomUI's popup frame +/// reverse each other from where the panel is. Code-driven because the motion has to run on the popup's frame /// (outside any template) and gate the popup's removal on the exit. Reduced motion and Off show and hide the panel at /// once (Arc turns the panel animation off for reduced motion). Also reports presses outside the panel and the bell, /// and Escape. @@ -69,7 +67,7 @@ internal sealed class NotificationCenterPresence : IDisposable private readonly MotionValue _pose; private readonly MatrixTransform _transform = new(); private readonly MatrixTransform _fallbackTransform = new(); - private BaseMotionActor? _actor; + private ArcPopupFrame? _actor; private Control? _anchor; private TopLevel? _topLevel; private bool _closing; @@ -78,7 +76,7 @@ internal sealed class NotificationCenterPresence : IDisposable private Size _originSize; private bool _originAbove; private bool _originKnown; - private BaseMotionActor? _originActor; + private ArcPopupFrame? _originActor; public NotificationCenterPresence(NotificationCenterPanel panel) { @@ -91,8 +89,8 @@ public NotificationCenterPresence(NotificationCenterPanel panel) EnterOffset = 0, EnterScale = 1 }; - // The motion runs here, not in AtomUI's popup motion: animation priority wins over ArcPopup's own setting. - Popup.SetValue(AtomPopup.IsMotionEnabledProperty, false, BindingPriority.Animation); + // The motion runs here, not in the popup's own motion: animation priority wins over ArcPopup's own setting. + Popup.SetValue(ArcPopup.IsMotionEnabledProperty, false, BindingPriority.Animation); Popup.Opened += OnPopupOpened; _opacity = new MotionValue(panel, 0, MotionVectors.Double); _pose = new MotionValue(panel, new Point(0, 1), MotionVectors.Point); @@ -210,16 +208,16 @@ private void Finish() private void OnPopupOpened(object? sender, EventArgs e) { - _actor = Panel.FindAncestorOfType(); + _actor = Panel.FindAncestorOfType(); if (_actor is not null) { _actor.RenderTransformOrigin = new RelativePoint(0, 0, RelativeUnit.Absolute); - _actor.MotionTransform = _transform; + _actor.RenderTransform = _transform; } Apply(); } - /// The side the panel landed on after AtomUI's flip. + /// The side the panel landed on after the positioner's flip. private bool IsAbove => Popup.IsVerticalFlipped; private void Apply() diff --git a/src/TKCode.Arc.Blocks/Blocks/AppShell/NotificationCenter/NotificationCenterRow.cs b/src/TKCode.Arc.Blocks/Blocks/AppShell/NotificationCenter/NotificationCenterRow.cs index 239d9daf..b402287a 100644 --- a/src/TKCode.Arc.Blocks/Blocks/AppShell/NotificationCenter/NotificationCenterRow.cs +++ b/src/TKCode.Arc.Blocks/Blocks/AppShell/NotificationCenter/NotificationCenterRow.cs @@ -1,7 +1,6 @@ // Ported from Arc's free notification-center block (MIT, Copyright (c) 2026 Elia Kuratli). using TKCode.Arc.Motion; using TKCode.Arc.Primitives; -using AtomUI.Controls; using Avalonia; using Avalonia.Automation; using Avalonia.Automation.Peers; diff --git a/src/TKCode.Arc.Blocks/Blocks/AppShell/NotificationCenter/NotificationCenterToken.cs b/src/TKCode.Arc.Blocks/Blocks/AppShell/NotificationCenter/NotificationCenterToken.cs index 2b49527e..b71629ba 100644 --- a/src/TKCode.Arc.Blocks/Blocks/AppShell/NotificationCenter/NotificationCenterToken.cs +++ b/src/TKCode.Arc.Blocks/Blocks/AppShell/NotificationCenter/NotificationCenterToken.cs @@ -1,5 +1,4 @@ // Ported from Arc's free notification-center block (MIT, Copyright (c) 2026 Elia Kuratli). -using AtomUI.Theme.DesignTokens; using Avalonia; namespace TKCode.Arc; diff --git a/src/TKCode.Arc.Blocks/Blocks/AppShell/NotificationCenter/NotificationItem.cs b/src/TKCode.Arc.Blocks/Blocks/AppShell/NotificationCenter/NotificationItem.cs index 605e76f2..1f2bee74 100644 --- a/src/TKCode.Arc.Blocks/Blocks/AppShell/NotificationCenter/NotificationItem.cs +++ b/src/TKCode.Arc.Blocks/Blocks/AppShell/NotificationCenter/NotificationItem.cs @@ -1,5 +1,4 @@ // Ported from Arc's free notification-center block (MIT, Copyright (c) 2026 Elia Kuratli). -using AtomUI.Controls; using Avalonia; using Avalonia.Data; using Avalonia.Interactivity; diff --git a/src/TKCode.Arc.Blocks/Blocks/AppShell/NotificationCenter/Themes/NotificationCenterTheme.axaml b/src/TKCode.Arc.Blocks/Blocks/AppShell/NotificationCenter/Themes/NotificationCenterTheme.axaml index a045c642..b5f4f7b0 100644 --- a/src/TKCode.Arc.Blocks/Blocks/AppShell/NotificationCenter/Themes/NotificationCenterTheme.axaml +++ b/src/TKCode.Arc.Blocks/Blocks/AppShell/NotificationCenter/Themes/NotificationCenterTheme.axaml @@ -1,7 +1,8 @@ + x:ClassModifier="internal" + x:Class="TKCode.Arc.NotificationCenterTheme"> diff --git a/src/TKCode.Arc.Blocks/Blocks/AppShell/PageHeader/PageHeaderTabToken.cs b/src/TKCode.Arc.Blocks/Blocks/AppShell/PageHeader/PageHeaderTabToken.cs index b9fef438..c7af0297 100644 --- a/src/TKCode.Arc.Blocks/Blocks/AppShell/PageHeader/PageHeaderTabToken.cs +++ b/src/TKCode.Arc.Blocks/Blocks/AppShell/PageHeader/PageHeaderTabToken.cs @@ -1,4 +1,3 @@ -using AtomUI.Theme.DesignTokens; using Avalonia; using Avalonia.Media; diff --git a/src/TKCode.Arc.Blocks/Blocks/AppShell/PageHeader/PageHeaderToken.cs b/src/TKCode.Arc.Blocks/Blocks/AppShell/PageHeader/PageHeaderToken.cs index ebe1c08c..91f724ee 100644 --- a/src/TKCode.Arc.Blocks/Blocks/AppShell/PageHeader/PageHeaderToken.cs +++ b/src/TKCode.Arc.Blocks/Blocks/AppShell/PageHeader/PageHeaderToken.cs @@ -1,4 +1,3 @@ -using AtomUI.Theme.DesignTokens; using Avalonia; using Avalonia.Media; diff --git a/src/TKCode.Arc.Blocks/Blocks/AppShell/PageHeader/Themes/PageHeaderTabTheme.axaml b/src/TKCode.Arc.Blocks/Blocks/AppShell/PageHeader/Themes/PageHeaderTabTheme.axaml index eae7262b..6623c2b5 100644 --- a/src/TKCode.Arc.Blocks/Blocks/AppShell/PageHeader/Themes/PageHeaderTabTheme.axaml +++ b/src/TKCode.Arc.Blocks/Blocks/AppShell/PageHeader/Themes/PageHeaderTabTheme.axaml @@ -1,7 +1,8 @@ + x:ClassModifier="internal" + x:Class="TKCode.Arc.PageHeaderTabTheme"> diff --git a/src/TKCode.Arc.Blocks/Blocks/AppShell/PageHeader/Themes/PageHeaderTheme.axaml b/src/TKCode.Arc.Blocks/Blocks/AppShell/PageHeader/Themes/PageHeaderTheme.axaml index 4e875599..5719dc75 100644 --- a/src/TKCode.Arc.Blocks/Blocks/AppShell/PageHeader/Themes/PageHeaderTheme.axaml +++ b/src/TKCode.Arc.Blocks/Blocks/AppShell/PageHeader/Themes/PageHeaderTheme.axaml @@ -1,7 +1,8 @@ + x:ClassModifier="internal" + x:Class="TKCode.Arc.PageHeaderTheme"> diff --git a/src/TKCode.Arc.Blocks/Blocks/AppShell/SearchResults/SearchResultItem.cs b/src/TKCode.Arc.Blocks/Blocks/AppShell/SearchResults/SearchResultItem.cs index e471d387..3ede066a 100644 --- a/src/TKCode.Arc.Blocks/Blocks/AppShell/SearchResults/SearchResultItem.cs +++ b/src/TKCode.Arc.Blocks/Blocks/AppShell/SearchResults/SearchResultItem.cs @@ -1,7 +1,6 @@ using Avalonia; using Avalonia.Collections; using Avalonia.Interactivity; -using AtomUI.Controls; namespace TKCode.Arc; diff --git a/src/TKCode.Arc.Blocks/Blocks/AppShell/SearchResults/SearchResultsToken.cs b/src/TKCode.Arc.Blocks/Blocks/AppShell/SearchResults/SearchResultsToken.cs index 304e7bd8..cbfe13a4 100644 --- a/src/TKCode.Arc.Blocks/Blocks/AppShell/SearchResults/SearchResultsToken.cs +++ b/src/TKCode.Arc.Blocks/Blocks/AppShell/SearchResults/SearchResultsToken.cs @@ -1,4 +1,3 @@ -using AtomUI.Theme.DesignTokens; using Avalonia; namespace TKCode.Arc; diff --git a/src/TKCode.Arc.Blocks/Blocks/AppShell/SearchResults/Themes/SearchResultsTheme.axaml b/src/TKCode.Arc.Blocks/Blocks/AppShell/SearchResults/Themes/SearchResultsTheme.axaml index bf6f0d26..e806805c 100644 --- a/src/TKCode.Arc.Blocks/Blocks/AppShell/SearchResults/Themes/SearchResultsTheme.axaml +++ b/src/TKCode.Arc.Blocks/Blocks/AppShell/SearchResults/Themes/SearchResultsTheme.axaml @@ -1,7 +1,8 @@ + x:ClassModifier="internal" + x:Class="TKCode.Arc.SearchResultsTheme"> diff --git a/src/TKCode.Arc.Blocks/Blocks/AppShell/Sidebar/SidebarToken.cs b/src/TKCode.Arc.Blocks/Blocks/AppShell/Sidebar/SidebarToken.cs index 90cee31c..7f15a001 100644 --- a/src/TKCode.Arc.Blocks/Blocks/AppShell/Sidebar/SidebarToken.cs +++ b/src/TKCode.Arc.Blocks/Blocks/AppShell/Sidebar/SidebarToken.cs @@ -1,4 +1,3 @@ -using AtomUI.Theme.DesignTokens; using Avalonia; using Avalonia.Media; diff --git a/src/TKCode.Arc.Blocks/Blocks/AppShell/Sidebar/Themes/SidebarTheme.axaml b/src/TKCode.Arc.Blocks/Blocks/AppShell/Sidebar/Themes/SidebarTheme.axaml index 4b147802..b3b141e2 100644 --- a/src/TKCode.Arc.Blocks/Blocks/AppShell/Sidebar/Themes/SidebarTheme.axaml +++ b/src/TKCode.Arc.Blocks/Blocks/AppShell/Sidebar/Themes/SidebarTheme.axaml @@ -1,7 +1,8 @@ + x:ClassModifier="internal" + x:Class="TKCode.Arc.SidebarTheme"> diff --git a/src/TKCode.Arc.Blocks/Blocks/Auth/LoginCentered/LoginCenteredToken.cs b/src/TKCode.Arc.Blocks/Blocks/Auth/LoginCentered/LoginCenteredToken.cs index e5e67cce..8c07821f 100644 --- a/src/TKCode.Arc.Blocks/Blocks/Auth/LoginCentered/LoginCenteredToken.cs +++ b/src/TKCode.Arc.Blocks/Blocks/Auth/LoginCentered/LoginCenteredToken.cs @@ -1,5 +1,4 @@ // Ported from Arc's free login-centered block (MIT, Copyright (c) 2026 Elia Kuratli): login-centered.module.css. -using AtomUI.Theme.DesignTokens; using Avalonia; namespace TKCode.Arc; diff --git a/src/TKCode.Arc.Blocks/Blocks/Auth/LoginCentered/Themes/LoginCenteredTheme.axaml b/src/TKCode.Arc.Blocks/Blocks/Auth/LoginCentered/Themes/LoginCenteredTheme.axaml index f91fb8e4..799d06e6 100644 --- a/src/TKCode.Arc.Blocks/Blocks/Auth/LoginCentered/Themes/LoginCenteredTheme.axaml +++ b/src/TKCode.Arc.Blocks/Blocks/Auth/LoginCentered/Themes/LoginCenteredTheme.axaml @@ -1,7 +1,8 @@ + x:ClassModifier="internal" + x:Class="TKCode.Arc.LoginCenteredTheme"> - + diff --git a/src/TKCode.Arc.Blocks/Blocks/Auth/OtpInput/OtpInputToken.cs b/src/TKCode.Arc.Blocks/Blocks/Auth/OtpInput/OtpInputToken.cs index 7fb2f636..b159a852 100644 --- a/src/TKCode.Arc.Blocks/Blocks/Auth/OtpInput/OtpInputToken.cs +++ b/src/TKCode.Arc.Blocks/Blocks/Auth/OtpInput/OtpInputToken.cs @@ -1,5 +1,4 @@ // Ported from Arc's free otp-input block (MIT, Copyright (c) 2026 Elia Kuratli): otp-input.module.css. -using AtomUI.Theme.DesignTokens; using Avalonia.Media; namespace TKCode.Arc; diff --git a/src/TKCode.Arc.Blocks/Blocks/Auth/OtpInput/Themes/OtpInputTheme.axaml b/src/TKCode.Arc.Blocks/Blocks/Auth/OtpInput/Themes/OtpInputTheme.axaml index 28838681..1b5000be 100644 --- a/src/TKCode.Arc.Blocks/Blocks/Auth/OtpInput/Themes/OtpInputTheme.axaml +++ b/src/TKCode.Arc.Blocks/Blocks/Auth/OtpInput/Themes/OtpInputTheme.axaml @@ -1,7 +1,8 @@ + x:ClassModifier="internal" + x:Class="TKCode.Arc.OtpInputTheme"> diff --git a/src/TKCode.Arc.Blocks/Blocks/HeadersFooters/_Shared/SiteSwap.cs b/src/TKCode.Arc.Blocks/Blocks/HeadersFooters/_Shared/SiteSwap.cs index b228644c..55e93acd 100644 --- a/src/TKCode.Arc.Blocks/Blocks/HeadersFooters/_Shared/SiteSwap.cs +++ b/src/TKCode.Arc.Blocks/Blocks/HeadersFooters/_Shared/SiteSwap.cs @@ -287,7 +287,7 @@ private void StartExit(Layer layer, bool full) layer.Presenter.Effect = ArcBlur.Get(blur * p); }; progress.AnimateTo(1, MotionSpec.Tween(duration, ExitEasing)) - .ContinueWith(_ => Avalonia.Threading.Dispatcher.UIThread.Post(() => + .ContinueWith(_ => Dispatcher.Post(() => { if (_exiting.Contains(layer)) { diff --git a/src/TKCode.Arc.Blocks/Blocks/LineCharts/LineReplay/LineReplayToken.cs b/src/TKCode.Arc.Blocks/Blocks/LineCharts/LineReplay/LineReplayToken.cs index 1a789cbf..4c2dd93b 100644 --- a/src/TKCode.Arc.Blocks/Blocks/LineCharts/LineReplay/LineReplayToken.cs +++ b/src/TKCode.Arc.Blocks/Blocks/LineCharts/LineReplay/LineReplayToken.cs @@ -1,4 +1,3 @@ -using AtomUI.Theme.DesignTokens; using Avalonia; namespace TKCode.Arc; diff --git a/src/TKCode.Arc.Blocks/Blocks/LineCharts/LineReplay/Themes/LineReplayTheme.axaml b/src/TKCode.Arc.Blocks/Blocks/LineCharts/LineReplay/Themes/LineReplayTheme.axaml index 156ebcaa..76ac94ee 100644 --- a/src/TKCode.Arc.Blocks/Blocks/LineCharts/LineReplay/Themes/LineReplayTheme.axaml +++ b/src/TKCode.Arc.Blocks/Blocks/LineCharts/LineReplay/Themes/LineReplayTheme.axaml @@ -1,7 +1,8 @@ + x:ClassModifier="internal" + x:Class="TKCode.Arc.LineReplayTheme"> diff --git a/src/TKCode.Arc.Blocks/Blocks/Projects/ReleaseReadiness/ReleaseReadinessToken.cs b/src/TKCode.Arc.Blocks/Blocks/Projects/ReleaseReadiness/ReleaseReadinessToken.cs index 75920b9c..1cf80ddf 100644 --- a/src/TKCode.Arc.Blocks/Blocks/Projects/ReleaseReadiness/ReleaseReadinessToken.cs +++ b/src/TKCode.Arc.Blocks/Blocks/Projects/ReleaseReadiness/ReleaseReadinessToken.cs @@ -1,4 +1,3 @@ -using AtomUI.Theme.DesignTokens; using Avalonia; using Avalonia.Media; diff --git a/src/TKCode.Arc.Blocks/Blocks/Projects/ReleaseReadiness/Themes/ReleaseReadinessTheme.axaml b/src/TKCode.Arc.Blocks/Blocks/Projects/ReleaseReadiness/Themes/ReleaseReadinessTheme.axaml index 0c0e774a..24d545dc 100644 --- a/src/TKCode.Arc.Blocks/Blocks/Projects/ReleaseReadiness/Themes/ReleaseReadinessTheme.axaml +++ b/src/TKCode.Arc.Blocks/Blocks/Projects/ReleaseReadiness/Themes/ReleaseReadinessTheme.axaml @@ -1,7 +1,8 @@ + x:ClassModifier="internal" + x:Class="TKCode.Arc.ReleaseReadinessTheme"> diff --git a/src/TKCode.Arc.Blocks/Blocks/Projects/WeekCalendar/WeekCalendarToken.cs b/src/TKCode.Arc.Blocks/Blocks/Projects/WeekCalendar/WeekCalendarToken.cs index dddc0380..d8a8fb25 100644 --- a/src/TKCode.Arc.Blocks/Blocks/Projects/WeekCalendar/WeekCalendarToken.cs +++ b/src/TKCode.Arc.Blocks/Blocks/Projects/WeekCalendar/WeekCalendarToken.cs @@ -1,4 +1,3 @@ -using AtomUI.Theme.DesignTokens; using Avalonia; using Avalonia.Media; diff --git a/src/TKCode.Arc.Blocks/Blocks/Projects/_Shared/Themes/ProjectsProgressLineTheme.axaml b/src/TKCode.Arc.Blocks/Blocks/Projects/_Shared/Themes/ProjectsProgressLineTheme.axaml index 74cb3a7d..7023de6f 100644 --- a/src/TKCode.Arc.Blocks/Blocks/Projects/_Shared/Themes/ProjectsProgressLineTheme.axaml +++ b/src/TKCode.Arc.Blocks/Blocks/Projects/_Shared/Themes/ProjectsProgressLineTheme.axaml @@ -1,7 +1,8 @@ + x:ClassModifier="internal" + x:Class="TKCode.Arc.ProjectsProgressLineTheme"> diff --git a/src/TKCode.Arc.Blocks/Blocks/Showpieces/LayoutMorph/LayoutMorphToken.cs b/src/TKCode.Arc.Blocks/Blocks/Showpieces/LayoutMorph/LayoutMorphToken.cs index a53d37c4..4aac0c62 100644 --- a/src/TKCode.Arc.Blocks/Blocks/Showpieces/LayoutMorph/LayoutMorphToken.cs +++ b/src/TKCode.Arc.Blocks/Blocks/Showpieces/LayoutMorph/LayoutMorphToken.cs @@ -1,4 +1,3 @@ -using AtomUI.Theme.DesignTokens; using Avalonia; namespace TKCode.Arc; diff --git a/src/TKCode.Arc.Blocks/Blocks/Showpieces/LayoutMorph/Themes/LayoutMorphTheme.axaml b/src/TKCode.Arc.Blocks/Blocks/Showpieces/LayoutMorph/Themes/LayoutMorphTheme.axaml index f3541c9e..83165409 100644 --- a/src/TKCode.Arc.Blocks/Blocks/Showpieces/LayoutMorph/Themes/LayoutMorphTheme.axaml +++ b/src/TKCode.Arc.Blocks/Blocks/Showpieces/LayoutMorph/Themes/LayoutMorphTheme.axaml @@ -1,7 +1,8 @@ + x:ClassModifier="internal" + x:Class="TKCode.Arc.LayoutMorphTheme"> diff --git a/src/TKCode.Arc.Blocks/Blocks/Showpieces/RevenueGlobe/RevenueGlobeToken.cs b/src/TKCode.Arc.Blocks/Blocks/Showpieces/RevenueGlobe/RevenueGlobeToken.cs index a02079c9..1f4f4bc6 100644 --- a/src/TKCode.Arc.Blocks/Blocks/Showpieces/RevenueGlobe/RevenueGlobeToken.cs +++ b/src/TKCode.Arc.Blocks/Blocks/Showpieces/RevenueGlobe/RevenueGlobeToken.cs @@ -1,4 +1,3 @@ -using AtomUI.Theme.DesignTokens; using Avalonia; namespace TKCode.Arc; diff --git a/src/TKCode.Arc.Blocks/Blocks/Showpieces/RevenueGlobe/Themes/RevenueGlobeTheme.axaml b/src/TKCode.Arc.Blocks/Blocks/Showpieces/RevenueGlobe/Themes/RevenueGlobeTheme.axaml index b5b7f8a7..ea77e552 100644 --- a/src/TKCode.Arc.Blocks/Blocks/Showpieces/RevenueGlobe/Themes/RevenueGlobeTheme.axaml +++ b/src/TKCode.Arc.Blocks/Blocks/Showpieces/RevenueGlobe/Themes/RevenueGlobeTheme.axaml @@ -1,7 +1,8 @@ + x:ClassModifier="internal" + x:Class="TKCode.Arc.RevenueGlobeTheme"> diff --git a/src/TKCode.Arc.Blocks/Blocks/Showpieces/SemanticZoom/SemanticZoomToken.cs b/src/TKCode.Arc.Blocks/Blocks/Showpieces/SemanticZoom/SemanticZoomToken.cs index aa3641cb..11a30c1d 100644 --- a/src/TKCode.Arc.Blocks/Blocks/Showpieces/SemanticZoom/SemanticZoomToken.cs +++ b/src/TKCode.Arc.Blocks/Blocks/Showpieces/SemanticZoom/SemanticZoomToken.cs @@ -1,4 +1,3 @@ -using AtomUI.Theme.DesignTokens; using Avalonia; namespace TKCode.Arc; diff --git a/src/TKCode.Arc.Blocks/Blocks/Showpieces/SemanticZoom/Themes/SemanticZoomPanelTheme.axaml b/src/TKCode.Arc.Blocks/Blocks/Showpieces/SemanticZoom/Themes/SemanticZoomPanelTheme.axaml index 5438fa6d..67418cc7 100644 --- a/src/TKCode.Arc.Blocks/Blocks/Showpieces/SemanticZoom/Themes/SemanticZoomPanelTheme.axaml +++ b/src/TKCode.Arc.Blocks/Blocks/Showpieces/SemanticZoom/Themes/SemanticZoomPanelTheme.axaml @@ -1,7 +1,8 @@ + x:ClassModifier="internal" + x:Class="TKCode.Arc.SemanticZoomPanelTheme"> $(NoWarn);CS0436 + true - diff --git a/src/TKCode.Arc.Core/Motion/ArcMotion.cs b/src/TKCode.Arc.Core/Motion/ArcMotion.cs index ddb21f53..98616ffe 100644 --- a/src/TKCode.Arc.Core/Motion/ArcMotion.cs +++ b/src/TKCode.Arc.Core/Motion/ArcMotion.cs @@ -6,13 +6,7 @@ namespace TKCode.Arc.Motion; -/// Arc motion levels (min of all sources wins). -public enum ArcMotionLevel -{ - Off = 0, - Reduced = 1, - Full = 2 -} +// ArcMotionLevel (min of all sources wins) now comes from TKCode.Arc.Foundation, same name, namespace and values. /// /// Motion level policy. is inherited: set it on a window or any container. diff --git a/src/TKCode.Arc.Core/Primitives/ArcContentSwap.cs b/src/TKCode.Arc.Core/Primitives/ArcContentSwap.cs index a12aef32..7f6ffcad 100644 --- a/src/TKCode.Arc.Core/Primitives/ArcContentSwap.cs +++ b/src/TKCode.Arc.Core/Primitives/ArcContentSwap.cs @@ -470,7 +470,7 @@ private void StartPopExit(Layer layer, bool full) progress.AnimateTo(1, full ? MotionSpec.Tween(TimeSpan.FromSeconds(0.24), ArcEasings.Standard) : MotionSpec.Tween(ArcMotionPresets.Instant, new Avalonia.Animation.Easings.LinearEasing())) - .ContinueWith(_ => Avalonia.Threading.Dispatcher.UIThread.Post(() => RemoveLayer(layer)), TaskScheduler.Default); + .ContinueWith(_ => Dispatcher.Post(() => RemoveLayer(layer)), TaskScheduler.Default); } private void StartExit(Layer layer, bool full, double toOffset, bool horizontal = false, bool blur = true, TimeSpan? duration = null) @@ -499,7 +499,7 @@ private void StartExit(Layer layer, bool full, double toOffset, bool horizontal var spec = full ? MotionSpec.Tween(duration ?? ArcMotionPresets.Fast, ArcEasings.Standard) : MotionSpec.Tween(ArcMotionPresets.Instant, new Avalonia.Animation.Easings.LinearEasing()); - progress.AnimateTo(1, spec).ContinueWith(_ => Avalonia.Threading.Dispatcher.UIThread.Post(() => RemoveLayer(layer)), + progress.AnimateTo(1, spec).ContinueWith(_ => Dispatcher.Post(() => RemoveLayer(layer)), TaskScheduler.Default); } diff --git a/src/TKCode.Arc.Core/Primitives/Images/ArcImage.cs b/src/TKCode.Arc.Core/Primitives/Images/ArcImage.cs new file mode 100644 index 00000000..f3400962 --- /dev/null +++ b/src/TKCode.Arc.Core/Primitives/Images/ArcImage.cs @@ -0,0 +1,345 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Threading; + +namespace TKCode.Arc.Primitives; + +/// +/// A picture that loads in the background: http(s), avares://, files, data: addresses, picked storage files +/// or a bitmap you hold (). Pictures are decoded off the UI thread, scaled down when they are much +/// larger than the slot they fill, and shared through , so a picture that was shown before +/// appears at once without a loading state. Changing or leaving the visual tree cancels the load +/// (the download stops once no image waits for it); it resumes when the image is attached again. +/// +/// , and report the state, so a template can fade +/// the picture in or fall back to initials. Loading goes through (inherited, so it can be set on a +/// window or page), or when none is set. Lays out and stretches like Avalonia's Image. +/// +/// +public class ArcImage : Control +{ + /// The picture to show; null shows nothing. + public static readonly StyledProperty SourceProperty = + AvaloniaProperty.Register(nameof(Source)); + + /// How the picture fills the image's bounds (default Uniform, as Avalonia's Image). + public static readonly StyledProperty StretchProperty = + Image.StretchProperty.AddOwner(); + + /// Whether the picture may be scaled up, down or both (default Both). + public static readonly StyledProperty StretchDirectionProperty = + Image.StretchDirectionProperty.AddOwner(); + + /// + /// The loader that reads pictures for this image and the images inside it (inherited); null uses + /// . + /// + public static readonly AttachedProperty LoaderProperty = + AvaloniaProperty.RegisterAttached("Loader", inherits: true); + + /// True while the picture of is being read or decoded. + public static readonly DirectProperty IsLoadingProperty = + AvaloniaProperty.RegisterDirect(nameof(IsLoading), o => o.IsLoading); + + /// True while the picture of is shown. + public static readonly DirectProperty IsImageLoadedProperty = + AvaloniaProperty.RegisterDirect(nameof(IsImageLoaded), o => o.IsImageLoaded); + + /// True when the picture of could not be read or decoded (see ). + public static readonly DirectProperty IsFailedProperty = + AvaloniaProperty.RegisterDirect(nameof(IsFailed), o => o.IsFailed); + + /// The decoded picture being shown, or null. + public static readonly DirectProperty CurrentImageProperty = + AvaloniaProperty.RegisterDirect(nameof(CurrentImage), o => o.CurrentImage); + + private static IArcImageLoader s_defaultLoader = ArcImageLoader.Shared; + + private ArcImageFrame? _frame; + private CancellationTokenSource? _load; + private bool _upgradeTried; + private bool _scheduled; + private int _version; + private bool _isLoading; + private bool _isImageLoaded; + private bool _isFailed; + + static ArcImage() + { + AffectsRender(StretchProperty, StretchDirectionProperty); + AffectsMeasure(StretchProperty, StretchDirectionProperty); + } + + /// The loader images use when no is set (default ). + public static IArcImageLoader DefaultLoader + { + get => s_defaultLoader; + set => s_defaultLoader = value ?? throw new ArgumentNullException(nameof(value)); + } + + /// + public ImageSource? Source { get => GetValue(SourceProperty); set => SetValue(SourceProperty, value); } + + /// + public Stretch Stretch { get => GetValue(StretchProperty); set => SetValue(StretchProperty, value); } + + /// + public StretchDirection StretchDirection { get => GetValue(StretchDirectionProperty); set => SetValue(StretchDirectionProperty, value); } + + /// + public IArcImageLoader? Loader { get => GetValue(LoaderProperty); set => SetValue(LoaderProperty, value); } + + /// + public bool IsLoading + { + get => _isLoading; + private set => SetAndRaise(IsLoadingProperty, ref _isLoading, value); + } + + /// + public bool IsImageLoaded + { + get => _isImageLoaded; + private set => SetAndRaise(IsImageLoadedProperty, ref _isImageLoaded, value); + } + + /// + public bool IsFailed + { + get => _isFailed; + private set => SetAndRaise(IsFailedProperty, ref _isFailed, value); + } + + /// + public IImage? CurrentImage => _frame?.Bitmap; + + /// Why the last load failed, while is true. + public Exception? LoadError { get; private set; } + + /// Gets the loader set on (see ). + public static IArcImageLoader? GetLoader(Visual visual) => visual.GetValue(LoaderProperty); + + /// Sets the loader for and the images inside it (see ). + public static void SetLoader(Visual visual, IArcImageLoader? loader) => visual.SetValue(LoaderProperty, loader); + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == SourceProperty) + { + ApplySource(change.GetOldValue(), change.GetNewValue()); + } + } + + private void ApplySource(ImageSource? old, ImageSource? source) + { + // The same picture again (a list rebinding a fresh source object) keeps what is shown or loading; a failed one retries. + if (old is not null && source is not null && !IsFailed && old.IsSamePicture(source)) + { + return; + } + CancelLoad(); + _version++; + _upgradeTried = false; + LoadError = null; + IsFailed = false; + if (source is null) + { + SetFrame(null); + IsImageLoaded = false; + IsLoading = false; + return; + } + if (source is BitmapImageSource { Bitmap: var bitmap }) + { + SetFrame(new ArcImageFrame(bitmap, bitmap.Size, bitmap.PixelSize, isReduced: false)); + IsImageLoaded = true; + IsLoading = false; + return; + } + if (ArcImageCache.TryGet(source, out var cached)) + { + // Shown at once; a copy that was decoded for a smaller slot is replaced quietly after layout. + SetFrame(cached); + IsImageLoaded = true; + IsLoading = false; + ScheduleLoad(); + return; + } + SetFrame(null); + IsImageLoaded = false; + IsLoading = true; + ScheduleLoad(); + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + ScheduleLoad(); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + // IsLoading stays true, so the load starts again when the image is attached again. + CancelLoad(); + } + + protected override void OnSizeChanged(SizeChangedEventArgs e) + { + base.OnSizeChanged(e); + if (_frame is { IsReduced: true }) + { + _upgradeTried = false; + ScheduleLoad(); + } + } + + /// Starts the load after layout, when the slot the picture fills is known (used to decode at that size). + private void ScheduleLoad() + { + if (_scheduled || VisualRoot is null || Source is null) + { + return; + } + _scheduled = true; + Dispatcher.UIThread.Post(RunScheduledLoad, DispatcherPriority.Loaded); + } + + private void RunScheduledLoad() + { + _scheduled = false; + if (VisualRoot is null || Source is not { } source || _load is not null) + { + return; + } + var slot = SlotPixels(); + if (IsLoading) + { + _ = LoadAsync(source, slot, upgrade: false); + } + else if (IsImageLoaded && !_upgradeTried && _frame is { IsReduced: true } frame && slot is { } size && !frame.Covers(size, Stretch)) + { + _upgradeTried = true; + _ = LoadAsync(source, slot, upgrade: true); + } + } + + private async Task LoadAsync(ImageSource source, Size? slot, bool upgrade) + { + var cancellation = new CancellationTokenSource(); + _load = cancellation; + var version = _version; + try + { + var frame = await ArcImageCache.LoadAsync(source, Loader ?? DefaultLoader, slot, Stretch, cancellation.Token); + if (cancellation.IsCancellationRequested || version != _version) + { + return; + } + _load = null; + SetFrame(frame); + IsImageLoaded = true; + IsLoading = false; + if (!upgrade) + { + // A shared download may have been decoded for a smaller slot. + ScheduleLoad(); + } + } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) + { + } + catch (Exception error) + { + if (version != _version || cancellation.IsCancellationRequested) + { + return; + } + _load = null; + if (upgrade) + { + // The smaller copy stays. + return; + } + LoadError = error; + IsFailed = true; + IsLoading = false; + } + finally + { + // Not disposed: Cancel can run this continuation inline, and a source without a timer holds nothing to free. + if (ReferenceEquals(_load, cancellation)) + { + _load = null; + } + } + } + + private void CancelLoad() + { + if (_load is { } load) + { + _load = null; + load.Cancel(); + } + } + + /// The slot the picture fills, in device pixels, or null when it is not known yet. + private Size? SlotPixels() + { + var width = Bounds.Width > 0 ? Bounds.Width : double.IsFinite(Width) ? Width : double.IsFinite(MaxWidth) ? MaxWidth : double.NaN; + var height = Bounds.Height > 0 ? Bounds.Height : double.IsFinite(Height) ? Height : double.IsFinite(MaxHeight) ? MaxHeight : double.NaN; + if (double.IsNaN(width) && double.IsNaN(height)) + { + return null; + } + var scale = LayoutHelper.GetLayoutScale(this); + return new Size(width * scale, height * scale); + } + + private void SetFrame(ArcImageFrame? frame) + { + if (ReferenceEquals(_frame, frame)) + { + return; + } + var old = _frame?.Bitmap; + _frame = frame; + RaisePropertyChanged(CurrentImageProperty, old, frame?.Bitmap); + InvalidateMeasure(); + InvalidateVisual(); + } + + protected override Size MeasureOverride(Size availableSize) => + _frame is { } frame ? Stretch.CalculateSize(availableSize, frame.NaturalSize, StretchDirection) : default; + + protected override Size ArrangeOverride(Size finalSize) => + // Without a picture the image keeps its slot, so the slot is known when the picture is decoded. + _frame is { } frame ? Stretch.CalculateSize(finalSize, frame.NaturalSize) : finalSize; + + public override void Render(DrawingContext context) + { + if (_frame is not { } frame || Bounds.Width <= 0 || Bounds.Height <= 0) + { + return; + } + var natural = frame.NaturalSize; + if (natural.Width <= 0 || natural.Height <= 0) + { + return; + } + var viewPort = new Rect(Bounds.Size); + var scale = Stretch.CalculateScaling(Bounds.Size, natural, StretchDirection); + var destRect = viewPort.CenterRect(new Rect(natural * scale)).Intersect(viewPort); + var sourceRect = new Rect(natural).CenterRect(new Rect(destRect.Size / scale)); + // The source rectangle is in the bitmap's pixels (which a reduced copy has fewer of than the picture's own size). + var pixels = frame.Bitmap.PixelSize; + var kx = pixels.Width / natural.Width; + var ky = pixels.Height / natural.Height; + context.DrawImage(frame.Bitmap, new Rect(sourceRect.X * kx, sourceRect.Y * ky, sourceRect.Width * kx, sourceRect.Height * ky), destRect); + } +} diff --git a/src/TKCode.Arc.Core/Primitives/Images/ArcImageCache.cs b/src/TKCode.Arc.Core/Primitives/Images/ArcImageCache.cs new file mode 100644 index 00000000..87296d26 --- /dev/null +++ b/src/TKCode.Arc.Core/Primitives/Images/ArcImageCache.cs @@ -0,0 +1,281 @@ +using Avalonia; +using Avalonia.Media; +using Avalonia.Media.Imaging; + +namespace TKCode.Arc.Primitives; + +/// A decoded picture: the bitmap and the size it was before it was scaled down for its slot. +internal sealed class ArcImageFrame +{ + public ArcImageFrame(Bitmap bitmap, Size naturalSize, PixelSize naturalPixels, bool isReduced) + { + Bitmap = bitmap; + NaturalSize = naturalSize; + NaturalPixels = naturalPixels; + IsReduced = isReduced; + } + + public Bitmap Bitmap { get; } + + /// The picture's own size in device-independent pixels (layout uses it, so a reduced picture lays out the same). + public Size NaturalSize { get; } + + /// The picture's own size in pixels. + public PixelSize NaturalPixels { get; } + + /// True when holds fewer pixels than the original. + public bool IsReduced { get; } + + public long Bytes => (long)Bitmap.PixelSize.Width * Bitmap.PixelSize.Height * 4; + + /// Whether this frame has enough pixels for a slot of drawn with . + public bool Covers(Size slotPixels, Stretch stretch) + { + if (!IsReduced) + { + return true; + } + var needed = ArcImageDecoder.TargetPixels(NaturalPixels, slotPixels, stretch); + return needed is null || (Bitmap.PixelSize.Width >= needed.Value.Width - 1 && Bitmap.PixelSize.Height >= needed.Value.Height - 1); + } +} + +/// +/// The decoded pictures shares, by : a picture that is cached shows +/// at once (no loading state), and two images that ask for the same address while it downloads share one request. The +/// least recently used pictures are dropped once the cache holds more than . +/// Used on the UI thread. +/// +public static class ArcImageCache +{ + private sealed class Pending + { + public required Task Task { get; init; } + public required CancellationTokenSource Cancellation { get; init; } + public int Waiters; + } + + private static readonly Dictionary> s_entries = new(StringComparer.Ordinal); + private static readonly LinkedList<(string Key, ArcImageFrame Frame)> s_order = new(); + private static readonly Dictionary s_pending = new(StringComparer.Ordinal); + private static long s_bytes; + private static long s_maxBytes = 64L * 1024 * 1024; + + /// How many bytes of decoded pixels the cache keeps (default 64 MB); 0 turns caching off. + public static long MaxBytes + { + get => s_maxBytes; + set + { + s_maxBytes = Math.Max(0, value); + Trim(); + } + } + + /// The number of cached pictures. + public static int Count => s_entries.Count; + + /// Bytes of decoded pixels the cache holds now. + public static long Bytes => s_bytes; + + /// Whether the picture of is cached. + public static bool Contains(ImageSource source) => source.CacheKey is { } key && s_entries.ContainsKey(key); + + /// Drops the picture of (images that show it keep it until their source changes). + public static void Remove(ImageSource source) + { + if (source.CacheKey is { } key && s_entries.Remove(key, out var node)) + { + s_order.Remove(node); + s_bytes -= node.Value.Frame.Bytes; + } + } + + /// Drops every cached picture. + public static void Clear() + { + s_entries.Clear(); + s_order.Clear(); + s_bytes = 0; + } + + internal static bool TryGet(ImageSource source, out ArcImageFrame frame) + { + if (source.CacheKey is { } key && s_entries.TryGetValue(key, out var node)) + { + s_order.Remove(node); + s_order.AddFirst(node); + frame = node.Value.Frame; + return true; + } + frame = null!; + return false; + } + + /// + /// Loads and decodes for a slot of , joining a download of the same + /// address that is already running. Cancelling stops waiting; the download itself + /// is cancelled once nobody waits for it. + /// + internal static async Task LoadAsync(ImageSource source, IArcImageLoader loader, Size? slotPixels, Stretch stretch, + CancellationToken cancellationToken) + { + var key = source.CacheKey; + if (key is null || s_maxBytes == 0) + { + return await ArcImageDecoder.LoadAsync(source, loader, slotPixels, stretch, cancellationToken); + } + if (!s_pending.TryGetValue(key, out var pending)) + { + var cancellation = new CancellationTokenSource(); + pending = new Pending + { + Task = ArcImageDecoder.LoadAsync(source, loader, slotPixels, stretch, cancellation.Token), + Cancellation = cancellation + }; + s_pending[key] = pending; + _ = Settle(key, pending); + } + pending.Waiters++; + try + { + return await pending.Task.WaitAsync(cancellationToken); + } + finally + { + pending.Waiters--; + if (pending.Waiters == 0 && !pending.Task.IsCompleted) + { + pending.Cancellation.Cancel(); + if (s_pending.TryGetValue(key, out var current) && ReferenceEquals(current, pending)) + { + s_pending.Remove(key); + } + } + } + } + + private static async Task Settle(string key, Pending pending) + { + try + { + var frame = await pending.Task; + Store(key, frame); + } + catch (Exception) + { + // Failures are not cached: the next request for the address tries again. + } + finally + { + if (s_pending.TryGetValue(key, out var current) && ReferenceEquals(current, pending)) + { + s_pending.Remove(key); + } + } + } + + private static void Store(string key, ArcImageFrame frame) + { + if (s_entries.Remove(key, out var old)) + { + s_order.Remove(old); + s_bytes -= old.Value.Frame.Bytes; + } + if (frame.Bytes > s_maxBytes) + { + return; + } + s_entries[key] = s_order.AddFirst((key, frame)); + s_bytes += frame.Bytes; + Trim(); + } + + private static void Trim() + { + while (s_bytes > s_maxBytes && s_order.Last is { } last) + { + s_order.RemoveLast(); + s_entries.Remove(last.Value.Key); + s_bytes -= last.Value.Frame.Bytes; + } + } +} + +/// Reads a picture through a loader and decodes it, scaled down when it is much larger than its slot. +internal static class ArcImageDecoder +{ + /// A picture is scaled down only when it is more than this much larger than its slot needs. + private const double ReduceThreshold = 1.5; + + public static async Task LoadAsync(ImageSource source, IArcImageLoader loader, Size? slotPixels, Stretch stretch, + CancellationToken cancellationToken) + { + var stream = await loader.OpenAsync(source, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + // Decoding is CPU work: off the UI thread on the desktop (WebAssembly without threads runs it inline). + return await Task.Run(() => + { + using (stream) + { + return Decode(stream, slotPixels, stretch); + } + }, cancellationToken); + } + + public static ArcImageFrame Decode(Stream stream, Size? slotPixels, Stretch stretch) + { + if (!stream.CanSeek) + { + var copy = new MemoryStream(); + stream.CopyTo(copy); + copy.Position = 0; + stream = copy; + } + var bitmap = new Bitmap(stream); + var natural = bitmap.Size; + var pixels = bitmap.PixelSize; + if (slotPixels is { } slot && TargetPixels(pixels, slot, stretch) is { } target && + pixels.Width > target.Width * ReduceThreshold && pixels.Height > target.Height * ReduceThreshold) + { + var reduced = bitmap.CreateScaledBitmap(target, BitmapInterpolationMode.HighQuality); + bitmap.Dispose(); + return new ArcImageFrame(reduced, natural, pixels, isReduced: true); + } + return new ArcImageFrame(bitmap, natural, pixels, isReduced: false); + } + + /// + /// The pixel size a picture of needs to fill a slot of with + /// , or null when its own size is needed (no stretch, unknown slot, or a slot larger than the picture). + /// + public static PixelSize? TargetPixels(PixelSize naturalPixels, Size slotPixels, Stretch stretch) + { + var natural = new Size(naturalPixels.Width, naturalPixels.Height); + if (stretch == Stretch.None || natural.Width <= 0 || natural.Height <= 0) + { + return null; + } + var sx = slotPixels.Width > 0 && double.IsFinite(slotPixels.Width) ? slotPixels.Width / natural.Width : double.NaN; + var sy = slotPixels.Height > 0 && double.IsFinite(slotPixels.Height) ? slotPixels.Height / natural.Height : double.NaN; + double scale; + if (double.IsNaN(sx) && double.IsNaN(sy)) + { + return null; + } + if (double.IsNaN(sx) || double.IsNaN(sy)) + { + scale = double.IsNaN(sx) ? sy : sx; + } + else + { + // Uniform fits inside the slot; UniformToFill and Fill must cover it on both axes. + scale = stretch == Stretch.Uniform ? Math.Min(sx, sy) : Math.Max(sx, sy); + } + if (scale >= 1) + { + return null; + } + return new PixelSize(Math.Max(1, (int)Math.Ceiling(natural.Width * scale)), Math.Max(1, (int)Math.Ceiling(natural.Height * scale))); + } +} diff --git a/src/TKCode.Arc.Core/Primitives/Images/ArcImageLoader.cs b/src/TKCode.Arc.Core/Primitives/Images/ArcImageLoader.cs new file mode 100644 index 00000000..98a5afe1 --- /dev/null +++ b/src/TKCode.Arc.Core/Primitives/Images/ArcImageLoader.cs @@ -0,0 +1,100 @@ +using Avalonia.Platform; + +namespace TKCode.Arc.Primitives; + +/// +/// Reads the encoded bytes of an for , which decodes, sizes and caches them. +/// Replace (or set on a subtree) to add headers, use +/// your own HTTP client or serve pictures from a test fixture. +/// +public interface IArcImageLoader +{ + /// + /// Opens the encoded picture (PNG, JPEG, WebP …) of . Called on the UI thread; the work may + /// continue anywhere. Throw (or return a faulted task) when the picture cannot be read; honour + /// , which fires when no control waits for the picture any more. + /// never reaches a loader. + /// + Task OpenAsync(ImageSource source, CancellationToken cancellationToken); +} + +/// +/// The default : http(s) through one shared (the browser's +/// fetch in WebAssembly), avares:// through , file:// from disk, +/// data: addresses inline, and storage files through . +/// +public class ArcImageLoader : IArcImageLoader +{ + private static readonly Lazy s_http = new(static () => new HttpClient { Timeout = TimeSpan.FromSeconds(30) }); + + /// The shared instance. + public static ArcImageLoader Shared { get; } = new(); + + /// The HTTP client used for http(s) addresses. + protected virtual HttpClient Http => s_http.Value; + + /// + public virtual async Task OpenAsync(ImageSource source, CancellationToken cancellationToken) + { + switch (source) + { + case UriImageSource { Uri: var uri }: + return await OpenUriAsync(uri, cancellationToken).ConfigureAwait(false); + case StorageFileImageSource { File: var file }: + { + await using var stream = await file.OpenReadAsync().ConfigureAwait(false); + return await CopyAsync(stream, cancellationToken).ConfigureAwait(false); + } + default: + throw new NotSupportedException($"{source.GetType().Name} cannot be opened by {nameof(ArcImageLoader)}."); + } + } + + /// Opens an absolute http(s), avares, file or data address. + protected virtual async Task OpenUriAsync(Uri uri, CancellationToken cancellationToken) + { + if (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps) + { + var bytes = await Http.GetByteArrayAsync(uri, cancellationToken).ConfigureAwait(false); + return new MemoryStream(bytes, writable: false); + } + if (uri.Scheme == "avares") + { + using var asset = AssetLoader.Open(uri); + return await CopyAsync(asset, cancellationToken).ConfigureAwait(false); + } + if (uri.Scheme == Uri.UriSchemeFile) + { + var bytes = await File.ReadAllBytesAsync(uri.LocalPath, cancellationToken).ConfigureAwait(false); + return new MemoryStream(bytes, writable: false); + } + if (uri.Scheme == "data") + { + return new MemoryStream(DecodeDataUri(uri.OriginalString), writable: false); + } + throw new NotSupportedException($"'{uri.Scheme}' addresses are not supported."); + } + + /// The bytes of a data: address (base64 or percent-encoded). + internal static byte[] DecodeDataUri(string text) + { + var comma = text.IndexOf(','); + if (comma < 0) + { + throw new FormatException("A data: address needs a comma before its content."); + } + var header = text.AsSpan(0, comma); + var body = text[(comma + 1)..]; + return header.Contains(";base64", StringComparison.OrdinalIgnoreCase) + ? Convert.FromBase64String(Uri.UnescapeDataString(body)) + : System.Text.Encoding.UTF8.GetBytes(Uri.UnescapeDataString(body)); + } + + private static async Task CopyAsync(Stream stream, CancellationToken cancellationToken) + { + var copy = new MemoryStream(); + await stream.CopyToAsync(copy, cancellationToken).ConfigureAwait(false); + copy.Position = 0; + return copy; + } +} diff --git a/src/TKCode.Arc.Core/Primitives/Images/ImageSource.cs b/src/TKCode.Arc.Core/Primitives/Images/ImageSource.cs new file mode 100644 index 00000000..bd90a2e3 --- /dev/null +++ b/src/TKCode.Arc.Core/Primitives/Images/ImageSource.cs @@ -0,0 +1,178 @@ +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using Avalonia.Media.Imaging; +using Avalonia.Platform.Storage; + +namespace TKCode.Arc; + +/// +/// Where a picture comes from: an address (: http(s), avares://, file:// or +/// data:), a file the user picked () or a bitmap you already hold +/// (). loads it; in XAML a URL or an absolute file path +/// converts to a source, and in code / do the same. +/// +/// +/// Sources compare by reference, so assigning a new source for the same address asks for the picture again (a failed +/// address is retried); a picture that is already in still shows at once. +/// +[TypeConverter(typeof(ImageSourceConverter))] +public abstract class ImageSource +{ + private protected ImageSource() + { + } + + /// + /// The key under which keeps the decoded picture, or null when it is not cached + /// (a bitmap source is already decoded). + /// + public abstract string? CacheKey { get; } + + /// Whether names the same picture (same address, same file or same bitmap). + internal abstract bool IsSamePicture(ImageSource other); + + /// A source for a bitmap that is already decoded; nothing is loaded and nothing is cached. + public static ImageSource FromBitmap(Bitmap bitmap) => new BitmapImageSource(bitmap); + + /// A source for a file the user picked (or dropped); it is read when it is shown. + public static ImageSource FromStorageFile(IStorageFile file) => new StorageFileImageSource(file); + + /// A source for an absolute address: http(s), avares://, file:// or data:. + public static ImageSource FromUri(Uri uri) => new UriImageSource(uri); + + /// + /// Reads an absolute URL (http, https, avares, file, data) or an absolute file path. + /// + /// The text is not such an address. + public static ImageSource Parse(string text) + { + if (TryParse(text, out var source)) + { + return source; + } + throw new FormatException($"'{text}' is not an image address (an absolute http(s), avares, file or data URL, or an absolute file path)."); + } + + /// Like , but returns false instead of throwing. + public static bool TryParse([NotNullWhen(true)] string? text, [NotNullWhen(true)] out ImageSource? source) + { + source = null; + var value = text?.Trim(); + if (string.IsNullOrEmpty(value)) + { + return false; + } + if (Uri.TryCreate(value, UriKind.Absolute, out var uri) && UriImageSource.IsSupported(uri)) + { + // On Unix "/photos/a.png" parses as a file URI too; both end up as file:// here. + source = new UriImageSource(uri); + return true; + } + if (Path.IsPathRooted(value) && Uri.TryCreate(Path.GetFullPath(value), UriKind.Absolute, out var file) && file.IsFile) + { + source = new UriImageSource(file); + return true; + } + return false; + } + + /// The address as text (a URL, a file path or a short description). + public abstract override string ToString(); +} + +/// A picture at an absolute address: http(s), avares://, file:// or data:. +public sealed class UriImageSource : ImageSource +{ + /// Creates a source for . + /// The address is relative or its scheme is not supported. + public UriImageSource(Uri uri) + { + ArgumentNullException.ThrowIfNull(uri); + if (!IsSupported(uri)) + { + throw new ArgumentException($"'{uri}' is not an absolute http(s), avares, file or data address.", nameof(uri)); + } + Uri = uri; + } + + /// The address of the picture. + public Uri Uri { get; } + + /// + public override string CacheKey => Uri.OriginalString; + + internal static bool IsSupported(Uri uri) => + uri.IsAbsoluteUri && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeFile || + uri.Scheme == "avares" || uri.Scheme == "data"); + + internal override bool IsSamePicture(ImageSource other) => + other is UriImageSource uri && string.Equals(uri.Uri.OriginalString, Uri.OriginalString, StringComparison.Ordinal); + + /// + public override string ToString() => Uri.OriginalString; +} + +/// A picture in a file from the storage provider (a picked, dropped or pasted file); read when it is shown. +public sealed class StorageFileImageSource : ImageSource +{ + /// Creates a source for . + public StorageFileImageSource(IStorageFile file) + { + ArgumentNullException.ThrowIfNull(file); + File = file; + } + + /// The file holding the picture. + public IStorageFile File { get; } + + /// + /// Files with an absolute path are cached by it; others (some browser files) are not cached. + public override string? CacheKey => File.Path is { IsAbsoluteUri: true } path ? "storage:" + path.AbsoluteUri : null; + + internal override bool IsSamePicture(ImageSource other) => other is StorageFileImageSource file && ReferenceEquals(file.File, File); + + /// + public override string ToString() => File.Name; +} + +/// A bitmap that is already decoded; it shows at once and is never cached or disposed by Arc. +public sealed class BitmapImageSource : ImageSource +{ + /// Creates a source for . + public BitmapImageSource(Bitmap bitmap) + { + ArgumentNullException.ThrowIfNull(bitmap); + Bitmap = bitmap; + } + + /// The picture. + public Bitmap Bitmap { get; } + + /// + public override string? CacheKey => null; + + internal override bool IsSamePicture(ImageSource other) => other is BitmapImageSource bitmap && ReferenceEquals(bitmap.Bitmap, Bitmap); + + /// + public override string ToString() => $"Bitmap {Bitmap.PixelSize.Width}×{Bitmap.PixelSize.Height}"; +} + +/// Converts XAML text (a URL or an absolute file path) to an . +public sealed class ImageSourceConverter : TypeConverter +{ + /// + public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) => + sourceType == typeof(string) || sourceType == typeof(Uri) || typeof(Bitmap).IsAssignableFrom(sourceType) || + base.CanConvertFrom(context, sourceType); + + /// + public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value) => + value switch + { + string text => ImageSource.Parse(text), + Uri uri => new UriImageSource(uri), + Bitmap bitmap => new BitmapImageSource(bitmap), + _ => base.ConvertFrom(context, culture, value) + }; +} diff --git a/src/TKCode.Arc.Core/Primitives/Modal/ArcOverlayHost.cs b/src/TKCode.Arc.Core/Primitives/Modal/ArcOverlayHost.cs new file mode 100644 index 00000000..2a104344 --- /dev/null +++ b/src/TKCode.Arc.Core/Primitives/Modal/ArcOverlayHost.cs @@ -0,0 +1,116 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.VisualTree; + +namespace TKCode.Arc.Primitives; + +/// +/// A panel that gives its descendants their own overlay layer: modals opened from inside it (dialogs, drawers, bottom +/// sheets, command palettes) mount on this panel's and are bounded by it instead of covering the +/// whole window. Children stack like a ; the layer sits above all of them. Used for previews and docs +/// stages; an app does not need it. +/// +/// <arc:ArcOverlayHost Height="480"> +/// <StackPanel>…</StackPanel> +/// <arc:Dialog IsOpen="True" … /> +/// </arc:ArcOverlayHost> +/// +/// +public class ArcOverlayHost : Panel +{ + private readonly ArcOverlayLayer _layer; + + /// Creates the host and its (initially empty) overlay layer. + public ArcOverlayHost() + { + _layer = new ArcOverlayLayer(this) { ZIndex = int.MaxValue }; + // The layer is a visual child only and stays last: Panel inserts its children at their own indices, which never + // pass the layer. + VisualChildren.Add(_layer); + } + + /// The overlay layer hosted modals mount on; it covers the host's bounds. + public ArcOverlayLayer Layer => _layer; + + /// The nearest that contains , or null. + public static ArcOverlayHost? FindHost(Visual visual) + { + ArgumentNullException.ThrowIfNull(visual); + foreach (var ancestor in visual.GetVisualAncestors()) + { + if (ancestor is ArcOverlayHost host) + { + return host; + } + } + return null; + } + + /// + protected override Size MeasureOverride(Size availableSize) + { + var size = base.MeasureOverride(availableSize); + _layer.Measure(availableSize); + return size; + } + + /// + protected override Size ArrangeOverride(Size finalSize) + { + var size = base.ArrangeOverride(finalSize); + _layer.Arrange(new Rect(finalSize)); + return size; + } +} + +/// +/// The overlay layer of an : a covering the host, transparent to +/// pointer input where it has no content. resolves the layer a modal should mount on. +/// +public sealed class ArcOverlayLayer : Canvas +{ + internal ArcOverlayLayer(ArcOverlayHost host) + { + Host = host; + } + + /// The host panel this layer belongs to (its size is the modal viewport). + public ArcOverlayHost Host { get; } + + /// + /// The layer a modal opened from mounts on: the layer of the nearest + /// ancestor, otherwise the window's , which is switched on in + /// the nearest when it is off (null when is not attached to + /// a window). + /// + public static Panel? Find(Visual anchor) + { + ArgumentNullException.ThrowIfNull(anchor); + if (ArcOverlayHost.FindHost(anchor) is { } host) + { + return host.Layer; + } + if (OverlayLayer.GetOverlayLayer(anchor) is { } overlay) + { + return overlay; + } + if (TopLevel.GetTopLevel(anchor) is not { } topLevel) + { + return null; + } + if (OverlayLayer.GetOverlayLayer(topLevel) is { } topOverlay) + { + return topOverlay; + } + // Layer managers create their overlay layer on demand. + var manager = anchor.FindAncestorOfType(includeSelf: true) + ?? topLevel.GetVisualDescendants().OfType().FirstOrDefault(); + if (manager is null) + { + return null; + } + manager.EnableOverlayLayer = true; + return OverlayLayer.GetOverlayLayer(anchor) ?? OverlayLayer.GetOverlayLayer(topLevel); + } +} diff --git a/src/TKCode.Arc.Core/Primitives/Modal/ModalPresence.cs b/src/TKCode.Arc.Core/Primitives/Modal/ModalPresence.cs index f2453bc2..eeb91378 100644 --- a/src/TKCode.Arc.Core/Primitives/Modal/ModalPresence.cs +++ b/src/TKCode.Arc.Core/Primitives/Modal/ModalPresence.cs @@ -1,6 +1,5 @@ using TKCode.Arc.DesignTokens; using TKCode.Arc.Motion; -using AtomUI.Controls.Primitives; using Avalonia; using Avalonia.Animation.Easings; using Avalonia.Controls; @@ -107,7 +106,7 @@ public IBrush? ScrimBrush /// /// Backdrop blur radius in px applied to the window content while the scrim is shown (Arc: dialog 7, drawer 4; /// tokens BackdropBlurDialog / BackdropBlurDrawer). Ignored when the ReduceTransparency - /// token is on, and inside a scoped overlay panel. + /// token is on, and inside an . /// public double BackdropBlur { get; set; } @@ -518,14 +517,7 @@ private void OnAnchorDetached(object? sender, VisualTreeAttachmentEventArgs e) #region Layer - private static Panel? ResolveLayer(Visual anchor) - { - if (ScopeAwareOverlayLayer.GetLayer(anchor) is { } scoped && scoped.GetVisualParent() is not null) - { - return scoped; - } - return OverlayLayer.GetOverlayLayer(anchor); - } + private static Panel? ResolveLayer(Visual anchor) => ArcOverlayLayer.Find(anchor); private void OnLayerSizeChanged(object? sender, SizeChangedEventArgs e) { @@ -536,7 +528,7 @@ private void OnLayerSizeChanged(object? sender, SizeChangedEventArgs e) private void UpdateLayerSize() { var size = _layer?.Bounds.Size ?? default; - if ((size.Width <= 0 || size.Height <= 0) && _layer is ScopeAwareOverlayLayer { LayerHost: Visual host }) + if ((size.Width <= 0 || size.Height <= 0) && _layer is ArcOverlayLayer { Host: { } host }) { size = host.Bounds.Size; } @@ -577,8 +569,8 @@ internal double CurrentBackdropBlur } } - /// Window content under the overlay layer (null inside a scoped overlay panel). - internal Visual? BlurTarget => _blurTargetCache ??= _layer is ScopeAwareOverlayLayer { LayerHost: ScopeAwareOverlayLayerPanel } + /// Window content under the overlay layer (null inside an ). + internal Visual? BlurTarget => _blurTargetCache ??= _layer is ArcOverlayLayer ? null : _layer is null ? null : ModalStack.FindBlurTarget(_layer); diff --git a/src/TKCode.Arc.Core/Primitives/Popups/ArcPopup.cs b/src/TKCode.Arc.Core/Primitives/Popups/ArcPopup.cs index 95e2ebd7..65f45627 100644 --- a/src/TKCode.Arc.Core/Primitives/Popups/ArcPopup.cs +++ b/src/TKCode.Arc.Core/Primitives/Popups/ArcPopup.cs @@ -1,24 +1,39 @@ +using System.Runtime.CompilerServices; using TKCode.Arc.DesignTokens; using TKCode.Arc.Motion; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Primitives; using Avalonia.Controls.Primitives.PopupPositioning; -using AtomPopup = AtomUI.Desktop.Controls.Popup; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.Threading; +using Avalonia.VisualTree; namespace TKCode.Arc.Primitives; /// -/// Anchored popup for Arc floating surfaces (S8). Reuses AtomUI's popup (placement, flipping, shadow-aware -/// hosting in window or overlay mode) with Arc's floating shadow and Arc enter / exit motion (P10). +/// Anchored popup for Arc floating surfaces (S8): Avalonia's with Arc's placement (the requested +/// side, kept whichever way the positioner flips), Arc's floating shadow drawn by an +/// around the surface (with transparent room for it in a native popup window), and Arc enter / exit motion (P10) played on that +/// frame. Works in the window's overlay layer (the default; also browser and headless) and in native popup windows. /// The child should be a or templated surface so the shadow follows its corner radius. /// -public class ArcPopup : AtomPopup +/// +/// is rewritten to ; the requested side is kept in +/// and resolved by Arc (code that reads the side reads +/// RequestedPlacement ?? Placement). Closing with an exit motion keeps true until +/// the motion finished (the value is coerced), so the host only goes away once the surface is gone; opening again +/// during the exit reverses it from where the surface is. +/// +public class ArcPopup : Popup { /// Distance the surface travels toward its anchor while entering (Arc popover: 5px, tooltip: 3px). public static readonly StyledProperty EnterOffsetProperty = AvaloniaProperty.Register(nameof(EnterOffset), 4); + /// Scale the surface grows from while entering. public static readonly StyledProperty EnterScaleProperty = AvaloniaProperty.Register(nameof(EnterScale), 0.97); @@ -33,18 +48,45 @@ public class ArcPopup : AtomPopup public static readonly StyledProperty IsExitInstantProperty = AvaloniaProperty.Register(nameof(IsExitInstant)); + /// Gap between the surface and its anchor along the placement side (Arc's sideOffset). + public static readonly StyledProperty MarginToAnchorProperty = + AvaloniaProperty.Register(nameof(MarginToAnchor), 8); + + /// + /// Whether the popup plays its and . Code that animates the + /// itself sets it false (with animation priority, which wins over the value the popup + /// derives from the motion level). + /// + public static readonly StyledProperty IsMotionEnabledProperty = + AvaloniaProperty.Register(nameof(IsMotionEnabled), true); + + /// The side the popup was asked to open on ( itself is Custom). + public static readonly StyledProperty RequestedPlacementProperty = + AvaloniaProperty.Register(nameof(RequestedPlacement)); + + /// Shadow drawn around the surface when the popup is a native popup window. + public static readonly StyledProperty PopupRootShadowProperty = + AvaloniaProperty.Register(nameof(PopupRootShadow)); + + /// Shadow drawn around the surface when the popup is hosted in the window's overlay layer. + public static readonly StyledProperty OverlayHostShadowProperty = + AvaloniaProperty.Register(nameof(OverlayHostShadow)); + + /// public double EnterOffset { get => GetValue(EnterOffsetProperty); set => SetValue(EnterOffsetProperty, value); } + /// public double EnterScale { get => GetValue(EnterScaleProperty); set => SetValue(EnterScaleProperty, value); } + /// public bool IsInstant { get => GetValue(IsInstantProperty); @@ -58,11 +100,60 @@ public bool IsExitInstant set => SetValue(IsExitInstantProperty, value); } + /// + public double MarginToAnchor + { + get => GetValue(MarginToAnchorProperty); + set => SetValue(MarginToAnchorProperty, value); + } + + /// + public bool IsMotionEnabled + { + get => GetValue(IsMotionEnabledProperty); + set => SetValue(IsMotionEnabledProperty, value); + } + + /// + public PlacementMode? RequestedPlacement + { + get => GetValue(RequestedPlacementProperty); + set => SetValue(RequestedPlacementProperty, value); + } + + /// + public BoxShadows PopupRootShadow + { + get => GetValue(PopupRootShadowProperty); + set => SetValue(PopupRootShadowProperty, value); + } + + /// + public BoxShadows OverlayHostShadow + { + get => GetValue(OverlayHostShadowProperty); + set => SetValue(OverlayHostShadowProperty, value); + } + + /// Motion played on the frame when the popup opens (null: it appears at once). + public ArcPopupMotion? OpenMotion { get; set; } + + /// Motion played on the frame before the popup closes (null: it closes at once). + public ArcPopupMotion? CloseMotion { get; set; } + + /// True when the last placement put the surface on the opposite vertical side of the requested one. + public bool IsVerticalFlipped { get; private set; } + + /// True when the last placement put the surface on the opposite horizontal side of the requested one. + public bool IsHorizontalFlipped { get; private set; } + + /// Raised when or changes. + public event EventHandler? PositionFlipped; + static ArcPopup() { // Defaults, not local values: popups declared in a ControlTemplate set these with template priority, which a // local value set in the constructor would silently override. - MarginToAnchorProperty.OverrideDefaultValue(8); IsLightDismissEnabledProperty.OverrideDefaultValue(true); // Arc surfaces sit a few pixels from their anchor while their floating shadow spreads 28–68px. In a native // popup window that shadow is part of the window, so it would cover the anchor and take its pointer input @@ -70,67 +161,129 @@ static ArcPopup() // paint, as on the web. Native windows remain available per popup (ShouldUseOverlayLayer = false). ShouldUseOverlayLayerProperty.OverrideDefaultValue(true); // Coerce rather than react: Popup makes the assigned child its logical child before a changed handler runs, so - // a handler could no longer move that child into a wrapper. + // a handler could no longer move that child into a frame. ChildProperty.OverrideMetadata(new StyledPropertyMetadata(coerce: static (popup, child) => popup is ArcPopup arcPopup ? arcPopup.HostChild(child) : child)); + // Closing with an exit motion keeps the popup open until the motion finished. + IsOpenProperty.OverrideMetadata(new StyledPropertyMetadata(coerce: static (popup, open) => + popup is ArcPopup arcPopup ? arcPopup.CoerceIsOpen(open) : open)); + // Pointer placements open at the last pointer position of the window. + InputElement.PointerMovedEvent.AddClassHandler(TrackPointer, RoutingStrategies.Tunnel, true); + InputElement.PointerPressedEvent.AddClassHandler(TrackPointer, RoutingStrategies.Tunnel, true); } - private ArcZoomHost? _zoomHost; + private ArcPopupFrame? _frame; + private MotionValue? _opacity; + private MotionValue? _translate; + private MotionValue? _scale; + private readonly MatrixTransform _motionTransform = new(); + private ArcPopupMotion? _playing; + private bool _frameMoved; + private bool _exiting; + private bool _finishingExit; + private bool _awaitingRest; + private int _motionGeneration; + private TopLevel? _dismissTopLevel; + + public ArcPopup() + { + ArcTokens.Bind(this, PopupRootShadowProperty, ArcDesignTokenKind.ShadowFloating, Avalonia.Data.BindingPriority.Style); + ArcTokens.Bind(this, OverlayHostShadowProperty, ArcDesignTokenKind.ShadowFloating, Avalonia.Data.BindingPriority.Style); + SetCurrentValue(RequestedPlacementProperty, PlacementMode.Bottom); + SetCurrentValue(PlacementProperty, PlacementMode.Custom); + CustomPopupPlacementCallback = HandlePlacement; + Opened += HandleOpened; + Closed += HandleClosed; + UpdateMotions(); + } /// /// The surface that was assigned to . Assigned surfaces are hosted in an - /// so they follow ; code that needs the surface itself reads this - /// instead of . + /// ; code that needs the surface itself reads this instead of . /// - public Control? Surface => Child is ArcZoomHost host ? host.Surface : Child; + public Control? Surface => Child is ArcPopupFrame frame ? frame.Surface : Child; + + /// Runs the placement again (after the surface or its anchor changed while open). + public void Reposition() + { + if (!IsOpen) + { + return; + } + // Offset changes are the public way to make the popup run its placement again. + var offset = VerticalOffset; + SetCurrentValue(VerticalOffsetProperty, offset + 1); + SetCurrentValue(VerticalOffsetProperty, offset); + } + + #region Frame /// The element actually holds for an assigned surface (reused while it is the same surface). private Control? HostChild(Control? surface) { - if (surface is null or ArcZoomHost) + if (surface is null or ArcPopupFrame) { // Release the previous surface so another popup (or the same one later) can host it. - if (surface is null && _zoomHost is not null) + if (surface is null && _frame is not null) { - _zoomHost.Surface = null; + _frame.Surface = null; } return surface; } - if (_zoomHost is null) + if (_frame is null) { - _zoomHost = new ArcZoomHost { Scale = ArcZoom.GetScale(this) }; + _frame = new ArcPopupFrame { Scale = ArcZoom.GetScale(this) }; + _opacity = new MotionValue(_frame, 1, MotionVectors.Double); + _translate = new MotionValue(_frame, default, MotionVectors.Vector); + _scale = new MotionValue(_frame, 1, MotionVectors.Double); + _opacity.Changed += _ => ApplyMotion(); + _translate.Changed += _ => ApplyMotion(); + _scale.Changed += _ => ApplyMotion(); + SyncShadow(ShouldUseOverlayLayer); } - if (!ReferenceEquals(_zoomHost.Surface, surface)) + if (!ReferenceEquals(_frame.Surface, surface)) { - _zoomHost.Surface = null; - _zoomHost.Surface = surface; + _frame.Surface = null; + _frame.Surface = surface; } - return _zoomHost; + return _frame; } private void SyncZoom() { - if (_zoomHost is not null) + if (_frame is not null) { - _zoomHost.Scale = ArcZoom.GetScale(this); + _frame.Scale = ArcZoom.GetScale(this); } } - public ArcPopup() + /// + /// Gives the frame the shadow of the host it is (about to be) in. A native popup window clips to its size, so there + /// the frame also takes the room the shadow needs as its margin. In the overlay layer nothing clips the popup host: + /// the shadow is painted past the host's bounds and takes no layout room, so a surface that fills the window's + /// height is not squeezed by its own shadow. + /// + private void SyncShadow(bool overlay) { - ArcTokens.Bind(this, PopupRootShadowProperty, ArcDesignTokenKind.ShadowFloating, Avalonia.Data.BindingPriority.Style); - ArcTokens.Bind(this, OverlayHostShadowProperty, ArcDesignTokenKind.ShadowFloating, Avalonia.Data.BindingPriority.Style); - // Always go through AtomUI's placement (margin to anchor, flipping): AtomUI only enters it when the placement - // changes, so a popup that keeps the default Bottom would otherwise ignore MarginToAnchor. - SetCurrentValue(RequestedPlacementProperty, PlacementMode.Bottom); - CustomPlacementCallback = HandlePlacement; - UpdateMotions(); + if (_frame is null) + { + return; + } + var shadow = overlay ? OverlayHostShadow : PopupRootShadow; + _frame.BoxShadow = shadow; + _frame.Margin = overlay ? default : ArcPopupFrame.RoomFor(shadow); } + #endregion + + #region Placement + /// - /// Runs after AtomUI positioned the popup (and after Arc removed the shadow margin). Subclasses adjust the final - /// here (custom anchors, motion origins from the flipped side); offsets are in the - /// popup host's coordinates. + /// Runs after Arc resolved the placement (requested side, margin to anchor, flips). Subclasses adjust the final + /// here (custom anchors, motion origins from the flipped side). Offsets and the anchor + /// rectangle are in the window's client coordinates and place the surface itself; + /// includes the shadow room around it (use + /// , not , which is the room the host leaves out). /// protected virtual void OnPlacementResolved(CustomPopupPlacement placement, Thickness shadow, double marginToAnchor, bool isOverlayHost, bool flipX, bool flipY) @@ -138,32 +291,88 @@ protected virtual void OnPlacementResolved(CustomPopupPlacement placement, Thick } /// - /// Runs first, right after AtomUI computed the placement: subclasses that anchor somewhere else (a point instead of - /// the last pointer position) change the anchor rectangle here and report the flips the positioner will apply, - /// so the shadow margin is removed on the correct sides. + /// Runs first, before the margin to the anchor is applied and flips are predicted: subclasses that anchor somewhere + /// else (a point, a menu-wide rectangle) change here. /// - protected virtual void AdjustPlacementAnchor(CustomPopupPlacement placement, ref bool flipX, ref bool flipY) + protected virtual void AdjustPlacementAnchor(CustomPopupPlacement placement) { } - private void HandlePlacement(CustomPopupPlacement placement, Thickness shadow, double marginToAnchor, bool isOverlayHost, - bool flipX, bool flipY) + /// True when the surface sits in a native popup window (headless and overlay hosting report false). + protected bool IsHostedInPopupWindow(bool isOverlayHost) => + !isOverlayHost && Child is { } child && TopLevel.GetTopLevel(child) is PopupRoot; + + private void HandlePlacement(CustomPopupPlacement placement) { - AdjustPlacementAnchor(placement, ref flipX, ref flipY); - if (isOverlayHost) + var requested = RequestedPlacement is { } side && side != PlacementMode.Custom ? side : PlacementMode.Bottom; + var topLevel = TopLevel.GetTopLevel(placement.Target); + var isOverlayHost = !(Child is { } child && TopLevel.GetTopLevel(child) is PopupRoot); + // The shadow room is the frame's margin. The host may or may not leave its child's margin out when it places + // the popup (that depends on the host's template), so Arc places the surface itself and only hands the host + // a position (see the end of this method). + var room = Child is ArcPopupFrame frame ? frame.Margin : placement.Deflate; + var margin = MarginToAnchor; + if (requested == PlacementMode.Pointer) + { + placement.AnchorRectangle = new Rect(PointerPosition(topLevel, placement.AnchorRectangle), new Size(1, 1)); + } + (placement.Anchor, placement.Gravity) = AnchorAndGravity(requested); + AdjustPlacementAnchor(placement); + GrowAnchor(placement, requested, margin, isOverlayHost ? default : room); + var surfaceSize = placement.PopupSize.Deflate(room); + var bounds = topLevel is null ? (Rect?)null : ConstraintBounds(topLevel, isOverlayHost); + var (_, flipX, flipY) = bounds is { } predicted ? Resolve(placement, surfaceSize, predicted) : (default, false, false); + OnPlacementResolved(placement, room, margin, isOverlayHost, flipX, flipY); + if (bounds is { } final) { - PlaceInOverlay(placement, marginToAnchor); + // Flip and slide the surface (without its shadow room) the way the positioner would, then pin the host + // there: whatever room the host leaves out, the surface lands on the resolved rectangle and the shadow + // spreads around it. Only resizing a surface that does not fit is left to the positioner. + (var surface, flipX, flipY) = Resolve(placement, surfaceSize, final); + var hostRoom = placement.Deflate; + placement.Anchor = PopupAnchor.TopLeft; + placement.Gravity = PopupGravity.BottomRight; + placement.AnchorRectangle = new Rect(surface.X + hostRoom.Left - room.Left, surface.Y + hostRoom.Top - room.Top, 1, 1); + placement.Offset = default; + // The host (shadow room included) may reach past the bounds while the surface fits: the positioner + // resizes only when the surface itself does not fit. + var resize = PopupPositionerConstraintAdjustment.None; + if ((placement.ConstraintAdjustment & PopupPositionerConstraintAdjustment.ResizeX) != 0 && !FitsX(surface, final)) + { + resize |= PopupPositionerConstraintAdjustment.ResizeX; + } + if ((placement.ConstraintAdjustment & PopupPositionerConstraintAdjustment.ResizeY) != 0 && !FitsY(surface, final)) + { + resize |= PopupPositionerConstraintAdjustment.ResizeY; + } + placement.ConstraintAdjustment = resize; } - else if (IsHostedInPopupWindow(isOverlayHost)) + if (flipX != IsHorizontalFlipped || flipY != IsVerticalFlipped) { - PlaceInPopupWindow(placement, shadow, marginToAnchor); + IsHorizontalFlipped = flipX; + IsVerticalFlipped = flipY; + PositionFlipped?.Invoke(this, EventArgs.Empty); } - OnPlacementResolved(placement, shadow, marginToAnchor, isOverlayHost, flipX, flipY); } - /// True when the surface sits in a native popup window (headless and overlay hosting report false). - protected bool IsHostedInPopupWindow(bool isOverlayHost) => - !isOverlayHost && Child is { } child && TopLevel.GetTopLevel(child) is PopupRoot; + private (PopupAnchor Anchor, PopupGravity Gravity) AnchorAndGravity(PlacementMode placement) => placement switch + { + PlacementMode.Top => (PopupAnchor.Top, PopupGravity.Top), + PlacementMode.Left => (PopupAnchor.Left, PopupGravity.Left), + PlacementMode.Right => (PopupAnchor.Right, PopupGravity.Right), + PlacementMode.Center => (PopupAnchor.None, PopupGravity.None), + PlacementMode.Pointer => (PopupAnchor.TopLeft, PopupGravity.BottomRight), + PlacementMode.AnchorAndGravity => (PlacementAnchor, PlacementGravity), + PlacementMode.TopEdgeAlignedLeft => (PopupAnchor.TopLeft, PopupGravity.TopRight), + PlacementMode.TopEdgeAlignedRight => (PopupAnchor.TopRight, PopupGravity.TopLeft), + PlacementMode.BottomEdgeAlignedLeft => (PopupAnchor.BottomLeft, PopupGravity.BottomRight), + PlacementMode.BottomEdgeAlignedRight => (PopupAnchor.BottomRight, PopupGravity.BottomLeft), + PlacementMode.LeftEdgeAlignedTop => (PopupAnchor.TopLeft, PopupGravity.BottomLeft), + PlacementMode.LeftEdgeAlignedBottom => (PopupAnchor.BottomLeft, PopupGravity.TopLeft), + PlacementMode.RightEdgeAlignedTop => (PopupAnchor.TopRight, PopupGravity.BottomRight), + PlacementMode.RightEdgeAlignedBottom => (PopupAnchor.BottomRight, PopupGravity.TopRight), + _ => (PopupAnchor.Bottom, PopupGravity.Bottom) + }; private static bool IsVertical(PlacementMode placement) => placement is PlacementMode.Top or PlacementMode.Bottom or PlacementMode.TopEdgeAlignedLeft or PlacementMode.TopEdgeAlignedRight or @@ -174,49 +383,163 @@ PlacementMode.LeftEdgeAlignedTop or PlacementMode.LeftEdgeAlignedBottom or PlacementMode.RightEdgeAlignedTop or PlacementMode.RightEdgeAlignedBottom; /// - /// Overlay hosting: AtomUI applies MarginToAnchor as an offset in the direction it predicts, but predicts flips - /// with the shadow-inflated size while the positioner flips with the real one, so near a window edge the margin - /// can point the wrong way and the surface overlaps its anchor. Instead the anchor grows by the margin along the - /// placement axis and the offset along that axis is the plain popup offset: whichever side the positioner picks, - /// the surface lands MarginToAnchor away. + /// The anchor grows by the margin on both sides of the placement axis and the offset along that axis stays the plain + /// popup offset, so whichever side the positioner picks, the surface lands MarginToAnchor away. A native popup + /// window carries its shadow inside the window, so there the anchor also grows by the shadow facing it: the window + /// must not cover the anchor (it would take its pointer input), and the visible gap is margin + shadow. /// - private void PlaceInOverlay(CustomPopupPlacement placement, double margin) + private static void GrowAnchor(CustomPopupPlacement placement, PlacementMode requested, double margin, Thickness windowShadow) { - var requested = RequestedPlacement ?? Placement; var anchor = placement.AnchorRectangle; if (IsVertical(requested)) { - placement.AnchorRectangle = new Rect(anchor.X, anchor.Y - margin, anchor.Width, anchor.Height + 2 * margin); - placement.Offset = placement.Offset.WithY(VerticalOffset); + var above = margin + windowShadow.Bottom; + var below = margin + windowShadow.Top; + placement.AnchorRectangle = new Rect(anchor.X, anchor.Y - above, anchor.Width, anchor.Height + above + below); } else if (IsHorizontal(requested)) { - placement.AnchorRectangle = new Rect(anchor.X - margin, anchor.Y, anchor.Width + 2 * margin, anchor.Height); - placement.Offset = placement.Offset.WithX(HorizontalOffset); + var before = margin + windowShadow.Right; + var after = margin + windowShadow.Left; + placement.AnchorRectangle = new Rect(anchor.X - before, anchor.Y, anchor.Width + before + after, anchor.Height); + } + else if (requested == PlacementMode.Pointer && margin != 0) + { + placement.AnchorRectangle = anchor.Inflate(margin); } } + /// The area the positioner keeps the surface in, in the window's client coordinates. + private static Rect ConstraintBounds(TopLevel topLevel, bool isOverlayHost) + { + if (!isOverlayHost && topLevel.Screens?.ScreenFromTopLevel(topLevel) is { } screen) + { + var area = screen.WorkingArea; + var topLeft = topLevel.PointToClient(area.TopLeft); + var bottomRight = topLevel.PointToClient(area.BottomRight); + return new Rect(topLeft, bottomRight); + } + var insets = topLevel.InsetsManager?.SafeAreaPadding ?? default; + return new Rect(topLevel.ClientSize).Deflate(insets); + } + /// - /// Native popup window (opt-in): the window carries the shadow as a margin, and moving it closer than AtomUI does - /// would cover the anchor, so directional placements keep AtomUI's position (visible gap = margin + shadow). - /// Pointer placements are corrected: AtomUI adds the shadow to the offset instead of removing it, which puts the - /// surface two shadow widths past the point; growing the 1px anchor by the shadow on every side lands the - /// surface on the point in whichever direction the positioner flips. + /// Where the positioner's rules put a surface of : on the requested side, flipped to the + /// opposite side when it does not fit there but fits there, then slid inside (each as the + /// constraint adjustment allows; horizontal first, as the positioner does). /// - private void PlaceInPopupWindow(CustomPopupPlacement placement, Thickness shadow, double marginToAnchor) + private static (Rect Surface, bool FlipX, bool FlipY) Resolve(CustomPopupPlacement placement, Size size, Rect bounds) { - if ((RequestedPlacement ?? Placement) is not PlacementMode.Pointer) + var adjustment = placement.ConstraintAdjustment; + var rect = Unconstrained(placement, placement.Anchor, placement.Gravity, size); + var flipX = false; + var flipY = false; + if ((adjustment & PopupPositionerConstraintAdjustment.FlipX) != 0 && !FitsX(rect, bounds)) { - return; + var flipped = Unconstrained(placement, FlipX(placement.Anchor), FlipX(placement.Gravity), size); + if (FitsX(flipped, bounds)) + { + rect = rect.WithX(flipped.X); + flipX = true; + } } - var anchor = placement.AnchorRectangle; - placement.AnchorRectangle = new Rect(anchor.X - shadow.Left, anchor.Y - shadow.Top, - anchor.Width + shadow.Left + shadow.Right, anchor.Height + shadow.Top + shadow.Bottom); - placement.Offset = new Point(HorizontalOffset + marginToAnchor, VerticalOffset + marginToAnchor); + if ((adjustment & PopupPositionerConstraintAdjustment.SlideX) != 0) + { + rect = rect.WithX(Math.Max(rect.X, bounds.X)); + if (rect.Right > bounds.Right) + { + rect = rect.WithX(bounds.Right - rect.Width); + } + } + if ((adjustment & PopupPositionerConstraintAdjustment.FlipY) != 0 && !FitsY(rect, bounds)) + { + var flipped = Unconstrained(placement, FlipY(placement.Anchor), FlipY(placement.Gravity), size); + if (FitsY(flipped, bounds)) + { + rect = rect.WithY(flipped.Y); + flipY = true; + } + } + if ((adjustment & PopupPositionerConstraintAdjustment.SlideY) != 0) + { + rect = rect.WithY(Math.Max(rect.Y, bounds.Y)); + if (rect.Bottom > bounds.Bottom) + { + rect = rect.WithY(bounds.Bottom - rect.Height); + } + } + return (rect, flipX, flipY); + } + + private static bool FitsX(Rect rect, Rect bounds) => rect.X >= bounds.X && rect.Right <= bounds.Right; + + private static bool FitsY(Rect rect, Rect bounds) => rect.Y >= bounds.Y && rect.Bottom <= bounds.Bottom; + + private static Rect Unconstrained(CustomPopupPlacement placement, PopupAnchor anchor, PopupGravity gravity, Size size) + { + var rect = placement.AnchorRectangle; + var x = (anchor & PopupAnchor.Left) != 0 ? rect.X : (anchor & PopupAnchor.Right) != 0 ? rect.Right : rect.X + rect.Width / 2; + var y = (anchor & PopupAnchor.Top) != 0 ? rect.Y : (anchor & PopupAnchor.Bottom) != 0 ? rect.Bottom : rect.Y + rect.Height / 2; + x += (gravity & PopupGravity.Left) != 0 ? -size.Width : (gravity & PopupGravity.Right) != 0 ? 0 : -size.Width / 2; + y += (gravity & PopupGravity.Top) != 0 ? -size.Height : (gravity & PopupGravity.Bottom) != 0 ? 0 : -size.Height / 2; + return new Rect(new Point(x, y) + placement.Offset, size); } + private static PopupAnchor FlipX(PopupAnchor anchor) => (anchor & PopupAnchor.HorizontalMask) switch + { + PopupAnchor.Left => (anchor & ~PopupAnchor.HorizontalMask) | PopupAnchor.Right, + PopupAnchor.Right => (anchor & ~PopupAnchor.HorizontalMask) | PopupAnchor.Left, + _ => anchor + }; + + private static PopupAnchor FlipY(PopupAnchor anchor) => (anchor & PopupAnchor.VerticalMask) switch + { + PopupAnchor.Top => (anchor & ~PopupAnchor.VerticalMask) | PopupAnchor.Bottom, + PopupAnchor.Bottom => (anchor & ~PopupAnchor.VerticalMask) | PopupAnchor.Top, + _ => anchor + }; + + private static PopupGravity FlipX(PopupGravity gravity) => + (gravity & (PopupGravity.Left | PopupGravity.Right)) switch + { + PopupGravity.Left => (gravity & ~PopupGravity.Left) | PopupGravity.Right, + PopupGravity.Right => (gravity & ~PopupGravity.Right) | PopupGravity.Left, + _ => gravity + }; + + private static PopupGravity FlipY(PopupGravity gravity) => + (gravity & (PopupGravity.Top | PopupGravity.Bottom)) switch + { + PopupGravity.Top => (gravity & ~PopupGravity.Top) | PopupGravity.Bottom, + PopupGravity.Bottom => (gravity & ~PopupGravity.Bottom) | PopupGravity.Top, + _ => gravity + }; + + private sealed class PointerSlot + { + public Point Position; + } + + private static readonly ConditionalWeakTable s_pointers = new(); + + private static void TrackPointer(TopLevel topLevel, PointerEventArgs e) => + s_pointers.GetOrCreateValue(topLevel).Position = e.GetPosition(topLevel); + + /// The last pointer position over , or the anchor's centre when none was seen. + private static Point PointerPosition(TopLevel? topLevel, Rect anchor) => + topLevel is not null && s_pointers.TryGetValue(topLevel, out var slot) ? slot.Position : anchor.Center; + + #endregion + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { + if (change.Property == PlacementProperty && change.GetNewValue() is var placement && + placement != PlacementMode.Custom) + { + // Arc resolves every side itself (margin, flips): keep the requested one and let the positioner call back. + SetCurrentValue(RequestedPlacementProperty, placement); + SetCurrentValue(PlacementProperty, PlacementMode.Custom); + } if (change.Property == PlacementProperty || change.Property == RequestedPlacementProperty || change.Property == EnterOffsetProperty || change.Property == EnterScaleProperty || change.Property == IsInstantProperty || change.Property == IsExitInstantProperty || change.Property == ArcMotion.LevelProperty || change.Property == PlacementTargetProperty) @@ -229,19 +552,28 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang // the surface must have before the popup measures it. UpdateMotions(); SyncZoom(); + SyncShadow(ShouldUseOverlayLayer); } else if (change.Property == ArcZoom.ScaleProperty) { SyncZoom(); } + else if (change.Property == PopupRootShadowProperty || change.Property == OverlayHostShadowProperty || + change.Property == ShouldUseOverlayLayerProperty) + { + SyncShadow(IsOpen ? IsUsingOverlayLayer : ShouldUseOverlayLayer); + } + else if (change.Property == MarginToAnchorProperty || change.Property == RequestedPlacementProperty) + { + Reposition(); + } base.OnPropertyChanged(change); } private void UpdateMotions() { - var level = PlacementTarget is { } target ? ArcMotion.GetEffectiveLevel(target) : ArcMotion.GetEffectiveLevel(this); + var level = MotionLevel(); IsMotionEnabled = level != ArcMotionLevel.Off; - // AtomUI's popup rewrites Placement to Custom and keeps the requested side in RequestedPlacement. var (origin, direction) = OriginFor(RequestedPlacement ?? Placement); var fadeOnly = level == ArcMotionLevel.Reduced || IsInstant; var offset = direction * EnterOffset; @@ -252,6 +584,9 @@ private void UpdateMotions() : new ArcPopupExitMotion(origin, 0.98, level == ArcMotionLevel.Reduced); } + private ArcMotionLevel MotionLevel() => + PlacementTarget is { } target ? ArcMotion.GetEffectiveLevel(target) : ArcMotion.GetEffectiveLevel(this); + /// Transform origin on the anchor side and the direction the surface travels from. internal static (RelativePoint Origin, Vector Direction) OriginFor(PlacementMode placement) => placement switch { @@ -264,4 +599,317 @@ PlacementMode.Right or PlacementMode.RightEdgeAlignedTop or PlacementMode.RightE PlacementMode.Pointer => (new RelativePoint(0, 0, RelativeUnit.Relative), new Vector(0, -1)), _ => (new RelativePoint(0.5, 0, RelativeUnit.Relative), new Vector(0, -1)) }; + + #region Motion + + // Code-driven (MotionValue) because ControlTheme transitions cannot express it: the motion runs on the frame the + // popup creates around its surface (outside any template), the popup may only close after the exit played, and an + // open during the exit reverses from the values on screen. + + /// True when can play now (motion on, frame on screen). + private bool CanAnimate(ArcPopupMotion? motion) => + motion is not null && IsMotionEnabled && _frame is { } frame && TopLevel.GetTopLevel(frame) is not null && + MotionLevel() != ArcMotionLevel.Off; + + private bool CoerceIsOpen(bool open) + { + if (open) + { + if (_exiting) + { + // Opened again during the exit: the popup never closed, the surface comes back from where it is. + _exiting = false; + var generation = ++_motionGeneration; + Dispatcher.UIThread.Post(() => + { + if (generation == _motionGeneration && IsOpen) + { + PlayEnter(fromCurrent: true); + } + }); + } + return true; + } + // The host is already gone (light dismiss, Close(), a detached anchor) or the exit finished. + if (_finishingExit || _frame is not { } frame || !IsInsidePopup(frame)) + { + return false; + } + if (_exiting) + { + return true; + } + if (!CanAnimate(CloseMotion)) + { + return false; + } + _exiting = true; + var exit = ++_motionGeneration; + // Started outside the coercion: the motion values write the frame and may finish at once. + Dispatcher.UIThread.Post(() => PlayExit(exit)); + return true; + } + + private void HandleOpened(object? sender, EventArgs e) + { + SyncShadow(IsUsingOverlayLayer); + HookLightDismiss(); + HookAnchorTracking(); + if (!CanAnimate(OpenMotion)) + { + StopMotion(); + return; + } + PlayEnter(fromCurrent: false); + } + + private void HandleClosed(object? sender, EventArgs e) + { + _exiting = false; + _motionGeneration++; + UnhookLightDismiss(); + UnhookAnchorTracking(); + StopMotion(); + } + + private void PlayEnter(bool fromCurrent) + { + if (OpenMotion is not { } motion || !CanAnimate(motion) || _opacity is null || _translate is null || _scale is null) + { + StopMotion(); + return; + } + _motionGeneration++; + _playing = motion; + if (!fromCurrent) + { + _opacity.Jump(motion.Fades ? 0 : 1); + _translate.Jump(motion.FadeOnly ? default : motion.Offset); + _scale.Jump(motion.FadeOnly ? 1 : motion.Scale); + } + _opacity.AnimateTo(1, motion.OpacitySpec); + _translate.AnimateTo(default, motion.TransformSpec); + _scale.AnimateTo(1, motion.TransformSpec); + AwaitRest(); + } + + private void PlayExit(int generation) + { + if (generation != _motionGeneration || !_exiting) + { + return; + } + if (CloseMotion is not { } motion || !CanAnimate(motion) || _opacity is null || _translate is null || _scale is null) + { + FinishExit(); + return; + } + _playing = motion; + _opacity.AnimateTo(motion.Fades ? 0 : 1, motion.OpacitySpec); + _translate.AnimateTo(motion.FadeOnly ? default : motion.Offset, motion.TransformSpec); + _scale.AnimateTo(motion.FadeOnly ? 1 : motion.Scale, motion.TransformSpec); + AwaitRest(); + } + + // The end of a motion is seen on the frame that writes the final values (all three at their targets), not through + // the values' tasks: those complete on the thread pool, which may run late, while frames (and tests' manual + // frame clocks) keep going. What follows is posted, so the frame's last values are written first. + + private void AwaitRest() + { + _awaitingRest = true; + CheckRest(); + } + + private void CheckRest() + { + if (!_awaitingRest || _opacity is null || _translate is null || _scale is null || + _opacity.Value != _opacity.Target || _translate.Value != _translate.Target || _scale.Value != _scale.Target) + { + return; + } + _awaitingRest = false; + var generation = _motionGeneration; + Dispatcher.UIThread.Post(() => + { + if (generation != _motionGeneration) + { + return; + } + if (_exiting) + { + FinishExit(); + } + else + { + // At rest: hand the frame back without a transform (P10 leaves nothing behind). + ResetFrame(); + } + }); + } + + /// Lets the coerced IsOpen fall to its requested value (false), which closes the host. + private void FinishExit() + { + _exiting = false; + _finishingExit = true; + try + { + CoerceValue(IsOpenProperty); + } + finally + { + _finishingExit = false; + } + } + + private void StopMotion() + { + _awaitingRest = false; + if (_frameMoved) + { + _opacity?.Jump(1); + _translate?.Jump(default); + _scale?.Jump(1); + } + ResetFrame(); + } + + private void ApplyMotion() + { + if (_frame is not { } frame || _opacity is null || _translate is null || _scale is null) + { + return; + } + var scale = _scale.Value; + var translate = _translate.Value; + frame.Opacity = Math.Clamp(_opacity.Value, 0, 1); + frame.RenderTransformOrigin = _playing?.Origin ?? RelativePoint.TopLeft; + _motionTransform.Matrix = Matrix.CreateScale(scale, scale) * Matrix.CreateTranslation(translate.X, translate.Y); + if (!ReferenceEquals(frame.RenderTransform, _motionTransform)) + { + frame.RenderTransform = _motionTransform; + } + _frameMoved = true; + CheckRest(); + } + + /// Clears what the popup's own motion wrote on the frame (frames animated by other code are left alone). + private void ResetFrame() + { + if (!_frameMoved || _frame is not { } frame) + { + return; + } + _frameMoved = false; + frame.Opacity = 1; + if (ReferenceEquals(frame.RenderTransform, _motionTransform)) + { + frame.RenderTransform = null; + } + } + + #endregion + + #region Anchor tracking + + // Avalonia places the popup again when its anchor changes size, not when the anchor moves in the window (a page + // scrolling under an open menu). The surface stays on its anchor: after every layout pass the anchor's place in + // the window is compared with the last one. + + private Control? _trackedAnchor; + private Rect _trackedRect; + + private void HookAnchorTracking() + { + UnhookAnchorTracking(); + if (PlacementTarget is not { } anchor) + { + return; + } + _trackedAnchor = anchor; + _trackedRect = AnchorRect(anchor); + anchor.LayoutUpdated += HandleAnchorLayoutUpdated; + } + + private void UnhookAnchorTracking() + { + if (_trackedAnchor is { } anchor) + { + anchor.LayoutUpdated -= HandleAnchorLayoutUpdated; + _trackedAnchor = null; + } + } + + private void HandleAnchorLayoutUpdated(object? sender, EventArgs e) + { + if (_trackedAnchor is not { } anchor || !IsOpen) + { + return; + } + var rect = AnchorRect(anchor); + if (rect == _trackedRect) + { + return; + } + _trackedRect = rect; + Reposition(); + } + + private static Rect AnchorRect(Control anchor) => + TopLevel.GetTopLevel(anchor) is { } topLevel && anchor.TransformToVisual(topLevel) is { } transform + ? new Rect(anchor.Bounds.Size).TransformToAABB(transform) + : default; + + #endregion + + #region Light dismiss + + // Avalonia dismisses a light-dismiss popup from a transparent layer under the popups, closing the host at once. Arc + // handles presses on that layer first, so the popup leaves with its exit motion (it closes through IsOpen). + // Popups that pass the dismissing press through to the content below keep Avalonia's handling. + + private void HookLightDismiss() + { + UnhookLightDismiss(); + if (!IsLightDismissEnabled || OverlayDismissEventPassThrough) + { + return; + } + var anchor = PlacementTarget ?? Parent as Visual; + if (anchor is null || TopLevel.GetTopLevel(anchor) is not { } topLevel) + { + return; + } + _dismissTopLevel = topLevel; + topLevel.AddHandler(InputElement.PointerPressedEvent, HandleTopLevelPressed, RoutingStrategies.Tunnel, true); + } + + private void UnhookLightDismiss() + { + if (_dismissTopLevel is { } topLevel) + { + topLevel.RemoveHandler(InputElement.PointerPressedEvent, HandleTopLevelPressed); + _dismissTopLevel = null; + } + } + + private void HandleTopLevelPressed(object? sender, PointerPressedEventArgs e) + { + if (!IsOpen || !IsLightDismissEnabled || OverlayDismissEventPassThrough || !IsLightDismissLayer(e.Source)) + { + return; + } + e.Handled = true; + SetCurrentValue(IsOpenProperty, false); + } + + /// + /// The light-dismiss layer is the transparent border a shows over its content + /// while a light-dismiss popup is open (the content child is the only other border it hosts directly). + /// + private static bool IsLightDismissLayer(object? source) => + source is Border border && border.GetVisualParent() is VisualLayerManager manager && !ReferenceEquals(manager.Child, border); + + #endregion } diff --git a/src/TKCode.Arc.Core/Primitives/Popups/ArcPopupFrame.cs b/src/TKCode.Arc.Core/Primitives/Popups/ArcPopupFrame.cs new file mode 100644 index 00000000..c6e8e48a --- /dev/null +++ b/src/TKCode.Arc.Core/Primitives/Popups/ArcPopupFrame.cs @@ -0,0 +1,111 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Media; + +namespace TKCode.Arc.Primitives; + +/// +/// The element an puts between its popup host and its surface. It draws the floating shadow +/// around the surface with the surface's corner radius, carries the popup's motion (its Opacity and +/// RenderTransform, so the shadow fades, travels and scales with the surface) and lays the surface out at the +/// scale. In the window's overlay layer the shadow is painted past the frame's bounds; in a native +/// popup window, which clips to its size, the frame's Margin is the room the shadow needs and the popup places +/// the surface (not the window) where the placement puts it, so the shadow spreads into transparent room around it. +/// +/// +/// Code that animates a popup itself (presences that turn off) finds this frame +/// with FindAncestorOfType<ArcPopupFrame>() from the surface and writes its Opacity and +/// RenderTransform; frame coordinates are the surface's (scaled) coordinates. +/// The zoom is a , which draws its transform through its child's +/// RenderTransform; Arc surfaces animate their own RenderTransform (enter, press), so the surface sits +/// in a plain slot panel that nothing else touches. At scale 1 there is no transform. +/// +public sealed class ArcPopupFrame : Border +{ + private readonly LayoutTransformControl _transform; + private readonly Panel _slot = new(); + private double _scale = 1.0; + + public ArcPopupFrame() + { + _transform = new LayoutTransformControl { Child = _slot }; + Child = _transform; + } + + /// The popup surface this frame hosts. + public Control? Surface + { + get => _slot.Children.Count > 0 ? _slot.Children[0] : null; + internal set + { + if (Surface is { } previous) + { + previous.PropertyChanged -= HandleSurfacePropertyChanged; + } + _slot.Children.Clear(); + if (value is not null) + { + _slot.Children.Add(value); + value.PropertyChanged += HandleSurfacePropertyChanged; + } + SyncCornerRadius(); + } + } + + /// Layout scale of the surface (). + internal double Scale + { + get => _scale; + set + { + if (_scale == value) + { + return; + } + _scale = value; + _transform.LayoutTransform = value == 1.0 ? null : new ScaleTransform(value, value); + SyncCornerRadius(); + } + } + + /// Room the outer (non-inset) shadows in spread to on each side, in whole pixels. + internal static Thickness RoomFor(BoxShadows shadows) + { + double left = 0, top = 0, right = 0, bottom = 0; + foreach (var shadow in shadows) + { + if (shadow.IsInset) + { + continue; + } + // Skia's blur fades out within the blur radius; spread grows the shadow shape itself. + var reach = Math.Max(0, shadow.Blur) + shadow.Spread; + left = Math.Max(left, reach - shadow.OffsetX); + right = Math.Max(right, reach + shadow.OffsetX); + top = Math.Max(top, reach - shadow.OffsetY); + bottom = Math.Max(bottom, reach + shadow.OffsetY); + } + return new Thickness(Math.Ceiling(left), Math.Ceiling(top), Math.Ceiling(right), Math.Ceiling(bottom)); + } + + private void HandleSurfacePropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) + { + if (e.Property == Border.CornerRadiusProperty || e.Property == TemplatedControl.CornerRadiusProperty) + { + SyncCornerRadius(); + } + } + + private void SyncCornerRadius() + { + var radius = Surface switch + { + Border border => border.CornerRadius, + TemplatedControl templated => templated.CornerRadius, + _ => default + }; + CornerRadius = new CornerRadius(radius.TopLeft * _scale, radius.TopRight * _scale, + radius.BottomRight * _scale, radius.BottomLeft * _scale); + } +} diff --git a/src/TKCode.Arc.Core/Primitives/Popups/ArcPopupMotions.cs b/src/TKCode.Arc.Core/Primitives/Popups/ArcPopupMotions.cs index 799f7492..4962da48 100644 --- a/src/TKCode.Arc.Core/Primitives/Popups/ArcPopupMotions.cs +++ b/src/TKCode.Arc.Core/Primitives/Popups/ArcPopupMotions.cs @@ -1,93 +1,80 @@ using TKCode.Arc.Motion; -using AtomUI.MotionScene; using Avalonia; -using Avalonia.Animation; using Avalonia.Animation.Easings; namespace TKCode.Arc.Primitives; /// -/// P10 enter: origin-aware scale + offset + fade, played on AtomUI's popup motion actor so the frame shadow -/// moves with the surface. Default timing is Arc's popover / tooltip enter (fast, ease enter). +/// One half (enter or exit) of an 's motion, played on the popup's so +/// the floating shadow moves with the surface. The motion runs between the resting pose (opacity 1, no transform) and +/// a hidden pose: of travel and around , faded out when +/// . An enter starts at the hidden pose and settles at rest; an exit leaves rest for the hidden pose. +/// Opacity and transform may run on different curves (, ). /// -public sealed class ArcPopupEnterMotion : AbstractMotion +public abstract class ArcPopupMotion { - private readonly RelativePoint _origin; - private readonly Vector _offset; - private readonly double _scale; - private readonly bool _fadeOnly; + /// Transform origin, relative to the surface. + public RelativePoint Origin { get; set; } = new(0.5, 0, RelativeUnit.Relative); - public ArcPopupEnterMotion(RelativePoint origin, Vector offset, double scale = 0.97, bool fadeOnly = false, - TimeSpan? duration = null, Easing? easing = null) - : base(duration ?? ArcMotionPresets.Fast, easing ?? ArcEasings.Enter, FillMode.Forward) - { - _origin = origin; - _offset = offset; - _scale = scale; - _fadeOnly = fadeOnly; - } + /// Translation of the hidden pose (toward the anchor). + public Vector Offset { get; set; } - protected override void ConfigureTransitions() - { - base.ConfigureTransitions(); - RenderTransformOrigin = _origin; - } + /// Uniform scale of the hidden pose. + public double Scale { get; protected set; } = 1; - protected override void ConfigureMotionStartValue(BaseMotionActor actor) - { - actor.Opacity = 0.0; - actor.MotionTransform = _fadeOnly ? null : BuildTranslateScaleAndTransform(_scale, _scale, _offset.X, _offset.Y); - } + /// When true only the opacity animates (reduced motion, warm-up opens). + public bool FadeOnly { get; set; } - protected override void ConfigureMotionEndValue(BaseMotionActor actor) - { - actor.Opacity = 1.0; - actor.MotionTransform = _fadeOnly ? null : BuildTranslateScaleAndTransform(1, 1, 0, 0); - } + /// When true the hidden pose is transparent; when false the surface keeps its opacity (a sheet slides). + public bool Fades { get; set; } = true; - protected override void NotifyCompleted(BaseMotionActor actor) - { - actor.Opacity = 1.0; - actor.MotionTransform = null; - } + /// Timing of the opacity. + public abstract MotionSpec OpacitySpec { get; } + + /// Timing of the travel and scale. + public abstract MotionSpec TransformSpec { get; } } -/// P10 exit: shorter than the enter, fades and shrinks slightly toward the origin. -public sealed class ArcPopupExitMotion : AbstractMotion +/// +/// P10 enter: origin-aware scale + offset + fade. Default timing is Arc's popover / tooltip enter (fast, ease enter). +/// +public sealed class ArcPopupEnterMotion : ArcPopupMotion { - private readonly RelativePoint _origin; - private readonly double _scale; - private readonly bool _fadeOnly; + private readonly MotionSpec _spec; - public ArcPopupExitMotion(RelativePoint origin, double scale = 0.98, bool fadeOnly = false, TimeSpan? duration = null) - : base(duration ?? ArcMotionPresets.Instant, ArcEasings.Standard, FillMode.Forward) + public ArcPopupEnterMotion(RelativePoint origin, Vector offset, double scale = 0.97, bool fadeOnly = false, + TimeSpan? duration = null, Easing? easing = null) { - _origin = origin; - _scale = scale; - _fadeOnly = fadeOnly; + Origin = origin; + Offset = offset; + Scale = scale; + FadeOnly = fadeOnly; + _spec = MotionSpec.Tween(duration ?? ArcMotionPresets.Fast, easing ?? ArcEasings.Enter); } - protected override void ConfigureTransitions() - { - base.ConfigureTransitions(); - RenderTransformOrigin = _origin; - } + /// + public override MotionSpec OpacitySpec => _spec; - protected override void ConfigureMotionStartValue(BaseMotionActor actor) - { - actor.Opacity = 1.0; - actor.MotionTransform = _fadeOnly ? null : BuildScaleTransform(1.0); - } + /// + public override MotionSpec TransformSpec => _spec; +} - protected override void ConfigureMotionEndValue(BaseMotionActor actor) - { - actor.Opacity = 0.0; - actor.MotionTransform = _fadeOnly ? null : BuildScaleTransform(_scale); - } +/// P10 exit: shorter than the enter, fades and shrinks slightly toward the origin. +public sealed class ArcPopupExitMotion : ArcPopupMotion +{ + private readonly MotionSpec _spec; - protected override void NotifyCompleted(BaseMotionActor actor) + public ArcPopupExitMotion(RelativePoint origin, double scale = 0.98, bool fadeOnly = false, TimeSpan? duration = null) { - actor.Opacity = 1.0; - actor.MotionTransform = null; + Origin = origin; + Scale = scale; + FadeOnly = fadeOnly; + _spec = MotionSpec.Tween(duration ?? ArcMotionPresets.Instant, ArcEasings.Standard); } + + /// + public override MotionSpec OpacitySpec => _spec; + + /// + public override MotionSpec TransformSpec => _spec; } diff --git a/src/TKCode.Arc.Core/Primitives/Popups/ArcZoom.cs b/src/TKCode.Arc.Core/Primitives/Popups/ArcZoom.cs index 4444f164..39bc311e 100644 --- a/src/TKCode.Arc.Core/Primitives/Popups/ArcZoom.cs +++ b/src/TKCode.Arc.Core/Primitives/Popups/ArcZoom.cs @@ -1,7 +1,4 @@ using Avalonia; -using Avalonia.Controls; -using Avalonia.Controls.Primitives; -using Avalonia.Media; namespace TKCode.Arc.Primitives; @@ -26,86 +23,3 @@ private ArcZoom() public static void SetScale(AvaloniaObject element, double value) => element.SetValue(ScaleProperty, value); } - -/// -/// The element an puts between itself and its surface: it lays the surface out at -/// and enlarges the result, so text stays vector sharp and the popup is placed by the scaled size. -/// The popup pushes the scale in (it is in the tree and inherits ; a surface that is -/// not attached yet cannot) before the popup measures the surface. At scale 1 there is no transform. -/// -/// -/// AtomUI's popup draws the floating shadow with the corner radius of the popup's direct child when that child is a -/// or templated control, and square otherwise (a square shadow shows past the surface's rounded -/// corners). The host is that child, so it is a Border that draws nothing and carries the surface's corner radius, -/// scaled with the surface. -/// draws its transform through its child's RenderTransform, and Arc surfaces -/// animate their own RenderTransform (enter, press), so the surface sits in a plain slot panel that nothing else -/// touches. -/// -internal sealed class ArcZoomHost : Border -{ - private readonly LayoutTransformControl _transform; - private readonly Panel _slot = new(); - private double _scale = 1.0; - - public ArcZoomHost() - { - _transform = new LayoutTransformControl { Child = _slot }; - Child = _transform; - } - - /// The popup surface this host scales. - public Control? Surface - { - get => _slot.Children.Count > 0 ? _slot.Children[0] : null; - set - { - if (Surface is { } previous) - { - previous.PropertyChanged -= HandleSurfacePropertyChanged; - } - _slot.Children.Clear(); - if (value is not null) - { - _slot.Children.Add(value); - value.PropertyChanged += HandleSurfacePropertyChanged; - } - SyncCornerRadius(); - } - } - - public double Scale - { - get => _scale; - set - { - if (_scale == value) - { - return; - } - _scale = value; - _transform.LayoutTransform = value == 1.0 ? null : new ScaleTransform(value, value); - SyncCornerRadius(); - } - } - - private void HandleSurfacePropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) - { - if (e.Property == Border.CornerRadiusProperty || e.Property == TemplatedControl.CornerRadiusProperty) - { - SyncCornerRadius(); - } - } - - private void SyncCornerRadius() - { - var radius = Surface switch - { - Border border => border.CornerRadius, - TemplatedControl templated => templated.CornerRadius, - _ => default - }; - CornerRadius = new CornerRadius(radius.TopLeft * _scale, radius.TopRight * _scale, - radius.BottomRight * _scale, radius.BottomLeft * _scale); - } -} diff --git a/src/TKCode.Arc.Core/Primitives/RollingDigits.cs b/src/TKCode.Arc.Core/Primitives/RollingDigits.cs index f60d1dbe..f9c32c66 100644 --- a/src/TKCode.Arc.Core/Primitives/RollingDigits.cs +++ b/src/TKCode.Arc.Core/Primitives/RollingDigits.cs @@ -272,7 +272,7 @@ private void Rebuild() { var leaving = gone; gone.Presence.AnimateTo(0, MotionSpec.Spring(ArcSpring.Morph)).ContinueWith(_ => - Avalonia.Threading.Dispatcher.UIThread.Post(() => + Dispatcher.Post(() => { _columns.Remove(leaving); InvalidateMeasure(); diff --git a/src/TKCode.Arc.Core/TKCode.Arc.Core.csproj b/src/TKCode.Arc.Core/TKCode.Arc.Core.csproj index 2a6e07d8..374d544a 100644 --- a/src/TKCode.Arc.Core/TKCode.Arc.Core.csproj +++ b/src/TKCode.Arc.Core/TKCode.Arc.Core.csproj @@ -4,15 +4,15 @@ TKCode.Arc.Core TKCode.Arc TKCode.Arc.Core - $(AssemblyName) - TKCode.Arc + true - + + diff --git a/src/TKCode.Arc.Core/Theme/ArcDesign.cs b/src/TKCode.Arc.Core/Theme/ArcDesign.cs index 27ea1224..af337fc6 100644 --- a/src/TKCode.Arc.Core/Theme/ArcDesign.cs +++ b/src/TKCode.Arc.Core/Theme/ArcDesign.cs @@ -4,8 +4,8 @@ namespace TKCode.Arc; /// -/// Identity carrier for the Arc global design tokens. It is never placed in a visual tree. -/// It must stay public: the AtomUI registration pipeline only registers public control types. +/// Former identity carrier of the Arc global design tokens (they are now the global identity of +/// ArcDesignToken). Kept for source compatibility; it is never placed in a visual tree. /// [EditorBrowsable(EditorBrowsableState.Never)] public sealed class ArcDesign : Control diff --git a/src/TKCode.Arc.Core/Theme/ArcDesignToken.cs b/src/TKCode.Arc.Core/Theme/ArcDesignToken.cs index 41558aae..a557cf71 100644 --- a/src/TKCode.Arc.Core/Theme/ArcDesignToken.cs +++ b/src/TKCode.Arc.Core/Theme/ArcDesignToken.cs @@ -1,5 +1,4 @@ using TKCode.Arc.Motion; -using AtomUI.Theme.DesignTokens; using Avalonia; using Avalonia.Animation.Easings; using Avalonia.Media; @@ -9,9 +8,10 @@ namespace TKCode.Arc; /// /// Arc global semantic tokens, carried by . The single source of every value is /// design/architecture/theming/token-catalog.md. Values are computed from the accent seed -/// (AtomUI ColorPrimary on the ArcDesign identity) and the appearance. +/// (, the theme state's accent) and the appearance. It is the global identity: theme +/// overrides that name no identity (Token("RadiusControl", "12")) land here. /// -[ControlDesignToken] +[ControlDesignToken(IsGlobal = true)] internal sealed class ArcDesignToken : AbstractControlDesignToken { #region Neutral ramp @@ -254,7 +254,12 @@ public override void CalculateTokenValues(bool isDarkMode) BlurText = 8; BackdropBlurDialog = 7; BackdropBlurDrawer = 4; - MotionMode = EffectiveGlobalToken.EnableMotion ? "full" : "off"; + MotionMode = EffectiveGlobalToken.MotionLevel switch + { + ArcMotionLevel.Off => "off", + ArcMotionLevel.Reduced => "reduced", + _ => "full" + }; ReduceTransparency = false; } diff --git a/src/TKCode.Arc.Core/Theme/ArcPalette.cs b/src/TKCode.Arc.Core/Theme/ArcPalette.cs index 9db1c829..9bb972d6 100644 --- a/src/TKCode.Arc.Core/Theme/ArcPalette.cs +++ b/src/TKCode.Arc.Core/Theme/ArcPalette.cs @@ -1,6 +1,4 @@ -using AtomUI.Theme.Algorithms; using Avalonia.Media; -using Avalonia.Styling; namespace TKCode.Arc; @@ -34,9 +32,9 @@ public readonly record struct ArcAccentColors( string Preset); /// -/// Resolves Arc accent colors from an accent seed (AtomUI ColorPrimary). Exact preset seeds use Arc's -/// hand-tuned tables; seeds produced by AtomUI's dark algorithm map back to their preset; AtomUI's default -/// primary maps to Arc's default (neutral); anything else is derived in OKLCH. +/// Resolves Arc accent colors from an accent seed (ArcSeedToken.ColorPrimary). Exact preset seeds use Arc's +/// hand-tuned tables; seeds of the Ant-style dark palette (as AtomUI's dark algorithm produced them in 0.3) map back to +/// their preset; the default seed (#1677FF) maps to Arc's default (neutral); anything else is derived in OKLCH. /// public static class ArcPalette { @@ -116,13 +114,12 @@ public static ArcAccentColors Resolve(Color seed, bool isDark) private static Dictionary BuildDarkDerivedLookup() { var map = new Dictionary(); - var option = new PaletteGenerateOption { ThemeVariant = ThemeVariant.Dark }; foreach (var preset in s_presets) { - var derived = PaletteGenerator.GeneratePalette(Color.Parse(preset.Seed), option)[5]; + var derived = ArcPaletteGenerator.Generate(Color.Parse(preset.Seed), isDark: true)[5]; map.TryAdd(Key(derived), preset); } - var defaultDerived = PaletteGenerator.GeneratePalette(AtomUIDefaultPrimary, option)[5]; + var defaultDerived = ArcPaletteGenerator.Generate(AtomUIDefaultPrimary, isDark: true)[5]; map.TryAdd(Key(defaultDerived), s_presets[0]); return map; } diff --git a/src/TKCode.Arc.Core/Theme/ArcPaletteGenerator.cs b/src/TKCode.Arc.Core/Theme/ArcPaletteGenerator.cs new file mode 100644 index 00000000..be23e8a2 --- /dev/null +++ b/src/TKCode.Arc.Core/Theme/ArcPaletteGenerator.cs @@ -0,0 +1,205 @@ +using Avalonia.Media; + +namespace TKCode.Arc; + +/// +/// The ten-step color palette of a seed color, following the published algorithm of the MIT-licensed +/// @ant-design/colors package (generate, v7 with @ant-design/fast-color): five lighter steps and four +/// darker ones around the seed in HSV (hue turns 2° per step, saturation and value move in fixed steps, rounded to two +/// decimals), and for the dark theme each step is the background mixed toward a light-theme step by a fixed amount. +/// Index 5 is the seed in the light palette and the dark theme's primary in the dark one. +/// +internal static class ArcPaletteGenerator +{ + private const int HueStep = 2; + private const double SaturationStep = 0.16; + private const double SaturationStep2 = 0.05; + private const double BrightnessStep1 = 0.05; + private const double BrightnessStep2 = 0.15; + private const int LightColorCount = 5; + private const int DarkColorCount = 4; + + /// Dark theme: (light palette index, percent mixed from the background toward it) per output index. + private static readonly (int Index, int Amount)[] s_darkColorMap = + [ + (7, 15), (6, 25), (5, 30), (5, 45), (5, 65), (5, 85), (4, 90), (3, 95), (2, 97), (1, 98) + ]; + + /// The default dark background the dark palette is mixed against. + public static readonly Color DefaultDarkBackground = Color.FromRgb(0x14, 0x14, 0x14); + + /// + /// Ten opaque colors for (its alpha is ignored), light to dark for the light theme, or mixed + /// against (default #141414) when . + /// + public static Color[] Generate(Color seed, bool isDark, Color? darkBackground = null) + { + var (h, s, v) = ToHsv(seed.R, seed.G, seed.B); + var patterns = new Color[LightColorCount + 1 + DarkColorCount]; + var n = 0; + for (var i = LightColorCount; i > 0; i--) + { + patterns[n++] = FromHsv(GetHue(h, i, true), GetSaturation(h, s, i, true), GetValue(v, i, true)); + } + patterns[n++] = Color.FromRgb(seed.R, seed.G, seed.B); + for (var i = 1; i <= DarkColorCount; i++) + { + patterns[n++] = FromHsv(GetHue(h, i, false), GetSaturation(h, s, i, false), GetValue(v, i, false)); + } + if (!isDark) + { + return patterns; + } + var bg = darkBackground ?? DefaultDarkBackground; + var dark = new Color[s_darkColorMap.Length]; + for (var i = 0; i < s_darkColorMap.Length; i++) + { + var (index, amount) = s_darkColorMap[i]; + dark[i] = Mix(bg, patterns[index], amount); + } + return dark; + } + + /// fast-color getHue / getSaturation / getValue from 0–255 channels; hue is rounded to whole degrees. + private static (int H, double S, double V) ToHsv(byte r, byte g, byte b) + { + int max = Math.Max(r, Math.Max(g, b)); + int min = Math.Min(r, Math.Min(g, b)); + double delta = max - min; + int h; + double s; + if (delta == 0) + { + h = 0; + s = 0; + } + else + { + double sector; + if (r == max) + { + sector = (g - b) / delta + (g < b ? 6 : 0); + } + else if (g == max) + { + sector = (b - r) / delta + 2; + } + else + { + sector = (r - g) / delta + 4; + } + h = (int)JsRound(60 * sector); + s = delta / max; + } + return (h, s, max / 255.0); + } + + private static int GetHue(int h, int i, bool light) + { + int hue; + if (h >= 60 && h <= 240) + { + hue = light ? h - HueStep * i : h + HueStep * i; + } + else + { + hue = light ? h + HueStep * i : h - HueStep * i; + } + if (hue < 0) + { + hue += 360; + } + else if (hue >= 360) + { + hue -= 360; + } + return hue; + } + + private static double GetSaturation(int h, double s, int i, bool light) + { + // Grey keeps its saturation. + if (h == 0 && s == 0) + { + return s; + } + double saturation; + if (light) + { + saturation = s - SaturationStep * i; + } + else if (i == DarkColorCount) + { + saturation = s + SaturationStep; + } + else + { + saturation = s + SaturationStep2 * i; + } + if (saturation > 1) + { + saturation = 1; + } + // The lightest step keeps its saturation within 0.06–0.1. + if (light && i == LightColorCount && saturation > 0.1) + { + saturation = 0.1; + } + if (saturation < 0.06) + { + saturation = 0.06; + } + return JsRound(saturation * 100) / 100; + } + + private static double GetValue(double v, int i, bool light) + { + var value = light ? v + BrightnessStep1 * i : v - BrightnessStep2 * i; + value = Math.Max(0, Math.Min(1, value)); + return JsRound(value * 100) / 100; + } + + /// fast-color fromHsv: each channel rounded on its own. + private static Color FromHsv(int h, double s, double v) + { + var vv = Channel(v * 255); + if (s <= 0) + { + return Color.FromRgb(vv, vv, vv); + } + var hh = h / 60.0; + var i = (int)Math.Floor(hh); + var ff = hh - i; + var p = Channel(v * (1.0 - s) * 255); + var q = Channel(v * (1.0 - s * ff) * 255); + var t = Channel(v * (1.0 - s * (1.0 - ff)) * 255); + return i switch + { + 0 => Color.FromRgb(vv, t, p), + 1 => Color.FromRgb(q, vv, p), + 2 => Color.FromRgb(p, vv, t), + 3 => Color.FromRgb(p, q, vv), + 4 => Color.FromRgb(t, p, vv), + _ => Color.FromRgb(vv, p, q) + }; + } + + /// fast-color mix: moved percent toward . + private static Color Mix(Color from, Color to, int amount) + { + var p = amount / 100.0; + return Color.FromRgb( + Channel((to.R - from.R) * p + from.R), + Channel((to.G - from.G) * p + from.G), + Channel((to.B - from.B) * p + from.B)); + } + + private static byte Channel(double value) => (byte)Math.Clamp(JsRound(value), 0, 255); + + /// JavaScript Math.round: nearest integer, halves toward positive infinity. + private static double JsRound(double value) + { + var floor = Math.Floor(value); + return value - floor >= 0.5 ? floor + 1 : floor; + } +} diff --git a/src/TKCode.Arc.Core/Theme/ArcThemeConfig.cs b/src/TKCode.Arc.Core/Theme/ArcThemeConfig.cs index 9e1ca76b..e4ef0cef 100644 --- a/src/TKCode.Arc.Core/Theme/ArcThemeConfig.cs +++ b/src/TKCode.Arc.Core/Theme/ArcThemeConfig.cs @@ -1,95 +1,14 @@ -using TKCode.Arc.DesignTokens; -using TKCode.Arc.Motion; -using AtomUI.Theme.Schema; -using AtomUI.Theme.Configuration; -using Avalonia.Media; - namespace TKCode.Arc; -/// Accumulates ArcDesign overrides into a single control config (WithControl replaces per identity). -public sealed class ArcThemeConfigBuilder -{ - private readonly Dictionary _tokens = new(StringComparer.Ordinal); - private readonly Dictionary<(string Catalog, string Control), Dictionary> _controls = new(); - - /// Uses one of Arc's accent presets. - public ArcThemeConfigBuilder Accent(ArcAccentPreset preset) => Accent(ArcPalette.GetSeed(preset)); - - /// Uses any brand color as accent seed (derived in OKLCH when it is not a preset). - public ArcThemeConfigBuilder Accent(Color seed) - { - _tokens["ColorPrimary"] = $"#{seed.R:X2}{seed.G:X2}{seed.B:X2}"; - return this; - } - - /// Overrides the final value of an ArcDesign token. - public ArcThemeConfigBuilder Token(string name, string value) - { - _tokens[name] = value; - return this; - } - - /// - /// Overrides the final value of a control token (for example Checkbox.BoxRadius in catalog TKCode.Arc). - /// Overrides of the same control are merged, so helpers that each write some tokens of a control do not replace one - /// another. Naming a control of a package the app has not registered fails the whole theme change. - /// - public ArcThemeConfigBuilder ControlToken(string catalog, string control, string name, string value) - { - if (!_controls.TryGetValue((catalog, control), out var tokens)) - { - tokens = new Dictionary(StringComparer.Ordinal); - _controls[(catalog, control)] = tokens; - } - tokens[name] = value; - return this; - } - - public ArcThemeConfigBuilder MotionMode(ArcMotionLevel level) - { - _tokens[nameof(ArcDesignTokenKind.MotionMode)] = level switch - { - ArcMotionLevel.Off => "off", - ArcMotionLevel.Reduced => "reduced", - _ => "full" - }; - return this; - } - - internal ControlThemeConfig Build() => Build(_tokens); - - internal ThemeConfigBuilder ApplyTo(ThemeConfigBuilder target) - { - target = target.WithControl(ArcDesignTokens.Identity, Build(_tokens)); - foreach (var ((catalog, control), tokens) in _controls) - { - target = target.WithControl(new ControlTokenIdentity(catalog, control), Build(tokens)); - } - return target; - } - - private static ControlThemeConfig Build(Dictionary tokens) - { - // No WithAlgorithm: the mode stays Disabled so ColorPrimary passes through unchanged in dark mode. - var builder = new ControlThemeConfigBuilder(); - foreach (var (name, value) in tokens) - { - builder.WithToken(name, value); - } - return builder.Build(); - } -} - -public static class ArcThemeConfigExtensions +/// +/// Arc's additions to . Accent seeds, token overrides (Token(name, value) for ArcDesign, +/// Token(identity, name, value) for a control) and the motion level are on the builder itself. +/// +public static class ArcThemeBuilderExtensions { - /// - /// Applies Arc overrides (accent, token values, motion mode) to the ArcDesign identity, and control token overrides to - /// their controls. Put every override in one call: a second call replaces the configs the first one wrote. - /// - public static ThemeConfigBuilder WithArc(this ThemeConfigBuilder builder, Action configure) - { - var arc = new ArcThemeConfigBuilder(); - configure(arc); - return arc.ApplyTo(builder); - } + /// Uses one of Arc's accent presets as accent seed. + /// The theme builder. + /// The preset. + public static ArcThemeBuilder Accent(this ArcThemeBuilder builder, ArcAccentPreset preset) + => builder.Accent(ArcPalette.GetSeed(preset)); } diff --git a/src/TKCode.Arc.Core/Theme/ArcTokens.cs b/src/TKCode.Arc.Core/Theme/ArcTokens.cs index 068f6388..38ee42d9 100644 --- a/src/TKCode.Arc.Core/Theme/ArcTokens.cs +++ b/src/TKCode.Arc.Core/Theme/ArcTokens.cs @@ -1,5 +1,4 @@ using TKCode.Arc.DesignTokens; -using AtomUI.Theme.Resources; using Avalonia; using Avalonia.Controls; using Avalonia.Data; @@ -12,8 +11,8 @@ namespace TKCode.Arc; public static class ArcTokens { /// - /// Binds to an ArcDesign token so it follows theme changes. Uses the raw - /// own-token key: AtomUI's CreateControlTokenBinding would wrap the key with the owner type. + /// Binds to an ArcDesign token so it follows theme changes. The key is the + /// boxed token kind, the key the theme manager publishes the token under. /// public static IDisposable Bind(StyledElement target, AvaloniaProperty property, ArcDesignTokenKind kind, BindingPriority priority = BindingPriority.Template) @@ -35,9 +34,29 @@ public static TTarget BindCornerRadius(this TTarget target, ArcDesignTo return target; } - /// Reads the current value of an ArcDesign token for 's theme scope. + /// + /// Reads the current value of an ArcDesign token for 's theme scope. Color tokens are published + /// as brushes; asking for a unwraps them. Throws when the token is not found (Arc is not set up, or + /// the owner is not in a tree that reaches the application's resources) or has another type. + /// public static T Get(Control owner, ArcDesignTokenKind kind) - => ControlTokenAccessor.Capture(owner).GetOwn(kind); + { + if (!owner.TryFindResource(kind, owner.ActualThemeVariant, out var value)) + { + throw new InvalidOperationException( + $"ArcDesign token '{kind}' was not found from {owner.GetType().Name}: is Arc set up (UseArc) and the control in a tree?"); + } + if (value is T typed) + { + return typed; + } + if (value is ISolidColorBrush brush && brush.Color is T color) + { + return color; + } + throw new InvalidOperationException( + $"ArcDesign token '{kind}' is a {value?.GetType().Name ?? "null"}, not a {typeof(T).Name}."); + } /// /// Reads a corner radius token like , but returns instead of throwing when @@ -64,7 +83,7 @@ public static Color GetColor(Control owner, ArcDesignTokenKind kind) /// /// One-shot (non-dynamic) ArcDesign token value for properties that cannot take a dynamic resource, -/// such as Transition.Duration. Mirrors AtomUI's SharedTokenResourceValue. +/// such as Transition.Duration. Reads the application's current value once. /// public sealed class ArcTokenValueExtension : MarkupExtension { diff --git a/src/TKCode.Arc.Core/Theme/Base/ArcBaseTheme.cs b/src/TKCode.Arc.Core/Theme/Base/ArcBaseTheme.cs new file mode 100644 index 00000000..6d031d9c --- /dev/null +++ b/src/TKCode.Arc.Core/Theme/Base/ArcBaseTheme.cs @@ -0,0 +1,154 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Embedding; +using Avalonia.Controls.Primitives; +using Avalonia.Markup.Xaml; +using Avalonia.Styling; + +namespace TKCode.Arc; + +/// +/// Arc's base theme: default ControlThemes, in Arc's look, for the Avalonia built-in controls that Arc templates use +/// without naming a theme, and for the hosts every app meets (; +/// design/architecture/base-theme-inventory.md). An app built on Arc alone has no other source for them. +/// +/// +/// +/// The base theme yields to any theme that already styles these controls: when the application resolves a default +/// ControlTheme for one of other than (FluentTheme and SimpleTheme in +/// App.axaml do), nothing is registered, so their keys never clash and their look stays whole. The check runs when +/// Arc is set up, after the application loaded its own styles. +/// +/// +/// yields on its own: an application that themes only its window keeps that theme and still gets the +/// rest of the base theme. A window that sets its template itself (the gallery's) is not affected either way. +/// +/// +/// The theme files live in Theme/Base/, outside the Themes/*Theme.axaml pattern, so the Foundation generator +/// never registers them unconditionally; is the only way in (Core's package calls it from its +/// commit callback, UseArcCore()). +/// +/// +public static class ArcBaseTheme +{ + /// The built-in control types the base theme gives a default ControlTheme. + public static IReadOnlyList StyledTypes { get; } = + [ + typeof(ScrollViewer), typeof(ScrollBar), typeof(ItemsControl), typeof(PathIcon), typeof(TextSelectionHandle), + typeof(OverlayPopupHost), typeof(PopupRoot), typeof(EmbeddableControlRoot), typeof(Window) + ]; + + /// + /// Creates the base theme's resources: one dictionary that merges the theme files. They are small, so they are + /// loaded together. + /// + public static ResourceDictionary Create() => Create(includeWindow: true); + + private static ResourceDictionary Create(bool includeWindow) + { + var resources = new ResourceDictionary(); + resources.MergedDictionaries.Add(new ScrollViewerBaseTheme()); + resources.MergedDictionaries.Add(new ItemsControlBaseTheme()); + resources.MergedDictionaries.Add(new PathIconBaseTheme()); + resources.MergedDictionaries.Add(new TextSelectionHandleBaseTheme()); + resources.MergedDictionaries.Add(new PopupHostBaseTheme()); + if (includeWindow) + { + resources.MergedDictionaries.Add(new WindowBaseTheme()); + } + return resources; + } + + /// + /// Whether already resolve a default ControlTheme for one of + /// (FluentTheme, SimpleTheme, another theme, or the base theme itself). A theme for alone does not + /// count: an application's own window theme is not a theme for the built-in controls. + /// + /// An application, a theme (new FluentTheme()) or any resource node. + public static bool DefinesBuiltInThemes(IResourceNode resources) + { + foreach (var type in StyledTypes) + { + if (type != typeof(Window) && DefinesTheme(resources, type)) + { + return true; + } + } + return false; + } + + /// Whether would register the base theme in . + /// The application, after it loaded its own styles. + public static bool ShouldRegister(Application application) => !DefinesBuiltInThemes(application); + + /// + /// Adds the base theme to 's merged dictionaries unless another theme already styles the + /// built-in controls (see ). The theme is left out when the application + /// already has one. Calling it again does nothing. + /// + /// The application, after it loaded its own styles. + /// True when the base theme was registered by this call. + public static bool TryRegister(Application application) + { + if (!ShouldRegister(application)) + { + return false; + } + var resources = Create(includeWindow: !DefinesTheme(application, typeof(Window))); + application.Resources.MergedDictionaries.Add(resources); + // Avalonia.Browser creates its root (an EmbeddableControlRoot) and applies its template before Arc is set up (its + // AfterApplicationSetup callback runs ahead of AfterSetup), and a control looks up its default theme only when it + // joins a tree, so the root would keep Avalonia's template, without the overlay layer popups need. A style reaches + // controls that exist already; it sets the same theme. + if (resources.TryGetResource(typeof(EmbeddableControlRoot), null, out var root) && root is ControlTheme rootTheme) + { + application.Styles.Add(new Style(static x => x.OfType()) + { + Setters = { new Setter(StyledElement.ThemeProperty, rootTheme) } + }); + } + return true; + } + + private static bool DefinesTheme(IResourceNode resources, Type type) + => resources.TryGetResource(type, null, out var value) && value is ControlTheme; +} + +// The theme files' x:Class types. Compiled XAML turns the Load call into a direct call of the generated populate method, +// so creating a file is a plain constructor call (no URI loading, AOT safe; design/architecture/foundation.md 5.3). + +/// ScrollViewer and ScrollBar (ScrollViewerBaseTheme.axaml). +internal sealed partial class ScrollViewerBaseTheme : ResourceDictionary +{ + public ScrollViewerBaseTheme() => AvaloniaXamlLoader.Load(this); +} + +/// ItemsControl (ItemsControlBaseTheme.axaml). +internal sealed partial class ItemsControlBaseTheme : ResourceDictionary +{ + public ItemsControlBaseTheme() => AvaloniaXamlLoader.Load(this); +} + +/// PathIcon (PathIconBaseTheme.axaml). +internal sealed partial class PathIconBaseTheme : ResourceDictionary +{ + public PathIconBaseTheme() => AvaloniaXamlLoader.Load(this); +} + +/// TextSelectionHandle (TextSelectionHandleBaseTheme.axaml). +internal sealed partial class TextSelectionHandleBaseTheme : ResourceDictionary +{ + public TextSelectionHandleBaseTheme() => AvaloniaXamlLoader.Load(this); +} + +/// OverlayPopupHost, PopupRoot and EmbeddableControlRoot (PopupHostBaseTheme.axaml). +internal sealed partial class PopupHostBaseTheme : ResourceDictionary +{ + public PopupHostBaseTheme() => AvaloniaXamlLoader.Load(this); +} + +/// Window (WindowBaseTheme.axaml). +internal sealed partial class WindowBaseTheme : ResourceDictionary +{ + public WindowBaseTheme() => AvaloniaXamlLoader.Load(this); +} diff --git a/src/TKCode.Arc.Core/Theme/Base/ItemsControlBaseTheme.axaml b/src/TKCode.Arc.Core/Theme/Base/ItemsControlBaseTheme.axaml new file mode 100644 index 00000000..b4d6ef5b --- /dev/null +++ b/src/TKCode.Arc.Core/Theme/Base/ItemsControlBaseTheme.axaml @@ -0,0 +1,19 @@ + + + + + + + + + + + + diff --git a/src/TKCode.Arc.Core/Theme/Base/PathIconBaseTheme.axaml b/src/TKCode.Arc.Core/Theme/Base/PathIconBaseTheme.axaml new file mode 100644 index 00000000..f36230b5 --- /dev/null +++ b/src/TKCode.Arc.Core/Theme/Base/PathIconBaseTheme.axaml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + diff --git a/src/TKCode.Arc.Core/Theme/Base/PopupHostBaseTheme.axaml b/src/TKCode.Arc.Core/Theme/Base/PopupHostBaseTheme.axaml new file mode 100644 index 00000000..81cf4494 --- /dev/null +++ b/src/TKCode.Arc.Core/Theme/Base/PopupHostBaseTheme.axaml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/TKCode.Arc.Core/Theme/Base/ScrollViewerAutoHide.cs b/src/TKCode.Arc.Core/Theme/Base/ScrollViewerAutoHide.cs new file mode 100644 index 00000000..d64c9ec0 --- /dev/null +++ b/src/TKCode.Arc.Core/Theme/Base/ScrollViewerAutoHide.cs @@ -0,0 +1,218 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Threading; + +namespace TKCode.Arc; + +/// +/// When the base theme's overlay scroll bars show (ScrollViewerBaseTheme.axaml; design/architecture/foundation.md +/// 14.2), ScrollArea's default: while a mouse is over the viewer, while the offset changes (wheel, keyboard, touch, +/// code) and after the last change, and while a bar's thumb is dragged. With +/// false the bars always show. The theme reads ; +/// the fade itself is ControlTheme transitions. +/// +/// +/// Code because a ControlTheme cannot express two of the triggers: "for a while after the offset last changed" needs a +/// timer, and hover counts only for a mouse (ScrollArea: hover reveals only for fine pointers), while +/// :pointerover also matches a finger on the screen. Turned on by the ScrollViewer theme's +/// setter, so a viewer under another theme carries none of it. +/// +internal static class ScrollViewerAutoHide +{ + /// How long the bars stay after the offset last changed or a drag ended (ScrollArea's HideDelay). + public static readonly TimeSpan HideDelay = TimeSpan.FromMilliseconds(900); + + /// Hooks the viewer up (set by the base theme). + public static readonly AttachedProperty IsEnabledProperty = + AvaloniaProperty.RegisterAttached("IsEnabled", typeof(ScrollViewerAutoHide)); + + /// True while the viewer's bars show (written here, read by the theme). + public static readonly AttachedProperty IsShownProperty = + AvaloniaProperty.RegisterAttached("IsShown", typeof(ScrollViewerAutoHide)); + + private static readonly AttachedProperty TrackerProperty = + AvaloniaProperty.RegisterAttached("Tracker", typeof(ScrollViewerAutoHide)); + + /// Creates the hide timer: a delay and its callback (tests swap in a manual clock). + internal static Func CreateTimer { get; set; } = + static (delay, elapsed) => new DispatcherHideTimer(delay, elapsed); + + static ScrollViewerAutoHide() + { + IsEnabledProperty.Changed.AddClassHandler(OnIsEnabledChanged); + } + + public static bool GetIsEnabled(ScrollViewer viewer) => viewer.GetValue(IsEnabledProperty); + + public static void SetIsEnabled(ScrollViewer viewer, bool value) => viewer.SetValue(IsEnabledProperty, value); + + public static bool GetIsShown(ScrollViewer viewer) => viewer.GetValue(IsShownProperty); + + public static void SetIsShown(ScrollViewer viewer, bool value) => viewer.SetValue(IsShownProperty, value); + + private static void OnIsEnabledChanged(ScrollViewer viewer, AvaloniaPropertyChangedEventArgs e) + { + viewer.GetValue(TrackerProperty)?.Detach(); + viewer.ClearValue(TrackerProperty); + if (e.NewValue is true) + { + viewer.SetValue(TrackerProperty, new Tracker(viewer)); + } + else + { + viewer.ClearValue(IsShownProperty); + } + } + + /// A restartable one-shot delay. + internal interface IHideTimer + { + /// Starts the delay over. + void Restart(); + + void Stop(); + } + + private sealed class DispatcherHideTimer : IHideTimer + { + private readonly DispatcherTimer _timer; + private readonly Action _elapsed; + + public DispatcherHideTimer(TimeSpan delay, Action elapsed) + { + _elapsed = elapsed; + _timer = new DispatcherTimer(DispatcherPriority.Normal) { Interval = delay }; + _timer.Tick += OnTick; + } + + public void Restart() + { + _timer.Stop(); + _timer.Start(); + } + + public void Stop() => _timer.Stop(); + + private void OnTick(object? sender, EventArgs e) + { + _timer.Stop(); + _elapsed(); + } + } + + private sealed class Tracker + { + private readonly ScrollViewer _viewer; + private IHideTimer? _timer; + private bool _hovering; + private bool _scrolling; + private bool _dragging; + + public Tracker(ScrollViewer viewer) + { + _viewer = viewer; + viewer.PropertyChanged += OnPropertyChanged; + viewer.AddHandler(InputElement.PointerEnteredEvent, OnPointerEntered, RoutingStrategies.Direct, true); + viewer.AddHandler(InputElement.PointerExitedEvent, OnPointerExited, RoutingStrategies.Direct, true); + viewer.AddHandler(Thumb.DragStartedEvent, OnDragStarted, RoutingStrategies.Bubble, true); + viewer.AddHandler(Thumb.DragCompletedEvent, OnDragCompleted, RoutingStrategies.Bubble, true); + viewer.DetachedFromVisualTree += OnDetached; + Update(); + } + + public void Detach() + { + _timer?.Stop(); + _viewer.PropertyChanged -= OnPropertyChanged; + _viewer.RemoveHandler(InputElement.PointerEnteredEvent, OnPointerEntered); + _viewer.RemoveHandler(InputElement.PointerExitedEvent, OnPointerExited); + _viewer.RemoveHandler(Thumb.DragStartedEvent, OnDragStarted); + _viewer.RemoveHandler(Thumb.DragCompletedEvent, OnDragCompleted); + _viewer.DetachedFromVisualTree -= OnDetached; + } + + private void OnPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) + { + if (e.Property == ScrollViewer.OffsetProperty) + { + Wake(); + } + else if (e.Property == ScrollViewer.AllowAutoHideProperty) + { + Update(); + } + } + + private void OnPointerEntered(object? sender, PointerEventArgs e) + { + if (e.Pointer.Type == PointerType.Mouse) + { + _hovering = true; + Update(); + } + } + + private void OnPointerExited(object? sender, PointerEventArgs e) + { + _hovering = false; + Update(); + } + + private void OnDragStarted(object? sender, VectorEventArgs e) + { + if (IsOwnThumb(e.Source)) + { + _dragging = true; + Update(); + } + } + + private void OnDragCompleted(object? sender, VectorEventArgs e) + { + if (IsOwnThumb(e.Source) && _dragging) + { + _dragging = false; + Wake(); + } + } + + // A slider or a nested viewer in the content raises the same events; only this viewer's bars count. + private bool IsOwnThumb(object? source) => + source is Thumb { TemplatedParent: ScrollBar bar } && ReferenceEquals(bar.TemplatedParent, _viewer); + + private void OnDetached(object? sender, VisualTreeAttachmentEventArgs e) + { + _timer?.Stop(); + _hovering = false; + _scrolling = false; + _dragging = false; + Update(); + } + + private void Wake() + { + _scrolling = true; + _timer ??= CreateTimer(HideDelay, OnHideElapsed); + _timer.Restart(); + Update(); + } + + private void OnHideElapsed() + { + _scrolling = false; + Update(); + } + + private void Update() + { + var shown = !_viewer.AllowAutoHide || _hovering || _scrolling || _dragging; + if (GetIsShown(_viewer) != shown) + { + SetIsShown(_viewer, shown); + } + } + } +} diff --git a/src/TKCode.Arc.Core/Theme/Base/ScrollViewerBaseTheme.axaml b/src/TKCode.Arc.Core/Theme/Base/ScrollViewerBaseTheme.axaml new file mode 100644 index 00000000..dc194b39 --- /dev/null +++ b/src/TKCode.Arc.Core/Theme/Base/ScrollViewerBaseTheme.axaml @@ -0,0 +1,184 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/TKCode.Arc.Core/Theme/Base/TextSelectionHandleBaseTheme.axaml b/src/TKCode.Arc.Core/Theme/Base/TextSelectionHandleBaseTheme.axaml new file mode 100644 index 00000000..89f77d60 --- /dev/null +++ b/src/TKCode.Arc.Core/Theme/Base/TextSelectionHandleBaseTheme.axaml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/TKCode.Arc.Core/Theme/Base/WindowBaseTheme.axaml b/src/TKCode.Arc.Core/Theme/Base/WindowBaseTheme.axaml new file mode 100644 index 00000000..5791fd64 --- /dev/null +++ b/src/TKCode.Arc.Core/Theme/Base/WindowBaseTheme.axaml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/TKCode.Arc.Core/ThemeManagerBuilderExtensions.cs b/src/TKCode.Arc.Core/ThemeManagerBuilderExtensions.cs index e9fdb1eb..249fdd1a 100644 --- a/src/TKCode.Arc.Core/ThemeManagerBuilderExtensions.cs +++ b/src/TKCode.Arc.Core/ThemeManagerBuilderExtensions.cs @@ -1,13 +1,15 @@ -using AtomUI; -using AtomUI.Desktop.Controls; -using AtomUI.Generated.TKCodeArcCore; +using TKCode.Arc.Generated.TKCodeArcCore; namespace TKCode.Arc; public static class ArcCoreBuilderExtensions { - /// Enables the Arc core package (design tokens, motion, primitives). - public static IAtomUIBuilder UseArcCore(this IAtomUIBuilder builder) - => GeneratedControlPackageRegistration.Definition.Register(builder, static package => package - .DependsOn(static dependency => dependency.UseDesktopControls())); + /// + /// Enables the Arc core package (design tokens, motion, primitives). At startup it also registers + /// , the themes of the Avalonia built-in controls Arc's templates use (ScrollViewer, + /// ItemsControl, popup hosts, ...), unless the application already has a theme for them (FluentTheme, SimpleTheme). + /// + public static ArcBuilder UseArcCore(this ArcBuilder builder) + => builder.UsePackage(ArcGeneratedAssets.PackageId, static package => ArcGeneratedAssets.AddTo(package) + .OnCommit(static context => ArcBaseTheme.TryRegister(context.Application))); } diff --git a/src/TKCode.Arc.Fonts/ArcFontCollections.cs b/src/TKCode.Arc.Fonts/ArcFontCollections.cs index 900ad024..d37aa745 100644 --- a/src/TKCode.Arc.Fonts/ArcFontCollections.cs +++ b/src/TKCode.Arc.Fonts/ArcFontCollections.cs @@ -42,7 +42,7 @@ public static AppBuilder WithArcFonts(this AppBuilder builder) => builder.Config fontManager.AddFontCollection(new GeistMonoFontCollection()); }); - /// Registers the pixel fonts (Silkscreen, VT323) that ArcPixelTheme.PixelFontStack names first. Not part of to keep its size out of apps that do not use the pixel theme. + /// Registers the pixel fonts (Silkscreen, VT323) that ArcPixelPresets.PixelFontStack names first. Not part of to keep its size out of apps that do not use the pixel theme. public static AppBuilder WithArcPixelFonts(this AppBuilder builder) => builder.ConfigureFonts(fontManager => { fontManager.AddFontCollection(new ArcPixelFontCollection()); diff --git a/src/TKCode.Arc.Mobile/Inputs/Button/ButtonToken.cs b/src/TKCode.Arc.Mobile/Inputs/Button/ButtonToken.cs index 081ba225..cfe293b0 100644 --- a/src/TKCode.Arc.Mobile/Inputs/Button/ButtonToken.cs +++ b/src/TKCode.Arc.Mobile/Inputs/Button/ButtonToken.cs @@ -1,4 +1,3 @@ -using AtomUI.Theme.DesignTokens; using Avalonia; namespace TKCode.Arc.Mobile; @@ -7,7 +6,8 @@ namespace TKCode.Arc.Mobile; /// Geometry of the mobile : a 44px minimum touch target, padding, and the press feedback. /// Colors, radii and type come from the ArcDesign tokens in the theme. /// -[ControlDesignToken] +// The desktop TKCode.Arc package declares a ButtonToken too, and identities are unique per application. +[ControlDesignToken(Identity = "MobileButton")] internal sealed class ButtonToken : AbstractControlDesignToken { /// Minimum width and height: 44. diff --git a/src/TKCode.Arc.Mobile/Inputs/Button/Themes/ButtonTheme.axaml b/src/TKCode.Arc.Mobile/Inputs/Button/Themes/ButtonTheme.axaml index de67bf4b..e2bd0a1c 100644 --- a/src/TKCode.Arc.Mobile/Inputs/Button/Themes/ButtonTheme.axaml +++ b/src/TKCode.Arc.Mobile/Inputs/Button/Themes/ButtonTheme.axaml @@ -2,7 +2,8 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:arc="https://github.com/AIDotNet/TKCode.Arc" xmlns:arcm="https://github.com/AIDotNet/TKCode.Arc.Mobile" - x:ClassModifier="internal"> + x:ClassModifier="internal" + x:Class="TKCode.Arc.Mobile.ButtonTheme"> diff --git a/src/TKCode.Arc.Mobile/Inputs/Checkbox/CheckboxToken.cs b/src/TKCode.Arc.Mobile/Inputs/Checkbox/CheckboxToken.cs index bc141fe2..e68f8993 100644 --- a/src/TKCode.Arc.Mobile/Inputs/Checkbox/CheckboxToken.cs +++ b/src/TKCode.Arc.Mobile/Inputs/Checkbox/CheckboxToken.cs @@ -1,10 +1,10 @@ -using AtomUI.Theme.DesignTokens; using Avalonia; namespace TKCode.Arc.Mobile; /// Geometry of : a 22px box with 6px corners, a 44px row, a 16px check. -[ControlDesignToken] +// The desktop TKCode.Arc package declares a CheckboxToken too, and identities are unique per application. +[ControlDesignToken(Identity = "MobileCheckbox")] internal sealed class CheckboxToken : AbstractControlDesignToken { /// Smallest height of the row: 44. diff --git a/src/TKCode.Arc.Mobile/Inputs/Checkbox/Themes/CheckboxTheme.axaml b/src/TKCode.Arc.Mobile/Inputs/Checkbox/Themes/CheckboxTheme.axaml index 585fe620..8b062ee6 100644 --- a/src/TKCode.Arc.Mobile/Inputs/Checkbox/Themes/CheckboxTheme.axaml +++ b/src/TKCode.Arc.Mobile/Inputs/Checkbox/Themes/CheckboxTheme.axaml @@ -2,7 +2,8 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:arc="https://github.com/AIDotNet/TKCode.Arc" xmlns:arcm="https://github.com/AIDotNet/TKCode.Arc.Mobile" - x:ClassModifier="internal"> + x:ClassModifier="internal" + x:Class="TKCode.Arc.Mobile.CheckboxTheme"> diff --git a/src/TKCode.Arc.Mobile/Inputs/IconButton/IconButtonToken.cs b/src/TKCode.Arc.Mobile/Inputs/IconButton/IconButtonToken.cs index 2a15c8f4..8626f3d0 100644 --- a/src/TKCode.Arc.Mobile/Inputs/IconButton/IconButtonToken.cs +++ b/src/TKCode.Arc.Mobile/Inputs/IconButton/IconButtonToken.cs @@ -1,4 +1,3 @@ -using AtomUI.Theme.DesignTokens; namespace TKCode.Arc.Mobile; diff --git a/src/TKCode.Arc.Mobile/Inputs/IconButton/Themes/IconButtonTheme.axaml b/src/TKCode.Arc.Mobile/Inputs/IconButton/Themes/IconButtonTheme.axaml index b1340502..13c91455 100644 --- a/src/TKCode.Arc.Mobile/Inputs/IconButton/Themes/IconButtonTheme.axaml +++ b/src/TKCode.Arc.Mobile/Inputs/IconButton/Themes/IconButtonTheme.axaml @@ -2,7 +2,8 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:arc="https://github.com/AIDotNet/TKCode.Arc" xmlns:arcm="https://github.com/AIDotNet/TKCode.Arc.Mobile" - x:ClassModifier="internal"> + x:ClassModifier="internal" + x:Class="TKCode.Arc.Mobile.IconButtonTheme"> diff --git a/src/TKCode.Arc.Mobile/Inputs/Radio/RadioToken.cs b/src/TKCode.Arc.Mobile/Inputs/Radio/RadioToken.cs index 4551edec..ae8de815 100644 --- a/src/TKCode.Arc.Mobile/Inputs/Radio/RadioToken.cs +++ b/src/TKCode.Arc.Mobile/Inputs/Radio/RadioToken.cs @@ -1,4 +1,3 @@ -using AtomUI.Theme.DesignTokens; using Avalonia; namespace TKCode.Arc.Mobile; diff --git a/src/TKCode.Arc.Mobile/Inputs/Radio/Themes/RadioTheme.axaml b/src/TKCode.Arc.Mobile/Inputs/Radio/Themes/RadioTheme.axaml index 34db180b..deb35380 100644 --- a/src/TKCode.Arc.Mobile/Inputs/Radio/Themes/RadioTheme.axaml +++ b/src/TKCode.Arc.Mobile/Inputs/Radio/Themes/RadioTheme.axaml @@ -2,7 +2,8 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:arc="https://github.com/AIDotNet/TKCode.Arc" xmlns:arcm="https://github.com/AIDotNet/TKCode.Arc.Mobile" - x:ClassModifier="internal"> + x:ClassModifier="internal" + x:Class="TKCode.Arc.Mobile.RadioTheme"> diff --git a/src/TKCode.Arc.Mobile/Inputs/Segmented/SegmentedItemToken.cs b/src/TKCode.Arc.Mobile/Inputs/Segmented/SegmentedItemToken.cs index 76479a1f..efcab326 100644 --- a/src/TKCode.Arc.Mobile/Inputs/Segmented/SegmentedItemToken.cs +++ b/src/TKCode.Arc.Mobile/Inputs/Segmented/SegmentedItemToken.cs @@ -1,4 +1,3 @@ -using AtomUI.Theme.DesignTokens; using Avalonia; namespace TKCode.Arc.Mobile; diff --git a/src/TKCode.Arc.Mobile/Inputs/Segmented/SegmentedToken.cs b/src/TKCode.Arc.Mobile/Inputs/Segmented/SegmentedToken.cs index d1b17b09..680519c8 100644 --- a/src/TKCode.Arc.Mobile/Inputs/Segmented/SegmentedToken.cs +++ b/src/TKCode.Arc.Mobile/Inputs/Segmented/SegmentedToken.cs @@ -1,4 +1,3 @@ -using AtomUI.Theme.DesignTokens; using Avalonia; namespace TKCode.Arc.Mobile; diff --git a/src/TKCode.Arc.Mobile/Inputs/Segmented/Themes/SegmentedTheme.axaml b/src/TKCode.Arc.Mobile/Inputs/Segmented/Themes/SegmentedTheme.axaml index 45329c86..68d8e0d2 100644 --- a/src/TKCode.Arc.Mobile/Inputs/Segmented/Themes/SegmentedTheme.axaml +++ b/src/TKCode.Arc.Mobile/Inputs/Segmented/Themes/SegmentedTheme.axaml @@ -2,7 +2,8 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:arc="https://github.com/AIDotNet/TKCode.Arc" xmlns:arcm="https://github.com/AIDotNet/TKCode.Arc.Mobile" - x:ClassModifier="internal"> + x:ClassModifier="internal" + x:Class="TKCode.Arc.Mobile.SegmentedTheme"> diff --git a/src/TKCode.Arc.Mobile/Inputs/Switch/SwitchToken.cs b/src/TKCode.Arc.Mobile/Inputs/Switch/SwitchToken.cs index 43b85c02..9ca9b643 100644 --- a/src/TKCode.Arc.Mobile/Inputs/Switch/SwitchToken.cs +++ b/src/TKCode.Arc.Mobile/Inputs/Switch/SwitchToken.cs @@ -1,10 +1,10 @@ -using AtomUI.Theme.DesignTokens; using Avalonia; namespace TKCode.Arc.Mobile; /// Geometry of the mobile : a 51 by 31 track, a 27px thumb and a 44px touch target. -[ControlDesignToken] +// The desktop TKCode.Arc package declares a SwitchToken too, and identities are unique per application. +[ControlDesignToken(Identity = "MobileSwitch")] internal sealed class SwitchToken : AbstractControlDesignToken { /// Track width: 51. diff --git a/src/TKCode.Arc.Mobile/Inputs/Switch/Themes/SwitchTheme.axaml b/src/TKCode.Arc.Mobile/Inputs/Switch/Themes/SwitchTheme.axaml index ce52d21c..e39abbdf 100644 --- a/src/TKCode.Arc.Mobile/Inputs/Switch/Themes/SwitchTheme.axaml +++ b/src/TKCode.Arc.Mobile/Inputs/Switch/Themes/SwitchTheme.axaml @@ -2,7 +2,8 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:arc="https://github.com/AIDotNet/TKCode.Arc" xmlns:arcm="https://github.com/AIDotNet/TKCode.Arc.Mobile" - x:ClassModifier="internal"> + x:ClassModifier="internal" + x:Class="TKCode.Arc.Mobile.SwitchTheme"> diff --git a/src/TKCode.Arc.Mobile/Inputs/TextField/TextFieldToken.cs b/src/TKCode.Arc.Mobile/Inputs/TextField/TextFieldToken.cs index 9f4c564c..7857e80e 100644 --- a/src/TKCode.Arc.Mobile/Inputs/TextField/TextFieldToken.cs +++ b/src/TKCode.Arc.Mobile/Inputs/TextField/TextFieldToken.cs @@ -1,4 +1,3 @@ -using AtomUI.Theme.DesignTokens; using Avalonia; namespace TKCode.Arc.Mobile; diff --git a/src/TKCode.Arc.Mobile/Inputs/TextField/Themes/TextFieldTheme.axaml b/src/TKCode.Arc.Mobile/Inputs/TextField/Themes/TextFieldTheme.axaml index 9b6ab0f5..becf5914 100644 --- a/src/TKCode.Arc.Mobile/Inputs/TextField/Themes/TextFieldTheme.axaml +++ b/src/TKCode.Arc.Mobile/Inputs/TextField/Themes/TextFieldTheme.axaml @@ -2,7 +2,8 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:arc="https://github.com/AIDotNet/TKCode.Arc" xmlns:arcm="https://github.com/AIDotNet/TKCode.Arc.Mobile" - x:ClassModifier="internal"> + x:ClassModifier="internal" + x:Class="TKCode.Arc.Mobile.TextFieldTheme"> diff --git a/src/TKCode.Arc.Mobile/Lists/ListGroup/ListGroupToken.cs b/src/TKCode.Arc.Mobile/Lists/ListGroup/ListGroupToken.cs index 226fd763..ffab28d7 100644 --- a/src/TKCode.Arc.Mobile/Lists/ListGroup/ListGroupToken.cs +++ b/src/TKCode.Arc.Mobile/Lists/ListGroup/ListGroupToken.cs @@ -1,4 +1,3 @@ -using AtomUI.Theme.DesignTokens; using Avalonia; namespace TKCode.Arc.Mobile; diff --git a/src/TKCode.Arc.Mobile/Lists/ListGroup/Themes/ListGroupTheme.axaml b/src/TKCode.Arc.Mobile/Lists/ListGroup/Themes/ListGroupTheme.axaml index 027c9439..40dce206 100644 --- a/src/TKCode.Arc.Mobile/Lists/ListGroup/Themes/ListGroupTheme.axaml +++ b/src/TKCode.Arc.Mobile/Lists/ListGroup/Themes/ListGroupTheme.axaml @@ -2,7 +2,8 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:arc="https://github.com/AIDotNet/TKCode.Arc" xmlns:arcm="https://github.com/AIDotNet/TKCode.Arc.Mobile" - x:ClassModifier="internal"> + x:ClassModifier="internal" + x:Class="TKCode.Arc.Mobile.ListGroupTheme"> diff --git a/src/TKCode.Arc.Mobile/Lists/ListItem/ListItemToken.cs b/src/TKCode.Arc.Mobile/Lists/ListItem/ListItemToken.cs index 667413f8..63288f4e 100644 --- a/src/TKCode.Arc.Mobile/Lists/ListItem/ListItemToken.cs +++ b/src/TKCode.Arc.Mobile/Lists/ListItem/ListItemToken.cs @@ -1,4 +1,3 @@ -using AtomUI.Theme.DesignTokens; using Avalonia; namespace TKCode.Arc.Mobile; diff --git a/src/TKCode.Arc.Mobile/Lists/ListItem/Themes/ListItemTheme.axaml b/src/TKCode.Arc.Mobile/Lists/ListItem/Themes/ListItemTheme.axaml index f8477be3..5e23c90c 100644 --- a/src/TKCode.Arc.Mobile/Lists/ListItem/Themes/ListItemTheme.axaml +++ b/src/TKCode.Arc.Mobile/Lists/ListItem/Themes/ListItemTheme.axaml @@ -2,7 +2,8 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:arc="https://github.com/AIDotNet/TKCode.Arc" xmlns:arcm="https://github.com/AIDotNet/TKCode.Arc.Mobile" - x:ClassModifier="internal"> + x:ClassModifier="internal" + x:Class="TKCode.Arc.Mobile.ListItemTheme"> diff --git a/src/TKCode.Arc.Mobile/Lists/PullToRefresh/PullToRefreshToken.cs b/src/TKCode.Arc.Mobile/Lists/PullToRefresh/PullToRefreshToken.cs index da94f1ef..f74b40c6 100644 --- a/src/TKCode.Arc.Mobile/Lists/PullToRefresh/PullToRefreshToken.cs +++ b/src/TKCode.Arc.Mobile/Lists/PullToRefresh/PullToRefreshToken.cs @@ -1,4 +1,3 @@ -using AtomUI.Theme.DesignTokens; namespace TKCode.Arc.Mobile; diff --git a/src/TKCode.Arc.Mobile/Lists/PullToRefresh/Themes/PullToRefreshTheme.axaml b/src/TKCode.Arc.Mobile/Lists/PullToRefresh/Themes/PullToRefreshTheme.axaml index 3f4e14a0..b391ec02 100644 --- a/src/TKCode.Arc.Mobile/Lists/PullToRefresh/Themes/PullToRefreshTheme.axaml +++ b/src/TKCode.Arc.Mobile/Lists/PullToRefresh/Themes/PullToRefreshTheme.axaml @@ -2,7 +2,8 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:arc="https://github.com/AIDotNet/TKCode.Arc" xmlns:arcm="https://github.com/AIDotNet/TKCode.Arc.Mobile" - x:ClassModifier="internal"> + x:ClassModifier="internal" + x:Class="TKCode.Arc.Mobile.PullToRefreshTheme"> diff --git a/src/TKCode.Arc.Mobile/Overlays/ActionSheet/Themes/ActionSheetTheme.axaml b/src/TKCode.Arc.Mobile/Overlays/ActionSheet/Themes/ActionSheetTheme.axaml index 39de4e8f..b76c7efe 100644 --- a/src/TKCode.Arc.Mobile/Overlays/ActionSheet/Themes/ActionSheetTheme.axaml +++ b/src/TKCode.Arc.Mobile/Overlays/ActionSheet/Themes/ActionSheetTheme.axaml @@ -2,7 +2,8 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:arc="https://github.com/AIDotNet/TKCode.Arc" xmlns:arcm="https://github.com/AIDotNet/TKCode.Arc.Mobile" - x:ClassModifier="internal"> + x:ClassModifier="internal" + x:Class="TKCode.Arc.Mobile.ActionSheetTheme"> diff --git a/src/TKCode.Arc.Mobile/Shell/NavigationStack/NavigationStackToken.cs b/src/TKCode.Arc.Mobile/Shell/NavigationStack/NavigationStackToken.cs index 6043f218..84cf0575 100644 --- a/src/TKCode.Arc.Mobile/Shell/NavigationStack/NavigationStackToken.cs +++ b/src/TKCode.Arc.Mobile/Shell/NavigationStack/NavigationStackToken.cs @@ -1,4 +1,3 @@ -using AtomUI.Theme.DesignTokens; using Avalonia.Media; namespace TKCode.Arc.Mobile; diff --git a/src/TKCode.Arc.Mobile/Shell/NavigationStack/Themes/NavigationStackTheme.axaml b/src/TKCode.Arc.Mobile/Shell/NavigationStack/Themes/NavigationStackTheme.axaml index ac33dce7..e659f611 100644 --- a/src/TKCode.Arc.Mobile/Shell/NavigationStack/Themes/NavigationStackTheme.axaml +++ b/src/TKCode.Arc.Mobile/Shell/NavigationStack/Themes/NavigationStackTheme.axaml @@ -2,7 +2,8 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:arc="https://github.com/AIDotNet/TKCode.Arc" xmlns:arcm="https://github.com/AIDotNet/TKCode.Arc.Mobile" - x:ClassModifier="internal"> + x:ClassModifier="internal" + x:Class="TKCode.Arc.Mobile.NavigationStackTheme"> diff --git a/src/TKCode.Arc.Mobile/Shell/Page/PageToken.cs b/src/TKCode.Arc.Mobile/Shell/Page/PageToken.cs index 8bd97f7b..3be3ac01 100644 --- a/src/TKCode.Arc.Mobile/Shell/Page/PageToken.cs +++ b/src/TKCode.Arc.Mobile/Shell/Page/PageToken.cs @@ -1,4 +1,3 @@ -using AtomUI.Theme.DesignTokens; using Avalonia; namespace TKCode.Arc.Mobile; diff --git a/src/TKCode.Arc.Mobile/Shell/Page/Themes/PageTheme.axaml b/src/TKCode.Arc.Mobile/Shell/Page/Themes/PageTheme.axaml index 0d702af5..c367ede4 100644 --- a/src/TKCode.Arc.Mobile/Shell/Page/Themes/PageTheme.axaml +++ b/src/TKCode.Arc.Mobile/Shell/Page/Themes/PageTheme.axaml @@ -2,7 +2,8 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:arc="https://github.com/AIDotNet/TKCode.Arc" xmlns:arcm="https://github.com/AIDotNet/TKCode.Arc.Mobile" - x:ClassModifier="internal"> + x:ClassModifier="internal" + x:Class="TKCode.Arc.Mobile.PageTheme"> diff --git a/src/TKCode.Arc.Mobile/Shell/Tabbar/TabbarItemToken.cs b/src/TKCode.Arc.Mobile/Shell/Tabbar/TabbarItemToken.cs index b6158080..77e3a743 100644 --- a/src/TKCode.Arc.Mobile/Shell/Tabbar/TabbarItemToken.cs +++ b/src/TKCode.Arc.Mobile/Shell/Tabbar/TabbarItemToken.cs @@ -1,4 +1,3 @@ -using AtomUI.Theme.DesignTokens; using Avalonia; namespace TKCode.Arc.Mobile; diff --git a/src/TKCode.Arc.Mobile/Shell/Tabbar/TabbarToken.cs b/src/TKCode.Arc.Mobile/Shell/Tabbar/TabbarToken.cs index 6e69ccad..46ff282c 100644 --- a/src/TKCode.Arc.Mobile/Shell/Tabbar/TabbarToken.cs +++ b/src/TKCode.Arc.Mobile/Shell/Tabbar/TabbarToken.cs @@ -1,4 +1,3 @@ -using AtomUI.Theme.DesignTokens; using Avalonia; namespace TKCode.Arc.Mobile; diff --git a/src/TKCode.Arc.Mobile/Shell/Tabbar/Themes/TabbarTheme.axaml b/src/TKCode.Arc.Mobile/Shell/Tabbar/Themes/TabbarTheme.axaml index 09b0625e..75497da8 100644 --- a/src/TKCode.Arc.Mobile/Shell/Tabbar/Themes/TabbarTheme.axaml +++ b/src/TKCode.Arc.Mobile/Shell/Tabbar/Themes/TabbarTheme.axaml @@ -2,7 +2,8 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:arc="https://github.com/AIDotNet/TKCode.Arc" xmlns:arcm="https://github.com/AIDotNet/TKCode.Arc.Mobile" - x:ClassModifier="internal"> + x:ClassModifier="internal" + x:Class="TKCode.Arc.Mobile.TabbarTheme"> diff --git a/src/TKCode.Arc.Mobile/Shell/Toolbar/Themes/ToolbarTheme.axaml b/src/TKCode.Arc.Mobile/Shell/Toolbar/Themes/ToolbarTheme.axaml index 16274fbf..10ebeab5 100644 --- a/src/TKCode.Arc.Mobile/Shell/Toolbar/Themes/ToolbarTheme.axaml +++ b/src/TKCode.Arc.Mobile/Shell/Toolbar/Themes/ToolbarTheme.axaml @@ -2,7 +2,8 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:arc="https://github.com/AIDotNet/TKCode.Arc" xmlns:arcm="https://github.com/AIDotNet/TKCode.Arc.Mobile" - x:ClassModifier="internal"> + x:ClassModifier="internal" + x:Class="TKCode.Arc.Mobile.ToolbarTheme"> diff --git a/src/TKCode.Arc.Mobile/Shell/Toolbar/ToolbarToken.cs b/src/TKCode.Arc.Mobile/Shell/Toolbar/ToolbarToken.cs index f7469e84..71e577e8 100644 --- a/src/TKCode.Arc.Mobile/Shell/Toolbar/ToolbarToken.cs +++ b/src/TKCode.Arc.Mobile/Shell/Toolbar/ToolbarToken.cs @@ -1,4 +1,3 @@ -using AtomUI.Theme.DesignTokens; using Avalonia; namespace TKCode.Arc.Mobile; diff --git a/src/TKCode.Arc.Mobile/TKCode.Arc.Mobile.csproj b/src/TKCode.Arc.Mobile/TKCode.Arc.Mobile.csproj index 4fc8c69c..3bc43d73 100644 --- a/src/TKCode.Arc.Mobile/TKCode.Arc.Mobile.csproj +++ b/src/TKCode.Arc.Mobile/TKCode.Arc.Mobile.csproj @@ -5,11 +5,9 @@ TKCode.Arc.Mobile TKCode.Arc.Mobile Touch-first Arc components for Avalonia (first slice: page, navbar, tabbar, button, switch) with press states and 44px touch targets. Previewed in a desktop phone frame; not yet verified on Android or iOS devices. - $(AssemblyName) - - TKCode.Arc.Mobile $(NoWarn);CS0436 + true diff --git a/src/TKCode.Arc.Special/Ai/AgentPlan/AgentPlanRow.cs b/src/TKCode.Arc.Special/Ai/AgentPlan/AgentPlanRow.cs index f4413821..191f2dd6 100644 --- a/src/TKCode.Arc.Special/Ai/AgentPlan/AgentPlanRow.cs +++ b/src/TKCode.Arc.Special/Ai/AgentPlan/AgentPlanRow.cs @@ -265,7 +265,7 @@ public void Leave(bool animate, Action done) } _left = done; _ = _fade.AnimateTo(0, AgentPlanMotion.Enter(AgentPlanMotion.RowExit)).ContinueWith( - _ => Avalonia.Threading.Dispatcher.UIThread.Post(InvalidateMeasure), TaskScheduler.Default); + _ => Dispatcher.Post(InvalidateMeasure), TaskScheduler.Default); InvalidateMeasure(); } diff --git a/src/TKCode.Arc.Special/Ai/AgentPlan/AgentPlanToken.cs b/src/TKCode.Arc.Special/Ai/AgentPlan/AgentPlanToken.cs index a2d79fc4..69403d65 100644 --- a/src/TKCode.Arc.Special/Ai/AgentPlan/AgentPlanToken.cs +++ b/src/TKCode.Arc.Special/Ai/AgentPlan/AgentPlanToken.cs @@ -1,4 +1,3 @@ -using AtomUI.Theme.DesignTokens; using Avalonia; namespace TKCode.Arc; diff --git a/src/TKCode.Arc.Special/Ai/AgentPlan/Themes/AgentPlanTheme.axaml b/src/TKCode.Arc.Special/Ai/AgentPlan/Themes/AgentPlanTheme.axaml index a77b86f6..4fe33184 100644 --- a/src/TKCode.Arc.Special/Ai/AgentPlan/Themes/AgentPlanTheme.axaml +++ b/src/TKCode.Arc.Special/Ai/AgentPlan/Themes/AgentPlanTheme.axaml @@ -1,7 +1,8 @@ + x:ClassModifier="internal" + x:Class="TKCode.Arc.AgentPlanTheme"> diff --git a/src/TKCode.Arc.Special/Ai/AgentStatus/AgentStatusToken.cs b/src/TKCode.Arc.Special/Ai/AgentStatus/AgentStatusToken.cs index 50a971ce..5ba5e6b4 100644 --- a/src/TKCode.Arc.Special/Ai/AgentStatus/AgentStatusToken.cs +++ b/src/TKCode.Arc.Special/Ai/AgentStatus/AgentStatusToken.cs @@ -1,4 +1,3 @@ -using AtomUI.Theme.DesignTokens; using Avalonia; using Avalonia.Media; diff --git a/src/TKCode.Arc.Special/Ai/AgentStatus/Themes/AgentStatusTheme.axaml b/src/TKCode.Arc.Special/Ai/AgentStatus/Themes/AgentStatusTheme.axaml index 2c7421cb..639de61c 100644 --- a/src/TKCode.Arc.Special/Ai/AgentStatus/Themes/AgentStatusTheme.axaml +++ b/src/TKCode.Arc.Special/Ai/AgentStatus/Themes/AgentStatusTheme.axaml @@ -1,7 +1,8 @@ + x:ClassModifier="internal" + x:Class="TKCode.Arc.AgentStatusTheme"> diff --git a/src/TKCode.Arc.Special/Ai/AgentTimeline/AgentTimelineToken.cs b/src/TKCode.Arc.Special/Ai/AgentTimeline/AgentTimelineToken.cs index 2f8849ff..38dd88a8 100644 --- a/src/TKCode.Arc.Special/Ai/AgentTimeline/AgentTimelineToken.cs +++ b/src/TKCode.Arc.Special/Ai/AgentTimeline/AgentTimelineToken.cs @@ -1,4 +1,3 @@ -using AtomUI.Theme.DesignTokens; using Avalonia; using Avalonia.Media; diff --git a/src/TKCode.Arc.Special/Ai/AgentTimeline/Themes/AgentTimelineTheme.axaml b/src/TKCode.Arc.Special/Ai/AgentTimeline/Themes/AgentTimelineTheme.axaml index 2700ff08..b12d9db7 100644 --- a/src/TKCode.Arc.Special/Ai/AgentTimeline/Themes/AgentTimelineTheme.axaml +++ b/src/TKCode.Arc.Special/Ai/AgentTimeline/Themes/AgentTimelineTheme.axaml @@ -1,7 +1,8 @@ + x:ClassModifier="internal" + x:Class="TKCode.Arc.AgentTimelineTheme"> diff --git a/src/TKCode.Arc.Special/Illustrations/MixtureOfExperts/MixtureOfExpertsToken.cs b/src/TKCode.Arc.Special/Illustrations/MixtureOfExperts/MixtureOfExpertsToken.cs index 3cb1e03d..463a6d0a 100644 --- a/src/TKCode.Arc.Special/Illustrations/MixtureOfExperts/MixtureOfExpertsToken.cs +++ b/src/TKCode.Arc.Special/Illustrations/MixtureOfExperts/MixtureOfExpertsToken.cs @@ -1,4 +1,3 @@ -using AtomUI.Theme.DesignTokens; using Avalonia; namespace TKCode.Arc; diff --git a/src/TKCode.Arc.Special/Illustrations/MixtureOfExperts/Themes/MixtureOfExpertsTheme.axaml b/src/TKCode.Arc.Special/Illustrations/MixtureOfExperts/Themes/MixtureOfExpertsTheme.axaml index 15be55be..3f39003c 100644 --- a/src/TKCode.Arc.Special/Illustrations/MixtureOfExperts/Themes/MixtureOfExpertsTheme.axaml +++ b/src/TKCode.Arc.Special/Illustrations/MixtureOfExperts/Themes/MixtureOfExpertsTheme.axaml @@ -1,7 +1,8 @@ + x:ClassModifier="internal" + x:Class="TKCode.Arc.MixtureOfExpertsTheme"> diff --git a/src/TKCode.Arc.Special/Illustrations/SpeculativeDecoding/SpeculativeDecodingToken.cs b/src/TKCode.Arc.Special/Illustrations/SpeculativeDecoding/SpeculativeDecodingToken.cs index 131a85dd..5866ed6c 100644 --- a/src/TKCode.Arc.Special/Illustrations/SpeculativeDecoding/SpeculativeDecodingToken.cs +++ b/src/TKCode.Arc.Special/Illustrations/SpeculativeDecoding/SpeculativeDecodingToken.cs @@ -1,4 +1,3 @@ -using AtomUI.Theme.DesignTokens; using Avalonia; using Avalonia.Media; diff --git a/src/TKCode.Arc.Special/Illustrations/SpeculativeDecoding/Themes/SpeculativeDecodingTheme.axaml b/src/TKCode.Arc.Special/Illustrations/SpeculativeDecoding/Themes/SpeculativeDecodingTheme.axaml index 18c0df42..6e9127f1 100644 --- a/src/TKCode.Arc.Special/Illustrations/SpeculativeDecoding/Themes/SpeculativeDecodingTheme.axaml +++ b/src/TKCode.Arc.Special/Illustrations/SpeculativeDecoding/Themes/SpeculativeDecodingTheme.axaml @@ -1,7 +1,8 @@ + x:ClassModifier="internal" + x:Class="TKCode.Arc.SpeculativeDecodingTheme"> diff --git a/src/TKCode.Arc.Special/SpecialBuilderExtensions.cs b/src/TKCode.Arc.Special/SpecialBuilderExtensions.cs index 2cade87a..187705c8 100644 --- a/src/TKCode.Arc.Special/SpecialBuilderExtensions.cs +++ b/src/TKCode.Arc.Special/SpecialBuilderExtensions.cs @@ -1,5 +1,4 @@ -using AtomUI; -using AtomUI.Generated.TKCodeArcSpecial; +using TKCode.Arc.Generated.TKCodeArcSpecial; namespace TKCode.Arc; @@ -7,11 +6,9 @@ public static class ArcSpecialBuilderExtensions { /// /// Enables the Arc special components (the animated AI explainers such as AgentLoop). Depends on - /// . + /// . /// - public static IAtomUIBuilder UseArcSpecial(this IAtomUIBuilder builder) - => GeneratedControlPackageRegistration.Definition.Register(builder, static package => - { - package.DependsOn(static dependency => dependency.UseArc()); - }); + public static ArcBuilder UseArcSpecial(this ArcBuilder builder) + => builder.UsePackage(ArcGeneratedAssets.PackageId, static package => ArcGeneratedAssets.AddTo(package + .DependsOn(static b => b.UseArcControls()))); } diff --git a/src/TKCode.Arc.Special/TKCode.Arc.Special.csproj b/src/TKCode.Arc.Special/TKCode.Arc.Special.csproj index 7a053997..43aef627 100644 --- a/src/TKCode.Arc.Special/TKCode.Arc.Special.csproj +++ b/src/TKCode.Arc.Special/TKCode.Arc.Special.csproj @@ -5,13 +5,11 @@ TKCode.Arc TKCode.Arc.Special Special components for TKCode.Arc, the uiarc.dev "Special components" section: animated AI explainers (prompt cache, reasoning tree, speculative decoding, mixture of experts, agent loop) and pointer effects (dot grid, gravity well, ink fluid). - $(AssemblyName) - TKCode.Arc.Special $(NoWarn);CS0436 + true - diff --git a/src/TKCode.Arc.Themes.Corners/ArcCornerPresets.cs b/src/TKCode.Arc.Themes.Corners/ArcCornerPresets.cs new file mode 100644 index 00000000..17648a23 --- /dev/null +++ b/src/TKCode.Arc.Themes.Corners/ArcCornerPresets.cs @@ -0,0 +1,71 @@ +using System.Globalization; + +namespace TKCode.Arc.Themes.Corners; + +/// +/// Corner styles as theme presets (design/architecture/theming/corner-style.md). A corner style writes the final value of +/// every radius token of ArcDesign and of the control packages, computed from the defaults in ; +/// nothing else changes. Overrides of packages the app did not enable are skipped, so there is no scope parameter. +/// +public static class ArcCornerPresets +{ + /// The slot of corner styles: a state holds one corner style, the last one added. + public const string Slot = "ArcCorners"; + + /// Every corner square, pills and circles included. + public static ArcThemePreset Square { get; } = Create(ArcCornerStyle.Square); + + /// Every radius halved; pills and circles stay round (the Gallery's "half" corners). + public static ArcThemePreset Half { get; } = Create(ArcCornerStyle.Scale(0.5)); + + /// + /// The preset of , named ArcCornersDefault, ArcCornersSquare or + /// ArcCornersScale{factor}. The default style gives an empty preset: adding it removes a previous corner style. + /// + /// The corner style. + public static ArcThemePreset Create(ArcCornerStyle style) + => ArcThemePreset.Create(NameOf(style), preset => AddTo(preset, style), Slot); + + /// Makes and discoverable (called by the package's UseArcCorners()). + /// The package being defined. + internal static void Register(ArcPackageBuilder package) => package.AddPresets([Square, Half]); + + private static string NameOf(ArcCornerStyle style) + { + if (style.IsDefault) + { + return "ArcCornersDefault"; + } + if (style.IsSquare) + { + return "ArcCornersSquare"; + } + return "ArcCornersScale" + style.Factor.ToString("0.###", CultureInfo.InvariantCulture); + } + + private static void AddTo(ArcThemePresetBuilder preset, ArcCornerStyle style) + { + if (style.IsDefault) + { + return; + } + foreach (var entry in CornerTable.ArcDesign) + { + preset.Token(entry.Token, CornerValues.Value(entry, style)); + } + // The scroll bar thumb entries name AtomUI's ScrollViewer identity. Arc's base theme draws scroll bars with fixed + // sizes and registers no such identity, so presets skip them; they stay so the preset matches + // tests/Baselines/presets/corners-*.json (ThemePresetPortTests). + foreach (var table in new[] + { + CornerTable.Controls, AtomUiCornerTable.ScrollViewer, CornerTable.Blocks, CornerTable.Special, + CornerTable.AdvancedCharts, CornerTable.Workflow + }) + { + foreach (var entry in table) + { + preset.Token(entry.Control, entry.Token, CornerValues.Value(entry, style)); + } + } + } +} diff --git a/src/TKCode.Arc.Themes.Corners/ArcCornerScope.cs b/src/TKCode.Arc.Themes.Corners/ArcCornerScope.cs deleted file mode 100644 index a131c635..00000000 --- a/src/TKCode.Arc.Themes.Corners/ArcCornerScope.cs +++ /dev/null @@ -1,30 +0,0 @@ -namespace TKCode.Arc.Themes.Corners; - -/// -/// Which Arc control packages the app registers; the corner style overrides the tokens of exactly these. Naming a -/// package the app has not registered fails the whole theme change. -/// -[Flags] -public enum ArcCornerScope -{ - /// Only the ArcDesign tokens. - None = 0, - - /// The TKCode.Arc package (UseArc()). - Controls = 1, - - /// The TKCode.Arc.Blocks package. - Blocks = 2, - - /// The TKCode.Arc.Special package. - Special = 4, - - /// The TKCode.Arc.AdvancedCharts package. - AdvancedCharts = 8, - - /// The TKCode.Arc.Workflow package. - Workflow = 16, - - /// Every Arc package. - All = Controls | Blocks | Special | AdvancedCharts | Workflow -} diff --git a/src/TKCode.Arc.Themes.Corners/ArcCornersBuilderExtensions.cs b/src/TKCode.Arc.Themes.Corners/ArcCornersBuilderExtensions.cs new file mode 100644 index 00000000..2eddd6f1 --- /dev/null +++ b/src/TKCode.Arc.Themes.Corners/ArcCornersBuilderExtensions.cs @@ -0,0 +1,24 @@ +namespace TKCode.Arc.Themes.Corners; + +public static class ArcCornersBuilderExtensions +{ + /// + /// Enables the corner style package: registers and + /// so they can be listed (ArcRuntime.Presets). Registering does not apply them; + /// apply a style with ArcTheme.Apply(t => t.Corners(ArcCornerStyle.Square)). + /// + public static ArcBuilder UseArcCorners(this ArcBuilder builder) + => builder.UsePackage("TKCode.Arc.Themes.Corners", static package => + { + package.DependsOn(static b => b.UseArcCore()); + ArcCornerPresets.Register(package); + }); + + /// + /// Applies a corner style (t.Preset(ArcCornerPresets.Create(style))). It fills the + /// slot, so it replaces the previous corner style; + /// removes it. + /// + public static ArcThemeBuilder Corners(this ArcThemeBuilder theme, ArcCornerStyle style) + => theme.Preset(ArcCornerPresets.Create(style)); +} diff --git a/src/TKCode.Arc.Themes.Corners/ArcCornersExtensions.cs b/src/TKCode.Arc.Themes.Corners/ArcCornersExtensions.cs deleted file mode 100644 index 45c6c536..00000000 --- a/src/TKCode.Arc.Themes.Corners/ArcCornersExtensions.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System.Globalization; -using AtomUI.Theme.Configuration; - -namespace TKCode.Arc.Themes.Corners; - -/// -/// Global corner style (design/architecture/theming/corner-style.md). It writes the final value of every radius token of -/// ArcDesign (the four tiers and the radius steps) and of the control packages in the scope, and nothing else. -/// -public static class ArcCornersExtensions -{ - /// - /// Applies a corner style to a . It uses WithArc, so a second WithArc or - /// WithArcCorners call replaces what this one wrote; to combine it with an accent or other overrides use - /// inside a single WithArc call. - /// - /// The builder. - /// The corner style; writes nothing. - /// The control packages the app registers. - public static ThemeConfigBuilder WithArcCorners(this ThemeConfigBuilder builder, ArcCornerStyle style, - ArcCornerScope scope = ArcCornerScope.Controls) - => builder.WithArc(arc => arc.Corners(style, scope)); - - /// Applies a corner style to the overrides being collected by WithArc. - public static ArcThemeConfigBuilder Corners(this ArcThemeConfigBuilder arc, ArcCornerStyle style, - ArcCornerScope scope = ArcCornerScope.Controls) - { - if (style.IsDefault) - { - return arc; - } - - foreach (var entry in CornerTable.ArcDesign) - { - arc.Token(entry.Token, Value(entry, style)); - } - Add(arc, scope, ArcCornerScope.Controls, CornerTable.Controls, "TKCode.Arc", style); - // AtomUI's scroll bar thumb sits in every Arc popup and scrolling surface, so it follows the main package. - Add(arc, scope, ArcCornerScope.Controls, AtomUiCornerTable.ScrollViewer, AtomUiCornerTable.Catalog, style); - Add(arc, scope, ArcCornerScope.Blocks, CornerTable.Blocks, "TKCode.Arc.Blocks", style); - Add(arc, scope, ArcCornerScope.Special, CornerTable.Special, "TKCode.Arc.Special", style); - Add(arc, scope, ArcCornerScope.AdvancedCharts, CornerTable.AdvancedCharts, "TKCode.Arc.AdvancedCharts", style); - Add(arc, scope, ArcCornerScope.Workflow, CornerTable.Workflow, "TKCode.Arc.Workflow", style); - return arc; - } - - private static void Add(ArcThemeConfigBuilder arc, ArcCornerScope scope, ArcCornerScope flag, CornerEntry[] entries, - string catalog, ArcCornerStyle style) - { - if (!scope.HasFlag(flag)) - { - return; - } - foreach (var entry in entries) - { - arc.ControlToken(catalog, entry.Control, entry.Token, Value(entry, style)); - } - } - - /// Radii from this value up are pills or circles: they stay as they are unless the style is square. - private const double PillThreshold = 999; - - private static string Value(CornerEntry entry, ArcCornerStyle style) - { - if (style.IsSquare) - { - return "0"; - } - - // "5", or "19,19,0,0" for a radius with its own corners. Pill radii are not scaled. - var parts = entry.Default.Split(','); - for (var i = 0; i < parts.Length; i++) - { - var value = double.Parse(parts[i], CultureInfo.InvariantCulture); - if (value < PillThreshold) - { - value = Math.Round(value * style.Factor, 3); - } - parts[i] = value.ToString("0.###", CultureInfo.InvariantCulture); - } - return string.Join(',', parts); - } -} diff --git a/src/TKCode.Arc.Themes.Corners/CornerValues.cs b/src/TKCode.Arc.Themes.Corners/CornerValues.cs new file mode 100644 index 00000000..1ba50e11 --- /dev/null +++ b/src/TKCode.Arc.Themes.Corners/CornerValues.cs @@ -0,0 +1,35 @@ +using System.Globalization; + +namespace TKCode.Arc.Themes.Corners; + +/// +/// The value a corner style writes for one radius token, used by the corner presets (ArcCornerPresets). Free of +/// Foundation types so tests can compile it on its own. +/// +internal static class CornerValues +{ + /// Radii from this value up are pills or circles: they stay as they are unless the style is square. + internal const double PillThreshold = 999; + + /// The override text of under (not called for the default style). + internal static string Value(CornerEntry entry, ArcCornerStyle style) + { + if (style.IsSquare) + { + return "0"; + } + + // "5", or "19,19,0,0" for a radius with its own corners. Pill radii are not scaled. + var parts = entry.Default.Split(','); + for (var i = 0; i < parts.Length; i++) + { + var value = double.Parse(parts[i], CultureInfo.InvariantCulture); + if (value < PillThreshold) + { + value = Math.Round(value * style.Factor, 3); + } + parts[i] = value.ToString("0.###", CultureInfo.InvariantCulture); + } + return string.Join(',', parts); + } +} diff --git a/src/TKCode.Arc.Themes.Pixel/ArcPixelBuilderExtensions.cs b/src/TKCode.Arc.Themes.Pixel/ArcPixelBuilderExtensions.cs index 87ad1976..beeb9fda 100644 --- a/src/TKCode.Arc.Themes.Pixel/ArcPixelBuilderExtensions.cs +++ b/src/TKCode.Arc.Themes.Pixel/ArcPixelBuilderExtensions.cs @@ -1,18 +1,20 @@ -using AtomUI; -using AtomUI.Generated.TKCodeArcThemesPixel; - namespace TKCode.Arc.Themes.Pixel; public static class ArcPixelBuilderExtensions { /// - /// Registers the compiled themes ArcPixel (light) and ArcPixelDark (dark). They are never the default; - /// select one with builder.Theme.WithInitialTheme(ArcPixelTheme.LightThemeId) or an - /// IThemeManager.ApplyThemeAsync request. Call it after UseArc() or UseArcCore(). + /// Enables the Pixel style package: registers so it can be listed + /// (ArcRuntime.Presets). Registering does not apply it; apply it with + /// ArcTheme.Apply(t => t.Pixel()) and go back with t.RemovePreset(ArcPixelPresets.Slot). The preset + /// carries a light and a dark palette; the theme's appearance picks one. /// - public static IAtomUIBuilder UseArcPixelTheme(this IAtomUIBuilder builder) - { - GeneratedThemePackageRegistration.Register(builder.Theme); - return builder; - } + public static ArcBuilder UseArcPixelTheme(this ArcBuilder builder) + => builder.UsePackage("TKCode.Arc.Themes.Pixel", static package => + { + package.DependsOn(static b => b.UseArcCore()); + ArcPixelPresets.Register(package); + }); + + /// Applies the Pixel style (t.Preset(ArcPixelPresets.Pixel)). + public static ArcThemeBuilder Pixel(this ArcThemeBuilder theme) => theme.Preset(ArcPixelPresets.Pixel); } diff --git a/src/TKCode.Arc.Themes.Pixel/ArcPixelPresets.cs b/src/TKCode.Arc.Themes.Pixel/ArcPixelPresets.cs new file mode 100644 index 00000000..fe858a1c --- /dev/null +++ b/src/TKCode.Arc.Themes.Pixel/ArcPixelPresets.cs @@ -0,0 +1,261 @@ +using Avalonia.Media; +using TKCode.Arc.Themes.Corners; + +namespace TKCode.Arc.Themes.Pixel; + +/// +/// The Arc pixel style as a theme preset (design/architecture/theming/pixel-theme.md): square corners, hard zero-blur +/// shadows, a 16-colour palette for light and one for dark, pixel fonts, 2px edges. Token overrides only; motion is left as +/// the app configures it. +/// +public static class ArcPixelPresets +{ + /// The preset name. + public const string Name = "ArcPixel"; + + /// + /// The slot of style presets: a state holds one style. Corner styles have their own slot + /// (), so a corner style added after Pixel changes Pixel's (square) corners. + /// + public const string Slot = "ArcStyle"; + + /// Pixel body and mono font stack (register the fonts with WithArcPixelFonts()). + public const string PixelFontStack = "fonts:ArcPixel#VT323, Menlo, Cascadia Mono, Consolas, DejaVu Sans Mono, Courier New, $Default"; + + /// Pixel display font stack: Silkscreen for headings, then . + public const string PixelDisplayFontStack = "fonts:ArcPixel#Silkscreen, " + PixelFontStack; + + /// + /// The pixel style, light and dark in one preset (the appearance of the state picks the palette). Overrides of packages + /// the app did not enable are skipped, so there is no scope parameter. + /// + public static ArcThemePreset Pixel => s_pixel.Value; + + // Lazy: the tables below are static fields initialized after this one. + private static readonly Lazy s_pixel = new(() => ArcThemePreset.Create(Name, Define, Slot)); + + /// Makes the preset discoverable through ArcRuntime.Presets (called by the package's UseArcPixelTheme()). + /// The package being defined. + internal static void Register(ArcPackageBuilder package) => package.AddPresets([Pixel]); + + private static void Define(ArcThemePresetBuilder preset) + { + // Order: shared, appearance palette, square corners, derived control colors (as the 0.3 pixel theme applied them). + Add(preset, Shared); + preset.ForLight(light => Add(light, Light)); + preset.ForDark(dark => Add(dark, Dark)); + preset.Include(ArcCornerPresets.Square); + preset.ForLight(light => AddDerived(light, LightRoles)); + preset.ForDark(dark => AddDerived(dark, DarkRoles)); + } + + private static void Add(ArcThemePresetBuilder preset, (string, string)[] tokens) + { + foreach (var (name, value) in tokens) + { + preset.Token(name, value); + } + } + + private static void AddDerived(ArcThemePresetBuilder preset, Roles roles) + { + foreach (var (identity, tokens) in DerivedColors(roles)) + { + foreach (var (name, value) in tokens) + { + preset.Token(identity, name, value); + } + } + } + + // Palette: Sweetie-16 style, ink #1A1C2C, paper #F4F4F4, grays #94B0C2 #566C86 #333C57. + private static readonly (string, string)[] Shared = + [ + ("DisplayFontFamily", PixelDisplayFontStack), + ("BodyFontFamily", PixelFontStack), + ("MonoFontFamily", PixelFontStack), + ("WeightMedium", "Normal"), + ("TrackingDisplay", "0"), + ("TrackingBody", "0"), + ("EdgeThickness", "2"), + ("FocusEdgeThickness", "3"), + ("EdgeThicknessTop", "0,2,0,0"), + ("EdgeThicknessBottom", "0,0,0,2"), + ("EdgeThicknessLeft", "2,0,0,0"), + ("EdgeThicknessRight", "0,0,2,0"), + ("EdgeThicknessRightBottom", "0,0,2,2"), + ("EdgeThicknessNoBottom", "2,2,2,0"), + ("EdgeThicknessNoRight", "2,2,0,2"), + ("EdgeThicknessNoLeft", "0,2,2,2"), + ("FocusRingWidth", "2"), + ("FocusRingOffset", "2"), + ("IconStrokeWidth", "2"), + ("BlurSubtle", "0"), + ("BlurSoft", "0"), + ("BlurText", "0"), + ("BackdropBlurDialog", "0"), + ("BackdropBlurDrawer", "0"), + ("ReduceTransparency", "True"), + ]; + private static readonly (string, string)[] Light = + [ + ("Background", "#F4F4F4"), + ("Surface", "#FFFFFF"), + ("SurfaceRaised", "#FFFFFF"), + ("SurfaceMuted", "#DCE3E8"), + ("Scrim", "#991A1C2C"), + ("Foreground", "#1A1C2C"), + ("TextSecondary", "#333C57"), + ("TextMuted", "#566C86"), + ("Border", "#1A1C2C"), + ("BorderSubtle", "#94B0C2"), + ("BorderStrong", "#1A1C2C"), + ("Accent", "#3B5DC9"), + ("AccentStrong", "#29366F"), + ("AccentSubtle", "#D3DEF8"), + ("AccentForeground", "#F4F4F4"), + ("ControlOn", "#3B5DC9"), + ("ControlOnSubtle", "#1F3B5DC9"), + ("ControlGlyph", "#F4F4F4"), + ("ControlTrack", "#94B0C2"), + ("ControlTrackHover", "#566C86"), + ("ControlThumb", "#F4F4F4"), + ("ControlThumbOn", "#F4F4F4"), + ("ControlFill", "#3B5DC9"), + ("Success", "#257179"), + ("Warning", "#B8601F"), + ("Danger", "#B13E53"), + ("SuccessSubtle", "#CDEBDC"), + ("WarningSubtle", "#FFE6B8"), + ("DangerSubtle", "#F3CFD6"), + ("Series1", "#3B5DC9"), + ("Series2", "#38B764"), + ("Series3", "#EF7D57"), + ("Series4", "#B13E53"), + ("FocusRing", "#EF7D57"), + ("ControlThumbShadow", "0 0 0 1 #1A1C2C"), + ("ShadowResting", "2 2 0 0 #1A1C2C"), + ("ShadowRaised", "4 4 0 0 #1A1C2C"), + ("ShadowFloating", "6 6 0 0 #1A1C2C"), + ]; + private static readonly (string, string)[] Dark = + [ + ("Background", "#1A1C2C"), + ("Surface", "#262B44"), + ("SurfaceRaised", "#333C57"), + ("SurfaceMuted", "#29315A"), + ("Scrim", "#CC000000"), + ("Foreground", "#F4F4F4"), + ("TextSecondary", "#C5D3DD"), + ("TextMuted", "#94B0C2"), + ("Border", "#94B0C2"), + ("BorderSubtle", "#566C86"), + ("BorderStrong", "#F4F4F4"), + ("Accent", "#73EFF7"), + ("AccentStrong", "#41A6F6"), + ("AccentSubtle", "#1F4A6B"), + ("AccentForeground", "#1A1C2C"), + ("ControlOn", "#73EFF7"), + ("ControlOnSubtle", "#2673EFF7"), + ("ControlGlyph", "#1A1C2C"), + ("ControlTrack", "#566C86"), + ("ControlTrackHover", "#94B0C2"), + ("ControlThumb", "#F4F4F4"), + ("ControlThumbOn", "#1A1C2C"), + ("ControlFill", "#73EFF7"), + ("Success", "#A7F070"), + ("Warning", "#FFCD75"), + ("Danger", "#EF7D57"), + ("SuccessSubtle", "#27503A"), + ("WarningSubtle", "#5A4A2A"), + ("DangerSubtle", "#5A2E3A"), + ("Series1", "#41A6F6"), + ("Series2", "#A7F070"), + ("Series3", "#FFCD75"), + ("Series4", "#EF7D57"), + ("FocusRing", "#FFCD75"), + ("ControlThumbShadow", "0 0 0 1 #000000"), + ("ShadowResting", "2 2 0 0 #000000"), + ("ShadowRaised", "4 4 0 0 #000000"), + ("ShadowFloating", "6 6 0 0 #000000"), + ]; + + // Derived colors of control tokens do not follow the ArcDesign overrides (they mix the fixed Arc neutrals), so the most + // visible controls get hand-mixed solid values here. The mixes use the pixel palette; nothing is translucent but scrims. + private sealed record Roles(Color Fg, Color Bg, Color Surface, Color Raised, Color Muted, Color Border, Color BorderSubtle, + Color TextMuted, Color Accent, Color Danger, Color Success, Color Warning, Color Scrim); + + private static readonly Roles LightRoles = new( + C("#1A1C2C"), C("#F4F4F4"), C("#FFFFFF"), C("#FFFFFF"), C("#DCE3E8"), C("#1A1C2C"), C("#94B0C2"), + C("#566C86"), C("#3B5DC9"), C("#B13E53"), C("#257179"), C("#B8601F"), C("#991A1C2C")); + + private static readonly Roles DarkRoles = new( + C("#F4F4F4"), C("#1A1C2C"), C("#262B44"), C("#333C57"), C("#29315A"), C("#94B0C2"), C("#566C86"), + C("#94B0C2"), C("#73EFF7"), C("#EF7D57"), C("#A7F070"), C("#FFCD75"), C("#CC000000")); + + private static Color C(string value) => Color.Parse(value); + + /// Solid mix: of over (sRGB, opaque). + private static Color Mix(Color a, Color b, double amount) + => Color.FromRgb(Lerp(a.R, b.R, amount), Lerp(a.G, b.G, amount), Lerp(a.B, b.B, amount)); + + private static byte Lerp(byte a, byte b, double amount) => (byte)Math.Round(a + (b - a) * amount); + + private static string Hex(Color c) => $"#{c.A:X2}{c.R:X2}{c.G:X2}{c.B:X2}"; + + private static (string, string)[] T(params (string, Color)[] tokens) => tokens.Select(t => (t.Item1, Hex(t.Item2))).ToArray(); + + private static (string, (string, string)[])[] DerivedColors(Roles r) + { + var danger = Mix(r.Surface, r.Danger, 0.18); + var success = Mix(r.Surface, r.Success, 0.18); + var warning = Mix(r.Surface, r.Warning, 0.18); + var press = Mix(r.Surface, r.Fg, 0.16); + var solidHighlight = Mix(r.Fg, r.Bg, 0.20); + var solidPressed = Mix(r.Fg, r.Bg, 0.35); + var solidDivider = Mix(r.Fg, r.Bg, 0.30); + return + [ + ("Button", T(("DangerDisabledForeground", Mix(r.Muted, r.Danger, 0.55)), ("GhostRaisedHoverBackground", Mix(r.Raised, r.Fg, 0.10)))), + ("SplitButton", T(("PrimaryHoverColor", solidHighlight), ("PrimaryPressedColor", solidPressed), ("PrimaryDividerColor", solidDivider), + ("SecondaryPressedColor", Mix(r.Muted, r.Border, 0.30)))), + ("Badge", T(("SuccessBackground", success), ("SuccessBorder", r.Success), ("WarningBackground", warning), ("WarningBorder", r.Warning), + ("DangerBackground", danger), ("DangerBorder", r.Danger), ("NeutralHoverBackground", r.Muted), ("NeutralHoverBorder", r.Border), + ("SuccessHoverBackground", Mix(r.Surface, r.Success, 0.30)), ("SuccessHoverBorder", r.Success), + ("WarningHoverBackground", Mix(r.Surface, r.Warning, 0.30)), ("WarningHoverBorder", r.Warning), + ("DangerHoverBackground", Mix(r.Surface, r.Danger, 0.30)), ("DangerHoverBorder", r.Danger))), + ("BillingToggle", T(("BadgeBackground", Mix(r.Surface, r.Accent, 0.25)))), + ("BottomSheet", T(("ScrimColor", r.Scrim))), + ("Card", T(("ScrimColor", r.Scrim), ("CloseButtonBackground", r.Raised), ("CloseButtonBorder", r.Border))), + ("Dialog", T(("ScrimColor", r.Scrim))), + ("Drawer", T(("ScrimColor", r.Scrim))), + ("DialogSurface", T(("CloseButtonBackground", r.Raised))), + ("DrawerSurface", T(("CloseButtonBackground", r.Raised))), + ("ButtonGroup", T(("OutlinePressedHighlightColor", press), ("SolidDividerColor", solidDivider), ("SolidHighlightColor", solidHighlight), + ("SolidMutedInk", Mix(r.Fg, r.Bg, 0.70)), ("SolidPressedHighlightColor", solidPressed))), + ("ButtonGroupItem", T(("SolidDividerColor", solidDivider))), + ("ConfirmMorph", T(("TriggerHoverColor", Mix(r.Surface, r.Fg, 0.10)), ("TriggerPressColor", press), ("TriggerFocusColor", Mix(r.Surface, r.Fg, 0.12)), + ("AnswerHoverColor", Mix(r.Surface, r.Fg, 0.12)), ("AnswerPressColor", Mix(r.Surface, r.Fg, 0.20)), ("AnswerFocusColor", Mix(r.Surface, r.Fg, 0.14)), + ("ConfirmRestColor", danger), ("ConfirmHoverColor", Mix(r.Surface, r.Danger, 0.28)), ("ConfirmPressColor", Mix(r.Surface, r.Danger, 0.38)), + ("ConfirmFocusColor", Mix(r.Surface, r.Danger, 0.32)))), + ("Countdown", T(("CompleteBackground", success), ("CompleteBorder", r.Success))), + ("ExpandingButtonGroupItem", T(("DangerExpandedColor", danger), ("DoneColor", success))), + ("FloatingButtonGroup", T(("TrayColor", r.Muted), ("TrayEdgeColor", r.Border), ("HighlightColor", press), ("HighlightPressColor", Mix(r.Surface, r.Fg, 0.26)))), + ("FloatingButtonGroupSeparator", T(("RuleColor", r.Border))), + ("MetricCard", T(("UpFill", success), ("DownFill", danger))), + ("Skeleton", T(("BlockColor", Mix(r.Surface, r.Fg, 0.12)))), + ("Timeline", T(("MarkerRingColor", r.Border))), + ("Tooltip", T(("BubbleBorderColor", r.Border))), + ("Slider", T(("BubbleBorderColor", r.Border), ("TickColor", r.TextMuted))), + ("Toast", T(("SurfaceColor", r.Raised), ("IconFill", r.Muted), ("IconRing", r.Border))), + ("UsageMeter", T(("TrackColor", r.Muted), ("NearBadgeBackground", warning), ("OverBadgeBackground", danger))), + ("Stepper", T(("ErrorDisc", r.Danger), ("ErrorDiscHover", Mix(r.Danger, r.Fg, 0.20)), ("ErrorHalo", danger))), + ("FieldTextBox", T(("SelectionFill", Color.FromArgb(0x66, r.Accent.R, r.Accent.G, r.Accent.B)))), + ("MenuSurface", T(("AccountHighlight", r.Muted), ("DangerHighlight", danger))), + ("MenuEntry", T(("AccountSeparatorColor", r.BorderSubtle), ("PressedTint", press))), + ("MenuEntrySeparator", T(("AccountLineColor", r.BorderSubtle))), + ("DropdownMenu", T(("TriggerPressedBackground", press))), + ("UserMenu", T(("TriggerPressedBackground", press))), + ]; + } +} diff --git a/src/TKCode.Arc.Themes.Pixel/ArcPixelScope.cs b/src/TKCode.Arc.Themes.Pixel/ArcPixelScope.cs deleted file mode 100644 index 9634dda8..00000000 --- a/src/TKCode.Arc.Themes.Pixel/ArcPixelScope.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace TKCode.Arc.Themes.Pixel; - -/// Which Arc control packages the app registers; the pixel theme overrides the tokens of exactly these. -[Flags] -public enum ArcPixelScope -{ - /// Only the ArcDesign tokens. - None = 0, - - /// The TKCode.Arc package (UseArc()). - Controls = 1, - - /// The TKCode.Arc.Blocks package. - Blocks = 2, - - /// The TKCode.Arc.Special package. - Special = 4, - - /// The TKCode.Arc.AdvancedCharts package. - AdvancedCharts = 8, - - /// The TKCode.Arc.Workflow package. - Workflow = 16, - - /// Every Arc package. - All = Controls | Blocks | Special | AdvancedCharts | Workflow -} diff --git a/src/TKCode.Arc.Themes.Pixel/ArcPixelTheme.cs b/src/TKCode.Arc.Themes.Pixel/ArcPixelTheme.cs deleted file mode 100644 index 19fdb4e5..00000000 --- a/src/TKCode.Arc.Themes.Pixel/ArcPixelTheme.cs +++ /dev/null @@ -1,281 +0,0 @@ -using AtomUI.Theme; -using AtomUI.Theme.Algorithms; -using AtomUI.Theme.Configuration; -using AtomUI.Theme.Schema; -using Avalonia.Media; -using TKCode.Arc.DesignTokens; -using TKCode.Arc.Motion; -using TKCode.Arc.Themes.Corners; - -namespace TKCode.Arc.Themes.Pixel; - -/// -/// The Arc pixel theme: token overrides only (design/architecture/theming/pixel-theme.md). Square corners, hard -/// zero-blur shadows, a 16-colour hand-tuned palette, a pixel font stack (Silkscreen, VT323). Motion is left as the app configures it (the stock springs still run; stepped motion needs Core support). It writes final -/// values on the ArcDesign identity (and a few control identities) and never an Algorithm on them; the dark variant only -/// adds the global Dark algorithm, like every Arc dark config. -/// -public static class ArcPixelTheme -{ - /// Theme id used for . Starts with Arc; the default theme stays DaybreakBlue. - public const string LightThemeId = "ArcPixel"; - - /// Id of the compiled dark variant. - public const string DarkThemeId = "ArcPixelDark"; - - /// The catalog of Arc control token identities. - private const string Catalog = "TKCode.Arc"; - - /// - /// Pixel body and mono font stack: VT323 from TKCode.Arc.Fonts (register it with WithArcPixelFonts()), then - /// monospaced system fonts so the look degrades to blocky text when the collection is not registered. The stack ends - /// in the default font, which also covers CJK text the pixel fonts do not have. - /// - public const string PixelFontStack = "fonts:ArcPixel#VT323, Menlo, Cascadia Mono, Consolas, DejaVu Sans Mono, Courier New, $Default"; - - /// Pixel display font stack: Silkscreen (caps-style bitmap face) for headings, then . - public const string PixelDisplayFontStack = "fonts:ArcPixel#Silkscreen, " + PixelFontStack; - - /// Creates a theme config. selects the hand-tuned dark palette. - /// True for the dark palette. - /// The control packages the app registers; naming a package that is not registered fails the whole theme change. - public static ThemeConfig CreateConfig(bool dark, ArcPixelScope scope = ArcPixelScope.Controls) - { - var builder = new ThemeConfigBuilder(); - if (dark) - { - builder = builder.WithAlgorithms(ThemeAlgorithm.Default, ThemeAlgorithm.Dark); - } - return builder.WithArcPixel(dark, scope).Build(); - } - - /// Creates the request to hand to IThemeManager.ApplyThemeAsync. - public static ThemeRequest CreateRequest(bool dark, ThemeTransitionReason reason = ThemeTransitionReason.UserRequest, - ArcPixelScope scope = ArcPixelScope.Controls) - => new(dark ? DarkThemeId : LightThemeId, CreateConfig(dark, scope), reason); - - /// Adds the pixel overrides to an existing builder (the caller sets the algorithms). - public static ThemeConfigBuilder WithArcPixel(this ThemeConfigBuilder builder, bool dark, ArcPixelScope scope = ArcPixelScope.Controls) - { - var p = dark ? Dark : Light; - // Every override goes through one WithArc call: WithControl replaces the whole config of an identity, so the tokens - // of each control are merged by the accumulator. The corners are the shared Corners package (square in all - // packages of the scope); the compiled XML themes get the ArcDesign radii from the same table - // (tools/pixel/generate_pixel_themes.py). - return builder.WithArc(arc => - { - foreach (var (name, value) in Shared) - { - arc.Token(name, value); - } - foreach (var (name, value) in p) - { - arc.Token(name, value); - } - arc.Corners(ArcCornerStyle.Square, (ArcCornerScope)(int)scope); - if (scope.HasFlag(ArcPixelScope.Controls)) - { - foreach (var (id, tokens) in DerivedColors(dark ? DarkRoles : LightRoles)) - { - foreach (var (name, value) in tokens) - { - arc.ControlToken(Catalog, id, name, value); - } - } - } - }); - } - - // Palette: Sweetie-16 style, ink #1A1C2C, paper #F4F4F4, grays #94B0C2 #566C86 #333C57. - private static readonly (string, string)[] Shared = - [ - (nameof(ArcDesignTokenKind.DisplayFontFamily), PixelDisplayFontStack), - (nameof(ArcDesignTokenKind.BodyFontFamily), PixelFontStack), - (nameof(ArcDesignTokenKind.MonoFontFamily), PixelFontStack), - (nameof(ArcDesignTokenKind.WeightMedium), "Normal"), - (nameof(ArcDesignTokenKind.TrackingDisplay), "0"), - (nameof(ArcDesignTokenKind.TrackingBody), "0"), - (nameof(ArcDesignTokenKind.EdgeThickness), "2"), - (nameof(ArcDesignTokenKind.FocusEdgeThickness), "3"), - (nameof(ArcDesignTokenKind.EdgeThicknessTop), "0,2,0,0"), - (nameof(ArcDesignTokenKind.EdgeThicknessBottom), "0,0,0,2"), - (nameof(ArcDesignTokenKind.EdgeThicknessLeft), "2,0,0,0"), - (nameof(ArcDesignTokenKind.EdgeThicknessRight), "0,0,2,0"), - (nameof(ArcDesignTokenKind.EdgeThicknessRightBottom), "0,0,2,2"), - (nameof(ArcDesignTokenKind.EdgeThicknessNoBottom), "2,2,2,0"), - (nameof(ArcDesignTokenKind.EdgeThicknessNoRight), "2,2,0,2"), - (nameof(ArcDesignTokenKind.EdgeThicknessNoLeft), "0,2,2,2"), - (nameof(ArcDesignTokenKind.FocusRingWidth), "2"), - (nameof(ArcDesignTokenKind.FocusRingOffset), "2"), - (nameof(ArcDesignTokenKind.IconStrokeWidth), "2"), - (nameof(ArcDesignTokenKind.BlurSubtle), "0"), - (nameof(ArcDesignTokenKind.BlurSoft), "0"), - (nameof(ArcDesignTokenKind.BlurText), "0"), - (nameof(ArcDesignTokenKind.BackdropBlurDialog), "0"), - (nameof(ArcDesignTokenKind.BackdropBlurDrawer), "0"), - (nameof(ArcDesignTokenKind.ReduceTransparency), "True"), - ]; - - private static readonly (string, string)[] Light = - [ - (nameof(ArcDesignTokenKind.Background), "#F4F4F4"), - (nameof(ArcDesignTokenKind.Surface), "#FFFFFF"), - (nameof(ArcDesignTokenKind.SurfaceRaised), "#FFFFFF"), - (nameof(ArcDesignTokenKind.SurfaceMuted), "#DCE3E8"), - (nameof(ArcDesignTokenKind.Scrim), "#991A1C2C"), - (nameof(ArcDesignTokenKind.Foreground), "#1A1C2C"), - (nameof(ArcDesignTokenKind.TextSecondary), "#333C57"), - (nameof(ArcDesignTokenKind.TextMuted), "#566C86"), - (nameof(ArcDesignTokenKind.Border), "#1A1C2C"), - (nameof(ArcDesignTokenKind.BorderSubtle), "#94B0C2"), - (nameof(ArcDesignTokenKind.BorderStrong), "#1A1C2C"), - (nameof(ArcDesignTokenKind.Accent), "#3B5DC9"), - (nameof(ArcDesignTokenKind.AccentStrong), "#29366F"), - (nameof(ArcDesignTokenKind.AccentSubtle), "#D3DEF8"), - (nameof(ArcDesignTokenKind.AccentForeground), "#F4F4F4"), - (nameof(ArcDesignTokenKind.ControlOn), "#3B5DC9"), - (nameof(ArcDesignTokenKind.ControlOnSubtle), "#1F3B5DC9"), - (nameof(ArcDesignTokenKind.ControlGlyph), "#F4F4F4"), - (nameof(ArcDesignTokenKind.ControlTrack), "#94B0C2"), - (nameof(ArcDesignTokenKind.ControlTrackHover), "#566C86"), - (nameof(ArcDesignTokenKind.ControlThumb), "#F4F4F4"), - (nameof(ArcDesignTokenKind.ControlThumbOn), "#F4F4F4"), - (nameof(ArcDesignTokenKind.ControlFill), "#3B5DC9"), - (nameof(ArcDesignTokenKind.Success), "#257179"), - (nameof(ArcDesignTokenKind.Warning), "#B8601F"), - (nameof(ArcDesignTokenKind.Danger), "#B13E53"), - (nameof(ArcDesignTokenKind.SuccessSubtle), "#CDEBDC"), - (nameof(ArcDesignTokenKind.WarningSubtle), "#FFE6B8"), - (nameof(ArcDesignTokenKind.DangerSubtle), "#F3CFD6"), - (nameof(ArcDesignTokenKind.Series1), "#3B5DC9"), - (nameof(ArcDesignTokenKind.Series2), "#38B764"), - (nameof(ArcDesignTokenKind.Series3), "#EF7D57"), - (nameof(ArcDesignTokenKind.Series4), "#B13E53"), - (nameof(ArcDesignTokenKind.FocusRing), "#EF7D57"), - (nameof(ArcDesignTokenKind.ControlThumbShadow), "0 0 0 1 #1A1C2C"), - (nameof(ArcDesignTokenKind.ShadowResting), "2 2 0 0 #1A1C2C"), - (nameof(ArcDesignTokenKind.ShadowRaised), "4 4 0 0 #1A1C2C"), - (nameof(ArcDesignTokenKind.ShadowFloating), "6 6 0 0 #1A1C2C"), - ]; - - private static readonly (string, string)[] Dark = - [ - (nameof(ArcDesignTokenKind.Background), "#1A1C2C"), - (nameof(ArcDesignTokenKind.Surface), "#262B44"), - (nameof(ArcDesignTokenKind.SurfaceRaised), "#333C57"), - (nameof(ArcDesignTokenKind.SurfaceMuted), "#29315A"), - (nameof(ArcDesignTokenKind.Scrim), "#CC000000"), - (nameof(ArcDesignTokenKind.Foreground), "#F4F4F4"), - (nameof(ArcDesignTokenKind.TextSecondary), "#C5D3DD"), - (nameof(ArcDesignTokenKind.TextMuted), "#94B0C2"), - (nameof(ArcDesignTokenKind.Border), "#94B0C2"), - (nameof(ArcDesignTokenKind.BorderSubtle), "#566C86"), - (nameof(ArcDesignTokenKind.BorderStrong), "#F4F4F4"), - (nameof(ArcDesignTokenKind.Accent), "#73EFF7"), - (nameof(ArcDesignTokenKind.AccentStrong), "#41A6F6"), - (nameof(ArcDesignTokenKind.AccentSubtle), "#1F4A6B"), - (nameof(ArcDesignTokenKind.AccentForeground), "#1A1C2C"), - (nameof(ArcDesignTokenKind.ControlOn), "#73EFF7"), - (nameof(ArcDesignTokenKind.ControlOnSubtle), "#2673EFF7"), - (nameof(ArcDesignTokenKind.ControlGlyph), "#1A1C2C"), - (nameof(ArcDesignTokenKind.ControlTrack), "#566C86"), - (nameof(ArcDesignTokenKind.ControlTrackHover), "#94B0C2"), - (nameof(ArcDesignTokenKind.ControlThumb), "#F4F4F4"), - (nameof(ArcDesignTokenKind.ControlThumbOn), "#1A1C2C"), - (nameof(ArcDesignTokenKind.ControlFill), "#73EFF7"), - (nameof(ArcDesignTokenKind.Success), "#A7F070"), - (nameof(ArcDesignTokenKind.Warning), "#FFCD75"), - (nameof(ArcDesignTokenKind.Danger), "#EF7D57"), - (nameof(ArcDesignTokenKind.SuccessSubtle), "#27503A"), - (nameof(ArcDesignTokenKind.WarningSubtle), "#5A4A2A"), - (nameof(ArcDesignTokenKind.DangerSubtle), "#5A2E3A"), - (nameof(ArcDesignTokenKind.Series1), "#41A6F6"), - (nameof(ArcDesignTokenKind.Series2), "#A7F070"), - (nameof(ArcDesignTokenKind.Series3), "#FFCD75"), - (nameof(ArcDesignTokenKind.Series4), "#EF7D57"), - (nameof(ArcDesignTokenKind.FocusRing), "#FFCD75"), - (nameof(ArcDesignTokenKind.ControlThumbShadow), "0 0 0 1 #000000"), - (nameof(ArcDesignTokenKind.ShadowResting), "2 2 0 0 #000000"), - (nameof(ArcDesignTokenKind.ShadowRaised), "4 4 0 0 #000000"), - (nameof(ArcDesignTokenKind.ShadowFloating), "6 6 0 0 #000000"), - ]; - - // Derived colors of control tokens do not follow the ArcDesign overrides (they mix the fixed Arc neutrals), so the most - // visible controls get hand-mixed solid values here. The mixes use the pixel palette; nothing is translucent but scrims. - private sealed record Roles(Color Fg, Color Bg, Color Surface, Color Raised, Color Muted, Color Border, Color BorderSubtle, - Color TextMuted, Color Accent, Color Danger, Color Success, Color Warning, Color Scrim); - - private static readonly Roles LightRoles = new( - C("#1A1C2C"), C("#F4F4F4"), C("#FFFFFF"), C("#FFFFFF"), C("#DCE3E8"), C("#1A1C2C"), C("#94B0C2"), - C("#566C86"), C("#3B5DC9"), C("#B13E53"), C("#257179"), C("#B8601F"), C("#991A1C2C")); - - private static readonly Roles DarkRoles = new( - C("#F4F4F4"), C("#1A1C2C"), C("#262B44"), C("#333C57"), C("#29315A"), C("#94B0C2"), C("#566C86"), - C("#94B0C2"), C("#73EFF7"), C("#EF7D57"), C("#A7F070"), C("#FFCD75"), C("#CC000000")); - - private static Color C(string value) => Color.Parse(value); - - /// Solid mix: of over (sRGB, opaque). - private static Color Mix(Color a, Color b, double amount) - => Color.FromRgb(Lerp(a.R, b.R, amount), Lerp(a.G, b.G, amount), Lerp(a.B, b.B, amount)); - - private static byte Lerp(byte a, byte b, double amount) => (byte)Math.Round(a + (b - a) * amount); - - private static string Hex(Color c) => $"#{c.A:X2}{c.R:X2}{c.G:X2}{c.B:X2}"; - - private static (string, string)[] T(params (string, Color)[] tokens) => tokens.Select(t => (t.Item1, Hex(t.Item2))).ToArray(); - - private static (string, (string, string)[])[] DerivedColors(Roles r) - { - var danger = Mix(r.Surface, r.Danger, 0.18); - var success = Mix(r.Surface, r.Success, 0.18); - var warning = Mix(r.Surface, r.Warning, 0.18); - var press = Mix(r.Surface, r.Fg, 0.16); - var solidHighlight = Mix(r.Fg, r.Bg, 0.20); - var solidPressed = Mix(r.Fg, r.Bg, 0.35); - var solidDivider = Mix(r.Fg, r.Bg, 0.30); - return - [ - ("Button", T(("DangerDisabledForeground", Mix(r.Muted, r.Danger, 0.55)), ("GhostRaisedHoverBackground", Mix(r.Raised, r.Fg, 0.10)))), - ("SplitButton", T(("PrimaryHoverColor", solidHighlight), ("PrimaryPressedColor", solidPressed), ("PrimaryDividerColor", solidDivider), - ("SecondaryPressedColor", Mix(r.Muted, r.Border, 0.30)))), - ("Badge", T(("SuccessBackground", success), ("SuccessBorder", r.Success), ("WarningBackground", warning), ("WarningBorder", r.Warning), - ("DangerBackground", danger), ("DangerBorder", r.Danger), ("NeutralHoverBackground", r.Muted), ("NeutralHoverBorder", r.Border), - ("SuccessHoverBackground", Mix(r.Surface, r.Success, 0.30)), ("SuccessHoverBorder", r.Success), - ("WarningHoverBackground", Mix(r.Surface, r.Warning, 0.30)), ("WarningHoverBorder", r.Warning), - ("DangerHoverBackground", Mix(r.Surface, r.Danger, 0.30)), ("DangerHoverBorder", r.Danger))), - ("BillingToggle", T(("BadgeBackground", Mix(r.Surface, r.Accent, 0.25)))), - ("BottomSheet", T(("ScrimColor", r.Scrim))), - ("Card", T(("ScrimColor", r.Scrim), ("CloseButtonBackground", r.Raised), ("CloseButtonBorder", r.Border))), - ("Dialog", T(("ScrimColor", r.Scrim))), - ("Drawer", T(("ScrimColor", r.Scrim))), - ("DialogSurface", T(("CloseButtonBackground", r.Raised))), - ("DrawerSurface", T(("CloseButtonBackground", r.Raised))), - ("ButtonGroup", T(("OutlinePressedHighlightColor", press), ("SolidDividerColor", solidDivider), ("SolidHighlightColor", solidHighlight), - ("SolidMutedInk", Mix(r.Fg, r.Bg, 0.70)), ("SolidPressedHighlightColor", solidPressed))), - ("ButtonGroupItem", T(("SolidDividerColor", solidDivider))), - ("ConfirmMorph", T(("TriggerHoverColor", Mix(r.Surface, r.Fg, 0.10)), ("TriggerPressColor", press), ("TriggerFocusColor", Mix(r.Surface, r.Fg, 0.12)), - ("AnswerHoverColor", Mix(r.Surface, r.Fg, 0.12)), ("AnswerPressColor", Mix(r.Surface, r.Fg, 0.20)), ("AnswerFocusColor", Mix(r.Surface, r.Fg, 0.14)), - ("ConfirmRestColor", danger), ("ConfirmHoverColor", Mix(r.Surface, r.Danger, 0.28)), ("ConfirmPressColor", Mix(r.Surface, r.Danger, 0.38)), - ("ConfirmFocusColor", Mix(r.Surface, r.Danger, 0.32)))), - ("Countdown", T(("CompleteBackground", success), ("CompleteBorder", r.Success))), - ("ExpandingButtonGroupItem", T(("DangerExpandedColor", danger), ("DoneColor", success))), - ("FloatingButtonGroup", T(("TrayColor", r.Muted), ("TrayEdgeColor", r.Border), ("HighlightColor", press), ("HighlightPressColor", Mix(r.Surface, r.Fg, 0.26)))), - ("FloatingButtonGroupSeparator", T(("RuleColor", r.Border))), - ("MetricCard", T(("UpFill", success), ("DownFill", danger))), - ("Skeleton", T(("BlockColor", Mix(r.Surface, r.Fg, 0.12)))), - ("Timeline", T(("MarkerRingColor", r.Border))), - ("Tooltip", T(("BubbleBorderColor", r.Border))), - ("Slider", T(("BubbleBorderColor", r.Border), ("TickColor", r.TextMuted))), - ("Toast", T(("SurfaceColor", r.Raised), ("IconFill", r.Muted), ("IconRing", r.Border))), - ("UsageMeter", T(("TrackColor", r.Muted), ("NearBadgeBackground", warning), ("OverBadgeBackground", danger))), - ("Stepper", T(("ErrorDisc", r.Danger), ("ErrorDiscHover", Mix(r.Danger, r.Fg, 0.20)), ("ErrorHalo", danger))), - ("FieldTextBox", T(("SelectionFill", Color.FromArgb(0x66, r.Accent.R, r.Accent.G, r.Accent.B)))), - ("MenuSurface", T(("AccountHighlight", r.Muted), ("DangerHighlight", danger))), - ("MenuEntry", T(("AccountSeparatorColor", r.BorderSubtle), ("PressedTint", press))), - ("MenuEntrySeparator", T(("AccountLineColor", r.BorderSubtle))), - ("DropdownMenu", T(("TriggerPressedBackground", press))), - ("UserMenu", T(("TriggerPressedBackground", press))), - ]; - } -} diff --git a/src/TKCode.Arc.Themes.Pixel/TKCode.Arc.Themes.Pixel.csproj b/src/TKCode.Arc.Themes.Pixel/TKCode.Arc.Themes.Pixel.csproj index 300bc88d..b32920d3 100644 --- a/src/TKCode.Arc.Themes.Pixel/TKCode.Arc.Themes.Pixel.csproj +++ b/src/TKCode.Arc.Themes.Pixel/TKCode.Arc.Themes.Pixel.csproj @@ -5,10 +5,8 @@ TKCode.Arc.Themes.Pixel TKCode.Arc.Themes.Pixel Pixel-art theme (token overrides only, no controls) for TKCode.Arc: square corners, hard shadows, limited palette, light and dark. - $(AssemblyName) - diff --git a/src/TKCode.Arc.Themes.Pixel/Themes/ArcPixel.xml b/src/TKCode.Arc.Themes.Pixel/Themes/ArcPixel.xml deleted file mode 100644 index 1bac5670..00000000 --- a/src/TKCode.Arc.Themes.Pixel/Themes/ArcPixel.xml +++ /dev/null @@ -1,113 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/TKCode.Arc.Themes.Pixel/Themes/ArcPixelDark.xml b/src/TKCode.Arc.Themes.Pixel/Themes/ArcPixelDark.xml deleted file mode 100644 index 2fb8fdf8..00000000 --- a/src/TKCode.Arc.Themes.Pixel/Themes/ArcPixelDark.xml +++ /dev/null @@ -1,113 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/TKCode.Arc.Workflow/TKCode.Arc.Workflow.csproj b/src/TKCode.Arc.Workflow/TKCode.Arc.Workflow.csproj index 28f60ed5..6210a6e3 100644 --- a/src/TKCode.Arc.Workflow/TKCode.Arc.Workflow.csproj +++ b/src/TKCode.Arc.Workflow/TKCode.Arc.Workflow.csproj @@ -5,13 +5,11 @@ TKCode.Arc TKCode.Arc.Workflow Workflow canvas for TKCode.Arc: show and edit graphs of steps and connections on a dotted canvas, watch a run step by step, pan and zoom, arrange automatically and undo. - $(AssemblyName) - TKCode.Arc.Workflow $(NoWarn);CS0436 + true - diff --git a/src/TKCode.Arc.Workflow/WorkflowBuilderExtensions.cs b/src/TKCode.Arc.Workflow/WorkflowBuilderExtensions.cs index b651db51..7d70754f 100644 --- a/src/TKCode.Arc.Workflow/WorkflowBuilderExtensions.cs +++ b/src/TKCode.Arc.Workflow/WorkflowBuilderExtensions.cs @@ -1,5 +1,4 @@ -using AtomUI; -using AtomUI.Generated.TKCodeArcWorkflow; +using TKCode.Arc.Generated.TKCodeArcWorkflow; namespace TKCode.Arc; @@ -7,11 +6,9 @@ public static class ArcWorkflowBuilderExtensions { /// /// Enables the Arc workflow canvas () and its built-in text in en-US, zh-CN and zh-TW. Depends on - /// . + /// . /// - public static IAtomUIBuilder UseArcWorkflow(this IAtomUIBuilder builder) - => GeneratedControlPackageRegistration.Definition.Register(builder, static package => - { - package.DependsOn(static dependency => dependency.UseArc()); - }); + public static ArcBuilder UseArcWorkflow(this ArcBuilder builder) + => builder.UsePackage(ArcGeneratedAssets.PackageId, static package => ArcGeneratedAssets.AddTo(package + .DependsOn(static b => b.UseArcControls()))); } diff --git a/src/TKCode.Arc.Workflow/WorkflowCanvas/Localization/WorkflowLangResourceKind.cs b/src/TKCode.Arc.Workflow/WorkflowCanvas/Localization/WorkflowLangResourceKind.cs index 08b6a078..b44d03bf 100644 --- a/src/TKCode.Arc.Workflow/WorkflowCanvas/Localization/WorkflowLangResourceKind.cs +++ b/src/TKCode.Arc.Workflow/WorkflowCanvas/Localization/WorkflowLangResourceKind.cs @@ -1,4 +1,3 @@ -using AtomUI.Localization; namespace TKCode.Arc.Localization; diff --git a/src/TKCode.Arc.Workflow/WorkflowCanvas/Localization/WorkflowText.cs b/src/TKCode.Arc.Workflow/WorkflowCanvas/Localization/WorkflowText.cs index aca335d0..501ab6e8 100644 --- a/src/TKCode.Arc.Workflow/WorkflowCanvas/Localization/WorkflowText.cs +++ b/src/TKCode.Arc.Workflow/WorkflowCanvas/Localization/WorkflowText.cs @@ -1,30 +1,20 @@ using System.Globalization; -using AtomUI; -using AtomUI.Localization; -using Avalonia; using TKCode.Arc.Localization; namespace TKCode.Arc; -/// The workflow controls' built-in text in the current AtomUI language (Localization/*.xlf). +/// The workflow controls' built-in text in the current Arc language (Localization/*.xlf). internal static class WorkflowText { - public static ILanguageManager? Languages => Application.Current?.GetLanguageManager(); + public static ArcLanguageManager? Languages => ArcLanguageManager.Current; - /// The AtomUI formatting culture, or the current culture before the application starts. - public static CultureInfo FormattingCulture => Languages?.Current.FormattingCulture ?? CultureInfo.CurrentCulture; + /// The culture of the current Arc language (the current culture before the application starts). + public static CultureInfo FormattingCulture => ArcLanguage.Culture; - public static string Get(WorkflowLangResourceKind kind) => - Application.Current?.GetLocalizer()?.Get(kind) ?? Fallback(kind); + public static string Get(WorkflowLangResourceKind kind) => WorkflowLangResources.Get(kind); - public static string Format(WorkflowLangResourceKind kind, params object?[] arguments) - { - if (Application.Current?.GetLocalizer() is { } localizer) - { - return localizer.Format(kind, arguments); - } - return string.Format(CultureInfo.InvariantCulture, Fallback(kind), arguments); - } + public static string Format(WorkflowLangResourceKind kind, params object?[] arguments) => + WorkflowLangResources.Format(kind, arguments); /// The word for a run state (null for ). public static string? State(WorkflowNodeState state) => state switch @@ -59,61 +49,4 @@ public static string Duration(TimeSpan duration) public static string CanvasName(int steps) => steps == 1 ? Get(WorkflowLangResourceKind.CanvasNameOne) : Format(WorkflowLangResourceKind.CanvasNameOther, steps.ToString("N0", FormattingCulture)); - - /// English text for code that runs before the application starts (design time, plain unit tests). - private static string Fallback(WorkflowLangResourceKind kind) => kind switch - { - WorkflowLangResourceKind.CanvasNameOne => "Workflow, 1 step", - WorkflowLangResourceKind.CanvasNameOther => "Workflow, {0} steps", - WorkflowLangResourceKind.DurationMilliseconds => "{0} ms", - WorkflowLangResourceKind.DurationMinutes => "{0} min {1} s", - WorkflowLangResourceKind.DurationSeconds => "{0} s", - WorkflowLangResourceKind.StateDone => "Done", - WorkflowLangResourceKind.StateFailed => "Failed", - WorkflowLangResourceKind.StatePending => "Waiting", - WorkflowLangResourceKind.StateRunning => "Running", - WorkflowLangResourceKind.StateSkipped => "Skipped", - WorkflowLangResourceKind.AnnounceFailed => "{0} failed", - WorkflowLangResourceKind.AnnounceFailedReason => "{0} failed: {1}", - WorkflowLangResourceKind.AnnounceFinished => "Run finished: {0} done, {1} failed, {2} skipped", - WorkflowLangResourceKind.CanvasHelp => "Arrow keys move between steps, Alt with an arrow follows the connections, Enter opens a step, plus and minus zoom, Shift+1 fits the workflow.", - WorkflowLangResourceKind.ZoomFit => "Fit to view", - WorkflowLangResourceKind.ZoomIn => "Zoom in", - WorkflowLangResourceKind.ZoomOut => "Zoom out", - WorkflowLangResourceKind.ZoomPercent => "{0}%", - WorkflowLangResourceKind.ZoomReset => "Reset to 100%", - WorkflowLangResourceKind.ConnectSelf => "Can't connect a step to itself", - WorkflowLangResourceKind.ConnectDirection => "Connect an output to an input", - WorkflowLangResourceKind.ConnectDuplicate => "Already connected", - WorkflowLangResourceKind.ConnectFull => "This port takes no more connections", - WorkflowLangResourceKind.ConnectType => "Port types don't match", - WorkflowLangResourceKind.ConnectCycle => "This would create a loop", - WorkflowLangResourceKind.AnnounceConnected => "Connected {0} to {1}", - WorkflowLangResourceKind.AnnounceDisconnected => "Disconnected {0} from {1}", - WorkflowLangResourceKind.AnnounceRefused => "Can't connect: {0}", - WorkflowLangResourceKind.AnnounceConnectMode => "Connecting from {0} to {1}: arrow keys choose a step, Enter connects, Escape cancels", - WorkflowLangResourceKind.AnnounceConnectPort => "Output {0}: left and right choose an output, Enter continues", - WorkflowLangResourceKind.AnnounceConnectNoTarget => "No step can take this connection", - WorkflowLangResourceKind.AnnounceConnectCancelled => "Connection cancelled", - WorkflowLangResourceKind.AnnounceUndo => "Undone", - WorkflowLangResourceKind.AnnounceRedo => "Redone", - WorkflowLangResourceKind.AnnounceDeletedEdgesOne => "Deleted 1 connection", - WorkflowLangResourceKind.AnnounceDeletedEdgesOther => "Deleted {0} connections", - WorkflowLangResourceKind.CanvasHelpEditable => "Arrow keys move between steps, Alt with an arrow follows the connections, Enter opens a step, Space selects, M moves the selected steps, C connects the focused step, Delete removes the selection, Ctrl+C and Ctrl+V copy and paste, Ctrl+Z undoes, plus and minus zoom, Shift+1 fits the workflow.", - WorkflowLangResourceKind.AnnounceDeletedOne => "Deleted 1 step", - WorkflowLangResourceKind.AnnounceDeletedOther => "Deleted {0} steps", - WorkflowLangResourceKind.AnnounceMovedOne => "Moved 1 step", - WorkflowLangResourceKind.AnnounceMovedOther => "Moved {0} steps", - WorkflowLangResourceKind.AnnounceMoveMode => "Moving: arrow keys move, Enter places, Escape cancels", - WorkflowLangResourceKind.AnnounceMoveCancelled => "Move cancelled", - WorkflowLangResourceKind.CardSelected => "selected", - WorkflowLangResourceKind.AnnounceCopiedOne => "Copied 1 step", - WorkflowLangResourceKind.AnnounceCopiedOther => "Copied {0} steps", - WorkflowLangResourceKind.AnnounceCutOne => "Cut 1 step", - WorkflowLangResourceKind.AnnounceCutOther => "Cut {0} steps", - WorkflowLangResourceKind.AnnounceNothingToPaste => "Nothing to paste", - WorkflowLangResourceKind.AnnouncePastedOne => "Pasted 1 step", - WorkflowLangResourceKind.AnnouncePastedOther => "Pasted {0} steps", - _ => kind.ToString() - }; } diff --git a/src/TKCode.Arc.Workflow/WorkflowCanvas/Themes/WorkflowCanvasTheme.axaml b/src/TKCode.Arc.Workflow/WorkflowCanvas/Themes/WorkflowCanvasTheme.axaml index 739b0252..c2ed77ad 100644 --- a/src/TKCode.Arc.Workflow/WorkflowCanvas/Themes/WorkflowCanvasTheme.axaml +++ b/src/TKCode.Arc.Workflow/WorkflowCanvas/Themes/WorkflowCanvasTheme.axaml @@ -1,7 +1,8 @@ + x:ClassModifier="internal" + x:Class="TKCode.Arc.WorkflowCanvasTheme"> diff --git a/src/TKCode.Arc.Workflow/WorkflowCanvas/Themes/WorkflowNodeCardTheme.axaml b/src/TKCode.Arc.Workflow/WorkflowCanvas/Themes/WorkflowNodeCardTheme.axaml index 78b3203d..7207bca9 100644 --- a/src/TKCode.Arc.Workflow/WorkflowCanvas/Themes/WorkflowNodeCardTheme.axaml +++ b/src/TKCode.Arc.Workflow/WorkflowCanvas/Themes/WorkflowNodeCardTheme.axaml @@ -1,7 +1,8 @@ + x:ClassModifier="internal" + x:Class="TKCode.Arc.WorkflowNodeCardTheme"> diff --git a/src/TKCode.Arc.Workflow/WorkflowCanvas/WorkflowCanvasToken.cs b/src/TKCode.Arc.Workflow/WorkflowCanvas/WorkflowCanvasToken.cs index 1d9cd325..5a8caa13 100644 --- a/src/TKCode.Arc.Workflow/WorkflowCanvas/WorkflowCanvasToken.cs +++ b/src/TKCode.Arc.Workflow/WorkflowCanvas/WorkflowCanvasToken.cs @@ -1,4 +1,3 @@ -using AtomUI.Theme.DesignTokens; using Avalonia.Media; namespace TKCode.Arc; diff --git a/src/TKCode.Arc.Workflow/WorkflowCanvas/WorkflowNodeCard.cs b/src/TKCode.Arc.Workflow/WorkflowCanvas/WorkflowNodeCard.cs index cbb055db..94e3c5da 100644 --- a/src/TKCode.Arc.Workflow/WorkflowCanvas/WorkflowNodeCard.cs +++ b/src/TKCode.Arc.Workflow/WorkflowCanvas/WorkflowNodeCard.cs @@ -10,8 +10,8 @@ using Avalonia.Media; using Avalonia.Media.Immutable; using Avalonia.Threading; -using AtomUI.Localization; using TKCode.Arc.DesignTokens; +using TKCode.Arc.Localization; namespace TKCode.Arc; @@ -120,7 +120,7 @@ public class WorkflowNodeCard : TemplatedControl private WorkflowCardPanel? _layout; private ContentPresenter? _content; private WorkflowNode? _observed; - private ILanguageManager? _languages; + private ArcLanguageManager? _languages; static WorkflowNodeCard() { @@ -299,7 +299,7 @@ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) _languages = WorkflowText.Languages; if (_languages is not null) { - _languages.LanguageChanged += OnLanguageChanged; + _languages.Changed += OnLanguageChanged; } Refresh(); } @@ -311,7 +311,7 @@ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e Observe(null); if (_languages is not null) { - _languages.LanguageChanged -= OnLanguageChanged; + _languages.Changed -= OnLanguageChanged; _languages = null; } } diff --git a/src/TKCode.Arc.Workflow/WorkflowCanvas/WorkflowNodeCardToken.cs b/src/TKCode.Arc.Workflow/WorkflowCanvas/WorkflowNodeCardToken.cs index fbf455fb..d9de4946 100644 --- a/src/TKCode.Arc.Workflow/WorkflowCanvas/WorkflowNodeCardToken.cs +++ b/src/TKCode.Arc.Workflow/WorkflowCanvas/WorkflowNodeCardToken.cs @@ -1,4 +1,3 @@ -using AtomUI.Theme.DesignTokens; using Avalonia; using Avalonia.Media; diff --git a/src/TKCode.Arc.Workflow/WorkflowMinimap/Themes/WorkflowMinimapTheme.axaml b/src/TKCode.Arc.Workflow/WorkflowMinimap/Themes/WorkflowMinimapTheme.axaml index a4ee96a7..674d113d 100644 --- a/src/TKCode.Arc.Workflow/WorkflowMinimap/Themes/WorkflowMinimapTheme.axaml +++ b/src/TKCode.Arc.Workflow/WorkflowMinimap/Themes/WorkflowMinimapTheme.axaml @@ -1,7 +1,8 @@ + x:ClassModifier="internal" + x:Class="TKCode.Arc.WorkflowMinimapTheme"> diff --git a/src/TKCode.Arc.Workflow/WorkflowMinimap/WorkflowMinimapToken.cs b/src/TKCode.Arc.Workflow/WorkflowMinimap/WorkflowMinimapToken.cs index dd67f844..e0295963 100644 --- a/src/TKCode.Arc.Workflow/WorkflowMinimap/WorkflowMinimapToken.cs +++ b/src/TKCode.Arc.Workflow/WorkflowMinimap/WorkflowMinimapToken.cs @@ -1,4 +1,3 @@ -using AtomUI.Theme.DesignTokens; using Avalonia; namespace TKCode.Arc; diff --git a/src/TKCode.Arc.Workflow/WorkflowZoomControls/Themes/WorkflowZoomControlsTheme.axaml b/src/TKCode.Arc.Workflow/WorkflowZoomControls/Themes/WorkflowZoomControlsTheme.axaml index 5e86146f..b405a044 100644 --- a/src/TKCode.Arc.Workflow/WorkflowZoomControls/Themes/WorkflowZoomControlsTheme.axaml +++ b/src/TKCode.Arc.Workflow/WorkflowZoomControls/Themes/WorkflowZoomControlsTheme.axaml @@ -1,7 +1,8 @@ + x:ClassModifier="internal" + x:Class="TKCode.Arc.WorkflowZoomControlsTheme"> diff --git a/src/TKCode.Arc.Workflow/WorkflowZoomControls/WorkflowZoomControls.cs b/src/TKCode.Arc.Workflow/WorkflowZoomControls/WorkflowZoomControls.cs index 7a1e16a2..70d8fed4 100644 --- a/src/TKCode.Arc.Workflow/WorkflowZoomControls/WorkflowZoomControls.cs +++ b/src/TKCode.Arc.Workflow/WorkflowZoomControls/WorkflowZoomControls.cs @@ -6,7 +6,6 @@ using Avalonia.Interactivity; using Avalonia.Media; using Avalonia.Threading; -using AtomUI.Localization; using TKCode.Arc.Localization; using AvaloniaButton = Avalonia.Controls.Button; @@ -37,7 +36,7 @@ public class WorkflowZoomControls : TemplatedControl private WorkflowZoomButton? _zoomIn; private WorkflowZoomButton? _fit; private WorkflowCanvas? _observed; - private ILanguageManager? _languages; + private ArcLanguageManager? _languages; /// public WorkflowCanvas? Target @@ -98,7 +97,7 @@ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) _languages = WorkflowText.Languages; if (_languages is not null) { - _languages.LanguageChanged += OnLanguageChanged; + _languages.Changed += OnLanguageChanged; } Refresh(); } @@ -109,7 +108,7 @@ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e Observe(null); if (_languages is not null) { - _languages.LanguageChanged -= OnLanguageChanged; + _languages.Changed -= OnLanguageChanged; _languages = null; } } diff --git a/src/TKCode.Arc.Workflow/WorkflowZoomControls/WorkflowZoomControlsToken.cs b/src/TKCode.Arc.Workflow/WorkflowZoomControls/WorkflowZoomControlsToken.cs index b32fe27d..e5828728 100644 --- a/src/TKCode.Arc.Workflow/WorkflowZoomControls/WorkflowZoomControlsToken.cs +++ b/src/TKCode.Arc.Workflow/WorkflowZoomControls/WorkflowZoomControlsToken.cs @@ -1,4 +1,3 @@ -using AtomUI.Theme.DesignTokens; using Avalonia; namespace TKCode.Arc; diff --git a/src/TKCode.Arc/Activity/ChatThread/ChatComposer.cs b/src/TKCode.Arc/Activity/ChatThread/ChatComposer.cs index f79139cc..1250165b 100644 --- a/src/TKCode.Arc/Activity/ChatThread/ChatComposer.cs +++ b/src/TKCode.Arc/Activity/ChatThread/ChatComposer.cs @@ -13,7 +13,6 @@ using Avalonia.Layout; using Avalonia.Media; using Avalonia.Platform.Storage; -using AtomUI.Controls; using AvaloniaTextBox = Avalonia.Controls.TextBox; namespace TKCode.Arc; @@ -682,7 +681,7 @@ internal sealed class ChatComposerTile : Panel private static readonly SpringSpec s_pop = SpringSpec.FromVisual(0.3, 0.22); private readonly ScaleTransform _scale = new(); private MotionValue? _presence; - private readonly AsyncImage? _image; + private readonly ArcImage? _image; public ChatComposerTile(ChatComposerFile file) { @@ -693,7 +692,7 @@ public ChatComposerTile(ChatComposerFile file) Control body; if (file.IsImage) { - _image = new AsyncImage { Stretch = Stretch.UniformToFill, Source = new StorageFileImageSource(file.File) }; + _image = new ArcImage { Stretch = Stretch.UniformToFill, Source = new StorageFileImageSource(file.File) }; body = new ChatComposerTileFrame { Child = _image, IsImage = true }; } else diff --git a/src/TKCode.Arc/Activity/ChatThread/ChatComposerToken.cs b/src/TKCode.Arc/Activity/ChatThread/ChatComposerToken.cs index 191c8a03..9f85f4ce 100644 --- a/src/TKCode.Arc/Activity/ChatThread/ChatComposerToken.cs +++ b/src/TKCode.Arc/Activity/ChatThread/ChatComposerToken.cs @@ -1,4 +1,3 @@ -using AtomUI.Theme.DesignTokens; using Avalonia; namespace TKCode.Arc; diff --git a/src/TKCode.Arc/Activity/ChatThread/ChatThreadToken.cs b/src/TKCode.Arc/Activity/ChatThread/ChatThreadToken.cs index 9ed72a6f..c0d6e1de 100644 --- a/src/TKCode.Arc/Activity/ChatThread/ChatThreadToken.cs +++ b/src/TKCode.Arc/Activity/ChatThread/ChatThreadToken.cs @@ -1,4 +1,3 @@ -using AtomUI.Theme.DesignTokens; using Avalonia; using Avalonia.Media; diff --git a/src/TKCode.Arc/Activity/ChatThread/ChatThreadTypes.cs b/src/TKCode.Arc/Activity/ChatThread/ChatThreadTypes.cs index 0c9d50fd..8206526a 100644 --- a/src/TKCode.Arc/Activity/ChatThread/ChatThreadTypes.cs +++ b/src/TKCode.Arc/Activity/ChatThread/ChatThreadTypes.cs @@ -1,6 +1,5 @@ using System.ComponentModel; using System.Runtime.CompilerServices; -using AtomUI.Controls; using Avalonia.Interactivity; using Avalonia.Platform.Storage; diff --git a/src/TKCode.Arc/Activity/ChatThread/ChatThreadViews.cs b/src/TKCode.Arc/Activity/ChatThread/ChatThreadViews.cs index e31ee16e..cdcfffed 100644 --- a/src/TKCode.Arc/Activity/ChatThread/ChatThreadViews.cs +++ b/src/TKCode.Arc/Activity/ChatThread/ChatThreadViews.cs @@ -1,4 +1,3 @@ -using AtomUI.Controls; using TKCode.Arc.Motion; using TKCode.Arc.Primitives; using Avalonia; @@ -476,7 +475,7 @@ public void Unbind() { foreach (var tile in _gallery.Children.OfType()) { - if (tile.Content is Panel { Children: [AsyncImage image, ..] }) + if (tile.Content is Panel { Children: [ArcImage image, ..] }) { image.Source = null; } @@ -654,7 +653,7 @@ private void RebuildAttachments(ChatThread thread, ChatThreadMessageRow row) _gallery.Children.Add(tile); } var panel = (Panel)tile.Content!; - var asyncImage = (AsyncImage)panel.Children[0]; + var asyncImage = (ArcImage)panel.Children[0]; asyncImage.Source = image.ImageSource; var more = (Border)panel.Children[1]; var extra = images.Count - 4; @@ -706,7 +705,7 @@ private void RebuildAttachments(ChatThread thread, ChatThreadMessageRow row) private ChatThreadButton CreateTile() { - var image = new AsyncImage { Stretch = Stretch.UniformToFill }; + var image = new ArcImage { Stretch = Stretch.UniformToFill }; var more = new ChatThreadMore { IsVisible = false, diff --git a/src/TKCode.Arc/Activity/ChatThread/Themes/ChatComposerTheme.axaml b/src/TKCode.Arc/Activity/ChatThread/Themes/ChatComposerTheme.axaml index 64bde2ad..d0a1206a 100644 --- a/src/TKCode.Arc/Activity/ChatThread/Themes/ChatComposerTheme.axaml +++ b/src/TKCode.Arc/Activity/ChatThread/Themes/ChatComposerTheme.axaml @@ -1,7 +1,8 @@ + x:ClassModifier="internal" + x:Class="TKCode.Arc.ChatComposerTheme"> - - - - - - - - @@ -146,7 +146,7 @@ - - + + + + + + + + diff --git a/tests/TKCode.Arc.Foundation.Tests/Samples/SampleControls.cs b/tests/TKCode.Arc.Foundation.Tests/Samples/SampleControls.cs new file mode 100644 index 00000000..2f0a5a84 --- /dev/null +++ b/tests/TKCode.Arc.Foundation.Tests/Samples/SampleControls.cs @@ -0,0 +1,106 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Media; +using TKCode.Arc.Foundation.Samples.DesignTokens; +using TKCode.Arc.Generated.TKCodeArcFoundationTests; + +namespace TKCode.Arc.Foundation.Samples; + +/// A button-like sample control. Its theme is SampleButton/Themes/SampleButtonTheme.axaml. +public class SampleButton : Button +{ +} + +[ControlDesignToken] +internal sealed class SampleButtonToken : AbstractControlDesignToken +{ + public Thickness InlinePadding { get; set; } + public double HoverOpacity { get; set; } + + /// Accent-independent derived color (from neutrals only), as control tokens are allowed to hold. + public Color GhostHoverBackground { get; set; } + + public override void CalculateTokenValues(bool isDarkMode) + { + base.CalculateTokenValues(isDarkMode); + InlinePadding = new Thickness(16, 0); + HoverOpacity = 0.91; + GhostHoverBackground = isDarkMode ? Color.Parse("#2A2A2A") : Color.Parse("#F1F1F1"); + } +} + +/// A switch-like sample control. Its theme is SampleSwitch/Themes/SampleSwitchTheme.axaml. +public class SampleSwitch : ToggleButton +{ +} + +[ControlDesignToken] +internal sealed class SampleSwitchToken : AbstractControlDesignToken +{ + public double TrackWidth { get; set; } + public double TrackHeight { get; set; } + public double ThumbSize { get; set; } + public TimeSpan ThumbTravel { get; set; } + + public override void CalculateTokenValues(bool isDarkMode) + { + base.CalculateTokenValues(isDarkMode); + TrackWidth = 44; + TrackHeight = 26; + ThumbSize = 22; + ThumbTravel = TimeSpan.FromMilliseconds(240); + } +} + +/// A control nothing in the tests creates: its theme file must never load. +public class SampleBadge : TemplatedControl +{ +} + +[ControlDesignToken] +internal sealed class SampleBadgeToken : AbstractControlDesignToken +{ + public double DotSize { get; set; } + + public override void CalculateTokenValues(bool isDarkMode) + { + base.CalculateTokenValues(isDarkMode); + DotSize = 8; + } +} + +/// Package registration of the samples, the way Core and Arc will register after the switch-over. +public static class SampleArcBuilderExtensions +{ + /// Times the samples' first-use callback ran in the current application. + public static int FirstUseCount { get; set; } + + /// Package ids in the order their commit callbacks ran. + public static List CommitOrder { get; } = new(); + + /// A dependency-only package (no assets), standing in for TKCode.Arc.Core. + public static ArcBuilder UseSampleCore(this ArcBuilder builder) + => builder.UsePackage("Samples.Core", static p => p.OnCommit(static c => + { + CommitOrder.Clear(); + CommitOrder.Add(c.PackageId); + })); + + /// Enables the sample tokens and themes. + public static ArcBuilder UseFoundationSamples(this ArcBuilder builder) + => builder.UsePackage(ArcGeneratedAssets.PackageId, static p => + { + ArcGeneratedAssets.AddTo(p.DependsOn(static b => b.UseSampleCore())); + p.OnCommit(static c => + { + CommitOrder.Add(c.PackageId); + FirstUseCount = 0; + }); + p.OnFirstUse(static _ => FirstUseCount++); + }); + + /// Reads a published ArcDesign-like token the way ArcTokens.Bind does: by the boxed kind. + public static IDisposable BindToken(StyledElement target, AvaloniaProperty property, SampleDesignTokenKind kind) + => target.Bind(property, target.GetResourceObservable(kind)); +} diff --git a/tests/TKCode.Arc.Foundation.Tests/Samples/SampleDesignToken.cs b/tests/TKCode.Arc.Foundation.Tests/Samples/SampleDesignToken.cs new file mode 100644 index 00000000..1477d726 --- /dev/null +++ b/tests/TKCode.Arc.Foundation.Tests/Samples/SampleDesignToken.cs @@ -0,0 +1,82 @@ +using Avalonia; +using Avalonia.Animation; +using Avalonia.Animation.Easings; +using Avalonia.Media; +using TKCode.Arc.Motion; + +namespace TKCode.Arc.Foundation.Samples; + +/// Sample density, for the enum token path. +public enum SampleDensity +{ + /// Default spacing. + Comfortable, + + /// Tighter spacing. + Compact +} + +/// +/// An ArcDesign-like global identity: derives an accent ramp from the seed and switches neutrals by appearance. It covers +/// every token type the generator supports. +/// +[ControlDesignToken(IsGlobal = true)] +internal sealed class SampleDesignToken : AbstractControlDesignToken +{ + public Color Accent { get; set; } + public Color AccentStrong { get; set; } + public Color AccentForeground { get; set; } + public Color Surface { get; set; } + public Color Foreground { get; set; } + public Color ControlTrack { get; set; } + public CornerRadius RadiusControl { get; set; } + public CornerRadius RadiusPill { get; set; } + public BoxShadows ShadowRaised { get; set; } + public Thickness SpaceInset { get; set; } + public double ControlSizeMD { get; set; } + public int StaggerCount { get; set; } + public bool ReduceTransparency { get; set; } + public FontFamily BodyFontFamily { get; set; } = FontFamily.Default; + public FontWeight WeightMedium { get; set; } + public TimeSpan DurationFast { get; set; } + public Easing EaseStandard { get; set; } = new LinearEasing(); + public SampleDensity Density { get; set; } + public string MotionMode { get; set; } = "full"; + + public override void CalculateTokenValues(bool isDarkMode) + { + base.CalculateTokenValues(isDarkMode); + var seed = EffectiveGlobalToken.ColorPrimary; + // Dark mode lifts the accent towards white, a stand-in for ArcPalette's hand-tuned dark ramp. + Accent = isDarkMode ? Mix(seed, Colors.White, 0.2) : seed; + AccentStrong = isDarkMode ? Mix(seed, Colors.White, 0.35) : Mix(seed, Colors.Black, 0.15); + AccentForeground = isDarkMode ? Color.Parse("#0B0B0B") : Colors.White; + Surface = isDarkMode ? Color.Parse("#1D1D1D") : Color.Parse("#FFFFFF"); + Foreground = isDarkMode ? Color.Parse("#F1F1F1") : Color.Parse("#0B0B0B"); + ControlTrack = isDarkMode ? Color.Parse("#383838") : Color.Parse("#E3E3E3"); + RadiusControl = new CornerRadius(18); + RadiusPill = new CornerRadius(999); + ShadowRaised = BoxShadows.Parse(isDarkMode ? "0 2 8 0 #66000000" : "0 2 8 0 #1A000000"); + SpaceInset = new Thickness(16, 8); + ControlSizeMD = 44; + StaggerCount = 4; + ReduceTransparency = false; + BodyFontFamily = new FontFamily("Inter"); + WeightMedium = FontWeight.Medium; + DurationFast = TimeSpan.FromMilliseconds(160); + EaseStandard = new SplineEasing(new KeySpline(0.2, 0, 0, 1)); + Density = SampleDensity.Comfortable; + MotionMode = EffectiveGlobalToken.MotionLevel switch + { + ArcMotionLevel.Off => "off", + ArcMotionLevel.Reduced => "reduced", + _ => "full" + }; + } + + internal static Color Mix(Color a, Color b, double amount) + { + byte Lerp(byte x, byte y) => (byte)Math.Round(x + (y - x) * amount); + return Color.FromArgb(Lerp(a.A, b.A), Lerp(a.R, b.R), Lerp(a.G, b.G), Lerp(a.B, b.B)); + } +} diff --git a/tests/TKCode.Arc.Foundation.Tests/Samples/SampleSwitch/Themes/SampleSwitchTheme.axaml b/tests/TKCode.Arc.Foundation.Tests/Samples/SampleSwitch/Themes/SampleSwitchTheme.axaml new file mode 100644 index 00000000..1c362efd --- /dev/null +++ b/tests/TKCode.Arc.Foundation.Tests/Samples/SampleSwitch/Themes/SampleSwitchTheme.axaml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + diff --git a/tests/TKCode.Arc.Foundation.Tests/ScopeAndLazyThemeTests.cs b/tests/TKCode.Arc.Foundation.Tests/ScopeAndLazyThemeTests.cs new file mode 100644 index 00000000..4de072a4 --- /dev/null +++ b/tests/TKCode.Arc.Foundation.Tests/ScopeAndLazyThemeTests.cs @@ -0,0 +1,160 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.Media; +using Avalonia.Styling; +using Avalonia.Threading; +using TKCode.Arc.Foundation.Samples; +using static TKCode.Arc.Foundation.Tests.TestHelpers; + +namespace TKCode.Arc.Foundation.Tests; + +public class ScopeTests +{ + private static readonly Color Violet = Color.Parse("#7747FF"); + private static Color DarkAccent(Color seed) => Mix(seed, Colors.White, 0.2); + + [AvaloniaFact] + public void Dark_subtree_inside_a_light_app() + { + var outer = new SampleButton(); + var inner = new SampleButton(); + var innerSwitch = new SampleSwitch(); + var scope = new ThemeVariantScope { Child = new StackPanel { Children = { inner, innerSwitch } } }; + ArcThemeScope.SetAppearance(scope, ArcAppearance.Dark); + Show(new StackPanel { Children = { outer, scope } }); + + BrushColor(outer.Background).ShouldBe(ArcSeedToken.DefaultAccent); + BrushColor(inner.Background).ShouldBe(DarkAccent(ArcSeedToken.DefaultAccent)); + BrushColor(innerSwitch.Background).ShouldBe(Color.Parse("#383838")); + scope.ActualThemeVariant.ShouldBe(ThemeVariant.Dark); + + // The scope follows the application's accent, and stays dark. + ArcTheme.Apply(t => t.Accent(Violet)); + BrushColor(outer.Background).ShouldBe(Violet); + BrushColor(inner.Background).ShouldBe(DarkAccent(Violet)); + + // And it stays dark when the application turns dark and back. + ArcTheme.Apply(t => t.Appearance(ArcAppearance.Dark)); + ArcTheme.Apply(t => t.Appearance(ArcAppearance.Light)); + BrushColor(inner.Background).ShouldBe(DarkAccent(Violet)); + + ArcThemeScope.SetAppearance(scope, null); + Dispatcher.UIThread.RunJobs(); + BrushColor(inner.Background).ShouldBe(Violet); + scope.Resources.MergedDictionaries.Count.ShouldBe(0); + } + + [AvaloniaFact] + public void Light_and_dark_side_by_side_on_any_panel() + { + var light = new SampleButton(); + var dark = new SampleButton(); + var lightPanel = new Border { Child = light }; + var darkPanel = new Border { Child = dark }; + ArcThemeScope.SetAppearance(lightPanel, ArcAppearance.Light); + ArcThemeScope.SetAppearance(darkPanel, ArcAppearance.Dark); + Show(new StackPanel { Children = { lightPanel, darkPanel } }); + + ArcTheme.Apply(t => t.Appearance(ArcAppearance.Dark)); + BrushColor(light.Background).ShouldBe(ArcSeedToken.DefaultAccent); + BrushColor(dark.Background).ShouldBe(DarkAccent(ArcSeedToken.DefaultAccent)); + } + + [AvaloniaFact] + public void Fixed_state_scope_ignores_application_changes() + { + var inner = new SampleButton(); + var scope = new Border { Child = inner }; + ArcThemeScope.SetState(scope, ArcThemeState.Default.With(t => t.Accent(Violet))); + Show(scope); + + BrushColor(inner.Background).ShouldBe(Violet); + ArcTheme.Apply(t => t.Accent(Colors.Orange).Appearance(ArcAppearance.Dark)); + BrushColor(inner.Background).ShouldBe(Violet); + } +} + +public class LazyThemeTests +{ + private const string ButtonAsset = "TKCode.Arc.Foundation.Tests:Samples/SampleButton/Themes/SampleButtonTheme.axaml"; + private const string SwitchAsset = "TKCode.Arc.Foundation.Tests:Samples/SampleSwitch/Themes/SampleSwitchTheme.axaml"; + private const string BadgeAsset = "TKCode.Arc.Foundation.Tests:Samples/SampleBadge/Themes/SampleBadgeTheme.axaml"; + + [AvaloniaFact] + public void Theme_files_load_on_first_use_only() + { + var themes = ArcRuntime.Current!.Themes; + var loads = new List<(string Asset, object? Trigger)>(); + themes.AssetLoaded += (_, e) => loads.Add((e.Asset.Id, e.Trigger)); + themes.AssetCount.ShouldBe(3); + themes.LoadedCount.ShouldBe(0); + SampleArcBuilderExtensions.FirstUseCount.ShouldBe(0); + + var window = Show(new SampleButton()); + + loads.ShouldBe(new[] { (ButtonAsset, (object?)typeof(SampleButton)) }); + SampleArcBuilderExtensions.FirstUseCount.ShouldBe(1); + + // More buttons, and a theme switch, load nothing more. + window.Content = new StackPanel { Children = { new SampleButton(), new SampleButton() } }; + Dispatcher.UIThread.RunJobs(); + ArcTheme.Apply(t => t.Appearance(ArcAppearance.Dark)); + loads.Count.ShouldBe(1); + + window.Content = new SampleSwitch(); + Dispatcher.UIThread.RunJobs(); + loads.Select(l => l.Asset).ShouldBe(new[] { ButtonAsset, SwitchAsset }); + themes.IsLoaded(BadgeAsset).ShouldBeFalse(); + SampleArcBuilderExtensions.FirstUseCount.ShouldBe(1); + } + + [AvaloniaFact] + public void Named_keys_load_their_file_and_static_resources_inside_it_resolve() + { + var themes = ArcRuntime.Current!.Themes; + var ghost = Application.Current!.FindResource("SampleGhostButtonTheme").ShouldBeOfType(); + themes.IsLoaded(ButtonAsset).ShouldBeTrue(); + ghost.BasedOn.ShouldNotBeNull(); + ghost.BasedOn.TargetType.ShouldBe(typeof(SampleButton)); + + var button = new SampleButton { Theme = ghost }; + Show(button); + BrushColor(button.Background).ShouldBe(Color.Parse("#F1F1F1")); + button.Padding.ShouldBe(new Thickness(16, 0)); + } + + [AvaloniaFact] + public void Unrelated_lookups_load_nothing() + { + var themes = ArcRuntime.Current!.Themes; + Show(new StackPanel { Children = { new TextBlock(), new Border() } }); + Application.Current!.TryFindResource("NoSuchKey", out _).ShouldBeFalse(); + themes.LoadedCount.ShouldBe(0); + } + + [AvaloniaFact] + public void Eager_mode_loads_every_file_up_front() + { + var provider = new ArcThemeProvider(); + foreach (var asset in TKCode.Arc.Generated.TKCodeArcFoundationTests.ArcGeneratedAssets.Themes) + { + provider.Add(asset, null); + } + provider.LoadedCount.ShouldBe(0); + provider.LoadAll(); + provider.LoadedCount.ShouldBe(3); + provider.TryGetResource(typeof(SampleBadge), null, out var badge).ShouldBeTrue(); + badge.ShouldBeOfType(); + } + + [AvaloniaFact] + public void Duplicate_keys_across_files_are_rejected() + { + var provider = new ArcThemeProvider(); + provider.Add(new ArcThemeAsset("a", new object[] { typeof(SampleBadge) }, static () => new ResourceDictionary()), null); + Should.Throw(() => + provider.Add(new ArcThemeAsset("b", new object[] { typeof(SampleBadge) }, static () => new ResourceDictionary()), null)) + .Message.ShouldContain("defined by both 'a' and 'b'"); + } +} diff --git a/tests/TKCode.Arc.Foundation.Tests/Snapshots/ArcGeneratedAssets.Languages.g.cs.txt b/tests/TKCode.Arc.Foundation.Tests/Snapshots/ArcGeneratedAssets.Languages.g.cs.txt new file mode 100644 index 00000000..d92dafbd --- /dev/null +++ b/tests/TKCode.Arc.Foundation.Tests/Snapshots/ArcGeneratedAssets.Languages.g.cs.txt @@ -0,0 +1,32 @@ +// +#nullable enable + +namespace TKCode.Arc.Generated.DemoControls +{ + /// Token identities and control-theme files of Demo.Controls, for the package registration. + internal static class ArcGeneratedAssets + { + /// The assembly name, the default package id. + public const string PackageId = "Demo.Controls"; + + /// The token identities declared in this assembly. + public static readonly global::TKCode.Arc.ArcTokenDescriptor[] Tokens = new global::TKCode.Arc.ArcTokenDescriptor[] + { + }; + + /// The control-theme files of this assembly; each is instantiated on first lookup of one of its keys. + public static readonly global::TKCode.Arc.ArcThemeAsset[] Themes = new global::TKCode.Arc.ArcThemeAsset[] + { + }; + + /// The language catalogs of this assembly. + public static readonly global::TKCode.Arc.Localization.ArcLanguageCatalog[] Languages = new global::TKCode.Arc.Localization.ArcLanguageCatalog[] + { + global::Demo.Controls.Localization.DemoLangResources.Catalog, + }; + + /// Adds this assembly's tokens, theme files and language catalogs to . + public static global::TKCode.Arc.ArcPackageBuilder AddTo(global::TKCode.Arc.ArcPackageBuilder package) + => package.AddTokens(Tokens).AddThemes(Themes).AddLanguages(Languages); + } +} diff --git a/tests/TKCode.Arc.Foundation.Tests/Snapshots/ArcGeneratedAssets.g.cs.txt b/tests/TKCode.Arc.Foundation.Tests/Snapshots/ArcGeneratedAssets.g.cs.txt new file mode 100644 index 00000000..47528cfe --- /dev/null +++ b/tests/TKCode.Arc.Foundation.Tests/Snapshots/ArcGeneratedAssets.g.cs.txt @@ -0,0 +1,29 @@ +// +#nullable enable + +namespace TKCode.Arc.Generated.DemoControls +{ + /// Token identities and control-theme files of Demo.Controls, for the package registration. + internal static class ArcGeneratedAssets + { + /// The assembly name, the default package id. + public const string PackageId = "Demo.Controls"; + + /// The token identities declared in this assembly. + public static readonly global::TKCode.Arc.ArcTokenDescriptor[] Tokens = new global::TKCode.Arc.ArcTokenDescriptor[] + { + global::Demo.Controls.DesignTokens.DemoButtonTokens.Descriptor, + global::Demo.Controls.DesignTokens.DemoDesignTokens.Descriptor, + }; + + /// The control-theme files of this assembly; each is instantiated on first lookup of one of its keys. + public static readonly global::TKCode.Arc.ArcThemeAsset[] Themes = new global::TKCode.Arc.ArcThemeAsset[] + { + new global::TKCode.Arc.ArcThemeAsset("Demo.Controls:Controls/DemoButton/Themes/DemoButtonTheme.axaml", new object[] { typeof(global::Demo.Controls.DemoButton), "DemoGhostButtonTheme" }, static () => new global::Demo.Controls.Themes.DemoButtonTheme()), + }; + + /// Adds this assembly's tokens and theme files to . + public static global::TKCode.Arc.ArcPackageBuilder AddTo(global::TKCode.Arc.ArcPackageBuilder package) + => package.AddTokens(Tokens).AddThemes(Themes); + } +} diff --git a/tests/TKCode.Arc.Foundation.Tests/Snapshots/Demo.Controls.Localization.DemoLangResourceKind.Localization.g.cs.txt b/tests/TKCode.Arc.Foundation.Tests/Snapshots/Demo.Controls.Localization.DemoLangResourceKind.Localization.g.cs.txt new file mode 100644 index 00000000..1aea0ff2 --- /dev/null +++ b/tests/TKCode.Arc.Foundation.Tests/Snapshots/Demo.Controls.Localization.DemoLangResourceKind.Localization.g.cs.txt @@ -0,0 +1,93 @@ +// +#nullable enable + +namespace Demo.Controls.Localization +{ + /// The text of in every language (Localization/*.xlf). + public static class DemoLangResources + { + private static readonly object[] s_keys = new object[] + { + global::Demo.Controls.Localization.DemoLangResourceKind.Save, + global::Demo.Controls.Localization.DemoLangResourceKind.RowCount, + global::Demo.Controls.Localization.DemoLangResourceKind.@event, + }; + + /// The catalog: resource keys, member names and the text of each language. + public static global::TKCode.Arc.Localization.ArcLanguageCatalog Catalog { get; } = new global::TKCode.Arc.Localization.ArcLanguageCatalog( + "Demo.Controls.Localization.DemoLangResourceKind", + global::TKCode.Arc.Localization.LanguageTag.Parse("en-US"), + s_keys, + new string[] { "Save", "RowCount", "event" }, + new global::TKCode.Arc.Localization.ArcLanguageTable[] + { + new global::TKCode.Arc.Localization.ArcLanguageTable(global::TKCode.Arc.Localization.LanguageTag.Parse("en-US"), new string?[] + { + "Save & close", + "{0} rows", + "Say \"hi\"\\n", + }), + new global::TKCode.Arc.Localization.ArcLanguageTable(global::TKCode.Arc.Localization.LanguageTag.Parse("zh-CN"), new string?[] + { + "保存并关闭", + "{0} 行", + "事件", + }), + }); + + /// The resource key of : the boxed member, cached. + public static object Key(global::Demo.Controls.Localization.DemoLangResourceKind kind) + { + var index = Index(kind); + return index < 0 ? kind : s_keys[index]; + } + + /// The text of in the current language. + public static string Get(global::Demo.Controls.Localization.DemoLangResourceKind kind) + { + var index = Index(kind); + return index < 0 ? kind.ToString() : global::TKCode.Arc.Localization.ArcLanguage.Get(Catalog, index); + } + + /// The text of with its {0} placeholders filled, in the current language's culture. + public static string Format(global::Demo.Controls.Localization.DemoLangResourceKind kind, params object?[] arguments) + { + var index = Index(kind); + return index < 0 ? kind.ToString() : global::TKCode.Arc.Localization.ArcLanguage.Format(Catalog, index, arguments); + } + + /// The declaration index of , or -1 for a value the enum does not declare. + public static int Index(global::Demo.Controls.Localization.DemoLangResourceKind kind) + { + switch (kind) + { + case global::Demo.Controls.Localization.DemoLangResourceKind.Save: return 0; + case global::Demo.Controls.Localization.DemoLangResourceKind.RowCount: return 1; + case global::Demo.Controls.Localization.DemoLangResourceKind.@event: return 2; + default: return -1; + } + } + } + + /// Dynamic resource reference to a text, written {arc:DemoLangResource Member}; it follows the language. + public sealed class DemoLangResourceExtension + { + /// Creates the extension; set . + public DemoLangResourceExtension() + { + } + + /// Creates the extension for . + public DemoLangResourceExtension(global::Demo.Controls.Localization.DemoLangResourceKind kind) + { + Kind = kind; + } + + /// The text. + public global::Demo.Controls.Localization.DemoLangResourceKind Kind { get; set; } + + /// Returns a dynamic resource binding to the text's key (local priority, see ArcResourceReference). + public global::Avalonia.Data.BindingBase ProvideValue(global::System.IServiceProvider serviceProvider) + => global::TKCode.Arc.ArcResourceReference.Create(DemoLangResources.Key(Kind), serviceProvider); + } +} diff --git a/tests/TKCode.Arc.Foundation.Tests/Snapshots/Demo.Controls.Themes.DemoButtonTheme.g.cs.txt b/tests/TKCode.Arc.Foundation.Tests/Snapshots/Demo.Controls.Themes.DemoButtonTheme.g.cs.txt new file mode 100644 index 00000000..d2ea4ddb --- /dev/null +++ b/tests/TKCode.Arc.Foundation.Tests/Snapshots/Demo.Controls.Themes.DemoButtonTheme.g.cs.txt @@ -0,0 +1,15 @@ +// +#nullable enable + +namespace Demo.Controls.Themes +{ + /// The compiled dictionary of Controls/DemoButton/Themes/DemoButtonTheme.axaml. + internal partial class DemoButtonTheme : global::Avalonia.Controls.ResourceDictionary + { + /// Builds the dictionary from its compiled XAML (no parsing, no URI lookup). + public DemoButtonTheme() + { + global::Avalonia.Markup.Xaml.AvaloniaXamlLoader.Load(this); + } + } +} diff --git a/tests/TKCode.Arc.Foundation.Tests/Snapshots/DemoButtonToken.g.cs.txt b/tests/TKCode.Arc.Foundation.Tests/Snapshots/DemoButtonToken.g.cs.txt new file mode 100644 index 00000000..1c3341b2 --- /dev/null +++ b/tests/TKCode.Arc.Foundation.Tests/Snapshots/DemoButtonToken.g.cs.txt @@ -0,0 +1,329 @@ +// +#nullable enable + +namespace Demo.Controls.DesignTokens +{ + /// Token names of the DemoButton identity. A boxed value is the token's resource key. + public enum DemoButtonTokenKind + { + /// Token Fill (Color). + Fill = 0, + /// Token Opacity (double). + Opacity = 1, + /// Token Steps (int). + Steps = 2, + /// Token IsDense (bool). + IsDense = 3, + /// Token Label (string). + Label = 4, + /// Token Inset (Thickness). + Inset = 5, + /// Token Radius (CornerRadius). + Radius = 6, + /// Token Shadow (BoxShadows). + Shadow = 7, + /// Token Font (FontFamily). + Font = 8, + /// Token Weight (FontWeight). + Weight = 9, + /// Token Duration (TimeSpan). + Duration = 10, + /// Token Ease (Easing). + Ease = 11, + /// Token Tone (Tone). + Tone = 12, + } + + /// Identity and resource keys of the DemoButton tokens. + public static class DemoButtonTokens + { + /// The identity name, used by token overrides. + public const string Identity = "DemoButton"; + + internal static readonly object[] KeyTable = new object[] { DemoButtonTokenKind.Fill, DemoButtonTokenKind.Opacity, DemoButtonTokenKind.Steps, DemoButtonTokenKind.IsDense, DemoButtonTokenKind.Label, DemoButtonTokenKind.Inset, DemoButtonTokenKind.Radius, DemoButtonTokenKind.Shadow, DemoButtonTokenKind.Font, DemoButtonTokenKind.Weight, DemoButtonTokenKind.Duration, DemoButtonTokenKind.Ease, DemoButtonTokenKind.Tone }; + + /// The resource key of : the boxed kind (equal to any other boxing of it), cached. + public static object Key(DemoButtonTokenKind kind) => KeyTable[(int)kind]; + + /// The generated descriptor (writer and override parser). + public static global::TKCode.Arc.ArcTokenDescriptor Descriptor => DemoButtonTokenDescriptor.Instance; + } + + /// Dynamic resource reference to a DemoButton token, written {arc:DemoButtonTokenResource Name}. + public sealed class DemoButtonTokenResourceExtension + { + /// Creates the extension; set . + public DemoButtonTokenResourceExtension() + { + } + + /// Creates the extension for . + public DemoButtonTokenResourceExtension(DemoButtonTokenKind kind) + { + Kind = kind; + } + + /// The token. + public DemoButtonTokenKind Kind { get; set; } + + /// Returns a dynamic resource binding to the token's key (local priority, see ArcResourceReference). + public global::Avalonia.Data.BindingBase ProvideValue(global::System.IServiceProvider serviceProvider) + => global::TKCode.Arc.ArcResourceReference.Create(DemoButtonTokens.Key(Kind), serviceProvider); + } + + internal sealed class DemoButtonTokenDescriptor : global::TKCode.Arc.ArcTokenDescriptor + { + public static readonly DemoButtonTokenDescriptor Instance = new DemoButtonTokenDescriptor(); + + private DemoButtonTokenDescriptor() + : base(DemoButtonTokens.Identity, false, new string[] { "Fill", "Opacity", "Steps", "IsDense", "Label", "Inset", "Radius", "Shadow", "Font", "Weight", "Duration", "Ease", "Tone" }, DemoButtonTokens.KeyTable) + { + } + + public override global::TKCode.Arc.AbstractControlDesignToken CreateToken() => new global::Demo.Controls.DemoButtonToken(); + + public override void Write(global::TKCode.Arc.AbstractControlDesignToken token, object?[] values) + { + var t = (global::Demo.Controls.DemoButtonToken)token; + values[0] = global::TKCode.Arc.ArcTokenValueParser.Brush(t.Fill); + values[1] = t.Opacity; + values[2] = t.Steps; + values[3] = t.IsDense; + values[4] = t.Label; + values[5] = t.Inset; + values[6] = t.Radius; + values[7] = t.Shadow; + values[8] = t.Font; + values[9] = t.Weight; + values[10] = t.Duration; + values[11] = t.Ease; + values[12] = t.Tone; + } + + public override bool TrySetToken(global::TKCode.Arc.AbstractControlDesignToken token, string name, string value, out string? error) + { + var t = (global::Demo.Controls.DemoButtonToken)token; + switch (name) + { + case "Fill": + { + if (global::TKCode.Arc.ArcTokenValueParser.TryParseColor(value, out global::Avalonia.Media.Color parsed)) + { + t.Fill = parsed; + error = null; + return true; + } + error = InvalidValue(name, "color (#RRGGBB, #AARRGGBB or a name)", value); + return false; + } + case "Opacity": + { + if (global::TKCode.Arc.ArcTokenValueParser.TryParseDouble(value, out double parsed)) + { + t.Opacity = parsed; + error = null; + return true; + } + error = InvalidValue(name, "number", value); + return false; + } + case "Steps": + { + if (global::TKCode.Arc.ArcTokenValueParser.TryParseInt32(value, out int parsed)) + { + t.Steps = parsed; + error = null; + return true; + } + error = InvalidValue(name, "integer", value); + return false; + } + case "IsDense": + { + if (global::TKCode.Arc.ArcTokenValueParser.TryParseBoolean(value, out bool parsed)) + { + t.IsDense = parsed; + error = null; + return true; + } + error = InvalidValue(name, "boolean (true or false)", value); + return false; + } + case "Label": + { + t.Label = value; + error = null; + return true; + } + case "Inset": + { + if (global::TKCode.Arc.ArcTokenValueParser.TryParseThickness(value, out global::Avalonia.Thickness parsed)) + { + t.Inset = parsed; + error = null; + return true; + } + error = InvalidValue(name, "thickness (1, 2 or 4 numbers)", value); + return false; + } + case "Radius": + { + if (global::TKCode.Arc.ArcTokenValueParser.TryParseCornerRadius(value, out global::Avalonia.CornerRadius parsed)) + { + t.Radius = parsed; + error = null; + return true; + } + error = InvalidValue(name, "corner radius (1 or 4 numbers)", value); + return false; + } + case "Shadow": + { + if (global::TKCode.Arc.ArcTokenValueParser.TryParseBoxShadows(value, out global::Avalonia.Media.BoxShadows parsed)) + { + t.Shadow = parsed; + error = null; + return true; + } + error = InvalidValue(name, "box shadow list", value); + return false; + } + case "Font": + { + if (global::TKCode.Arc.ArcTokenValueParser.TryParseFontFamily(value, out global::Avalonia.Media.FontFamily parsed)) + { + t.Font = parsed; + error = null; + return true; + } + error = InvalidValue(name, "font family", value); + return false; + } + case "Weight": + { + if (TryParseAvalonia_Media_FontWeight(value, out global::Avalonia.Media.FontWeight parsed)) + { + t.Weight = parsed; + error = null; + return true; + } + error = InvalidValue(name, "FontWeight (Thin, ExtraLight, UltraLight, Light, SemiLight, Normal, Regular, Medium, DemiBold, SemiBold, Bold, ExtraBold, UltraBold, Black, Heavy, Solid, ExtraBlack, UltraBlack)", value); + return false; + } + case "Duration": + { + if (global::TKCode.Arc.ArcTokenValueParser.TryParseTimeSpan(value, out global::System.TimeSpan parsed)) + { + t.Duration = parsed; + error = null; + return true; + } + error = InvalidValue(name, "duration (120ms, 0.2s or 00:00:00.120)", value); + return false; + } + case "Ease": + { + if (global::TKCode.Arc.ArcTokenValueParser.TryParseEasing(value, out global::Avalonia.Animation.Easings.Easing parsed)) + { + t.Ease = parsed; + error = null; + return true; + } + error = InvalidValue(name, "easing (x1,y1,x2,y2 or an easing name)", value); + return false; + } + case "Tone": + { + if (TryParseDemo_Controls_Tone(value, out global::Demo.Controls.Tone parsed)) + { + t.Tone = parsed; + error = null; + return true; + } + error = InvalidValue(name, "Tone (Soft, Loud)", value); + return false; + } + } + error = UnknownToken(name); + return false; + } + + private static bool TryParseAvalonia_Media_FontWeight(string text, out global::Avalonia.Media.FontWeight value) + { + switch (text.Trim().ToUpperInvariant()) + { + case "THIN": + value = global::Avalonia.Media.FontWeight.Thin; + return true; + case "EXTRALIGHT": + value = global::Avalonia.Media.FontWeight.ExtraLight; + return true; + case "ULTRALIGHT": + value = global::Avalonia.Media.FontWeight.UltraLight; + return true; + case "LIGHT": + value = global::Avalonia.Media.FontWeight.Light; + return true; + case "SEMILIGHT": + value = global::Avalonia.Media.FontWeight.SemiLight; + return true; + case "NORMAL": + value = global::Avalonia.Media.FontWeight.Normal; + return true; + case "REGULAR": + value = global::Avalonia.Media.FontWeight.Regular; + return true; + case "MEDIUM": + value = global::Avalonia.Media.FontWeight.Medium; + return true; + case "DEMIBOLD": + value = global::Avalonia.Media.FontWeight.DemiBold; + return true; + case "SEMIBOLD": + value = global::Avalonia.Media.FontWeight.SemiBold; + return true; + case "BOLD": + value = global::Avalonia.Media.FontWeight.Bold; + return true; + case "EXTRABOLD": + value = global::Avalonia.Media.FontWeight.ExtraBold; + return true; + case "ULTRABOLD": + value = global::Avalonia.Media.FontWeight.UltraBold; + return true; + case "BLACK": + value = global::Avalonia.Media.FontWeight.Black; + return true; + case "HEAVY": + value = global::Avalonia.Media.FontWeight.Heavy; + return true; + case "SOLID": + value = global::Avalonia.Media.FontWeight.Solid; + return true; + case "EXTRABLACK": + value = global::Avalonia.Media.FontWeight.ExtraBlack; + return true; + case "ULTRABLACK": + value = global::Avalonia.Media.FontWeight.UltraBlack; + return true; + } + value = default; + return false; + } + + private static bool TryParseDemo_Controls_Tone(string text, out global::Demo.Controls.Tone value) + { + switch (text.Trim().ToUpperInvariant()) + { + case "SOFT": + value = global::Demo.Controls.Tone.Soft; + return true; + case "LOUD": + value = global::Demo.Controls.Tone.Loud; + return true; + } + value = default; + return false; + } + } +} diff --git a/tests/TKCode.Arc.Foundation.Tests/Snapshots/DemoDesignToken.g.cs.txt b/tests/TKCode.Arc.Foundation.Tests/Snapshots/DemoDesignToken.g.cs.txt new file mode 100644 index 00000000..e14a2dbe --- /dev/null +++ b/tests/TKCode.Arc.Foundation.Tests/Snapshots/DemoDesignToken.g.cs.txt @@ -0,0 +1,97 @@ +// +#nullable enable + +namespace Demo.Controls.DesignTokens +{ + /// Token names of the DemoDesign identity. A boxed value is the token's resource key. + public enum DemoDesignTokenKind + { + /// Token Accent (Color). + Accent = 0, + /// Token Preset (string). + Preset = 1, + } + + /// Identity and resource keys of the DemoDesign tokens. + public static class DemoDesignTokens + { + /// The identity name, used by token overrides. + public const string Identity = "DemoDesign"; + + internal static readonly object[] KeyTable = new object[] { DemoDesignTokenKind.Accent, DemoDesignTokenKind.Preset }; + + /// The resource key of : the boxed kind (equal to any other boxing of it), cached. + public static object Key(DemoDesignTokenKind kind) => KeyTable[(int)kind]; + + /// The generated descriptor (writer and override parser). + public static global::TKCode.Arc.ArcTokenDescriptor Descriptor => DemoDesignTokenDescriptor.Instance; + } + + /// Dynamic resource reference to a DemoDesign token, written {arc:DemoDesignTokenResource Name}. + public sealed class DemoDesignTokenResourceExtension + { + /// Creates the extension; set . + public DemoDesignTokenResourceExtension() + { + } + + /// Creates the extension for . + public DemoDesignTokenResourceExtension(DemoDesignTokenKind kind) + { + Kind = kind; + } + + /// The token. + public DemoDesignTokenKind Kind { get; set; } + + /// Returns a dynamic resource binding to the token's key (local priority, see ArcResourceReference). + public global::Avalonia.Data.BindingBase ProvideValue(global::System.IServiceProvider serviceProvider) + => global::TKCode.Arc.ArcResourceReference.Create(DemoDesignTokens.Key(Kind), serviceProvider); + } + + internal sealed class DemoDesignTokenDescriptor : global::TKCode.Arc.ArcTokenDescriptor + { + public static readonly DemoDesignTokenDescriptor Instance = new DemoDesignTokenDescriptor(); + + private DemoDesignTokenDescriptor() + : base(DemoDesignTokens.Identity, true, new string[] { "Accent", "Preset" }, DemoDesignTokens.KeyTable) + { + } + + public override global::TKCode.Arc.AbstractControlDesignToken CreateToken() => new global::Demo.Controls.DemoDesignToken(); + + public override void Write(global::TKCode.Arc.AbstractControlDesignToken token, object?[] values) + { + var t = (global::Demo.Controls.DemoDesignToken)token; + values[0] = global::TKCode.Arc.ArcTokenValueParser.Brush(t.Accent); + values[1] = t.Preset; + } + + public override bool TrySetToken(global::TKCode.Arc.AbstractControlDesignToken token, string name, string value, out string? error) + { + var t = (global::Demo.Controls.DemoDesignToken)token; + switch (name) + { + case "Accent": + { + if (global::TKCode.Arc.ArcTokenValueParser.TryParseColor(value, out global::Avalonia.Media.Color parsed)) + { + t.Accent = parsed; + error = null; + return true; + } + error = InvalidValue(name, "color (#RRGGBB, #AARRGGBB or a name)", value); + return false; + } + case "Preset": + { + t.Preset = value; + error = null; + return true; + } + } + error = UnknownToken(name); + return false; + } + } +} diff --git a/tests/TKCode.Arc.Foundation.Tests/Snapshots/Diagnostics.txt b/tests/TKCode.Arc.Foundation.Tests/Snapshots/Diagnostics.txt new file mode 100644 index 00000000..932c983c --- /dev/null +++ b/tests/TKCode.Arc.Foundation.Tests/Snapshots/Diagnostics.txt @@ -0,0 +1,14 @@ +ARCF001 Error :13 Token 'Odd.Offset' has type 'Point', which string overrides cannot parse; use Color, double, int, bool, string, Thickness, CornerRadius, BoxShadows, FontFamily, FontWeight, TimeSpan, Easing or an enum +ARCF001 Error :14 Token 'Odd.MaybeColor' has type 'Color?', which string overrides cannot parse; use Color, double, int, bool, string, Thickness, CornerRadius, BoxShadows, FontFamily, FontWeight, TimeSpan, Easing or an enum +ARCF002 Error :22 'NotAToken' is marked [ControlDesignToken] but does not derive from TKCode.Arc.AbstractControlDesignToken +ARCF003 Warning :19 'EmptyToken' declares no public settable properties; give the control at least one token or remove the class +ARCF004 Error :40 Token identity 'First' is declared by both 'global::Demo.Bad.FirstToken' and 'global::Demo.Other.FirstToken' +ARCF005 Error :34 'global::Demo.Bad.FirstToken' and 'global::Demo.Bad.SecondToken' are both marked IsGlobal; an application has a single global token identity +ARCF006 Error :28 'AbstractToken' must be a non-abstract, non-generic, top-level class with a parameterless constructor +ARCF007 Warning :25 'Widget' does not end with 'Token'; its identity is the whole class name +ARCF010 Error NoClassTheme.axaml:1 Theme file 'A/Themes/NoClassTheme.axaml' has no x:Class; add x:Class="Demo.Controls.NoClassTheme" so the dictionary can be created without loading XAML by URI (which is not AOT safe) +ARCF011 Error BrokenTheme.axaml:1 Theme file 'B/Themes/BrokenTheme.axaml' cannot be read: The 'unclosed' start tag on line 1 position 22 does not match the end tag of 'ResourceDictionary'. Line 1, position 33. +ARCF012 Warning UnresolvedTheme.axaml:3 Theme file 'C/Themes/UnresolvedTheme.axaml' keys a resource by '{x:Type b:Missing}', but no accessible type 'Missing' was found; the resource will not be loaded on demand +ARCF013 Error DuplicateTheme.axaml:3 Resource key '{x:Type b:Thing}' is defined by both 'C/Themes/UnresolvedTheme.axaml' and 'D/Themes/DuplicateTheme.axaml' +ARCF013 Error DuplicateTheme.axaml:4 Resource key 'Shared' is defined by both 'C/Themes/UnresolvedTheme.axaml' and 'D/Themes/DuplicateTheme.axaml' +ARCF014 Warning EmptyTheme.axaml:1 Theme file 'E/Themes/EmptyTheme.axaml' has no keyed top-level resources, so nothing can ever load it diff --git a/tests/TKCode.Arc.Foundation.Tests/Snapshots/LocalizationDiagnostics.txt b/tests/TKCode.Arc.Foundation.Tests/Snapshots/LocalizationDiagnostics.txt new file mode 100644 index 00000000..ad770b24 --- /dev/null +++ b/tests/TKCode.Arc.Foundation.Tests/Snapshots/LocalizationDiagnostics.txt @@ -0,0 +1,14 @@ +ARCF020 Error C/Localization/de-DE.xlf:1 Language file 'C/Localization/de-DE.xlf' cannot be read: the file is named 'de-DE' but declares the language 'fr-FR' (trgLang) +ARCF020 Error C/Localization/en-GB.xlf:1 Language file 'C/Localization/en-GB.xlf' cannot be read: the root element must be +ARCF020 Error C/Localization/fr-FR.xlf:1 Language file 'C/Localization/fr-FR.xlf' cannot be read: The 'unclosed' start tag on line 1 position 9 does not match the end tag of 'xliff'. Line 1, position 20. +ARCF020 Error C/Localization/notes.xlf:1 Language file 'C/Localization/notes.xlf' cannot be read: the file is named 'notes' but declares the language 'en-US' (srcLang) +ARCF021 Error A/Localization/zh-CN.xlf:3 'A/Localization/zh-CN.xlf' has no zh-CN text for 'GoodLangResourceKind.Cancel' +ARCF021 Error A/Localization/zh-TW.xlf:4 'A/Localization/zh-TW.xlf' has no zh-TW text for 'GoodLangResourceKind.Save' +ARCF022 Warning A/Localization/zh-CN.xlf:16 'A/Localization/zh-CN.xlf' has text for 'Stale', but 'Demo.Bad.GoodLangResourceKind' declares no such member; the text is ignored +ARCF023 Error A/Localization/zh-CN.xlf:10 'A/Localization/zh-CN.xlf' has text for 'GoodLangResourceKind.Save' twice +ARCF023 Error B/Localization/zh-TW.xlf:3 'A/Localization/zh-TW.xlf' and 'B/Localization/zh-TW.xlf' both hold the zh-TW text of 'Demo.Bad.GoodLangResourceKind' +ARCF024 Error :9 Language catalog 'Demo.Bad.OrphanLangResourceKind' has no source-language file (a Localization/.xlf without trgLang whose is 'Demo.Bad.OrphanLangResourceKind') +ARCF025 Error A/Localization/zh-CN.xlf:22 'A/Localization/zh-CN.xlf': the zh-CN text of 'GoodLangResourceKind.RowCount' uses placeholders {1} but the source text uses {0} +ARCF026 Error :12 Language catalog 'Demo.Bad.SameLangResourceKind' gives 'One' and 'Uno' the same value; each member is a resource key and needs its own value +ARCF026 Error :17 Language catalog 'Demo.Bad.NestedLangResourceKind' is nested in a type; declare it at namespace level +ARCF027 Warning C/Localization/ja-JP.xlf:3 'C/Localization/ja-JP.xlf' has text for 'Demo.Bad.Unknown', which is not a [LanguageCatalog] enum of this assembly; the text is ignored diff --git a/tests/TKCode.Arc.Foundation.Tests/TKCode.Arc.Foundation.Tests.csproj b/tests/TKCode.Arc.Foundation.Tests/TKCode.Arc.Foundation.Tests.csproj new file mode 100644 index 00000000..6a776d6e --- /dev/null +++ b/tests/TKCode.Arc.Foundation.Tests/TKCode.Arc.Foundation.Tests.csproj @@ -0,0 +1,43 @@ + + + $(ArcTestTargetFramework) + Exe + false + $(NoWarn);CS0436 + TKCode.Arc.Foundation.Tests + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/TKCode.Arc.Foundation.Tests/TestHelpers.cs b/tests/TKCode.Arc.Foundation.Tests/TestHelpers.cs new file mode 100644 index 00000000..5ceb4e21 --- /dev/null +++ b/tests/TKCode.Arc.Foundation.Tests/TestHelpers.cs @@ -0,0 +1,30 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Threading; +using Avalonia.VisualTree; + +namespace TKCode.Arc.Foundation.Tests; + +internal static class TestHelpers +{ + public static Window Show(Control content) + { + var window = new Window { Width = 400, Height = 300, Content = content }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + return window; + } + + public static Color BrushColor(IBrush? brush) + => brush is ISolidColorBrush solid ? solid.Color : throw new InvalidOperationException($"Not a solid brush: {brush}"); + + public static object? AppResource(object key) + => Application.Current!.TryGetResource(key, Application.Current.ActualThemeVariant, out var value) ? value : null; + + public static T Part(Visual root, string name) where T : Control + => root.GetVisualDescendants().OfType().First(c => c.Name == name); + + public static Color Mix(Color a, Color b, double amount) + => Samples.SampleDesignToken.Mix(a, b, amount); +} diff --git a/tests/TKCode.Arc.Foundation.Tests/ThemePresetPortTests.cs b/tests/TKCode.Arc.Foundation.Tests/ThemePresetPortTests.cs new file mode 100644 index 00000000..2cbe4f8e --- /dev/null +++ b/tests/TKCode.Arc.Foundation.Tests/ThemePresetPortTests.cs @@ -0,0 +1,110 @@ +using System.Collections.Immutable; +using System.Text.Json; +using TKCode.Arc.Themes.Corners; +using TKCode.Arc.Themes.Pixel; + +namespace TKCode.Arc.Foundation.Tests; + +/// +/// The Pixel and Corners presets (ArcPixelPresets.cs, ArcCornerPresets.cs, linked into this project so they +/// compile without their packages) hold exactly the overrides of the AtomUI-era definitions they replaced. Those were +/// captured before AtomUI was removed (phase F3) into tests/Baselines/presets/*.json; this test compares both +/// directions: nothing missing, nothing extra, same text. +/// +public class ThemePresetPortTests +{ + public static TheoryData Presets => new("pixel", "corners-square", "corners-scale-0.5", "corners-scale-2"); + + [Theory] + [MemberData(nameof(Presets))] + public void Preset_matches_the_AtomUI_definition(string name) + { + var preset = name switch + { + "pixel" => ArcPixelPresets.Pixel, + "corners-square" => ArcCornerPresets.Square, + "corners-scale-0.5" => ArcCornerPresets.Half, + "corners-scale-2" => ArcCornerPresets.Create(ArcCornerStyle.Scale(2)), + _ => throw new ArgumentOutOfRangeException(nameof(name), name, null) + }; + using var document = JsonDocument.Parse(File.ReadAllText(Path.Combine(BaselineFolder(), $"{name}.json"))); + var problems = new List(); + var count = 0; + foreach (var (appearance, isDark) in new[] { ("light", false), ("dark", true) }) + { + var expected = Flatten(document.RootElement.GetProperty(appearance)); + var actual = Flatten(preset.TokensFor(isDark)); + count += expected.Count; + foreach (var key in expected.Keys.Union(actual.Keys).Order(StringComparer.Ordinal)) + { + var hasExpected = expected.TryGetValue(key, out var before); + var hasActual = actual.TryGetValue(key, out var after); + if (!hasActual) + { + problems.Add($"{appearance} {key}: missing in the preset (AtomUI: {before})"); + } + else if (!hasExpected) + { + problems.Add($"{appearance} {key}: only in the preset ({after})"); + } + else if (!string.Equals(before, after, StringComparison.Ordinal)) + { + problems.Add($"{appearance} {key}: AtomUI {before}, preset {after}"); + } + } + } + problems.ShouldBeEmpty(string.Join('\n', problems)); + count.ShouldBeGreaterThan(name == "pixel" ? 700 : 600); + } + + [Fact] + public void Corner_presets_share_a_slot_and_the_default_style_is_empty() + { + ArcCornerPresets.Square.Slot.ShouldBe(ArcCornerPresets.Slot); + ArcCornerPresets.Half.Name.ShouldBe("ArcCornersScale0.5"); + var reset = ArcCornerPresets.Create(ArcCornerStyle.Default); + reset.IsEmpty.ShouldBeTrue(); + reset.Slot.ShouldBe(ArcCornerPresets.Slot); + ArcPixelPresets.Pixel.Slot.ShouldNotBe(ArcCornerPresets.Slot); + } + + private static Dictionary Flatten(JsonElement overrides) + { + var result = new Dictionary(StringComparer.Ordinal); + foreach (var identity in overrides.EnumerateObject()) + { + foreach (var token in identity.Value.EnumerateObject()) + { + result[$"{identity.Name}.{token.Name}"] = token.Value.GetString()!; + } + } + return result; + } + + /// The global identity ("") is Arc's ArcDesign in the AtomUI files. + private static Dictionary Flatten(IReadOnlyDictionary> overrides) + { + var result = new Dictionary(StringComparer.Ordinal); + foreach (var (identity, tokens) in overrides) + { + var name = identity == ArcThemePreset.GlobalIdentity ? "ArcDesign" : identity; + foreach (var (token, value) in tokens) + { + result.TryAdd($"{name}.{token}", value).ShouldBeTrue($"{name}.{token} is set both globally and on ArcDesign"); + } + } + return result; + } + + private static string BaselineFolder() + { + for (var folder = new DirectoryInfo(AppContext.BaseDirectory); folder is not null; folder = folder.Parent) + { + if (File.Exists(Path.Combine(folder.FullName, "TKCode.Arc.slnx"))) + { + return Path.Combine(folder.FullName, "tests", "Baselines", "presets"); + } + } + throw new InvalidOperationException("TKCode.Arc.slnx was not found above the test folder"); + } +} diff --git a/tests/TKCode.Arc.Foundation.Tests/ThemePresetTests.cs b/tests/TKCode.Arc.Foundation.Tests/ThemePresetTests.cs new file mode 100644 index 00000000..2cc1a66d --- /dev/null +++ b/tests/TKCode.Arc.Foundation.Tests/ThemePresetTests.cs @@ -0,0 +1,134 @@ +using Avalonia; +using Avalonia.Headless.XUnit; +using Avalonia.Media; +using TKCode.Arc.Foundation.Samples.DesignTokens; +using static TKCode.Arc.Foundation.Tests.TestHelpers; + +namespace TKCode.Arc.Foundation.Tests; + +/// Theme presets: named override bundles per appearance, applied between the calculation and the app's overrides. +public class ThemePresetTests +{ + private static readonly ArcThemePreset s_retro = ArcThemePreset.Create("Retro", p => p + .Token("RadiusControl", "0") + .Token("SampleSwitch", "TrackWidth", "60") + .ForLight(l => l.Token("Surface", "#F4F4F4")) + .ForDark(d => d.Token("Surface", "#1A1C2C").Token("SampleBadge", "DotSize", "12")), slot: "Style"); + + private static ArcThemePreset Corners(string name, string radius) + => ArcThemePreset.Create(name, p => p.Token("RadiusControl", radius).Token("RadiusPill", radius), slot: "Corners"); + + [AvaloniaFact] + public void A_preset_applies_shared_and_per_appearance_overrides() + { + ArcTheme.Apply(t => t.Preset(s_retro)); + + AppResource(SampleDesignTokenKind.RadiusControl).ShouldBe(new CornerRadius(0)); + AppResource(SampleSwitchTokenKind.TrackWidth).ShouldBe(60d); + BrushColor(AppResource(SampleDesignTokenKind.Surface) as IBrush).ShouldBe(Color.Parse("#F4F4F4")); + AppResource(SampleBadgeTokenKind.DotSize).ShouldNotBe(12d); + + ArcTheme.Apply(t => t.Appearance(ArcAppearance.Dark)); + + AppResource(SampleDesignTokenKind.RadiusControl).ShouldBe(new CornerRadius(0)); + BrushColor(AppResource(SampleDesignTokenKind.Surface) as IBrush).ShouldBe(Color.Parse("#1A1C2C")); + AppResource(SampleBadgeTokenKind.DotSize).ShouldBe(12d); + } + + [AvaloniaFact] + public void App_overrides_win_over_presets_and_later_presets_win_over_earlier_ones() + { + ArcTheme.Apply(t => t.Preset(s_retro).Preset(Corners("Round", "6"))); + AppResource(SampleDesignTokenKind.RadiusControl).ShouldBe(new CornerRadius(6)); + + ArcTheme.Apply(t => t.Token("RadiusControl", "3")); + AppResource(SampleDesignTokenKind.RadiusControl).ShouldBe(new CornerRadius(3)); + AppResource(SampleDesignTokenKind.RadiusPill).ShouldBe(new CornerRadius(6)); + } + + [AvaloniaFact] + public void A_preset_replaces_the_one_in_its_slot_in_place_and_can_be_removed() + { + ArcTheme.Apply(t => t.Preset(Corners("Round", "6")).Preset(s_retro).Preset(Corners("Rounder", "9"))); + + ArcTheme.State.Presets.Select(p => p.Name).ShouldBe(new[] { "Rounder", "Retro" }); + // Retro comes after the corner slot, so its RadiusControl wins; RadiusPill only comes from the corners. + AppResource(SampleDesignTokenKind.RadiusControl).ShouldBe(new CornerRadius(0)); + AppResource(SampleDesignTokenKind.RadiusPill).ShouldBe(new CornerRadius(9)); + + ArcTheme.Apply(t => t.RemovePreset("Style")); + AppResource(SampleDesignTokenKind.RadiusControl).ShouldBe(new CornerRadius(9)); + + ArcTheme.Apply(t => t.ClearPresets()); + ArcTheme.State.Presets.ShouldBeEmpty(); + AppResource(SampleDesignTokenKind.RadiusControl).ShouldBe(new CornerRadius(18)); + AppResource(SampleDesignTokenKind.RadiusPill).ShouldBe(new CornerRadius(999)); + AppResource(SampleSwitchTokenKind.TrackWidth).ShouldNotBe(60d); + } + + [AvaloniaFact] + public void Unregistered_identities_are_skipped_but_unknown_tokens_fail_the_change() + { + var wide = ArcThemePreset.Create("Wide", p => p + .Token("SampleSwitch", "TrackWidth", "70") + .Token("PackageNotEnabled", "Width", "1")); + ArcTheme.Apply(t => t.Preset(wide)); + AppResource(SampleSwitchTokenKind.TrackWidth).ShouldBe(70d); + + var before = ArcTheme.State; + var broken = ArcThemePreset.Create("Broken", p => p.Token("SampleSwitch", "Nope", "1").Token("RadiusControl", "round")); + var error = Should.Throw(() => ArcTheme.Apply(t => t.Preset(broken))); + error.Errors.ShouldBe(new[] + { + "Preset 'Broken': 'round' is not a valid corner radius (1 or 4 numbers) for token 'SampleDesign.RadiusControl'.", + "Preset 'Broken': 'SampleSwitch' has no token named 'Nope'." + }, ignoreOrder: true); + ArcTheme.State.ShouldBeSameAs(before); + } + + [AvaloniaFact] + public void Include_copies_each_appearance_and_later_writes_win() + { + var pixel = ArcThemePreset.Create("Pixel", p => p + .Token("Surface", "#000000") + .Include(s_retro) + .ForDark(d => d.Token("RadiusControl", "2"))); + + pixel.LightTokens[ArcThemePreset.GlobalIdentity]["Surface"].ShouldBe("#F4F4F4"); + pixel.LightTokens[ArcThemePreset.GlobalIdentity]["RadiusControl"].ShouldBe("0"); + pixel.DarkTokens[ArcThemePreset.GlobalIdentity]["RadiusControl"].ShouldBe("2"); + pixel.DarkTokens["SampleBadge"]["DotSize"].ShouldBe("12"); + pixel.LightTokens.ContainsKey("SampleBadge").ShouldBeFalse(); + pixel.Slot.ShouldBe("Pixel"); + ArcThemePreset.Create("Empty", _ => { }).IsEmpty.ShouldBeTrue(); + Should.Throw(() => ArcThemePreset.Create("", _ => { })); + } + + [AvaloniaFact] + public void Packages_register_presets_by_unique_name() + { + var builder = new ArcBuilder() + .UsePackage("Retro.Theme", p => p.AddPresets([s_retro])) + .UsePackage("Corners.Theme", p => p.AddPresets([Corners("Round", "6"), Corners("Square", "0")])); + builder.Presets.Keys.Order(StringComparer.Ordinal).ShouldBe(new[] { "Retro", "Round", "Square" }); + builder.Presets["Retro"].ShouldBeSameAs(s_retro); + + // The same instance from two packages is fine; a different preset with a taken name is not. + builder.UsePackage("Again", p => p.AddPresets([s_retro])); + var error = Should.Throw(() => + builder.UsePackage("Clash", p => p.AddPresets([Corners("Round", "1")]))); + error.Message.ShouldBe("Theme preset 'Round' is registered by both 'Corners.Theme' and 'Clash'."); + + // The running app's runtime exposes what its packages registered (the samples register none). + ArcRuntime.Current!.Presets.ShouldBeEmpty(); + } + + [AvaloniaFact] + public void Compute_uses_the_presets_of_the_requested_appearance() + { + ArcTheme.Apply(t => t.Preset(s_retro)); + var dark = ArcTheme.Manager.Compute(ArcTheme.State, isDark: true); + BrushColor(dark[SampleDesignTokenKind.Surface] as IBrush).ShouldBe(Color.Parse("#1A1C2C")); + dark[SampleBadgeTokenKind.DotSize].ShouldBe(12d); + } +} diff --git a/tests/TKCode.Arc.Foundation.Tests/ThemeRuntimeTests.cs b/tests/TKCode.Arc.Foundation.Tests/ThemeRuntimeTests.cs new file mode 100644 index 00000000..ea81a89f --- /dev/null +++ b/tests/TKCode.Arc.Foundation.Tests/ThemeRuntimeTests.cs @@ -0,0 +1,175 @@ +using Avalonia; +using Avalonia.Animation.Easings; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.Media; +using Avalonia.Styling; +using Avalonia.Threading; +using TKCode.Arc.Foundation.Samples; +using TKCode.Arc.Foundation.Samples.DesignTokens; +using TKCode.Arc.Motion; +using static TKCode.Arc.Foundation.Tests.TestHelpers; + +namespace TKCode.Arc.Foundation.Tests; + +public class ThemeRuntimeTests +{ + private static readonly Color Violet = Color.Parse("#7747FF"); + + [AvaloniaFact] + public void Tokens_are_published_under_their_boxed_kinds() + { + // A fresh boxing of the kind finds the value: this is what ArcTokens.Bind(target, property, ArcDesignTokenKind.X) does. + BrushColor(AppResource(SampleDesignTokenKind.Accent) as IBrush).ShouldBe(ArcSeedToken.DefaultAccent); + AppResource(SampleSwitchTokenKind.TrackWidth).ShouldBe(44d); + AppResource(SampleButtonTokenKind.InlinePadding).ShouldBe(new Thickness(16, 0)); + AppResource(SampleDesignTokenKind.RadiusControl).ShouldBe(new CornerRadius(18)); + AppResource(SampleDesignTokenKind.WeightMedium).ShouldBe(FontWeight.Medium); + AppResource(SampleDesignTokenKind.DurationFast).ShouldBe(TimeSpan.FromMilliseconds(160)); + AppResource(SampleDesignTokenKind.EaseStandard).ShouldBeOfType(); + AppResource(SampleDesignTokenKind.Density).ShouldBe(SampleDensity.Comfortable); + AppResource(SampleDesignTokenKind.MotionMode).ShouldBe("full"); + // Same enum value, different identity: different keys. + ((object)SampleBadgeTokenKind.DotSize).ShouldNotBe((object)SampleSwitchTokenKind.TrackWidth); + } + + [AvaloniaFact] + public void Control_themes_bind_tokens_in_setters_and_templates() + { + var button = new SampleButton { Content = "Save" }; + var toggle = new SampleSwitch(); + Show(new StackPanel { Children = { button, toggle } }); + + BrushColor(button.Background).ShouldBe(ArcSeedToken.DefaultAccent); + BrushColor(button.Foreground).ShouldBe(Colors.White); + button.Padding.ShouldBe(new Thickness(16, 0)); + button.CornerRadius.ShouldBe(new CornerRadius(18)); + button.MinHeight.ShouldBe(44); + button.FontWeight.ShouldBe(FontWeight.Medium); + Part(button, "PART_Frame").BoxShadow.ShouldBe(BoxShadows.Parse("0 2 8 0 #1A000000")); + + toggle.Width.ShouldBe(44); + toggle.Height.ShouldBe(26); + Part(toggle, "PART_Thumb").Width.ShouldBe(22); + BrushColor(toggle.Background).ShouldBe(Color.Parse("#E3E3E3")); + toggle.IsChecked = true; + Dispatcher.UIThread.RunJobs(); + BrushColor(toggle.Background).ShouldBe(ArcSeedToken.DefaultAccent); + } + + [AvaloniaFact] + public void Light_to_dark_updates_dynamic_resources() + { + var button = new SampleButton { Content = "Save" }; + var toggle = new SampleSwitch(); + var surface = new Border(); + SampleArcBuilderExtensions.BindToken(surface, Border.BackgroundProperty, SampleDesignTokenKind.Surface); + Show(new StackPanel { Children = { button, toggle, surface } }); + ArcThemeChangedEventArgs? changed = null; + ArcTheme.Manager.StateChanged += (_, e) => changed = e; + + ArcTheme.Apply(t => t.Appearance(ArcAppearance.Dark)); + + ArcTheme.Manager.IsDark.ShouldBeTrue(); + Application.Current!.RequestedThemeVariant.ShouldBe(ThemeVariant.Dark); + BrushColor(button.Background).ShouldBe(Mix(ArcSeedToken.DefaultAccent, Colors.White, 0.2)); + BrushColor(button.Foreground).ShouldBe(Color.Parse("#0B0B0B")); + BrushColor(toggle.Background).ShouldBe(Color.Parse("#383838")); + BrushColor(surface.Background).ShouldBe(Color.Parse("#1D1D1D")); + Part(button, "PART_Frame").BoxShadow.ShouldBe(BoxShadows.Parse("0 2 8 0 #66000000")); + changed.ShouldNotBeNull(); + changed.WasDark.ShouldBeFalse(); + changed.IsDark.ShouldBeTrue(); + + ArcTheme.Apply(t => t.Appearance(ArcAppearance.Light)); + BrushColor(button.Background).ShouldBe(ArcSeedToken.DefaultAccent); + BrushColor(surface.Background).ShouldBe(Colors.White); + } + + [AvaloniaFact] + public void Accent_seed_change_recomputes_derived_tokens() + { + var button = new SampleButton(); + Show(button); + + ArcTheme.Apply(t => t.Accent(Violet)); + BrushColor(button.Background).ShouldBe(Violet); + BrushColor(AppResource(SampleDesignTokenKind.AccentStrong) as IBrush).ShouldBe(Mix(Violet, Colors.Black, 0.15)); + + // The accent is kept when only the appearance changes. + ArcTheme.Apply(t => t.Appearance(ArcAppearance.Dark)); + ArcTheme.State.Accent.ShouldBe(Violet); + BrushColor(button.Background).ShouldBe(Mix(Violet, Colors.White, 0.2)); + + ArcTheme.Apply(t => t.DefaultAccent()); + BrushColor(button.Background).ShouldBe(Mix(ArcSeedToken.DefaultAccent, Colors.White, 0.2)); + } + + [AvaloniaFact] + public void Publishing_rewrites_only_changed_keys() + { + var manager = ArcTheme.Manager; + var surface = AppResource(SampleDesignTokenKind.Surface); + var accent = AppResource(SampleDesignTokenKind.Accent); + + manager.Apply(t => t.Accent(Violet)); + + // Accent and AccentStrong changed; nothing else did, so those keys keep their instances. + manager.LastPublishedCount.ShouldBe(2); + AppResource(SampleDesignTokenKind.Surface).ShouldBeSameAs(surface); + AppResource(SampleDesignTokenKind.Accent).ShouldNotBeSameAs(accent); + + manager.Apply(manager.State); + manager.LastPublishedCount.ShouldBe(0); + } + + [AvaloniaFact] + public void Motion_level_reaches_token_calculation() + { + ArcTheme.Apply(t => t.MotionLevel(ArcMotionLevel.Reduced)); + AppResource(SampleDesignTokenKind.MotionMode).ShouldBe("reduced"); + ArcTheme.Apply(t => t.MotionLevel(ArcMotionLevel.Off)); + AppResource(SampleDesignTokenKind.MotionMode).ShouldBe("off"); + } + + [AvaloniaFact] + public async Task ApplyAsync_computes_off_thread_and_publishes_on_ui_thread() + { + var button = new SampleButton(); + Show(button); + var task = ArcTheme.Manager.ApplyAsync(t => t.Appearance(ArcAppearance.Dark)); + while (!task.IsCompleted) + { + Dispatcher.UIThread.RunJobs(); + await Task.Delay(1); + } + await task; + BrushColor(button.Background).ShouldBe(Mix(ArcSeedToken.DefaultAccent, Colors.White, 0.2)); + } + + [AvaloniaFact] + public void Runtime_registers_packages_in_dependency_order() + { + var runtime = ArcRuntime.Current.ShouldNotBeNull(); + runtime.Packages.ShouldBe(new[] { "Samples.Core", "TKCode.Arc.Foundation.Tests" }); + SampleArcBuilderExtensions.CommitOrder.ShouldBe(new[] { "Samples.Core", "TKCode.Arc.Foundation.Tests" }); + runtime.Theme.Descriptors[0].Identity.ShouldBe("SampleDesign"); + runtime.Theme.Descriptors[0].IsGlobal.ShouldBeTrue(); + runtime.Theme.Descriptors.Count.ShouldBe(4); + Application.Current!.Resources.MergedDictionaries.ShouldContain(runtime.Theme.Resources); + Application.Current.Resources.MergedDictionaries.ShouldContain(runtime.Themes); + } + + [AvaloniaFact] + public void Package_builder_is_idempotent_and_rejects_cycles() + { + var builder = new ArcBuilder(); + builder.UsePackage("A", _ => { }).UsePackage("A", _ => throw new InvalidOperationException("defined twice")); + builder.Packages.Count.ShouldBe(1); + + var cyclic = new ArcBuilder(); + var error = Should.Throw(() => + cyclic.UsePackage("A", a => a.DependsOn(b => b.UsePackage("B", p => p.DependsOn(c => c.UsePackage("A", _ => { })))))); + error.Message.ShouldContain("A → B → A"); + } +} diff --git a/tests/TKCode.Arc.Mobile.Tests/AvaloniaTestApp.cs b/tests/TKCode.Arc.Mobile.Tests/AvaloniaTestApp.cs index 3c2d8176..cf496ef5 100644 --- a/tests/TKCode.Arc.Mobile.Tests/AvaloniaTestApp.cs +++ b/tests/TKCode.Arc.Mobile.Tests/AvaloniaTestApp.cs @@ -1,9 +1,8 @@ -using AtomUI; -using AtomUI.Localization; using Avalonia; using Avalonia.Headless; using Avalonia.Headless.XUnit; using TKCode.Arc.Mobile; +using TKCode.Arc.Localization; [assembly: AvaloniaTestApplication(typeof(TKCode.Arc.Mobile.Tests.TestAppBuilder))] [assembly: AvaloniaTestFramework] @@ -14,19 +13,14 @@ namespace TKCode.Arc.Mobile.Tests; public static class TestAppBuilder { - // Note: no UseDesktopControls() and no UseArc(): the mobile package must stand on Core alone. + // Note: no UseArcControls(): the mobile package must stand on Core alone. public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure() .UseHeadless(new AvaloniaHeadlessPlatformOptions()) - .UseAtomUI(builder => builder.UseArcMobile() - .UseLanguages(LanguageTags.EnUS, [LanguageTags.EnUS])); + .UseArc(arc => arc.UseArcMobile() + .UseLanguages(LanguageTags.EnUS, [LanguageTags.EnUS])); } -public partial class TestApplication : Application +public class TestApplication : Application { - public override void RegisterServices() - { - base.RegisterServices(); - ApplicationStartup.Attach(this); - } } diff --git a/tests/TKCode.Arc.Tests/AdvancedCharts/DataGrid/DataGridBehaviorTests.cs b/tests/TKCode.Arc.Tests/AdvancedCharts/DataGrid/DataGridBehaviorTests.cs index ce2b5784..d1b81ae9 100644 --- a/tests/TKCode.Arc.Tests/AdvancedCharts/DataGrid/DataGridBehaviorTests.cs +++ b/tests/TKCode.Arc.Tests/AdvancedCharts/DataGrid/DataGridBehaviorTests.cs @@ -490,7 +490,7 @@ public void A_Grid_Is_Collected_When_Its_Rows_Were_Swapped_While_It_Was_Away() var weak = TestHelpers.ShowAndClose(() => Create(), grid => { // A cached page: the grid leaves the tree, then its view model swaps the collection behind it. - ((Decorator)grid.Parent!).Child = null; + ((Window)grid.Parent!).Content = null; grid.ItemsSource = rows; }); TestHelpers.Collect(weak); diff --git a/tests/TKCode.Arc.Tests/AdvancedCharts/DataGrid/DataGridChromeTests.cs b/tests/TKCode.Arc.Tests/AdvancedCharts/DataGrid/DataGridChromeTests.cs index 8372481c..445d0d79 100644 --- a/tests/TKCode.Arc.Tests/AdvancedCharts/DataGrid/DataGridChromeTests.cs +++ b/tests/TKCode.Arc.Tests/AdvancedCharts/DataGrid/DataGridChromeTests.cs @@ -1,18 +1,18 @@ using System.Globalization; using System.IO.Compression; using System.Text; -using AtomUI; -using AtomUI.Localization; using Avalonia; using Avalonia.Automation.Peers; using Avalonia.Automation.Provider; using Avalonia.Controls; +using Avalonia.Headless; using Avalonia.Headless.XUnit; using Avalonia.Threading; using Avalonia.LogicalTree; using Avalonia.VisualTree; using Shouldly; using TKCode.Arc.Motion; +using TKCode.Arc.Localization; namespace TKCode.Arc.Tests.AdvancedCharts; @@ -104,6 +104,36 @@ public void The_Value_Checklist_Keeps_The_Checked_Values() grid.VisibleRowCount.ShouldBe(3); } + [AvaloniaFact] + public void The_Value_Checklist_Search_Has_A_Template_And_Filters_The_Values() + { + // More than seven values show the search field; it used to be a TextBox with no theme, so it drew nothing. + var grid = DataGridBehaviorTests.Create(); + grid.Columns[1].Options = ["Europe", "Asia Pacific", "North America", "Africa", "Oceania", "Middle East", "Latin America", "Nordics", "Baltics"]; + var window = DataGridBehaviorTests.Show(grid); + grid.IsFilterRowOpen = true; + Dispatcher.UIThread.RunJobs(); + grid.OpenSelectFilter(grid.Viewport.FilterRow, grid.Store.AccessorIndex("region")); + Dispatcher.UIThread.RunJobs(); + + var search = window.GetVisualDescendants().OfType().Single(b => b.IsVisible && b.Name is null); + search.IsFocused.ShouldBeTrue(); + search.GetVisualDescendants().OfType().ShouldHaveSingleItem(); + var placeholder = search.GetVisualDescendants().OfType().Single(t => t.Name == "PART_Placeholder"); + placeholder.IsVisible.ShouldBeTrue(); + placeholder.Text.ShouldBe(search.PlaceholderText); + placeholder.Bounds.Width.ShouldBeGreaterThan(0); + + window.KeyTextInput("ic"); + Dispatcher.UIThread.RunJobs(); + search.Text.ShouldBe("ic"); + var presenter = search.GetVisualDescendants().OfType().Single(); + presenter.Text.ShouldBe("ic"); + presenter.Bounds.Width.ShouldBeGreaterThan(0); + placeholder.IsVisible.ShouldBeFalse(); + window.GetVisualDescendants().OfType().Select(b => b.Content).ShouldBe(["Asia Pacific", "North America", "Africa", "Latin America", "Nordics", "Baltics"]); + } + [AvaloniaFact] public void Exports_Write_The_Visible_Columns_In_View_Order_And_Groups_As_Outlines() { @@ -140,20 +170,20 @@ public void Exports_Write_The_Visible_Columns_In_View_Order_And_Groups_As_Outlin [AvaloniaFact] public void Built_In_Text_Follows_The_Language() { - var languages = Application.Current!.GetLanguageManager()!; + var languages = ArcLanguageManager.Current!; var grid = DataGridBehaviorTests.Create(); grid.Culture = null; DataGridBehaviorTests.Show(grid); try { - languages.ChangeLanguage(LanguageTag.Parse("zh-CN")); + languages.Set(LanguageTag.Parse("zh-CN")); Dispatcher.UIThread.RunJobs(); grid.RowCountText().ShouldBe("4 行"); grid.Toolbar!.SearchBox.PlaceholderText.ShouldBe("搜索"); grid.ClearRange(new DataGridCellRange(0, 2, 0, 2)); grid.StatusBar!.MessageText.ShouldBe("已清空 1 个单元格"); grid.EffectiveCulture.TwoLetterISOLanguageName.ShouldBe("zh"); - languages.ChangeLanguage(LanguageTag.Parse("zh-TW")); + languages.Set(LanguageTag.Parse("zh-TW")); Dispatcher.UIThread.RunJobs(); grid.RowCountText().ShouldBe("4 列"); grid.SetFilter("arr", DataGridFilter.Text(">0")); @@ -162,7 +192,7 @@ public void Built_In_Text_Follows_The_Language() } finally { - languages.ChangeLanguage(LanguageTag.Parse("en-US")); + languages.Set(LanguageTag.Parse("en-US")); Dispatcher.UIThread.RunJobs(); } } diff --git a/tests/TKCode.Arc.Tests/AvaloniaTestApp.cs b/tests/TKCode.Arc.Tests/AvaloniaTestApp.cs index ffc4edd3..13dff1a6 100644 --- a/tests/TKCode.Arc.Tests/AvaloniaTestApp.cs +++ b/tests/TKCode.Arc.Tests/AvaloniaTestApp.cs @@ -1,13 +1,11 @@ -using AtomUI; using TKCode.Arc; using TKCode.Arc.Gallery; using TKCode.Arc.Gallery.Shell; using TKCode.Arc.Themes.Pixel; -using AtomUI.Desktop.Controls; -using AtomUI.Localization; using Avalonia; using Avalonia.Headless; using Avalonia.Headless.XUnit; +using TKCode.Arc.Localization; [assembly: AvaloniaTestApplication(typeof(TKCode.Arc.Tests.TestAppBuilder))] [assembly: AvaloniaTestFramework] @@ -30,17 +28,11 @@ public static AppBuilder BuildAvaloniaApp() private static AppBuilder Build() => AppBuilder.Configure() .UseHeadless(new AvaloniaHeadlessPlatformOptions()) - .UseAtomUI(builder => builder.UseDesktopControls() - .UseArc() - .UseArcGallery() - .UseLanguages(LanguageTags.EnUS, [LanguageTags.EnUS, LanguageTags.ZhCN, LanguageTags.ZhTW])); + .UseArc(arc => arc.UseArcControls() + .UseArcGallery() + .UseLanguages(LanguageTags.EnUS, [LanguageTags.EnUS, LanguageTags.ZhCN, LanguageTags.ZhTW])); } -public partial class TestApplication : Application +public class TestApplication : Application { - public override void RegisterServices() - { - base.RegisterServices(); - ApplicationStartup.Attach(this); - } } diff --git a/tests/TKCode.Arc.Tests/Blocks/Ai/AiAttachmentChipTests.cs b/tests/TKCode.Arc.Tests/Blocks/Ai/AiAttachmentChipTests.cs index 48bf3c75..36d94330 100644 --- a/tests/TKCode.Arc.Tests/Blocks/Ai/AiAttachmentChipTests.cs +++ b/tests/TKCode.Arc.Tests/Blocks/Ai/AiAttachmentChipTests.cs @@ -1,5 +1,3 @@ -using AtomUI.Theme.Algorithms; -using AtomUI.Theme.Configuration; using TKCode.Arc.DesignTokens; using TKCode.Arc.Motion; using TKCode.Arc.Primitives; @@ -290,7 +288,7 @@ public async Task The_Error_Border_Follows_The_Theme() var chip = Create(Sample("brief.pdf", 1_000, AiAttachmentStatus.Error, error: "Larger than 30 MB")); var window = Show(chip); var light = TestHelpers.ColorOf(chip.ErrorBorderBrush); - await TestHelpers.ApplyAsync(new ThemeConfigBuilder().WithAlgorithms(ThemeAlgorithm.Dark).Build()); + await TestHelpers.ApplyAsync(TestHelpers.Dark); try { var dark = TestHelpers.ColorOf(chip.ErrorBorderBrush); @@ -299,7 +297,7 @@ public async Task The_Error_Border_Follows_The_Theme() } finally { - await TestHelpers.ApplyAsync(new ThemeConfigBuilder().Build()); + await TestHelpers.ApplyAsync(null); window.Close(); } } diff --git a/tests/TKCode.Arc.Tests/Blocks/Ai/AiComposerTests.cs b/tests/TKCode.Arc.Tests/Blocks/Ai/AiComposerTests.cs index 43c1f51f..a43e9a05 100644 --- a/tests/TKCode.Arc.Tests/Blocks/Ai/AiComposerTests.cs +++ b/tests/TKCode.Arc.Tests/Blocks/Ai/AiComposerTests.cs @@ -1,5 +1,3 @@ -using AtomUI.Theme.Algorithms; -using AtomUI.Theme.Configuration; using TKCode.Arc.DesignTokens; using Avalonia; using Avalonia.Automation; @@ -54,7 +52,7 @@ public async Task The_Surface_Takes_Its_Colors_From_The_Theme_And_Darkens_Its_Bo Border().ShouldBe(Token(ArcDesignTokenKind.BorderStrong)); var light = TestHelpers.ColorOf(composer.Surface.Background); - await TestHelpers.ApplyAsync(new ThemeConfigBuilder().WithAlgorithms(ThemeAlgorithm.Dark).Build()); + await TestHelpers.ApplyAsync(TestHelpers.Dark); try { var dark = TestHelpers.ColorOf(composer.Surface.Background); @@ -64,7 +62,7 @@ public async Task The_Surface_Takes_Its_Colors_From_The_Theme_And_Darkens_Its_Bo } finally { - await TestHelpers.ApplyAsync(new ThemeConfigBuilder().Build()); + await TestHelpers.ApplyAsync(null); } } diff --git a/tests/TKCode.Arc.Tests/Blocks/Ai/AiRelatedQuestionsTests.cs b/tests/TKCode.Arc.Tests/Blocks/Ai/AiRelatedQuestionsTests.cs index f1f3ed6e..eb2e2541 100644 --- a/tests/TKCode.Arc.Tests/Blocks/Ai/AiRelatedQuestionsTests.cs +++ b/tests/TKCode.Arc.Tests/Blocks/Ai/AiRelatedQuestionsTests.cs @@ -1,8 +1,6 @@ using TKCode.Arc.DesignTokens; using TKCode.Arc.Motion; using TKCode.Arc.Primitives; -using AtomUI.Theme.Configuration; -using AtomUI.Theme.Algorithms; using Avalonia; using Avalonia.Automation; using Avalonia.Automation.Peers; @@ -233,14 +231,14 @@ public async Task The_Hairline_Is_BorderSubtle_In_Light_And_Border_In_Dark() line.Kind.ShouldBe(ArcDesignTokenKind.BorderSubtle); try { - await TestHelpers.ApplyAsync(new ThemeConfigBuilder().WithAlgorithms(ThemeAlgorithm.Dark).Build()); + await TestHelpers.ApplyAsync(TestHelpers.Dark); rig.Settle(); line.Kind.ShouldBe(ArcDesignTokenKind.Border); TestHelpers.ColorOf(line.Brush).ShouldBe(ArcTokens.GetColor(list, ArcDesignTokenKind.Border)); } finally { - await TestHelpers.ApplyAsync(new ThemeConfigBuilder().Build()); + await TestHelpers.ApplyAsync(null); rig.Settle(); } line.Kind.ShouldBe(ArcDesignTokenKind.BorderSubtle); diff --git a/tests/TKCode.Arc.Tests/Blocks/Ai/AiSourceStackTests.cs b/tests/TKCode.Arc.Tests/Blocks/Ai/AiSourceStackTests.cs index d1deffc2..2b14429b 100644 --- a/tests/TKCode.Arc.Tests/Blocks/Ai/AiSourceStackTests.cs +++ b/tests/TKCode.Arc.Tests/Blocks/Ai/AiSourceStackTests.cs @@ -1,5 +1,3 @@ -using AtomUI.Theme.Algorithms; -using AtomUI.Theme.Configuration; using TKCode.Arc.DesignTokens; using TKCode.Arc.Motion; using Avalonia; @@ -207,7 +205,7 @@ public async Task Discs_Use_Surface_And_The_Subtle_Hairline_In_Light_And_The_Rai disc.Classes.ShouldNotContain(AiSourceStackDisc.DarkPseudoClass); TestHelpers.ColorOf(disc.Fill).ShouldBe(ArcTokens.GetColor(disc, ArcDesignTokenKind.Surface)); TestHelpers.ColorOf(disc.Hairline).ShouldBe(ArcTokens.GetColor(disc, ArcDesignTokenKind.BorderSubtle)); - await TestHelpers.ApplyAsync(new ThemeConfigBuilder().WithAlgorithms(ThemeAlgorithm.Dark).Build()); + await TestHelpers.ApplyAsync(TestHelpers.Dark); try { disc.Classes.ShouldContain(AiSourceStackDisc.DarkPseudoClass); @@ -217,7 +215,7 @@ public async Task Discs_Use_Surface_And_The_Subtle_Hairline_In_Light_And_The_Rai } finally { - await TestHelpers.ApplyAsync(new ThemeConfigBuilder().Build()); + await TestHelpers.ApplyAsync(null); window.Close(); } } diff --git a/tests/TKCode.Arc.Tests/Blocks/Ai/AiSourcesRowTests.cs b/tests/TKCode.Arc.Tests/Blocks/Ai/AiSourcesRowTests.cs index bb60950c..be174318 100644 --- a/tests/TKCode.Arc.Tests/Blocks/Ai/AiSourcesRowTests.cs +++ b/tests/TKCode.Arc.Tests/Blocks/Ai/AiSourcesRowTests.cs @@ -1,5 +1,3 @@ -using AtomUI.Theme.Algorithms; -using AtomUI.Theme.Configuration; using TKCode.Arc.DesignTokens; using TKCode.Arc.Motion; using TKCode.Arc.Primitives; @@ -507,7 +505,7 @@ public async Task In_Dark_The_Hairline_Is_Border_And_The_Lit_Edge_BorderStrong() { var row = Create(expanded: true); using var rig = new AiReplyRig(row); - await TestHelpers.ApplyAsync(new ThemeConfigBuilder().WithAlgorithms(ThemeAlgorithm.Dark).Build()); + await TestHelpers.ApplyAsync(TestHelpers.Dark); try { rig.Settle(); @@ -523,7 +521,7 @@ public async Task In_Dark_The_Hairline_Is_Border_And_The_Lit_Edge_BorderStrong() } finally { - await TestHelpers.ApplyAsync(new ThemeConfigBuilder().Build()); + await TestHelpers.ApplyAsync(null); } } diff --git a/tests/TKCode.Arc.Tests/Blocks/Ai/AiSuggestionPopupTests.cs b/tests/TKCode.Arc.Tests/Blocks/Ai/AiSuggestionPopupTests.cs index 25f18137..a5813f79 100644 --- a/tests/TKCode.Arc.Tests/Blocks/Ai/AiSuggestionPopupTests.cs +++ b/tests/TKCode.Arc.Tests/Blocks/Ai/AiSuggestionPopupTests.cs @@ -1,6 +1,6 @@ +using TKCode.Arc.Primitives; using TKCode.Arc.Motion; using TKCode.Arc.Tests.CorePrimitives; -using AtomUI.MotionScene; using Avalonia; using Avalonia.Controls; using Avalonia.Headless.XUnit; @@ -16,8 +16,8 @@ public class AiSuggestionPopupTests { private static Matrix FrameMatrix(AiSuggestionPop pop) { - var actor = pop.FindAncestorOfType(); - var transform = (actor?.MotionTransform ?? pop.RenderTransform) as MatrixTransform; + var actor = pop.FindAncestorOfType(); + var transform = (actor?.RenderTransform ?? pop.RenderTransform) as MatrixTransform; return transform?.Matrix ?? Matrix.Identity; } diff --git a/tests/TKCode.Arc.Tests/Blocks/Ai/AiUserBubbleTests.cs b/tests/TKCode.Arc.Tests/Blocks/Ai/AiUserBubbleTests.cs index 5231f6ed..b50e29bc 100644 --- a/tests/TKCode.Arc.Tests/Blocks/Ai/AiUserBubbleTests.cs +++ b/tests/TKCode.Arc.Tests/Blocks/Ai/AiUserBubbleTests.cs @@ -500,7 +500,7 @@ public void Leaving_The_Tree_Cancels_The_Check() { var rig = new Rig(); rig.Bubble.Copy(); - ((Avalonia.Controls.Primitives.VisualLayerManager)rig.Window.Content!).Child = null; + rig.Window.Content = null; rig.Settle(); rig.Bubble.IsCopied.ShouldBeFalse(); rig.Schedule.Pending[0].Handle.IsDisposed.ShouldBeTrue(); diff --git a/tests/TKCode.Arc.Tests/Blocks/Ai/DocumentPageTests.cs b/tests/TKCode.Arc.Tests/Blocks/Ai/DocumentPageTests.cs index 737c05d7..da0859a0 100644 --- a/tests/TKCode.Arc.Tests/Blocks/Ai/DocumentPageTests.cs +++ b/tests/TKCode.Arc.Tests/Blocks/Ai/DocumentPageTests.cs @@ -1,5 +1,3 @@ -using AtomUI.Theme.Algorithms; -using AtomUI.Theme.Configuration; using Avalonia; using Avalonia.Automation.Peers; using Avalonia.Controls; @@ -666,7 +664,7 @@ public async Task The_Paper_Stays_White_In_The_Dark_Theme_And_The_Tints_Follow_T { page.SetSpan("vendor", s_linked); var light = PromptTone.SeriesColor(page, 1); - await TestHelpers.ApplyAsync(new ThemeConfigBuilder().WithAlgorithms(ThemeAlgorithm.Dark).Build()); + await TestHelpers.ApplyAsync(TestHelpers.Dark); try { Settle(window); @@ -680,7 +678,7 @@ public async Task The_Paper_Stays_White_In_The_Dark_Theme_And_The_Tints_Follow_T } finally { - await TestHelpers.ApplyAsync(new ThemeConfigBuilder().Build()); + await TestHelpers.ApplyAsync(null); } } finally diff --git a/tests/TKCode.Arc.Tests/Blocks/Ai/DocumentViewerTests.cs b/tests/TKCode.Arc.Tests/Blocks/Ai/DocumentViewerTests.cs index 4caf9910..f0646ef5 100644 --- a/tests/TKCode.Arc.Tests/Blocks/Ai/DocumentViewerTests.cs +++ b/tests/TKCode.Arc.Tests/Blocks/Ai/DocumentViewerTests.cs @@ -1,5 +1,3 @@ -using AtomUI.Theme.Algorithms; -using AtomUI.Theme.Configuration; using Avalonia; using Avalonia.Automation.Peers; using Avalonia.Automation.Provider; @@ -741,7 +739,7 @@ public async Task The_Canvas_Is_A_Little_Deeper_Than_The_Surface_In_Both_Themes( TestHelpers.ColorOf(viewer.CanvasBrush).ShouldBe(Oklch.MixOklab(surface, foreground, 0.05)); Oklch.FromColor(TestHelpers.ColorOf(viewer.CanvasBrush)).L.ShouldBe(0.9575, 0.01); TestHelpers.ColorOf(Part(viewer, "PART_CanvasHost").Background).ShouldBe(TestHelpers.ColorOf(viewer.CanvasBrush)); - await TestHelpers.ApplyAsync(new ThemeConfigBuilder().WithAlgorithms(ThemeAlgorithm.Dark).Build()); + await TestHelpers.ApplyAsync(TestHelpers.Dark); try { Settle(window); @@ -749,7 +747,7 @@ public async Task The_Canvas_Is_A_Little_Deeper_Than_The_Surface_In_Both_Themes( } finally { - await TestHelpers.ApplyAsync(new ThemeConfigBuilder().Build()); + await TestHelpers.ApplyAsync(null); } } finally diff --git a/tests/TKCode.Arc.Tests/Blocks/Ai/ModelMenuDataTests.cs b/tests/TKCode.Arc.Tests/Blocks/Ai/ModelMenuDataTests.cs index c172d3f5..ab9ee26e 100644 --- a/tests/TKCode.Arc.Tests/Blocks/Ai/ModelMenuDataTests.cs +++ b/tests/TKCode.Arc.Tests/Blocks/Ai/ModelMenuDataTests.cs @@ -291,12 +291,7 @@ public void A_Catalog_That_Outlives_The_Menu_Does_Not_Keep_It_Alive() { var source = new ObservableCollection(ModelMenuSamples.Models()); var reference = MenuOverSource(source); - for (var i = 0; i < 4 && reference.IsAlive; i++) - { - GC.Collect(); - GC.WaitForPendingFinalizers(); - Dispatcher.UIThread.RunJobs(); - } + TestHelpers.Collect(reference); reference.IsAlive.ShouldBeFalse(); // The catalog still works (the dead menu's links drop themselves). source.Add(ModelMenuSamples.Model("after", "After", "fjord", null, null, null, null)); diff --git a/tests/TKCode.Arc.Tests/Blocks/Ai/ModelMenuInlineEffortTests.cs b/tests/TKCode.Arc.Tests/Blocks/Ai/ModelMenuInlineEffortTests.cs index 87f1a0a9..352461b0 100644 --- a/tests/TKCode.Arc.Tests/Blocks/Ai/ModelMenuInlineEffortTests.cs +++ b/tests/TKCode.Arc.Tests/Blocks/Ai/ModelMenuInlineEffortTests.cs @@ -1,7 +1,6 @@ using TKCode.Arc.Motion; using TKCode.Arc.Primitives; using TKCode.Arc.Tests.CorePrimitives; -using AtomUI.MotionScene; using Avalonia; using Avalonia.Automation; using Avalonia.Automation.Peers; @@ -587,8 +586,8 @@ public void The_Menu_Grows_From_Its_Bottom_Right_Corner_Toward_The_Chip() host.Open(); var presence = host.Menu.MainPresence!; presence.CurrentPose.ShouldBe(new Point(6, 0.97)); - var actor = host.Main.FindAncestorOfType()!; - var matrix = ((MatrixTransform)actor.MotionTransform!).Matrix; + var actor = host.Main.FindAncestorOfType()!; + var matrix = ((MatrixTransform)actor.RenderTransform!).Matrix; // Below its rest position (toward the chip under it), scaled about the bottom-right corner: that corner moves // only by the travel. var corner = host.Main.TranslatePoint(new Point(host.Main.Bounds.Width, host.Main.Bounds.Height), actor)!.Value; diff --git a/tests/TKCode.Arc.Tests/Blocks/Ai/ModelMenuMotionTests.cs b/tests/TKCode.Arc.Tests/Blocks/Ai/ModelMenuMotionTests.cs index 52791f43..384be2e6 100644 --- a/tests/TKCode.Arc.Tests/Blocks/Ai/ModelMenuMotionTests.cs +++ b/tests/TKCode.Arc.Tests/Blocks/Ai/ModelMenuMotionTests.cs @@ -1,6 +1,6 @@ +using TKCode.Arc.Primitives; using TKCode.Arc.Motion; using TKCode.Arc.Tests.CorePrimitives; -using AtomUI.MotionScene; using Avalonia; using Avalonia.Animation.Easings; using Avalonia.Headless.XUnit; @@ -18,8 +18,8 @@ public class ModelMenuMotionTests private static Matrix FrameMatrix(ModelMenuPanel panel) { - var actor = panel.FindAncestorOfType(); - var transform = (actor?.MotionTransform ?? panel.RenderTransform) as MatrixTransform; + var actor = panel.FindAncestorOfType(); + var transform = (actor?.RenderTransform ?? panel.RenderTransform) as MatrixTransform; return transform?.Matrix ?? Matrix.Identity; } diff --git a/tests/TKCode.Arc.Tests/Blocks/Ai/PromptModelMenuTests.cs b/tests/TKCode.Arc.Tests/Blocks/Ai/PromptModelMenuTests.cs index 2879381e..b341082a 100644 --- a/tests/TKCode.Arc.Tests/Blocks/Ai/PromptModelMenuTests.cs +++ b/tests/TKCode.Arc.Tests/Blocks/Ai/PromptModelMenuTests.cs @@ -450,7 +450,7 @@ public void The_Popover_Scales_About_Its_Top_Right_Corner() var presence = host.ModelMenu.Presence!; presence.CurrentPose.ShouldBe(new Point(5, 0.97)); var surface = host.ModelMenu.Surface!; - var actor = surface.FindAncestorOfType()!; + var actor = surface.FindAncestorOfType()!; var corner = surface.TranslatePoint(new Point(surface.Bounds.Width, 0), actor)!.Value; var moved = presence.CurrentMatrix.Transform(corner); moved.X.ShouldBe(corner.X, 0.001); diff --git a/tests/TKCode.Arc.Tests/Blocks/Ai/PromptVersionMenuTests.cs b/tests/TKCode.Arc.Tests/Blocks/Ai/PromptVersionMenuTests.cs index 0158e4b2..0f70c873 100644 --- a/tests/TKCode.Arc.Tests/Blocks/Ai/PromptVersionMenuTests.cs +++ b/tests/TKCode.Arc.Tests/Blocks/Ai/PromptVersionMenuTests.cs @@ -616,7 +616,7 @@ public void The_Popover_Fades_In_From_5px_Above_At_97_Percent_About_Its_Top_Left var matrix = presence.CurrentMatrix; // Scaled about the top-left corner and moved up toward the pill: the corner stays, only shifted up by 5. var surface = host.VersionMenu.Surface!; - var actor = surface.FindAncestorOfType()!; + var actor = surface.FindAncestorOfType()!; var corner = surface.TranslatePoint(default, actor)!.Value; matrix.Transform(corner).ShouldBe(new Point(corner.X, corner.Y - 5)); diff --git a/tests/TKCode.Arc.Tests/Blocks/Auth/LoginCenteredTests.cs b/tests/TKCode.Arc.Tests/Blocks/Auth/LoginCenteredTests.cs index 550f2833..9b6973ee 100644 --- a/tests/TKCode.Arc.Tests/Blocks/Auth/LoginCenteredTests.cs +++ b/tests/TKCode.Arc.Tests/Blocks/Auth/LoginCenteredTests.cs @@ -635,7 +635,8 @@ public void Steps_Glide_The_Card_Height_At_Full_Motion() Dispatcher.UIThread.RunJobs(); window.UpdateLayout(); Assert.False(viewport.IsGliding); - Assert.Equal(0, viewport.ExitingCount); + // The old step leaves from an await on its exit motions, which can come back through the thread pool. + TestHelpers.ShouldDrain(() => viewport.ExitingCount); Assert.Equal(block.View!.DesiredSize.Height, viewport.Bounds.Height, 1); Assert.Equal(1, block.View.Opacity); } diff --git a/tests/TKCode.Arc.Tests/Blocks/Auth/MultiStepFormTests.cs b/tests/TKCode.Arc.Tests/Blocks/Auth/MultiStepFormTests.cs index da279544..085c5824 100644 --- a/tests/TKCode.Arc.Tests/Blocks/Auth/MultiStepFormTests.cs +++ b/tests/TKCode.Arc.Tests/Blocks/Auth/MultiStepFormTests.cs @@ -562,8 +562,8 @@ public void A_removed_step_lets_go_of_its_fields() // The same step (and control) can be shown by another form. var other = new MultiStepForm(); other.Steps.Add(step); - var host = (StackPanel)new StackPanel(); - ((Avalonia.Controls.Primitives.VisualLayerManager)window.Content!).Child = host; + var host = new StackPanel(); + window.Content = host; host.Children.Add(form); host.Children.Add(other); Dispatcher.UIThread.RunJobs(); diff --git a/tests/TKCode.Arc.Tests/Blocks/EmptyStatesTests.cs b/tests/TKCode.Arc.Tests/Blocks/EmptyStatesTests.cs index a7b63d90..120213fd 100644 --- a/tests/TKCode.Arc.Tests/Blocks/EmptyStatesTests.cs +++ b/tests/TKCode.Arc.Tests/Blocks/EmptyStatesTests.cs @@ -874,12 +874,7 @@ public void Items_That_Outlive_The_Block_Do_Not_Keep_It_Alive() { var items = new[] { new EmptyStatesItem(EmptyStatesScene.Offline), new EmptyStatesItem(EmptyStatesScene.CaughtUp) }; var reference = BlockOver(items); - for (var i = 0; i < 4 && reference.IsAlive; i++) - { - GC.Collect(); - GC.WaitForPendingFinalizers(); - Dispatcher.UIThread.RunJobs(); - } + TestHelpers.Collect(reference); reference.IsAlive.ShouldBeFalse(); items[0].Title = "Edited after the block is gone"; GC.KeepAlive(items); diff --git a/tests/TKCode.Arc.Tests/Blocks/HeadersFooters/SiteFooterTests.cs b/tests/TKCode.Arc.Tests/Blocks/HeadersFooters/SiteFooterTests.cs index 526632ad..93568c14 100644 --- a/tests/TKCode.Arc.Tests/Blocks/HeadersFooters/SiteFooterTests.cs +++ b/tests/TKCode.Arc.Tests/Blocks/HeadersFooters/SiteFooterTests.cs @@ -622,12 +622,7 @@ public void A_Footer_Is_Collected_While_Its_Data_Lives_On() }); // The sample columns are static: they must not keep a default footer either. var withSamples = FooterOver(null); - for (var i = 0; i < 4 && (withData.IsAlive || withSamples.IsAlive); i++) - { - GC.Collect(); - GC.WaitForPendingFinalizers(); - Dispatcher.UIThread.RunJobs(); - } + TestHelpers.Collect(withData, withSamples); withData.IsAlive.ShouldBeFalse(); withSamples.IsAlive.ShouldBeFalse(); source[0].Links.Add(new SiteFooterLink("Later")); diff --git a/tests/TKCode.Arc.Tests/Blocks/Media/MediaPlayerTests.cs b/tests/TKCode.Arc.Tests/Blocks/Media/MediaPlayerTests.cs index f97d624b..0ea2d2c6 100644 --- a/tests/TKCode.Arc.Tests/Blocks/Media/MediaPlayerTests.cs +++ b/tests/TKCode.Arc.Tests/Blocks/Media/MediaPlayerTests.cs @@ -432,8 +432,10 @@ public void The_Simulated_Backend_Plays_Silently() player.IsAudible.ShouldBeFalse(); player.Duration.ShouldBe(TimeSpan.FromSeconds(2)); player.Play(); - TestHelpers.PumpUntil(() => player.Position > TimeSpan.Zero, 3000).ShouldBeTrue(); - TestHelpers.PumpUntil(() => !player.IsPlaying, 5000).ShouldBeTrue(); + // The simulated player reports from a thread-pool timer every 250ms; on a loaded runner that report can take + // seconds to arrive, so wait with PumpUntil's default bound (it returns as soon as the report lands). + TestHelpers.PumpUntil(() => player.Position > TimeSpan.Zero).ShouldBeTrue(); + TestHelpers.PumpUntil(() => !player.IsPlaying).ShouldBeTrue(); player.Position.ShouldBe(TimeSpan.Zero); } diff --git a/tests/TKCode.Arc.Tests/Blocks/NotificationCenterTests.cs b/tests/TKCode.Arc.Tests/Blocks/NotificationCenterTests.cs index e203f54f..9bfe8b77 100644 --- a/tests/TKCode.Arc.Tests/Blocks/NotificationCenterTests.cs +++ b/tests/TKCode.Arc.Tests/Blocks/NotificationCenterTests.cs @@ -1065,12 +1065,7 @@ public void A_Source_That_Outlives_The_Bell_Does_Not_Keep_It_Alive() { var source = new ObservableCollection(Many(20)); var reference = BellOverSource(source); - for (var i = 0; i < 4 && reference.IsAlive; i++) - { - GC.Collect(); - GC.WaitForPendingFinalizers(); - Dispatcher.UIThread.RunJobs(); - } + TestHelpers.Collect(reference); reference.IsAlive.ShouldBeFalse(); // The source still works (the dead bell's link drops itself). source.Add(new NotificationItem { Id = "late", Title = "Late" }); diff --git a/tests/TKCode.Arc.Tests/Blocks/PageHeaderTests.cs b/tests/TKCode.Arc.Tests/Blocks/PageHeaderTests.cs index 607967c3..78ad8853 100644 --- a/tests/TKCode.Arc.Tests/Blocks/PageHeaderTests.cs +++ b/tests/TKCode.Arc.Tests/Blocks/PageHeaderTests.cs @@ -972,12 +972,7 @@ public void A_Collection_And_Actions_That_Outlive_The_Header_Do_Not_Keep_It_Aliv var crumbs = new System.Collections.ObjectModel.ObservableCollection { "Northline", "Projects", "Checkout redesign" }; var action = new PageHeaderAction { Label = "Follow", Icon = ArcIcons.Bell }; var reference = HeaderOver(crumbs, action); - for (var i = 0; i < 4 && reference.IsAlive; i++) - { - GC.Collect(); - GC.WaitForPendingFinalizers(); - Pump(); - } + TestHelpers.Collect(reference); reference.IsAlive.ShouldBeFalse(); crumbs.Add("Later"); action.Label = "Following"; diff --git a/tests/TKCode.Arc.Tests/Blocks/Showpieces/ActivityTerrainTests.cs b/tests/TKCode.Arc.Tests/Blocks/Showpieces/ActivityTerrainTests.cs index 824104f6..c5f74998 100644 --- a/tests/TKCode.Arc.Tests/Blocks/Showpieces/ActivityTerrainTests.cs +++ b/tests/TKCode.Arc.Tests/Blocks/Showpieces/ActivityTerrainTests.cs @@ -1,8 +1,6 @@ using TKCode.Arc.Gallery.Demos; using TKCode.Arc.Motion; using TKCode.Arc.Primitives; -using AtomUI.Theme.Algorithms; -using AtomUI.Theme.Configuration; using Avalonia; using Avalonia.Automation; using Avalonia.Automation.Peers; @@ -795,7 +793,7 @@ public async Task Theme_Change_Reresolves_The_Plot_Colors() Pump(); var swatch = TestHelpers.Descendant(terrain, ActivityTerrain.SwatchesPart).Children.OfType().First(); var light = TestHelpers.ColorOf(swatch.Background); - await TestHelpers.ApplyAsync(new ThemeConfigBuilder().WithAlgorithms(ThemeAlgorithm.Dark).Build()); + await TestHelpers.ApplyAsync(TestHelpers.Dark); Pump(); var dark = TestHelpers.ColorOf(swatch.Background); dark.ShouldNotBe(light); @@ -837,7 +835,7 @@ public async Task Demos_Render_In_Light_And_Dark(string name) terrain.HighlightedCell = terrain.Summary.Peak; Pump(); RenderPlots(window); - await TestHelpers.ApplyAsync(new ThemeConfigBuilder().WithAlgorithms(ThemeAlgorithm.Dark).Build()); + await TestHelpers.ApplyAsync(TestHelpers.Dark); Pump(); RenderPlots(window); } diff --git a/tests/TKCode.Arc.Tests/Blocks/Showpieces/CustomerGalaxyDemosTests.cs b/tests/TKCode.Arc.Tests/Blocks/Showpieces/CustomerGalaxyDemosTests.cs index da51a643..63319958 100644 --- a/tests/TKCode.Arc.Tests/Blocks/Showpieces/CustomerGalaxyDemosTests.cs +++ b/tests/TKCode.Arc.Tests/Blocks/Showpieces/CustomerGalaxyDemosTests.cs @@ -1,5 +1,3 @@ -using AtomUI.Theme.Algorithms; -using AtomUI.Theme.Configuration; using Avalonia.Controls; using Avalonia.Headless; using Avalonia.Headless.XUnit; @@ -28,7 +26,7 @@ public async Task Every_Demo_Renders_In_Light_And_Dark() { foreach (var dark in new[] { false, true }) { - await TestHelpers.ApplyAsync(dark ? new ThemeConfigBuilder().WithAlgorithms(ThemeAlgorithm.Dark).Build() : null); + await TestHelpers.ApplyAsync(dark ? TestHelpers.Dark : null); foreach (var create in Demos()) { var demo = create(); diff --git a/tests/TKCode.Arc.Tests/Blocks/Showpieces/CustomerGalaxyTests.cs b/tests/TKCode.Arc.Tests/Blocks/Showpieces/CustomerGalaxyTests.cs index 2ec3b552..fc0d5dbd 100644 --- a/tests/TKCode.Arc.Tests/Blocks/Showpieces/CustomerGalaxyTests.cs +++ b/tests/TKCode.Arc.Tests/Blocks/Showpieces/CustomerGalaxyTests.cs @@ -1,5 +1,3 @@ -using AtomUI.Theme.Algorithms; -using AtomUI.Theme.Configuration; using Avalonia; using Avalonia.Automation; using Avalonia.Controls; @@ -683,7 +681,7 @@ public async Task Colors_Come_From_The_Theme_Tokens_And_Follow_It() var success = galaxy.HealthStates.Single(s => s.Tone == GalaxyTone.Success); var light = galaxy.ResolveColor(success); light.ShouldBe(ArcTokens.GetColor(galaxy, TKCode.Arc.DesignTokens.ArcDesignTokenKind.Success)); - await TestHelpers.ApplyAsync(new ThemeConfigBuilder().WithAlgorithms(ThemeAlgorithm.Dark).Build()); + await TestHelpers.ApplyAsync(TestHelpers.Dark); Settle(); var dark = galaxy.ResolveColor(success); dark.ShouldNotBe(light); @@ -708,11 +706,10 @@ public async Task Colors_Are_Read_Again_When_The_Block_Returns_After_A_Theme_Swi var (galaxy, window) = Show(); try { - var layers = (Avalonia.Controls.Primitives.VisualLayerManager)window.Content!; - layers.Child = null; + window.Content = null; Settle(); - await TestHelpers.ApplyAsync(new ThemeConfigBuilder().WithAlgorithms(ThemeAlgorithm.Dark).Build()); - layers.Child = galaxy; + await TestHelpers.ApplyAsync(TestHelpers.Dark); + window.Content = galaxy; Settle(); var dark = ArcTokens.GetColor(galaxy, TKCode.Arc.DesignTokens.ArcDesignTokenKind.Success); dark.A.ShouldBeGreaterThan((byte)0); diff --git a/tests/TKCode.Arc.Tests/Blocks/Showpieces/ExplodedViewDemosTests.cs b/tests/TKCode.Arc.Tests/Blocks/Showpieces/ExplodedViewDemosTests.cs index dc0b3415..b3e99713 100644 --- a/tests/TKCode.Arc.Tests/Blocks/Showpieces/ExplodedViewDemosTests.cs +++ b/tests/TKCode.Arc.Tests/Blocks/Showpieces/ExplodedViewDemosTests.cs @@ -1,5 +1,3 @@ -using AtomUI.Theme.Algorithms; -using AtomUI.Theme.Configuration; using Avalonia; using Avalonia.Controls; using Avalonia.Headless; @@ -26,7 +24,7 @@ public async Task Every_Demo_Renders_In_Light_And_Dark() { foreach (var dark in new[] { false, true }) { - await TestHelpers.ApplyAsync(dark ? new ThemeConfigBuilder().WithAlgorithms(ThemeAlgorithm.Dark).Build() : null); + await TestHelpers.ApplyAsync(dark ? TestHelpers.Dark : null); foreach (var demo in demos) { var control = demo.Create(); diff --git a/tests/TKCode.Arc.Tests/Blocks/Showpieces/LayoutMorphRenderingTests.cs b/tests/TKCode.Arc.Tests/Blocks/Showpieces/LayoutMorphRenderingTests.cs index b752cf6c..6d9d6023 100644 --- a/tests/TKCode.Arc.Tests/Blocks/Showpieces/LayoutMorphRenderingTests.cs +++ b/tests/TKCode.Arc.Tests/Blocks/Showpieces/LayoutMorphRenderingTests.cs @@ -269,15 +269,14 @@ public void With_full_motion_copies_grow_a_few_per_frame_and_shrink_only_after_t public void Copies_go_with_the_stage_and_with_their_pictures() { var (window, morph) = Show(); - var layers = (Avalonia.Controls.Primitives.VisualLayerManager)window.Content!; try { var stage = morph.Stage; stage.Pictures.Count.ShouldBe(15); - layers.Child = null; + window.Content = null; Pump(); stage.Pictures.Count.ShouldBe(0); - layers.Child = morph; + window.Content = morph; Pump(); stage.Pictures.Count.ShouldBe(15); var first = PictureCards(stage)[0]; @@ -604,7 +603,6 @@ public void Leaving_the_window_mid_turn_drops_the_bitmaps_and_the_copies() var morph = Create(); morph.Layout = LayoutMorphMode.Carousel; var (window, _) = Show(morph, level: ArcMotionLevel.Full); - var layers = (Avalonia.Controls.Primitives.VisualLayerManager)window.Content!; try { var stage = morph.Stage; @@ -614,11 +612,11 @@ public void Leaving_the_window_mid_turn_drops_the_bitmaps_and_the_copies() morph.FocusItem(morph.EffectiveItems[6]); RunFrames(clock, 4); Enumerable.Range(0, stage.Count).Count(i => stage.IsCardCached(i)).ShouldBeGreaterThan(0); - layers.Child = null; + window.Content = null; Should.NotThrow(() => Pump()); Enumerable.Range(0, stage.Count).ShouldAllBe(i => !stage.IsCardCached(i) && stage.CardAt(i).CacheMode == null); stage.Pictures.Count.ShouldBe(0); - layers.Child = morph; + window.Content = morph; Should.NotThrow(() => Pump()); RunFrames(clock, 120); // Copies again for every picture the carousel shows smaller than its pixels (the front ones are drawn at full size). diff --git a/tests/TKCode.Arc.Tests/Blocks/Showpieces/LayoutMorphTests.cs b/tests/TKCode.Arc.Tests/Blocks/Showpieces/LayoutMorphTests.cs index 326d52c0..45072a47 100644 --- a/tests/TKCode.Arc.Tests/Blocks/Showpieces/LayoutMorphTests.cs +++ b/tests/TKCode.Arc.Tests/Blocks/Showpieces/LayoutMorphTests.cs @@ -1,6 +1,4 @@ using System.Collections.ObjectModel; -using AtomUI.Theme.Algorithms; -using AtomUI.Theme.Configuration; using TKCode.Arc.Gallery.Demos; using TKCode.Arc.Motion; using Avalonia; @@ -960,7 +958,7 @@ public async Task Theme_changes_recolor_the_cards() try { var light = morph.Stage.Palette.CardFill; - await TestHelpers.ApplyAsync(new ThemeConfigBuilder().WithAlgorithms(ThemeAlgorithm.Dark).Build()); + await TestHelpers.ApplyAsync(TestHelpers.Dark); Pump(); morph.Stage.Palette.CardFill.ShouldNotBe(light); morph.Stage.Palette.IsDark.ShouldBeTrue(); @@ -984,13 +982,12 @@ public async Task Theme_changes_recolor_the_cards() public void A_theme_change_then_a_detach_before_the_repaint_runs_does_not_throw() { var (window, morph) = Show(); - var layers = (Avalonia.Controls.Primitives.VisualLayerManager)window.Content!; try { // The theme variant changes (the stage posts its palette refresh), then the block leaves the window before // the posted job runs. window.RequestedThemeVariant = Avalonia.Styling.ThemeVariant.Dark; - layers.Child = null; + window.Content = null; Should.NotThrow(() => Pump()); TopLevel.GetTopLevel(morph.Stage).ShouldBeNull(); } @@ -1004,12 +1001,11 @@ public void A_theme_change_then_a_detach_before_the_repaint_runs_does_not_throw( public void Detaching_and_changing_items_while_detached_does_not_throw() { var (window, morph) = Show(); - var layers = (Avalonia.Controls.Primitives.VisualLayerManager)window.Content!; try { // A detach raises a resource change (the stage marks its palette dirty); the posted refresh, a collection // rebuild and a size pass all run while the block is out of the tree. - layers.Child = null; + window.Content = null; morph.Items.RemoveAt(0); morph.Items.Add(new LayoutMorphItem { Title = "Late arrival", Value = "$12" }); Should.NotThrow(() => Pump()); @@ -1023,7 +1019,7 @@ public void Detaching_and_changing_items_while_detached_does_not_throw() morph.Stage.Count.ShouldBe(24); // Back in the window: the cards paint with the window's palette again. - layers.Child = morph; + window.Content = morph; Pump(); morph.Stage.Palette.CardFill.ShouldBe(ThemeColor(morph.Stage, TKCode.Arc.DesignTokens.ArcDesignTokenKind.SurfaceRaised)); morph.Stage.Palette.CardFillBrush.ShouldNotBeNull(); @@ -1104,15 +1100,14 @@ public void A_morph_put_back_shows_what_changed_while_it_was_away() public async Task A_theme_switched_while_detached_is_picked_up_on_attach() { var (window, morph) = Show(); - var layers = (Avalonia.Controls.Primitives.VisualLayerManager)window.Content!; try { var light = morph.Stage.Palette.CardFill; - layers.Child = null; + window.Content = null; Pump(); - await TestHelpers.ApplyAsync(new ThemeConfigBuilder().WithAlgorithms(ThemeAlgorithm.Dark).Build()); + await TestHelpers.ApplyAsync(TestHelpers.Dark); Pump(); - layers.Child = morph; + window.Content = morph; Pump(); var dark = ThemeColor(morph.Stage, TKCode.Arc.DesignTokens.ArcDesignTokenKind.SurfaceRaised); dark.ShouldNotBe(light); @@ -1130,18 +1125,17 @@ public async Task A_theme_switched_while_detached_is_picked_up_on_attach() public async Task A_block_created_before_the_theme_switch_and_attached_after_paints_with_the_new_theme() { // The window is up and the block exists (with items, never attached) when the theme switches to dark. - var layers = new Avalonia.Controls.Primitives.VisualLayerManager(); - var window = new Window { Content = layers, Width = 1200, Height = 1100 }; + var window = new Window { Width = 1200, Height = 1100 }; ArcMotion.SetLevel(window, ArcMotionLevel.Full); window.Show(); var morph = Create(); try { Pump(); - await TestHelpers.ApplyAsync(new ThemeConfigBuilder().WithAlgorithms(ThemeAlgorithm.Dark).Build()); + await TestHelpers.ApplyAsync(TestHelpers.Dark); morph.Layout = LayoutMorphMode.Carousel; Should.NotThrow(() => Pump()); - layers.Child = morph; + window.Content = morph; Should.NotThrow(() => Pump()); var dark = ThemeColor(morph.Stage, TKCode.Arc.DesignTokens.ArcDesignTokenKind.SurfaceRaised); morph.Stage.Palette.CardFill.ShouldBe(dark); @@ -1185,7 +1179,7 @@ public async Task Demos_load_in_light_and_dark(string name, int cards, bool dark { if (dark) { - await TestHelpers.ApplyAsync(new ThemeConfigBuilder().WithAlgorithms(ThemeAlgorithm.Dark).Build()); + await TestHelpers.ApplyAsync(TestHelpers.Dark); } try { diff --git a/tests/TKCode.Arc.Tests/Blocks/Showpieces/RevenueGlobeTests.cs b/tests/TKCode.Arc.Tests/Blocks/Showpieces/RevenueGlobeTests.cs index 2a814109..29ef29a0 100644 --- a/tests/TKCode.Arc.Tests/Blocks/Showpieces/RevenueGlobeTests.cs +++ b/tests/TKCode.Arc.Tests/Blocks/Showpieces/RevenueGlobeTests.cs @@ -1,5 +1,3 @@ -using AtomUI.Theme.Algorithms; -using AtomUI.Theme.Configuration; using TKCode.Arc.Gallery.Demos; using TKCode.Arc.Motion; using Avalonia; @@ -789,7 +787,7 @@ public async Task Demos_Render_In_Light_And_Dark() { foreach (var dark in new[] { false, true }) { - await TestHelpers.ApplyAsync(dark ? new ThemeConfigBuilder().WithAlgorithms(ThemeAlgorithm.Dark).Build() : null); + await TestHelpers.ApplyAsync(dark ? TestHelpers.Dark : null); foreach (var create in demos) { var demo = create(); diff --git a/tests/TKCode.Arc.Tests/Blocks/Showpieces/SemanticZoomDemosTests.cs b/tests/TKCode.Arc.Tests/Blocks/Showpieces/SemanticZoomDemosTests.cs index 8de7504a..edfdcfd0 100644 --- a/tests/TKCode.Arc.Tests/Blocks/Showpieces/SemanticZoomDemosTests.cs +++ b/tests/TKCode.Arc.Tests/Blocks/Showpieces/SemanticZoomDemosTests.cs @@ -1,5 +1,3 @@ -using AtomUI.Theme.Algorithms; -using AtomUI.Theme.Configuration; using Avalonia.Controls; using Avalonia.Headless; using Avalonia.Headless.XUnit; @@ -27,7 +25,7 @@ public async Task Every_Demo_Renders_In_Light_And_Dark() { foreach (var dark in new[] { false, true }) { - await TestHelpers.ApplyAsync(dark ? new ThemeConfigBuilder().WithAlgorithms(ThemeAlgorithm.Dark).Build() : null); + await TestHelpers.ApplyAsync(dark ? TestHelpers.Dark : null); foreach (var create in Demos()) { var demo = create(); diff --git a/tests/TKCode.Arc.Tests/Blocks/Showpieces/SemanticZoomTests.cs b/tests/TKCode.Arc.Tests/Blocks/Showpieces/SemanticZoomTests.cs index f8be35d3..fff9753f 100644 --- a/tests/TKCode.Arc.Tests/Blocks/Showpieces/SemanticZoomTests.cs +++ b/tests/TKCode.Arc.Tests/Blocks/Showpieces/SemanticZoomTests.cs @@ -1,5 +1,3 @@ -using AtomUI.Theme.Algorithms; -using AtomUI.Theme.Configuration; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Templates; @@ -644,7 +642,7 @@ public async Task Theme_Changes_Recolor_The_Map() { var stage = Stage(map); var light = stage.Palette.Surface; - await TestHelpers.ApplyAsync(new ThemeConfigBuilder().WithAlgorithms(ThemeAlgorithm.Dark).Build()); + await TestHelpers.ApplyAsync(TestHelpers.Dark); Settle(); stage.Palette.Surface.ShouldNotBe(light); using var bitmap = new Avalonia.Media.Imaging.RenderTargetBitmap(new PixelSize(64, 64)); @@ -753,12 +751,7 @@ public void A_Map_And_Its_Panel_Are_Collected_While_The_Organization_Lives_On() var org = SemanticZoomDemoData.Create(); var map = MapOver(org); var panel = PanelOver(org.Teams[0].Projects[0]); - for (var i = 0; i < 4 && (map.IsAlive || panel.IsAlive); i++) - { - GC.Collect(); - GC.WaitForPendingFinalizers(); - Settle(); - } + TestHelpers.Collect(map, panel); map.IsAlive.ShouldBeFalse(); panel.IsAlive.ShouldBeFalse(); // The organization goes on without them. diff --git a/tests/TKCode.Arc.Tests/Blocks/Showpieces/ShowpieceStageTests.cs b/tests/TKCode.Arc.Tests/Blocks/Showpieces/ShowpieceStageTests.cs index aeef9331..d61559a7 100644 --- a/tests/TKCode.Arc.Tests/Blocks/Showpieces/ShowpieceStageTests.cs +++ b/tests/TKCode.Arc.Tests/Blocks/Showpieces/ShowpieceStageTests.cs @@ -1,6 +1,4 @@ using TKCode.Arc.Motion; -using AtomUI.Theme.Algorithms; -using AtomUI.Theme.Configuration; using Avalonia; using Avalonia.Automation.Peers; using Avalonia.Controls; @@ -342,7 +340,7 @@ public async Task Theme_Changes_Clear_The_Text_Cache() stage.Cache.Brush(Colors.Black, 0.5); stage.Cache.Count.ShouldBe(1); var changes = stage.ThemeChanges; - await TestHelpers.ApplyAsync(new ThemeConfigBuilder().WithAlgorithms(ThemeAlgorithm.Dark).Build()); + await TestHelpers.ApplyAsync(TestHelpers.Dark); stage.ThemeChanges.ShouldBeGreaterThan(changes); stage.Cache.Count.ShouldBe(0); stage.Cache.BrushCount.ShouldBe(0); diff --git a/tests/TKCode.Arc.Tests/Blocks/Showpieces/ShowpiecesLifecycleTests.cs b/tests/TKCode.Arc.Tests/Blocks/Showpieces/ShowpiecesLifecycleTests.cs index b605a080..b2f2d767 100644 --- a/tests/TKCode.Arc.Tests/Blocks/Showpieces/ShowpiecesLifecycleTests.cs +++ b/tests/TKCode.Arc.Tests/Blocks/Showpieces/ShowpiecesLifecycleTests.cs @@ -1,7 +1,3 @@ -using AtomUI; -using AtomUI.Theme; -using AtomUI.Theme.Algorithms; -using AtomUI.Theme.Configuration; using TKCode.Arc.Gallery.Demos; using TKCode.Arc.Motion; using Avalonia; @@ -28,11 +24,10 @@ private static readonly (string Name, Func Create)[] s_blocks = ("RevenueGlobe", () => new RevenueGlobePreviewDemo()), ]; - private static Task ApplyWithoutRunningJobs(ThemeConfig? config) + private static Task ApplyWithoutRunningJobs(Action? configure) { - var manager = Application.Current!.GetThemeManager()!; - return manager.ApplyThemeAsync(new ThemeRequest(IThemeManager.DEFAULT_THEME_ID, config, ThemeTransitionReason.UserRequest), - TestContext.Current.CancellationToken); + ArcTheme.Manager.Apply(configure is null ? ArcThemeState.Default : ArcThemeState.Default.With(configure)); + return Task.CompletedTask; } private static void RunQueue() @@ -82,7 +77,7 @@ public async Task A_Theme_Switch_Followed_By_Leaving_Leaves_No_Failing_Job() window.Width = 900; window.Height = 700; RunQueue(); - await ApplyWithoutRunningJobs(new ThemeConfigBuilder().WithAlgorithms(ThemeAlgorithm.Dark).Build()); + await ApplyWithoutRunningJobs(TestHelpers.Dark); window.Content = null; window.Close(); RunQueue(name, failures); @@ -116,7 +111,7 @@ public async Task A_Theme_Switch_While_The_Block_Stays_Leaves_No_Failing_Job() window.Width = 900; window.Height = 700; RunQueue(); - await ApplyWithoutRunningJobs(new ThemeConfigBuilder().WithAlgorithms(ThemeAlgorithm.Dark).Build()); + await ApplyWithoutRunningJobs(TestHelpers.Dark); RunQueue(name, failures); await ApplyWithoutRunningJobs(null); RunQueue(name, failures); diff --git a/tests/TKCode.Arc.Tests/Buttons/ButtonsMotionTests.cs b/tests/TKCode.Arc.Tests/Buttons/ButtonsMotionTests.cs index 7a401e0f..f8409983 100644 --- a/tests/TKCode.Arc.Tests/Buttons/ButtonsMotionTests.cs +++ b/tests/TKCode.Arc.Tests/Buttons/ButtonsMotionTests.cs @@ -153,8 +153,10 @@ public async Task ThemeSwitcher_Reveals_The_New_Theme_Under_A_Snapshot() using (clock.Install()) { var switcher = new ThemeSwitcher { Variant = ThemeSwitcherVariant.Reveal }; - // An AtomUI window has the overlay layer the snapshot lives in. - var window = new AtomUI.Desktop.Controls.Window { Content = new StackPanel { Children = { switcher } }, Width = 400, Height = 300 }; + // The overlay layer holds the snapshot. Avalonia's default Window template has none (AtomUI's window had one), so + // the content sits in a VisualLayerManager with the overlay layer on, as in GalleryWindow's template. + var layers = new VisualLayerManager { EnableOverlayLayer = true, Child = new StackPanel { Children = { switcher } } }; + var window = new Window { Content = layers, Width = 400, Height = 300 }; ArcMotion.SetLevel(window, ArcMotionLevel.Full); window.Show(); Dispatcher.UIThread.RunJobs(); diff --git a/tests/TKCode.Arc.Tests/Buttons/ConfirmMorphTests.cs b/tests/TKCode.Arc.Tests/Buttons/ConfirmMorphTests.cs index f1876a41..f11c04f9 100644 --- a/tests/TKCode.Arc.Tests/Buttons/ConfirmMorphTests.cs +++ b/tests/TKCode.Arc.Tests/Buttons/ConfirmMorphTests.cs @@ -178,7 +178,9 @@ public void Countdown_Can_Be_Stopped_Or_Runs_The_Action() Pump((int)ConfirmMorph.ArmDelay.TotalMilliseconds + 40); Part(morph, "PART_Confirm").Focus(NavigationMethod.Tab); Press(window, PhysicalKey.Space); - WaitFor(() => morph.State == ConfirmMorphState.Done, 4000).ShouldBeTrue(); + // A one-second countdown on the wall clock: WaitFor returns when it lands, its default bound only covers a + // loaded runner. + WaitFor(() => morph.State == ConfirmMorphState.Done).ShouldBeTrue(); runs.ShouldBe(1); } finally diff --git a/tests/TKCode.Arc.Tests/Buttons/ThemeSwitcherTests.cs b/tests/TKCode.Arc.Tests/Buttons/ThemeSwitcherTests.cs index 2824565d..86838b74 100644 --- a/tests/TKCode.Arc.Tests/Buttons/ThemeSwitcherTests.cs +++ b/tests/TKCode.Arc.Tests/Buttons/ThemeSwitcherTests.cs @@ -1,5 +1,3 @@ -using AtomUI.Theme.Algorithms; -using AtomUI.Theme.Configuration; using Avalonia; using Avalonia.Automation; using Avalonia.Automation.Provider; @@ -134,23 +132,56 @@ public void The_Snapshot_Draws_The_Whole_Bitmap_On_Scaled_Displays(double scalin } [AvaloniaFact] - public void Theme_Config_Keeps_The_Rest_Of_The_Theme_And_Toggles_Dark() + public void The_Reveal_Runs_In_A_Plain_Window() { - var previous = new ThemeConfigBuilder() - .WithAlgorithms(ThemeAlgorithm.Default, ThemeAlgorithm.Compact) - .WithToken("FontSize", "15") - .WithArc(arc => arc.Accent(ArcAccentPreset.Violet)) - .Build(); - var dark = ThemeSwitcherThemeApplier.Build(previous, null, dark: true, accent: null); - dark.Algorithms.ShouldBe([ThemeAlgorithm.Default, ThemeAlgorithm.Compact, ThemeAlgorithm.Dark]); - dark.Tokens["FontSize"].ShouldBe("15"); - dark.Controls.Count.ShouldBe(previous.Controls.Count); - var light = ThemeSwitcherThemeApplier.Build(dark, null, dark: false, accent: null); - light.Algorithms.ShouldBe([ThemeAlgorithm.Default, ThemeAlgorithm.Compact]); + // No layer manager of the test's own: the overlay layer comes from Arc's base Window theme. + var trigger = new Border { Width = 40, Height = 20 }; + var window = new Window { Content = trigger, Width = 400, Height = 300 }; + Motion.ArcMotion.SetLevel(window, Motion.ArcMotionLevel.Full); + window.Show(); + Dispatcher.UIThread.RunJobs(); + try + { + var layer = Avalonia.Controls.Primitives.OverlayLayer.GetOverlayLayer(trigger); + layer.ShouldNotBeNull(); + var snapshotShown = false; + var run = ThemeSwitcherTransition.RunAsync(trigger, ThemeSwitcherVariant.Reveal, () => + { + snapshotShown = layer.Children.OfType().Any(); + return Task.CompletedTask; + }); + snapshotShown.ShouldBeTrue(); + TestHelpers.PumpUntil(() => run.IsCompleted).ShouldBeTrue(); + layer.Children.OfType().ShouldBeEmpty(); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public async Task Theme_Change_Keeps_The_Rest_Of_The_Theme_And_Toggles_Dark() + { + var host = new Border(); + var window = TestHelpers.Show(host); + try + { + await TestHelpers.ApplyAsync(t => t.Accent(ArcAccentPreset.Violet).Token("RadiusControl", "3")); + await ThemeSwitcherThemeApplier.ApplyAsync(host, dark: true); + ArcTheme.State.Appearance.ShouldBe(ArcAppearance.Dark); + ArcTheme.State.Accent.ShouldBe(ArcPalette.GetSeed(ArcAccentPreset.Violet)); + ArcTheme.State.TokenOverrides[ArcThemeState.GlobalIdentity]["RadiusControl"].ShouldBe("3"); + host.ActualThemeVariant.ShouldBe(Avalonia.Styling.ThemeVariant.Dark); - // Before any request was observed, the current accent preset is carried over. - var fresh = ThemeSwitcherThemeApplier.Build(null, [ThemeAlgorithm.Default], dark: true, accent: ArcAccentPreset.Blue); - fresh.Algorithms.ShouldBe([ThemeAlgorithm.Default, ThemeAlgorithm.Dark]); - fresh.Controls.Count.ShouldBe(1); + await ThemeSwitcherThemeApplier.ApplyAsync(host, dark: false); + ArcTheme.State.Appearance.ShouldBe(ArcAppearance.Light); + ArcTheme.State.Accent.ShouldBe(ArcPalette.GetSeed(ArcAccentPreset.Violet)); + } + finally + { + window.Close(); + await TestHelpers.ApplyAsync(null); + } } } diff --git a/tests/TKCode.Arc.Tests/Content/MarkdownTests.cs b/tests/TKCode.Arc.Tests/Content/MarkdownTests.cs index f4c622bc..6c71564d 100644 --- a/tests/TKCode.Arc.Tests/Content/MarkdownTests.cs +++ b/tests/TKCode.Arc.Tests/Content/MarkdownTests.cs @@ -1,6 +1,4 @@ using TKCode.Arc.DesignTokens; -using AtomUI.Theme.Algorithms; -using AtomUI.Theme.Configuration; using Avalonia.Controls; using Avalonia.Headless.XUnit; using Avalonia.Interactivity; @@ -139,10 +137,7 @@ public async Task Follows_Dark_Mode_And_The_Accent() var window = Show(markdown); try { - await TestHelpers.ApplyAsync(new ThemeConfigBuilder() - .WithAlgorithms(ThemeAlgorithm.Dark) - .WithArc(a => a.Accent(ArcAccentPreset.Violet)) - .Build()); + await TestHelpers.ApplyAsync(t => t.Appearance(ArcAppearance.Dark).Accent(ArcAccentPreset.Violet)); var view = markdown.View!; view.ActualThemeVariant.ShouldBe(ThemeVariant.Dark); var style = MarkdownStyle.Resolve(view); diff --git a/tests/TKCode.Arc.Tests/CorePrimitives/ArcImageTests.cs b/tests/TKCode.Arc.Tests/CorePrimitives/ArcImageTests.cs new file mode 100644 index 00000000..5a6854ed --- /dev/null +++ b/tests/TKCode.Arc.Tests/CorePrimitives/ArcImageTests.cs @@ -0,0 +1,348 @@ +using TKCode.Arc.Primitives; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using Avalonia.Threading; + +namespace TKCode.Arc.Tests.CorePrimitives; + +/// +/// Core : loading state, the shared cache, cancellation and failures, through a fake +/// (no network). +/// +public class ArcImageTests +{ + /// A 1×1 PNG. + private static readonly byte[] s_png = Convert.FromBase64String( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="); + + /// Answers each request when the test says so; records the calls and their tokens. + private sealed class FakeLoader : IArcImageLoader + { + private readonly bool _manual; + + public FakeLoader(bool manual = false) => _manual = manual; + + public List<(ImageSource Source, CancellationToken Token, TaskCompletionSource Answer)> Calls { get; } = new(); + + public bool Fail { get; set; } + + public Task OpenAsync(ImageSource source, CancellationToken cancellationToken) + { + var answer = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + cancellationToken.Register(() => answer.TrySetCanceled(cancellationToken)); + Calls.Add((source, cancellationToken, answer)); + if (!_manual) + { + Answer(Calls.Count - 1); + } + return answer.Task; + } + + public void Answer(int call) + { + if (Fail) + { + Calls[call].Answer.TrySetException(new HttpRequestException("404")); + } + else + { + Calls[call].Answer.TrySetResult(new MemoryStream(s_png)); + } + } + } + + private static ImageSource Unique(string name = "photo") => ImageSource.Parse($"https://images.test/{Guid.NewGuid():N}/{name}.png"); + + private static (ArcImage Image, Window Window) Show(FakeLoader loader, ImageSource? source = null) + { + var image = new ArcImage { Width = 32, Height = 32, Stretch = Stretch.UniformToFill, Loader = loader, Source = source }; + return (image, TestHelpers.Show(new StackPanel { Children = { image } })); + } + + [AvaloniaFact] + public void Loads_Through_The_Loader_And_Reports_Its_State() + { + var loader = new FakeLoader(manual: true); + var source = Unique(); + var image = new ArcImage { Width = 32, Height = 32, Loader = loader }; + var states = new List(); + image.PropertyChanged += (_, e) => + { + if (e.Property == ArcImage.IsLoadingProperty || e.Property == ArcImage.IsImageLoadedProperty || e.Property == ArcImage.IsFailedProperty) + { + states.Add($"{e.Property.Name}={e.NewValue}"); + } + }; + image.Source = source; + // Pending before it is attached; nothing is read until it is. + image.IsLoading.ShouldBeTrue(); + image.IsImageLoaded.ShouldBeFalse(); + loader.Calls.ShouldBeEmpty(); + + var window = TestHelpers.Show(new StackPanel { Children = { image } }); + try + { + TestHelpers.PumpUntil(() => loader.Calls.Count == 1).ShouldBeTrue(); + loader.Calls[0].Source.ShouldBeSameAs(source); + image.IsLoading.ShouldBeTrue(); + image.CurrentImage.ShouldBeNull(); + + loader.Answer(0); + TestHelpers.PumpUntil(() => image.IsImageLoaded).ShouldBeTrue(); + image.IsLoading.ShouldBeFalse(); + image.IsFailed.ShouldBeFalse(); + image.CurrentImage.ShouldNotBeNull(); + states.ShouldBe(["IsLoading=True", "IsImageLoaded=True", "IsLoading=False"]); + ArcImageCache.Contains(source).ShouldBeTrue(); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void A_Cached_Picture_Shows_At_Once_Without_Loading() + { + var loader = new FakeLoader(); + var url = $"https://images.test/{Guid.NewGuid():N}/cached.png"; + var (first, window) = Show(loader, ImageSource.Parse(url)); + try + { + TestHelpers.PumpUntil(() => first.IsImageLoaded).ShouldBeTrue(); + loader.Calls.Count.ShouldBe(1); + + // A new source object for the same address is served from the cache, synchronously. + var second = new ArcImage { Loader = loader }; + var sawLoading = false; + second.PropertyChanged += (_, e) => sawLoading |= e.Property == ArcImage.IsLoadingProperty && e.NewValue is true; + second.Source = ImageSource.Parse(url); + second.IsImageLoaded.ShouldBeTrue(); + second.CurrentImage.ShouldBeSameAs(first.CurrentImage); + sawLoading.ShouldBeFalse(); + ((StackPanel)first.Parent!).Children.Add(second); + Dispatcher.UIThread.RunJobs(); + loader.Calls.Count.ShouldBe(1); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void Images_Waiting_For_The_Same_Address_Share_One_Request() + { + var loader = new FakeLoader(manual: true); + var url = $"https://images.test/{Guid.NewGuid():N}/shared.png"; + var a = new ArcImage { Loader = loader, Source = ImageSource.Parse(url) }; + var b = new ArcImage { Loader = loader, Source = ImageSource.Parse(url) }; + var window = TestHelpers.Show(new StackPanel { Children = { a, b } }); + try + { + TestHelpers.PumpUntil(() => loader.Calls.Count > 0).ShouldBeTrue(); + Dispatcher.UIThread.RunJobs(); + loader.Calls.Count.ShouldBe(1); + + // One of them leaves: the shared download carries on for the other. + a.Source = null; + Dispatcher.UIThread.RunJobs(); + loader.Calls[0].Token.IsCancellationRequested.ShouldBeFalse(); + + loader.Answer(0); + TestHelpers.PumpUntil(() => b.IsImageLoaded).ShouldBeTrue(); + a.IsImageLoaded.ShouldBeFalse(); + a.IsLoading.ShouldBeFalse(); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void Changing_The_Source_Cancels_The_Running_Load() + { + var loader = new FakeLoader(manual: true); + var (image, window) = Show(loader, Unique("first")); + try + { + TestHelpers.PumpUntil(() => loader.Calls.Count == 1).ShouldBeTrue(); + var next = Unique("second"); + image.Source = next; + Dispatcher.UIThread.RunJobs(); + loader.Calls[0].Token.IsCancellationRequested.ShouldBeTrue(); + image.IsLoading.ShouldBeTrue(); + + TestHelpers.PumpUntil(() => loader.Calls.Count == 2).ShouldBeTrue(); + loader.Calls[1].Source.ShouldBeSameAs(next); + // The first answer arriving late changes nothing. + loader.Calls[0].Answer.TrySetResult(new MemoryStream(s_png)); + Dispatcher.UIThread.RunJobs(); + image.IsImageLoaded.ShouldBeFalse(); + + loader.Answer(1); + TestHelpers.PumpUntil(() => image.IsImageLoaded).ShouldBeTrue(); + image.IsLoading.ShouldBeFalse(); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void Leaving_The_Tree_Cancels_And_Coming_Back_Resumes() + { + var loader = new FakeLoader(manual: true); + var (image, window) = Show(loader, Unique()); + try + { + TestHelpers.PumpUntil(() => loader.Calls.Count == 1).ShouldBeTrue(); + var panel = (StackPanel)image.Parent!; + panel.Children.Remove(image); + Dispatcher.UIThread.RunJobs(); + loader.Calls[0].Token.IsCancellationRequested.ShouldBeTrue(); + image.IsLoading.ShouldBeTrue(); + + panel.Children.Add(image); + TestHelpers.PumpUntil(() => loader.Calls.Count == 2).ShouldBeTrue(); + loader.Answer(1); + TestHelpers.PumpUntil(() => image.IsImageLoaded).ShouldBeTrue(); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void A_Failed_Load_Reports_IsFailed_And_A_New_Source_Retries() + { + var loader = new FakeLoader { Fail = true }; + var url = $"https://images.test/{Guid.NewGuid():N}/missing.png"; + var (image, window) = Show(loader, ImageSource.Parse(url)); + try + { + TestHelpers.PumpUntil(() => image.IsFailed).ShouldBeTrue(); + image.IsLoading.ShouldBeFalse(); + image.IsImageLoaded.ShouldBeFalse(); + image.LoadError.ShouldBeOfType(); + ArcImageCache.Contains(ImageSource.Parse(url)).ShouldBeFalse(); + + // Failures are not cached: assigning the address again asks for it again. + loader.Fail = false; + image.Source = ImageSource.Parse(url); + image.IsFailed.ShouldBeFalse(); + image.IsLoading.ShouldBeTrue(); + TestHelpers.PumpUntil(() => image.IsImageLoaded).ShouldBeTrue(); + loader.Calls.Count.ShouldBe(2); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void A_Bitmap_Source_Shows_At_Once_And_Null_Clears() + { + var loader = new FakeLoader(); + var bitmap = new WriteableBitmap(new PixelSize(4, 2), new Vector(96, 96), PixelFormats.Bgra8888, AlphaFormat.Premul); + var (image, window) = Show(loader, new BitmapImageSource(bitmap)); + try + { + image.IsImageLoaded.ShouldBeTrue(); + image.CurrentImage.ShouldBeSameAs(bitmap); + Dispatcher.UIThread.RunJobs(); + loader.Calls.ShouldBeEmpty(); + + image.Source = null; + image.IsImageLoaded.ShouldBeFalse(); + image.IsLoading.ShouldBeFalse(); + image.CurrentImage.ShouldBeNull(); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void Loader_Is_Inherited_From_The_Window() + { + var loader = new FakeLoader(); + var image = new ArcImage(); + var window = TestHelpers.Show(new StackPanel { Children = { image } }); + try + { + ArcImage.SetLoader(window, loader); + image.Source = Unique(); + TestHelpers.PumpUntil(() => image.IsImageLoaded).ShouldBeTrue(); + loader.Calls.Count.ShouldBeGreaterThan(0); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void Avatar_Shows_The_Photo_Or_Falls_Back_To_Initials() + { + var loader = new FakeLoader(manual: true); + var avatar = new Avatar { DisplayName = "Ada Lovelace", Source = Unique() }; + ArcImage.SetLoader(avatar, loader); + var window = TestHelpers.Show(avatar); + try + { + avatar.Classes.Contains(Avatar.ShowImagePseudoClass).ShouldBeTrue(); + avatar.Classes.Contains(Avatar.ImageLoadingPseudoClass).ShouldBeTrue(); + TestHelpers.PumpUntil(() => loader.Calls.Count == 1).ShouldBeTrue(); + loader.Answer(0); + TestHelpers.PumpUntil(() => !avatar.Classes.Contains(Avatar.ImageLoadingPseudoClass)).ShouldBeTrue(); + avatar.Classes.Contains(Avatar.ShowImagePseudoClass).ShouldBeTrue(); + + loader.Fail = true; + avatar.Source = Unique("broken"); + TestHelpers.PumpUntil(() => loader.Calls.Count == 2).ShouldBeTrue(); + loader.Answer(1); + TestHelpers.PumpUntil(() => avatar.Classes.Contains(Avatar.FallbackPseudoClass)).ShouldBeTrue(); + avatar.Classes.Contains(Avatar.ShowImagePseudoClass).ShouldBeFalse(); + } + finally + { + window.Close(); + } + } + + [Fact] + public void Parses_Addresses_And_File_Paths() + { + ImageSource.Parse("https://i.pravatar.cc/160?img=47").ShouldBeOfType().Uri.Host.ShouldBe("i.pravatar.cc"); + ImageSource.Parse("avares://TKCode.Arc.Gallery/Assets/logo.png").CacheKey.ShouldBe("avares://TKCode.Arc.Gallery/Assets/logo.png"); + ImageSource.TryParse(Path.Combine(Path.GetTempPath(), "photo.png"), out var file).ShouldBeTrue(); + ((UriImageSource)file).Uri.IsFile.ShouldBeTrue(); + ImageSource.TryParse("photo.png", out _).ShouldBeFalse(); + ImageSource.TryParse("ftp://host/photo.png", out _).ShouldBeFalse(); + ImageSource.TryParse(null, out _).ShouldBeFalse(); + Should.Throw(() => ImageSource.Parse("not an address")); + ArcImageLoader.DecodeDataUri("data:image/png;base64," + Convert.ToBase64String(s_png)).ShouldBe(s_png); + } + + [Fact] + public void A_Picture_Is_Reduced_Only_When_Much_Larger_Than_Its_Slot() + { + // Cover fit of a 640×400 photo into a 32×32 slot needs 52×32; Uniform needs 32×20. + ArcImageDecoder.TargetPixels(new PixelSize(640, 400), new Size(32, 32), Stretch.UniformToFill).ShouldBe(new PixelSize(52, 32)); + ArcImageDecoder.TargetPixels(new PixelSize(640, 400), new Size(32, 32), Stretch.Uniform).ShouldBe(new PixelSize(32, 20)); + ArcImageDecoder.TargetPixels(new PixelSize(640, 400), new Size(double.NaN, 100), Stretch.Uniform).ShouldBe(new PixelSize(160, 100)); + ArcImageDecoder.TargetPixels(new PixelSize(640, 400), new Size(1280, 800), Stretch.UniformToFill).ShouldBeNull(); + ArcImageDecoder.TargetPixels(new PixelSize(640, 400), new Size(32, 32), Stretch.None).ShouldBeNull(); + ArcImageDecoder.TargetPixels(new PixelSize(640, 400), new Size(double.NaN, double.NaN), Stretch.Fill).ShouldBeNull(); + } +} diff --git a/tests/TKCode.Arc.Tests/Gallery/GalleryDemoAppTests.cs b/tests/TKCode.Arc.Tests/Gallery/GalleryDemoAppTests.cs index f4f8087f..a5f8d5a4 100644 --- a/tests/TKCode.Arc.Tests/Gallery/GalleryDemoAppTests.cs +++ b/tests/TKCode.Arc.Tests/Gallery/GalleryDemoAppTests.cs @@ -69,6 +69,10 @@ public void The_Home_Page_Live_Demo_Button_Opens_The_Overview_Without_The_Galler [AvaloniaFact] public void The_Sidebar_Highlight_Glides_To_The_Chosen_Page_Through_The_Page_Switch() { + // The glide runs on frames the test hands out: on the window's own frames a slow page switch (a loaded test run) + // lets the spring finish before the assertions look at it. + var frames = new ManualFrameClock(); + using var _ = frames.Install(); var window = Open(); ArcMotion.SetLevel(window, ArcMotionLevel.Full); try @@ -88,8 +92,19 @@ public void The_Sidebar_Highlight_Glides_To_The_Chosen_Page_Through_The_Page_Swi // jump it straight onto the row. var row = nav.GetVisualDescendants().OfType().Single(r => r.IsSelected); var target = row.TranslatePoint(default, highlight.GetVisualParent()!)!.Value; + target.Y.ShouldBeGreaterThan(from.Y + 1); + highlight.CurrentRect.Y.ShouldBe(from.Y, 0.5, "no frame has run, so the layout passes have not moved it"); + + frames.Advance(TimeSpan.FromMilliseconds(80)); + Pump(); + window.UpdateLayout(); + highlight.CurrentRect.Y.ShouldBeGreaterThan(from.Y); highlight.CurrentRect.Y.ShouldBeLessThan(target.Y - 1, "still on its way down from the overview row"); - highlight.CurrentRect.Y.ShouldBeGreaterThanOrEqualTo(from.Y); + + frames.Advance(TimeSpan.FromSeconds(2)); + Pump(); + window.UpdateLayout(); + highlight.CurrentRect.Y.ShouldBe(target.Y, 0.5); } finally { diff --git a/tests/TKCode.Arc.Tests/Gallery/GalleryDemosTests.cs b/tests/TKCode.Arc.Tests/Gallery/GalleryDemosTests.cs index 1fc00f6b..34ef1834 100644 --- a/tests/TKCode.Arc.Tests/Gallery/GalleryDemosTests.cs +++ b/tests/TKCode.Arc.Tests/Gallery/GalleryDemosTests.cs @@ -1,4 +1,3 @@ -using AtomUI.Localization; using TKCode.Arc.Gallery.Catalog; using TKCode.Arc.Gallery.Docs; using TKCode.Arc.Gallery.Pages; @@ -10,6 +9,7 @@ using Avalonia.Input; using Avalonia.Threading; using Avalonia.VisualTree; +using TKCode.Arc.Localization; namespace TKCode.Arc.Tests.Gallery; @@ -18,7 +18,7 @@ public class GalleryDemosTests { private static GalleryEntry Entry(string className) => GalleryCatalog.AllEntries.First(e => e.ClassName == className); - private static ILanguageManager Languages() => global::AtomUI.ApplicationExtensions.GetLanguageManager(Application.Current!)!; + private static ArcLanguageManager Languages() => ArcLanguageManager.Current!; /// Opens 's page and waits until StreamMark has parsed and laid out its document. private static async Task<(GalleryPageView Page, Window Window)> OpenDocsPage(string className, double width = 1440) @@ -73,6 +73,9 @@ public void Every_Demo_Renders_And_Has_Its_Files() window.Width = 800; window.Height = 600; Dispatcher.UIThread.RunJobs(); + // A built-in TextBox has no template of its own (nothing supplies one): a demo uses Arc's fields. + control.GetVisualDescendants().OfType().Where(t => t.IsEffectivelyVisible) + .ShouldAllBe(t => t.GetVisualChildren().Any(), demo.Id); } finally { @@ -252,7 +255,7 @@ public async Task Docs_Page_Follows_The_Language() try { page.SectionTitles[0].ShouldBe("Preview"); - languages.ChangeLanguage(LanguageTags.ZhCN); + languages.Set(LanguageTags.ZhCN); Dispatcher.UIThread.RunJobs(); page.Docs!.Text.ShouldNotBeNull().ShouldContain("## 何时使用"); for (var i = 0; i < 300 && !page.SectionTitles.Contains("何时使用"); i++) @@ -267,7 +270,7 @@ public async Task Docs_Page_Follows_The_Language() } finally { - languages.ChangeLanguage(LanguageTags.EnUS); + languages.Set(LanguageTags.EnUS); window.Close(); } } diff --git a/tests/TKCode.Arc.Tests/Gallery/GalleryDocsTests.cs b/tests/TKCode.Arc.Tests/Gallery/GalleryDocsTests.cs index 0f590a13..8a3de188 100644 --- a/tests/TKCode.Arc.Tests/Gallery/GalleryDocsTests.cs +++ b/tests/TKCode.Arc.Tests/Gallery/GalleryDocsTests.cs @@ -1,4 +1,3 @@ -using AtomUI.Localization; using TKCode.Arc.Gallery.Catalog; using TKCode.Arc.Gallery.Docs; using TKCode.Arc.Gallery.Pages; @@ -8,6 +7,7 @@ using Avalonia.Headless.XUnit; using Avalonia.Threading; using Avalonia.VisualTree; +using TKCode.Arc.Localization; namespace TKCode.Arc.Tests.Gallery; @@ -16,7 +16,7 @@ public class GalleryDocsTests { private static GalleryEntry Entry(string className) => GalleryCatalog.AllEntries.First(e => e.ClassName == className); - private static ILanguageManager Languages() => global::AtomUI.ApplicationExtensions.GetLanguageManager(Application.Current!)!; + private static ArcLanguageManager Languages() => ArcLanguageManager.Current!; [AvaloniaFact] public void Every_Page_Belongs_To_A_Gallery_Entry_And_Has_An_English_Source() diff --git a/tests/TKCode.Arc.Tests/Gallery/GalleryEventLogDemoTests.cs b/tests/TKCode.Arc.Tests/Gallery/GalleryEventLogDemoTests.cs new file mode 100644 index 00000000..99815d8c --- /dev/null +++ b/tests/TKCode.Arc.Tests/Gallery/GalleryEventLogDemoTests.cs @@ -0,0 +1,41 @@ +using Avalonia.Controls; +using Avalonia.Controls.Presenters; +using Avalonia.Headless; +using Avalonia.Headless.XUnit; +using Avalonia.Threading; +using Avalonia.VisualTree; +using TKCode.Arc.Gallery.Demos; + +namespace TKCode.Arc.Tests.Gallery; + +/// +/// The EnvVariables "event log" demo's search box: it used to be a built-in TextBox, which has no template, so it drew +/// nothing; it is Arc's Input now. +/// +public class GalleryEventLogDemoTests +{ + [AvaloniaFact] + public void The_Search_Box_Renders_Its_Text_And_Binds_Both_Ways() + { + var demo = new EnvVariablesEventLogDemo(); + var window = TestHelpers.Show(demo); + window.Width = 900; + window.Height = 700; + Dispatcher.UIThread.RunJobs(); + var search = demo.FindControl("Search").ShouldNotBeNull(); + var block = demo.FindControl("Block").ShouldNotBeNull(); + + search.Focus(); + window.KeyTextInput("API"); + Dispatcher.UIThread.RunJobs(); + var presenter = search.GetVisualDescendants().OfType().ShouldHaveSingleItem(); + presenter.Text.ShouldBe("API"); + presenter.Bounds.Width.ShouldBeGreaterThan(0); + block.SearchText.ShouldBe("API"); + + block.SearchText = "DB"; + Dispatcher.UIThread.RunJobs(); + search.Text.ShouldBe("DB"); + window.Close(); + } +} diff --git a/tests/TKCode.Arc.Tests/Gallery/GalleryGuidesTests.cs b/tests/TKCode.Arc.Tests/Gallery/GalleryGuidesTests.cs index 7a204c3e..a7b83f59 100644 --- a/tests/TKCode.Arc.Tests/Gallery/GalleryGuidesTests.cs +++ b/tests/TKCode.Arc.Tests/Gallery/GalleryGuidesTests.cs @@ -1,5 +1,4 @@ using System.Text; -using AtomUI.Localization; using TKCode.Arc.Gallery.Catalog; using TKCode.Arc.Gallery.Docs; using TKCode.Arc.Gallery.Pages; @@ -9,6 +8,7 @@ using Avalonia.Platform; using Avalonia.Threading; using Avalonia.VisualTree; +using TKCode.Arc.Localization; namespace TKCode.Arc.Tests.Gallery; @@ -18,7 +18,7 @@ namespace TKCode.Arc.Tests.Gallery; /// public class GalleryGuidesTests { - private static ILanguageManager Languages() => global::AtomUI.ApplicationExtensions.GetLanguageManager(Application.Current!)!; + private static ArcLanguageManager Languages() => ArcLanguageManager.Current!; [AvaloniaFact] public void Every_Docs_Entry_Has_A_Page_In_Every_Language() @@ -116,14 +116,14 @@ public void A_Docs_Page_Shows_Its_Screenshots_And_Follows_The_Language() markdown.Text.ShouldContain(" GalleryPages.IsImplemented(e.ClassName))) { var page = GalleryPages.Create(entry); diff --git a/tests/TKCode.Arc.Tests/Gallery/GalleryTextSourceTests.cs b/tests/TKCode.Arc.Tests/Gallery/GalleryTextSourceTests.cs index 90c19ff8..61fc1a4a 100644 --- a/tests/TKCode.Arc.Tests/Gallery/GalleryTextSourceTests.cs +++ b/tests/TKCode.Arc.Tests/Gallery/GalleryTextSourceTests.cs @@ -79,10 +79,9 @@ public void A_Page_That_Arrives_Later_Is_Shown_When_It_Does() view.Docs.Text.ShouldBe(expected.Markdown); // Leaving the visual tree and coming back (as navigation does) shows the page it has, without asking again. - var layers = (Avalonia.Controls.Primitives.VisualLayerManager)window.Content!; - layers.Child = null; + window.Content = null; Dispatcher.UIThread.RunJobs(); - layers.Child = view; + window.Content = view; Dispatcher.UIThread.RunJobs(); pending.Requests.ShouldBe(1); view.Docs.Text.ShouldBe(expected.Markdown); diff --git a/tests/TKCode.Arc.Tests/Gallery/GalleryTitleBarTests.cs b/tests/TKCode.Arc.Tests/Gallery/GalleryTitleBarTests.cs new file mode 100644 index 00000000..002519b8 --- /dev/null +++ b/tests/TKCode.Arc.Tests/Gallery/GalleryTitleBarTests.cs @@ -0,0 +1,225 @@ +using TKCode.Arc.Gallery.Localization; +using TKCode.Arc.Gallery.Shell; +using TKCode.Arc.Motion; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Headless; +using Avalonia.Headless.XUnit; +using Avalonia.Interactivity; +using Avalonia.Threading; +using Avalonia.VisualTree; + +namespace TKCode.Arc.Tests.Gallery; + +/// +/// The desktop window draws its own title bar (GalleryTitleBar): logo and title, the keep-on-top pin and, off macOS, the +/// caption buttons; on macOS it keeps the traffic lights and leaves room for them. +/// +public class GalleryTitleBarTests +{ + private static GalleryWindow Open(bool drawsCaptionButtons) + { + var window = new GalleryWindow(drawsCaptionButtons) { Width = 1280, Height = 860 }; + ArcMotion.SetLevel(window, ArcMotionLevel.Off); + window.Show(); + Dispatcher.UIThread.RunJobs(); + return window; + } + + private static void Press(Button button) => button.RaiseEvent(new RoutedEventArgs(Button.ClickEvent)); + + [AvaloniaFact] + public void The_Window_Extends_Into_The_Decorations_With_The_Bar_Above_The_Shell() + { + var window = Open(drawsCaptionButtons: true); + try + { + window.ExtendClientAreaToDecorationsHint.ShouldBeTrue(); + window.ExtendClientAreaTitleBarHeightHint.ShouldBe(GalleryTitleBar.BarHeight); + var bar = window.TitleBar; + bar.IsVisible.ShouldBeTrue(); + bar.Bounds.Height.ShouldBe(GalleryTitleBar.BarHeight); + bar.TranslatePoint(default, window).ShouldBe(new Point(0, 0)); + bar.Bounds.Width.ShouldBe(window.Bounds.Width); + // The shell starts under the bar. + window.Shell.TranslatePoint(default, window)!.Value.Y.ShouldBe(GalleryTitleBar.BarHeight); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void Off_MacOS_The_Bar_Has_Logo_Title_Pin_And_Caption_Buttons() + { + var window = Open(drawsCaptionButtons: true); + try + { + var bar = window.TitleBar; + bar.DrawsCaptionButtons.ShouldBeTrue(); + bar.LeadingInset.ShouldBe(0); + bar.Brand.GetVisualDescendants().OfType().ShouldHaveSingleItem().Source.ShouldNotBeNull(); + bar.Brand.GetVisualDescendants().OfType().ShouldContain(t => t.Text == window.Title); + + bar.MinimizeButton.ShouldNotBeNull(); + bar.MaximizeButton.ShouldNotBeNull(); + bar.CloseButton.ShouldNotBeNull(); + var buttons = new[] { bar.PinButton, bar.MinimizeButton!, bar.MaximizeButton!, bar.CloseButton! }; + foreach (var button in buttons) + { + button.IsEffectivelyVisible.ShouldBeTrue(); + Tooltip.GetTip(button).ShouldBeOfType().ShouldNotBeNullOrWhiteSpace(); + } + // Left to right: pin, minimize, maximize, close; close ends the bar. + var lefts = buttons.Select(b => b.TranslatePoint(default, bar)!.Value.X).ToArray(); + lefts.ShouldBe(lefts.Order().ToArray()); + var close = bar.CloseButton!; + (close.TranslatePoint(default, bar)!.Value.X + close.Bounds.Width).ShouldBeGreaterThan(bar.Bounds.Width - 16); + Tooltip.GetTip(close).ShouldBe(GalleryText.Get(GalleryShellLangResourceKind.WindowClose)); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void On_MacOS_The_Bar_Keeps_The_Traffic_Lights() + { + var window = Open(drawsCaptionButtons: false); + try + { + var bar = window.TitleBar; + bar.DrawsCaptionButtons.ShouldBeFalse(); + bar.MinimizeButton.ShouldBeNull(); + bar.MaximizeButton.ShouldBeNull(); + bar.CloseButton.ShouldBeNull(); + bar.Buttons.Children.OfType