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
+1 -1
View File
@@ -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
View File
@@ -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>();
+226
View File
@@ -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;
}
}
+6
View File
@@ -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>
+6
View File
@@ -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>
+20
View File
@@ -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();
}
+126
View File
@@ -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();
}
}
}
+9 -2
View File
@@ -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;
}
+17 -3
View File
@@ -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;
}
+54
View File
@@ -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;
}
}
}
+181
View File
@@ -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;
}
}
+60
View File
@@ -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">
+6 -1
View File
@@ -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>