diff --git a/Src/Common/FwAvalonia/Detail/DataTree.cs b/Src/Common/FwAvalonia/Detail/DataTree.cs index 2437d1a60a..a8b1397140 100644 --- a/Src/Common/FwAvalonia/Detail/DataTree.cs +++ b/Src/Common/FwAvalonia/Detail/DataTree.cs @@ -87,12 +87,12 @@ public sealed class DataTree : UserControl, IDetailPopupSink new List<(TextBlock Label, double Reserved)>(); /// - /// Optional expansion-state hooks (11.8): supplies the + /// Optional expansion-state hooks: supplies the /// persisted state per header stable id (overriding the layout's initial state) and /// records toggles, so collapse state survives record - /// switches/re-shows -- the legacy PropertyTable expansion persistence. + /// switches and re-shows. /// / persist - /// the splitter position the same way (11.15): the host owns the remembered width so it + /// the splitter position the same way: the host owns the remembered width so it /// survives re-shows WITHOUT a process-global field -- each host/window keeps its own. /// public DataTree(DetailModel model, IDetailEditContext editContext = null, @@ -162,7 +162,7 @@ public DataTree(DetailModel model, IDetailEditContext editContext = null, var splitter = new GridSplitter { ResizeDirection = GridResizeDirection.Columns, - Background = FwAvaloniaDensity.TransparentBrush, // legacy splitter is window-colored/invisible (12.6) + Background = FwAvaloniaDensity.TransparentBrush, // the splitter is invisible, not chrome Width = FwAvaloniaDensity.SplitterWidth }; AutomationProperties.SetAutomationId(splitter, "DataTree.Splitter"); @@ -219,8 +219,7 @@ public DataTree(DetailModel model, IDetailEditContext editContext = null, AddHandler(Avalonia.Input.InputElement.KeyDownEvent, OnViewKeyDown, Avalonia.Interactivity.RoutingStrategies.Bubble); - // Auto-save (14.4): legacy slices commit as the user moves on -- any editor losing - // focus + // Auto-save: the view commits as the user moves on, so any editor losing focus // while a session is open commits it (validation-gated; one undo step per field). AddHandler(Avalonia.Input.InputElement.LostFocusEvent, (s, e) => { @@ -354,8 +353,8 @@ private static void ApplyRowTabIndex(Control root, int row) /// public event EventHandler InteractionCompleted; - // 14.4: no Save/Cancel buttons -- the legacy view saves as you go. The footer carries - // only the inline validation messages (a failed autosave is never silent). + // No Save/Cancel buttons: the view saves as you go. The footer carries only the + // inline validation messages (a failed autosave is never silent). private Control CreateEditFooter() { _validationBlock = new TextBlock @@ -482,7 +481,7 @@ private void RebuildItems() } // A header's recorded expansion state prefers this session's own toggles over the - // host-supplied persisted state (11.8), so a toggle applies immediately rather than + // host-supplied persisted state, so a toggle applies immediately rather than // waiting on the host's round-trip. private bool? GetRecordedExpansion(string stableId) => _expansionState.TryGetValue(stableId, out var v) ? (bool?)v : _getExpansionState?.Invoke(stableId); @@ -503,8 +502,8 @@ private Control BuildItem(int index, DetailField field) return content; } - // 12.1: the legacy 1px inter-slice rule renders as a per-item bottom border; the last - // field gets none. + // The 1px inter-slice rule renders as a per-item bottom border; the last field + // gets none. private Control ApplyRule(Control content, int index) { if (index >= Model.Fields.Count - 1) @@ -578,7 +577,7 @@ private FieldContent AddField(int row, DetailField field) Text = field.Label ?? field.Field ?? string.Empty, FontWeight = FontWeight.Bold, Margin = new Thickness(indent.Left, 4, 0, FwAvaloniaDensity.FieldSpacing), - // 14.2: a null background only hit-tests the glyphs; the whole header area + // A null background only hit-tests the glyphs; the whole header area // must take the right-click. Background = FwAvaloniaDensity.TransparentBrush }; @@ -586,7 +585,7 @@ private FieldContent AddField(int row, DetailField field) AutomationProperties.SetAutomationId(header, automationId); AutomationProperties.SetName(header, field.Label ?? string.Empty); - // 13.3/13.5: the header answers right-click with its slice menu; the hover + // The header answers right-click with its slice menu; the hover // "..." field-menu button (in a thin gutter to the left of the header) // opens the section menu/hotlinks. var headerCell = WrapWithFieldMenu(header, field, automationId, out var headerKebab); @@ -599,9 +598,9 @@ private FieldContent AddField(int row, DetailField field) // through the existing host bridge identically. var hotlinkStrip = CreateHotlinkStrip(field, automationId, indent); - // Viewing parity (11.15): top-level sections get the legacy heavy-weight separator rule. - // The header cell and its inline hotlink strip always travel together (the strip is part of - // the header row, hidden/shown with it by the collapse logic). + // Top-level sections get the heavy-weight separator rule. The header cell and + // its hotlink strip travel together: the strip is part of the header row, + // hidden and shown with it. Control headerControl; if (field.Indent == 0 && row > 0) { @@ -658,15 +657,14 @@ private FieldContent AddField(int row, DetailField field) // this // local value wins for our own TextBlock and keeps labels regular, like legacy. FontWeight = FontWeight.Normal, - // 14.2: a null background only hit-tests the glyphs; the whole label area must - // take - // the right-click for the slice menu. + // A null background only hit-tests the glyphs; the whole label area must + // take the right-click for the slice menu. Background = FwAvaloniaDensity.TransparentBrush }; _labelBlocks.Add((labelBlock, labelReserved)); AutomationProperties.SetAutomationId(labelBlock, automationId + ".Label"); AutomationProperties.SetName(labelBlock, field.Label ?? field.Field ?? string.Empty); - ToolTip.SetTip(labelBlock, field.Label ?? field.Field); // 11.17: legacy label tooltips + ToolTip.SetTip(labelBlock, field.Label ?? field.Field); // the label text is its own tip var editor = CreateEditor(field, automationId); editor.Margin = new Thickness(0, 0, 0, FwAvaloniaDensity.FieldSpacing); @@ -680,7 +678,7 @@ private FieldContent AddField(int row, DetailField field) vector.SelectionChanged += OnVectorSelectionChanged; } - // 13.3: the field's slice menu opens from the label cell's right-click or the + // The field's slice menu opens from the label cell's right-click or the // gutter "..." button; the editor's current item rides each request it raises. var labelCell = WrapWithFieldMenu(labelBlock, field, automationId, out var labelKebab, editor as IDetailItemSelection); diff --git a/Src/Common/FwAvalonia/Detail/DetailFocusMemory.cs b/Src/Common/FwAvalonia/Detail/DetailFocusMemory.cs index 711d00edd5..12d25c50dc 100644 --- a/Src/Common/FwAvalonia/Detail/DetailFocusMemory.cs +++ b/Src/Common/FwAvalonia/Detail/DetailFocusMemory.cs @@ -13,7 +13,7 @@ namespace SIL.FieldWorks.Common.FwAvalonia.Detail /// /// Keeps keyboard focus stable across detail-view re-shows. The host re-resolves and REPLACES the /// whole detail view after every committed edit and every delivered external refresh; without - /// this, tabbing out of a field (which auto-commits, 14.4) would tear down the editor the user + /// this, tabbing out of a field (which auto-commits) would tear down the editor the user /// just moved into and dump focus on the floor. Capture reads the focused editor's stable /// automation id (and caret) from the outgoing view; restore finds the same id in the incoming /// view and gives it focus -- automation ids are stable per field/writing system by design, diff --git a/Src/Common/FwAvalonia/Detail/DetailMenuFlyout.cs b/Src/Common/FwAvalonia/Detail/DetailMenuFlyout.cs index 0cbd69e545..b0fa79ac87 100644 --- a/Src/Common/FwAvalonia/Detail/DetailMenuFlyout.cs +++ b/Src/Common/FwAvalonia/Detail/DetailMenuFlyout.cs @@ -9,7 +9,7 @@ namespace SIL.FieldWorks.Common.FwAvalonia.Detail { /// - /// Framework-neutral context-menu item (15.1): what the host resolved from its menu system + /// Framework-neutral context-menu item: what the host resolved from its menu system /// (for FieldWorks, the xCore ChoiceGroup -- labels, enablement, checkmarks, submenus, and an /// execute action that dispatches through the mediator). FwAvalonia renders these natively; /// it knows nothing about xCore, preserving the engine-isolation boundary. @@ -45,10 +45,9 @@ private DetailMenuItem() /// /// Renders host-built trees as a native Avalonia - /// (15.1) -- the same items, enablement, checkmarks, and submenus - /// the - /// legacy WinForms adapter menu shows, rendered with native Avalonia controls. Density: every item carries the - /// explicit compact padding/height of the legacy WinForms menus + /// -- the same items, enablement, checkmarks and submenus the + /// host resolved, rendered with native Avalonia controls. Density: every item carries + /// explicit compact padding and height /// (/, /// not the Fluent theme defaults); long menus keep the presenter's scrolling. /// diff --git a/Src/Common/FwAvalonia/Detail/FwFieldControls.cs b/Src/Common/FwAvalonia/Detail/FwFieldControls.cs index f45808e0fc..8c72d11e9e 100644 --- a/Src/Common/FwAvalonia/Detail/FwFieldControls.cs +++ b/Src/Common/FwAvalonia/Detail/FwFieldControls.cs @@ -753,7 +753,7 @@ private void AddValueRow(DetailField field, string automationId, Children.Add(rowPanel); } - // Legacy look (12.3): small raised blue abbreviation, a superscript-style label kept in its + // A small raised blue abbreviation, a superscript-style label kept in its // own fixed gutter column (see the row Grid below) so a bold vernacular value can never crowd // or overlap it. ClipToBounds keeps an unusually long abbreviation inside the gutter width // rather than bleeding into the value column. @@ -788,7 +788,7 @@ private static TextBox CreateValueBox(DetailField field, DetailWsValue value, bo FlowDirection = value.RightToLeft ? FlowDirection.RightToLeft : FlowDirection.LeftToRight, BorderThickness = new Thickness(0), Background = FwAvaloniaDensity.TransparentBrush, - TextWrapping = TextWrapping.Wrap // 14.5: long values wrap; the row grows vertically + TextWrapping = TextWrapping.Wrap // long values wrap; the row grows vertically }; // 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). @@ -801,7 +801,7 @@ private static TextBox CreateValueBox(DetailField field, DetailWsValue value, bo if (value.FontSize > 0) box.FontSize = value.FontSize; if (value.Bold) - box.FontWeight = FontWeight.Bold; // legacy (11.15) + box.FontWeight = FontWeight.Bold; // the value's own metadata asked for bold return box; } @@ -809,9 +809,8 @@ private void WireGhostPrompt(TextBox box, DetailField field) { if (!string.IsNullOrEmpty(field.GhostPrompt)) { - // 14.1: the legacy ghost add-prompt is a watermark -- it disappears the moment - // the - // user clicks in (focus), and reappears only if they leave without typing. + // The ghost add-prompt is a watermark: it disappears the moment the user + // clicks in, and reappears only if they leave without typing. box.Watermark = field.GhostPrompt; EventHandler ghostGot = (s2, e2) => box.Watermark = string.Empty; EventHandler ghostLost = (s2, e2) => @@ -836,7 +835,7 @@ private bool WireBridgeContextMenu(TextBox box, DetailField field, var hasBridge = menuRequested != null && !string.IsNullOrEmpty(field.ContextMenuId); if (hasBridge) { - // 15.2: exactly ONE menu -- drop the TextBox flyout (Cut/Copy/Paste) so + // Exactly ONE menu -- drop the TextBox flyout (Cut/Copy/Paste) so // only the bridged menu shows. Tunnelling puts this handler ahead of // anything the box or the whole-row handler would open. box.ContextFlyout = null; @@ -892,7 +891,7 @@ private static Grid CreateRowPanel(TextBlock abbrev, Control valueContent, bool { var rowPanel = new Grid { - // 14.2: a null background only hit-tests the glyphs -- the whole row must + // A null background only hit-tests the glyphs -- the whole row must // receive hover/right-click over the gaps too. Background = FwAvaloniaDensity.TransparentBrush }; @@ -1340,13 +1339,13 @@ private static Action FindSink(FlyoutBase flyout) /// legacy STORES an invalid environment and marks it rather than refusing it. /// /// CREATE-ON-TYPE (opt-in): a row whose edit context implements for it also lets the user mint a target object by typing + /// cref="IReferenceTextEditing"/> for it also lets the user mint a target object by typing /// into the picker's filter box -- the list offers a create row when the text matches /// nothing. This is what makes an environments row reach an environment the project does not /// own yet, which the picker alone cannot do. Every other vector row passes allowCreate: /// false and is unaffected. /// - public sealed class FwReferenceVectorField : StackPanel, IHoverAffordanceProvider, + public sealed class FwReferenceVectorField : WrapPanel, IHoverAffordanceProvider, IDetailItemSelection, IDisposable { private readonly List _affordances = new List(); @@ -1354,7 +1353,7 @@ public sealed class FwReferenceVectorField : StackPanel, IHoverAffordanceProvide // gear click, and the option flyout, so a recycled vector cell releases every closure. private readonly List _teardown = new List(); private readonly IReadOnlyList _items; - private readonly List _itemBlocks = new List(); + private readonly List _itemBlocks = new List(); private int _selectedIndex = -1; private bool _disposed; @@ -1377,35 +1376,92 @@ public FwReferenceVectorField( Action linkRequested = null, Action menuRequested = null) { + // Wraps, like the one Views paragraph PhoneEnvReferenceView puts its items in: + // a stack runs off the right edge, cutting the item the width ran out in. Orientation = Orientation.Horizontal; - // 14.2-style hit-testing rule: a null background only hit-tests the glyphs -- the - // WHOLE - // row must receive hover so the reveal affordances work over the gaps between items. + // A null background only hit-tests the glyphs -- the WHOLE row must receive + // hover so the reveal affordances work over the gaps between items. Background = FwAvaloniaDensity.TransparentBrush; AutomationProperties.SetAutomationId(this, automationId); AutomationProperties.SetName(this, field.Label ?? field.Field ?? automationId); _items = field.Items; var editable = editContext != null && field.IsEditable; + // A row whose domain can reconcile typed text renders its items as editors rather + // than labels, so an item already on the field can be changed and not only replaced. + var textEditing = editContext as IReferenceTextEditing; + var retypable = editable && textEditing != null + && textEditing.CanEditReferenceItemText(field); for (var index = 0; index < field.Items.Count; index++) { var item = field.Items[index]; - var text = new TextBlock + Control text; + if (retypable) { - Text = item.Name, - VerticalAlignment = VerticalAlignment.Center, - Margin = FwAvaloniaDensity.TrailingItemGap, - // 14.2: a null background only hit-tests the glyphs -- the whole item must - // take - // the right-click or the Remove flyout only opens over ink. - Background = FwAvaloniaDensity.TransparentBrush - }; + // Flat -- no border, no fill -- because every other editor in this view + // is, and an item reads as part of the row rather than a control in it. + var box = new TextBox + { + Text = item.Name, + VerticalAlignment = VerticalAlignment.Center, + Margin = FwAvaloniaDensity.TrailingItemGap, + Padding = FwAvaloniaDensity.EditorPadding, + MinWidth = 0, + MinHeight = 0, + BorderThickness = new Thickness(0), + Background = FwAvaloniaDensity.TransparentBrush + }; + // Held to the width its text measures, so the last character is not + // cut, and kept in step while typing. + FloorWidthToText(box); + var itemKey = item.Key; + // Staged when the edit FINISHES, not per keystroke: each stage reconciles + // against the project, so "/", "/_", "/_a" would leave two junk objects + // behind. This handler runs before the view's autosave. + var original = item.Name; + Action commitText = () => + { + if (box.Text == original) + return; + if (textEditing.TrySetReferenceItemText(field, itemKey, box.Text)) + gestureCompleted?.Invoke(); + }; + EventHandler commitOnBlur = + (s2, e2) => commitText(); + box.LostFocus += commitOnBlur; + EventHandler commitOnEnter = (s2, e2) => + { + if (e2.Key != Key.Enter) + return; + e2.Handled = true; + commitText(); + }; + box.KeyDown += commitOnEnter; + _teardown.Add(() => + { + box.LostFocus -= commitOnBlur; + box.KeyDown -= commitOnEnter; + }); + text = box; + } + else + { + text = new TextBlock + { + Text = item.Name, + VerticalAlignment = VerticalAlignment.Center, + Margin = FwAvaloniaDensity.TrailingItemGap, + // A null background only hit-tests the glyphs -- the whole item must + // take the right-click, or the Remove flyout only opens over ink. + Background = FwAvaloniaDensity.TransparentBrush + }; + } 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. Items are focusable (a click focuses one) but not tab stops. var itemIndex = index; text.Focusable = true; - KeyboardNavigation.SetIsTabStop(text, false); + KeyboardNavigation.SetIsTabStop(text, retypable); EventHandler focusSelect = (s, e) => SelectItem(itemIndex); text.GotFocus += focusSelect; EventHandler select = (s, e) => @@ -1429,16 +1485,26 @@ public FwReferenceVectorField( }); if (item.HasValidationMessage) { - // Legacy draws a red squiggle. Avalonia has no wavy decoration, so this is - // colour PLUS an underline -- colour alone would carry the whole signal. - text.Foreground = FwAvaloniaDensity.ValidationErrorBrush; - text.TextDecorations = TextDecorations.Underline; + // No wavy underline exists here, so this is colour PLUS an underline -- + // colour alone would carry the whole signal. An editor takes the colour + // only; its chrome owns the rest. + if (text is TextBlock label) + { + label.Foreground = FwAvaloniaDensity.ValidationErrorBrush; + label.TextDecorations = TextDecorations.Underline; + } + else if (text is TextBox editor) + { + editor.Foreground = FwAvaloniaDensity.ValidationErrorBrush; + } + ToolTip.SetTip(text, item.ValidationMessage); AutomationProperties.SetHelpText(text, item.ValidationMessage); } - if (editable) + // Backspace and Delete remove the focused item -- but on an editor those keys + // are text editing, and removal stays on the item menu. + if (editable && !retypable) { - // Backspace or Delete removes the focused item. EventHandler keyRemove = (s, e) => { if (e.Key != Key.Back && e.Key != Key.Delete) @@ -1497,11 +1563,65 @@ public FwReferenceVectorField( return; } - // The legacy empty add slot: a trailing bar (added above for the last item; one leads - // the launcher when the vector is empty) plus the chooser launcher. + // A trailing bar (added above for the last item; one leads the launcher when the + // vector is empty) plus the chooser launcher. if (field.Items.Count == 0) AddSeparatorBar(); + var canCreate = textEditing != null && textEditing.CanCreateReferenceItem(field); + if (canCreate) + { + // Typing a new one never has to go through the chooser: + // PhoneEnvReferenceView keeps an always-present empty line at the end for + // exactly this, and it is where an empty row offers somewhere to start. + var newItem = new TextBox + { + VerticalAlignment = VerticalAlignment.Center, + Margin = FwAvaloniaDensity.TrailingItemGap, + Padding = FwAvaloniaDensity.EditorPadding, + MinWidth = FwAvaloniaDensity.NewItemSlotMinWidth, + MinHeight = 0, + BorderThickness = new Thickness(0), + Background = FwAvaloniaDensity.TransparentBrush, + Watermark = FwAvaloniaStrings.AddItem + }; + // Keeps its empty width until what is typed needs more than that. + FloorWidthToText(newItem, FwAvaloniaDensity.NewItemSlotMinWidth); + AutomationProperties.SetAutomationId(newItem, automationId + ".New"); + AutomationProperties.SetName(newItem, FwAvaloniaStrings.AddItem); + Action commitNew = () => + { + var typed = newItem.Text; + if (string.IsNullOrWhiteSpace(typed)) + return; + if (textEditing.TryCreateAndAddReferenceItem(field, typed)) + gestureCompleted?.Invoke(); + }; + EventHandler newOnBlur = + (s2, e2) => commitNew(); + newItem.LostFocus += newOnBlur; + EventHandler newOnEnter = (s2, e2) => + { + if (e2.Key != Key.Enter) + return; + e2.Handled = true; + commitNew(); + }; + newItem.KeyDown += newOnEnter; + // This slot names no item, so it must not leave a stale one current: a menu + // request from here would otherwise act on whichever item was clicked before. + EventHandler newClearsSelection = (s2, e2) => ClearSelection(); + newItem.GotFocus += newClearsSelection; + _teardown.Add(() => + { + newItem.LostFocus -= newOnBlur; + newItem.KeyDown -= newOnEnter; + newItem.GotFocus -= newClearsSelection; + }); + Children.Add(newItem); + AddSeparatorBar(); + } + var addButton = new Button { Content = "+", @@ -1529,8 +1649,6 @@ public FwReferenceVectorField( // object offers it (environments find-or-create a PhEnvironment from the typed // string). Every other vector row passes allowCreate: false and behaves exactly as // before. - var creation = editContext as IReferenceItemCreation; - var canCreate = creation != null && creation.CanCreateReferenceItem(field); var picker = new FwOptionChooser(field.Options, field.SearchOptions, automationId, field.Items.Select(i => i.Key), multiSelect: true, allowCreate: canCreate, normalizeName: field.NormalizeOptionName); @@ -1562,7 +1680,7 @@ public FwReferenceVectorField( // and a failed create leaves the row untouched rather than completing the gesture. Action created = text => { - var added = canCreate && creation.TryCreateAndAddReferenceItem(field, text); + var added = canCreate && textEditing.TryCreateAndAddReferenceItem(field, text); flyout.Hide(); addButton.Focus(); if (added) @@ -1667,11 +1785,12 @@ private void SelectItem(int index) { if (index == _selectedIndex) return; - if (_selectedIndex >= 0) - _itemBlocks[_selectedIndex].Background = FwAvaloniaDensity.TransparentBrush; + // An editable item shows its own focus, so only a read-only one is painted. + if (_selectedIndex >= 0 && _itemBlocks[_selectedIndex] is TextBlock previous) + previous.Background = FwAvaloniaDensity.TransparentBrush; _selectedIndex = index; - if (index >= 0) - _itemBlocks[index].Background = FwAvaloniaDensity.SelectedRowBrush; + if (index >= 0 && _itemBlocks[index] is TextBlock current) + current.Background = FwAvaloniaDensity.SelectedRowBrush; SelectionChanged?.Invoke(this, EventArgs.Empty); } @@ -1698,6 +1817,33 @@ public void Dispose() _teardown.Clear(); } + // The width a TextBox derives from its own content falls short of what it draws, + // cutting the end off. This is the width the text measures. + private void FloorWidthToText(TextBox box, double emptyWidth = 0) + { + Action measure = () => + { + var text = box.Text ?? string.Empty; + var typeface = new Typeface(box.FontFamily, box.FontStyle, box.FontWeight); + var measured = new FormattedText(text, CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, typeface, box.FontSize, null); + box.MinWidth = Math.Max(emptyWidth, + measured.WidthIncludingTrailingWhitespace + + box.Padding.Left + box.Padding.Right); + }; + // Font size and family arrive with the theme, so the first measure waits for + // the box to be in the tree. + EventHandler onAttached = (s, e) => measure(); + EventHandler onTextChanged = (s, e) => measure(); + box.AttachedToVisualTree += onAttached; + box.TextChanged += onTextChanged; + _teardown.Add(() => + { + box.AttachedToVisualTree -= onAttached; + box.TextChanged -= onTextChanged; + }); + } + // The legacy VwSeparatorBox: a ~2px, font-height, light grey vertical bar after each item // (and fronting the add slot) -- the affordance that marks where content can be added. private void AddSeparatorBar() @@ -1757,7 +1903,7 @@ public FwDialogLauncherField(string value, string label, Action launch) VerticalAlignment = VerticalAlignment.Center, TextWrapping = TextWrapping.Wrap, Margin = FwAvaloniaDensity.TrailingGap, - Background = FwAvaloniaDensity.TransparentBrush // 14.2 again: the value text is the hover surface + Background = FwAvaloniaDensity.TransparentBrush // the value text is the hover surface }; AutomationProperties.SetName(text, label ?? string.Empty); diff --git a/Src/Common/FwAvalonia/Detail/IReferenceItemCreation.cs b/Src/Common/FwAvalonia/Detail/IReferenceItemCreation.cs deleted file mode 100644 index 779fd53087..0000000000 --- a/Src/Common/FwAvalonia/Detail/IReferenceItemCreation.cs +++ /dev/null @@ -1,45 +0,0 @@ -// 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) - -namespace SIL.FieldWorks.Common.FwAvalonia.Detail -{ - /// - /// The optional create-from-typed-text capability of a reference field, kept off the core - /// so only a context that can actually mint a target object - /// carries it. A caller acquires it with ctx as IReferenceItemCreation and treats a - /// null result as "this row picks from the list only", exactly as is acquired. - /// - /// It exists because some legacy reference slices are BOTH a chooser and a typed editor. - /// Environments is the case in hand: PhoneEnvReferenceLauncher opens a - /// SimpleListChooser over the existing environments, while its inline - /// PhoneEnvReferenceView lets the user type a new environment string that - /// ConnectToRealCache reconciles against the project -- finding an existing - /// PhEnvironment or creating one. A picker alone cannot reach an environment the - /// project does not have yet. - /// - public interface IReferenceItemCreation - { - /// - /// Whether accepts creation from typed text. Drives whether the - /// picker offers its create row at all, so it must not depend on what the user has typed. - /// - bool CanCreateReferenceItem(DetailField field); - - /// - /// Finds or creates the target object named by and stages adding - /// it to . Returns false -- without opening the session -- for a - /// field that cannot create, or for text the domain cannot turn into an object at all. - /// - /// Matching is the domain's business, not the caller's: environments match with spaces - /// stripped (legacy's RemoveSpaces), so "/ # _" and "/#_" must resolve to the SAME - /// object rather than creating a second one. - /// - /// Validity is NOT a precondition. Legacy creates the object whether or not it passes - /// domain validation and annotates the invalid one instead; rejecting it here would - /// discard what the user typed. - /// - bool TryCreateAndAddReferenceItem(DetailField field, string text); - } -} diff --git a/Src/Common/FwAvalonia/Detail/IReferenceTextEditing.cs b/Src/Common/FwAvalonia/Detail/IReferenceTextEditing.cs new file mode 100644 index 0000000000..39035a978d --- /dev/null +++ b/Src/Common/FwAvalonia/Detail/IReferenceTextEditing.cs @@ -0,0 +1,75 @@ +// 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) + +namespace SIL.FieldWorks.Common.FwAvalonia.Detail +{ + /// + /// The optional typed-text capability of a reference field -- creating a target object from + /// what the user types, and re-pointing an existing item at what they type over it. Kept off + /// the core so only a context that can actually reconcile + /// text against the domain carries it. A caller acquires it with + /// ctx as IReferenceTextEditing and treats a null result as "this row picks from the + /// list only", exactly as is acquired. + /// + /// It exists because some reference rows are BOTH a chooser and a typed editor. + /// Environments is the case in hand: PhoneEnvReferenceLauncher opens a + /// SimpleListChooser over the existing environments, while its inline + /// PhoneEnvReferenceView lets the user type a new environment string that + /// ConnectToRealCache reconciles against the project -- finding an existing + /// PhEnvironment or creating one. A picker alone cannot reach an environment the + /// project does not have yet. + /// + public interface IReferenceTextEditing + { + /// + /// Whether accepts creation from typed text. Drives whether the + /// picker offers its create row at all, so it must not depend on what the user has typed. + /// + bool CanCreateReferenceItem(DetailField field); + + /// + /// Finds or creates the target object named by and stages adding + /// it to . Returns false -- without opening the session -- for a + /// field that cannot create, or for text the domain cannot turn into an object at all. + /// + /// Matching is the domain's business, not the caller's: environments match with spaces + /// stripped (PhoneEnvReferenceView.RemoveSpaces), so "/ # _" and "/#_" must + /// resolve to the SAME object rather than creating a second one. + /// + /// Validity is NOT a precondition. The object is created whether or not it passes + /// domain validation and annotates the invalid one instead; rejecting it here would + /// discard what the user typed. + /// + bool TryCreateAndAddReferenceItem(DetailField field, string text); + + /// + /// Whether lets its existing items be retyped, which decides + /// whether the row renders them as editable text at all. Independent of any particular + /// item, and of what the user has typed. + /// + bool CanEditReferenceItemText(DetailField field); + + /// + /// Re-points the item named by at whatever + /// names, staging the change. Returns false -- without opening + /// the session -- for a field that cannot do this, or a key the field does not carry. + /// + /// Addressed by key rather than position because these rows are reference COLLECTIONS. + /// PhoneEnv is unordered, so an index means only "wherever it sat when composed". + /// + /// Reconciliation belongs to the domain, and is NOT simply "find or create". An + /// environment is identified by its text with spaces stripped. Text stripping to what + /// the item already names leaves the reference alone and RENAMES the shared object, + /// which every field referencing it then shows. Text stripping differently re-points + /// the item, creating the target only when the project has none. + /// + /// Blank text is a REMOVAL, not a rejected edit: emptying an item takes it off the + /// field and creates nothing. Whitespace alone counts as blank. + /// + /// Validity is not a precondition, for the same reason it is not on creation: the + /// value is staged and annotated, never corrected or discarded. + /// + bool TrySetReferenceItemText(DetailField field, string itemKey, string text); + } +} diff --git a/Src/Common/FwAvalonia/FwAvaloniaDensity.cs b/Src/Common/FwAvalonia/FwAvaloniaDensity.cs index ef9d00925e..4327872a7d 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaDensity.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaDensity.cs @@ -321,6 +321,13 @@ public static class FwAvaloniaDensity /// public static double PickerMinWidth => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_PickerMinWidth); + /// + /// Empty width of the slot that types a NEW reference-vector item. It sits inline among + /// the existing items and grows with what is typed, so this is only how much of a target + /// it offers when empty. + /// + public static double NewItemSlotMinWidth => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_NewItemSlotMinWidth); + /// /// The DETERMINISTIC, GLOBAL small-glyph icon size (px), the gear/kebab counterpart of /// -- the same 14px so every small glyph (checkbox, radio, diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/Detail/RetypableVectorItemTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/Detail/RetypableVectorItemTests.cs new file mode 100644 index 0000000000..9b6dd70def --- /dev/null +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/Detail/RetypableVectorItemTests.cs @@ -0,0 +1,366 @@ +// 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 System.Linq; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Headless.NUnit; +using Avalonia.Input; +using Avalonia.Threading; +using Avalonia.VisualTree; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia.Detail; +using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; + +namespace FwAvaloniaTests.Detail +{ + /// + /// A reference-vector row whose domain can reconcile typed text renders its items as editors, + /// so an item already on the field can be changed rather than only added and removed. Every + /// other vector row keeps the read-only items it has always had, which most of these pin. + /// + [TestFixture] + public class RetypableVectorItemTests + { + /// Records what the row stages, and whether it claims the capability. + private sealed class FakeTextEditing : IDetailEditContext, IReferenceTextEditing + { + public bool Retypable = true; + public bool SetResult = true; + public readonly List<(string Key, string Text)> Edits = new List<(string, string)>(); + + public bool CanEditReferenceItemText(DetailField field) => Retypable; + + public bool TrySetReferenceItemText(DetailField field, string itemKey, string text) + { + Edits.Add((itemKey, text)); + return SetResult; + } + + public bool Creatable; + public bool CreateResult = true; + public readonly List Created = new List(); + + public bool CanCreateReferenceItem(DetailField field) => Creatable; + + public bool TryCreateAndAddReferenceItem(DetailField field, string text) + { + Created.Add(text); + return CreateResult; + } + + public bool IsOpen => false; + public bool TrySetText(DetailField f, string ws, string v) => false; + public bool TrySetRichText(DetailField f, string ws, DetailRichTextValue v) => false; + public bool TrySetOption(DetailField f, string key) => false; + public bool TryAddReferenceItem(DetailField f, string key) => false; + public bool TryRemoveReferenceItem(DetailField f, string key) => true; + public bool TryMoveReferenceItem(DetailField f, string key, bool forward) => false; + public bool TryResetReferenceOrder(DetailField f) => false; + public IReadOnlyList Validate() => new List(); + public void Commit() { } + public void Cancel() { } + } + + private static DetailField Row() => new DetailField( + "MoStemAllomorph/x/#0", "Environments", "PhoneEnv", null, + DetailFieldKind.ReferenceVector, EditorClassification.Known, "PhoneEnv", null, + HostRouting.Inherit, null, null, null, isEditable: true, + items: new List + { + new DetailChoiceOption("e1", "/_#"), + new DetailChoiceOption("e2", "/_a") + }); + + private static DetailField RowOf(params string[] names) => new DetailField( + "MoStemAllomorph/x/#0", "Environments", "PhoneEnv", null, + DetailFieldKind.ReferenceVector, EditorClassification.Known, "PhoneEnv", null, + HostRouting.Inherit, null, null, null, isEditable: true, + items: names.Select((n, i) => new DetailChoiceOption("e" + i, n)).ToList()); + + private static (FwReferenceVectorField Row, Window Window) Show( + FakeTextEditing context, System.Action gestureCompleted = null) + { + var row = new FwReferenceVectorField(Row(), "PhoneEnv", context, gestureCompleted); + var window = new Window { Content = row, Width = 480, Height = 200 }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + return (row, window); + } + + // Fails with the reason rather than handing back null, so a row that rendered labels + // says so instead of surfacing as a NullReferenceException three lines later. + private static TextBox NewItemSlot(FwReferenceVectorField row) + => row.GetVisualDescendants().OfType().FirstOrDefault( + b => AutomationProperties.GetAutomationId(b) == "PhoneEnv.New"); + + private static void PressEnter(TextBox box) + { + box.RaiseEvent(new KeyEventArgs + { + RoutedEvent = InputElement.KeyDownEvent, + Key = Key.Enter + }); + Dispatcher.UIThread.RunJobs(); + } + + private static TextBox Editor(FwReferenceVectorField row, string key) + { + var box = row.GetVisualDescendants().OfType().FirstOrDefault( + b => AutomationProperties.GetAutomationId(b) + == FwReferenceVectorField.ItemAutomationId("PhoneEnv", key)); + Assert.That(box, Is.Not.Null, "the row rendered no editor for item '" + key + "'"); + return box; + } + + /// + /// PhoneEnvReferenceView puts every item in one Views paragraph, which breaks to a new + /// line when it runs out of width. A horizontal stack ran off the right edge instead, + /// cutting whichever item the width ran out in -- the end of an environment simply + /// disappeared. + /// + [AvaloniaTest] + public void ARowNarrowerThanItsItems_WrapsThemRatherThanCuttingOne() + { + var row = new FwReferenceVectorField( + RowOf("/_#", "/_a", "/ _ zt", "/_[V]", "/_[C]"), "PhoneEnv", + new FakeTextEditing(), null); + var host = new Border { Child = row, Width = 150 }; + var window = new Window { Content = host, Width = 200, Height = 300 }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + Dispatcher.UIThread.RunJobs(); + + var overhang = row.Children + .Where(c => c.Bounds.Right > row.Bounds.Width + 0.5) + .Select(c => $"{c.GetType().Name} right={c.Bounds.Right:F1}") + .ToList(); + + Assert.That(overhang, Is.Empty, + "an item arranged past the row's own width is cut off at the edge, which is " + + "how the end of an environment went missing; row width " + + row.Bounds.Width.ToString("F1")); + Assert.That(row.Children.Any(c => c.Bounds.Y > 0.5), Is.True, + "and they must actually have wrapped -- if everything still sits on one line " + + "the row was wide enough and this test proves nothing"); + } + + [AvaloniaTest] + public void ARetypableRow_RendersItsItemsAsEditors() + { + var (row, _) = Show(new FakeTextEditing()); + + Assert.That(Editor(row, "e1").Text, Is.EqualTo("/_#"), + "an item the domain can reconcile has to be typeable and show its own text, or " + + "it can only be removed and re-added"); + } + + /// + /// A TextBox measures its own text short of what it draws, so the last character was + /// cut off. The editor is held to the width the text actually measures. + /// + [AvaloniaTest] + public void AnItemEditor_IsNeverNarrowerThanItsOwnText() + { + var (row, _) = Show(new FakeTextEditing()); + var box = Editor(row, "e1"); + + var label = new TextBlock { Text = box.Text, FontSize = box.FontSize }; + label.Measure(new Avalonia.Size(double.PositiveInfinity, double.PositiveInfinity)); + + Assert.That(box.MinWidth, Is.GreaterThanOrEqualTo(label.DesiredSize.Width), + "an editor narrower than its own text cuts the end off it; text '" + box.Text + + "' measures " + label.DesiredSize.Width.ToString("F1")); + } + + [AvaloniaTest] + public void ARowThatCannotRetype_KeepsReadOnlyItems() + { + var (row, _) = Show(new FakeTextEditing { Retypable = false }); + + Assert.That(row.GetVisualDescendants().OfType(), Is.Empty, + "every other vector row in the app must be untouched by this"); + Assert.That(row.GetVisualDescendants().OfType().Select(t => t.Text), + Does.Contain("/_#")); + } + + /// + /// Staged when the edit FINISHES, not per keystroke. Each stage reconciles the text + /// against the project, so staging every keystroke of "/_zz" would leave junk + /// environments behind for "/", "/_" and "/_z". + /// + [AvaloniaTest] + public void TypingAlone_StagesNothing_UntilTheEditIsFinished() + { + var context = new FakeTextEditing(); + var (row, _) = Show(context); + var box = Editor(row, "e1"); + + box.Text = "/_zz"; + Dispatcher.UIThread.RunJobs(); + + Assert.That(context.Edits, Is.Empty, + "a stage per keystroke would mint an environment per keystroke"); + + box.RaiseEvent(new KeyEventArgs + { + RoutedEvent = InputElement.KeyDownEvent, + Key = Key.Enter + }); + Dispatcher.UIThread.RunJobs(); + + Assert.That(context.Edits, Is.EqualTo(new[] { ("e1", "/_zz") }), + "finishing the edit stages it once, against the item's own key"); + } + + /// + /// Emptying an item is how the user removes it, so the row must pass the blank text + /// through to the domain rather than treating it as nothing to do. + /// + [AvaloniaTest] + public void EmptyingAnItemEditor_StagesTheBlankText() + { + var context = new FakeTextEditing(); + var (row, _) = Show(context); + var box = Editor(row, "e1"); + + box.Text = string.Empty; + PressEnter(box); + + Assert.That(context.Edits.Count, Is.EqualTo(1), + "a cleared item must reach the domain, which is what removes it"); + Assert.That(context.Edits[0].Key, Is.EqualTo("e1")); + Assert.That(string.IsNullOrEmpty(context.Edits[0].Text), Is.True, + "and it must arrive blank, not filtered out on the way"); + } + + [AvaloniaTest] + public void FinishingAnUnchangedEdit_StagesNothing() + { + var context = new FakeTextEditing(); + var (row, _) = Show(context); + var box = Editor(row, "e1"); + + box.RaiseEvent(new KeyEventArgs + { + RoutedEvent = InputElement.KeyDownEvent, + Key = Key.Enter + }); + Dispatcher.UIThread.RunJobs(); + + Assert.That(context.Edits, Is.Empty, + "text that did not change must not reconcile, which would rewrite the shared " + + "environment for every field referencing it"); + } + + /// + /// PhoneEnvReferenceView keeps an always-present empty line at the end, so a new + /// environment can be typed without going near the chooser. The "+" picker stays: it is + /// the other route, not the only one. + /// + [AvaloniaTest] + public void ARowThatCanCreate_OffersATypedSlot_AlongsideThePicker() + { + var (row, _) = Show(new FakeTextEditing { Creatable = true }); + + Assert.That(NewItemSlot(row), Is.Not.Null, + "adding by typing must not require the chooser"); + Assert.That(row.GetVisualDescendants().OfType