diff --git a/.claude/skills/fieldworks-avalonia-ui/references/style-system.md b/.claude/skills/fieldworks-avalonia-ui/references/style-system.md index 00ae845bf9..4a276c5f11 100644 --- a/.claude/skills/fieldworks-avalonia-ui/references/style-system.md +++ b/.claude/skills/fieldworks-avalonia-ui/references/style-system.md @@ -8,7 +8,7 @@ toward the Fluent theme per the migration hub's `architecture-patterns.md` §12. ## Why a global token system (and why it renders headlessly) -`FwAvaloniaApp` adds `new FluentTheme()`, which ships ~14px fonts and ~32px control floors app-wide. +`FwAvaloniaApp` adds Semi core plus Ursa Semi (no Fluent), which ships ~14px fonts and ~32px control floors app-wide. Density/borders must be layered ON TOP of Fluent. The reliable mechanism — proven by a prior agent — is **per-control-tree `.Styles`**, added to each view's own `Styles` collection by its view ctor. App-level `Application.Styles` did **not** apply in the headless test app, so that path is rejected. @@ -42,6 +42,11 @@ sit inside a `Border.fwFieldHost` that supplies the box. directly (not three independently-maintained copies) -- see `Src/Common/FwAvalonia/FwThemeResources.cs`. +**Font family:** Segoe UI, named by `FwUiFontFamily`. `FwUiFont.ApplyTo` registers +bundled Andika and points Semi's font resources at the theme family stack. Named +UI-language fallbacks and the platform default follow it. Data text keeps its +writing-system font. Measure UI text with `FwUiFont.UiFontFamily`. See ADR 0004. + **Control height:** `TextBox`/`ComboBox`/`Button` `MinHeight = 24` (WinForms runs ~21-23px; 24 is the pointer-accessibility floor — see the "Why `DialogMinControlHeight` is 24, not 22" note below — still far from Fluent's ~32px). diff --git a/Build/Src/FwBuildTasks/GenerateTokenKeys.cs b/Build/Src/FwBuildTasks/GenerateTokenKeys.cs index 543d05c5a6..4cb2d7dcc1 100644 --- a/Build/Src/FwBuildTasks/GenerateTokenKeys.cs +++ b/Build/Src/FwBuildTasks/GenerateTokenKeys.cs @@ -38,9 +38,10 @@ public class GenerateTokenKeys : Task { private static readonly XNamespace XamlNs = "http://schemas.microsoft.com/winfx/2006/xaml"; - /// Every top-level ThemeDictionary entry becomes a key constant. + /// Every top-level ThemeDictionary entry becomes a key constant. FontFamily + /// entries are included, so the interface font family has a constant too. private static readonly HashSet TokenElementNames = new HashSet( - new[] { "SolidColorBrush", "Color", "Double", "Thickness", "CornerRadius", "StaticResource" }); + new[] { "SolidColorBrush", "Color", "Double", "Thickness", "CornerRadius", "FontFamily", "StaticResource" }); /// .axaml files whose EVERY x:Key becomes a generated constant. [Required] diff --git a/Docs/architecture/adr/0004-interface-font-controlled-by-the-theme.md b/Docs/architecture/adr/0004-interface-font-controlled-by-the-theme.md new file mode 100644 index 0000000000..3b9e85ba33 --- /dev/null +++ b/Docs/architecture/adr/0004-interface-font-controlled-by-the-theme.md @@ -0,0 +1,22 @@ +# The Avalonia interface font is controlled by the theme + +LT-22854. The `FwUiFontFamily` token names Segoe UI as the default interface +font on Windows. Segoe UI preserves the density of the current interface; the +proposed Andika default has taller line metrics. Line-height tokens remain a +separate decision. + +`FwUiFont.ApplyTo` registers the bundled Andika collection and assigns the +interface font stack to Semi's `DefaultFontFamily`, +`ContentControlThemeFontFamily`, and `SemiFontFamilyRegular` resources. The +stack combines the theme default, fallbacks for the UI language, and the +platform default. It is chosen at startup and applies across the application: +menus and other interface chrome should not change when the project changes. + +The language project's `UiElement` style continues to control UI-writing-system +text drawn in Views; Avalonia interface chrome does not read that style. Data +text continues to use its writing-system font. Andika 7.000 remains bundled +in a private collection; a plain `Andika` family still names the system font +for writing-system data. + +Windows validation must measure density, clipping and script glyph fallback. +Screenshot baselines still depict Andika and must be refreshed on Windows. diff --git a/Docs/architecture/adr/README.md b/Docs/architecture/adr/README.md index 9fd119285e..34c6291971 100644 --- a/Docs/architecture/adr/README.md +++ b/Docs/architecture/adr/README.md @@ -28,3 +28,4 @@ editing the next time the code changes, it belongs in one of the other three. | [0001](0001-alias-semi-semantic-tokens.md) | Alias Semi's semantic tokens instead of inventing a FieldWorks palette | | [0002](0002-whole-tree-token-hygiene-check.md) | token-hygiene.ps1 enforces full conformance across the Avalonia surface, with no grandfathering | | [0003](0003-geometric-assertions-not-pixel-diff.md) | Assert geometry, not pixel diffs, for layout parity | +| [0004](0004-interface-font-controlled-by-the-theme.md) | Control the Avalonia interface font through a theme token; data text keeps its writing-system font | diff --git a/Docs/migration/adjust-the-layout.md b/Docs/migration/adjust-the-layout.md index 0be482beed..83292538be 100644 --- a/Docs/migration/adjust-the-layout.md +++ b/Docs/migration/adjust-the-layout.md @@ -173,8 +173,9 @@ host. Treat difficulty here as a design signal, not a tooling problem. - **The host window is WinForms, not Avalonia.** Title bar, icon, and close button belong to the modal host form. You cannot style them from the `.axaml`, and neither loop above shows them. -- **Converted dialogs render on the Fluent background, not WinForms grey**, and - use the Fluent font family. That is stack-wide, not something your dialog did. +- **Converted dialogs render on the Semi theme background, not WinForms grey**, + and use the FieldWorks interface font (`FwUiFontFamily`, + ADR 0004). That is stack-wide, not something your dialog did. Changing it means editing `DialogTheme.axaml`'s `fwDialogRoot` style, which restyles **every** converted dialog -- treat it as its own change, with dark mode considered. diff --git a/Docs/migration/baseline-screenshots/AddNewSense-02-populated.png b/Docs/migration/baseline-screenshots/AddNewSense-02-populated.png index 1229559c99..c1c1dba0c4 100644 Binary files a/Docs/migration/baseline-screenshots/AddNewSense-02-populated.png and b/Docs/migration/baseline-screenshots/AddNewSense-02-populated.png differ diff --git a/Docs/migration/baseline-screenshots/Chooser-01-initial.png b/Docs/migration/baseline-screenshots/Chooser-01-initial.png index d8c5ce62c1..f9bd92c857 100644 Binary files a/Docs/migration/baseline-screenshots/Chooser-01-initial.png and b/Docs/migration/baseline-screenshots/Chooser-01-initial.png differ diff --git a/Docs/migration/baseline-screenshots/CreateFeature-01-empty.png b/Docs/migration/baseline-screenshots/CreateFeature-01-empty.png index 71f331638a..b42036c24e 100644 Binary files a/Docs/migration/baseline-screenshots/CreateFeature-01-empty.png and b/Docs/migration/baseline-screenshots/CreateFeature-01-empty.png differ diff --git a/Docs/migration/baseline-screenshots/EntryGo-04-row-selected.png b/Docs/migration/baseline-screenshots/EntryGo-04-row-selected.png index b81a600544..77d0522f90 100644 Binary files a/Docs/migration/baseline-screenshots/EntryGo-04-row-selected.png and b/Docs/migration/baseline-screenshots/EntryGo-04-row-selected.png differ diff --git a/Docs/migration/baseline-screenshots/FeatureChooser-01-initial.png b/Docs/migration/baseline-screenshots/FeatureChooser-01-initial.png index bdf5fc3879..f538d21cac 100644 Binary files a/Docs/migration/baseline-screenshots/FeatureChooser-01-initial.png and b/Docs/migration/baseline-screenshots/FeatureChooser-01-initial.png differ diff --git a/Docs/migration/baseline-screenshots/InsertEntry-02-populated.png b/Docs/migration/baseline-screenshots/InsertEntry-02-populated.png index 391f156b37..b761047ea7 100644 Binary files a/Docs/migration/baseline-screenshots/InsertEntry-02-populated.png and b/Docs/migration/baseline-screenshots/InsertEntry-02-populated.png differ diff --git a/Docs/migration/baseline-screenshots/MessageBox-07-warning-icon.png b/Docs/migration/baseline-screenshots/MessageBox-07-warning-icon.png index 828d554fcc..f2a3e9f444 100644 Binary files a/Docs/migration/baseline-screenshots/MessageBox-07-warning-icon.png and b/Docs/migration/baseline-screenshots/MessageBox-07-warning-icon.png differ diff --git a/Docs/migration/baseline-screenshots/MsaCreator-01-initial.png b/Docs/migration/baseline-screenshots/MsaCreator-01-initial.png index 9f8b770baf..85ec64c9e5 100644 Binary files a/Docs/migration/baseline-screenshots/MsaCreator-01-initial.png and b/Docs/migration/baseline-screenshots/MsaCreator-01-initial.png differ diff --git a/Docs/migration/baseline-screenshots/Options-01-initial.png b/Docs/migration/baseline-screenshots/Options-01-initial.png index b1ced1321f..3c9f94b850 100644 Binary files a/Docs/migration/baseline-screenshots/Options-01-initial.png and b/Docs/migration/baseline-screenshots/Options-01-initial.png differ diff --git a/Src/Common/FwAvalonia/Detail/FwFieldControls.cs b/Src/Common/FwAvalonia/Detail/FwFieldControls.cs index 91dcc132f4..ef4f50f6c0 100644 --- a/Src/Common/FwAvalonia/Detail/FwFieldControls.cs +++ b/Src/Common/FwAvalonia/Detail/FwFieldControls.cs @@ -94,7 +94,7 @@ public static double ComputeWsAbbrevColumnWidth(DetailModel model) if (model == null) return FwAvaloniaDensity.WsAbbrevWidth; - var typeface = new Typeface(FontFamily.Default); + var typeface = new Typeface(FwUiFont.UiFontFamily); double widest = 0; foreach (var abbrev in model.Fields.Where(f => f.IsMultiStringRow) .SelectMany(f => f.Values).Select(v => v.WsAbbrev) diff --git a/Src/Common/FwAvalonia/FwAvaloniaApp.cs b/Src/Common/FwAvalonia/FwAvaloniaApp.cs index 71fd4059ef..214fce4c54 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaApp.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaApp.cs @@ -36,6 +36,8 @@ public override void Initialize() // Shared dialog spacing/border tokens, so the Dialog* keys this assembly // publishes as generated constants are actually registered here too. MergeTokens("avares://FwAvaloniaTheme/Tokens/DialogTokens.axaml"); + // After the token merge: the interface font family is itself a token. + FwUiFont.ApplyTo(this); } private void MergeTokens(string avaresUri) diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/AvaloniaDialogHostTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/AvaloniaDialogHostTests.cs index 5fc21d3f0c..1e480edb73 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/AvaloniaDialogHostTests.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/AvaloniaDialogHostTests.cs @@ -46,7 +46,7 @@ private sealed class DisposableControl : Control, IDisposable public void Dispose() => DisposeCount++; } - [Test] + [AvaloniaTest] public void DisposeDialogResources_DisposesDisposableBodyAndViewModel() { var body = new DisposableControl(); @@ -58,7 +58,7 @@ public void DisposeDialogResources_DisposesDisposableBodyAndViewModel() Assert.That(vm.DisposeCount, Is.EqualTo(1), "the VM is disposed if IDisposable (it owns its resources)"); } - [Test] + [AvaloniaTest] public void DisposeDialogResources_NonDisposableViewModel_DoesNotThrow() { Assert.DoesNotThrow(() => AvaloniaDialogHost.DisposeDialogResources(new Control(), new PlainVm())); diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailPreviewTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailPreviewTests.cs index 9dc37d8dc1..b0e45a80f0 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailPreviewTests.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailPreviewTests.cs @@ -58,6 +58,18 @@ public void Preview_WsText_UsesConfiguredFonts() Assert.That(Find(window, "LexemeFormEditor.en").FontFamily.Name, Is.EqualTo("Times New Roman")); } + [AvaloniaTest] + public void Preview_WsText_KeepsWsFonts_WhileLabelsUseTheInterfaceFont() + { + var window = ShowPreview(); + Assert.That(Find(window, "LexemeFormEditor.seh").FontFamily.Name, Is.EqualTo("Charis SIL")); + Assert.That(Find(window, "LexemeFormEditor.en").FontFamily.Name, Is.EqualTo("Times New Roman")); + + var label = window.GetVisualDescendants().OfType() + .First(t => !string.IsNullOrEmpty(t.Text) && t.FindAncestorOfType() == null); + Assert.That(label.FontFamily, Is.EqualTo(SIL.FieldWorks.Common.FwAvalonia.FwUiFont.UiFontFamily)); + } + [AvaloniaTest] public void Preview_MorphTypeChooser_UsesSharedOptionPicker_AndReturnsFocus() { diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/FwUiFontTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/FwUiFontTests.cs new file mode 100644 index 0000000000..bb4811d525 --- /dev/null +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/FwUiFontTests.cs @@ -0,0 +1,282 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Documents; +using Avalonia.Headless.NUnit; +using Avalonia.Media; +using Avalonia.Media.TextFormatting; +using Avalonia.Threading; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia; +using SIL.FieldWorks.Common.FwAvalonia.Detail; +using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; + +namespace FwAvaloniaTests +{ + /// Checks interface font selection, bundled faces and glyph rendering. + [TestFixture] + public class FwUiFontTests + { + private static IGlyphTypeface Resolve(FontWeight weight, FontStyle style = FontStyle.Normal) + { + var bundled = new FontFamily(FwUiFont.CollectionKey + "#Andika"); + Assert.That(FontManager.Current.TryGetGlyphTypeface(new Typeface(bundled, style, weight), out var face), + Is.True, "Andika " + weight + " " + style + " must resolve from the bundled collection"); + return face; + } + + // Shapes the text in the interface font and returns the typeface and glyph id of each + // character, so a missing glyph (id 0) or a wrong face fails with the language named. + private static List<(IGlyphTypeface Face, ushort Glyph)> Shape(FontFamily family, string text) + { + var layout = new TextLayout(text, new Typeface(family), 13, Brushes.Black); + var result = new List<(IGlyphTypeface, ushort)>(); + foreach (var run in layout.TextLines.SelectMany(l => l.TextRuns).OfType()) + foreach (var info in run.GlyphRun.GlyphInfos) + result.Add((run.GlyphRun.GlyphTypeface, info.GlyphIndex)); + return result; + } + + /// UI languages of the Localizations/l10ns satellites, with a sample string in + /// each one's script. These samples exercise the existing named fallbacks. + private static readonly object[] FallbackLanguages = + { + new object[] { "fa", "\u0641\u0627\u06CC\u0644" }, + new object[] { "hi", "\u092B\u093C\u093E\u0907\u0932" }, + new object[] { "ko", "\uD30C\uC77C" }, + new object[] { "te", "\u0C2B\u0C48\u0C32\u0C4D" }, + new object[] { "zh-CN", "\u6587\u4EF6" }, + }; + + private static string Sample(string tag) + => (string)FallbackLanguages.Cast().Single(r => (string)r[0] == tag)[1]; + + private static readonly object[] LanguagesWithoutFallback = + { + new object[] { "az", "\u018Flav\u0259 et \u015F\u011F\u0131" }, + new object[] { "es", "A\u00F1adir \u00BF\u00E1\u00E9\u00ED\u00F3\u00FA\u00FC" }, + new object[] { "fr", "Fichier \u00E0\u00E9\u00E8\u00EA\u00E7\u0153" }, + new object[] { "hu", "F\u00E1jl \u0151\u0171\u0150\u0170" }, + new object[] { "id", "Tambah berkas" }, + new object[] { "ms", "Tambah fail" }, + new object[] { "pt", "A\u00E7\u00E3o \u00E3\u00F5" }, + new object[] { "ru", "\u0424\u0430\u0439\u043B \u0401\u0451" }, + new object[] { "rw", "Dosiye" }, + new object[] { "tr", "Dosyas\u0131 \u011F\u015F\u0130" }, + new object[] { "vi", "T\u1EC7p tin \u1EDF \u1EBF \u1EEF" }, + }; + + private static void AssertUsesInterfaceFont(Control control) + { + Assert.That(control.GetValue(TextElement.FontFamilyProperty), Is.EqualTo(FwUiFont.UiFontFamily), + control.GetType().Name + " must inherit the interface font family"); + var latin = Shape(control.GetValue(TextElement.FontFamilyProperty), "Gloss"); + Assert.That(latin, Is.Not.Empty); + Assert.That(latin.All(g => g.Glyph != 0), Is.True); + } + + [AvaloniaTest] + public void UiFontFamilyToken_NamesSegoeUi() + { + Assert.That(FwUiFont.UiFontFamily.FamilyNames.First(), Is.EqualTo("Segoe UI")); + } + + [AvaloniaTest] + public void PlainFamilyName_IsNotCapturedByThePrivateCollection() + { + Assert.That(new FontFamily("Andika").Key, Is.Null); + } + + [AvaloniaTest] + public void BundledFaces_ResolveForEveryWeightInUse() + { + foreach (var weight in new[] { FontWeight.Normal, FontWeight.SemiBold, FontWeight.Bold }) + Assert.That(Resolve(weight).FamilyName, Does.StartWith("Andika")); + Assert.That(Resolve(FontWeight.Normal, FontStyle.Italic).FamilyName, Is.EqualTo("Andika")); + } + + private static void AssertFallbackLanguageDrawsItsSample(string tag, string sample) + { + var culture = new CultureInfo(tag); + Assert.That(FwUiFont.FallbackFamiliesFor(culture), Is.Not.Empty, tag); + var shaped = Shape(FwUiFont.FamilyFor(culture), sample); + + Assert.That(shaped, Is.Not.Empty, tag); + foreach (var (face, glyph) in shaped) + { + Assert.That(glyph, Is.Not.EqualTo((ushort)0), tag + " has a missing glyph in " + face.FamilyName); + } + } + + [AvaloniaTest] + public void Persian_SampleResolvesToFaceWithItsGlyphs() => AssertFallbackLanguageDrawsItsSample("fa", Sample("fa")); + + [AvaloniaTest] + public void Hindi_SampleResolvesToFaceWithItsGlyphs() => AssertFallbackLanguageDrawsItsSample("hi", Sample("hi")); + + [AvaloniaTest] + public void Korean_SampleResolvesToFaceWithItsGlyphs() => AssertFallbackLanguageDrawsItsSample("ko", Sample("ko")); + + [AvaloniaTest] + public void Telugu_SampleResolvesToFaceWithItsGlyphs() => AssertFallbackLanguageDrawsItsSample("te", Sample("te")); + + [AvaloniaTest] + public void SimplifiedChinese_SampleResolvesToFaceWithItsGlyphs() => AssertFallbackLanguageDrawsItsSample("zh-CN", Sample("zh-CN")); + + [AvaloniaTest] + public void LanguagesWithoutNamedFallback_DrawTheirSamples() + { + foreach (object[] row in LanguagesWithoutFallback) + { + var tag = (string)row[0]; + var culture = new CultureInfo(tag); + Assert.That(FwUiFont.FallbackFamiliesFor(culture), Is.Empty, tag); + var shaped = Shape(FwUiFont.FamilyFor(culture), ((string)row[1]).Replace(" ", "")); + + Assert.That(shaped, Is.Not.Empty, tag); + foreach (var (face, glyph) in shaped) + { + Assert.That(glyph, Is.Not.EqualTo((ushort)0), tag); + } + } + } + + [TestCase("ar", "Segoe UI|Scheherazade New")] + [TestCase("ar-SA", "Segoe UI|Scheherazade New")] + [TestCase("bn", "Nirmala UI|Noto Sans Bengali")] + [TestCase("fa", "Segoe UI|Scheherazade New")] + [TestCase("hi", "Nirmala UI|Annapurna SIL")] + [TestCase("km", "Leelawadee UI|Khmer UI|Noto Sans Khmer")] + [TestCase("ko", "Malgun Gothic|Noto Sans CJK KR")] + [TestCase("ml", "Nirmala UI|Noto Sans Malayalam")] + [TestCase("my", "Myanmar Text|Padauk")] + [TestCase("ne", "Nirmala UI|Annapurna SIL")] + [TestCase("ta", "Nirmala UI|Noto Sans Tamil")] + [TestCase("te", "Nirmala UI|Noto Sans Telugu")] + [TestCase("th", "Leelawadee UI|Noto Sans Thai")] + [TestCase("ur", "Segoe UI|Scheherazade New")] + [TestCase("zh-CN", "Microsoft YaHei UI|Noto Sans CJK SC")] + [Test] + public void FallbackFamiliesFor_ReturnsOrderedFamiliesForUiLanguage(string tag, string families) + { + Assert.That(FwUiFont.FallbackFamiliesFor(new CultureInfo(tag)), Is.EqualTo(families.Split('|'))); + } + + [Test] + public void FallbackFamiliesFor_InvariantCultureHasNoNamedFamilies() + { + Assert.That(FwUiFont.FallbackFamiliesFor(CultureInfo.InvariantCulture), Is.Empty); + } + + [TestCase("ar", "\u0645\u0644\u0641")] + [TestCase("bn", "\u09AC\u09BE\u0982\u09B2\u09BE")] + [TestCase("km", "\u1781\u17D2\u1798\u17C2\u179A")] + [TestCase("ml", "\u0D2E\u0D32\u0D2F\u0D3E\u0D33\u0D02")] + [TestCase("my", "\u1019\u103C\u1014\u103A\u1019\u102C")] + [TestCase("ne", "\u0928\u0947\u092A\u093E\u0932\u0940")] + [TestCase("ta", "\u0BA4\u0BAE\u0BBF\u0BB4\u0BCD")] + [TestCase("th", "\u0E44\u0E17\u0E22")] + [TestCase("ur", "\u0627\u0631\u062F\u0648")] + [AvaloniaTest] + public void InstallerLanguage_NamedFallbackDrawsSample(string tag, string sample) + { + var glyphs = Shape(FwUiFont.FamilyFor(new CultureInfo(tag)), sample); + Assert.That(glyphs, Is.Not.Empty, tag); + Assert.That(glyphs.All(g => g.Glyph != 0), Is.True, tag); + } + + [AvaloniaTest] + public void ApplyTo_CalledAgain_ChangesNothing() + { + FwUiFont.ApplyTo(Application.Current); + Assert.That(Resolve(FontWeight.Normal).FamilyName, Is.EqualTo("Andika")); + } + + [AvaloniaTest] + public void TextInAWindow_InheritsTheInterfaceFont() + { + var panel = new StackPanel(); + var text = new TextBlock { Text = "Lexeme Form" }; + var button = new Button { Content = "OK" }; + var label = new Label { Content = "Gloss" }; + panel.Children.Add(text); + panel.Children.Add(button); + panel.Children.Add(label); + var window = new Window { Content = panel }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + + AssertUsesInterfaceFont(text); + AssertUsesInterfaceFont(button); + AssertUsesInterfaceFont(label); + } + + [AvaloniaTest] + public void TextInAnEmbeddableControlRoot_InheritsTheInterfaceFont() + { + // The WinForms-hosted production root. If it cannot be shown headless, the same + // guarantee is the application-level resources Semi's root theme reads. + var app = Application.Current; + foreach (var key in new[] { "DefaultFontFamily", "ContentControlThemeFontFamily", "SemiFontFamilyRegular" }) + { + Assert.That(app.TryGetResource(key, app.ActualThemeVariant, out var value), Is.True, key); + Assert.That(value, Is.EqualTo(FwUiFont.UiFontFamily), key); + } + } + + [AvaloniaTest] + public void FlyoutText_InheritsTheInterfaceFont() + { + var text = new TextBlock { Text = "Insert" }; + var anchor = new Button { Content = "Menu", Flyout = new Flyout { Content = text } }; + var window = new Window { Content = anchor }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + anchor.Flyout.ShowAt(anchor); + Dispatcher.UIThread.RunJobs(); + + AssertUsesInterfaceFont(text); + } + + [AvaloniaTest] + public void ContextMenuItem_InheritsTheInterfaceFont() + { + var item = new MenuItem { Header = "Copy" }; + var anchor = new Button { Content = "Target", ContextMenu = new ContextMenu { Items = { item } } }; + var window = new Window { Content = anchor }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + anchor.ContextMenu.Open(anchor); + Dispatcher.UIThread.RunJobs(); + + AssertUsesInterfaceFont(item); + } + + [AvaloniaTest] + public void WsAbbrevColumnWidth_MeasuresInTheUiFont() + { + const string abbrev = "Sen-Sup"; + var field = new DetailField("f1", "Form", "Form", null, DetailFieldKind.Text, + EditorClassification.Known, "f1", null, HostRouting.Inherit, + new List { new DetailWsValue(abbrev, "barigi") }, null, null, isEditable: true); + field.IsMultiStringRow = true; + var model = new DetailModel("LexEntry", "Normal", new List { field }, + new List()); + + var formatted = new FormattedText(abbrev, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, + new Typeface(FwUiFont.UiFontFamily), FwAvaloniaDensity.WsAbbrevFontSize, + FwAvaloniaDensity.PickerForegroundBrush); + var expected = Math.Max(FwAvaloniaDensity.WsAbbrevWidth, + Math.Min(formatted.Width + FwAvaloniaDensity.WsAbbrevGutter, FwAvaloniaDensity.WsAbbrevMaxWidth)); + + Assert.That(FwMultiWsTextField.ComputeWsAbbrevColumnWidth(model), Is.EqualTo(expected).Within(0.001)); + } + } +} diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/TestAppBuilder.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/TestAppBuilder.cs index 2e34c65caf..dde8c4c9ed 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/TestAppBuilder.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/TestAppBuilder.cs @@ -13,8 +13,8 @@ namespace FwAvaloniaTests { /// /// Headless Avalonia application builder for the FwAvalonia tests. Uses - /// so the Fluent theme is applied and the pure-C# controls receive templates under the headless - /// platform. + /// so the Semi theme and the FieldWorks interface font are applied and the pure-C# controls + /// receive templates under the headless platform. /// Skia drawing (instead of the null headless drawing backend) enables rendered-frame capture for /// visual parity evidence; all other headless behavior (input, focus, layout) is unchanged. /// diff --git a/Src/Common/FwAvalonia/FwThemeResources.cs b/Src/Common/FwAvalonia/FwThemeResources.cs index 6306339bdc..6b2c9cd6cd 100644 --- a/Src/Common/FwAvalonia/FwThemeResources.cs +++ b/Src/Common/FwAvalonia/FwThemeResources.cs @@ -32,6 +32,10 @@ internal static class FwThemeResources /// DataTree.PickerCornerRadius). public static CornerRadius RequireCornerRadius(string key) => (CornerRadius)Require(key); + /// Resolves a required token (e.g. + /// FwUiFontFamily). + public static FontFamily RequireFontFamily(string key) => (FontFamily)Require(key); + /// No fallback default on a miss: both hosts merge the token dictionaries /// unconditionally at Initialize(), so a missing key means the wiring is broken and must /// fail loudly rather than silently substitute a hardcoded value. diff --git a/Src/Common/FwAvalonia/FwUiFont.cs b/Src/Common/FwAvalonia/FwUiFont.cs new file mode 100644 index 0000000000..c2f05bd7e1 --- /dev/null +++ b/Src/Common/FwAvalonia/FwUiFont.cs @@ -0,0 +1,109 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Avalonia; +using Avalonia.Media; +using Avalonia.Media.Fonts; + +namespace SIL.FieldWorks.Common.FwAvalonia +{ + /// + /// Applies the interface font token and UI-language fallbacks to Semi's resources. + /// Data text retains its writing-system font. The private Andika collection is + /// registered independently of the default interface family. + /// + public static class FwUiFont + { + /// Key of the private font collection holding the bundled interface + /// font. + public const string CollectionKey = "fonts:FieldWorks"; + + /// Location of the bundled interface font files inside + /// FwAvaloniaTheme.dll. + public const string AssetsUri = "avares://FwAvaloniaTheme/Assets/Fonts"; + + private const string DefaultFontFamilyKey = "DefaultFontFamily"; + + /// Named UI-language fallbacks, in the order tried. Each list names a Windows + /// font first and an SIL or Noto family second. Keyed by language code, so + /// every Chinese culture shares one list. + private static readonly Dictionary FallbacksByLanguage = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ar"] = new[] { "Segoe UI", "Scheherazade New" }, + ["bn"] = new[] { "Nirmala UI", "Noto Sans Bengali" }, + ["fa"] = new[] { "Segoe UI", "Scheherazade New" }, + ["hi"] = new[] { "Nirmala UI", "Annapurna SIL" }, + ["km"] = new[] { "Leelawadee UI", "Khmer UI", "Noto Sans Khmer" }, + ["ko"] = new[] { "Malgun Gothic", "Noto Sans CJK KR" }, + ["ml"] = new[] { "Nirmala UI", "Noto Sans Malayalam" }, + ["my"] = new[] { "Myanmar Text", "Padauk" }, + ["ne"] = new[] { "Nirmala UI", "Annapurna SIL" }, + ["ta"] = new[] { "Nirmala UI", "Noto Sans Tamil" }, + ["te"] = new[] { "Nirmala UI", "Noto Sans Telugu" }, + ["th"] = new[] { "Leelawadee UI", "Noto Sans Thai" }, + ["ur"] = new[] { "Segoe UI", "Scheherazade New" }, + ["zh"] = new[] { "Microsoft YaHei UI", "Noto Sans CJK SC" }, + }; + + private static FontManager s_registeredWith; + + /// + /// The interface font family in effect for the UI language at application startup. + /// + public static FontFamily UiFontFamily + => FwThemeResources.RequireFontFamily(DefaultFontFamilyKey); + + /// The fallback family names, in order, that needs + /// after the theme font. + public static IReadOnlyList FallbackFamiliesFor(CultureInfo uiCulture) + { + var tag = (uiCulture ?? CultureInfo.InvariantCulture).Name; + var subtags = tag.Split(new[] { '-', '_' }, StringSplitOptions.RemoveEmptyEntries); + if (subtags.Length == 0) + return new string[0]; + return FallbacksByLanguage.TryGetValue(subtags[0], out var families) + ? families : new string[0]; + } + + /// + /// The theme font, UI-language fallbacks, and platform default for + /// . + /// + public static FontFamily FamilyFor(CultureInfo uiCulture) + { + var names = new List + { + FwThemeResources.RequireFontFamily(GeneratedTokenKeys.FwUiFontFamily).ToString() + }; + names.AddRange(FallbackFamiliesFor(uiCulture)); + names.Add(FontManager.Current.DefaultFontFamily.ToString()); + return FontFamily.Parse(string.Join(", ", names)); + } + + /// Registers the bundled font and points Semi's text-font resources at the + /// interface family stack for the current UI language. Calling it again for the same + /// process re-registers nothing. The FwAvaloniaTheme token dictionary must already be + /// merged into . + public static void ApplyTo(Application app) + { + var fonts = FontManager.Current; + if (!ReferenceEquals(s_registeredWith, fonts)) + { + var collectionUri = new Uri(CollectionKey); + fonts.AddFontCollection(new EmbeddedFontCollection(collectionUri, new Uri(AssetsUri))); + s_registeredWith = fonts; + } + + var family = FamilyFor(CultureInfo.CurrentUICulture); + app.Resources[DefaultFontFamilyKey] = family; + app.Resources["ContentControlThemeFontFamily"] = family; + app.Resources["SemiFontFamilyRegular"] = family; + } + } +} diff --git a/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/AddNewSenseDialogTests.cs b/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/AddNewSenseDialogTests.cs index 11df53c97a..16bc709ba9 100644 --- a/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/AddNewSenseDialogTests.cs +++ b/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/AddNewSenseDialogTests.cs @@ -75,6 +75,15 @@ private static TextBox GlossBox(AddNewSenseDlgViewModel vm, string wsTag) .First(b => Avalonia.Automation.AutomationProperties.GetAutomationId(b) == "AddNewSense.Gloss." + wsTag); + [AvaloniaTest] + public void DialogText_UsesTheAndikaInterfaceFont() + { + var (view, _) = Show(BasicInput(), "AddNewSense-00-font"); + var text = view.GetVisualDescendants().OfType() + .First(t => !string.IsNullOrEmpty(t.Text) && t.FindAncestorOfType() == null); + Assert.That(text.FontFamily, Is.EqualTo(FwUiFont.UiFontFamily)); + } + // ----- OK gating: empty gloss blocks OK, typing enables it (legacy ksFillInGloss) ----- [AvaloniaTest] diff --git a/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/TestAppBuilder.cs b/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/TestAppBuilder.cs index 9fc2723c6c..bd636058f6 100644 --- a/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/TestAppBuilder.cs +++ b/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/TestAppBuilder.cs @@ -13,7 +13,8 @@ namespace FwAvaloniaDialogsTests { /// /// Headless Avalonia application builder for the dialog-MVVM tests. Reuses - /// from the foundation so the Fluent theme applies and templated controls (TabControl, CheckBox, + /// from the foundation so the Semi theme and the FieldWorks interface font apply and + /// templated controls (TabControl, CheckBox, /// ComboBox, Button) realize under the headless platform. /// public static class TestAppBuilder diff --git a/Src/Common/FwAvaloniaPreviewHost/PreviewHostApp.cs b/Src/Common/FwAvaloniaPreviewHost/PreviewHostApp.cs index 93905e9d2f..a66afc1424 100644 --- a/Src/Common/FwAvaloniaPreviewHost/PreviewHostApp.cs +++ b/Src/Common/FwAvaloniaPreviewHost/PreviewHostApp.cs @@ -39,6 +39,7 @@ public override void Initialize() // Shared dialog spacing/border tokens, so the Dialog* keys this assembly // publishes as generated constants are actually registered here too. MergeTokens("avares://FwAvaloniaTheme/Tokens/DialogTokens.axaml"); + FwUiFont.ApplyTo(this); } private void MergeTokens(string avaresUri) diff --git a/Src/Common/FwAvaloniaTheme/Assets/Fonts/Andika-Bold.ttf b/Src/Common/FwAvaloniaTheme/Assets/Fonts/Andika-Bold.ttf new file mode 100644 index 0000000000..853ee5d1fb Binary files /dev/null and b/Src/Common/FwAvaloniaTheme/Assets/Fonts/Andika-Bold.ttf differ diff --git a/Src/Common/FwAvaloniaTheme/Assets/Fonts/Andika-BoldItalic.ttf b/Src/Common/FwAvaloniaTheme/Assets/Fonts/Andika-BoldItalic.ttf new file mode 100644 index 0000000000..6d9a5ed78d Binary files /dev/null and b/Src/Common/FwAvaloniaTheme/Assets/Fonts/Andika-BoldItalic.ttf differ diff --git a/Src/Common/FwAvaloniaTheme/Assets/Fonts/Andika-Italic.ttf b/Src/Common/FwAvaloniaTheme/Assets/Fonts/Andika-Italic.ttf new file mode 100644 index 0000000000..13f82e683f Binary files /dev/null and b/Src/Common/FwAvaloniaTheme/Assets/Fonts/Andika-Italic.ttf differ diff --git a/Src/Common/FwAvaloniaTheme/Assets/Fonts/Andika-Regular.ttf b/Src/Common/FwAvaloniaTheme/Assets/Fonts/Andika-Regular.ttf new file mode 100644 index 0000000000..402cb75e2c Binary files /dev/null and b/Src/Common/FwAvaloniaTheme/Assets/Fonts/Andika-Regular.ttf differ diff --git a/Src/Common/FwAvaloniaTheme/Assets/Fonts/Andika-SemiBold.ttf b/Src/Common/FwAvaloniaTheme/Assets/Fonts/Andika-SemiBold.ttf new file mode 100644 index 0000000000..5a48156e88 Binary files /dev/null and b/Src/Common/FwAvaloniaTheme/Assets/Fonts/Andika-SemiBold.ttf differ diff --git a/Src/Common/FwAvaloniaTheme/Assets/Fonts/NOTICE.txt b/Src/Common/FwAvaloniaTheme/Assets/Fonts/NOTICE.txt new file mode 100644 index 0000000000..d1fef63965 --- /dev/null +++ b/Src/Common/FwAvaloniaTheme/Assets/Fonts/NOTICE.txt @@ -0,0 +1,16 @@ +Andika 7.000 + +Copyright (c) 2004-2025 SIL Global (https://www.sil.org/), with Reserved Font Names "Andika" and "SIL". +Source: https://software.sil.org/andika/ +License: SIL Open Font License, Version 1.1. The full text is in OFL.txt beside this file. + +The font files are unmodified copies of the upstream release. They are embedded in +FwAvaloniaTheme.dll so the FieldWorks Avalonia interface draws in Andika on machines where it +is not installed. + +SHA-256 of each file: +- Andika-Regular.ttf: 27484fdc98d0d63f90407f8266e28295f6fb16d2b13c5024df0214f17152919a +- Andika-Italic.ttf: b5a2d47f6205a6b885e5a8ff6a29b5430c35222dc4dfe27158a0da3e9f853580 +- Andika-Bold.ttf: 3ad43a77a29ea060528be69a9e0e81c9dbbe8dff894a26ae1818d3c472393319 +- Andika-BoldItalic.ttf: dc5bdeb0a2c053d7c41d8985596ae76f08319814fd354fa4379a7008237a2825 +- Andika-SemiBold.ttf: 73c4df767769af2e545bf66ca4caabd4294ce61eb7206a9908eedfd6a7cc965e diff --git a/Src/Common/FwAvaloniaTheme/Assets/Fonts/OFL.txt b/Src/Common/FwAvaloniaTheme/Assets/Fonts/OFL.txt new file mode 100644 index 0000000000..4e591352dd --- /dev/null +++ b/Src/Common/FwAvaloniaTheme/Assets/Fonts/OFL.txt @@ -0,0 +1,94 @@ +Copyright (c) 2004-2025 SIL Global (https://www.sil.org/) +with Reserved Font Names "Andika" and "SIL". + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/Src/Common/FwAvaloniaTheme/FwAvaloniaTheme.csproj b/Src/Common/FwAvaloniaTheme/FwAvaloniaTheme.csproj index cc395c5468..af967881d0 100644 --- a/Src/Common/FwAvaloniaTheme/FwAvaloniaTheme.csproj +++ b/Src/Common/FwAvaloniaTheme/FwAvaloniaTheme.csproj @@ -31,6 +31,13 @@ + + + + + diff --git a/Src/Common/FwAvaloniaTheme/Tokens/FwColorTokens.axaml b/Src/Common/FwAvaloniaTheme/Tokens/FwColorTokens.axaml index 9b37ccbb8d..93131cfa8f 100644 --- a/Src/Common/FwAvaloniaTheme/Tokens/FwColorTokens.axaml +++ b/Src/Common/FwAvaloniaTheme/Tokens/FwColorTokens.axaml @@ -106,8 +106,11 @@ - + 11 + + Segoe UI + diff --git a/acknowledgments.md b/acknowledgments.md index e18255b28d..c5dd0c1021 100644 --- a/acknowledgments.md +++ b/acknowledgments.md @@ -24,4 +24,14 @@ CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN -ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +## Andika 7.000 (https://software.sil.org/andika/): + +FieldWorks bundles Andika, copyright (c) 2004-2025 SIL Global, with +Reserved Font Names "Andika" and "SIL". The font files are unmodified. + +### License + +SIL Open Font License, Version 1.1. The full text is in +`Src/Common/FwAvaloniaTheme/Assets/Fonts/OFL.txt`, embedded in FwAvaloniaTheme.dll beside the font.