diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1a6953f..6880754 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,7 +8,9 @@ versions retain their original package contents.
- Bound each log-viewer refresh to 1 MiB of trailing input plus the encoding header,
including huge single lines. Preserve supported BOM encodings and show a notice
- when older input is omitted. Existing two-argument log-reader calls remain compatible.
+ when older input is omitted, including a persistent toolbar notice while following
+ new entries. WPF follows the newest entry on first open and subsequent refreshes.
+ Existing two-argument log-reader calls remain compatible.
- Add an `ILogger` adapter for activity logs with original message templates, typed
scalar fields, event IDs, and asynchronous scopes stored as JSON. Existing
`IActivityLog` methods and third-party implementations remain compatible. Foundation
diff --git a/src/KofTwentyTwo.AppKit.WinUI/LogWindow.cs b/src/KofTwentyTwo.AppKit.WinUI/LogWindow.cs
index 50fd616..30912c4 100644
--- a/src/KofTwentyTwo.AppKit.WinUI/LogWindow.cs
+++ b/src/KofTwentyTwo.AppKit.WinUI/LogWindow.cs
@@ -5,6 +5,7 @@
using KofTwentyTwo.AppKit.Logging;
using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Automation;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Controls.Primitives;
using Microsoft.UI.Xaml.Media;
@@ -26,6 +27,7 @@ public sealed partial class LogWindow : Window
private readonly IActivityLog _log;
private readonly DispatcherTimer _refresh = new() { Interval = s_refreshInterval };
private readonly TextBlock _pathText = new() { VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis, Opacity = 0.7 };
+ private readonly TextBlock _limitNotice = new() { Text = "Older log content omitted", VerticalAlignment = VerticalAlignment.Center, Visibility = Visibility.Collapsed };
private readonly TextBlock _logText = new() { FontFamily = new FontFamily("Cascadia Mono, Consolas"), FontSize = 12, IsTextSelectionEnabled = true, TextWrapping = TextWrapping.NoWrap };
private readonly ScrollViewer _scroll = new() { HorizontalScrollBarVisibility = ScrollBarVisibility.Auto, VerticalScrollBarVisibility = ScrollBarVisibility.Auto, Padding = new Thickness(12) };
private readonly ToggleButton _follow = new() { Content = "Follow", IsChecked = true };
@@ -42,6 +44,8 @@ public sealed partial class LogWindow : Window
ArgumentNullException.ThrowIfNull(app);
_log = log;
Title = app.DisplayName + " — Activity log";
+ AutomationProperties.SetAutomationId(_logText, "LogText");
+ AutomationProperties.SetAutomationId(_limitNotice, "LogTruncationNotice");
var copy = new Button { Content = "Copy" };
var openFolder = new Button { Content = "Open logs folder" };
@@ -60,6 +64,7 @@ public sealed partial class LogWindow : Window
toolbar.Children.Add(_errorsOnly);
toolbar.Children.Add(copy);
toolbar.Children.Add(openFolder);
+ toolbar.Children.Add(_limitNotice);
toolbar.Children.Add(_pathText);
_scroll.Content = _logText;
@@ -146,6 +151,7 @@ public sealed partial class LogWindow : Window
_pathText.Text = _log.CurrentLogFilePath; // the day can roll over while the window is open
LogSnapshot snapshot = LogTail.Read(_log.CurrentLogFilePath);
_lastLength = snapshot.Length;
+ _limitNotice.Visibility = snapshot.IsTruncated ? Visibility.Visible : Visibility.Collapsed;
bool errorsOnly = _errorsOnly.IsChecked == true;
_logText.Text = LogTail.ToDisplayText(errorsOnly ? LogTail.ErrorsOnly(snapshot.Lines) : snapshot.Lines, errorsOnly, snapshot.IsTruncated);
diff --git a/src/KofTwentyTwo.AppKit.Wpf/LogWindow.cs b/src/KofTwentyTwo.AppKit.Wpf/LogWindow.cs
index 8427178..9753e1c 100644
--- a/src/KofTwentyTwo.AppKit.Wpf/LogWindow.cs
+++ b/src/KofTwentyTwo.AppKit.Wpf/LogWindow.cs
@@ -6,6 +6,7 @@
using System.Diagnostics;
using System.IO;
using System.Windows;
+using System.Windows.Automation;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Media;
@@ -27,6 +28,7 @@ public sealed class LogWindow : Window
private readonly IActivityLog _log;
private readonly DispatcherTimer _refresh = new() { Interval = TimeSpan.FromSeconds(1) };
private readonly TextBlock _pathText = new() { VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis, Opacity = 0.7, Margin = new Thickness(8, 0, 0, 0) };
+ private readonly TextBlock _limitNotice = new() { Text = "Older log content omitted", VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(8, 0, 0, 0), Visibility = Visibility.Collapsed };
private readonly TextBox _logText = new() { FontFamily = new FontFamily("Cascadia Mono, Consolas"), FontSize = 12, IsReadOnly = true, BorderThickness = new Thickness(0), HorizontalScrollBarVisibility = ScrollBarVisibility.Auto, VerticalScrollBarVisibility = ScrollBarVisibility.Auto, Padding = new Thickness(12) };
private readonly ToggleButton _follow = new() { Content = "Follow", IsChecked = true, Padding = new Thickness(10, 4, 10, 4) };
private readonly ToggleButton _errorsOnly = new() { Content = "Errors only", Padding = new Thickness(10, 4, 10, 4), Margin = new Thickness(8, 0, 0, 0) };
@@ -42,6 +44,9 @@ public sealed class LogWindow : Window
ArgumentNullException.ThrowIfNull(app);
_log = log;
Title = app.DisplayName + " — Activity log";
+ AutomationProperties.SetAutomationId(_logText, "LogText");
+ AutomationProperties.SetAutomationId(_limitNotice, "LogTruncationNotice");
+ _logText.Loaded += (_, _) => _logText.ScrollToEnd();
Width = 1000;
Height = 680;
this.SetAppIcon(app);
@@ -66,6 +71,8 @@ public sealed class LogWindow : Window
buttons.Children.Add(openFolder);
DockPanel.SetDock(buttons, Dock.Left);
toolbar.Children.Add(buttons);
+ DockPanel.SetDock(_limitNotice, Dock.Right);
+ toolbar.Children.Add(_limitNotice);
toolbar.Children.Add(_pathText);
var root = new DockPanel();
@@ -134,8 +141,10 @@ public sealed class LogWindow : Window
_pathText.Text = _log.CurrentLogFilePath;
LogSnapshot snapshot = LogTail.Read(_log.CurrentLogFilePath);
_lastLength = snapshot.Length;
+ _limitNotice.Visibility = snapshot.IsTruncated ? Visibility.Visible : Visibility.Collapsed;
bool errorsOnly = _errorsOnly.IsChecked == true;
_logText.Text = LogTail.ToDisplayText(errorsOnly ? LogTail.ErrorsOnly(snapshot.Lines) : snapshot.Lines, errorsOnly, snapshot.IsTruncated);
+ _logText.UpdateLayout();
_logText.ScrollToEnd();
}
}
diff --git a/tests/KofTwentyTwo.AppKit.UiTests/AppSession.cs b/tests/KofTwentyTwo.AppKit.UiTests/AppSession.cs
index 8ff5d90..d3101eb 100644
--- a/tests/KofTwentyTwo.AppKit.UiTests/AppSession.cs
+++ b/tests/KofTwentyTwo.AppKit.UiTests/AppSession.cs
@@ -36,6 +36,7 @@ public sealed class AppSession : IDisposable
private static readonly TimeSpan s_defaultTimeout = TimeSpan.FromSeconds(10);
private static readonly TimeSpan s_launchTimeout = TimeSpan.FromSeconds(30);
private static readonly TimeSpan s_pollInterval = TimeSpan.FromMilliseconds(250);
+ private readonly DpiAwarenessScope _dpi;
@@ -52,9 +53,10 @@ public sealed class AppSession : IDisposable
var startInfo = new ProcessStartInfo(ExePath) { WorkingDirectory = Path.GetDirectoryName(ExePath) ?? "" };
startInfo.Environment["APPKIT_SAMPLE_DATA_DIR"] = DataDirectory;
- Automation = new UIA3Automation();
+ _dpi = new DpiAwarenessScope();
try
{
+ Automation = new UIA3Automation();
App = Application.Launch(startInfo);
MainWindow = WaitForMainWindow();
}
@@ -295,32 +297,39 @@ public sealed class AppSession : IDisposable
{
try
{
- App?.Close();
- }
- catch
- {
- // Already gone; the kill below double-checks.
- }
- try
- {
- if(App is { HasExited: false })
+ try
{
- App.Kill();
+ App?.Close();
+ }
+ catch
+ {
+ // Already gone; the kill below double-checks.
+ }
+ try
+ {
+ if(App is { HasExited: false })
+ {
+ App.Kill();
+ }
+ }
+ catch
+ {
+ // Best effort.
+ }
+ App?.Dispose();
+ Automation?.Dispose();
+ try
+ {
+ Directory.Delete(DataDirectory, recursive: true);
+ }
+ catch
+ {
+ // A log handle can linger briefly after exit; the temp cleaner gets it.
}
}
- catch
+ finally
{
- // Best effort.
- }
- App?.Dispose();
- Automation.Dispose();
- try
- {
- Directory.Delete(DataDirectory, recursive: true);
- }
- catch
- {
- // A log handle can linger briefly after exit; the temp cleaner gets it.
+ _dpi.Dispose();
}
}
}
diff --git a/tests/KofTwentyTwo.AppKit.UiTests/DpiAwarenessScope.cs b/tests/KofTwentyTwo.AppKit.UiTests/DpiAwarenessScope.cs
new file mode 100644
index 0000000..78b7e61
--- /dev/null
+++ b/tests/KofTwentyTwo.AppKit.UiTests/DpiAwarenessScope.cs
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2026 James Maes (KofTwentyTwo)
+ * SPDX-License-Identifier: MIT
+ */
+
+using System.ComponentModel;
+using System.Runtime.InteropServices;
+
+
+namespace KofTwentyTwo.AppKit.UiTests;
+
+/// Uses physical screen coordinates for UI input and capture, restoring the synchronous test thread afterwards.
+internal sealed class DpiAwarenessScope : IDisposable
+{
+ private readonly nint _previous;
+
+
+
+ /// Disables DPI virtualization on this thread with per-monitor awareness v2 (Windows 10 1703+).
+ public DpiAwarenessScope()
+ {
+ _previous = SetThreadDpiAwarenessContext(-4);
+ if(_previous == 0)
+ {
+ throw new Win32Exception(Marshal.GetLastPInvokeError());
+ }
+ }
+
+
+
+ /// Restores the context on the same thread that ran the synchronous UI test.
+ public void Dispose() => SetThreadDpiAwarenessContext(_previous);
+
+
+
+ /// Windows' thread-scoped DPI override, supported by the repository's minimum OS.
+ [DllImport("user32.dll", ExactSpelling = true, SetLastError = true)]
+ private static extern nint SetThreadDpiAwarenessContext(nint dpiContext);
+}
diff --git a/tests/KofTwentyTwo.AppKit.UiTests/SampleAppTests.cs b/tests/KofTwentyTwo.AppKit.UiTests/SampleAppTests.cs
index c3d5d13..e5c0de3 100644
--- a/tests/KofTwentyTwo.AppKit.UiTests/SampleAppTests.cs
+++ b/tests/KofTwentyTwo.AppKit.UiTests/SampleAppTests.cs
@@ -3,7 +3,9 @@
* SPDX-License-Identifier: MIT
*/
+using System.Drawing;
using FlaUI.Core.AutomationElements;
+using FlaUI.Core.Capturing;
namespace KofTwentyTwo.AppKit.UiTests;
@@ -76,6 +78,52 @@ public class SampleAppTests
+ /// Huge logs retain the newest entries and a visible notice even while Follow scrolls to the end.
+ [Theory]
+ [InlineData(SampleKind.WinUI)]
+ [InlineData(SampleKind.Wpf)]
+ public void ActivityLog_HugeFile_ShowsLatestEntryAndPersistentLimitNotice(SampleKind kind)
+ {
+ using var session = new AppSession(kind);
+ string path = Directory.GetFiles(Path.Combine(session.DataDirectory, "logs"), "*.log").Single();
+ using(var writer = new StreamWriter(path, append: true))
+ {
+ string padding = new('x', 80);
+ for(int index = 0; index < 30_000; index++)
+ {
+ writer.WriteLine("2026-10-10 12:00:00.000 [INFO] Older entry " + padding);
+ }
+ writer.WriteLine("2026-10-10 12:00:01.000 [INFO] Latest entry marker");
+ }
+ session.InvokeMenuItem("Help", "ActivityLogMenuItem");
+ AutomationElement text = session.WaitForElement("LogText");
+ string contents = kind == SampleKind.WinUI ? text.Name : text.AsTextBox().Text;
+ Assert.Contains("Latest entry marker", contents, StringComparison.Ordinal);
+ Assert.StartsWith("Showing a bounded log tail;", contents, StringComparison.Ordinal);
+ Assert.True(contents.Length < 1024 * 1024);
+ session.WaitFor(
+ () => text.Patterns.Text.Pattern.GetVisibleRanges().Any(range => range.GetText(-1).Contains("Latest entry marker", StringComparison.Ordinal)) ? text : null,
+ "the newest log entry to be visible with Follow enabled");
+ AutomationElement notice = session.WaitForElement("LogTruncationNotice");
+ Assert.Equal("Older log content omitted", notice.Name);
+ Assert.False(notice.IsOffscreen);
+ Assert.False(session.App.HasExited);
+
+ // Optional local review evidence; images are restricted to the test app's window.
+ string? screenshots = Environment.GetEnvironmentVariable("APPKIT_UITEST_SCREENSHOT_DIR");
+ if(!string.IsNullOrWhiteSpace(screenshots))
+ {
+ Directory.CreateDirectory(screenshots);
+ AutomationElement window = session.TopLevelWindows().Single(w => string.Equals(w.Name, "AppKit Sample — Activity log", StringComparison.Ordinal));
+ window.AsWindow().SetForeground();
+ Rectangle bounds = window.BoundingRectangle;
+ using CaptureImage capture = Capture.ElementRectangle(window, new System.Drawing.Rectangle(12, 12, bounds.Width - 24, bounds.Height - 24));
+ capture.ToFile(Path.Combine(screenshots, $"LogTail-{kind}.png"));
+ }
+ }
+
+
+
/// ThemeChoice: is saved.
[Theory]
[InlineData(SampleKind.WinUI)]