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/AGENTS.md b/AGENTS.md index ac5cdb78..204a2330 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,13 +1,14 @@ # TKCode.Arc agent guide -TKCode.Arc is an Arc-style (uiarc.dev) component library for Avalonia built on AtomUI 6.2.8. +TKCode.Arc is an Arc-style (uiarc.dev) component library for Avalonia 12.1.4. Every package depends only on Avalonia and the in-house MIT `TKCode.Arc.Foundation` (token runtime, theme manager, package registration, localization; design/architecture/foundation.md). Design source of truth: `design/` (start at `design/overview.md`). ## Layout +- `src/TKCode.Arc.Foundation` (+ `TKCode.Arc.Foundation.Generators`, shipped as its analyzer): token and theme runtime (`ArcThemeManager`, `ArcTheme`, presets), `AppBuilder.UseArc(...)` / `ArcBuilder`, language runtime (`TKCode.Arc.Localization`), generators for tokens, theme files and `Localization/*.xlf`. - `src/TKCode.Arc.Core`: ArcDesign tokens, palette, motion engine (`TKCode.Arc.Motion`), shared primitives (`TKCode.Arc.Primitives`), Lucide glyphs. - `src/TKCode.Arc`: controls, organized as `//` (category = Arc sidebar group). -- `tests/`: `TKCode.Arc.Core.Tests` (pure), `TKCode.Arc.Tests` (headless Avalonia). +- `tests/`: `TKCode.Arc.Core.Tests` (pure), `TKCode.Arc.Tests` (headless Avalonia), `TKCode.Arc.Foundation.Tests` (runtime + generator snapshots), `TKCode.Arc.BaseTheme.Tests` (base theme in an app with no other theme), `TKCode.Arc.Mobile.Tests`; `tests/Baselines/` holds the token and preset snapshots. - `gallery/`: gallery shell (home page, routes, top bar) and pages; `TKCode.Arc.Gallery.Shots` holds the docs screenshots and `TKCode.Arc.Gallery.Content` the pages' Markdown as resources (the Browser host serves both as files and fetches a page's text when it is shown, through `GalleryTexts`; section 6.11). A component's page is its docs page `Docs///.md` with live examples embedded by ``, each written as `Demos///.axaml` (plus `.axaml.cs` only when it needs logic); the demo's Code tab shows those files (sections 4 and 6.7). `TKCode.Arc.Gallery.Generators` turns `` into API tables from the TKCode.Arc XML docs, collects the demos and checks translations (design/engineering/gallery-app.md section 6). Shell text comes from `Localization/*.xlf` via `GalleryText` (hosts call `UseArcGallery()`; section 5.1). The sidebar's Docs section (Introduction, Installation, Theming, Motion, AI and MCP, Changelog) is `Guides//.md`, read at run time by `GalleryGuides` (section 6.9). `TKCode.Arc.Gallery.Llms` writes the web site's files for AI assistants from the same pages (`llms.txt`, `llms-full.txt`, every page as Markdown, the agent skill; hand-written text in its `Content/`; section 6.10). - `tools/lucide/generate_lucide.py`: regenerates Lucide glyph geometry. - `tools/docshots`: checks a docs page without a build (`check`), captures its screenshots (`shoot`: each `![caption](images/Name.png)` line plus the `xml` block under it, light and dark, as lossless WebP files `images/Name.webp`; install `cwebp` for small files) and renders a gallery page (`page`); see design/engineering/gallery-app.md section 6.8. @@ -18,11 +19,13 @@ Read `design/engineering/implementation-guide.md` before writing a component: fi ## Non-negotiable rules -- Controls derive from Avalonia base types; only public AtomUI APIs are used (design/architecture/atomui-integration.md). +- Controls derive from Avalonia base types; only public Avalonia APIs are used. No AtomUI code or packages, and none of its source is consulted (clean room; design/decisions/0031-replace-atomui.md). - Class names have no prefix; flat namespace `TKCode.Arc`; xmlns `https://github.com/AIDotNet/TKCode.Arc`. -- Every public control has a `[ControlDesignToken]` `Token` in the same namespace with at least one property (an empty token crashes at runtime) and no name from `design/engineering/reserved-token-names.md`; Semantic Part paths start with `arc.`. +- Every public control has a `[ControlDesignToken]` `Token` in the same namespace with at least one property (ARCF003). Its identity (class name minus `Token`, or `[ControlDesignToken(Identity = "...")]`) is unique across all Arc packages, Mobile included (Mobile uses `MobileButton`, `MobileSwitch`, ...; design/engineering/reserved-token-names.md). Token property types are limited to those in foundation.md section 3.4 (ARCF001). - Colors, radii, shadows and fonts come from `{arc:ArcDesignTokenResource ...}`; control tokens hold geometry and accent-independent derived colors only. -- Themes live in `/Themes/Theme.axaml`; never create `*Themes.axaml` aggregates. +- Themes live in `/Themes/Theme.axaml` with `x:Class="." x:ClassModifier="internal"` (flat, e.g. `TKCode.Arc.ButtonTheme`, `TKCode.Arc.Mobile.PageTheme`; the generator supplies the class and loads the file on first use; ARCF010–014). A `ControlTheme` key is defined by one file only (ARCF013). Never create `*Themes.axaml` aggregates. +- Avalonia built-ins inside templates (`ScrollViewer`, `ScrollBar`, `ItemsControl`, `PathIcon`, `TextSelectionHandle`, popup hosts) get their theme from Core's base theme (`src/TKCode.Arc.Core/Theme/Base/`), which steps aside when the app uses Fluent or Simple. Other built-ins (e.g. `TextBox`) have no template: use the Arc control. Scroll bars auto-hide; set `ScrollViewer.AllowAutoHide="False"` to keep them. +- Theme changes go through `ArcTheme.Apply(t => ...)` / `ArcThemeManager.Apply`; never block on `ArcThemeManager.ApplyAsync` from the UI thread (its publish step needs the UI thread, so `.Wait()` / `.Result` deadlocks; await it or call `Apply`). - Transitions are declared in ControlTheme setters under `^:motion-full` / `^:motion-reduced` (set `arc:ArcMotion.Tracks="True"`); code-driven motion uses `MotionValue` and must say why ControlTheme cannot express it. - C# control-flow bodies always use braces. No reflection; AOT and trimming safe. - Every Arc component needs gallery docs pages `gallery/TKCode.Arc.Gallery/Docs///{en-US,zh-CN,zh-TW}.md` (checked by `GalleryDocsTests`; design/engineering/gallery-app.md section 6.6), and its public properties and events need XML `` text (they feed the API tables). @@ -34,5 +37,6 @@ Read `design/engineering/implementation-guide.md` before writing a component: fi ```bash dotnet build TKCode.Arc.slnx dotnet test tests/TKCode.Arc.Core.Tests +dotnet test tests/TKCode.Arc.Foundation.Tests dotnet test tests/TKCode.Arc.Tests ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index f7780481..7485b107 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,11 +3,147 @@ All notable changes to TKCode.Arc are listed here. Versions follow [Semantic Versioning](https://semver.org/); while the version carries a `-preview` suffix the API may still change between releases. -NuGet packages published with each release: `TKCode.Arc`, `TKCode.Arc.Core`, `TKCode.Arc.Fonts`, -`TKCode.Arc.Icons.Lucide`, `TKCode.Arc.Blocks`, `TKCode.Arc.AdvancedCharts`, `TKCode.Arc.Special`, +NuGet packages published with each release: `TKCode.Arc`, `TKCode.Arc.Core`, `TKCode.Arc.Foundation` (from 0.4), +`TKCode.Arc.Fonts`, `TKCode.Arc.Icons.Lucide`, `TKCode.Arc.Blocks`, `TKCode.Arc.AdvancedCharts`, `TKCode.Arc.Special`, `TKCode.Arc.Workflow`, `TKCode.Arc.Themes.Pixel`, `TKCode.Arc.Themes.Corners`, `TKCode.Arc.Mobile`, `TKCode.Arc.Terminal`, `TKCode.Arc.Audio`, `TKCode.Arc.Audio.Native` and `TKCode.Arc.Audio.Browser`. +## 0.4.0-preview.1 - Unreleased + +Arc no longer depends on AtomUI. Every package now depends only on Avalonia and on the new MIT package +`TKCode.Arc.Foundation`, so the whole dependency chain is under permissive licenses and closed-source applications can +publish Arc as a single file or with NativeAOT (issue #25). The application entry point and the theme API change; the +controls, their properties and the XAML (`{arc:ArcDesignTokenResource ...}`, `{arc:XxxTokenResource ...}`) do not. See +*Migrating from 0.3 to 0.4* below. + +### Added + +- **Foundation** (new package `TKCode.Arc.Foundation`): the theme manager (`ArcThemeManager`, `ArcTheme`, + `ArcThemeState`, `ArcThemeBuilder`), theme presets (`ArcThemePreset`), local themes (`ArcThemeScope`), package + registration (`AppBuilder.UseArc(...)`, `ArcBuilder`), languages (`ArcLanguage`, `ArcLanguageManager`) and a source + generator for tokens, theme files and language catalogs. No reflection; trimming and NativeAOT safe. A control's + theme loads the first time a control of that type is used. +- **ArcImage** and `ImageSource` (Core): background image loading (http, `avares://`, files, `data:`, bitmaps) with a + shared cache, on desktop and in the browser. +- **ArcOverlayHost** (Core): a panel with its own overlay layer, so dialogs, drawers and sheets opened inside it stay + within it. +- **Base theme** (Core): Arc's own themes for the Avalonia controls its templates use (`ScrollViewer`, `ScrollBar`, + `ItemsControl`, `PathIcon`, `TextSelectionHandle` and the popup hosts). It steps aside when the application uses + `FluentTheme` or `SimpleTheme`. +- **Scroll bars** look like `ScrollArea`: thin, shown while the pointer is over the view or while it scrolls, hidden + at rest. `ScrollViewer.AllowAutoHide="False"` keeps them visible. +- **Corners theme**: `UseArcCorners()` and `t.Corners(style)`; ready-made `ArcCornerPresets.Square` and + `ArcCornerPresets.Half`. +- **Menus**: long menus scroll inside `MaxDropDownHeight`. +- **Gallery**: the desktop window draws its own title bar (logo, title, pin on top; window buttons on Windows and + Linux). + +### Changed + +- **Breaking: entry point.** `AppBuilder.UseAtomUI(b => b.UseDesktopControls().UseArc())` is now + `AppBuilder.UseArc(arc => arc.UseArcControls())`. The `Application` class no longer needs to be `partial`. +- **Breaking: theme API.** `ThemeConfigBuilder`, `ThemeAlgorithm`, `IThemeManager.ApplyThemeAsync` and `WithArc(...)` + are replaced by `ArcTheme.Apply(t => ...)`, `ArcThemeManager.Apply` / `ApplyAsync` and `WithTheme(...)` in `UseArc`. +- **Breaking: Pixel and Corners** are theme presets: `t.Pixel()` and `t.Corners(style)`. `ArcPixelTheme`, + `ArcPixelScope`, `WithArcCorners` and `ArcCornerScope` are removed; a preset skips the packages the application has + not registered. +- **Breaking: languages** move to `TKCode.Arc.Localization` (`ArcLanguage`, `ArcLanguageManager`); `UseLanguages` is + on `ArcBuilder`. +- **Breaking: images and popups.** `AsyncImage` is replaced by `ArcImage` (`IsLoaded` is now `IsImageLoaded`). + `ArcPopup` derives from Avalonia's `Popup`; `OpenMotion` and `CloseMotion` are `ArcPopupMotion`, + `AdjustPlacementAnchor` lost its `ref flipX, ref flipY` parameters, and `IsOpen` stays true until the exit motion + finishes. `ArcPopupFrame` replaces `BaseMotionActor`, and `ArcOverlayHost` replaces `ScopeAwareOverlayLayerPanel`. +- **Breaking: mobile token identities** are `MobileButton`, `MobileCheckbox`, `MobileDialog`, `MobileSwitch` and + `MobileToast`, so they no longer share names with the desktop controls. +- Arc no longer shares its theme with AtomUI controls; applications that mix the two stay on 0.3.x. + +### Fixed + +- **ToastStack**: a toast dismissed while the stack resumes its timers no longer breaks the resume. +- **DataGrid**: the search box of the filter list is drawn again (it had no template). +- **PasswordField**: long strength labels no longer overlap the strength bar (#23). +- **Gallery**: the text box of the env variables event log is drawn again (it had no template). + +### Migrating from 0.3 to 0.4 + +1. Remove every `AtomUI.*` package reference and `using AtomUI...;` line; Arc brings in what it needs. +2. Replace `UseAtomUI(...)` with `UseArc(...)` and rename `UseArc()` (the controls) to `UseArcControls()`. The other + package calls keep their names and move into the `UseArc` callback. +3. Replace theme requests with `ArcTheme.Apply`. A change names only what changes; the rest of the state stays. +4. The `App` class may drop its `partial` keyword; nothing generates code into it any more. +5. With the .NET 11 SDK and `PublishAot`, set `false` in the application + project. + +```csharp +// 0.3 +AppBuilder.Configure() + .UsePlatformDetect() + .UseAtomUI(builder => + { + builder.UseDesktopControls(); + builder.UseArc(); + builder.UseArcBlocks(); + builder.UseLanguages(LanguageTags.EnUS, [LanguageTags.EnUS, LanguageTags.ZhCN]); + }); + +// 0.4 +AppBuilder.Configure() + .UsePlatformDetect() + .UseArc(arc => arc + .UseArcControls() + .UseArcBlocks() + .UseLanguages(LanguageTags.EnUS, [LanguageTags.EnUS, LanguageTags.ZhCN]) + .WithTheme(t => t.Appearance(ArcAppearance.System))); +``` + +```csharp +// 0.3 +var builder = new ThemeConfigBuilder() + .WithAlgorithms(ThemeAlgorithm.Default, ThemeAlgorithm.Dark) + .WithArc(arc => arc.Accent(ArcAccentPreset.Blue).MotionMode(ArcMotionLevel.Reduced)); +await manager.ApplyThemeAsync(new ThemeRequest(IThemeManager.DEFAULT_THEME_ID, builder.Build(), + ThemeTransitionReason.UserRequest)); + +// 0.4 +ArcTheme.Apply(t => t + .Appearance(ArcAppearance.Dark) + .Accent(ArcAccentPreset.Blue) + .MotionLevel(ArcMotionLevel.Reduced)); +``` + +| 0.3 (AtomUI) | 0.4 | +|---|---| +| `AppBuilder.UseAtomUI(b => ...)`, `UseDesktopControls()` | `AppBuilder.UseArc(arc => ...)` | +| `UseArc()` (the controls) | `UseArcControls()` | +| `UseArcCore`, `UseArcBlocks`, `UseArcAdvancedCharts`, `UseArcSpecial`, `UseArcWorkflow`, `UseArcMobile`, `UseArcPixelTheme`, `UseArcGallery` on `IAtomUIBuilder` | The same names on `ArcBuilder`; new `UseArcCorners()` | +| `builder.UseLanguages(...)` | `arc.UseLanguages(...)`, same arguments | +| `partial class App`, `ApplicationStartup.Attach` | Not needed | +| `ThemeConfigBuilder`, `WithAlgorithms(ThemeAlgorithm.Dark)` | `t.Appearance(ArcAppearance.Dark)` (or `Light`, `System`) | +| `IThemeManager.ApplyThemeAsync(new ThemeRequest(...))` | `ArcTheme.Apply(t => ...)`, `ArcThemeManager.Current.Apply` / `ApplyAsync` (await it; never block on it on the UI thread) | +| `WithArc(a => a.Accent(preset or color))` | `t.Accent(preset or color)` | +| `WithArc(a => a.Token(name, value))` | `t.Token(name, value)` | +| `WithArc(a => a.ControlToken(catalog, control, name, value))` | `t.Token(identity, name, value)` | +| `WithArc(a => a.MotionMode(level))` | `t.MotionLevel(level)` | +| `WithArcCorners(style, scope)`, `arc.Corners(style, scope)`, `ArcCornerScope` | `t.Corners(style)`, `ArcCornerPresets` | +| `ArcPixelTheme.CreateRequest(...)`, `WithArcPixel(...)`, `ArcPixelScope` | `t.Pixel()`, `ArcPixelPresets`; back to Arc with `t.RemovePreset(ArcPixelPresets.Slot)` | +| `using AtomUI.Localization;` | `using TKCode.Arc.Localization;` | +| `Application.Current.GetLanguageManager()`, `ILanguageManager` | `ArcLanguageManager.Current` | +| `GetLocalizer().Get(kind)`, `Format(kind, args)` | `XxxLangResources.Get(kind)`, `XxxLangResources.Format(kind, args)` | +| `LanguageChanged`, `ChangeLanguage(tag)` | `Changed`, `Set(tag)` (or `ArcLanguage.Changed`, `ArcLanguage.Set`) | +| `FormattingCulture`, `CurrentLanguage` | `ArcLanguage.Culture`, `ArcLanguage.Current` | +| Mobile token overrides by catalog | Identities `MobileButton`, `MobileCheckbox`, `MobileDialog`, `MobileSwitch`, `MobileToast` | +| `AtomUI.Controls.ImageSource`, `AsyncImage`, `IsLoaded` | `TKCode.Arc.ImageSource`, `ArcImage`, `IsImageLoaded` | +| `ArcPopup` on AtomUI's `Popup`, `BaseMotionActor` | `ArcPopup` on Avalonia's `Popup`, `ArcPopupFrame`; `OpenMotion` / `CloseMotion` are `ArcPopupMotion` | +| `AdjustPlacementAnchor(placement, ref flipX, ref flipY)` | `AdjustPlacementAnchor(placement)` | +| `ScopeAwareOverlayLayerPanel` | `ArcOverlayHost` | +| AtomUI's themes for `ScrollViewer`, `ScrollBar`, `ItemsControl` and the popup hosts | Arc's base theme; scroll bars hide at rest (`ScrollViewer.AllowAutoHide="False"` keeps them) | + +- **Windows and overlays.** Without AtomUI's window theme, a plain Avalonia `Window` has no overlay layer unless its + template has a `VisualLayerManager` with `EnableOverlayLayer="True"`. `ThemeSwitcher` needs one for its transition; + without it, it switches directly. +- **AtomUI controls.** An application that also uses AtomUI controls no longer gets one shared theme for both. Stay on + 0.3.x until those screens are moved to Arc. + ## 0.3.1-preview.6 - 2026-10-28 ### Added 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/README.md b/README.md index 59a4f0d7..a3652537 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,14 @@ English | [简体中文](README.zh-CN.md) -Arc-style components for Avalonia, built on [AtomUI](https://github.com/AtomUI/AtomUI). +Arc-style components for Avalonia. TKCode.Arc ports the free components of [Arc](https://uiarc.dev) — 112 components in 22 groups — with Arc's visual language and spring motion. - Targets `net10.0` and `net11.0`; desktop (Windows, macOS, Linux). -- Depends on AtomUI 6.2.8 and Avalonia 12.1.4 (exact versions), and on - [StreamMark](https://www.nuget.org/packages/TKCode.StreamMark.Highlighting.TextMate) for Markdown and code highlighting. +- Depends on Avalonia 12.1.4 (exact version), on `TKCode.Arc.Foundation` (its own MIT theme, token and language + runtime) and on [StreamMark](https://www.nuget.org/packages/TKCode.StreamMark.Highlighting.TextMate) for Markdown and + code highlighting. Every package in the chain is MIT licensed; there is no AtomUI dependency since 0.4. - Design documents: [design/overview.md](design/overview.md). - Live demo: (the gallery of every component, in the browser). @@ -16,8 +17,9 @@ with Arc's visual language and spring motion. | Package | Contents | |---|---| -| `TKCode.Arc` | The components (`UseArc()`), organized like Arc's sidebar, plus a streaming `Markdown` control. | +| `TKCode.Arc` | The components (`UseArcControls()`), organized like Arc's sidebar, plus a streaming `Markdown` control. | | `TKCode.Arc.Core` | ArcDesign tokens and palette, the spring motion engine, shared primitives, 187 built-in Lucide glyphs. | +| `TKCode.Arc.Foundation` | The theme, token, language and package runtime (`UseArc(...)`, `ArcTheme`, `ArcLanguage`); from 0.4. | | `TKCode.Arc.Icons.Lucide` | The full Lucide set (2155 icons): `LucideIcon` control and `LucideGeometry` markup extension. | | `TKCode.Arc.Fonts` | Geist and Geist Mono (`WithArcFonts()`); body text uses Inter from `Avalonia.Fonts.Inter`. | @@ -31,26 +33,28 @@ dotnet add package TKCode.Arc.Fonts --version 0.3.1-preview.6 # option dotnet add package TKCode.Arc.Icons.Lucide --version 0.3.1-preview.6 # optional: the full Lucide set ``` -`TKCode.Arc` brings in `TKCode.Arc.Core`. `TKCode.Arc.Blocks`, `TKCode.Arc.AdvancedCharts` and `TKCode.Arc.Special` +`TKCode.Arc` brings in `TKCode.Arc.Core` (and, from 0.4, `TKCode.Arc.Foundation`). `TKCode.Arc.Blocks`, `TKCode.Arc.AdvancedCharts` and `TKCode.Arc.Special` (the animated AI explainers of the Special components section) are separate packages on NuGet; `TKCode.Arc.Workflow` (the workflow canvas, `UseArcWorkflow()`) ships with them too. `TKCode.Arc.Themes.Pixel` (a pixel-art theme, `UseArcPixelTheme()`) and -`TKCode.Arc.Mobile` (the first touch-first controls, `UseArcMobile()`, not yet verified on devices) are new in 0.3.1-preview.1; `TKCode.Arc.Themes.Corners` (global corner style, `WithArcCorners()`) is new in 0.3.1-preview.2. `TKCode.Arc.Terminal` (`TerminalView` over libghostty-vt, native libraries bundled) is new in 0.3.1-preview.4. `TKCode.Arc.Audio` (the `IAudioPlayer` abstraction behind the media blocks, silent by default) with its backends `TKCode.Arc.Audio.Native` (desktop, miniaudio, `ArcAudioNative.Use()`) and `TKCode.Arc.Audio.Browser` (WebAssembly, `ArcAudioBrowser.UseAsync()`) is new in 0.3.1-preview.6. Versions carry a `-preview` suffix, so the API may still change between +`TKCode.Arc.Mobile` (the first touch-first controls, `UseArcMobile()`, not yet verified on devices) are new in 0.3.1-preview.1; `TKCode.Arc.Themes.Corners` (global corner style, `UseArcCorners()`) is new in 0.3.1-preview.2. `TKCode.Arc.Terminal` (`TerminalView` over libghostty-vt, native libraries bundled) is new in 0.3.1-preview.4. `TKCode.Arc.Audio` (the `IAudioPlayer` abstraction behind the media blocks, silent by default) with its backends `TKCode.Arc.Audio.Native` (desktop, miniaudio, `ArcAudioNative.Use()`) and `TKCode.Arc.Audio.Browser` (WebAssembly, `ArcAudioBrowser.UseAsync()`) is new in 0.3.1-preview.6. Versions carry a `-preview` suffix, so the API may still change between releases. ## Getting started +The code below is the API on `main` (0.4, not yet on NuGet). Apps on 0.3.x register Arc inside `UseAtomUI(...)`; +[CHANGELOG.md](CHANGELOG.md) has the migration notes. + ```csharp AppBuilder.Configure() .UsePlatformDetect() .WithInterFont() .WithArcFonts() // optional: Geist display type and Geist Mono code type - .UseAtomUI(builder => - { - builder.UseDesktopControls(); - builder.UseArc(); - }); + .UseArc(arc => arc + .UseArcControls() + .WithTheme(t => t.Appearance(ArcAppearance.System))); -public partial class App : Application { } +// Later, for example from a settings page: +ArcTheme.Apply(t => t.Appearance(ArcAppearance.Dark).Accent(ArcAccentPreset.Violet)); ``` ```xml @@ -101,6 +105,8 @@ reply.Complete(); Notes for applications: - Do not enable `InvariantGlobalization` (number and date formatting needs culture data). +- NativeAOT with the .NET 11 SDK: set `false`, otherwise the compiled XAML + is left out of the published app. - Anchored popups (menus, selects, tooltips, popovers) are drawn in the window's overlay layer, so they sit exactly `sideOffset` away from their trigger and never cover it. - `Button`, `Calendar`, `Carousel`, `ContextMenu`, `DatePicker`, `Slider`, `SplitButton`, `TabItem`, `TimePicker`, @@ -108,7 +114,7 @@ Notes for applications: ## Building -Requires the .NET 11 SDK (see `global.json`) and the .NET 10 runtime (AtomUI build tasks run on it). +Requires the .NET 11 SDK (see `global.json`) and the .NET 10 runtime (the tests and the gallery run on `net10.0`). ```bash dotnet build TKCode.Arc.slnx @@ -124,5 +130,6 @@ access to Claude, OpenAI and DeepSeek models. Thank you for supporting the proje ## License -MIT. See [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md) for Arc (MIT), Lucide (ISC), fonts (OFL), -StreamMark, TextMateSharp, Oniguruma and Markdig, and the AtomUI (LGPL-3.0) dependency. +MIT. Arc depends only on Avalonia (MIT) and MIT packages of its own, so closed-source applications can ship it in a +single file or a NativeAOT build. See [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md) for Arc (MIT), Lucide (ISC), +fonts (OFL), StreamMark, TextMateSharp, Oniguruma and Markdig. diff --git a/README.zh-CN.md b/README.zh-CN.md index a2aec5a1..191dfd1f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -2,13 +2,13 @@ [English](README.md) | 简体中文 -面向 Avalonia 的 Arc 风格组件库,基于 [AtomUI](https://github.com/AtomUI/AtomUI) 构建。 +面向 Avalonia 的 Arc 风格组件库。 TKCode.Arc 移植了 [Arc](https://uiarc.dev) 的免费组件(22 个分组共 112 个组件), 保留 Arc 的视觉语言和弹簧动效。 - 目标框架 `net10.0` 和 `net11.0`;支持桌面平台(Windows、macOS、Linux)。 -- 依赖 AtomUI 6.2.8 和 Avalonia 12.1.4(精确版本),Markdown 与代码高亮依赖 - [StreamMark](https://www.nuget.org/packages/TKCode.StreamMark.Highlighting.TextMate)。 +- 依赖 Avalonia 12.1.4(精确版本)、自有的 MIT 主题 / 令牌 / 语言运行时 `TKCode.Arc.Foundation`,Markdown 与代码高亮依赖 + [StreamMark](https://www.nuget.org/packages/TKCode.StreamMark.Highlighting.TextMate)。整条依赖链都是 MIT 许可;自 0.4 起不再依赖 AtomUI。 - 设计文档:[design/overview.md](design/overview.md)。 - 在线体验站点:,在浏览器里查看全部组件。 @@ -16,8 +16,9 @@ TKCode.Arc 移植了 [Arc](https://uiarc.dev) 的免费组件(22 个分组共 | 包 | 内容 | |---|---| -| `TKCode.Arc` | 全部组件(`UseArc()`),目录结构与 Arc 侧边栏一致,另含流式渲染的 `Markdown` 控件。 | +| `TKCode.Arc` | 全部组件(`UseArcControls()`),目录结构与 Arc 侧边栏一致,另含流式渲染的 `Markdown` 控件。 | | `TKCode.Arc.Core` | ArcDesign 令牌与调色板、弹簧动效引擎、共享基础组件、187 个内置 Lucide 图标。 | +| `TKCode.Arc.Foundation` | 主题、令牌、语言与包注册的运行时(`UseArc(...)`、`ArcTheme`、`ArcLanguage`);自 0.4 起。 | | `TKCode.Arc.Icons.Lucide` | 完整的 Lucide 图标集(2155 个图标):`LucideIcon` 控件和 `LucideGeometry` 标记扩展。 | | `TKCode.Arc.Fonts` | Geist 与 Geist Mono 字体(`WithArcFonts()`);正文使用 `Avalonia.Fonts.Inter` 提供的 Inter。 | @@ -31,23 +32,25 @@ dotnet add package TKCode.Arc.Fonts --version 0.3.1-preview.6 # 可选 dotnet add package TKCode.Arc.Icons.Lucide --version 0.3.1-preview.6 # 可选:完整的 Lucide 图标集 ``` -`TKCode.Arc` 会带上 `TKCode.Arc.Core`。`TKCode.Arc.Blocks`、`TKCode.Arc.AdvancedCharts` 与 `TKCode.Arc.Special` -(Special components 区的 AI 动画讲解组件)是独立的 NuGet 包,需要时单独安装;工作流画布 `TKCode.Arc.Workflow`(`UseArcWorkflow()`)随 0.3.1-preview.1 起一同发布;像素风主题包 `TKCode.Arc.Themes.Pixel`(`UseArcPixelTheme()`)与移动端首批控件 `TKCode.Arc.Mobile`(`UseArcMobile()`,尚未在真机上验证)自 0.3.1-preview.1 起发布;全局圆角包 `TKCode.Arc.Themes.Corners`(`WithArcCorners()`)自 0.3.1-preview.2 起发布;终端控件 `TKCode.Arc.Terminal`(基于 libghostty-vt 的 `TerminalView`,附带原生库)自 0.3.1-preview.4 起发布。音频播放抽象 `TKCode.Arc.Audio`(媒体类 Block 使用的 `IAudioPlayer`,默认静音模拟)及其平台后端 `TKCode.Arc.Audio.Native`(桌面,miniaudio,`ArcAudioNative.Use()`)与 `TKCode.Arc.Audio.Browser`(WebAssembly,`ArcAudioBrowser.UseAsync()`)自 0.3.1-preview.6 起发布。版本号带 `-preview` 后缀,各版本之间 API 仍可能变化。 +`TKCode.Arc` 会带上 `TKCode.Arc.Core`(自 0.4 起还有 `TKCode.Arc.Foundation`)。`TKCode.Arc.Blocks`、`TKCode.Arc.AdvancedCharts` 与 `TKCode.Arc.Special` +(Special components 区的 AI 动画讲解组件)是独立的 NuGet 包,需要时单独安装;工作流画布 `TKCode.Arc.Workflow`(`UseArcWorkflow()`)随 0.3.1-preview.1 起一同发布;像素风主题包 `TKCode.Arc.Themes.Pixel`(`UseArcPixelTheme()`)与移动端首批控件 `TKCode.Arc.Mobile`(`UseArcMobile()`,尚未在真机上验证)自 0.3.1-preview.1 起发布;全局圆角包 `TKCode.Arc.Themes.Corners`(`UseArcCorners()`)自 0.3.1-preview.2 起发布;终端控件 `TKCode.Arc.Terminal`(基于 libghostty-vt 的 `TerminalView`,附带原生库)自 0.3.1-preview.4 起发布。音频播放抽象 `TKCode.Arc.Audio`(媒体类 Block 使用的 `IAudioPlayer`,默认静音模拟)及其平台后端 `TKCode.Arc.Audio.Native`(桌面,miniaudio,`ArcAudioNative.Use()`)与 `TKCode.Arc.Audio.Browser`(WebAssembly,`ArcAudioBrowser.UseAsync()`)自 0.3.1-preview.6 起发布。版本号带 `-preview` 后缀,各版本之间 API 仍可能变化。 ## 快速开始 +下面是 `main` 分支上的 API(0.4,尚未发布到 NuGet)。0.3.x 的应用在 `UseAtomUI(...)` 里注册 Arc;迁移说明见 +[CHANGELOG.md](CHANGELOG.md)。 + ```csharp AppBuilder.Configure() .UsePlatformDetect() .WithInterFont() .WithArcFonts() // 可选:Geist 展示字体和 Geist Mono 代码字体 - .UseAtomUI(builder => - { - builder.UseDesktopControls(); - builder.UseArc(); - }); + .UseArc(arc => arc + .UseArcControls() + .WithTheme(t => t.Appearance(ArcAppearance.System))); -public partial class App : Application { } +// 之后,例如在设置页里: +ArcTheme.Apply(t => t.Appearance(ArcAppearance.Dark).Accent(ArcAccentPreset.Violet)); ``` ```xml @@ -98,6 +101,7 @@ reply.Complete(); 应用开发注意事项: - 不要启用 `InvariantGlobalization`(数字和日期格式化依赖区域性数据)。 +- 用 .NET 11 SDK 做 NativeAOT 发布时设置 `false`,否则发布的应用缺少编译后的 XAML。 - 锚点弹出层(菜单、选择器、工具提示、气泡卡片)绘制在窗口的覆盖层中,与触发元素的距离正好是 `sideOffset`,不会遮挡触发元素。 - `Button`、`Calendar`、`Carousel`、`ContextMenu`、`DatePicker`、`Slider`、`SplitButton`、`TabItem`、`TimePicker`、 @@ -105,7 +109,7 @@ reply.Complete(); ## 构建 -需要 .NET 11 SDK(见 `global.json`)和 .NET 10 运行时(AtomUI 的构建任务运行在其上)。 +需要 .NET 11 SDK(见 `global.json`)和 .NET 10 运行时(测试与 Gallery 运行在 `net10.0` 上)。 ```bash dotnet build TKCode.Arc.slnx @@ -121,5 +125,5 @@ TKCode.Arc 主要由 [nextco.work](https://nextco.work/) 赞助,其提供低 ## 许可证 -MIT。Arc(MIT)、Lucide(ISC)、字体(OFL)、StreamMark、TextMateSharp、Oniguruma 和 Markdig, -以及 AtomUI(LGPL-3.0)依赖的许可信息见 [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md)。 +MIT。Arc 只依赖 Avalonia(MIT)和自有的 MIT 包,闭源应用可以单文件或 NativeAOT 发布。Arc(MIT)、Lucide(ISC)、 +字体(OFL)、StreamMark、TextMateSharp、Oniguruma 和 Markdig 的许可信息见 [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md)。 diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index 7aee7df4..16768d66 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -236,6 +236,4 @@ SOFTWARE. ## Dependencies -- AtomUI (LGPL-3.0), consumed as published NuGet binaries. Applications using TKCode.Arc also - link AtomUI and must follow its license. - Avalonia (MIT). 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/Content/SKILL.md b/gallery/TKCode.Arc.Gallery.Llms/Content/SKILL.md index c45cc9dc..c69866f6 100644 --- a/gallery/TKCode.Arc.Gallery.Llms/Content/SKILL.md +++ b/gallery/TKCode.Arc.Gallery.Llms/Content/SKILL.md @@ -1,6 +1,6 @@ --- name: {{skill-name}} -description: Builds and reviews Avalonia interfaces with TKCode.Arc, the Arc design language (uiarc.dev) as native Avalonia controls on AtomUI. Picks the right Arc component or block, installs and registers the packages, wires only documented properties, and applies Arc's rules for design tokens, motion, copy, accessibility and layout. Use when a project references a TKCode.Arc package, uses the XAML namespace https://github.com/AIDotNet/TKCode.Arc or calls UseArc(), or when asked to build or polish an Avalonia screen such as a settings page, form, dashboard, data table, dialog or command palette with Arc. +description: Builds and reviews Avalonia interfaces with TKCode.Arc, the Arc design language (uiarc.dev) as native Avalonia controls. Picks the right Arc component or block, installs and registers the packages, wires only documented properties, and applies Arc's rules for design tokens, motion, copy, accessibility and layout. Use when a project references a TKCode.Arc package, uses the XAML namespace https://github.com/AIDotNet/TKCode.Arc or calls UseArc(...), or when asked to build or polish an Avalonia screen such as a settings page, form, dashboard, data table, dialog or command palette with Arc. --- # TKCode.Arc @@ -11,7 +11,7 @@ is a `Button`), so binding, commands, styles and data validation work as usual, design tokens. The quality bar is Arc's: a calm, neutral interface where type, spacing and one considered interaction carry the character. -This skill is for TKCode.Arc {{version}} on Avalonia {{avalonia}} and AtomUI {{atomui}}. The documentation index is +This skill is for TKCode.Arc {{version}} on Avalonia {{avalonia}}. The documentation index is {{base}}/llms.txt. ## Workflow @@ -22,7 +22,7 @@ Copy this checklist and tick it off: Arc task: - [ ] 1. Find: an Arc component or block for each region of the screen (components.md) - [ ] 2. Read: the page of every component you use, including "When not to use" -- [ ] 3. Install: packages added, UseArc() registered, Application class partial +- [ ] 3. Install: packages added, UseArc(arc => arc.UseArcControls()) registered - [ ] 4. Wire: documented properties and events only, bound to a view model - [ ] 5. Style: tokens for every color, radius, size and font; Arc's motion, not your own - [ ] 6. Review: run checklist.md, fix, and run it again until every line passes @@ -49,18 +49,17 @@ AppBuilder.Configure() .UsePlatformDetect() .WithInterFont() // Avalonia.Fonts.Inter .WithArcFonts() // TKCode.Arc.Fonts - .UseAtomUI(builder => - { - builder.UseDesktopControls(); - builder.UseArc(); - }); + .UseArc(arc => arc + .UseArcControls() + .WithTheme(t => t.Appearance(ArcAppearance.System))); -public partial class App : Application { } // partial: AtomUI's generator adds to it (ATOMUILOC008 otherwise) +// Theme changes at run time name only what changes: +ArcTheme.Apply(t => t.Appearance(ArcAppearance.Dark).Accent(ArcAccentPreset.Violet)); ``` XAML uses `xmlns:arc="https://github.com/AIDotNet/TKCode.Arc"` for controls, token resources and markup extensions. Blocks (`TKCode.Arc.Blocks`, `UseArcBlocks()`) and the DataGrid (`TKCode.Arc.AdvancedCharts`, -`UseArcAdvancedCharts()`) are not on NuGet yet: reference the projects from {{repository}}. Full steps: +`UseArcAdvancedCharts()`) are separate packages, registered in the same `UseArc` callback. Full steps: {{base}}/docs/installation.md. **Wire.** Bind two-way properties (`Text`, `IsChecked`, `SelectedItem`, `Value`) to settable view model properties and diff --git a/gallery/TKCode.Arc.Gallery.Llms/Content/checklist.md b/gallery/TKCode.Arc.Gallery.Llms/Content/checklist.md index a00b62df..403fd334 100644 --- a/gallery/TKCode.Arc.Gallery.Llms/Content/checklist.md +++ b/gallery/TKCode.Arc.Gallery.Llms/Content/checklist.md @@ -9,7 +9,7 @@ passes. Each line names what to look for in the code. - [ ] Every property, event and enum value on an Arc control is in that component's API reference ({{base}}/components/.md). - [ ] C# files that use Arc and `Avalonia.Controls` together alias one of the clashing names (`using ArcButton = TKCode.Arc.Button;`). - [ ] No WPF or WinUI API: `Visibility`, `DataTemplateSelector`, `x:Array`, `Trigger`. -- [ ] Packages are pinned to {{version}}, the application references Avalonia {{avalonia}}, and `UseArc()` is called inside `UseAtomUI(...)` (plus `UseArcBlocks()` or `UseArcAdvancedCharts()` when those are used). +- [ ] Packages are pinned to {{version}}, the application references Avalonia {{avalonia}}, and `AppBuilder.UseArc(arc => arc.UseArcControls())` is called (plus `UseArcBlocks()` or `UseArcAdvancedCharts()` in the same callback when those are used); the theme is changed with `ArcTheme.Apply(...)`. ## Tokens and theme diff --git a/gallery/TKCode.Arc.Gallery.Llms/Content/llms-full.txt b/gallery/TKCode.Arc.Gallery.Llms/Content/llms-full.txt index 89353562..d0d3bb20 100644 --- a/gallery/TKCode.Arc.Gallery.Llms/Content/llms-full.txt +++ b/gallery/TKCode.Arc.Gallery.Llms/Content/llms-full.txt @@ -1,6 +1,6 @@ # TKCode.Arc documentation -> Every page of {{base}} in one file, for TKCode.Arc {{version}} (Avalonia {{avalonia}}, AtomUI {{atomui}}): the guides, then every component and block. The index with one line per page is {{base}}/llms.txt. +> Every page of {{base}} in one file, for TKCode.Arc {{version}} (Avalonia {{avalonia}}): the guides, then every component and block. The index with one line per page is {{base}}/llms.txt. --- diff --git a/gallery/TKCode.Arc.Gallery.Llms/Content/llms.txt b/gallery/TKCode.Arc.Gallery.Llms/Content/llms.txt index 0e34f64b..562ad657 100644 --- a/gallery/TKCode.Arc.Gallery.Llms/Content/llms.txt +++ b/gallery/TKCode.Arc.Gallery.Llms/Content/llms.txt @@ -1,6 +1,6 @@ # TKCode.Arc -> TKCode.Arc brings Arc (uiarc.dev) to Avalonia: {{arc-count}} components in Arc's 22 groups, {{block-count}} page-level blocks, a spreadsheet-style data grid and a streaming Markdown renderer, as native Avalonia {{avalonia}} controls built on AtomUI {{atomui}}. Every control derives from an Avalonia base type and reads one set of design tokens, with light and dark appearances, eight accents and spring motion that respects reduced motion. MIT licensed, trimming and NativeAOT compatible. +> TKCode.Arc brings Arc (uiarc.dev) to Avalonia: {{arc-count}} components in Arc's 22 groups, {{block-count}} page-level blocks, a spreadsheet-style data grid and a streaming Markdown renderer, as native Avalonia {{avalonia}} controls with no other UI framework dependency. Every control derives from an Avalonia base type and reads one set of design tokens, with light and dark appearances, eight accents and spring motion that respects reduced motion. MIT licensed, trimming and NativeAOT compatible. Version {{version}} (preview: pin the exact version). Each link below is a page as Markdown: when to use the component, when not to and what to use instead, usage in XAML and C#, the source of every live example, the API reference generated from the library (every property with its type and default, events, pseudo-classes), accessibility and motion. @@ -14,25 +14,25 @@ dotnet add package TKCode.Arc.Fonts --version {{version}} # optional: Geist an dotnet add package Avalonia.Fonts.Inter --version {{avalonia}} # optional: Inter for body text ``` -Register Arc in AtomUI's builder, and declare the `Application` class `partial` (AtomUI's source generator adds to it; the build stops with `ATOMUILOC008` otherwise): +Register Arc on the `AppBuilder` with `UseArc(...)`; the `Application` class needs nothing from Arc: ```csharp AppBuilder.Configure() .UsePlatformDetect() .WithInterFont() .WithArcFonts() - .UseAtomUI(builder => - { - builder.UseDesktopControls(); - builder.UseArc(); - }); + .UseArc(arc => arc + .UseArcControls() + .WithTheme(t => t.Appearance(ArcAppearance.System))); + +ArcTheme.Apply(t => t.Appearance(ArcAppearance.Dark).Accent(ArcAccentPreset.Violet)); // switch the theme at run time ``` -Controls, token resources and markup extensions share one XAML namespace: `xmlns:arc="https://github.com/AIDotNet/TKCode.Arc"`, as in ``. `TKCode.Arc.Blocks` (`UseArcBlocks()`) and `TKCode.Arc.AdvancedCharts` (`UseArcAdvancedCharts()`, the DataGrid) are not on NuGet yet: reference those projects from {{repository}}. +Controls, token resources and markup extensions share one XAML namespace: `xmlns:arc="https://github.com/AIDotNet/TKCode.Arc"`, as in ``. Other packages register inside the same callback: `TKCode.Arc.Blocks` (`UseArcBlocks()`), `TKCode.Arc.AdvancedCharts` (`UseArcAdvancedCharts()`, the DataGrid), `TKCode.Arc.Special` (`UseArcSpecial()`), `TKCode.Arc.Workflow` (`UseArcWorkflow()`). ## Rules for agents -- This is Avalonia {{avalonia}} with AtomUI {{atomui}}, not WPF, WinUI or HTML: no `Visibility`, no `DataTemplateSelector`, no `x:Array`, no CSS. +- This is Avalonia {{avalonia}} with TKCode.Arc, not WPF, WinUI or HTML: no `Visibility`, no `DataTemplateSelector`, no `x:Array`, no CSS. - Write the `arc:` prefix. Without it, `Button`, `Slider`, `TreeView` and the other names Arc shares with Avalonia resolve to Avalonia's own controls. In C#, alias one namespace: `using ArcButton = TKCode.Arc.Button;`. - Use only the properties, events and enum values in the component's API reference. Anything else is invented and fails at build or run time. - Colors, radii, spacing and fonts come from tokens: `{arc:ArcDesignTokenResource Surface}` in XAML, `ArcTokens.Bind(...)` in code. A hard-coded hex color breaks dark mode and the accents. 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/Gestures/SwipeActions/Inbox.axaml b/gallery/TKCode.Arc.Gallery/Demos/Gestures/SwipeActions/Inbox.axaml index 71fead63..e812281f 100644 --- a/gallery/TKCode.Arc.Gallery/Demos/Gestures/SwipeActions/Inbox.axaml +++ b/gallery/TKCode.Arc.Gallery/Demos/Gestures/SwipeActions/Inbox.axaml @@ -4,15 +4,28 @@ xmlns:demos="using:TKCode.Arc.Gallery.Demos" x:Class="TKCode.Arc.Gallery.Demos.SwipeActionsInboxDemo"> - + - + + + + + - - - + + + + + + + - - - - - + + - + + - diff --git a/gallery/TKCode.Arc.Gallery/Demos/Gestures/SwipeActions/Inbox.axaml.cs b/gallery/TKCode.Arc.Gallery/Demos/Gestures/SwipeActions/Inbox.axaml.cs index 2dc375f2..2912d0e4 100644 --- a/gallery/TKCode.Arc.Gallery/Demos/Gestures/SwipeActions/Inbox.axaml.cs +++ b/gallery/TKCode.Arc.Gallery/Demos/Gestures/SwipeActions/Inbox.axaml.cs @@ -15,16 +15,23 @@ public SwipeActionsInboxDemo() { InitializeComponent(); Inbox.ItemsSource = _messages; + _messages.CollectionChanged += (_, _) => UpdateRemaining(); Fill(); } + private void UpdateRemaining() + { + Remaining.Text = _messages.Count == 0 ? "All caught up" : $"{_messages.Count} left to triage"; + } + private void Fill() { _messages.Clear(); - _messages.Add(new Message("Maya Chen", "Design review moved to Thursday", "The room is booked from 2pm. Bring the latest flows.", "9:41")); - _messages.Add(new Message("Linear", "3 issues assigned to you", "ARC-212 Swipe rows stutter on resize, and two more.", "8:15")); - _messages.Add(new Message("Jonas Weber", "Invoice #2041", "Attached is the invoice for October. Thanks again!", "Yesterday")); - _messages.Add(new Message("Calendar", "Standup in 10 minutes", "Daily standup · Room 4B · 10:00–10:15", "Yesterday")); + _messages.Add(new Message("Maya Chen", "Design review moved to Thursday", "The room is booked from 2pm. Bring the latest flows.", "9:41", 47)); + _messages.Add(new Message("Daniel Ortiz", "Contract draft for Fernhill", "Two clauses changed since Monday. Legal wants a reply today.", "8:15", 12)); + _messages.Add(new Message("Priya Nair", "Survey results are in", "Response rate was 38%. The summary deck is in the shared folder.", "Yesterday", 45) { IsUnread = false }); + _messages.Add(new Message("Jonas Weber", "Invoice #2041", "Attached is the invoice for October. Thanks again!", "Mon", 59) { IsUnread = false }); + UpdateRemaining(); } private void OnActionInvoked(object? sender, SwipeActionInvokedEventArgs e) @@ -45,6 +52,7 @@ private void OnActionInvoked(object? sender, SwipeActionInvokedEventArgs e) _lastRemoved = message; _messages.Remove(message); Undo.IsVisible = true; + Recovery.IsVisible = true; Status.Text = $"{(e.Action.Tone == SwipeActionTone.Danger ? "Deleted" : "Archived")} “{message.Subject}”."; } @@ -65,10 +73,11 @@ private void OnResetClick(object? sender, RoutedEventArgs e) { Fill(); Undo.IsVisible = false; - Status.Text = "Inbox reset."; + Recovery.IsVisible = false; + Status.Text = "Swipe a message, or use its menu."; } - public sealed class Message(string sender, string subject, string preview, string time) : INotifyPropertyChanged + public sealed class Message(string sender, string subject, string preview, string time, int portrait) : INotifyPropertyChanged { private bool _isUnread = true; @@ -82,6 +91,8 @@ public sealed class Message(string sender, string subject, string preview, strin public string Time { get; } = time; + public ImageSource? Photo { get; } = ImageSource.TryParse($"https://i.pravatar.cc/160?img={portrait}", out var source) ? source : null; + public bool IsUnread { get => _isUnread; 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/Docs/Ai/AgentPlan/en-US.md b/gallery/TKCode.Arc.Gallery/Docs/Ai/AgentPlan/en-US.md index e8d3b3ea..2a93dd01 100644 --- a/gallery/TKCode.Arc.Gallery/Docs/Ai/AgentPlan/en-US.md +++ b/gallery/TKCode.Arc.Gallery/Docs/Ai/AgentPlan/en-US.md @@ -46,7 +46,7 @@ the built-in simulation when it is approved. ``` `AgentPlan` derives from Avalonia's `TemplatedControl` and lives in the Special package; register it with -`UseArcSpecial()` next to `UseArc()`. The card is as wide as its container up to 560px, reads well from about 320px, +`UseArcSpecial()` next to `UseArcControls()`. The card is as wide as its container up to 560px, reads well from about 320px, and tightens below 420px. ### Anatomy diff --git a/gallery/TKCode.Arc.Gallery/Docs/Ai/AgentPlan/zh-CN.md b/gallery/TKCode.Arc.Gallery/Docs/Ai/AgentPlan/zh-CN.md index ec863841..7c539a86 100644 --- a/gallery/TKCode.Arc.Gallery/Docs/Ai/AgentPlan/zh-CN.md +++ b/gallery/TKCode.Arc.Gallery/Docs/Ai/AgentPlan/zh-CN.md @@ -1,7 +1,7 @@ --- title: Agent plan summary: 将智能体的计划呈现为可编辑的清单:草稿阶段可以重排、改名和添加步骤,批准后则逐步跟随运行过程。 -source-hash: 0e89f7dff7a5 +source-hash: 2ce58e71cf24 --- @@ -34,7 +34,7 @@ source-hash: 0e89f7dff7a5 ``` -`AgentPlan` 派生自 Avalonia 的 `TemplatedControl`,位于 Special 包中;请在 `UseArc()` 旁调用 `UseArcSpecial()` 以注册它。卡片的宽度与其容器相同,最宽为 560 px,在约 320 px 时仍然易读,并在小于 420 px 时收紧布局。 +`AgentPlan` 派生自 Avalonia 的 `TemplatedControl`,位于 Special 包中;请在 `UseArcControls()` 旁调用 `UseArcSpecial()` 以注册它。卡片的宽度与其容器相同,最宽为 560 px,在约 320 px 时仍然易读,并在小于 420 px 时收紧布局。 ### 结构 diff --git a/gallery/TKCode.Arc.Gallery/Docs/Ai/AgentPlan/zh-TW.md b/gallery/TKCode.Arc.Gallery/Docs/Ai/AgentPlan/zh-TW.md index 38f2e8d2..2c860d00 100644 --- a/gallery/TKCode.Arc.Gallery/Docs/Ai/AgentPlan/zh-TW.md +++ b/gallery/TKCode.Arc.Gallery/Docs/Ai/AgentPlan/zh-TW.md @@ -1,7 +1,7 @@ --- title: Agent plan summary: "代理的計畫是一份可編輯的檢查清單:草稿階段可以重新排序、改名與新增步驟,核准後則依序追蹤執行過程。" -source-hash: 0e89f7dff7a5 +source-hash: 2ce58e71cf24 --- @@ -34,7 +34,7 @@ source-hash: 0e89f7dff7a5 ``` -`AgentPlan` 衍生自 Avalonia 的 `TemplatedControl`,位於 Special 套件中;請在 `UseArc()` 旁呼叫 `UseArcSpecial()` 來註冊。卡片的寬度與容器相同,最寬 560px,約 320px 寬時仍易於閱讀,低於 420px 時會收緊間距。 +`AgentPlan` 衍生自 Avalonia 的 `TemplatedControl`,位於 Special 套件中;請在 `UseArcControls()` 旁呼叫 `UseArcSpecial()` 來註冊。卡片的寬度與容器相同,最寬 560px,約 320px 寬時仍易於閱讀,低於 420px 時會收緊間距。 ### 結構 diff --git a/gallery/TKCode.Arc.Gallery/Docs/Ai/AgentStatus/en-US.md b/gallery/TKCode.Arc.Gallery/Docs/Ai/AgentStatus/en-US.md index 8484909d..59554ddf 100644 --- a/gallery/TKCode.Arc.Gallery/Docs/Ai/AgentStatus/en-US.md +++ b/gallery/TKCode.Arc.Gallery/Docs/Ai/AgentStatus/en-US.md @@ -46,7 +46,7 @@ not to the pill. ``` `AgentStatus` derives from Avalonia's `TemplatedControl`. It lives in the Special package: register it with -`UseArcSpecial()` next to `UseArc()`. +`UseArcSpecial()` next to `UseArcControls()`. In an app, drive it from your agent's events. Set `Status`, keep `Activity` to the latest tool call in the present tense, list `Steps`, give a `StartedAt` that stays fixed for the whole run, and handle the action events. The pill never diff --git a/gallery/TKCode.Arc.Gallery/Docs/Ai/AgentStatus/zh-CN.md b/gallery/TKCode.Arc.Gallery/Docs/Ai/AgentStatus/zh-CN.md index a2ab171e..911c2835 100644 --- a/gallery/TKCode.Arc.Gallery/Docs/Ai/AgentStatus/zh-CN.md +++ b/gallery/TKCode.Arc.Gallery/Docs/Ai/AgentStatus/zh-CN.md @@ -1,7 +1,7 @@ --- title: Agent status summary: 运行中智能体的实时状态胶囊:标识位于旋转的环中,显示当前步骤与计时;按一下即展开为最近的步骤,并带有暂停、停止与审批控件。 -source-hash: 6b4ca8a10305 +source-hash: 6d0078b450f9 --- @@ -35,7 +35,7 @@ source-hash: 6b4ca8a10305 ``` -`AgentStatus` 派生自 Avalonia 的 `TemplatedControl`。它位于 Special 包中:请在 `UseArc()` 旁调用 `UseArcSpecial()` 进行注册。 +`AgentStatus` 派生自 Avalonia 的 `TemplatedControl`。它位于 Special 包中:请在 `UseArcControls()` 旁调用 `UseArcSpecial()` 进行注册。 在应用中,请根据智能体的事件驱动它。设置 `Status`,让 `Activity` 保持为最新的工具调用并使用现在时描述,列出 `Steps`,为整个运行提供一个始终不变的 `StartedAt`,并处理各个操作事件。胶囊有数据时从不自行更改状态:按下 Approve 会引发 `ApproveRequested`,由你的代码决定接下来做什么。 diff --git a/gallery/TKCode.Arc.Gallery/Docs/Ai/AgentStatus/zh-TW.md b/gallery/TKCode.Arc.Gallery/Docs/Ai/AgentStatus/zh-TW.md index 1a144206..d8866550 100644 --- a/gallery/TKCode.Arc.Gallery/Docs/Ai/AgentStatus/zh-TW.md +++ b/gallery/TKCode.Arc.Gallery/Docs/Ai/AgentStatus/zh-TW.md @@ -1,7 +1,7 @@ --- title: Agent status summary: 執行中代理的即時狀態膠囊:其標記位於旋轉的環中,顯示目前的活動與計時器;按一下即展開成近期步驟,並提供暫停、停止與核准控制。 -source-hash: 6b4ca8a10305 +source-hash: 6d0078b450f9 --- @@ -35,7 +35,7 @@ source-hash: 6b4ca8a10305 ``` -`AgentStatus` 衍生自 Avalonia 的 `TemplatedControl`。它位於 Special 套件中:請在 `UseArc()` 旁呼叫 `UseArcSpecial()` 來註冊。 +`AgentStatus` 衍生自 Avalonia 的 `TemplatedControl`。它位於 Special 套件中:請在 `UseArcControls()` 旁呼叫 `UseArcSpecial()` 來註冊。 在應用程式中,請由代理的事件驅動它。設定 `Status`,讓 `Activity` 保持為最新的工具呼叫(使用現在式),列出 `Steps`,給定一個在整個執行過程中固定不變的 `StartedAt`,並處理動作事件。有資料時,膠囊永遠不會自行變更狀態:按下核准會引發 `ApproveRequested`,接下來要怎麼做由你的程式碼決定。 diff --git a/gallery/TKCode.Arc.Gallery/Docs/Ai/AgentTimeline/en-US.md b/gallery/TKCode.Arc.Gallery/Docs/Ai/AgentTimeline/en-US.md index 22320296..264d9466 100644 --- a/gallery/TKCode.Arc.Gallery/Docs/Ai/AgentTimeline/en-US.md +++ b/gallery/TKCode.Arc.Gallery/Docs/Ai/AgentTimeline/en-US.md @@ -36,7 +36,7 @@ parallel speedup. ## Usage Given no data, the timeline shows an invented sample run and replays it the first time it is about a fifth in view. -Register the package with `UseArcSpecial()` next to `UseArc()`. +Register the package with `UseArcSpecial()` next to `UseArcControls()`. ```xml @@ -25,7 +25,7 @@ source-hash: ba648bfd9702 ## 用法 -没有提供数据时,时间线会展示一次虚构的示例运行,并在它首次有大约五分之一进入视野时开始回放。请在 `UseArc()` 旁调用 `UseArcSpecial()` 注册该包。 +没有提供数据时,时间线会展示一次虚构的示例运行,并在它首次有大约五分之一进入视野时开始回放。请在 `UseArcControls()` 旁调用 `UseArcSpecial()` 注册该包。 ```xml @@ -25,7 +25,7 @@ source-hash: ba648bfd9702 ## 用法 -未提供資料時,時間軸會顯示一次虛構的範例執行過程,並在第一次它有約五分之一進入可視範圍時開始重播。請在 `UseArc()` 旁呼叫 `UseArcSpecial()` 來註冊該套件。 +未提供資料時,時間軸會顯示一次虛構的範例執行過程,並在第一次它有約五分之一進入可視範圍時開始重播。請在 `UseArcControls()` 旁呼叫 `UseArcSpecial()` 來註冊該套件。 ```xml @@ -31,7 +31,7 @@ source-hash: 8aab979d927a ``` -`ContextWindow` 是 Special 包中的 `TemplatedControl`。请在 `UseArc()` 旁调用 `UseArcSpecial()` 以注册该包。卡片最宽为 744px,宽度跟随它自身,而不是窗口的宽度。 +`ContextWindow` 是 Special 包中的 `TemplatedControl`。请在 `UseArcControls()` 旁调用 `UseArcSpecial()` 以注册该包。卡片最宽为 744px,宽度跟随它自身,而不是窗口的宽度。 完全没有数据时(没有模型、项目、轮次或文件),卡片会显示来自 `ContextWindowSamples` 的虚构示例会话:三个示例模型,窗口分别为 1M、512K 和 400K;一段用量为 436K token 的编码会话;Send message 对应五个轮次,Attach file 对应三个文件。示例使用自己的副本,因此 `Items` 保持为空。只要你提供了四个列表中的任意一个,卡片就会改用你的数据。 diff --git a/gallery/TKCode.Arc.Gallery/Docs/Ai/ContextWindow/zh-TW.md b/gallery/TKCode.Arc.Gallery/Docs/Ai/ContextWindow/zh-TW.md index 83d92810..45fb1dec 100644 --- a/gallery/TKCode.Arc.Gallery/Docs/Ai/ContextWindow/zh-TW.md +++ b/gallery/TKCode.Arc.Gallery/Docs/Ai/ContextWindow/zh-TW.md @@ -1,7 +1,7 @@ --- title: Context window summary: 模型的上下文視窗呈現為 200 格的方格,逐段填滿,並帶有壓縮門檻、圖例,以及顯示還剩多少空間的動作。 -source-hash: 8aab979d927a +source-hash: 10028f4973b8 --- @@ -31,7 +31,7 @@ source-hash: 8aab979d927a ``` -`ContextWindow` 是 Special 套件中的 `TemplatedControl`。請在 `UseArc()` 旁呼叫 `UseArcSpecial()` 來註冊該套件。卡片最寬為 744px,並依自身寬度調整,而非視窗的寬度。 +`ContextWindow` 是 Special 套件中的 `TemplatedControl`。請在 `UseArcControls()` 旁呼叫 `UseArcSpecial()` 來註冊該套件。卡片最寬為 744px,並依自身寬度調整,而非視窗的寬度。 完全沒有資料(沒有模型、項目、回合或檔案)時,卡片會顯示 `ContextWindowSamples` 提供的虛構範例工作階段:三個範例模型,視窗分別為 1M、512K 與 400K;一個 436K token 的程式設計工作階段;Send message 的五個回合,以及 Attach file 的三個檔案。範例使用它自己的副本,因此 `Items` 保持為空。只要你為卡片提供上述四個清單中的任何一個,卡片就會改用你的資料。 diff --git a/gallery/TKCode.Arc.Gallery/Docs/Api/zh-CN.xlf b/gallery/TKCode.Arc.Gallery/Docs/Api/zh-CN.xlf index abd0559c..4b86cf85 100644 --- a/gallery/TKCode.Arc.Gallery/Docs/Api/zh-CN.xlf +++ b/gallery/TKCode.Arc.Gallery/Docs/Api/zh-CN.xlf @@ -5825,8 +5825,8 @@ 在内置菜单打开之前触发,可以修改条目或取消菜单。 - Culture for formatting and parsing values; null follows the AtomUI language. - 格式化和解析值所用的文化;为 null 时跟随 AtomUI 的语言。 + Culture for formatting and parsing values; null follows the Arc language. + 格式化和解析值所用的文化;为 null 时跟随 Arc 的语言。 Loads rows from a server one block at a time as people scroll, instead of `ItemsSource`. The grid is then read only, does not group, hides Select all and exports only checked rows. @@ -8381,8 +8381,8 @@ 故事切换到另一步骤时引发。 - Before and after, split by a divider (Arc image compare). Drag anywhere to move it: the handle follows 1:1, rubber-bands past the edges and stays where it is released. A press on the image springs the divider there; on touch the press waits for a drag along the divider's axis so the page still scrolls, and a tap moves the divider. Arrow keys step 1% (Shift 10%), PageUp / PageDown 10%, Home and End reveal one side fully, and a double-click on the handle springs it back to the middle. Changing `Orientation` swings the divider a quarter turn. `Before` / `After` take any content (an `Image` with `Stretch="UniformToFill"`, a drawing, a control). `BeforeSource` / `AfterSource` load a picture with AtomUI's `AsyncImage` on top of that content: it fades in from a soft blur while downloading, and when it fails the content underneath shows instead. - 前后对比,由分隔线分开(Arc 图像对比)。拖动任意位置即可移动:手柄 1:1 跟随,越过边缘时有橡皮筋式回弹,松手后停留在原处。在图像上按下会将分隔线以弹簧方式移动到该处;触摸时需等待沿分隔线方向的拖动,以便页面仍可滚动,轻点则移动分隔线。方向键每次步进 1%(按住 Shift 为 10%),PageUp / PageDown 为 10%,Home 和 End 将一侧完整显示,双击手柄会将其弹回中间。更改 `Orientation` 会使分隔线旋转四分之一圈。`Before` / `After` 可接受任意内容(`Stretch="UniformToFill"` 的 `Image`、绘图或控件)。`BeforeSource` / `AfterSource` 通过 AtomUI 的 `AsyncImage` 在该内容之上加载图片:下载时从柔和的模糊中淡入,加载失败时则显示底层内容。 + Before and after, split by a divider (Arc image compare). Drag anywhere to move it: the handle follows 1:1, rubber-bands past the edges and stays where it is released. A press on the image springs the divider there; on touch the press waits for a drag along the divider's axis so the page still scrolls, and a tap moves the divider. Arrow keys step 1% (Shift 10%), PageUp / PageDown 10%, Home and End reveal one side fully, and a double-click on the handle springs it back to the middle. Changing `Orientation` swings the divider a quarter turn. `Before` / `After` take any content (an `Image` with `Stretch="UniformToFill"`, a drawing, a control). `BeforeSource` / `AfterSource` load a picture with an `ArcImage` on top of that content: it fades in from a soft blur while downloading, and when it fails the content underneath shows instead. + 前后对比,由分隔线分开(Arc 图像对比)。拖动任意位置即可移动:手柄 1:1 跟随,越过边缘时有橡皮筋式回弹,松手后停留在原处。在图像上按下会将分隔线以弹簧方式移动到该处;触摸时需等待沿分隔线方向的拖动,以便页面仍可滚动,轻点则移动分隔线。方向键每次步进 1%(按住 Shift 为 10%),PageUp / PageDown 为 10%,Home 和 End 将一侧完整显示,双击手柄会将其弹回中间。更改 `Orientation` 会使分隔线旋转四分之一圈。`Before` / `After` 可接受任意内容(`Stretch="UniformToFill"` 的 `Image`、绘图或控件)。`BeforeSource` / `AfterSource` 通过 `ArcImage` 在该内容之上加载图片:下载时从柔和的模糊中淡入,加载失败时则显示底层内容。 The result, shown on the right (or bottom). @@ -8409,8 +8409,8 @@ Before 一侧上方的标注(默认“Before”)。 - Picture drawn over `Before` (AtomUI `AsyncImage`, cover fit). - 绘制在 `Before` 之上的图片(AtomUI `AsyncImage`,覆盖填充)。 + Picture drawn over `Before` (an `ArcImage`, cover fit). + 绘制在 `Before` 之上的图片(`ArcImage`,覆盖填充)。 Accessible name of the divider (default "Before and after"). @@ -13905,8 +13905,8 @@ 当窗口未覆盖首页和末页时,以省略号形式保持它们可见(Arc 中不存在此功能)。 - Arc password field: a labeled password input with a reveal toggle inside the frame. The toggle's eye is crossed out by a slash that draws itself (it is never swapped for a second icon), and after the first toggle every switch between dots and characters resolves in place from a light blur. Enter or Space on the toggle shows or hides the password; the toggle reports its pressed state and switches its name between `ShowPasswordText` and `HidePasswordText`. - Arc 密码输入框:带标签的密码输入框,外框内有显示切换按钮。切换按钮的眼睛图标上有一条自行绘制的斜线(从不替换为第二个图标);首次切换之后,圆点与字符之间的每次切换都会通过轻微模糊原地过渡。在切换按钮上按 Enter 或 Space 可显示或隐藏密码;该按钮会报告其按下状态,并在 `ShowPasswordText` 与 `HidePasswordText` 之间切换其名称。 + Arc password field: a labeled password input with a reveal toggle inside the frame. The toggle's eye is crossed out by a slash that draws itself (it is never swapped for a second icon), and after the first toggle every switch between dots and characters resolves in place from a light blur. Enter or Space on the toggle shows or hides the password; the toggle reports its pressed state and switches its name between `ShowPasswordText` and `HidePasswordText`. The explicit reveal selection persists across focus changes until the user or caller changes `IsRevealed`. + Arc 密码输入框:带标签的密码输入框,外框内有显示切换按钮。切换按钮的眼睛图标上有一条自行绘制的斜线(从不替换为第二个图标);首次切换之后,圆点与字符之间的每次切换都会通过轻微模糊原地过渡。在切换按钮上按 Enter 或 Space 可显示或隐藏密码;该按钮会报告其按下状态,并在 `ShowPasswordText` 与 `HidePasswordText` 之间切换其名称。用户明确选择的显示状态在焦点变化时保持不变,直到用户或调用方更改 `IsRevealed`。 Name of the toggle while the password is shown. @@ -19973,8 +19973,8 @@ 显示可拖动字段加高的右下角手柄(Arc:`resize: vertical`)。默认为 true。 - A single light / dark switch for a header: a secondary button that swaps sun and moon on a shared turn and names the next appearance. Pressing it raises `ThemeChangeRequested`; unless a handler marks it handled, the switcher applies the change through AtomUI's theme manager (toggling `ThemeAlgorithm.Dark` and keeping the rest of the current theme) inside the page transition of its `Variant`. Arc hands that transition to the View Transitions API; here the old window is snapshotted into an overlay whose animated clip reveals the new theme beneath (`RunTransitionAsync`, also usable from a handler). Reduced motion switches without a transition. - 用于页眉的单一明暗切换:一个次要按钮,在共享的转动动画中切换太阳与月亮图标,并标明下一种外观。按下时引发 `ThemeChangeRequested`;除非处理程序将其标记为已处理,否则切换器会通过 AtomUI 的主题管理器应用更改(切换 `ThemeAlgorithm.Dark` 并保留当前主题的其他设置),并在其 `Variant` 的页面过渡中进行。Arc 将该过渡交给 View Transitions API;此处则将旧窗口截取为覆盖层,由其动画裁剪区在下方显示新主题(`RunTransitionAsync`,也可在处理程序中使用)。减少动态效果时直接切换,不做过渡。 + A single light / dark switch for a header: a secondary button that swaps sun and moon on a shared turn and names the next appearance. Pressing it raises `ThemeChangeRequested`; unless a handler marks it handled, the switcher applies the change through `ArcThemeManager` (changing the appearance and keeping the rest of the current theme) inside the page transition of its `Variant`. Arc hands that transition to the View Transitions API; here the old window is snapshotted into an overlay whose animated clip reveals the new theme beneath (`RunTransitionAsync`, also usable from a handler). Reduced motion switches without a transition. + 用于页眉的单一明暗切换:一个次要按钮,在共享的转动动画中切换太阳与月亮图标,并标明下一种外观。按下时引发 `ThemeChangeRequested`;除非处理程序将其标记为已处理,否则切换器会通过 `ArcThemeManager` 应用更改(只改变外观,保留当前主题的其他设置),并在其 `Variant` 的页面过渡中进行。Arc 将该过渡交给 View Transitions API;此处则将旧窗口截取为覆盖层,由其动画裁剪区在下方显示新主题(`RunTransitionAsync`,也可在处理程序中使用)。减少动态效果时直接切换,不做过渡。 The appearance shown; null follows the control's actual theme variant (default). @@ -19993,8 +19993,8 @@ 图标旁可见的文字(默认为“切换主题”)。 - Raised before the theme changes. Set `Handled` to apply the theme yourself (optionally inside `RunTransitionAsync`); otherwise the switcher applies it through AtomUI's theme manager. - 主题更改前引发。设置 `Handled` 以自行应用主题(可选择在 `RunTransitionAsync` 中进行);否则切换器通过 AtomUI 的主题管理器应用。 + Raised before the theme changes. Set `Handled` to apply the theme yourself (optionally inside `RunTransitionAsync`); otherwise the switcher applies it through `ArcThemeManager`. + 主题更改前引发。设置 `Handled` 以自行应用主题(可选择在 `RunTransitionAsync` 中进行);否则切换器通过 `ArcThemeManager` 应用。 Which page transition to play (default Reveal). diff --git a/gallery/TKCode.Arc.Gallery/Docs/Api/zh-TW.xlf b/gallery/TKCode.Arc.Gallery/Docs/Api/zh-TW.xlf index 22d34cc8..2b32121f 100644 --- a/gallery/TKCode.Arc.Gallery/Docs/Api/zh-TW.xlf +++ b/gallery/TKCode.Arc.Gallery/Docs/Api/zh-TW.xlf @@ -5825,8 +5825,8 @@ 在內建選單開啟之前觸發,可以修改項目或取消選單。 - Culture for formatting and parsing values; null follows the AtomUI language. - 格式化和解析值所用的文化;為 null 時跟隨 AtomUI 的語言。 + Culture for formatting and parsing values; null follows the Arc language. + 格式化和解析值所用的文化;為 null 時跟隨 Arc 的語言。 Loads rows from a server one block at a time as people scroll, instead of `ItemsSource`. The grid is then read only, does not group, hides Select all and exports only checked rows. @@ -8381,8 +8381,8 @@ 故事切換到另一步驟時引發。 - Before and after, split by a divider (Arc image compare). Drag anywhere to move it: the handle follows 1:1, rubber-bands past the edges and stays where it is released. A press on the image springs the divider there; on touch the press waits for a drag along the divider's axis so the page still scrolls, and a tap moves the divider. Arrow keys step 1% (Shift 10%), PageUp / PageDown 10%, Home and End reveal one side fully, and a double-click on the handle springs it back to the middle. Changing `Orientation` swings the divider a quarter turn. `Before` / `After` take any content (an `Image` with `Stretch="UniformToFill"`, a drawing, a control). `BeforeSource` / `AfterSource` load a picture with AtomUI's `AsyncImage` on top of that content: it fades in from a soft blur while downloading, and when it fails the content underneath shows instead. - 前後對比,由分隔線分開(Arc 影像對比)。拖曳任意位置即可移動:把手 1:1 跟隨,越過邊緣時有橡皮筋式回彈,放開後停留在原處。在影像上按下會將分隔線以彈簧方式移動到該處;觸控時需等待沿分隔線方向的拖曳,以便頁面仍可捲動,輕點則移動分隔線。方向鍵每次步進 1%(按住 Shift 為 10%),PageUp / PageDown 為 10%,Home 和 End 將一側完整顯示,雙擊把手會將其彈回中間。變更 `Orientation` 會使分隔線旋轉四分之一圈。`Before` / `After` 可接受任意內容(`Stretch="UniformToFill"` 的 `Image`、繪圖或控制項)。`BeforeSource` / `AfterSource` 透過 AtomUI 的 `AsyncImage` 在該內容之上載入圖片:下載時從柔和的模糊中淡入,載入失敗時則顯示底層內容。 + Before and after, split by a divider (Arc image compare). Drag anywhere to move it: the handle follows 1:1, rubber-bands past the edges and stays where it is released. A press on the image springs the divider there; on touch the press waits for a drag along the divider's axis so the page still scrolls, and a tap moves the divider. Arrow keys step 1% (Shift 10%), PageUp / PageDown 10%, Home and End reveal one side fully, and a double-click on the handle springs it back to the middle. Changing `Orientation` swings the divider a quarter turn. `Before` / `After` take any content (an `Image` with `Stretch="UniformToFill"`, a drawing, a control). `BeforeSource` / `AfterSource` load a picture with an `ArcImage` on top of that content: it fades in from a soft blur while downloading, and when it fails the content underneath shows instead. + 前後對比,由分隔線分開(Arc 影像對比)。拖曳任意位置即可移動:把手 1:1 跟隨,越過邊緣時有橡皮筋式回彈,放開後停留在原處。在影像上按下會將分隔線以彈簧方式移動到該處;觸控時需等待沿分隔線方向的拖曳,以便頁面仍可捲動,輕點則移動分隔線。方向鍵每次步進 1%(按住 Shift 為 10%),PageUp / PageDown 為 10%,Home 和 End 將一側完整顯示,雙擊把手會將其彈回中間。變更 `Orientation` 會使分隔線旋轉四分之一圈。`Before` / `After` 可接受任意內容(`Stretch="UniformToFill"` 的 `Image`、繪圖或控制項)。`BeforeSource` / `AfterSource` 透過 `ArcImage` 在該內容之上載入圖片:下載時從柔和的模糊中淡入,載入失敗時則顯示底層內容。 The result, shown on the right (or bottom). @@ -8409,8 +8409,8 @@ Before 一側上方的標註(預設「Before」)。 - Picture drawn over `Before` (AtomUI `AsyncImage`, cover fit). - 繪製於 `Before` 之上的圖片(AtomUI `AsyncImage`,覆蓋填滿)。 + Picture drawn over `Before` (an `ArcImage`, cover fit). + 繪製於 `Before` 之上的圖片(`ArcImage`,覆蓋填滿)。 Accessible name of the divider (default "Before and after"). @@ -13905,8 +13905,8 @@ 當視窗未涵蓋首頁與末頁時,以省略號形式保留其可見(Arc 中沒有此功能)。 - Arc password field: a labeled password input with a reveal toggle inside the frame. The toggle's eye is crossed out by a slash that draws itself (it is never swapped for a second icon), and after the first toggle every switch between dots and characters resolves in place from a light blur. Enter or Space on the toggle shows or hides the password; the toggle reports its pressed state and switches its name between `ShowPasswordText` and `HidePasswordText`. - Arc 密碼輸入框:帶標籤的密碼輸入框,外框內有顯示切換按鈕。切換按鈕的眼睛圖示上有一條自行繪製的斜線(從不替換為第二個圖示);首次切換之後,圓點與字元之間的每次切換都會透過輕微模糊原地過渡。在切換按鈕上按 Enter 或 Space 可顯示或隱藏密碼;該按鈕會回報其按下狀態,並在 `ShowPasswordText` 與 `HidePasswordText` 之間切換其名稱。 + Arc password field: a labeled password input with a reveal toggle inside the frame. The toggle's eye is crossed out by a slash that draws itself (it is never swapped for a second icon), and after the first toggle every switch between dots and characters resolves in place from a light blur. Enter or Space on the toggle shows or hides the password; the toggle reports its pressed state and switches its name between `ShowPasswordText` and `HidePasswordText`. The explicit reveal selection persists across focus changes until the user or caller changes `IsRevealed`. + Arc 密碼輸入框:帶標籤的密碼輸入框,外框內有顯示切換按鈕。切換按鈕的眼睛圖示上有一條自行繪製的斜線(從不替換為第二個圖示);首次切換之後,圓點與字元之間的每次切換都會透過輕微模糊原地過渡。在切換按鈕上按 Enter 或 Space 可顯示或隱藏密碼;該按鈕會回報其按下狀態,並在 `ShowPasswordText` 與 `HidePasswordText` 之間切換其名稱。使用者明確選擇的顯示狀態在焦點變化時維持不變,直到使用者或呼叫端變更 `IsRevealed`。 Name of the toggle while the password is shown. @@ -19973,8 +19973,8 @@ 顯示可拖曳欄位加高的右下角把手(Arc:`resize: vertical`)。預設為 true。 - A single light / dark switch for a header: a secondary button that swaps sun and moon on a shared turn and names the next appearance. Pressing it raises `ThemeChangeRequested`; unless a handler marks it handled, the switcher applies the change through AtomUI's theme manager (toggling `ThemeAlgorithm.Dark` and keeping the rest of the current theme) inside the page transition of its `Variant`. Arc hands that transition to the View Transitions API; here the old window is snapshotted into an overlay whose animated clip reveals the new theme beneath (`RunTransitionAsync`, also usable from a handler). Reduced motion switches without a transition. - 用於頁首的單一明暗切換:一個次要按鈕,在共用的轉動動畫中切換太陽與月亮圖示,並標示下一種外觀。按下時引發 `ThemeChangeRequested`;除非處理程式將其標記為已處理,否則切換器會透過 AtomUI 的主題管理器套用變更(切換 `ThemeAlgorithm.Dark` 並保留目前主題的其他設定),並在其 `Variant` 的頁面過渡中進行。Arc 將該過渡交給 View Transitions API;此處則將舊視窗擷取為覆蓋層,由其動畫裁剪區在下方顯示新主題(`RunTransitionAsync`,也可在處理程式中使用)。減少動態效果時直接切換,不做過渡。 + A single light / dark switch for a header: a secondary button that swaps sun and moon on a shared turn and names the next appearance. Pressing it raises `ThemeChangeRequested`; unless a handler marks it handled, the switcher applies the change through `ArcThemeManager` (changing the appearance and keeping the rest of the current theme) inside the page transition of its `Variant`. Arc hands that transition to the View Transitions API; here the old window is snapshotted into an overlay whose animated clip reveals the new theme beneath (`RunTransitionAsync`, also usable from a handler). Reduced motion switches without a transition. + 用於頁首的單一明暗切換:一個次要按鈕,在共用的轉動動畫中切換太陽與月亮圖示,並標示下一種外觀。按下時引發 `ThemeChangeRequested`;除非處理程式將其標記為已處理,否則切換器會透過 `ArcThemeManager` 套用變更(只改變外觀,保留目前主題的其他設定),並在其 `Variant` 的頁面過渡中進行。Arc 將該過渡交給 View Transitions API;此處則將舊視窗擷取為覆蓋層,由其動畫裁剪區在下方顯示新主題(`RunTransitionAsync`,也可在處理程式中使用)。減少動態效果時直接切換,不做過渡。 The appearance shown; null follows the control's actual theme variant (default). @@ -19993,8 +19993,8 @@ 圖示旁可見的文字(預設為「切換主題」)。 - Raised before the theme changes. Set `Handled` to apply the theme yourself (optionally inside `RunTransitionAsync`); otherwise the switcher applies it through AtomUI's theme manager. - 主題變更前引發。設定 `Handled` 以自行套用主題(可選擇在 `RunTransitionAsync` 中進行);否則切換器透過 AtomUI 的主題管理器套用。 + Raised before the theme changes. Set `Handled` to apply the theme yourself (optionally inside `RunTransitionAsync`); otherwise the switcher applies it through `ArcThemeManager`. + 主題變更前引發。設定 `Handled` 以自行套用主題(可選擇在 `RunTransitionAsync` 中進行);否則切換器透過 `ArcThemeManager` 套用。 Which page transition to play (default Reveal). diff --git a/gallery/TKCode.Arc.Gallery/Docs/Cursor/DotGrid/en-US.md b/gallery/TKCode.Arc.Gallery/Docs/Cursor/DotGrid/en-US.md index ea1b1470..3e98d74e 100644 --- a/gallery/TKCode.Arc.Gallery/Docs/Cursor/DotGrid/en-US.md +++ b/gallery/TKCode.Arc.Gallery/Docs/Cursor/DotGrid/en-US.md @@ -34,7 +34,7 @@ inside sits on top of the dots and keeps working as usual. ``` `DotGrid` is a `ContentControl` in the Special package. Register the package with `UseArcSpecial()` next to -`UseArc()`. Give the control a height, or let its content size it; it is at least 240px high. The field has no +`UseArcControls()`. Give the control a height, or let its content size it; it is at least 240px high. The field has no background of its own: set `Background`, `BorderBrush`, `BorderThickness` and `CornerRadius` to frame it, and the dots are clipped to that rounded box. diff --git a/gallery/TKCode.Arc.Gallery/Docs/Cursor/DotGrid/zh-CN.md b/gallery/TKCode.Arc.Gallery/Docs/Cursor/DotGrid/zh-CN.md index b71ba753..1dae27c6 100644 --- a/gallery/TKCode.Arc.Gallery/Docs/Cursor/DotGrid/zh-CN.md +++ b/gallery/TKCode.Arc.Gallery/Docs/Cursor/DotGrid/zh-CN.md @@ -1,7 +1,7 @@ --- title: Dot grid summary: 一片点阵,像透镜一样在指针周围向外鼓起,每次点击都会向外扩散一圈涟漪。 -source-hash: d97edb6e6314 +source-hash: eea42c7255b6 --- @@ -31,7 +31,7 @@ source-hash: d97edb6e6314 ``` -`DotGrid` 是 Special 包中的 `ContentControl`。在 `UseArc()` 旁边调用 `UseArcSpecial()` 注册该包。给控件一个高度,或让内容撑开它;最小高度为 240px。点阵本身没有背景:设置 `Background`、`BorderBrush`、`BorderThickness` 和 `CornerRadius` 给它加上边框,点会被裁剪在这个圆角框内。 +`DotGrid` 是 Special 包中的 `ContentControl`。在 `UseArcControls()` 旁边调用 `UseArcSpecial()` 注册该包。给控件一个高度,或让内容撑开它;最小高度为 240px。点阵本身没有背景:设置 `Background`、`BorderBrush`、`BorderThickness` 和 `CornerRadius` 给它加上边框,点会被裁剪在这个圆角框内。 ### 用作首屏 diff --git a/gallery/TKCode.Arc.Gallery/Docs/Cursor/DotGrid/zh-TW.md b/gallery/TKCode.Arc.Gallery/Docs/Cursor/DotGrid/zh-TW.md index 97744b87..f9c3742e 100644 --- a/gallery/TKCode.Arc.Gallery/Docs/Cursor/DotGrid/zh-TW.md +++ b/gallery/TKCode.Arc.Gallery/Docs/Cursor/DotGrid/zh-TW.md @@ -1,7 +1,7 @@ --- title: Dot grid summary: 一片點陣,像透鏡一樣在指標周圍向外鼓起,每次點擊都會向外擴散一圈漣漪。 -source-hash: d97edb6e6314 +source-hash: eea42c7255b6 --- @@ -31,7 +31,7 @@ source-hash: d97edb6e6314 ``` -`DotGrid` 是 Special 套件中的 `ContentControl`。在 `UseArc()` 旁呼叫 `UseArcSpecial()` 註冊此套件。給控制項一個高度,或讓內容撐開它;最小高度為 240px。點陣本身沒有背景:設定 `Background`、`BorderBrush`、`BorderThickness` 與 `CornerRadius` 為它加上外框,點會被裁切在這個圓角框內。 +`DotGrid` 是 Special 套件中的 `ContentControl`。在 `UseArcControls()` 旁呼叫 `UseArcSpecial()` 註冊此套件。給控制項一個高度,或讓內容撐開它;最小高度為 240px。點陣本身沒有背景:設定 `Background`、`BorderBrush`、`BorderThickness` 與 `CornerRadius` 為它加上外框,點會被裁切在這個圓角框內。 ### 用作首屏 diff --git a/gallery/TKCode.Arc.Gallery/Docs/Cursor/GravityWell/en-US.md b/gallery/TKCode.Arc.Gallery/Docs/Cursor/GravityWell/en-US.md index 874e58d1..e0a3ef63 100644 --- a/gallery/TKCode.Arc.Gallery/Docs/Cursor/GravityWell/en-US.md +++ b/gallery/TKCode.Arc.Gallery/Docs/Cursor/GravityWell/en-US.md @@ -35,7 +35,7 @@ that rolls out through the grid and flashes each tile's ring as it passes. ``` `GravityWell` is an `ItemsControl` in the Special package. Register the package with `UseArcSpecial()` next to -`UseArc()`. Each item becomes one square tile, either written inline or created from `ItemsSource` and an +`UseArcControls()`. Each item becomes one square tile, either written inline or created from `ItemsSource` and an `ItemTemplate`. The tile clips its content to `TileCornerRadius` (12px by default) and shows a muted surface behind it, so photos that are still loading keep their place. Stretch images with `Stretch="UniformToFill"` to fill the square. diff --git a/gallery/TKCode.Arc.Gallery/Docs/Cursor/GravityWell/zh-CN.md b/gallery/TKCode.Arc.Gallery/Docs/Cursor/GravityWell/zh-CN.md index 96829f92..96d29204 100644 --- a/gallery/TKCode.Arc.Gallery/Docs/Cursor/GravityWell/zh-CN.md +++ b/gallery/TKCode.Arc.Gallery/Docs/Cursor/GravityWell/zh-CN.md @@ -1,7 +1,7 @@ --- title: Gravity well summary: 图块向指针倾斜、缩小并变暗,仿佛被吸入一口引力井;点击会让一道冲击波滚过整个网格。 -source-hash: 67762547d826 +source-hash: fb2917485c5f --- @@ -32,7 +32,7 @@ source-hash: 67762547d826 ``` -`GravityWell` 是 Special 包中的 `ItemsControl`。在 `UseArc()` 旁边调用 `UseArcSpecial()` 注册该包。每个项目成为一个正方形图块,可以直接写在里面,也可以由 `ItemsSource` 和 `ItemTemplate` 生成。图块把内容裁剪到 `TileCornerRadius`(默认 12px),并在内容后面显示一层弱化表面,让仍在加载的照片保持占位。图片用 `Stretch="UniformToFill"` 铺满正方形。 +`GravityWell` 是 Special 包中的 `ItemsControl`。在 `UseArcControls()` 旁边调用 `UseArcSpecial()` 注册该包。每个项目成为一个正方形图块,可以直接写在里面,也可以由 `ItemsSource` 和 `ItemTemplate` 生成。图块把内容裁剪到 `TileCornerRadius`(默认 12px),并在内容后面显示一层弱化表面,让仍在加载的照片保持占位。图片用 `Stretch="UniformToFill"` 铺满正方形。 ### 列数 diff --git a/gallery/TKCode.Arc.Gallery/Docs/Cursor/GravityWell/zh-TW.md b/gallery/TKCode.Arc.Gallery/Docs/Cursor/GravityWell/zh-TW.md index a5127ef6..730d8e06 100644 --- a/gallery/TKCode.Arc.Gallery/Docs/Cursor/GravityWell/zh-TW.md +++ b/gallery/TKCode.Arc.Gallery/Docs/Cursor/GravityWell/zh-TW.md @@ -1,7 +1,7 @@ --- title: Gravity well summary: 圖塊向指標傾斜、縮小並變暗,彷彿被吸入一口重力井;點擊會讓一道衝擊波滾過整個網格。 -source-hash: 67762547d826 +source-hash: fb2917485c5f --- @@ -32,7 +32,7 @@ source-hash: 67762547d826 ``` -`GravityWell` 是 Special 套件中的 `ItemsControl`。在 `UseArc()` 旁呼叫 `UseArcSpecial()` 註冊此套件。每個項目成為一個正方形圖塊,可以直接寫在裡面,也可以由 `ItemsSource` 與 `ItemTemplate` 產生。圖塊把內容裁切到 `TileCornerRadius`(預設 12px),並在內容後方顯示一層柔和表面,讓仍在載入的照片保持位置。圖片用 `Stretch="UniformToFill"` 填滿正方形。 +`GravityWell` 是 Special 套件中的 `ItemsControl`。在 `UseArcControls()` 旁呼叫 `UseArcSpecial()` 註冊此套件。每個項目成為一個正方形圖塊,可以直接寫在裡面,也可以由 `ItemsSource` 與 `ItemTemplate` 產生。圖塊把內容裁切到 `TileCornerRadius`(預設 12px),並在內容後方顯示一層柔和表面,讓仍在載入的照片保持位置。圖片用 `Stretch="UniformToFill"` 填滿正方形。 ### 欄數 diff --git a/gallery/TKCode.Arc.Gallery/Docs/Cursor/InkFluid/en-US.md b/gallery/TKCode.Arc.Gallery/Docs/Cursor/InkFluid/en-US.md index 27102560..aeb5bed1 100644 --- a/gallery/TKCode.Arc.Gallery/Docs/Cursor/InkFluid/en-US.md +++ b/gallery/TKCode.Arc.Gallery/Docs/Cursor/InkFluid/en-US.md @@ -32,7 +32,7 @@ moving, a slow stir keeps a little ink drifting so the area never looks frozen. ``` `InkFluid` is a `ContentControl` in the Special package. Register the package with `UseArcSpecial()` next to -`UseArc()`. The ink is drawn on transparent pixels over the control's own background, so give it a `Background`, and a +`UseArcControls()`. The ink is drawn on transparent pixels over the control's own background, so give it a `Background`, and a `CornerRadius` and border if it should look like a card; the ink is clipped to that rounded box. The control is at least 240px high. diff --git a/gallery/TKCode.Arc.Gallery/Docs/Cursor/InkFluid/zh-CN.md b/gallery/TKCode.Arc.Gallery/Docs/Cursor/InkFluid/zh-CN.md index 08d49394..eb863269 100644 --- a/gallery/TKCode.Arc.Gallery/Docs/Cursor/InkFluid/zh-CN.md +++ b/gallery/TKCode.Arc.Gallery/Docs/Cursor/InkFluid/zh-CN.md @@ -1,7 +1,7 @@ --- title: Ink fluid summary: 内容背后的流体模拟:指针搅动柔和的墨水,墨水打旋后淡去;没人操作时,一阵缓慢的搅动让它保持流动。 -source-hash: 9960e34120bf +source-hash: 5593fd1750d8 --- @@ -30,7 +30,7 @@ source-hash: 9960e34120bf ``` -`InkFluid` 是 Special 包中的 `ContentControl`。在 `UseArc()` 旁边调用 `UseArcSpecial()` 注册该包。墨水画在控件自身背景之上的透明像素上,所以要给它一个 `Background`;想让它看起来像卡片时再加上 `CornerRadius` 和边框,墨水会被裁剪在这个圆角框内。控件最小高度为 240px。 +`InkFluid` 是 Special 包中的 `ContentControl`。在 `UseArcControls()` 旁边调用 `UseArcSpecial()` 注册该包。墨水画在控件自身背景之上的透明像素上,所以要给它一个 `Background`;想让它看起来像卡片时再加上 `CornerRadius` 和边框,墨水会被裁剪在这个圆角框内。控件最小高度为 240px。 墨水的颜色来自主题:中性墨水使用前景色 Token,带色墨水使用强调色 Token,因此会跟随亮色、暗色主题和强调色切换。设置 `InkBrush` 或 `AccentInkBrush` 可以分别覆盖。 diff --git a/gallery/TKCode.Arc.Gallery/Docs/Cursor/InkFluid/zh-TW.md b/gallery/TKCode.Arc.Gallery/Docs/Cursor/InkFluid/zh-TW.md index 9beb75b6..d7186cfe 100644 --- a/gallery/TKCode.Arc.Gallery/Docs/Cursor/InkFluid/zh-TW.md +++ b/gallery/TKCode.Arc.Gallery/Docs/Cursor/InkFluid/zh-TW.md @@ -1,7 +1,7 @@ --- title: Ink fluid summary: 內容背後的流體模擬:指標攪動柔和的墨水,墨水打旋後淡去;沒人操作時,一陣緩慢的攪動讓它保持流動。 -source-hash: 9960e34120bf +source-hash: 5593fd1750d8 --- @@ -30,7 +30,7 @@ source-hash: 9960e34120bf ``` -`InkFluid` 是 Special 套件中的 `ContentControl`。在 `UseArc()` 旁呼叫 `UseArcSpecial()` 註冊此套件。墨水畫在控制項自身背景之上的透明像素上,所以要給它一個 `Background`;想讓它看起來像卡片時再加上 `CornerRadius` 與外框,墨水會被裁切在這個圓角框內。控制項最小高度為 240px。 +`InkFluid` 是 Special 套件中的 `ContentControl`。在 `UseArcControls()` 旁呼叫 `UseArcSpecial()` 註冊此套件。墨水畫在控制項自身背景之上的透明像素上,所以要給它一個 `Background`;想讓它看起來像卡片時再加上 `CornerRadius` 與外框,墨水會被裁切在這個圓角框內。控制項最小高度為 240px。 墨水的顏色來自主題:中性墨水使用前景色 Token,帶色墨水使用強調色 Token,因此會跟隨淺色、深色主題與強調色切換。設定 `InkBrush` 或 `AccentInkBrush` 可以分別覆寫。 diff --git a/gallery/TKCode.Arc.Gallery/Docs/Illustrations/AgentLoop/en-US.md b/gallery/TKCode.Arc.Gallery/Docs/Illustrations/AgentLoop/en-US.md index 39bc197d..dac1bb1b 100644 --- a/gallery/TKCode.Arc.Gallery/Docs/Illustrations/AgentLoop/en-US.md +++ b/gallery/TKCode.Arc.Gallery/Docs/Illustrations/AgentLoop/en-US.md @@ -54,7 +54,7 @@ invented model and tools. It starts the first time it is mostly in view and stop from Avalonia's `TemplatedControl`. The base supplies the story clock, `IsPlaying`, `Speed`, `AutoPlay`, `IsLooping`, the play, step and restart buttons, the polite live region and the reduced-motion opening state. `AgentLoop` adds the iterations, the ring and its stations, the lanes and the context stack. Register the package with `UseArcSpecial()` -next to `UseArc()`. +next to `UseArcControls()`. The numbers in the card (tokens, durations, results) are illustrative. Say so in the text around it when the page is about a real product. diff --git a/gallery/TKCode.Arc.Gallery/Docs/Illustrations/AgentLoop/zh-CN.md b/gallery/TKCode.Arc.Gallery/Docs/Illustrations/AgentLoop/zh-CN.md index 2108fa9d..cc55e7ea 100644 --- a/gallery/TKCode.Arc.Gallery/Docs/Illustrations/AgentLoop/zh-CN.md +++ b/gallery/TKCode.Arc.Gallery/Docs/Illustrations/AgentLoop/zh-CN.md @@ -1,7 +1,7 @@ --- title: Agent loop summary: 智能体收集上下文、调用工具并检查结果,沿循环反复进行,直到任务完成。 -source-hash: dc59ab4f64b0 +source-hash: e5ae207d1603 --- @@ -35,7 +35,7 @@ source-hash: dc59ab4f64b0 ``` -`AgentLoop` 派生自 `Explainer`,后者是 Special 包中动画 AI 讲解控件的基类,而 `Explainer` 派生自 Avalonia 的 `TemplatedControl`。基类提供故事时钟、`IsPlaying`、`Speed`、`AutoPlay`、`IsLooping`、播放、单步与重新开始按钮、礼貌级别的实时区域以及减弱动效下的开场状态。`AgentLoop` 则增加了迭代、环及其站点、泳道和上下文堆栈。请在 `UseArc()` 旁调用 `UseArcSpecial()` 以注册该包。 +`AgentLoop` 派生自 `Explainer`,后者是 Special 包中动画 AI 讲解控件的基类,而 `Explainer` 派生自 Avalonia 的 `TemplatedControl`。基类提供故事时钟、`IsPlaying`、`Speed`、`AutoPlay`、`IsLooping`、播放、单步与重新开始按钮、礼貌级别的实时区域以及减弱动效下的开场状态。`AgentLoop` 则增加了迭代、环及其站点、泳道和上下文堆栈。请在 `UseArcControls()` 旁调用 `UseArcSpecial()` 以注册该包。 卡片中的数字(token、时长、结果)仅作示意。当页面涉及真实产品时,请在卡片周围的文字中说明这一点。 diff --git a/gallery/TKCode.Arc.Gallery/Docs/Illustrations/AgentLoop/zh-TW.md b/gallery/TKCode.Arc.Gallery/Docs/Illustrations/AgentLoop/zh-TW.md index a04183d8..fceb8b7a 100644 --- a/gallery/TKCode.Arc.Gallery/Docs/Illustrations/AgentLoop/zh-TW.md +++ b/gallery/TKCode.Arc.Gallery/Docs/Illustrations/AgentLoop/zh-TW.md @@ -1,7 +1,7 @@ --- title: Agent loop summary: AI 代理會收集上下文、呼叫工具並檢查結果,循環往復直到任務完成。 -source-hash: dc59ab4f64b0 +source-hash: e5ae207d1603 --- @@ -35,7 +35,7 @@ source-hash: dc59ab4f64b0 ``` -`AgentLoop` 衍生自 `Explainer`。`Explainer` 是 Special 套件中動畫 AI 解說控制項的基底類別,而它又衍生自 Avalonia 的 `TemplatedControl`。基底類別提供故事時鐘、`IsPlaying`、`Speed`、`AutoPlay`、`IsLooping`、播放、單步與重新開始按鈕、禮貌即時區域,以及減少動態效果的開啟狀態。`AgentLoop` 另外新增循環內容、圓環與其站點、泳道和上下文堆疊。請在 `UseArc()` 旁呼叫 `UseArcSpecial()` 來註冊該套件。 +`AgentLoop` 衍生自 `Explainer`。`Explainer` 是 Special 套件中動畫 AI 解說控制項的基底類別,而它又衍生自 Avalonia 的 `TemplatedControl`。基底類別提供故事時鐘、`IsPlaying`、`Speed`、`AutoPlay`、`IsLooping`、播放、單步與重新開始按鈕、禮貌即時區域,以及減少動態效果的開啟狀態。`AgentLoop` 另外新增循環內容、圓環與其站點、泳道和上下文堆疊。請在 `UseArcControls()` 旁呼叫 `UseArcSpecial()` 來註冊該套件。 卡片中的數字(token、時長、結果)都是示意值。若頁面談的是真實產品,請在卡片周圍的文字中說明這一點。 diff --git a/gallery/TKCode.Arc.Gallery/Docs/Media/CardStack/en-US.md b/gallery/TKCode.Arc.Gallery/Docs/Media/CardStack/en-US.md index 2347cb77..cb5a586e 100644 --- a/gallery/TKCode.Arc.Gallery/Docs/Media/CardStack/en-US.md +++ b/gallery/TKCode.Arc.Gallery/Docs/Media/CardStack/en-US.md @@ -219,7 +219,7 @@ offer a new pass. `EmptyContentTemplate` builds the same content from a data tem ### Photos -Card faces can show `AsyncImage` photos over a drawn scene, so the card still looks complete while the photo loads or +Card faces can show `ArcImage` photos over a drawn scene, so the card still looks complete while the photo loads or when the device is offline. Keep the photo inside the card's fixed height, and let the drawn scene stand in until the image arrives. diff --git a/gallery/TKCode.Arc.Gallery/Docs/Media/CardStack/zh-CN.md b/gallery/TKCode.Arc.Gallery/Docs/Media/CardStack/zh-CN.md index 1f4a8d40..1ff73cc5 100644 --- a/gallery/TKCode.Arc.Gallery/Docs/Media/CardStack/zh-CN.md +++ b/gallery/TKCode.Arc.Gallery/Docs/Media/CardStack/zh-CN.md @@ -1,7 +1,7 @@ --- title: 卡片堆叠 summary: 逐张审阅卡片,支持划走与撤销。 -source-hash: 19ea9efed1b2 +source-hash: 57ab5ef13cfb --- @@ -176,7 +176,7 @@ stack.LabelSelector = item => ((Destination)item!).Name; ### 照片 -卡片正面可以在绘制的场景之上显示 `AsyncImage` 照片,因此卡片在照片加载期间或设备离线时仍然显得完整。照片应保持在卡片固定的高度之内,并让绘制的场景在图片到达之前代为占位。 +卡片正面可以在绘制的场景之上显示 `ArcImage` 照片,因此卡片在照片加载期间或设备离线时仍然显得完整。照片应保持在卡片固定的高度之内,并让绘制的场景在图片到达之前代为占位。 diff --git a/gallery/TKCode.Arc.Gallery/Docs/Media/CardStack/zh-TW.md b/gallery/TKCode.Arc.Gallery/Docs/Media/CardStack/zh-TW.md index 0497913d..7f43182a 100644 --- a/gallery/TKCode.Arc.Gallery/Docs/Media/CardStack/zh-TW.md +++ b/gallery/TKCode.Arc.Gallery/Docs/Media/CardStack/zh-TW.md @@ -1,7 +1,7 @@ --- title: 卡片堆疊 summary: 逐張審閱卡片,支援划走與復原。 -source-hash: 19ea9efed1b2 +source-hash: 57ab5ef13cfb --- @@ -176,7 +176,7 @@ stack.LabelSelector = item => ((Destination)item!).Name; ### 照片 -卡片正面可以在繪製的場景之上顯示 `AsyncImage` 照片,因此卡片在照片載入期間或裝置離線時仍然完整。照片應保持在卡片固定的高度之內,並讓繪製的場景在圖片到達之前代為佔位。 +卡片正面可以在繪製的場景之上顯示 `ArcImage` 照片,因此卡片在照片載入期間或裝置離線時仍然完整。照片應保持在卡片固定的高度之內,並讓繪製的場景在圖片到達之前代為佔位。 diff --git a/gallery/TKCode.Arc.Gallery/Docs/Media/Carousel/en-US.md b/gallery/TKCode.Arc.Gallery/Docs/Media/Carousel/en-US.md index 7cfcb280..e7f3ac9c 100644 --- a/gallery/TKCode.Arc.Gallery/Docs/Media/Carousel/en-US.md +++ b/gallery/TKCode.Arc.Gallery/Docs/Media/Carousel/en-US.md @@ -217,7 +217,7 @@ dots. ### Photos -Slides can show `AsyncImage` photos over a drawn scene, so they still look complete offline. +Slides can show `ArcImage` photos over a drawn scene, so they still look complete offline. diff --git a/gallery/TKCode.Arc.Gallery/Docs/Media/Carousel/zh-CN.md b/gallery/TKCode.Arc.Gallery/Docs/Media/Carousel/zh-CN.md index a60210b0..75c004be 100644 --- a/gallery/TKCode.Arc.Gallery/Docs/Media/Carousel/zh-CN.md +++ b/gallery/TKCode.Arc.Gallery/Docs/Media/Carousel/zh-CN.md @@ -1,7 +1,7 @@ --- title: 轮播 summary: 通过拖动、轻扫或方向键浏览一排幻灯片。 -source-hash: 4a960604b2d2 +source-hash: 6fd1e19ca14e --- @@ -173,7 +173,7 @@ carousel.SlideLabelFormatter = (index, count) => $"档案照片 {index + 1}, ### 照片 -幻灯片可以在绘制的场景之上显示 `AsyncImage` 照片,因此离线时看起来仍然完整。 +幻灯片可以在绘制的场景之上显示 `ArcImage` 照片,因此离线时看起来仍然完整。 diff --git a/gallery/TKCode.Arc.Gallery/Docs/Media/Carousel/zh-TW.md b/gallery/TKCode.Arc.Gallery/Docs/Media/Carousel/zh-TW.md index cc4bcd32..7a5f26b8 100644 --- a/gallery/TKCode.Arc.Gallery/Docs/Media/Carousel/zh-TW.md +++ b/gallery/TKCode.Arc.Gallery/Docs/Media/Carousel/zh-TW.md @@ -1,7 +1,7 @@ --- title: 輪播 summary: 透過拖曳、輕掃或方向鍵瀏覽一排投影片。 -source-hash: 4a960604b2d2 +source-hash: 6fd1e19ca14e --- @@ -173,7 +173,7 @@ carousel.SlideLabelFormatter = (index, count) => $"檔案照片 {index + 1}, ### 照片 -投影片可以在繪製的場景之上顯示 `AsyncImage` 照片,因此離線時看起來仍然完整。 +投影片可以在繪製的場景之上顯示 `ArcImage` 照片,因此離線時看起來仍然完整。 diff --git a/gallery/TKCode.Arc.Gallery/Docs/Mobile/MobilePage/en-US.md b/gallery/TKCode.Arc.Gallery/Docs/Mobile/MobilePage/en-US.md index 4cb64d4c..de09fb8a 100644 --- a/gallery/TKCode.Arc.Gallery/Docs/Mobile/MobilePage/en-US.md +++ b/gallery/TKCode.Arc.Gallery/Docs/Mobile/MobilePage/en-US.md @@ -39,7 +39,7 @@ Set `Header`, the content and `Footer`. The mobile controls have their own XAML ``` -Register the controls with `UseArcMobile()` next to `UseArcCore()` or `UseArc()`. +Register the controls with `UseArcMobile()` next to `UseArcCore()` or `UseArcControls()`. ### Safe area diff --git a/gallery/TKCode.Arc.Gallery/Docs/Mobile/MobilePage/zh-CN.md b/gallery/TKCode.Arc.Gallery/Docs/Mobile/MobilePage/zh-CN.md index f1765a2c..77aa8c0e 100644 --- a/gallery/TKCode.Arc.Gallery/Docs/Mobile/MobilePage/zh-CN.md +++ b/gallery/TKCode.Arc.Gallery/Docs/Mobile/MobilePage/zh-CN.md @@ -1,7 +1,7 @@ --- title: 移动端页面 summary: 手机屏幕:页头、内容和页脚,排布在设备的安全区之内。 -source-hash: 7d668709675d +source-hash: 9c9b9e57e9a2 --- @@ -40,7 +40,7 @@ source-hash: 7d668709675d ``` -在 `UseArcCore()` 或 `UseArc()` 旁边用 `UseArcMobile()` 注册这些控件。 +在 `UseArcCore()` 或 `UseArcControls()` 旁边用 `UseArcMobile()` 注册这些控件。 ### 安全区 diff --git a/gallery/TKCode.Arc.Gallery/Docs/Mobile/MobilePage/zh-TW.md b/gallery/TKCode.Arc.Gallery/Docs/Mobile/MobilePage/zh-TW.md index f2257e5d..4a2196de 100644 --- a/gallery/TKCode.Arc.Gallery/Docs/Mobile/MobilePage/zh-TW.md +++ b/gallery/TKCode.Arc.Gallery/Docs/Mobile/MobilePage/zh-TW.md @@ -1,7 +1,7 @@ --- title: 移動端頁面 summary: 手機螢幕:頁首、內容和頁尾,排佈在裝置的安全區之內。 -source-hash: 7d668709675d +source-hash: 9c9b9e57e9a2 --- @@ -40,7 +40,7 @@ source-hash: 7d668709675d ``` -在 `UseArcCore()` 或 `UseArc()` 旁邊用 `UseArcMobile()` 註冊這些控制項。 +在 `UseArcCore()` 或 `UseArcControls()` 旁邊用 `UseArcMobile()` 註冊這些控制項。 ### 安全區 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/Guides/AiAndMcp/en-US.md b/gallery/TKCode.Arc.Gallery/Guides/AiAndMcp/en-US.md index 8bf9cc49..33a4f2bc 100644 --- a/gallery/TKCode.Arc.Gallery/Guides/AiAndMcp/en-US.md +++ b/gallery/TKCode.Arc.Gallery/Guides/AiAndMcp/en-US.md @@ -18,9 +18,9 @@ instruction file or at the start of a conversation: | Fact | Why it matters | |---|---| -| This is Avalonia 12 with AtomUI 6.2.8, not WPF, WinUI or HTML. | Assistants otherwise mix in WPF properties (`Visibility`, `DataTemplateSelector`) or CSS ideas. | +| This is Avalonia 12 with TKCode.Arc, not WPF, WinUI or HTML. | Assistants otherwise mix in WPF properties (`Visibility`, `DataTemplateSelector`) or CSS ideas. | | Arc controls live in `xmlns:arc="https://github.com/AIDotNet/TKCode.Arc"`. | Without the prefix, `Button` resolves to Avalonia's own button and loses Arc's look. | -| The app calls `UseArc()` inside `UseAtomUI(...)`, and the `Application` class is `partial`. | Missing either produces unstyled controls or a build error. | +| The app calls `AppBuilder.UseArc(arc => arc.UseArcControls())`, and switches the theme with `ArcTheme.Apply(...)`. | Without `UseArcControls()` the controls are unstyled; theme APIs from other libraries do not exist here. | | Colors, radii and fonts come from `{arc:ArcDesignTokenResource ...}` or `ArcTokens`. | Hard-coded hex values break dark mode and accents. | | Motion is declared under `^:motion-full` and `^:motion-reduced` and respects `ArcMotion.Level`. | Hand-written animations otherwise ignore reduced motion. | | Only documented properties exist. | The component pages and API tables list them; anything else is invented. | @@ -101,7 +101,8 @@ most of the mistakes above: ```text ## UI library: TKCode.Arc -- UI is Avalonia 12.1.4 + AtomUI 6.2.8 + TKCode.Arc. Do not use WPF or WinUI APIs. +- UI is Avalonia 12.1.4 + TKCode.Arc. Do not use WPF or WinUI APIs. +- Setup: AppBuilder.UseArc(arc => arc.UseArcControls()); theme changes: ArcTheme.Apply(t => t.Appearance(...)). - Arc controls use xmlns:arc="https://github.com/AIDotNet/TKCode.Arc" (arc:Button, arc:Input, ...). - Only use properties, events and enum values that the component's docs page or API table lists. - Colors, radii, spacing and fonts: {arc:ArcDesignTokenResource Name} in XAML, ArcTokens.Bind in code. @@ -164,7 +165,7 @@ directly: "mcpServers": { "tkcode-arc-docs": { "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/AtomUI.Arc/gallery/TKCode.Arc.Gallery/Docs"] + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/TKCode.Arc/gallery/TKCode.Arc.Gallery/Docs"] } } } diff --git a/gallery/TKCode.Arc.Gallery/Guides/AiAndMcp/zh-CN.md b/gallery/TKCode.Arc.Gallery/Guides/AiAndMcp/zh-CN.md index 32899e4d..34907035 100644 --- a/gallery/TKCode.Arc.Gallery/Guides/AiAndMcp/zh-CN.md +++ b/gallery/TKCode.Arc.Gallery/Guides/AiAndMcp/zh-CN.md @@ -1,6 +1,6 @@ --- title: AI 与 MCP -source-hash: abfe18a713be +source-hash: ef197b997de4 --- # AI 与 MCP @@ -13,9 +13,9 @@ AI 编程助手能很好地配合 TKCode.Arc 使用,因为它需要的一切 | 事实 | 重要原因 | |---|---| -| 这是 Avalonia 12 与 AtomUI 6.2.8,而不是 WPF、WinUI 或 HTML。 | 否则助手会混入 WPF 属性(`Visibility`、`DataTemplateSelector`)或 CSS 的思路。 | +| 这是 Avalonia 12 与 TKCode.Arc,而不是 WPF、WinUI 或 HTML。 | 否则助手会混入 WPF 属性(`Visibility`、`DataTemplateSelector`)或 CSS 的思路。 | | Arc 控件位于 `xmlns:arc="https://github.com/AIDotNet/TKCode.Arc"`。 | 没有该前缀时,`Button` 会解析为 Avalonia 自带的按钮,失去 Arc 的外观。 | -| 应用在 `UseAtomUI(...)` 中调用 `UseArc()`,并且 `Application` 类是 `partial`。 | 缺少其中任何一项,都会导致控件没有样式或构建错误。 | +| 应用调用 `AppBuilder.UseArc(arc => arc.UseArcControls())`,并用 `ArcTheme.Apply(...)` 切换主题。 | 缺少 `UseArcControls()` 时控件没有样式;其他库的主题 API 在这里并不存在。 | | 颜色、圆角与字体来自 `{arc:ArcDesignTokenResource ...}` 或 `ArcTokens`。 | 硬编码的十六进制值会破坏深色模式与强调色。 | | 动效声明在 `^:motion-full` 与 `^:motion-reduced` 之下,并遵循 `ArcMotion.Level`。 | 手写的动画会忽略减弱动效的设置。 | | 只存在文档中列出的属性。 | 组件页面与 API 表格列出了全部属性;其他属性都是凭空捏造的。 | @@ -85,7 +85,8 @@ Claude Code、Cursor 与 GitHub Copilot 等助手会读取仓库中的指令文 ```text ## UI 库:TKCode.Arc -- 界面使用 Avalonia 12.1.4 + AtomUI 6.2.8 + TKCode.Arc。不要使用 WPF 或 WinUI 的 API。 +- 界面使用 Avalonia 12.1.4 + TKCode.Arc。不要使用 WPF 或 WinUI 的 API。 +- 启动:AppBuilder.UseArc(arc => arc.UseArcControls());切换主题:ArcTheme.Apply(t => t.Appearance(...))。 - Arc 控件使用 xmlns:arc="https://github.com/AIDotNet/TKCode.Arc"(arc:Button、arc:Input 等)。 - 只使用组件文档页面或 API 表格中列出的属性、事件与枚举值。 - 颜色、圆角、间距与字体:XAML 中使用 {arc:ArcDesignTokenResource Name},代码中使用 ArcTokens.Bind。 @@ -139,7 +140,7 @@ TKCode.Arc 目前尚未提供自己的 MCP 服务器。在提供之前,有三 "mcpServers": { "tkcode-arc-docs": { "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/AtomUI.Arc/gallery/TKCode.Arc.Gallery/Docs"] + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/TKCode.Arc/gallery/TKCode.Arc.Gallery/Docs"] } } } diff --git a/gallery/TKCode.Arc.Gallery/Guides/AiAndMcp/zh-TW.md b/gallery/TKCode.Arc.Gallery/Guides/AiAndMcp/zh-TW.md index 42ec7dc5..38ec30f5 100644 --- a/gallery/TKCode.Arc.Gallery/Guides/AiAndMcp/zh-TW.md +++ b/gallery/TKCode.Arc.Gallery/Guides/AiAndMcp/zh-TW.md @@ -1,6 +1,6 @@ --- title: AI 與 MCP -source-hash: abfe18a713be +source-hash: ef197b997de4 --- # AI 與 MCP @@ -13,9 +13,9 @@ AI 程式設計助理能很好地配合 TKCode.Arc 使用,因為它需要的 | 事實 | 重要原因 | |---|---| -| 這是 Avalonia 12 與 AtomUI 6.2.8,而不是 WPF、WinUI 或 HTML。 | 否則助理會混入 WPF 屬性(`Visibility`、`DataTemplateSelector`)或 CSS 的概念。 | +| 這是 Avalonia 12 與 TKCode.Arc,而不是 WPF、WinUI 或 HTML。 | 否則助理會混入 WPF 屬性(`Visibility`、`DataTemplateSelector`)或 CSS 的概念。 | | Arc 控制項位於 `xmlns:arc="https://github.com/AIDotNet/TKCode.Arc"`。 | 沒有該前綴時,`Button` 會解析為 Avalonia 自帶的按鈕,失去 Arc 的外觀。 | -| 應用程式在 `UseAtomUI(...)` 中呼叫 `UseArc()`,並且 `Application` 類別為 `partial`。 | 缺少其中任何一項,都會導致控制項沒有樣式或建置錯誤。 | +| 應用程式呼叫 `AppBuilder.UseArc(arc => arc.UseArcControls())`,並以 `ArcTheme.Apply(...)` 切換主題。 | 缺少 `UseArcControls()` 時控制項沒有樣式;其他函式庫的主題 API 在這裡並不存在。 | | 顏色、圓角與字型來自 `{arc:ArcDesignTokenResource ...}` 或 `ArcTokens`。 | 寫死的十六進位數值會破壞深色模式與強調色。 | | 動態效果宣告在 `^:motion-full` 與 `^:motion-reduced` 之下,並遵循 `ArcMotion.Level`。 | 手寫的動畫會忽略減少動態效果的設定。 | | 只存在文件中列出的屬性。 | 元件頁面與 API 表格列出了全部屬性;其他屬性都是憑空捏造的。 | @@ -85,7 +85,8 @@ Claude Code、Cursor 與 GitHub Copilot 等助理會讀取儲存庫中的指示 ```text ## UI 函式庫:TKCode.Arc -- 介面使用 Avalonia 12.1.4 + AtomUI 6.2.8 + TKCode.Arc。不要使用 WPF 或 WinUI 的 API。 +- 介面使用 Avalonia 12.1.4 + TKCode.Arc。不要使用 WPF 或 WinUI 的 API。 +- 啟動:AppBuilder.UseArc(arc => arc.UseArcControls());切換主題:ArcTheme.Apply(t => t.Appearance(...))。 - Arc 控制項使用 xmlns:arc="https://github.com/AIDotNet/TKCode.Arc"(arc:Button、arc:Input 等)。 - 只使用元件文件頁面或 API 表格中列出的屬性、事件與列舉值。 - 顏色、圓角、間距與字型:XAML 中使用 {arc:ArcDesignTokenResource Name},程式碼中使用 ArcTokens.Bind。 @@ -139,7 +140,7 @@ TKCode.Arc 目前尚未提供自己的 MCP 伺服器。在提供之前,有三 "mcpServers": { "tkcode-arc-docs": { "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/AtomUI.Arc/gallery/TKCode.Arc.Gallery/Docs"] + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/TKCode.Arc/gallery/TKCode.Arc.Gallery/Docs"] } } } diff --git a/gallery/TKCode.Arc.Gallery/Guides/Changelog/en-US.md b/gallery/TKCode.Arc.Gallery/Guides/Changelog/en-US.md index 2b9823e7..3934af36 100644 --- a/gallery/TKCode.Arc.Gallery/Guides/Changelog/en-US.md +++ b/gallery/TKCode.Arc.Gallery/Guides/Changelog/en-US.md @@ -8,9 +8,116 @@ All notable changes to TKCode.Arc, newest first. Versions follow [Semantic Versi version carries a `-preview` suffix, the API may still change between releases; read the notes before upgrading and keep the version pinned in your project. The same list is kept in `CHANGELOG.md` in the repository. -NuGet packages published with each release: `TKCode.Arc`, `TKCode.Arc.Core`, `TKCode.Arc.Fonts` and +NuGet packages published with each release: `TKCode.Arc`, `TKCode.Arc.Core`, `TKCode.Arc.Foundation` (from 0.4), `TKCode.Arc.Fonts` and `TKCode.Arc.Icons.Lucide`, `TKCode.Arc.Blocks`, `TKCode.Arc.AdvancedCharts`, `TKCode.Arc.Special`, `TKCode.Arc.Workflow`, `TKCode.Arc.Themes.Pixel`, `TKCode.Arc.Themes.Corners`, `TKCode.Arc.Mobile`, `TKCode.Arc.Terminal`, `TKCode.Arc.Audio`, `TKCode.Arc.Audio.Native` and `TKCode.Arc.Audio.Browser`. +## 0.4.0-preview.1 (unreleased) + +Arc no longer depends on AtomUI. Every package now depends only on Avalonia and on the new MIT package `TKCode.Arc.Foundation`, so the whole dependency chain is under permissive licenses and closed-source applications can publish Arc as a single file or with NativeAOT (issue #25). The application entry point and the theme API change; the controls, their properties and the XAML (`{arc:ArcDesignTokenResource ...}`, `{arc:XxxTokenResource ...}`) do not. See *Migrating from 0.3 to 0.4* below. + +### Added + +- **Foundation** (new package `TKCode.Arc.Foundation`): the theme manager (`ArcThemeManager`, `ArcTheme`, `ArcThemeState`, `ArcThemeBuilder`), theme presets (`ArcThemePreset`), local themes (`ArcThemeScope`), package registration (`AppBuilder.UseArc(...)`, `ArcBuilder`), languages (`ArcLanguage`, `ArcLanguageManager`) and a source generator for tokens, theme files and language catalogs. No reflection; trimming and NativeAOT safe. A control's theme loads the first time a control of that type is used. +- **ArcImage** and `ImageSource` (Core): background image loading (http, `avares://`, files, `data:`, bitmaps) with a shared cache, on desktop and in the browser. +- **ArcOverlayHost** (Core): a panel with its own overlay layer, so dialogs, drawers and sheets opened inside it stay within it. +- **Base theme** (Core): Arc's own themes for the Avalonia controls its templates use (`ScrollViewer`, `ScrollBar`, `ItemsControl`, `PathIcon`, `TextSelectionHandle` and the popup hosts). It steps aside when the application uses `FluentTheme` or `SimpleTheme`. +- **Scroll bars** look like `ScrollArea`: thin, shown while the pointer is over the view or while it scrolls, hidden at rest. `ScrollViewer.AllowAutoHide="False"` keeps them visible. +- **Corners theme**: `UseArcCorners()` and `t.Corners(style)`; ready-made `ArcCornerPresets.Square` and `ArcCornerPresets.Half`. +- **Menus**: long menus scroll inside `MaxDropDownHeight`. +- **Gallery**: the desktop window draws its own title bar (logo, title, pin on top; window buttons on Windows and Linux). + +### Changed + +- **Breaking: entry point.** `AppBuilder.UseAtomUI(b => b.UseDesktopControls().UseArc())` is now `AppBuilder.UseArc(arc => arc.UseArcControls())`. The `Application` class no longer needs to be `partial`. +- **Breaking: theme API.** `ThemeConfigBuilder`, `ThemeAlgorithm`, `IThemeManager.ApplyThemeAsync` and `WithArc(...)` are replaced by `ArcTheme.Apply(t => ...)`, `ArcThemeManager.Apply` / `ApplyAsync` and `WithTheme(...)` in `UseArc`. +- **Breaking: Pixel and Corners** are theme presets: `t.Pixel()` and `t.Corners(style)`. `ArcPixelTheme`, `ArcPixelScope`, `WithArcCorners` and `ArcCornerScope` are removed; a preset skips the packages the application has not registered. +- **Breaking: languages** move to `TKCode.Arc.Localization` (`ArcLanguage`, `ArcLanguageManager`); `UseLanguages` is on `ArcBuilder`. +- **Breaking: images and popups.** `AsyncImage` is replaced by `ArcImage` (`IsLoaded` is now `IsImageLoaded`). `ArcPopup` derives from Avalonia's `Popup`; `OpenMotion` and `CloseMotion` are `ArcPopupMotion`, `AdjustPlacementAnchor` lost its `ref flipX, ref flipY` parameters, and `IsOpen` stays true until the exit motion finishes. `ArcPopupFrame` replaces `BaseMotionActor`, and `ArcOverlayHost` replaces `ScopeAwareOverlayLayerPanel`. +- **Breaking: mobile token identities** are `MobileButton`, `MobileCheckbox`, `MobileDialog`, `MobileSwitch` and `MobileToast`, so they no longer share names with the desktop controls. +- Arc no longer shares its theme with AtomUI controls; applications that mix the two stay on 0.3.x. + +### Fixed + +- **ToastStack**: a toast dismissed while the stack resumes its timers no longer breaks the resume. +- **DataGrid**: the search box of the filter list is drawn again (it had no template). +- **PasswordField**: long strength labels no longer overlap the strength bar (#23). +- **Gallery**: the text box of the env variables event log is drawn again (it had no template). + +### Migrating from 0.3 to 0.4 + +1. Remove every `AtomUI.*` package reference and `using AtomUI...;` line; Arc brings in what it needs. +2. Replace `UseAtomUI(...)` with `UseArc(...)` and rename `UseArc()` (the controls) to `UseArcControls()`. The other package calls keep their names and move into the `UseArc` callback. +3. Replace theme requests with `ArcTheme.Apply`. A change names only what changes; the rest of the state stays. +4. The `App` class may drop its `partial` keyword; nothing generates code into it any more. +5. With the .NET 11 SDK and `PublishAot`, set `false` in the application project. + +```csharp +// 0.3 +AppBuilder.Configure() + .UsePlatformDetect() + .UseAtomUI(builder => + { + builder.UseDesktopControls(); + builder.UseArc(); + builder.UseArcBlocks(); + builder.UseLanguages(LanguageTags.EnUS, [LanguageTags.EnUS, LanguageTags.ZhCN]); + }); + +// 0.4 +AppBuilder.Configure() + .UsePlatformDetect() + .UseArc(arc => arc + .UseArcControls() + .UseArcBlocks() + .UseLanguages(LanguageTags.EnUS, [LanguageTags.EnUS, LanguageTags.ZhCN]) + .WithTheme(t => t.Appearance(ArcAppearance.System))); +``` + +```csharp +// 0.3 +var builder = new ThemeConfigBuilder() + .WithAlgorithms(ThemeAlgorithm.Default, ThemeAlgorithm.Dark) + .WithArc(arc => arc.Accent(ArcAccentPreset.Blue).MotionMode(ArcMotionLevel.Reduced)); +await manager.ApplyThemeAsync(new ThemeRequest(IThemeManager.DEFAULT_THEME_ID, builder.Build(), + ThemeTransitionReason.UserRequest)); + +// 0.4 +ArcTheme.Apply(t => t + .Appearance(ArcAppearance.Dark) + .Accent(ArcAccentPreset.Blue) + .MotionLevel(ArcMotionLevel.Reduced)); +``` + +| 0.3 (AtomUI) | 0.4 | +|---|---| +| `AppBuilder.UseAtomUI(b => ...)`, `UseDesktopControls()` | `AppBuilder.UseArc(arc => ...)` | +| `UseArc()` (the controls) | `UseArcControls()` | +| `UseArcCore`, `UseArcBlocks`, `UseArcAdvancedCharts`, `UseArcSpecial`, `UseArcWorkflow`, `UseArcMobile`, `UseArcPixelTheme`, `UseArcGallery` on `IAtomUIBuilder` | The same names on `ArcBuilder`; new `UseArcCorners()` | +| `builder.UseLanguages(...)` | `arc.UseLanguages(...)`, same arguments | +| `partial class App`, `ApplicationStartup.Attach` | Not needed | +| `ThemeConfigBuilder`, `WithAlgorithms(ThemeAlgorithm.Dark)` | `t.Appearance(ArcAppearance.Dark)` (or `Light`, `System`) | +| `IThemeManager.ApplyThemeAsync(new ThemeRequest(...))` | `ArcTheme.Apply(t => ...)`, `ArcThemeManager.Current.Apply` / `ApplyAsync` (await it; never block on it on the UI thread) | +| `WithArc(a => a.Accent(preset or color))` | `t.Accent(preset or color)` | +| `WithArc(a => a.Token(name, value))` | `t.Token(name, value)` | +| `WithArc(a => a.ControlToken(catalog, control, name, value))` | `t.Token(identity, name, value)` | +| `WithArc(a => a.MotionMode(level))` | `t.MotionLevel(level)` | +| `WithArcCorners(style, scope)`, `arc.Corners(style, scope)`, `ArcCornerScope` | `t.Corners(style)`, `ArcCornerPresets` | +| `ArcPixelTheme.CreateRequest(...)`, `WithArcPixel(...)`, `ArcPixelScope` | `t.Pixel()`, `ArcPixelPresets`; back to Arc with `t.RemovePreset(ArcPixelPresets.Slot)` | +| `using AtomUI.Localization;` | `using TKCode.Arc.Localization;` | +| `Application.Current.GetLanguageManager()`, `ILanguageManager` | `ArcLanguageManager.Current` | +| `GetLocalizer().Get(kind)`, `Format(kind, args)` | `XxxLangResources.Get(kind)`, `XxxLangResources.Format(kind, args)` | +| `LanguageChanged`, `ChangeLanguage(tag)` | `Changed`, `Set(tag)` (or `ArcLanguage.Changed`, `ArcLanguage.Set`) | +| `FormattingCulture`, `CurrentLanguage` | `ArcLanguage.Culture`, `ArcLanguage.Current` | +| Mobile token overrides by catalog | Identities `MobileButton`, `MobileCheckbox`, `MobileDialog`, `MobileSwitch`, `MobileToast` | +| `AtomUI.Controls.ImageSource`, `AsyncImage`, `IsLoaded` | `TKCode.Arc.ImageSource`, `ArcImage`, `IsImageLoaded` | +| `ArcPopup` on AtomUI's `Popup`, `BaseMotionActor` | `ArcPopup` on Avalonia's `Popup`, `ArcPopupFrame`; `OpenMotion` / `CloseMotion` are `ArcPopupMotion` | +| `AdjustPlacementAnchor(placement, ref flipX, ref flipY)` | `AdjustPlacementAnchor(placement)` | +| `ScopeAwareOverlayLayerPanel` | `ArcOverlayHost` | +| AtomUI's themes for `ScrollViewer`, `ScrollBar`, `ItemsControl` and the popup hosts | Arc's base theme; scroll bars hide at rest (`ScrollViewer.AllowAutoHide="False"` keeps them) | + +- **Windows and overlays.** Without AtomUI's window theme, a plain Avalonia `Window` has no overlay layer unless its template has a `VisualLayerManager` with `EnableOverlayLayer="True"`. `ThemeSwitcher` needs one for its transition; without it, it switches directly. +- **AtomUI controls.** An application that also uses AtomUI controls no longer gets one shared theme for both. Stay on 0.3.x until those screens are moved to Arc. + ## 0.3.1-preview.6 (2026-10-28) ### Added diff --git a/gallery/TKCode.Arc.Gallery/Guides/Changelog/zh-CN.md b/gallery/TKCode.Arc.Gallery/Guides/Changelog/zh-CN.md index 6ef8d557..fb5a337e 100644 --- a/gallery/TKCode.Arc.Gallery/Guides/Changelog/zh-CN.md +++ b/gallery/TKCode.Arc.Gallery/Guides/Changelog/zh-CN.md @@ -1,13 +1,120 @@ --- title: 更新日志 -source-hash: 55098180ce25 +source-hash: 2a8a33522b07 --- # 更新日志 TKCode.Arc 的所有重要变更,最新的在前。版本遵循[语义化版本](https://semver.org/)。版本带有 `-preview` 后缀期间,API 在各发布版本之间仍可能变化;升级前请阅读说明,并在项目中固定版本号。同样的列表也保存在仓库的 `CHANGELOG.md` 中。 -每次发布附带的 NuGet 包:`TKCode.Arc`、`TKCode.Arc.Core`、`TKCode.Arc.Fonts` `TKCode.Arc.Icons.Lucide`、`TKCode.Arc.Blocks`、`TKCode.Arc.AdvancedCharts` 、`TKCode.Arc.Special`、`TKCode.Arc.Workflow`、`TKCode.Arc.Themes.Pixel`、`TKCode.Arc.Themes.Corners`、`TKCode.Arc.Mobile`、`TKCode.Arc.Terminal`、`TKCode.Arc.Audio`、`TKCode.Arc.Audio.Native` 和 `TKCode.Arc.Audio.Browser`。 +每次发布附带的 NuGet 包:`TKCode.Arc`、`TKCode.Arc.Core`、`TKCode.Arc.Foundation`(0.4 起)、`TKCode.Arc.Fonts` `TKCode.Arc.Icons.Lucide`、`TKCode.Arc.Blocks`、`TKCode.Arc.AdvancedCharts` 、`TKCode.Arc.Special`、`TKCode.Arc.Workflow`、`TKCode.Arc.Themes.Pixel`、`TKCode.Arc.Themes.Corners`、`TKCode.Arc.Mobile`、`TKCode.Arc.Terminal`、`TKCode.Arc.Audio`、`TKCode.Arc.Audio.Native` 和 `TKCode.Arc.Audio.Browser`。 + +## 0.4.0-preview.1(未发布) + +Arc 不再依赖 AtomUI。每个包现在只依赖 Avalonia 和新的 MIT 包 `TKCode.Arc.Foundation`,整条依赖链都采用宽松许可,闭源应用可以把 Arc 以单文件或 NativeAOT 方式发布(issue #25)。应用的入口和主题 API 有变化;控件、控件属性和 XAML(`{arc:ArcDesignTokenResource ...}`、`{arc:XxxTokenResource ...}`)不变。见下文的*从 0.3 迁移到 0.4*。 + +### 新增 + +- **Foundation**(新包 `TKCode.Arc.Foundation`):主题管理器(`ArcThemeManager`、`ArcTheme`、`ArcThemeState`、`ArcThemeBuilder`)、主题预设(`ArcThemePreset`)、局部主题(`ArcThemeScope`)、包注册(`AppBuilder.UseArc(...)`、`ArcBuilder`)、语言(`ArcLanguage`、`ArcLanguageManager`),以及生成令牌、主题文件和语言目录的源代码生成器。不使用反射,兼容裁剪与 NativeAOT。控件的主题在该类型的控件第一次使用时才加载。 +- **ArcImage** 与 `ImageSource`(Core):在后台加载图片(http、`avares://`、文件、`data:`、位图),带共享缓存,桌面与浏览器均可用。 +- **ArcOverlayHost**(Core):自带覆盖层的面板,在其中打开的对话框、抽屉和底部面板都限制在它的范围内。 +- **基础主题**(Core):Arc 为其模板用到的 Avalonia 控件提供自己的主题(`ScrollViewer`、`ScrollBar`、`ItemsControl`、`PathIcon`、`TextSelectionHandle` 和弹出层宿主)。应用使用 `FluentTheme` 或 `SimpleTheme` 时,它会让位。 +- **滚动条**与 `ScrollArea` 一致:细长,指针悬停在视图上或滚动时显示,静止时隐藏。`ScrollViewer.AllowAutoHide="False"` 可让它们一直显示。 +- **圆角主题**:`UseArcCorners()` 与 `t.Corners(style)`;现成的 `ArcCornerPresets.Square` 和 `ArcCornerPresets.Half`。 +- **菜单**:较长的菜单在 `MaxDropDownHeight` 内滚动。 +- **Gallery**:桌面窗口自己绘制标题栏(徽标、标题、置顶按钮;Windows 与 Linux 上还有窗口按钮)。 + +### 变更 + +- **不兼容:入口。** `AppBuilder.UseAtomUI(b => b.UseDesktopControls().UseArc())` 改为 `AppBuilder.UseArc(arc => arc.UseArcControls())`。`Application` 类不再需要 `partial`。 +- **不兼容:主题 API。** `ThemeConfigBuilder`、`ThemeAlgorithm`、`IThemeManager.ApplyThemeAsync` 和 `WithArc(...)` 由 `ArcTheme.Apply(t => ...)`、`ArcThemeManager.Apply` / `ApplyAsync` 以及 `UseArc` 中的 `WithTheme(...)` 取代。 +- **不兼容:Pixel 与 Corners** 改为主题预设:`t.Pixel()` 与 `t.Corners(style)`。`ArcPixelTheme`、`ArcPixelScope`、`WithArcCorners` 和 `ArcCornerScope` 已移除;预设会跳过应用没有注册的包。 +- **不兼容:语言**移到 `TKCode.Arc.Localization`(`ArcLanguage`、`ArcLanguageManager`);`UseLanguages` 在 `ArcBuilder` 上。 +- **不兼容:图片与弹出层。** `AsyncImage` 由 `ArcImage` 取代(`IsLoaded` 改为 `IsImageLoaded`)。`ArcPopup` 派生自 Avalonia 的 `Popup`;`OpenMotion` 与 `CloseMotion` 的类型是 `ArcPopupMotion`,`AdjustPlacementAnchor` 去掉了 `ref flipX, ref flipY` 参数,`IsOpen` 会保持为 true 直到退场动效结束。`ArcPopupFrame` 取代 `BaseMotionActor`,`ArcOverlayHost` 取代 `ScopeAwareOverlayLayerPanel`。 +- **不兼容:移动端令牌标识**改为 `MobileButton`、`MobileCheckbox`、`MobileDialog`、`MobileSwitch` 和 `MobileToast`,不再与桌面控件同名。 +- Arc 不再与 AtomUI 控件共享主题;混用两者的应用请留在 0.3.x。 + +### 修复 + +- **ToastStack**:在堆栈恢复计时器的过程中关闭某个 Toast,不再打断恢复。 +- **DataGrid**:筛选列表的搜索框重新显示(之前它没有模板)。 +- **PasswordField**:较长的强度标签不再与强度条重叠(#23)。 +- **Gallery**:环境变量事件日志中的文本框重新显示(之前它没有模板)。 + +### 从 0.3 迁移到 0.4 + +1. 删除所有 `AtomUI.*` 包引用和 `using AtomUI...;`;Arc 会引入它需要的一切。 +2. 把 `UseAtomUI(...)` 换成 `UseArc(...)`,并把 `UseArc()`(控件包)改名为 `UseArcControls()`。其他包的注册方法名不变,移到 `UseArc` 的回调中。 +3. 把主题请求换成 `ArcTheme.Apply`。一次更改只写要改的部分,状态的其余部分保持不变。 +4. `App` 类可以去掉 `partial` 关键字;不再有代码生成到它里面。 +5. 使用 .NET 11 SDK 且 `PublishAot` 时,在应用项目中设置 `false`。 + +```csharp +// 0.3 +AppBuilder.Configure() + .UsePlatformDetect() + .UseAtomUI(builder => + { + builder.UseDesktopControls(); + builder.UseArc(); + builder.UseArcBlocks(); + builder.UseLanguages(LanguageTags.EnUS, [LanguageTags.EnUS, LanguageTags.ZhCN]); + }); + +// 0.4 +AppBuilder.Configure() + .UsePlatformDetect() + .UseArc(arc => arc + .UseArcControls() + .UseArcBlocks() + .UseLanguages(LanguageTags.EnUS, [LanguageTags.EnUS, LanguageTags.ZhCN]) + .WithTheme(t => t.Appearance(ArcAppearance.System))); +``` + +```csharp +// 0.3 +var builder = new ThemeConfigBuilder() + .WithAlgorithms(ThemeAlgorithm.Default, ThemeAlgorithm.Dark) + .WithArc(arc => arc.Accent(ArcAccentPreset.Blue).MotionMode(ArcMotionLevel.Reduced)); +await manager.ApplyThemeAsync(new ThemeRequest(IThemeManager.DEFAULT_THEME_ID, builder.Build(), + ThemeTransitionReason.UserRequest)); + +// 0.4 +ArcTheme.Apply(t => t + .Appearance(ArcAppearance.Dark) + .Accent(ArcAccentPreset.Blue) + .MotionLevel(ArcMotionLevel.Reduced)); +``` + +| 0.3(AtomUI) | 0.4 | +|---|---| +| `AppBuilder.UseAtomUI(b => ...)`、`UseDesktopControls()` | `AppBuilder.UseArc(arc => ...)` | +| `UseArc()`(控件包) | `UseArcControls()` | +| `IAtomUIBuilder` 上的 `UseArcCore`、`UseArcBlocks`、`UseArcAdvancedCharts`、`UseArcSpecial`、`UseArcWorkflow`、`UseArcMobile`、`UseArcPixelTheme`、`UseArcGallery` | `ArcBuilder` 上的同名方法;新增 `UseArcCorners()` | +| `builder.UseLanguages(...)` | `arc.UseLanguages(...)`,参数相同 | +| `partial class App`、`ApplicationStartup.Attach` | 不再需要 | +| `ThemeConfigBuilder`、`WithAlgorithms(ThemeAlgorithm.Dark)` | `t.Appearance(ArcAppearance.Dark)`(或 `Light`、`System`) | +| `IThemeManager.ApplyThemeAsync(new ThemeRequest(...))` | `ArcTheme.Apply(t => ...)`、`ArcThemeManager.Current.Apply` / `ApplyAsync`(请 await,切勿在 UI 线程上阻塞等待) | +| `WithArc(a => a.Accent(preset or color))` | `t.Accent(preset or color)` | +| `WithArc(a => a.Token(name, value))` | `t.Token(name, value)` | +| `WithArc(a => a.ControlToken(catalog, control, name, value))` | `t.Token(identity, name, value)` | +| `WithArc(a => a.MotionMode(level))` | `t.MotionLevel(level)` | +| `WithArcCorners(style, scope)`、`arc.Corners(style, scope)`、`ArcCornerScope` | `t.Corners(style)`、`ArcCornerPresets` | +| `ArcPixelTheme.CreateRequest(...)`、`WithArcPixel(...)`、`ArcPixelScope` | `t.Pixel()`、`ArcPixelPresets`;用 `t.RemovePreset(ArcPixelPresets.Slot)` 回到 Arc | +| `using AtomUI.Localization;` | `using TKCode.Arc.Localization;` | +| `Application.Current.GetLanguageManager()`、`ILanguageManager` | `ArcLanguageManager.Current` | +| `GetLocalizer().Get(kind)`、`Format(kind, args)` | `XxxLangResources.Get(kind)`、`XxxLangResources.Format(kind, args)` | +| `LanguageChanged`、`ChangeLanguage(tag)` | `Changed`、`Set(tag)`(或 `ArcLanguage.Changed`、`ArcLanguage.Set`) | +| `FormattingCulture`、`CurrentLanguage` | `ArcLanguage.Culture`、`ArcLanguage.Current` | +| 按目录覆盖移动端令牌 | 标识 `MobileButton`、`MobileCheckbox`、`MobileDialog`、`MobileSwitch`、`MobileToast` | +| `AtomUI.Controls.ImageSource`、`AsyncImage`、`IsLoaded` | `TKCode.Arc.ImageSource`、`ArcImage`、`IsImageLoaded` | +| 基于 AtomUI `Popup` 的 `ArcPopup`、`BaseMotionActor` | 基于 Avalonia `Popup` 的 `ArcPopup`、`ArcPopupFrame`;`OpenMotion` / `CloseMotion` 的类型是 `ArcPopupMotion` | +| `AdjustPlacementAnchor(placement, ref flipX, ref flipY)` | `AdjustPlacementAnchor(placement)` | +| `ScopeAwareOverlayLayerPanel` | `ArcOverlayHost` | +| AtomUI 为 `ScrollViewer`、`ScrollBar`、`ItemsControl` 和弹出层宿主提供的主题 | Arc 的基础主题;滚动条静止时隐藏(`ScrollViewer.AllowAutoHide="False"` 让它们一直显示) | + +- **窗口与覆盖层。** 没有了 AtomUI 的窗口主题,普通的 Avalonia `Window` 只有在模板中含有 `EnableOverlayLayer="True"` 的 `VisualLayerManager` 时才有覆盖层。`ThemeSwitcher` 的过渡效果需要它;没有时会直接切换。 +- **AtomUI 控件。** 同时使用 AtomUI 控件的应用,不再能让两者共享同一个主题。在这些界面迁移到 Arc 之前,请留在 0.3.x。 ## 0.3.1-preview.6 (2026-10-28) diff --git a/gallery/TKCode.Arc.Gallery/Guides/Changelog/zh-TW.md b/gallery/TKCode.Arc.Gallery/Guides/Changelog/zh-TW.md index 56db715a..99a5e00d 100644 --- a/gallery/TKCode.Arc.Gallery/Guides/Changelog/zh-TW.md +++ b/gallery/TKCode.Arc.Gallery/Guides/Changelog/zh-TW.md @@ -1,13 +1,120 @@ --- title: 更新日誌 -source-hash: 55098180ce25 +source-hash: 2a8a33522b07 --- # 更新日誌 TKCode.Arc 的所有重要變更,最新的在前。版本遵循[語意化版本](https://semver.org/)。版本帶有 `-preview` 後綴期間,API 在各發行版本之間仍可能變動;升級前請閱讀說明,並在專案中固定版本號。同一份清單也保存在儲存庫的 `CHANGELOG.md` 中。 -每次發行附帶的 NuGet 套件:`TKCode.Arc`、`TKCode.Arc.Core`、`TKCode.Arc.Fonts` 、`TKCode.Arc.Icons.Lucide`、`TKCode.Arc.Blocks`、`TKCode.Arc.AdvancedCharts` 、`TKCode.Arc.Special`、`TKCode.Arc.Workflow`、`TKCode.Arc.Themes.Pixel`、`TKCode.Arc.Themes.Corners`、`TKCode.Arc.Mobile`、`TKCode.Arc.Terminal`、`TKCode.Arc.Audio`、`TKCode.Arc.Audio.Native` 與 `TKCode.Arc.Audio.Browser`。 +每次發行附帶的 NuGet 套件:`TKCode.Arc`、`TKCode.Arc.Core`、`TKCode.Arc.Foundation`(0.4 起)、`TKCode.Arc.Fonts` 、`TKCode.Arc.Icons.Lucide`、`TKCode.Arc.Blocks`、`TKCode.Arc.AdvancedCharts` 、`TKCode.Arc.Special`、`TKCode.Arc.Workflow`、`TKCode.Arc.Themes.Pixel`、`TKCode.Arc.Themes.Corners`、`TKCode.Arc.Mobile`、`TKCode.Arc.Terminal`、`TKCode.Arc.Audio`、`TKCode.Arc.Audio.Native` 與 `TKCode.Arc.Audio.Browser`。 + +## 0.4.0-preview.1(未發佈) + +Arc 不再依賴 AtomUI。每個套件現在只依賴 Avalonia 與新的 MIT 套件 `TKCode.Arc.Foundation`,整條相依鏈都採用寬鬆授權,閉源應用程式可以把 Arc 以單一檔案或 NativeAOT 方式發佈(issue #25)。應用程式的進入點與主題 API 有變化;控制項、控制項屬性與 XAML(`{arc:ArcDesignTokenResource ...}`、`{arc:XxxTokenResource ...}`)不變。請參閱下文的*從 0.3 遷移到 0.4*。 + +### 新增 + +- **Foundation**(新套件 `TKCode.Arc.Foundation`):主題管理員(`ArcThemeManager`、`ArcTheme`、`ArcThemeState`、`ArcThemeBuilder`)、主題預設集(`ArcThemePreset`)、局部主題(`ArcThemeScope`)、套件註冊(`AppBuilder.UseArc(...)`、`ArcBuilder`)、語言(`ArcLanguage`、`ArcLanguageManager`),以及產生權杖、主題檔與語言目錄的原始碼產生器。不使用反射,相容於裁剪與 NativeAOT。控制項的主題在該型別的控制項第一次使用時才載入。 +- **ArcImage** 與 `ImageSource`(Core):在背景載入圖片(http、`avares://`、檔案、`data:`、點陣圖),帶共用快取,桌面與瀏覽器皆可使用。 +- **ArcOverlayHost**(Core):自帶覆疊層的面板,在其中開啟的對話方塊、抽屜與底部面板都限制在它的範圍內。 +- **基礎主題**(Core):Arc 為其範本用到的 Avalonia 控制項提供自己的主題(`ScrollViewer`、`ScrollBar`、`ItemsControl`、`PathIcon`、`TextSelectionHandle` 與快顯視窗宿主)。應用程式使用 `FluentTheme` 或 `SimpleTheme` 時,它會讓位。 +- **捲軸**與 `ScrollArea` 一致:細長,指標停留在檢視上或捲動時顯示,靜止時隱藏。`ScrollViewer.AllowAutoHide="False"` 可讓它們一直顯示。 +- **圓角主題**:`UseArcCorners()` 與 `t.Corners(style)`;現成的 `ArcCornerPresets.Square` 與 `ArcCornerPresets.Half`。 +- **選單**:較長的選單在 `MaxDropDownHeight` 內捲動。 +- **Gallery**:桌面視窗自行繪製標題列(標誌、標題、釘選按鈕;Windows 與 Linux 上還有視窗按鈕)。 + +### 變更 + +- **不相容:進入點。** `AppBuilder.UseAtomUI(b => b.UseDesktopControls().UseArc())` 改為 `AppBuilder.UseArc(arc => arc.UseArcControls())`。`Application` 類別不再需要 `partial`。 +- **不相容:主題 API。** `ThemeConfigBuilder`、`ThemeAlgorithm`、`IThemeManager.ApplyThemeAsync` 與 `WithArc(...)` 由 `ArcTheme.Apply(t => ...)`、`ArcThemeManager.Apply` / `ApplyAsync` 以及 `UseArc` 中的 `WithTheme(...)` 取代。 +- **不相容:Pixel 與 Corners** 改為主題預設集:`t.Pixel()` 與 `t.Corners(style)`。`ArcPixelTheme`、`ArcPixelScope`、`WithArcCorners` 與 `ArcCornerScope` 已移除;預設集會略過應用程式沒有註冊的套件。 +- **不相容:語言**移到 `TKCode.Arc.Localization`(`ArcLanguage`、`ArcLanguageManager`);`UseLanguages` 在 `ArcBuilder` 上。 +- **不相容:圖片與快顯視窗。** `AsyncImage` 由 `ArcImage` 取代(`IsLoaded` 改為 `IsImageLoaded`)。`ArcPopup` 衍生自 Avalonia 的 `Popup`;`OpenMotion` 與 `CloseMotion` 的型別是 `ArcPopupMotion`,`AdjustPlacementAnchor` 移除了 `ref flipX, ref flipY` 參數,`IsOpen` 會維持為 true 直到退場動態效果結束。`ArcPopupFrame` 取代 `BaseMotionActor`,`ArcOverlayHost` 取代 `ScopeAwareOverlayLayerPanel`。 +- **不相容:行動端權杖識別**改為 `MobileButton`、`MobileCheckbox`、`MobileDialog`、`MobileSwitch` 與 `MobileToast`,不再與桌面控制項同名。 +- Arc 不再與 AtomUI 控制項共用主題;混用兩者的應用程式請留在 0.3.x。 + +### 修正 + +- **ToastStack**:在堆疊恢復計時器的過程中關閉某個 Toast,不再中斷恢復。 +- **DataGrid**:篩選清單的搜尋框重新顯示(之前它沒有範本)。 +- **PasswordField**:較長的強度標籤不再與強度條重疊(#23)。 +- **Gallery**:環境變數事件記錄中的文字方塊重新顯示(之前它沒有範本)。 + +### 從 0.3 遷移到 0.4 + +1. 刪除所有 `AtomUI.*` 套件參照與 `using AtomUI...;`;Arc 會引入它需要的一切。 +2. 把 `UseAtomUI(...)` 換成 `UseArc(...)`,並把 `UseArc()`(控制項套件)改名為 `UseArcControls()`。其他套件的註冊方法名稱不變,移到 `UseArc` 的回呼中。 +3. 把主題請求換成 `ArcTheme.Apply`。一次變更只寫要改的部分,狀態的其餘部分維持不變。 +4. `App` 類別可以拿掉 `partial` 關鍵字;不再有程式碼產生到它裡面。 +5. 使用 .NET 11 SDK 且 `PublishAot` 時,在應用程式專案中設定 `false`。 + +```csharp +// 0.3 +AppBuilder.Configure() + .UsePlatformDetect() + .UseAtomUI(builder => + { + builder.UseDesktopControls(); + builder.UseArc(); + builder.UseArcBlocks(); + builder.UseLanguages(LanguageTags.EnUS, [LanguageTags.EnUS, LanguageTags.ZhCN]); + }); + +// 0.4 +AppBuilder.Configure() + .UsePlatformDetect() + .UseArc(arc => arc + .UseArcControls() + .UseArcBlocks() + .UseLanguages(LanguageTags.EnUS, [LanguageTags.EnUS, LanguageTags.ZhCN]) + .WithTheme(t => t.Appearance(ArcAppearance.System))); +``` + +```csharp +// 0.3 +var builder = new ThemeConfigBuilder() + .WithAlgorithms(ThemeAlgorithm.Default, ThemeAlgorithm.Dark) + .WithArc(arc => arc.Accent(ArcAccentPreset.Blue).MotionMode(ArcMotionLevel.Reduced)); +await manager.ApplyThemeAsync(new ThemeRequest(IThemeManager.DEFAULT_THEME_ID, builder.Build(), + ThemeTransitionReason.UserRequest)); + +// 0.4 +ArcTheme.Apply(t => t + .Appearance(ArcAppearance.Dark) + .Accent(ArcAccentPreset.Blue) + .MotionLevel(ArcMotionLevel.Reduced)); +``` + +| 0.3(AtomUI) | 0.4 | +|---|---| +| `AppBuilder.UseAtomUI(b => ...)`、`UseDesktopControls()` | `AppBuilder.UseArc(arc => ...)` | +| `UseArc()`(控制項套件) | `UseArcControls()` | +| `IAtomUIBuilder` 上的 `UseArcCore`、`UseArcBlocks`、`UseArcAdvancedCharts`、`UseArcSpecial`、`UseArcWorkflow`、`UseArcMobile`、`UseArcPixelTheme`、`UseArcGallery` | `ArcBuilder` 上的同名方法;新增 `UseArcCorners()` | +| `builder.UseLanguages(...)` | `arc.UseLanguages(...)`,參數相同 | +| `partial class App`、`ApplicationStartup.Attach` | 不再需要 | +| `ThemeConfigBuilder`、`WithAlgorithms(ThemeAlgorithm.Dark)` | `t.Appearance(ArcAppearance.Dark)`(或 `Light`、`System`) | +| `IThemeManager.ApplyThemeAsync(new ThemeRequest(...))` | `ArcTheme.Apply(t => ...)`、`ArcThemeManager.Current.Apply` / `ApplyAsync`(請 await,切勿在 UI 執行緒上封鎖等待) | +| `WithArc(a => a.Accent(preset or color))` | `t.Accent(preset or color)` | +| `WithArc(a => a.Token(name, value))` | `t.Token(name, value)` | +| `WithArc(a => a.ControlToken(catalog, control, name, value))` | `t.Token(identity, name, value)` | +| `WithArc(a => a.MotionMode(level))` | `t.MotionLevel(level)` | +| `WithArcCorners(style, scope)`、`arc.Corners(style, scope)`、`ArcCornerScope` | `t.Corners(style)`、`ArcCornerPresets` | +| `ArcPixelTheme.CreateRequest(...)`、`WithArcPixel(...)`、`ArcPixelScope` | `t.Pixel()`、`ArcPixelPresets`;用 `t.RemovePreset(ArcPixelPresets.Slot)` 回到 Arc | +| `using AtomUI.Localization;` | `using TKCode.Arc.Localization;` | +| `Application.Current.GetLanguageManager()`、`ILanguageManager` | `ArcLanguageManager.Current` | +| `GetLocalizer().Get(kind)`、`Format(kind, args)` | `XxxLangResources.Get(kind)`、`XxxLangResources.Format(kind, args)` | +| `LanguageChanged`、`ChangeLanguage(tag)` | `Changed`、`Set(tag)`(或 `ArcLanguage.Changed`、`ArcLanguage.Set`) | +| `FormattingCulture`、`CurrentLanguage` | `ArcLanguage.Culture`、`ArcLanguage.Current` | +| 依目錄覆寫行動端權杖 | 識別 `MobileButton`、`MobileCheckbox`、`MobileDialog`、`MobileSwitch`、`MobileToast` | +| `AtomUI.Controls.ImageSource`、`AsyncImage`、`IsLoaded` | `TKCode.Arc.ImageSource`、`ArcImage`、`IsImageLoaded` | +| 以 AtomUI `Popup` 為基礎的 `ArcPopup`、`BaseMotionActor` | 以 Avalonia `Popup` 為基礎的 `ArcPopup`、`ArcPopupFrame`;`OpenMotion` / `CloseMotion` 的型別是 `ArcPopupMotion` | +| `AdjustPlacementAnchor(placement, ref flipX, ref flipY)` | `AdjustPlacementAnchor(placement)` | +| `ScopeAwareOverlayLayerPanel` | `ArcOverlayHost` | +| AtomUI 為 `ScrollViewer`、`ScrollBar`、`ItemsControl` 與快顯視窗宿主提供的主題 | Arc 的基礎主題;捲軸靜止時隱藏(`ScrollViewer.AllowAutoHide="False"` 讓它們一直顯示) | + +- **視窗與覆疊層。** 少了 AtomUI 的視窗主題,一般的 Avalonia `Window` 只有在範本中含有 `EnableOverlayLayer="True"` 的 `VisualLayerManager` 時才有覆疊層。`ThemeSwitcher` 的轉場效果需要它;沒有時會直接切換。 +- **AtomUI 控制項。** 同時使用 AtomUI 控制項的應用程式,無法再讓兩者共用同一個主題。在這些畫面遷移到 Arc 之前,請留在 0.3.x。 ## 0.3.1-preview.6 (2026-10-28) diff --git a/gallery/TKCode.Arc.Gallery/Guides/Installation/en-US.md b/gallery/TKCode.Arc.Gallery/Guides/Installation/en-US.md index e73a2087..f8ca9205 100644 --- a/gallery/TKCode.Arc.Gallery/Guides/Installation/en-US.md +++ b/gallery/TKCode.Arc.Gallery/Guides/Installation/en-US.md @@ -13,11 +13,12 @@ takes about five minutes on a new project and does not change how the rest of yo |---|---|---| | .NET | `net10.0` or `net11.0` | The packages target both. | | Avalonia | `12.1.4` | Pinned to the exact version the library is built against. | -| AtomUI | `6.2.8` | `AtomUI.Desktop.Controls`; Arc's theme and token system sit on AtomUI. | +| TKCode.Arc.Foundation | same as Arc | MIT; the theme, token, language and package runtime. Comes with `TKCode.Arc`. | | Fonts (optional) | `Avalonia.Fonts.Inter` | Arc's body text uses Inter; Geist comes from `TKCode.Arc.Fonts`. | -TKCode.Arc is in preview, and AtomUI and Avalonia are pinned to exact versions. Use the same Avalonia version in your -application; mixing versions leads to restore warnings and, at run time, to missing styles or type load errors. +TKCode.Arc is in preview, and Avalonia is pinned to an exact version. Besides Avalonia, every Arc package depends only +on MIT packages of its own. Use the same Avalonia version in your application; mixing versions leads to restore +warnings and, at run time, to missing styles or type load errors. ## 1. Add the packages @@ -28,8 +29,8 @@ dotnet add package TKCode.Arc.Icons.Lucide --version 0.3.1-preview.6 # option dotnet add package Avalonia.Fonts.Inter --version 12.1.4 # optional: Inter for body text ``` -`TKCode.Arc` brings in `TKCode.Arc.Core`, AtomUI and StreamMark. If your solution uses central package management, add -the versions to `Directory.Packages.props` once and keep the project files version-free: +`TKCode.Arc` brings in `TKCode.Arc.Core`, `TKCode.Arc.Foundation` and StreamMark. If your solution uses central +package management, add the versions to `Directory.Packages.props` once and keep the project files version-free: ```xml @@ -45,11 +46,9 @@ restore. ## 2. Register Arc in the application builder -Arc is registered in AtomUI's builder, next to AtomUI's desktop controls. A typical `Program.cs`: +Arc is registered on the `AppBuilder` with `UseArc(...)`. A typical `Program.cs`: ```csharp -using AtomUI; -using AtomUI.Desktop.Controls; using Avalonia; using TKCode.Arc; using TKCode.Arc.Fonts; @@ -64,11 +63,9 @@ internal static class Program .UsePlatformDetect() .WithInterFont() // Inter for body text (Avalonia.Fonts.Inter) .WithArcFonts() // Geist for display text, Geist Mono for code (TKCode.Arc.Fonts) - .UseAtomUI(builder => - { - builder.UseDesktopControls(); - builder.UseArc(); - }); + .UseArc(arc => arc + .UseArcControls() + .WithTheme(t => t.Appearance(ArcAppearance.System).Accent(ArcAccentPreset.Neutral))); } ``` @@ -76,16 +73,15 @@ What each call does: | Call | Purpose | |---|---| -| `UseAtomUI(...)` | Starts AtomUI's theme manager, design tokens and localization for the application. | -| `UseDesktopControls()` | AtomUI's desktop control package, which Arc builds on. | -| `UseArc()` | Registers Arc's controls, themes and tokens. It also registers `TKCode.Arc.Core` (`UseArcCore()`), so you do not call that yourself. | +| `UseArc(...)` | Starts Arc's theme manager, design tokens and languages once the application is initialized. The callback lists the packages and the initial theme. | +| `UseArcControls()` | Registers Arc's controls, themes and tokens. It also registers `TKCode.Arc.Core` (`UseArcCore()`), so you do not call that yourself. | +| `WithTheme(...)` | Optional: the theme the first frame is drawn with (appearance, accent, token overrides). Without it Arc starts light with the `Neutral` accent. See **Theming**. | | `WithInterFont()`, `WithArcFonts()` | Register the fonts Arc's type scale is designed for. Without them, text falls back to the system font. | -Declare the `Application` class `partial`. AtomUI's source generator adds its startup code to it, and the build stops -with `ATOMUILOC008` when the class is not partial: +The `Application` class needs nothing from Arc: no base class, no `partial` keyword, no styles in `App.axaml`. ```csharp -public partial class App : Application +public class App : Application { public override void OnFrameworkInitializationCompleted() { @@ -98,6 +94,11 @@ public partial class App : Application } ``` +Arc also styles the Avalonia controls its templates use (`ScrollViewer`, `ScrollBar`, `ItemsControl`, `PathIcon`, the +text selection handles and the popup hosts). Scroll bars look like Arc's `ScrollArea`: thin, shown while the pointer is +over the view or while it scrolls, hidden at rest; set `ScrollViewer.AllowAutoHide="False"` to keep them visible. When +`App.axaml` already includes `FluentTheme` or `SimpleTheme`, Arc leaves these controls to that theme. + ## 3. Use the controls in XAML Arc's XAML namespace is `https://github.com/AIDotNet/TKCode.Arc`; every control, token resource and markup @@ -159,7 +160,7 @@ Lucide icon, add `TKCode.Arc.Icons.Lucide` and use its namespace: ### Blocks and the data grid `TKCode.Arc.Blocks` (page-level blocks), `TKCode.Arc.AdvancedCharts` (the `DataGrid`) and `TKCode.Arc.Special` (animated -AI explainers) are separate packages. Add the ones you need and register them next to `UseArc()`: +AI explainers) are separate packages. Add the ones you need and register them inside `UseArc(...)`: ```bash dotnet add package TKCode.Arc.Blocks --version 0.3.1-preview.6 @@ -167,27 +168,45 @@ dotnet add package TKCode.Arc.AdvancedCharts --version 0.3.1-preview.6 dotnet add package TKCode.Arc.Special --version 0.3.1-preview.6 dotnet add package TKCode.Arc.Workflow --version 0.3.1-preview.6 dotnet add package TKCode.Arc.Themes.Pixel --version 0.3.1-preview.6 # optional: pixel-art theme (UseArcPixelTheme()) -dotnet add package TKCode.Arc.Themes.Corners --version 0.3.1-preview.6 # optional: global corner style (WithArcCorners()) +dotnet add package TKCode.Arc.Themes.Corners --version 0.3.1-preview.6 # optional: global corner style (UseArcCorners()) dotnet add package TKCode.Arc.Mobile --version 0.3.1-preview.6 # optional: touch-first controls, not yet verified on devices (UseArcMobile()) ``` ```csharp -builder.UseDesktopControls(); -builder.UseArc(); -builder.UseArcBlocks(); // TKCode.Arc.Blocks -builder.UseArcAdvancedCharts(); // TKCode.Arc.AdvancedCharts -builder.UseArcSpecial(); // TKCode.Arc.Special +.UseArc(arc => arc + .UseArcControls() + .UseArcBlocks() // TKCode.Arc.Blocks + .UseArcAdvancedCharts() // TKCode.Arc.AdvancedCharts + .UseArcSpecial() // TKCode.Arc.Special + .UseArcWorkflow() // TKCode.Arc.Workflow + .UseArcPixelTheme() // TKCode.Arc.Themes.Pixel + .UseArcCorners() // TKCode.Arc.Themes.Corners + .UseArcMobile()) // TKCode.Arc.Mobile ``` +Each package registers the packages it depends on (Blocks registers the controls, for example), and registering one +twice does nothing. + ### Languages -AtomUI handles the application language. To offer more than one, declare them in the builder; the gallery offers -English, Simplified Chinese and Traditional Chinese this way: +Arc handles the application language (namespace `TKCode.Arc.Localization`). To offer more than one, declare them in +`UseArc(...)`: the first argument is the start-up language, the second the languages the application offers. The +gallery offers English, Simplified Chinese and Traditional Chinese this way: ```csharp -builder.UseLanguages(LanguageTags.EnUS, [LanguageTags.EnUS, LanguageTags.ZhCN, LanguageTags.ZhTW]); +.UseArc(arc => arc + .UseArcControls() + .UseLanguages(LanguageTags.EnUS, [LanguageTags.EnUS, LanguageTags.ZhCN, LanguageTags.ZhTW])) + +// Later, for example from a settings page: +ArcLanguage.Set(LanguageTags.ZhCN); ``` +`ArcLanguage.Current` is the current language, `ArcLanguage.Culture` the culture to format numbers and dates with, and +`ArcLanguage.Changed` is raised after a switch. The packages with built-in text of their own (the `DataGrid`, the +workflow canvas) ship English, Simplified Chinese and Traditional Chinese. Start-up fails with a list of the missing +text when an enabled package does not cover a declared language. + Arc controls ship English defaults for their built-in text (for example "Show password" or "Clear search"). The controls expose that text as properties or label objects, so you can translate it from your own resources. @@ -196,6 +215,11 @@ controls expose that text as properties or label objects, so you can translate i - **NativeAOT and trimming.** The libraries are trimming- and AOT-compatible and use no reflection. Publish a desktop app as a single self-contained file with `-p:PublishAot=true`, or `-p:PublishSingleFile=true --self-contained` when you do not use AOT. +- **.NET SDK 11 and NativeAOT.** The .NET 11 SDK's optimized AOT publish drops the compiled XAML of every Avalonia + 12.1 application, which then fails at start with `No precompiled XAML found`. Turn it off in the application's + project file: `false`. +- **License.** Arc, its Foundation package and its other dependencies are MIT licensed (Avalonia too), so a closed-source + single-file or NativeAOT publish has no LGPL obligations. - **Globalization.** Do not enable `InvariantGlobalization`. Number, currency and date formatting in the inputs, pickers and charts needs culture data. - **macOS.** The repository's `tools/macos` scripts show how the gallery is wrapped into a signed and notarized `.app`; @@ -203,12 +227,13 @@ controls expose that text as properties or label objects, so you can translate i ## Building from source -Building the repository needs the .NET 11 SDK (see `global.json`) and the .NET 10 runtime, which AtomUI's build tasks -run on: +Building the repository needs the .NET 11 SDK (see `global.json`) and the .NET 10 runtime (the tests and the +gallery run on `net10.0`): ```bash dotnet build TKCode.Arc.slnx dotnet test tests/TKCode.Arc.Core.Tests +dotnet test tests/TKCode.Arc.Foundation.Tests dotnet test tests/TKCode.Arc.Tests dotnet run --project gallery/TKCode.Arc.Gallery.Desktop -f net10.0 ``` @@ -217,16 +242,20 @@ dotnet run --project gallery/TKCode.Arc.Gallery.Desktop -f net10.0 | Symptom | Cause | Fix | |---|---|---| -| Arc controls appear unstyled or empty | `UseArc()` is not called in `UseAtomUI(...)` | Register it after `UseDesktopControls()`. | -| Build error `ATOMUILOC008` | The `Application` class is not `partial` | Declare it `public partial class App : Application`. | +| Arc controls appear unstyled or empty | `UseArcControls()` is not called in `UseArc(...)` | Add it to the `UseArc(...)` callback. | +| `InvalidOperationException` at start-up listing missing text | A language in `UseLanguages` is not covered by an enabled package | Declare only languages the packages ship, or add the missing translations. | +| `No precompiled XAML found` in a NativeAOT build | The .NET 11 SDK's optimized publish | Set `false`. | +| `ThemeSwitcher` switches without its transition | The window has no overlay layer | Give the window's template a `VisualLayerManager` with `EnableOverlayLayer="True"`. | | Headings and code use the system font | The fonts are not registered | Call `WithInterFont()` and `WithArcFonts()` on the `AppBuilder`. | | Wrong number or date formats, or exceptions while formatting | `InvariantGlobalization` is enabled | Remove it from the project file. | | `'Button' is an ambiguous reference` | `TKCode.Arc` and `Avalonia.Controls` are both imported | Alias one of them, as shown above. | | Restore warnings about Avalonia versions | Another package pulls a different Avalonia | Reference Avalonia `12.1.4` explicitly in the application. | -| Blocks, `DataGrid` or Special types are not found | The package is not referenced or not registered | Add `TKCode.Arc.Blocks`, `TKCode.Arc.AdvancedCharts` or `TKCode.Arc.Special` and call `UseArcBlocks()`, `UseArcAdvancedCharts()` or `UseArcSpecial()`. | +| Blocks, `DataGrid` or Special types are not found | The package is not referenced or not registered | Add `TKCode.Arc.Blocks`, `TKCode.Arc.AdvancedCharts` or `TKCode.Arc.Special` and call `UseArcBlocks()`, `UseArcAdvancedCharts()` or `UseArcSpecial()` in `UseArc(...)`. | ## Next steps - **Theming**: light and dark, the eight accents and the design tokens. - **Motion**: motion levels and reduced motion. - The component pages: start with Button, Input and Card, which most screens are built from. +- **Changelog**: *Migrating from 0.3 to 0.4* lists every change for applications that used the AtomUI-based 0.3 + releases. diff --git a/gallery/TKCode.Arc.Gallery/Guides/Installation/zh-CN.md b/gallery/TKCode.Arc.Gallery/Guides/Installation/zh-CN.md index 4104b663..42552751 100644 --- a/gallery/TKCode.Arc.Gallery/Guides/Installation/zh-CN.md +++ b/gallery/TKCode.Arc.Gallery/Guides/Installation/zh-CN.md @@ -1,6 +1,6 @@ --- title: 安装 -source-hash: 1e936494a3c3 +source-hash: b89f52eff864 --- # 安装 @@ -13,10 +13,10 @@ source-hash: 1e936494a3c3 |---|---|---| | .NET | `net10.0` 或 `net11.0` | 这些包同时面向两个目标框架。 | | Avalonia | `12.1.4` | 固定为库构建时所用的确切版本。 | -| AtomUI | `6.2.8` | `AtomUI.Desktop.Controls`;Arc 的主题与令牌系统基于 AtomUI。 | +| TKCode.Arc.Foundation | 与 Arc 相同 | MIT;主题、令牌、语言与包注册的运行时,随 `TKCode.Arc` 一起引入。 | | 字体(可选) | `Avalonia.Fonts.Inter` | Arc 的正文使用 Inter;Geist 来自 `TKCode.Arc.Fonts`。 | -TKCode.Arc 目前处于预览阶段,AtomUI 与 Avalonia 都固定为确切版本。请在你的应用中使用相同的 Avalonia 版本;版本混用会导致还原警告,并在运行时出现样式缺失或类型加载错误。 +TKCode.Arc 目前处于预览阶段,Avalonia 固定为确切版本。除 Avalonia 之外,每个 Arc 包只依赖 Arc 自己的 MIT 包。请在你的应用中使用相同的 Avalonia 版本;版本混用会导致还原警告,并在运行时出现样式缺失或类型加载错误。 ## 1. 添加包 @@ -27,7 +27,7 @@ dotnet add package TKCode.Arc.Icons.Lucide --version 0.3.1-preview.6 # 可选 dotnet add package Avalonia.Fonts.Inter --version 12.1.4 # 可选:正文使用的 Inter ``` -`TKCode.Arc` 会引入 `TKCode.Arc.Core`、AtomUI 和 StreamMark。如果你的解决方案使用集中式包管理,请把版本号一次性添加到 `Directory.Packages.props` 中,并让项目文件不包含版本号: +`TKCode.Arc` 会引入 `TKCode.Arc.Core`、`TKCode.Arc.Foundation` 和 StreamMark。如果你的解决方案使用集中式包管理,请把版本号一次性添加到 `Directory.Packages.props` 中,并让项目文件不包含版本号: ```xml @@ -42,11 +42,9 @@ dotnet add package Avalonia.Fonts.Inter --version 12.1.4 # 可 ## 2. 在应用构建器中注册 Arc -Arc 在 AtomUI 的构建器中注册,与 AtomUI 的桌面控件并列。一个典型的 `Program.cs` 如下: +Arc 通过 `AppBuilder` 上的 `UseArc(...)` 注册。一个典型的 `Program.cs` 如下: ```csharp -using AtomUI; -using AtomUI.Desktop.Controls; using Avalonia; using TKCode.Arc; using TKCode.Arc.Fonts; @@ -61,11 +59,9 @@ internal static class Program .UsePlatformDetect() .WithInterFont() // 正文使用的 Inter(Avalonia.Fonts.Inter) .WithArcFonts() // 展示文字使用 Geist,代码使用 Geist Mono(TKCode.Arc.Fonts) - .UseAtomUI(builder => - { - builder.UseDesktopControls(); - builder.UseArc(); - }); + .UseArc(arc => arc + .UseArcControls() + .WithTheme(t => t.Appearance(ArcAppearance.System).Accent(ArcAccentPreset.Neutral))); } ``` @@ -73,15 +69,15 @@ internal static class Program | 调用 | 用途 | |---|---| -| `UseAtomUI(...)` | 为应用启动 AtomUI 的主题管理器、设计令牌与本地化。 | -| `UseDesktopControls()` | AtomUI 的桌面控件包,Arc 构建于其上。 | -| `UseArc()` | 注册 Arc 的控件、主题与令牌。它也会注册 `TKCode.Arc.Core`(`UseArcCore()`),因此你无需自行调用。 | +| `UseArc(...)` | 在应用初始化后启动 Arc 的主题管理器、设计令牌与语言。回调中列出要启用的包和初始主题。 | +| `UseArcControls()` | 注册 Arc 的控件、主题与令牌。它也会注册 `TKCode.Arc.Core`(`UseArcCore()`),因此你无需自行调用。 | +| `WithTheme(...)` | 可选:绘制第一帧时使用的主题(明暗、强调色、令牌覆盖)。不调用时,Arc 以浅色和 `Neutral` 强调色启动。参见 **主题**。 | | `WithInterFont()`、`WithArcFonts()` | 注册 Arc 字号体系所设计的字体。若不注册,文字会回退到系统字体。 | -把 `Application` 类声明为 `partial`。AtomUI 的源代码生成器会为它添加启动代码;如果该类不是 partial,构建会因 `ATOMUILOC008` 而停止: +`Application` 类不需要为 Arc 做任何事:不需要特定基类,不需要 `partial` 关键字,`App.axaml` 中也不需要样式。 ```csharp -public partial class App : Application +public class App : Application { public override void OnFrameworkInitializationCompleted() { @@ -94,6 +90,8 @@ public partial class App : Application } ``` +Arc 也为其模板用到的 Avalonia 控件提供样式(`ScrollViewer`、`ScrollBar`、`ItemsControl`、`PathIcon`、文本选择手柄和弹出层宿主)。滚动条与 Arc 的 `ScrollArea` 一致:细长,指针悬停在视图上或滚动时显示,静止时隐藏;设置 `ScrollViewer.AllowAutoHide="False"` 可让它们一直显示。如果 `App.axaml` 已包含 `FluentTheme` 或 `SimpleTheme`,Arc 会把这些控件交给该主题。 + ## 3. 在 XAML 中使用控件 Arc 的 XAML 命名空间是 `https://github.com/AIDotNet/TKCode.Arc`;所有控件、令牌资源与标记扩展都位于其中。 @@ -148,7 +146,7 @@ Arc 自有的图标字形(`{x:Static arc:ArcIcons.Check}` 以及另外 186 个 ### 区块与数据表格 -`TKCode.Arc.Blocks`(页面级区块)、`TKCode.Arc.AdvancedCharts`(`DataGrid`)和 `TKCode.Arc.Special`(AI 动画讲解)是独立的包。按需添加,并把它们注册在 `UseArc()` 旁边: +`TKCode.Arc.Blocks`(页面级区块)、`TKCode.Arc.AdvancedCharts`(`DataGrid`)和 `TKCode.Arc.Special`(AI 动画讲解)是独立的包。按需添加,并在 `UseArc(...)` 中注册: ```bash dotnet add package TKCode.Arc.Blocks --version 0.3.1-preview.6 @@ -156,41 +154,57 @@ dotnet add package TKCode.Arc.AdvancedCharts --version 0.3.1-preview.6 dotnet add package TKCode.Arc.Special --version 0.3.1-preview.6 dotnet add package TKCode.Arc.Workflow --version 0.3.1-preview.6 dotnet add package TKCode.Arc.Themes.Pixel --version 0.3.1-preview.6 # 可选:像素风主题(UseArcPixelTheme()) -dotnet add package TKCode.Arc.Themes.Corners --version 0.3.1-preview.6 # optional: global corner style (WithArcCorners()) +dotnet add package TKCode.Arc.Themes.Corners --version 0.3.1-preview.6 # 可选:全局圆角风格(UseArcCorners()) dotnet add package TKCode.Arc.Mobile --version 0.3.1-preview.6 # 可选:移动端控件,尚未在真机上验证(UseArcMobile()) ``` ```csharp -builder.UseDesktopControls(); -builder.UseArc(); -builder.UseArcBlocks(); // TKCode.Arc.Blocks -builder.UseArcAdvancedCharts(); // TKCode.Arc.AdvancedCharts -builder.UseArcSpecial(); // TKCode.Arc.Special +.UseArc(arc => arc + .UseArcControls() + .UseArcBlocks() // TKCode.Arc.Blocks + .UseArcAdvancedCharts() // TKCode.Arc.AdvancedCharts + .UseArcSpecial() // TKCode.Arc.Special + .UseArcWorkflow() // TKCode.Arc.Workflow + .UseArcPixelTheme() // TKCode.Arc.Themes.Pixel + .UseArcCorners() // TKCode.Arc.Themes.Corners + .UseArcMobile()) // TKCode.Arc.Mobile ``` +每个包会注册它所依赖的包(例如 Blocks 会注册控件包),重复注册同一个包不会产生任何效果。 + ### 语言 -应用语言由 AtomUI 处理。若要提供多种语言,请在构建器中声明它们;本图库以这种方式提供英文、简体中文和繁体中文: +应用语言由 Arc 处理(命名空间 `TKCode.Arc.Localization`)。若要提供多种语言,请在 `UseArc(...)` 中声明:第一个参数是启动时的语言,第二个参数是应用提供的语言。本图库以这种方式提供英文、简体中文和繁体中文: ```csharp -builder.UseLanguages(LanguageTags.EnUS, [LanguageTags.EnUS, LanguageTags.ZhCN, LanguageTags.ZhTW]); +.UseArc(arc => arc + .UseArcControls() + .UseLanguages(LanguageTags.EnUS, [LanguageTags.EnUS, LanguageTags.ZhCN, LanguageTags.ZhTW])) + +// 之后,例如在设置页中: +ArcLanguage.Set(LanguageTags.ZhCN); ``` +`ArcLanguage.Current` 是当前语言,`ArcLanguage.Culture` 是格式化数字和日期所用的区域文化,切换语言后会引发 `ArcLanguage.Changed`。自带内置文字的包(`DataGrid`、工作流画布)提供英文、简体中文和繁体中文。如果某个已启用的包没有覆盖声明的某种语言,启动会失败,并列出缺失的文字。 + Arc 控件为其内置文字提供英文默认值(例如 "Show password" 或 "Clear search")。控件将这些文字以属性或标签对象的形式公开,因此你可以在自己的资源中进行翻译。 ## 发布 - **NativeAOT 与裁剪。** 这些库兼容裁剪与 AOT,且不使用反射。若要将桌面应用发布为单个自包含文件,请使用 `-p:PublishAot=true`;若不使用 AOT,则使用 `-p:PublishSingleFile=true --self-contained`。 +- **.NET 11 SDK 与 NativeAOT。** .NET 11 SDK 的优化 AOT 发布会丢掉所有 Avalonia 12.1 应用编译后的 XAML,应用启动时会抛出 `No precompiled XAML found`。请在应用的项目文件中关闭它:`false`。 +- **许可。** Arc、它的 Foundation 包和其他依赖(包括 Avalonia)都采用 MIT 等宽松许可,闭源应用以单文件或 NativeAOT 发布时没有 LGPL 义务。 - **全球化。** 不要启用 `InvariantGlobalization`。输入框、选择器与图表中的数字、货币和日期格式化需要区域文化数据。 - **macOS。** 仓库中的 `tools/macos` 脚本演示了如何将图库打包为已签名并完成公证的 `.app`;同样的流程也适用于你的应用。 ## 从源代码构建 -构建仓库需要 .NET 11 SDK(见 `global.json`)以及 .NET 10 运行时,AtomUI 的构建任务运行在其上: +构建仓库需要 .NET 11 SDK(见 `global.json`)以及 .NET 10 运行时(测试与图库运行在 `net10.0` 上): ```bash dotnet build TKCode.Arc.slnx dotnet test tests/TKCode.Arc.Core.Tests +dotnet test tests/TKCode.Arc.Foundation.Tests dotnet test tests/TKCode.Arc.Tests dotnet run --project gallery/TKCode.Arc.Gallery.Desktop -f net10.0 ``` @@ -199,16 +213,19 @@ dotnet run --project gallery/TKCode.Arc.Gallery.Desktop -f net10.0 | 现象 | 原因 | 解决方法 | |---|---|---| -| Arc 控件没有样式或显示为空 | 没有在 `UseAtomUI(...)` 中调用 `UseArc()` | 把它注册在 `UseDesktopControls()` 之后。 | -| 构建错误 `ATOMUILOC008` | `Application` 类不是 `partial` | 将其声明为 `public partial class App : Application`。 | +| Arc 控件没有样式或显示为空 | 没有在 `UseArc(...)` 中调用 `UseArcControls()` | 把它加到 `UseArc(...)` 的回调中。 | +| 启动时抛出 `InvalidOperationException` 并列出缺失的文字 | `UseLanguages` 中的某种语言没有被已启用的包覆盖 | 只声明这些包提供的语言,或补上缺失的翻译。 | +| NativeAOT 构建中出现 `No precompiled XAML found` | .NET 11 SDK 的优化发布 | 设置 `false`。 | +| `ThemeSwitcher` 切换时没有过渡效果 | 窗口没有覆盖层 | 在窗口模板中放一个 `EnableOverlayLayer="True"` 的 `VisualLayerManager`。 | | 标题与代码使用系统字体 | 没有注册字体 | 在 `AppBuilder` 上调用 `WithInterFont()` 和 `WithArcFonts()`。 | | 数字或日期格式错误,或格式化时抛出异常 | 启用了 `InvariantGlobalization` | 从项目文件中移除它。 | | `'Button' is an ambiguous reference` | `TKCode.Arc` 与 `Avalonia.Controls` 同时被导入 | 为其中一个设置别名,如上所示。 | | 关于 Avalonia 版本的还原警告 | 其他包引入了不同版本的 Avalonia | 在应用中显式引用 Avalonia `12.1.4`。 | -| 找不到 Blocks、`DataGrid` 或 Special 类型 | 没有引用该包或没有注册 | 添加 `TKCode.Arc.Blocks`、`TKCode.Arc.AdvancedCharts` 或 `TKCode.Arc.Special`,并调用 `UseArcBlocks()`、`UseArcAdvancedCharts()` 或 `UseArcSpecial()`。 | +| 找不到 Blocks、`DataGrid` 或 Special 类型 | 没有引用该包或没有注册 | 添加 `TKCode.Arc.Blocks`、`TKCode.Arc.AdvancedCharts` 或 `TKCode.Arc.Special`,并在 `UseArc(...)` 中调用 `UseArcBlocks()`、`UseArcAdvancedCharts()` 或 `UseArcSpecial()`。 | ## 下一步 - **主题**:浅色与深色模式、八种强调色以及设计令牌。 - **动效**:动效级别与减弱动效。 - 组件页面:从按钮、输入框和卡片开始,它们是大多数界面的基础。 +- **更新日志**:*从 0.3 迁移到 0.4* 列出了使用基于 AtomUI 的 0.3 版本的应用需要做的全部改动。 diff --git a/gallery/TKCode.Arc.Gallery/Guides/Installation/zh-TW.md b/gallery/TKCode.Arc.Gallery/Guides/Installation/zh-TW.md index 330d167c..f1617730 100644 --- a/gallery/TKCode.Arc.Gallery/Guides/Installation/zh-TW.md +++ b/gallery/TKCode.Arc.Gallery/Guides/Installation/zh-TW.md @@ -1,6 +1,6 @@ --- title: 安裝 -source-hash: 1e936494a3c3 +source-hash: b89f52eff864 --- # 安裝 @@ -13,10 +13,10 @@ source-hash: 1e936494a3c3 |---|---|---| | .NET | `net10.0` 或 `net11.0` | 這些套件同時以兩個目標框架為對象。 | | Avalonia | `12.1.4` | 固定為函式庫建置時所用的確切版本。 | -| AtomUI | `6.2.8` | `AtomUI.Desktop.Controls`;Arc 的佈景主題與權杖系統建立在 AtomUI 之上。 | +| TKCode.Arc.Foundation | 與 Arc 相同 | MIT;佈景主題、權杖、語言與套件註冊的執行階段,隨 `TKCode.Arc` 一起引入。 | | 字型(選用) | `Avalonia.Fonts.Inter` | Arc 的內文使用 Inter;Geist 來自 `TKCode.Arc.Fonts`。 | -TKCode.Arc 目前處於預覽階段,AtomUI 與 Avalonia 都固定為確切版本。請在你的應用程式中使用相同的 Avalonia 版本;版本混用會導致還原警告,並在執行時出現樣式遺失或型別載入錯誤。 +TKCode.Arc 目前處於預覽階段,Avalonia 固定為確切版本。除 Avalonia 之外,每個 Arc 套件只依賴 Arc 自己的 MIT 套件。請在你的應用程式中使用相同的 Avalonia 版本;版本混用會導致還原警告,並在執行時出現樣式遺失或型別載入錯誤。 ## 1. 加入套件 @@ -27,7 +27,7 @@ dotnet add package TKCode.Arc.Icons.Lucide --version 0.3.1-preview.6 # 選用 dotnet add package Avalonia.Fonts.Inter --version 12.1.4 # 選用:內文使用的 Inter ``` -`TKCode.Arc` 會引入 `TKCode.Arc.Core`、AtomUI 與 StreamMark。如果你的方案使用集中式套件管理,請將版本號一次加入 `Directory.Packages.props`,並讓專案檔不含版本號: +`TKCode.Arc` 會引入 `TKCode.Arc.Core`、`TKCode.Arc.Foundation` 與 StreamMark。如果你的方案使用集中式套件管理,請將版本號一次加入 `Directory.Packages.props`,並讓專案檔不含版本號: ```xml @@ -42,11 +42,9 @@ dotnet add package Avalonia.Fonts.Inter --version 12.1.4 # 選 ## 2. 在應用程式建構器中註冊 Arc -Arc 在 AtomUI 的建構器中註冊,與 AtomUI 的桌面控制項並列。一個典型的 `Program.cs` 如下: +Arc 透過 `AppBuilder` 上的 `UseArc(...)` 註冊。一個典型的 `Program.cs` 如下: ```csharp -using AtomUI; -using AtomUI.Desktop.Controls; using Avalonia; using TKCode.Arc; using TKCode.Arc.Fonts; @@ -61,11 +59,9 @@ internal static class Program .UsePlatformDetect() .WithInterFont() // 內文使用的 Inter(Avalonia.Fonts.Inter) .WithArcFonts() // 展示文字使用 Geist,程式碼使用 Geist Mono(TKCode.Arc.Fonts) - .UseAtomUI(builder => - { - builder.UseDesktopControls(); - builder.UseArc(); - }); + .UseArc(arc => arc + .UseArcControls() + .WithTheme(t => t.Appearance(ArcAppearance.System).Accent(ArcAccentPreset.Neutral))); } ``` @@ -73,15 +69,15 @@ internal static class Program | 呼叫 | 用途 | |---|---| -| `UseAtomUI(...)` | 為應用程式啟動 AtomUI 的佈景主題管理員、設計權杖與在地化。 | -| `UseDesktopControls()` | AtomUI 的桌面控制項套件,Arc 建立於其上。 | -| `UseArc()` | 註冊 Arc 的控制項、佈景主題與權杖。它也會註冊 `TKCode.Arc.Core`(`UseArcCore()`),因此你不需要自行呼叫。 | +| `UseArc(...)` | 在應用程式初始化後啟動 Arc 的佈景主題管理員、設計權杖與語言。回呼中列出要啟用的套件與初始佈景主題。 | +| `UseArcControls()` | 註冊 Arc 的控制項、佈景主題與權杖。它也會註冊 `TKCode.Arc.Core`(`UseArcCore()`),因此你不需要自行呼叫。 | +| `WithTheme(...)` | 選用:繪製第一個畫格時使用的佈景主題(明暗、強調色、權杖覆寫)。未呼叫時,Arc 以淺色與 `Neutral` 強調色啟動。請參閱 **佈景主題**。 | | `WithInterFont()`、`WithArcFonts()` | 註冊 Arc 字級系統所設計的字型。若未註冊,文字會退回系統字型。 | -將 `Application` 類別宣告為 `partial`。AtomUI 的原始碼產生器會為它加入啟動程式碼;若該類別不是 partial,建置會以 `ATOMUILOC008` 停止: +`Application` 類別不需要為 Arc 做任何事:不需要特定基底類別,不需要 `partial` 關鍵字,`App.axaml` 中也不需要樣式。 ```csharp -public partial class App : Application +public class App : Application { public override void OnFrameworkInitializationCompleted() { @@ -94,6 +90,8 @@ public partial class App : Application } ``` +Arc 也為其範本用到的 Avalonia 控制項提供樣式(`ScrollViewer`、`ScrollBar`、`ItemsControl`、`PathIcon`、文字選取控點與快顯視窗宿主)。捲軸與 Arc 的 `ScrollArea` 一致:細長,指標停留在檢視上或捲動時顯示,靜止時隱藏;設定 `ScrollViewer.AllowAutoHide="False"` 可讓它們一直顯示。如果 `App.axaml` 已包含 `FluentTheme` 或 `SimpleTheme`,Arc 會把這些控制項交給該佈景主題。 + ## 3. 在 XAML 中使用控制項 Arc 的 XAML 命名空間是 `https://github.com/AIDotNet/TKCode.Arc`;所有控制項、權杖資源與標記延伸都位於其中。 @@ -148,7 +146,7 @@ Arc 自有的圖示字形(`{x:Static arc:ArcIcons.Check}` 以及另外 186 個 ### 區塊與資料表格 -`TKCode.Arc.Blocks`(頁面層級區塊)、`TKCode.Arc.AdvancedCharts`(`DataGrid`)與 `TKCode.Arc.Special`(AI 動畫講解)是獨立的套件。依需要加入,並將它們註冊在 `UseArc()` 旁邊: +`TKCode.Arc.Blocks`(頁面層級區塊)、`TKCode.Arc.AdvancedCharts`(`DataGrid`)與 `TKCode.Arc.Special`(AI 動畫講解)是獨立的套件。依需要加入,並在 `UseArc(...)` 中註冊: ```bash dotnet add package TKCode.Arc.Blocks --version 0.3.1-preview.6 @@ -156,41 +154,57 @@ dotnet add package TKCode.Arc.AdvancedCharts --version 0.3.1-preview.6 dotnet add package TKCode.Arc.Special --version 0.3.1-preview.6 dotnet add package TKCode.Arc.Workflow --version 0.3.1-preview.6 dotnet add package TKCode.Arc.Themes.Pixel --version 0.3.1-preview.6 # 選用:像素風主題(UseArcPixelTheme()) -dotnet add package TKCode.Arc.Themes.Corners --version 0.3.1-preview.6 # optional: global corner style (WithArcCorners()) +dotnet add package TKCode.Arc.Themes.Corners --version 0.3.1-preview.6 # 選用:全域圓角風格(UseArcCorners()) dotnet add package TKCode.Arc.Mobile --version 0.3.1-preview.6 # 選用:行動端控制項,尚未在真機上驗證(UseArcMobile()) ``` ```csharp -builder.UseDesktopControls(); -builder.UseArc(); -builder.UseArcBlocks(); // TKCode.Arc.Blocks -builder.UseArcAdvancedCharts(); // TKCode.Arc.AdvancedCharts -builder.UseArcSpecial(); // TKCode.Arc.Special +.UseArc(arc => arc + .UseArcControls() + .UseArcBlocks() // TKCode.Arc.Blocks + .UseArcAdvancedCharts() // TKCode.Arc.AdvancedCharts + .UseArcSpecial() // TKCode.Arc.Special + .UseArcWorkflow() // TKCode.Arc.Workflow + .UseArcPixelTheme() // TKCode.Arc.Themes.Pixel + .UseArcCorners() // TKCode.Arc.Themes.Corners + .UseArcMobile()) // TKCode.Arc.Mobile ``` +每個套件會註冊它所依賴的套件(例如 Blocks 會註冊控制項套件),重複註冊同一個套件不會有任何作用。 + ### 語言 -應用程式語言由 AtomUI 處理。若要提供多種語言,請在建構器中宣告它們;本圖庫以這種方式提供英文、簡體中文與繁體中文: +應用程式語言由 Arc 處理(命名空間 `TKCode.Arc.Localization`)。若要提供多種語言,請在 `UseArc(...)` 中宣告:第一個參數是啟動時的語言,第二個參數是應用程式提供的語言。本圖庫以這種方式提供英文、簡體中文與繁體中文: ```csharp -builder.UseLanguages(LanguageTags.EnUS, [LanguageTags.EnUS, LanguageTags.ZhCN, LanguageTags.ZhTW]); +.UseArc(arc => arc + .UseArcControls() + .UseLanguages(LanguageTags.EnUS, [LanguageTags.EnUS, LanguageTags.ZhCN, LanguageTags.ZhTW])) + +// 之後,例如在設定頁中: +ArcLanguage.Set(LanguageTags.ZhCN); ``` +`ArcLanguage.Current` 是目前的語言,`ArcLanguage.Culture` 是格式化數字與日期所用的地區文化,切換語言後會引發 `ArcLanguage.Changed`。自帶內建文字的套件(`DataGrid`、工作流程畫布)提供英文、簡體中文與繁體中文。如果某個已啟用的套件沒有涵蓋宣告的某種語言,啟動會失敗,並列出缺少的文字。 + Arc 控制項為其內建文字提供英文預設值(例如 "Show password" 或 "Clear search")。控制項以屬性或標籤物件的形式公開這些文字,因此你可以在自己的資源中加以翻譯。 ## 發佈 - **NativeAOT 與裁剪。** 這些函式庫相容於裁剪與 AOT,且不使用反射。若要把桌面應用程式發佈為單一的自含檔案,請使用 `-p:PublishAot=true`;若不使用 AOT,則使用 `-p:PublishSingleFile=true --self-contained`。 +- **.NET 11 SDK 與 NativeAOT。** .NET 11 SDK 的最佳化 AOT 發佈會遺漏所有 Avalonia 12.1 應用程式編譯後的 XAML,應用程式啟動時會擲出 `No precompiled XAML found`。請在應用程式的專案檔中關閉它:`false`。 +- **授權。** Arc、它的 Foundation 套件與其他相依項目(包括 Avalonia)都採用 MIT 等寬鬆授權,閉源應用程式以單一檔案或 NativeAOT 發佈時沒有 LGPL 義務。 - **全球化。** 不要啟用 `InvariantGlobalization`。輸入框、選取器與圖表中的數字、貨幣與日期格式化需要地區文化資料。 - **macOS。** 儲存庫中的 `tools/macos` 指令碼示範了如何把圖庫包裝為已簽署並完成公證的 `.app`;同樣的流程也適用於你的應用程式。 ## 從原始碼建置 -建置儲存庫需要 .NET 11 SDK(見 `global.json`)以及 .NET 10 執行階段,AtomUI 的建置工作即在其上執行: +建置儲存庫需要 .NET 11 SDK(見 `global.json`)以及 .NET 10 執行階段(測試與圖庫在 `net10.0` 上執行): ```bash dotnet build TKCode.Arc.slnx dotnet test tests/TKCode.Arc.Core.Tests +dotnet test tests/TKCode.Arc.Foundation.Tests dotnet test tests/TKCode.Arc.Tests dotnet run --project gallery/TKCode.Arc.Gallery.Desktop -f net10.0 ``` @@ -199,16 +213,19 @@ dotnet run --project gallery/TKCode.Arc.Gallery.Desktop -f net10.0 | 症狀 | 原因 | 解決方法 | |---|---|---| -| Arc 控制項沒有樣式或顯示為空白 | 未在 `UseAtomUI(...)` 中呼叫 `UseArc()` | 將它註冊在 `UseDesktopControls()` 之後。 | -| 建置錯誤 `ATOMUILOC008` | `Application` 類別不是 `partial` | 將其宣告為 `public partial class App : Application`。 | +| Arc 控制項沒有樣式或顯示為空白 | 未在 `UseArc(...)` 中呼叫 `UseArcControls()` | 將它加到 `UseArc(...)` 的回呼中。 | +| 啟動時擲出 `InvalidOperationException` 並列出缺少的文字 | `UseLanguages` 中的某種語言沒有被已啟用的套件涵蓋 | 只宣告這些套件提供的語言,或補上缺少的翻譯。 | +| NativeAOT 建置中出現 `No precompiled XAML found` | .NET 11 SDK 的最佳化發佈 | 設定 `false`。 | +| `ThemeSwitcher` 切換時沒有轉場效果 | 視窗沒有覆疊層 | 在視窗範本中放一個 `EnableOverlayLayer="True"` 的 `VisualLayerManager`。 | | 標題與程式碼使用系統字型 | 未註冊字型 | 在 `AppBuilder` 上呼叫 `WithInterFont()` 與 `WithArcFonts()`。 | | 數字或日期格式錯誤,或格式化時擲出例外 | 啟用了 `InvariantGlobalization` | 從專案檔中移除它。 | | `'Button' is an ambiguous reference` | `TKCode.Arc` 與 `Avalonia.Controls` 同時被匯入 | 為其中一個設定別名,如上所示。 | | 關於 Avalonia 版本的還原警告 | 其他套件引入了不同版本的 Avalonia | 在應用程式中明確參照 Avalonia `12.1.4`。 | -| 找不到 Blocks、`DataGrid` 或 Special 型別 | 未參照該套件或未註冊 | 加入 `TKCode.Arc.Blocks`、`TKCode.Arc.AdvancedCharts` 或 `TKCode.Arc.Special`,並呼叫 `UseArcBlocks()`、`UseArcAdvancedCharts()` 或 `UseArcSpecial()`。 | +| 找不到 Blocks、`DataGrid` 或 Special 型別 | 未參照該套件或未註冊 | 加入 `TKCode.Arc.Blocks`、`TKCode.Arc.AdvancedCharts` 或 `TKCode.Arc.Special`,並在 `UseArc(...)` 中呼叫 `UseArcBlocks()`、`UseArcAdvancedCharts()` 或 `UseArcSpecial()`。 | ## 下一步 - **主題**:淺色與深色模式、八種強調色以及設計權杖。 - **動態效果**:動態效果等級與減少動態效果。 - 元件頁面:從按鈕、輸入框與卡片開始,它們是大多數畫面的基礎。 +- **更新日誌**:*從 0.3 遷移到 0.4* 列出了使用以 AtomUI 為基礎的 0.3 版本的應用程式需要做的全部變更。 diff --git a/gallery/TKCode.Arc.Gallery/Guides/Introduction/en-US.md b/gallery/TKCode.Arc.Gallery/Guides/Introduction/en-US.md index 3e9d98d1..563ea860 100644 --- a/gallery/TKCode.Arc.Gallery/Guides/Introduction/en-US.md +++ b/gallery/TKCode.Arc.Gallery/Guides/Introduction/en-US.md @@ -5,7 +5,7 @@ title: Introduction # Introduction TKCode.Arc brings [Arc](https://uiarc.dev) to Avalonia: the same component groups, the same visual language and the -same spring motion, built as native Avalonia controls on top of [AtomUI](https://github.com/AtomUI/AtomUI). It ports +same spring motion, built as native Avalonia controls that depend on nothing but Avalonia and MIT packages. It ports the free Arc components (110 components in 22 groups) and adds page-level blocks, a spreadsheet-style data grid and a streaming Markdown renderer, so a desktop product can be assembled from one consistent kit instead of a patchwork of styles. @@ -107,8 +107,9 @@ as small, fast-starting single files. | Package | Contents | Where | |---|---|---| -| `TKCode.Arc` | The components (`UseArc()`), the streaming `Markdown` control and `CodeBlock`. Brings in `TKCode.Arc.Core`. | NuGet | -| `TKCode.Arc.Core` | ArcDesign tokens and palette, the spring motion engine, shared primitives, 187 built-in Lucide glyphs. | NuGet | +| `TKCode.Arc` | The components (`UseArcControls()`), the streaming `Markdown` control and `CodeBlock`. Brings in `TKCode.Arc.Core`. | NuGet | +| `TKCode.Arc.Core` | ArcDesign tokens and palette, the spring motion engine, shared primitives, 187 built-in Lucide glyphs. Brings in `TKCode.Arc.Foundation`. | NuGet | +| `TKCode.Arc.Foundation` | The theme, token, language and package runtime (`UseArc(...)`, `ArcTheme`, `ArcLanguage`) and its source generator. | NuGet (0.4 and later) | | `TKCode.Arc.Fonts` | Geist and Geist Mono (`WithArcFonts()`). | NuGet | | `TKCode.Arc.Icons.Lucide` | The full Lucide set (2155 icons): the `LucideIcon` control and the `LucideGeometry` markup extension. | NuGet | | `TKCode.Arc.Blocks` | Page-level blocks (`UseArcBlocks()`). | NuGet | @@ -120,8 +121,8 @@ as small, fast-starting single files. ## Requirements and platforms - **.NET**: the packages target `net8.0`, `net10.0` and `net11.0`. -- **Avalonia and AtomUI**: Avalonia `12.1.4` and AtomUI `6.2.8`, pinned to exact versions. Use the same versions in - your application. +- **Avalonia**: Avalonia `12.1.4`, pinned to the exact version. Use the same version in your application. Arc has no + other UI framework dependency. - **Platforms**: desktop on Windows, macOS and Linux. The gallery also runs in the browser through its WebAssembly host. - **Rendering of text and code**: Markdown and syntax highlighting use StreamMark, which the `TKCode.Arc` package @@ -152,8 +153,9 @@ TKCode.Arc is in preview. Versions follow [Semantic Versioning](https://semver.o ## License TKCode.Arc is released under the MIT license. It ports Arc (MIT) and uses Lucide (ISC), the Geist fonts (OFL), -StreamMark, TextMateSharp, Oniguruma and Markdig, and depends on AtomUI (LGPL-3.0). `THIRD-PARTY-NOTICES.md` in the -repository lists them all. +StreamMark, TextMateSharp, Oniguruma and Markdig. Every package in the dependency chain, Avalonia included, is under a +permissive license, so closed-source applications can ship Arc in a single file or a NativeAOT build. +`THIRD-PARTY-NOTICES.md` in the repository lists them all. ## Next steps diff --git a/gallery/TKCode.Arc.Gallery/Guides/Introduction/zh-CN.md b/gallery/TKCode.Arc.Gallery/Guides/Introduction/zh-CN.md index 9004e954..b433e042 100644 --- a/gallery/TKCode.Arc.Gallery/Guides/Introduction/zh-CN.md +++ b/gallery/TKCode.Arc.Gallery/Guides/Introduction/zh-CN.md @@ -1,11 +1,11 @@ --- title: 介绍 -source-hash: ef5b7ba19b53 +source-hash: f9ec58832e79 --- # 介绍 -TKCode.Arc 将 [Arc](https://uiarc.dev) 带到 Avalonia:相同的组件分组、相同的视觉语言和相同的弹簧动效,以原生 Avalonia 控件的形式构建在 [AtomUI](https://github.com/AtomUI/AtomUI) 之上。它移植了免费的 Arc 组件(22 个分组中的 110 个组件),并新增页面级区块、电子表格风格的数据表格和流式 Markdown 渲染器,使桌面产品可以由一套统一的工具包组装而成,而不是拼凑各种样式。 +TKCode.Arc 将 [Arc](https://uiarc.dev) 带到 Avalonia:相同的组件分组、相同的视觉语言和相同的弹簧动效,以原生 Avalonia 控件的形式构建,除 Avalonia 和 MIT 包之外没有其他依赖。它移植了免费的 Arc 组件(22 个分组中的 110 个组件),并新增页面级区块、电子表格风格的数据表格和流式 Markdown 渲染器,使桌面产品可以由一套统一的工具包组装而成,而不是拼凑各种样式。 本图库即是该库的文档。每个组件页面都根据组件源码编写,展示可交互的实时示例,并列出生成的 API 参考。 @@ -95,8 +95,9 @@ TKCode.Arc 将 [Arc](https://uiarc.dev) 带到 Avalonia:相同的组件分组 | 包 | 内容 | 来源 | |---|---|---| -| `TKCode.Arc` | 组件(`UseArc()`)、流式 `Markdown` 控件和 `CodeBlock`。引入 `TKCode.Arc.Core`。 | NuGet | -| `TKCode.Arc.Core` | ArcDesign 设计令牌与调色板、弹簧动效引擎、共享基础组件、187 个内置 Lucide 字形。 | NuGet | +| `TKCode.Arc` | 组件(`UseArcControls()`)、流式 `Markdown` 控件和 `CodeBlock`。引入 `TKCode.Arc.Core`。 | NuGet | +| `TKCode.Arc.Core` | ArcDesign 设计令牌与调色板、弹簧动效引擎、共享基础组件、187 个内置 Lucide 字形。引入 `TKCode.Arc.Foundation`。 | NuGet | +| `TKCode.Arc.Foundation` | 主题、令牌、语言与包注册的运行时(`UseArc(...)`、`ArcTheme`、`ArcLanguage`)及其源代码生成器。 | NuGet(0.4 起) | | `TKCode.Arc.Fonts` | Geist 与 Geist Mono(`WithArcFonts()`)。 | NuGet | | `TKCode.Arc.Icons.Lucide` | 完整的 Lucide 集(2155 个图标):`LucideIcon` 控件和 `LucideGeometry` 标记扩展。 | NuGet | | `TKCode.Arc.Blocks` | 页面级区块(`UseArcBlocks()`)。 | NuGet | @@ -108,7 +109,7 @@ TKCode.Arc 将 [Arc](https://uiarc.dev) 带到 Avalonia:相同的组件分组 ## 要求与平台 - **.NET**:这些包面向 `net8.0`、`net10.0` 和 `net11.0`。 -- **Avalonia 与 AtomUI**:Avalonia `12.1.4` 与 AtomUI `6.2.8`,锁定为精确版本。请在你的应用中使用相同的版本。 +- **Avalonia**:Avalonia `12.1.4`,锁定为精确版本。请在你的应用中使用相同的版本。Arc 没有其他 UI 框架依赖。 - **平台**:Windows、macOS 和 Linux 桌面端。图库也可以通过其 WebAssembly 宿主在浏览器中运行。 - **文本与代码的渲染**:Markdown 与语法高亮使用 StreamMark,由 `TKCode.Arc` 包引入。 @@ -126,7 +127,7 @@ TKCode.Arc 处于预览阶段。版本遵循[语义化版本](https://semver.org ## 许可证 -TKCode.Arc 以 MIT 许可证发布。它移植了 Arc(MIT),使用 Lucide(ISC)、Geist 字体(OFL)、StreamMark、TextMateSharp、Oniguruma 和 Markdig,并依赖 AtomUI(LGPL-3.0)。仓库中的 `THIRD-PARTY-NOTICES.md` 列出了全部内容。 +TKCode.Arc 以 MIT 许可证发布。它移植了 Arc(MIT),使用 Lucide(ISC)、Geist 字体(OFL)、StreamMark、TextMateSharp、Oniguruma 和 Markdig。依赖链中的每个包(包括 Avalonia)都采用宽松许可,闭源应用可以把 Arc 以单文件或 NativeAOT 方式发布。仓库中的 `THIRD-PARTY-NOTICES.md` 列出了全部内容。 ## 下一步 diff --git a/gallery/TKCode.Arc.Gallery/Guides/Introduction/zh-TW.md b/gallery/TKCode.Arc.Gallery/Guides/Introduction/zh-TW.md index 1eb05188..fc177cca 100644 --- a/gallery/TKCode.Arc.Gallery/Guides/Introduction/zh-TW.md +++ b/gallery/TKCode.Arc.Gallery/Guides/Introduction/zh-TW.md @@ -1,11 +1,11 @@ --- title: 介紹 -source-hash: ef5b7ba19b53 +source-hash: f9ec58832e79 --- # 介紹 -TKCode.Arc 將 [Arc](https://uiarc.dev) 帶到 Avalonia:相同的元件分組、相同的視覺語言與相同的彈簧動態效果,以原生 Avalonia 控制項的形式建構在 [AtomUI](https://github.com/AtomUI/AtomUI) 之上。它移植了免費的 Arc 元件(22 個分組中的 110 個元件),並新增頁面層級的區塊、試算表風格的資料表格,以及串流式 Markdown 渲染器,使桌面產品可以由一套一致的工具組組裝而成,而不是拼湊各種樣式。 +TKCode.Arc 將 [Arc](https://uiarc.dev) 帶到 Avalonia:相同的元件分組、相同的視覺語言與相同的彈簧動態效果,以原生 Avalonia 控制項的形式建構,除 Avalonia 與 MIT 套件之外沒有其他相依項目。它移植了免費的 Arc 元件(22 個分組中的 110 個元件),並新增頁面層級的區塊、試算表風格的資料表格,以及串流式 Markdown 渲染器,使桌面產品可以由一套一致的工具組組裝而成,而不是拼湊各種樣式。 本圖庫即是該程式庫的文件。每個元件頁面都根據元件原始碼撰寫,展示可互動的即時範例,並列出產生的 API 參考。 @@ -95,8 +95,9 @@ TKCode.Arc 將 [Arc](https://uiarc.dev) 帶到 Avalonia:相同的元件分組 | 套件 | 內容 | 來源 | |---|---|---| -| `TKCode.Arc` | 元件(`UseArc()`)、串流式 `Markdown` 控制項與 `CodeBlock`。引入 `TKCode.Arc.Core`。 | NuGet | -| `TKCode.Arc.Core` | ArcDesign 設計權杖與調色盤、彈簧動態效果引擎、共用基礎元件、187 個內建 Lucide 字形。 | NuGet | +| `TKCode.Arc` | 元件(`UseArcControls()`)、串流式 `Markdown` 控制項與 `CodeBlock`。引入 `TKCode.Arc.Core`。 | NuGet | +| `TKCode.Arc.Core` | ArcDesign 設計權杖與調色盤、彈簧動態效果引擎、共用基礎元件、187 個內建 Lucide 字形。引入 `TKCode.Arc.Foundation`。 | NuGet | +| `TKCode.Arc.Foundation` | 主題、權杖、語言與套件註冊的執行階段(`UseArc(...)`、`ArcTheme`、`ArcLanguage`)及其原始碼產生器。 | NuGet(0.4 起) | | `TKCode.Arc.Fonts` | Geist 與 Geist Mono(`WithArcFonts()`)。 | NuGet | | `TKCode.Arc.Icons.Lucide` | 完整的 Lucide 集(2155 個圖示):`LucideIcon` 控制項與 `LucideGeometry` 標記延伸。 | NuGet | | `TKCode.Arc.Blocks` | 頁面層級區塊(`UseArcBlocks()`)。 | NuGet | @@ -108,7 +109,7 @@ TKCode.Arc 將 [Arc](https://uiarc.dev) 帶到 Avalonia:相同的元件分組 ## 需求與平台 - **.NET**:這些套件以 `net8.0`、`net10.0` 與 `net11.0` 為目標。 -- **Avalonia 與 AtomUI**:Avalonia `12.1.4` 與 AtomUI `6.2.8`,固定為確切版本。請在你的應用程式中使用相同的版本。 +- **Avalonia**:Avalonia `12.1.4`,固定為確切版本。請在你的應用程式中使用相同的版本。Arc 沒有其他 UI 框架相依項目。 - **平台**:Windows、macOS 與 Linux 桌面版。圖庫也可透過其 WebAssembly 主機在瀏覽器中執行。 - **文字與程式碼的渲染**:Markdown 與語法醒目提示使用 StreamMark,由 `TKCode.Arc` 套件引入。 @@ -126,7 +127,7 @@ TKCode.Arc 處於預覽階段。版本遵循[語意化版本](https://semver.org ## 授權 -TKCode.Arc 以 MIT 授權發佈。它移植了 Arc(MIT),並使用 Lucide(ISC)、Geist 字型(OFL)、StreamMark、TextMateSharp、Oniguruma 與 Markdig,且依賴 AtomUI(LGPL-3.0)。儲存庫中的 `THIRD-PARTY-NOTICES.md` 列出了全部內容。 +TKCode.Arc 以 MIT 授權發佈。它移植了 Arc(MIT),並使用 Lucide(ISC)、Geist 字型(OFL)、StreamMark、TextMateSharp、Oniguruma 與 Markdig。相依鏈中的每個套件(包括 Avalonia)都採用寬鬆授權,閉源應用程式可以把 Arc 以單一檔案或 NativeAOT 方式發佈。儲存庫中的 `THIRD-PARTY-NOTICES.md` 列出了全部內容。 ## 下一步 diff --git a/gallery/TKCode.Arc.Gallery/Guides/Motion/en-US.md b/gallery/TKCode.Arc.Gallery/Guides/Motion/en-US.md index 0db126bd..b864aa6a 100644 --- a/gallery/TKCode.Arc.Gallery/Guides/Motion/en-US.md +++ b/gallery/TKCode.Arc.Gallery/Guides/Motion/en-US.md @@ -38,7 +38,7 @@ ArcMotion.SetLevel(mainWindow, ArcMotionLevel.Reduced); ``` -The theme can set a level for the whole application too, through `WithArc(arc => arc.MotionMode(...))` (see +The theme can set a level for the whole application too, through `ArcTheme.Apply(t => t.MotionLevel(...))` (see **Theming**). The effective level of a control is the lower of the two: the inherited `ArcMotion.Level` and the theme's motion mode. A window set to `Full` in a theme set to `Reduced` runs reduced motion. diff --git a/gallery/TKCode.Arc.Gallery/Guides/Motion/zh-CN.md b/gallery/TKCode.Arc.Gallery/Guides/Motion/zh-CN.md index e8a99e19..7bd6344e 100644 --- a/gallery/TKCode.Arc.Gallery/Guides/Motion/zh-CN.md +++ b/gallery/TKCode.Arc.Gallery/Guides/Motion/zh-CN.md @@ -1,6 +1,6 @@ --- title: 动效 -source-hash: 09a655958638 +source-hash: 57d2e62976e2 --- # 动效 @@ -34,7 +34,7 @@ ArcMotion.SetLevel(mainWindow, ArcMotionLevel.Reduced); ``` -主题也可以为整个应用设置级别,方法是通过 `WithArc(arc => arc.MotionMode(...))`(参见 **主题**)。控件的实际级别取两者中较低的那个:继承的 `ArcMotion.Level` 与主题的动效模式。在设为 `Reduced` 的主题中,即使窗口设为 `Full`,也会以减弱动效运行。 +主题也可以为整个应用设置级别,方法是通过 `ArcTheme.Apply(t => t.MotionLevel(...))`(参见 **主题**)。控件的实际级别取两者中较低的那个:继承的 `ArcMotion.Level` 与主题的动效模式。在设为 `Reduced` 的主题中,即使窗口设为 `Full`,也会以减弱动效运行。 库本身不会读取操作系统的减少动效偏好。请在应用程序中提供设置(图库的外观面板提供 Full、Reduced 和 Off 三个选项),将其与用户的其他偏好一起保存,并在启动时应用到主窗口。 diff --git a/gallery/TKCode.Arc.Gallery/Guides/Motion/zh-TW.md b/gallery/TKCode.Arc.Gallery/Guides/Motion/zh-TW.md index 099fccbb..264fa8b6 100644 --- a/gallery/TKCode.Arc.Gallery/Guides/Motion/zh-TW.md +++ b/gallery/TKCode.Arc.Gallery/Guides/Motion/zh-TW.md @@ -1,6 +1,6 @@ --- title: 動態效果 -source-hash: 09a655958638 +source-hash: 57d2e62976e2 --- # 動態效果 @@ -34,7 +34,7 @@ ArcMotion.SetLevel(mainWindow, ArcMotionLevel.Reduced); ``` -主題也可以為整個應用程式設定級別,方法是透過 `WithArc(arc => arc.MotionMode(...))`(參見 **主題**)。控制項的實際級別取兩者中較低的那個:繼承的 `ArcMotion.Level` 與主題的動態效果模式。在設為 `Reduced` 的主題中,即使視窗設為 `Full`,也會以減弱動態效果執行。 +主題也可以為整個應用程式設定級別,方法是透過 `ArcTheme.Apply(t => t.MotionLevel(...))`(參見 **主題**)。控制項的實際級別取兩者中較低的那個:繼承的 `ArcMotion.Level` 與主題的動態效果模式。在設為 `Reduced` 的主題中,即使視窗設為 `Full`,也會以減弱動態效果執行。 程式庫本身不會讀取作業系統的減少動態效果偏好。請在應用程式中提供設定(圖庫的外觀面板提供 Full、Reduced 和 Off 三個選項),將其與使用者的其他偏好一起儲存,並在啟動時套用到主視窗。 diff --git a/gallery/TKCode.Arc.Gallery/Guides/Theming/en-US.md b/gallery/TKCode.Arc.Gallery/Guides/Theming/en-US.md index dcd46b97..49a1a364 100644 --- a/gallery/TKCode.Arc.Gallery/Guides/Theming/en-US.md +++ b/gallery/TKCode.Arc.Gallery/Guides/Theming/en-US.md @@ -13,53 +13,52 @@ your own views and controls. ## How the theme is built -TKCode.Arc sits on AtomUI's theme system: - -1. **AtomUI's theme manager** (`IThemeManager`) owns the active theme and applies it to the application. A theme is a - `ThemeConfig` built with `ThemeConfigBuilder`. -2. **Algorithms** decide the appearance: `ThemeAlgorithm.Default` for light, plus `ThemeAlgorithm.Dark` for dark. -3. **The ArcDesign identity** holds Arc's own tokens. `WithArc(...)` on the builder sets the accent, overrides tokens - and sets the motion mode for that identity. +The theme runtime is part of `TKCode.Arc.Foundation`, which `TKCode.Arc` brings in: + +1. **The theme manager** (`ArcThemeManager`, one per application, created by `UseArc(...)`) owns the active theme and + publishes its tokens to the application's resources. +2. **The theme state** (`ArcThemeState`) is everything a theme is computed from: the appearance (`Light`, `Dark` or + `System`), the accent seed, the motion level, theme presets and token overrides. It is immutable; an + `ArcThemeBuilder` describes a change, and what a change does not mention stays as it was. +3. **The ArcDesign identity** holds Arc's own tokens: colors, radii, type, spacing and motion. 4. **Control tokens** hold the geometry of individual controls (paddings, widths, offsets) and colors that do not depend on the accent. -When a theme is applied, every token is recalculated for the appearance and the accent, and every control that reads a -token updates in place: no page reload, no restart. +When a theme is applied, every token is recalculated for the appearance and the accent, only the values that changed +are written, and every control that reads a token updates in place: no page reload, no restart. ## Light and dark -Build a theme config and apply it through the theme manager: +Set the theme the application starts with in `UseArc(...)`, and change it later with `ArcTheme.Apply`: ```csharp -using AtomUI; -using AtomUI.Theme; -using AtomUI.Theme.Algorithms; -using AtomUI.Theme.Configuration; using TKCode.Arc; -public static async Task ApplyAppearanceAsync(bool dark, ArcAccentPreset accent) -{ - var builder = new ThemeConfigBuilder(); - builder = dark - ? builder.WithAlgorithms(ThemeAlgorithm.Default, ThemeAlgorithm.Dark) - : builder.WithAlgorithms(ThemeAlgorithm.Default); - builder = builder.WithArc(arc => arc.Accent(accent)); - - var manager = Application.Current!.GetThemeManager()!; - await manager.ApplyThemeAsync(new ThemeRequest(IThemeManager.DEFAULT_THEME_ID, builder.Build(), - ThemeTransitionReason.UserRequest)); -} +// Program.cs: the first frame is drawn with this theme. +.UseArc(arc => arc + .UseArcControls() + .WithTheme(t => t.Appearance(ArcAppearance.System).Accent(ArcAccentPreset.Violet))) + +// At run time: only the appearance changes, the accent and every other setting stay. +public static void ApplyAppearance(bool dark) + => ArcTheme.Apply(t => t.Appearance(dark ? ArcAppearance.Dark : ArcAppearance.Light)); ``` -Keep the user's choice (light, dark, or following the system) in your settings store and apply it at start-up, before -the main window is shown, so the first frame already has the right appearance. The gallery's Appearance panel works -exactly this way. +`ArcAppearance.System` follows the operating system's light or dark setting and switches with it. `ArcTheme.State` is +the current state, and `ArcThemeManager.Current` the manager: its `IsDark` says which appearance is showing, and +`StateChanged` is raised after every change. `ArcThemeManager.ApplyAsync` computes the tokens on a background thread and +publishes them on the UI thread; await it, and never block on it (`.Wait()`, `.Result`) on the UI thread, which +deadlocks. A change that names an unknown token or an invalid value throws `ArcThemeException` listing every problem, +and nothing is changed. + +Keep the user's choice (light, dark, or following the system) in your settings store and pass it to `WithTheme` at +start-up, so the first frame already has the right appearance. The gallery's Appearance panel works exactly this way. ### The ThemeSwitcher control For a header or a toolbar, `ThemeSwitcher` is a ready-made light and dark switch with Arc's page transitions (`Reveal`, `Eclipse`, `Split` and `Rise`, set with `Variant`). Pressing it raises `ThemeChangeRequested`. If no handler marks the event -handled, the switcher toggles `ThemeAlgorithm.Dark` itself and keeps the rest of the current theme. Handle the event +handled, the switcher changes the appearance through `ArcThemeManager` itself and keeps the rest of the current theme. Handle the event when the application owns the appearance, for example to save the choice: ```csharp @@ -67,10 +66,13 @@ themeSwitcher.ThemeChangeRequested += async (_, e) => { e.Handled = true; await settings.SaveAppearanceAsync(e.Next); - await ApplyAppearanceAsync(e.Next == ThemeSwitcherAppearance.Dark, settings.Accent); + ApplyAppearance(e.Next == ThemeSwitcherAppearance.Dark); }; ``` +The transition draws on the window's overlay layer. A window whose template has no `VisualLayerManager` with +`EnableOverlayLayer="True"` switches without it. + ## Accents Arc ships eight accent presets. The accent drives buttons, selection, focus rings, switches, sliders, chart series and @@ -148,7 +150,7 @@ When none of the presets matches the brand, pass the brand color itself. Arc der from it in the OKLCH color space, so the steps stay perceptually even: ```csharp -builder = builder.WithArc(arc => arc.Accent(Color.Parse("#0E7C86"))); +ArcTheme.Apply(t => t.Accent(Color.Parse("#0E7C86"))); ``` Check the result in both appearances. Very light seeds (yellow, lime) make poor text colors on white; prefer a preset @@ -265,15 +267,21 @@ like an Arc field, read these instead of copying the numbers. ## Changing tokens -`WithArc` can override the final value of any ArcDesign token, and set the motion mode for the whole theme: +`Token(name, value)` overrides the final value of an ArcDesign token, `Token(identity, name, value)` one of a control's +tokens, and `MotionLevel` sets the motion level for the whole theme: ```csharp -builder = builder.WithArc(arc => arc +ArcTheme.Apply(t => t .Accent(ArcAccentPreset.Blue) .Token(nameof(ArcDesignTokenKind.Danger), "#D92D20") - .MotionMode(ArcMotionLevel.Reduced)); + .Token("Switch", "TrackWidth", "50") + .MotionLevel(ArcMotionLevel.Reduced)); ``` +Values are text, parsed for the token's type (`#RRGGBB`, `12`, `4,8`, `120ms`, an enum member). The identity of a control +token is its control name; the mobile controls use `MobileButton`, `MobileCheckbox`, `MobileDialog`, `MobileSwitch` and +`MobileToast`. `ClearTokens()` removes every override. + Change as little as possible. A token change applies everywhere: overriding `Danger` changes every error message, destructive button and failed status in the application. Overrides of colors should be checked in both appearances, because the override is not recalculated for dark mode. @@ -287,14 +295,14 @@ highlight behind a hovered menu row) and the radii that some controls compute wh Setting only the four leaves popups, hover backgrounds and checkboxes round. The `TKCode.Arc.Themes.Corners` package sets all of them with one call and changes nothing but corners: colors, borders, -shadows and motion stay as they are. +shadows and motion stay as they are. Register it with `UseArcCorners()` in `UseArc(...)`, then: ```csharp using TKCode.Arc.Themes.Corners; -builder = builder.WithArc(arc => arc +ArcTheme.Apply(t => t .Accent(ArcAccentPreset.Blue) - .Corners(ArcCornerStyle.Square, ArcCornerScope.All)); + .Corners(ArcCornerStyle.Square)); ``` | Style | Result | @@ -305,14 +313,16 @@ builder = builder.WithArc(arc => arc - **Pills and circles.** Radii of 999 and more (pill buttons, round avatars, dots) keep their shape under `Scale`. Only `Square` flattens them, so a square style turns round avatars into squares. Use `Scale` to keep them round. -- **Scope.** `ArcCornerScope` names the control packages the application registers: `Controls`, `Blocks`, `Special`, - `AdvancedCharts`, `Workflow`, or `All`. Naming a package the application has not registered fails the whole theme - change, so list only the ones you use. -- **One call.** `WithArc` replaces what an earlier `WithArc` call wrote, so put the accent and the corner style in the same - call, as above. `builder.WithArcCorners(style, scope)` is a shortcut for a call that only sets corners. -- **Scroll bars.** The thumb of AtomUI's scroll bar sits inside every Arc popup and scrolling surface, so the - corner style also changes the scroll bar thumb of the whole application, including AtomUI controls that are not Arc's. -- **Pixel.** The Pixel style is built on the same package and is always square. +- **Presets.** A corner style is a theme preset (`ArcThemePreset`): a named set of token overrides for light and dark. + `Corners(style)` is `Preset(ArcCornerPresets.Create(style))`; `ArcCornerPresets.Square` and `ArcCornerPresets.Half` + are ready-made. A preset covers every Arc package and skips the ones the application has not registered. +- **Replacing.** A corner style fills one slot (`ArcCornerPresets.Slot`), so the next `Corners(...)` replaces it and + `Corners(ArcCornerStyle.Default)` removes it. Your own `Token(...)` overrides are applied after presets and win. +- **Scroll bars.** Scroll bars keep their thin pill shape under every corner style. +- **Pixel.** The `TKCode.Arc.Themes.Pixel` package (`UseArcPixelTheme()`) adds the Pixel style: square corners, hard + shadows, a limited palette for light and dark and pixel fonts. Apply it with `ArcTheme.Apply(t => t.Pixel())` and + go back with `t.RemovePreset(ArcPixelPresets.Slot)`. It has its own slot, so a corner style chosen after it changes + its corners. ## Styling a single control diff --git a/gallery/TKCode.Arc.Gallery/Guides/Theming/zh-CN.md b/gallery/TKCode.Arc.Gallery/Guides/Theming/zh-CN.md index f2c08bfd..e6843242 100644 --- a/gallery/TKCode.Arc.Gallery/Guides/Theming/zh-CN.md +++ b/gallery/TKCode.Arc.Gallery/Guides/Theming/zh-CN.md @@ -1,6 +1,6 @@ --- title: 主题 -source-hash: 8f4d6266eb37 +source-hash: 65d9e8b78c7c --- # 主题 @@ -11,55 +11,51 @@ TKCode.Arc 中的每一种颜色、圆角、阴影、字体大小和动效时长 ## 主题的构建方式 -TKCode.Arc 构建在 AtomUI 的主题系统之上: +主题运行时属于 `TKCode.Arc.Foundation`,由 `TKCode.Arc` 一并引入: -1. **AtomUI 的主题管理器**(`IThemeManager`)持有当前主题,并将其应用到应用程序。主题是由 `ThemeConfigBuilder` 构建的 `ThemeConfig`。 -2. **算法**决定外观:`ThemeAlgorithm.Default` 用于浅色,`ThemeAlgorithm.Dark` 用于深色。 -3. **ArcDesign 标识**持有 Arc 自己的令牌。构建器上的 `WithArc(...)` 为该标识设置强调色、覆盖令牌并设置动效模式。 +1. **主题管理器**(`ArcThemeManager`,每个应用一个,由 `UseArc(...)` 创建)持有当前主题,并把它的令牌发布到应用的资源中。 +2. **主题状态**(`ArcThemeState`)是计算主题所需的全部输入:外观(`Light`、`Dark` 或 `System`)、强调色种子、动效级别、主题预设和令牌覆盖。它不可变;`ArcThemeBuilder` 描述一次更改,更改中没有提到的部分保持原样。 +3. **ArcDesign 标识**持有 Arc 自己的令牌:颜色、圆角、字体、间距和动效。 4. **控件令牌**持有单个控件的几何参数(内边距、宽度、偏移量),以及不依赖强调色的颜色。 -应用主题时,每个令牌都会根据外观和强调色重新计算,读取令牌的每个控件都会原地更新:无需重新加载页面,也无需重启。 +应用主题时,每个令牌都会根据外观和强调色重新计算,只写入发生变化的值,读取令牌的每个控件都会原地更新:无需重新加载页面,也无需重启。 ## 浅色与深色 -构建主题配置,并通过主题管理器应用: +在 `UseArc(...)` 中设置应用启动时的主题,之后用 `ArcTheme.Apply` 更改: ```csharp -using AtomUI; -using AtomUI.Theme; -using AtomUI.Theme.Algorithms; -using AtomUI.Theme.Configuration; using TKCode.Arc; -public static async Task ApplyAppearanceAsync(bool dark, ArcAccentPreset accent) -{ - var builder = new ThemeConfigBuilder(); - builder = dark - ? builder.WithAlgorithms(ThemeAlgorithm.Default, ThemeAlgorithm.Dark) - : builder.WithAlgorithms(ThemeAlgorithm.Default); - builder = builder.WithArc(arc => arc.Accent(accent)); - - var manager = Application.Current!.GetThemeManager()!; - await manager.ApplyThemeAsync(new ThemeRequest(IThemeManager.DEFAULT_THEME_ID, builder.Build(), - ThemeTransitionReason.UserRequest)); -} +// Program.cs:第一帧就使用这个主题绘制。 +.UseArc(arc => arc + .UseArcControls() + .WithTheme(t => t.Appearance(ArcAppearance.System).Accent(ArcAccentPreset.Violet))) + +// 运行时:只改变外观,强调色和其他设置保持不变。 +public static void ApplyAppearance(bool dark) + => ArcTheme.Apply(t => t.Appearance(dark ? ArcAppearance.Dark : ArcAppearance.Light)); ``` -将用户的选择(浅色、深色或跟随系统)保存在设置存储中,并在启动时、主窗口显示之前应用,使第一帧就具有正确的外观。图库的外观面板正是这样工作的。 +`ArcAppearance.System` 跟随操作系统的浅色或深色设置,并随之切换。`ArcTheme.State` 是当前状态,`ArcThemeManager.Current` 是管理器:它的 `IsDark` 表示当前显示的是哪种外观,每次更改后都会引发 `StateChanged`。`ArcThemeManager.ApplyAsync` 在后台线程计算令牌,在 UI 线程发布;请 await 它,切勿在 UI 线程上阻塞等待它(`.Wait()`、`.Result`),否则会死锁。如果一次更改中有未知的令牌或无效的值,会抛出 `ArcThemeException` 并列出全部问题,且不做任何更改。 + +将用户的选择(浅色、深色或跟随系统)保存在设置存储中,并在启动时传给 `WithTheme`,使第一帧就具有正确的外观。图库的外观面板正是这样工作的。 ### ThemeSwitcher 控件 -对于页头或工具栏,`ThemeSwitcher` 是现成的浅色/深色切换控件,带有 Arc 的页面过渡效果(通过 `Variant` 设置 `Reveal`、`Eclipse`、`Split` 和 `Rise`)。按下时会触发 `ThemeChangeRequested`。如果没有处理程序将事件标记为已处理,切换器会自行切换到 `ThemeAlgorithm.Dark`,并保留当前主题的其余部分。当应用程序自己掌管外观时(例如需要保存用户的选择),请处理该事件: +对于页头或工具栏,`ThemeSwitcher` 是现成的浅色/深色切换控件,带有 Arc 的页面过渡效果(通过 `Variant` 设置 `Reveal`、`Eclipse`、`Split` 和 `Rise`)。按下时会触发 `ThemeChangeRequested`。如果没有处理程序将事件标记为已处理,切换器会自行通过 `ArcThemeManager` 更改外观,并保留当前主题的其余部分。当应用程序自己掌管外观时(例如需要保存用户的选择),请处理该事件: ```csharp themeSwitcher.ThemeChangeRequested += async (_, e) => { e.Handled = true; await settings.SaveAppearanceAsync(e.Next); - await ApplyAppearanceAsync(e.Next == ThemeSwitcherAppearance.Dark, settings.Accent); + ApplyAppearance(e.Next == ThemeSwitcherAppearance.Dark); }; ``` +过渡效果绘制在窗口的覆盖层上。如果窗口模板中没有 `EnableOverlayLayer="True"` 的 `VisualLayerManager`,切换时就没有过渡。 + ## 强调色 Arc 提供八种强调色预设。强调色驱动按钮、选中状态、焦点环、开关、滑块、图表系列和链接;其他部分保持中性,因此更换强调色只会改变品牌色,不会改变布局或文字的对比度。 @@ -133,7 +129,7 @@ Arc 提供八种强调色预设。强调色驱动按钮、选中状态、焦点 当所有预设都不符合品牌时,直接传入品牌色本身。Arc 会在 OKLCH 色彩空间中由它推导出浅色与深色的强调色系,使各个色阶在视觉上保持均匀: ```csharp -builder = builder.WithArc(arc => arc.Accent(Color.Parse("#0E7C86"))); +ArcTheme.Apply(t => t.Accent(Color.Parse("#0E7C86"))); ``` 请在两种外观下检查结果。非常浅的种子色(黄色、青柠色)在白色背景上作为文字颜色效果很差;这类情况下请优先使用预设,或使用品牌色的较深色调。 @@ -243,15 +239,18 @@ var accent = ArcTokens.GetColor(chart, ArcDesignTokenKind.Accent); ## 更改令牌 -`WithArc` 可以覆盖任意 ArcDesign 令牌的最终值,并为整个主题设置动效模式: +`Token(name, value)` 覆盖某个 ArcDesign 令牌的最终值,`Token(identity, name, value)` 覆盖某个控件的令牌,`MotionLevel` 为整个主题设置动效级别: ```csharp -builder = builder.WithArc(arc => arc +ArcTheme.Apply(t => t .Accent(ArcAccentPreset.Blue) .Token(nameof(ArcDesignTokenKind.Danger), "#D92D20") - .MotionMode(ArcMotionLevel.Reduced)); + .Token("Switch", "TrackWidth", "50") + .MotionLevel(ArcMotionLevel.Reduced)); ``` +值是文本,按令牌的类型解析(`#RRGGBB`、`12`、`4,8`、`120ms`、枚举成员名)。控件令牌的标识就是控件名;移动端控件使用 `MobileButton`、`MobileCheckbox`、`MobileDialog`、`MobileSwitch` 和 `MobileToast`。`ClearTokens()` 会移除全部覆盖。 + 尽量少改。令牌的修改会应用到所有地方:覆盖 `Danger` 会改变应用中每一条错误提示、每一个破坏性按钮和每一个失败状态。颜色的覆盖值应在两种外观下检查,因为深色模式不会重新计算该覆盖值。 ## 圆角风格 @@ -260,14 +259,15 @@ Arc 的圆角偏大:控件 18、面板 26、表面 34。如果产品想要更 令牌。这四个只是最上层:模板内部使用的圆角阶梯、约 300 个控件属性各自的圆角令牌(复选框的方框、Combobox 的选项、 悬停菜单行背后的高亮),以及某些控件在动画中自己计算的圆角,都是另外的值。只改那四个,弹层、悬停背景和复选框仍然是圆的。 -`TKCode.Arc.Themes.Corners` 包用一次调用改掉全部,并且只改圆角:颜色、描边、阴影和动效保持不变。 +`TKCode.Arc.Themes.Corners` 包用一次调用改掉全部,并且只改圆角:颜色、描边、阴影和动效保持不变。在 `UseArc(...)` 中用 +`UseArcCorners()` 注册它,然后: ```csharp using TKCode.Arc.Themes.Corners; -builder = builder.WithArc(arc => arc +ArcTheme.Apply(t => t .Accent(ArcAccentPreset.Blue) - .Corners(ArcCornerStyle.Square, ArcCornerScope.All)); + .Corners(ArcCornerStyle.Square)); ``` | 风格 | 结果 | @@ -278,13 +278,15 @@ builder = builder.WithArc(arc => arc - **药丸与圆形。** 半径达到 999 及以上的(药丸按钮、圆形头像、圆点)在 `Scale` 下保持形状,只有 `Square` 会把它们变平, 所以直角风格会把圆形头像变成方形。想保留圆形,请用 `Scale`。 -- **范围。** `ArcCornerScope` 指明应用注册了哪些控件包:`Controls`、`Blocks`、`Special`、`AdvancedCharts`、`Workflow`, - 或 `All`。点名了应用没有注册的包,整次主题切换会失败,所以只列实际使用的。 -- **一次调用。** 后一次 `WithArc` 会替换前一次写入的内容,所以像上面这样把强调色和圆角风格放在同一次调用里。 - 只设置圆角时,可以用 `builder.WithArcCorners(style, scope)` 简写。 -- **滚动条。** AtomUI 滚动条的滑块出现在每个 Arc 弹层和滚动区域里,所以圆角风格也会改变整个应用的滚动条滑块, - 包括不属于 Arc 的 AtomUI 控件。 -- **Pixel。** Pixel 风格建立在同一个包上,始终是直角。 +- **预设。** 圆角风格是一个主题预设(`ArcThemePreset`):一组带名字、分浅色与深色的令牌覆盖。`Corners(style)` 等于 + `Preset(ArcCornerPresets.Create(style))`;`ArcCornerPresets.Square` 和 `ArcCornerPresets.Half` 是现成的。预设覆盖所有 + Arc 包,并跳过应用没有注册的包。 +- **替换。** 圆角风格占用一个槽位(`ArcCornerPresets.Slot`),所以下一次 `Corners(...)` 会替换它, + `Corners(ArcCornerStyle.Default)` 会移除它。你自己的 `Token(...)` 覆盖在预设之后应用,优先生效。 +- **滚动条。** 无论哪种圆角风格,滚动条都保持细长的胶囊形状。 +- **Pixel。** `TKCode.Arc.Themes.Pixel` 包(`UseArcPixelTheme()`)提供 Pixel 风格:直角、硬阴影、浅色与深色各一套有限 + 调色板以及像素字体。用 `ArcTheme.Apply(t => t.Pixel())` 应用,用 `t.RemovePreset(ArcPixelPresets.Slot)` 恢复。它有 + 自己的槽位,所以在它之后选择的圆角风格会改变它的圆角。 ## 单个控件的样式 diff --git a/gallery/TKCode.Arc.Gallery/Guides/Theming/zh-TW.md b/gallery/TKCode.Arc.Gallery/Guides/Theming/zh-TW.md index 1f71e5e8..59d8c810 100644 --- a/gallery/TKCode.Arc.Gallery/Guides/Theming/zh-TW.md +++ b/gallery/TKCode.Arc.Gallery/Guides/Theming/zh-TW.md @@ -1,6 +1,6 @@ --- title: 主題 -source-hash: 8f4d6266eb37 +source-hash: 65d9e8b78c7c --- # 主題 @@ -11,55 +11,51 @@ TKCode.Arc 中的每一種顏色、圓角、陰影、字型大小和動態效果 ## 主題的建構方式 -TKCode.Arc 建構在 AtomUI 的主題系統之上: +主題執行階段屬於 `TKCode.Arc.Foundation`,由 `TKCode.Arc` 一併引入: -1. **AtomUI 的主題管理器**(`IThemeManager`)持有目前的主題,並將其套用到應用程式。主題是由 `ThemeConfigBuilder` 建立的 `ThemeConfig`。 -2. **演算法**決定外觀:`ThemeAlgorithm.Default` 用於淺色,`ThemeAlgorithm.Dark` 用於深色。 -3. **ArcDesign 識別**持有 Arc 自己的權杖。建構器上的 `WithArc(...)` 為該識別設定強調色、覆寫權杖並設定動態效果模式。 +1. **主題管理器**(`ArcThemeManager`,每個應用程式一個,由 `UseArc(...)` 建立)持有目前的主題,並把它的權杖發佈到應用程式的資源中。 +2. **主題狀態**(`ArcThemeState`)是計算主題所需的全部輸入:外觀(`Light`、`Dark` 或 `System`)、強調色種子、動態效果級別、主題預設集和權杖覆寫。它不可變;`ArcThemeBuilder` 描述一次變更,變更中沒有提到的部分維持原樣。 +3. **ArcDesign 識別**持有 Arc 自己的權杖:顏色、圓角、字型、間距和動態效果。 4. **控制項權杖**持有個別控制項的幾何參數(內距、寬度、位移),以及不依賴強調色的顏色。 -套用主題時,每個權杖都會依外觀和強調色重新計算,讀取權杖的每個控制項都會就地更新:不需要重新載入頁面,也不需要重新啟動。 +套用主題時,每個權杖都會依外觀和強調色重新計算,只寫入有變化的值,讀取權杖的每個控制項都會就地更新:不需要重新載入頁面,也不需要重新啟動。 ## 淺色與深色 -建立主題設定,並透過主題管理器套用: +在 `UseArc(...)` 中設定應用程式啟動時的主題,之後用 `ArcTheme.Apply` 變更: ```csharp -using AtomUI; -using AtomUI.Theme; -using AtomUI.Theme.Algorithms; -using AtomUI.Theme.Configuration; using TKCode.Arc; -public static async Task ApplyAppearanceAsync(bool dark, ArcAccentPreset accent) -{ - var builder = new ThemeConfigBuilder(); - builder = dark - ? builder.WithAlgorithms(ThemeAlgorithm.Default, ThemeAlgorithm.Dark) - : builder.WithAlgorithms(ThemeAlgorithm.Default); - builder = builder.WithArc(arc => arc.Accent(accent)); - - var manager = Application.Current!.GetThemeManager()!; - await manager.ApplyThemeAsync(new ThemeRequest(IThemeManager.DEFAULT_THEME_ID, builder.Build(), - ThemeTransitionReason.UserRequest)); -} +// Program.cs:第一個畫面就使用這個主題繪製。 +.UseArc(arc => arc + .UseArcControls() + .WithTheme(t => t.Appearance(ArcAppearance.System).Accent(ArcAccentPreset.Violet))) + +// 執行時:只改變外觀,強調色和其他設定維持不變。 +public static void ApplyAppearance(bool dark) + => ArcTheme.Apply(t => t.Appearance(dark ? ArcAppearance.Dark : ArcAppearance.Light)); ``` -將使用者的選擇(淺色、深色或跟隨系統)保存在設定儲存區中,並在啟動時、主視窗顯示之前套用,使第一個畫面就具有正確的外觀。圖庫的外觀面板正是這樣運作的。 +`ArcAppearance.System` 跟隨作業系統的淺色或深色設定,並隨之切換。`ArcTheme.State` 是目前的狀態,`ArcThemeManager.Current` 是管理器:它的 `IsDark` 表示目前顯示的是哪種外觀,每次變更後都會引發 `StateChanged`。`ArcThemeManager.ApplyAsync` 在背景執行緒計算權杖,在 UI 執行緒發佈;請 await 它,切勿在 UI 執行緒上封鎖等待它(`.Wait()`、`.Result`),否則會死結。如果一次變更中有未知的權杖或無效的值,會擲出 `ArcThemeException` 並列出全部問題,且不做任何變更。 + +將使用者的選擇(淺色、深色或跟隨系統)保存在設定儲存區中,並在啟動時傳給 `WithTheme`,使第一個畫面就具有正確的外觀。圖庫的外觀面板正是這樣運作的。 ### ThemeSwitcher 控制項 -對於頁首或工具列,`ThemeSwitcher` 是現成的淺色/深色切換開關,帶有 Arc 的頁面轉場效果(透過 `Variant` 設定 `Reveal`、`Eclipse`、`Split` 和 `Rise`)。按下時會觸發 `ThemeChangeRequested`。如果沒有處理常式將事件標示為已處理,切換器會自行切換 `ThemeAlgorithm.Dark`,並保留目前主題的其餘部分。當應用程式自行掌管外觀時(例如要儲存使用者的選擇),請處理該事件: +對於頁首或工具列,`ThemeSwitcher` 是現成的淺色/深色切換開關,帶有 Arc 的頁面轉場效果(透過 `Variant` 設定 `Reveal`、`Eclipse`、`Split` 和 `Rise`)。按下時會觸發 `ThemeChangeRequested`。如果沒有處理常式將事件標示為已處理,切換器會自行透過 `ArcThemeManager` 變更外觀,並保留目前主題的其餘部分。當應用程式自行掌管外觀時(例如要儲存使用者的選擇),請處理該事件: ```csharp themeSwitcher.ThemeChangeRequested += async (_, e) => { e.Handled = true; await settings.SaveAppearanceAsync(e.Next); - await ApplyAppearanceAsync(e.Next == ThemeSwitcherAppearance.Dark, settings.Accent); + ApplyAppearance(e.Next == ThemeSwitcherAppearance.Dark); }; ``` +轉場效果繪製在視窗的覆疊層上。如果視窗範本中沒有 `EnableOverlayLayer="True"` 的 `VisualLayerManager`,切換時就沒有轉場。 + ## 強調色 Arc 提供八種強調色預設。強調色驅動按鈕、選取狀態、焦點環、開關、滑桿、圖表數列和連結;其他部分維持中性,因此更換強調色只會改變品牌色,不會改變版面配置或文字的對比度。 @@ -133,7 +129,7 @@ Arc 提供八種強調色預設。強調色驅動按鈕、選取狀態、焦點 當所有預設都不符合品牌時,直接傳入品牌色本身。Arc 會在 OKLCH 色彩空間中由它推導出淺色與深色的強調色系,使各個色階在視覺上保持均勻: ```csharp -builder = builder.WithArc(arc => arc.Accent(Color.Parse("#0E7C86"))); +ArcTheme.Apply(t => t.Accent(Color.Parse("#0E7C86"))); ``` 請在兩種外觀下檢查結果。非常淺的種子色(黃色、萊姆綠)在白色背景上作為文字顏色效果很差;這類情況請優先使用預設,或使用品牌色的較深色調。 @@ -243,15 +239,18 @@ var accent = ArcTokens.GetColor(chart, ArcDesignTokenKind.Accent); ## 更改權杖 -`WithArc` 可以覆寫任何 ArcDesign 權杖的最終值,並為整個主題設定動態效果模式: +`Token(name, value)` 覆寫某個 ArcDesign 權杖的最終值,`Token(identity, name, value)` 覆寫某個控制項的權杖,`MotionLevel` 為整個主題設定動態效果級別: ```csharp -builder = builder.WithArc(arc => arc +ArcTheme.Apply(t => t .Accent(ArcAccentPreset.Blue) .Token(nameof(ArcDesignTokenKind.Danger), "#D92D20") - .MotionMode(ArcMotionLevel.Reduced)); + .Token("Switch", "TrackWidth", "50") + .MotionLevel(ArcMotionLevel.Reduced)); ``` +值是文字,依權杖的型別解析(`#RRGGBB`、`12`、`4,8`、`120ms`、列舉成員名稱)。控制項權杖的識別就是控制項名稱;行動端控制項使用 `MobileButton`、`MobileCheckbox`、`MobileDialog`、`MobileSwitch` 和 `MobileToast`。`ClearTokens()` 會移除全部覆寫。 + 請盡量少改。權杖的修改會套用到所有地方:覆寫 `Danger` 會改變應用程式中每一則錯誤訊息、每一個破壞性按鈕和每一個失敗狀態。顏色的覆寫值應在兩種外觀下檢查,因為深色模式不會重新計算該覆寫值。 ## 圓角風格 @@ -260,14 +259,15 @@ Arc 的圓角偏大:控制項 18、面板 26、表面 34。如果產品想要 權杖。這四個只是最上層:範本內部使用的圓角階梯、約 300 個控制項屬性各自的圓角權杖(核取方塊的方框、Combobox 的選項、 懸停選單列後面的高亮),以及某些控制項在動畫中自己計算的圓角,都是另外的值。只改那四個,彈層、懸停背景和核取方塊仍然是圓的。 -`TKCode.Arc.Themes.Corners` 套件用一次呼叫改掉全部,並且只改圓角:顏色、描邊、陰影和動效保持不變。 +`TKCode.Arc.Themes.Corners` 套件用一次呼叫改掉全部,並且只改圓角:顏色、描邊、陰影和動效保持不變。在 `UseArc(...)` 中用 +`UseArcCorners()` 註冊它,然後: ```csharp using TKCode.Arc.Themes.Corners; -builder = builder.WithArc(arc => arc +ArcTheme.Apply(t => t .Accent(ArcAccentPreset.Blue) - .Corners(ArcCornerStyle.Square, ArcCornerScope.All)); + .Corners(ArcCornerStyle.Square)); ``` | 風格 | 結果 | @@ -278,13 +278,15 @@ builder = builder.WithArc(arc => arc - **藥丸與圓形。** 半徑達到 999 及以上的(藥丸按鈕、圓形頭像、圓點)在 `Scale` 下保持形狀,只有 `Square` 會把它們變平, 所以直角風格會把圓形頭像變成方形。想保留圓形,請用 `Scale`。 -- **範圍。** `ArcCornerScope` 指明應用程式註冊了哪些控制項套件:`Controls`、`Blocks`、`Special`、`AdvancedCharts`、`Workflow`, - 或 `All`。點名了應用程式沒有註冊的套件,整次主題切換會失敗,所以只列實際使用的。 -- **一次呼叫。** 後一次 `WithArc` 會取代前一次寫入的內容,所以像上面這樣把強調色和圓角風格放在同一次呼叫裡。 - 只設定圓角時,可以用 `builder.WithArcCorners(style, scope)` 簡寫。 -- **捲軸。** AtomUI 捲軸的滑塊出現在每個 Arc 彈層和捲動區域裡,所以圓角風格也會改變整個應用程式的捲軸滑塊, - 包括不屬於 Arc 的 AtomUI 控制項。 -- **Pixel。** Pixel 風格建立在同一個套件上,始終是直角。 +- **預設集。** 圓角風格是一個主題預設集(`ArcThemePreset`):一組有名稱、分淺色與深色的權杖覆寫。`Corners(style)` 等於 + `Preset(ArcCornerPresets.Create(style))`;`ArcCornerPresets.Square` 和 `ArcCornerPresets.Half` 是現成的。預設集涵蓋所有 + Arc 套件,並略過應用程式沒有註冊的套件。 +- **取代。** 圓角風格佔用一個槽位(`ArcCornerPresets.Slot`),所以下一次 `Corners(...)` 會取代它, + `Corners(ArcCornerStyle.Default)` 會移除它。你自己的 `Token(...)` 覆寫在預設集之後套用,優先生效。 +- **捲軸。** 無論哪種圓角風格,捲軸都維持細長的膠囊形狀。 +- **Pixel。** `TKCode.Arc.Themes.Pixel` 套件(`UseArcPixelTheme()`)提供 Pixel 風格:直角、硬陰影、淺色與深色各一套有限 + 調色盤以及像素字型。用 `ArcTheme.Apply(t => t.Pixel())` 套用,用 `t.RemovePreset(ArcPixelPresets.Slot)` 還原。它有 + 自己的槽位,所以在它之後選擇的圓角風格會改變它的圓角。 ## 單一控制項的樣式 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..58751350 --- /dev/null +++ b/src/TKCode.Arc.Core/Theme/Base/PopupHostBaseTheme.axaml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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.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/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/ButtonTests.cs b/tests/TKCode.Arc.Tests/Buttons/ButtonTests.cs index 6e31fb90..fb2d9ec2 100644 --- a/tests/TKCode.Arc.Tests/Buttons/ButtonTests.cs +++ b/tests/TKCode.Arc.Tests/Buttons/ButtonTests.cs @@ -1,5 +1,6 @@ using Avalonia.Headless.XUnit; using Avalonia.Interactivity; +using Avalonia.VisualTree; namespace TKCode.Arc.Tests.Buttons; @@ -27,6 +28,28 @@ public void Loading_Swallows_Clicks_But_Keeps_Focus() } } + [AvaloniaFact] + public void Spinner_Is_Built_On_First_Load_And_Sized_By_Token() + { + var button = new Button { Content = "Save" }; + var window = TestHelpers.Show(button); + try + { + button.GetVisualDescendants().OfType().ShouldBeEmpty(); + button.IsLoading = true; + window.UpdateLayout(); + var spinner = TestHelpers.Descendant(button, "PART_Spinner"); + spinner.IsVisible.ShouldBeTrue(); + spinner.Width.ShouldBe(16); + button.IsLoading = false; + spinner.IsVisible.ShouldBeFalse(); + } + finally + { + window.Close(); + } + } + [AvaloniaFact] public void Template_Uses_Token_Heights_For_Sizes() { 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