diff --git a/PostCodeSerialMonitor.Tests/AutoScrollTrackerTests.cs b/PostCodeSerialMonitor.Tests/AutoScrollTrackerTests.cs new file mode 100644 index 0000000..1545134 --- /dev/null +++ b/PostCodeSerialMonitor.Tests/AutoScrollTrackerTests.cs @@ -0,0 +1,49 @@ +using PostCodeSerialMonitor.Utils; +using Xunit; + +namespace PostCodeSerialMonitor.Tests; + +public class AutoScrollTrackerTests +{ + [Fact] + public void OnScrollChanged_ContentGrowsFasterThanScrollToEndCanCatchUp_StaysAutoScrolled() + { + var tracker = new AutoScrollTracker(); + + // Simulates a burst of fast-arriving log lines: ScrollToEnd() was called while the + // extent was still 104 (viewport 50), setting the offset to 54. Before that resulting + // ScrollChanged is processed, more lines already grew the extent further to 130. The + // offset only ever moved forward, so this is not the user scrolling away. + tracker.OnScrollChanged(offsetY: 54, extentHeight: 130, viewportHeight: 50); + + Assert.True(tracker.AutoScroll); + } + + [Fact] + public void OnScrollChanged_UserDragsScrollbarUp_Detaches() + { + var tracker = new AutoScrollTracker(); + tracker.OnScrollChanged(offsetY: 76, extentHeight: 130, viewportHeight: 50); // starts at bottom + + // The offset moves backward - only a manual scrollbar drag does that. + tracker.OnScrollChanged(offsetY: 20, extentHeight: 130, viewportHeight: 50); + + Assert.False(tracker.AutoScroll); + } + + [Fact] + public void OnScrollChanged_UserDragsAwayAfterSustainedAutoScrolling_StillDetaches() + { + var tracker = new AutoScrollTracker(); + + // Content keeps growing, offset keeps chasing it forward - auto-scroll stays engaged. + tracker.OnScrollChanged(offsetY: 50, extentHeight: 104, viewportHeight: 50); + tracker.OnScrollChanged(offsetY: 76, extentHeight: 130, viewportHeight: 50); + Assert.True(tracker.AutoScroll); + + // The user then grabs the scrollbar and drags it up. + tracker.OnScrollChanged(offsetY: 20, extentHeight: 130, viewportHeight: 50); + + Assert.False(tracker.AutoScroll); + } +} diff --git a/PostCodeSerialMonitor.Tests/SerialServiceTests.cs b/PostCodeSerialMonitor.Tests/SerialServiceTests.cs index e853e77..dcb0e0d 100644 --- a/PostCodeSerialMonitor.Tests/SerialServiceTests.cs +++ b/PostCodeSerialMonitor.Tests/SerialServiceTests.cs @@ -2,8 +2,10 @@ using System.IO; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using Moq; using PostCodeSerialMonitor.Models; +using PostCodeSerialMonitor.Services; using Xunit; namespace PostCodeSerialMonitor.Tests; @@ -16,8 +18,9 @@ public class SerialServiceTests public SerialServiceTests() { + var logger = new Mock>(); _mockSerialPort = new Mock(); - _serialService = new SerialService(); + _serialService = new SerialService(logger.Object); } [Fact] @@ -140,3 +143,48 @@ public async Task Connect_ShouldRetryOnFailedReset() } } */ + +public class SerialServiceDisconnectTests +{ + [Fact] + public async Task Disconnect_WhileReadLoopIsBlockedOnRead_FiresDisconnectedExactlyOnce() + { + // Arrange + var logger = new Mock>(); + var service = new SerialService(logger.Object); + + var readerBlocked = new ManualResetEventSlim(false); // ReadLoop has entered ReadChar() + var releaseReader = new ManualResetEventSlim(false); // Close() happened, ReadChar() may throw + + var mockPort = new Mock(); + mockPort.SetupGet(p => p.IsOpen).Returns(true); + mockPort.Setup(p => p.ReadChar()).Returns(() => + { + readerBlocked.Set(); + releaseReader.Wait(TimeSpan.FromSeconds(5)); + throw new IOException("Port closed while a read was pending"); + }); + mockPort.Setup(p => p.Close()).Callback(() => releaseReader.Set()); + + service._serialPort = mockPort.Object; + service._readCts = new CancellationTokenSource(); + + var disconnectedCount = 0; + service.Disconnected += () => Interlocked.Increment(ref disconnectedCount); + + var readLoopTask = Task.Run(() => service.ReadLoop(service._readCts.Token)); + + // Ensure ReadLoop is genuinely blocked inside ReadChar() before disconnecting, + // to reproduce "close happens while a read is pending" deterministically. + Assert.True(readerBlocked.Wait(TimeSpan.FromSeconds(2)), "ReadLoop never reached ReadChar()"); + + // Act: single call, same as MainWindowViewModel.ToggleConnectionAsync does + service.Disconnect(); + + // Wait for the background ReadLoop thread's catch block to finish too. + await readLoopTask; + + // Assert + Assert.Equal(1, disconnectedCount); + } +} diff --git a/PostCodeSerialMonitor/App.axaml b/PostCodeSerialMonitor/App.axaml index 89db5ab..c3829b7 100644 --- a/PostCodeSerialMonitor/App.axaml +++ b/PostCodeSerialMonitor/App.axaml @@ -9,6 +9,11 @@ M24 13.616v-3.183l-2.869-1.02c-.198-.687-.472-1.342-.811-1.955l1.308-2.751-2.257-2.258-2.752 1.307c-.613-.339-1.269-.613-1.955-.811l-1.021-2.869h-3.183l-1.021 2.869c-.686.198-1.342.472-1.955.811l-2.751-1.308-2.258 2.257 1.307 2.752c-.339.613-.614 1.268-.811 1.955l-2.869 1.02v3.183l2.869 1.02c.197.687.472 1.342.811 1.955l-1.308 2.752 2.257 2.258 2.752-1.308c.613.339 1.269.613 1.955.811l1.021 2.869h3.183l1.021-2.869c.687-.198 1.342-.472 1.955-.811l2.751 1.308 2.258-2.257-1.308-2.752c.339-.613.613-1.268.811-1.955l2.869-1.02zm-12 2.384c-2.209 0-4-1.791-4-4s1.791-4 4-4 4 1.791 4 4-1.791 4-4 4z M6.75 13.5H17.25C17.6642 13.5 18 13.8358 18 14.25C18 14.6297 17.7178 14.9435 17.3518 14.9932L17.25 15H6.75C6.33579 15 6 14.6642 6 14.25C6 13.8703 6.28215 13.5565 6.64823 13.5068L6.75 13.5Z M17.25 16.5H6.75L6.64823 16.5068C6.28215 16.5565 6 16.8703 6 17.25C6 17.6642 6.33579 18 6.75 18H17.25L17.3518 17.9932C17.7178 17.9435 18 17.6297 18 17.25C18 16.8358 17.6642 16.5 17.25 16.5Z M21 5.75C21 4.23122 19.7688 3 18.25 3H5.75C4.23122 3 3 4.23122 3 5.75V18.25C3 19.7688 4.23122 21 5.75 21H18.25C19.7688 21 21 19.7688 21 18.25V5.75ZM5.75 4.5H18.25C18.9404 4.5 19.5 5.05964 19.5 5.75V18.25C19.5 18.9404 18.9404 19.5 18.25 19.5H5.75C5.05964 19.5 4.5 18.9404 4.5 18.25V5.75C4.5 5.05964 5.05964 4.5 5.75 4.5Z + M7.74944331,5.18010908 C8.0006303,5.50946902 7.93725859,5.9800953 7.60789865,6.23128229 C5.81957892,7.59514774 4.75,9.70820889 4.75,12 C4.75,15.7359812 7.57583716,18.8119527 11.2066921,19.2070952 L10.5303301,18.5303301 C10.2374369,18.2374369 10.2374369,17.7625631 10.5303301,17.4696699 C10.7965966,17.2034034 11.2132603,17.1791973 11.5068718,17.3970518 L11.5909903,17.4696699 L13.5909903,19.4696699 C13.8572568,19.7359365 13.8814629,20.1526002 13.6636084,20.4462117 L13.5909903,20.5303301 L11.5909903,22.5303301 C11.298097,22.8232233 10.8232233,22.8232233 10.5303301,22.5303301 C10.2640635,22.2640635 10.2398575,21.8473998 10.4577119,21.5537883 L10.5303301,21.4696699 L11.280567,20.7208479 C6.78460951,20.3549586 3.25,16.5902554 3.25,12 C3.25,9.23526399 4.54178532,6.68321165 6.6982701,5.03856442 C7.02763004,4.78737743 7.49825632,4.85074914 7.74944331,5.18010908 Z M13.4696699,1.46966991 C13.7625631,1.76256313 13.7625631,2.23743687 13.4696699,2.53033009 L12.7204313,3.27923335 C17.2159137,3.64559867 20.75,7.4100843 20.75,12 C20.75,14.6444569 19.5687435,17.0974104 17.5691913,18.7491089 C17.2498402,19.0129038 16.7771069,18.9678666 16.513312,18.6485156 C16.2495171,18.3291645 16.2945543,17.8564312 16.6139054,17.5926363 C18.2720693,16.2229363 19.25,14.1922015 19.25,12 C19.25,8.26436254 16.4246828,5.18861329 12.7943099,4.7930139 L13.4696699,5.46966991 C13.7625631,5.76256313 13.7625631,6.23743687 13.4696699,6.53033009 C13.1767767,6.8232233 12.701903,6.8232233 12.4090097,6.53033009 L10.4090097,4.53033009 C10.1161165,4.23743687 10.1161165,3.76256313 10.4090097,3.46966991 L12.4090097,1.46966991 C12.701903,1.1767767 13.1767767,1.1767767 13.4696699,1.46966991 Z + M3 5.75C3 4.23122 4.23122 3 5.75 3H15.7145C16.5764 3 17.4031 3.34241 18.0126 3.9519L20.0481 5.98744C20.6576 6.59693 21 7.42358 21 8.28553V18.25C21 19.7688 19.7688 21 18.25 21H5.75C4.23122 21 3 19.7688 3 18.25V5.75ZM5.75 4.5C5.05964 4.5 4.5 5.05964 4.5 5.75V18.25C4.5 18.9404 5.05964 19.5 5.75 19.5H6V14.25C6 13.0074 7.00736 12 8.25 12H15.75C16.9926 12 18 13.0074 18 14.25V19.5H18.25C18.9404 19.5 19.5 18.9404 19.5 18.25V8.28553C19.5 7.8214 19.3156 7.37629 18.9874 7.0481L16.9519 5.01256C16.6918 4.75246 16.3582 4.58269 16 4.52344V7.25C16 8.49264 14.9926 9.5 13.75 9.5H9.25C8.00736 9.5 7 8.49264 7 7.25V4.5H5.75ZM16.5 19.5V14.25C16.5 13.8358 16.1642 13.5 15.75 13.5H8.25C7.83579 13.5 7.5 13.8358 7.5 14.25V19.5H16.5ZM8.5 4.5V7.25C8.5 7.66421 8.83579 8 9.25 8H13.75C14.1642 8 14.5 7.66421 14.5 7.25V4.5H8.5Z + M15.8698693,2.66881311 L20.838395,7.63733874 C21.7170746,8.5160184 21.7170746,9.9406396 20.838395,10.8193193 L12.1565953,19.4998034 L18.25448,19.5 C18.6341758,19.5 18.947971,19.7821539 18.9976334,20.1482294 L19.00448,20.25 C19.00448,20.6296958 18.7223262,20.943491 18.3562506,20.9931534 L18.25448,21 L9.84446231,21.0012505 C9.22825282,21.0348734 8.60085192,20.8163243 8.13013068,20.345603 L3.16160505,15.3770774 C2.28292539,14.4983977 2.28292539,13.0737765 3.16160505,12.1950969 L12.6878888,2.66881311 C13.5665685,1.79013346 14.9911897,1.79013346 15.8698693,2.66881311 Z M5.70859531,11.7678034 L4.22226522,13.255757 C3.929372,13.5486503 3.929372,14.023524 4.22226522,14.3164172 L9.19079085,19.2849428 C9.33723746,19.4313895 9.5291792,19.5046128 9.72112094,19.5046128 L9.75,19.5 L9.78849588,19.5015989 C9.95740385,19.4864544 10.1221581,19.4142357 10.251451,19.2849428 L11.7375953,17.7978034 L5.70859531,11.7678034 Z M13.748549,3.72947329 L6.76959531,10.7068034 L12.7985953,16.7368034 L19.7777348,9.75865909 C20.070628,9.46576587 20.070628,8.99089214 19.7777348,8.69799892 L14.8092091,3.72947329 C14.5163159,3.43658007 14.0414422,3.43658007 13.748549,3.72947329 Z + M13.7501344,8.41212026 L38.1671892,21.1169293 C39.7594652,21.9454306 40.3786269,23.9078584 39.5501255,25.5001344 C39.2420737,26.0921715 38.7592263,26.5750189 38.1671892,26.8830707 L13.7501344,39.5878797 C12.1578584,40.4163811 10.1954306,39.7972194 9.36692926,38.2049434 C9.12586301,37.7416442 9,37.2270724 9,36.704809 L9,11.295191 C9,9.50026556 10.4550746,8.045191 12.25,8.045191 C12.6976544,8.045191 13.1396577,8.13766178 13.5485655,8.31589049 L13.7501344,8.41212026 Z M12.5961849,10.629867 L12.4856981,10.5831892 C12.4099075,10.5581 12.3303482,10.545191 12.25,10.545191 C11.8357864,10.545191 11.5,10.8809774 11.5,11.295191 L11.5,36.704809 C11.5,36.8253313 11.5290453,36.9440787 11.584676,37.0509939 C11.7758686,37.4184422 12.2287365,37.5613256 12.5961849,37.370133 L37.0132397,24.665324 C37.1498636,24.5942351 37.2612899,24.4828088 37.3323788,24.3461849 C37.5235714,23.9787365 37.380688,23.5258686 37.0132397,23.334676 L12.5961849,10.629867 Z + M22.7399 6.32717C24.3781 8.48282 24.2132 11.571 22.2453 13.5389L20.3007 15.4835C20.0078 15.7764 19.533 15.7764 19.2401 15.4835L12.5226 8.76595C12.2297 8.47306 12.2297 7.99818 12.5226 7.70529L14.4671 5.76075C16.4352 3.79268 19.5237 3.62792 21.6793 5.26646L24.7238 2.22166C25.0167 1.92875 25.4916 1.92873 25.7845 2.22161C26.0774 2.51449 26.0774 2.98936 25.7845 3.28227L22.7399 6.32717ZM19.7704 13.8925L21.1846 12.4783C22.7467 10.9162 22.7467 8.3835 21.1846 6.82141C19.6225 5.25931 17.0899 5.25931 15.5278 6.82141L14.1135 8.23562L19.7704 13.8925Z M12.7778 11.215C13.0707 11.5079 13.0707 11.9828 12.7778 12.2757L10.6514 14.402L13.5982 17.3489L15.7238 15.2234C16.0167 14.9305 16.4916 14.9305 16.7844 15.2234C17.0773 15.5163 17.0773 15.9912 16.7844 16.284L14.6589 18.4095L15.4858 19.2364C15.7787 19.5293 15.7787 20.0042 15.4858 20.2971L13.5412 22.2416C11.5732 24.2096 8.48484 24.3745 6.32918 22.7361L3.28475 25.7808C2.99187 26.0737 2.517 26.0737 2.22409 25.7808C1.93118 25.488 1.93116 25.0131 2.22404 24.7202L5.26853 21.6754C3.63025 19.5197 3.79509 16.4314 5.76306 14.4635L7.7076 12.5189C8.0005 12.226 8.47537 12.226 8.76826 12.5189L9.59072 13.3414L11.7172 11.215C12.0101 10.9221 12.485 10.9221 12.7778 11.215ZM6.83028 21.1875C8.3929 22.7431 10.9207 22.7409 12.4806 21.181L13.8948 19.7668L8.23793 14.1099L6.82372 15.5241C5.26383 17.084 5.26163 19.6117 6.81709 21.1743L6.82366 21.1808L6.83028 21.1875Z diff --git a/PostCodeSerialMonitor/Assets/Resources.Designer.cs b/PostCodeSerialMonitor/Assets/Resources.Designer.cs index 03b363c..b6f92e6 100644 --- a/PostCodeSerialMonitor/Assets/Resources.Designer.cs +++ b/PostCodeSerialMonitor/Assets/Resources.Designer.cs @@ -547,7 +547,25 @@ public static string CheckFirmwareUpdates { return ResourceManager.GetString("CheckFirmwareUpdates", resourceCulture); } } - + + /// + /// In Views/ConfigurationDialog.axaml + /// + public static string LogSettings { + get { + return ResourceManager.GetString("LogSettings", resourceCulture); + } + } + + /// + /// In Views/ConfigurationDialog.axaml + /// + public static string ShowTimestamps { + get { + return ResourceManager.GetString("ShowTimestamps", resourceCulture); + } + } + /// /// In Views/MainWindow.axaml /// @@ -619,6 +637,15 @@ public static string SaveLog { return ResourceManager.GetString("SaveLog", resourceCulture); } } + + /// + /// In Views/MainWindow.axaml + /// + public static string ClearLog { + get { + return ResourceManager.GetString("ClearLog", resourceCulture); + } + } /// /// In Views/MainWindow.axaml @@ -628,15 +655,116 @@ public static string AppVersion { return ResourceManager.GetString("AppVersion", resourceCulture); } } - + /// - /// In Views/MainWindow.axaml + /// In Views/MainWindow.axaml, Views/DebugDialog.axaml + /// + public static string Debug { + get { + return ResourceManager.GetString("Debug", resourceCulture); + } + } + + /// + /// In Views/DebugDialog.axaml + /// + public static string FillDummyData { + get { + return ResourceManager.GetString("FillDummyData", resourceCulture); + } + } + + /// + /// In Views/DebugDialog.axaml + /// + public static string Fill { + get { + return ResourceManager.GetString("Fill", resourceCulture); + } + } + + /// + /// In Views/DebugDialog.axaml + /// + public static string DecodeStandaloneCode { + get { + return ResourceManager.GetString("DecodeStandaloneCode", resourceCulture); + } + } + + /// + /// In Views/DebugDialog.axaml + /// + public static string CodeFlavorLabel { + get { + return ResourceManager.GetString("CodeFlavorLabel", resourceCulture); + } + } + + /// + /// In Views/DebugDialog.axaml + /// + public static string ConsoleTypeLabel { + get { + return ResourceManager.GetString("ConsoleTypeLabel", resourceCulture); + } + } + + /// + /// In Views/DebugDialog.axaml + /// + public static string Decode { + get { + return ResourceManager.GetString("Decode", resourceCulture); + } + } + + /// + /// In Views/DebugDialog.axaml + /// + public static string Result { + get { + return ResourceManager.GetString("Result", resourceCulture); + } + } + + /// + /// In Views/DebugDialog.axaml + /// + public static string Close { + get { + return ResourceManager.GetString("Close", resourceCulture); + } + } + + /// + /// In ViewModels/DebugDialogViewModel.cs /// - public static string VisitXboxResearch { + public static string InvalidCodeFormatMessageBoxError { get { + return ResourceManager.GetString("InvalidCodeFormatMessageBoxError", resourceCulture); + } + } + + /// + /// In Views/MainWindow.axaml + /// + public static string VisitXboxResearch + { + get + { return ResourceManager.GetString("VisitXboxResearch", resourceCulture); } } + + /// + /// In Views/MainWindow.axaml + /// + public static string ConsiderDonation { + get { + return ResourceManager.GetString("ConsiderDonation", resourceCulture); + } + } /// /// In Views/MainWindow.axaml.cs diff --git a/PostCodeSerialMonitor/Assets/Resources.pt-BR.resx b/PostCodeSerialMonitor/Assets/Resources.pt-BR.resx index 9afb5d7..3ef5923 100644 --- a/PostCodeSerialMonitor/Assets/Resources.pt-BR.resx +++ b/PostCodeSerialMonitor/Assets/Resources.pt-BR.resx @@ -234,11 +234,11 @@ In ViewModels/MainWindowViewModel.cs - Uma nova versão do aplicativo está disponível em {0}. + Uma nova versão do aplicativo está disponível In ViewModels/MainWindowViewModel.cs - Uma nova versão do firmware está disponível em {0}. + Uma nova versão do firmware está disponível In ViewModels/MainWindowViewModel.cs @@ -333,6 +333,14 @@ Verifique atualizações de firmware In Views/ConfigurationDialog.axaml + + Configurações de log + In Views/ConfigurationDialog.axaml + + + Mostrar timestamps + In Views/ConfigurationDialog.axaml + URL de atualização do firmware In Views/ConfigurationDialog.axaml @@ -365,14 +373,62 @@ Salvar Log In Views/MainWindow.axaml + + Limpar Log + In Views/MainWindow.axaml + Versão do App: In Views/MainWindow.axaml + + Depuração + In Views/MainWindow.axaml, Views/DebugDialog.axaml + + + Preencher com dados fictícios + In Views/DebugDialog.axaml + + + Preencher + In Views/DebugDialog.axaml + + + Decodificar código avulso + In Views/DebugDialog.axaml + + + Tipo de código + In Views/DebugDialog.axaml + + + Modelo do console + In Views/DebugDialog.axaml + + + Decodificar + In Views/DebugDialog.axaml + + + Resultado + In Views/DebugDialog.axaml + + + Fechar + In Views/DebugDialog.axaml + + + Formato de código inválido: {0} + In ViewModels/DebugDialogViewModel.cs + Visite XboxResearch.com In Views/MainWindow.axaml + + Se quiser, considere fazer uma doação + In Views/MainWindow.axaml + Pico Firmware: In Views/MainWindow.axaml diff --git a/PostCodeSerialMonitor/Assets/Resources.resx b/PostCodeSerialMonitor/Assets/Resources.resx index ca22351..a08d40b 100644 --- a/PostCodeSerialMonitor/Assets/Resources.resx +++ b/PostCodeSerialMonitor/Assets/Resources.resx @@ -234,11 +234,11 @@ In ViewModels/MainWindowViewModel.cs - A new app release is available at {0}. + A new app release is available In ViewModels/MainWindowViewModel.cs - A new firmware release is available at {0}. + A new firmware release is available In ViewModels/MainWindowViewModel.cs @@ -333,6 +333,14 @@ Check for firmware updates In Views/ConfigurationDialog.axaml + + Log settings + In Views/ConfigurationDialog.axaml + + + Show timestamps + In Views/ConfigurationDialog.axaml + Firmware update URL In Views/ConfigurationDialog.axaml @@ -365,14 +373,62 @@ Save Log In Views/MainWindow.axaml + + Clear Log + In Views/MainWindow.axaml + App Version: In Views/MainWindow.axaml + + Debug + In Views/MainWindow.axaml, Views/DebugDialog.axaml + + + Fill view with dummy data + In Views/DebugDialog.axaml + + + Fill + In Views/DebugDialog.axaml + + + Decode standalone code + In Views/DebugDialog.axaml + + + Code flavor + In Views/DebugDialog.axaml + + + Console type + In Views/DebugDialog.axaml + + + Decode + In Views/DebugDialog.axaml + + + Result + In Views/DebugDialog.axaml + + + Close + In Views/DebugDialog.axaml + + + Invalid code format: {0} + In ViewModels/DebugDialogViewModel.cs + Visit XboxResearch.com In Views/MainWindow.axaml + + If you like, consider donating + In Views/MainWindow.axaml + Pico Firmware: In Views/MainWindow.axaml diff --git a/PostCodeSerialMonitor/Models/AppConfiguration.cs b/PostCodeSerialMonitor/Models/AppConfiguration.cs index 1986d01..b2aacfc 100644 --- a/PostCodeSerialMonitor/Models/AppConfiguration.cs +++ b/PostCodeSerialMonitor/Models/AppConfiguration.cs @@ -12,6 +12,7 @@ public class AppConfiguration public bool CheckForAppUpdates { get; set; } = true; public bool CheckForCodeUpdates { get; set; } = true; public bool CheckForFwUpdates { get; set; } = true; + public bool ShowTimestamps { get; set; } = false; [Required] [Url] diff --git a/PostCodeSerialMonitor/Models/LogEntry.cs b/PostCodeSerialMonitor/Models/LogEntry.cs index 9b971be..a77b325 100644 --- a/PostCodeSerialMonitor/Models/LogEntry.cs +++ b/PostCodeSerialMonitor/Models/LogEntry.cs @@ -6,7 +6,10 @@ public class LogEntry { public string RawText { get; set; } = string.Empty; public DateTime Timestamp { get; set; } = DateTime.Now; + public string TimestampText => Timestamp.ToString("HH:mm:ss.fff"); public required DecodedCode DecodedCode { get; set; } + + public string FormattedWithTs => $"{TimestampText} {FormattedText}"; // CodeText + Description public string FormattedText => FormatText(); // Flavor, index and code (hex) diff --git a/PostCodeSerialMonitor/Models/PortInfo.cs b/PostCodeSerialMonitor/Models/PortInfo.cs new file mode 100644 index 0000000..ba73491 --- /dev/null +++ b/PostCodeSerialMonitor/Models/PortInfo.cs @@ -0,0 +1,6 @@ +namespace PostCodeSerialMonitor.Models; + +public record PortInfo(string Name, string? Description) +{ + public override string ToString() => Name; +} diff --git a/PostCodeSerialMonitor/PostCodeSerialMonitor.csproj b/PostCodeSerialMonitor/PostCodeSerialMonitor.csproj index 47b46fe..e56e277 100644 --- a/PostCodeSerialMonitor/PostCodeSerialMonitor.csproj +++ b/PostCodeSerialMonitor/PostCodeSerialMonitor.csproj @@ -9,9 +9,9 @@ true false - 0.2.0 - 0.2.0.0 - 0.2.0.0 + 9.9.9 + 9.9.9.0 + 9.9.9.0 XboxResearch PostCode Serial Monitor Serial monitor for Xbox POST codes @@ -22,6 +22,10 @@ + + + + PublicResXFileCodeGenerator @@ -64,5 +68,6 @@ + diff --git a/PostCodeSerialMonitor/Services/SerialService.cs b/PostCodeSerialMonitor/Services/SerialService.cs index ea5742d..daa7d5a 100644 --- a/PostCodeSerialMonitor/Services/SerialService.cs +++ b/PostCodeSerialMonitor/Services/SerialService.cs @@ -1,16 +1,28 @@ using System; using System.Collections.Generic; +using System.Diagnostics; +using System.IO; using System.IO.Ports; +using System.Linq; +using System.Management; +using System.Runtime.Versioning; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; - +using PostCodeSerialMonitor.Models; namespace PostCodeSerialMonitor.Services; + +public static partial class SerialPortRegex { + [GeneratedRegex(@"\(COM\d+\)$", RegexOptions.IgnoreCase)] + public static partial Regex Windows(); +} + public class SerialService : IDisposable { private readonly ILogger _logger; - private ISerialPort? _serialPort; - private CancellationTokenSource? _readCts; + internal ISerialPort? _serialPort; + internal CancellationTokenSource? _readCts; public event Action? DataReceived; public event Action? Disconnected; public event Action? DeviceStateChanged; @@ -29,9 +41,89 @@ public SerialService(ILogger logger) _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } - public IEnumerable GetPortNames() + public static IEnumerable GetPortNames() + { + // Filtering for unique port names via hashset conversion + return SerialPort.GetPortNames().ToHashSet(); + } + + public IEnumerable GetPortInfos() + { + var descriptions = OperatingSystem.IsWindows() ? GetWindowsPortDescriptions() + : OperatingSystem.IsLinux() ? GetLinuxPortDescriptions() + : []; + + return GetPortNames().Select(name => new PortInfo(name, descriptions.GetValueOrDefault(name))); + } + + [SupportedOSPlatform("windows")] + private Dictionary GetWindowsPortDescriptions() { - return SerialPort.GetPortNames(); + var result = new Dictionary(); + try + { + using var searcher = new ManagementObjectSearcher( + "SELECT Name FROM Win32_PnPEntity WHERE Name LIKE '%(COM%'"); + foreach (ManagementBaseObject device in searcher.Get()) + { + if (device["Name"] is not string name) + continue; + + var match = SerialPortRegex.Windows().Match(name); + if (!match.Success) + continue; + + result[match.Value.Trim('(', ')')] = name[..match.Index].Trim(); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to query WMI for serial port descriptions"); + } + return result; + } + + [SupportedOSPlatform("linux")] + private Dictionary GetLinuxPortDescriptions() + { + var result = new Dictionary(); + try + { + foreach (var path in SerialPort.GetPortNames()) + { + // Two hops: /sys/class/tty/{port} is itself a symlink, so its "device" symlink's + // relative target must be resolved against the already-resolved class directory. + var classDir = new DirectoryInfo($"/sys/class/tty/{Path.GetFileName(path)}") + .ResolveLinkTarget(returnFinalTarget: true) as DirectoryInfo; + if (classDir == null) + continue; + + var dir = new DirectoryInfo(Path.Combine(classDir.FullName, "device")) + .ResolveLinkTarget(returnFinalTarget: true) as DirectoryInfo; + + for (var i = 0; i < 5 && dir != null; i++, dir = dir.Parent) + { + var manufacturer = ReadSysfsAttribute(dir, "manufacturer"); + var product = ReadSysfsAttribute(dir, "product"); + if (manufacturer == null && product == null) + continue; + + result[path] = string.Join(' ', new[] { manufacturer, product }.Where(s => !string.IsNullOrEmpty(s))); + break; + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to read sysfs for serial port descriptions"); + } + return result; + } + + private static string? ReadSysfsAttribute(DirectoryInfo dir, string name) + { + var path = Path.Combine(dir.FullName, name); + return File.Exists(path) ? File.ReadAllText(path).Trim() : null; } public bool IsOpen => _serialPort?.IsOpen ?? false; @@ -78,7 +170,8 @@ public async Task ConnectAsync(string portName, int baudRate = 115200) if (!success) { Disconnect(); - throw new Exception(Assets.Resources.FailedFwVersion);} + throw new Exception(Assets.Resources.FailedFwVersion); + } // Get config state _serialPort.WriteLine("config"); @@ -187,7 +280,7 @@ public void Disconnect() PrintColors = false; } - private void ReadLoop(CancellationToken token) + internal void ReadLoop(CancellationToken token) { try { @@ -205,7 +298,8 @@ private void ReadLoop(CancellationToken token) } catch (Exception) { - Disconnected?.Invoke(); + if (!token.IsCancellationRequested) + Disconnected?.Invoke(); } } diff --git a/PostCodeSerialMonitor/Utils/AutoScrollTracker.cs b/PostCodeSerialMonitor/Utils/AutoScrollTracker.cs new file mode 100644 index 0000000..7f53857 --- /dev/null +++ b/PostCodeSerialMonitor/Utils/AutoScrollTracker.cs @@ -0,0 +1,36 @@ +namespace PostCodeSerialMonitor.Utils; + +// Decides whether a log view should keep auto-scrolling to the bottom as new +// entries arrive, versus staying put because the user scrolled away. +public class AutoScrollTracker +{ + // Distance from the bottom (in pixels) still considered "at the bottom", to absorb layout jitter + // from item virtualization/resizing so autoscroll doesn't flicker on/off during normal updates. + private const double BottomThreshold = 4; + + private double _lastOffsetY; + + public bool AutoScroll { get; private set; } = true; + + public void OnScrollChanged(double offsetY, double extentHeight, double viewportHeight) + { + if (offsetY < _lastOffsetY) + { + // The offset moved backward. Our own auto-scroll (ScrollToEnd) never does that - + // it only ever moves forward, chasing a growing extent - so this can only be the + // user dragging the scrollbar up. + AutoScroll = false; + } + else if (offsetY >= extentHeight - viewportHeight - BottomThreshold) + { + AutoScroll = true; + } + + _lastOffsetY = offsetY; + } + + public void Reset() + { + AutoScroll = true; + } +} diff --git a/PostCodeSerialMonitor/Utils/GlobalActions.cs b/PostCodeSerialMonitor/Utils/GlobalActions.cs new file mode 100644 index 0000000..76db625 --- /dev/null +++ b/PostCodeSerialMonitor/Utils/GlobalActions.cs @@ -0,0 +1,21 @@ +using System; +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace PostCodeSerialMonitor.Utils; + +public static class GlobalActions +{ + + public static void OpenHyperlinkAction(string url) + { + using var proc = new Process + { + StartInfo = { + UseShellExecute = true, + FileName = url + } + }; + proc.Start(); + } +} \ No newline at end of file diff --git a/PostCodeSerialMonitor/ViewModels/ConfigurationDialogViewModel.cs b/PostCodeSerialMonitor/ViewModels/ConfigurationDialogViewModel.cs index f94b827..0a6a410 100644 --- a/PostCodeSerialMonitor/ViewModels/ConfigurationDialogViewModel.cs +++ b/PostCodeSerialMonitor/ViewModels/ConfigurationDialogViewModel.cs @@ -28,6 +28,9 @@ public partial class ConfigurationDialogViewModel : ViewModelBase [ObservableProperty] private bool checkForFwUpdates; + [ObservableProperty] + private bool showTimestamps; + [ObservableProperty] private string codesMetaBaseUrl; @@ -55,6 +58,7 @@ public ConfigurationDialogViewModel(ConfigurationService configurationService) CheckForAppUpdates = _originalConfiguration.CheckForAppUpdates; CheckForCodeUpdates = _originalConfiguration.CheckForCodeUpdates; CheckForFwUpdates = _originalConfiguration.CheckForFwUpdates; + ShowTimestamps = _originalConfiguration.ShowTimestamps; CodesMetaBaseUrl = _originalConfiguration.CodesMetaBaseUrl.ToString(); SelectedLanguage = _originalConfiguration.Language; @@ -72,6 +76,7 @@ await _configurationService.UpdateConfigurationAsync(config => config.CheckForAppUpdates = CheckForAppUpdates; config.CheckForCodeUpdates = CheckForCodeUpdates; config.CheckForFwUpdates = CheckForFwUpdates; + config.ShowTimestamps = ShowTimestamps; config.CodesMetaBaseUrl = new Uri(CodesMetaBaseUrl); config.Language = SelectedLanguage; }); @@ -82,7 +87,7 @@ await _configurationService.UpdateConfigurationAsync(config => await MessageBoxManager .GetMessageBoxStandard(Assets.Resources.RestartRequired, string.Format(Assets.Resources.LanguageChangedPleaseRestart), ButtonEnum.Ok) - .ShowAsync(); + .ShowAsPopupAsync(GetParentWindow()); } } diff --git a/PostCodeSerialMonitor/ViewModels/DebugDialogViewModel.cs b/PostCodeSerialMonitor/ViewModels/DebugDialogViewModel.cs new file mode 100644 index 0000000..47589cb --- /dev/null +++ b/PostCodeSerialMonitor/ViewModels/DebugDialogViewModel.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using MsBox.Avalonia; +using MsBox.Avalonia.Enums; +using PostCodeSerialMonitor.Models; +using PostCodeSerialMonitor.Services; + +namespace PostCodeSerialMonitor.ViewModels; + +public partial class DebugDialogViewModel : ViewModelBase +{ + private readonly ObservableCollection _rawLogEntries; + private readonly ObservableCollection _logEntries; + private readonly SerialLineDecoder _serialLineDecoder; + + public ObservableCollection ConsoleTypes { get; } + public ObservableCollection CodeFlavors { get; } = new(new[] + { + CodeFlavor.SMC, CodeFlavor.SP, CodeFlavor.CPU, CodeFlavor.OS + }); + + [ObservableProperty] + private ConsoleType selectedConsoleType; + + [ObservableProperty] + private decimal entryCount = 20; + + [ObservableProperty] + private string codeInput = string.Empty; + + [ObservableProperty] + private string decodedResultText = string.Empty; + + public DebugDialogViewModel( + ObservableCollection rawLogEntries, + ObservableCollection logEntries, + SerialLineDecoder serialLineDecoder, + ObservableCollection consoleTypes) + { + _rawLogEntries = rawLogEntries; + _logEntries = logEntries; + _serialLineDecoder = serialLineDecoder; + ConsoleTypes = consoleTypes; + + SelectedConsoleType = ConsoleTypes.FirstOrDefault(); + } + + [RelayCommand] + private void FillDummyData() + { + for (int i = 0; i < (int)EntryCount; i++) + { + var code = i * 0x11; + var segment = i % 4; + var flavor = CodeFlavors[i % CodeFlavors.Count]; + + var rawLine = $"{flavor.ToString().PadRight(4)} ({segment}) 0x{code:X4}\r\n"; + _rawLogEntries.Add(rawLine); + _logEntries.Add(new LogEntry + { + DecodedCode = new DecodedCode + { + Flavor = flavor, + Index = segment, + Code = code, + SeverityLevel = (CodeSeverity)(i % 3), + Name = $"DEBUG_CODE_{i}", + Description = i % 4 == 0 + ? $"This is a long debug description for entry {i}, used to verify that the log window wraps text correctly and fills the full width of the resized main window instead of being clipped at a fixed maximum width." + : $"Debug entry {i}" + } + }); + } + } + + [RelayCommand] + private async Task DecodeStandaloneAsync() + { + int code; + try + { + var hex = CodeInput.Trim(); + if (hex.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + hex = hex[2..]; + code = Convert.ToInt32(hex, 16) & 0xFFFF; + } + catch (Exception) + { + await MessageBoxManager + .GetMessageBoxStandard(Assets.Resources.Error, string.Format(Assets.Resources.InvalidCodeFormatMessageBoxError, CodeInput), ButtonEnum.Ok) + .ShowAsPopupAsync(GetParentWindow()); + return; + } + + DecodedResultText = String.Empty; + foreach (var codeFlavor in CodeFlavors) + { + var line = $"{codeFlavor} (0): 0x{code:X4}"; + var decoded = _serialLineDecoder.DecodeLine(line, SelectedConsoleType); + DecodedResultText += decoded != null + ? new LogEntry { DecodedCode = decoded }.FormattedText + : $"Decoding '{line}' failed"; + DecodedResultText += "\n"; + } + } + + [RelayCommand] + private void Close(Avalonia.Controls.Window window) + { + window.Close(); + } +} diff --git a/PostCodeSerialMonitor/ViewModels/MainWindowViewModel.cs b/PostCodeSerialMonitor/ViewModels/MainWindowViewModel.cs index 0228d03..a01d280 100644 --- a/PostCodeSerialMonitor/ViewModels/MainWindowViewModel.cs +++ b/PostCodeSerialMonitor/ViewModels/MainWindowViewModel.cs @@ -14,9 +14,12 @@ using PostCodeSerialMonitor.Views; using PostCodeSerialMonitor.Services; using PostCodeSerialMonitor.Models; +using PostCodeSerialMonitor.Utils; using MsBox.Avalonia; using MsBox.Avalonia.Enums; +using MsBox.Avalonia.Dto; +using Avalonia.Media; namespace PostCodeSerialMonitor.ViewModels; @@ -31,7 +34,7 @@ public partial class MainWindowViewModel : ViewModelBase private GithubUpdateService _githubUpdateService; private IStorageProvider? _storageProvider; - public ObservableCollection SerialPorts { get; } = new(); + public ObservableCollection SerialPorts { get; } = new(); public ObservableCollection ConsoleModels { get; } = new(); @@ -44,11 +47,24 @@ public partial class MainWindowViewModel : ViewModelBase private ConsoleType selectedConsoleModel; [ObservableProperty] - private string? selectedPort; + [NotifyPropertyChangedFor(nameof(CanToggleConnection))] + private PortInfo? selectedPort; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanToggleConnection))] + [NotifyPropertyChangedFor(nameof(ConnectionButtonText))] + [NotifyPropertyChangedFor(nameof(ConnectionButtonIcon))] private bool isConnected; + public bool CanToggleConnection => IsConnected || SelectedPort != null; + + public string ConnectionButtonText => IsConnected ? Assets.Resources.Disconnect : Assets.Resources.Connect; + public StreamGeometry? ConnectionButtonIcon => + Avalonia.Application.Current?.Resources.TryGetResource( + IsConnected ? "plug_disconnected_regular" : "play_regular", null, out var resource) == true + ? resource as StreamGeometry + : null; + [ObservableProperty] private int selectedTabIndex; @@ -61,6 +77,9 @@ public partial class MainWindowViewModel : ViewModelBase [ObservableProperty] private bool printTimestamps; + [ObservableProperty] + private bool showTimestamps; + [ObservableProperty] private string i2cScanOutput = Assets.Resources.ScanButtonText; @@ -76,6 +95,11 @@ public partial class MainWindowViewModel : ViewModelBase [ObservableProperty] private string appVersion; + [ObservableProperty] + private bool debugModeUnlocked; + + private int _appVersionClickCount; + public IStorageProvider? StorageProvider { get => _storageProvider; @@ -112,6 +136,7 @@ public MainWindowViewModel( } } SelectedConsoleModel = ConsoleModels.FirstOrDefault(); + ShowTimestamps = _configurationService.Config.ShowTimestamps; RefreshPorts(); _serialService.DataReceived += OnDataReceived; @@ -120,6 +145,25 @@ public MainWindowViewModel( _serialService.DeviceConfigChanged += OnDeviceConfigChanged; } + private MessageBoxStandardParams MsgBoxHyperlink(string title, string text, string link) + { + return new MessageBoxStandardParams + { + ContentTitle = title, + ContentMessage = text, + ButtonDefinitions = ButtonEnum.Ok, + Icon = Icon.None, + WindowStartupLocation = WindowStartupLocation.CenterScreen, + SizeToContent = SizeToContent.WidthAndHeight, + + HyperLinkParams = new HyperLinkParams + { + Text = link, + Action = new Action(() => GlobalActions.OpenHyperlinkAction(link)), + } + }; + } + // Executed by code behind view public async void OnLoaded() { @@ -133,7 +177,7 @@ public async void OnLoaded() ButtonEnum.YesNo ); - var result = await box.ShowAsync(); + var result = await box.ShowAsPopupAsync(GetParentWindow()); if (result.HasFlag(ButtonResult.Yes)) { @@ -146,7 +190,7 @@ public async void OnLoaded() _logger.LogError(ex, Assets.Resources.FailedUpdateMetadata); await MessageBoxManager .GetMessageBoxStandard(Assets.Resources.Error, string.Format(Assets.Resources.FailedUpdateMetadataMessageBoxError, ex.Message), ButtonEnum.Ok) - .ShowAsync(); + .ShowAsPopupAsync(GetParentWindow()); } } } @@ -162,7 +206,7 @@ await MessageBoxManager .GetMessageBoxStandard(Assets.Resources.Warning, Assets.Resources.FailedLoadLocalMetadataMessageBoxWarning, ButtonEnum.Ok); - await box.ShowAsync(); + await box.ShowAsPopupAsync(GetParentWindow()); } try @@ -175,7 +219,7 @@ await MessageBoxManager await MessageBoxManager .GetMessageBoxStandard(Assets.Resources.Error, string.Format(Assets.Resources.FailedLoadLocalMetadataMessageBoxError, ex.Message), ButtonEnum.Ok) - .ShowAsync(); + .ShowAsPopupAsync(GetParentWindow()); } if (_configurationService.Config.CheckForAppUpdates) @@ -184,14 +228,23 @@ await MessageBoxManager if (updateAvailable) { var box = MessageBoxManager - .GetMessageBoxStandard(Assets.Resources.Warning, - string.Format(Assets.Resources.NewAppReleaseAvailable, "https://github.com/xboxoneresearch/XboxPostcodeMonitor/releases"), ButtonEnum.Ok); - - await box.ShowAsync(); + .GetMessageBoxStandard(MsgBoxHyperlink( + Assets.Resources.Warning, + Assets.Resources.NewAppReleaseAvailable, + "https://github.com/xboxoneresearch/XboxPostcodeMonitor/releases" + )); + await box.ShowAsPopupAsync(GetParentWindow()); } } } + [RelayCommand] + private void ClearLog() + { + LogEntries.Clear(); + RawLogEntries.Clear(); + } + [RelayCommand] private async Task SaveLogAsync() { @@ -232,7 +285,7 @@ private async Task SaveLogAsync() sb.AppendLine("=== Raw Log ==="); foreach (var entry in RawLogEntries) { - sb.AppendLine(entry); + sb.AppendLine(entry?.Trim()); } sb.AppendLine(); @@ -240,7 +293,7 @@ private async Task SaveLogAsync() sb.AppendLine("=== Decoded Log ==="); foreach (var entry in LogEntries.Where(e => e.DecodedCode != null)) { - sb.AppendLine(entry.FormattedText); + sb.AppendLine(entry.FormattedWithTs); } try @@ -253,7 +306,7 @@ private async Task SaveLogAsync() await MessageBoxManager .GetMessageBoxStandard(Assets.Resources.Error, string.Format(Assets.Resources.ErrorSavingLogFileMessageBoxError, ex.Message), ButtonEnum.Ok) - .ShowAsync(); + .ShowAsPopupAsync(GetParentWindow()); } } @@ -261,55 +314,59 @@ await MessageBoxManager private void RefreshPorts() { SerialPorts.Clear(); - foreach (var port in _serialService.GetPortNames()) + foreach (var port in _serialService.GetPortInfos()) SerialPorts.Add(port); if (SerialPorts.Count > 0 && SelectedPort == null) SelectedPort = SerialPorts.FirstOrDefault(); } [RelayCommand] - private async Task ConnectAsync() + private async Task ToggleConnectionAsync() { - if (SelectedPort != null) + if (SelectedPort == null) + { + return; + } + + try { - try + if (IsConnected) { - await _serialService.ConnectAsync(SelectedPort); - RawLogEntries?.Clear(); - LogEntries?.Clear(); - IsConnected = true; + _serialService.Disconnect(); + IsConnected = false; } - catch (Exception ex) + else { - _logger.LogError(ex, Assets.Resources.ErrorConection); - await MessageBoxManager - .GetMessageBoxStandard(Assets.Resources.Error, string.Format(Assets.Resources.ErrorConectionMessageBoxError, ex.Message), - ButtonEnum.Ok) - .ShowAsync(); + await _serialService.ConnectAsync(SelectedPort.Name); + ClearLog(); + IsConnected = true; } + } + catch (Exception ex) + { + _logger.LogError(ex, Assets.Resources.ErrorConection); + await MessageBoxManager + .GetMessageBoxStandard(Assets.Resources.Error, string.Format(Assets.Resources.ErrorConectionMessageBoxError, ex.Message), + ButtonEnum.Ok) + .ShowAsPopupAsync(GetParentWindow()); + } - if (IsConnected && _configurationService.Config.CheckForFwUpdates) + if (IsConnected && _configurationService.Config.CheckForFwUpdates) + { + var updateAvailable = await _githubUpdateService.CheckForFirmwareUpdatesAsync(_serialService.FirmwareVersion); + if (updateAvailable) { - var updateAvailable = await _githubUpdateService.CheckForFirmwareUpdatesAsync(_serialService.FirmwareVersion); - if (updateAvailable) - { - var box = MessageBoxManager - .GetMessageBoxStandard(Assets.Resources.Warning, - string.Format(Assets.Resources.NewFirmwareReleaseAvailable, "https://github.com/xboxoneresearch/PicoDurangoPOST/releases"), ButtonEnum.Ok); - - await box.ShowAsync(); - } + var box = MessageBoxManager + .GetMessageBoxStandard(MsgBoxHyperlink( + Assets.Resources.Warning, + Assets.Resources.NewFirmwareReleaseAvailable, + "https://github.com/xboxoneresearch/PicoDurangoPOST/releases" + )); + await box.ShowAsPopupAsync(GetParentWindow()); } } } - [RelayCommand] - private void Disconnect() - { - _serialService.Disconnect(); - IsConnected = false; - } - private void OnDataReceived(string line) { RawLogEntries.Add(line); @@ -361,13 +418,29 @@ private async Task ShowConfigurationAsync() }; await dialog.ShowDialog(GetParentWindow()); + + ShowTimestamps = _configurationService.Config.ShowTimestamps; } - private Window GetParentWindow() + [RelayCommand] + private void AppVersionClicked() { - if (Avalonia.Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) - return desktop?.MainWindow ?? throw new Exception(Assets.Resources.FailedGetMainWindow); - else - throw new Exception(Assets.Resources.FailedGetApplicationLifetime); + if (DebugModeUnlocked) + return; + + _appVersionClickCount++; + if (_appVersionClickCount >= 5) + DebugModeUnlocked = true; + } + + [RelayCommand] + private async Task ShowDebugMenuAsync() + { + var dialog = new DebugDialog + { + DataContext = new DebugDialogViewModel(RawLogEntries, LogEntries, _serialLineDecoder, ConsoleModels) + }; + + await dialog.ShowDialog(GetParentWindow()); } } \ No newline at end of file diff --git a/PostCodeSerialMonitor/ViewModels/ViewModelBase.cs b/PostCodeSerialMonitor/ViewModels/ViewModelBase.cs index 47960c5..965d134 100644 --- a/PostCodeSerialMonitor/ViewModels/ViewModelBase.cs +++ b/PostCodeSerialMonitor/ViewModels/ViewModelBase.cs @@ -1,7 +1,17 @@ -using CommunityToolkit.Mvvm.ComponentModel; +using System; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using CommunityToolkit.Mvvm.ComponentModel; namespace PostCodeSerialMonitor.ViewModels; public class ViewModelBase : ObservableObject { + internal Window GetParentWindow() + { + if (Avalonia.Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + return desktop?.MainWindow ?? throw new Exception(Assets.Resources.FailedGetMainWindow); + else + throw new Exception(Assets.Resources.FailedGetApplicationLifetime); + } } diff --git a/PostCodeSerialMonitor/Views/ConfigurationDialog.axaml b/PostCodeSerialMonitor/Views/ConfigurationDialog.axaml index d8ea325..ed42c1c 100644 --- a/PostCodeSerialMonitor/Views/ConfigurationDialog.axaml +++ b/PostCodeSerialMonitor/Views/ConfigurationDialog.axaml @@ -31,9 +31,13 @@ Watermark="{x:Static assets:Resources.CodeMetaUrl}" IsEnabled="{Binding CheckForCodeUpdates}"/> - + + + diff --git a/PostCodeSerialMonitor/Views/DebugDialog.axaml b/PostCodeSerialMonitor/Views/DebugDialog.axaml new file mode 100644 index 0000000..092cfda --- /dev/null +++ b/PostCodeSerialMonitor/Views/DebugDialog.axaml @@ -0,0 +1,45 @@ + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + @@ -72,23 +115,42 @@ - + + diff --git a/PostCodeSerialMonitor/Views/MainWindow.axaml.cs b/PostCodeSerialMonitor/Views/MainWindow.axaml.cs index f542dbf..3e90608 100644 --- a/PostCodeSerialMonitor/Views/MainWindow.axaml.cs +++ b/PostCodeSerialMonitor/Views/MainWindow.axaml.cs @@ -3,12 +3,13 @@ using PostCodeSerialMonitor.ViewModels; using System.Diagnostics; using System; +using PostCodeSerialMonitor.Utils; namespace PostCodeSerialMonitor.Views; public partial class MainWindow : Window { - private bool _autoScroll = true; + private readonly AutoScrollTracker _autoScroll = new(); private ScrollViewer? _scrollViewer; private ItemsRepeater? _itemsRepeater; @@ -41,20 +42,16 @@ private void OnScrollChanged(object? sender, ScrollChangedEventArgs e) { if (_scrollViewer == null) return; - // If user scrolls up, disable autoscroll and show the button - if (e.OffsetDelta.Y < 0) + _autoScroll.OnScrollChanged(_scrollViewer.Offset.Y, _scrollViewer.Extent.Height, _scrollViewer.Viewport.Height); + if (AutoScrollButton != null) { - _autoScroll = false; - if (AutoScrollButton != null) - { - AutoScrollButton.IsVisible = true; - } + AutoScrollButton.IsVisible = !_autoScroll.AutoScroll; } } private void OnItemsRepeaterLayoutUpdated(object? sender, EventArgs e) { - if (_autoScroll && _scrollViewer != null) + if (_autoScroll.AutoScroll && _scrollViewer != null) { _scrollViewer.ScrollToEnd(); } @@ -62,7 +59,7 @@ private void OnItemsRepeaterLayoutUpdated(object? sender, EventArgs e) private void OnAutoScrollButtonClick(object? sender, RoutedEventArgs e) { - _autoScroll = true; + _autoScroll.Reset(); if (AutoScrollButton != null) { AutoScrollButton.IsVisible = false; @@ -73,17 +70,21 @@ private void OnAutoScrollButtonClick(object? sender, RoutedEventArgs e) } } + private void OnAppVersionPointerPressed(object? sender, Avalonia.Input.PointerPressedEventArgs e) + { + if (DataContext is MainWindowViewModel viewModel) + { + viewModel.AppVersionClickedCommand.Execute(null); + } + } + private void OnHyperlinkClick(object sender, RoutedEventArgs e) { if (sender is TextBlock textBlock && textBlock.Tag is string url) { try { - Process.Start(new ProcessStartInfo - { - FileName = url, - UseShellExecute = true - }); + GlobalActions.OpenHyperlinkAction(url); } catch (Exception ex) {