Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
e0874c6
style(ip): compact 510px ARSAS-native dialog with hidden expert optio…
masarray Oct 23, 2026
d5114f5
ux(ip): keep alternate address support but remove technical guidance …
masarray Oct 23, 2026
c24eda2
ux(ip): simplify recent address labels while retaining verified endpo…
masarray Oct 23, 2026
a395e0d
test(ip): lock compact Inter styling, progressive disclosure and WPF …
masarray Oct 23, 2026
24f97f0
fix(ip): remove unsupported Border.FontFamily property in history popup
masarray Oct 23, 2026
1962d5f
style(ip): explicitly apply ARSAS Inter to popup text controls only
masarray Oct 23, 2026
c3555b6
Merge compact ARSAS-native Smart IP dialog refinement (#691)
masarray Oct 23, 2026
91d7427
ux(ip): redesign compact IP entry with unified ARSAS Inter typography…
masarray Oct 23, 2026
1025a92
fix(xaml): use WPF Shape stroke endpoint properties for advanced chevron
masarray Oct 23, 2026
15ffcb0
fix(startup): apply visual adapters once on window Loaded, never on e…
masarray Oct 23, 2026
45a9af5
fix(startup): prewarm industrial templates and stop competing nav hei…
masarray Oct 23, 2026
6639df6
fix(typography): preserve bundled Inter and do not override navigatio…
masarray Oct 23, 2026
22f610b
fix(startup): enforce one-time per-window visual adapter and skip unm…
masarray Oct 23, 2026
e4daacd
fix(startup): apply intended maximized state before first MainWindow …
masarray Oct 23, 2026
e5d8129
fix(startup): remove third nav shell height and padding override; gua…
masarray Oct 23, 2026
1379005
ux(ip): reveal custom MMS port on initial load and exact saved histor…
masarray Oct 23, 2026
ee6603e
test(visual): enforce first-paint theme ownership and compact Smart I…
masarray Oct 23, 2026
5f58f2c
test(startup): verify authoritative 60px nav height and clipping guar…
masarray Oct 23, 2026
354f020
ux(ip): accessible advanced toggle with correctly rotating chevron an…
masarray Oct 23, 2026
fe317a1
Merge Smart IP visual polish and one-time startup styling (#692)
masarray Oct 23, 2026
5d46f87
merge(ui): preserve latest maximized-startup correction alongside SG7…
masarray Oct 23, 2026
1fc3078
merge(visual): advance SSOT v2 base to latest Smart IP/startup stabil…
masarray Oct 23, 2026
51fcb69
feat(ssot): shared weak-model immutable schema index with single-flig…
masarray Oct 23, 2026
509df24
refactor(ssot): Browser consumes shared schema task, not its private …
masarray Oct 23, 2026
d66c16a
feat(ssot): explicit retry for failed shared schema build only
masarray Oct 23, 2026
d15e539
refactor(ssot): operator schema retry via centralized model identity …
masarray Oct 23, 2026
545d8d0
refactor(ssot): Read and Report projections reuse Browser's verified …
masarray Oct 23, 2026
9bffbe6
feat(ssot): bounded multi-consumer canonical change journal, no value…
masarray Oct 23, 2026
9901761
feat(ssot): publish exact reference/FC dirty revisions atomically wit…
masarray Oct 23, 2026
906e60f
test(ssot): concurrent shared schema, independent workspace delta cur…
masarray Oct 23, 2026
7b9466e
test(ssot): compare independent cursor deltas by ordered values, not …
masarray Oct 23, 2026
3ebaf0e
docs(architecture): durable SSOT v2 ownership contract, migration sta…
masarray Oct 23, 2026
ba5b711
integration(ssot): carry exact FAT-approved Smart IP form without mod…
masarray Oct 23, 2026
cc6ee37
Merge approved Smart IP form into SSOT Phase 1 (#700)
masarray Oct 23, 2026
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
32 changes: 23 additions & 9 deletions App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,22 @@ protected override void OnStartup(StartupEventArgs e)
// smoke test validate that the vector ResourceDictionary is packaged.
InstallArvrelMiniIedFascia();

// Install workstation templates before StartupUri creates its first visual
// tree. No theme/style dictionary should be injected on window activation.
P2IndustrialWorkstationUx.PreloadResources();

if (Array.Exists(e.Args, argument =>
string.Equals(argument, "--portable-smoke-test", StringComparison.OrdinalIgnoreCase)))
{
Shutdown(RunPortableSmokeTest());
return;
}

// Loaded runs before the initial ContentRendered frame. Apply the
// legacy-only chrome once per real window, not on every focus change.
EventManager.RegisterClassHandler(typeof(Window), FrameworkElement.LoadedEvent,
new RoutedEventHandler(AppWindow_Loaded), handledEventsToo: true);

GridUxBehavior.Install();
P2InteractionBehavior.Install();
FaultRecordUxBehavior.Install();
Expand All @@ -71,19 +80,24 @@ protected override void OnStartup(StartupEventArgs e)
new Action(() => _ = AppUpdateCoordinator.RunLazyAsync(_updateCancellation.Token)));
}

protected override void OnActivated(EventArgs e)
// Style installation on every Activated/focus return used to overwrite the
// bundled Inter font and re-template live controls, causing visible repaint.
// The one-time Loaded path handles both MainWindow and independently opened FAT.
private readonly System.Runtime.CompilerServices.ConditionalWeakTable<Window, object> _styledWindows = new();

private void AppWindow_Loaded(object sender, RoutedEventArgs e)
{
base.OnActivated(e);
if (sender is not Window window || !ReferenceEquals(e.OriginalSource, window))
return;
if (_styledWindows.TryGetValue(window, out _))
return;

// A handful of legacy controls own local visual resources. Re-apply the
// tiny P2 adapter when windows activate so newly opened FAT workspaces also
// inherit the selected industrial theme without touching engine workflows.
P2BlueSteelGreigeUx.ApplyToOpenWindows(this);
_styledWindows.Add(window, new object());

// Keep onboarding task-first while preserving the existing protocol handlers:
// Add IED exposes SCL/CID/ICD or IP discovery, and bulk connect appears only
// when multiple loaded IEDs make the action useful. The behavior is dispatcher-safe.
if (Current?.MainWindow is MainWindow mainWindow)
// One onboarding install per MainWindow: model/collection notifications
// continue refreshing after the window is displayed.
if (window is MainWindow mainWindow)
SmartIedOnboardingBehavior.Install(mainWindow);
}

Expand Down
404 changes: 260 additions & 144 deletions IpConnectWizardWindow.xaml

Large diffs are not rendered by default.

67 changes: 21 additions & 46 deletions IpConnectWizardWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ public string MmsPortText
private readonly bool _rememberDiscoveryEndpoint;
private readonly ICollectionView _historyView;
private bool _updatingOctets;
private bool _rawAddressMode;
public int MmsPort { get; private set; } = 102;

// UI projection ONLY; UserPreferenceStore remains the sole persistence authority.
Expand Down Expand Up @@ -71,13 +72,16 @@ public IpConnectWizardWindow(string initialIp = "", int initialPort = 102,
}
MmsPortText = selectedPort.ToString(CultureInfo.InvariantCulture);
ApplyAddress(selectedIp);
// Never conceal a nonstandard port in the collapsed expert section.
if (selectedPort != 102)
MoreOptionsExpander.IsExpanded = true;

foreach (var box in Octets)
DataObject.AddPastingHandler(box, Octet_Pasting);

Loaded += (_, _) =>
{
if (ManualAddressCheck.IsChecked == true) RawIpBox.Focus();
if (_rawAddressMode) RawIpBox.Focus();
else Octet1Box.Focus();
};
}
Expand Down Expand Up @@ -126,7 +130,7 @@ private void ApplyAddress(string address)
RelayIpAddress = address;
if (TrySplitIpv4(address, out var parts))
{
ManualAddressCheck.IsChecked = false;
_rawAddressMode = false;
_updatingOctets = true;
try
{
Expand All @@ -138,13 +142,13 @@ private void ApplyAddress(string address)
}
else if (!string.IsNullOrWhiteSpace(address))
{
ManualAddressCheck.IsChecked = true;
_rawAddressMode = true;
RawIpBox.Text = address;
SetAddressMode(true);
}
else
{
ManualAddressCheck.IsChecked = false;
_rawAddressMode = false;
_updatingOctets = true;
try { foreach (var box in Octets) box.Clear(); }
finally { _updatingOctets = false; }
Expand All @@ -159,41 +163,10 @@ private void SetAddressMode(bool manual)
OctetEditor.Visibility = manual ? Visibility.Collapsed : Visibility.Visible;
HistoryButton.Visibility = manual ? Visibility.Collapsed : Visibility.Visible;
RawIpBox.Visibility = manual ? Visibility.Visible : Visibility.Collapsed;
_rawAddressMode = manual;
AddressHintText.Text = manual
? "Enter a numeric IPv6 or IPv4 address. Connection starts only when you confirm."
: "Enter four IPv4 numbers (0–255). Use dot, arrows, Tab or paste a complete address.";
}

private void ManualAddressCheck_Click(object sender, RoutedEventArgs e)
{
var manual = ManualAddressCheck.IsChecked == true;
if (manual && TryNormalizeIpv4Octets(Octets.Select(b => b.Text).ToArray(), out var ip))
{
RelayIpAddress = ip;
RawIpBox.Text = ip;
}
else if (!manual && TrySplitIpv4(RawIpBox.Text, out var parts))
{
_updatingOctets = true;
try
{
var boxes = Octets;
for (int i = 0; i < 4; i++) boxes[i].Text = parts[i];
}
finally { _updatingOctets = false; }
}
else if (!manual && !string.IsNullOrWhiteSpace(RawIpBox.Text))
{
// Never silently truncate a valid IPv6 literal into four octets.
ManualAddressCheck.IsChecked = true;
WizardStatusText.Text = "IPv6 requires Advanced / manual address mode.";
return;
}

SetAddressMode(manual);
if (manual) RawIpBox.Focus();
else Octet1Box.Focus();
WizardStatusText.Text = string.Empty;
? "Enter the address exactly as provided."
: "Example: 192.168.1.10";
}

private int OctetIndex(TextBox box) => Array.IndexOf(Octets, box);
Expand Down Expand Up @@ -222,7 +195,7 @@ private void Octet_PreviewTextInput(object sender, TextCompositionEventArgs e)
if (!IsValidPartialOctet(replacement))
{
e.Handled = true;
WizardStatusText.Text = "Each IPv4 octet must be a number from 0 to 255.";
WizardStatusText.Text = "Each number must be between 0 and 255.";
}
}

