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