Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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\<version>\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
Expand All @@ -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'
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions Src/Common/FieldWorks/WelcomeToFieldWorksDlg.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
40 changes: 40 additions & 0 deletions Src/Common/FwUtils/FwUtilsTests/UIModeGatesTests.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 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.
/// </summary>
[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);
}
}
}
41 changes: 39 additions & 2 deletions Src/Common/FwUtils/UIModeGates.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
public static class UIModeGates
{
/// <summary>True only when the UI mode setting is exactly "New" (case-insensitive).</summary>
/// <summary>Environment variable a user sets to opt in to choosing the New UI.</summary>
public const string SwitchingEnabledVariable = "FW_AVALONIA";

/// <summary>
/// True only when <paramref name="currentUiMode"/> is exactly "New" (case-insensitive). Judges the
/// value alone and does not consult <see cref="SwitchingEnabledVariable"/>, 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 <see cref="ShouldUseAvaloniaUIFromSettings"/>.
/// </summary>
public static bool ShouldUseAvaloniaUI(string currentUiMode) =>
string.Equals(currentUiMode, "New", StringComparison.OrdinalIgnoreCase);

/// <summary>
/// True when the persisted UI-mode setting selects the New UI AND the user has opted in via
/// <see cref="SwitchingEnabledVariable"/>. 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.
/// </summary>
public static bool ShouldUseAvaloniaUIFromSettings(string persistedUiMode) =>
IsSwitchingEnabled() && ShouldUseAvaloniaUI(persistedUiMode);

/// <summary>Reads <see cref="SwitchingEnabledVariable"/> from the current process environment.</summary>
public static bool IsSwitchingEnabled() =>
IsSwitchingEnabled(Environment.GetEnvironmentVariable(SwitchingEnabledVariable));

/// <summary>
/// True for any <paramref name="variableValue"/> 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.
/// </summary>
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);
}
}
}
41 changes: 27 additions & 14 deletions Src/LexText/LexTextControls/LexOptionsDlg.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand All @@ -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));
}
}
Expand Down Expand Up @@ -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.
Expand Down
23 changes: 13 additions & 10 deletions Src/xWorks/FwXWindow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -546,14 +546,17 @@ protected void InitMediatorValues(LcmCache cache)

/// <summary>
/// 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.
/// </summary>
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);
Expand Down Expand Up @@ -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<Exception> errorHandler = null;
Expand Down Expand Up @@ -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);
Expand All @@ -2350,8 +2353,8 @@ public override void OnPropertyChanged(string name)
}

/// <summary>
/// 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.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
/// <summary>
/// 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.
Expand All @@ -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();
}
Expand Down
Loading