diff --git a/.gitignore b/.gitignore index 3c188d9..b8f5c9b 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,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 a22b078..78272f2 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,11 +27,13 @@ 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; + // An untouched password box is "no password" (null), not an empty-string password; + // downstream the two are sent identically (Password ?? string.Empty). public string? Password => SelectedAuthType == AuthenticationType.UserName - ? _passwordField.Text : null; + && !string.IsNullOrEmpty(_passwordField.Text) ? _passwordField.Text : null; public ConnectDialog( string? initialEndpoint = null, @@ -60,8 +62,7 @@ public ConnectDialog( X = 1, Y = 2, Text = ProtocolPrefix, - ColorScheme = theme.MainColorScheme - }; + }.WithScheme(theme.MainColorScheme); _endpointField = new TextField { @@ -95,8 +96,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 +106,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 +149,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; @@ -167,8 +167,7 @@ public ConnectDialog( Y = 14, Text = $"{theme.ButtonPrefix}Connect{theme.ButtonSuffix}", IsDefault = true, - ColorScheme = defaultButtonScheme - }; + }.WithScheme(defaultButtonScheme); connectButton.Accepting += (_, _) => { @@ -184,8 +183,7 @@ public ConnectDialog( X = Pos.Center() + 4, Y = 14, Text = $"{theme.ButtonPrefix}Cancel{theme.ButtonSuffix}", - ColorScheme = theme.ButtonColorScheme - }; + }.WithScheme(theme.ButtonColorScheme); cancelButton.Accepting += (_, _) => { @@ -208,7 +206,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; } @@ -217,20 +215,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; } @@ -239,7 +237,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; } @@ -247,7 +245,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; } @@ -275,7 +273,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 6b61b34..ee3ac5a 100644 --- a/App/Dialogs/HelpDialog.cs +++ b/App/Dialogs/HelpDialog.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; @@ -25,7 +24,7 @@ public HelpDialog(KeybindingManager keybindingManager) var theme = ThemeManager.Current; // Apply theme styling with emphasized border (double-line) - ColorScheme = theme.MainColorScheme; + SetScheme(theme.MainColorScheme); BorderStyle = theme.EmphasizedBorderStyle; // Create content view with the help text @@ -37,20 +36,19 @@ public HelpDialog(KeybindingManager keybindingManager) Height = Dim.Fill(2), ReadOnly = true, WordWrap = true, - 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) + }); contentView.Text = GenerateHelpFromBindings(keybindingManager); // OK button - highlighted with accent color (default action) - var defaultButtonScheme = new ColorScheme + var defaultButtonScheme = new Scheme { Normal = new Attribute(theme.Accent, theme.Background), Focus = new Attribute(theme.AccentBright, theme.Background), @@ -65,8 +63,7 @@ public HelpDialog(KeybindingManager keybindingManager) X = Pos.Center(), Y = Pos.AnchorEnd(1), IsDefault = true, - ColorScheme = defaultButtonScheme - }; + }.WithScheme(defaultButtonScheme); okButton.Accepting += (_, _) => RequestStop(); Add(contentView); @@ -126,7 +123,7 @@ private void OnThemeChanged(AppTheme theme) { Application.Invoke(() => { - ColorScheme = theme.MainColorScheme; + SetScheme(theme.MainColorScheme); BorderStyle = theme.EmphasizedBorderStyle; SetNeedsLayout(); }); diff --git a/App/Dialogs/OpenConfigDialog.cs b/App/Dialogs/OpenConfigDialog.cs index 062a869..79b3728 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,8 +70,7 @@ public OpenConfigDialog() Y = Pos.AnchorEnd(1), Text = $"{theme.ButtonPrefix}Open{theme.ButtonSuffix}", IsDefault = true, - ColorScheme = ThemeStyler.CreateAccentButtonScheme(theme) - }; + }.WithScheme(ThemeStyler.CreateAccentButtonScheme(theme)); openButton.Accepting += (_, _) => Confirm(); var browseButton = new Button @@ -81,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 @@ -90,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; @@ -124,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 cb8a14b..7b1c8ea 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 { @@ -57,8 +56,7 @@ public PasswordPromptDialog(string username, string endpoint) Y = 6, Text = $"{theme.ButtonPrefix}OK{theme.ButtonSuffix}", IsDefault = true, - ColorScheme = defaultButtonScheme - }; + }.WithScheme(defaultButtonScheme); okButton.Accepting += (_, _) => { @@ -71,8 +69,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/SaveConfigDialog.cs b/App/Dialogs/SaveConfigDialog.cs index 171b2ad..264de06 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,8 +114,7 @@ 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 = ThemeStyler.CreateAccentButtonScheme(theme); @@ -128,8 +125,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 @@ -137,8 +133,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, @@ -218,7 +213,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; } @@ -226,7 +221,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; } @@ -234,7 +229,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; } @@ -248,7 +243,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; } @@ -256,7 +251,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 b0752ef..ba82f1f 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 @@ -108,8 +107,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 += (_, _) => { @@ -125,8 +123,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 += (_, _) => { @@ -184,13 +181,17 @@ 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(); + // 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) @@ -204,10 +205,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]; + var selected = _fileListItems[sel.Value]; if (selected == "..") { @@ -243,7 +248,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; } @@ -254,7 +259,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; } @@ -263,7 +268,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..312f61c 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() ?? Theme.MainColorScheme); _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 e59e2ac..8034bb7 100644 --- a/App/Dialogs/WriteValueDialog.cs +++ b/App/Dialogs/WriteValueDialog.cs @@ -116,15 +116,14 @@ 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(); @@ -137,21 +136,19 @@ public WriteValueDialog(NodeId nodeId, string nodeName, BuiltInType dataType, st { 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"); @@ -213,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 8e76f63..b434fd0 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 b3e7e7d..ba618e6 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; @@ -51,9 +51,6 @@ public class MainWindow : Toplevel, DefaultKeybindings.IKeybindingActions private View? _focusedPanel; private FocusManager? _focusManager; - // Stored so it can be unsubscribed from the static Application event in Dispose - private readonly EventHandler _sizeChangingHandler; - // Lazygit-inspired keybinding system private readonly KeybindingManager _keybindingManager; @@ -88,7 +85,7 @@ 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"] = ThemeStyler.CreateFlatBarScheme(theme); + SchemeManager.AddScheme("Menu", ThemeStyler.CreateFlatBarScheme(theme)); // Create theme toggle menu item _themeToggleItem = new MenuItem(GetThemeToggleTitle(), "", ToggleTheme); @@ -142,7 +139,7 @@ public MainWindow() }; // Also set ColorScheme directly on the StatusBar instance - _statusBar.ColorScheme = ThemeStyler.CreateFlatBarScheme(theme); + _statusBar.SetScheme(ThemeStyler.CreateFlatBarScheme(theme)); // Connection status indicator (colored) - FAR RIGHT, overlaid on status bar row // We position it dynamically based on text width @@ -211,9 +208,10 @@ public MainWindow() // Apply initial theme (after all controls are created) ApplyTheme(); - // Handle window resize to update connection status label position - _sizeChangingHandler = (s, e) => UiThread.Run(UpdateConnectionStatusLabelPosition); - Application.SizeChanging += _sizeChangingHandler; + // 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(); @@ -241,10 +239,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), () => @@ -254,10 +252,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 } @@ -281,13 +279,13 @@ 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("Toggle Recording", "", ToggleRecording, shortcutKey: Key.R.WithCtrl), + new MenuItem("Toggle Recording", "", ToggleRecording, Key.R.WithCtrl), null!, // Separator - new MenuItem("E_xit", "", () => RequestStop(), shortcutKey: Key.Q.WithCtrl) + new MenuItem("E_xit", "", () => RequestStop(), Key.Q.WithCtrl) }), new MenuBarItem("_Connection", new MenuItem[] { @@ -316,17 +314,15 @@ private void ApplyTheme() var theme = ThemeManager.Current; // Update global "Menu" ColorScheme (used by StatusBar) - Colors.ColorSchemes["Menu"] = ThemeStyler.CreateFlatBarScheme(theme); + SchemeManager.AddScheme("Menu", ThemeStyler.CreateFlatBarScheme(theme)); // 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); @@ -334,15 +330,15 @@ private void ApplyTheme() // Apply clean status bar styling (no blue background) // Must set ColorScheme AND call SetNeedsDisplay to override Terminal.Gui defaults var cleanStatusBarScheme = ThemeStyler.CreateFlatBarScheme(theme); - _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) @@ -558,14 +554,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; } @@ -614,14 +610,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; } @@ -704,9 +700,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(); } } @@ -718,7 +716,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(); @@ -749,7 +747,7 @@ private void UpdateStatusBarShortcuts() 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 @@ -837,7 +835,7 @@ private void OnConnectionError(string message) { UiThread.Run(() => { - MessageBox.ErrorQuery("Connection Error", message, "OK"); + MessageBox.ErrorQuery(Application.Instance, "Connection Error", message, "OK"); }); } @@ -875,21 +873,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() @@ -959,7 +957,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; } @@ -967,7 +965,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; } @@ -986,7 +984,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; } @@ -994,7 +992,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"); @@ -1019,7 +1017,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"); } } } @@ -1034,7 +1032,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() @@ -1095,7 +1093,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"); } /// @@ -1270,7 +1268,7 @@ private async Task LoadConfigurationAsync(string filePath) UpdateWindowTitle(); _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 has been closed. Use Connect to reconnect.", "OK"); } @@ -1290,7 +1288,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 { @@ -1335,7 +1333,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 { @@ -1364,7 +1362,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", @@ -1434,7 +1432,6 @@ protected override void Dispose(bool disposing) } Application.KeyDown -= OnApplicationKeyDown; - Application.SizeChanging -= _sizeChangingHandler; _connectionManager.Dispose(); } diff --git a/App/Themes/AppTheme.cs b/App/Themes/AppTheme.cs index 540edab..d7cdf2c 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; @@ -124,16 +123,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, @@ -142,7 +141,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, @@ -151,7 +150,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), @@ -160,7 +159,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, @@ -169,7 +168,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, @@ -182,7 +181,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, @@ -194,8 +193,12 @@ 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. + /// TODO: currently unused — Terminal.Gui 2.4 removed per-adornment schemes, so this + /// can no longer be applied to the main window border. Kept for the planned + /// reintroduction of title highlighting via Scheme VisualRoles (see MainWindow.ApplyTheme). /// - public virtual ColorScheme HighlightTitleBorderColorScheme => _highlightTitleBorderColorScheme ??= new() + [Obsolete("Not applied since the Terminal.Gui 2.4 migration removed per-adornment schemes; pending Scheme VisualRoles.")] + public virtual Scheme HighlightTitleBorderColorScheme => _highlightTitleBorderColorScheme ??= new() { Normal = BorderAttr, Focus = BorderAttr, @@ -208,7 +211,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/TerminalTheme.cs b/App/Themes/TerminalTheme.cs index bc219fb..28f262f 100644 --- a/App/Themes/TerminalTheme.cs +++ b/App/Themes/TerminalTheme.cs @@ -1,5 +1,4 @@ using Terminal.Gui; -using Attribute = Terminal.Gui.Attribute; namespace Opcilloscope.App.Themes; @@ -82,10 +81,10 @@ public class TerminalTheme : AppTheme // Override color schemes - focus highlight uses a DarkGray band since // panel-background shades are not available in the 16-color palette - 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(ColorName16.DarkGray)), @@ -94,7 +93,7 @@ public class TerminalTheme : 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/ThemeManager.cs b/App/Themes/ThemeManager.cs index efd1ce8..2040b4b 100644 --- a/App/Themes/ThemeManager.cs +++ b/App/Themes/ThemeManager.cs @@ -82,10 +82,8 @@ public static void SetTheme(AppTheme theme) /// private static void ApplyTerminalColorMode(AppTheme theme) { - Application.Force16Colors = theme.UseTerminalColors; - - // The v2 facade driver caches its own flag rather than reading - // Application.Force16Colors, so propagate explicitly when running + // Terminal.Gui 2.4 removed the static Application.Force16Colors; + // the flag now lives on the driver itself. if (Application.Driver is { } driver) { driver.Force16Colors = theme.UseTerminalColors; diff --git a/App/Themes/ThemeStyler.cs b/App/Themes/ThemeStyler.cs index c5e430a..03ec510 100644 --- a/App/Themes/ThemeStyler.cs +++ b/App/Themes/ThemeStyler.cs @@ -18,17 +18,14 @@ public static void ApplyToFrame(FrameView frame, AppTheme? theme = null) theme ??= ThemeManager.Current; // Apply color scheme - frame.ColorScheme = theme.MainColorScheme; + frame.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 (frame.Border != null) - { - frame.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 (frame.Margin != null) @@ -49,13 +46,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; - } } /// @@ -64,22 +56,22 @@ public static void ApplyToDialog(Dialog dialog, AppTheme? theme = null) public static void ApplyToMenuBar(MenuBar menuBar, AppTheme? theme = null) { theme ??= ThemeManager.Current; - menuBar.ColorScheme = theme.MenuColorScheme; + menuBar.SetScheme(theme.MenuColorScheme); } /// /// Creates the accent-colored scheme used to highlight a dialog's default button. /// - public static ColorScheme CreateAccentButtonScheme(AppTheme? theme = null) + public static Scheme CreateAccentButtonScheme(AppTheme? theme = null) { theme ??= ThemeManager.Current; - return new ColorScheme + return 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) }; } @@ -87,16 +79,16 @@ public static ColorScheme CreateAccentButtonScheme(AppTheme? theme = null) /// Creates the flat menu/status bar scheme. Unlike MenuColorScheme this keeps the /// theme background on focus, avoiding inverted highlight flashes on the bars. /// - public static ColorScheme CreateFlatBarScheme(AppTheme? theme = null) + public static Scheme CreateFlatBarScheme(AppTheme? theme = null) { theme ??= ThemeManager.Current; - return new ColorScheme + return 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) }; } } diff --git a/App/Views/AddressSpaceView.cs b/App/Views/AddressSpaceView.cs index 64fb47c..b81dc4d 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,16 @@ 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. + // Safe from activate/select races: Activated is raised synchronously on the UI + // thread by the command that acted on the selection, so it still matches. + var activated = _treeView.SelectedObject; + if (activated != null && activated.NodeClass == Opc.Ua.NodeClass.Variable) { - NodeSubscribeRequested?.Invoke(e.ActivatedObject); + NodeSubscribeRequested?.Invoke(activated); } } @@ -243,7 +243,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 6a57a96..49b1905 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(); }); } @@ -113,11 +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.TopItem = Math.Max(0, _displayedEntries.Count - _listView.Frame.Height); + _listView.EnsureSelectedItemVisible(); } }); } diff --git a/App/Views/MonitoredVariablesView.cs b/App/Views/MonitoredVariablesView.cs index b230a7b..7c88114 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,19 @@ 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 +153,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 +162,14 @@ 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 +178,14 @@ 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 +195,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 +226,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 +439,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 +474,15 @@ 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. + // Mouse.Position is Point? in Terminal.Gui 2.4 - the null pattern is required. + 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 && @@ -491,7 +493,12 @@ private void HandleMouseClick(object? sender, MouseEventArgs 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. } } @@ -511,14 +518,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 +568,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 +598,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 9d7620a..7fc5aba 100644 --- a/App/Views/NodeDetailsView.cs +++ b/App/Views/NodeDetailsView.cs @@ -36,10 +36,9 @@ public NodeDetailsView() X = Pos.AnchorEnd(8), Y = 0, Height = 1, - ShadowStyle = ShadowStyle.None, - ColorScheme = theme.ButtonColorScheme, + ShadowStyle = ShadowStyles.None, Enabled = false - }; + }.WithScheme(theme.ButtonColorScheme); _copyButton.Accepting += OnCopyClicked; _detailsLabel = new Label @@ -50,11 +49,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 +63,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(); }); @@ -225,19 +220,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 f06a49e..69784d7 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,8 +302,6 @@ private bool OnTimerTick() /// protected override bool OnDrawingContent(DrawContext? context) { - if (Driver is null) return false; - AppTheme theme; lock (_themeLock) { @@ -336,12 +332,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 +474,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 +484,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 +519,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 +542,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 +553,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 +581,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 +594,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; @@ -626,7 +622,7 @@ private void DrawXAxisLabels(AppTheme theme, int plotLeft, int labelY, labelX = Math.Clamp(labelX, plotLeft, maxLabelX); Move(labelX, labelY); - Driver!.AddStr(label); + AddStr(label); } } @@ -657,9 +653,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]); } } @@ -730,8 +726,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); } } @@ -762,13 +758,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/App/ThemeManagerTests.cs b/Tests/Opcilloscope.Tests/App/ThemeManagerTests.cs index 31e6ab3..9cfe105 100644 --- a/Tests/Opcilloscope.Tests/App/ThemeManagerTests.cs +++ b/Tests/Opcilloscope.Tests/App/ThemeManagerTests.cs @@ -123,8 +123,11 @@ public void ThemeManager_SetThemeByName_WithInvalidName_DoesNothing() } [Fact] - public void ThemeManager_SetTerminalTheme_TogglesForce16Colors() + public void ThemeManager_SetTerminalTheme_TogglesTerminalColorMode() { + // Terminal.Gui 2.4 removed the static Application.Force16Colors; the flag now + // lives on Application.Driver, which is null in headless tests. Assert on the + // theme property that ThemeManager propagates to the driver when one exists. try { // Act - Terminal theme enables 16-color ANSI output @@ -132,13 +135,13 @@ public void ThemeManager_SetTerminalTheme_TogglesForce16Colors() // Assert Assert.IsType(ThemeManager.Current); - Assert.True(Terminal.Gui.Application.Force16Colors); + Assert.True(ThemeManager.Current.UseTerminalColors); // Act - switching back restores 24-bit color output ThemeManager.SetTheme("Dark"); // Assert - Assert.False(Terminal.Gui.Application.Force16Colors); + Assert.False(ThemeManager.Current.UseTerminalColors); } finally { 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; diff --git a/Tests/Opcilloscope.Tests/Tui/ConnectDialogTests.cs b/Tests/Opcilloscope.Tests/Tui/ConnectDialogTests.cs new file mode 100644 index 0000000..c43bbd5 --- /dev/null +++ b/Tests/Opcilloscope.Tests/Tui/ConnectDialogTests.cs @@ -0,0 +1,82 @@ +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() + { + using 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. + using var dialog = new ConnectDialog(initialEndpoint: "opc.tcp://server:4840"); + + Assert.Equal("opc.tcp://server:4840", dialog.EndpointUrl); + } + + [Fact] + public void PublishingInterval_IsTakenFromConstructor() + { + using var dialog = new ConnectDialog(initialEndpoint: "x:1", publishingInterval: 750); + + Assert.Equal(750, dialog.PublishingInterval); + } + + [Fact] + public void Anonymous_AuthExposesNoCredentials() + { + using 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() + { + using var dialog = new ConnectDialog( + initialEndpoint: "x:1", + authType: AuthenticationType.UserName, + username: "operator"); + + Assert.Equal(AuthenticationType.UserName, dialog.SelectedAuthType); + Assert.Equal("operator", dialog.Username); + // The credential-hiding logic depends on an absent password staying null. + Assert.Null(dialog.Password); + } + + [Fact] + public void Username_AuthExposesTypedPassword() + { + using var dialog = new ConnectDialog( + initialEndpoint: "x:1", + authType: AuthenticationType.UserName, + username: "operator"); + + // The password box is the dialog's only Secret TextField; type into it. + var passwordField = FindSecretTextField(dialog); + Assert.NotNull(passwordField); + passwordField!.Text = "hunter2"; + + Assert.Equal("hunter2", dialog.Password); + } + + private static TextField? FindSecretTextField(View root) => + root.SubViews.OfType().FirstOrDefault(f => f.Secret) + ?? root.SubViews.Select(FindSecretTextField).FirstOrDefault(f => f is not null); +} diff --git a/Tests/Opcilloscope.Tests/Tui/MonitoredVariablesViewTests.cs b/Tests/Opcilloscope.Tests/Tui/MonitoredVariablesViewTests.cs new file mode 100644 index 0000000..bd6d93d --- /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() + { + using var view = new MonitoredVariablesView(); + + Assert.Null(view.SelectedVariable); + Assert.Empty(view.ScopeSelectedNodes); + Assert.Equal(0, view.ScopeSelectionCount); + } + + [Fact] + public void AddVariable_AddsScopeSelectedNodeToScopeCollection() + { + using 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() + { + using 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() + { + using 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..78fea3d --- /dev/null +++ b/Tests/Opcilloscope.Tests/Tui/ThemeSchemeTests.cs @@ -0,0 +1,68 @@ +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() }, + new object[] { new TerminalTheme() }, + }; + + [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_ApplyToFrame_SetsTheViewScheme() + { + var theme = new DarkTheme(); + using var view = new FrameView(); + + ThemeStyler.ApplyToFrame(view, theme); + + Scheme? applied = view.GetScheme(); + Assert.NotNull(applied); + Assert.Equal(theme.Foreground, applied!.Normal.Foreground); + Assert.Equal(theme.Background, applied.Normal.Background); + } + + [Fact] + public void WithScheme_AppliesTheSchemeAndReturnsTheSameView() + { + // Guards the load-bearing migration glue: SetScheme() cannot be used in an + // object initializer, so WithScheme() must both apply and chain. + var scheme = new DarkTheme().MainColorScheme; + using var label = new Label { Text = "x" }; + + var returned = label.WithScheme(scheme); + + Assert.Same(label, returned); + Assert.Same(scheme, label.GetScheme()); + } +} 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..f9e3d4f --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,50 @@ +# 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. + +> **Note:** the E2E project is not yet delivered — it arrives in the follow-up +> layer-2 (PTY harness) PR. The references above describe the planned layout. diff --git a/docs/pr165/connect-dark.png b/docs/pr165/connect-dark.png new file mode 100644 index 0000000..34cb9d0 Binary files /dev/null and b/docs/pr165/connect-dark.png differ diff --git a/docs/pr165/main-dark.png b/docs/pr165/main-dark.png new file mode 100644 index 0000000..3d53ffa Binary files /dev/null and b/docs/pr165/main-dark.png differ diff --git a/docs/pr165/main-light.png b/docs/pr165/main-light.png new file mode 100644 index 0000000..36e3af7 Binary files /dev/null and b/docs/pr165/main-light.png differ diff --git a/docs/pr165/main-terminal.png b/docs/pr165/main-terminal.png new file mode 100644 index 0000000..c81f2ae Binary files /dev/null and b/docs/pr165/main-terminal.png differ diff --git a/docs/pr165/scope-dark.png b/docs/pr165/scope-dark.png new file mode 100644 index 0000000..974244f Binary files /dev/null and b/docs/pr165/scope-dark.png differ