From 1300965c6041e5e8a189dba5cf3e7e17031c1a7d Mon Sep 17 00:00:00 2001 From: Brett Kinny Date: Wed, 10 Jun 2026 11:47:05 +1000 Subject: [PATCH 1/4] Upgrade Terminal.Gui 2.0.0 -> 2.4.5 (full UI-layer migration) The component-test APIs (Application.Create, InjectKey/InjectSequence, VirtualTimeProvider) first appear in Terminal.Gui 2.1.0, which also landed a breaking API redesign. This ports the whole UI layer to 2.4.5: - Namespace reorg: add GlobalUsings for Terminal.Gui.{App,ViewBase,Views, Drawing,Input,Drivers,Text,Configuration}; alias Attribute -> Drawing.Attribute. - ColorScheme -> Scheme; view.ColorScheme = x -> view.SetScheme(x); add a WithScheme() fluent helper for initializer-style scheme assignment. - Toplevel -> Window; RadioGroup -> OptionSelector (Labels/Value/ValueChanged). - TableView: SelectedRow -> Value?.SelectedCell.Y; SelectedCellChanged -> ValueChanged; MouseClick -> MouseEvent(Mouse). - TreeView ObjectActivated -> Activated (read SelectedObject). - ListView OpenSelectedItem -> Accepting; drop TopItem (SelectedItem autoscrolls). - MenuItem shortcutKey: named arg -> positional Key. - Custom drawing: Driver!.X(...) -> view-level SetAttribute/AddRune/AddStr/Move in OnDrawingContent(DrawContext). - Application.Top -> TopRunnable/TopRunnableView; SizeChanging -> SubViewLayout; Colors.ColorSchemes["Menu"] -> SchemeManager.AddScheme; MessageBox.* now take Application.Instance; ShadowStyle.None -> ShadowStyles.None; Subviews -> SubViews; TextField.CursorPosition -> MoveEnd(). Known regression (flagged for review): Terminal.Gui 2.4 adornments have no independent Scheme, so per-border colouring (grey borders, focus-highlight title) now inherits the view scheme; focus highlight reapplied via FrameView scheme. Builds clean; all 613 existing tests pass; published --help smoke exits 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 1 + App/Dialogs/ConnectDialog.cs | 52 ++++---- App/Dialogs/HelpDialog.cs | Bin 4917 -> 4727 bytes App/Dialogs/OpenConfigDialog.cs | 26 ++-- App/Dialogs/PasswordPromptDialog.cs | 21 ++-- App/Dialogs/QuickHelpDialog.cs | 16 +-- App/Dialogs/SaveConfigDialog.cs | 37 +++--- App/Dialogs/SaveRecordingDialog.cs | 35 +++--- App/Dialogs/ScopeDialog.cs | 13 +- App/Dialogs/WriteValueDialog.cs | 32 ++--- App/FocusManager.cs | 2 +- App/MainWindow.cs | 154 ++++++++++++----------- App/Themes/AppTheme.cs | 35 +++--- App/Themes/DarkTheme.cs | 9 +- App/Themes/LightTheme.cs | 9 +- App/Themes/SchemeExtensions.cs | 21 ++++ App/Themes/ThemeStyler.cs | 48 +++---- App/Views/AddressSpaceView.cs | 26 ++-- App/Views/LogView.cs | 9 +- App/Views/MonitoredVariablesView.cs | 84 +++++-------- App/Views/NodeDetailsView.cs | 36 +++--- App/Views/ScopeView.cs | 77 ++++++------ GlobalUsings.cs | 17 +++ Opcilloscope.csproj | 2 +- Tests/Opcilloscope.Tests/GlobalUsings.cs | 9 ++ 25 files changed, 361 insertions(+), 410 deletions(-) create mode 100644 App/Themes/SchemeExtensions.cs create mode 100644 GlobalUsings.cs create mode 100644 Tests/Opcilloscope.Tests/GlobalUsings.cs diff --git a/.gitignore b/.gitignore index 52bcf7a..2b1e1e1 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ appsettings.*.json # Claude Code local settings .claude/settings.local.json +publish/ diff --git a/App/Dialogs/ConnectDialog.cs b/App/Dialogs/ConnectDialog.cs index dd35868..0a8194c 100644 --- a/App/Dialogs/ConnectDialog.cs +++ b/App/Dialogs/ConnectDialog.cs @@ -15,7 +15,7 @@ public class ConnectDialog : Dialog private const string ProtocolPrefix = "opc.tcp://"; private readonly TextField _endpointField; private readonly NumericUpDown _publishIntervalField; - private readonly RadioGroup _authTypeRadio; + private readonly OptionSelector _authTypeRadio; private readonly Label _usernameLabel; private readonly TextField _usernameField; private readonly Label _passwordLabel; @@ -27,7 +27,7 @@ public class ConnectDialog : Dialog public bool Confirmed => _confirmed; public int PublishingInterval => _publishIntervalField.Value; public AuthenticationType SelectedAuthType => - _authTypeRadio.SelectedItem == 1 ? AuthenticationType.UserName : AuthenticationType.Anonymous; + _authTypeRadio.Value == 1 ? AuthenticationType.UserName : AuthenticationType.Anonymous; public string? Username => SelectedAuthType == AuthenticationType.UserName ? _usernameField.Text?.Trim() : null; public string? Password => SelectedAuthType == AuthenticationType.UserName @@ -60,8 +60,7 @@ public ConnectDialog( X = 1, Y = 2, Text = ProtocolPrefix, - ColorScheme = theme.MainColorScheme - }; + }.WithScheme(theme.MainColorScheme); _endpointField = new TextField { @@ -95,8 +94,7 @@ public ConnectDialog( X = 1, Y = 6, Text = "How often the server sends data updates (100-10000)", - ColorScheme = theme.MainColorScheme - }; + }.WithScheme(theme.MainColorScheme); // Authentication section var authLabel = new Label @@ -106,13 +104,13 @@ public ConnectDialog( Text = "Authentication:" }; - _authTypeRadio = new RadioGroup + _authTypeRadio = new OptionSelector { X = 1, Y = 9, - RadioLabels = ["Anonymous", "Username/Password"], + Labels = ["Anonymous", "Username/Password"], Orientation = Orientation.Horizontal, - SelectedItem = authType == AuthenticationType.UserName ? 1 : 0 + Value = authType == AuthenticationType.UserName ? 1 : 0 }; _usernameLabel = new Label @@ -149,9 +147,9 @@ public ConnectDialog( Visible = authType == AuthenticationType.UserName }; - _authTypeRadio.SelectedItemChanged += (_, _) => + _authTypeRadio.ValueChanged += (_, _) => { - var showCredentials = _authTypeRadio.SelectedItem == 1; + var showCredentials = _authTypeRadio.Value == 1; _usernameLabel.Visible = showCredentials; _usernameField.Visible = showCredentials; _passwordLabel.Visible = showCredentials; @@ -159,13 +157,13 @@ public ConnectDialog( }; // Default button highlighted with amber - var defaultButtonScheme = new ColorScheme + var defaultButtonScheme = new Scheme { - Normal = new Terminal.Gui.Attribute(theme.Accent, theme.Background), - Focus = new Terminal.Gui.Attribute(theme.AccentBright, theme.Background), - HotNormal = new Terminal.Gui.Attribute(theme.Accent, theme.Background), - HotFocus = new Terminal.Gui.Attribute(theme.AccentBright, theme.Background), - Disabled = new Terminal.Gui.Attribute(theme.MutedText, theme.Background) + Normal = new Attribute(theme.Accent, theme.Background), + Focus = new Attribute(theme.AccentBright, theme.Background), + HotNormal = new Attribute(theme.Accent, theme.Background), + HotFocus = new Attribute(theme.AccentBright, theme.Background), + Disabled = new Attribute(theme.MutedText, theme.Background) }; var connectButton = new Button @@ -174,8 +172,7 @@ public ConnectDialog( Y = 14, Text = $"{theme.ButtonPrefix}Connect{theme.ButtonSuffix}", IsDefault = true, - ColorScheme = defaultButtonScheme - }; + }.WithScheme(defaultButtonScheme); connectButton.Accepting += (_, _) => { @@ -191,8 +188,7 @@ public ConnectDialog( X = Pos.Center() + 4, Y = 14, Text = $"{theme.ButtonPrefix}Cancel{theme.ButtonSuffix}", - ColorScheme = theme.ButtonColorScheme - }; + }.WithScheme(theme.ButtonColorScheme); cancelButton.Accepting += (_, _) => { @@ -215,7 +211,7 @@ private bool ValidateInput() if (string.IsNullOrEmpty(serverAddress)) { - MessageBox.ErrorQuery("Error", "Please enter a server address", "OK"); + MessageBox.ErrorQuery(Application.Instance, "Error", "Please enter a server address", "OK"); return false; } @@ -224,20 +220,20 @@ private bool ValidateInput() var uri = new Uri(EndpointUrl); if (string.IsNullOrEmpty(uri.Host)) { - MessageBox.ErrorQuery("Error", "Invalid host in server address", "OK"); + MessageBox.ErrorQuery(Application.Instance, "Error", "Invalid host in server address", "OK"); return false; } } catch { - MessageBox.ErrorQuery("Error", "Invalid server address format", "OK"); + MessageBox.ErrorQuery(Application.Instance, "Error", "Invalid server address format", "OK"); return false; } var interval = _publishIntervalField.Value; if (interval < 100 || interval > 10000) { - MessageBox.ErrorQuery("Error", "Publishing interval must be between 100 and 10000 ms", "OK"); + MessageBox.ErrorQuery(Application.Instance, "Error", "Publishing interval must be between 100 and 10000 ms", "OK"); return false; } @@ -246,7 +242,7 @@ private bool ValidateInput() var username = _usernameField.Text?.Trim() ?? string.Empty; if (string.IsNullOrEmpty(username)) { - MessageBox.ErrorQuery("Error", "Please enter a username", "OK"); + MessageBox.ErrorQuery(Application.Instance, "Error", "Please enter a username", "OK"); _usernameField.SetFocus(); return false; } @@ -254,7 +250,7 @@ private bool ValidateInput() var password = _passwordField.Text ?? string.Empty; if (string.IsNullOrEmpty(password)) { - MessageBox.ErrorQuery("Error", "Please enter a password", "OK"); + MessageBox.ErrorQuery(Application.Instance, "Error", "Please enter a password", "OK"); _passwordField.SetFocus(); return false; } @@ -282,7 +278,7 @@ private void OnTextChanged(object? sender, EventArgs e) { _endpointField.Text = cleaned; // Move cursor to end - _endpointField.CursorPosition = cleaned.Length; + _endpointField.MoveEnd(); } finally { diff --git a/App/Dialogs/HelpDialog.cs b/App/Dialogs/HelpDialog.cs index d5517334d543ca0a6613853b39e7be8cc05ce8bf..f98234b499c8b5ca656e31c11f593a8339a119a3 100644 GIT binary patch delta 182 zcmdn0_FZK{?8f%{OkBaKCBexVskx~dliv%9GHPz_WOiYctknz8EXjaM=Rr7=9XS-hk`8P(%nG%dlc%ul*c`}iz@&&`UP@|O oVrfo^Q)x*_ejdbN&CMG)4LQ&p!>(z~r2qtzyP1tA+Y0>v0NcAep#T5? delta 274 zcmeyavQ=$Dtde6%Nl|7}X-TSrtwKm@QEp~lVve4BX{H`rVq@rYCO+rg8v&v2W!Nxn8kzE1CW(I1WY`|&{ z7N5wj3=^Mxm)(OOWO6MRhy;P!$!}P9@F3a5HJOoDZ1X`jVJ0DvJdzB^eGfT=xe$6c Yf8{Xbz~TiqYp|Nham>b(>ji%R02eq`Pyhe` diff --git a/App/Dialogs/OpenConfigDialog.cs b/App/Dialogs/OpenConfigDialog.cs index 564307b..a682f6d 100644 --- a/App/Dialogs/OpenConfigDialog.cs +++ b/App/Dialogs/OpenConfigDialog.cs @@ -43,8 +43,7 @@ public OpenConfigDialog() Y = 0, Width = Dim.Fill(1), Text = configDir, - ColorScheme = theme.MainColorScheme - }; + }.WithScheme(theme.MainColorScheme); // Scan config directory for files sorted by last modified (newest first) LoadFiles(configDir); @@ -61,10 +60,9 @@ public OpenConfigDialog() Y = 2, Width = Dim.Fill(1), Height = Dim.Fill(3), - ColorScheme = theme.MainColorScheme - }; + }.WithScheme(theme.MainColorScheme); _fileListView.SetSource(new System.Collections.ObjectModel.ObservableCollection(displayNames)); - _fileListView.OpenSelectedItem += (_, _) => Confirm(); + _fileListView.Accepting += (_, _) => Confirm(); var openButton = new Button { @@ -72,15 +70,7 @@ public OpenConfigDialog() Y = Pos.AnchorEnd(1), Text = $"{theme.ButtonPrefix}Open{theme.ButtonSuffix}", IsDefault = true, - ColorScheme = new ColorScheme - { - Normal = new Terminal.Gui.Attribute(theme.Accent, theme.Background), - Focus = new Terminal.Gui.Attribute(theme.AccentBright, theme.Background), - HotNormal = new Terminal.Gui.Attribute(theme.Accent, theme.Background), - HotFocus = new Terminal.Gui.Attribute(theme.AccentBright, theme.Background), - Disabled = new Terminal.Gui.Attribute(theme.MutedText, theme.Background) - } - }; + }.WithScheme(new Scheme { Normal = new Attribute(theme.Accent, theme.Background), Focus = new Attribute(theme.AccentBright, theme.Background), HotNormal = new Attribute(theme.Accent, theme.Background), HotFocus = new Attribute(theme.AccentBright, theme.Background), Disabled = new Attribute(theme.MutedText, theme.Background) }); openButton.Accepting += (_, _) => Confirm(); var browseButton = new Button @@ -88,8 +78,7 @@ public OpenConfigDialog() X = Pos.Right(openButton) + 1, Y = Pos.AnchorEnd(1), Text = $"{theme.ButtonPrefix}Browse...{theme.ButtonSuffix}", - ColorScheme = theme.ButtonColorScheme - }; + }.WithScheme(theme.ButtonColorScheme); browseButton.Accepting += OnBrowse; var cancelButton = new Button @@ -97,8 +86,7 @@ public OpenConfigDialog() X = Pos.Right(browseButton) + 1, Y = Pos.AnchorEnd(1), Text = $"{theme.ButtonPrefix}Cancel{theme.ButtonSuffix}", - ColorScheme = theme.ButtonColorScheme - }; + }.WithScheme(theme.ButtonColorScheme); cancelButton.Accepting += (_, _) => { _confirmed = false; @@ -131,7 +119,7 @@ private void Confirm() { if (_fileListView.SelectedItem >= 0 && _fileListView.SelectedItem < _files.Count) { - SelectedFilePath = _files[_fileListView.SelectedItem].FullName; + SelectedFilePath = _files[_fileListView.SelectedItem!.Value].FullName; _confirmed = true; Application.RequestStop(); } diff --git a/App/Dialogs/PasswordPromptDialog.cs b/App/Dialogs/PasswordPromptDialog.cs index 168d408..e9bc613 100644 --- a/App/Dialogs/PasswordPromptDialog.cs +++ b/App/Dialogs/PasswordPromptDialog.cs @@ -38,8 +38,7 @@ public PasswordPromptDialog(string username, string endpoint) X = 1, Y = 2, Text = endpoint.Length > 50 ? endpoint[..47] + "..." : endpoint, - ColorScheme = theme.MainColorScheme - }; + }.WithScheme(theme.MainColorScheme); _passwordField = new TextField { @@ -49,13 +48,13 @@ public PasswordPromptDialog(string username, string endpoint) Secret = true }; - var defaultButtonScheme = new ColorScheme + var defaultButtonScheme = new Scheme { - Normal = new Terminal.Gui.Attribute(theme.Accent, theme.Background), - Focus = new Terminal.Gui.Attribute(theme.AccentBright, theme.Background), - HotNormal = new Terminal.Gui.Attribute(theme.Accent, theme.Background), - HotFocus = new Terminal.Gui.Attribute(theme.AccentBright, theme.Background), - Disabled = new Terminal.Gui.Attribute(theme.MutedText, theme.Background) + Normal = new Attribute(theme.Accent, theme.Background), + Focus = new Attribute(theme.AccentBright, theme.Background), + HotNormal = new Attribute(theme.Accent, theme.Background), + HotFocus = new Attribute(theme.AccentBright, theme.Background), + Disabled = new Attribute(theme.MutedText, theme.Background) }; var okButton = new Button @@ -64,8 +63,7 @@ public PasswordPromptDialog(string username, string endpoint) Y = 6, Text = $"{theme.ButtonPrefix}OK{theme.ButtonSuffix}", IsDefault = true, - ColorScheme = defaultButtonScheme - }; + }.WithScheme(defaultButtonScheme); okButton.Accepting += (_, _) => { @@ -78,8 +76,7 @@ public PasswordPromptDialog(string username, string endpoint) X = Pos.Center() + 4, Y = 6, Text = $"{theme.ButtonPrefix}Cancel{theme.ButtonSuffix}", - ColorScheme = theme.ButtonColorScheme - }; + }.WithScheme(theme.ButtonColorScheme); cancelButton.Accepting += (_, _) => { diff --git a/App/Dialogs/QuickHelpDialog.cs b/App/Dialogs/QuickHelpDialog.cs index 77bd2ab..c2fb441 100644 --- a/App/Dialogs/QuickHelpDialog.cs +++ b/App/Dialogs/QuickHelpDialog.cs @@ -1,7 +1,6 @@ using Terminal.Gui; using Opcilloscope.App.Keybindings; using Opcilloscope.App.Themes; -using Attribute = Terminal.Gui.Attribute; using ThemeManager = Opcilloscope.App.Themes.ThemeManager; namespace Opcilloscope.App.Dialogs; @@ -45,7 +44,7 @@ public QuickHelpDialog(KeybindingManager keybindingManager) var theme = ThemeManager.Current; // Apply theme styling - ColorScheme = theme.MainColorScheme; + SetScheme(theme.MainColorScheme); BorderStyle = theme.EmphasizedBorderStyle; // Create content with keybindings @@ -60,15 +59,7 @@ public QuickHelpDialog(KeybindingManager keybindingManager) ReadOnly = true, WordWrap = false, Text = content, - ColorScheme = new ColorScheme - { - Normal = new Attribute(theme.Foreground, theme.Background), - Focus = new Attribute(theme.Foreground, theme.Background), - HotNormal = new Attribute(theme.Foreground, theme.Background), - HotFocus = new Attribute(theme.Foreground, theme.Background), - Disabled = new Attribute(theme.MutedText, theme.Background) - } - }; + }.WithScheme(new Scheme { Normal = new Attribute(theme.Foreground, theme.Background), Focus = new Attribute(theme.Foreground, theme.Background), HotNormal = new Attribute(theme.Foreground, theme.Background), HotFocus = new Attribute(theme.Foreground, theme.Background), Disabled = new Attribute(theme.MutedText, theme.Background) }); // Close on any key KeyDown += (_, e) => @@ -86,8 +77,7 @@ public QuickHelpDialog(KeybindingManager keybindingManager) X = Pos.Center(), Y = Pos.AnchorEnd(1), IsDefault = true, - ColorScheme = theme.ButtonColorScheme - }; + }.WithScheme(theme.ButtonColorScheme); closeButton.Accepting += (_, _) => RequestStop(); Add(textView); diff --git a/App/Dialogs/SaveConfigDialog.cs b/App/Dialogs/SaveConfigDialog.cs index 6718def..b3e8b43 100644 --- a/App/Dialogs/SaveConfigDialog.cs +++ b/App/Dialogs/SaveConfigDialog.cs @@ -77,8 +77,7 @@ public SaveConfigDialog(string defaultDirectory, string defaultFilename) X = Pos.Right(_directoryField) + 1, Y = 2, Text = "Browse...", - ColorScheme = theme.ButtonColorScheme - }; + }.WithScheme(theme.ButtonColorScheme); browseButton.Accepting += OnBrowseDirectory; // Filename section @@ -107,8 +106,7 @@ public SaveConfigDialog(string defaultDirectory, string defaultFilename) X = 1, Y = 6, Text = $"Save as type: Opcilloscope Config (*{ConfigurationService.ConfigFileExtension})", - ColorScheme = theme.MainColorScheme - }; + }.WithScheme(theme.MainColorScheme); // Info hint about preserving filename var hintLabel = new Label @@ -116,17 +114,16 @@ public SaveConfigDialog(string defaultDirectory, string defaultFilename) X = 1, Y = 7, Text = "Tip: Use Browse to change folder - filename is preserved", - ColorScheme = theme.MainColorScheme - }; + }.WithScheme(theme.MainColorScheme); // Buttons - var defaultButtonScheme = new ColorScheme + var defaultButtonScheme = new Scheme { - Normal = new Terminal.Gui.Attribute(theme.Accent, theme.Background), - Focus = new Terminal.Gui.Attribute(theme.AccentBright, theme.Background), - HotNormal = new Terminal.Gui.Attribute(theme.Accent, theme.Background), - HotFocus = new Terminal.Gui.Attribute(theme.AccentBright, theme.Background), - Disabled = new Terminal.Gui.Attribute(theme.MutedText, theme.Background) + Normal = new Attribute(theme.Accent, theme.Background), + Focus = new Attribute(theme.AccentBright, theme.Background), + HotNormal = new Attribute(theme.Accent, theme.Background), + HotFocus = new Attribute(theme.AccentBright, theme.Background), + Disabled = new Attribute(theme.MutedText, theme.Background) }; var saveButton = new Button @@ -135,8 +132,7 @@ public SaveConfigDialog(string defaultDirectory, string defaultFilename) Y = 9, Text = $"{theme.ButtonPrefix}Save{theme.ButtonSuffix}", IsDefault = true, - ColorScheme = defaultButtonScheme - }; + }.WithScheme(defaultButtonScheme); saveButton.Accepting += OnSave; var cancelButton = new Button @@ -144,8 +140,7 @@ public SaveConfigDialog(string defaultDirectory, string defaultFilename) X = Pos.Center() + 3, Y = 9, Text = $"{theme.ButtonPrefix}Cancel{theme.ButtonSuffix}", - ColorScheme = theme.ButtonColorScheme - }; + }.WithScheme(theme.ButtonColorScheme); cancelButton.Accepting += OnCancel; Add(directoryLabel, _directoryField, browseButton, @@ -225,7 +220,7 @@ private bool ValidateSave() var filename = _currentFilename.Trim(); if (string.IsNullOrEmpty(filename)) { - MessageBox.ErrorQuery("Error", "Please enter a filename", "OK"); + MessageBox.ErrorQuery(Application.Instance, "Error", "Please enter a filename", "OK"); return false; } @@ -233,7 +228,7 @@ private bool ValidateSave() var invalidChars = Path.GetInvalidFileNameChars(); if (filename.IndexOfAny(invalidChars) >= 0) { - MessageBox.ErrorQuery("Error", "Filename contains invalid characters", "OK"); + MessageBox.ErrorQuery(Application.Instance, "Error", "Filename contains invalid characters", "OK"); return false; } @@ -241,7 +236,7 @@ private bool ValidateSave() var directory = _currentDirectory.Trim(); if (string.IsNullOrEmpty(directory)) { - MessageBox.ErrorQuery("Error", "Please specify a directory", "OK"); + MessageBox.ErrorQuery(Application.Instance, "Error", "Please specify a directory", "OK"); return false; } @@ -255,7 +250,7 @@ private bool ValidateSave() } catch (Exception ex) { - MessageBox.ErrorQuery("Error", $"Cannot create directory: {ex.Message}", "OK"); + MessageBox.ErrorQuery(Application.Instance, "Error", $"Cannot create directory: {ex.Message}", "OK"); return false; } @@ -263,7 +258,7 @@ private bool ValidateSave() var fullPath = FilePath; if (File.Exists(fullPath)) { - var result = MessageBox.Query("Confirm Overwrite", + var result = MessageBox.Query(Application.Instance, "Confirm Overwrite", $"File '{Path.GetFileName(fullPath)}' already exists.\nDo you want to replace it?", "Yes", "No"); if (result != 0) // "No" selected diff --git a/App/Dialogs/SaveRecordingDialog.cs b/App/Dialogs/SaveRecordingDialog.cs index 2c26c23..26526c0 100644 --- a/App/Dialogs/SaveRecordingDialog.cs +++ b/App/Dialogs/SaveRecordingDialog.cs @@ -77,10 +77,9 @@ public SaveRecordingDialog(string defaultDirectory, string defaultFilename) Y = 5, Width = Dim.Fill(1), Height = Dim.Fill(6), - ColorScheme = theme.MainColorScheme - }; + }.WithScheme(theme.MainColorScheme); - _fileListView.OpenSelectedItem += OnFileListOpenSelected; + _fileListView.Accepting += OnFileListOpenSelected; _fileListView.KeyDown += OnFileListKeyDown; // Filename label and field @@ -100,13 +99,13 @@ public SaveRecordingDialog(string defaultDirectory, string defaultFilename) }; // Buttons - var defaultButtonScheme = new ColorScheme + var defaultButtonScheme = new Scheme { - Normal = new Terminal.Gui.Attribute(theme.Accent, theme.Background), - Focus = new Terminal.Gui.Attribute(theme.AccentBright, theme.Background), - HotNormal = new Terminal.Gui.Attribute(theme.Accent, theme.Background), - HotFocus = new Terminal.Gui.Attribute(theme.AccentBright, theme.Background), - Disabled = new Terminal.Gui.Attribute(theme.MutedText, theme.Background) + Normal = new Attribute(theme.Accent, theme.Background), + Focus = new Attribute(theme.AccentBright, theme.Background), + HotNormal = new Attribute(theme.Accent, theme.Background), + HotFocus = new Attribute(theme.AccentBright, theme.Background), + Disabled = new Attribute(theme.MutedText, theme.Background) }; var saveButton = new Button @@ -115,8 +114,7 @@ public SaveRecordingDialog(string defaultDirectory, string defaultFilename) Y = Pos.AnchorEnd(1), Text = $"{theme.ButtonPrefix}Save{theme.ButtonSuffix}", IsDefault = true, - ColorScheme = defaultButtonScheme - }; + }.WithScheme(defaultButtonScheme); saveButton.Accepting += (_, _) => { @@ -132,8 +130,7 @@ public SaveRecordingDialog(string defaultDirectory, string defaultFilename) X = Pos.Center() + 4, Y = Pos.AnchorEnd(1), Text = $"{theme.ButtonPrefix}Cancel{theme.ButtonSuffix}", - ColorScheme = theme.ButtonColorScheme - }; + }.WithScheme(theme.ButtonColorScheme); cancelButton.Accepting += (_, _) => { @@ -191,11 +188,11 @@ private void LoadDirectory(string directory) } catch (Exception ex) { - MessageBox.ErrorQuery("Error", $"Cannot access directory:\n{ex.Message}", "OK"); + MessageBox.ErrorQuery(Application.Instance, "Error", $"Cannot access directory:\n{ex.Message}", "OK"); } } - private void OnFileListOpenSelected(object? sender, ListViewItemEventArgs e) + private void OnFileListOpenSelected(object? sender, CommandEventArgs e) { NavigateToSelected(); } @@ -214,7 +211,7 @@ private void NavigateToSelected() if (_fileListView.SelectedItem < 0 || _fileListView.SelectedItem >= _fileListItems.Count) return; - var selected = _fileListItems[_fileListView.SelectedItem]; + var selected = _fileListItems[_fileListView.SelectedItem!.Value]; if (selected == "..") { @@ -250,7 +247,7 @@ private bool ValidateAndSetPath() if (string.IsNullOrEmpty(filename)) { - MessageBox.ErrorQuery("Error", "Please enter a filename", "OK"); + MessageBox.ErrorQuery(Application.Instance, "Error", "Please enter a filename", "OK"); return false; } @@ -261,7 +258,7 @@ private bool ValidateAndSetPath() var invalidChars = Path.GetInvalidFileNameChars(); if (filename.IndexOfAny(invalidChars) >= 0) { - MessageBox.ErrorQuery("Error", "Filename contains invalid characters", "OK"); + MessageBox.ErrorQuery(Application.Instance, "Error", "Filename contains invalid characters", "OK"); return false; } @@ -270,7 +267,7 @@ private bool ValidateAndSetPath() // Check if file already exists if (File.Exists(fullPath)) { - var result = MessageBox.Query("Confirm Overwrite", + var result = MessageBox.Query(Application.Instance, "Confirm Overwrite", $"File already exists:\n{filename}\n\nOverwrite?", "Yes", "No"); if (result != 0) diff --git a/App/Dialogs/ScopeDialog.cs b/App/Dialogs/ScopeDialog.cs index 6784133..7b8b246 100644 --- a/App/Dialogs/ScopeDialog.cs +++ b/App/Dialogs/ScopeDialog.cs @@ -47,16 +47,14 @@ public ScopeDialog( Y = Pos.Bottom(_scopeView), Width = Dim.Fill(), Height = 2, - ColorScheme = ColorScheme - }; + }.WithScheme(GetScheme()!); _pauseButton = new Button { X = 1, Y = 0, Text = $"{Theme.ButtonPrefix}PAUSE{Theme.ButtonSuffix}", - ColorScheme = Theme.ButtonColorScheme - }; + }.WithScheme(Theme.ButtonColorScheme); _pauseButton.Accepting += OnPauseToggle; _closeButton = new Button @@ -64,8 +62,7 @@ public ScopeDialog( X = Pos.Right(_pauseButton) + 2, Y = 0, Text = $"{Theme.ButtonPrefix}CLOSE{Theme.ButtonSuffix}", - ColorScheme = Theme.ButtonColorScheme - }; + }.WithScheme(Theme.ButtonColorScheme); _closeButton.Accepting += (_, _) => Application.RequestStop(); buttonFrame.Add(_pauseButton, _closeButton); @@ -109,10 +106,10 @@ private void OnThemeChanged(AppTheme theme) _pauseButton.Text = _scopeView.IsPaused ? $"{theme.ButtonPrefix}RESUME{theme.ButtonSuffix}" : $"{theme.ButtonPrefix}PAUSE{theme.ButtonSuffix}"; - _pauseButton.ColorScheme = theme.ButtonColorScheme; + _pauseButton.SetScheme(theme.ButtonColorScheme); _closeButton.Text = $"{theme.ButtonPrefix}CLOSE{theme.ButtonSuffix}"; - _closeButton.ColorScheme = theme.ButtonColorScheme; + _closeButton.SetScheme(theme.ButtonColorScheme); _scopeView.SetNeedsLayout(); }); diff --git a/App/Dialogs/WriteValueDialog.cs b/App/Dialogs/WriteValueDialog.cs index 76b0ca6..fbbb805 100644 --- a/App/Dialogs/WriteValueDialog.cs +++ b/App/Dialogs/WriteValueDialog.cs @@ -116,49 +116,39 @@ public WriteValueDialog(NodeId nodeId, string nodeName, BuiltInType dataType, st Y = Pos.Bottom(_valueField), Width = Dim.Fill()! - 1, Text = "", - ColorScheme = new ColorScheme - { - Normal = new Terminal.Gui.Attribute(theme.Error, theme.Background), - Focus = new Terminal.Gui.Attribute(theme.Error, theme.Background), - HotNormal = new Terminal.Gui.Attribute(theme.Error, theme.Background), - HotFocus = new Terminal.Gui.Attribute(theme.Error, theme.Background), - Disabled = new Terminal.Gui.Attribute(theme.Error, theme.Background) - } - }; + }.WithScheme(new Scheme { Normal = new Attribute(theme.Error, theme.Background), Focus = new Attribute(theme.Error, theme.Background), HotNormal = new Attribute(theme.Error, theme.Background), HotFocus = new Attribute(theme.Error, theme.Background), Disabled = new Attribute(theme.Error, theme.Background) }); // Real-time validation _valueField.TextChanged += (_, _) => ValidateInput(); // Default button highlighted with amber - var defaultButtonScheme = new ColorScheme + var defaultButtonScheme = new Scheme { - Normal = new Terminal.Gui.Attribute(theme.Accent, theme.Background), - Focus = new Terminal.Gui.Attribute(theme.AccentBright, theme.Background), - HotNormal = new Terminal.Gui.Attribute(theme.Accent, theme.Background), - HotFocus = new Terminal.Gui.Attribute(theme.AccentBright, theme.Background), - Disabled = new Terminal.Gui.Attribute(theme.MutedText, theme.Background) + Normal = new Attribute(theme.Accent, theme.Background), + Focus = new Attribute(theme.AccentBright, theme.Background), + HotNormal = new Attribute(theme.Accent, theme.Background), + HotFocus = new Attribute(theme.AccentBright, theme.Background), + Disabled = new Attribute(theme.MutedText, theme.Background) }; var writeButton = new Button { Text = $"{theme.ButtonPrefix}Write{theme.ButtonSuffix}", IsDefault = true, - ColorScheme = defaultButtonScheme - }; + }.WithScheme(defaultButtonScheme); var cancelButton = new Button { Text = $"{theme.ButtonPrefix}Cancel{theme.ButtonSuffix}", - ColorScheme = theme.ButtonColorScheme - }; + }.WithScheme(theme.ButtonColorScheme); writeButton.Accepting += (_, _) => { if (ValidateAndParse()) { // Show confirmation dialog before writing - var confirmResult = MessageBox.Query( + var confirmResult = MessageBox.Query(Application.Instance, "Confirm Write", $"Write '{_valueField.Text}' to {nodeName}?", "Yes", "No"); @@ -220,7 +210,7 @@ private bool ValidateAndParse() // Check if write is supported for this data type if (!OpcValueConverter.IsWriteSupported(_dataType)) { - MessageBox.ErrorQuery("Write Error", $"Write not supported for data type: {_dataType}", "OK"); + MessageBox.ErrorQuery(Application.Instance, "Write Error", $"Write not supported for data type: {_dataType}", "OK"); return false; } diff --git a/App/FocusManager.cs b/App/FocusManager.cs index 70a2849..c63eff9 100644 --- a/App/FocusManager.cs +++ b/App/FocusManager.cs @@ -53,7 +53,7 @@ public void StopTracking() private bool PollFocus() { - var focused = Application.Top?.MostFocused; + var focused = Application.TopRunnableView?.MostFocused; var newPane = FindContainingPane(focused); if (newPane != _currentPane) diff --git a/App/MainWindow.cs b/App/MainWindow.cs index efc81a5..b8fa722 100644 --- a/App/MainWindow.cs +++ b/App/MainWindow.cs @@ -17,7 +17,7 @@ namespace Opcilloscope.App; /// Main application window with layout orchestration. /// Implements lazygit-inspired keybinding system. /// -public class MainWindow : Toplevel, DefaultKeybindings.IKeybindingActions +public class MainWindow : Window, DefaultKeybindings.IKeybindingActions { private readonly Logger _logger; private readonly ConnectionManager _connectionManager; @@ -85,14 +85,14 @@ public MainWindow() // Override global "Menu" ColorScheme BEFORE creating any views // This prevents StatusBar's blue background flash on first render var theme = ThemeManager.Current; - Colors.ColorSchemes["Menu"] = new ColorScheme + SchemeManager.AddScheme("Menu", new Scheme { - Normal = new Terminal.Gui.Attribute(theme.Foreground, theme.Background), - Focus = new Terminal.Gui.Attribute(theme.ForegroundBright, theme.Background), - HotNormal = new Terminal.Gui.Attribute(theme.Accent, theme.Background), - HotFocus = new Terminal.Gui.Attribute(theme.AccentBright, theme.Background), - Disabled = new Terminal.Gui.Attribute(theme.MutedText, theme.Background) - }; + Normal = new Attribute(theme.Foreground, theme.Background), + Focus = new Attribute(theme.ForegroundBright, theme.Background), + HotNormal = new Attribute(theme.Accent, theme.Background), + HotFocus = new Attribute(theme.AccentBright, theme.Background), + Disabled = new Attribute(theme.MutedText, theme.Background) + }); // Create theme toggle menu item _themeToggleItem = new MenuItem(GetThemeToggleTitle(), "", ToggleTheme); @@ -146,14 +146,14 @@ public MainWindow() }; // Also set ColorScheme directly on the StatusBar instance - _statusBar.ColorScheme = new ColorScheme + _statusBar.SetScheme(new Scheme { - Normal = new Terminal.Gui.Attribute(theme.Foreground, theme.Background), - Focus = new Terminal.Gui.Attribute(theme.ForegroundBright, theme.Background), - HotNormal = new Terminal.Gui.Attribute(theme.Accent, theme.Background), - HotFocus = new Terminal.Gui.Attribute(theme.AccentBright, theme.Background), - Disabled = new Terminal.Gui.Attribute(theme.MutedText, theme.Background) - }; + Normal = new Attribute(theme.Foreground, theme.Background), + Focus = new Attribute(theme.ForegroundBright, theme.Background), + HotNormal = new Attribute(theme.Accent, theme.Background), + HotFocus = new Attribute(theme.AccentBright, theme.Background), + Disabled = new Attribute(theme.MutedText, theme.Background) + }); // Connection status indicator (colored) - FAR RIGHT, overlaid on status bar row // We position it dynamically based on text width @@ -222,8 +222,10 @@ public MainWindow() // Apply initial theme (after all controls are created) ApplyTheme(); - // Handle window resize to update connection status label position - Application.SizeChanging += (s, e) => UiThread.Run(UpdateConnectionStatusLabelPosition); + // Handle window resize to update connection status label position. + // Terminal.Gui 2.4 removed Application.SizeChanging; the window's own + // SubViewLayout fires whenever the terminal (and thus this window) is re-laid out. + SubViewLayout += (s, e) => UiThread.Run(UpdateConnectionStatusLabelPosition); // Run status bar startup sequence RunStatusBarStartup(); @@ -251,10 +253,10 @@ private void RunStatusBarStartup() // Show first message immediately _connectionStatusLabel.Text = " Square Wave Systems 2026 "; - _connectionStatusLabel.ColorScheme = new ColorScheme + _connectionStatusLabel.SetScheme(new Scheme { - Normal = new Terminal.Gui.Attribute(theme.Accent, theme.Background) - }; + Normal = new Attribute(theme.Accent, theme.Background) + }); UpdateConnectionStatusLabelPosition(); _startupStatusTimer = Application.AddTimeout(TimeSpan.FromSeconds(1), () => @@ -264,10 +266,10 @@ private void RunStatusBarStartup() { // Second message _connectionStatusLabel.Text = " All systems nominal "; - _connectionStatusLabel.ColorScheme = new ColorScheme + _connectionStatusLabel.SetScheme(new Scheme { - Normal = new Terminal.Gui.Attribute(theme.StatusGood, theme.Background) - }; + Normal = new Attribute(theme.StatusGood, theme.Background) + }); UpdateConnectionStatusLabelPosition(); return true; // Continue } @@ -291,14 +293,14 @@ private MenuBar CreateMenuBar() { new MenuBarItem("_File", new MenuItem[] { - new MenuItem("_Open Config...", "", OpenConfig, shortcutKey: Key.O.WithCtrl), - new MenuItem("_Save Config", "", SaveConfig, shortcutKey: Key.S.WithCtrl), - new MenuItem("Save Config _As...", "", SaveConfigAs, shortcutKey: Key.S.WithCtrl.WithShift), + new MenuItem("_Open Config...", "", OpenConfig, Key.O.WithCtrl), + new MenuItem("_Save Config", "", SaveConfig, Key.S.WithCtrl), + new MenuItem("Save Config _As...", "", SaveConfigAs, Key.S.WithCtrl.WithShift), null!, // Separator - new MenuItem("Start Recording...", "", () => OnRecordRequested(), shortcutKey: Key.R.WithCtrl), + new MenuItem("Start Recording...", "", () => OnRecordRequested(), Key.R.WithCtrl), new MenuItem("Stop Recording", "", () => OnStopRecordingRequested()), null!, // Separator - new MenuItem("E_xit", "", () => RequestStop(), shortcutKey: Key.Q.WithCtrl) + new MenuItem("E_xit", "", () => RequestStop(), Key.Q.WithCtrl) }), new MenuBarItem("_Connection", new MenuItem[] { @@ -327,47 +329,45 @@ private void ApplyTheme() var theme = ThemeManager.Current; // Update global "Menu" ColorScheme (used by StatusBar) - Colors.ColorSchemes["Menu"] = new ColorScheme + SchemeManager.AddScheme("Menu", new Scheme { - Normal = new Terminal.Gui.Attribute(theme.Foreground, theme.Background), - Focus = new Terminal.Gui.Attribute(theme.ForegroundBright, theme.Background), - HotNormal = new Terminal.Gui.Attribute(theme.Accent, theme.Background), - HotFocus = new Terminal.Gui.Attribute(theme.AccentBright, theme.Background), - Disabled = new Terminal.Gui.Attribute(theme.MutedText, theme.Background) - }; + Normal = new Attribute(theme.Foreground, theme.Background), + Focus = new Attribute(theme.ForegroundBright, theme.Background), + HotNormal = new Attribute(theme.Accent, theme.Background), + HotFocus = new Attribute(theme.AccentBright, theme.Background), + Disabled = new Attribute(theme.MutedText, theme.Background) + }); // Apply main window styling - double-line for emphasis - ColorScheme = theme.MainColorScheme; + SetScheme(theme.MainColorScheme); BorderStyle = theme.EmphasizedBorderStyle; - // Apply highlight title color to main window border so "opcilloscope" stands out - if (Border != null) - { - Border.ColorScheme = theme.HighlightTitleBorderColorScheme; - } + // NOTE: Terminal.Gui 2.4 removed per-adornment schemes, so the main window border + // can no longer be given the distinct HighlightTitleBorderColorScheme; it inherits + // the window scheme. Title-highlight colouring to be revisited via Scheme VisualRoles. // Apply styling to menu bar ThemeStyler.ApplyToMenuBar(_menuBar, theme); // Apply clean status bar styling (no blue background) // Must set ColorScheme AND call SetNeedsDisplay to override Terminal.Gui defaults - var cleanStatusBarScheme = new ColorScheme + var cleanStatusBarScheme = new Scheme { - Normal = new Terminal.Gui.Attribute(theme.Foreground, theme.Background), - Focus = new Terminal.Gui.Attribute(theme.ForegroundBright, theme.Background), - HotNormal = new Terminal.Gui.Attribute(theme.Accent, theme.Background), - HotFocus = new Terminal.Gui.Attribute(theme.AccentBright, theme.Background), - Disabled = new Terminal.Gui.Attribute(theme.MutedText, theme.Background) + Normal = new Attribute(theme.Foreground, theme.Background), + Focus = new Attribute(theme.ForegroundBright, theme.Background), + HotNormal = new Attribute(theme.Accent, theme.Background), + HotFocus = new Attribute(theme.AccentBright, theme.Background), + Disabled = new Attribute(theme.MutedText, theme.Background) }; - _statusBar.ColorScheme = cleanStatusBarScheme; + _statusBar.SetScheme(cleanStatusBarScheme); _statusBar.SetNeedsLayout(); // Also apply theme to connection status label UpdateConnectionStatusLabelStyle(_isConnected); // Apply theme to activity spinner and label (for async operations) - _activitySpinner.ColorScheme = cleanStatusBarScheme; - _activityLabel.ColorScheme = cleanStatusBarScheme; + _activitySpinner.SetScheme(cleanStatusBarScheme); + _activityLabel.SetScheme(cleanStatusBarScheme); // Apply to child views with border differentiation // MonitoredVariables gets double-line (emphasized) @@ -583,14 +583,14 @@ private void WriteToMonitoredVariable(MonitoredNode variable) if (!variable.IsWritable) { _logger.Warning($"Node '{variable.DisplayName}' is not writable"); - MessageBox.ErrorQuery("Write", $"Node '{variable.DisplayName}' is not writable.", "OK"); + MessageBox.ErrorQuery(Application.Instance, "Write", $"Node '{variable.DisplayName}' is not writable.", "OK"); return; } if (!OpcValueConverter.IsWriteSupported(variable.DataType)) { _logger.Warning($"Write not supported for data type {variable.DataType}"); - MessageBox.ErrorQuery("Write", $"Write not supported for data type: {variable.DataType}", "OK"); + MessageBox.ErrorQuery(Application.Instance, "Write", $"Write not supported for data type: {variable.DataType}", "OK"); return; } @@ -639,14 +639,14 @@ private async Task WriteToAddressSpaceNodeAsync(BrowsedNode node) if ((accessLevel & Opc.Ua.AccessLevels.CurrentWrite) == 0) { _logger.Warning($"Node '{node.DisplayName}' is not writable"); - UiThread.Run(() => MessageBox.ErrorQuery("Write", $"Node '{node.DisplayName}' is not writable.", "OK")); + UiThread.Run(() => MessageBox.ErrorQuery(Application.Instance, "Write", $"Node '{node.DisplayName}' is not writable.", "OK")); return; } if (!OpcValueConverter.IsWriteSupported(builtInType)) { _logger.Warning($"Write not supported for data type {builtInType}"); - UiThread.Run(() => MessageBox.ErrorQuery("Write", $"Write not supported for data type: {builtInType}", "OK")); + UiThread.Run(() => MessageBox.ErrorQuery(Application.Instance, "Write", $"Write not supported for data type: {builtInType}", "OK")); return; } @@ -729,9 +729,11 @@ private void UpdatePanelBorder(View panel, bool isFocused) if (panel is FrameView frameView && frameView.Border != null) { - frameView.Border.ColorScheme = isFocused + // Terminal.Gui 2.4 adornments have no independent scheme; the border/title render + // from the FrameView's own scheme, so apply the focus scheme to the frame itself. + frameView.SetScheme(isFocused ? theme.FocusedBorderColorScheme - : theme.BorderColorScheme; + : theme.BorderColorScheme); frameView.SetNeedsLayout(); } } @@ -743,7 +745,7 @@ private void UpdatePanelBorder(View panel, bool isFocused) private void UpdateStatusBarShortcuts() { // Remove existing shortcuts (preserve activity spinner and labels) - var itemsToRemove = _statusBar.Subviews + var itemsToRemove = _statusBar.SubViews .OfType() .ToList(); @@ -783,7 +785,7 @@ private void ShowQuickHelp() private void OnApplicationKeyDown(object? sender, Key e) { if (e.Handled) return; - if (Application.Top != this) return; // Don't fire during dialogs + if (Application.TopRunnable != this) return; // Don't fire during dialogs if (IsViewNavigationKey(e)) return; // Let Enter/Space/etc reach local handlers @@ -879,7 +881,7 @@ private void OnConnectionError(string message) { UiThread.Run(() => { - MessageBox.ErrorQuery("Connection Error", message, "OK"); + MessageBox.ErrorQuery(Application.Instance, "Connection Error", message, "OK"); }); } @@ -917,21 +919,21 @@ private void UpdateConnectionStatus(bool isConnected) private void UpdateConnectionStatusLabelStyle(bool isConnected) { var theme = ThemeManager.Current; - _connectionStatusLabel.ColorScheme = new ColorScheme + _connectionStatusLabel.SetScheme(new Scheme { - Normal = new Terminal.Gui.Attribute( + Normal = new Attribute( isConnected ? theme.StatusGood : theme.Accent, theme.Background), - Focus = new Terminal.Gui.Attribute( + Focus = new Attribute( isConnected ? theme.StatusGood : theme.Accent, theme.Background), - HotNormal = new Terminal.Gui.Attribute( + HotNormal = new Attribute( isConnected ? theme.StatusGood : theme.Accent, theme.Background), - HotFocus = new Terminal.Gui.Attribute( + HotFocus = new Attribute( isConnected ? theme.StatusGood : theme.Accent, theme.Background) - }; + }); } private void ToggleRecording() @@ -1001,7 +1003,7 @@ private void LaunchScope() { if (_connectionManager.SubscriptionManager == null) { - MessageBox.Query("Scope", "Connect to a server first.", "OK"); + MessageBox.Query(Application.Instance, "Scope", "Connect to a server first.", "OK"); return; } @@ -1009,7 +1011,7 @@ private void LaunchScope() if (selectedNodes.Count == 0) { - MessageBox.Query("Scope", "Select up to 5 nodes to display in Scope.\nUse Space to toggle selection on monitored variables.", "OK"); + MessageBox.Query(Application.Instance, "Scope", "Select up to 5 nodes to display in Scope.\nUse Space to toggle selection on monitored variables.", "OK"); return; } @@ -1028,7 +1030,7 @@ private void OnRecordRequested() var subscriptionManager = _connectionManager.SubscriptionManager; if (subscriptionManager == null || !subscriptionManager.MonitoredVariables.Any()) { - MessageBox.Query("Record", "No variables to record. Subscribe to variables first.", "OK"); + MessageBox.Query(Application.Instance, "Record", "No variables to record. Subscribe to variables first.", "OK"); return; } @@ -1036,7 +1038,7 @@ private void OnRecordRequested() var selectedCount = _monitoredVariablesView.ScopeSelectionCount; if (selectedCount == 0) { - MessageBox.Query("Record", + MessageBox.Query(Application.Instance, "Record", "No variables selected for recording.\n\n" + "Use Space to select variables in the Sel column (◉).\n" + "Selected variables will be recorded and shown in Scope.", "OK"); @@ -1061,7 +1063,7 @@ private void OnRecordRequested() } else { - MessageBox.ErrorQuery("Recording Error", "Failed to start recording", "OK"); + MessageBox.ErrorQuery(Application.Instance, "Recording Error", "Failed to start recording", "OK"); } } } @@ -1076,7 +1078,7 @@ private void OnStopRecordingRequested() StopRecordingStatusUpdates(); _csvRecordingManager.StopRecording(); _monitoredVariablesView.UpdateRecordingStatus("", false); - MessageBox.Query("Recording", $"Recording saved.\n{_csvRecordingManager.RecordCount} records written.", "OK"); + MessageBox.Query(Application.Instance, "Recording", $"Recording saved.\n{_csvRecordingManager.RecordCount} records written.", "OK"); } private void StartRecordingStatusUpdates() @@ -1137,7 +1139,7 @@ industrial automation data in real-time. © 2026 Square Wave Systems License: MIT "; - MessageBox.Query("About opcilloscope", about, "OK"); + MessageBox.Query(Application.Instance, "About opcilloscope", about, "OK"); } #region Configuration File Handling @@ -1272,7 +1274,7 @@ private async Task LoadConfigurationAsync(string filePath) else { _logger.Error($"Failed to connect to {config.Server.EndpointUrl}"); - MessageBox.ErrorQuery("Connection Failed", + MessageBox.ErrorQuery(Application.Instance, "Connection Failed", $"Could not connect to server:\n{config.Server.EndpointUrl}\n\nThe previous connection and data have been preserved.", "OK"); } @@ -1297,7 +1299,7 @@ private async Task LoadConfigurationAsync(string filePath) catch (Exception ex) { _logger.Error($"Failed to load configuration: {ex.Message}"); - MessageBox.ErrorQuery("Error", $"Failed to load configuration:\n{ex.Message}", "OK"); + MessageBox.ErrorQuery(Application.Instance, "Error", $"Failed to load configuration:\n{ex.Message}", "OK"); } finally { @@ -1342,7 +1344,7 @@ private async Task SaveConfigurationAsync(string filePath) catch (Exception ex) { _logger.Error($"Failed to save configuration: {ex.Message}"); - MessageBox.ErrorQuery("Error", $"Failed to save:\n{ex.Message}", "OK"); + MessageBox.ErrorQuery(Application.Instance, "Error", $"Failed to save:\n{ex.Message}", "OK"); } finally { @@ -1371,7 +1373,7 @@ private void UpdateWindowTitle() /// True if the user confirms, false to cancel the operation. private bool ConfirmDiscardChanges() { - var result = MessageBox.Query( + var result = MessageBox.Query(Application.Instance, "Unsaved Changes", "You have unsaved changes. Do you want to discard them?", "Discard", diff --git a/App/Themes/AppTheme.cs b/App/Themes/AppTheme.cs index 7d07b1f..32a12c0 100644 --- a/App/Themes/AppTheme.cs +++ b/App/Themes/AppTheme.cs @@ -1,5 +1,4 @@ using Terminal.Gui; -using Attribute = Terminal.Gui.Attribute; namespace Opcilloscope.App.Themes; @@ -116,16 +115,16 @@ public abstract class AppTheme public virtual bool EnableGlow => true; // === Cached Color Schemes for Terminal.Gui Widgets === - private ColorScheme? _mainColorScheme; - private ColorScheme? _dialogColorScheme; - private ColorScheme? _menuColorScheme; - private ColorScheme? _buttonColorScheme; - private ColorScheme? _frameColorScheme; - private ColorScheme? _borderColorScheme; - private ColorScheme? _focusedBorderColorScheme; - private ColorScheme? _highlightTitleBorderColorScheme; - - public virtual ColorScheme MainColorScheme => _mainColorScheme ??= new() + private Scheme? _mainColorScheme; + private Scheme? _dialogColorScheme; + private Scheme? _menuColorScheme; + private Scheme? _buttonColorScheme; + private Scheme? _frameColorScheme; + private Scheme? _borderColorScheme; + private Scheme? _focusedBorderColorScheme; + private Scheme? _highlightTitleBorderColorScheme; + + public virtual Scheme MainColorScheme => _mainColorScheme ??= new() { Normal = NormalAttr, Focus = BrightAttr, @@ -134,7 +133,7 @@ public abstract class AppTheme Disabled = new Attribute(StatusInactive, Background) }; - public virtual ColorScheme DialogColorScheme => _dialogColorScheme ??= new() + public virtual Scheme DialogColorScheme => _dialogColorScheme ??= new() { Normal = DimAttr, Focus = BrightAttr, @@ -143,7 +142,7 @@ public abstract class AppTheme Disabled = new Attribute(StatusInactive, Background) }; - public virtual ColorScheme MenuColorScheme => _menuColorScheme ??= new() + public virtual Scheme MenuColorScheme => _menuColorScheme ??= new() { Normal = NormalAttr, Focus = new Attribute(Background, Foreground), @@ -152,7 +151,7 @@ public abstract class AppTheme Disabled = new Attribute(StatusInactive, Background) }; - public virtual ColorScheme ButtonColorScheme => _buttonColorScheme ??= new() + public virtual Scheme ButtonColorScheme => _buttonColorScheme ??= new() { Normal = BorderAttr, Focus = BrightAttr, @@ -161,7 +160,7 @@ public abstract class AppTheme Disabled = new Attribute(StatusInactive, Background) }; - public virtual ColorScheme FrameColorScheme => _frameColorScheme ??= new() + public virtual Scheme FrameColorScheme => _frameColorScheme ??= new() { Normal = BorderAttr, Focus = BrightAttr, @@ -174,7 +173,7 @@ public abstract class AppTheme /// Color scheme for structural borders - uses grey for border lines, /// but accent color for titles (HotNormal is used for title text). /// - public virtual ColorScheme BorderColorScheme => _borderColorScheme ??= new() + public virtual Scheme BorderColorScheme => _borderColorScheme ??= new() { Normal = BorderAttr, Focus = BorderAttr, @@ -187,7 +186,7 @@ public abstract class AppTheme /// Color scheme for the main window border - uses bright accent for the title /// so "opcilloscope" stands out prominently from sub-panel titles. /// - public virtual ColorScheme HighlightTitleBorderColorScheme => _highlightTitleBorderColorScheme ??= new() + public virtual Scheme HighlightTitleBorderColorScheme => _highlightTitleBorderColorScheme ??= new() { Normal = BorderAttr, Focus = BorderAttr, @@ -200,7 +199,7 @@ public abstract class AppTheme /// Color scheme for focused view borders - uses accent color to highlight /// which panel currently has keyboard focus. /// - public virtual ColorScheme FocusedBorderColorScheme => _focusedBorderColorScheme ??= new() + public virtual Scheme FocusedBorderColorScheme => _focusedBorderColorScheme ??= new() { Normal = AccentAttr, Focus = AccentAttr, diff --git a/App/Themes/DarkTheme.cs b/App/Themes/DarkTheme.cs index 8f0c7ea..ec606ff 100644 --- a/App/Themes/DarkTheme.cs +++ b/App/Themes/DarkTheme.cs @@ -1,5 +1,4 @@ using Terminal.Gui; -using Attribute = Terminal.Gui.Attribute; namespace Opcilloscope.App.Themes; @@ -75,10 +74,10 @@ public class DarkTheme : AppTheme public override bool EnableGlow => true; // Override color schemes for dark display with amber highlights - private ColorScheme? _mainColorScheme; - private ColorScheme? _menuColorScheme; + private Scheme? _mainColorScheme; + private Scheme? _menuColorScheme; - public override ColorScheme MainColorScheme => _mainColorScheme ??= new() + public override Scheme MainColorScheme => _mainColorScheme ??= new() { Normal = NormalAttr, Focus = new Attribute(ForegroundBright, new Color(45, 45, 45)), // #2d2d2d panel background @@ -87,7 +86,7 @@ public class DarkTheme : AppTheme Disabled = new Attribute(StatusInactive, Background) }; - public override ColorScheme MenuColorScheme => _menuColorScheme ??= new() + public override Scheme MenuColorScheme => _menuColorScheme ??= new() { Normal = NormalAttr, Focus = new Attribute(Background, Foreground), // Inverted for menu focus diff --git a/App/Themes/LightTheme.cs b/App/Themes/LightTheme.cs index f9970ae..2765cfe 100644 --- a/App/Themes/LightTheme.cs +++ b/App/Themes/LightTheme.cs @@ -1,5 +1,4 @@ using Terminal.Gui; -using Attribute = Terminal.Gui.Attribute; namespace Opcilloscope.App.Themes; @@ -77,13 +76,13 @@ public class LightTheme : AppTheme public override bool EnableGlow => false; // Override color schemes for light display with amber highlights - private ColorScheme? _mainColorScheme; - private ColorScheme? _menuColorScheme; + private Scheme? _mainColorScheme; + private Scheme? _menuColorScheme; // Highlight color for selection - warm tan for visible contrast on light background private Color HighlightBackground => new(232, 212, 184); // #e8d4b8 warm tan - public override ColorScheme MainColorScheme => _mainColorScheme ??= new() + public override Scheme MainColorScheme => _mainColorScheme ??= new() { Normal = NormalAttr, Focus = new Attribute(Foreground, HighlightBackground), // Dark text on tan background @@ -92,7 +91,7 @@ public class LightTheme : AppTheme Disabled = new Attribute(StatusInactive, Background) }; - public override ColorScheme MenuColorScheme => _menuColorScheme ??= new() + public override Scheme MenuColorScheme => _menuColorScheme ??= new() { Normal = NormalAttr, Focus = new Attribute(Background, Foreground), // Inverted for menu focus diff --git a/App/Themes/SchemeExtensions.cs b/App/Themes/SchemeExtensions.cs new file mode 100644 index 0000000..2ffcaa1 --- /dev/null +++ b/App/Themes/SchemeExtensions.cs @@ -0,0 +1,21 @@ +namespace Opcilloscope.App.Themes; + +/// +/// Helpers for applying a to a view. +/// Terminal.Gui 2.4 replaced the settable View.ColorScheme property with the +/// SetScheme(Scheme) method, which cannot be used inside an object initializer. +/// restores a fluent form so a scheme can still be applied +/// in a single expression: new Label { Text = "x" }.WithScheme(scheme). +/// +public static class SchemeExtensions +{ + /// + /// Applies to and returns the view + /// so the call can be chained onto a constructor expression. + /// + public static T WithScheme(this T view, Scheme scheme) where T : View + { + view.SetScheme(scheme); + return view; + } +} diff --git a/App/Themes/ThemeStyler.cs b/App/Themes/ThemeStyler.cs index 6bfdd84..f9d4aae 100644 --- a/App/Themes/ThemeStyler.cs +++ b/App/Themes/ThemeStyler.cs @@ -18,17 +18,14 @@ public static void ApplyTo(View view, AppTheme? theme = null) theme ??= ThemeManager.Current; // Apply color scheme - view.ColorScheme = theme.MainColorScheme; + view.SetScheme(theme.MainColorScheme); // Note: BorderStyle is NOT set here - callers should set it explicitly // to control emphasized vs secondary border styling - // Configure border colors - use BorderColorScheme for consistent grey borders - // that don't change to amber/yellow when focused (avoids terminal inconsistencies) - if (view.Border != null) - { - view.Border.ColorScheme = theme.BorderColorScheme; - } + // NOTE: Terminal.Gui 2.4 made adornments (Border/Margin/Padding) non-View objects + // without their own Scheme, so borders now render with the view's scheme. The former + // per-border grey/focus colouring (BorderColorScheme) no longer applies here. // Apply margin and padding from theme if (view.Margin != null) @@ -60,13 +57,8 @@ public static void ApplyToDialog(Dialog dialog, AppTheme? theme = null) { theme ??= ThemeManager.Current; - dialog.ColorScheme = theme.DialogColorScheme; + dialog.SetScheme(theme.DialogColorScheme); dialog.BorderStyle = theme.BorderLineStyle; - - if (dialog.Border != null) - { - dialog.Border.ColorScheme = theme.BorderColorScheme; - } } /// @@ -75,7 +67,7 @@ public static void ApplyToDialog(Dialog dialog, AppTheme? theme = null) public static void ApplyToButton(Button button, AppTheme? theme = null) { theme ??= ThemeManager.Current; - button.ColorScheme = theme.ButtonColorScheme; + button.SetScheme(theme.ButtonColorScheme); } /// @@ -84,7 +76,7 @@ public static void ApplyToButton(Button button, AppTheme? theme = null) public static void ApplyToMenuBar(MenuBar menuBar, AppTheme? theme = null) { theme ??= ThemeManager.Current; - menuBar.ColorScheme = theme.MenuColorScheme; + menuBar.SetScheme(theme.MenuColorScheme); } /// @@ -93,7 +85,7 @@ public static void ApplyToMenuBar(MenuBar menuBar, AppTheme? theme = null) public static void ApplyToStatusBar(StatusBar statusBar, AppTheme? theme = null) { theme ??= ThemeManager.Current; - statusBar.ColorScheme = theme.MenuColorScheme; + statusBar.SetScheme(theme.MenuColorScheme); } /// @@ -102,11 +94,9 @@ public static void ApplyToStatusBar(StatusBar statusBar, AppTheme? theme = null) public static Label CreateLabel(string text, AppTheme? theme = null) { theme ??= ThemeManager.Current; - return new Label - { - Text = text, - ColorScheme = theme.MainColorScheme - }; + var label = new Label { Text = text }; + label.SetScheme(theme.MainColorScheme); + return label; } /// @@ -115,11 +105,9 @@ public static Label CreateLabel(string text, AppTheme? theme = null) public static TextField CreateTextField(string text = "", AppTheme? theme = null) { theme ??= ThemeManager.Current; - return new TextField - { - Text = text, - ColorScheme = theme.MainColorScheme - }; + var field = new TextField { Text = text }; + field.SetScheme(theme.MainColorScheme); + return field; } /// @@ -128,10 +116,8 @@ public static TextField CreateTextField(string text = "", AppTheme? theme = null public static Button CreateButton(string text, AppTheme? theme = null) { theme ??= ThemeManager.Current; - return new Button - { - Text = text, - ColorScheme = theme.ButtonColorScheme - }; + var button = new Button { Text = text }; + button.SetScheme(theme.ButtonColorScheme); + return button; } } diff --git a/App/Views/AddressSpaceView.cs b/App/Views/AddressSpaceView.cs index 64fb47c..fa3e044 100644 --- a/App/Views/AddressSpaceView.cs +++ b/App/Views/AddressSpaceView.cs @@ -47,7 +47,6 @@ public AddressSpaceView() // Configure tree style for cleaner look _treeView.Style.CollapseableSymbol = new Rune('▼'); _treeView.Style.ExpandableSymbol = new Rune('▶'); - _treeView.Style.LeaveLastRow = false; _treeView.SelectionChanged += (_, args) => { @@ -58,7 +57,7 @@ public AddressSpaceView() }; _treeView.KeyDown += HandleKeyDown; - _treeView.ObjectActivated += HandleObjectActivated; + _treeView.Activated += HandleObjectActivated; // Create empty state label _emptyStateLabel = new Label @@ -66,11 +65,7 @@ public AddressSpaceView() X = Pos.Center(), Y = Pos.Center(), Text = "", - ColorScheme = new ColorScheme - { - Normal = new Terminal.Gui.Attribute(theme.MutedText, theme.Background) - } - }; + }.WithScheme(new Scheme { Normal = new Attribute(theme.MutedText, theme.Background) }); Add(_treeView); Add(_emptyStateLabel); @@ -87,10 +82,10 @@ private void OnThemeChanged(AppTheme theme) { Application.Invoke(() => { - _emptyStateLabel.ColorScheme = new ColorScheme + _emptyStateLabel.SetScheme(new Scheme { - Normal = new Terminal.Gui.Attribute(theme.MutedText, theme.Background) - }; + Normal = new Attribute(theme.MutedText, theme.Background) + }); SetNeedsLayout(); }); } @@ -229,11 +224,14 @@ private void HandleKeyDown(object? _, Key e) } } - private void HandleObjectActivated(object? _, ObjectActivatedEventArgs e) + private void HandleObjectActivated(object? _, EventArgs e) { - if (e.ActivatedObject != null && e.ActivatedObject.NodeClass == Opc.Ua.NodeClass.Variable) + // Terminal.Gui 2.4 replaced TreeView.ObjectActivated (which carried the object) + // with the generic Activated command event; read the current selection instead. + var activated = _treeView.SelectedObject; + if (activated != null && activated.NodeClass == Opc.Ua.NodeClass.Variable) { - NodeSubscribeRequested?.Invoke(e.ActivatedObject); + NodeSubscribeRequested?.Invoke(activated); } } @@ -243,7 +241,7 @@ protected override void Dispose(bool disposing) { ThemeManager.ThemeChanged -= OnThemeChanged; _treeView.KeyDown -= HandleKeyDown; - _treeView.ObjectActivated -= HandleObjectActivated; + _treeView.Activated -= HandleObjectActivated; } base.Dispose(disposing); } diff --git a/App/Views/LogView.cs b/App/Views/LogView.cs index dad77fa..f43006b 100644 --- a/App/Views/LogView.cs +++ b/App/Views/LogView.cs @@ -2,7 +2,6 @@ using Opcilloscope.Utilities; using Opcilloscope.App.Themes; using System.Collections.ObjectModel; -using Attribute = Terminal.Gui.Attribute; using ThemeManager = Opcilloscope.App.Themes.ThemeManager; namespace Opcilloscope.App.Views; @@ -35,9 +34,8 @@ public LogView() X = Pos.AnchorEnd(8), Y = 0, Height = 1, - ShadowStyle = ShadowStyle.None, - ColorScheme = theme.ButtonColorScheme - }; + ShadowStyle = ShadowStyles.None, + }.WithScheme(theme.ButtonColorScheme); _copyButton.Accepting += OnCopyClicked; _listView = new ListView @@ -94,7 +92,7 @@ private void OnThemeChanged(AppTheme theme) Application.Invoke(() => { BorderStyle = theme.FrameLineStyle; - _copyButton.ColorScheme = theme.ButtonColorScheme; + _copyButton.SetScheme(theme.ButtonColorScheme); SetNeedsLayout(); }); } @@ -117,7 +115,6 @@ private void OnLogAdded(LogEntry entry) if (_displayedEntries.Count > 0) { _listView.SelectedItem = _displayedEntries.Count - 1; - _listView.TopItem = Math.Max(0, _displayedEntries.Count - _listView.Frame.Height); } }); } diff --git a/App/Views/MonitoredVariablesView.cs b/App/Views/MonitoredVariablesView.cs index b230a7b..3f5b5ab 100644 --- a/App/Views/MonitoredVariablesView.cs +++ b/App/Views/MonitoredVariablesView.cs @@ -3,7 +3,6 @@ using Opcilloscope.App.Themes; using System.Collections.Concurrent; using System.Data; -using Attribute = Terminal.Gui.Attribute; using ThemeManager = Opcilloscope.App.Themes.ThemeManager; namespace Opcilloscope.App.Views; @@ -58,9 +57,10 @@ public MonitoredNode? SelectedVariable { get { - if (_tableView.SelectedRow >= 0 && _tableView.SelectedRow < _dataTable.Rows.Count) + var selectedRow = _tableView.Value?.SelectedCell.Y ?? -1; + if (selectedRow >= 0 && selectedRow < _dataTable.Rows.Count) { - var row = _dataTable.Rows[_tableView.SelectedRow]; + var row = _dataTable.Rows[selectedRow]; return row["_VariableRef"] as MonitoredNode; } return null; @@ -127,20 +127,12 @@ public MonitoredVariablesView() Height = Dim.Fill(), Table = new DataTableSource(_dataTable), FullRowSelect = true, - ColorScheme = new ColorScheme - { - Normal = new Attribute(theme.Foreground, theme.Background), - Focus = new Attribute(theme.ForegroundBright, theme.Background), - HotNormal = new Attribute(theme.Accent, theme.Background), - HotFocus = new Attribute(theme.AccentBright, theme.Background), - Disabled = new Attribute(theme.MutedText, theme.Background) - } - }; + }.WithScheme(new Scheme { Normal = new Attribute(theme.Foreground, theme.Background), Focus = new Attribute(theme.ForegroundBright, theme.Background), HotNormal = new Attribute(theme.Accent, theme.Background), HotFocus = new Attribute(theme.AccentBright, theme.Background), Disabled = new Attribute(theme.MutedText, theme.Background) }); // Configure table style for cleaner look _tableView.Style.ShowHorizontalHeaderOverline = false; _tableView.Style.ShowHorizontalHeaderUnderline = true; - _tableView.Style.ShowHorizontalBottomline = false; + _tableView.Style.ShowHorizontalBottomLine = false; _tableView.Style.AlwaysShowHeaders = true; _tableView.Style.ShowVerticalCellLines = false; _tableView.Style.ShowVerticalHeaderLines = false; @@ -154,8 +146,8 @@ public MonitoredVariablesView() // Terminal.Gui v2 TableView doesn't support per-row coloring _tableView.KeyDown += HandleKeyDown; - _tableView.MouseClick += HandleMouseClick; - _tableView.SelectedCellChanged += OnSelectedCellChanged; + _tableView.MouseEvent += HandleMouseClick; + _tableView.ValueChanged += OnSelectedCellChanged; // Create empty state label _emptyStateLabel = new Label @@ -163,15 +155,7 @@ public MonitoredVariablesView() X = Pos.Center(), Y = Pos.Center(), Text = "", - ColorScheme = new ColorScheme - { - Normal = new Attribute(theme.MutedText, theme.Background), - Focus = new Attribute(theme.MutedText, theme.Background), - HotNormal = new Attribute(theme.MutedText, theme.Background), - HotFocus = new Attribute(theme.MutedText, theme.Background), - Disabled = new Attribute(theme.MutedText, theme.Background) - } - }; + }.WithScheme(new Scheme { Normal = new Attribute(theme.MutedText, theme.Background), Focus = new Attribute(theme.MutedText, theme.Background), HotNormal = new Attribute(theme.MutedText, theme.Background), HotFocus = new Attribute(theme.MutedText, theme.Background), Disabled = new Attribute(theme.MutedText, theme.Background) }); // Recording indicator (left of record button in title bar area) _recordingIndicatorLabel = new Label @@ -180,15 +164,7 @@ public MonitoredVariablesView() Y = 0, Text = "", Visible = false, - ColorScheme = new ColorScheme - { - Normal = new Attribute(theme.MutedText, theme.Background), - Focus = new Attribute(theme.MutedText, theme.Background), - HotNormal = new Attribute(theme.MutedText, theme.Background), - HotFocus = new Attribute(theme.MutedText, theme.Background), - Disabled = new Attribute(theme.MutedText, theme.Background) - } - }; + }.WithScheme(new Scheme { Normal = new Attribute(theme.MutedText, theme.Background), Focus = new Attribute(theme.MutedText, theme.Background), HotNormal = new Attribute(theme.MutedText, theme.Background), HotFocus = new Attribute(theme.MutedText, theme.Background), Disabled = new Attribute(theme.MutedText, theme.Background) }); // Recording toggle button (right-aligned, matching LogView Copy button) _recordButton = new Button @@ -198,9 +174,8 @@ public MonitoredVariablesView() Y = 0, Width = 10, Height = 1, - ShadowStyle = ShadowStyle.None, - ColorScheme = theme.ButtonColorScheme - }; + ShadowStyle = ShadowStyles.None, + }.WithScheme(theme.ButtonColorScheme); _recordButton.Accepting += OnRecordButtonClicked; // Subscribe to theme changes @@ -230,27 +205,27 @@ private void OnThemeChanged(AppTheme theme) BorderStyle = theme.EmphasizedBorderStyle; // Update empty state label color - _emptyStateLabel.ColorScheme = new ColorScheme + _emptyStateLabel.SetScheme(new Scheme { Normal = new Attribute(theme.MutedText, theme.Background), Focus = new Attribute(theme.MutedText, theme.Background), HotNormal = new Attribute(theme.MutedText, theme.Background), HotFocus = new Attribute(theme.MutedText, theme.Background), Disabled = new Attribute(theme.MutedText, theme.Background) - }; + }); // Update table view colors - _tableView.ColorScheme = new ColorScheme + _tableView.SetScheme(new Scheme { Normal = new Attribute(theme.Foreground, theme.Background), Focus = new Attribute(theme.ForegroundBright, theme.Background), HotNormal = new Attribute(theme.Accent, theme.Background), HotFocus = new Attribute(theme.AccentBright, theme.Background), Disabled = new Attribute(theme.MutedText, theme.Background) - }; + }); // Update record button colors - _recordButton.ColorScheme = theme.ButtonColorScheme; + _recordButton.SetScheme(theme.ButtonColorScheme); SetNeedsLayout(); }); @@ -443,11 +418,12 @@ private void OnRecordButtonClicked(object? sender, CommandEventArgs e) RecordToggleRequested?.Invoke(); } - private void OnSelectedCellChanged(object? sender, SelectedCellChangedEventArgs e) + private void OnSelectedCellChanged(object? sender, ValueChangedEventArgs e) { - if (e.NewRow >= 0 && e.NewRow < _dataTable.Rows.Count) + var newRow = e.NewValue?.SelectedCell.Y ?? -1; + if (newRow >= 0 && newRow < _dataTable.Rows.Count) { - var row = _dataTable.Rows[e.NewRow]; + var row = _dataTable.Rows[newRow]; if (row["_VariableRef"] is MonitoredNode node) { SelectedVariableChanged?.Invoke(node); @@ -477,10 +453,14 @@ private void HandleKeyDown(object? _, Key e) } } - private void HandleMouseClick(object? sender, MouseEventArgs e) + private void HandleMouseClick(object? sender, Mouse e) { + // Only react to a discrete click, not move/press/release events + if (!e.IsSingleClicked || e.Position is not { } pos) + return; + // Convert screen position to table cell - _tableView.ScreenToCell(e.Position.X, e.Position.Y, out int? columnIndex, out int? rowIndex); + _tableView.ScreenToCell(pos.X, pos.Y, out int? columnIndex, out int? rowIndex); // Only toggle selection when clicking on the "Sel" column (column 0) if (columnIndex.HasValue && columnIndex.Value == 0 && @@ -511,14 +491,14 @@ private void ToggleScopeSelectionForVariable(MonitoredNode variable) // Show feedback that max is reached var theme = ThemeManager.Current; _selectionFeedback.Text = $"Max {MaxScopeSelections} variables for Scope/Recording"; - _selectionFeedback.ColorScheme = new ColorScheme + _selectionFeedback.SetScheme(new Scheme { Normal = new Attribute(theme.Warning, theme.Background), Focus = new Attribute(theme.Warning, theme.Background), HotNormal = new Attribute(theme.Warning, theme.Background), HotFocus = new Attribute(theme.Warning, theme.Background), Disabled = new Attribute(theme.Warning, theme.Background) - }; + }); _selectionFeedback.Visible = true; // Hide after a delay @@ -561,14 +541,14 @@ public void UpdateRecordingStatus(string text, bool isRecording) _recordingIndicatorLabel.Text = text; _recordingIndicatorLabel.Visible = !string.IsNullOrEmpty(text); - _recordingIndicatorLabel.ColorScheme = new ColorScheme + _recordingIndicatorLabel.SetScheme(new Scheme { Normal = new Attribute(color, theme.Background), Focus = new Attribute(color, theme.Background), HotNormal = new Attribute(color, theme.Background), HotFocus = new Attribute(color, theme.Background), Disabled = new Attribute(color, theme.Background) - }; + }); SetNeedsLayout(); } @@ -591,8 +571,8 @@ protected override void Dispose(bool disposing) ThemeManager.ThemeChanged -= OnThemeChanged; _recordButton.Accepting -= OnRecordButtonClicked; - _tableView.MouseClick -= HandleMouseClick; - _tableView.SelectedCellChanged -= OnSelectedCellChanged; + _tableView.MouseEvent -= HandleMouseClick; + _tableView.ValueChanged -= OnSelectedCellChanged; _tableView.KeyDown -= HandleKeyDown; } base.Dispose(disposing); diff --git a/App/Views/NodeDetailsView.cs b/App/Views/NodeDetailsView.cs index 9678d64..51ce42d 100644 --- a/App/Views/NodeDetailsView.cs +++ b/App/Views/NodeDetailsView.cs @@ -36,10 +36,10 @@ public NodeDetailsView() X = Pos.AnchorEnd(8), Y = 0, Height = 1, - ShadowStyle = ShadowStyle.None, - ColorScheme = theme.ButtonColorScheme, + ShadowStyle = ShadowStyles.None, Enabled = false }; + _copyButton.SetScheme(theme.ButtonColorScheme); _copyButton.Accepting += OnCopyClicked; _detailsLabel = new Label @@ -50,11 +50,7 @@ public NodeDetailsView() Height = Dim.Fill(), Text = "", TextAlignment = Alignment.Start, - ColorScheme = new ColorScheme - { - Normal = new Terminal.Gui.Attribute(theme.MutedText, theme.Background) - } - }; + }.WithScheme(new Scheme { Normal = new Attribute(theme.MutedText, theme.Background) }); // Subscribe to theme changes ThemeManager.ThemeChanged += OnThemeChanged; @@ -68,23 +64,23 @@ private void OnThemeChanged(AppTheme theme) Application.Invoke(() => { // Update copy button styling - _copyButton.ColorScheme = theme.ButtonColorScheme; + _copyButton.SetScheme(theme.ButtonColorScheme); // When showing empty state, keep muted color if (_detailsLabel.Text == "" || _detailsLabel.Text == "Not connected") { - _detailsLabel.ColorScheme = new ColorScheme + _detailsLabel.SetScheme(new Scheme { - Normal = new Terminal.Gui.Attribute(theme.MutedText, theme.Background) - }; + Normal = new Attribute(theme.MutedText, theme.Background) + }); } else { - _detailsLabel.ColorScheme = new ColorScheme + _detailsLabel.SetScheme(new Scheme { - Normal = new Terminal.Gui.Attribute(theme.Foreground, theme.Background) - }; + Normal = new Attribute(theme.Foreground, theme.Background) + }); } SetNeedsLayout(); }); @@ -214,19 +210,19 @@ public void Clear() private void SetMutedColor() { var theme = ThemeManager.Current; - _detailsLabel.ColorScheme = new ColorScheme + _detailsLabel.SetScheme(new Scheme { - Normal = new Terminal.Gui.Attribute(theme.MutedText, theme.Background) - }; + Normal = new Attribute(theme.MutedText, theme.Background) + }); } private void SetNormalColor() { var theme = ThemeManager.Current; - _detailsLabel.ColorScheme = new ColorScheme + _detailsLabel.SetScheme(new Scheme { - Normal = new Terminal.Gui.Attribute(theme.Foreground, theme.Background) - }; + Normal = new Attribute(theme.Foreground, theme.Background) + }); } private static string FormatValueRank(int? valueRank) diff --git a/App/Views/ScopeView.cs b/App/Views/ScopeView.cs index 17cedd1..a2a1336 100644 --- a/App/Views/ScopeView.cs +++ b/App/Views/ScopeView.cs @@ -3,7 +3,6 @@ using Opcilloscope.OpcUa.Models; using Opcilloscope.App.Themes; using Opcilloscope.Utilities; -using Attribute = Terminal.Gui.Attribute; using ThemeManager = Opcilloscope.App.Themes.ThemeManager; namespace Opcilloscope.App.Views; @@ -27,7 +26,7 @@ private class SeriesData { public MonitoredNode Node { get; init; } = null!; public List Samples { get; } = new(2000); - public Terminal.Gui.Color LineColor { get; init; } + public Color LineColor { get; init; } // Stats tracking public float CurrentValue { get; set; } = float.NaN; @@ -47,13 +46,13 @@ private class SeriesData private const double TimeWindowZoomFactor = 1.5; // Distinct colors for up to 5 series - private static readonly Terminal.Gui.Color[] SeriesColors = + private static readonly Color[] SeriesColors = { - Terminal.Gui.Color.Green, - Terminal.Gui.Color.Cyan, - Terminal.Gui.Color.Yellow, - Terminal.Gui.Color.Magenta, - Terminal.Gui.Color.White + Color.Green, + Color.Cyan, + Color.Yellow, + Color.Magenta, + Color.White }; // Layout constants @@ -103,7 +102,6 @@ public ScopeView() ThemeManager.ThemeChanged += OnThemeChanged; CanFocus = true; - WantMousePositionReports = false; _startTime = DateTime.Now; } @@ -116,14 +114,14 @@ private void ApplyTheme() theme = _currentTheme; } - ColorScheme = new ColorScheme + SetScheme(new Scheme { Normal = theme.NormalAttr, Focus = theme.BrightAttr, HotNormal = theme.AccentAttr, HotFocus = theme.BrightAttr, Disabled = theme.DimAttr - }; + }); } private void OnThemeChanged(AppTheme newTheme) @@ -304,7 +302,6 @@ private bool OnTimerTick() /// protected override bool OnDrawingContent(DrawContext? context) { - if (Driver is null) return false; AppTheme theme; lock (_themeLock) @@ -336,12 +333,12 @@ protected override bool OnDrawingContent(DrawContext? context) // Clear the viewport var normalAttr = theme.NormalAttr; - Driver!.SetAttribute(normalAttr); + SetAttribute(normalAttr); for (int y = 0; y < totalHeight; y++) { Move(0, y); for (int x = 0; x < totalWidth; x++) - Driver!.AddRune(' '); + AddRune(' '); } // === Draw header === @@ -478,8 +475,8 @@ protected override bool OnDrawingContent(DrawContext? context) char brailleChar = canvas.GetCellFiltered(cx, cy); if (brailleChar == '\u2800') { - Driver!.SetAttribute(normalAttr); - Driver!.AddRune(' '); + SetAttribute(normalAttr); + AddRune(' '); continue; } @@ -488,19 +485,19 @@ protected override bool OnDrawingContent(DrawContext? context) if (dominantLayer == 100) { // Cursor - Driver!.SetAttribute(accentAttr); + SetAttribute(accentAttr); } else if (dominantLayer >= 0 && dominantLayer < signalAttrs.Length) { - Driver!.SetAttribute(signalAttrs[dominantLayer]); + SetAttribute(signalAttrs[dominantLayer]); } else { // Grid or unknown - Driver!.SetAttribute(gridAttr); + SetAttribute(gridAttr); } - Driver!.AddRune(brailleChar); + AddRune(brailleChar); } } @@ -523,9 +520,9 @@ protected override bool OnDrawingContent(DrawContext? context) int msgY = plotTop + plotHeight / 2; if (msgX >= 0 && msgY >= 0) { - Driver!.SetAttribute(theme.DimAttr); + SetAttribute(theme.DimAttr); Move(msgX, msgY); - Driver!.AddStr(msg); + AddStr(msg); } } @@ -546,10 +543,10 @@ private void DrawHeader(AppTheme theme, List seriesCopy, int totalWi string activityIndicator = !_isPaused && (_frameCount % 10) < 5 ? "●" : "○"; string headerText = $"{theme.TitleDecoration} {title} {theme.TitleDecoration} {statusIndicator} {activityIndicator}"; - Driver!.SetAttribute(theme.BrightAttr); + SetAttribute(theme.BrightAttr); int headerX = Math.Max(0, (totalWidth - headerText.Length) / 2); Move(headerX, 0); - Driver!.AddStr(headerText.Length <= totalWidth ? headerText : headerText[..totalWidth]); + AddStr(headerText.Length <= totalWidth ? headerText : headerText[..totalWidth]); // Legend line var legendParts = seriesCopy.Select((s, i) => @@ -557,10 +554,10 @@ private void DrawHeader(AppTheme theme, List seriesCopy, int totalWi .ToList(); string legendText = string.Join(" ", legendParts); - Driver!.SetAttribute(theme.DimAttr); + SetAttribute(theme.DimAttr); int legendX = Math.Max(0, (totalWidth - legendText.Length) / 2); Move(legendX, 1); - Driver!.AddStr(legendText.Length <= totalWidth ? legendText : legendText[..totalWidth]); + AddStr(legendText.Length <= totalWidth ? legendText : legendText[..totalWidth]); } private void DrawGrid(BrailleCanvas canvas, int pixelW, int pixelH) @@ -585,7 +582,7 @@ private void DrawGrid(BrailleCanvas canvas, int pixelW, int pixelH) private void DrawYAxisLabels(AppTheme theme, int plotTop, int plotHeight, float visibleMin, float visibleMax) { - Driver!.SetAttribute(theme.DimAttr); + SetAttribute(theme.DimAttr); int numLabels = Math.Min(plotHeight, 5); for (int i = 0; i <= numLabels; i++) @@ -598,14 +595,14 @@ private void DrawYAxisLabels(AppTheme theme, int plotTop, int plotHeight, // Right-align the label int x = Math.Max(0, YAxisLabelWidth - 1 - label.Length); Move(x, y); - Driver!.AddStr(label); + AddStr(label); } } private void DrawXAxisLabels(AppTheme theme, int plotLeft, int labelY, int plotWidth, double windowDuration) { - Driver!.SetAttribute(theme.DimAttr); + SetAttribute(theme.DimAttr); int numLabels = Math.Min(plotWidth / 8, 6); // At least 8 chars apart if (numLabels < 2) numLabels = 2; @@ -622,7 +619,7 @@ private void DrawXAxisLabels(AppTheme theme, int plotLeft, int labelY, labelX = Math.Clamp(labelX, plotLeft, plotLeft + plotWidth - label.Length); Move(labelX, labelY); - Driver!.AddStr(label); + AddStr(label); } } @@ -653,9 +650,9 @@ private void DrawStatsOverlay(AppTheme theme, List seriesCopy, // Use signal color for the stat line var attr = new Attribute(s.LineColor, theme.Background); - Driver!.SetAttribute(attr); + SetAttribute(attr); Move(x, y); - Driver!.AddStr(statsText.Length <= plotWidth ? statsText : statsText[..plotWidth]); + AddStr(statsText.Length <= plotWidth ? statsText : statsText[..plotWidth]); } } @@ -726,8 +723,8 @@ private void DrawStatusBar(AppTheme theme, List seriesCopy, foreach (var seg in segments) { - Driver!.SetAttribute(seg.IsKey ? keyAttr : labelAttr); - Driver!.AddStr(seg.Text); + SetAttribute(seg.IsKey ? keyAttr : labelAttr); + AddStr(seg.Text); } } @@ -758,13 +755,13 @@ private static float InterpolateSampleAtTime(List samples, return (float)(before.Value + t * (after.Value - before.Value)); } - private static string GetColorName(Terminal.Gui.Color color) + private static string GetColorName(Color color) { - if (color == Terminal.Gui.Color.Green) return "GRN"; - if (color == Terminal.Gui.Color.Cyan) return "CYN"; - if (color == Terminal.Gui.Color.Yellow) return "YEL"; - if (color == Terminal.Gui.Color.Magenta) return "MAG"; - if (color == Terminal.Gui.Color.White) return "WHT"; + if (color == Color.Green) return "GRN"; + if (color == Color.Cyan) return "CYN"; + if (color == Color.Yellow) return "YEL"; + if (color == Color.Magenta) return "MAG"; + if (color == Color.White) return "WHT"; return "???"; } diff --git a/GlobalUsings.cs b/GlobalUsings.cs new file mode 100644 index 0000000..8a66994 --- /dev/null +++ b/GlobalUsings.cs @@ -0,0 +1,17 @@ +// Global usings for Terminal.Gui v2.4.x namespace layout. +// Terminal.Gui 2.1+ split the former flat `Terminal.Gui` namespace into sub-namespaces +// (App / ViewBase / Views / Drawing / Input / Text / Configuration). These global usings +// keep the application source free of per-file using churn after the 2.0 -> 2.4 upgrade. +global using Terminal.Gui.App; +global using Terminal.Gui.ViewBase; +global using Terminal.Gui.Views; +global using Terminal.Gui.Drawing; +global using Terminal.Gui.Input; +global using Terminal.Gui.Drivers; +global using Terminal.Gui.Text; +global using Terminal.Gui.Configuration; + +// `Attribute` is ambiguous between System.Attribute and Terminal.Gui.Drawing.Attribute once +// the Drawing namespace is imported globally. Alias the bare name to the Terminal.Gui type, +// which is the only one the UI code uses. +global using Attribute = Terminal.Gui.Drawing.Attribute; diff --git a/Opcilloscope.csproj b/Opcilloscope.csproj index 23beed8..b1a5ae3 100644 --- a/Opcilloscope.csproj +++ b/Opcilloscope.csproj @@ -35,7 +35,7 @@ - + diff --git a/Tests/Opcilloscope.Tests/GlobalUsings.cs b/Tests/Opcilloscope.Tests/GlobalUsings.cs new file mode 100644 index 0000000..4bd4ad9 --- /dev/null +++ b/Tests/Opcilloscope.Tests/GlobalUsings.cs @@ -0,0 +1,9 @@ +// Terminal.Gui 2.4.x split the former flat `Terminal.Gui` namespace into sub-namespaces. +// Mirror the application's global usings so test code can reference the UI types directly. +global using Terminal.Gui.App; +global using Terminal.Gui.ViewBase; +global using Terminal.Gui.Views; +global using Terminal.Gui.Drawing; +global using Terminal.Gui.Input; +global using Terminal.Gui.Drivers; +global using Attribute = Terminal.Gui.Drawing.Attribute; From fb44660d50f277704cf932e7ebeb4b65054e9a30 Mon Sep 17 00:00:00 2001 From: Brett Kinny Date: Wed, 10 Jun 2026 11:56:55 +1000 Subject: [PATCH 2/4] Add layer-1 in-process TUI component tests Constructs the real Terminal.Gui views/dialogs and asserts on observable component behaviour: - MonitoredVariablesView: AddVariable/RemoveVariable, scope-selection bookkeeping, per-client-handle idempotency. - ConnectDialog: endpoint protocol-prefix handling, publishing interval, and the authentication selector (migrated RadioGroup -> OptionSelector). - Theme/Scheme: DarkTheme/LightTheme schemes and ThemeStyler.ApplyTo guard the ColorScheme -> Scheme migration. Tests run in a non-parallel xUnit collection because Terminal.Gui's Application is global state. Picked up automatically by `dotnet test` / CI. Rendered cell-buffer assertions are intentionally deferred to the black-box PTY suite: Terminal.Gui 2.4.5 stable exposes no public headless driver (Application.Create leaves Driver null; DriverAssert is develop-only). Documented in docs/TESTING.md. 627/627 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Tui/ConnectDialogTests.cs | 60 +++++++++++++++ .../Tui/MonitoredVariablesViewTests.cs | 76 +++++++++++++++++++ .../Tui/ThemeSchemeTests.cs | 53 +++++++++++++ Tests/Opcilloscope.Tests/Tui/TuiCollection.cs | 4 + docs/TESTING.md | 47 ++++++++++++ 5 files changed, 240 insertions(+) create mode 100644 Tests/Opcilloscope.Tests/Tui/ConnectDialogTests.cs create mode 100644 Tests/Opcilloscope.Tests/Tui/MonitoredVariablesViewTests.cs create mode 100644 Tests/Opcilloscope.Tests/Tui/ThemeSchemeTests.cs create mode 100644 Tests/Opcilloscope.Tests/Tui/TuiCollection.cs create mode 100644 docs/TESTING.md diff --git a/Tests/Opcilloscope.Tests/Tui/ConnectDialogTests.cs b/Tests/Opcilloscope.Tests/Tui/ConnectDialogTests.cs new file mode 100644 index 0000000..5bd0fb0 --- /dev/null +++ b/Tests/Opcilloscope.Tests/Tui/ConnectDialogTests.cs @@ -0,0 +1,60 @@ +using Opcilloscope.App.Dialogs; +using Opcilloscope.OpcUa; + +namespace Opcilloscope.Tests.Tui; + +/// +/// In-process component tests for the real . Exercises the +/// authentication selector (migrated from Terminal.Gui RadioGroup to OptionSelector in 2.4) +/// and the endpoint / publishing-interval fields through the dialog's public API. +/// +[Collection("Tui")] +public class ConnectDialogTests +{ + [Fact] + public void EndpointUrl_AlwaysCarriesProtocolPrefix() + { + var dialog = new ConnectDialog(initialEndpoint: "localhost:4840"); + + Assert.Equal("opc.tcp://localhost:4840", dialog.EndpointUrl); + } + + [Fact] + public void EndpointUrl_StripsAPastedProtocolPrefix() + { + // A pasted endpoint that already contains the scheme must not be double-prefixed. + var dialog = new ConnectDialog(initialEndpoint: "opc.tcp://server:4840"); + + Assert.Equal("opc.tcp://server:4840", dialog.EndpointUrl); + } + + [Fact] + public void PublishingInterval_IsTakenFromConstructor() + { + var dialog = new ConnectDialog(initialEndpoint: "x:1", publishingInterval: 750); + + Assert.Equal(750, dialog.PublishingInterval); + } + + [Fact] + public void Anonymous_AuthExposesNoCredentials() + { + var dialog = new ConnectDialog(initialEndpoint: "x:1", authType: AuthenticationType.Anonymous); + + Assert.Equal(AuthenticationType.Anonymous, dialog.SelectedAuthType); + Assert.Null(dialog.Username); + Assert.Null(dialog.Password); + } + + [Fact] + public void Username_AuthExposesUsername() + { + var dialog = new ConnectDialog( + initialEndpoint: "x:1", + authType: AuthenticationType.UserName, + username: "operator"); + + Assert.Equal(AuthenticationType.UserName, dialog.SelectedAuthType); + Assert.Equal("operator", dialog.Username); + } +} diff --git a/Tests/Opcilloscope.Tests/Tui/MonitoredVariablesViewTests.cs b/Tests/Opcilloscope.Tests/Tui/MonitoredVariablesViewTests.cs new file mode 100644 index 0000000..d85c1c8 --- /dev/null +++ b/Tests/Opcilloscope.Tests/Tui/MonitoredVariablesViewTests.cs @@ -0,0 +1,76 @@ +using Opcilloscope.App.Views; +using Opcilloscope.OpcUa.Models; + +namespace Opcilloscope.Tests.Tui; + +/// +/// In-process component tests that construct the real +/// (a Terminal.Gui v2 TableView-backed view) and exercise its public data API. +/// +/// NOTE on rendered-cell assertions: Terminal.Gui 2.4.5 (stable) does not expose a public +/// headless driver — Application.Create() leaves Driver null until the real +/// console event loop runs, and the develop-branch DriverAssert helper is not shipped. +/// So these tests assert on observable component behaviour/state; assertions on the *rendered* +/// screen are covered by the black-box PTY end-to-end suite (Opcilloscope.E2ETests). +/// +/// Terminal.Gui's Application is global mutable state, so all TUI tests share a +/// non-parallel collection (see ). +/// +[Collection("Tui")] +public class MonitoredVariablesViewTests +{ + private static MonitoredNode Node(uint handle, string name, bool scope = false) => new() + { + ClientHandle = handle, + NodeId = $"ns=2;s={name}", + DisplayName = name, + IsSelectedForScope = scope, + }; + + [Fact] + public void NewView_StartsEmpty() + { + var view = new MonitoredVariablesView(); + + Assert.Null(view.SelectedVariable); + Assert.Empty(view.ScopeSelectedNodes); + Assert.Equal(0, view.ScopeSelectionCount); + } + + [Fact] + public void AddVariable_AddsScopeSelectedNodeToScopeCollection() + { + var view = new MonitoredVariablesView(); + + view.AddVariable(Node(1, "Counter", scope: true)); + view.AddVariable(Node(2, "SineWave", scope: false)); + + Assert.Equal(1, view.ScopeSelectionCount); + Assert.Single(view.ScopeSelectedNodes); + Assert.Equal("Counter", view.ScopeSelectedNodes[0].DisplayName); + } + + [Fact] + public void AddVariable_IsIdempotentPerClientHandle() + { + var view = new MonitoredVariablesView(); + + view.AddVariable(Node(1, "Counter", scope: true)); + view.AddVariable(Node(1, "Counter", scope: true)); // same handle - ignored + + Assert.Equal(1, view.ScopeSelectionCount); + } + + [Fact] + public void RemoveVariable_RemovesFromScopeSelection() + { + var view = new MonitoredVariablesView(); + view.AddVariable(Node(1, "Counter", scope: true)); + view.AddVariable(Node(2, "SineWave", scope: true)); + + view.RemoveVariable(1); + + Assert.Single(view.ScopeSelectedNodes); + Assert.Equal("SineWave", view.ScopeSelectedNodes[0].DisplayName); + } +} diff --git a/Tests/Opcilloscope.Tests/Tui/ThemeSchemeTests.cs b/Tests/Opcilloscope.Tests/Tui/ThemeSchemeTests.cs new file mode 100644 index 0000000..48290ff --- /dev/null +++ b/Tests/Opcilloscope.Tests/Tui/ThemeSchemeTests.cs @@ -0,0 +1,53 @@ +using Opcilloscope.App.Themes; + +namespace Opcilloscope.Tests.Tui; + +/// +/// Guards the Terminal.Gui 2.0 -> 2.4 theming migration, where the former +/// ColorScheme type and settable View.ColorScheme property were replaced by +/// Scheme and View.SetScheme(). These tests verify the app's themes still +/// produce schemes with the expected colours and that applies them. +/// +[Collection("Tui")] +public class ThemeSchemeTests +{ + public static IEnumerable Themes() => new[] + { + new object[] { new DarkTheme() }, + new object[] { new LightTheme() }, + }; + + [Theory] + [MemberData(nameof(Themes))] + public void MainScheme_NormalRoleMatchesThemeForegroundAndBackground(AppTheme theme) + { + Scheme scheme = theme.MainColorScheme; + + Assert.Equal(theme.Foreground, scheme.Normal.Foreground); + Assert.Equal(theme.Background, scheme.Normal.Background); + } + + [Theory] + [MemberData(nameof(Themes))] + public void ButtonScheme_IsADistinctScheme(AppTheme theme) + { + // Sanity: the button scheme is produced and usable as a Terminal.Gui Scheme. + Scheme button = theme.ButtonColorScheme; + + Assert.Equal(theme.Background, button.Normal.Background); + } + + [Fact] + public void ThemeStyler_ApplyTo_SetsTheViewScheme() + { + var theme = new DarkTheme(); + var view = new View(); + + ThemeStyler.ApplyTo(view, theme); + + Scheme? applied = view.GetScheme(); + Assert.NotNull(applied); + Assert.Equal(theme.Foreground, applied!.Normal.Foreground); + Assert.Equal(theme.Background, applied.Normal.Background); + } +} diff --git a/Tests/Opcilloscope.Tests/Tui/TuiCollection.cs b/Tests/Opcilloscope.Tests/Tui/TuiCollection.cs new file mode 100644 index 0000000..c872eea --- /dev/null +++ b/Tests/Opcilloscope.Tests/Tui/TuiCollection.cs @@ -0,0 +1,4 @@ +namespace Opcilloscope.Tests.Tui; + +[CollectionDefinition("Tui", DisableParallelization = true)] +public class TuiCollection { } diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..707f156 --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,47 @@ +# Testing opcilloscope + +The suite has three layers. All run under `dotnet test` with **no extra language +toolchain** (pure .NET). + +## 1. Unit / integration tests (existing) + +`Tests/Opcilloscope.Tests/` — xUnit tests for the OPC UA, configuration, theming and +utility layers, plus integration tests against the in-process `Opcilloscope.TestServer`. + +```bash +dotnet test # everything +dotnet test --filter "FullyQualifiedName~Integration" +``` + +## 2. In-process TUI component tests + +`Tests/Opcilloscope.Tests/Tui/` — constructs the **real** Terminal.Gui views and dialogs +(`MonitoredVariablesView`, `ConnectDialog`, the theme/`Scheme` system, …) and asserts on +their observable behaviour and state. + +```bash +dotnet test --filter "FullyQualifiedName~Tui" +``` + +These tests live in a **non-parallel xUnit collection** (`[Collection("Tui")]`) because +Terminal.Gui's `Application` is global mutable state and must not be shared across parallel +tests. + +> **Why these assert on state, not rendered cells.** Terminal.Gui **2.4.5 (stable)** does not +> expose a public headless driver: `Application.Create()` leaves `Driver` null until the real +> console event loop runs, and the cell-buffer assertion helper (`DriverAssert`) only exists +> on the upstream `develop` branch. Assertions on the *rendered screen* are therefore done by +> layer 3 (below), which drives the real published binary. When upstream ships a public test +> driver, these component tests can add cell-level snapshots. + +## 3. Black-box end-to-end (PTY) tests + +`Tests/Opcilloscope.E2ETests/` — launches the **published binary** attached to a pseudo- +terminal, reconstructs the rendered screen from the VT/ANSI output, and asserts on it. Pure +.NET (uses the system `script` PTY + an in-process ANSI→grid parser); no Node/Python. + +```bash +dotnet test Tests/Opcilloscope.E2ETests # publishes the binary on first run +``` + +See `Tests/Opcilloscope.E2ETests/README.md` for details and CI notes. From 7d2799b6d1c11be448ce5169619c6a23f2e41835 Mon Sep 17 00:00:00 2001 From: Brett Kinny Date: Wed, 10 Jun 2026 12:28:59 +1000 Subject: [PATCH 3/4] Fix behavioral regressions found by migration review Adversarial review of the Terminal.Gui 2.0->2.4 migration confirmed three event-routing / API-semantics regressions (TG 2.4 moved selection/activation from overridden handlers into the command pipeline, which now runs AFTER the raw MouseEvent/Accepting events): - MonitoredVariablesView.HandleMouseClick: dropped `e.Handled = true` on Sel-column clicks. Marking it handled now pre-empts TableView's LeftButtonClicked -> Command.Activate -> SetSelection, so a Sel click toggled scope but no longer highlighted the row or raised SelectedVariableChanged. Unhandled restores both. - SaveRecordingDialog.OnFileListOpenSelected: set `e.Handled = true`. An unhandled Accepting bubbles Accept to the default Save button, so navigating a folder or picking a file would save+close the dialog mid-browse. - SaveRecordingDialog.NavigateToSelected: null-safe guard. ListView.SelectedItem is now int? (null = none); the OR-form guard didn't catch null and dereferenced null.Value (InvalidOperationException at a CSV-less filesystem root). - LogView: restore log auto-scroll via EnsureSelectedItemVisible() (TG 2.4 removed ListView.TopItem); without it the log stopped following new entries. 627/627 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- App/Dialogs/SaveRecordingDialog.cs | 12 ++++++++++-- App/Views/LogView.cs | 5 ++++- App/Views/MonitoredVariablesView.cs | 7 ++++++- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/App/Dialogs/SaveRecordingDialog.cs b/App/Dialogs/SaveRecordingDialog.cs index 26526c0..f166255 100644 --- a/App/Dialogs/SaveRecordingDialog.cs +++ b/App/Dialogs/SaveRecordingDialog.cs @@ -195,6 +195,10 @@ private void LoadDirectory(string directory) private void OnFileListOpenSelected(object? sender, CommandEventArgs e) { NavigateToSelected(); + // Consume the command: in Terminal.Gui 2.4 an unhandled Accepting bubbles Accept to the + // dialog's default (Save) button, which would save/close the moment the user tries to + // navigate a folder or pick a file. Browsing must not trigger Save. + e.Handled = true; } private void OnFileListKeyDown(object? sender, Key e) @@ -208,10 +212,14 @@ private void OnFileListKeyDown(object? sender, Key e) private void NavigateToSelected() { - if (_fileListView.SelectedItem < 0 || _fileListView.SelectedItem >= _fileListItems.Count) + // Terminal.Gui 2.4 ListView.SelectedItem is int? (null = no selection). An OR-form + // lower-bound guard does not catch null (lifted comparisons are false), so check it + // explicitly before dereferencing. + var sel = _fileListView.SelectedItem; + if (sel is null || sel < 0 || sel >= _fileListItems.Count) return; - var selected = _fileListItems[_fileListView.SelectedItem!.Value]; + var selected = _fileListItems[sel.Value]; if (selected == "..") { diff --git a/App/Views/LogView.cs b/App/Views/LogView.cs index f43006b..fac07ed 100644 --- a/App/Views/LogView.cs +++ b/App/Views/LogView.cs @@ -111,10 +111,13 @@ private void OnLogAdded(LogEntry entry) _displayedEntries.RemoveAt(0); } - // Auto-scroll to bottom + // Auto-scroll to bottom. Terminal.Gui 2.4 removed ListView.TopItem; moving the + // selection to the newest entry and asking the list to reveal it keeps the log + // following new lines. if (_displayedEntries.Count > 0) { _listView.SelectedItem = _displayedEntries.Count - 1; + _listView.EnsureSelectedItemVisible(); } }); } diff --git a/App/Views/MonitoredVariablesView.cs b/App/Views/MonitoredVariablesView.cs index 3f5b5ab..03d0e77 100644 --- a/App/Views/MonitoredVariablesView.cs +++ b/App/Views/MonitoredVariablesView.cs @@ -471,7 +471,12 @@ private void HandleMouseClick(object? sender, Mouse e) if (variable == null) return; ToggleScopeSelectionForVariable(variable); - e.Handled = true; + // NOTE: do NOT set e.Handled here. In Terminal.Gui 2.4 the MouseEvent handler runs + // BEFORE the command pipeline that moves the table cursor (LeftButtonClicked -> + // Command.Activate -> SetSelection). Marking it handled would suppress that, so a + // Sel-column click would toggle scope but no longer highlight the row / raise + // SelectedVariableChanged. Leaving it unhandled preserves the 2.0 behavior where a + // click both toggled scope and selected the row. } } From f83ea592480245f54bb70ae3e1c8907314aecd95 Mon Sep 17 00:00:00 2001 From: Brett Kinny Date: Wed, 10 Jun 2026 12:11:36 +1000 Subject: [PATCH 4/4] Add layer-2 black-box e2e TUI tests (pure-.NET PTY harness) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Launches the published binary attached to a real pseudo-terminal, reconstructs the rendered screen from the VT/ANSI output, and asserts on it — the rendered-output coverage the in-process component tests can't provide on Terminal.Gui 2.4.5 stable. Pure .NET, no extra toolchain: - `Pty`: sized PTY via libc `openpty` + `posix_spawn(POSIX_SPAWN_SETSID)` (P/Invoke). Hand-rolled because `script` yields a 0x0 window over a pipe and `Pty.Net` is unmaintained/Windows-only. - `VtScreen`: minimal in-tree VT100/ANSI -> character-grid emulator (the .NET VT libraries are stale). - `OpcilloscopeSession`: pumps output into the screen and, crucially, answers the terminal capability queries Terminal.Gui emits (CSI 18t size, DSR 6n cursor, OSC 10/11 colours) so the app actually paints; exposes Snapshot/WaitForText/input. - `PublishedBinaryFixture`: publishes once (or reuses $OPCILLOSCOPE_BIN). Tests: startup renders all panes; status-bar keybinding hints; menu bar; '?' opens the help dialog. Stable across repeated runs (wait-for-text, not race-the-first-paint). CI: dedicated `e2e` job publishes once and runs the project; the unit job is scoped to the unit/integration/component project to avoid double-running. Linux-only. See Tests/Opcilloscope.E2ETests/README.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 34 +++- .gitignore | 1 + Opcilloscope.sln | 44 +++++ .../Opcilloscope.E2ETests.csproj | 23 +++ .../OpcilloscopeSession.cs | 110 +++++++++++ Tests/Opcilloscope.E2ETests/Pty.cs | 153 +++++++++++++++ .../PublishedBinaryFixture.cs | 62 ++++++ Tests/Opcilloscope.E2ETests/README.md | 52 +++++ Tests/Opcilloscope.E2ETests/StartupTests.cs | 65 +++++++ Tests/Opcilloscope.E2ETests/VtScreen.cs | 177 ++++++++++++++++++ 10 files changed, 719 insertions(+), 2 deletions(-) create mode 100644 Tests/Opcilloscope.E2ETests/Opcilloscope.E2ETests.csproj create mode 100644 Tests/Opcilloscope.E2ETests/OpcilloscopeSession.cs create mode 100644 Tests/Opcilloscope.E2ETests/Pty.cs create mode 100644 Tests/Opcilloscope.E2ETests/PublishedBinaryFixture.cs create mode 100644 Tests/Opcilloscope.E2ETests/README.md create mode 100644 Tests/Opcilloscope.E2ETests/StartupTests.cs create mode 100644 Tests/Opcilloscope.E2ETests/VtScreen.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b724fac..2324f49 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,8 +28,8 @@ jobs: - name: Build run: dotnet build --no-restore --configuration Release - - name: Test - run: dotnet test --no-build --configuration Release --verbosity normal + - name: Test (unit + integration + TUI component) + run: dotnet test Tests/Opcilloscope.Tests/Opcilloscope.Tests.csproj --no-build --configuration Release --verbosity normal - name: Publish (smoke) run: dotnet publish Opcilloscope.csproj -c Release -r linux-x64 -o ./publish @@ -44,3 +44,33 @@ jobs: code=$? echo "Published binary exited with code $code" test "$code" -eq 0 + + e2e: + # Black-box end-to-end TUI tests: drive the published binary over a PTY and assert on the + # reconstructed screen. PTY allocation works on GitHub's ubuntu runners by default. + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '10.0.x' + + - name: Build e2e project + run: dotnet build Tests/Opcilloscope.E2ETests/Opcilloscope.E2ETests.csproj --configuration Release + + - name: Publish binary under test + run: dotnet publish Opcilloscope.csproj -c Release -r linux-x64 -o ./publish + + - name: Run e2e tests + env: + # Reuse the binary published above instead of republishing inside the fixture. + OPCILLOSCOPE_BIN: ${{ github.workspace }}/publish/opcilloscope + run: | + chmod +x ./publish/opcilloscope + dotnet test Tests/Opcilloscope.E2ETests/Opcilloscope.E2ETests.csproj --no-build --configuration Release --verbosity normal diff --git a/.gitignore b/.gitignore index 2b1e1e1..054d0c5 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ appsettings.*.json # Claude Code local settings .claude/settings.local.json publish/ +publish-e2e/ diff --git a/Opcilloscope.sln b/Opcilloscope.sln index f310790..24968b3 100644 --- a/Opcilloscope.sln +++ b/Opcilloscope.sln @@ -1,3 +1,4 @@ + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.5.2.0 @@ -10,30 +11,73 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{0AB3BF05 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Opcilloscope.Tests", "Tests\Opcilloscope.Tests\Opcilloscope.Tests.csproj", "{CCFD0A5B-8983-5414-07B9-728A32ED6185}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Opcilloscope.E2ETests", "Tests\Opcilloscope.E2ETests\Opcilloscope.E2ETests.csproj", "{71536F02-DCF2-4BD4-8A96-E62445E892D2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {4E2A8F99-A9A5-3CCC-02A4-8A18F90CE74A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4E2A8F99-A9A5-3CCC-02A4-8A18F90CE74A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4E2A8F99-A9A5-3CCC-02A4-8A18F90CE74A}.Debug|x64.ActiveCfg = Debug|Any CPU + {4E2A8F99-A9A5-3CCC-02A4-8A18F90CE74A}.Debug|x64.Build.0 = Debug|Any CPU + {4E2A8F99-A9A5-3CCC-02A4-8A18F90CE74A}.Debug|x86.ActiveCfg = Debug|Any CPU + {4E2A8F99-A9A5-3CCC-02A4-8A18F90CE74A}.Debug|x86.Build.0 = Debug|Any CPU {4E2A8F99-A9A5-3CCC-02A4-8A18F90CE74A}.Release|Any CPU.ActiveCfg = Release|Any CPU {4E2A8F99-A9A5-3CCC-02A4-8A18F90CE74A}.Release|Any CPU.Build.0 = Release|Any CPU + {4E2A8F99-A9A5-3CCC-02A4-8A18F90CE74A}.Release|x64.ActiveCfg = Release|Any CPU + {4E2A8F99-A9A5-3CCC-02A4-8A18F90CE74A}.Release|x64.Build.0 = Release|Any CPU + {4E2A8F99-A9A5-3CCC-02A4-8A18F90CE74A}.Release|x86.ActiveCfg = Release|Any CPU + {4E2A8F99-A9A5-3CCC-02A4-8A18F90CE74A}.Release|x86.Build.0 = Release|Any CPU {7B3E5A12-F8C4-4D9E-A6B1-2C8D9E0F1234}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7B3E5A12-F8C4-4D9E-A6B1-2C8D9E0F1234}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7B3E5A12-F8C4-4D9E-A6B1-2C8D9E0F1234}.Debug|x64.ActiveCfg = Debug|Any CPU + {7B3E5A12-F8C4-4D9E-A6B1-2C8D9E0F1234}.Debug|x64.Build.0 = Debug|Any CPU + {7B3E5A12-F8C4-4D9E-A6B1-2C8D9E0F1234}.Debug|x86.ActiveCfg = Debug|Any CPU + {7B3E5A12-F8C4-4D9E-A6B1-2C8D9E0F1234}.Debug|x86.Build.0 = Debug|Any CPU {7B3E5A12-F8C4-4D9E-A6B1-2C8D9E0F1234}.Release|Any CPU.ActiveCfg = Release|Any CPU {7B3E5A12-F8C4-4D9E-A6B1-2C8D9E0F1234}.Release|Any CPU.Build.0 = Release|Any CPU + {7B3E5A12-F8C4-4D9E-A6B1-2C8D9E0F1234}.Release|x64.ActiveCfg = Release|Any CPU + {7B3E5A12-F8C4-4D9E-A6B1-2C8D9E0F1234}.Release|x64.Build.0 = Release|Any CPU + {7B3E5A12-F8C4-4D9E-A6B1-2C8D9E0F1234}.Release|x86.ActiveCfg = Release|Any CPU + {7B3E5A12-F8C4-4D9E-A6B1-2C8D9E0F1234}.Release|x86.Build.0 = Release|Any CPU {CCFD0A5B-8983-5414-07B9-728A32ED6185}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {CCFD0A5B-8983-5414-07B9-728A32ED6185}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CCFD0A5B-8983-5414-07B9-728A32ED6185}.Debug|x64.ActiveCfg = Debug|Any CPU + {CCFD0A5B-8983-5414-07B9-728A32ED6185}.Debug|x64.Build.0 = Debug|Any CPU + {CCFD0A5B-8983-5414-07B9-728A32ED6185}.Debug|x86.ActiveCfg = Debug|Any CPU + {CCFD0A5B-8983-5414-07B9-728A32ED6185}.Debug|x86.Build.0 = Debug|Any CPU {CCFD0A5B-8983-5414-07B9-728A32ED6185}.Release|Any CPU.ActiveCfg = Release|Any CPU {CCFD0A5B-8983-5414-07B9-728A32ED6185}.Release|Any CPU.Build.0 = Release|Any CPU + {CCFD0A5B-8983-5414-07B9-728A32ED6185}.Release|x64.ActiveCfg = Release|Any CPU + {CCFD0A5B-8983-5414-07B9-728A32ED6185}.Release|x64.Build.0 = Release|Any CPU + {CCFD0A5B-8983-5414-07B9-728A32ED6185}.Release|x86.ActiveCfg = Release|Any CPU + {CCFD0A5B-8983-5414-07B9-728A32ED6185}.Release|x86.Build.0 = Release|Any CPU + {71536F02-DCF2-4BD4-8A96-E62445E892D2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {71536F02-DCF2-4BD4-8A96-E62445E892D2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {71536F02-DCF2-4BD4-8A96-E62445E892D2}.Debug|x64.ActiveCfg = Debug|Any CPU + {71536F02-DCF2-4BD4-8A96-E62445E892D2}.Debug|x64.Build.0 = Debug|Any CPU + {71536F02-DCF2-4BD4-8A96-E62445E892D2}.Debug|x86.ActiveCfg = Debug|Any CPU + {71536F02-DCF2-4BD4-8A96-E62445E892D2}.Debug|x86.Build.0 = Debug|Any CPU + {71536F02-DCF2-4BD4-8A96-E62445E892D2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {71536F02-DCF2-4BD4-8A96-E62445E892D2}.Release|Any CPU.Build.0 = Release|Any CPU + {71536F02-DCF2-4BD4-8A96-E62445E892D2}.Release|x64.ActiveCfg = Release|Any CPU + {71536F02-DCF2-4BD4-8A96-E62445E892D2}.Release|x64.Build.0 = Release|Any CPU + {71536F02-DCF2-4BD4-8A96-E62445E892D2}.Release|x86.ActiveCfg = Release|Any CPU + {71536F02-DCF2-4BD4-8A96-E62445E892D2}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {CCFD0A5B-8983-5414-07B9-728A32ED6185} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {71536F02-DCF2-4BD4-8A96-E62445E892D2} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {2B89358B-67FF-4D9C-8329-CBFF96D7B770} diff --git a/Tests/Opcilloscope.E2ETests/Opcilloscope.E2ETests.csproj b/Tests/Opcilloscope.E2ETests/Opcilloscope.E2ETests.csproj new file mode 100644 index 0000000..c418f75 --- /dev/null +++ b/Tests/Opcilloscope.E2ETests/Opcilloscope.E2ETests.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + enable + enable + false + true + + true + + + + + + + + + + + + + diff --git a/Tests/Opcilloscope.E2ETests/OpcilloscopeSession.cs b/Tests/Opcilloscope.E2ETests/OpcilloscopeSession.cs new file mode 100644 index 0000000..5e0e567 --- /dev/null +++ b/Tests/Opcilloscope.E2ETests/OpcilloscopeSession.cs @@ -0,0 +1,110 @@ +using System.Diagnostics; +using System.Text; + +namespace Opcilloscope.E2ETests; + +/// +/// Drives the published opcilloscope binary over a : pumps its output into a +/// , answers the terminal capability queries Terminal.Gui's net driver +/// sends (so it actually paints), and exposes the reconstructed screen plus key input. +/// +public sealed class OpcilloscopeSession : IDisposable +{ + private readonly Pty _pty; + private readonly VtScreen _screen; + private readonly Thread _reader; + private readonly object _gate = new(); + private volatile bool _stop; + private string _pending = ""; + + public int Rows { get; } + public int Cols { get; } + + public OpcilloscopeSession(string binaryPath, IReadOnlyList? args = null, int rows = 30, int cols = 100) + { + Rows = rows; + Cols = cols; + _screen = new VtScreen(rows, cols); + _pty = Pty.Spawn(binaryPath, args ?? Array.Empty(), rows, cols); + _reader = new Thread(ReadLoop) { IsBackground = true, Name = "pty-reader" }; + _reader.Start(); + } + + private void ReadLoop() + { + var decoder = Encoding.UTF8.GetDecoder(); + var buf = new byte[8192]; + var chars = new char[8192]; + while (!_stop) + { + int n = _pty.Read(buf); + if (n <= 0) break; // EOF / process gone + int cn = decoder.GetChars(buf, 0, n, chars, 0); + var chunk = new string(chars, 0, cn); + lock (_gate) + { + _screen.Feed(chunk); + RespondToQueries(chunk); + } + } + } + + /// + /// Terminal.Gui's net driver detects size/capabilities by emitting queries and waiting for + /// the terminal's reply. A bare PTY has no emulator on the other end, so we answer the ones + /// that gate the first paint: text-area size (CSI 18 t), cursor position (DSR 6 n), and the + /// OSC 10/11 fg/bg colour queries. + /// + private void RespondToQueries(string chunk) + { + _pending += chunk; + if (_pending.Length > 2048) _pending = _pending[^512..]; + + if (Take("\x1b[18t")) Reply($"\x1b[8;{Rows};{Cols}t"); + if (Take("\x1b[6n")) Reply($"\x1b[{Rows};{Cols}R"); + if (Take("\x1b]10;?")) Reply("\x1b]10;rgb:cccc/cccc/cccc\x1b\\"); + if (Take("\x1b]11;?")) Reply("\x1b]11;rgb:0000/0000/0000\x1b\\"); + } + + private bool Take(string marker) + { + int idx = _pending.IndexOf(marker, StringComparison.Ordinal); + if (idx < 0) return false; + _pending = _pending.Remove(idx, marker.Length); + return true; + } + + private void Reply(string s) => _pty.Write(Encoding.ASCII.GetBytes(s)); + + /// Current reconstructed screen as text (one line per row). + public string Snapshot() + { + lock (_gate) return _screen.Text(); + } + + /// Blocks until the screen contains or the timeout elapses. + public bool WaitForText(string text, TimeSpan timeout) + { + var sw = Stopwatch.StartNew(); + while (sw.Elapsed < timeout) + { + if (Snapshot().Contains(text)) return true; + if (_pty.HasExited) return Snapshot().Contains(text); + Thread.Sleep(40); + } + return Snapshot().Contains(text); + } + + /// Sends raw bytes as terminal input. + public void Send(string raw) => _pty.Write(Encoding.UTF8.GetBytes(raw)); + + /// Sends a single byte (e.g. a control character). + public void SendByte(byte b) => _pty.Write(new[] { b }); + + public void Dispose() + { + _stop = true; + _pty.Dispose(); + _reader.Join(500); + } +} diff --git a/Tests/Opcilloscope.E2ETests/Pty.cs b/Tests/Opcilloscope.E2ETests/Pty.cs new file mode 100644 index 0000000..76206fb --- /dev/null +++ b/Tests/Opcilloscope.E2ETests/Pty.cs @@ -0,0 +1,153 @@ +using System.Runtime.InteropServices; + +namespace Opcilloscope.E2ETests; + +/// +/// A pseudo-terminal that runs a child process attached to a sized PTY, implemented purely +/// with libc P/Invoke (no NuGet PTY library, no Node/Python). Linux-only — which is all the +/// CI runners and the dev environment need, and the only place the TUI runs anyway. +/// +/// We allocate the PTY ourselves (rather than shelling out to script) because +/// script produces a 0x0 window when its stdout is a pipe, and Terminal.Gui refuses to +/// draw without a known size. lets us fix the rows/cols up front. +/// +public sealed class Pty : IDisposable +{ + [StructLayout(LayoutKind.Sequential)] + private struct WinSize { public ushort Rows, Cols, XPixel, YPixel; } + + [DllImport("libc", SetLastError = true)] + private static extern int openpty(out int master, out int slave, IntPtr name, IntPtr termios, ref WinSize win); + + [DllImport("libc", SetLastError = true)] + private static extern int posix_spawn(out int pid, string path, IntPtr fileActions, IntPtr attr, string?[] argv, string?[] envp); + [DllImport("libc")] private static extern int posix_spawn_file_actions_init(IntPtr fa); + [DllImport("libc")] private static extern int posix_spawn_file_actions_adddup2(IntPtr fa, int fd, int newFd); + [DllImport("libc")] private static extern int posix_spawn_file_actions_addclose(IntPtr fa, int fd); + [DllImport("libc")] private static extern int posix_spawnattr_init(IntPtr attr); + [DllImport("libc")] private static extern int posix_spawnattr_setflags(IntPtr attr, short flags); + + [DllImport("libc", SetLastError = true)] private static extern long read(int fd, byte[] buf, long count); + [DllImport("libc", SetLastError = true)] private static extern long write(int fd, byte[] buf, long count); + [DllImport("libc")] private static extern int close(int fd); + [DllImport("libc")] private static extern int kill(int pid, int sig); + [DllImport("libc")] private static extern int waitpid(int pid, out int status, int options); + + // glibc posix_spawnattr flag: create a new session for the child (POSIX_SPAWN_SETSID), + // so the slave PTY becomes its controlling terminal. + private const short POSIX_SPAWN_SETSID = 0x80; + private const int SIGTERM = 15; + private const int WNOHANG = 1; + + private readonly int _master; + private int _pid; + private bool _disposed; + + public int Rows { get; } + public int Cols { get; } + + private Pty(int master, int pid, int rows, int cols) + { + _master = master; + _pid = pid; + Rows = rows; + Cols = cols; + } + + /// Spawns attached to a fresh rows x cols PTY. + public static Pty Spawn(string path, IReadOnlyList args, int rows, int cols, IReadOnlyDictionary? extraEnv = null) + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + throw new PlatformNotSupportedException("The PTY e2e harness is Linux-only."); + + var win = new WinSize { Rows = (ushort)rows, Cols = (ushort)cols }; + if (openpty(out int master, out int slave, IntPtr.Zero, IntPtr.Zero, ref win) != 0) + throw new InvalidOperationException($"openpty failed (errno {Marshal.GetLastWin32Error()})"); + + IntPtr fa = Marshal.AllocHGlobal(1024); + IntPtr attr = Marshal.AllocHGlobal(1024); + try + { + // Zero the opaque structs (over-allocated to be safe across libc versions). + for (int i = 0; i < 1024; i++) { Marshal.WriteByte(fa, i, 0); Marshal.WriteByte(attr, i, 0); } + + posix_spawn_file_actions_init(fa); + posix_spawn_file_actions_adddup2(fa, slave, 0); + posix_spawn_file_actions_adddup2(fa, slave, 1); + posix_spawn_file_actions_adddup2(fa, slave, 2); + posix_spawn_file_actions_addclose(fa, master); + posix_spawn_file_actions_addclose(fa, slave); + + posix_spawnattr_init(attr); + posix_spawnattr_setflags(attr, POSIX_SPAWN_SETSID); + + var argv = new List { path }; + argv.AddRange(args); + argv.Add(null); + + var envp = BuildEnv(extraEnv); + + int rc = posix_spawn(out int pid, path, fa, attr, argv.ToArray(), envp); + if (rc != 0) + throw new InvalidOperationException($"posix_spawn('{path}') failed (rc {rc})"); + + close(slave); // parent keeps only the master end + return new Pty(master, pid, rows, cols); + } + finally + { + Marshal.FreeHGlobal(fa); + Marshal.FreeHGlobal(attr); + } + } + + private static string?[] BuildEnv(IReadOnlyDictionary? extra) + { + var env = new Dictionary(); + foreach (System.Collections.DictionaryEntry e in Environment.GetEnvironmentVariables()) + env[(string)e.Key] = (string?)e.Value ?? ""; + env["TERM"] = "xterm-256color"; + if (extra != null) + foreach (var kv in extra) env[kv.Key] = kv.Value; + + var list = env.Select(kv => (string?)$"{kv.Key}={kv.Value}").ToList(); + list.Add(null); + return list.ToArray(); + } + + /// Reads up to .Length bytes (blocking). Returns 0 at EOF. + public int Read(byte[] buffer) + { + long n = read(_master, buffer, buffer.Length); + return n < 0 ? 0 : (int)n; + } + + /// Writes bytes to the child's input (the PTY master). + public void Write(byte[] data) => write(_master, data, data.Length); + + public bool HasExited + { + get + { + if (_pid == 0) return true; + int r = waitpid(_pid, out _, WNOHANG); + if (r == _pid) { _pid = 0; return true; } + return false; + } + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + if (_pid != 0) + { + kill(_pid, SIGTERM); + // brief reap + for (int i = 0; i < 20 && waitpid(_pid, out _, WNOHANG) == 0; i++) + Thread.Sleep(10); + _pid = 0; + } + close(_master); + } +} diff --git a/Tests/Opcilloscope.E2ETests/PublishedBinaryFixture.cs b/Tests/Opcilloscope.E2ETests/PublishedBinaryFixture.cs new file mode 100644 index 0000000..10b69da --- /dev/null +++ b/Tests/Opcilloscope.E2ETests/PublishedBinaryFixture.cs @@ -0,0 +1,62 @@ +using System.Diagnostics; + +namespace Opcilloscope.E2ETests; + +/// +/// Provides the path to a published opcilloscope binary for the e2e tests. In CI the path is +/// supplied via the OPCILLOSCOPE_BIN environment variable (the workflow publishes once +/// and reuses it); locally, the fixture publishes a self-contained linux-x64 build on first use. +/// +public sealed class PublishedBinaryFixture : IAsyncLifetime +{ + public string BinaryPath { get; private set; } = ""; + + public Task InitializeAsync() + { + var env = Environment.GetEnvironmentVariable("OPCILLOSCOPE_BIN"); + if (!string.IsNullOrWhiteSpace(env) && File.Exists(env)) + { + BinaryPath = env; + return Task.CompletedTask; + } + + var repoRoot = FindRepoRoot(); + var outDir = Path.Combine(repoRoot, "publish-e2e"); + var binary = Path.Combine(outDir, "opcilloscope"); + if (!File.Exists(binary)) + Publish(repoRoot, outDir); + + if (!File.Exists(binary)) + throw new InvalidOperationException($"Published binary not found at {binary}"); + BinaryPath = binary; + return Task.CompletedTask; + } + + public Task DisposeAsync() => Task.CompletedTask; + + private static void Publish(string repoRoot, string outDir) + { + var psi = new ProcessStartInfo("dotnet", + $"publish \"{Path.Combine(repoRoot, "Opcilloscope.csproj")}\" -c Release -r linux-x64 -o \"{outDir}\"") + { + WorkingDirectory = repoRoot, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + using var p = Process.Start(psi)!; + p.WaitForExit(); + if (p.ExitCode != 0) + throw new InvalidOperationException("dotnet publish failed:\n" + p.StandardError.ReadToEnd()); + } + + private static string FindRepoRoot() + { + var dir = AppContext.BaseDirectory; + while (dir != null && !File.Exists(Path.Combine(dir, "Opcilloscope.sln"))) + dir = Directory.GetParent(dir)?.FullName; + return dir ?? throw new InvalidOperationException("Could not locate repo root (Opcilloscope.sln)."); + } +} + +[CollectionDefinition("E2E")] +public class E2ECollection : ICollectionFixture { } diff --git a/Tests/Opcilloscope.E2ETests/README.md b/Tests/Opcilloscope.E2ETests/README.md new file mode 100644 index 0000000..796770b --- /dev/null +++ b/Tests/Opcilloscope.E2ETests/README.md @@ -0,0 +1,52 @@ +# Opcilloscope.E2ETests — black-box PTY end-to-end tests + +Launches the **published** opcilloscope binary attached to a real pseudo-terminal, +reconstructs the rendered screen from the terminal output, and asserts on it. This is the +only layer that exercises the full stack: the self-contained single-file binary, the +Terminal.Gui console driver, and actual rendering. + +**Pure .NET, no extra toolchain.** No Node, no Python, no NuGet PTY/VT library. + +## How it works + +| Piece | Responsibility | +|-------|----------------| +| `Pty` | Allocates a sized PTY via libc `openpty` and launches the binary on it with `posix_spawn` (`POSIX_SPAWN_SETSID`). Pure P/Invoke. | +| `VtScreen` | A minimal VT100/ANSI emulator: parses cursor moves, erases and printable runs into a character grid. Ignores colour/SGR and OSC. | +| `OpcilloscopeSession` | Pumps PTY output into `VtScreen`, **answers the terminal capability queries** Terminal.Gui sends (size `CSI 18 t`, cursor `DSR 6 n`, OSC 10/11 colours) so the app actually paints, and exposes `Snapshot()` / `WaitForText()` / key input. | +| `PublishedBinaryFixture` | Supplies the binary path: from `$OPCILLOSCOPE_BIN` if set, else publishes a self-contained linux-x64 build once. | + +### Why a hand-rolled PTY instead of `script` or `Pty.Net`? + +- **`script`** (util-linux) creates a **0×0** window when its stdout is a pipe, and has no size + flag — Terminal.Gui refuses to draw without a known size. We must set the window size + ourselves (`openpty` takes a `winsize`). +- **`Pty.Net`** (the usual .NET PTY package) is unmaintained (last release 2018, unlisted on + NuGet, ships only a Windows `winpty.dll`). The VT libraries (`VtNetCore`, `XtermSharp`) are + likewise stale, so the emulator is kept in-tree and scoped to what Terminal.Gui emits. + +### Why answer terminal queries? + +Terminal.Gui's net driver detects size/colours by **emitting escape sequences and waiting for +the terminal to reply**. A bare PTY has no emulator on the other end, so without our replies the +app prints its setup sequences and then blocks before the first paint. `OpcilloscopeSession` +replies to the size/cursor/colour queries, which unblocks rendering. + +## Running + +```bash +# Publishes the binary on first run, then drives it over a PTY: +dotnet test Tests/Opcilloscope.E2ETests + +# Reuse an already-published binary (what CI does): +OPCILLOSCOPE_BIN=$PWD/publish/opcilloscope dotnet test Tests/Opcilloscope.E2ETests +``` + +**Linux only** (the TUI and the `openpty` P/Invoke). CI runs it on `ubuntu-latest` in a +dedicated `e2e` job (see `.github/workflows/ci.yml`); PTY allocation works there by default. + +## Adding tests + +Tests assert on `Snapshot()` text. Prefer `WaitForText(...)` over an immediate `Snapshot()` — +the UI paints over several frames, so racing the first paint is flaky. Send input with +`Send("?")` (text/keys) or `SendByte(0x11)` (control characters, e.g. Ctrl+Q). diff --git a/Tests/Opcilloscope.E2ETests/StartupTests.cs b/Tests/Opcilloscope.E2ETests/StartupTests.cs new file mode 100644 index 0000000..6851735 --- /dev/null +++ b/Tests/Opcilloscope.E2ETests/StartupTests.cs @@ -0,0 +1,65 @@ +namespace Opcilloscope.E2ETests; + +/// +/// Black-box end-to-end tests: launch the real published binary over a pseudo-terminal, +/// reconstruct the rendered screen, and assert on it. These exercise the full stack — the +/// published single-file binary, the Terminal.Gui driver, and actual rendering — that the +/// in-process component tests cannot reach. +/// +[Collection("E2E")] +public class StartupTests +{ + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(15); + private readonly PublishedBinaryFixture _fx; + + public StartupTests(PublishedBinaryFixture fx) => _fx = fx; + + [Fact] + public void Startup_RendersAllPanesAndStatusBar() + { + using var app = new OpcilloscopeSession(_fx.BinaryPath); + + // Panes paint over a few frames; wait for each rather than racing the first paint. + foreach (var pane in new[] { "Address Space", "Monitored Variables", "Node Details", "Log" }) + Assert.True(app.WaitForText(pane, Timeout), $"Missing pane '{pane}'.\n{Snapshot(app)}"); + } + + [Fact] + public void Startup_RendersStatusBarKeybindingHints() + { + using var app = new OpcilloscopeSession(_fx.BinaryPath); + + // The status bar renders the keybinding hint row (Tab=Switch, Enter=Subscribe, F5=Refresh) + // shortly after the panes; wait for it rather than racing the first paint. + Assert.True(app.WaitForText("Switch", Timeout), Snapshot(app)); + Assert.Contains("Subscribe", app.Snapshot()); + } + + [Fact] + public void Startup_RendersMenuBar() + { + using var app = new OpcilloscopeSession(_fx.BinaryPath); + Assert.True(app.WaitForText("Address Space", Timeout), Snapshot(app)); + + var screen = app.Snapshot(); + Assert.Contains("File", screen); + Assert.Contains("Connection", screen); + Assert.Contains("View", screen); + Assert.Contains("Help", screen); + } + + [Fact] + public void QuestionMark_OpensHelpDialog() + { + using var app = new OpcilloscopeSession(_fx.BinaryPath); + Assert.True(app.WaitForText("Address Space", Timeout), Snapshot(app)); + + app.Send("?"); + + // The help dialog's border title only appears once the dialog is open. + Assert.True(app.WaitForText("opcilloscope - Help", Timeout), Snapshot(app)); + } + + private static string Snapshot(OpcilloscopeSession app) => + "Rendered screen was:\n" + app.Snapshot(); +} diff --git a/Tests/Opcilloscope.E2ETests/VtScreen.cs b/Tests/Opcilloscope.E2ETests/VtScreen.cs new file mode 100644 index 0000000..44ba887 --- /dev/null +++ b/Tests/Opcilloscope.E2ETests/VtScreen.cs @@ -0,0 +1,177 @@ +using System.Text; + +namespace Opcilloscope.E2ETests; + +/// +/// A minimal VT100/ANSI screen emulator: feed it the bytes a Terminal.Gui app writes and it +/// reconstructs the rendered character grid for assertions. It deliberately understands only +/// what Terminal.Gui emits — cursor positioning, erases, and printable runs — and ignores +/// colour/style (SGR), mode toggles, and OSC strings. Maintained in-tree rather than depending +/// on the stale VtNetCore / XtermSharp packages. +/// +public sealed class VtScreen +{ + private readonly int _rows; + private readonly int _cols; + private readonly char[,] _grid; + private int _row, _col; + + public VtScreen(int rows, int cols) + { + _rows = rows; + _cols = cols; + _grid = new char[rows, cols]; + Clear(); + } + + private void Clear() + { + for (int r = 0; r < _rows; r++) + for (int c = 0; c < _cols; c++) + _grid[r, c] = ' '; + } + + /// Feeds a chunk of already-UTF-8-decoded output, updating the grid and cursor. + public void Feed(string s) + { + int i = 0; + while (i < s.Length) + { + char c = s[i]; + if (c == '\x1b') { i = HandleEscape(s, i); continue; } + switch (c) + { + case '\r': _col = 0; break; + case '\n': NewLine(); break; + case '\b': _col = Math.Max(0, _col - 1); break; + case '\t': _col = Math.Min(_cols - 1, (_col / 8 + 1) * 8); break; + default: + if (c >= ' ') + { + if (_col >= _cols) { _col = 0; NewLine(); } + if (InBounds(_row, _col)) _grid[_row, _col] = c; + _col++; + } + break; + } + i++; + } + } + + private int HandleEscape(string s, int i) + { + // s[i] == ESC + if (i + 1 >= s.Length) return i + 1; + char n = s[i + 1]; + switch (n) + { + case '[': return HandleCsi(s, i + 2); + case ']': return SkipOsc(s, i + 2); + case 'P': case '^': case '_': return SkipUntilSt(s, i + 2); // DCS/PM/APC + case '(': case ')': case '*': case '+': return i + 3; // charset designation + default: return i + 2; // ESC = / ESC > / etc. + } + } + + private int HandleCsi(string s, int i) + { + int start = i; + bool priv = i < s.Length && (s[i] == '?' || s[i] == '>' || s[i] == '!'); + if (priv) i++; + while (i < s.Length && !(s[i] >= '@' && s[i] <= '~')) i++; + if (i >= s.Length) return i; + char final = s[i]; + string body = s.Substring(start + (priv ? 1 : 0), i - start - (priv ? 1 : 0)); + var p = ParseParams(body); + + if (!priv) + { + switch (final) + { + case 'H': case 'f': _row = Clamp(P(p, 0, 1) - 1, _rows); _col = Clamp(P(p, 1, 1) - 1, _cols); break; + case 'A': _row = Clamp(_row - Math.Max(1, P(p, 0, 1)), _rows); break; + case 'B': _row = Clamp(_row + Math.Max(1, P(p, 0, 1)), _rows); break; + case 'C': _col = Clamp(_col + Math.Max(1, P(p, 0, 1)), _cols); break; + case 'D': _col = Clamp(_col - Math.Max(1, P(p, 0, 1)), _cols); break; + case 'E': _col = 0; _row = Clamp(_row + Math.Max(1, P(p, 0, 1)), _rows); break; + case 'F': _col = 0; _row = Clamp(_row - Math.Max(1, P(p, 0, 1)), _rows); break; + case 'G': _col = Clamp(P(p, 0, 1) - 1, _cols); break; + case 'd': _row = Clamp(P(p, 0, 1) - 1, _rows); break; + case 'J': EraseDisplay(P(p, 0, 0)); break; + case 'K': EraseLine(P(p, 0, 0)); break; + // 'm' (SGR), 'h'/'l' (modes), 'r', 'n', 't', etc. → ignored + } + } + return i + 1; + } + + private void EraseDisplay(int mode) + { + if (mode == 2 || mode == 3) { Clear(); return; } + if (mode == 0) { EraseLine(0); for (int r = _row + 1; r < _rows; r++) for (int c = 0; c < _cols; c++) _grid[r, c] = ' '; } + else if (mode == 1) { for (int r = 0; r < _row; r++) for (int c = 0; c < _cols; c++) _grid[r, c] = ' '; EraseLine(1); } + } + + private void EraseLine(int mode) + { + if (!InBounds(_row, 0)) return; + int from = mode == 0 ? _col : 0; + int to = mode == 1 ? _col : _cols - 1; + for (int c = Math.Max(0, from); c <= Math.Min(_cols - 1, to); c++) _grid[_row, c] = ' '; + } + + private void NewLine() + { + if (_row < _rows - 1) _row++; + // (no scrollback needed: Terminal.Gui repaints absolutely) + } + + private static int SkipOsc(string s, int i) + { + while (i < s.Length) + { + if (s[i] == '\x07') return i + 1; // BEL terminator + if (s[i] == '\x1b' && i + 1 < s.Length && s[i + 1] == '\\') return i + 2; // ST + i++; + } + return i; + } + + private static int SkipUntilSt(string s, int i) + { + while (i < s.Length) + { + if (s[i] == '\x1b' && i + 1 < s.Length && s[i + 1] == '\\') return i + 2; + i++; + } + return i; + } + + private static List ParseParams(string body) + { + var list = new List(); + foreach (var part in body.Split(';')) + list.Add(int.TryParse(part, out int v) ? v : 0); + return list; + } + + private static int P(List p, int idx, int def) => idx < p.Count && p[idx] != 0 ? p[idx] : (idx < p.Count ? p[idx] : def); + private int Clamp(int v, int max) => Math.Max(0, Math.Min(max - 1, v)); + private bool InBounds(int r, int c) => r >= 0 && r < _rows && c >= 0 && c < _cols; + + /// The full screen as text, one line per row (trailing spaces trimmed per row). + public string Text() + { + var sb = new StringBuilder(); + for (int r = 0; r < _rows; r++) + { + var line = new StringBuilder(); + for (int c = 0; c < _cols; c++) line.Append(_grid[r, c]); + sb.Append(line.ToString().TrimEnd()); + if (r < _rows - 1) sb.Append('\n'); + } + return sb.ToString(); + } + + public bool Contains(string needle) => Text().Contains(needle); +}