diff --git a/.claude/skills/fieldworks-winapp/scripts/Set-FieldWorksLegacyMode.ps1 b/.claude/skills/fieldworks-winapp/scripts/Set-FieldWorksLegacyMode.ps1 index 631c9920b3..836ac6cb4c 100644 --- a/.claude/skills/fieldworks-winapp/scripts/Set-FieldWorksLegacyMode.ps1 +++ b/.claude/skills/fieldworks-winapp/scripts/Set-FieldWorksLegacyMode.ps1 @@ -6,24 +6,28 @@ This skill scrapes the legacy WinForms UI for "truth" screenshots, workflows, and behaviour (the Avalonia UI is captured separately, headless). The WinForms UIA2 MCP can ONLY see WinForms: if FieldWorks comes up in New (Avalonia) UI mode, the element tree is empty and PrintWindow renders a - blank window — the process looks healthy but nothing is visible to the MCP. + blank window - the process looks healthy but nothing is visible to the MCP. UI mode is the `UIMode` application setting (default "Legacy"), persisted by libpalaso's CrossPlatformSettingsProvider at: %LOCALAPPDATA%\SIL\SIL FieldWorks\\user.config - (NOT the per-exe `FieldWorks.exe_Url_*` files, which hold other settings). When that value is "New", - FieldWorks launches Avalonia. This script sets it to "Legacy" in every existing FieldWorks settings - store (and, if the exe's own version store is missing, creates it), so the next launch is WinForms. + (NOT the per-exe `FieldWorks.exe_Url_*` files, which hold other settings). FieldWorks launches + Avalonia only when that value is "New" AND the launching process has the FW_AVALONIA environment + variable set to anything other than 0/false/off. Without FW_AVALONIA the Tools > Options mode + chooser is not built at all. This script sets UIMode to "Legacy" in every existing FieldWorks + settings store (and, if the exe's own version store is missing, creates it), so the next launch is + WinForms whatever FW_AVALONIA says. Idempotent and safe to run every time. SIDE EFFECT: this also flips the developer's own persisted - UI mode to Legacy — re-select New in Tools ▸ Options (or re-run with -RestoreNew) when you want the - Avalonia UI back interactively. + UI mode to Legacy - re-run with -RestoreNew (or re-select New in Tools > Options, which requires + FW_AVALONIA) when you want the Avalonia UI back interactively. .PARAMETER Configuration - Debug or Release — used only to locate the exe and read its version. Default Debug. + Debug or Release - used only to locate the exe and read its version. Default Debug. .PARAMETER RestoreNew - Instead of forcing Legacy, set UIMode back to New (developer convenience). + Instead of forcing Legacy, set UIMode back to New (developer convenience). Refuses to run unless + FW_AVALONIA is set in this session, since UIMode=New alone does not produce an Avalonia launch. .EXAMPLE .\.claude\skills\fieldworks-winapp\scripts\Set-FieldWorksLegacyMode.ps1 @@ -36,6 +40,15 @@ param( ) $ErrorActionPreference = 'Stop' + +# Checked before any write, so a refused -RestoreNew leaves UIMode untouched. +if ($RestoreNew) { + $optIn = $env:FW_AVALONIA + if ([string]::IsNullOrWhiteSpace($optIn) -or $optIn.Trim() -in @('0', 'false', 'off')) { + throw "FW_AVALONIA is not set in this session, so UIMode=New would still launch WinForms. Run `$env:FW_AVALONIA = '1' first (and set it wherever FieldWorks is launched from), then re-run with -RestoreNew." + } +} + $target = if ($RestoreNew) { 'New' } else { 'Legacy' } $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..\..')).Path $storeRoot = Join-Path $env:LOCALAPPDATA 'SIL\SIL FieldWorks' @@ -105,7 +118,7 @@ if (Test-Path $exe) { } if ($changed.Count -eq 0) { - Write-Host "[OK] No FieldWorks settings store found; default UI mode is Legacy — nothing to do." -ForegroundColor Green + Write-Host "[OK] No FieldWorks settings store found; default UI mode is Legacy - nothing to do." -ForegroundColor Green } else { Write-Host "[OK] UIMode set to '$target' in:" -ForegroundColor Green diff --git a/Src/Common/FieldWorks/WelcomeToFieldWorksDlg.cs b/Src/Common/FieldWorks/WelcomeToFieldWorksDlg.cs index 493f1874fa..ac4b0c0e53 100644 --- a/Src/Common/FieldWorks/WelcomeToFieldWorksDlg.cs +++ b/Src/Common/FieldWorks/WelcomeToFieldWorksDlg.cs @@ -300,10 +300,10 @@ private void Import_Click(object sender, EventArgs e) private void m_btnOptions_Click(object sender, EventArgs e) { // Migrated Options dialog: in New (Avalonia) UI mode show the owned Avalonia Options dialog. - // This is the pre-project (bare-bones) path — no cache/mediator/project — so Plugins are + // This is the pre-project (bare-bones) path -- no cache/mediator/project -- so Plugins are // unavailable. Legacy mode keeps the WinForms dialog. var settings = new FwApplicationSettings(); - if (UIModeGates.ShouldUseAvaloniaUI(settings.UIMode)) + if (UIModeGates.ShouldUseAvaloniaUIFromSettings(settings.UIMode)) { ShowAvaloniaOptionsDialog(settings); return; diff --git a/Src/Common/FwUtils/FwUtilsTests/UIModeGatesTests.cs b/Src/Common/FwUtils/FwUtilsTests/UIModeGatesTests.cs new file mode 100644 index 0000000000..93041cbee8 --- /dev/null +++ b/Src/Common/FwUtils/FwUtilsTests/UIModeGatesTests.cs @@ -0,0 +1,40 @@ +// 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 NUnit.Framework; + +namespace SIL.FieldWorks.Common.FwUtils +{ + /// + /// Covers which FW_AVALONIA values count as opting in to the New UI. The rule is a pure function, + /// so nothing here reads or writes the process environment. + /// + [TestFixture] + public class UIModeGatesTests + { + [TestCase("1")] + [TestCase("true")] + [TestCase("TRUE")] + [TestCase("yes")] + [TestCase("on")] + [TestCase(" 1 ")] + public void IsSwitchingEnabled_TreatsAnyOtherValueAsOptedIn(string value) + { + Assert.That(UIModeGates.IsSwitchingEnabled(value), Is.True); + } + + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + [TestCase("0")] + [TestCase("false")] + [TestCase("False")] + [TestCase("off")] + [TestCase(" off ")] + public void IsSwitchingEnabled_FailsClosedForUnsetAndNegativeValues(string value) + { + Assert.That(UIModeGates.IsSwitchingEnabled(value), Is.False); + } + } +} diff --git a/Src/Common/FwUtils/UIModeGates.cs b/Src/Common/FwUtils/UIModeGates.cs index 976cebf025..c5c5b592d9 100644 --- a/Src/Common/FwUtils/UIModeGates.cs +++ b/Src/Common/FwUtils/UIModeGates.cs @@ -10,12 +10,49 @@ namespace SIL.FieldWorks.Common.FwUtils /// The single shared New-mode gate. Lives in FwUtils, with no Avalonia-referencing types anywhere in /// this class, so checking the gate never causes the CLR to load the Avalonia assemblies: legacy-mode /// call sites must pair this with a [MethodImpl(NoInlining)] helper holding their Avalonia branch. - /// Fails closed: null, blank, or any unrecognized value means Legacy. + /// Default is off: New requires both the FW_AVALONIA opt-in and a "New" mode value, and + /// null, blank, or any unrecognized value means Legacy. /// public static class UIModeGates { - /// True only when the UI mode setting is exactly "New" (case-insensitive). + /// Environment variable a user sets to opt in to choosing the New UI. + public const string SwitchingEnabledVariable = "FW_AVALONIA"; + + /// + /// True only when is exactly "New" (case-insensitive). Judges the + /// value alone and does not consult , so it suits values that + /// were already gated when the persisted setting was read (the PropertyTable "UIMode" property); + /// a persisted setting read straight from disk needs . + /// public static bool ShouldUseAvaloniaUI(string currentUiMode) => string.Equals(currentUiMode, "New", StringComparison.OrdinalIgnoreCase); + + /// + /// True when the persisted UI-mode setting selects the New UI AND the user has opted in via + /// . Without the opt-in a persisted "New" is ignored, which + /// leaves the value untouched on disk so it takes effect again once the variable is set. + /// + public static bool ShouldUseAvaloniaUIFromSettings(string persistedUiMode) => + IsSwitchingEnabled() && ShouldUseAvaloniaUI(persistedUiMode); + + /// Reads from the current process environment. + public static bool IsSwitchingEnabled() => + IsSwitchingEnabled(Environment.GetEnvironmentVariable(SwitchingEnabledVariable)); + + /// + /// True for any except null, blank, "0", "false", and "off" + /// (case-insensitive), so the variable can be spelled "1", "true", or "yes" and still turns + /// switching on, while "0" reliably turns it back off. + /// + internal static bool IsSwitchingEnabled(string variableValue) + { + if (string.IsNullOrWhiteSpace(variableValue)) + return false; + + var trimmed = variableValue.Trim(); + return !string.Equals(trimmed, "0", StringComparison.OrdinalIgnoreCase) && + !string.Equals(trimmed, "false", StringComparison.OrdinalIgnoreCase) && + !string.Equals(trimmed, "off", StringComparison.OrdinalIgnoreCase); + } } } diff --git a/Src/LexText/LexTextControls/LexOptionsDlg.cs b/Src/LexText/LexTextControls/LexOptionsDlg.cs index 82f770566f..87ab9254af 100644 --- a/Src/LexText/LexTextControls/LexOptionsDlg.cs +++ b/Src/LexText/LexTextControls/LexOptionsDlg.cs @@ -138,21 +138,25 @@ private void m_btnOK_Click(object sender, EventArgs e) } } - // UIMode (and its per-tool overrides) flips live — RecordEditView settles any open edit and - // re-resolves the framework on the spot, so unlike the settings below, this never needs a restart. - // Compare against the PropertyTable's LIVE value (falling back to settings): if the table and - // the persisted setting ever disagree, OK re-broadcasts and heals the running views. - var oldUiMode = NormalizeUIMode(m_propertyTable == null - ? m_settings.UIMode - : m_propertyTable.GetStringProperty(UIModePropertyName, m_settings.UIMode)); - var newUiMode = SelectedUIMode; - if (oldUiMode != newUiMode) + // The UI mode applies immediately, so it never sets restartRequired. Broadcasting the + // property makes the open views re-resolve which framework draws them. The old value comes + // from the PropertyTable rather than the persisted setting because that is the one those + // views are using. A null chooser means no FW_AVALONIA opt-in, and the persisted mode is + // then left untouched. + if (m_uiModeChooser != null) { - m_settings.UIMode = newUiMode; - if (m_propertyTable != null) + var oldUiMode = NormalizeUIMode(m_propertyTable == null + ? m_settings.UIMode + : m_propertyTable.GetStringProperty(UIModePropertyName, m_settings.UIMode)); + var newUiMode = SelectedUIMode; + if (oldUiMode != newUiMode) { - m_propertyTable.SetProperty(UIModePropertyName, newUiMode, true); - m_propertyTable.SetPropertyPersistence(UIModePropertyName, false); + m_settings.UIMode = newUiMode; + if (m_propertyTable != null) + { + m_propertyTable.SetProperty(UIModePropertyName, newUiMode, true); + m_propertyTable.SetPropertyPersistence(UIModePropertyName, false); + } } } @@ -447,6 +451,12 @@ public override string ToString() private void InitializeUIModeControls() { + // Opting out leaves the group unbuilt rather than hidden: the layout below grows the form and + // pushes the controls under it down, so building an invisible group would leave a blank band + // on the Interface tab for every user who has not set FW_AVALONIA. + if (!UIModeGates.IsSwitchingEnabled()) + return; + m_uiModeGroup = new GroupBox(); m_uiModeLabel = new Label(); m_uiModeChooser = new ComboBox(); @@ -512,13 +522,16 @@ private string SelectedUIMode { get { - var item = m_uiModeChooser.SelectedItem as UiModeMenuItem; + var item = m_uiModeChooser?.SelectedItem as UiModeMenuItem; return item != null ? item.Mode : LegacyUIMode; } } private void SelectUIMode(string mode) { + if (m_uiModeChooser == null) + return; + var desired = NormalizeUIMode(mode); foreach (var item in m_uiModeChooser.Items) { diff --git a/Src/LexText/LexTextControls/LexTextControlsTests/Avalonia/LexOptionsDlgTests.cs b/Src/LexText/LexTextControls/LexTextControlsTests/Avalonia/LexOptionsDlgTests.cs index a68f3b5ccb..de709a7308 100644 --- a/Src/LexText/LexTextControls/LexTextControlsTests/Avalonia/LexOptionsDlgTests.cs +++ b/Src/LexText/LexTextControls/LexTextControlsTests/Avalonia/LexOptionsDlgTests.cs @@ -14,6 +14,26 @@ namespace LexTextControlsTests [Apartment(System.Threading.ApartmentState.STA)] public class LexOptionsDlgTests { + private string m_savedSwitchingVariable; + + [SetUp] + public void SetUp() + { + // The UI-mode group is only built when the user has opted in via FW_AVALONIA, and it is + // built in the dialog's constructor. This is setup for the tests below, not something + // they verify. + m_savedSwitchingVariable = + Environment.GetEnvironmentVariable(UIModeGates.SwitchingEnabledVariable); + Environment.SetEnvironmentVariable(UIModeGates.SwitchingEnabledVariable, "1"); + } + + [TearDown] + public void TearDown() + { + Environment.SetEnvironmentVariable( + UIModeGates.SwitchingEnabledVariable, m_savedSwitchingVariable); + } + [Test] public void OkClick_SavesUIModeAndMirrorsItIntoPropertyTable() { @@ -37,7 +57,7 @@ public void OkClick_SavesUIModeAndMirrorsItIntoPropertyTable() Assert.That(settings.UIMode, Is.EqualTo("New")); Assert.That(settings.SaveCalls, Is.EqualTo(1)); Assert.That(propertyTable.GetStringProperty("UIMode", "Legacy"), Is.EqualTo("New")); - // UIMode flips live (RecordEditView settles and re-resolves on the spot) — no restart needed. + // UIMode flips live (RecordEditView settles and re-resolves on the spot) -- no restart needed. Assert.That(dlg.RestartPromptCount, Is.EqualTo(0)); } } @@ -70,7 +90,7 @@ public void OkClick_LeavesLegacyWhenUserDoesNotChangeSelection() /// below the injection point that are top-anchored (m_labelAdvanced, m_autoOpenCheckBox) don't move on /// their own, so the code moves them by hand. Controls that are bottom-anchored (tabControl1, /// the OK/Cancel/Help buttons) already move/resize automatically as a side effect of Form.Height - /// growing — a prior version of this code ALSO manually re-added the same delta to those, which + /// growing -- a prior version of this code ALSO manually re-added the same delta to those, which /// shifts them by 2x delta and can push the buttons below the visible (non-scrollable) client /// area. This pins the "exactly once" invariant against the dialog's own designer-time positions /// (read from LexOptionsDlg.resx), so re-introducing the double shift fails this test. diff --git a/Src/xWorks/FwXWindow.cs b/Src/xWorks/FwXWindow.cs index 8423d592d5..54003677e0 100644 --- a/Src/xWorks/FwXWindow.cs +++ b/Src/xWorks/FwXWindow.cs @@ -536,7 +536,7 @@ protected void InitMediatorValues(LcmCache cache) Directory.CreateDirectory(path); m_propertyTable.UserSettingDirectory = path; Mediator.PathVariables["{DISTFILES}"] = FwDirectoryFinder.CodeDirectory; - // Seed the UI-mode properties BEFORE LoadUI creates the content views — + // Seed the UI-mode properties BEFORE LoadUI creates the content views -- // RecordEditView resolves its framework during window construction, so seeding any later // (or relying on the app to do it after NewMainAppWnd returns) leaves a persisted // UIMode=New coming up on Legacy until the setting is toggled again. @@ -546,14 +546,17 @@ protected void InitMediatorValues(LcmCache cache) /// /// Seeds the UI-mode selection properties from their persisted app-setting values, normalized - /// fail-closed (anything but "New" means Legacy). No broadcast: this runs before the content - /// views exist; later changes go through the Options dialogs, which broadcast. + /// fail-closed (anything but "New" means Legacy, and New additionally requires the FW_AVALONIA + /// opt-in). No broadcast: this runs before the content views exist; later changes go through the + /// Options dialogs, which broadcast. /// internal static void SeedUIModeProperties(PropertyTable propertyTable, string settingsUiMode, string settingsDisabledTools) { propertyTable.SetProperty(UIFrameworkResolver.UIModePropertyName, - UIFrameworkResolver.NormalizeUIMode(settingsUiMode), false); + UIModeGates.ShouldUseAvaloniaUIFromSettings(settingsUiMode) + ? UIFrameworkResolver.NewUIMode + : UIFrameworkResolver.LegacyUIMode, false); propertyTable.SetPropertyPersistence(UIFrameworkResolver.UIModePropertyName, false); propertyTable.SetProperty(UIFrameworkResolver.UIModeDisabledToolsPropertyName, settingsDisabledTools ?? string.Empty, false); @@ -967,10 +970,10 @@ public bool OnShowCharMap(object command) { CheckDisposed(); - // This menu command shells out to the OS character map (charmap.exe / gucharmap) — there is + // This menu command shells out to the OS character map (charmap.exe / gucharmap) -- there is // no FieldWorks character-picker dialog to migrate here. The "special-char insert" work instead // ships a NET-NEW in-app Avalonia Unicode picker (SpecialCharacterDialogView/ViewModel in - // FwAvaloniaDialogs, headless-tested: filterable curated list → ChosenCharacter) for the New-UI + // FwAvaloniaDialogs, headless-tested: filterable curated list -> ChosenCharacter) for the New-UI // insert-into-field affordance; this legacy OS-charmap shellout is preserved unchanged. var program = "charmap.exe"; Action errorHandler = null; @@ -2335,8 +2338,8 @@ public override void OnPropertyChanged(string name) if (name == "currentContentControl") { // The outgoing view may still hold an open fenced detail-edit undo task (the user was - // mid-edit when they switched). Settle it — committing a valid staged edit as its own undo - // step, rolling an invalid one back — BEFORE the save-on-tool-switch commit below: an open + // mid-edit when they switched). Settle it -- committing a valid staged edit as its own undo + // step, rolling an invalid one back -- BEFORE the save-on-tool-switch commit below: an open // task makes that commit throw "Commit at wrong place." This is the same auto-save the // view performs on record navigation and go-away, applied to the tool/area switch too. SettlePendingContentEdits(CurrentContentControl); @@ -2350,8 +2353,8 @@ public override void OnPropertyChanged(string name) } /// - /// Settles any open fenced detail-edit session held by the outgoing content control — or by a - /// view nested inside it — so the save-on-tool-switch commit does not fault on an open undo + /// Settles any open fenced detail-edit session held by the outgoing content control -- or by a + /// view nested inside it -- so the save-on-tool-switch commit does not fault on an open undo /// task. The detail view is usually nested inside a record-list/detail container, so the whole /// subtree is walked rather than only the top-level control. /// diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/FwXWindowUIModeSeedingTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/FwXWindowUIModeSeedingTests.cs index 2b7b06b2f7..2fc22eae6d 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Hosting/FwXWindowUIModeSeedingTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/FwXWindowUIModeSeedingTests.cs @@ -2,15 +2,17 @@ // This software is licensed under the LGPL, version 2.1 or later // (http://www.gnu.org/licenses/lgpl-2.1.html) +using System; using NUnit.Framework; using SIL.FieldWorks.Common.FwAvalonia; +using SIL.FieldWorks.Common.FwUtils; using XCore; namespace SIL.FieldWorks.XWorks { /// /// The UI-mode properties must be in the PropertyTable BEFORE LoadUI creates - /// the content views — RecordEditView resolves its framework during window construction, so a + /// the content views -- RecordEditView resolves its framework during window construction, so a /// window created with a persisted UIMode=New must see "New" at that moment or it comes up on /// Legacy. FwXWindow.InitMediatorValues seeds via this helper; these tests pin the /// helper's normalization and no-broadcast contract. @@ -20,17 +22,25 @@ public class FwXWindowUIModeSeedingTests { private Mediator m_mediator; private PropertyTable m_propertyTable; + private string m_savedSwitchingVariable; [SetUp] public void SetUp() { m_mediator = new Mediator(); m_propertyTable = new PropertyTable(m_mediator); + // Seeding New requires the FW_AVALONIA opt-in. This is setup for the normalization + // these tests are about, not something they verify. + m_savedSwitchingVariable = + Environment.GetEnvironmentVariable(UIModeGates.SwitchingEnabledVariable); + Environment.SetEnvironmentVariable(UIModeGates.SwitchingEnabledVariable, "1"); } [TearDown] public void TearDown() { + Environment.SetEnvironmentVariable( + UIModeGates.SwitchingEnabledVariable, m_savedSwitchingVariable); m_propertyTable.Dispose(); m_mediator.Dispose(); }