diff --git a/Src/Common/FwAvalonia/Detail/DataTree.cs b/Src/Common/FwAvalonia/Detail/DataTree.cs index a5d29ede26..a45c14bf78 100644 --- a/Src/Common/FwAvalonia/Detail/DataTree.cs +++ b/Src/Common/FwAvalonia/Detail/DataTree.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2026 SIL International +// 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) @@ -968,6 +968,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); @@ -1149,6 +1150,11 @@ private void WireLabelContextMenu(Control cell, DetailField field, }, Avalonia.Interactivity.RoutingStrategies.Bubble); } + // 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 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/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/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 91dcc132f4..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) @@ -937,6 +938,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 @@ -1137,6 +1144,7 @@ public FwChooserField( Text = CurrentName(field), VerticalAlignment = VerticalAlignment.Center }; + FwSurfaceStyles.MarkValueText(_valueText); ApplyEmptyValueWidth(); var content = new StackPanel { @@ -1440,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); @@ -1493,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 @@ -1678,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"); @@ -2187,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/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/Common/FwAvalonia/FwAvaloniaTests/DetailViewingParityTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailViewingParityTests.cs index aabae6d3bb..84ac71d0aa 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(); @@ -687,6 +690,162 @@ 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: 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