diff --git a/.github/workflows/build-native.yml b/.github/workflows/build-native.yml index 4299849f..fa1c8f7a 100644 --- a/.github/workflows/build-native.yml +++ b/.github/workflows/build-native.yml @@ -36,7 +36,7 @@ concurrency: jobs: native: - uses: SimonCropp/PlatformHeads/.github/workflows/build-native.yml@0.9.0 + uses: SimonCropp/PlatformHeads/.github/workflows/build-native.yml@0.10.0 permissions: contents: write pull-requests: write diff --git a/claude.md b/claude.md index 1b5006bc..4547d1d4 100644 --- a/claude.md +++ b/claude.md @@ -279,7 +279,7 @@ the comment there about not caching "nothing staged" asks for. by which colour object, so a draw that changes little lays out little. An enlarged picture below its own size is drawn from a copy at that size (`reduced`), made on the work queue in the fitted copy's slot, except in a capture, past its own size, or when both panes name one picture. None - of this head can be compiled or run from Windows: CI's `macos-14` job is the first build, and + of this head can be compiled or run from Windows: the macOS leg of CI's `unix` job is the first build, and its OSX baselines come from that job's `received-*` artifacts. That job only captures, so it exercises none of the clip test and none of `reduced`. A picture or a scaled copy that lands during a draw clipped to less than the window is owed a whole redraw, which `takeFinished` @@ -621,7 +621,7 @@ the comment there about not caching "nothing staged" asks for. `native/CMakeLists.txt`, `PlatformHeads`, `PlatformHeads.Core`, `PlatformHeads.Linux` (the Linux tray's) and `PlatformHeads.WindowsForms` in `Directory.Packages.props`, which move together, and the `PlatformHeads` package - in `native/swift/Package.swift`, all at 0.9.0. So is the native runtime under both heads' drawing: the C++ + in `native/swift/Package.swift`, all at 0.10.0. So is the native runtime under both heads' drawing: the C++ one `deview.cpp` calls as `ph::` (the window, GLFW's input as it happened, which frames are built and drawn, the renderer, the fonts and fontconfig, and the label helpers) and the Swift `PlatformHeadsMac` the AppKit head imports (`HeadWindow`, `EventPump`, `TerminateGuard`, @@ -692,9 +692,18 @@ the comment there about not caching "nothing staged" asks for. `TrayMenuItem`s built from what the tracker holds, an action for each by id, and tips kept beside the items since a Linux tray's menu cannot show one. Ids name the entry acted on by its key, so a click from a menu built before a change acts on that entry or on nothing. It comes in two halves, - `Fixed` and `Tracked`, because the head's `MenuBuilder` keeps the fixed items and builds the - tracked ones on every open, making each a ToolStrip item with PlatformHeads' `TrayMenuItems`, - and setting the tips and the submenus' direction through its options' `Style`. What only a head + `Fixed` and `Tracked`. Every head shows it the same way, by `TrayTurns`: a model of both halves + handed to the tray every tenth of a second, and a click done on the menus it was chosen from. + The Windows head's tray is PlatformHeads.WindowsForms' `NotifyIconTray`, which makes the menu + from the last model as it opens, and its turns run from a WinForms timer on the message loop, + where the icon's events are raised. It used to build the tracked half itself on every open, and + a menu can now be a turn behind the tracker, which the ids make safe. `MenuBuilder` is only + how it looks: the options the tray is made with, the tips and the submenus' direction through + `Style`, the font and the direction through `Theme`, and either button opening it + (`LeftClickOpensMenu`). Hot keys are registered against the icon's window + (`NotifyIconTray.Handle`), and its balloons are `TrayNotificationKind.Warning`. `MenuBuilderTest` + shows the menu through `MenuTray`, which makes it as `NotifyIconTray` does with no icon in the + notification area. What only a head can do (its windows, exit) is handed in as `TrayActions`, with the core's `DiffToolLauncher` for a move's diff tool, which brings the tool forward only on Windows and elsewhere ends it, where it may, and starts it again. Two seams @@ -902,7 +911,7 @@ the comment there about not caching "nothing staged" asks for. `async Task Main` lands on a method the runtime does not start, and the thread came up MTA. - `SessionEndWindow`, PlatformHeads.WindowsForms', is a hidden top level window that hears `WM_ENDSESSION`, which neither the notify icon nor a message filter does. So are - `MenuOnOpening`, which builds the menu's tracked items on every open beside its fixed ones and + `NotifyIconTray`, and `MenuOnOpening` under it, which builds the menu's items on every open and disposes them at the next, and `IssueLink` and `LinkOpener` under `IssueLauncher` and `LinkLauncher`: BuildMonitor's tray shares them, and a fix to any of them goes there. An owning tray stages its queue from inside the message (`OwnedInlineHost.SessionEnding`) and refuses patches from then on, because a logoff never comes diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt index 65c40ecb..23638342 100644 --- a/native/CMakeLists.txt +++ b/native/CMakeLists.txt @@ -15,7 +15,7 @@ include(FetchContent) FetchContent_Declare( platformheads GIT_REPOSITORY https://github.com/SimonCropp/PlatformHeads.git - GIT_TAG 0.9.0 + GIT_TAG 0.10.0 GIT_SHALLOW ON) FetchContent_MakeAvailable(platformheads) include(${platformheads_SOURCE_DIR}/cmake/PlatformHeads.cmake) diff --git a/native/swift/Package.swift b/native/swift/Package.swift index 4dd9ba1b..8c2f710a 100644 --- a/native/swift/Package.swift +++ b/native/swift/Package.swift @@ -15,7 +15,7 @@ let package = Package( dependencies: [ // What every AppKit head does underneath its drawing, shared with BuildMonitor. The sixth // pin of PlatformHeads, moved with the other five. - .package(url: "https://github.com/SimonCropp/PlatformHeads", exact: "0.9.0") + .package(url: "https://github.com/SimonCropp/PlatformHeads", exact: "0.10.0") ], targets: [ // Exists only to import the ABI. Swift does not guarantee struct layout, so the structs diff --git a/src/DiffEngineTray.Core/TrayLoop.cs b/src/DiffEngineTray.Core/TrayLoop.cs index e54125f9..d0693680 100644 --- a/src/DiffEngineTray.Core/TrayLoop.cs +++ b/src/DiffEngineTray.Core/TrayLoop.cs @@ -1,12 +1,14 @@ /// -/// The loop of a head whose tray is a model and no window: the menu built from the tracker and -/// handed to the tray, and what was chosen on it done, on the loop's own thread. +/// The loop of a head whose tray is a model: the menu built from the tracker and handed to the +/// tray, and what was chosen on it done, on the loop's own thread. /// -/// Built every turn rather than as the menu opens, which is how the Windows head builds it. A -/// StatusNotifierItem's menu is the panel's to show, and the panel's question before it shows one, -/// dbusmenu's AboutToShow, PlatformHeads answers itself with "nothing changed" and tells the app -/// nothing, so the tray has to hold the current layout before anyone opens it. A turn that changes -/// nothing hands the tray a model equal to the one it holds, which it sends nowhere. +/// Built every turn rather than as the menu opens. A StatusNotifierItem's menu is the panel's to +/// show, and the panel's question before it shows one, dbusmenu's AboutToShow, PlatformHeads +/// answers itself with "nothing changed" and tells the app nothing, so the tray has to hold the +/// current layout before anyone opens it. A turn that changes nothing hands the tray a model equal +/// to the one it holds, which it sends nowhere. The Windows head built its menu as it opened, and +/// takes the same turns now, from a timer on its message loop (), so a +/// menu can be a turn behind the tracker, which the ids make safe: see . /// /// static class TrayLoop @@ -14,7 +16,7 @@ static class TrayLoop /// /// How long a click waits to be done, and how stale the menu can be. /// - static readonly TimeSpan turn = TimeSpan.FromMilliseconds(100); + public static readonly TimeSpan Turn = TimeSpan.FromMilliseconds(100); /// /// Null where there is nowhere to show the menu, as on a GNOME without the AppIndicator @@ -29,11 +31,11 @@ public static async Task Run(ITray? tray, Tracker tracker, TrayActions return; } - var shown = Show(tray, tracker, head, icon); + var turns = new TrayTurns(tray, tracker, head, icon); while (!cancel.IsCancellationRequested) { - await Wait(turn, cancel); - shown = Turn(tray, shown, tracker, head, icon); + await Wait(Turn, cancel); + turns.Turn(); } } @@ -45,57 +47,11 @@ public static async Task Run(ITray? tray, Tracker tracker, TrayActions /// public static void RunPumped(ITray tray, Action pump, Tracker tracker, TrayActions head, Func icon, Cancel cancel) { - var shown = Show(tray, tracker, head, icon); + var turns = new TrayTurns(tray, tracker, head, icon); while (!cancel.IsCancellationRequested) { pump(); - shown = Turn(tray, shown, tracker, head, icon); - } - } - - /// - /// What was chosen on the menus last shown done, and the menus shown again. - /// - static (TrayMenu Fixed, TrayMenu Tracked) Turn( - ITray tray, - (TrayMenu Fixed, TrayMenu Tracked) shown, - Tracker tracker, - TrayActions head, - Func icon) - { - if (tray.Poll().ClickedItem is { } id) - { - Invoke(shown.Fixed, shown.Tracked, id); - } - - return Show(tray, tracker, head, icon); - } - - static (TrayMenu Fixed, TrayMenu Tracked) Show(ITray tray, Tracker tracker, TrayActions head, Func icon) - { - var fixedItems = TrayMenu.Fixed(head); - var tracked = TrayMenu.Tracked(tracker, head); - tray.Apply(new(icon(), "DiffEngineTray", [.. fixedItems.Items, .. tracked.Items])); - return (fixedItems, tracked); - } - - /// - /// On the menus the item was shown from. Its id names what it acts on, so one built since - /// would act on the same entry, but an item the tracker has since dropped is in no other. - /// - static void Invoke(TrayMenu fixedItems, TrayMenu tracked, string id) - { - try - { - if (!fixedItems.Invoke(id)) - { - tracked.Invoke(id); - } - } - catch (Exception exception) - { - // Thrown out of here, it would end the loop, and with it the tray - ExceptionHandler.Handle($"Failed to do what the menu item {id} does", exception); + turns.Turn(); } } diff --git a/src/DiffEngineTray.Core/TrayTurns.cs b/src/DiffEngineTray.Core/TrayTurns.cs new file mode 100644 index 00000000..f4fa4fda --- /dev/null +++ b/src/DiffEngineTray.Core/TrayTurns.cs @@ -0,0 +1,72 @@ +/// +/// The turns of , for whichever loop runs them: the menus last shown, so a +/// click is done on the menu it was chosen from and a tip is the one for the item shown. The +/// Windows head runs them from a timer on its message loop, where the tray's events are raised. +/// +sealed class TrayTurns +{ + readonly ITray tray; + readonly Tracker tracker; + readonly TrayActions head; + readonly Func icon; + TrayMenu fixedItems; + TrayMenu tracked; + + /// + /// Shows the menus at once, so the tray holds them before anyone can open it. + /// + public TrayTurns(ITray tray, Tracker tracker, TrayActions head, Func icon) + { + this.tray = tray; + this.tracker = tracker; + this.head = head; + this.icon = icon; + (fixedItems, tracked) = Show(); + } + + /// + /// What was chosen on the menus last shown done, and the menus shown again. Done on those + /// menus: an item's id names what it acts on, so one built since would act on the same entry, + /// but an item the tracker has since dropped is in no other. + /// + public void Turn() + { + if (tray.Poll().ClickedItem is { } id) + { + Invoke(id); + } + + (fixedItems, tracked) = Show(); + } + + /// + /// The tip of an item on the menus last shown, which a tray whose menus can show one asks as + /// it builds them. + /// + public string? Tooltip(string id) => + fixedItems.Tooltip(id) ?? tracked.Tooltip(id); + + (TrayMenu Fixed, TrayMenu Tracked) Show() + { + var shownFixed = TrayMenu.Fixed(head); + var shownTracked = TrayMenu.Tracked(tracker, head); + tray.Apply(new(icon(), "DiffEngineTray", [.. shownFixed.Items, .. shownTracked.Items])); + return (shownFixed, shownTracked); + } + + void Invoke(string id) + { + try + { + if (!fixedItems.Invoke(id)) + { + tracked.Invoke(id); + } + } + catch (Exception exception) + { + // Thrown out of here, it would end the loop, and with it the tray + ExceptionHandler.Handle($"Failed to do what the menu item {id} does", exception); + } + } +} diff --git a/src/DiffEngineTray.Mac/runtimes/osx-arm64/native/libdiffengine_tray.dylib b/src/DiffEngineTray.Mac/runtimes/osx-arm64/native/libdiffengine_tray.dylib index c2d7f91e..f537d0ba 100644 Binary files a/src/DiffEngineTray.Mac/runtimes/osx-arm64/native/libdiffengine_tray.dylib and b/src/DiffEngineTray.Mac/runtimes/osx-arm64/native/libdiffengine_tray.dylib differ diff --git a/src/DiffEngineTray.Mac/runtimes/osx-x64/native/libdiffengine_tray.dylib b/src/DiffEngineTray.Mac/runtimes/osx-x64/native/libdiffengine_tray.dylib index c2d7f91e..f537d0ba 100644 Binary files a/src/DiffEngineTray.Mac/runtimes/osx-x64/native/libdiffengine_tray.dylib and b/src/DiffEngineTray.Mac/runtimes/osx-x64/native/libdiffengine_tray.dylib differ diff --git a/src/DiffEngineTray.Tests/MenuBuilderTest.cs b/src/DiffEngineTray.Tests/MenuBuilderTest.cs index ae37a33a..589b3625 100644 --- a/src/DiffEngineTray.Tests/MenuBuilderTest.cs +++ b/src/DiffEngineTray.Tests/MenuBuilderTest.cs @@ -1,6 +1,7 @@ /// -/// How WinForms shows the tray's menu. What the menu says and does is TrayMenuTests', in -/// the core's tests, as text. +/// How WinForms shows the tray's menu: the head's options and turns, through a tray that makes its +/// menu as PlatformHeads' NotifyIconTray does (). What the menu says and +/// does is TrayMenuTests', in the core's tests, as text. /// [TUnit.Core.Executors.STAThreadExecutor] public class MenuBuilderTest : @@ -10,7 +11,7 @@ public class MenuBuilderTest : public async Task Empty() { await using var tracker = new RecordingTracker(); - var menu = MenuBuilder.Build(actions, tracker); + var menu = Build(tracker); await Verify(menu, settings); } @@ -23,7 +24,7 @@ public async Task Full() tracker.AddDelete(file2); tracker.AddMove(file3, file3, "theExe", "theArguments", true, null); tracker.AddMove(file4, file4, "theExe", "theArguments", true, null); - var menu = MenuBuilder.Build(actions, tracker); + var menu = Build(tracker); await Verify(menu, settings); } @@ -35,7 +36,7 @@ public async Task FullGrouped() tracker.AddDelete("file2.txt"); tracker.AddMove(file3, file3, "theExe", "theArguments", true, null); tracker.AddMove(file4, "file4.txt", "theExe", "theArguments", true, null); - var menu = MenuBuilder.Build(actions, tracker); + var menu = Build(tracker); await Verify(menu, settings); } @@ -47,7 +48,7 @@ public async Task FullGrouped() public async Task ATipIsOnItsItem() { await using var tracker = new RecordingTracker(inline: new StubInlineHost(new PendingSnapshot("c:\\repo\\sample.cs|12", "Sample.cs:12", status))); - var menu = MenuBuilder.Build(actions, tracker); + var menu = Build(tracker); Open(menu); var item = Snapshot(menu); @@ -73,7 +74,7 @@ public async Task ATipIsOnItsItem() public async Task SnapshotThatWasNotWrittenDropDown() { await using var tracker = new RecordingTracker(inline: new StubInlineHost(new PendingSnapshot("c:\\repo\\sample.cs|12", "Sample.cs:12", status))); - var menu = MenuBuilder.Build(actions, tracker); + var menu = Build(tracker); Open(menu); var item = Snapshot(menu); @@ -86,22 +87,24 @@ public async Task SnapshotThatWasNotWrittenDropDown() } /// - /// A click reports the item's id, and each half of the menu answers for its own: the fixed - /// items reach the head's actions, the tracked ones the tracker. + /// A click reports the item's id, and the next turn does it on the menus it was chosen from: + /// the fixed items reach the head's actions, the tracked ones the tracker. /// [Test] public async Task AClickDoesWhatItsItemDoes() { await using var tracker = new RecordingTracker(); tracker.AddDelete(file1); - var menu = MenuBuilder.Build(actions, tracker); + var menu = Build(tracker); Open(menu); - // Both found first, since a click closes the menu and the close takes the tracked items + // Both found first, since a click closes the menu and the close takes the items var exit = Item(menu.Items, "Exit"); var accept = Item(menu.Items, "Accept delete"); exit.PerformClick(); + turns!.Turn(); accept.PerformClick(); + turns.Turn(); menu.Close(); await Assert.That(exits).IsEqualTo(1); @@ -109,6 +112,17 @@ public async Task AClickDoesWhatItsItemDoes() await Assert.That(File.Exists(file1)).IsFalse(); } + /// + /// The menu as the head's tray makes it. + /// + System.Windows.Forms.ContextMenuStrip Build(Tracker tracker) + { + var tray = new MenuTray(MenuBuilder.Options(_ => turns!.Tooltip(_))); + trays.Add(tray); + turns = new(tray, tracker, actions, () => TrayIcon.Default); + return tray.Menu; + } + static System.Windows.Forms.ToolStripMenuItem Item(System.Windows.Forms.ToolStripItemCollection items, string text) => Flatten(items).First(_ => _.Text == text); @@ -145,7 +159,7 @@ static void Open(System.Windows.Forms.ContextMenuStrip menu) public async Task AMenuOpenedByATestIsNotOnTheDesktop() { await using var tracker = new RecordingTracker(inline: new StubInlineHost(new PendingSnapshot("c:\\repo\\sample.cs|12", "Sample.cs:12", null))); - var menu = MenuBuilder.Build(actions, tracker); + var menu = Build(tracker); Open(menu); var item = Snapshot(menu); item.DropDown.TopLevel = false; @@ -182,78 +196,6 @@ static MemoryStream Draw(System.Windows.Forms.Control control) return stream; } - /// - /// The tracked items were told from the fixed ones by their text, so a solution named after - /// one of them kept its group header through the close that removed everything under it, and - /// grew another on the next open. - /// - [Test] - public async Task A_group_named_after_a_fixed_item_is_removed_with_the_rest() - { - var directory = Path.Combine(Path.GetTempPath(), $"MenuBuilderTest_{Guid.NewGuid()}"); - Directory.CreateDirectory(directory); - try - { - await File.WriteAllTextAsync(Path.Combine(directory, "Options.sln"), ""); - var file = Path.Combine(directory, "file.txt"); - await File.WriteAllTextAsync(file, ""); - await using var tracker = new RecordingTracker(); - tracker.AddDelete(file); - var menu = MenuBuilder.Build(actions, tracker); - var fixedCount = menu.Items.Count; - - Open(menu); - var opened = menu.Items - .Cast() - .Skip(fixedCount) - .Select(_ => _.Text) - .ToList(); - menu.Close(); - - // That the group is there at all, and under the name the rest of this is about - await Assert.That(opened).Contains("Options"); - await Assert.That(menu.Items.Count).IsEqualTo(fixedCount); - } - finally - { - Directory.Delete(directory, true); - } - } - - /// - /// A close removed the tracked items and the next open disposed whatever was still in the - /// collection, which by then was only the fixed ones. So every open leaked a menu's worth of - /// controls to the finaliser. - /// - [Test] - public async Task Tracked_items_are_disposed_by_the_next_open() - { - await using var tracker = new RecordingTracker(); - tracker.AddDelete(file1); - var menu = MenuBuilder.Build(actions, tracker); - var fixedCount = menu.Items.Count; - - Open(menu); - var tracked = menu.Items - .Cast() - .Skip(fixedCount) - .ToList(); - // ToolStripItem.IsDisposed stays false through a Dispose, so the event is what says it - // happened - var disposed = 0; - foreach (var item in tracked) - { - item.Disposed += (_, _) => disposed++; - } - - menu.Close(); - Open(menu); - menu.Close(); - - await Assert.That(tracked).IsNotEmpty(); - await Assert.That(disposed).IsEqualTo(tracked.Count); - } - const string status = "Sample.cs:12 not written. No Verify or Throws call at line 12. One reached through a receiver of its own does not count."; public MenuBuilderTest() @@ -285,6 +227,11 @@ public MenuBuilderTest() public void Dispose() { + foreach (var tray in trays) + { + tray.Dispose(); + } + File.Delete(file1); File.Delete(file2); File.Delete(file3); @@ -293,6 +240,8 @@ public void Dispose() int exits; TrayActions actions; + TrayTurns? turns; + List trays = []; string file1; string file2; string file3; diff --git a/src/DiffEngineTray.Tests/MenuTray.cs b/src/DiffEngineTray.Tests/MenuTray.cs new file mode 100644 index 00000000..04d95629 --- /dev/null +++ b/src/DiffEngineTray.Tests/MenuTray.cs @@ -0,0 +1,50 @@ +/// +/// A tray that makes its menu as PlatformHeads' does, from +/// the last model and by the head's options, and puts no icon in the notification area: a +/// NotifyIconTray shows one as it is made, on the desktop of whoever runs the tests. +/// +sealed class MenuTray : + ITray +{ + TrayModel? model; + string? clicked; + + public MenuTray(NotifyIconOptions options) + { + options.Theme?.Invoke(Menu); + MenuOnOpening.Rebuild( + Menu, + items => + { + options.Theme?.Invoke(Menu); + if (model is null) + { + return; + } + + foreach (var item in model.Items) + { + items.Add(TrayMenuItems.Create(item, options, _ => clicked = _)); + } + }); + } + + public System.Windows.Forms.ContextMenuStrip Menu { get; } = new(); + + public void Apply(TrayModel next) => + model = next; + + public void Notify(TrayNotification notification) + { + } + + public TrayInput Poll() + { + var input = new TrayInput(clicked, false, null); + clicked = null; + return input; + } + + public void Dispose() => + Menu.Dispose(); +} diff --git a/src/DiffEngineTray/Extensions.cs b/src/DiffEngineTray/Extensions.cs index 5e1cabcd..8fab6ffc 100644 --- a/src/DiffEngineTray/Extensions.cs +++ b/src/DiffEngineTray/Extensions.cs @@ -1,14 +1,5 @@ static class Extensions { - // cant use UnsafeAccessor since _window is a NotifyIconNativeWindow which is not public - public static IntPtr Handle(this NotifyIcon target) - { - var windowField = typeof(NotifyIcon) - .GetField("_window", BindingFlags.NonPublic | BindingFlags.Instance)!; - var window = (NativeWindow) windowField.GetValue(target)!; - return window.Handle; - } - public static void Add(this ToolStripSplitButton target, params ToolStripItem[] items) => target.DropDownItems.AddRange(items); -} \ No newline at end of file +} diff --git a/src/DiffEngineTray/MenuBuilder.cs b/src/DiffEngineTray/MenuBuilder.cs index a5839af3..9d4969fa 100644 --- a/src/DiffEngineTray/MenuBuilder.cs +++ b/src/DiffEngineTray/MenuBuilder.cs @@ -1,61 +1,43 @@ /// -/// The tray's menu as WinForms shows it: 's items made ToolStrip items by -/// PlatformHeads' , which BuildMonitor's tray shows its model with too. -/// What the menu says and does is the core's; what is here is how it looks. +/// How the tray's menu looks on Windows: the options PlatformHeads' +/// makes its menu from 's items with, by , +/// which BuildMonitor's tray shows its model with too. What the menu says and does is the core's. /// static class MenuBuilder { - public static ContextMenuStrip Build(TrayActions actions, Tracker tracker) - { - var menu = new ContextMenuStrip - { - DefaultDropDownDirection = ToolStripDropDownDirection.AboveLeft - }; - menu.Font = new(menu.Font.FontFamily, 10); - var fixedItems = TrayMenu.Fixed(actions); - TrayMenu? tracked = null; - - var options = new NotifyIconOptions( + /// The tip for an item, by its id, from the menus last shown. + public static NotifyIconOptions Options(Func tooltip) => + new( "DiffEngineTray", _ => _ == TrayIcon.Active ? Images.Active : Images.Default, Glyph, - Style: (item, model) => + Theme: _ => { - item.ToolTipText = fixedItems.Tooltip(model.Id) ?? tracked?.Tooltip(model.Id); - if (model.HasChildren) + // The menu itself, which this is handed before every opening, and not a submenu, + // which opens where its item says + if (_ is not ContextMenuStrip menu) { - item.DropDownDirection = ToolStripDropDownDirection.Left; + return; } - }); - - void Clicked(string id) - { - if (!fixedItems.Invoke(id)) - { - tracked?.Invoke(id); - } - } - foreach (var item in fixedItems.Items) - { - menu.Items.Add(TrayMenuItems.Create(item, options, Clicked)); - } - - // After the fixed items, which it keeps as themselves: told apart by their text, a - // solution named after one of them - Options, say - gave that group a header that was - // never removed. The tracked ones are built again on every open, and disposed at the next. - MenuOnOpening.Rebuild( - menu, - _ => + menu.DefaultDropDownDirection = ToolStripDropDownDirection.AboveLeft; + if (menu.Font.Size != fontSize) + { + menu.Font = new(menu.Font.FontFamily, fontSize); + } + }, + Style: (item, model) => { - tracked = TrayMenu.Tracked(tracker, actions); - foreach (var item in tracked.Items) + item.ToolTipText = tooltip(model.Id); + if (model.HasChildren) { - _.Add(TrayMenuItems.Create(item, options, Clicked)); + item.DropDownDirection = ToolStripDropDownDirection.Left; } - }); - return menu; - } + }, + // The menu is all the icon has, so either button opens it + LeftClickOpensMenu: true); + + const float fontSize = 10; static Image? Glyph(string name) => name switch diff --git a/src/DiffEngineTray/Program.cs b/src/DiffEngineTray/Program.cs index 69e78b08..92c7068e 100644 --- a/src/DiffEngineTray/Program.cs +++ b/src/DiffEngineTray/Program.cs @@ -1,5 +1,3 @@ -using NotifyIcon = System.Windows.Forms.NotifyIcon; - static class Program { /// @@ -60,20 +58,21 @@ static async Task Inner() // first use of a setting needs one, and until this line the user is looking at an empty // notification area. Still after the mutex, so a second instance exits without ever // showing an icon. - using var icon = new NotifyIcon - { - Icon = Images.Default, - Visible = true, - Text = "DiffEngineTray" - }; + // The tips are those of the menus last shown, which the turns below hold, and are asked + // for only as a menu is built, by when there are turns to ask + TrayTurns? turns = null; + using var tray = NotifyIconTray.Open(MenuBuilder.Options(_ => turns?.Tooltip(_)), out var trayError) ?? + throw new InvalidOperationException($"Could not make the tray icon. {trayError}"); + // A picture now rather than at the first turn, which waits on everything below + tray.Apply(new(TrayIcon.Default, "DiffEngineTray", [])); void Warn(string message) => - icon.ShowBalloonTip(10000, "DiffEngineTray", message, ToolTipIcon.Warning); + tray.Notify(new("DiffEngineTray", message, Kind: TrayNotificationKind.Warning)); if (TrayUpdate.Report() is { } report) { - var kind = report.Kind == TrayNotificationKind.Info ? ToolTipIcon.Info : ToolTipIcon.Warning; - icon.ShowBalloonTip(10000, report.Title, report.Message, kind); + // A failure as a warning, as every balloon of this tray's is + tray.Notify(report.Kind == TrayNotificationKind.Info ? report : report with {Kind = TrayNotificationKind.Warning}); } var settings = GetSettings(); @@ -94,9 +93,10 @@ void Warn(string message) => Log.Information("A viewer owns the inline queue. Driving it remotely."); } + var icon = TrayIcon.Default; await using var tracker = new Tracker( - active: () => icon.Icon = Images.Active, - inactive: () => icon.Icon = Images.Default, + active: () => icon = TrayIcon.Active, + inactive: () => icon = TrayIcon.Default, lockedFilesResolver: LockedFilesHandler.Resolve, acceptFailed: move => Warn( $"Could not accept '{move.Name}': the file move keeps failing. The move is still pending, so accept can be retried."), @@ -132,30 +132,28 @@ void Warn(string message) => var task = listener is null ? Task.CompletedTask : PiperServer.Serve(listener, tracker, cancel); - using var keyRegister = new KeyRegister(icon.Handle()); + using var keyRegister = new KeyRegister(tray.Handle); ReBindKeys(settings, keyRegister, tracker, Warn); - var menuStrip = MenuBuilder.Build( + turns = new( + tray, + tracker, new( Exit: Application.Exit, Options: async () => await OptionsFormLauncher.Launch(keyRegister, tracker), DebugView: async () => await DebugFormLauncher.Launch(tracker), Purge: FilePurger.Launch, OpenDiffTool: DiffToolLauncher.Launch), - tracker); + () => icon); - icon.MouseClick += (_, args) => + // The tray's events are raised on this thread as Application.Run pumps it, and what they + // chose is done here too, a turn at a time, as the other heads' loops do + using var timer = new System.Windows.Forms.Timer { - if (args.Button == MouseButtons.Left) - { - var position = Cursor.Position; - position.Offset(-menuStrip.Width, -menuStrip.Height); - menuStrip.Location = position; - ShowContextMenu(icon); - } + Interval = (int) TrayLoop.Turn.TotalMilliseconds }; - - icon.ContextMenuStrip = menuStrip; + timer.Tick += (_, _) => turns.Turn(); + timer.Start(); try { @@ -170,9 +168,6 @@ void Warn(string message) => await task; } - [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "ShowContextMenu")] - static extern void ShowContextMenu(NotifyIcon icon); - internal static void ReBindKeys(Settings settings, KeyRegister keyRegister, Tracker tracker, Action? warn = null) { foreach (var binding in BuildKeyBindings(settings, tracker)) diff --git a/src/DiffEngineViewer.Mac/runtimes/osx-arm64/native/libdiffengine_viewer.dylib b/src/DiffEngineViewer.Mac/runtimes/osx-arm64/native/libdiffengine_viewer.dylib index 835fd5a8..c45c4d27 100644 Binary files a/src/DiffEngineViewer.Mac/runtimes/osx-arm64/native/libdiffengine_viewer.dylib and b/src/DiffEngineViewer.Mac/runtimes/osx-arm64/native/libdiffengine_viewer.dylib differ diff --git a/src/DiffEngineViewer.Mac/runtimes/osx-x64/native/libdiffengine_viewer.dylib b/src/DiffEngineViewer.Mac/runtimes/osx-x64/native/libdiffengine_viewer.dylib index 835fd5a8..c45c4d27 100644 Binary files a/src/DiffEngineViewer.Mac/runtimes/osx-x64/native/libdiffengine_viewer.dylib and b/src/DiffEngineViewer.Mac/runtimes/osx-x64/native/libdiffengine_viewer.dylib differ diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 39bdd539..c4e5fe94 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -19,10 +19,10 @@ --> - - - - + + + +