added tray menu
This commit is contained in:
@@ -18,6 +18,9 @@ public sealed class AppTests
|
||||
[InlineData(typeof(ThemeService))]
|
||||
[InlineData(typeof(IThemeService))]
|
||||
[InlineData(typeof(MainWindowPlacement))]
|
||||
[InlineData(typeof(MainWindowPresenter))]
|
||||
[InlineData(typeof(TrayIcon))]
|
||||
[InlineData(typeof(ITrayIcon))]
|
||||
[InlineData(typeof(ILocalizationService))]
|
||||
[InlineData(typeof(IStartupService))]
|
||||
[InlineData(typeof(IUpdateService))]
|
||||
@@ -100,6 +103,15 @@ public sealed class AppTests
|
||||
Assert.NotNull(window.ImplementationFactory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_tray_icon_and_its_interface_are_one_icon()
|
||||
{
|
||||
ServiceDescriptor tray = Describe()
|
||||
.Single(descriptor => descriptor.ServiceType == typeof(ITrayIcon));
|
||||
|
||||
Assert.NotNull(tray.ImplementationFactory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_theme_and_its_interface_are_one_service()
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using CursorLang.Services;
|
||||
|
||||
namespace CursorLang.Tests;
|
||||
|
||||
@@ -25,6 +26,9 @@ public sealed class EndToEndTests
|
||||
private static readonly TimeSpan StartTimeout = TimeSpan.FromSeconds(30);
|
||||
private static readonly TimeSpan ExitTimeout = TimeSpan.FromSeconds(15);
|
||||
|
||||
/// <summary>How long "the application went on working" is worth watching for.</summary>
|
||||
private static readonly TimeSpan StayTimeout = TimeSpan.FromSeconds(3);
|
||||
|
||||
[Fact]
|
||||
public void The_application_starts_and_shows_the_settings_window()
|
||||
{
|
||||
@@ -34,6 +38,22 @@ public sealed class EndToEndTests
|
||||
Assert.False(launch.Process.HasExited);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Started by Windows itself, the application goes straight to the tray: the
|
||||
/// user asked for it to be there, not for a window to greet them.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_launch_by_Windows_shows_no_window()
|
||||
{
|
||||
using Launch launch = Launch.Start(StartupLaunch.Argument);
|
||||
|
||||
// Nothing is expected to appear, so the wait is for the whole time
|
||||
Assert.False(launch.Process.WaitForExit(StayTimeout), "the application ended by itself");
|
||||
|
||||
launch.Process.Refresh();
|
||||
Assert.Equal(IntPtr.Zero, launch.Process.MainWindowHandle);
|
||||
}
|
||||
|
||||
// A second run raises no second window but shows the window of the running one
|
||||
[Fact]
|
||||
public void The_second_run_ends_by_itself()
|
||||
@@ -50,15 +70,19 @@ public sealed class EndToEndTests
|
||||
Assert.False(launch.Process.HasExited);
|
||||
}
|
||||
|
||||
// The way out of the application is the tray menu alone: the close button of
|
||||
// the window merely puts the window away
|
||||
[Fact]
|
||||
public void Closing_the_window_ends_the_application()
|
||||
public void Closing_the_window_leaves_the_application_in_the_tray()
|
||||
{
|
||||
using Launch launch = Launch.Start();
|
||||
launch.WaitForWindow();
|
||||
|
||||
Assert.True(launch.Process.CloseMainWindow(), "the window did not accept the request to close");
|
||||
Assert.True(launch.Process.WaitForExit(ExitTimeout), "the application did not end after the window closed");
|
||||
Assert.Equal(0, launch.Process.ExitCode);
|
||||
Assert.False(launch.Process.WaitForExit(StayTimeout), "the application ended together with its window");
|
||||
|
||||
launch.Process.Refresh();
|
||||
Assert.Equal(IntPtr.Zero, launch.Process.MainWindowHandle);
|
||||
}
|
||||
|
||||
/// <summary>A started application that shuts down together with the check.</summary>
|
||||
@@ -79,7 +103,7 @@ public sealed class EndToEndTests
|
||||
internal Process Process { get; }
|
||||
|
||||
/// <summary>Starts the application first — making sure the place is free.</summary>
|
||||
internal static Launch Start()
|
||||
internal static Launch Start(params string[] arguments)
|
||||
{
|
||||
if (!HasInteractiveDesktop())
|
||||
{
|
||||
@@ -91,11 +115,11 @@ public sealed class EndToEndTests
|
||||
Assert.Skip("The application is already running — this check keeps out of someone else's run");
|
||||
}
|
||||
|
||||
return new Launch(StartProcess());
|
||||
return new Launch(StartProcess(arguments));
|
||||
}
|
||||
|
||||
/// <summary>Starts the application the way the user does.</summary>
|
||||
internal static Process StartProcess()
|
||||
/// <summary>Starts the application the way the user — or Windows — does.</summary>
|
||||
internal static Process StartProcess(params string[] arguments)
|
||||
{
|
||||
string path = ExecutablePath();
|
||||
|
||||
@@ -104,7 +128,14 @@ public sealed class EndToEndTests
|
||||
Assert.Skip($"The application is not built: {path}");
|
||||
}
|
||||
|
||||
return Process.Start(new ProcessStartInfo(path) { UseShellExecute = true })!;
|
||||
var start = new ProcessStartInfo(path) { UseShellExecute = true };
|
||||
|
||||
foreach (string argument in arguments)
|
||||
{
|
||||
start.ArgumentList.Add(argument);
|
||||
}
|
||||
|
||||
return Process.Start(start)!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -171,12 +202,11 @@ public sealed class EndToEndTests
|
||||
{
|
||||
if (!Process.HasExited)
|
||||
{
|
||||
// The polite way first — that way the app gets to save its settings
|
||||
if (!Process.CloseMainWindow() || !Process.WaitForExit(ExitTimeout))
|
||||
{
|
||||
Process.Kill(entireProcessTree: true);
|
||||
Process.WaitForExit(ExitTimeout);
|
||||
}
|
||||
// Asking the window to close would only put it away into the
|
||||
// tray, and the exit lives in a menu no test can reach. The
|
||||
// settings are saved as they change, so nothing is lost here
|
||||
Process.Kill(entireProcessTree: true);
|
||||
Process.WaitForExit(ExitTimeout);
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
using System.Windows;
|
||||
using CursorLang.Services;
|
||||
using CursorLang.Tests.Infrastructure;
|
||||
|
||||
namespace CursorLang.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The fate of the settings window: it comes and goes, and the application
|
||||
/// outlives it either way.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The window has to be alive for real — showing and hiding are the point here —
|
||||
/// so it is kept fully transparent and off the taskbar.
|
||||
/// </remarks>
|
||||
public sealed class MainWindowPresenterTests
|
||||
{
|
||||
[Fact]
|
||||
public void The_close_button_hides_the_window_instead_of_closing_it()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var presenter = new MainWindowPresenter(new MainWindowPlacement());
|
||||
|
||||
Open(presenter, window =>
|
||||
{
|
||||
window.Close();
|
||||
|
||||
Assert.False(window.IsVisible);
|
||||
Assert.False(presenter.IsShown);
|
||||
|
||||
// The window is hidden rather than closed: a closed one would
|
||||
// refuse to be shown again
|
||||
presenter.Show();
|
||||
Assert.True(window.IsVisible);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_minimise_button_hides_the_window_as_well()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var presenter = new MainWindowPresenter(new MainWindowPlacement());
|
||||
|
||||
Open(presenter, window =>
|
||||
{
|
||||
window.WindowState = WindowState.Minimized;
|
||||
|
||||
Assert.False(window.IsVisible);
|
||||
|
||||
// And the window is left in the state the next show needs:
|
||||
// a minimised one would come back as an icon on the taskbar
|
||||
Assert.Equal(WindowState.Normal, window.WindowState);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_hidden_window_comes_back_on_request()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var presenter = new MainWindowPresenter(new MainWindowPlacement());
|
||||
|
||||
Open(presenter, window =>
|
||||
{
|
||||
presenter.Hide();
|
||||
Assert.False(presenter.IsShown);
|
||||
|
||||
presenter.Show();
|
||||
|
||||
Assert.True(window.IsVisible);
|
||||
Assert.True(presenter.IsShown);
|
||||
Assert.Equal(WindowState.Normal, window.WindowState);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// On the way out of the application the window closes for real: otherwise
|
||||
// the exit from the tray menu would run into the very same refusal
|
||||
[Fact]
|
||||
public void The_window_closes_once_the_application_is_quitting()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var presenter = new MainWindowPresenter(new MainWindowPlacement());
|
||||
|
||||
Open(presenter, window =>
|
||||
{
|
||||
presenter.AllowClose();
|
||||
window.Close();
|
||||
|
||||
Assert.Throws<InvalidOperationException>(window.Show);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Without a notification area there is no way back to a hidden window,
|
||||
// so the window goes back to closing when told to close
|
||||
[Fact]
|
||||
public void A_window_let_go_of_behaves_the_usual_way()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var presenter = new MainWindowPresenter(new MainWindowPlacement());
|
||||
|
||||
Open(presenter, window =>
|
||||
{
|
||||
presenter.Detach();
|
||||
window.Close();
|
||||
|
||||
Assert.Throws<InvalidOperationException>(window.Show);
|
||||
});
|
||||
|
||||
// Letting go twice is what happens when the application quits
|
||||
// right after: it is no reason to fail
|
||||
presenter.Detach();
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void With_no_window_attached_nothing_happens()
|
||||
{
|
||||
var presenter = new MainWindowPresenter(new MainWindowPlacement());
|
||||
|
||||
presenter.Show();
|
||||
presenter.Hide();
|
||||
|
||||
Assert.False(presenter.IsShown);
|
||||
}
|
||||
|
||||
private static void Open(MainWindowPresenter presenter, Action<Window> check)
|
||||
{
|
||||
var window = new Window
|
||||
{
|
||||
// The window is needed alive, but not in sight
|
||||
Opacity = 0,
|
||||
ShowInTaskbar = false,
|
||||
ShowActivated = false,
|
||||
Width = 100,
|
||||
Height = 100,
|
||||
};
|
||||
|
||||
presenter.Attach(window);
|
||||
|
||||
try
|
||||
{
|
||||
window.Show();
|
||||
check(window);
|
||||
}
|
||||
finally
|
||||
{
|
||||
presenter.AllowClose();
|
||||
window.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using CursorLang.Services;
|
||||
|
||||
namespace CursorLang.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Telling a launch by Windows apart from a launch by the user: the first one goes
|
||||
/// to the tray without a window, the second one is what the window is for.
|
||||
/// </summary>
|
||||
public sealed class StartupLaunchTests
|
||||
{
|
||||
[Fact]
|
||||
public void A_launch_by_the_user_carries_no_argument()
|
||||
{
|
||||
Assert.False(StartupLaunch.HasArgument([]));
|
||||
Assert.False(StartupLaunch.IsAutomatic([]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_startup_entry_says_so_in_the_command_line()
|
||||
{
|
||||
Assert.True(StartupLaunch.HasArgument([StartupLaunch.Argument]));
|
||||
Assert.True(StartupLaunch.IsAutomatic([StartupLaunch.Argument]));
|
||||
}
|
||||
|
||||
// The argument does not have to come first: Windows may put its own
|
||||
// alongside it one day
|
||||
[Fact]
|
||||
public void The_argument_is_looked_for_among_the_others()
|
||||
{
|
||||
Assert.True(StartupLaunch.HasArgument(["--whatever", StartupLaunch.Argument]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_case_of_the_argument_does_not_matter()
|
||||
{
|
||||
Assert.True(StartupLaunch.HasArgument([StartupLaunch.Argument.ToUpperInvariant()]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Anything_else_is_a_launch_by_the_user()
|
||||
{
|
||||
Assert.False(StartupLaunch.HasArgument(["--startupp", "startup", "-startup"]));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The command written into the registry carries the argument: that is the whole
|
||||
/// point of the argument.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void The_startup_entry_is_written_with_the_argument()
|
||||
{
|
||||
string? command = RegistryStartup.GetCommand();
|
||||
|
||||
Assert.NotNull(command);
|
||||
Assert.EndsWith(StartupLaunch.Argument, command, StringComparison.Ordinal);
|
||||
|
||||
// And the path itself stays quoted: it has spaces in it more often than not
|
||||
Assert.StartsWith("\"", command, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Controls.Primitives;
|
||||
using CursorLang.Interop;
|
||||
using CursorLang.Services;
|
||||
using CursorLang.Tests.Infrastructure;
|
||||
|
||||
namespace CursorLang.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The icon in the notification area and its menu — the whole interface of an
|
||||
/// application that works in the background.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The icon needs a window of its own, so the checks run on the interface thread.
|
||||
/// Whether the tray accepts the icon is up to Windows: there is no notification area
|
||||
/// in a session without a desktop, and such a check skips itself.
|
||||
/// </remarks>
|
||||
public sealed class TrayIconTests
|
||||
{
|
||||
[Fact]
|
||||
public void The_icon_ends_up_in_the_notification_area()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var tray = new TrayIcon(new FakeLocalizationService());
|
||||
|
||||
if (!tray.Install())
|
||||
{
|
||||
Assert.Skip("Windows did not take the icon — there is no notification area here");
|
||||
}
|
||||
|
||||
// Asking twice changes nothing: a second icon of the same
|
||||
// application in the tray is not what anyone wants
|
||||
Assert.True(tray.Install());
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_icon_is_taken_away_on_the_way_out()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var tray = new TrayIcon(new FakeLocalizationService());
|
||||
tray.Install();
|
||||
|
||||
tray.Dispose();
|
||||
|
||||
// A second disposal is what the container does after the application
|
||||
// has already shut the tray down itself
|
||||
tray.Dispose();
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_menu_offers_the_settings_and_the_way_out()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var tray = new TrayIcon(new FakeLocalizationService());
|
||||
|
||||
List<MenuItem> items = [.. tray.Menu.Items.OfType<MenuItem>()];
|
||||
|
||||
Assert.Equal(2, items.Count);
|
||||
Assert.Equal("en:TrayMenuSettings", items[0].Header);
|
||||
Assert.Equal("en:TrayMenuExit", items[1].Header);
|
||||
|
||||
// The way out is set apart from the rest: it is the one point of no return
|
||||
Assert.Single(tray.Menu.Items.OfType<Separator>());
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_menu_follows_the_language_chosen_in_the_settings()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var localization = new FakeLocalizationService();
|
||||
using var tray = new TrayIcon(localization);
|
||||
|
||||
MenuItem settings = tray.Menu.Items.OfType<MenuItem>().First();
|
||||
Assert.Equal("en:TrayMenuSettings", settings.Header);
|
||||
|
||||
localization.CurrentLanguage = "ru";
|
||||
|
||||
Assert.Equal("ru:TrayMenuSettings", settings.Header);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_settings_point_of_the_menu_asks_for_the_window()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var tray = new TrayIcon(new FakeLocalizationService());
|
||||
int opened = 0;
|
||||
int exits = 0;
|
||||
|
||||
tray.OpenRequested += (_, _) => opened++;
|
||||
tray.ExitRequested += (_, _) => exits++;
|
||||
|
||||
Click(tray.Menu.Items.OfType<MenuItem>().First());
|
||||
|
||||
Assert.Equal(1, opened);
|
||||
Assert.Equal(0, exits);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_exit_point_of_the_menu_asks_for_the_way_out()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var tray = new TrayIcon(new FakeLocalizationService());
|
||||
int opened = 0;
|
||||
int exits = 0;
|
||||
|
||||
tray.OpenRequested += (_, _) => opened++;
|
||||
tray.ExitRequested += (_, _) => exits++;
|
||||
|
||||
Click(tray.Menu.Items.OfType<MenuItem>().Last());
|
||||
|
||||
Assert.Equal(1, exits);
|
||||
Assert.Equal(0, opened);
|
||||
});
|
||||
}
|
||||
|
||||
// The menu belongs to no window and hangs on no element tree: WPF has to
|
||||
// raise it all the same
|
||||
[Fact]
|
||||
public void The_menu_shows_up_on_request()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var tray = new TrayIcon(new FakeLocalizationService());
|
||||
tray.Install();
|
||||
|
||||
try
|
||||
{
|
||||
tray.ShowMenu();
|
||||
|
||||
Assert.True(tray.Menu.IsOpen);
|
||||
Assert.Equal(PlacementMode.MousePoint, tray.Menu.Placement);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// An open menu holds the mouse: the checks that follow would
|
||||
// never see a click of their own
|
||||
tray.Menu.IsOpen = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The line between the points of the menu is drawn by the theme of the app.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// WPF gives a separator inside a menu a style of its own, found by a key rather
|
||||
/// than by the type: a style by type never reaches it. Left with the system one,
|
||||
/// the line comes out indented from the left, where the icons of a system menu
|
||||
/// would go, and stretched past the right edge of the menu.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void The_line_between_the_points_of_the_menu_keeps_to_the_theme()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var themed = Application.Current.TryFindResource(MenuItem.SeparatorStyleKey) as Style;
|
||||
|
||||
Assert.NotNull(themed);
|
||||
Assert.Equal(typeof(Separator), themed.TargetType);
|
||||
|
||||
using var tray = new TrayIcon(new FakeLocalizationService());
|
||||
|
||||
try
|
||||
{
|
||||
tray.ShowMenu();
|
||||
|
||||
Separator line = tray.Menu.Items.OfType<Separator>().Single();
|
||||
|
||||
// The style has reached the line: it is the theme drawing it,
|
||||
// margins and all
|
||||
Assert.Same(themed, line.Style);
|
||||
Assert.True(line.ActualWidth > 0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
tray.Menu.IsOpen = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// The image comes from the executable itself; the icon Windows keeps for an
|
||||
// application without one is the last resort. An empty spot in the tray is not
|
||||
// an option either way
|
||||
[Fact]
|
||||
public void There_is_an_icon_to_show()
|
||||
{
|
||||
IntPtr icon = TrayIconNative.LoadApplicationIcon();
|
||||
|
||||
Assert.NotEqual(IntPtr.Zero, icon);
|
||||
|
||||
TrayIconNative.ReleaseIcon(icon);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Explorer_restarting_is_a_message_of_its_own()
|
||||
{
|
||||
Assert.NotEqual(0, TrayIconNative.TaskbarCreatedMessage);
|
||||
}
|
||||
|
||||
private static void Click(MenuItem item) => item.RaiseEvent(new RoutedEventArgs(MenuItem.ClickEvent));
|
||||
}
|
||||
@@ -265,6 +265,28 @@ public sealed class MainWindowTests
|
||||
});
|
||||
}
|
||||
|
||||
// The application lives in the tray, and the settings window is a guest on the
|
||||
// screen: its close button puts it away rather than ends anything
|
||||
[Fact]
|
||||
public void The_window_hooks_up_to_the_tray()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using SettingsViewModel viewModel = CreateViewModel();
|
||||
|
||||
Open(viewModel, window =>
|
||||
{
|
||||
window.Close();
|
||||
|
||||
Assert.False(window.IsVisible);
|
||||
|
||||
// A closed window would refuse this
|
||||
window.Show();
|
||||
Assert.True(window.IsVisible);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_window_hooks_up_to_the_placement()
|
||||
{
|
||||
@@ -305,7 +327,9 @@ public sealed class MainWindowTests
|
||||
MainWindowPlacement placement,
|
||||
Action<MainWindow> check)
|
||||
{
|
||||
var window = new MainWindow(viewModel, theme, placement)
|
||||
var presenter = new MainWindowPresenter(placement);
|
||||
|
||||
var window = new MainWindow(viewModel, theme, placement, presenter)
|
||||
{
|
||||
// The window is needed alive, but not in sight
|
||||
Opacity = 0,
|
||||
@@ -322,6 +346,9 @@ public sealed class MainWindowTests
|
||||
}
|
||||
finally
|
||||
{
|
||||
// The window belongs to the tray now and refuses to close until
|
||||
// the application is on its way out
|
||||
presenter.AllowClose();
|
||||
window.Close();
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
<Application x:Class="CursorLang.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
ShutdownMode="OnMainWindowClose">
|
||||
ShutdownMode="OnExplicitShutdown">
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
|
||||
+41
-18
@@ -11,14 +11,17 @@ public partial class App : Application
|
||||
{
|
||||
private ServiceProvider? _services;
|
||||
private SingleInstanceGate? _instanceGate;
|
||||
private MainWindowPlacement? _placement;
|
||||
private MainWindowPresenter? _presenter;
|
||||
private ITrayIcon? _tray;
|
||||
|
||||
protected override void OnStartup(StartupEventArgs e)
|
||||
{
|
||||
base.OnStartup(e);
|
||||
|
||||
bool automatic = StartupLaunch.IsAutomatic(e.Args);
|
||||
|
||||
var gate = new SingleInstanceGate();
|
||||
if (!gate.TryAcquire())
|
||||
if (!gate.TryAcquire(showRunningInstance: !automatic))
|
||||
{
|
||||
gate.Dispose();
|
||||
Shutdown();
|
||||
@@ -34,9 +37,26 @@ public partial class App : Application
|
||||
|
||||
_services.GetRequiredService<ThemeService>();
|
||||
|
||||
_placement = _services.GetRequiredService<MainWindowPlacement>();
|
||||
_presenter = _services.GetRequiredService<MainWindowPresenter>();
|
||||
|
||||
MainWindow = _services.GetRequiredService<MainWindow>();
|
||||
MainWindow.Show();
|
||||
|
||||
_tray = _services.GetRequiredService<ITrayIcon>();
|
||||
_tray.OpenRequested += OnOpenRequested;
|
||||
_tray.ExitRequested += OnExitRequested;
|
||||
|
||||
bool hasTray = _tray.Install();
|
||||
|
||||
if (!automatic || !hasTray)
|
||||
{
|
||||
_presenter.Show();
|
||||
}
|
||||
|
||||
if (!hasTray)
|
||||
{
|
||||
_presenter.Detach();
|
||||
ShutdownMode = ShutdownMode.OnMainWindowClose;
|
||||
}
|
||||
|
||||
_ = _services.GetRequiredService<SettingsViewModel>().InitializeAsync();
|
||||
_ = _services.GetRequiredService<UpdateViewModel>().StartAsync();
|
||||
@@ -46,6 +66,12 @@ public partial class App : Application
|
||||
|
||||
protected override void OnExit(ExitEventArgs e)
|
||||
{
|
||||
if (_tray is not null)
|
||||
{
|
||||
_tray.OpenRequested -= OnOpenRequested;
|
||||
_tray.ExitRequested -= OnExitRequested;
|
||||
}
|
||||
|
||||
_services?.Dispose();
|
||||
|
||||
if (_instanceGate is not null)
|
||||
@@ -57,21 +83,14 @@ public partial class App : Application
|
||||
base.OnExit(e);
|
||||
}
|
||||
|
||||
private void OnActivationRequested(object? sender, EventArgs e)
|
||||
private void OnActivationRequested(object? sender, EventArgs e) => _presenter?.Show();
|
||||
|
||||
private void OnOpenRequested(object? sender, EventArgs e) => _presenter?.Show();
|
||||
|
||||
private void OnExitRequested(object? sender, EventArgs e)
|
||||
{
|
||||
if (MainWindow is not { IsVisible: true })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (MainWindow.WindowState == WindowState.Minimized)
|
||||
{
|
||||
MainWindow.WindowState = WindowState.Normal;
|
||||
}
|
||||
|
||||
_placement?.Apply(MainWindow);
|
||||
|
||||
MainWindow.Activate();
|
||||
_presenter?.AllowClose();
|
||||
Shutdown();
|
||||
}
|
||||
|
||||
internal static void ConfigureServices(IServiceCollection services)
|
||||
@@ -86,6 +105,10 @@ public partial class App : Application
|
||||
services.AddSingleton<IThemeService>(provider => provider.GetRequiredService<ThemeService>());
|
||||
|
||||
services.AddSingleton<MainWindowPlacement>();
|
||||
services.AddSingleton<MainWindowPresenter>();
|
||||
|
||||
services.AddSingleton<TrayIcon>();
|
||||
services.AddSingleton<ITrayIcon>(provider => provider.GetRequiredService<TrayIcon>());
|
||||
|
||||
services.AddSingleton<ILocalizationService, LocalizationService>();
|
||||
services.AddSingleton<IStartupService, StartupService>();
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace CursorLang.Interop;
|
||||
|
||||
/// <summary>
|
||||
/// Win32 API for the notification area: the icon itself, the messages it sends
|
||||
/// and the icon image taken from the executable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The icon is asked for at version 4 of the protocol. It is the only version that
|
||||
/// reports a request for the context menu as such — by the keyboard as well as by
|
||||
/// the right button — and passes the point of the click along with it. The
|
||||
/// notification then arrives in the low word of <c>lParam</c>, and the point in
|
||||
/// <c>wParam</c>, which is the opposite of the earlier versions.
|
||||
/// </remarks>
|
||||
internal static class TrayIconNative
|
||||
{
|
||||
/// <summary>The message the icon sends to its window. WM_APP is free for the app.</summary>
|
||||
internal const int CallbackMessage = 0x8000 + 1;
|
||||
|
||||
/// <summary>The user chose the icon: a click of the left button or Enter on it.</summary>
|
||||
internal const int SelectNotification = 0x0400;
|
||||
|
||||
/// <summary>The same by the space bar — Windows tells the two apart.</summary>
|
||||
internal const int KeySelectNotification = 0x0403;
|
||||
|
||||
/// <summary>The context menu is asked for: the right button or the menu key.</summary>
|
||||
internal const int ContextMenuNotification = 0x007B;
|
||||
|
||||
private const int NIM_ADD = 0x00000000;
|
||||
private const int NIM_MODIFY = 0x00000001;
|
||||
private const int NIM_DELETE = 0x00000002;
|
||||
private const int NIM_SETVERSION = 0x00000004;
|
||||
|
||||
private const uint NIF_MESSAGE = 0x00000001;
|
||||
private const uint NIF_ICON = 0x00000002;
|
||||
private const uint NIF_TIP = 0x00000004;
|
||||
private const uint NIF_SHOWTIP = 0x00000080;
|
||||
|
||||
private const uint NotifyIconVersion4 = 4;
|
||||
|
||||
private const uint IMAGE_ICON = 1;
|
||||
private const uint LR_DEFAULTCOLOR = 0x00000000;
|
||||
|
||||
private const int SM_CXSMICON = 49;
|
||||
private const int SM_CYSMICON = 50;
|
||||
|
||||
/// <summary>The resource the .NET build puts the application icon under.</summary>
|
||||
private const int ApplicationIconResource = 32512;
|
||||
|
||||
/// <summary>
|
||||
/// Explorer says it has restarted this way. The icons of every application are
|
||||
/// gone by then and have to be put back.
|
||||
/// </summary>
|
||||
internal static int TaskbarCreatedMessage { get; } = RegisterWindowMessage("TaskbarCreated");
|
||||
|
||||
/// <summary>Puts the icon into the notification area.</summary>
|
||||
internal static bool Add(IntPtr window, int id, IntPtr icon, string tooltip)
|
||||
{
|
||||
NotifyIconData data = Describe(window, id, icon, tooltip);
|
||||
|
||||
if (!Shell_NotifyIcon(NIM_ADD, ref data))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// The version is asked for after the icon is added and applies to it alone
|
||||
data.uVersion = NotifyIconVersion4;
|
||||
Shell_NotifyIcon(NIM_SETVERSION, ref data);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Replaces the image and the tooltip of an icon already there.</summary>
|
||||
internal static bool Modify(IntPtr window, int id, IntPtr icon, string tooltip)
|
||||
{
|
||||
NotifyIconData data = Describe(window, id, icon, tooltip);
|
||||
return Shell_NotifyIcon(NIM_MODIFY, ref data);
|
||||
}
|
||||
|
||||
/// <summary>Takes the icon away. A forgotten icon stays in the tray until hovered.</summary>
|
||||
internal static void Remove(IntPtr window, int id)
|
||||
{
|
||||
var data = new NotifyIconData
|
||||
{
|
||||
cbSize = Marshal.SizeOf<NotifyIconData>(),
|
||||
hWnd = window,
|
||||
uID = (uint)id,
|
||||
};
|
||||
|
||||
Shell_NotifyIcon(NIM_DELETE, ref data);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The icon of the application at the size the tray asks for.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The image comes from the executable itself, so the tray shows what the user
|
||||
/// sees in Explorer. The build puts the icon under the standard resource; should
|
||||
/// it end up elsewhere, the first icon of the file is taken, and failing that —
|
||||
/// the icon Windows gives to an application without one. An icon is needed
|
||||
/// either way: without it the tray shows an empty spot.
|
||||
/// </remarks>
|
||||
internal static IntPtr LoadApplicationIcon()
|
||||
{
|
||||
int width = GetSystemMetrics(SM_CXSMICON);
|
||||
int height = GetSystemMetrics(SM_CYSMICON);
|
||||
|
||||
IntPtr icon = LoadImage(
|
||||
GetModuleHandle(null), ApplicationIconResource, IMAGE_ICON, width, height, LR_DEFAULTCOLOR);
|
||||
|
||||
if (icon == IntPtr.Zero && Environment.ProcessPath is { Length: > 0 } path)
|
||||
{
|
||||
icon = ExtractIconEx(path, 0, out IntPtr large, out IntPtr small, 1) > 0 ? small : IntPtr.Zero;
|
||||
|
||||
if (large != IntPtr.Zero)
|
||||
{
|
||||
DestroyIcon(large);
|
||||
}
|
||||
}
|
||||
|
||||
return icon != IntPtr.Zero ? icon : LoadIcon(IntPtr.Zero, ApplicationIconResource);
|
||||
}
|
||||
|
||||
/// <summary>Releases an icon loaded by <see cref="LoadApplicationIcon"/>.</summary>
|
||||
internal static void ReleaseIcon(IntPtr icon)
|
||||
{
|
||||
if (icon != IntPtr.Zero)
|
||||
{
|
||||
DestroyIcon(icon);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The notification the icon has sent: it sits in the low word of lParam.</summary>
|
||||
internal static int NotificationOf(IntPtr lParam) => (int)(lParam.ToInt64() & 0xFFFF);
|
||||
|
||||
/// <summary>
|
||||
/// Brings the window to the foreground.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Windows takes a menu down when its owner window loses the foreground. A tray
|
||||
/// icon belongs to a window that is never shown, so the foreground has to be
|
||||
/// asked for by hand — otherwise the menu stays on screen after the user has
|
||||
/// clicked past it.
|
||||
/// </remarks>
|
||||
internal static void BringToForeground(IntPtr window) => SetForegroundWindow(window);
|
||||
|
||||
private static NotifyIconData Describe(IntPtr window, int id, IntPtr icon, string tooltip) => new()
|
||||
{
|
||||
cbSize = Marshal.SizeOf<NotifyIconData>(),
|
||||
hWnd = window,
|
||||
uID = (uint)id,
|
||||
uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP | NIF_SHOWTIP,
|
||||
uCallbackMessage = CallbackMessage,
|
||||
hIcon = icon,
|
||||
szTip = tooltip,
|
||||
};
|
||||
|
||||
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern bool Shell_NotifyIcon(int dwMessage, ref NotifyIconData lpData);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern int RegisterWindowMessage(string lpString);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr LoadImage(IntPtr hInst, IntPtr name, uint type, int cx, int cy, uint fuLoad);
|
||||
|
||||
private static IntPtr LoadImage(IntPtr hInst, int resource, uint type, int cx, int cy, uint fuLoad) =>
|
||||
LoadImage(hInst, new IntPtr(resource), type, cx, cy, fuLoad);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr LoadIcon(IntPtr hInstance, IntPtr lpIconName);
|
||||
|
||||
private static IntPtr LoadIcon(IntPtr hInstance, int resource) =>
|
||||
LoadIcon(hInstance, new IntPtr(resource));
|
||||
|
||||
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern int ExtractIconEx(string lpszFile, int nIconIndex,
|
||||
out IntPtr phiconLarge, out IntPtr phiconSmall, int nIcons);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool DestroyIcon(IntPtr hIcon);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern int GetSystemMetrics(int nIndex);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool SetForegroundWindow(IntPtr hWnd);
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern IntPtr GetModuleHandle(string? lpModuleName);
|
||||
|
||||
/// <summary>
|
||||
/// NOTIFYICONDATAW. The whole structure is described even though only its first
|
||||
/// half is used: Windows reads its size and refuses one it does not know.
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
private struct NotifyIconData
|
||||
{
|
||||
public int cbSize;
|
||||
public IntPtr hWnd;
|
||||
public uint uID;
|
||||
public uint uFlags;
|
||||
public int uCallbackMessage;
|
||||
public IntPtr hIcon;
|
||||
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
|
||||
public string szTip;
|
||||
|
||||
public uint dwState;
|
||||
public uint dwStateMask;
|
||||
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
|
||||
public string szInfo;
|
||||
|
||||
/// <summary>A timeout in the older versions and the protocol version here.</summary>
|
||||
public uint uVersion;
|
||||
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)]
|
||||
public string szInfoTitle;
|
||||
|
||||
public uint dwInfoFlags;
|
||||
public Guid guidItem;
|
||||
public IntPtr hBalloonIcon;
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,12 @@
|
||||
<data name="SettingsTitle" xml:space="preserve">
|
||||
<value>CursorLang — Settings</value>
|
||||
</data>
|
||||
<data name="TrayMenuSettings" xml:space="preserve">
|
||||
<value>Settings</value>
|
||||
</data>
|
||||
<data name="TrayMenuExit" xml:space="preserve">
|
||||
<value>Exit</value>
|
||||
</data>
|
||||
<data name="SectionInterface" xml:space="preserve">
|
||||
<value>Interface</value>
|
||||
</data>
|
||||
|
||||
@@ -61,6 +61,12 @@
|
||||
<data name="SettingsTitle" xml:space="preserve">
|
||||
<value>CursorLang — Настройки</value>
|
||||
</data>
|
||||
<data name="TrayMenuSettings" xml:space="preserve">
|
||||
<value>Настройки</value>
|
||||
</data>
|
||||
<data name="TrayMenuExit" xml:space="preserve">
|
||||
<value>Выход</value>
|
||||
</data>
|
||||
<data name="SectionInterface" xml:space="preserve">
|
||||
<value>Интерфейс</value>
|
||||
</data>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace CursorLang.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The icon in the notification area — the only face of an application that works
|
||||
/// in the background.
|
||||
/// </summary>
|
||||
public interface ITrayIcon
|
||||
{
|
||||
/// <summary>The user asks for the settings window: the icon or its menu.</summary>
|
||||
event EventHandler? OpenRequested;
|
||||
|
||||
/// <summary>The user asks the application to quit. There is no other way out.</summary>
|
||||
event EventHandler? ExitRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Puts the icon into the notification area. Returns <c>false</c> when Windows
|
||||
/// refuses to take it.
|
||||
/// </summary>
|
||||
bool Install();
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
using System.ComponentModel;
|
||||
using System.Windows;
|
||||
|
||||
namespace CursorLang.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Keeps the settings window between the screen and the tray.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The application works in the background, and the settings window is a guest on
|
||||
/// the screen rather than the application itself. Both buttons in its title bar
|
||||
/// therefore mean the same thing — put the window away — and neither ends the
|
||||
/// application: that is what the tray menu is for.
|
||||
///
|
||||
/// The window is hidden rather than closed. Closing it would take its handle with
|
||||
/// it, and everything hung on the window — the theme, the place it was left in, the
|
||||
/// computed layout — would have to be built anew on every show.
|
||||
/// </remarks>
|
||||
public sealed class MainWindowPresenter
|
||||
{
|
||||
private readonly MainWindowPlacement _placement;
|
||||
|
||||
private Window? _window;
|
||||
|
||||
// The application is quitting: the window may close for real now
|
||||
private bool _isExiting;
|
||||
|
||||
public MainWindowPresenter(MainWindowPlacement placement) => _placement = placement;
|
||||
|
||||
/// <summary>Whether the window is on the screen right now.</summary>
|
||||
public bool IsShown => _window is { IsVisible: true };
|
||||
|
||||
/// <summary>Takes over the fate of the window: the close button and the minimise one.</summary>
|
||||
public void Attach(Window window)
|
||||
{
|
||||
_window = window;
|
||||
|
||||
window.Closing += OnClosing;
|
||||
window.StateChanged += OnStateChanged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows the window and brings it forward — from the tray as well as from a
|
||||
/// second launch of the application.
|
||||
/// </summary>
|
||||
public void Show()
|
||||
{
|
||||
if (_window is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_window.Show();
|
||||
|
||||
// A window put away while minimised is restored first: only then does it
|
||||
// have bounds to be placed by
|
||||
if (_window.WindowState == WindowState.Minimized)
|
||||
{
|
||||
_window.WindowState = WindowState.Normal;
|
||||
}
|
||||
|
||||
_placement.Apply(_window);
|
||||
_window.Activate();
|
||||
}
|
||||
|
||||
/// <summary>Puts the window away into the tray.</summary>
|
||||
public void Hide()
|
||||
{
|
||||
if (_window is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_window.Hide();
|
||||
|
||||
// The state is straightened out after the window is out of sight: the next
|
||||
// show is to bring back a window, not an icon on the taskbar
|
||||
_window.WindowState = WindowState.Normal;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lets the window close. Called on the way out through the tray menu — up to
|
||||
/// that moment closing means hiding.
|
||||
/// </summary>
|
||||
public void AllowClose() => _isExiting = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gives the window back its usual behaviour: the close button closes, and the
|
||||
/// minimise button minimises.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The last resort for a session with no notification area in it. Without the
|
||||
/// icon there is no way back to a hidden window and no way out of the
|
||||
/// application — the window has to take both duties back.
|
||||
/// </remarks>
|
||||
public void Detach()
|
||||
{
|
||||
if (_window is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_window.Closing -= OnClosing;
|
||||
_window.StateChanged -= OnStateChanged;
|
||||
_window = null;
|
||||
}
|
||||
|
||||
private void OnClosing(object? sender, CancelEventArgs e)
|
||||
{
|
||||
if (_isExiting)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
e.Cancel = true;
|
||||
Hide();
|
||||
}
|
||||
|
||||
private void OnStateChanged(object? sender, EventArgs e)
|
||||
{
|
||||
if (_window is { WindowState: WindowState.Minimized })
|
||||
{
|
||||
Hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,13 @@ internal sealed class RegistryStartup
|
||||
/// What Windows is to run. <c>null</c> — the path of the running program is
|
||||
/// unknown, and there is nothing to write down.
|
||||
/// </summary>
|
||||
private static string? GetCommand() =>
|
||||
Environment.ProcessPath is { Length: > 0 } path ? $"\"{path}\"" : null;
|
||||
/// <remarks>
|
||||
/// The argument is how the application recognises a launch of this kind and goes
|
||||
/// straight to the tray: see <see cref="StartupLaunch"/>. The user starting the
|
||||
/// application themselves passes no such thing and gets the window.
|
||||
/// </remarks>
|
||||
internal static string? GetCommand() =>
|
||||
Environment.ProcessPath is { Length: > 0 } path
|
||||
? $"\"{path}\" {StartupLaunch.Argument}"
|
||||
: null;
|
||||
}
|
||||
|
||||
@@ -51,7 +51,17 @@ public sealed class SingleInstanceGate : IDisposable
|
||||
/// Takes the single-instance slot. When the application is already running, asks
|
||||
/// it to show itself and returns <c>false</c> — the caller is left to exit.
|
||||
/// </summary>
|
||||
public bool TryAcquire()
|
||||
public bool TryAcquire() => TryAcquire(showRunningInstance: true);
|
||||
|
||||
/// <summary>
|
||||
/// The same, with a say in what is to happen to the application already running.
|
||||
/// </summary>
|
||||
/// <param name="showRunningInstance">
|
||||
/// Whether the running application is to be brought up. A launch by Windows
|
||||
/// itself passes <c>false</c>: it was not asked for a window, and the
|
||||
/// application already in the tray is answer enough.
|
||||
/// </param>
|
||||
public bool TryAcquire(bool showRunningInstance)
|
||||
{
|
||||
_mutex = new Mutex(initiallyOwned: false, _mutexName);
|
||||
|
||||
@@ -73,8 +83,12 @@ public sealed class SingleInstanceGate : IDisposable
|
||||
|
||||
if (!_isOwner)
|
||||
{
|
||||
ForegroundPermissionNative.GrantToAnyProcess();
|
||||
_activationRequest.Set();
|
||||
if (showRunningInstance)
|
||||
{
|
||||
ForegroundPermissionNative.GrantToAnyProcess();
|
||||
_activationRequest.Set();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using CursorLang.Interop;
|
||||
using Windows.ApplicationModel;
|
||||
using Windows.ApplicationModel.Activation;
|
||||
|
||||
namespace CursorLang.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Whether Windows started the application by itself.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A launch of its own accord ends up in the tray without a window: the user asked
|
||||
/// for the application to be there when they sign in, not for a window to greet them
|
||||
/// every morning. A launch by the user is another matter — the window is what they
|
||||
/// clicked for.
|
||||
///
|
||||
/// The two builds tell the launches apart differently. A build in a folder is
|
||||
/// started from the registry, and the command written there carries an argument of
|
||||
/// its own — see <see cref="RegistryStartup"/>. A package has no say in its command
|
||||
/// line, and Windows is asked about the activation instead.
|
||||
/// </remarks>
|
||||
internal static class StartupLaunch
|
||||
{
|
||||
/// <summary>What the registry entry adds to the path of the application.</summary>
|
||||
internal const string Argument = "--startup";
|
||||
|
||||
/// <summary>Whether this launch is the doing of Windows rather than of the user.</summary>
|
||||
internal static bool IsAutomatic(IReadOnlyList<string> arguments) =>
|
||||
HasArgument(arguments) || IsStartupActivation();
|
||||
|
||||
/// <summary>The command line says the launch comes from the startup entry.</summary>
|
||||
internal static bool HasArgument(IReadOnlyList<string> arguments) =>
|
||||
arguments.Any(argument => string.Equals(argument, Argument, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static bool IsStartupActivation()
|
||||
{
|
||||
if (!PackageIdentityNative.IsPackaged)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return AppInstance.GetActivatedEventArgs() is { Kind: ActivationKind.StartupTask };
|
||||
}
|
||||
catch (Exception e) when (e is COMException or InvalidOperationException or NotSupportedException)
|
||||
{
|
||||
// Windows has nothing to say about the activation. A window shown when it
|
||||
// was not asked for is a smaller mishap than an application that hides
|
||||
// when the user has just started it
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Controls.Primitives;
|
||||
using System.Windows.Data;
|
||||
using System.Windows.Interop;
|
||||
using CursorLang.Interop;
|
||||
|
||||
namespace CursorLang.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The icon in the notification area: a way to bring the settings window back and
|
||||
/// the only way to quit the application.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The icon belongs to a window rather than to a process, and the application has no
|
||||
/// window that is always there — the settings window is hidden most of the time.
|
||||
/// So a window of its own is created here and never shown: it exists to receive the
|
||||
/// messages of the icon.
|
||||
///
|
||||
/// The menu is a WPF one rather than a system one on purpose: that way it obeys the
|
||||
/// theme and the language chosen in the settings, like the rest of the interface.
|
||||
/// </remarks>
|
||||
public sealed class TrayIcon : ITrayIcon, IDisposable
|
||||
{
|
||||
/// <summary>Windows shows it under the pointer. The name of the app says enough.</summary>
|
||||
private const string Tooltip = "CursorLang";
|
||||
|
||||
/// <summary>Distinguishes the icon among those of the same window; we have one.</summary>
|
||||
private const int IconId = 1;
|
||||
|
||||
private readonly ILocalizationService _localization;
|
||||
|
||||
private HwndSource? _source;
|
||||
private ContextMenu? _menu;
|
||||
private IntPtr _icon;
|
||||
private bool _isInstalled;
|
||||
|
||||
public TrayIcon(ILocalizationService localization) => _localization = localization;
|
||||
|
||||
public event EventHandler? OpenRequested;
|
||||
|
||||
public event EventHandler? ExitRequested;
|
||||
|
||||
public bool Install()
|
||||
{
|
||||
if (_isInstalled)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
_source ??= CreateMessageWindow();
|
||||
_icon = TrayIconNative.LoadApplicationIcon();
|
||||
|
||||
_isInstalled = TrayIconNative.Add(_source.Handle, IconId, _icon, Tooltip);
|
||||
|
||||
return _isInstalled;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_source is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isInstalled)
|
||||
{
|
||||
TrayIconNative.Remove(_source.Handle, IconId);
|
||||
_isInstalled = false;
|
||||
}
|
||||
|
||||
TrayIconNative.ReleaseIcon(_icon);
|
||||
_icon = IntPtr.Zero;
|
||||
|
||||
_source.RemoveHook(OnMessage);
|
||||
_source.Dispose();
|
||||
_source = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The menu of the icon. Built on the first ask: the application starts more
|
||||
/// often than the user reaches for the menu.
|
||||
/// </summary>
|
||||
internal ContextMenu Menu => _menu ??= CreateMenu();
|
||||
|
||||
private HwndSource CreateMessageWindow()
|
||||
{
|
||||
// A window with no WS_VISIBLE shows nowhere, yet is a window in every other
|
||||
// way. A message-only window would do as well were it not for the news of
|
||||
// Explorer restarting: that one is broadcast, and broadcasts pass such
|
||||
// windows by
|
||||
var parameters = new HwndSourceParameters("CursorLang tray")
|
||||
{
|
||||
WindowStyle = unchecked((int)0x80000000), // WS_POPUP
|
||||
Width = 0,
|
||||
Height = 0,
|
||||
};
|
||||
|
||||
var source = new HwndSource(parameters);
|
||||
source.AddHook(OnMessage);
|
||||
|
||||
return source;
|
||||
}
|
||||
|
||||
private IntPtr OnMessage(IntPtr window, int message, IntPtr wParam, IntPtr lParam, ref bool handled)
|
||||
{
|
||||
// Explorer has restarted and taken every icon down with it
|
||||
if (message == TrayIconNative.TaskbarCreatedMessage && _isInstalled)
|
||||
{
|
||||
TrayIconNative.Add(window, IconId, _icon, Tooltip);
|
||||
handled = true;
|
||||
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
if (message != TrayIconNative.CallbackMessage)
|
||||
{
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
switch (TrayIconNative.NotificationOf(lParam))
|
||||
{
|
||||
case TrayIconNative.SelectNotification:
|
||||
case TrayIconNative.KeySelectNotification:
|
||||
OpenRequested?.Invoke(this, EventArgs.Empty);
|
||||
handled = true;
|
||||
break;
|
||||
|
||||
case TrayIconNative.ContextMenuNotification:
|
||||
ShowMenu();
|
||||
handled = true;
|
||||
break;
|
||||
}
|
||||
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raises the menu of the icon where the pointer is.
|
||||
/// </summary>
|
||||
internal void ShowMenu()
|
||||
{
|
||||
if (_source is not null)
|
||||
{
|
||||
TrayIconNative.BringToForeground(_source.Handle);
|
||||
}
|
||||
|
||||
// The menu goes where the pointer is. Windows does report the point of the
|
||||
// click, but in physical pixels, while WPF places a popup in its own units —
|
||||
// and the two only agree on a monitor scaled at 100%
|
||||
Menu.Placement = PlacementMode.MousePoint;
|
||||
Menu.IsOpen = true;
|
||||
}
|
||||
|
||||
private ContextMenu CreateMenu()
|
||||
{
|
||||
var menu = new ContextMenu();
|
||||
|
||||
menu.Items.Add(Item("TrayMenuSettings", (_, _) => OpenRequested?.Invoke(this, EventArgs.Empty)));
|
||||
menu.Items.Add(new Separator());
|
||||
menu.Items.Add(Item("TrayMenuExit", (_, _) => ExitRequested?.Invoke(this, EventArgs.Empty)));
|
||||
|
||||
return menu;
|
||||
}
|
||||
|
||||
// The caption is bound rather than assigned: the language is changed in the
|
||||
// settings without a restart, and the menu is built once
|
||||
private MenuItem Item(string key, RoutedEventHandler onClick)
|
||||
{
|
||||
var item = new MenuItem();
|
||||
|
||||
item.SetBinding(HeaderedItemsControl.HeaderProperty, new Binding($"[{key}]")
|
||||
{
|
||||
Source = _localization,
|
||||
});
|
||||
|
||||
item.Click += onClick;
|
||||
|
||||
return item;
|
||||
}
|
||||
}
|
||||
@@ -455,6 +455,66 @@
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<!-- ======================= Tray menu ======================= -->
|
||||
|
||||
<Style TargetType="ContextMenu">
|
||||
<Setter Property="Foreground" Value="{DynamicResource Theme.Foreground}" />
|
||||
<Setter Property="HasDropShadow" Value="False" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ContextMenu">
|
||||
<Border Background="{DynamicResource Theme.PopupBackground}"
|
||||
BorderBrush="{DynamicResource Theme.ControlBorder}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="4"
|
||||
Padding="3"
|
||||
SnapsToDevicePixels="True">
|
||||
<StackPanel IsItemsHost="True" KeyboardNavigation.DirectionalNavigation="Cycle" />
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="MenuItem">
|
||||
<Setter Property="Foreground" Value="{DynamicResource Theme.Foreground}" />
|
||||
<Setter Property="Padding" Value="12,6" />
|
||||
<Setter Property="MinWidth" Value="160" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="MenuItem">
|
||||
<Border x:Name="Border"
|
||||
Background="Transparent"
|
||||
CornerRadius="3"
|
||||
Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter ContentSource="Header" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsHighlighted" Value="True">
|
||||
<Setter TargetName="Border" Property="Background"
|
||||
Value="{DynamicResource Theme.SelectionBackground}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="{x:Static MenuItem.SeparatorStyleKey}" TargetType="Separator">
|
||||
<Setter Property="Height" Value="1" />
|
||||
<Setter Property="Margin" Value="8,4" />
|
||||
<Setter Property="Background" Value="{DynamicResource Theme.ControlBorder}" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Separator">
|
||||
<Border Background="{TemplateBinding Background}"
|
||||
HorizontalAlignment="Stretch"
|
||||
SnapsToDevicePixels="True" />
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ======================= ToolTip ======================= -->
|
||||
|
||||
<Style TargetType="ToolTip">
|
||||
|
||||
@@ -12,12 +12,17 @@ namespace CursorLang.Views;
|
||||
/// </summary>
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
public MainWindow(SettingsViewModel viewModel, IThemeService theme, MainWindowPlacement placement)
|
||||
public MainWindow(
|
||||
SettingsViewModel viewModel,
|
||||
IThemeService theme,
|
||||
MainWindowPlacement placement,
|
||||
MainWindowPresenter presenter)
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContext = viewModel;
|
||||
theme.Register(this);
|
||||
placement.Attach(this);
|
||||
presenter.Attach(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+30
-1
@@ -33,13 +33,42 @@ MSIX всегда выполняются в контексте вошедшег
|
||||
раскладки чужого окна не ограничено, поэтому сама подсказка продолжает работать
|
||||
везде.
|
||||
|
||||
## Трей
|
||||
|
||||
Приложение работает в фоне и живёт в области уведомлений. Окно настроек —
|
||||
гость на экране, а не само приложение: оно показывается после установки и
|
||||
всякий раз, когда его просят иконка или её меню. Обе кнопки в заголовке окна —
|
||||
свернуть и закрыть — убирают окно в трей и ничего не завершают; выход из
|
||||
приложения — пункт «Выход» в меню трея.
|
||||
|
||||
Окно скрывается, а не закрывается. Закрытие унесло бы с собой его дескриптор, и
|
||||
всё, что на окне держится, — тема, место, где его оставили, вычисленная
|
||||
разметка, — строилось бы заново при каждом показе.
|
||||
|
||||
Меню иконки сделано средствами WPF, а не системное: так оно держится темы и
|
||||
языка, выбранных в настройках, как и остальной интерфейс.
|
||||
|
||||
Запущенное самой Windows, приложение не показывает окна вовсе и сразу уходит в
|
||||
трей — пользователь просил, чтобы оно было на месте при входе в систему, а не
|
||||
чтобы окно встречало его каждое утро. Такой запуск две сборки распознают
|
||||
по-разному: запись в реестре у распакованной сборки несёт аргумент `--startup`, а
|
||||
пакет своей командной строкой не распоряжается, и вместо неё Windows
|
||||
спрашивают об активации.
|
||||
|
||||
Если Windows откажет иконке — в сеансе без рабочего стола области уведомлений
|
||||
нет, — окно возвращает себе обычные обязанности: показывается при любом запуске,
|
||||
а его закрытие завершает приложение. Иначе у пользователя осталось бы
|
||||
приложение, которое он не может ни увидеть, ни закрыть.
|
||||
|
||||
## Автозапуск
|
||||
|
||||
Автозапуск включается из настроек приложения тем способом, который доступен
|
||||
сборке. Пакет объявляет его в манифесте как `windows.startupTask`. Сборка,
|
||||
распакованная в папку, прописывается так, как это всегда делали программы для
|
||||
рабочего стола, — в `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, и прав
|
||||
администратора для этого не нужно.
|
||||
администратора для этого не нужно. Записанная там команда заканчивается на
|
||||
`--startup`: так приложение отличает запуск, устроенный самой Windows, от
|
||||
запуска пользователем.
|
||||
|
||||
И в том, и в другом случае Windows показывает приложение в разделе Параметры —
|
||||
Приложения — Автозагрузка; если пользователь выключит его там, приложение уже не
|
||||
|
||||
@@ -31,13 +31,41 @@ neither delivers keystrokes to the low-level hook nor accepts the layout change
|
||||
request, so the hotkey does nothing there. Reading the layout of a foreign window
|
||||
is not restricted, so the tooltip itself keeps working everywhere.
|
||||
|
||||
## The tray
|
||||
|
||||
The app works in the background and lives in the notification area. The settings
|
||||
window is a guest on the screen rather than the app itself: it shows up after the
|
||||
installation and whenever the icon or its menu is asked for. Both buttons in its
|
||||
title bar — minimise and close — put the window away into the tray and end nothing;
|
||||
the way out of the app is the Exit item of the tray menu.
|
||||
|
||||
The window is hidden rather than closed. Closing it would take its handle with it,
|
||||
and everything hung on the window — the theme, the place it was left in, the
|
||||
computed layout — would have to be built anew on every show.
|
||||
|
||||
The menu of the icon is a WPF one rather than a system one: that way it keeps to
|
||||
the theme and the language chosen in the settings, like the rest of the interface.
|
||||
|
||||
Started by Windows itself, the app shows no window at all and goes straight to the
|
||||
tray — the user asked for it to be there when they sign in, not for a window to
|
||||
greet them every morning. The two builds tell such a launch apart differently: the
|
||||
registry entry of an unpacked build carries the `--startup` argument, while a
|
||||
package has no say in its command line, and Windows is asked about the activation
|
||||
instead.
|
||||
|
||||
Should Windows refuse the icon — there is no notification area in a session without
|
||||
a desktop — the window takes its usual duties back: it shows up whatever the launch
|
||||
was, and closing it ends the app. The alternative would be an app the user can
|
||||
neither see nor quit.
|
||||
|
||||
## Startup
|
||||
|
||||
Startup is switched on from the app's settings by whichever means the build has.
|
||||
A package declares it in the manifest as a `windows.startupTask`. A build unpacked
|
||||
into a folder registers itself the way desktop programs always have — under
|
||||
`HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, which needs no administrator
|
||||
rights.
|
||||
rights. The command written there ends with `--startup`: that is how the app tells
|
||||
a launch of Windows' own doing from a launch by the user.
|
||||
|
||||
Either way Windows lists the app in Settings — Apps — Startup; if the user turns
|
||||
it off there, the app can no longer turn it back on and says so instead of
|
||||
|
||||
Reference in New Issue
Block a user