From 4e566af8d4a243f402832c517a79c07dd01a11cd Mon Sep 17 00:00:00 2001 From: Zachary Burnham Date: Fri, 6 Nov 2026 11:49:04 -0500 Subject: [PATCH 1/7] LT-22811: Show Avalonia detail data at the Normal style's size The Avalonia detail view drew its data at the fixed 11-unit surface font (about 8 pt), so changing Normal in Format > Styles left it unchanged while the WinForms slices followed. The composer now reads the project's Normal style through the stylesheet and gives each text value Normal's size for its writing system, falling back to Normal's size for all writing systems and then to 10 pt, and the lexeme form's 120% scales that size. The detail model carries Normal's analysis size, and each value editor sizes its TextBlocks and TextBoxes with it in place of the surface font. Only those two types are styled, so the text a box draws inherits the box's own size, as the lexeme form's larger size needs. A styled or multi-run value, shown read-only until it is clicked, keeps its own size and bold there too. RecordEditView now answers RefreshDisplay, so closing the Styles dialog or pressing F5 recomposes the Avalonia view once any open edit is done. Co-Authored-By: Claude Opus 5.5 --- Src/Common/FwAvalonia/Detail/DataTree.cs | 6 + Src/Common/FwAvalonia/Detail/DetailModel.cs | 11 +- .../FwAvalonia/Detail/FwFieldControls.cs | 6 + .../DetailViewingParityTests.cs | 74 +++++++++++- Src/Common/FwAvalonia/FwSurfaceStyles.cs | 49 +++++--- .../Avalonia/Composer/DetailComposer.cs | 21 +++- .../Avalonia/Composer/DetailValueFactory.cs | 16 ++- .../Avalonia/Composer/NormalStyleFontSizes.cs | 62 ++++++++++ .../Hosting/RecordEditView.Avalonia.cs | 14 ++- .../Composer/DetailComposerFontSizeTests.cs | 107 ++++++++++++++++++ 10 files changed, 338 insertions(+), 28 deletions(-) create mode 100644 Src/xWorks/Avalonia/Composer/NormalStyleFontSizes.cs create mode 100644 Src/xWorks/xWorksTests/Avalonia/Composer/DetailComposerFontSizeTests.cs diff --git a/Src/Common/FwAvalonia/Detail/DataTree.cs b/Src/Common/FwAvalonia/Detail/DataTree.cs index 4cb49fa741..eb8506c473 100644 --- a/Src/Common/FwAvalonia/Detail/DataTree.cs +++ b/Src/Common/FwAvalonia/Detail/DataTree.cs @@ -889,6 +889,7 @@ private FieldContent AddField(int row, DetailField field, ShownRow shown) AutomationProperties.SetName(labelBlock, field.Label ?? field.Field ?? string.Empty); ToolTip.SetTip(labelBlock, field.Label ?? field.Field); // the label text is its own tip var editor = CreateEditor(field, automationId); + ApplyValueFontSize(editor); if (editor is IDisposable disposable) shown.Editor = disposable; editor.Margin = new Thickness(0, 0, 0, FwAvaloniaDensity.FieldSpacing); @@ -1112,6 +1113,11 @@ private Control CreateHotlinkStrip(DetailField field, string automationId, Thick // SliceFactory. The detail pane passes its full callback set (per-WS keyboard, slice // menu, link, clipboard) and routes reference-vector gesture completion to its validation-gated // OnSave (the autosave). New DetailFieldKinds are added once, in the factory. + // Shows the editor's data at the model's value size, the Normal style's, in place of the + // surface font (LT-22811). + private void ApplyValueFontSize(Control editor) + => FwSurfaceStyles.ApplyValueFontSize(editor, Model.ValueFontSize); + private Control CreateEditor(DetailField field, string automationId) => SliceFactory.Build(field, automationId, new SliceFactoryContext( editContext: _editContext, diff --git a/Src/Common/FwAvalonia/Detail/DetailModel.cs b/Src/Common/FwAvalonia/Detail/DetailModel.cs index 5a98a5ae81..77fc40a4e6 100644 --- a/Src/Common/FwAvalonia/Detail/DetailModel.cs +++ b/Src/Common/FwAvalonia/Detail/DetailModel.cs @@ -2114,18 +2114,27 @@ public DetailModel( string className, string layoutName, IReadOnlyList fields, - IReadOnlyList diagnostics) + IReadOnlyList diagnostics, + double valueFontSize = 0) { ClassName = className; LayoutName = layoutName; Fields = fields ?? new List(); Diagnostics = diagnostics ?? new List(); + ValueFontSize = valueFontSize; } public string ClassName { get; } public string LayoutName { get; } public IReadOnlyList Fields { get; } public IReadOnlyList Diagnostics { get; } + + /// + /// The font size of the field values, in device-independent units: the project's Normal + /// style size. A value that carries its own shows at + /// that instead. 0 leaves the values at the theme's size. + /// + public double ValueFontSize { get; } } /// diff --git a/Src/Common/FwAvalonia/Detail/FwFieldControls.cs b/Src/Common/FwAvalonia/Detail/FwFieldControls.cs index 91dcc132f4..fccb422dc1 100644 --- a/Src/Common/FwAvalonia/Detail/FwFieldControls.cs +++ b/Src/Common/FwAvalonia/Detail/FwFieldControls.cs @@ -937,6 +937,12 @@ private Control CreateValueContentWithFontSwap(DetailField field, string automat var rtl = box.FlowDirection == FlowDirection.RightToLeft; var display = DetailRichTextChrome.CreatePerRunFontDisplay(currentRich, field.WritingSystemFonts, automationId + "." + wsKey + ".Display", rtl); + // The display stands in for the box, so it shows the value's own size and emphasis + // (LT-22811). + if (box.IsSet(TextBox.FontSizeProperty)) + display.FontSize = box.FontSize; + if (box.IsSet(TextBox.FontWeightProperty)) + display.FontWeight = box.FontWeight; _valueStops.AttachDisplay(valueStop, display); // Exactly ONE of {display, box} occupies the row at a time (IsVisible collapses the other out diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailViewingParityTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailViewingParityTests.cs index b82d78caca..5f0a18e1b8 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailViewingParityTests.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailViewingParityTests.cs @@ -9,6 +9,7 @@ using Avalonia.Automation.Peers; using Avalonia.Automation.Provider; using Avalonia.Controls; +using Avalonia.Controls.Presenters; using Avalonia.Headless; using Avalonia.Headless.NUnit; using Avalonia.Interactivity; @@ -47,10 +48,12 @@ private static DetailField Text(string id, string label, int indent) return field; } - private static DataTree Show(params DetailField[] fields) + private static DataTree Show(params DetailField[] fields) => ShowSized(0, fields); + + private static DataTree ShowSized(double valueFontSize, params DetailField[] fields) { var model = new DetailModel("LexEntry", "Normal", - fields.ToList(), new List()); + fields.ToList(), new List(), valueFontSize); var view = new DataTree(model); var window = new Window { Content = view, Width = 480, Height = 300 }; window.Show(); @@ -682,6 +685,73 @@ public void LabelTooltips_Splitter_BoldEmphasis_AndCopyMenu_RenderLikeLegacy() Assert.That(box.ContextFlyout, Is.Not.Null, "11.17: rows carry the Copy context menu"); } + // LT-22811: the model's value size reaches every kind of value over the theme's size; + // labels, writing-system abbreviations and a value with its own size keep theirs. + [AvaloniaTest] + public void ValueFontSize_SizesEveryValue_ButNotLabelsOrSizedValues() + { + const double normalSize = 20; + var chooser = new DetailField("c", "Morph Type", "MorphType", null, + DetailFieldKind.Chooser, EditorClassification.Known, "c", null, + HostRouting.Inherit, null, + new List { new DetailChoiceOption("mt-stem", "stem") }, + "mt-stem"); + var ownSize = new DetailField("s", "Lexeme Form", "Form", null, DetailFieldKind.Text, + EditorClassification.Known, "s", null, HostRouting.Inherit, + new List { new DetailWsValue("seh", "casa", null, 30, false, "seh") }, + null, null); + var themed = Show(Text("t", "Gloss", 0)); + var view = ShowSized(normalSize, Text("t", "Gloss", 0), chooser, ownSize); + TextBox Box(Control root, string text) + => root.GetVisualDescendants().OfType().Single(b => b.Text == text); + + Assert.That(Box(themed, "value").FontSize, Is.Not.EqualTo(normalSize), + "precondition: the theme's own size differs"); + Assert.That(Box(view, "value").FontSize, Is.EqualTo(normalSize), "a text value"); + var chooserText = view.GetVisualDescendants().OfType() + .FirstOrDefault(t => t.Text == "stem"); + Assert.That(chooserText?.FontSize, Is.EqualTo(normalSize), "a chooser's value"); + Assert.That(Box(view, "casa").FontSize, Is.EqualTo(30), "a value's own size wins"); + TextPresenter Drawn(string text) => Box(view, text).GetVisualDescendants() + .OfType().Single(); + Assert.That(Drawn("casa").FontSize, Is.EqualTo(30), "and it is the size the box draws"); + Assert.That(Drawn("value").FontSize, Is.EqualTo(normalSize)); + var label = view.GetVisualDescendants().OfType() + .First(t => AutomationProperties.GetAutomationId(t) == "t.Label"); + Assert.That(label.FontSize, + Is.EqualTo(SIL.FieldWorks.Common.FwAvalonia.FwAvaloniaDensity.LabelFontSize)); + var abbrev = view.GetVisualDescendants().OfType().First(t => t.Text == "en"); + Assert.That(abbrev.FontSize, + Is.EqualTo(SIL.FieldWorks.Common.FwAvalonia.FwAvaloniaDensity.WsAbbrevFontSize)); + } + + // LT-22811: a styled value shows through a read-only display until clicked; it keeps the + // value's own size and bold, as the lexeme form's 120% emphasis needs. + [AvaloniaTest] + public void ValueFontSize_StyledValueDisplay_KeepsTheValuesSizeAndBold() + { + var rich = new DetailRichTextValue("dog", new[] + { + new DetailTextRun("do", "seh"), + new DetailTextRun("g", "seh", namedStyle: "Emphasis") + }); + var styled = new DetailField("s", "Lexeme Form", "Form", null, DetailFieldKind.Text, + EditorClassification.Known, "s", null, HostRouting.Inherit, + new List + { + new DetailWsValue("seh", "dog", null, 30, false, "seh", bold: true, richText: rich) + }, + null, null); + var view = ShowSized(20, styled); + + var display = view.GetVisualDescendants().OfType() + .Single(t => (AutomationProperties.GetAutomationId(t) ?? string.Empty) + .EndsWith(".Display", System.StringComparison.Ordinal)); + Assert.That(display.IsVisible, Is.True, "precondition: the styled value shows its display"); + Assert.That(display.FontSize, Is.EqualTo(30), "the value's own size, not the view's"); + Assert.That(display.FontWeight, Is.EqualTo(Avalonia.Media.FontWeight.Bold)); + } + [AvaloniaTest] public void VisualFidelity_FlatEditors_SliceRules_AndLegacyTokens() { diff --git a/Src/Common/FwAvalonia/FwSurfaceStyles.cs b/Src/Common/FwAvalonia/FwSurfaceStyles.cs index 2853a17951..d3cf5522fa 100644 --- a/Src/Common/FwAvalonia/FwSurfaceStyles.cs +++ b/Src/Common/FwAvalonia/FwSurfaceStyles.cs @@ -62,22 +62,23 @@ public static void Apply(Control surface) surface.Styles.Add(style); } + /// + /// Shows the text under at in place + /// of the surface font, for the controls the surface font sizes. A size a control sets + /// for itself still wins. Does nothing for a null editor or a size that is not positive. + /// + public static void ApplyValueFontSize(Control editor, double fontSize) + { + if (editor == null || fontSize <= 0) + return; + foreach (var style in TextFontStyles(fontSize)) + editor.Styles.Add(style); + } + private static IEnumerable Build() { - // TextBlock -- the detail view labels/values and browse cells/headers. A - // per-control FontSize is a LOCAL value that outranks this setter, so only - // the unset Fluent default is replaced. - yield return new Style(s => s.OfType()) - { - Setters = { new Setter(TextBlock.FontSizeProperty, SurfaceFontSize) } - }; - // TextBox -- the editable detail values / browse cells. Font only: the editors - // are deliberately borderless/transparent, so no border or min-height floor - // here, unlike the boxed dialog inputs. - yield return new Style(s => s.OfType()) - { - Setters = { new Setter(TemplatedControl.FontSizeProperty, SurfaceFontSize) } - }; + foreach (var style in TextFontStyles(SurfaceFontSize)) + yield return style; // TabItem -- the bulk-edit bar's mode tabs. Fluent's default tab headers are large // (~16px semibold // with tall padding); with several tabs on a narrow bar they WRAP to a second row, eating the bar @@ -100,5 +101,25 @@ private static IEnumerable Build() // Semi sizes CheckBox/RadioButton controls from overridable resources; FwSemiDensity // retargets those once at the Application level. } + + // Sizes exactly TextBlock and TextBox, never a control inside a TextBox's template, so + // the text a box draws inherits the box's own size. + private static IEnumerable TextFontStyles(double fontSize) + { + // TextBlock -- the detail view labels/values and browse cells/headers. A + // per-control FontSize is a LOCAL value that outranks this setter, so only + // the unset Fluent default is replaced. + yield return new Style(s => s.OfType()) + { + Setters = { new Setter(TextBlock.FontSizeProperty, fontSize) } + }; + // TextBox -- the editable detail values / browse cells. Font only: the editors + // are deliberately borderless/transparent, so no border or min-height floor + // here, unlike the boxed dialog inputs. + yield return new Style(s => s.OfType()) + { + Setters = { new Setter(TemplatedControl.FontSizeProperty, fontSize) } + }; + } } } diff --git a/Src/xWorks/Avalonia/Composer/DetailComposer.cs b/Src/xWorks/Avalonia/Composer/DetailComposer.cs index 2cf6f83c9a..e94b771db0 100644 --- a/Src/xWorks/Avalonia/Composer/DetailComposer.cs +++ b/Src/xWorks/Avalonia/Composer/DetailComposer.cs @@ -173,7 +173,8 @@ public static ComposedDetail Compose(ICmObject obj, LcmCache cache, string layou var context = new ComposedDetailEditContext(cache, obj, state.Handlers); composedContext = context; - var model = new DetailModel(obj.ClassName, layoutName, state.Fields, root.Diagnostics); + var model = new DetailModel(obj.ClassName, layoutName, state.Fields, root.Diagnostics, + state.ValueFontSize); return new ComposedDetail(model, context); } @@ -374,8 +375,17 @@ public ComposeState(LcmCache cache, bool showHiddenFields, _sda = cache.DomainDataByFlid; _mdc = (IFwMetaDataCacheManaged)cache.DomainDataByFlid.MetaDataCache; _summaryLines = new DetailSummaryLines(cache); + _normalFontSizes = new NormalStyleFontSizes(cache); } + private readonly NormalStyleFontSizes _normalFontSizes; + + /// + /// The size of data that is not tied to one writing system: the Normal style's size + /// in the default analysis writing system. + /// + public double ValueFontSize => _normalFontSizes.For(_cache.DefaultAnalWs); + // Track which compiled model the walk is currently inside so each emitted // field is stamped with its (class, layout). EnterModel/ExitModel bracket each compiled model's // roots (the entry's own, and each descended object's), AddField stamps from the top of stack. @@ -1347,8 +1357,11 @@ private IReadOnlyList CreateTextRowValues(int hvo, int flid, Cell IReadOnlyList systems, ViewNode node, out bool anyData, out bool anyAudio) { var dataSeen = false; - // The lexeme form's legacy bold/120% emphasis. - var fontSize = node.FontScalePercent > 0 ? 12.0 * node.FontScalePercent / 100.0 : 0; + // Each alternative shows at the Normal style's size for its writing system, + // scaled by the part's fontsize percentage (the lexeme form's 120%). + var scale = node.FontScalePercent > 0 ? node.FontScalePercent / 100.0 : 1.0; + Func fontSize = + ws => _normalFontSizes.For(ws.Handle) * scale; // The per-ws value rows build through the shared factory // (LexiconEditErrorFallback uses the same one), this path only supplies the text. IReadOnlyList values; @@ -1390,7 +1403,7 @@ private IReadOnlyList CreateTextRowValues(int hvo, int flid, Cell // of a plain text box. rebuilt.Add(new DetailWsValue(ws.Abbreviation, values[i]?.Value ?? string.Empty, - ws.DefaultFontName, fontSize, + ws.DefaultFontName, fontSize(ws), ws.RightToLeftScript, ws.Id, node.BoldEmphasis, isAudio: true)); } else diff --git a/Src/xWorks/Avalonia/Composer/DetailValueFactory.cs b/Src/xWorks/Avalonia/Composer/DetailValueFactory.cs index 39968d2c46..da99043e22 100644 --- a/Src/xWorks/Avalonia/Composer/DetailValueFactory.cs +++ b/Src/xWorks/Avalonia/Composer/DetailValueFactory.cs @@ -26,18 +26,20 @@ internal static class DetailValueFactory /// /// One per writing system, in list order, carrying the /// project's per-ws display metadata (abbreviation, default font, RTL, IETF tag); - /// supplies each alternative's text (null reads as empty). + /// supplies each alternative's text (null reads as empty) and + /// , when given, its font size (0 leaves the view's size). /// internal static IReadOnlyList CreateMultiWsValues( IEnumerable systems, Func readText, - double fontSize = 0, bool boldEmphasis = false) + Func fontSize = null, bool boldEmphasis = false) { var values = new List(); foreach (var ws in systems) { values.Add(new DetailWsValue(ws.Abbreviation, readText(ws) ?? string.Empty, - ws.DefaultFontName, fontSize, ws.RightToLeftScript, ws.Id, boldEmphasis)); + ws.DefaultFontName, fontSize?.Invoke(ws) ?? 0, ws.RightToLeftScript, ws.Id, + boldEmphasis)); } return values; } @@ -45,13 +47,14 @@ internal static IReadOnlyList CreateMultiWsValues( /// /// One per writing system from an ITsString-backed value, /// preserving the source rich-text runs in a neutral Avalonia-facing shape while keeping the - /// common project free of any LCModel dependency. + /// common project free of any LCModel dependency. , when + /// given, supplies each alternative's font size (0 leaves the view's size). /// internal static IReadOnlyList CreateMultiWsValues( IEnumerable systems, Func readText, ILgWritingSystemFactory writingSystemFactory, - double fontSize = 0, bool boldEmphasis = false) + Func fontSize = null, bool boldEmphasis = false) { var values = new List(); foreach (var ws in systems) @@ -59,7 +62,8 @@ internal static IReadOnlyList CreateMultiWsValues( var tss = readText(ws); var richText = DetailRichTextAdapter.FromTsString(tss, writingSystemFactory); values.Add(new DetailWsValue(ws.Abbreviation, tss?.Text ?? string.Empty, - ws.DefaultFontName, fontSize, ws.RightToLeftScript, ws.Id, boldEmphasis, richText)); + ws.DefaultFontName, fontSize?.Invoke(ws) ?? 0, ws.RightToLeftScript, ws.Id, + boldEmphasis, richText)); } return values; } diff --git a/Src/xWorks/Avalonia/Composer/NormalStyleFontSizes.cs b/Src/xWorks/Avalonia/Composer/NormalStyleFontSizes.cs new file mode 100644 index 0000000000..9bc139c783 --- /dev/null +++ b/Src/xWorks/Avalonia/Composer/NormalStyleFontSizes.cs @@ -0,0 +1,62 @@ +// 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.Collections.Generic; +using SIL.LCModel; +using SIL.LCModel.DomainServices; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// The project's Normal style font size for each writing system, in Avalonia + /// device-independent units. Detail-view data shows at this size, as the WinForms slices + /// draw unstyled text in Normal (LT-22811). + /// + internal sealed class NormalStyleFontSizes + { + // The size Normal falls back to when the project's style sets none, in millipoints. + private const int DefaultMillipoints = 10000; + private const double MillipointsPerPoint = 1000.0; + private const double UnitsPerPoint = 96.0 / 72.0; + + private readonly BaseStyleInfo _normal; + private readonly Dictionary _sizes = new Dictionary(); + + /// Reads the Normal style of 's project. + public NormalStyleFontSizes(LcmCache cache) + { + if (cache.LangProject == null) + return; + var styleSheet = new LcmStyleSheet(); + styleSheet.Init(cache, cache.LangProject.Hvo, LangProjectTags.kflidStyles); + if (styleSheet.Styles.Contains(StyleServices.NormalStyleName)) + _normal = styleSheet.Styles[StyleServices.NormalStyleName]; + } + + /// + /// Normal's size for the writing system with handle : its own size + /// when Normal sets one for it, otherwise Normal's size for all writing systems. + /// + public double For(int ws) + { + if (!_sizes.TryGetValue(ws, out var size)) + { + size = Millipoints(ws) / MillipointsPerPoint * UnitsPerPoint; + _sizes[ws] = size; + } + return size; + } + + private int Millipoints(int ws) + { + if (_normal == null) + return DefaultMillipoints; + var own = _normal.FontInfoForWs(ws)?.m_fontSize; + if (own != null && own.ValueIsSet && own.Value > 0) + return own.Value; + var all = _normal.FontInfoForWs(-1)?.m_fontSize; + return all != null && all.ValueIsSet && all.Value > 0 ? all.Value : DefaultMillipoints; + } + } +} diff --git a/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs b/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs index cfb3a00b86..0b8947b7ad 100644 --- a/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs +++ b/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs @@ -40,8 +40,20 @@ namespace SIL.FieldWorks.XWorks /// Form/Form.Designer.cs split) so the legacy-facing file stays small; nothing here /// changes behavior when UIMode is Legacy. /// - public partial class RecordEditView + public partial class RecordEditView : IRefreshableRoot { + /// + /// Recomposes the Avalonia detail view when the window refreshes, as after View > + /// Refresh or the Styles dialog (LT-22811); an open edit completes first. Always false, + /// so the views this one contains still refresh themselves. + /// + public bool RefreshDisplay() + { + if (ShouldUseAvaloniaLexiconEdit) + m_avaloniaRefreshController?.RequestRefresh(); + return false; + } + private UIFramework m_activeUIFramework; private readonly EditControlFactory m_lexicalEditControlFactory; private readonly UIFrameworkSelectionService m_frameworkSelectionService = new UIFrameworkSelectionService(); diff --git a/Src/xWorks/xWorksTests/Avalonia/Composer/DetailComposerFontSizeTests.cs b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailComposerFontSizeTests.cs new file mode 100644 index 0000000000..d3fd2932b0 --- /dev/null +++ b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailComposerFontSizeTests.cs @@ -0,0 +1,107 @@ +// 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.Linq; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia.Detail; +using SIL.FieldWorks.FwCoreDlgControls; +using SIL.LCModel; +using SIL.LCModel.Core.KernelInterfaces; +using SIL.LCModel.Core.Text; +using SIL.LCModel.DomainServices; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// The Avalonia detail view shows its data at the project's Normal style size, as the + /// WinForms slices do, so changing Normal in Format > Styles resizes it (LT-22811). + /// + [TestFixture] + public class DetailComposerFontSizeTests : MemoryOnlyBackendProviderRestoredForEachTestTestBase + { + // Avalonia sizes text in device-independent units, 96 to the inch. + private const double UnitsPerPoint = 96.0 / 72.0; + + private ILexEntry m_entry; + + public override void TestSetup() + { + base.TestSetup(); + m_entry = Cache.ServiceLocator.GetInstance().Create(); + var morph = Cache.ServiceLocator.GetInstance().Create(); + m_entry.LexemeFormOA = morph; + morph.Form.set_String(Cache.DefaultVernWs, TsStringUtils.MakeString("casa", Cache.DefaultVernWs)); + var sense = Cache.ServiceLocator.GetInstance().Create(); + m_entry.SensesOS.Add(sense); + sense.Gloss.set_String(Cache.DefaultAnalWs, TsStringUtils.MakeString("house", Cache.DefaultAnalWs)); + } + + // Normal with the given size for all writing systems, created if the project has none. + private IStStyle NormalStyle(int millipoints) + { + var normal = Cache.LangProject.StylesOC + .FirstOrDefault(s => s.Name == StyleServices.NormalStyleName); + if (normal == null) + { + normal = Cache.ServiceLocator.GetInstance().Create(); + Cache.LangProject.StylesOC.Add(normal); + normal.Name = StyleServices.NormalStyleName; + normal.Type = StyleType.kstParagraph; + normal.IsBuiltIn = true; + } + var props = TsStringUtils.MakePropsBldr(); + props.SetIntPropValues((int)FwTextPropType.ktptFontSize, + (int)FwTextPropVar.ktpvMilliPoint, millipoints); + normal.Rules = props.GetTextProps(); + return normal; + } + + private DetailWsValue Value(ComposedDetail composed, string text) + => composed.Model.Fields.Where(f => f.Values != null).SelectMany(f => f.Values) + .Single(v => v.Value == text); + + [Test] + public void Compose_ValuesShowAtTheNormalStyleSize() + { + NormalStyle(20000); + + var composed = DetailComposer.Compose(m_entry, Cache); + + Assert.That(Value(composed, "house").FontSize, Is.EqualTo(20 * UnitsPerPoint).Within(0.01)); + Assert.That(composed.Model.ValueFontSize, Is.EqualTo(20 * UnitsPerPoint).Within(0.01), + "values with no writing system of their own show at the same size"); + Assert.That(Value(composed, "casa").FontSize, Is.EqualTo(1.2 * 20 * UnitsPerPoint).Within(0.01), + "the lexeme form keeps its 120% emphasis, now of Normal's size"); + } + + [Test] + public void Compose_UsesNormalsOwnSizeForAWritingSystem() + { + var normal = NormalStyle(20000); + var info = new StyleInfo(normal); + info.FontInfoForWs(Cache.DefaultVernWs).m_fontSize.ExplicitValue = 30000; + info.SaveToDB(normal, true, true); + + var composed = DetailComposer.Compose(m_entry, Cache); + + Assert.That(Value(composed, "casa").FontSize, Is.EqualTo(1.2 * 30 * UnitsPerPoint).Within(0.01), + "the vernacular lexeme form uses Normal's vernacular size"); + Assert.That(Value(composed, "house").FontSize, Is.EqualTo(20 * UnitsPerPoint).Within(0.01), + "the analysis gloss keeps Normal's size for all writing systems"); + } + + [Test] + public void Compose_WithNoNormalStyle_ShowsValuesAtTenPoints() + { + var normal = Cache.LangProject.StylesOC + .FirstOrDefault(s => s.Name == StyleServices.NormalStyleName); + if (normal != null) + Cache.LangProject.StylesOC.Remove(normal); + + var composed = DetailComposer.Compose(m_entry, Cache); + + Assert.That(Value(composed, "house").FontSize, Is.EqualTo(10 * UnitsPerPoint).Within(0.01)); + } + } +} From 7fd14763e26310db2d1fcf812a02f9a82be9730a Mon Sep 17 00:00:00 2001 From: Zachary Burnham Date: Fri, 6 Nov 2026 13:51:06 -0500 Subject: [PATCH 2/7] LT-22811: Reuse Normal's sizes and size the error fallback Each compose built a new LcmStyleSheet to read the Normal style. The sizes are now kept per project and rebuilt when Normal's rules change, which saving the Styles dialog does. The Lexicon Edit error fallback built its model without a value size, so it still showed data at the 11-unit surface font. It now uses Normal's sizes too. DataTree.ApplyValueFontSize moves above CreateEditor's comment, which it had separated from its method. Co-Authored-By: Claude Opus 5.5 --- Src/Common/FwAvalonia/Detail/DataTree.cs | 8 ++--- .../FwAvalonia/Detail/DetailModelProjector.cs | 11 +++++-- .../Avalonia/Composer/DetailComposer.cs | 2 +- .../Composer/LexiconEditErrorFallback.cs | 10 ++++-- .../Avalonia/Composer/NormalStyleFontSizes.cs | 31 +++++++++++++++++-- .../Composer/DetailComposerFontSizeTests.cs | 27 ++++++++++++++++ 6 files changed, 76 insertions(+), 13 deletions(-) diff --git a/Src/Common/FwAvalonia/Detail/DataTree.cs b/Src/Common/FwAvalonia/Detail/DataTree.cs index eb8506c473..667de47e8e 100644 --- a/Src/Common/FwAvalonia/Detail/DataTree.cs +++ b/Src/Common/FwAvalonia/Detail/DataTree.cs @@ -1109,15 +1109,15 @@ private Control CreateHotlinkStrip(DetailField field, string automationId, Thick return link; } - // The field->control dispatch is shared with the browse in-cell editor through - // SliceFactory. The detail pane passes its full callback set (per-WS keyboard, slice - // menu, link, clipboard) and routes reference-vector gesture completion to its validation-gated - // OnSave (the autosave). New DetailFieldKinds are added once, in the factory. // Shows the editor's data at the model's value size, the Normal style's, in place of the // surface font (LT-22811). private void ApplyValueFontSize(Control editor) => FwSurfaceStyles.ApplyValueFontSize(editor, Model.ValueFontSize); + // The field->control dispatch is shared with the browse in-cell editor through + // SliceFactory. The detail pane passes its full callback set (per-WS keyboard, slice + // menu, link, clipboard) and routes reference-vector gesture completion to its validation-gated + // OnSave (the autosave). New DetailFieldKinds are added once, in the factory. private Control CreateEditor(DetailField field, string automationId) => SliceFactory.Build(field, automationId, new SliceFactoryContext( editContext: _editContext, diff --git a/Src/Common/FwAvalonia/Detail/DetailModelProjector.cs b/Src/Common/FwAvalonia/Detail/DetailModelProjector.cs index d37e533574..afee2ed1e9 100644 --- a/Src/Common/FwAvalonia/Detail/DetailModelProjector.cs +++ b/Src/Common/FwAvalonia/Detail/DetailModelProjector.cs @@ -15,9 +15,15 @@ namespace SIL.FieldWorks.Common.FwAvalonia.Detail /// public static class DetailModelProjector { + /// + /// Projects into a detail model whose rows read their + /// values from . becomes the + /// model's (0 leaves the theme's size). + /// public static DetailModel FromViewDefinition( ViewDefinitionModel definition, - IDetailValueProvider values) + IDetailValueProvider values, + double valueFontSize = 0) { if (definition == null) throw new System.ArgumentNullException(nameof(definition)); @@ -34,7 +40,8 @@ public static DetailModel FromViewDefinition( definition.ClassName, definition.LayoutName, fields, - definition.Diagnostics); + definition.Diagnostics, + valueFontSize); } private static void CollectFields( diff --git a/Src/xWorks/Avalonia/Composer/DetailComposer.cs b/Src/xWorks/Avalonia/Composer/DetailComposer.cs index e94b771db0..5fa3555eff 100644 --- a/Src/xWorks/Avalonia/Composer/DetailComposer.cs +++ b/Src/xWorks/Avalonia/Composer/DetailComposer.cs @@ -375,7 +375,7 @@ public ComposeState(LcmCache cache, bool showHiddenFields, _sda = cache.DomainDataByFlid; _mdc = (IFwMetaDataCacheManaged)cache.DomainDataByFlid.MetaDataCache; _summaryLines = new DetailSummaryLines(cache); - _normalFontSizes = new NormalStyleFontSizes(cache); + _normalFontSizes = NormalStyleFontSizes.ForProject(cache); } private readonly NormalStyleFontSizes _normalFontSizes; diff --git a/Src/xWorks/Avalonia/Composer/LexiconEditErrorFallback.cs b/Src/xWorks/Avalonia/Composer/LexiconEditErrorFallback.cs index b7103f4a93..231fc86455 100644 --- a/Src/xWorks/Avalonia/Composer/LexiconEditErrorFallback.cs +++ b/Src/xWorks/Avalonia/Composer/LexiconEditErrorFallback.cs @@ -33,11 +33,13 @@ public sealed class LexiconEditErrorFallback : IDetailValueProvider private readonly ILexEntry _entry; private readonly LcmCache _cache; + private readonly NormalStyleFontSizes _fontSizes; private LexiconEditErrorFallback(ILexEntry entry, LcmCache cache) { _entry = entry; _cache = cache; + _fontSizes = NormalStyleFontSizes.ForProject(cache); } /// @@ -50,7 +52,8 @@ public static DetailModel Build(ICmObject obj, LcmCache cache) return null; var provider = new LexiconEditErrorFallback(entry, cache); - return DetailModelProjector.FromViewDefinition(FirstSliceDefinition.Value, provider); + return DetailModelProjector.FromViewDefinition(FirstSliceDefinition.Value, provider, + provider._fontSizes.For(cache.DefaultAnalWs)); } /// @@ -100,7 +103,7 @@ private IReadOnlyList GetLexemeFormValues() if ((text == null || text.Length == 0) && ws.Handle == _cache.DefaultVernWs) text = _entry.CitationForm.get_String(ws.Handle); // legacy fallback, default ws only return text; - }, _cache.WritingSystemFactory); + }, _cache.WritingSystemFactory, ws => _fontSizes.For(ws.Handle)); } private IReadOnlyList GetGlossValues() @@ -111,7 +114,8 @@ private IReadOnlyList GetGlossValues() var gloss = _entry.SensesOS[0].Gloss; return DetailValueFactory.CreateMultiWsValues( _cache.ServiceLocator.WritingSystems.CurrentAnalysisWritingSystems, - ws => gloss.get_String(ws.Handle), _cache.WritingSystemFactory); + ws => gloss.get_String(ws.Handle), _cache.WritingSystemFactory, + ws => _fontSizes.For(ws.Handle)); } /// diff --git a/Src/xWorks/Avalonia/Composer/NormalStyleFontSizes.cs b/Src/xWorks/Avalonia/Composer/NormalStyleFontSizes.cs index 9bc139c783..7df41b228e 100644 --- a/Src/xWorks/Avalonia/Composer/NormalStyleFontSizes.cs +++ b/Src/xWorks/Avalonia/Composer/NormalStyleFontSizes.cs @@ -3,7 +3,10 @@ // (http://www.gnu.org/licenses/lgpl-2.1.html) using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; using SIL.LCModel; +using SIL.LCModel.Core.KernelInterfaces; using SIL.LCModel.DomainServices; namespace SIL.FieldWorks.XWorks @@ -20,13 +23,32 @@ internal sealed class NormalStyleFontSizes private const double MillipointsPerPoint = 1000.0; private const double UnitsPerPoint = 96.0 / 72.0; + private static readonly ConditionalWeakTable s_byProject = + new ConditionalWeakTable(); + + private readonly ITsTextProps _rules; private readonly BaseStyleInfo _normal; private readonly Dictionary _sizes = new Dictionary(); - /// Reads the Normal style of 's project. - public NormalStyleFontSizes(LcmCache cache) + /// + /// The sizes for 's project as its Normal style is now. Reuses + /// the last result until the style's rules change, as saving the Styles dialog does. + /// + public static NormalStyleFontSizes ForProject(LcmCache cache) { - if (cache.LangProject == null) + var rules = NormalStyle(cache)?.Rules; + if (s_byProject.TryGetValue(cache, out var sizes) && ReferenceEquals(sizes._rules, rules)) + return sizes; + sizes = new NormalStyleFontSizes(cache, rules); + s_byProject.Remove(cache); + s_byProject.Add(cache, sizes); + return sizes; + } + + private NormalStyleFontSizes(LcmCache cache, ITsTextProps rules) + { + _rules = rules; + if (rules == null) return; var styleSheet = new LcmStyleSheet(); styleSheet.Init(cache, cache.LangProject.Hvo, LangProjectTags.kflidStyles); @@ -34,6 +56,9 @@ public NormalStyleFontSizes(LcmCache cache) _normal = styleSheet.Styles[StyleServices.NormalStyleName]; } + private static IStStyle NormalStyle(LcmCache cache) + => cache.LangProject?.StylesOC.FirstOrDefault(s => s.Name == StyleServices.NormalStyleName); + /// /// Normal's size for the writing system with handle : its own size /// when Normal sets one for it, otherwise Normal's size for all writing systems. diff --git a/Src/xWorks/xWorksTests/Avalonia/Composer/DetailComposerFontSizeTests.cs b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailComposerFontSizeTests.cs index d3fd2932b0..e049acaa11 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Composer/DetailComposerFontSizeTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailComposerFontSizeTests.cs @@ -91,6 +91,33 @@ public void Compose_UsesNormalsOwnSizeForAWritingSystem() "the analysis gloss keeps Normal's size for all writing systems"); } + // The sizes are reused between composes, so this pins that a Styles-dialog change to + // Normal still reaches the next compose. + [Test] + public void Compose_AfterNormalChanges_UsesTheNewSize() + { + NormalStyle(20000); + DetailComposer.Compose(m_entry, Cache); + NormalStyle(30000); + + var composed = DetailComposer.Compose(m_entry, Cache); + + Assert.That(Value(composed, "house").FontSize, Is.EqualTo(30 * UnitsPerPoint).Within(0.01)); + } + + [Test] + public void ErrorFallback_ShowsValuesAtTheNormalStyleSize() + { + NormalStyle(20000); + + var model = LexiconEditErrorFallback.Build(m_entry, Cache); + + Assert.That(model.ValueFontSize, Is.EqualTo(20 * UnitsPerPoint).Within(0.01)); + var gloss = model.Fields.Single(f => f.Field == "Gloss").Values + .Single(v => v.Value == "house"); + Assert.That(gloss.FontSize, Is.EqualTo(20 * UnitsPerPoint).Within(0.01)); + } + [Test] public void Compose_WithNoNormalStyle_ShowsValuesAtTenPoints() { From 28a76c2cbddbf7eac85b685622f32b6a6a989c42 Mon Sep 17 00:00:00 2001 From: Zachary Burnham Date: Fri, 6 Nov 2026 14:15:51 -0500 Subject: [PATCH 3/7] LT-22811: Keep the lexeme form's 120% and bold in the fallback The Lexicon Edit error fallback showed the lexeme form at Normal's size but neither scaled by its part's 120% nor bold, because the first-slice nodes it builds dropped both properties. The nodes now carry them, and the fallback applies them as the composed view does. Co-Authored-By: Claude Opus 5.5 --- Src/Common/FwAvalonia/Detail/LexiconFirstSlice.cs | 4 +++- Src/xWorks/Avalonia/Composer/LexiconEditErrorFallback.cs | 9 ++++++--- .../Avalonia/Composer/DetailComposerFontSizeTests.cs | 5 +++++ 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/Src/Common/FwAvalonia/Detail/LexiconFirstSlice.cs b/Src/Common/FwAvalonia/Detail/LexiconFirstSlice.cs index 01d386c5e2..9d6824b6ec 100644 --- a/Src/Common/FwAvalonia/Detail/LexiconFirstSlice.cs +++ b/Src/Common/FwAvalonia/Detail/LexiconFirstSlice.cs @@ -179,7 +179,9 @@ private static ViewNode StampProductLeaf(ViewNode source, string automationId, s => new ViewNode(source.StableId, ViewNodeKind.Field, labelOverride ?? source.Label, source.Abbreviation, source.Field, source.RawEditor, source.EditorClassification, source.WritingSystem, source.Visibility, source.Expansion, source.Indented, source.TargetLayout, null, - source.LocalizationKey, automationId, HostRouting.Product, helpTopicId: source.HelpTopicId); + source.LocalizationKey, automationId, HostRouting.Product, + boldEmphasis: source.BoldEmphasis, fontScalePercent: source.FontScalePercent, + helpTopicId: source.HelpTopicId); private static ViewNode Leaf(string stableId, string label, string field, string editor, string ws, string automationId) => new ViewNode(stableId, ViewNodeKind.Field, label, null, field, editor, diff --git a/Src/xWorks/Avalonia/Composer/LexiconEditErrorFallback.cs b/Src/xWorks/Avalonia/Composer/LexiconEditErrorFallback.cs index 231fc86455..39c9b21229 100644 --- a/Src/xWorks/Avalonia/Composer/LexiconEditErrorFallback.cs +++ b/Src/xWorks/Avalonia/Composer/LexiconEditErrorFallback.cs @@ -83,7 +83,7 @@ public IReadOnlyList GetValues(ViewNode fieldNode) switch (fieldNode.Field) { case LexemeFormField: - return GetLexemeFormValues(); + return GetLexemeFormValues(fieldNode); case GlossField: return GetGlossValues(); default: @@ -94,8 +94,10 @@ public IReadOnlyList GetValues(ViewNode fieldNode) // One row per current writing system, using the shared DetailValueFactory per-ws row // recipe (same as the composer), so both views render consistently; only the text reads // live here. - private IReadOnlyList GetLexemeFormValues() + private IReadOnlyList GetLexemeFormValues(ViewNode node) { + // The part's fontsize percentage and bold, as the composer applies them. + var scale = node.FontScalePercent > 0 ? node.FontScalePercent / 100.0 : 1.0; return DetailValueFactory.CreateMultiWsValues( _cache.ServiceLocator.WritingSystems.CurrentVernacularWritingSystems, ws => { @@ -103,7 +105,8 @@ private IReadOnlyList GetLexemeFormValues() if ((text == null || text.Length == 0) && ws.Handle == _cache.DefaultVernWs) text = _entry.CitationForm.get_String(ws.Handle); // legacy fallback, default ws only return text; - }, _cache.WritingSystemFactory, ws => _fontSizes.For(ws.Handle)); + }, _cache.WritingSystemFactory, ws => _fontSizes.For(ws.Handle) * scale, + node.BoldEmphasis); } private IReadOnlyList GetGlossValues() diff --git a/Src/xWorks/xWorksTests/Avalonia/Composer/DetailComposerFontSizeTests.cs b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailComposerFontSizeTests.cs index e049acaa11..41b2e6937a 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Composer/DetailComposerFontSizeTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailComposerFontSizeTests.cs @@ -116,6 +116,11 @@ public void ErrorFallback_ShowsValuesAtTheNormalStyleSize() var gloss = model.Fields.Single(f => f.Field == "Gloss").Values .Single(v => v.Value == "house"); Assert.That(gloss.FontSize, Is.EqualTo(20 * UnitsPerPoint).Within(0.01)); + var lexemeForm = model.Fields.Single(f => f.Field == "Form").Values + .Single(v => v.Value == "casa"); + Assert.That(lexemeForm.FontSize, Is.EqualTo(1.2 * 20 * UnitsPerPoint).Within(0.01), + "the lexeme form keeps its 120%, as in the composed view"); + Assert.That(lexemeForm.Bold, Is.True, "and its bold"); } [Test] From 70f44112c7f9cf53eea757be3176d34f59abe4d1 Mon Sep 17 00:00:00 2001 From: Zachary Burnham Date: Fri, 6 Nov 2026 14:39:10 -0500 Subject: [PATCH 4/7] LT-22811: Drop a comment that restates the fallback's sizing The comment above the fallback lexeme form's scale named the two inputs the code beside it already reads. Co-Authored-By: Claude Opus 5.5 --- Src/xWorks/Avalonia/Composer/LexiconEditErrorFallback.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/Src/xWorks/Avalonia/Composer/LexiconEditErrorFallback.cs b/Src/xWorks/Avalonia/Composer/LexiconEditErrorFallback.cs index 39c9b21229..8cfa43f38c 100644 --- a/Src/xWorks/Avalonia/Composer/LexiconEditErrorFallback.cs +++ b/Src/xWorks/Avalonia/Composer/LexiconEditErrorFallback.cs @@ -96,7 +96,6 @@ public IReadOnlyList GetValues(ViewNode fieldNode) // live here. private IReadOnlyList GetLexemeFormValues(ViewNode node) { - // The part's fontsize percentage and bold, as the composer applies them. var scale = node.FontScalePercent > 0 ? node.FontScalePercent / 100.0 : 1.0; return DetailValueFactory.CreateMultiWsValues( _cache.ServiceLocator.WritingSystems.CurrentVernacularWritingSystems, ws => From e695725fddf51ea3c639361d408e863f4056fc95 Mon Sep 17 00:00:00 2001 From: Zachary Burnham Date: Fri, 6 Nov 2026 22:04:58 -0500 Subject: [PATCH 5/7] LT-22811: Test that RefreshDisplay recomposes the Avalonia view RecordEditView.RefreshDisplay, which View > Refresh and the Styles dialog reach, had no test. One test now changes Normal's size, checks that the change alone leaves the Avalonia view as it was, then that RefreshDisplay recomposes it at the new size and returns false so the views inside still refresh. Another checks that in the WinForms UI mode it returns false and builds no Avalonia view. Co-Authored-By: Claude Opus 5.5 --- .../Hosting/RecordEditViewSwitchTests.cs | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/RecordEditViewSwitchTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/RecordEditViewSwitchTests.cs index 7598004d3d..7d6aaca7a2 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Hosting/RecordEditViewSwitchTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/RecordEditViewSwitchTests.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Collections.Generic; +using System.Linq; using System.Reflection; using System.Windows.Forms; using System.Xml; @@ -9,6 +10,9 @@ using SIL.FieldWorks.Common.FwAvalonia; using SIL.FieldWorks.Common.FwUtils; using SIL.LCModel; +using SIL.LCModel.Core.KernelInterfaces; +using SIL.LCModel.Core.Text; +using SIL.LCModel.DomainServices; using SIL.LCModel.Infrastructure; using XCore; @@ -190,6 +194,95 @@ public void RegisteredRecordEditTools_ResolveToAvalonia_WhenUIModeIsNew(string t toolValue); } + // LT-22811: View > Refresh and closing the Styles dialog reach the Avalonia view through + // RefreshDisplay, which recomposes it at Normal's new size. + [Test] + public void RefreshDisplay_InNewMode_RecomposesAtNormalsNewSize() + { + m_propertyTable.SetProperty("UIMode", "New", true); + m_propertyTable.SetPropertyPersistence("UIMode", false); + + LoadRecordEditView(); + DrainMediatorAndIdleQueues(); + + var control = m_propertyTable.GetValue("currentContentControlObject", null) as RecordEditView; + Assert.That(control, Is.Not.Null); + EnsureCurrentRecord(control); + Assert.That(GetPrivateField(control, "m_avaloniaEntryForm")?.ShownDetail, + Is.Not.Null, "precondition: the Avalonia view shows the entry"); + + IStStyle normal = null; + ITsTextProps originalRules = null; + var createdNormal = false; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + normal = Cache.LangProject.StylesOC + .FirstOrDefault(s => s.Name == StyleServices.NormalStyleName); + createdNormal = normal == null; + if (createdNormal) + { + normal = Cache.ServiceLocator.GetInstance().Create(); + Cache.LangProject.StylesOC.Add(normal); + normal.Name = StyleServices.NormalStyleName; + normal.Type = StyleType.kstParagraph; + } + originalRules = normal.Rules; + var props = TsStringUtils.MakePropsBldr(); + props.SetIntPropValues((int)FwTextPropType.ktptFontSize, + (int)FwTextPropVar.ktpvMilliPoint, 20000); + normal.Rules = props.GetTextProps(); + }); + try + { + DrainMediatorAndIdleQueues(); + Assert.That(ShownValueFontSize(control), Is.Not.EqualTo(20 * UnitsPerPoint).Within(0.01), + "precondition: changing the style alone does not recompose the view"); + + Assert.That(control.RefreshDisplay(), Is.False, + "the views inside still refresh themselves"); + DrainMediatorAndIdleQueues(); + + Assert.That(ShownValueFontSize(control), Is.EqualTo(20 * UnitsPerPoint).Within(0.01)); + } + finally + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + if (createdNormal) + Cache.LangProject.StylesOC.Remove(normal); + else + normal.Rules = originalRules; + }); + } + } + + // LT-22811: in the WinForms UI mode, RefreshDisplay leaves the refresh to the + // views inside. + [Test] + public void RefreshDisplay_InLegacyMode_LeavesTheRefreshToTheViewsInside() + { + m_propertyTable.SetProperty("UIMode", "Legacy", true); + m_propertyTable.SetPropertyPersistence("UIMode", false); + + LoadRecordEditView(); + DrainMediatorAndIdleQueues(); + + var control = m_propertyTable.GetValue("currentContentControlObject", null) as RecordEditView; + Assert.That(control, Is.Not.Null); + EnsureCurrentRecord(control); + + Assert.That(control.RefreshDisplay(), Is.False); + DrainMediatorAndIdleQueues(); + Assert.That(GetPrivateFieldValue(control, "m_avaloniaEntryForm"), Is.Null, + "no Avalonia view is built"); + } + + // Avalonia sizes text in device-independent units, 96 to the inch. + private const double UnitsPerPoint = 96.0 / 72.0; + + private static double ShownValueFontSize(RecordEditView control) + => GetPrivateField(control, "m_avaloniaEntryForm").ShownDetail.ValueFontSize; + private void LoadRecordEditView() { LoadRecordEditView("lexiconEdit"); From 93e400e6a15e84741f6ee8941395a71aa0d843e2 Mon Sep 17 00:00:00 2001 From: Zachary Burnham Date: Fri, 6 Nov 2026 22:26:13 -0500 Subject: [PATCH 6/7] LT-22811: Size ghost rows by their writing system A ghost row's value carried no size, so text typed into it showed at Normal's analysis size whatever writing system it went into. It now shows at Normal's size for the ghost's writing system. The part's fontsize percentage is applied in one place, NormalStyleFontSizes.For(ws, percent), for both the composer and the Lexicon Edit error fallback. A headless test confirms a value's Copy menu keeps its own size when the value size applies. Co-Authored-By: Claude Opus 5.5 --- .../DetailViewingParityTests.cs | 23 ++++++++++++++++ .../Avalonia/Composer/DetailComposer.cs | 13 +++++++--- .../Composer/LexiconEditErrorFallback.cs | 3 +-- .../Avalonia/Composer/NormalStyleFontSizes.cs | 6 +++++ .../Composer/DetailComposerFontSizeTests.cs | 26 +++++++++++++++++++ 5 files changed, 66 insertions(+), 5 deletions(-) diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailViewingParityTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailViewingParityTests.cs index 5f0a18e1b8..14d063cfd3 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailViewingParityTests.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailViewingParityTests.cs @@ -725,6 +725,29 @@ TextPresenter Drawn(string text) => Box(view, text).GetVisualDescendants() Is.EqualTo(SIL.FieldWorks.Common.FwAvalonia.FwAvaloniaDensity.WsAbbrevFontSize)); } + // LT-22811: a value's own Copy menu keeps the menu size; the value size is for the data. + [AvaloniaTest] + public void ValueFontSize_LeavesTheValuesCopyMenuAtItsOwnSize() + { + double MenuTextSize(DataTree view) + { + var box = view.GetVisualDescendants().OfType().Single(b => b.Text == "value"); + var flyout = (MenuFlyout)box.ContextFlyout; + flyout.ShowAt(box); + Dispatcher.UIThread.RunJobs(); + var size = flyout.Items.OfType().First() + .GetVisualDescendants().OfType().First().FontSize; + flyout.Hide(); + return size; + } + + var themed = MenuTextSize(Show(Text("t", "Gloss", 0))); + var sized = MenuTextSize(ShowSized(20, Text("t", "Gloss", 0))); + + Assert.That(sized, Is.EqualTo(themed), "the menu text is the size it is without a value size"); + Assert.That(sized, Is.Not.EqualTo(20)); + } + // LT-22811: a styled value shows through a read-only display until clicked; it keeps the // value's own size and bold, as the lexeme form's 120% emphasis needs. [AvaloniaTest] diff --git a/Src/xWorks/Avalonia/Composer/DetailComposer.cs b/Src/xWorks/Avalonia/Composer/DetailComposer.cs index 5fa3555eff..49f415a99f 100644 --- a/Src/xWorks/Avalonia/Composer/DetailComposer.cs +++ b/Src/xWorks/Avalonia/Composer/DetailComposer.cs @@ -1359,9 +1359,8 @@ private IReadOnlyList CreateTextRowValues(int hvo, int flid, Cell var dataSeen = false; // Each alternative shows at the Normal style's size for its writing system, // scaled by the part's fontsize percentage (the lexeme form's 120%). - var scale = node.FontScalePercent > 0 ? node.FontScalePercent / 100.0 : 1.0; Func fontSize = - ws => _normalFontSizes.For(ws.Handle) * scale; + ws => _normalFontSizes.For(ws.Handle, node.FontScalePercent); // The per-ws value rows build through the shared factory // (LexiconEditErrorFallback uses the same one), this path only supplies the text. IReadOnlyList values; @@ -3323,10 +3322,16 @@ private void AddGhostPrompt(ViewNode node, ICmObject obj, int depth) return; var stableId = StableId(node, obj) + DetailField.GhostStableIdSuffix; + // Typed text goes into the ghost's writing system, so it shows at that size. + var ghostFontSize = ghost == null ? 0 : _normalFontSizes.For(ghost.WsHandle); AddField(new DetailField(stableId, label, node.Field, node.WritingSystem, DetailFieldKind.Text, node.EditorClassification, node.AutomationId, node.LocalizationKey, node.Routing, - new List { new DetailWsValue(ghost?.WsAbbrev ?? "", string.Empty, wsTag: ghost?.WsTag) }, + new List + { + new DetailWsValue(ghost?.WsAbbrev ?? "", string.Empty, fontSize: ghostFontSize, + wsTag: ghost?.WsTag) + }, null, null, isEditable: ghost != null, indent: depth, menuId: node.MenuId, hotlinksId: node.HotlinksId, objectHvo: obj.Hvo, ghostPrompt: prompt)); @@ -3339,6 +3344,7 @@ private sealed class GhostCreation { public string WsAbbrev; public string WsTag; // unique IETF tag (ws.Id), the edit-routing identity + public int WsHandle; public Func Setter; } @@ -3408,6 +3414,7 @@ private GhostCreation ResolveGhostCreation(ViewNode node, ICmObject obj) { WsAbbrev = ws.Abbreviation, WsTag = ws.Id, + WsHandle = ws.Handle, Setter = (wsKey, value) => { // The closure outlives the edit session: a Cancel rolls MakeNewObject diff --git a/Src/xWorks/Avalonia/Composer/LexiconEditErrorFallback.cs b/Src/xWorks/Avalonia/Composer/LexiconEditErrorFallback.cs index 8cfa43f38c..c600407867 100644 --- a/Src/xWorks/Avalonia/Composer/LexiconEditErrorFallback.cs +++ b/Src/xWorks/Avalonia/Composer/LexiconEditErrorFallback.cs @@ -96,7 +96,6 @@ public IReadOnlyList GetValues(ViewNode fieldNode) // live here. private IReadOnlyList GetLexemeFormValues(ViewNode node) { - var scale = node.FontScalePercent > 0 ? node.FontScalePercent / 100.0 : 1.0; return DetailValueFactory.CreateMultiWsValues( _cache.ServiceLocator.WritingSystems.CurrentVernacularWritingSystems, ws => { @@ -104,7 +103,7 @@ private IReadOnlyList GetLexemeFormValues(ViewNode node) if ((text == null || text.Length == 0) && ws.Handle == _cache.DefaultVernWs) text = _entry.CitationForm.get_String(ws.Handle); // legacy fallback, default ws only return text; - }, _cache.WritingSystemFactory, ws => _fontSizes.For(ws.Handle) * scale, + }, _cache.WritingSystemFactory, ws => _fontSizes.For(ws.Handle, node.FontScalePercent), node.BoldEmphasis); } diff --git a/Src/xWorks/Avalonia/Composer/NormalStyleFontSizes.cs b/Src/xWorks/Avalonia/Composer/NormalStyleFontSizes.cs index 7df41b228e..8ea70bb636 100644 --- a/Src/xWorks/Avalonia/Composer/NormalStyleFontSizes.cs +++ b/Src/xWorks/Avalonia/Composer/NormalStyleFontSizes.cs @@ -73,6 +73,12 @@ public double For(int ws) return size; } + /// + /// Normal's size for the writing system with handle , scaled by a + /// part's fontsize ; 0 or less leaves it unscaled. + /// + public double For(int ws, int percent) => For(ws) * (percent > 0 ? percent / 100.0 : 1.0); + private int Millipoints(int ws) { if (_normal == null) diff --git a/Src/xWorks/xWorksTests/Avalonia/Composer/DetailComposerFontSizeTests.cs b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailComposerFontSizeTests.cs index 41b2e6937a..9b5b6facdc 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Composer/DetailComposerFontSizeTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailComposerFontSizeTests.cs @@ -91,6 +91,32 @@ public void Compose_UsesNormalsOwnSizeForAWritingSystem() "the analysis gloss keeps Normal's size for all writing systems"); } + // Text typed into a ghost row lands in the ghost's writing system, so the row shows at + // Normal's size for that writing system. + [Test] + public void Compose_GhostRows_UseTheirWritingSystemsSize() + { + var normal = NormalStyle(20000); + var info = new StyleInfo(normal); + info.FontInfoForWs(Cache.DefaultVernWs).m_fontSize.ExplicitValue = 30000; + info.SaveToDB(normal, true, true); + + var ghosts = DetailComposer.Compose(m_entry, Cache).Model.Fields + .Where(f => f.StableId.EndsWith(DetailField.GhostStableIdSuffix) + && f.Values != null) + .SelectMany(f => f.Values) + .Where(v => !string.IsNullOrEmpty(v.WsTag)) + .ToList(); + + Assert.That(ghosts, Is.Not.Empty, "precondition: the entry shows editable ghost rows"); + foreach (var ghost in ghosts) + { + var points = Cache.WritingSystemFactory.GetWsFromStr(ghost.WsTag) == Cache.DefaultVernWs + ? 30 : 20; + Assert.That(ghost.FontSize, Is.EqualTo(points * UnitsPerPoint).Within(0.01), ghost.WsTag); + } + } + // The sizes are reused between composes, so this pins that a Styles-dialog change to // Normal still reaches the next compose. [Test] From 19b53edaafae92937efad4357d505f85d839952d Mon Sep 17 00:00:00 2001 From: Zachary Burnham Date: Tue, 10 Nov 2026 10:09:07 -0500 Subject: [PATCH 7/7] LT-22811: Size only data text with Normal, not interface text The value size was a style on every TextBlock and TextBox inside a value editor, so interface text beside the data grew with Normal too: "lit" message rows, the Unsupported placeholder, watermark prompts and tooltips. The field controls now mark the text that shows data with the fwValueText style class, and only marked text takes the value size. Everything else keeps the 11-unit surface font. Marked: value and reference-item text, chooser and dropdown option names, the option filter, the read-only per-run display, structured-text paragraphs, reversal entries and the dialog launcher's value. Co-Authored-By: Claude Opus 5.5 --- .../FwAvalonia/Detail/DetailRichTextChrome.cs | 1 + .../FwAvalonia/Detail/FwFieldControls.cs | 6 ++ .../FwAvalonia/Detail/FwOptionChooser.cs | 5 ++ .../Detail/FwReversalEntriesField.cs | 2 + .../Detail/FwStructuredTextField.cs | 1 + .../DetailViewingParityTests.cs | 66 +++++++++++++++++++ Src/Common/FwAvalonia/FwSurfaceStyles.cs | 34 +++++++--- 7 files changed, 106 insertions(+), 9 deletions(-) diff --git a/Src/Common/FwAvalonia/Detail/DetailRichTextChrome.cs b/Src/Common/FwAvalonia/Detail/DetailRichTextChrome.cs index 5001347fa6..c156b156e6 100644 --- a/Src/Common/FwAvalonia/Detail/DetailRichTextChrome.cs +++ b/Src/Common/FwAvalonia/Detail/DetailRichTextChrome.cs @@ -60,6 +60,7 @@ internal static TextBlock CreatePerRunFontDisplay(DetailRichTextValue rich, Background = FwAvaloniaDensity.TransparentBrush, FlowDirection = rightToLeft ? FlowDirection.RightToLeft : FlowDirection.LeftToRight }; + FwSurfaceStyles.MarkValueText(display); AutomationProperties.SetAutomationId(display, automationId); foreach (var run in rich.Runs) diff --git a/Src/Common/FwAvalonia/Detail/FwFieldControls.cs b/Src/Common/FwAvalonia/Detail/FwFieldControls.cs index fccb422dc1..53f583bcd4 100644 --- a/Src/Common/FwAvalonia/Detail/FwFieldControls.cs +++ b/Src/Common/FwAvalonia/Detail/FwFieldControls.cs @@ -799,6 +799,7 @@ private static TextBox CreateValueBox(DetailField field, DetailWsValue value, bo Background = FwAvaloniaDensity.TransparentBrush, TextWrapping = TextWrapping.Wrap // long values wrap; the row grows vertically }; + FwSurfaceStyles.MarkValueText(box); // A voice/audio writing system has no sound player in this view yet, so the row // is read-only and says why (a distinct message from the rich-content read-only case). if (value.IsAudio) @@ -1143,6 +1144,7 @@ public FwChooserField( Text = CurrentName(field), VerticalAlignment = VerticalAlignment.Center }; + FwSurfaceStyles.MarkValueText(_valueText); ApplyEmptyValueWidth(); var content = new StackPanel { @@ -1446,6 +1448,7 @@ public FwReferenceVectorField( BorderThickness = new Thickness(0), Background = FwAvaloniaDensity.TransparentBrush }; + FwSurfaceStyles.MarkValueText(box); // Held to the width its text measures, so the last character is not // cut, and kept in step while typing. FloorWidthToText(box); @@ -1499,6 +1502,7 @@ public FwReferenceVectorField( Background = FwAvaloniaDensity.TransparentBrush }; } + FwSurfaceStyles.MarkValueText(text); AutomationProperties.SetAutomationId(text, ItemAutomationId(automationId, item.Key)); // Any button selects, so a right-click's menu acts on the item under the pointer; // focus selects too. The first item is a chip row's one Tab stop; the editors of @@ -1684,6 +1688,7 @@ public FwReferenceVectorField( Background = FwAvaloniaDensity.TransparentBrush, Watermark = FwAvaloniaStrings.AddItem }; + FwSurfaceStyles.MarkValueText(newItem); // Keeps its empty width until what is typed needs more than that. FloorWidthToText(newItem, FwAvaloniaDensity.NewItemSlotMinWidth); AutomationProperties.SetAutomationId(newItem, automationId + ".New"); @@ -2193,6 +2198,7 @@ public FwDialogLauncherField(string value, string label, Action launch) Margin = FwAvaloniaDensity.TrailingGap, Background = FwAvaloniaDensity.TransparentBrush // the value text is the hover surface }; + FwSurfaceStyles.MarkValueText(text); AutomationProperties.SetName(text, label ?? string.Empty); DockPanel.SetDock(_button, Dock.Right); diff --git a/Src/Common/FwAvalonia/Detail/FwOptionChooser.cs b/Src/Common/FwAvalonia/Detail/FwOptionChooser.cs index c3751881aa..418650a8b0 100644 --- a/Src/Common/FwAvalonia/Detail/FwOptionChooser.cs +++ b/Src/Common/FwAvalonia/Detail/FwOptionChooser.cs @@ -156,6 +156,8 @@ public FwOptionChooser(IReadOnlyList options, BorderThickness = new Thickness(0), Watermark = FwAvaloniaStrings.SearchPrompt }; + // What is typed here matches the option names, so it shows at their size. + FwSurfaceStyles.MarkValueText(_filterBox); AutomationProperties.SetAutomationId(_filterBox, automationId + ".Search"); AutomationProperties.SetName(_filterBox, FwAvaloniaStrings.SearchPrompt); _filterBox.TextChanged += (s, e) => ApplyFilter(); @@ -226,6 +228,7 @@ public FwOptionChooser(IReadOnlyList options, VerticalAlignment = VerticalAlignment.Center, Foreground = FwAvaloniaDensity.PickerForegroundBrush }; + FwSurfaceStyles.MarkValueText(_dropdownLabel); var chevron = new TextBlock { Text = "▾", // ▾ collapsed-dropdown affordance @@ -948,6 +951,7 @@ private IDataTemplate OptionTemplate() VerticalAlignment = VerticalAlignment.Center, Foreground = FwAvaloniaDensity.HotlinkBrush }; + FwSurfaceStyles.MarkValueText(create); AutomationProperties.SetAutomationId(create, _automationId + ".Create"); AutomationProperties.SetName(create, option.Name); return create; @@ -959,6 +963,7 @@ private IDataTemplate OptionTemplate() Foreground = IsOptionAvailable(option) ? FwAvaloniaDensity.PickerForegroundBrush : FwAvaloniaDensity.DisabledOptionBrush, Opacity = IsOptionAvailable(option) ? 1.0 : 0.55 }; + FwSurfaceStyles.MarkValueText(label); if (!_multiSelect) { label.Margin = new Thickness(option.Depth * 14, 0, 0, 0); diff --git a/Src/Common/FwAvalonia/Detail/FwReversalEntriesField.cs b/Src/Common/FwAvalonia/Detail/FwReversalEntriesField.cs index 71904df428..1cd2148a7f 100644 --- a/Src/Common/FwAvalonia/Detail/FwReversalEntriesField.cs +++ b/Src/Common/FwAvalonia/Detail/FwReversalEntriesField.cs @@ -370,6 +370,7 @@ private Control CreateRow(GroupState state, DetailReversalRow row, string rowId, Background = FwAvaloniaDensity.TransparentBrush, TextWrapping = TextWrapping.NoWrap }; + FwSurfaceStyles.MarkValueText(editor); box = editor; if (!string.IsNullOrEmpty(group.FontFamily)) editor.FontFamily = new FontFamily(group.FontFamily); @@ -852,6 +853,7 @@ private static Control CreateOtherWsSuffix(DetailReversalRow row, string automat Padding = FwAvaloniaDensity.EditorPadding, FlowDirection = FlowDirection.LeftToRight }; + FwSurfaceStyles.MarkValueText(block); block.Inlines.Add(new Run("[")); for (var i = 0; i < row.OtherWsForms.Count; i++) { diff --git a/Src/Common/FwAvalonia/Detail/FwStructuredTextField.cs b/Src/Common/FwAvalonia/Detail/FwStructuredTextField.cs index ee6c22a75c..b6ccfb1aa4 100644 --- a/Src/Common/FwAvalonia/Detail/FwStructuredTextField.cs +++ b/Src/Common/FwAvalonia/Detail/FwStructuredTextField.cs @@ -105,6 +105,7 @@ private Control CreateParagraphRow( Background = FwAvaloniaDensity.TransparentBrush, TextWrapping = TextWrapping.Wrap }; + FwSurfaceStyles.MarkValueText(box); var paragraphStop = _paragraphStops.Add(box); AutomationProperties.SetAutomationId(box, automationId + ".Para." + index); AutomationProperties.SetName(box, FwAvaloniaStrings.StructuredTextParagraphName(field.Label ?? automationId, index + 1)); diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailViewingParityTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailViewingParityTests.cs index 14d063cfd3..0324d148ca 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailViewingParityTests.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailViewingParityTests.cs @@ -725,6 +725,72 @@ TextPresenter Drawn(string text) => Box(view, text).GetVisualDescendants() Is.EqualTo(SIL.FieldWorks.Common.FwAvalonia.FwAvaloniaDensity.WsAbbrevFontSize)); } + // LT-22811: only the data in the value column takes the value size. Message rows, the + // Unsupported placeholder, a watermark prompt and a button caption keep the surface font. + [AvaloniaTest] + public void ValueFontSize_SizesTheData_NotTheInterfaceTextBesideIt() + { + const double normalSize = 20; + DetailField[] Fields() => new[] + { + new DetailField("lit", "", "Message", null, DetailFieldKind.Literal, + EditorClassification.Known, "lit", null, HostRouting.Inherit, + new List { new DetailWsValue("", "A message") }, null, null), + new DetailField("bad", "Odd", "Odd", null, DetailFieldKind.Unsupported, + EditorClassification.Unknown, "bad", null, HostRouting.Inherit, null, null, null), + new DetailField("ghost", "Lexeme Form", "LexemeForm", null, DetailFieldKind.Text, + EditorClassification.Known, "ghost", null, HostRouting.Inherit, + new List { new DetailWsValue("vern", "") }, null, null, + isEditable: true, indent: 0, ghostPrompt: "Click here to add Lexeme Form"), + new DetailField("pub", "Publish In", "PublishIn", null, DetailFieldKind.ReferenceVector, + EditorClassification.Known, "pub", null, HostRouting.Inherit, null, + new List + { + new DetailChoiceOption("p1", "Main Dictionary"), + new DetailChoiceOption("p2", "Pocket") + }, + null, isEditable: true, indent: 0, + items: new List { new DetailChoiceOption("p1", "Main Dictionary") }) + }; + TextBlock Block(Control root, string text) + => root.GetVisualDescendants().OfType().First(t => t.Text == text); + double Caption(Control root) + { + // The add button is a hover affordance, outside the tree until revealed. + var vector = root.GetVisualDescendants().OfType().Single(); + var add = ((IHoverAffordanceProvider)vector).HoverAffordances.OfType