diff --git a/examples/Avalonia.Preview/App.cs b/examples/Avalonia.Preview/App.cs new file mode 100644 index 0000000..a883644 --- /dev/null +++ b/examples/Avalonia.Preview/App.cs @@ -0,0 +1,65 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Layout; +using Avalonia.Markup.Xaml; +using Avalonia.Media; +using Avalonia.Themes.Fluent; +using AvaloniaDemo.Components.UI; + +namespace AvaloniaPreview; + +public class App : Application +{ + // The frame around the preview. + public static Border? Host { get; private set; } + + public override void Initialize() + { + Styles.Add(new FluentTheme()); + RequestedThemeVariant = Program.Theme; + ShellTheme.EnsureInitialized(); + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is ISingleViewApplicationLifetime single) + { + Host = new Border { Padding = new Thickness(24), Child = Load(Program.Component) }; + Host.Token(Border.BackgroundProperty, ShellToken.Background); + single.MainView = Host; + } + + base.OnFrameworkInitializationCompleted(); + } + + // A preview is Previews/.axaml: the markup a docs page shows, in a panel that places it. + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "The previews are in this assembly, which the project file keeps whole.")] + private static Control Load(string name) + { + if (name.Length > 0 && name.All(c => char.IsAsciiLetterOrDigit(c) || c == '-')) + { + try + { + return (Control)AvaloniaXamlLoader.Load(new Uri($"avares://AvaloniaPreview/Previews/{name}.axaml")); + } + catch (Exception) + { + // No such file: say so below. + } + } + + var message = new TextBlock + { + Text = name.Length == 0 ? "Add ?c= to the address to pick a preview." : $"There is no preview named \"{name}\".", + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + TextWrapping = TextWrapping.Wrap, + }; + message.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground); + return message; + } +} diff --git a/examples/Avalonia.Preview/AvaloniaPreview.csproj b/examples/Avalonia.Preview/AvaloniaPreview.csproj new file mode 100644 index 0000000..446e874 --- /dev/null +++ b/examples/Avalonia.Preview/AvaloniaPreview.csproj @@ -0,0 +1,40 @@ + + + + net10.0-browser + Exe + true + enable + disable + AvaloniaPreview + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/Avalonia.Preview/Previews/button.axaml b/examples/Avalonia.Preview/Previews/button.axaml new file mode 100644 index 0000000..8be4641 --- /dev/null +++ b/examples/Avalonia.Preview/Previews/button.axaml @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/examples/Avalonia.Preview/Previews/dialog.axaml b/examples/Avalonia.Preview/Previews/dialog.axaml new file mode 100644 index 0000000..73d8ccd --- /dev/null +++ b/examples/Avalonia.Preview/Previews/dialog.axaml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/Avalonia.Preview/Previews/switch.axaml b/examples/Avalonia.Preview/Previews/switch.axaml new file mode 100644 index 0000000..3fa2438 --- /dev/null +++ b/examples/Avalonia.Preview/Previews/switch.axaml @@ -0,0 +1,6 @@ + + + + diff --git a/examples/Avalonia.Preview/Previews/tabs.axaml b/examples/Avalonia.Preview/Previews/tabs.axaml new file mode 100644 index 0000000..d62d6b5 --- /dev/null +++ b/examples/Avalonia.Preview/Previews/tabs.axaml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/examples/Avalonia.Preview/Program.cs b/examples/Avalonia.Preview/Program.cs new file mode 100644 index 0000000..1a15b45 --- /dev/null +++ b/examples/Avalonia.Preview/Program.cs @@ -0,0 +1,71 @@ +using System; +using System.Globalization; +using System.Runtime.InteropServices.JavaScript; +using System.Runtime.Versioning; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Browser; +using Avalonia.Styling; +using Avalonia.Threading; +using AvaloniaDemo.Components.UI; + +[assembly: SupportedOSPlatform("browser")] + +namespace AvaloniaPreview; + +internal static partial class Program +{ + // The preview to show, from ?c=button. + public static string Component { get; private set; } = ""; + + // From ?theme=dark or ?theme=light; anything else follows the browser. + public static ThemeVariant Theme { get; private set; } = ThemeVariant.Default; + + // main.js passes the page's address as the first argument. + private static Task Main(string[] args) + { + var query = args.Length > 0 && Uri.TryCreate(args[0], UriKind.Absolute, out var uri) ? uri.Query : ""; + Component = Read(query, "c"); + Theme = ParseTheme(Read(query, "theme")); + + // Without ICU data the only culture is the invariant one, whose currency sign is a + // placeholder. Money in the previews reads as dollars instead. + var culture = (CultureInfo)CultureInfo.InvariantCulture.Clone(); + culture.NumberFormat.CurrencySymbol = "$"; + CultureInfo.CurrentCulture = CultureInfo.DefaultThreadCurrentCulture = culture; + return BuildAvaloniaApp().WithInterFont().StartBrowserAppAsync("out"); + } + + public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure(); + + // Called by main.js when the page hosting the frame changes its theme. + [JSExport] + internal static void SetTheme(string theme) + { + Theme = ParseTheme(theme); + if (Application.Current is null) return; + Dispatcher.UIThread.Post(() => ShellTheme.SetTheme(Theme)); + } + + // The height the preview asks for, with the frame's padding, so a page can size its frame. + [JSExport] + internal static double ContentHeight() => + App.Host?.Child is { } preview ? Math.Ceiling(preview.DesiredSize.Height + App.Host.Padding.Top + App.Host.Padding.Bottom) : 0; + + private static ThemeVariant ParseTheme(string theme) => theme switch + { + "dark" => ThemeVariant.Dark, + "light" => ThemeVariant.Light, + _ => ThemeVariant.Default, + }; + + private static string Read(string query, string key) + { + foreach (var pair in query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var parts = pair.Split('=', 2); + if (parts[0] == key) return parts.Length > 1 ? Uri.UnescapeDataString(parts[1]) : ""; + } + return ""; + } +} diff --git a/examples/Avalonia.Preview/wwwroot/app.css b/examples/Avalonia.Preview/wwwroot/app.css new file mode 100644 index 0000000..e822fae --- /dev/null +++ b/examples/Avalonia.Preview/wwwroot/app.css @@ -0,0 +1,62 @@ +/* The colours match ShellTheme's Background, Border and MutedForeground. */ +:root { + color-scheme: light dark; + --background: #fcfcfc; + --border: #e4e4e4; + --muted-foreground: #737373; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --background: #0a0a0a; + --border: #2a2a2a; + --muted-foreground: #a3a3a3; + } +} + +:root[data-theme="dark"] { + --background: #0a0a0a; + --border: #2a2a2a; + --muted-foreground: #a3a3a3; +} + +html, body { + height: 100%; + margin: 0; + overflow: hidden; + background: var(--background); +} + +#out { + position: relative; + height: 100%; +} + +.avalonia-splash { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: var(--background); + transition: opacity 150ms ease-out; +} + +/* Avalonia adds this class when the first frame is ready. */ +.avalonia-splash.splash-close { + opacity: 0; + pointer-events: none; +} + +.spinner { + width: 24px; + height: 24px; + border: 2px solid var(--border); + border-top-color: var(--muted-foreground); + border-radius: 50%; + animation: spin 700ms linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} diff --git a/examples/Avalonia.Preview/wwwroot/index.html b/examples/Avalonia.Preview/wwwroot/index.html new file mode 100644 index 0000000..8438a96 --- /dev/null +++ b/examples/Avalonia.Preview/wwwroot/index.html @@ -0,0 +1,22 @@ + + + + + + ShellUI Native preview + + + + +
+
+
+
+
+ + + diff --git a/examples/Avalonia.Preview/wwwroot/main.js b/examples/Avalonia.Preview/wwwroot/main.js new file mode 100644 index 0000000..d7a2de4 --- /dev/null +++ b/examples/Avalonia.Preview/wwwroot/main.js @@ -0,0 +1,19 @@ +import { dotnet } from './_framework/dotnet.js'; + +const runtime = await dotnet.create(); +const config = runtime.getConfig(); +const preview = (await runtime.getAssemblyExports(config.mainAssemblyName)).AvaloniaPreview.Program; + +// For the page that hosts the frame, and for tools that measure a preview. +globalThis.shelluiPreview = { + setTheme: (theme) => preview.SetTheme(String(theme)), + contentHeight: () => preview.ContentHeight(), +}; + +// ShellDocs posts the reader's theme to its preview frames when it changes. +window.addEventListener('message', (event) => { + if (event.data?.type === 'shelldocs-theme') preview.SetTheme(String(event.data.theme)); +}); + +await runtime.runMain(config.mainAssemblyName, [globalThis.location.href]); +performance.mark('preview-started'); diff --git a/examples/README.md b/examples/README.md index 96e680e..bb1dcda 100644 --- a/examples/README.md +++ b/examples/README.md @@ -8,6 +8,7 @@ This folder contains example projects demonstrating ShellUI Native components. |---------|----------|-------------| | [MAUI.Demo](./MAUI.Demo/) | .NET MAUI | Complete MAUI app showcasing all components | | [Avalonia.Demo](./Avalonia.Demo/) | Avalonia 12 | Avalonia components as they are ported (Phase 2) | +| [Avalonia.Preview](./Avalonia.Preview/) | Avalonia 12 on WebAssembly | One component per URL, for the live previews on the docs site | | WinUI.Demo | WinUI 3 | *(Coming Soon)* | | WPF.Demo | WPF | *(Coming Soon)* | @@ -38,6 +39,24 @@ dotnet run --project examples/Avalonia.Demo/AvaloniaDemo.csproj Runs on Windows, macOS and Linux. +### Avalonia Preview + +The Avalonia components running in the browser. It compiles the demo's own component files, and +shows the preview named in the address: `?c=button`, with `&theme=dark` or `&theme=light`. + +```bash +dotnet workload install wasm-tools +dotnet run --project examples/Avalonia.Preview/AvaloniaPreview.csproj +dotnet publish examples/Avalonia.Preview/AvaloniaPreview.csproj -c Release +``` + +A preview is one file, `Previews/.axaml`: the markup the docs page shows, inside a panel that +places it. The ones here are samples. The docs site keeps a preview for every example in its own +repository and builds this project with `-p:PreviewsDir=` (`.axaml` files, their +`.axaml.cs` files, and pictures under `assets/`, addressed as `/Assets/`). + +The project is not in the solution, so building the solution doesn't need the workload. + ## Purpose These examples serve as: