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 @@
-->
-
-
-
-
+
+
+
+