11 Commits
Author SHA1 Message Date
alex 42e93346ab Merge pull request 'fixed a bug with displaying the application version in the window title' (#13) from fix-app-version into master
Release / release (push) Successful in 3m32s
Reviewed-on: #13
2026-08-31 06:52:05 +00:00
alex 9ab26afc16 fixed a bug with displaying the application version in the window title
Pull request / build (pull_request) Successful in 29s
2026-08-31 11:51:13 +05:00
alex 743974a0c1 added comments to readme (#12)
Release / release (push) Successful in 3m47s
Reviewed-on: #12
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
2026-08-15 02:56:58 +00:00
alex 9c6ec489e1 modified caret mode (#11)
Reviewed-on: #11
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
2026-08-14 23:07:20 +00:00
alex b2c7b31eb5 take the artifact action from a fork of our own (#10)
Release / release (push) Successful in 4m31s
Upstream v4 refuses to run anywhere but github.com: it reads
GITHUB_SERVER_URL, calls everything else a GitHub Enterprise Server and
throws before making a request. A probe run settled the rest — the name
cannot be overridden from a step, because the runner sets the GITHUB_
ones last. So the action now comes from this Gitea, forked with that one
check removed, on a fixed tag rather than a floating one.

Reviewed-on: #10
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
2026-08-14 00:26:27 +00:00
alex 5670ad0e64 fixed isGhes func in release pipeline (#9)
Release / release (push) Failing after 4m12s
Reviewed-on: #9
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
2026-08-13 23:34:38 +00:00
alex 1540be3b79 fix wix installer bug (#8)
Release / release (push) Failing after 4m3s
Reviewed-on: #8
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
2026-08-13 22:02:29 +00:00
alex 88dd931cb4 added sending closing event message to settings windows (#7)
Release / release (push) Successful in 5m7s
Reviewed-on: #7
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
2026-08-13 21:20:03 +00:00
alex 70b83a5fec reflow to vertical (#6)
Reviewed-on: #6
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
2026-08-13 21:13:28 +00:00
alex 0682f2b875 fixed warning 2026-08-14 02:07:56 +05:00
alex 5dea510bbc fixed msi pipeline
Release / release (push) Successful in 5m10s
2026-08-13 17:13:51 +05:00
17 changed files with 808 additions and 429 deletions
+18 -7
View File
@@ -138,9 +138,22 @@ jobs:
- name: Build the installer - name: Build the installer
run: ./Packaging/build-installer.ps1 -Version ${{ steps.version.outputs.plain }} run: ./Packaging/build-installer.ps1 -Version ${{ steps.version.outputs.plain }}
# The artifact is where the package waits to be uploaded to Partner Center # The artifact is where the package waits to be uploaded to Partner Center.
#
# The action comes from this Gitea rather than from github.com, and it is
# a fork of actions/upload-artifact with one line changed. Upstream reads
# GITHUB_SERVER_URL, takes every host but github.com and its enterprise
# ones for a GitHub Enterprise Server — where the v4 artifact backend does
# not exist — and throws before it makes a request. Here that backend does
# exist and answers, but the check runs on the runner ahead of the network
# and never lets it prove itself; nor can the name be corrected from this
# file, as the runner sets the GITHUB_ ones over the env of a step. The
# fork carries the reasoning in full in its GITEA-PATCH.md.
#
# The tag is a fixed one. A floating tag is how this broke before: the
# action is fetched at run time, and what arrives can change on its own
- name: Keep the package - name: Keep the package
uses: actions/upload-artifact@v4 uses: https://git.alrakis.kz/actions/upload-artifact@v4-gitea
with: with:
name: msix-${{ steps.version.outputs.version }} name: msix-${{ steps.version.outputs.version }}
path: artifacts/packages/ path: artifacts/packages/
@@ -149,7 +162,7 @@ jobs:
# The .wixpdb next to each installer is left out on purpose: it is of use # The .wixpdb next to each installer is left out on purpose: it is of use
# only when something has to be traced back to the WiX source # only when something has to be traced back to the WiX source
- name: Keep the installer - name: Keep the installer
uses: actions/upload-artifact@v4 uses: https://git.alrakis.kz/actions/upload-artifact@v4-gitea
with: with:
name: installer-${{ steps.version.outputs.plain }} name: installer-${{ steps.version.outputs.plain }}
path: artifacts/installers/*.msi path: artifacts/installers/*.msi
@@ -167,10 +180,8 @@ jobs:
# The GITHUB_ names are what Gitea itself hands to the workflow — its # The GITHUB_ names are what Gitea itself hands to the workflow — its
# actions repeat those of GitHub, and the addresses in them point at # actions repeat those of GitHub, and the addresses in them point at
# this Gitea instance. GITHUB_API_URL used not to reach the steps at # this Gitea instance
# all, so the address is put together from the server one when empty $api = "$env:GITHUB_API_URL/repos/$env:GITHUB_REPOSITORY/releases"
$root = if ($env:GITHUB_API_URL) { $env:GITHUB_API_URL } else { "$env:GITHUB_SERVER_URL/api/v1" }
$api = "$root/repos/$env:GITHUB_REPOSITORY/releases"
$headers = @{ Authorization = "token $env:GITEA_TOKEN" } $headers = @{ Authorization = "token $env:GITEA_TOKEN" }
# Gitea makes a release of its own for a pushed tag, so the release is # Gitea makes a release of its own for a pushed tag, so the release is
+28
View File
@@ -133,6 +133,34 @@ public sealed partial class EndToEndTests
Assert.False(launch.Process.HasExited); Assert.False(launch.Process.HasExited);
} }
/// <summary>
/// "Exit" in the tray menu ends the application whole: the settings window goes with
/// the agent instead of staying on the screen belonging to nothing.
/// </summary>
/// <remarks>
/// The menu itself is out of reach of a test — it is a <c>TrackPopupMenuEx</c> menu
/// with a modal loop of its own — so what is checked is the request the menu makes.
/// The settings window here is the real one, started by the agent, and it must be
/// listening by the time it is on the screen.
/// </remarks>
[Fact]
public void The_agents_exit_closes_the_settings_window()
{
using Launch launch = Launch.Start();
launch.WaitForSettingsWindow();
Process settings = Launch.SettingsProcesses().Single();
try
{
Assert.True(SettingsCloseSignal.RequestClose(), "the settings window was not listening");
Assert.True(settings.WaitForExit(ExitTimeout), "the settings window outlived the agent's exit");
}
finally
{
settings.Dispose();
}
}
/// <summary>A started application that shuts down together with the check.</summary> /// <summary>A started application that shuts down together with the check.</summary>
private sealed class Launch : IDisposable private sealed class Launch : IDisposable
{ {
+8 -1
View File
@@ -125,5 +125,12 @@ internal sealed class Agent : IDisposable
private void OnOpenRequested(object? sender, EventArgs e) => SettingsLauncher.Open(); private void OnOpenRequested(object? sender, EventArgs e) => SettingsLauncher.Open();
private void OnExitRequested(object? sender, EventArgs e) => _window.Quit(); // "Exit" means the application, not just the background half of it. A settings
// window left open would outlive the tray icon it was opened from, so it is asked
// to close first — it may be the very window the user is looking at
private void OnExitRequested(object? sender, EventArgs e)
{
SettingsCloseSignal.RequestClose();
_window.Quit();
}
} }
+2 -2
View File
@@ -19,8 +19,8 @@
<PublishTrimmed>false</PublishTrimmed> <PublishTrimmed>false</PublishTrimmed>
<PublishReadyToRun Condition="'$(RuntimeIdentifier)' != ''">true</PublishReadyToRun> <PublishReadyToRun Condition="'$(RuntimeIdentifier)' != ''">true</PublishReadyToRun>
<Version>1.0.0</Version> <Version>1.0.0</Version>
<AssemblyVersion>1.0.0.0</AssemblyVersion> <AssemblyVersion>$(Version)</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion> <FileVersion>$(Version)</FileVersion>
<Product>CursorLang</Product> <Product>CursorLang</Product>
<Company>Aleksandr Neichev</Company> <Company>Aleksandr Neichev</Company>
<Description>Shows the keyboard layout at the cursor</Description> <Description>Shows the keyboard layout at the cursor</Description>
+62 -38
View File
@@ -68,42 +68,42 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
return; return;
} }
bool atFixedPoint = _settings.PlacementMode == PopupPlacementMode.FixedPoint; PopupWindowNative.Rect? anchor = TryGetAnchor();
PopupModeSettings mode = ModeFor(anchor);
PopupWindowNative.Rect work = default; PopupWindowNative.Rect work = default;
PopupWindowNative.Rect anchor = default;
double scale; double scale;
if (atFixedPoint) if (anchor is { } at)
{ {
(work, scale) = PopupWindowNative.GetActiveMonitorWorkArea(); scale = PopupWindowNative.GetScaleAt(new PopupWindowNative.Point { X = at.Left, Y = at.Top });
} }
else else
{ {
anchor = GetAnchor(); (work, scale) = PopupWindowNative.GetActiveMonitorWorkArea();
scale = PopupWindowNative.GetScaleAt(new PopupWindowNative.Point { X = anchor.Left, Y = anchor.Top });
} }
EnsureFont(scale); EnsureFont(mode, scale);
Size measured = MeasureText(text); Size measured = MeasureText(text);
int width = measured.Width + (2 * PopupLayout.ToPixels(PaddingX, scale)); int width = measured.Width + (2 * PopupLayout.ToPixels(PaddingX, scale));
int height = measured.Height + (2 * PopupLayout.ToPixels(PaddingY, scale)); int height = measured.Height + (2 * PopupLayout.ToPixels(PaddingY, scale));
PopupWindowNative.Point position = atFixedPoint PopupWindowNative.Point position = anchor is { } near
? PopupLayout.OnScreen( ? PopupLayout.NearAnchor(
work, near,
_settings.FixedPoint.Position, SideForMode(),
PopupLayout.ToPixels(_settings.FixedPoint.Offset, scale), PopupLayout.ToPixels(mode.Offset, scale),
width, width,
height) height)
: PopupLayout.NearAnchor( : PopupLayout.OnScreen(
anchor, work,
SideForMode(), _settings.FixedPoint.Position,
PopupLayout.ToPixels(_settings.Current.Offset, scale), PopupLayout.ToPixels(mode.Offset, scale),
width, width,
height); height);
if (!Draw(text, position, width, height, PopupLayout.ToPixels(CornerRadius, scale))) if (!Draw(mode, text, position, width, height, PopupLayout.ToPixels(CornerRadius, scale)))
{ {
return; return;
} }
@@ -139,7 +139,8 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
/// kilobytes for the length of one call, the popup is shown rarely, and a cached one /// kilobytes for the length of one call, the popup is shown rarely, and a cached one
/// would have to be rebuilt on every change of size, colour or scale anyway. /// would have to be rebuilt on every change of size, colour or scale anyway.
/// </remarks> /// </remarks>
private bool Draw(string text, PopupWindowNative.Point at, int width, int height, int radius) private bool Draw(
PopupModeSettings mode, string text, PopupWindowNative.Point at, int width, int height, int radius)
{ {
IntPtr screen = GdiNative.GetDC(IntPtr.Zero); IntPtr screen = GdiNative.GetDC(IntPtr.Zero);
if (screen == IntPtr.Zero) if (screen == IntPtr.Zero)
@@ -166,8 +167,8 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
GdiNative.SelectObject(memory, surface); GdiNative.SelectObject(memory, surface);
Fill(bits, width, height); Fill(mode, bits, width, height);
DrawText(memory, text, width, height); DrawText(mode, memory, text, width, height);
// GDI writes nothing into the alpha channel, so the letters it just drew are // GDI writes nothing into the alpha channel, so the letters it just drew are
// sitting at zero alpha and would come out invisible. The inside of the // sitting at zero alpha and would come out invisible. The inside of the
@@ -178,7 +179,7 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
RoundTheCorners(bits, width, height, radius); RoundTheCorners(bits, width, height, radius);
var size = new WindowNative.Size { Width = width, Height = height }; var size = new WindowNative.Size { Width = width, Height = height };
var alpha = (byte)Math.Clamp(Math.Round(_settings.Current.Opacity * 255), 0, 255); var alpha = (byte)Math.Clamp(Math.Round(mode.Opacity * 255), 0, 255);
return WindowNative.SetContent(Handle, at, size, memory, alpha); return WindowNative.SetContent(Handle, at, size, memory, alpha);
} }
@@ -200,9 +201,9 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
} }
} }
private void Fill(IntPtr bits, int width, int height) private static void Fill(PopupModeSettings mode, IntPtr bits, int width, int height)
{ {
Color background = _settings.Current.BackgroundColor; Color background = mode.BackgroundColor;
// Straight into the bitmap rather than through a brush: the pixels have to be // Straight into the bitmap rather than through a brush: the pixels have to be
// written anyway to carry an alpha channel GDI would not touch // written anyway to carry an alpha channel GDI would not touch
@@ -216,7 +217,8 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
} }
} }
private void DrawText(IntPtr deviceContext, string text, int width, int height) private void DrawText(
PopupModeSettings mode, IntPtr deviceContext, string text, int width, int height)
{ {
if (_font == IntPtr.Zero || text.Length == 0) if (_font == IntPtr.Zero || text.Length == 0)
{ {
@@ -227,7 +229,7 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
IntPtr previousFont = GdiNative.SelectObject(deviceContext, _font); IntPtr previousFont = GdiNative.SelectObject(deviceContext, _font);
GdiNative.SetBkMode(deviceContext, GdiNative.TRANSPARENT); GdiNative.SetBkMode(deviceContext, GdiNative.TRANSPARENT);
GdiNative.SetTextColor(deviceContext, GdiNative.ToColorRef(_settings.Current.ForegroundColor)); GdiNative.SetTextColor(deviceContext, GdiNative.ToColorRef(mode.ForegroundColor));
GdiNative.DrawText(deviceContext, text, text.Length, ref bounds, GdiNative.DrawText(deviceContext, text, text.Length, ref bounds,
GdiNative.DT_SINGLELINE | GdiNative.DT_CENTER | GdiNative.DT_VCENTER | GdiNative.DT_SINGLELINE | GdiNative.DT_CENTER | GdiNative.DT_VCENTER |
@@ -329,18 +331,40 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
return (int)((alpha << 24) | (red << 16) | (green << 8) | blue); return (int)((alpha << 24) | (red << 16) | (green << 8) | blue);
} }
// The anchor point: the caret in the input field or the mouse cursor. The cursor /// <summary>
// is a rectangle of zero size, so the corner computation is shared by both /// What the popup is placed next to, or <c>null</c> when there is nothing: the fixed
private PopupWindowNative.Rect GetAnchor() /// point mode, and the caret mode where the application reports no caret.
/// </summary>
/// <remarks>
/// The cursor is a rectangle of zero size, so the corner arithmetic is shared by it
/// and the caret.
/// </remarks>
private PopupWindowNative.Rect? TryGetAnchor() => _settings.PlacementMode switch
{ {
if (_settings.PlacementMode == PopupPlacementMode.AtCaret && PopupPlacementMode.FixedPoint => null,
CaretNative.TryGetCaretRect() is { } caret) PopupPlacementMode.AtCaret => CaretNative.TryGetCaretRect(),
{ _ => PopupLayout.AsAnchor(PopupWindowNative.GetCursorPosition()),
return caret; };
}
return PopupLayout.AsAnchor(PopupWindowNative.GetCursorPosition()); /// <summary>
} /// The settings the popup is shown with: those of the mode chosen, or those of the
/// fixed point when there is no anchor to stand next to.
/// </summary>
/// <remarks>
/// The caret mode falls back to the fixed point rather than to the mouse cursor:
/// the cursor is wherever it was last left — off to a side, on another monitor, or
/// over the very text being typed — and a popup that lands there while the eyes are
/// on the caret is one that is looked for and not found. The fixed point is always
/// in the same place, so it is known where to look.
///
/// The look comes from the fixed point mode too, not just the place. The two are set
/// up together for a reason: the popup by the caret is small and quiet because it
/// sits inside a text being read, while the one in the corner of the monitor is
/// looked for on purpose and is set larger. Keeping the caret look at the corner
/// would put a popup meant to go unnoticed where nothing else draws the eye.
/// </remarks>
private PopupModeSettings ModeFor(PopupWindowNative.Rect? anchor) =>
anchor is null ? _settings.FixedPoint : _settings.Current;
// The caret has two sides to choose from and the cursor has six, so each mode names // The caret has two sides to choose from and the cursor has six, so each mode names
// its own side in its own terms // its own side in its own terms
@@ -373,10 +397,10 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
// The font is rebuilt only when the size in the settings or the monitor scale // The font is rebuilt only when the size in the settings or the monitor scale
// changes: it is the one expensive thing a show does. A switch of the placement // changes: it is the one expensive thing a show does. A switch of the placement
// mode counts as a change of the size, since the size belongs to the mode // mode counts as a change of the size, since the size belongs to the mode
private void EnsureFont(double scale) private void EnsureFont(PopupModeSettings mode, double scale)
{ {
if (_font != IntPtr.Zero && if (_font != IntPtr.Zero &&
Math.Abs(_fontSize - _settings.Current.FontSize) < 0.01 && Math.Abs(_fontSize - mode.FontSize) < 0.01 &&
Math.Abs(_fontScale - scale) < 0.01) Math.Abs(_fontScale - scale) < 0.01)
{ {
return; return;
@@ -384,7 +408,7 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
ReleaseFont(); ReleaseFont();
_fontSize = _settings.Current.FontSize; _fontSize = mode.FontSize;
_fontScale = scale; _fontScale = scale;
_font = GdiNative.CreateFont(_fontSize, scale); _font = GdiNative.CreateFont(_fontSize, scale);
} }
@@ -0,0 +1,115 @@
using System.Collections.Concurrent;
using CursorLang.Core.Services;
using CursorLang.Tests.Shared;
namespace CursorLang.Core.Tests.Services;
/// <summary>
/// The one thing the agent says to the settings window: quit with me.
/// </summary>
/// <remarks>
/// The event names here are the tests' own. The application's name is fixed, and a test
/// listening on it would answer for a settings window someone is using — or, signalling,
/// close it.
/// </remarks>
public sealed class SettingsCloseSignalTests
{
[Fact]
public void The_request_reaches_the_settings_window()
{
string suffix = UniqueSuffix();
var signal = new SettingsCloseSignal(suffix);
ConcurrentQueue<EventArgs> requests = new();
signal.CloseRequested += (_, e) => requests.Enqueue(e);
try
{
signal.Listen();
Assert.True(RequestApart(suffix), "the request found nobody listening");
Pump.WaitFor(() => !requests.IsEmpty, "the settings window got the request to close");
}
finally
{
signal.Dispose();
}
}
// The usual case: the user quits from the tray with no settings window on the screen
[Fact]
public void A_request_with_no_settings_window_open_passes_without_consequence()
{
Assert.False(RequestApart(UniqueSuffix()));
}
// The window has closed on its own, and the process is on its way out anyway
[Fact]
public void No_request_arrives_after_the_window_is_gone()
{
string suffix = UniqueSuffix();
var signal = new SettingsCloseSignal(suffix);
ConcurrentQueue<EventArgs> requests = new();
signal.CloseRequested += (_, e) => requests.Enqueue(e);
signal.Listen();
signal.Dispose();
RequestApart(suffix);
Pump.Pause(TimeSpan.FromMilliseconds(80));
Assert.Empty(requests);
}
[Fact]
public void Listening_twice_leaves_one_listener()
{
string suffix = UniqueSuffix();
var signal = new SettingsCloseSignal(suffix);
var requests = 0;
signal.CloseRequested += (_, _) => Interlocked.Increment(ref requests);
try
{
signal.Listen();
signal.Listen();
Assert.True(RequestApart(suffix));
Pump.WaitFor(() => Volatile.Read(ref requests) > 0, "the request arrived");
Pump.Pause(TimeSpan.FromMilliseconds(80));
Assert.Equal(1, Volatile.Read(ref requests));
}
finally
{
signal.Dispose();
}
}
[Fact]
public void Closing_without_listening_passes_without_consequence()
{
var signal = new SettingsCloseSignal(UniqueSuffix());
signal.Dispose();
signal.Dispose();
}
// Every test gets a namespace of kernel objects of its own
private static string UniqueSuffix() => "." + Guid.NewGuid().ToString("N");
/// <summary>
/// Asks for the close the way the agent does it — from another process, and here
/// from another thread, which is as foreign as a test can get.
/// </summary>
private static bool RequestApart(string suffix)
{
var heard = false;
Pump.RunApart(() => heard = SettingsCloseSignal.RequestClose(suffix));
return heard;
}
}
+2 -2
View File
@@ -113,7 +113,7 @@ internal static class CaretNative
private static PopupWindowNative.Rect? TryGetAutomationCaret() private static PopupWindowNative.Rect? TryGetAutomationCaret()
{ {
// A hung application must not hang the popup along with it: we wait for the // A hung application must not hang the popup along with it: we wait for the
// answer for a limited time, otherwise we show the popup at the cursor // answer for a limited time, otherwise the popup goes to the fixed point
Task<PopupWindowNative.Rect?> query = Task.Run(QueryAutomationCaret); Task<PopupWindowNative.Rect?> query = Task.Run(QueryAutomationCaret);
return query.Wait(AutomationTimeout) ? query.Result : null; return query.Wait(AutomationTimeout) ? query.Result : null;
} }
@@ -165,7 +165,7 @@ internal static class CaretNative
} }
#else #else
// Built without UI Automation: Chromium and Electron keep the system caret and MSAA // Built without UI Automation: Chromium and Electron keep the system caret and MSAA
// steps above, and where those stay silent the popup falls back to the cursor // steps above, and where those stay silent the popup falls back to the fixed point
private static PopupWindowNative.Rect? TryGetAutomationCaret() => null; private static PopupWindowNative.Rect? TryGetAutomationCaret() => null;
#endif #endif
+2 -1
View File
@@ -10,7 +10,8 @@ public enum PopupPlacementMode
/// <summary> /// <summary>
/// Next to the caret in the active input field. When the application does not /// Next to the caret in the active input field. When the application does not
/// report its position, the popup is shown at the mouse cursor. /// report its position, the popup is shown as in <see cref="FixedPoint"/> — at the
/// place and with the look that mode is set up with.
/// </summary> /// </summary>
AtCaret, AtCaret,
@@ -0,0 +1,98 @@
namespace CursorLang.Core.Services;
/// <summary>
/// Tells the settings window that the agent is quitting and it is to close with it.
/// </summary>
/// <remarks>
/// The one thing the agent says to the settings window, and the mirror image of
/// <see cref="SettingsSignal"/>. "Exit" in the tray menu means the application is done
/// with, and a settings window left alone on the screen after it is a window belonging
/// to nothing: the tray icon it was opened from is gone, and closing it would be the
/// user's only remaining move.
///
/// A named event rather than a window message, because the agent has no handle to send
/// one to: the settings window lives in a process the agent starts and deliberately
/// does not keep hold of. The name has no <c>Global</c> prefix, so it lives in the
/// session namespace — same reasoning as <see cref="SingleInstanceGate"/>, and the same
/// consequence: with fast user switching each user's halves talk to their own.
///
/// Only the settings window creates the object; the agent opens what is already there
/// and stays silent when there is nothing. Were it the other way round, the request
/// would sit in an auto-reset event waiting for the next settings window to open and
/// close it the moment it did.
/// </remarks>
internal sealed class SettingsCloseSignal : IDisposable
{
private const string EventName = "CursorLang.CloseSettings";
private readonly string _eventName;
private EventWaitHandle? _request;
private RegisteredWaitHandle? _wait;
/// <summary>
/// Listens on the name the two halves agree on.
/// </summary>
/// <param name="nameSuffix">
/// A namespace of its own. Empty for the application; the tests pass one so that
/// they do not answer for — or worse, close — a settings window someone is using.
/// </param>
internal SettingsCloseSignal(string nameSuffix = "") => _eventName = EventName + nameSuffix;
/// <summary>The agent asks for the window to be closed.</summary>
/// <remarks>
/// Raised on a thread pool thread, wherever the wait happened to be answered — a
/// window obeys only its own, so the handler has to get back to it.
/// </remarks>
internal event EventHandler? CloseRequested;
/// <summary>
/// Asks the settings window of this session, if one is open, to close. Silence is
/// a normal answer: most of the time the user quits with no window on the screen.
/// </summary>
/// <returns>Whether there was anybody to hear it.</returns>
internal static bool RequestClose(string nameSuffix = "")
{
if (!EventWaitHandle.TryOpenExisting(EventName + nameSuffix, out EventWaitHandle? request))
{
return false;
}
using (request)
{
return request.Set();
}
}
/// <summary>Starts waiting for the request. Called once, by the settings window.</summary>
internal void Listen()
{
if (_request is not null)
{
return;
}
_request = new EventWaitHandle(false, EventResetMode.AutoReset, _eventName);
// As in the gate: the thread pool holds the wait, there is no reason to keep a
// thread of our own for a request that may never come
_wait = ThreadPool.RegisterWaitForSingleObject(
_request,
OnCloseSignalled,
state: null,
Timeout.Infinite,
executeOnlyOnce: false);
}
public void Dispose()
{
_wait?.Unregister(null);
_wait = null;
_request?.Dispose();
_request = null;
}
private void OnCloseSignalled(object? state, bool timedOut) =>
CloseRequested?.Invoke(this, EventArgs.Empty);
}
@@ -1,6 +1,5 @@
using CursorLang.Core.Models; using CursorLang.Core.Models;
using CursorLang.Core.Services; using CursorLang.Core.Services;
using CursorLang.Settings.Tests.Infrastructure;
using CursorLang.Settings.ViewModels; using CursorLang.Settings.ViewModels;
using CursorLang.Tests.Shared; using CursorLang.Tests.Shared;
+32
View File
@@ -26,6 +26,7 @@ public partial class App : Application
{ {
private ServiceProvider? _services; private ServiceProvider? _services;
private SingleInstanceGate? _instanceGate; private SingleInstanceGate? _instanceGate;
private SettingsCloseSignal? _closeSignal;
protected override void OnStartup(StartupEventArgs e) protected override void OnStartup(StartupEventArgs e)
{ {
@@ -42,6 +43,10 @@ public partial class App : Application
_instanceGate = gate; _instanceGate = gate;
_instanceGate.ActivationRequested += OnActivationRequested; _instanceGate.ActivationRequested += OnActivationRequested;
_closeSignal = new SettingsCloseSignal();
_closeSignal.CloseRequested += OnCloseRequested;
_closeSignal.Listen();
var services = new ServiceCollection(); var services = new ServiceCollection();
ConfigureServices(services); ConfigureServices(services);
_services = services.BuildServiceProvider(); _services = services.BuildServiceProvider();
@@ -61,6 +66,12 @@ public partial class App : Application
{ {
_services?.Dispose(); _services?.Dispose();
if (_closeSignal is not null)
{
_closeSignal.CloseRequested -= OnCloseRequested;
_closeSignal.Dispose();
}
if (_instanceGate is not null) if (_instanceGate is not null)
{ {
_instanceGate.ActivationRequested -= OnActivationRequested; _instanceGate.ActivationRequested -= OnActivationRequested;
@@ -95,6 +106,27 @@ public partial class App : Application
private void OnActivationRequested(object? sender, EventArgs e) => private void OnActivationRequested(object? sender, EventArgs e) =>
Dispatcher.BeginInvoke(ShowMainWindow); Dispatcher.BeginInvoke(ShowMainWindow);
// The agent is quitting. Answered on a thread pool thread, and the window is closed
// on its own one
private void OnCloseRequested(object? sender, EventArgs e) =>
Dispatcher.BeginInvoke(CloseMainWindow);
/// <summary>
/// Closes the window the way the title bar button does, so that everything hanging
/// off closing happens; <see cref="ShutdownMode.OnMainWindowClose"/> ends the process
/// after it. Without a window there is nothing to close and the process simply ends.
/// </summary>
private void CloseMainWindow()
{
if (MainWindow is { } window)
{
window.Close();
return;
}
Shutdown();
}
private void ShowMainWindow() private void ShowMainWindow()
{ {
if (MainWindow is not { } window) if (MainWindow is not { } window)
@@ -21,8 +21,8 @@
<PublishTrimmed>false</PublishTrimmed> <PublishTrimmed>false</PublishTrimmed>
<PublishReadyToRun Condition="'$(RuntimeIdentifier)' != ''">true</PublishReadyToRun> <PublishReadyToRun Condition="'$(RuntimeIdentifier)' != ''">true</PublishReadyToRun>
<Version>1.0.0</Version> <Version>1.0.0</Version>
<AssemblyVersion>1.0.0.0</AssemblyVersion> <AssemblyVersion>$(Version)</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion> <FileVersion>$(Version)</FileVersion>
<Product>CursorLang</Product> <Product>CursorLang</Product>
<Company>Aleksandr Neichev</Company> <Company>Aleksandr Neichev</Company>
<Description>Settings window of CursorLang</Description> <Description>Settings window of CursorLang</Description>
+324 -346
View File
@@ -8,7 +8,7 @@
mc:Ignorable="d" mc:Ignorable="d"
d:DataContext="{d:DesignInstance Type=vm:SettingsViewModel}" d:DataContext="{d:DesignInstance Type=vm:SettingsViewModel}"
Title="{Binding Title}" Title="{Binding Title}"
Width="1000" SizeToContent="Height" MaxHeight="900" Width="560" SizeToContent="Height" MaxHeight="940"
ResizeMode="CanMinimize" ResizeMode="CanMinimize"
Background="{DynamicResource Theme.WindowBackground}" Background="{DynamicResource Theme.WindowBackground}"
Foreground="{DynamicResource Theme.Foreground}"> Foreground="{DynamicResource Theme.Foreground}">
@@ -59,370 +59,348 @@
</Style> </Style>
</Window.Resources> </Window.Resources>
<!-- The settings are laid out in two columns: this way the window fits on the
screen entirely and does without scrolling. Scrolling is kept for the case of
a large system font, with which the content is taller than the monitor after all.
The left column holds what the popup does; the right one holds the single
section describing how and where the popup looks -->
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="16"> <ScrollViewer VerticalScrollBarVisibility="Auto" Padding="16">
<Grid> <StackPanel>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<StackPanel> <GroupBox Header="{Binding Localization[SectionInterface]}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="164" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<GroupBox Header="{Binding Localization[SectionInterface]}"> <TextBlock Style="{StaticResource FieldLabel}"
<Grid> Text="{Binding Localization[LanguageLabel]}" />
<Grid.ColumnDefinitions> <ComboBox Grid.Column="1"
<ColumnDefinition Width="164" /> ItemsSource="{Binding Localization.AvailableLanguages}"
<ColumnDefinition Width="*" /> DisplayMemberPath="DisplayName"
</Grid.ColumnDefinitions> SelectedValuePath="Code"
<Grid.RowDefinitions> SelectedValue="{Binding Settings.Language}" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Style="{StaticResource FieldLabel}" <TextBlock Grid.Row="1" Margin="0,12,12,0"
Text="{Binding Localization[LanguageLabel]}" /> Style="{StaticResource FieldLabel}"
<ComboBox Grid.Column="1" Text="{Binding Localization[ThemeLabel]}" />
ItemsSource="{Binding Localization.AvailableLanguages}" <ComboBox Grid.Row="1" Grid.Column="1" Margin="0,12,0,0"
DisplayMemberPath="DisplayName" ItemsSource="{Binding Themes}"
SelectedValuePath="Code" ItemTemplate="{StaticResource EnumOptionTemplate}"
SelectedValue="{Binding Settings.Language}" /> SelectedValuePath="Value"
SelectedValue="{Binding Settings.Theme}" />
</Grid>
</GroupBox>
<TextBlock Grid.Row="1" Margin="0,12,12,0" <GroupBox Header="{Binding Localization[SectionPopup]}">
Style="{StaticResource FieldLabel}" <Grid>
Text="{Binding Localization[ThemeLabel]}" /> <Grid.ColumnDefinitions>
<ComboBox Grid.Row="1" Grid.Column="1" Margin="0,12,0,0" <ColumnDefinition Width="164" />
ItemsSource="{Binding Themes}" <ColumnDefinition Width="*" />
ItemTemplate="{StaticResource EnumOptionTemplate}" <ColumnDefinition Width="Auto" />
SelectedValuePath="Value" </Grid.ColumnDefinitions>
SelectedValue="{Binding Settings.Theme}" /> <Grid.RowDefinitions>
</Grid> <RowDefinition Height="Auto" />
</GroupBox> <RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<GroupBox Header="{Binding Localization[SectionBehavior]}"> <TextBlock Style="{StaticResource FieldLabel}">
<Grid> <Run Text="{Binding Localization[PlacementModeLabel], Mode=OneWay}" />
<Grid.ColumnDefinitions> <InlineUIContainer BaselineAlignment="Baseline">
<ColumnDefinition Width="164" /> <TextBlock Text="{Binding Localization[MoreInfoLink]}"
<ColumnDefinition Width="*" /> Foreground="{DynamicResource Theme.Accent}"
<ColumnDefinition Width="Auto" /> TextDecorations="Underline"
</Grid.ColumnDefinitions> Cursor="Help"
<Grid.RowDefinitions> ToolTipService.InitialShowDelay="200"
<RowDefinition Height="Auto" /> ToolTipService.ShowDuration="60000">
<RowDefinition Height="Auto" /> <TextBlock.ToolTip>
<RowDefinition Height="Auto" /> <ToolTip>
<RowDefinition Height="Auto" /> <TextBlock TextWrapping="Wrap" MaxWidth="320"
<RowDefinition Height="Auto" /> Text="{Binding Localization[PopupLookPerModeHint]}" />
<RowDefinition Height="Auto" /> </ToolTip>
</Grid.RowDefinitions> </TextBlock.ToolTip>
</TextBlock>
</InlineUIContainer>
</TextBlock>
<ComboBox Grid.Column="1" Grid.ColumnSpan="2"
ItemsSource="{Binding PlacementModes}"
ItemTemplate="{StaticResource EnumOptionTemplate}"
SelectedValuePath="Value"
SelectedValue="{Binding Settings.PlacementMode}" />
<TextBlock Style="{StaticResource FieldLabel}" <!-- The "at cursor" mode: a side and an offset of its own -->
Text="{Binding Localization[DurationLabel]}" /> <TextBlock Grid.Row="1" Margin="0,12,12,0"
<Slider Grid.Column="1" Minimum="200" Maximum="5000" TickFrequency="100" Style="{StaticResource FieldLabel}"
Value="{Binding Settings.DurationMilliseconds}" /> Text="{Binding Localization[CursorCornerLabel]}"
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center"> Visibility="{Binding Settings.PlacementMode,
<TextBlock Style="{StaticResource FieldValue}" Converter={StaticResource EnumToVisibility},
Text="{Binding Settings.DurationMilliseconds, StringFormat={}{0:F0}}" /> ConverterParameter=AtCursor}" />
<TextBlock Margin="4,0,0,0" <ComboBox Grid.Row="1" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
Foreground="{DynamicResource Theme.SecondaryForeground}" ItemsSource="{Binding AnchorSides}"
Text="{Binding Localization[MillisecondsSuffix]}" /> ItemTemplate="{StaticResource EnumOptionTemplate}"
</StackPanel> SelectedValuePath="Value"
SelectedValue="{Binding Settings.AtCursor.Side}"
Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility},
ConverterParameter=AtCursor}" />
<TextBlock Grid.Row="1" Margin="0,12,12,0" <TextBlock Grid.Row="2" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}" Style="{StaticResource FieldLabel}"
Text="{Binding Localization[CapsLockLabel]}" /> Text="{Binding Localization[CursorOffsetLabel]}"
<CheckBox Grid.Row="1" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0" Visibility="{Binding Settings.PlacementMode,
IsChecked="{Binding Settings.UseCapsLockHotkey}" Converter={StaticResource EnumToVisibility},
Content="{Binding Localization[CapsLockHotkeyCheck]}" /> ConverterParameter=AtCursor}" />
<Slider Grid.Row="2" Grid.Column="1" Margin="0,12,0,0"
Minimum="0" Maximum="80" TickFrequency="1"
Value="{Binding Settings.AtCursor.Offset}"
Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility},
ConverterParameter=AtCursor}" />
<TextBlock Grid.Row="2" Grid.Column="2" Margin="12,12,0,0"
Style="{StaticResource FieldValue}"
Text="{Binding Settings.AtCursor.Offset, StringFormat={}{0:F0}}"
Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility},
ConverterParameter=AtCursor}" />
<TextBlock Grid.Row="2" Margin="0,12,12,0" <!-- The "at caret" mode: a side and an offset of its own -->
Style="{StaticResource FieldLabel}" <TextBlock Grid.Row="3" Margin="0,12,12,0"
Text="{Binding Localization[CapsLockHoldLabel]}" /> Style="{StaticResource FieldLabel}"
<Slider Grid.Row="2" Grid.Column="1" Margin="0,12,0,0" Text="{Binding Localization[CursorCornerLabel]}"
Minimum="150" Maximum="1500" TickFrequency="50" Visibility="{Binding Settings.PlacementMode,
Value="{Binding Settings.CapsLockHoldMilliseconds}" Converter={StaticResource EnumToVisibility},
IsEnabled="{Binding Settings.UseCapsLockHotkey}" /> ConverterParameter=AtCaret}" />
<StackPanel Grid.Row="2" Grid.Column="2" Margin="0,12,0,0" <ComboBox Grid.Row="3" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
Orientation="Horizontal" VerticalAlignment="Center"> ItemsSource="{Binding CaretSides}"
<TextBlock Style="{StaticResource FieldValue}" ItemTemplate="{StaticResource EnumOptionTemplate}"
Text="{Binding Settings.CapsLockHoldMilliseconds, StringFormat={}{0:F0}}" /> SelectedValuePath="Value"
<TextBlock Margin="4,0,0,0" SelectedValue="{Binding Settings.AtCaret.Side}"
Foreground="{DynamicResource Theme.SecondaryForeground}" Visibility="{Binding Settings.PlacementMode,
Text="{Binding Localization[MillisecondsSuffix]}" /> Converter={StaticResource EnumToVisibility},
</StackPanel> ConverterParameter=AtCaret}" />
<TextBlock Grid.Row="3" Grid.Column="1" Grid.ColumnSpan="2" <TextBlock Grid.Row="4" Margin="0,12,12,0"
Margin="0,6,0,0" TextWrapping="Wrap" Style="{StaticResource FieldLabel}"
Foreground="{DynamicResource Theme.SecondaryForeground}"> Text="{Binding Localization[CursorOffsetLabel]}"
<Run Text="{Binding Localization[CapsLockHoldHint], Mode=OneWay}" /> Visibility="{Binding Settings.PlacementMode,
<InlineUIContainer BaselineAlignment="Baseline"> Converter={StaticResource EnumToVisibility},
<!-- The elevation caveat lives in the tooltip to keep the section compact --> ConverterParameter=AtCaret}" />
<TextBlock Text="{Binding Localization[MoreInfoLink]}" <Slider Grid.Row="4" Grid.Column="1" Margin="0,12,0,0"
Foreground="{DynamicResource Theme.Accent}" Minimum="0" Maximum="80" TickFrequency="1"
TextDecorations="Underline" Value="{Binding Settings.AtCaret.Offset}"
Cursor="Help" Visibility="{Binding Settings.PlacementMode,
ToolTipService.InitialShowDelay="200" Converter={StaticResource EnumToVisibility},
ToolTipService.ShowDuration="60000" ConverterParameter=AtCaret}" />
Visibility="{Binding Settings.UseCapsLockHotkey, Converter={StaticResource BooleanToVisibility}}"> <TextBlock Grid.Row="4" Grid.Column="2" Margin="12,12,0,0"
<TextBlock.ToolTip> Style="{StaticResource FieldValue}"
<ToolTip> Text="{Binding Settings.AtCaret.Offset, StringFormat={}{0:F0}}"
<TextBlock TextWrapping="Wrap" Visibility="{Binding Settings.PlacementMode,
Text="{Binding Localization[CapsLockElevationHint]}" /> Converter={StaticResource EnumToVisibility},
</ToolTip> ConverterParameter=AtCaret}" />
</TextBlock.ToolTip>
</TextBlock>
</InlineUIContainer>
</TextBlock>
<TextBlock Grid.Row="4" Margin="0,12,12,0" <!-- The "fixed point" mode -->
Style="{StaticResource FieldLabel}" <TextBlock Grid.Row="5" Margin="0,12,12,0"
Visibility="{Binding IsStartupAvailable, Converter={StaticResource BooleanToVisibility}}" Style="{StaticResource FieldLabel}"
Text="{Binding Localization[StartupLabel]}" /> Text="{Binding Localization[ScreenPositionLabel]}"
<CheckBox Grid.Row="4" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0" Visibility="{Binding Settings.PlacementMode,
Visibility="{Binding IsStartupAvailable, Converter={StaticResource BooleanToVisibility}}" Converter={StaticResource EnumToVisibility},
IsEnabled="{Binding CanChangeStartup}" ConverterParameter=FixedPoint}" />
IsChecked="{Binding RunAtStartup}" <ComboBox Grid.Row="5" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
Content="{Binding Localization[StartupCheck]}" /> ItemsSource="{Binding ScreenPositions}"
ItemTemplate="{StaticResource EnumOptionTemplate}"
SelectedValuePath="Value"
SelectedValue="{Binding Settings.FixedPoint.Position}"
Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility},
ConverterParameter=FixedPoint}" />
<TextBlock Grid.Row="5" Grid.Column="1" Grid.ColumnSpan="2" <TextBlock Grid.Row="6" Margin="0,12,12,0"
Margin="0,6,0,0" TextWrapping="Wrap" Style="{StaticResource FieldLabel}"
Foreground="{DynamicResource Theme.SecondaryForeground}" Text="{Binding Localization[ScreenMarginLabel]}"
Visibility="{Binding IsStartupLocked, Converter={StaticResource BooleanToVisibility}}" IsEnabled="{Binding Settings.FixedPoint.IsAtAnEdge}"
Text="{Binding Localization[StartupLockedHint]}" /> Visibility="{Binding Settings.PlacementMode,
</Grid> Converter={StaticResource EnumToVisibility},
</GroupBox> ConverterParameter=FixedPoint}" />
<Slider Grid.Row="6" Grid.Column="1" Margin="0,12,0,0"
Minimum="0" Maximum="200" TickFrequency="1"
Value="{Binding Settings.FixedPoint.Offset}"
IsEnabled="{Binding Settings.FixedPoint.IsAtAnEdge}"
Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility},
ConverterParameter=FixedPoint}" />
<TextBlock Grid.Row="6" Grid.Column="2" Margin="12,12,0,0"
Style="{StaticResource FieldValue}"
Text="{Binding Settings.FixedPoint.Offset, StringFormat={}{0:F0}}"
IsEnabled="{Binding Settings.FixedPoint.IsAtAnEdge}"
Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility},
ConverterParameter=FixedPoint}" />
</StackPanel> <Border Grid.Row="7" Grid.ColumnSpan="3"
Margin="0,16,0,0" Height="1"
Background="{DynamicResource Theme.ControlBorder}" />
<StackPanel Grid.Column="1" Margin="16,0,0,0"> <TextBlock Grid.Row="8" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}"
Text="{Binding Localization[FontSizeLabel]}" />
<Slider Grid.Row="8" Grid.Column="1" Margin="0,12,0,0"
Minimum="10" Maximum="72" TickFrequency="1"
Value="{Binding Settings.Current.FontSize}" />
<TextBlock Grid.Row="8" Grid.Column="2" Margin="12,12,0,0"
Style="{StaticResource FieldValue}"
Text="{Binding Settings.Current.FontSize, StringFormat={}{0:F0}}" />
<!-- Where the popup appears and how it looks are one question for the <TextBlock Grid.Row="9" Margin="0,12,12,0"
user, so both live in a single section: the rule is picked at the Style="{StaticResource FieldLabel}"
top and its result is seen in the preview at the bottom --> Text="{Binding Localization[OpacityLabel]}" />
<GroupBox Header="{Binding Localization[SectionPopup]}"> <Slider Grid.Row="9" Grid.Column="1" Margin="0,12,0,0"
<Grid> Minimum="0.1" Maximum="1" TickFrequency="0.05"
<Grid.ColumnDefinitions> Value="{Binding Settings.Current.Opacity}" />
<ColumnDefinition Width="164" /> <TextBlock Grid.Row="9" Grid.Column="2" Margin="12,12,0,0"
<ColumnDefinition Width="*" /> Style="{StaticResource FieldValue}"
<ColumnDefinition Width="Auto" /> Text="{Binding Settings.Current.Opacity, StringFormat={}{0:P0}}" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Style="{StaticResource FieldLabel}"> <TextBlock Grid.Row="10" Margin="0,12,12,0"
<Run Text="{Binding Localization[PlacementModeLabel], Mode=OneWay}" /> Style="{StaticResource FieldLabel}"
<InlineUIContainer BaselineAlignment="Baseline"> Text="{Binding Localization[BackgroundColorLabel]}" />
<TextBlock Text="{Binding Localization[MoreInfoLink]}" <ComboBox Grid.Row="10" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
Foreground="{DynamicResource Theme.Accent}" ItemsSource="{Binding BackgroundPalette}"
TextDecorations="Underline" ItemTemplate="{StaticResource ColorSwatchTemplate}"
Cursor="Help" SelectedItem="{Binding Settings.Current.BackgroundColor}" />
ToolTipService.InitialShowDelay="200"
ToolTipService.ShowDuration="60000">
<TextBlock.ToolTip>
<ToolTip>
<TextBlock TextWrapping="Wrap" MaxWidth="320"
Text="{Binding Localization[PopupLookPerModeHint]}" />
</ToolTip>
</TextBlock.ToolTip>
</TextBlock>
</InlineUIContainer>
</TextBlock>
<ComboBox Grid.Column="1" Grid.ColumnSpan="2"
ItemsSource="{Binding PlacementModes}"
ItemTemplate="{StaticResource EnumOptionTemplate}"
SelectedValuePath="Value"
SelectedValue="{Binding Settings.PlacementMode}" />
<!-- The "at cursor" mode: a side and an offset of its own --> <TextBlock Grid.Row="11" Margin="0,12,12,0"
<TextBlock Grid.Row="1" Margin="0,12,12,0" Style="{StaticResource FieldLabel}"
Style="{StaticResource FieldLabel}" Text="{Binding Localization[TextColorLabel]}" />
Text="{Binding Localization[CursorCornerLabel]}" <ComboBox Grid.Row="11" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
Visibility="{Binding Settings.PlacementMode, ItemsSource="{Binding TextPalette}"
Converter={StaticResource EnumToVisibility}, ItemTemplate="{StaticResource ColorSwatchTemplate}"
ConverterParameter=AtCursor}" /> SelectedItem="{Binding Settings.Current.ForegroundColor}" />
<ComboBox Grid.Row="1" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
ItemsSource="{Binding AnchorSides}"
ItemTemplate="{StaticResource EnumOptionTemplate}"
SelectedValuePath="Value"
SelectedValue="{Binding Settings.AtCursor.Side}"
Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility},
ConverterParameter=AtCursor}" />
<TextBlock Grid.Row="2" Margin="0,12,12,0" <TextBlock Grid.Row="12" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}" Style="{StaticResource FieldLabel}"
Text="{Binding Localization[CursorOffsetLabel]}" Text="{Binding Localization[PreviewLabel]}" />
Visibility="{Binding Settings.PlacementMode, <Border Grid.Row="12" Grid.Column="1" Grid.ColumnSpan="2"
Converter={StaticResource EnumToVisibility}, Margin="0,12,0,0" Padding="16"
ConverterParameter=AtCursor}" /> Height="152" ClipToBounds="True"
<Slider Grid.Row="2" Grid.Column="1" Margin="0,12,0,0" Background="{DynamicResource Theme.SurfaceStrong}" CornerRadius="4"
Minimum="0" Maximum="80" TickFrequency="1" HorizontalAlignment="Stretch">
Value="{Binding Settings.AtCursor.Offset}" <Border CornerRadius="4" Padding="10,4"
Visibility="{Binding Settings.PlacementMode, HorizontalAlignment="Center" VerticalAlignment="Center"
Converter={StaticResource EnumToVisibility}, Opacity="{Binding Settings.Current.Opacity}"
ConverterParameter=AtCursor}" /> Background="{Binding Settings.Current.BackgroundColor,
<TextBlock Grid.Row="2" Grid.Column="2" Margin="12,12,0,0" Converter={StaticResource ColorToBrush}}">
Style="{StaticResource FieldValue}" <TextBlock Text="RU" FontWeight="SemiBold"
Text="{Binding Settings.AtCursor.Offset, StringFormat={}{0:F0}}" FontSize="{Binding Settings.Current.FontSize}"
Visibility="{Binding Settings.PlacementMode, Foreground="{Binding Settings.Current.ForegroundColor,
Converter={StaticResource EnumToVisibility}, Converter={StaticResource ColorToBrush}}" />
ConverterParameter=AtCursor}" />
<!-- The "at caret" mode: a side and an offset of its own -->
<TextBlock Grid.Row="3" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}"
Text="{Binding Localization[CursorCornerLabel]}"
Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility},
ConverterParameter=AtCaret}" />
<ComboBox Grid.Row="3" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
ItemsSource="{Binding CaretSides}"
ItemTemplate="{StaticResource EnumOptionTemplate}"
SelectedValuePath="Value"
SelectedValue="{Binding Settings.AtCaret.Side}"
Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility},
ConverterParameter=AtCaret}" />
<TextBlock Grid.Row="4" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}"
Text="{Binding Localization[CursorOffsetLabel]}"
Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility},
ConverterParameter=AtCaret}" />
<Slider Grid.Row="4" Grid.Column="1" Margin="0,12,0,0"
Minimum="0" Maximum="80" TickFrequency="1"
Value="{Binding Settings.AtCaret.Offset}"
Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility},
ConverterParameter=AtCaret}" />
<TextBlock Grid.Row="4" Grid.Column="2" Margin="12,12,0,0"
Style="{StaticResource FieldValue}"
Text="{Binding Settings.AtCaret.Offset, StringFormat={}{0:F0}}"
Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility},
ConverterParameter=AtCaret}" />
<!-- The "fixed point" mode -->
<TextBlock Grid.Row="5" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}"
Text="{Binding Localization[ScreenPositionLabel]}"
Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility},
ConverterParameter=FixedPoint}" />
<ComboBox Grid.Row="5" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
ItemsSource="{Binding ScreenPositions}"
ItemTemplate="{StaticResource EnumOptionTemplate}"
SelectedValuePath="Value"
SelectedValue="{Binding Settings.FixedPoint.Position}"
Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility},
ConverterParameter=FixedPoint}" />
<TextBlock Grid.Row="6" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}"
Text="{Binding Localization[ScreenMarginLabel]}"
IsEnabled="{Binding Settings.FixedPoint.IsAtAnEdge}"
Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility},
ConverterParameter=FixedPoint}" />
<Slider Grid.Row="6" Grid.Column="1" Margin="0,12,0,0"
Minimum="0" Maximum="200" TickFrequency="1"
Value="{Binding Settings.FixedPoint.Offset}"
IsEnabled="{Binding Settings.FixedPoint.IsAtAnEdge}"
Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility},
ConverterParameter=FixedPoint}" />
<TextBlock Grid.Row="6" Grid.Column="2" Margin="12,12,0,0"
Style="{StaticResource FieldValue}"
Text="{Binding Settings.FixedPoint.Offset, StringFormat={}{0:F0}}"
IsEnabled="{Binding Settings.FixedPoint.IsAtAnEdge}"
Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility},
ConverterParameter=FixedPoint}" />
<!-- A hairline between the two halves of the section: the
placement above, the look of the popup below -->
<Border Grid.Row="7" Grid.ColumnSpan="3"
Margin="0,16,0,0" Height="1"
Background="{DynamicResource Theme.ControlBorder}" />
<TextBlock Grid.Row="8" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}"
Text="{Binding Localization[FontSizeLabel]}" />
<Slider Grid.Row="8" Grid.Column="1" Margin="0,12,0,0"
Minimum="10" Maximum="72" TickFrequency="1"
Value="{Binding Settings.Current.FontSize}" />
<TextBlock Grid.Row="8" Grid.Column="2" Margin="12,12,0,0"
Style="{StaticResource FieldValue}"
Text="{Binding Settings.Current.FontSize, StringFormat={}{0:F0}}" />
<TextBlock Grid.Row="9" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}"
Text="{Binding Localization[OpacityLabel]}" />
<Slider Grid.Row="9" Grid.Column="1" Margin="0,12,0,0"
Minimum="0.1" Maximum="1" TickFrequency="0.05"
Value="{Binding Settings.Current.Opacity}" />
<TextBlock Grid.Row="9" Grid.Column="2" Margin="12,12,0,0"
Style="{StaticResource FieldValue}"
Text="{Binding Settings.Current.Opacity, StringFormat={}{0:P0}}" />
<TextBlock Grid.Row="10" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}"
Text="{Binding Localization[BackgroundColorLabel]}" />
<ComboBox Grid.Row="10" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
ItemsSource="{Binding BackgroundPalette}"
ItemTemplate="{StaticResource ColorSwatchTemplate}"
SelectedItem="{Binding Settings.Current.BackgroundColor}" />
<TextBlock Grid.Row="11" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}"
Text="{Binding Localization[TextColorLabel]}" />
<ComboBox Grid.Row="11" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
ItemsSource="{Binding TextPalette}"
ItemTemplate="{StaticResource ColorSwatchTemplate}"
SelectedItem="{Binding Settings.Current.ForegroundColor}" />
<TextBlock Grid.Row="12" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}"
Text="{Binding Localization[PreviewLabel]}" />
<Border Grid.Row="12" Grid.Column="1" Grid.ColumnSpan="2"
Margin="0,12,0,0" Padding="16"
Height="152" ClipToBounds="True"
Background="{DynamicResource Theme.SurfaceStrong}" CornerRadius="4"
HorizontalAlignment="Stretch">
<Border CornerRadius="4" Padding="10,4"
HorizontalAlignment="Center" VerticalAlignment="Center"
Opacity="{Binding Settings.Current.Opacity}"
Background="{Binding Settings.Current.BackgroundColor,
Converter={StaticResource ColorToBrush}}">
<TextBlock Text="RU" FontWeight="SemiBold"
FontSize="{Binding Settings.Current.FontSize}"
Foreground="{Binding Settings.Current.ForegroundColor,
Converter={StaticResource ColorToBrush}}" />
</Border>
</Border> </Border>
</Grid> </Border>
</GroupBox> </Grid>
</GroupBox>
</StackPanel> <GroupBox Header="{Binding Localization[SectionBehavior]}">
</Grid> <Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="164" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Style="{StaticResource FieldLabel}"
Text="{Binding Localization[DurationLabel]}" />
<Slider Grid.Column="1" Minimum="200" Maximum="5000" TickFrequency="100"
Value="{Binding Settings.DurationMilliseconds}" />
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Style="{StaticResource FieldValue}"
Text="{Binding Settings.DurationMilliseconds, StringFormat={}{0:F0}}" />
<TextBlock Margin="4,0,0,0"
Foreground="{DynamicResource Theme.SecondaryForeground}"
Text="{Binding Localization[MillisecondsSuffix]}" />
</StackPanel>
<TextBlock Grid.Row="1" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}"
Text="{Binding Localization[CapsLockLabel]}" />
<CheckBox Grid.Row="1" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
IsChecked="{Binding Settings.UseCapsLockHotkey}"
Content="{Binding Localization[CapsLockHotkeyCheck]}" />
<TextBlock Grid.Row="2" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}"
Text="{Binding Localization[CapsLockHoldLabel]}" />
<Slider Grid.Row="2" Grid.Column="1" Margin="0,12,0,0"
Minimum="150" Maximum="1500" TickFrequency="50"
Value="{Binding Settings.CapsLockHoldMilliseconds}"
IsEnabled="{Binding Settings.UseCapsLockHotkey}" />
<StackPanel Grid.Row="2" Grid.Column="2" Margin="0,12,0,0"
Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Style="{StaticResource FieldValue}"
Text="{Binding Settings.CapsLockHoldMilliseconds, StringFormat={}{0:F0}}" />
<TextBlock Margin="4,0,0,0"
Foreground="{DynamicResource Theme.SecondaryForeground}"
Text="{Binding Localization[MillisecondsSuffix]}" />
</StackPanel>
<TextBlock Grid.Row="3" Grid.Column="1" Grid.ColumnSpan="2"
Margin="0,6,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource Theme.SecondaryForeground}">
<Run Text="{Binding Localization[CapsLockHoldHint], Mode=OneWay}" />
<InlineUIContainer BaselineAlignment="Baseline">
<!-- The elevation caveat lives in the tooltip to keep the section compact -->
<TextBlock Text="{Binding Localization[MoreInfoLink]}"
Foreground="{DynamicResource Theme.Accent}"
TextDecorations="Underline"
Cursor="Help"
ToolTipService.InitialShowDelay="200"
ToolTipService.ShowDuration="60000"
Visibility="{Binding Settings.UseCapsLockHotkey, Converter={StaticResource BooleanToVisibility}}">
<TextBlock.ToolTip>
<ToolTip>
<TextBlock TextWrapping="Wrap"
Text="{Binding Localization[CapsLockElevationHint]}" />
</ToolTip>
</TextBlock.ToolTip>
</TextBlock>
</InlineUIContainer>
</TextBlock>
<TextBlock Grid.Row="4" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}"
Visibility="{Binding IsStartupAvailable, Converter={StaticResource BooleanToVisibility}}"
Text="{Binding Localization[StartupLabel]}" />
<CheckBox Grid.Row="4" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
Visibility="{Binding IsStartupAvailable, Converter={StaticResource BooleanToVisibility}}"
IsEnabled="{Binding CanChangeStartup}"
IsChecked="{Binding RunAtStartup}"
Content="{Binding Localization[StartupCheck]}" />
<TextBlock Grid.Row="5" Grid.Column="1" Grid.ColumnSpan="2"
Margin="0,6,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource Theme.SecondaryForeground}"
Visibility="{Binding IsStartupLocked, Converter={StaticResource BooleanToVisibility}}"
Text="{Binding Localization[StartupLockedHint]}" />
</Grid>
</GroupBox>
</StackPanel>
</ScrollViewer> </ScrollViewer>
</Window> </Window>
+19 -6
View File
@@ -16,12 +16,25 @@
<OutputType>Package</OutputType> <OutputType>Package</OutputType>
<!-- <!--
MSI validation still assumes an installation for the whole machine. The ICE checks run inside the Windows Installer service, and a build
Installing into the user's own profile trips three of its rules: a agent has no access to it: every single check comes back as WIX0217,
component whose key path is a file (ICE38), a folder it wants listed "The Windows Installer Service could not be accessed", and the build
for removal (ICE64), and a warning that the files will not follow other fails on close to a hundred of them. Since the release is built by the
users of the machine (ICE91). All three describe exactly what was agent, validation cannot be part of it.
intended here, so they are turned off rather than worked around.
It stays switchable rather than deleted: -p:SuppressValidation=false
turns it back on where the service does answer, which is any ordinary
desktop machine.
-->
<SuppressValidation Condition="'$(SuppressValidation)' == ''">true</SuppressValidation>
<!--
What validation says when it does run. MSI still assumes an
installation for the whole machine, and installing into the user's own
profile trips three of its rules: a component whose key path is a file
(ICE38), a folder it wants listed for removal (ICE64), and a warning
that the files will not follow other users of the machine (ICE91). All
three describe exactly what was intended here.
--> -->
<SuppressIces>ICE38;ICE64;ICE91</SuppressIces> <SuppressIces>ICE38;ICE64;ICE91</SuppressIces>
+2 -2
View File
@@ -136,8 +136,8 @@
Condition="REMOVE=&quot;ALL&quot; AND NOT UPGRADINGPRODUCTCODE" /> Condition="REMOVE=&quot;ALL&quot; AND NOT UPGRADINGPRODUCTCODE" />
</InstallExecuteSequence> </InstallExecuteSequence>
<!-- Offered at the end of the wizard, the way an installer usually does --> <SetProperty Id="WixShellExecTarget" Value="[INSTALLFOLDER]CursorLang.exe"
<Property Id="WixShellExecTarget" Value="[#CursorLang.exe]" /> After="CostFinalize" Sequence="ui" />
<CustomAction Id="LaunchApplication" BinaryRef="Wix4UtilCA_$(sys.BUILDARCHSHORT)" <CustomAction Id="LaunchApplication" BinaryRef="Wix4UtilCA_$(sys.BUILDARCHSHORT)"
DllEntry="WixShellExec" Impersonate="yes" Return="ignore" /> DllEntry="WixShellExec" Impersonate="yes" Return="ignore" />
+48 -11
View File
@@ -84,20 +84,28 @@ MSIX всегда выполняются в контексте вошедшег
раскладкой. Без UI, и так должно остаться: всё, что попадёт туда, попадёт и раскладкой. Без UI, и так должно остаться: всё, что попадёт туда, попадёт и
в фоновый процесс. в фоновый процесс.
Связь между ними — только `settings.json`. Окно пишет его целиком, во временный Всё, что окно сообщает агенту, идёт только через `settings.json`. Окно пишет его
файл, который одним движением встаёт на место, и посылает агенту целиком, во временный файл, который одним движением встаёт на место, и посылает
зарегистрированное оконное сообщение, по которому тот перечитывает файл. агенту зарегистрированное оконное сообщение, по которому тот перечитывает файл.
Сообщение не несёт данных: пересылка самих изменений лишила бы файл роли Сообщение не несёт данных: пересылка самих изменений лишила бы файл роли
единственного источника правды. Работающий агент необязателен — без него окно единственного источника правды. Работающий агент необязателен — без него окно
работает так же. За файлом никто не следит: писатель у него один, и он сам работает так же. За файлом никто не следит: писатель у него один, и он сам
сообщает о записи. сообщает о записи.
В обратную сторону агент говорит одно слово, и то последнее: пункт «Выход» в меню
трея просит открытое окно настроек закрыться, прежде чем агент уйдёт, — чтобы на
экране не осталось окна, которому больше ничего не принадлежит. Просьба идёт
именованным событием сессии, а не оконным сообщением: агент запускает этот процесс
и намеренно не держит на него ссылки. Чаще всего слушать её некому, и это
нормальный ответ.
## Трей ## Трей
Окно настроек — гость на экране, а не само приложение: оно показывается после Окно настроек — гость на экране, а не само приложение: оно показывается после
установки и всякий раз, когда его просят иконка или её меню. Обе кнопки в установки и всякий раз, когда его просят иконка или её меню. Обе кнопки в
заголовке окна означают ровно то, что написано: окно закрывается, а его процесс заголовке окна означают ровно то, что написано: окно закрывается, а его процесс
завершается. Выход из самого приложения — пункт «Выход» в меню трея. завершается. Выход из самого приложения — пункт «Выход» в меню трея, и открытое
окно настроек он закрывает вместе с агентом.
Меню иконки системное, его рисует Windows. Надписи по-прежнему следуют языку, Меню иконки системное, его рисует Windows. Надписи по-прежнему следуют языку,
выбранному в настройках, а тема до меню больше не дотягивается: меню на WPF выбранному в настройках, а тема до меню больше не дотягивается: меню на WPF
@@ -161,6 +169,24 @@ MSIX всегда выполняются в контексте вошедшег
на месте, а не исчезает из раздела — появляющаяся и исчезающая строка сдвигала бы на месте, а не исчезает из раздела — появляющаяся и исчезающая строка сдвигала бы
всё, что ниже, при каждой смене места. всё, что ниже, при каждой смене места.
Положение каретки сообщает не всякое приложение, и тогда рядом с курсором ввода —
нигде. Подсказка уходит на фиксированную точку: место, отступ и оформление берутся
оттуда, из режима каретки не берётся ничего. Курсор мыши был бы запасным вариантом
поближе — и неверным: он там, где его оставили, сбоку или на другом мониторе, тогда
как смотрят на каретку. Фиксированная точка всегда на одном и том же месте, и потому
известно, куда смотреть, — а вместе с местом должно приходить и оформление: подсказка,
настроенная мелкой и незаметной для середины текста, в углу монитора останется
незамеченной.
Какие-то приложения, решает не то, на чём они написаны, а то, что окно о себе
сообщает. Обычный элемент Win32 заводит системную каретку, и её читают сразу;
Chrome и Electron рисуют свою и сообщают о ней через интерфейсы доступности.
Окну, которое рисует текст на голом холсте, сообщать нечего — и оба случая
уживаются в одном приложении: редакторы JetBrains отвечают, потому что каретку
для них держит их среда выполнения, а панель инструментов, нарисованная Compose,
не содержит текстового элемента, который нашли бы UI Automation или Java Access
Bridge. Ради такого окна фиксированная точка и нужна.
К какому режиму относится оформление, объясняет всплывающая подсказка рядом с самим К какому режиму относится оформление, объясняет всплывающая подсказка рядом с самим
режимом, а не строка текста в разделе: ползунки ниже показывают другие числа после режимом, а не строка текста в разделе: ползунки ниже показывают другие числа после
переключения режима, и это вопрос, который задают один раз. переключения режима, и это вопрос, который задают один раз.
@@ -333,10 +359,21 @@ pwsh -File Packaging\build-installer.ps1 -Version 1.0.1
снимет: SmartScreen смотрит на репутацию, а у нового сертификата её нет, пока снимет: SmartScreen смотрит на репутацию, а у нового сертификата её нет, пока
приложение не наберёт установок. приложение не наберёт установок.
Про проект WiX стоит знать две вещи, прежде чем его править. Он закреплён на WiX Про проект WiX стоит знать две вещи, прежде чем его править.
5, а не на нынешней 7: начиная с шестой версии инструмент требует принимать
лицензию Open Source Maintenance Fee — бесплатную при доходе меньше $10 000 в Он закреплён на WiX 5, а не на нынешней 7: начиная с шестой версии инструмент
год, но принимать её должен человек, а не сборочный скрипт. И он отключает три требует принимать лицензию Open Source Maintenance Fee — бесплатную при доходе
проверки ICE: MSI по-прежнему исходит из установки на всю машину, а установка в меньше $10 000 в год, но принимать её должен человек, а не сборочный скрипт.
профиль пользователя нарушает правила, которые описывают ровно то, что здесь и
задумано. И он собирается без проверки MSI. Проверки ICE выполняются службой установщика
Windows, до которой сборочному агенту не дотянуться: каждая возвращается
ошибкой `WIX0217`, и сборка умирает на без малого сотне таких. На обычной
машине служба отвечает, и проверка включается одним ключом:
```powershell
dotnet build Packaging\Installer\CursorLang.wixproj -p:SuppressValidation=false
```
Три правила остаются подавленными и тогда. MSI исходит из установки на всю
машину, а установка в профиль пользователя нарушает правила, которые описывают
ровно то, что здесь и задумано.
+46 -10
View File
@@ -81,19 +81,26 @@ the next time the window is asked for.
the layout tracking. No UI, and it must stay that way — whatever lands there lands the layout tracking. No UI, and it must stay that way — whatever lands there lands
in the background process. in the background process.
The connection between the two is `settings.json` and nothing else. The window Everything the window has to say to the agent goes through `settings.json` and
writes it — whole, into a temporary file moved into place in one step — and then nothing else. The window writes it — whole, into a temporary file moved into place in one step — and then
posts a registered window message to the agent, which re-reads. The message carries posts a registered window message to the agent, which re-reads. The message carries
no data: sending the changed values along would make the file stop being the only no data: sending the changed values along would make the file stop being the only
source of truth. An agent that is not running is a normal case — the window works the source of truth. An agent that is not running is a normal case — the window works the
same. Nobody watches the file: it has one writer, and that writer speaks up. same. Nobody watches the file: it has one writer, and that writer speaks up.
The one word in the other direction is the last one: Exit in the tray menu asks an
open settings window to close before the agent goes, so that no window is left
belonging to nothing. It travels as a named event of the session rather than a
window message — the agent starts that process and deliberately keeps no handle to
it. Nobody listening is the usual case, and it is a normal answer.
## The tray ## The tray
The settings window is a guest on the screen rather than the app itself: it shows 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 up after the installation and whenever the icon or its menu is asked for. Both
buttons in its title bar mean what they say — the window closes and its process buttons in its title bar mean what they say — the window closes and its process
ends. The way out of the app itself is the Exit item of the tray menu. ends. The way out of the app itself is the Exit item of the tray menu — and it
closes an open settings window along with the agent.
The menu of the icon is a system one, drawn by Windows. Its captions still follow The menu of the icon is a system one, drawn by Windows. Its captions still follow
the language chosen in the settings, but the theme no longer reaches it: a WPF the language chosen in the settings, but the theme no longer reaches it: a WPF
@@ -156,6 +163,24 @@ zero and the settings window shows it greyed out. It stays in place rather than
leaving the section — a row that comes and goes would move everything below it on leaving the section — a row that comes and goes would move everything below it on
every switch of the place. every switch of the place.
Not every application reports where its caret is, and next to the caret is then
nowhere. The popup goes to the fixed point instead — the place, the offset and the
look all from that mode, none of them from the caret one. The mouse cursor would be
the nearer fallback and is the wrong one: it is wherever it was last left, off to a
side or on another monitor, while the eyes are on the caret. The fixed point is
always in the same place, so it is known where to look — and the look must come with
it, since a popup set small and quiet for the middle of a text goes unnoticed in the
corner of a monitor.
Some applications those are is decided by what a window exposes rather than by
what it is written in. A Win32 control creates a system caret and is read at once;
Chromium and Electron draw their own and report it over the accessibility
interfaces. A window that paints its text into a bare canvas has nothing to report
at all — and both kinds live in the same application: the editors of a JetBrains
IDE answer, since the runtime keeps a caret for them, while a tool window whose
interface is painted by Compose holds no text component for UI Automation or the
Java Access Bridge to find. That window is where the fixed point earns its keep.
Which mode the look belongs to is explained in a tooltip next to the mode itself Which mode the look belongs to is explained in a tooltip next to the mode itself
rather than by a line of text in the section: the sliders below show other numbers rather than by a line of text in the section: the sliders below show other numbers
after a switch of the mode, and that is a question asked once. after a switch of the mode, and that is a question asked once.
@@ -327,10 +352,21 @@ user has to insist. Buying a certificate would not silence it at once either:
SmartScreen goes by reputation, and a fresh certificate has none until enough SmartScreen goes by reputation, and a fresh certificate has none until enough
people have installed the application. people have installed the application.
Two things about the WiX project are worth knowing before touching it. It pins Two things about the WiX project are worth knowing before touching it.
WiX 5 rather than the current 7: from version 6 the toolset asks every build to
accept the Open Source Maintenance Fee licence — free below $10,000 of yearly It pins WiX 5 rather than the current 7: from version 6 the toolset asks every
revenue, but a decision for a person rather than for a build script. And it turns build to accept the Open Source Maintenance Fee licence — free below $10,000 of
off three ICE validation rules: MSI still assumes an installation for the whole yearly revenue, but a decision for a person rather than for a build script.
machine, and installing into the user's own profile trips rules that describe
exactly what was intended here. And it builds without MSI validation. The ICE checks run inside the Windows
Installer service, which a build agent cannot reach: every check comes back as
`WIX0217` and the build dies on close to a hundred of them. On an ordinary
desktop machine the service does answer, and validation is one switch away:
```powershell
dotnet build Packaging\Installer\CursorLang.wixproj -p:SuppressValidation=false
```
Three of its rules stay suppressed even then. MSI assumes an installation for the
whole machine, and installing into the user's own profile trips rules that
describe exactly what was intended here.