Expand Down Expand Up @@ -284,7 +257,7 @@ private void Octet_Pasting(object sender, DataObjectPastingEventArgs e)
FocusOctet(3, atEnd: true);
}
else
WizardStatusText.Text = "Paste a complete IPv4 address with four octets (0–255).";
WizardStatusText.Text = "Paste a full IP address, for example 192.168.1.10.";
return;
}

Expand All @@ -293,7 +266,7 @@ private void Octet_Pasting(object sender, DataObjectPastingEventArgs e)
if (!IsValidPartialOctet(next))
{
e.CancelCommand();
WizardStatusText.Text = "Each IPv4 octet must be a number from 0 to 255.";
WizardStatusText.Text = "Each number must be between 0 and 255.";
}
}

Expand Down Expand Up @@ -362,21 +335,23 @@ private void CommitHistorySelection()
// Selecting history restores the exact saved IP:port, even when unverified.
// This is recall only; the connection still requires explicit submission.
MmsPortText = selection.MmsPort.ToString(CultureInfo.InvariantCulture);
if (selection.MmsPort != 102)
MoreOptionsExpander.IsExpanded = true;
HistoryPopup.IsOpen = false;
if (ManualAddressCheck.IsChecked == true) RawIpBox.Focus();
if (_rawAddressMode) RawIpBox.Focus();
else Octet1Box.Focus();
// Recalling an endpoint is UI-only. Only Connect_Click submits.
}

