Skip to content

Commit 3c5893e

Browse files
johnml1135claude
andcommitted
Phase-1 follow-up: avalonia-interlinear-editor (Words Analyses)
Restore the interlinear surface on top of phase1-base and activate it: - add back the 11 interlinear files (InterlinearRegionEditor + analysis model + projector/write-back + plugin + their tests, incl. the FwAvalonia model and Visual tests) - restore the InterlinearSlicePlugin registration in RegionEditorPlugins - restore the interlinear class name + resolve assertion in the burn-down census - FLIP: move "Analyses" from Phase1FollowUpSurfaceTools into DefaultSupportedTools so the Words Analyses detail editor resolves to Avalonia under UIMode=New - add the "Analyses" TestCase back to RegisteredRecordEditTools_ResolveToAvalonia The browse "Analyses" list pane stays inert (table follow-up territory). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b423a19 commit 3c5893e

15 files changed

Lines changed: 1873 additions & 18 deletions
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// Copyright (c) 2026 SIL International
2+
// This software is licensed under the LGPL, version 2.1 or later
3+
// (http://www.gnu.org/licenses/lgpl-2.1.html)
4+
5+
using System;
6+
using System.Collections.Generic;
7+
using NUnit.Framework;
8+
using SIL.FieldWorks.Common.FwAvalonia.Region;
9+
10+
namespace FwAvaloniaTests
11+
{
12+
/// <summary>
13+
/// avalonia-interlinear-editor (task 1.2) — the LCModel-free projection DTO the Avalonia interlinear
14+
/// control binds to. The Morphology plugin builds it (read) and maps per-bundle edits back with the
15+
/// Sandbox-parity MSA prune (write); the view never sees LCModel or a Sandbox.
16+
/// </summary>
17+
[TestFixture]
18+
public class InterlinearAnalysisModelTests
19+
{
20+
[Test]
21+
public void Bundle_CarriesFormsAndGuids()
22+
{
23+
var morph = Guid.NewGuid();
24+
var sense = Guid.NewGuid();
25+
var msa = Guid.NewGuid();
26+
var b = new InterlinearBundle("ka-", "P", "pfx", morph, sense, msa, lexEntry: "ka-");
27+
Assert.That(b.Morph, Is.EqualTo("ka-"));
28+
Assert.That(b.LexEntry, Is.EqualTo("ka-"), "the lex-entry headword line (legacy kflidLexEntries)");
29+
Assert.That(b.Gloss, Is.EqualTo("P"));
30+
Assert.That(b.GrammaticalInfo, Is.EqualTo("pfx"));
31+
Assert.That(b.MorphGuid, Is.EqualTo(morph));
32+
Assert.That(b.SenseGuid, Is.EqualTo(sense));
33+
Assert.That(b.MsaGuid, Is.EqualTo(msa));
34+
}
35+
36+
[Test]
37+
public void Model_WithBundles_HasAnalysisTrue()
38+
{
39+
var model = new InterlinearAnalysisModel("kapula", new[]
40+
{
41+
new InterlinearLine("kapula", new[]
42+
{
43+
new InterlinearBundle("ka-", "P", "pfx"),
44+
new InterlinearBundle("pula", "rain", "n"),
45+
}, Guid.NewGuid()),
46+
}, Guid.NewGuid());
47+
48+
Assert.That(model.HasAnalysis, Is.True);
49+
Assert.That(model.Lines, Has.Count.EqualTo(1));
50+
Assert.That(model.Lines[0].Bundles, Has.Count.EqualTo(2));
51+
Assert.That(model.Lines[0].Bundles[1].Gloss, Is.EqualTo("rain"));
52+
}
53+
54+
[Test]
55+
public void Model_NoAnalysisOrEmptyLines_HasAnalysisFalse()
56+
{
57+
Assert.That(new InterlinearAnalysisModel("kapula", null).HasAnalysis, Is.False,
58+
"a bare wordform with no analyses renders the bare-wordform state, not the grid");
59+
Assert.That(new InterlinearAnalysisModel("kapula", new[]
60+
{
61+
new InterlinearLine("kapula", new List<InterlinearBundle>()),
62+
}).HasAnalysis, Is.False, "an analysis line with no bundles is not a renderable interlinear");
63+
}
64+
}
65+
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
// Copyright (c) 2026 SIL International
2+
// This software is licensed under the LGPL, version 2.1 or later
3+
// (http://www.gnu.org/licenses/lgpl-2.1.html)
4+
5+
using System;
6+
using System.Linq;
7+
using Avalonia.Controls;
8+
using Avalonia.Headless.NUnit;
9+
using Avalonia.VisualTree;
10+
using NUnit.Framework;
11+
using SIL.FieldWorks.Common.FwAvalonia.Region;
12+
using FwAvaloniaDialogsTests; // DialogLayoutAssert
13+
14+
namespace FwAvaloniaTests.VisualChecks
15+
{
16+
/// <summary>
17+
/// avalonia-interlinear-editor (task 2.3) — T5 visual stages and T3 edge cases for the read-only
18+
/// interlinear control. Each is captured as a Skia PNG for subjective alignment/crowding review and run
19+
/// through the shared <see cref="DialogLayoutAssert"/> crowding tripwire. All LCModel-free: the
20+
/// <see cref="InterlinearAnalysisModel"/> DTOs are built directly (the projection is tested over a real
21+
/// cache in xWorksTests). Edges: empty analysis (bare wordform), a multi-analysis wordform, and an
22+
/// RTL/complex-script morpheme run.
23+
/// </summary>
24+
[TestFixture]
25+
public class InterlinearVisualTests
26+
{
27+
private static InterlinearBundle Bundle(string morph, string gloss, string gram, string lexEntry = null)
28+
=> new InterlinearBundle(morph, gloss, gram, Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(),
29+
lexEntry ?? morph);
30+
31+
private static InterlinearAnalysisModel Kapula()
32+
=> new InterlinearAnalysisModel("kapula", new[]
33+
{
34+
new InterlinearLine("kapula", new[]
35+
{
36+
Bundle("ka-", "1Sg.Subj", "pfx", "ka-"),
37+
Bundle("pul", "see", "v", "pula"),
38+
Bundle("-a", "Indic", "sfx", "-a")
39+
}, Guid.NewGuid())
40+
}, Guid.NewGuid());
41+
42+
[AvaloniaTest]
43+
public void ReadOnlyInterlinear_AlignsColumns()
44+
{
45+
var control = new InterlinearRegionEditor(Kapula());
46+
DialogSnapshot.Capture(control, "Interlinear-01-readonly", width: 460, height: 180);
47+
DialogLayoutAssert.AssertNoCrowding(control);
48+
49+
// All three lines plus the wordform render their text (4 rows × 3 columns + wordform).
50+
var texts = control.GetVisualDescendants().OfType<TextBlock>().Select(t => t.Text).ToList();
51+
Assert.That(texts, Does.Contain("kapula"));
52+
Assert.That(texts, Does.Contain("pul"));
53+
Assert.That(texts, Does.Contain("see"));
54+
Assert.That(texts, Does.Contain("Indic"));
55+
}
56+
57+
[AvaloniaTest]
58+
public void EmptyAnalysis_RendersTheBareWordform()
59+
{
60+
var model = new InterlinearAnalysisModel("kapula", Array.Empty<InterlinearLine>(), Guid.NewGuid());
61+
var control = new InterlinearRegionEditor(model);
62+
63+
DialogSnapshot.Capture(control, "Interlinear-02-bare-wordform", width: 420, height: 120);
64+
DialogLayoutAssert.AssertNoCrowding(control);
65+
66+
Assert.That(model.HasAnalysis, Is.False);
67+
var texts = control.GetVisualDescendants().OfType<TextBlock>().Select(t => t.Text).ToList();
68+
Assert.That(texts, Does.Contain("kapula"), "the bare wordform still shows");
69+
}
70+
71+
[AvaloniaTest]
72+
public void MultiAnalysisWordform_RendersOneLinePerAnalysis()
73+
{
74+
var model = new InterlinearAnalysisModel("kapula", new[]
75+
{
76+
new InterlinearLine("kapula", new[] { Bundle("ka-", "1Sg", "pfx"), Bundle("pula", "rain", "n") },
77+
Guid.NewGuid()),
78+
new InterlinearLine("kapula", new[] { Bundle("kapula", "downpour", "n") }, Guid.NewGuid())
79+
}, Guid.NewGuid());
80+
var control = new InterlinearRegionEditor(model);
81+
82+
DialogSnapshot.Capture(control, "Interlinear-03-multi-analysis", width: 460, height: 280);
83+
DialogLayoutAssert.AssertNoCrowding(control);
84+
85+
var texts = control.GetVisualDescendants().OfType<TextBlock>().Select(t => t.Text).ToList();
86+
Assert.That(texts, Does.Contain("rain"));
87+
Assert.That(texts, Does.Contain("downpour"), "the second analysis line renders too");
88+
}
89+
90+
[AvaloniaTest]
91+
public void EditableMode_RendersSenseAndMsaChoosers_AndInvokesTheCallbacks()
92+
{
93+
// W-5 (task 3.1): when the adapter supplies edit choices, both the gloss (sense) and
94+
// grammatical-info (MSA) cells render editable choosers (buttons opening an FwOptionPicker)
95+
// instead of static TextBlocks. The control stays LCModel-free — it gets RegionChoiceOption DTOs
96+
// + callbacks. (The morph line stays read-only — PARITY: re-segmentation is deferred.)
97+
var model = Kapula();
98+
string chosenSenseKey = null;
99+
string chosenMsaKey = null;
100+
string chosenMorphKey = null;
101+
var senseKey = Guid.NewGuid().ToString();
102+
var msaKey = Guid.NewGuid().ToString();
103+
var morphKey = Guid.NewGuid().ToString();
104+
Func<int, int, InterlinearBundleEditChoices> editChoices = (line, bundle) =>
105+
new InterlinearBundleEditChoices(
106+
new[] { new RegionChoiceOption(senseKey, "rain"), new RegionChoiceOption(Guid.NewGuid().ToString(), "storm") },
107+
key => chosenSenseKey = key,
108+
new[] { new RegionChoiceOption(msaKey, "n"), new RegionChoiceOption(Guid.NewGuid().ToString(), "v") },
109+
key => chosenMsaKey = key,
110+
new[] { new RegionChoiceOption(morphKey, "pula₁"), new RegionChoiceOption(Guid.NewGuid().ToString(), "pula₂") },
111+
key => chosenMorphKey = key);
112+
113+
var control = new InterlinearRegionEditor(model, "InterlinearEditor", editChoices: editChoices);
114+
Assert.That(control.IsEditable, Is.True);
115+
116+
DialogSnapshot.Capture(control, "Interlinear-05-editable", width: 560, height: 220);
117+
DialogLayoutAssert.AssertNoCrowding(control);
118+
119+
// Three chooser buttons per bundle (lex-entry + sense + MSA) × 3 bundles in Kapula = 9.
120+
var buttons = control.GetVisualDescendants().OfType<Button>().ToList();
121+
Assert.That(buttons, Has.Count.EqualTo(9),
122+
"an editable lex-entry + gloss + grammatical-info chooser per bundle (morph line stays read-only)");
123+
124+
// Each chooser routes its chosen key to the right adapter callback.
125+
CommitFirstOption(buttons.First(b => AutomationId(b) == "InterlinearEditor.Entry0"));
126+
Assert.That(chosenMorphKey, Is.EqualTo(morphKey), "picking a different entry routes its morph key");
127+
128+
CommitFirstOption(buttons.First(b => AutomationId(b) == "InterlinearEditor.Gloss0"));
129+
Assert.That(chosenSenseKey, Is.EqualTo(senseKey), "picking a sense routes its key to the adapter");
130+
131+
CommitFirstOption(buttons.First(b => AutomationId(b) == "InterlinearEditor.Gram0"));
132+
Assert.That(chosenMsaKey, Is.EqualTo(msaKey), "picking a grammatical-info/MSA routes its key to the adapter");
133+
}
134+
135+
private static string AutomationId(Control c) => Avalonia.Automation.AutomationProperties.GetAutomationId(c);
136+
137+
private static void CommitFirstOption(Button chooserButton)
138+
{
139+
var picker = (chooserButton.Flyout as Flyout)?.Content as FwOptionPicker;
140+
Assert.That(picker, Is.Not.Null, "the chooser button opens an FwOptionPicker flyout");
141+
picker.OptionsList.SelectedIndex = 0;
142+
picker.CommitHighlighted();
143+
}
144+
145+
[AvaloniaTest]
146+
public void RtlComplexScript_RendersRightToLeft_WithoutCrowding()
147+
{
148+
// A right-to-left vernacular run (Arabic-script morphemes) with analysis glosses.
149+
var model = new InterlinearAnalysisModel("كتبها", new[]
150+
{
151+
new InterlinearLine("كتبها", new[]
152+
{
153+
Bundle("كتب", "write", "v"),
154+
Bundle("ها", "3Sg.Fem.Obj", "sfx")
155+
}, Guid.NewGuid())
156+
}, Guid.NewGuid());
157+
var control = new InterlinearRegionEditor(model, "InterlinearEditor", rightToLeft: true);
158+
159+
DialogSnapshot.Capture(control, "Interlinear-04-rtl-complex", width: 460, height: 180);
160+
DialogLayoutAssert.AssertNoCrowding(control);
161+
162+
Assert.That(control.FlowDirection, Is.EqualTo(Avalonia.Media.FlowDirection.RightToLeft));
163+
var texts = control.GetVisualDescendants().OfType<TextBlock>().Select(t => t.Text).ToList();
164+
Assert.That(texts, Does.Contain("write"));
165+
}
166+
}
167+
}

Src/Common/FwAvalonia/LexicalEditSurfaceRegistry.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,20 +30,20 @@ public sealed class LexicalEditSurfaceRegistry
3030
{
3131
"lexiconEdit", "lexiconEditPopup",
3232
"notebookEdit", // §20.3.2 Notebook RnGenericRec (layout resolved via layoutChoiceField="Type")
33-
"posEdit" // §20.3.3 Grammar PartOfSpeech (MSA/feature launchers already plugin-claimed)
33+
"posEdit", // §20.3.3 Grammar PartOfSpeech (MSA/feature launchers already plugin-claimed)
34+
"Analyses" // avalonia-interlinear-editor follow-up: Words Analyses morph-bundle editor (flipped on)
3435
};
3536

3637
// PHASE-1 FOLLOW-UP surfaces — INERT in the base PR. The view-layer code for these ships (it lives in the
3738
// same FwAvalonia/xWorks assemblies) but the tools are deliberately NOT registered, so the resolver returns
3839
// "not supported" and they fall back to the legacy WinForms surface even under UIMode=New. Each follow-up PR
3940
// ACTIVATES its surface by moving its tool name(s) from this list into DefaultSupportedTools above (the one-line
4041
// "flip"). Verified by InertFollowUpSurfacesFallBackToLegacy in the resolver tests.
41-
// avalonia-interlinear-editor : "Analyses"
4242
// avalonia-rule-formula-editor: "PhonologicalRuleEdit","EnvironmentEdit","compoundRuleAdvancedEdit",
4343
// "naturalClassedit","phonemeEdit","AdhocCoprohibEdit"
44+
// (avalonia-interlinear-editor "Analyses" was flipped into DefaultSupportedTools above by its follow-up PR.)
4445
public static readonly string[] Phase1FollowUpSurfaceTools =
4546
{
46-
"Analyses",
4747
"PhonologicalRuleEdit", "EnvironmentEdit", "compoundRuleAdvancedEdit",
4848
"naturalClassedit", "phonemeEdit", "AdhocCoprohibEdit"
4949
};
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// Copyright (c) 2026 SIL International
2+
// This software is licensed under the LGPL, version 2.1 or later
3+
// (http://www.gnu.org/licenses/lgpl-2.1.html)
4+
5+
using System;
6+
using System.Collections.Generic;
7+
using System.Linq;
8+
9+
namespace SIL.FieldWorks.Common.FwAvalonia.Region
10+
{
11+
/// <summary>
12+
/// avalonia-interlinear-editor (task 1.2) — one morpheme column of an interlinear analysis, as an
13+
/// LCModel-free DTO (morph form, lex-gloss, grammatical-info abbreviation, plus the GUIDs the adapter
14+
/// maps edits back to). The Avalonia interlinear control binds to these; it never touches LCModel or a
15+
/// Sandbox (design Decision 1).
16+
/// </summary>
17+
public sealed class InterlinearBundle
18+
{
19+
public InterlinearBundle(string morph, string gloss, string grammaticalInfo,
20+
Guid? morphGuid = null, Guid? senseGuid = null, Guid? msaGuid = null, string lexEntry = null)
21+
{
22+
Morph = morph ?? string.Empty;
23+
LexEntry = lexEntry ?? string.Empty;
24+
Gloss = gloss ?? string.Empty;
25+
GrammaticalInfo = grammaticalInfo ?? string.Empty;
26+
MorphGuid = morphGuid;
27+
SenseGuid = senseGuid;
28+
MsaGuid = msaGuid;
29+
}
30+
31+
/// <summary>The morph form text (vernacular) — the legacy interlinear "Morphemes" line.</summary>
32+
public string Morph { get; }
33+
34+
/// <summary>The owning lex-entry headword (vernacular) — the legacy interlinear "Lex. Entries" line
35+
/// (rendered by LexEntryVc), distinct from the morpheme form: the headword/citation form of the entry
36+
/// the morph belongs to. Empty when the bundle has no chosen morph/entry.</summary>
37+
public string LexEntry { get; }
38+
39+
/// <summary>The lexical gloss text (analysis).</summary>
40+
public string Gloss { get; }
41+
42+
/// <summary>The grammatical-info (MSA) abbreviation shown under the gloss.</summary>
43+
public string GrammaticalInfo { get; }
44+
45+
/// <summary>Referenced MoForm GUID (or null when the bundle has no chosen morph).</summary>
46+
public Guid? MorphGuid { get; }
47+
48+
/// <summary>Referenced LexSense GUID (or null).</summary>
49+
public Guid? SenseGuid { get; }
50+
51+
/// <summary>Referenced MoMorphSynAnalysis GUID (or null). The adapter prunes an MSA no surviving
52+
/// sense uses on commit (write-back parity with the legacy Sandbox).</summary>
53+
public Guid? MsaGuid { get; }
54+
}
55+
56+
/// <summary>
57+
/// avalonia-interlinear-editor (task 1.2) — one analysis line of a wordform: the wordform form plus its
58+
/// ordered morpheme bundles. LCModel-free.
59+
/// </summary>
60+
public sealed class InterlinearLine
61+
{
62+
public InterlinearLine(string wordform, IEnumerable<InterlinearBundle> bundles, Guid? analysisGuid = null)
63+
{
64+
Wordform = wordform ?? string.Empty;
65+
Bundles = (bundles ?? Enumerable.Empty<InterlinearBundle>()).ToList();
66+
AnalysisGuid = analysisGuid;
67+
}
68+
69+
/// <summary>The wordform line text (vernacular).</summary>
70+
public string Wordform { get; }
71+
72+
/// <summary>The morpheme bundles in document order (left-to-right).</summary>
73+
public IReadOnlyList<InterlinearBundle> Bundles { get; }
74+
75+
/// <summary>The WfiAnalysis GUID this line projects (or null for the bare wordform).</summary>
76+
public Guid? AnalysisGuid { get; }
77+
}
78+
79+
/// <summary>
80+
/// avalonia-interlinear-editor (task 1.2) — the LCModel-free projection of a <c>WfiWordform</c>'s
81+
/// analyses the Avalonia interlinear control renders: the wordform plus its analysis lines. The
82+
/// Morphology plugin builds this (read) and applies per-bundle edits back to the real analysis with the
83+
/// Sandbox-parity MSA prune (write) inside one fenced UOW. NO Sandbox in the view (design Decision 1).
84+
/// </summary>
85+
public sealed class InterlinearAnalysisModel
86+
{
87+
public InterlinearAnalysisModel(string wordform, IEnumerable<InterlinearLine> lines, Guid? wordformGuid = null)
88+
{
89+
Wordform = wordform ?? string.Empty;
90+
Lines = (lines ?? Enumerable.Empty<InterlinearLine>()).ToList();
91+
WordformGuid = wordformGuid;
92+
}
93+
94+
/// <summary>The wordform form text (vernacular).</summary>
95+
public string Wordform { get; }
96+
97+
/// <summary>The wordform's analysis lines (one per WfiAnalysis); empty for a wordform with no analyses.</summary>
98+
public IReadOnlyList<InterlinearLine> Lines { get; }
99+
100+
/// <summary>The WfiWordform GUID this model projects (or null).</summary>
101+
public Guid? WordformGuid { get; }
102+
103+
/// <summary>True when the wordform has at least one analysis line with at least one bundle —
104+
/// the read-only renderer uses this to choose between the interlinear grid and the bare-wordform state.</summary>
105+
public bool HasAnalysis => Lines.Any(l => l.Bundles.Count > 0);
106+
}
107+
}

0 commit comments

Comments
 (0)