diff --git a/App/Dialogs/ConnectDialog.cs b/App/Dialogs/ConnectDialog.cs index 78272f2..02f9840 100644 --- a/App/Dialogs/ConnectDialog.cs +++ b/App/Dialogs/ConnectDialog.cs @@ -1,4 +1,5 @@ using Terminal.Gui; +using Opcilloscope.Utilities; using Opcilloscope.App.Themes; using Opcilloscope.OpcUa; using AppThemeManager = Opcilloscope.App.Themes.ThemeManager; @@ -174,7 +175,7 @@ public ConnectDialog( if (ValidateInput()) { _confirmed = true; - Application.RequestStop(); + TerminalUi.RequestStop(); } }; @@ -188,7 +189,7 @@ public ConnectDialog( cancelButton.Accepting += (_, _) => { _confirmed = false; - Application.RequestStop(); + TerminalUi.RequestStop(); }; Add(endpointLabel, protocolLabel, _endpointField, @@ -206,7 +207,7 @@ private bool ValidateInput() if (string.IsNullOrEmpty(serverAddress)) { - MessageBox.ErrorQuery(Application.Instance, "Error", "Please enter a server address", "OK"); + TerminalUi.ErrorQuery("Error", "Please enter a server address", "OK"); return false; } @@ -215,20 +216,20 @@ private bool ValidateInput() var uri = new Uri(EndpointUrl); if (string.IsNullOrEmpty(uri.Host)) { - MessageBox.ErrorQuery(Application.Instance, "Error", "Invalid host in server address", "OK"); + TerminalUi.ErrorQuery("Error", "Invalid host in server address", "OK"); return false; } } catch { - MessageBox.ErrorQuery(Application.Instance, "Error", "Invalid server address format", "OK"); + TerminalUi.ErrorQuery("Error", "Invalid server address format", "OK"); return false; } var interval = _publishIntervalField.Value; if (interval < 100 || interval > 10000) { - MessageBox.ErrorQuery(Application.Instance, "Error", "Publishing interval must be between 100 and 10000 ms", "OK"); + TerminalUi.ErrorQuery("Error", "Publishing interval must be between 100 and 10000 ms", "OK"); return false; } @@ -237,7 +238,7 @@ private bool ValidateInput() var username = _usernameField.Text?.Trim() ?? string.Empty; if (string.IsNullOrEmpty(username)) { - MessageBox.ErrorQuery(Application.Instance, "Error", "Please enter a username", "OK"); + TerminalUi.ErrorQuery("Error", "Please enter a username", "OK"); _usernameField.SetFocus(); return false; } @@ -245,7 +246,7 @@ private bool ValidateInput() var password = _passwordField.Text ?? string.Empty; if (string.IsNullOrEmpty(password)) { - MessageBox.ErrorQuery(Application.Instance, "Error", "Please enter a password", "OK"); + TerminalUi.ErrorQuery("Error", "Please enter a password", "OK"); _passwordField.SetFocus(); return false; } diff --git a/App/Dialogs/HelpDialog.cs b/App/Dialogs/HelpDialog.cs index ee3ac5a..88c89ac 100644 --- a/App/Dialogs/HelpDialog.cs +++ b/App/Dialogs/HelpDialog.cs @@ -1,4 +1,5 @@ using Terminal.Gui; +using Opcilloscope.Utilities; using Opcilloscope.App.Keybindings; using Opcilloscope.App.Themes; using ThemeManager = Opcilloscope.App.Themes.ThemeManager; @@ -27,7 +28,12 @@ public HelpDialog(KeybindingManager keybindingManager) SetScheme(theme.MainColorScheme); BorderStyle = theme.EmphasizedBorderStyle; - // Create content view with the help text + // Create content view with the help text. + // TextView is obsolete in Terminal.Gui 2.4.5, superseded by EditorView from + // the separate gui-cs/Editor package. A read-only scrolling text pane is all + // that's needed here, so keep TextView rather than take on a new dependency; + // revisit if/when EditorView ships in the Terminal.Gui package itself. +#pragma warning disable CS0618 // TextView is obsolete (replacement lives in gui-cs/Editor) var contentView = new TextView { X = 1, @@ -44,6 +50,7 @@ public HelpDialog(KeybindingManager keybindingManager) HotFocus = new Attribute(theme.Foreground, theme.Background), Disabled = new Attribute(theme.MutedText, theme.Background) }); +#pragma warning restore CS0618 contentView.Text = GenerateHelpFromBindings(keybindingManager); @@ -121,7 +128,7 @@ private static string GenerateHelpFromBindings(KeybindingManager manager) private void OnThemeChanged(AppTheme theme) { - Application.Invoke(() => + UiThread.Run(() => { SetScheme(theme.MainColorScheme); BorderStyle = theme.EmphasizedBorderStyle; diff --git a/App/Dialogs/OpenConfigDialog.cs b/App/Dialogs/OpenConfigDialog.cs index 79b3728..d702dd0 100644 --- a/App/Dialogs/OpenConfigDialog.cs +++ b/App/Dialogs/OpenConfigDialog.cs @@ -1,4 +1,5 @@ using Terminal.Gui; +using Opcilloscope.Utilities; using Opcilloscope.App.Themes; using Opcilloscope.Configuration; using AppThemeManager = Opcilloscope.App.Themes.ThemeManager; @@ -90,7 +91,7 @@ public OpenConfigDialog() cancelButton.Accepting += (_, _) => { _confirmed = false; - Application.RequestStop(); + TerminalUi.RequestStop(); }; Add(_directoryLabel, _fileListView, openButton, browseButton, cancelButton); @@ -121,7 +122,7 @@ private void Confirm() { SelectedFilePath = _files[_fileListView.SelectedItem!.Value].FullName; _confirmed = true; - Application.RequestStop(); + TerminalUi.RequestStop(); } } @@ -139,13 +140,13 @@ private void OnBrowse(object? sender, CommandEventArgs e) Path = ConfigurationService.GetDefaultConfigDirectory() }; - Application.Run(dialog); + TerminalUi.RunModal(dialog); if (!dialog.Canceled && dialog.Path != null) { SelectedFilePath = dialog.Path.ToString()!; _confirmed = true; - Application.RequestStop(); + TerminalUi.RequestStop(); } } } diff --git a/App/Dialogs/PasswordPromptDialog.cs b/App/Dialogs/PasswordPromptDialog.cs index 7b1c8ea..d1ff098 100644 --- a/App/Dialogs/PasswordPromptDialog.cs +++ b/App/Dialogs/PasswordPromptDialog.cs @@ -1,4 +1,5 @@ using Terminal.Gui; +using Opcilloscope.Utilities; using Opcilloscope.App.Themes; using AppThemeManager = Opcilloscope.App.Themes.ThemeManager; @@ -61,7 +62,7 @@ public PasswordPromptDialog(string username, string endpoint) okButton.Accepting += (_, _) => { _confirmed = true; - Application.RequestStop(); + TerminalUi.RequestStop(); }; var cancelButton = new Button @@ -74,7 +75,7 @@ public PasswordPromptDialog(string username, string endpoint) cancelButton.Accepting += (_, _) => { _confirmed = false; - Application.RequestStop(); + TerminalUi.RequestStop(); }; Add(promptLabel, endpointLabel, _passwordField, okButton, cancelButton); diff --git a/App/Dialogs/SaveConfigDialog.cs b/App/Dialogs/SaveConfigDialog.cs index 264de06..cffad25 100644 --- a/App/Dialogs/SaveConfigDialog.cs +++ b/App/Dialogs/SaveConfigDialog.cs @@ -1,4 +1,5 @@ using Terminal.Gui; +using Opcilloscope.Utilities; using Opcilloscope.App.Themes; using Opcilloscope.Configuration; using AppThemeManager = Opcilloscope.App.Themes.ThemeManager; @@ -161,7 +162,7 @@ private void OnBrowseDirectory(object? sender, CommandEventArgs e) // We'll let user navigate to any directory and extract the directory path }; - Application.Run(dialog); + TerminalUi.RunModal(dialog); if (!dialog.Canceled && dialog.Path != null) { @@ -198,13 +199,13 @@ private void OnSave(object? sender, CommandEventArgs e) return; _confirmed = true; - Application.RequestStop(); + TerminalUi.RequestStop(); } private void OnCancel(object? sender, CommandEventArgs e) { _confirmed = false; - Application.RequestStop(); + TerminalUi.RequestStop(); } private bool ValidateSave() @@ -213,7 +214,7 @@ private bool ValidateSave() var filename = _currentFilename.Trim(); if (string.IsNullOrEmpty(filename)) { - MessageBox.ErrorQuery(Application.Instance, "Error", "Please enter a filename", "OK"); + TerminalUi.ErrorQuery("Error", "Please enter a filename", "OK"); return false; } @@ -221,7 +222,7 @@ private bool ValidateSave() var invalidChars = Path.GetInvalidFileNameChars(); if (filename.IndexOfAny(invalidChars) >= 0) { - MessageBox.ErrorQuery(Application.Instance, "Error", "Filename contains invalid characters", "OK"); + TerminalUi.ErrorQuery("Error", "Filename contains invalid characters", "OK"); return false; } @@ -229,7 +230,7 @@ private bool ValidateSave() var directory = _currentDirectory.Trim(); if (string.IsNullOrEmpty(directory)) { - MessageBox.ErrorQuery(Application.Instance, "Error", "Please specify a directory", "OK"); + TerminalUi.ErrorQuery("Error", "Please specify a directory", "OK"); return false; } @@ -243,7 +244,7 @@ private bool ValidateSave() } catch (Exception ex) { - MessageBox.ErrorQuery(Application.Instance, "Error", $"Cannot create directory: {ex.Message}", "OK"); + TerminalUi.ErrorQuery("Error", $"Cannot create directory: {ex.Message}", "OK"); return false; } @@ -251,7 +252,7 @@ private bool ValidateSave() var fullPath = FilePath; if (File.Exists(fullPath)) { - var result = MessageBox.Query(Application.Instance, "Confirm Overwrite", + var result = TerminalUi.Query("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 ba82f1f..cbef01a 100644 --- a/App/Dialogs/SaveRecordingDialog.cs +++ b/App/Dialogs/SaveRecordingDialog.cs @@ -114,7 +114,7 @@ public SaveRecordingDialog(string defaultDirectory, string defaultFilename) if (ValidateAndSetPath()) { _confirmed = true; - Application.RequestStop(); + TerminalUi.RequestStop(); } }; @@ -128,7 +128,7 @@ public SaveRecordingDialog(string defaultDirectory, string defaultFilename) cancelButton.Accepting += (_, _) => { _confirmed = false; - Application.RequestStop(); + TerminalUi.RequestStop(); }; Add(directoryLabel, _directoryField, fileListLabel, _fileListView, @@ -181,7 +181,7 @@ private void LoadDirectory(string directory) } catch (Exception ex) { - MessageBox.ErrorQuery(Application.Instance, "Error", $"Cannot access directory:\n{ex.Message}", "OK"); + TerminalUi.ErrorQuery("Error", $"Cannot access directory:\n{ex.Message}", "OK"); } } @@ -248,7 +248,7 @@ private bool ValidateAndSetPath() if (string.IsNullOrEmpty(filename)) { - MessageBox.ErrorQuery(Application.Instance, "Error", "Please enter a filename", "OK"); + TerminalUi.ErrorQuery("Error", "Please enter a filename", "OK"); return false; } @@ -259,7 +259,7 @@ private bool ValidateAndSetPath() var invalidChars = Path.GetInvalidFileNameChars(); if (filename.IndexOfAny(invalidChars) >= 0) { - MessageBox.ErrorQuery(Application.Instance, "Error", "Filename contains invalid characters", "OK"); + TerminalUi.ErrorQuery("Error", "Filename contains invalid characters", "OK"); return false; } @@ -268,7 +268,7 @@ private bool ValidateAndSetPath() // Check if file already exists if (File.Exists(fullPath)) { - var result = MessageBox.Query(Application.Instance, "Confirm Overwrite", + var result = TerminalUi.Query("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 312f61c..04e094a 100644 --- a/App/Dialogs/ScopeDialog.cs +++ b/App/Dialogs/ScopeDialog.cs @@ -1,4 +1,5 @@ using Terminal.Gui; +using Opcilloscope.Utilities; using Opcilloscope.App.Views; using Opcilloscope.App.Themes; using Opcilloscope.OpcUa; @@ -63,7 +64,7 @@ public ScopeDialog( Y = 0, Text = $"{Theme.ButtonPrefix}CLOSE{Theme.ButtonSuffix}", }.WithScheme(Theme.ButtonColorScheme); - _closeButton.Accepting += (_, _) => Application.RequestStop(); + _closeButton.Accepting += (_, _) => TerminalUi.RequestStop(); buttonFrame.Add(_pauseButton, _closeButton); @@ -88,7 +89,7 @@ private void OnPauseToggle(object? _, CommandEventArgs _1) private void OnPauseStateChanged(bool isPaused) { - Application.Invoke(() => + UiThread.Run(() => { _pauseButton.Text = isPaused ? $"{Theme.ButtonPrefix}RESUME{Theme.ButtonSuffix}" @@ -98,7 +99,7 @@ private void OnPauseStateChanged(bool isPaused) private void OnThemeChanged(AppTheme theme) { - Application.Invoke(() => + UiThread.Run(() => { Title = $"{theme.TitleDecoration}[ SCOPE ]{theme.TitleDecoration}"; ThemeStyler.ApplyToDialog(this, theme); diff --git a/App/Dialogs/WriteValueDialog.cs b/App/Dialogs/WriteValueDialog.cs index 8034bb7..d8381e8 100644 --- a/App/Dialogs/WriteValueDialog.cs +++ b/App/Dialogs/WriteValueDialog.cs @@ -148,7 +148,7 @@ public WriteValueDialog(NodeId nodeId, string nodeName, BuiltInType dataType, st if (ValidateAndParse()) { // Show confirmation dialog before writing - var confirmResult = MessageBox.Query(Application.Instance, + var confirmResult = TerminalUi.Query( "Confirm Write", $"Write '{_valueField.Text}' to {nodeName}?", "Yes", "No"); @@ -156,7 +156,7 @@ public WriteValueDialog(NodeId nodeId, string nodeName, BuiltInType dataType, st if (confirmResult == 0) // Yes was selected { _confirmed = true; - Application.RequestStop(); + TerminalUi.RequestStop(); } } }; @@ -164,7 +164,7 @@ public WriteValueDialog(NodeId nodeId, string nodeName, BuiltInType dataType, st cancelButton.Accepting += (_, _) => { _confirmed = false; - Application.RequestStop(); + TerminalUi.RequestStop(); }; // Add all controls @@ -210,7 +210,7 @@ private bool ValidateAndParse() // Check if write is supported for this data type if (!OpcValueConverter.IsWriteSupported(_dataType)) { - MessageBox.ErrorQuery(Application.Instance, "Write Error", $"Write not supported for data type: {_dataType}", "OK"); + TerminalUi.ErrorQuery("Write Error", $"Write not supported for data type: {_dataType}", "OK"); return false; } diff --git a/App/FocusManager.cs b/App/FocusManager.cs index b434fd0..3507009 100644 --- a/App/FocusManager.cs +++ b/App/FocusManager.cs @@ -1,4 +1,5 @@ using Terminal.Gui; +using Opcilloscope.Utilities; namespace Opcilloscope.App; @@ -36,7 +37,7 @@ public FocusManager(params View[] panes) /// public void StartTracking() { - _pollTimer = Application.AddTimeout(TimeSpan.FromMilliseconds(100), PollFocus); + _pollTimer = TerminalUi.AddTimeout(TimeSpan.FromMilliseconds(100), PollFocus); } /// @@ -46,14 +47,14 @@ public void StopTracking() { if (_pollTimer != null) { - Application.RemoveTimeout(_pollTimer); + TerminalUi.RemoveTimeout(_pollTimer); _pollTimer = null; } } private bool PollFocus() { - var focused = Application.TopRunnableView?.MostFocused; + var focused = TerminalUi.TopRunnableView?.MostFocused; var newPane = FindContainingPane(focused); if (newPane != _currentPane) diff --git a/App/MainWindow.cs b/App/MainWindow.cs index ba618e6..ca3caab 100644 --- a/App/MainWindow.cs +++ b/App/MainWindow.cs @@ -185,7 +185,7 @@ public MainWindow() DefaultKeybindings.Configure(_keybindingManager, this); // Intercept letter/symbol keys at application level before views consume them - Application.KeyDown += OnApplicationKeyDown; + TerminalUi.AddKeyDownHandler(OnApplicationKeyDown); // Focus tracking using polling-based FocusManager (workaround for Terminal.Gui v2 Enter event instability) // Only track the two interactive panes (AddressSpace and MonitoredVariables) @@ -245,7 +245,7 @@ private void RunStatusBarStartup() }); UpdateConnectionStatusLabelPosition(); - _startupStatusTimer = Application.AddTimeout(TimeSpan.FromSeconds(1), () => + _startupStatusTimer = TerminalUi.AddTimeout(TimeSpan.FromSeconds(1), () => { step++; if (step == 1) @@ -404,7 +404,7 @@ private void ShowConnectDialog() currentInterval, currentCredentials.Type, currentCredentials.Username); - Application.Run(dialog); + TerminalUi.RunModal(dialog); if (dialog.Confirmed) { @@ -554,14 +554,14 @@ private void WriteToMonitoredVariable(MonitoredNode variable) if (!variable.IsWritable) { _logger.Warning($"Node '{variable.DisplayName}' is not writable"); - MessageBox.ErrorQuery(Application.Instance, "Write", $"Node '{variable.DisplayName}' is not writable.", "OK"); + TerminalUi.ErrorQuery("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(Application.Instance, "Write", $"Write not supported for data type: {variable.DataType}", "OK"); + TerminalUi.ErrorQuery("Write", $"Write not supported for data type: {variable.DataType}", "OK"); return; } @@ -610,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(Application.Instance, "Write", $"Node '{node.DisplayName}' is not writable.", "OK")); + UiThread.Run(() => TerminalUi.ErrorQuery("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(Application.Instance, "Write", $"Write not supported for data type: {builtInType}", "OK")); + UiThread.Run(() => TerminalUi.ErrorQuery("Write", $"Write not supported for data type: {builtInType}", "OK")); return; } @@ -627,7 +627,7 @@ private async Task WriteToAddressSpaceNodeAsync(BrowsedNode node) private void OpenWriteDialogAndWrite(Opc.Ua.NodeId nodeId, string displayName, Opc.Ua.BuiltInType dataType, string dataTypeName, string? currentValue) { using var dialog = new WriteValueDialog(nodeId, displayName, dataType, dataTypeName, currentValue); - Application.Run(dialog); + TerminalUi.RunModal(dialog); if (!dialog.Confirmed || dialog.ParsedValue == null) return; @@ -747,7 +747,7 @@ private void UpdateStatusBarShortcuts() private void OnApplicationKeyDown(object? sender, Key e) { if (e.Handled) return; - if (Application.TopRunnable != this) return; // Don't fire during dialogs + if (!TerminalUi.IsTopRunnable(this)) return; // Don't fire during dialogs if (IsViewNavigationKey(e)) return; // Let Enter/Space/etc reach local handlers @@ -835,7 +835,7 @@ private void OnConnectionError(string message) { UiThread.Run(() => { - MessageBox.ErrorQuery(Application.Instance, "Connection Error", message, "OK"); + TerminalUi.ErrorQuery("Connection Error", message, "OK"); }); } @@ -906,7 +906,7 @@ private void StartConnectingAnimation() { _isConnecting = true; _connectingDotCount = 1; - _connectingAnimationTimer = Application.AddTimeout(TimeSpan.FromMilliseconds(400), () => + _connectingAnimationTimer = TerminalUi.AddTimeout(TimeSpan.FromMilliseconds(400), () => { if (!_isConnecting) return false; // Stop animation @@ -925,7 +925,7 @@ private void StopConnectingAnimation() _isConnecting = false; if (_connectingAnimationTimer != null) { - Application.RemoveTimeout(_connectingAnimationTimer); + TerminalUi.RemoveTimeout(_connectingAnimationTimer); _connectingAnimationTimer = null; } } @@ -957,7 +957,7 @@ private void LaunchScope() { if (_connectionManager.SubscriptionManager == null) { - MessageBox.Query(Application.Instance, "Scope", "Connect to a server first.", "OK"); + TerminalUi.Query("Scope", "Connect to a server first.", "OK"); return; } @@ -965,12 +965,12 @@ private void LaunchScope() if (selectedNodes.Count == 0) { - MessageBox.Query(Application.Instance, "Scope", "Select up to 5 nodes to display in Scope.\nUse Space to toggle selection on monitored variables.", "OK"); + TerminalUi.Query("Scope", "Select up to 5 nodes to display in Scope.\nUse Space to toggle selection on monitored variables.", "OK"); return; } using var dialog = new ScopeDialog(selectedNodes, _connectionManager.SubscriptionManager); - Application.Run(dialog); + TerminalUi.RunModal(dialog); } private void OnRecordRequested() @@ -984,7 +984,7 @@ private void OnRecordRequested() var subscriptionManager = _connectionManager.SubscriptionManager; if (subscriptionManager == null || !subscriptionManager.MonitoredVariables.Any()) { - MessageBox.Query(Application.Instance, "Record", "No variables to record. Subscribe to variables first.", "OK"); + TerminalUi.Query("Record", "No variables to record. Subscribe to variables first.", "OK"); return; } @@ -992,7 +992,7 @@ private void OnRecordRequested() var selectedCount = _monitoredVariablesView.ScopeSelectionCount; if (selectedCount == 0) { - MessageBox.Query(Application.Instance, "Record", + TerminalUi.Query("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"); @@ -1006,7 +1006,7 @@ private void OnRecordRequested() selectedCount); using var dialog = new SaveRecordingDialog(defaultDir, defaultFilename); - Application.Run(dialog); + TerminalUi.RunModal(dialog); if (dialog.Confirmed && dialog.FilePath != null) { @@ -1017,7 +1017,7 @@ private void OnRecordRequested() } else { - MessageBox.ErrorQuery(Application.Instance, "Recording Error", "Failed to start recording", "OK"); + TerminalUi.ErrorQuery("Recording Error", "Failed to start recording", "OK"); } } } @@ -1032,13 +1032,13 @@ private void OnStopRecordingRequested() StopRecordingStatusUpdates(); _csvRecordingManager.StopRecording(); _monitoredVariablesView.UpdateRecordingStatus("", false); - MessageBox.Query(Application.Instance, "Recording", $"Recording saved.\n{_csvRecordingManager.RecordCount} records written.", "OK"); + TerminalUi.Query("Recording", $"Recording saved.\n{_csvRecordingManager.RecordCount} records written.", "OK"); } private void StartRecordingStatusUpdates() { - // Use Terminal.Gui's Application.AddTimeout for periodic updates - _recordingStatusTimer = Application.AddTimeout(TimeSpan.FromSeconds(1), () => + // Use the UI main-loop timer for periodic updates + _recordingStatusTimer = TerminalUi.AddTimeout(TimeSpan.FromSeconds(1), () => { if (_csvRecordingManager.IsRecording) { @@ -1054,7 +1054,7 @@ private void StopRecordingStatusUpdates() { if (_recordingStatusTimer != null) { - Application.RemoveTimeout(_recordingStatusTimer); + TerminalUi.RemoveTimeout(_recordingStatusTimer); _recordingStatusTimer = null; } } @@ -1062,7 +1062,7 @@ private void StopRecordingStatusUpdates() private void ShowHelp() { using var dialog = new HelpDialog(_keybindingManager); - Application.Run(dialog); + TerminalUi.RunModal(dialog); } private void ShowAbout() @@ -1093,7 +1093,7 @@ industrial automation data in real-time. © 2026 Square Wave Systems License: MIT "; - MessageBox.Query(Application.Instance, "About opcilloscope", about, "OK"); + TerminalUi.Query("About opcilloscope", about, "OK"); } /// @@ -1130,7 +1130,7 @@ private void OpenConfig() using var dialog = new Dialogs.OpenConfigDialog(); - Application.Run(dialog); + TerminalUi.RunModal(dialog); if (dialog.Confirmed && dialog.SelectedFilePath != null) { @@ -1165,7 +1165,7 @@ private void SaveConfigAs() using var dialog = new Dialogs.SaveConfigDialog(defaultDir, defaultFilename); - Application.Run(dialog); + TerminalUi.RunModal(dialog); if (dialog.Confirmed) { @@ -1197,7 +1197,7 @@ private async Task LoadConfigurationAsync(string filePath) using var pwDialog = new PasswordPromptDialog( config.Server.Authentication.Username, config.Server.EndpointUrl); - Application.Run(pwDialog); + TerminalUi.RunModal(pwDialog); if (!pwDialog.Confirmed) { @@ -1268,7 +1268,7 @@ private async Task LoadConfigurationAsync(string filePath) UpdateWindowTitle(); _logger.Error($"Failed to connect to {config.Server.EndpointUrl}"); - MessageBox.ErrorQuery(Application.Instance, "Connection Failed", + TerminalUi.ErrorQuery("Connection Failed", $"Could not connect to server:\n{config.Server.EndpointUrl}\n\nThe previous connection has been closed. Use Connect to reconnect.", "OK"); } @@ -1288,7 +1288,7 @@ private async Task LoadConfigurationAsync(string filePath) catch (Exception ex) { _logger.Error($"Failed to load configuration: {ex.Message}"); - MessageBox.ErrorQuery(Application.Instance, "Error", $"Failed to load configuration:\n{ex.Message}", "OK"); + TerminalUi.ErrorQuery("Error", $"Failed to load configuration:\n{ex.Message}", "OK"); } finally { @@ -1333,7 +1333,7 @@ private async Task SaveConfigurationAsync(string filePath) catch (Exception ex) { _logger.Error($"Failed to save configuration: {ex.Message}"); - MessageBox.ErrorQuery(Application.Instance, "Error", $"Failed to save:\n{ex.Message}", "OK"); + TerminalUi.ErrorQuery("Error", $"Failed to save:\n{ex.Message}", "OK"); } finally { @@ -1362,7 +1362,7 @@ private void UpdateWindowTitle() /// True if the user confirms, false to cancel the operation. private bool ConfirmDiscardChanges() { - var result = MessageBox.Query(Application.Instance, + var result = TerminalUi.Query( "Unsaved Changes", "You have unsaved changes. Do you want to discard them?", "Discard", @@ -1377,7 +1377,7 @@ private bool ConfirmDiscardChanges() /// Path to the configuration file. public void LoadConfigFromCommandLine(string configPath) { - Application.AddTimeout(TimeSpan.FromMilliseconds(100), () => + TerminalUi.AddTimeout(TimeSpan.FromMilliseconds(100), () => { LoadConfigurationAsync(configPath).FireAndForget(_logger); return false; @@ -1416,7 +1416,7 @@ protected override void Dispose(bool disposing) // Remove the startup status timer if it hasn't yet self-removed. if (_startupStatusTimer != null) { - Application.RemoveTimeout(_startupStatusTimer); + TerminalUi.RemoveTimeout(_startupStatusTimer); _startupStatusTimer = null; } @@ -1431,7 +1431,7 @@ protected override void Dispose(bool disposing) _focusManager.FocusChanged -= OnPanelFocusChanged; } - Application.KeyDown -= OnApplicationKeyDown; + TerminalUi.RemoveKeyDownHandler(OnApplicationKeyDown); _connectionManager.Dispose(); } diff --git a/App/Themes/ThemeManager.cs b/App/Themes/ThemeManager.cs index 2040b4b..abf5634 100644 --- a/App/Themes/ThemeManager.cs +++ b/App/Themes/ThemeManager.cs @@ -1,4 +1,5 @@ using Terminal.Gui; +using Opcilloscope.Utilities; namespace Opcilloscope.App.Themes; @@ -84,7 +85,7 @@ private static void ApplyTerminalColorMode(AppTheme theme) { // Terminal.Gui 2.4 removed the static Application.Force16Colors; // the flag now lives on the driver itself. - if (Application.Driver is { } driver) + if (TerminalUi.Driver is { } driver) { driver.Force16Colors = theme.UseTerminalColors; } diff --git a/App/Views/AddressSpaceView.cs b/App/Views/AddressSpaceView.cs index b81dc4d..db16124 100644 --- a/App/Views/AddressSpaceView.cs +++ b/App/Views/AddressSpaceView.cs @@ -1,5 +1,6 @@ using System.Text; using Terminal.Gui; +using Opcilloscope.Utilities; using Opcilloscope.OpcUa; using Opcilloscope.OpcUa.Models; using Opcilloscope.App.Themes; @@ -80,7 +81,7 @@ public AddressSpaceView() private void OnThemeChanged(AppTheme theme) { - Application.Invoke(() => + UiThread.Run(() => { _emptyStateLabel.SetScheme(new Scheme { @@ -120,7 +121,7 @@ private async Task RefreshAsync() } // Update UI on main thread - Application.Invoke(() => + UiThread.Run(() => { _treeView.ClearObjects(); _treeView.AddObject(_rootNode); @@ -162,7 +163,7 @@ private async Task LoadChildrenAsync(BrowsedNode node) await _nodeBrowser.GetChildrenAsync(node); // Refresh the tree on UI thread after children are loaded - Application.Invoke(() => + UiThread.Run(() => { _treeView.RefreshObject(node); if (node.ChildrenLoaded && node.Children.Count > 0) diff --git a/App/Views/LogView.cs b/App/Views/LogView.cs index 49b1905..2535067 100644 --- a/App/Views/LogView.cs +++ b/App/Views/LogView.cs @@ -89,7 +89,7 @@ private void OnRowRender(object? sender, ListViewRowEventArgs e) private void OnThemeChanged(AppTheme theme) { - Application.Invoke(() => + UiThread.Run(() => { BorderStyle = theme.FrameLineStyle; _copyButton.SetScheme(theme.ButtonColorScheme); @@ -146,7 +146,7 @@ private void OnCopyClicked(object? sender, CommandEventArgs e) return; var logText = string.Join(Environment.NewLine, _displayedEntries); - Clipboard.TrySetClipboardData(logText); + TerminalUi.TrySetClipboardData(logText); } protected override void Dispose(bool disposing) diff --git a/App/Views/MonitoredVariablesView.cs b/App/Views/MonitoredVariablesView.cs index 7c88114..a5bf77c 100644 --- a/App/Views/MonitoredVariablesView.cs +++ b/App/Views/MonitoredVariablesView.cs @@ -1,4 +1,5 @@ using Terminal.Gui; +using Opcilloscope.Utilities; using Opcilloscope.OpcUa.Models; using Opcilloscope.App.Themes; using System.Collections.Concurrent; @@ -221,7 +222,7 @@ private void UpdateEmptyState() private void OnThemeChanged(AppTheme theme) { - Application.Invoke(() => + UiThread.Run(() => { BorderStyle = theme.EmphasizedBorderStyle; @@ -300,7 +301,7 @@ private void EnsureUpdateTimerRunning() return; _updateTimerRunning = true; - _updateTimer = Application.AddTimeout(TimeSpan.FromMilliseconds(UpdateBatchIntervalMs), ProcessPendingUpdates); + _updateTimer = TerminalUi.AddTimeout(TimeSpan.FromMilliseconds(UpdateBatchIntervalMs), ProcessPendingUpdates); } } @@ -532,7 +533,7 @@ private void ToggleScopeSelectionForVariable(MonitoredNode variable) _ = Task.Run(async () => { await Task.Delay(2000); - Application.Invoke(() => _selectionFeedback.Visible = false); + UiThread.Run(() => _selectionFeedback.Visible = false); }); return; } @@ -589,7 +590,7 @@ protected override void Dispose(bool disposing) { if (_updateTimer != null) { - Application.RemoveTimeout(_updateTimer); + TerminalUi.RemoveTimeout(_updateTimer); _updateTimer = null; } _updateTimerRunning = false; diff --git a/App/Views/NodeDetailsView.cs b/App/Views/NodeDetailsView.cs index 7fc5aba..beca5a8 100644 --- a/App/Views/NodeDetailsView.cs +++ b/App/Views/NodeDetailsView.cs @@ -60,7 +60,7 @@ public NodeDetailsView() private void OnThemeChanged(AppTheme theme) { - Application.Invoke(() => + UiThread.Run(() => { // Update copy button styling _copyButton.SetScheme(theme.ButtonColorScheme); @@ -101,7 +101,7 @@ public async Task ShowNodeByIdAsync(NodeId? nodeId) if (nodeId == null || _nodeBrowser == null) { _currentNodeId = null; - Application.Invoke(() => + UiThread.Run(() => { _detailsLabel.Text = "Select a node to view details"; _copyButton.Enabled = false; @@ -113,7 +113,7 @@ public async Task ShowNodeByIdAsync(NodeId? nodeId) _currentNodeId = nodeId; var attrs = await _nodeBrowser.GetNodeAttributesAsync(nodeId); - Application.Invoke(() => + UiThread.Run(() => { // Guard against stale responses: rapid selection changes can complete // out of order, so only apply this result if it is still the current node. @@ -156,7 +156,7 @@ public async Task ShowNodeAsync(BrowsedNode? node) if (node == null || _nodeBrowser == null) { _currentNodeId = null; - Application.Invoke(() => + UiThread.Run(() => { _detailsLabel.Text = ""; _copyButton.Enabled = false; @@ -169,7 +169,7 @@ public async Task ShowNodeAsync(BrowsedNode? node) _currentNodeId = nodeId; var attrs = await _nodeBrowser.GetNodeAttributesAsync(nodeId); - Application.Invoke(() => + UiThread.Run(() => { // Guard against stale responses: rapid selection changes can complete // out of order, so only apply this result if it is still the current node. @@ -281,7 +281,7 @@ private async void OnCopyClicked(object? sender, CommandEventArgs e) if (cancellationToken.IsCancellationRequested) return; - Application.Invoke(() => + UiThread.Run(() => { if (attributes == null || attributes.Count == 0) { @@ -291,7 +291,7 @@ private async void OnCopyClicked(object? sender, CommandEventArgs e) } var formatted = NodeAttributeFormatter.Format(attributes); - var success = Clipboard.TrySetClipboardData(formatted); + var success = TerminalUi.TrySetClipboardData(formatted); if (success) { @@ -307,7 +307,7 @@ private async void OnCopyClicked(object? sender, CommandEventArgs e) catch (Exception ex) { _logger?.Error($"Error copying node attributes: {ex.Message}"); - Application.Invoke(() => ShowCopyResult("Err", originalText)); + UiThread.Run(() => ShowCopyResult("Err", originalText)); } } @@ -317,7 +317,7 @@ private async void OnCopyClicked(object? sender, CommandEventArgs e) private void ShowCopyResult(string result, string originalText) { _copyButton.Text = result; - Application.AddTimeout(TimeSpan.FromSeconds(1), () => + TerminalUi.AddTimeout(TimeSpan.FromSeconds(1), () => { _copyButton.Text = originalText; _copyButton.Enabled = _currentNodeId != null; diff --git a/App/Views/ScopeView.cs b/App/Views/ScopeView.cs index 69784d7..01eb2e4 100644 --- a/App/Views/ScopeView.cs +++ b/App/Views/ScopeView.cs @@ -133,7 +133,7 @@ private void OnThemeChanged(AppTheme newTheme) try { - Application.Invoke(() => + UiThread.Run(() => { ApplyTheme(); SetNeedsLayout(); @@ -278,14 +278,14 @@ private void StartUpdateTimer() if (_timerToken != null) return; // ~10 FPS update rate - _timerToken = Application.AddTimeout(TimeSpan.FromMilliseconds(100), OnTimerTick); + _timerToken = TerminalUi.AddTimeout(TimeSpan.FromMilliseconds(100), OnTimerTick); } private void StopUpdateTimer() { if (_timerToken != null) { - Application.RemoveTimeout(_timerToken); + TerminalUi.RemoveTimeout(_timerToken); _timerToken = null; } } diff --git a/CLAUDE.md b/CLAUDE.md index a9daada..f99adc6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -124,7 +124,8 @@ Opcilloscope/ │ ├── Utilities/ │ ├── Logger.cs # In-app logging service -│ ├── UiThread.cs # Thread marshalling for UI updates +│ ├── UiThread.cs # Thread marshalling for UI updates (via TerminalUi) +│ ├── TerminalUi.cs # Instance-based IApplication access (timers, dialogs, message boxes, clipboard) │ ├── CsvRecordingManager.cs # Background CSV recording of monitored values │ ├── OpcValueConverter.cs # OPC UA value type conversion utilities │ ├── TaskExtensions.cs # Async task helper extensions (FireAndForget) @@ -251,8 +252,28 @@ Record monitored variable values to CSV files: - Use `Height = n` instead of `Dim.Sized(n)` - Use `SetNeedsLayout()` or `Update()` instead of `SetNeedsDisplay()` - `ListView.SetSource()` requires `ObservableCollection` -- Use `Application.Invoke()` for thread marshalling (no MainLoop) -- Use `Application.AddTimeout()` for periodic updates + +#### Instance-based application model (do NOT use the static `Application`) +Terminal.Gui 2.4 deprecated the legacy static `Application` object (`Application.Invoke`, +`AddTimeout`, `Run`, `RequestStop`, `Instance`, `Driver`, `KeyDown`, `Init`/`Shutdown`, the +static `Clipboard`, etc.). The whole static surface is `[Obsolete]` and will be removed in a +future release, and `TreatWarningsAsErrors` is on — so a static-`Application` call is a build +error, not a warning. The app uses the instance-based model (`Application.Create()` → +`IApplication`) instead: +- `Program.Main` owns the lifecycle: `Application.Create()` → `app.Init()` → + `app.Run(mainWindow)` → `app.Dispose()` (Dispose replaces the obsolete `Shutdown`). It stores + the instance in `TerminalUi.App`. +- **All UI code routes through the helpers in `Utilities/`, never the static `Application`:** + - `UiThread.Run(...)` — marshal an action onto the UI thread (thread marshalling; no MainLoop) + - `TerminalUi.AddTimeout(...)` / `RemoveTimeout(...)` — periodic/one-shot main-loop timers + - `TerminalUi.RunModal(dialog)` / `RequestStop()` — open/close a modal dialog + - `TerminalUi.Query(...)` / `ErrorQuery(...)` — message boxes (no need to pass the app instance) + - `TerminalUi.TrySetClipboardData(...)` — OS clipboard + - `TerminalUi.Driver`, `TopRunnableView`, `IsTopRunnable(...)`, `Add`/`RemoveKeyDownHandler(...)` +- The direct `IApplication` uses (`Create`/`Init`/`Run`/`Dispose`, keyboard, driver) are confined + to `Program.cs`, `TerminalUi`, and `ThemeManager`. Add new helpers to `TerminalUi` rather than + reaching for the static API. In headless unit tests `TerminalUi.App` is null: fire-and-forget + helpers (Invoke, timers, clipboard) no-op and interactive ones (modal dialogs, message boxes) throw. ### OPC Foundation SDK API - Uses `Opc.Ua.Client.Session` for connection management @@ -414,13 +435,14 @@ Available test nodes: OPC Foundation callbacks arrive on background threads. All UI updates are marshalled to the UI thread: ```csharp -// Using UiThread helper +// Marshal onto the UI thread with the UiThread helper UiThread.Run(() => _monitoredVariablesView.UpdateVariable(variable)); - -// Using Application.Invoke directly -Application.Invoke(() => SetNeedsLayout()); +UiThread.Run(() => SetNeedsLayout()); ``` +Do not call the deprecated static `Application.Invoke()` directly — `UiThread.Run` wraps the +instance-based `IApplication.Invoke` (see the "Instance-based application model" note above). + ### Async Pattern with FireAndForget For async operations from synchronous event handlers: @@ -454,7 +476,7 @@ Automates release builds and publishing. 1. **`dotnet` command not found**: Install .NET SDK using the install script (see Environment Setup above) 2. **Tests fail with Xunit errors in main project**: Ensure `tests/**` is excluded in Opcilloscope.csproj -3. **UI thread exceptions**: Always use `Application.Invoke()` or `UiThread.Run()` for UI updates from background threads +3. **UI thread exceptions**: Always use `UiThread.Run()` for UI updates from background threads (it marshals via the instance-based `IApplication.Invoke`; do not call the deprecated static `Application.Invoke()`) 4. **Ambiguous NodeBrowser reference**: OPC Foundation has its own `Browser` class - use fully qualified names if needed 5. **Certificate validation errors**: Set `AutoAcceptUntrustedCertificates = true` in SecurityConfiguration for development 6. **Integration tests fail with "Unexpected error starting application"**: The OPC UA test server requires specific environment permissions - unit tests will still pass diff --git a/Opcilloscope.csproj b/Opcilloscope.csproj index b1a5ae3..961076a 100644 --- a/Opcilloscope.csproj +++ b/Opcilloscope.csproj @@ -9,6 +9,12 @@ opcilloscope true + + true + v Brett Kinny diff --git a/Program.cs b/Program.cs index acaab5b..b227451 100644 --- a/Program.cs +++ b/Program.cs @@ -1,6 +1,7 @@ using Terminal.Gui; using Opcilloscope.App; using Opcilloscope.OpcUa; +using Opcilloscope.Utilities; namespace Opcilloscope; @@ -8,7 +9,7 @@ class Program { static int Main(string[] args) { - bool initialized = false; + IApplication? app = null; try { // Parse command-line arguments before initializing the terminal, so that --help/-h @@ -66,10 +67,9 @@ static int Main(string[] args) return 1; } -#pragma warning disable IL2026 // Terminal.Gui Application.Init uses reflection and is not AOT-compatible - Application.Init(); -#pragma warning restore IL2026 - initialized = true; + app = Application.Create(); + TerminalUi.App = app; + app.Init(); var mainWindow = new MainWindow(); try @@ -87,7 +87,7 @@ static int Main(string[] args) "Please use a configuration file with an endpoint URL instead."); } - Application.Run(mainWindow); + app.Run(mainWindow); } finally { @@ -102,10 +102,10 @@ static int Main(string[] args) } finally { - if (initialized) - { - Application.Shutdown(); - } + // Disposing the application shuts down the terminal (the + // instance-based replacement for the legacy Application.Shutdown). + app?.Dispose(); + TerminalUi.App = null; } return 0; diff --git a/Tests/Opcilloscope.Tests/Utilities/TerminalUiTests.cs b/Tests/Opcilloscope.Tests/Utilities/TerminalUiTests.cs new file mode 100644 index 0000000..f55e64c --- /dev/null +++ b/Tests/Opcilloscope.Tests/Utilities/TerminalUiTests.cs @@ -0,0 +1,210 @@ +using Moq; +using Opcilloscope.Utilities; + +namespace Opcilloscope.Tests.Utilities; + +/// +/// Tests for the helper that centralizes access to the +/// instance-based Terminal.Gui . The design contract is: +/// fire-and-forget members (Invoke, timers, clipboard, top-level queries) degrade +/// to no-ops when no application is running (as in headless tests), while the +/// interactive members (modal dialogs, message boxes) throw rather than silently +/// skip, since a hidden no-op there would mask a real bug. +/// +/// is process-global mutable state, so these tests +/// share the non-parallel "Tui" collection with the other Terminal.Gui tests and +/// reset after each test. +/// +[Collection("Tui")] +public class TerminalUiTests : IDisposable +{ + public TerminalUiTests() + { + // Start from a known headless state (no application running). + TerminalUi.App = null; + } + + public void Dispose() + { + // Never leak a mock instance into sibling tests in the collection. + TerminalUi.App = null; + } + + // ── Fire-and-forget members: no-op when no application is running ── + + [Fact] + public void Invoke_WithNoApp_DoesNotThrow() + { + var ran = false; + // The action is queued onto the (non-existent) main loop, so it does not run, + // but the call itself must be a safe no-op. + var ex = Record.Exception(() => TerminalUi.Invoke(() => ran = true)); + + Assert.Null(ex); + Assert.False(ran); + } + + [Fact] + public void AddTimeout_WithNoApp_ReturnsNullAndDoesNotThrow() + { + object? token = null; + var ex = Record.Exception(() => + token = TerminalUi.AddTimeout(TimeSpan.FromMilliseconds(100), () => false)); + + Assert.Null(ex); + Assert.Null(token); + } + + [Fact] + public void RemoveTimeout_WithNoApp_DoesNotThrow() + { + var ex = Record.Exception(() => TerminalUi.RemoveTimeout(new object())); + + Assert.Null(ex); + } + + [Fact] + public void TrySetClipboardData_WithNoApp_ReturnsFalse() + { + Assert.False(TerminalUi.TrySetClipboardData("some text")); + } + + [Fact] + public void TopRunnableView_WithNoApp_IsNull() + { + Assert.Null(TerminalUi.TopRunnableView); + } + + [Fact] + public void IsTopRunnable_WithNoApp_IsFalse() + { + var runnable = new Mock().Object; + + Assert.False(TerminalUi.IsTopRunnable(runnable)); + } + + [Fact] + public void Driver_WithNoApp_IsNull() + { + Assert.Null(TerminalUi.Driver); + } + + [Fact] + public void KeyDownHandlers_WithNoApp_DoNotThrow() + { + EventHandler handler = (_, _) => { }; + + var ex = Record.Exception(() => + { + TerminalUi.AddKeyDownHandler(handler); + TerminalUi.RemoveKeyDownHandler(handler); + }); + + Assert.Null(ex); + } + + // ── Interactive members: throw when no application is running ── + + [Fact] + public void RunModal_WithNoApp_Throws() + { + var dialog = new Mock().Object; + + Assert.Throws(() => TerminalUi.RunModal(dialog)); + } + + [Fact] + public void RequestStop_WithNoApp_Throws() + { + Assert.Throws(() => TerminalUi.RequestStop()); + } + + [Fact] + public void Query_WithNoApp_Throws() + { + Assert.Throws(() => TerminalUi.Query("Title", "Message", "OK")); + } + + [Fact] + public void ErrorQuery_WithNoApp_Throws() + { + Assert.Throws(() => TerminalUi.ErrorQuery("Title", "Message", "OK")); + } + + // ── Delegation to the running application instance ── + + [Fact] + public void Invoke_WithApp_DelegatesToApplication() + { + var app = new Mock(); + TerminalUi.App = app.Object; + Action action = () => { }; + + TerminalUi.Invoke(action); + + app.Verify(a => a.Invoke(action), Times.Once); + } + + [Fact] + public void AddTimeout_WithApp_ReturnsApplicationToken() + { + var token = new object(); + var app = new Mock(); + app.Setup(a => a.AddTimeout(It.IsAny(), It.IsAny>())) + .Returns(token); + TerminalUi.App = app.Object; + + var result = TerminalUi.AddTimeout(TimeSpan.FromSeconds(1), () => false); + + Assert.Same(token, result); + } + + [Fact] + public void RemoveTimeout_WithApp_DelegatesToApplication() + { + var token = new object(); + var app = new Mock(); + TerminalUi.App = app.Object; + + TerminalUi.RemoveTimeout(token); + + app.Verify(a => a.RemoveTimeout(token), Times.Once); + } + + [Fact] + public void TrySetClipboardData_WithApp_DelegatesToClipboard() + { + var clipboard = new Mock(); + clipboard.Setup(c => c.TrySetClipboardData("payload")).Returns(true); + var app = new Mock(); + app.Setup(a => a.Clipboard).Returns(clipboard.Object); + TerminalUi.App = app.Object; + + Assert.True(TerminalUi.TrySetClipboardData("payload")); + clipboard.Verify(c => c.TrySetClipboardData("payload"), Times.Once); + } + + [Fact] + public void RequestStop_WithApp_DelegatesToApplication() + { + var app = new Mock(); + TerminalUi.App = app.Object; + + TerminalUi.RequestStop(); + + app.Verify(a => a.RequestStop(), Times.Once); + } + + [Fact] + public void IsTopRunnable_WithApp_ComparesAgainstTopRunnable() + { + var runnable = new Mock().Object; + var other = new Mock().Object; + var app = new Mock(); + app.Setup(a => a.TopRunnable).Returns(runnable); + TerminalUi.App = app.Object; + + Assert.True(TerminalUi.IsTopRunnable(runnable)); + Assert.False(TerminalUi.IsTopRunnable(other)); + } +} diff --git a/Tests/Opcilloscope.Tests/Utilities/UiThreadTests.cs b/Tests/Opcilloscope.Tests/Utilities/UiThreadTests.cs new file mode 100644 index 0000000..eb6e0e1 --- /dev/null +++ b/Tests/Opcilloscope.Tests/Utilities/UiThreadTests.cs @@ -0,0 +1,46 @@ +using Moq; +using Opcilloscope.Utilities; + +namespace Opcilloscope.Tests.Utilities; + +/// +/// Tests for the marshalling helper, which forwards to +/// . Like these touch +/// the process-global , so they share the non-parallel +/// "Tui" collection and reset it after each test. +/// +[Collection("Tui")] +public class UiThreadTests : IDisposable +{ + public UiThreadTests() + { + TerminalUi.App = null; + } + + public void Dispose() + { + TerminalUi.App = null; + } + + [Fact] + public void Run_WithNoApp_DoesNotThrow() + { + var ran = false; + var ex = Record.Exception(() => UiThread.Run(() => ran = true)); + + Assert.Null(ex); + Assert.False(ran); + } + + [Fact] + public void Run_WithApp_MarshalsThroughApplicationInvoke() + { + var app = new Mock(); + TerminalUi.App = app.Object; + Action action = () => { }; + + UiThread.Run(action); + + app.Verify(a => a.Invoke(action), Times.Once); + } +} diff --git a/Utilities/TerminalUi.cs b/Utilities/TerminalUi.cs new file mode 100644 index 0000000..780b421 --- /dev/null +++ b/Utilities/TerminalUi.cs @@ -0,0 +1,143 @@ +using Terminal.Gui; + +namespace Opcilloscope.Utilities; + +/// +/// Central access point for Terminal.Gui application services: main-loop timers, +/// modal dialogs, message boxes, clipboard, and top-level view queries. +/// All call sites route through here (and for thread +/// marshalling) so access to the Terminal.Gui application object is confined to +/// a handful of files rather than spread across the UI code. +/// +/// +/// Backed by the instance-based model +/// (Application.Create()); is assigned once at startup +/// by Program.Main. When no application is running (unit tests construct +/// views headlessly), the fire-and-forget members (, timers, +/// clipboard) degrade to no-ops, while the interactive members (modal dialogs, +/// message boxes) throw, since silently skipping them would hide real bugs. +/// +public static class TerminalUi +{ + /// + /// The running Terminal.Gui application instance. Set once by Program.Main + /// right after Application.Create(); null in headless unit tests. + /// + public static IApplication? App { get; set; } + + private static IApplication RequireApp() => + App ?? throw new InvalidOperationException("No Terminal.Gui application is running (TerminalUi.App is not set)."); + + /// + /// Executes an action on the UI thread via the application main loop. + /// No-op when no application is running. + /// + public static void Invoke(Action action) + { + App?.Invoke(action); + } + + /// + /// Adds a recurring timeout on the UI main loop. The callback runs on the UI + /// thread; returning true keeps the timer running, false stops it. + /// Returns a token for , or null when no + /// application is running. + /// + public static object? AddTimeout(TimeSpan interval, Func callback) + { + return App?.AddTimeout(interval, callback); + } + + /// + /// Removes a timeout previously added with . + /// + public static void RemoveTimeout(object token) + { + App?.RemoveTimeout(token); + } + + /// + /// Runs a view (dialog) modally, blocking until it requests stop. + /// + public static void RunModal(IRunnable view) + { + RequireApp().Run(view); + } + + /// + /// Requests that the currently running (top) view stop, closing a modal dialog. + /// + public static void RequestStop() + { + RequireApp().RequestStop(); + } + + /// + /// Shows an informational/confirmation message box. Returns the index of the + /// button pressed, or null if the message box was dismissed without a choice. + /// + public static int? Query(string title, string message, params string[] buttons) + { + return MessageBox.Query(RequireApp(), title, message, buttons); + } + + /// + /// Shows an error message box. Returns the index of the button pressed, + /// or null if the message box was dismissed without a choice. + /// + public static int? ErrorQuery(string title, string message, params string[] buttons) + { + return MessageBox.ErrorQuery(RequireApp(), title, message, buttons); + } + + /// + /// Copies text to the OS clipboard. Returns true on success. + /// + public static bool TrySetClipboardData(string text) + { + return App?.Clipboard?.TrySetClipboardData(text) ?? false; + } + + /// + /// Gets the view of the currently running (top) runnable, or null when none is running. + /// + public static View? TopRunnableView => App?.TopRunnableView; + + /// + /// Returns true when the given runnable is the currently running (top) one, + /// i.e. no dialog is running above it. + /// + public static bool IsTopRunnable(IRunnable runnable) + { + return App?.TopRunnable == runnable; + } + + /// + /// The driver of the running application, or null when none is running + /// (e.g. in headless tests). + /// + public static IDriver? Driver => App?.Driver; + + /// + /// Subscribes to application-level key-down events, which fire before any + /// view processes the key. No-op when no application is running. + /// + public static void AddKeyDownHandler(EventHandler handler) + { + if (App?.Keyboard is { } keyboard) + { + keyboard.KeyDown += handler; + } + } + + /// + /// Unsubscribes a handler added with . + /// + public static void RemoveKeyDownHandler(EventHandler handler) + { + if (App?.Keyboard is { } keyboard) + { + keyboard.KeyDown -= handler; + } + } +} diff --git a/Utilities/UiThread.cs b/Utilities/UiThread.cs index d01433e..8fddc5c 100644 --- a/Utilities/UiThread.cs +++ b/Utilities/UiThread.cs @@ -1,5 +1,3 @@ -using Terminal.Gui; - namespace Opcilloscope.Utilities; /// @@ -8,10 +6,11 @@ namespace Opcilloscope.Utilities; public static class UiThread { /// - /// Executes an action on the UI thread. + /// Executes an action on the UI thread. No-op when no Terminal.Gui + /// application is running (e.g. in headless tests). /// public static void Run(Action action) { - Application.Invoke(action); + TerminalUi.Invoke(action); } }