private void Connect_Click(object sender, RoutedEventArgs e)
{
string submittedAddress;
if (ManualAddressCheck.IsChecked == true)
if (_rawAddressMode)
{
submittedAddress = RawIpBox.Text.Trim();
if (!IPAddress.TryParse(submittedAddress, out _))
{
WizardStatusText.Text = "Enter a valid IPv4 or IPv6 address.";
WizardStatusText.Text = "Enter a valid IP address.";
RawIpBox.Focus();
return;
}
Expand All @@ -385,7 +360,7 @@ private void Connect_Click(object sender, RoutedEventArgs e)
{
if (!TryNormalizeIpv4Octets(Octets.Select(b => b.Text).ToArray(), out submittedAddress))
{
WizardStatusText.Text = "Complete all four IPv4 octets, each between 0 and 255.";
WizardStatusText.Text = "Enter all four parts of the IP address (0–255).";
return;
}
}
Expand All @@ -394,7 +369,7 @@ private void Connect_Click(object sender, RoutedEventArgs e)
out var port) || port is <= 0 or > 65535)
{
WizardStatusText.Text =
"MMS port must be between 1 and 65535. Standard IEC 61850 MMS uses TCP 102.";
"Port must be between 1 and 65535.";
return;
}

Expand Down
13 changes: 2 additions & 11 deletions MainWindow.IedBrowser.Performance.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,7 @@ namespace ArIED61850Tester;
public partial class MainWindow
{
private readonly IedBrowserUnitProjectionCache _iedBrowserUnitCache = new();
// Weak model keys: indexed declarations are reused when returning to SG7_BCU,
// without retaining replaced engineering models indefinitely.
private readonly ConditionalWeakTable<LiveIedModelDiscoveryDocument, Task<IedBrowserSchemaIndex>>
_iedBrowserSchemaTasks = new();
// Schema tasks are shared with Read bindings through SharedIedSchemaIndex.
private Task<IedBrowserSchemaIndex>? _iedBrowserAwaitedSchema;
private Iec61850MonitorDevice? _iedBrowserAwaitedSchemaDevice;
private Iec61850MonitorDevice? _iedBrowserPreparingDevice;
Expand Down Expand Up @@ -71,13 +68,7 @@ private bool TryActivateIedBrowserSchema(
_iedBrowserValueSchemaIndex = null;
return true;
}
var task = _iedBrowserSchemaTasks.GetValue(model, source =>
Task.Run(() =>
{
using var measure = IedBrowserPerformanceProbe.Measure(
"Schema.Index", device.Name);
return new IedBrowserSchemaIndex(source);
}));
var task = SharedIedSchemaIndex.PrepareAsync(model);
if (task.IsCompletedSuccessfully)
{
_iedBrowserValueSchemaIndex = task.Result;
Expand Down
5 changes: 2 additions & 3 deletions MainWindow.IedBrowser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -221,9 +221,8 @@ private void IedBrowserRefresh_Click(object sender, RoutedEventArgs e)
if (SelectedDevice is { } selected)
{
var model = selected.LiveDiscoveryModel ?? selected.SclWorkspace?.DesignModel;
if (model is not null && _iedBrowserSchemaTasks.TryGetValue(model, out var indexTask) &&
indexTask.IsFaulted)
_iedBrowserSchemaTasks.Remove(model);
if (model is not null)
SharedIedSchemaIndex.ForgetFaulted(model);
}
// Explicit refresh deliberately discards cached local SCL inventories.
// Routine IED switching keeps the bounded per-source cache warm.
Expand Down
5 changes: 2 additions & 3 deletions MainWindow.TopBarContainerHardening.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,9 @@ private static void Apply(MainWindow window)

if (window.FindName("WorkflowNavShell") is Border shell)
{
shell.Height = 64d;
shell.MinHeight = 64d;
// NavigationLayoutFix owns shell height (60) and padding. The
// container guard only prevents clipping/Z-order mistakes.
shell.Margin = new Thickness(0, 2, 0, 2);
shell.Padding = new Thickness(5, 7, 5, 7);
shell.ClipToBounds = false;
Panel.SetZIndex(shell, 60);
}
Expand Down
2 changes: 1 addition & 1 deletion MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
xmlns:views="clr-namespace:ArIED61850Tester.Views"
Title="ARSAS - Smart IEC 61850 Communication Tester"
Height="900" Width="1480" MinHeight="760" MinWidth="1180"
WindowStartupLocation="CenterScreen" Background="{StaticResource AppBackgroundGradient}"
WindowStartupLocation="CenterScreen" WindowState="Maximized" Background="{StaticResource AppBackgroundGradient}"
FontFamily="{StaticResource AppFontFamily}"
UseLayoutRounding="True" SnapsToDevicePixels="True"
TextOptions.TextFormattingMode="Ideal"
Expand Down
77 changes: 77 additions & 0 deletions Models/CanonicalValueChangeJournal.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
namespace ArIED61850Tester.Models;

/// <summary>
/// Revision-only notification from the canonical ledger. This is NOT another
/// live-value store, Report decoder, event history, or IEC acquisition channel.
/// Readers always resolve the current evidence through IedBrowserLiveValues.
/// </summary>
public enum CanonicalValueChangeKind
{
AssociationInvalidated,
Report,
Read,
ReportCompanion
}

public sealed record CanonicalValueChange(
long Revision, long Association, string Reference, string FunctionalConstraint,
CanonicalValueChangeKind Kind);

public sealed record CanonicalValueChangeBatch(
long ThroughRevision, long LatestRevision, bool RequiresFullRefresh,
IReadOnlyList<CanonicalValueChange> Changes);

/// <summary>
/// Constant-memory revision ring shared by arbitrarily many independent cursor
/// readers. All methods run within the enclosing IedBrowserLiveValues gate:
/// there is no second lock, cross-thread event handler, UI callback, or queue
/// consumer that can steal another workspace's notifications.
/// </summary>
internal sealed class CanonicalValueChangeJournal
{
private readonly CanonicalValueChange?[] _ring;
private int _count;
private long _latestRevision;

internal CanonicalValueChangeJournal(int capacity = 4096)
{
if (capacity < 2) throw new ArgumentOutOfRangeException(nameof(capacity));
_ring = new CanonicalValueChange[capacity];
}

internal void Append(CanonicalValueChange change)
{
if (change.Revision != _latestRevision + 1)
throw new InvalidOperationException("Canonical revision journal must be contiguous.");
_latestRevision = change.Revision;
_ring[(int)(_latestRevision % _ring.Length)] = change;
if (_count < _ring.Length) _count++;
}

internal CanonicalValueChangeBatch Read(long afterRevision, int maxChanges)
{
if (maxChanges is < 1 or > 4096)
throw new ArgumentOutOfRangeException(nameof(maxChanges));
if (afterRevision < 0 || afterRevision > _latestRevision)
return new(_latestRevision, _latestRevision, true, []);
if (afterRevision == _latestRevision)
return new(afterRevision, _latestRevision, false, []);

var earliestRetained = _latestRevision - _count + 1;
if (afterRevision < earliestRetained - 1)
return new(_latestRevision, _latestRevision, true, []);

var upper = Math.Min(_latestRevision, afterRevision + maxChanges);
var changes = new CanonicalValueChange[(int)(upper - afterRevision)];
var invalidated = false;
for (long revision = afterRevision + 1; revision <= upper; revision++)
{
var change = _ring[(int)(revision % _ring.Length)]!;
if (change.Revision != revision)
return new(_latestRevision, _latestRevision, true, []);
changes[(int)(revision - afterRevision - 1)] = change;
invalidated |= change.Kind == CanonicalValueChangeKind.AssociationInvalidated;
}
return new(upper, _latestRevision, invalidated, changes);
}
}
Loading
Loading