added tray menu

This commit is contained in:
2026-08-12 04:59:22 +05:00
parent 08192d317e
commit a0d3098fe4
21 changed files with 1327 additions and 42 deletions
@@ -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);
}
}
+213
View File
@@ -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));
}