diff --git a/CHANGELOG.md b/CHANGELOG.md index c156b77..701d092 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to Reentry are documented here. The format follows ## [Unreleased] +- HUD: call `AppWindow.SetIcon` on the main, settings, and consent windows (the caption does not pick up `ApplicationIcon`). Replace the 195-byte PNG-in-ICO with a 16/32/48 BMP glyph derived from the existing teal mark. +- HUD: update restore/startup rows in place — `Sync` no longer `Clear()`s bound collections on the 1 Hz tick (that emptied both lists and reset subsection scroll). Footer elapsed still ticks every second. +- HUD: session progress bar plus “N / M settled”, compact single-line rows, and colored status chips (Interactive green, Pending/Starting amber, Failed purple, Hung orange, Disabled gray). Per-row clocks that duplicated the footer are gone. + - Launch on Windows 11 25H2: use the installed WASDK 2.4 runtime instead of the self-contained CoreMessagingXP payload (0xC0000602). Give the tray icon a generated glyph so ForceCreate has an IconSource. ## [0.1.0-alpha1] - 2026-08-20 diff --git a/src/Reentry.App/Assets/reentry.ico b/src/Reentry.App/Assets/reentry.ico index c183bd5..85cce9d 100644 Binary files a/src/Reentry.App/Assets/reentry.ico and b/src/Reentry.App/Assets/reentry.ico differ diff --git a/src/Reentry.App/ConsentWindow.xaml.cs b/src/Reentry.App/ConsentWindow.xaml.cs index fd8397b..d23c4fa 100644 --- a/src/Reentry.App/ConsentWindow.xaml.cs +++ b/src/Reentry.App/ConsentWindow.xaml.cs @@ -11,6 +11,7 @@ public ConsentWindow() InitializeComponent(); Title = "Reentry"; SystemBackdrop = new Microsoft.UI.Xaml.Media.MicaBackdrop(); + WindowIcon.Apply(this); Closed += (_, _) => _tcs.TrySetResult(false); } diff --git a/src/Reentry.App/MainWindow.xaml b/src/Reentry.App/MainWindow.xaml index 6d75ad1..6a14112 100644 --- a/src/Reentry.App/MainWindow.xaml +++ b/src/Reentry.App/MainWindow.xaml @@ -5,8 +5,17 @@ xmlns:vm="using:Reentry.App.ViewModels" Title="Reentry"> - + + + + + @@ -21,60 +30,121 @@ - - + + + + + + + + + + + + + + - + - - + - - - - - - - - + + + + + + + + - - + + + + + - + - - + - - - - - - - - - - + + + + + + + + - + diff --git a/src/Reentry.App/MainWindow.xaml.cs b/src/Reentry.App/MainWindow.xaml.cs index a3095d2..2bae769 100644 --- a/src/Reentry.App/MainWindow.xaml.cs +++ b/src/Reentry.App/MainWindow.xaml.cs @@ -1,4 +1,3 @@ -using Microsoft.UI; using Microsoft.UI.Windowing; using Microsoft.UI.Xaml; using Reentry.App.ViewModels; @@ -15,9 +14,7 @@ public MainWindow(HudViewModel viewModel) Title = "Reentry"; SystemBackdrop = new Microsoft.UI.Xaml.Media.MicaBackdrop(); - var hwnd = WindowNative.GetWindowHandle(this); - var id = Win32Interop.GetWindowIdFromWindow(hwnd); - var appWindow = AppWindow.GetFromWindowId(id); + var appWindow = WindowIcon.Apply(this); appWindow.Resize(new Windows.Graphics.SizeInt32(540, 760)); if (appWindow.Presenter is OverlappedPresenter presenter) { diff --git a/src/Reentry.App/SettingsWindow.xaml.cs b/src/Reentry.App/SettingsWindow.xaml.cs index 3c4fa3e..f44ac64 100644 --- a/src/Reentry.App/SettingsWindow.xaml.cs +++ b/src/Reentry.App/SettingsWindow.xaml.cs @@ -12,6 +12,7 @@ public SettingsWindow(SettingsViewModel viewModel) InitializeComponent(); Title = "Reentry settings"; SystemBackdrop = new Microsoft.UI.Xaml.Media.MicaBackdrop(); + WindowIcon.Apply(this); } public SettingsViewModel ViewModel { get; } diff --git a/src/Reentry.App/ViewModels/HudViewModel.cs b/src/Reentry.App/ViewModels/HudViewModel.cs index 19a6ed3..ece7393 100644 --- a/src/Reentry.App/ViewModels/HudViewModel.cs +++ b/src/Reentry.App/ViewModels/HudViewModel.cs @@ -1,5 +1,6 @@ using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; +using Reentry.Core; using Reentry.Core.Models; namespace Reentry.App.ViewModels; @@ -10,6 +11,10 @@ public sealed partial class HudViewModel : ObservableObject [ObservableProperty] private string _bootDetail = ""; [ObservableProperty] private string _footerElapsed = "00:00"; [ObservableProperty] private DateTimeOffset _sessionStartedUtc = DateTimeOffset.UtcNow; + [ObservableProperty] private int _settledCount; + [ObservableProperty] private int _totalCount; + [ObservableProperty] private double _settledFraction; + [ObservableProperty] private string _settledSummary = "0 / 0 settled"; public HudViewModel(BootKind bootKind) { @@ -30,25 +35,23 @@ public void ReplaceRows(IReadOnlyList apps) var startup = apps.Where(a => a.Source is not AppSource.Arr and not AppSource.Explorer).ToList(); Sync(RestoreRows, restore); Sync(StartupRows, startup); + + TotalCount = apps.Count; + SettledCount = apps.Count(a => a.State.IsSettled()); + SettledFraction = TotalCount == 0 ? 0 : (double)SettledCount / TotalCount; + SettledSummary = $"{SettledCount} / {TotalCount} settled"; FooterElapsed = Format(DateTimeOffset.UtcNow - SessionStartedUtc); } private static void Sync(ObservableCollection target, List source) { - var byId = target.ToDictionary(r => r.Id); - target.Clear(); - foreach (var app in source) - { - if (byId.TryGetValue(app.Id, out var existing)) - { - existing.Apply(app); - target.Add(existing); - } - else - { - target.Add(TrackedAppRow.From(app)); - } - } + CollectionSync.InPlace( + target, + source, + itemKey: r => r.Id, + sourceKey: a => a.Id, + apply: (row, app) => row.Apply(app), + create: TrackedAppRow.From); } private static string Format(TimeSpan elapsed) diff --git a/src/Reentry.App/ViewModels/TrackedAppRow.cs b/src/Reentry.App/ViewModels/TrackedAppRow.cs index 2c244f9..d553a36 100644 --- a/src/Reentry.App/ViewModels/TrackedAppRow.cs +++ b/src/Reentry.App/ViewModels/TrackedAppRow.cs @@ -1,17 +1,29 @@ using CommunityToolkit.Mvvm.ComponentModel; using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Media; using Reentry.Core.Models; +using Windows.UI; namespace Reentry.App.ViewModels; public sealed partial class TrackedAppRow : ObservableObject { + private static readonly SolidColorBrush InteractiveBrush = Brush(16, 124, 16); + private static readonly SolidColorBrush PendingBrush = Brush(201, 156, 0); + private static readonly SolidColorBrush FailedBrush = Brush(155, 27, 90); + private static readonly SolidColorBrush HungBrush = Brush(230, 126, 34); + private static readonly SolidColorBrush DisabledBrush = Brush(107, 107, 107); + private static readonly SolidColorBrush ChipOnDark = Brush(255, 255, 255); + private static readonly SolidColorBrush ChipOnAmber = Brush(26, 18, 0); + [ObservableProperty] private string _name = ""; [ObservableProperty] private string _source = ""; [ObservableProperty] private string _state = ""; [ObservableProperty] private string _elapsed = ""; [ObservableProperty] private bool _isManaged; [ObservableProperty] private string _stateGlyph = "●"; + [ObservableProperty] private Brush _stateBrush = DisabledBrush; + [ObservableProperty] private Brush _chipForeground = ChipOnDark; public string Id { get; init; } = ""; public Visibility ManagedVisibility => IsManaged ? Visibility.Visible : Visibility.Collapsed; @@ -41,8 +53,19 @@ public void Apply(TrackedApp app) AppState.Disabled => "–", _ => "●", }; + (StateBrush, ChipForeground) = app.State switch + { + AppState.Interactive => ((Brush)InteractiveBrush, ChipOnDark), + AppState.Starting or AppState.Pending => (PendingBrush, ChipOnAmber), + AppState.Failed => (FailedBrush, ChipOnDark), + AppState.Hung => (HungBrush, ChipOnDark), + _ => (DisabledBrush, ChipOnDark), + }; } + private static SolidColorBrush Brush(byte r, byte g, byte b) => + new(Color.FromArgb(255, r, g, b)); + private static string FormatElapsed(TimeSpan elapsed) { if (elapsed.TotalHours >= 1) diff --git a/src/Reentry.App/WindowIcon.cs b/src/Reentry.App/WindowIcon.cs new file mode 100644 index 0000000..0dad436 --- /dev/null +++ b/src/Reentry.App/WindowIcon.cs @@ -0,0 +1,51 @@ +using Microsoft.UI; +using Microsoft.UI.Windowing; +using Microsoft.UI.Xaml; +using WinRT.Interop; + +namespace Reentry.App; + +/// +/// WinUI's does not pick up ApplicationIcon for the +/// caption. Every window calls with the same ICO +/// so the HUD, settings, and consent title bars match the exe/taskbar glyph. +/// +internal static class WindowIcon +{ + internal const string FileName = "reentry.ico"; + + public static AppWindow Apply(Window window) + { + ArgumentNullException.ThrowIfNull(window); + var hwnd = WindowNative.GetWindowHandle(window); + var id = Win32Interop.GetWindowIdFromWindow(hwnd); + var appWindow = AppWindow.GetFromWindowId(id); + var path = ResolvePath(); + if (path is not null) + appWindow.SetIcon(path); + return appWindow; + } + + internal static string? ResolvePath() + { + var bases = new[] + { + AppContext.BaseDirectory, + Path.GetDirectoryName(Environment.ProcessPath) ?? "", + }; + + foreach (var root in bases) + { + if (string.IsNullOrWhiteSpace(root)) + continue; + foreach (var relative in new[] { Path.Combine("Assets", FileName), FileName }) + { + var candidate = Path.GetFullPath(Path.Combine(root, relative)); + if (File.Exists(candidate)) + return candidate; + } + } + + return null; + } +} diff --git a/src/Reentry.Core/CollectionSync.cs b/src/Reentry.Core/CollectionSync.cs new file mode 100644 index 0000000..fb58231 --- /dev/null +++ b/src/Reentry.Core/CollectionSync.cs @@ -0,0 +1,53 @@ +using System.Collections.ObjectModel; + +namespace Reentry.Core; + +/// +/// In-place collection update so a 1 Hz HUD tick does not Clear() a bound +/// ObservableCollection (that rebuilds ListViews and resets subsection scroll). +/// +public static class CollectionSync +{ + public static void InPlace( + ObservableCollection target, + IReadOnlyList source, + Func itemKey, + Func sourceKey, + Action apply, + Func create) + where TKey : notnull + { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(itemKey); + ArgumentNullException.ThrowIfNull(sourceKey); + ArgumentNullException.ThrowIfNull(apply); + ArgumentNullException.ThrowIfNull(create); + + var existing = new Dictionary(target.Count); + foreach (var item in target) + existing[itemKey(item)] = item; + + for (var i = 0; i < source.Count; i++) + { + var src = source[i]; + var key = sourceKey(src); + if (existing.TryGetValue(key, out var item)) + { + apply(item, src); + var current = target.IndexOf(item); + if (current != i) + target.Move(current, i); + } + else + { + var created = create(src); + existing[key] = created; + target.Insert(i, created); + } + } + + while (target.Count > source.Count) + target.RemoveAt(target.Count - 1); + } +} diff --git a/src/Reentry.Core/Models/AppState.cs b/src/Reentry.Core/Models/AppState.cs index 7eaabc4..b030651 100644 --- a/src/Reentry.Core/Models/AppState.cs +++ b/src/Reentry.Core/Models/AppState.cs @@ -9,3 +9,13 @@ public enum AppState Hung, Disabled, } + +public static class AppStateExtensions +{ + /// + /// Terminal for the session progress bar: Interactive, Failed, Hung, Disabled. + /// Pending and Starting are still unfinished. + /// + public static bool IsSettled(this AppState state) => + state is AppState.Interactive or AppState.Failed or AppState.Hung or AppState.Disabled; +} diff --git a/tests/Reentry.Core.Tests/AppStateTests.cs b/tests/Reentry.Core.Tests/AppStateTests.cs new file mode 100644 index 0000000..3ba8a61 --- /dev/null +++ b/tests/Reentry.Core.Tests/AppStateTests.cs @@ -0,0 +1,16 @@ +using Reentry.Core.Models; + +namespace Reentry.Core.Tests; + +public class AppStateTests +{ + [Theory] + [InlineData(AppState.Interactive, true)] + [InlineData(AppState.Failed, true)] + [InlineData(AppState.Hung, true)] + [InlineData(AppState.Disabled, true)] + [InlineData(AppState.Pending, false)] + [InlineData(AppState.Starting, false)] + public void IsSettled_TerminalStatesOnly(AppState state, bool settled) + => Assert.Equal(settled, state.IsSettled()); +} diff --git a/tests/Reentry.Core.Tests/CollectionSyncTests.cs b/tests/Reentry.Core.Tests/CollectionSyncTests.cs new file mode 100644 index 0000000..e3c00c1 --- /dev/null +++ b/tests/Reentry.Core.Tests/CollectionSyncTests.cs @@ -0,0 +1,145 @@ +using System.Collections.ObjectModel; +using Reentry.Core; + +namespace Reentry.Core.Tests; + +public class CollectionSyncTests +{ + private sealed class Row + { + public required string Id { get; init; } + public string Name { get; set; } = ""; + public int Applies { get; set; } + } + + private readonly record struct Source(string Id, string Name); + + [Fact] + public void InPlace_EmptyTarget_InsertsInOrder() + { + var target = new ObservableCollection(); + Sync(target, [new("b", "B"), new("a", "A")]); + Assert.Equal(["b", "a"], target.Select(r => r.Id).ToArray()); + Assert.Equal(["B", "A"], target.Select(r => r.Name).ToArray()); + } + + [Fact] + public void InPlace_SameIds_AppliesExistingInstances_DoesNotReplace() + { + var target = new ObservableCollection + { + new() { Id = "steam", Name = "Steam" }, + new() { Id = "dropbox", Name = "Dropbox" }, + }; + var steam = target[0]; + var dropbox = target[1]; + + Sync(target, [new("steam", "Steam (running)"), new("dropbox", "Dropbox")]); + + Assert.Equal(2, target.Count); + Assert.Same(steam, target[0]); + Assert.Same(dropbox, target[1]); + Assert.Equal("Steam (running)", steam.Name); + Assert.Equal(1, steam.Applies); + Assert.Equal(1, dropbox.Applies); + } + + [Fact] + public void InPlace_RemovesMissing_InsertsNew_PreservesOrder() + { + var target = new ObservableCollection + { + new() { Id = "gone", Name = "Gone" }, + new() { Id = "keep", Name = "Keep" }, + }; + var keep = target[1]; + + Sync(target, [new("keep", "Keep 2"), new("new", "New")]); + + Assert.Equal(["keep", "new"], target.Select(r => r.Id).ToArray()); + Assert.Same(keep, target[0]); + Assert.Equal("Keep 2", keep.Name); + Assert.Equal("New", target[1].Name); + } + + [Fact] + public void InPlace_ReordersExistingWithoutClear() + { + var target = new ObservableCollection + { + new() { Id = "a", Name = "A" }, + new() { Id = "b", Name = "B" }, + new() { Id = "c", Name = "C" }, + }; + var a = target[0]; + var b = target[1]; + var c = target[2]; + + var changes = 0; + target.CollectionChanged += (_, _) => changes++; + + Sync(target, [new("c", "C"), new("a", "A"), new("b", "B")]); + + Assert.Equal(["c", "a", "b"], target.Select(r => r.Id).ToArray()); + Assert.Same(c, target[0]); + Assert.Same(a, target[1]); + Assert.Same(b, target[2]); + Assert.True(changes > 0); + } + + [Fact] + public void InPlace_IdenticalOrder_DoesNotRaiseCollectionChanged() + { + var target = new ObservableCollection + { + new() { Id = "a", Name = "A" }, + new() { Id = "b", Name = "B" }, + }; + + var changes = 0; + target.CollectionChanged += (_, _) => changes++; + + Sync(target, [new("a", "A2"), new("b", "B2")]); + + Assert.Equal(0, changes); + Assert.Equal("A2", target[0].Name); + Assert.Equal("B2", target[1].Name); + } + + [Fact] + public void InPlace_ClearsByRemovingTail_WhenSourceEmpty() + { + var target = new ObservableCollection + { + new() { Id = "a", Name = "A" }, + new() { Id = "b", Name = "B" }, + }; + + var reset = 0; + target.CollectionChanged += (_, e) => + { + if (e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Reset) + reset++; + }; + + Sync(target, []); + + Assert.Empty(target); + Assert.Equal(0, reset); + } + + private static void Sync(ObservableCollection target, IReadOnlyList source) + { + CollectionSync.InPlace( + target, + source, + itemKey: r => r.Id, + sourceKey: s => s.Id, + apply: (row, src) => + { + row.Name = src.Name; + row.Applies++; + }, + create: s => new Row { Id = s.Id, Name = s.Name }); + } +}