21 Commits
Author SHA1 Message Date
alex 52517bb09e Merge pull request 'added link for downloading app' (#16) from landing-site into master
Reviewed-on: #16
2026-09-04 07:11:52 +00:00
alex 8fe0e72664 added link for downloading app
Pull request / build (pull_request) Successful in 45s
2026-09-04 10:00:36 +05:00
alex 246751a4fd Merge pull request 'Fix certification comment' (#15) from fix-certification-comment into master
Release / release (push) Failing after 7m3s
Reviewed-on: #15
2026-09-03 05:00:16 +00:00
alex 53720e95db fixed width resize when change screen scale
Pull request / build (pull_request) Successful in 37s
2026-09-03 09:55:29 +05:00
alex ccd914d6fe fix resize on different screens try -1 2026-09-03 09:40:58 +05:00
alex cd63644a83 added material for certification 2026-09-03 08:18:05 +05:00
alex 1bad7785d0 add Store listing images: 9:16 poster and 300x300 logo
Partner Center's screenshots and logos steps needed sizes the MSIX package
doesn't produce, so they are drawn separately with the same mark.
2026-09-01 11:01:45 +05:00
alex 0ba581946f add site address to og:url, og:image and canonical link
The site is now live at cursor-lang.alrakis.kz, so these tags can be filled in.
2026-09-01 10:19:07 +05:00
alex 9b4ac00da9 Merge pull request 'add landing site for the Microsoft Store submission' (#14) from landing-site into master
Reviewed-on: #14
2026-09-01 04:46:20 +00:00
alex 936e53d55b add landing site for the Microsoft Store submission
Pull request / build (pull_request) Successful in 47s
A bilingual (EN/RU) static page in site/, split into index.html, styles.css,
boot.js and main.js, with a favicon.svg. Documents placement modes, per-mode
look, the Caps Lock shortcut and a privacy policy — the last is meant to
double as the Partner Center privacy policy URL.

STORE_URL is still a placeholder in both download buttons until the Store
listing exists.
2026-09-01 09:36:37 +05:00
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
39 changed files with 2544 additions and 434 deletions
+18 -7
View File
@@ -138,9 +138,22 @@ jobs:
- name: Build the installer
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
uses: actions/upload-artifact@v4
uses: https://git.alrakis.kz/actions/upload-artifact@v4-gitea
with:
name: msix-${{ steps.version.outputs.version }}
path: artifacts/packages/
@@ -149,7 +162,7 @@ jobs:
# 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
- name: Keep the installer
uses: actions/upload-artifact@v4
uses: https://git.alrakis.kz/actions/upload-artifact@v4-gitea
with:
name: installer-${{ steps.version.outputs.plain }}
path: artifacts/installers/*.msi
@@ -167,10 +180,8 @@ jobs:
# The GITHUB_ names are what Gitea itself hands to the workflow — its
# 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
# all, so the address is put together from the server one when empty
$root = if ($env:GITHUB_API_URL) { $env:GITHUB_API_URL } else { "$env:GITHUB_SERVER_URL/api/v1" }
$api = "$root/repos/$env:GITHUB_REPOSITORY/releases"
# this Gitea instance
$api = "$env:GITHUB_API_URL/repos/$env:GITHUB_REPOSITORY/releases"
$headers = @{ Authorization = "token $env:GITEA_TOKEN" }
# 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);
}
/// <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>
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 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>
<PublishReadyToRun Condition="'$(RuntimeIdentifier)' != ''">true</PublishReadyToRun>
<Version>1.0.0</Version>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion>
<AssemblyVersion>$(Version)</AssemblyVersion>
<FileVersion>$(Version)</FileVersion>
<Product>CursorLang</Product>
<Company>Aleksandr Neichev</Company>
<Description>Shows the keyboard layout at the cursor</Description>
+62 -38
View File
@@ -68,42 +68,42 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
return;
}
bool atFixedPoint = _settings.PlacementMode == PopupPlacementMode.FixedPoint;
PopupWindowNative.Rect? anchor = TryGetAnchor();
PopupModeSettings mode = ModeFor(anchor);
PopupWindowNative.Rect work = default;
PopupWindowNative.Rect anchor = default;
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
{
anchor = GetAnchor();
scale = PopupWindowNative.GetScaleAt(new PopupWindowNative.Point { X = anchor.Left, Y = anchor.Top });
(work, scale) = PopupWindowNative.GetActiveMonitorWorkArea();
}
EnsureFont(scale);
EnsureFont(mode, scale);
Size measured = MeasureText(text);
int width = measured.Width + (2 * PopupLayout.ToPixels(PaddingX, scale));
int height = measured.Height + (2 * PopupLayout.ToPixels(PaddingY, scale));
PopupWindowNative.Point position = atFixedPoint
? PopupLayout.OnScreen(
work,
_settings.FixedPoint.Position,
PopupLayout.ToPixels(_settings.FixedPoint.Offset, scale),
PopupWindowNative.Point position = anchor is { } near
? PopupLayout.NearAnchor(
near,
SideForMode(),
PopupLayout.ToPixels(mode.Offset, scale),
width,
height)
: PopupLayout.NearAnchor(
anchor,
SideForMode(),
PopupLayout.ToPixels(_settings.Current.Offset, scale),
: PopupLayout.OnScreen(
work,
_settings.FixedPoint.Position,
PopupLayout.ToPixels(mode.Offset, scale),
width,
height);
if (!Draw(text, position, width, height, PopupLayout.ToPixels(CornerRadius, scale)))
if (!Draw(mode, text, position, width, height, PopupLayout.ToPixels(CornerRadius, scale)))
{
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
/// would have to be rebuilt on every change of size, colour or scale anyway.
/// </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);
if (screen == IntPtr.Zero)
@@ -166,8 +167,8 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
GdiNative.SelectObject(memory, surface);
Fill(bits, width, height);
DrawText(memory, text, width, height);
Fill(mode, bits, width, height);
DrawText(mode, memory, text, width, height);
// 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
@@ -178,7 +179,7 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
RoundTheCorners(bits, width, height, radius);
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);
}
@@ -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
// 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)
{
@@ -227,7 +229,7 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
IntPtr previousFont = GdiNative.SelectObject(deviceContext, _font);
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.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);
}
// The anchor point: the caret in the input field or the mouse cursor. The cursor
// is a rectangle of zero size, so the corner computation is shared by both
private PopupWindowNative.Rect GetAnchor()
/// <summary>
/// What the popup is placed next to, or <c>null</c> when there is nothing: the fixed
/// 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 &&
CaretNative.TryGetCaretRect() is { } caret)
{
return caret;
}
PopupPlacementMode.FixedPoint => null,
PopupPlacementMode.AtCaret => CaretNative.TryGetCaretRect(),
_ => PopupLayout.AsAnchor(PopupWindowNative.GetCursorPosition()),
};
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
// 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
// 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
private void EnsureFont(double scale)
private void EnsureFont(PopupModeSettings mode, double scale)
{
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)
{
return;
@@ -384,7 +408,7 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
ReleaseFont();
_fontSize = _settings.Current.FontSize;
_fontSize = mode.FontSize;
_fontScale = 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()
{
// 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);
return query.Wait(AutomationTimeout) ? query.Result : null;
}
@@ -165,7 +165,7 @@ internal static class CaretNative
}
#else
// 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;
#endif
+2 -1
View File
@@ -10,7 +10,8 @@ public enum PopupPlacementMode
/// <summary>
/// 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>
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.Services;
using CursorLang.Settings.Tests.Infrastructure;
using CursorLang.Settings.ViewModels;
using CursorLang.Tests.Shared;
+32
View File
@@ -26,6 +26,7 @@ public partial class App : Application
{
private ServiceProvider? _services;
private SingleInstanceGate? _instanceGate;
private SettingsCloseSignal? _closeSignal;
protected override void OnStartup(StartupEventArgs e)
{
@@ -42,6 +43,10 @@ public partial class App : Application
_instanceGate = gate;
_instanceGate.ActivationRequested += OnActivationRequested;
_closeSignal = new SettingsCloseSignal();
_closeSignal.CloseRequested += OnCloseRequested;
_closeSignal.Listen();
var services = new ServiceCollection();
ConfigureServices(services);
_services = services.BuildServiceProvider();
@@ -61,6 +66,12 @@ public partial class App : Application
{
_services?.Dispose();
if (_closeSignal is not null)
{
_closeSignal.CloseRequested -= OnCloseRequested;
_closeSignal.Dispose();
}
if (_instanceGate is not null)
{
_instanceGate.ActivationRequested -= OnActivationRequested;
@@ -95,6 +106,27 @@ public partial class App : Application
private void OnActivationRequested(object? sender, EventArgs e) =>
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()
{
if (MainWindow is not { } window)
@@ -21,8 +21,8 @@
<PublishTrimmed>false</PublishTrimmed>
<PublishReadyToRun Condition="'$(RuntimeIdentifier)' != ''">true</PublishReadyToRun>
<Version>1.0.0</Version>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion>
<AssemblyVersion>$(Version)</AssemblyVersion>
<FileVersion>$(Version)</FileVersion>
<Product>CursorLang</Product>
<Company>Aleksandr Neichev</Company>
<Description>Settings window of CursorLang</Description>
@@ -1,5 +1,7 @@
using System.Windows;
using System.Windows.Interop;
using System.Windows.Media;
using System.Windows.Threading;
using CursorLang.Core.Interop;
using CursorLang.Settings.Interop;
@@ -23,6 +25,15 @@ public sealed class MainWindowPlacement
// what has to be remembered is only what the user chose
private bool _isPlacing;
// Captured once, before anything narrows MaxHeight to a particular monitor —
// otherwise a later, more generous monitor would stay capped at whatever a
// previous, shorter one left behind
private double? _designMaxHeight;
// Captured once, so a DPI change has a known-correct value to reassert — see
// RestoreDesignWidth
private double? _designWidth;
/// <summary>
/// Takes over the placement of the window: puts it in place by the first show
/// and follows where the user moves it.
@@ -31,6 +42,7 @@ public sealed class MainWindowPlacement
{
window.SourceInitialized += OnSourceInitialized;
window.LocationChanged += OnLocationChanged;
window.DpiChanged += OnDpiChanged;
}
/// <summary>
@@ -83,10 +95,108 @@ public sealed class MainWindowPlacement
}
window.SourceInitialized -= OnSourceInitialized;
_designMaxHeight = window.MaxHeight;
_designWidth = window.Width;
LimitHeightToOwnMonitor(window);
window.UpdateLayout();
Apply(window);
}
// A monitor is not necessarily final at creation time — Windows may place the new
// window on one monitor before Apply moves it to another, and the user is free to
// drag it to a third one later. Every one of those is a real DPI change, and this
// runs again for each: recomputing the cap from whichever monitor holds the window
// right now, rather than trusting a value worked out for a previous one.
private void OnDpiChanged(object sender, DpiChangedEventArgs e)
{
if (sender is not Window window)
{
return;
}
// Deferred rather than run inline: this event fires while WPF is still in
// the middle of its own response to the same DPI change (its per-monitor
// rescale of Width touches it after this handler if the fix-up runs
// synchronously, undoing it). Posting behind that on the dispatcher queue
// lets our fix-up run once WPF's own pass has finished.
window.Dispatcher.BeginInvoke(DispatcherPriority.ContextIdle, new Action(() =>
{
LimitHeightToOwnMonitor(window);
RestoreDesignWidth(window);
ReapplySizeToContent(window);
}));
}
// The width is a plain, explicit value rather than something SizeToContent
// computes, and WPF's own per-monitor rescaling does not reliably keep it at the
// same logical width when the system's scaling changes live under an
// already-open window, as opposed to the window being dragged onto a different
// monitor — it can come out scaled by roughly the ratio between the old and the
// new DPI instead of staying put. Reasserting the original value here is simpler
// than chasing exactly where that rescale goes wrong.
private void RestoreDesignWidth(Window window)
{
if (_designWidth is { } designWidth)
{
window.Width = designWidth;
}
}
// UpdateLayout alone settles the measure/arrange pass of the visual tree, but
// does not reliably make WPF redo its own step of resizing the native window to
// match SizeToContent when the change originates from a DPI event rather than an
// ordinary content change. Left alone, the window can end up either too tall — a
// blank strip below the real content, once MaxHeight has just pulled the content
// shorter — or stuck too short after being dragged back to a monitor with room to
// grow again. Turning SizeToContent off and back on forces that resizing step to
// run again from scratch.
private static void ReapplySizeToContent(Window window)
{
SizeToContent original = window.SizeToContent;
window.SizeToContent = SizeToContent.Manual;
window.SizeToContent = original;
window.UpdateLayout();
}
// MaxHeight in XAML is a constant tuned for an ordinary desktop monitor at 100%
// scaling. At a high scale factor the same number of device-independent pixels
// turns into more physical pixels than a short or heavily scaled monitor has, and
// SizeToContent then grows the window past the bottom of the screen instead of
// asking the ScrollViewer inside it to scroll — with nothing to grab, the excess
// is unreachable. Capping MaxHeight to what the window's own monitor really offers
// keeps the whole window on screen and lets the ScrollViewer take over.
private void LimitHeightToOwnMonitor(Window window)
{
if (_designMaxHeight is not { } designMaxHeight)
{
return;
}
IntPtr handle = new WindowInteropHelper(window).Handle;
if (handle == IntPtr.Zero
|| WindowPlacementNative.TryGetBounds(handle) is not { } bounds
|| WindowPlacementNative.TryGetWorkAreaNear(bounds) is not { } work
|| IsEmpty(work))
{
return;
}
double scale = VisualTreeHelper.GetDpi(window).DpiScaleY;
if (scale <= 0)
{
return;
}
// Leaves room for the title bar and a margin from the edges of the screen,
// in the same units as the work area height once the monitor's scale is
// divided out
const double reservedForChrome = 80;
double workAreaHeight = (work.Bottom - work.Top) / scale;
window.MaxHeight = Math.Min(designMaxHeight, Math.Max(200, workAreaHeight - reservedForChrome));
}
private void OnLocationChanged(object? sender, EventArgs e)
{
if (_isPlacing || sender is not Window { WindowState: WindowState.Normal } window)
+324 -346
View File
@@ -8,7 +8,7 @@
mc:Ignorable="d"
d:DataContext="{d:DesignInstance Type=vm:SettingsViewModel}"
Title="{Binding Title}"
Width="1000" SizeToContent="Height" MaxHeight="900"
Width="560" SizeToContent="Height" MaxHeight="940"
ResizeMode="CanMinimize"
Background="{DynamicResource Theme.WindowBackground}"
Foreground="{DynamicResource Theme.Foreground}">
@@ -59,370 +59,348 @@
</Style>
</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">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<StackPanel>
<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]}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="164" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Style="{StaticResource FieldLabel}"
Text="{Binding Localization[LanguageLabel]}" />
<ComboBox Grid.Column="1"
ItemsSource="{Binding Localization.AvailableLanguages}"
DisplayMemberPath="DisplayName"
SelectedValuePath="Code"
SelectedValue="{Binding Settings.Language}" />
<TextBlock Style="{StaticResource FieldLabel}"
Text="{Binding Localization[LanguageLabel]}" />
<ComboBox Grid.Column="1"
ItemsSource="{Binding Localization.AvailableLanguages}"
DisplayMemberPath="DisplayName"
SelectedValuePath="Code"
SelectedValue="{Binding Settings.Language}" />
<TextBlock Grid.Row="1" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}"
Text="{Binding Localization[ThemeLabel]}" />
<ComboBox Grid.Row="1" Grid.Column="1" Margin="0,12,0,0"
ItemsSource="{Binding Themes}"
ItemTemplate="{StaticResource EnumOptionTemplate}"
SelectedValuePath="Value"
SelectedValue="{Binding Settings.Theme}" />
</Grid>
</GroupBox>
<TextBlock Grid.Row="1" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}"
Text="{Binding Localization[ThemeLabel]}" />
<ComboBox Grid.Row="1" Grid.Column="1" Margin="0,12,0,0"
ItemsSource="{Binding Themes}"
ItemTemplate="{StaticResource EnumOptionTemplate}"
SelectedValuePath="Value"
SelectedValue="{Binding Settings.Theme}" />
</Grid>
</GroupBox>
<GroupBox Header="{Binding Localization[SectionPopup]}">
<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" />
<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]}">
<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}">
<Run Text="{Binding Localization[PlacementModeLabel], Mode=OneWay}" />
<InlineUIContainer BaselineAlignment="Baseline">
<TextBlock Text="{Binding Localization[MoreInfoLink]}"
Foreground="{DynamicResource Theme.Accent}"
TextDecorations="Underline"
Cursor="Help"
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}" />
<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>
<!-- The "at cursor" mode: a side and an offset of its own -->
<TextBlock Grid.Row="1" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}"
Text="{Binding Localization[CursorCornerLabel]}"
Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility},
ConverterParameter=AtCursor}" />
<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="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[CursorOffsetLabel]}"
Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility},
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"
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>
<!-- 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="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}"
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}" />
<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]}" />
<!-- 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="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>
<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}" />
</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
user, so both live in a single section: the rule is picked at the
top and its result is seen in the preview at the bottom -->
<GroupBox Header="{Binding Localization[SectionPopup]}">
<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" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<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 Style="{StaticResource FieldLabel}">
<Run Text="{Binding Localization[PlacementModeLabel], Mode=OneWay}" />
<InlineUIContainer BaselineAlignment="Baseline">
<TextBlock Text="{Binding Localization[MoreInfoLink]}"
Foreground="{DynamicResource Theme.Accent}"
TextDecorations="Underline"
Cursor="Help"
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}" />
<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}" />
<!-- The "at cursor" mode: a side and an offset of its own -->
<TextBlock Grid.Row="1" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}"
Text="{Binding Localization[CursorCornerLabel]}"
Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility},
ConverterParameter=AtCursor}" />
<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="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="2" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}"
Text="{Binding Localization[CursorOffsetLabel]}"
Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility},
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}" />
<!-- 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>
<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>
</Grid>
</GroupBox>
</Border>
</Grid>
</GroupBox>
</StackPanel>
</Grid>
<GroupBox Header="{Binding Localization[SectionBehavior]}">
<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>
</Window>
Binary file not shown.
Binary file not shown.
Binary file not shown.
+19 -6
View File
@@ -16,12 +16,25 @@
<OutputType>Package</OutputType>
<!--
MSI validation still assumes an installation for the whole machine.
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, so they are turned off rather than worked around.
The ICE checks run inside the Windows Installer service, and a build
agent has no access to it: every single check comes back as WIX0217,
"The Windows Installer Service could not be accessed", and the build
fails on close to a hundred of them. Since the release is built by the
agent, validation cannot be part of it.
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>
+2 -2
View File
@@ -136,8 +136,8 @@
Condition="REMOVE=&quot;ALL&quot; AND NOT UPGRADINGPRODUCTCODE" />
</InstallExecuteSequence>
<!-- Offered at the end of the wizard, the way an installer usually does -->
<Property Id="WixShellExecTarget" Value="[#CursorLang.exe]" />
<SetProperty Id="WixShellExecTarget" Value="[INSTALLFOLDER]CursorLang.exe"
After="CostFinalize" Sequence="ui" />
<CustomAction Id="LaunchApplication" BinaryRef="Wix4UtilCA_$(sys.BUILDARCHSHORT)"
DllEntry="WixShellExec" Impersonate="yes" Return="ignore" />
+84
View File
@@ -0,0 +1,84 @@
<#
.SYNOPSIS
Draws the Store 9:16 poster (720x1080), matching the mark drawn by
Packaging\New-Assets.ps1 (a dark plate with a white "Aя").
.DESCRIPTION
One-off script, not part of the packaging pipeline: this poster is a Store
listing image, uploaded by hand in Partner Center rather than shipped
inside the MSIX, so it does not belong next to the logos that build feeds
into the package.
#>
[CmdletBinding()]
param(
[string] $OutPath = (Join-Path $PSScriptRoot 'Poster720x1080.png')
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Drawing
$Width = 720
$Height = 1080
$Background = [System.Drawing.Color]::FromArgb(255, 0x20, 0x20, 0x20)
$Foreground = [System.Drawing.Color]::FromArgb(255, 0xFF, 0xFF, 0xFF)
$Glyph = 'Aя'
$bitmap = New-Object System.Drawing.Bitmap($Width, $Height, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
try {
$graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
$graphics.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::AntiAliasGridFit
# Full bleed: Partner Center masks/rounds the poster itself, so the plate
# here just fills the whole canvas rather than drawing its own corners
$graphics.Clear($Background)
# The glyph is sized off the width, the narrower of the two dimensions,
# the same way New-Assets.ps1 sizes it off the shorter side of a square
[System.Drawing.StringFormat] $format = [System.Drawing.StringFormat]::GenericTypographic.Clone()
try {
$target = $Width * 0.62
$ceiling = $Height * 0.30
$fontSize = $Width * 0.9
$font = $null
$measured = $null
for ($attempt = 0; $attempt -lt 20; $attempt++) {
if ($font) { $font.Dispose() }
$font = New-Object System.Drawing.Font('Segoe UI', $fontSize, [System.Drawing.FontStyle]::Bold, [System.Drawing.GraphicsUnit]::Pixel)
$measured = $graphics.MeasureString($Glyph, $font, ([System.Drawing.PointF]::new(0, 0)), $format)
if ($measured.Width -le $target -and $measured.Height -le $ceiling) { break }
$scale = [Math]::Min($target / $measured.Width, $ceiling / $measured.Height)
$fontSize = $fontSize * $scale * 0.98
}
try {
$x = [Math]::Round(($Width - $measured.Width) / 2.0)
$y = [Math]::Round(($Height - $measured.Height) / 2.0)
$brush = New-Object System.Drawing.SolidBrush($Foreground)
$origin = New-Object System.Drawing.PointF($x, $y)
try { $graphics.DrawString($Glyph, $font, $brush, $origin, $format) }
finally { $brush.Dispose() }
}
finally {
$font.Dispose()
}
}
finally {
$format.Dispose()
}
}
finally {
$graphics.Dispose()
}
try { $bitmap.Save($OutPath, [System.Drawing.Imaging.ImageFormat]::Png) } finally { $bitmap.Dispose() }
Write-Host "Saved $OutPath"
+91 -11
View File
@@ -84,20 +84,28 @@ MSIX всегда выполняются в контексте вошедшег
раскладкой. Без UI, и так должно остаться: всё, что попадёт туда, попадёт и
в фоновый процесс.
Связь между ними — только `settings.json`. Окно пишет его целиком, во временный
файл, который одним движением встаёт на место, и посылает агенту
зарегистрированное оконное сообщение, по которому тот перечитывает файл.
Всё, что окно сообщает агенту, идёт только через `settings.json`. Окно пишет его
целиком, во временный файл, который одним движением встаёт на место, и посылает
агенту зарегистрированное оконное сообщение, по которому тот перечитывает файл.
Сообщение не несёт данных: пересылка самих изменений лишила бы файл роли
единственного источника правды. Работающий агент необязателен — без него окно
работает так же. За файлом никто не следит: писатель у него один, и он сам
сообщает о записи.
В обратную сторону агент говорит одно слово, и то последнее: пункт «Выход» в меню
трея просит открытое окно настроек закрыться, прежде чем агент уйдёт, — чтобы на
экране не осталось окна, которому больше ничего не принадлежит. Просьба идёт
именованным событием сессии, а не оконным сообщением: агент запускает этот процесс
и намеренно не держит на него ссылки. Чаще всего слушать её некому, и это
нормальный ответ.
## Трей
Окно настроек — гость на экране, а не само приложение: оно показывается после
установки и всякий раз, когда его просят иконка или её меню. Обе кнопки в
заголовке окна означают ровно то, что написано: окно закрывается, а его процесс
завершается. Выход из самого приложения — пункт «Выход» в меню трея.
завершается. Выход из самого приложения — пункт «Выход» в меню трея, и открытое
окно настроек он закрывает вместе с агентом.
Меню иконки системное, его рисует Windows. Надписи по-прежнему следуют языку,
выбранному в настройках, а тема до меню больше не дотягивается: меню на WPF
@@ -161,6 +169,24 @@ MSIX всегда выполняются в контексте вошедшег
на месте, а не исчезает из раздела — появляющаяся и исчезающая строка сдвигала бы
всё, что ниже, при каждой смене места.
Положение каретки сообщает не всякое приложение, и тогда рядом с курсором ввода —
нигде. Подсказка уходит на фиксированную точку: место, отступ и оформление берутся
оттуда, из режима каретки не берётся ничего. Курсор мыши был бы запасным вариантом
поближе — и неверным: он там, где его оставили, сбоку или на другом мониторе, тогда
как смотрят на каретку. Фиксированная точка всегда на одном и том же месте, и потому
известно, куда смотреть, — а вместе с местом должно приходить и оформление: подсказка,
настроенная мелкой и незаметной для середины текста, в углу монитора останется
незамеченной.
Какие-то приложения, решает не то, на чём они написаны, а то, что окно о себе
сообщает. Обычный элемент Win32 заводит системную каретку, и её читают сразу;
Chrome и Electron рисуют свою и сообщают о ней через интерфейсы доступности.
Окну, которое рисует текст на голом холсте, сообщать нечего — и оба случая
уживаются в одном приложении: редакторы JetBrains отвечают, потому что каретку
для них держит их среда выполнения, а панель инструментов, нарисованная Compose,
не содержит текстового элемента, который нашли бы UI Automation или Java Access
Bridge. Ради такого окна фиксированная точка и нужна.
К какому режиму относится оформление, объясняет всплывающая подсказка рядом с самим
режимом, а не строка текста в разделе: ползунки ниже показывают другие числа после
переключения режима, и это вопрос, который задают один раз.
@@ -333,10 +359,64 @@ pwsh -File Packaging\build-installer.ps1 -Version 1.0.1
снимет: SmartScreen смотрит на репутацию, а у нового сертификата её нет, пока
приложение не наберёт установок.
Про проект WiX стоит знать две вещи, прежде чем его править. Он закреплён на WiX
5, а не на нынешней 7: начиная с шестой версии инструмент требует принимать
лицензию Open Source Maintenance Fee — бесплатную при доходе меньше $10 000 в
год, но принимать её должен человек, а не сборочный скрипт. И он отключает три
проверки ICE: MSI по-прежнему исходит из установки на всю машину, а установка в
профиль пользователя нарушает правила, которые описывают ровно то, что здесь и
задумано.
Про проект WiX стоит знать две вещи, прежде чем его править.
Он закреплён на WiX 5, а не на нынешней 7: начиная с шестой версии инструмент
требует принимать лицензию Open Source Maintenance Fee — бесплатную при доходе
меньше $10 000 в год, но принимать её должен человек, а не сборочный скрипт.
И он собирается без проверки MSI. Проверки ICE выполняются службой установщика
Windows, до которой сборочному агенту не дотянуться: каждая возвращается
ошибкой `WIX0217`, и сборка умирает на без малого сотне таких. На обычной
машине служба отвечает, и проверка включается одним ключом:
```powershell
dotnet build Packaging\Installer\CursorLang.wixproj -p:SuppressValidation=false
```
Три правила остаются подавленными и тогда. MSI исходит из установки на всю
машину, а установка в профиль пользователя нарушает правила, которые описывают
ровно то, что здесь и задумано.
## Сайт
`site/index.html` — страница, на которую ссылается заявка в Store: что делает
приложение и политика конфиденциальности, говорящая, что оно ничего не собирает.
Partner Center не требует политику от приложения, которое не собирает данных, но
страница, сказавшая это вслух, снимает вопрос у проверяющего.
Всё это — пять файлов в `site`, и ничего больше ниоткуда не подгружается: ни
фреймворка, ни сборки, ни шрифта из сети.
| Файл | Что внутри |
|---|---|
| `index.html` | Разметка, оба языка сразу |
| `styles.css` | Все правила, включая палитру |
| `boot.js` | Язык и тема прошлого визита — до первого кадра |
| `main.js` | Остальное: переключатели, меню, демонстрация, предпросмотр |
| `favicon.svg` | Та же тёмная плашка с «Aя», что и у иконки приложения |
`boot.js` вынесен отдельно и мал по одной причине: он должен отработать до
разбора `body`. Он читает выбранное в прошлый раз и ставит это на элемент
`<html>` — без него первый кадр был бы английским и в системной теме, а потом
на глазах исправлялся бы. `main.js` работает по разметке, которая к тому времени
должна существовать, поэтому он отложенный.
Оба языка живут в разметке одновременно, а переключатель в углу выбирает один.
Каждый переведённый кусок — это `<span lang="en">` рядом с `<span lang="ru">`, и
тот, который не совпадает с языком на элементе `<html>`, скрывается правилом в
две строки. С выключенными скриптами страница всё равно читается — по-английски.
Выкладка сводится к копированию этих пяти файлов как есть. До неё адреса ждут
две вещи.
Первая — страница в Store: обе кнопки загрузки сейчас не ссылки, а
`<span class="btn disabled" role="link">`, потому что заглушка в `href` была бы
относительным адресом и всякий, кто по ней пойдёт, получил бы 404. С адресом
каждая возвращается в `<a class="btn" href="…">`, а примечание рядом уходит.
Вторая — адрес самого сайта: его ждут три тега, которых в шапке документа пока
нет, — `og:image`, `og:url` и canonical.
Поддержка — `support@alrakis.kz`: адрес стоит в разделе о данных, под кнопками
загрузки и в подвале.
+90 -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
in the background process.
The connection between the two is `settings.json` and nothing else. The window
writes it — whole, into a temporary file moved into place in one step — and then
Everything the window has to say to the agent goes through `settings.json` and
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
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
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 settings window is a guest on the screen rather than the app itself: it shows
up after the installation and whenever the icon or its menu is asked for. Both
buttons in its title bar 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 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
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
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.
@@ -327,10 +352,65 @@ 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
people have installed the application.
Two things about the WiX project are worth knowing before touching it. It pins
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
revenue, but a decision for a person rather than for a build script. And it turns
off three ICE validation rules: MSI still assumes an installation for the whole
machine, and installing into the user's own profile trips rules that describe
exactly what was intended here.
Two things about the WiX project are worth knowing before touching it.
It pins 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 revenue, but a decision for a person rather than for a build script.
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.
## The site
`site/index.html` is the page the Store listing points at: what the application
does, and a privacy policy saying that it collects nothing. Partner Center leaves
the policy optional for an app that gathers no data, but a page saying so out
loud is what takes the question off the reviewer's desk.
The whole of it is five files in `site`, and nothing is fetched from anywhere
else — no framework, no build step, no font from a network:
| File | What is in it |
|---|---|
| `index.html` | The markup, both languages at once |
| `styles.css` | Every rule, the palette included |
| `boot.js` | The language and the theme of an earlier visit, before the first frame |
| `main.js` | The rest: the switches, the menu, the demo, the preview |
| `favicon.svg` | The same dark plate with "Aя" as the application icon |
`boot.js` is small and separate for one reason: it has to run before the body is
parsed. It reads what was chosen last time and puts it on the `<html>` element,
and without it the first frame would be English in the system theme and correct
itself a moment later, in plain sight. `main.js` works on markup that has to
exist by then, so it is deferred instead.
Both languages live in the markup at once, and a switch in the corner picks one.
Every translated piece is a `<span lang="en">` beside a `<span lang="ru">`, and
whichever does not match the language on the `<html>` element is hidden by a rule
of two lines. With the scripts switched off the page still reads, in English.
Publishing means copying the five files as they are. Before that, two things are
still waiting for an address.
The Store listing is the first: the two download buttons are
`<span class="btn disabled" role="link">` rather than links, because a
placeholder in `href` would be a relative address and anything following it would
land on a 404. The address turns each of them back into
`<a class="btn" href="…">` and takes the note beside it away.
The address of the site itself is the second, wanted by three tags the head does
not carry yet: `og:image`, `og:url` and the canonical link.
Support goes to `support@alrakis.kz`: it stands in the privacy section, under the
download buttons and in the footer.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+27
View File
@@ -0,0 +1,27 @@
(function () {
"use strict";
var root = document.documentElement;
var lang = null;
var theme = null;
try {
lang = localStorage.getItem("cursorlang.lang");
theme = localStorage.getItem("cursorlang.theme");
} catch (error) {
// Storage refused: what the markup says stands
}
if (!lang && (navigator.language || "").toLowerCase().indexOf("ru") === 0) {
lang = "ru";
}
if (lang === "ru" || lang === "en") {
root.setAttribute("data-lang", lang);
root.setAttribute("lang", lang);
}
if (theme === "dark" || theme === "light") {
root.setAttribute("data-theme", theme);
}
}());
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="12" fill="#202020"/>
<text x="32" y="33" font-family="Segoe UI, sans-serif" font-size="30" font-weight="700"
fill="#ffffff" text-anchor="middle" dominant-baseline="central">Aя</text>
</svg>

After

Width:  |  Height:  |  Size: 303 B

+671
View File
@@ -0,0 +1,671 @@
<!doctype html>
<html lang="en" data-lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CursorLang — the keyboard layout at your cursor</title>
<meta name="description" content="A tiny Windows tray app that names the keyboard layout you have just switched to — beside the text caret, beside the mouse pointer, or at a fixed point on the screen. Free, offline, under 9 MB."
data-content-ru="Небольшая программа в трее Windows, называющая раскладку, на которую вы только что переключились, — у текстового курсора, у указателя мыши или в выбранной точке экрана. Бесплатно, без доступа в сеть, меньше 9 МБ.">
<meta property="og:type" content="website">
<meta property="og:title" content="CursorLang — the keyboard layout at your cursor">
<meta property="og:description" content="Switch the layout and a small popup names it, right where you are typing. Free, offline, under 9 MB in the tray.">
<meta property="og:url" content="https://cursor-lang.alrakis.kz/">
<meta property="og:image" content="https://cursor-lang.alrakis.kz/favicon.svg">
<link rel="canonical" href="https://cursor-lang.alrakis.kz/">
<meta name="theme-color" content="#fbfbfd" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#0d0d10" media="(prefers-color-scheme: dark)">
<link rel="icon" href="favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="styles.css">
<!-- boot.js runs before the body is parsed, so the language and the theme of an
earlier visit are in place for the first frame; everything else waits for
the markup it works on -->
<script src="boot.js"></script>
<script src="main.js" defer></script>
</head>
<body>
<span id="top"></span>
<a class="skip" href="#main"><span lang="en">Skip to content</span><span lang="ru">К содержанию</span></a>
<header data-menu="closed">
<div class="wrap bar">
<div class="brand">
<svg width="26" height="26" viewBox="0 0 64 64" aria-hidden="true">
<rect width="64" height="64" rx="12" fill="#202020"></rect>
<text x="32" y="34" font-family="Segoe UI, sans-serif" font-size="29" font-weight="700"
fill="#fff" text-anchor="middle" dominant-baseline="central">Aя</text>
</svg>
CursorLang
</div>
<nav class="links" id="siteNav">
<a href="#position"><span lang="en">Position</span><span lang="ru">Положение</span></a>
<a href="#appearance"><span lang="en">Appearance</span><span lang="ru">Оформление</span></a>
<a href="#capslock">Caps Lock</a>
<a href="#lightweight"><span lang="en">Lightweight</span><span lang="ru">Легковесность</span></a>
<a href="#privacy"><span lang="en">Privacy</span><span lang="ru">Данные</span></a>
<a href="#download"><span lang="en">Download</span><span lang="ru">Загрузка</span></a>
</nav>
<div class="controls">
<div class="seg" role="group" aria-label="Language"
data-label-en="Language" data-label-ru="Язык">
<button type="button" data-set-lang="en" aria-pressed="true">EN</button>
<button type="button" data-set-lang="ru" aria-pressed="false">RU</button>
</div>
<button type="button" class="icon-btn" id="themeBtn" aria-label="Light or dark theme"
data-label-en="Light or dark theme" data-label-ru="Светлая или тёмная тема">
<svg class="sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" aria-hidden="true">
<circle cx="12" cy="12" r="4.2"></circle>
<path d="M12 2.6v2.2M12 19.2v2.2M2.6 12h2.2M19.2 12h2.2M5.4 5.4l1.6 1.6M17 17l1.6 1.6M18.6 5.4L17 7M7 17l-1.6 1.6"></path>
</svg>
<svg class="moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M20 14.2A8.2 8.2 0 0 1 9.8 4a8.2 8.2 0 1 0 10.2 10.2z"></path>
</svg>
</button>
<button type="button" class="icon-btn" id="menuBtn"
aria-label="Menu" data-label-en="Menu" data-label-ru="Меню"
aria-controls="siteNav" aria-expanded="false">
<svg class="bars" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" aria-hidden="true">
<path d="M4 7h16M4 12h16M4 17h16"></path>
</svg>
<svg class="cross" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18"></path>
</svg>
</button>
</div>
</div>
</header>
<div class="scrim" aria-hidden="true"></div>
<main id="main">
<!-- ============================================================== hero -->
<section class="hero">
<div class="wrap">
<h1>
<span lang="en">Your layout — right where you type.</span>
<span lang="ru">Раскладка — там, где вы печатаете.</span>
</h1>
<p class="lede">
<span lang="en">CursorLang is a lightweight Windows utility that lives in your system tray. Whenever you
switch your keyboard layout, a brief popup indicates the new language next to your text caret,
mouse pointer, or a fixed screen position. Half a second later, it fades away.
Simple and unobtrusive.</span>
<span lang="ru">CursorLang — это легковесная утилита для Windows, которая находится в системном трее.
При смене раскладки клавиатуры рядом с курсором, указателем мыши или фиксированной точкой на экране
появляется короткое всплывающее окно, указывающее на новый язык. Через полсекунды оно исчезает.
Просто и ненавязчиво.</span>
</p>
<div class="cta">
<a class="btn" role="link" href="https://apps.microsoft.com/detail/9nvpp6h49kpp">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M3 3h8.5v8.5H3V3zm9.5 0H21v8.5h-8.5V3zM3 12.5h8.5V21H3v-8.5zm9.5 0H21V21h-8.5v-8.5z"></path>
</svg>
<span lang="en">Download from Microsoft Store</span>
<span lang="ru">Скачать из Microsoft Store</span>
</a>
</div>
<div class="facts">
<span lang="en">Free</span><span lang="ru">Бесплатно</span>
<span lang="en">Windows 10 1809 and later, 64-bit</span>
<span lang="ru">Windows 10 1809 и новее, 64 бита</span>
<span lang="en">Uses under 9 MB of memory</span><span lang="ru">Использует менее 9 МБ памяти</span>
<span lang="en">No internet connection required, zero telemetry</span><span lang="ru">Без доступа в сеть, без телеметрии.</span>
</div>
<figure class="stage">
<div class="window">
<div class="titlebar" aria-hidden="true">
<span class="dots"><i></i><i></i><i></i></span>
<span class="name">
<span lang="en">Team chat</span><span lang="ru">Рабочий чат</span>
</span>
</div>
<div class="screen">
<p class="msg" aria-hidden="true">
<span lang="en">Is the build green?</span>
<span lang="ru">Сборка прошла?</span>
</p>
<div class="field">
<span class="typed" id="demoText"></span><span class="caret" id="demoCaret"><span
class="chip right fade" id="demoChip" hidden>EN</span></span>
</div>
</div>
<div class="taskbar" aria-hidden="true">
<span class="ind" id="demoIndicator">ENG</span>
<span>21:14</span>
</div>
</div>
</figure>
</div>
</section>
<!-- ========================================================== position -->
<section id="position">
<div class="wrap">
<h2><span lang="en">Position</span><span lang="ru">Положение</span></h2>
<p class="deck">
<span lang="en">Three placement modes</span>
<span lang="ru">Три режима размещения подсказки</span>
</p>
<p class="lede">
<span lang="en">Choose your preferred placement in the settings. Each mode maintains its own independent
position, offset, and appearance.</span>
<span lang="ru">Выберите желаемое положение в настройках. Каждый режим сохраняет свое независимое положение,
смещение и внешний вид.</span>
</p>
<div class="cards">
<article class="card">
<figure class="sketch">
<svg viewBox="0 0 260 130" role="img"
aria-label="A screen with the mouse pointer and the popup by its bottom right corner"
data-label-en="A screen with the mouse pointer and the popup by its bottom right corner"
data-label-ru="Экран с указателем мыши и подсказкой у его правого нижнего угла">
<rect class="frame" x="1" y="1" width="258" height="128" rx="10" stroke-width="1.5"></rect>
<path class="line" d="M28 30h120M28 46h86M28 62h104" stroke-width="4"></path>
<path class="ink" d="M120 66l0 30 7-8 5 11 5-2-5-11 10 0z"></path>
<rect class="plate" x="140" y="94" width="34" height="22" rx="6"></rect>
<text class="glyph" x="157" y="108" text-anchor="middle">RU</text>
</svg>
</figure>
<h3><span lang="en">Near the mouse pointer</span><span lang="ru">Рядом с указателем мыши</span></h3>
<p>
<span lang="en">Position the indicator at any of the six sides or corners with a custom offset.
The popup dynamically follows your mouse pointer.</span>
<span lang="ru">Разместите индикатор на любой из шести сторон или углов с заданным смещением.
Всплывающее окно динамически следует за указателем мыши.</span>
</p>
</article>
<article class="card">
<figure class="sketch">
<svg viewBox="0 0 260 130" role="img"
aria-label="A screen with lines of text and the popup beside the caret"
data-label-en="A screen with lines of text and the popup beside the caret"
data-label-ru="Экран со строками текста и подсказкой рядом с курсором">
<rect class="frame" x="1" y="1" width="258" height="128" rx="10" stroke-width="1.5"></rect>
<path class="line" d="M28 34h204M28 54h120" stroke-width="4"></path>
<rect class="mark" x="152" y="44" width="2.5" height="20" rx="1"></rect>
<rect class="plate" x="164" y="43" width="34" height="22" rx="6"></rect>
<text class="glyph" x="181" y="57" text-anchor="middle">RU</text>
<path class="line" d="M28 78h178M28 98h96" stroke-width="4"></path>
</svg>
</figure>
<h3><span lang="en">Near the text caret</span><span lang="ru">Рядом с текстовым курсором</span></h3>
<p>
<span lang="en">Displays to the left or right of the caret, aligned with your current text line.</span>
<span lang="ru">Отображается слева или справа от курсора, выравниваясь по текущей текстовой строке.</span>
</p>
</article>
<article class="card">
<figure class="sketch">
<svg viewBox="0 0 260 130" role="img"
aria-label="A screen with the popup in the middle and the other six places marked faintly"
data-label-en="A screen with the popup in the middle and the other six places marked faintly"
data-label-ru="Экран с подсказкой посередине и еле намеченными шестью другими местами">
<rect class="frame" x="1" y="1" width="258" height="128" rx="10" stroke-width="1.5"></rect>
<rect class="ghost" x="24" y="22" width="26" height="16" rx="5"></rect>
<rect class="ghost" x="117" y="22" width="26" height="16" rx="5"></rect>
<rect class="ghost" x="210" y="22" width="26" height="16" rx="5"></rect>
<rect class="ghost" x="24" y="92" width="26" height="16" rx="5"></rect>
<rect class="ghost" x="117" y="92" width="26" height="16" rx="5"></rect>
<rect class="ghost" x="210" y="92" width="26" height="16" rx="5"></rect>
<rect class="plate" x="113" y="54" width="34" height="22" rx="6"></rect>
<text class="glyph" x="130" y="68" text-anchor="middle">RU</text>
</svg>
</figure>
<h3><span lang="en">A fixed point on the screen</span><span lang="ru">В выбранной точке экрана</span></h3>
<p>
<span lang="en">Choose from seven preset positions on your active monitor, complete with
customizable margins. The indicator stays pinned to a consistent spot, so you always
know where to look.</span>
<span lang="ru">Выберите одно из семи предустановленных положений на активном мониторе с
настраиваемыми полями. Индикатор остается зафиксированным в одном и том же месте,
поэтому вы всегда будете знать, куда смотреть.</span>
</p>
</article>
</div>
<p class="aside">
<span lang="en"><b>Not every application exposes its text caret.</b> Standard Win32 fields respond
instantly, while Chromium and Electron apps rely on accessibility APIs. However, applications
rendering text onto custom canvases—like certain tool windows in a JetBrains IDE—may not report
caret coordinates at all.
In these fallback scenarios, the indicator automatically defaults to your configured fixed point
placement, using that mode's appearance. It avoids jumping to the mouse pointer, as your eyes are
focused on typing and the mouse might be left anywhere on the screen.</span>
<span lang="ru"><b>Не всякое приложение сообщает, где его курсор.</b> Стандартные поля Win32 реагируют
мгновенно, в то время как приложения Chromium и Electron используют API специальных возможностей.
Однако приложения, отображающие текст на пользовательских холстах — например, некоторые окна
инструментов в IDE JetBrains — могут вообще не сообщать координаты курсора.
В таких резервных сценариях индикатор автоматически переключается на заданное вами фиксированное
положение, используя внешний вид этого режима. Это позволяет избежать переключения на указатель мыши,
поскольку ваши глаза сосредоточены на наборе текста, а курсор может находиться в любой точке экрана.</span>
</p>
</div>
</section>
<!-- ============================================================== look -->
<section id="appearance">
<div class="wrap">
<h2><span lang="en">Appearance</span><span lang="ru">Оформление</span></h2>
<p class="deck">
<span lang="en">Each mode has its own unique look</span>
<span lang="ru">У каждого режима своё оформление</span>
</p>
<p class="lede">
<span lang="en">The caret popup lives right inside the text you're reading, keeping it compact and
unobtrusive. The fixed-screen popup, on the other hand, is meant to be larger and more prominent.
Therefore, offset, font size, opacity, and colors are saved independently for each mode — changing
one leaves the rest untouched.</span>
<span lang="ru">Всплывающее окно с курсором располагается прямо внутри читаемого текста, оставаясь
компактным и ненавязчивым. Всплывающее окно с фиксированным экраном, напротив, предназначено для
большего размера и большей заметности. Поэтому смещение, размер шрифта, прозрачность и цвета
сохраняются независимо для каждого режима — изменение одного параметра не влияет на остальные.</span>
</p>
<div class="playground">
<form class="panel" id="lookForm">
<h3><span lang="en">Layout popup</span><span lang="ru">Подсказка с раскладкой</span></h3>
<div class="row">
<label for="offset">
<span><span lang="en">Offset</span><span lang="ru">Отступ</span></span>
<span class="val" id="offsetVal">16</span>
</label>
<input type="range" id="offset" min="0" max="80" step="1" value="16">
</div>
<div class="row">
<label for="fontSize">
<span><span lang="en">Font size</span><span lang="ru">Размер шрифта</span></span>
<span class="val" id="fontSizeVal">20</span>
</label>
<input type="range" id="fontSize" min="10" max="72" step="1" value="20">
</div>
<div class="row">
<label for="opacity">
<span><span lang="en">Opacity</span><span lang="ru">Непрозрачность</span></span>
<span class="val" id="opacityVal">90%</span>
</label>
<input type="range" id="opacity" min="10" max="100" step="1" value="90">
</div>
<div class="row pair">
<div class="row tight">
<label for="bgColor"><span lang="en">Background</span><span lang="ru">Фон</span></label>
<input type="color" id="bgColor" value="#202020">
</div>
<div class="row tight">
<label for="fgColor"><span lang="en">Text</span><span lang="ru">Текст</span></label>
<input type="color" id="fgColor" value="#ffffff">
</div>
</div>
<div class="row">
<span class="lbl" id="sideLabel">
<span><span lang="en">Side of the caret</span><span lang="ru">Сторона от курсора</span></span>
</span>
<div class="seg" id="side" role="group" aria-labelledby="sideLabel">
<button type="button" data-side="left" aria-pressed="false">
<span lang="en">Left</span><span lang="ru">Слева</span>
</button>
<button type="button" data-side="right" aria-pressed="true">
<span lang="en">Right</span><span lang="ru">Справа</span>
</button>
</div>
</div>
<div class="row">
<label for="duration">
<span><span lang="en">Display time</span><span lang="ru">Время показа</span></span>
<span class="val" id="durationVal">500 ms</span>
</label>
<input type="range" id="duration" min="200" max="5000" step="100" value="500">
</div>
<button type="button" class="btn ghost block" id="previewBtn">
<span lang="en">Preview</span><span lang="ru">Предпросмотр</span>
</button>
</form>
<div class="preview-stage">
<div class="preview-line">
<span lang="en">Sent you a </span><span lang="ru">Отправил тебе </span>
<span class="typed">pull request</span><span class="caret" id="previewCaret"><span
class="chip right fade" id="previewChip">RU</span></span>
</div>
</div>
</div>
<p class="aside">
<span lang="en"><b>The popup only answers a question you explicitly asked.</b> It appears when you switch the
layout yourself. When you switch to another window, Windows may change the layout automatically in
the background, but the popup stays silent. After all, you didn't ask.</span>
<span lang="ru"><b>Всплывающее окно отвечает только на заданный вами вопрос.</b> Оно появляется, когда вы
сами меняете расположение окон. При переключении на другое окно Windows может автоматически изменить
расположение окон в фоновом режиме, но всплывающее окно остается незамеченным. В конце концов, вы же
не спрашивали.</span>
</p>
</div>
</section>
<!-- ========================================================== capslock -->
<section id="capslock">
<div class="wrap">
<h2>Caps Lock</h2>
<p class="deck">
<span lang="en">A key that could be doing more</span>
<span lang="ru">Клавиша, которая могла бы работать</span>
</p>
<p class="lede">
<span lang="en">When enabled in settings, a quick tap of Caps Lock switches your keyboard layout instead
of toggling letter casing. Hold it longer than your custom threshold, and nothing changes: the popup
simply displays your current layout — a quick look, without switching.</span>
<span lang="ru">Если эта функция включена в настройках, быстрое нажатие клавиши Caps Lock переключает
раскладку клавиатуры вместо изменения регистра букв. Если удерживать клавишу дольше заданного вами
порога, ничего не изменится: во всплывающем окне просто отобразится текущая раскладка — быстрый
взгляд без переключения.</span>
</p>
<ul class="bullets two-up">
<li>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 12a8 8 0 1 1 16 0 8 8 0 0 1-16 0z"></path><path d="M12 8v4l2.5 2"></path>
</svg>
<span>
<span lang="en"><b>Disabled by default.</b> This shortcut is disabled by default in the app, so
the Caps Lock key continues to function as usual.</span>
<span lang="ru"><b>По умолчанию выключено.</b> В приложении Caps Lock отключен по умолчанию,
поэтому он продолжает работать так, как работал всегда.</span>
</span>
</li>
<li>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M5 12h14M12 5v14"></path>
</svg>
<span>
<span lang="en"><b>No administrator rights required.</b> Built as a standard user-level
application, it runs under your active user account and simply doesn't need higher
privileges.</span>
<span lang="ru"><b>Права администратора не запрашиваются.</b> Приложение разработано как
стандартное приложение для пользователей и работает под вашей активной учетной записью,
поэтому более высокие привилегии не требуются.</span>
</span>
</li>
<li>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 3l7.5 3v5.5c0 4.4-3 7.9-7.5 9.5-4.5-1.6-7.5-5.1-7.5-9.5V6L12 3z"></path>
</svg>
<span>
<span lang="en"><b>Only one key is monitored.</b> The low-level hook watches strictly for
Caps Lock. No other keystrokes are recorded, stored, or transmitted anywhere — and since
the app is offline, they physically can't be.</span>
<span lang="ru"><b>Отслеживается одна клавиша.</b> Низкоуровневый перехватчик следит исключительно
за нажатием клавиши Caps Lock. Никакие другие нажатия клавиш не записываются, не сохраняются и
не передаются никуда — а поскольку приложение работает в автономном режиме, это
физически невозможно.</span>
</span>
</li>
<li>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 4.5l8 14H4l8-14z"></path><path d="M12 10v4M12 16.5v.01"></path>
</svg>
<span>
<span lang="en"><b>Doesn't interfere with elevated apps.</b> Windows blocks ordinary apps from
injecting shortcuts into windows running as administrator (like Task Manager, Registry Editor,
or UAC prompts).</span>
<span lang="ru"><b>Не работает в окнах, запущенных от администратора.</b>
Windows блокирует возможность передачу нажатия клавиш в окнах, запущенные от имени
администратора, и служебных приложениях (например, диспетчером задач, редактором реестра
или запросами UAC).</span>
</span>
</li>
</ul>
</div>
</section>
<!-- ===================================================== lightweight -->
<section id="lightweight">
<div class="wrap">
<h2><span lang="en">Lightweight</span><span lang="ru">Легковесность</span></h2>
<p class="deck">
<span lang="en">Small enough to forget about</span>
<span lang="ru">Настолько маленькое, что о нём легко забыть</span>
</p>
<p class="lede">
<span lang="en">CursorLang is split into two executables. The one that lives in your tray all day is
built without a UI rendering stack — a tooltip that Win32 draws in eight megabytes has no business
costing a hundred. Anything heavier is strictly kept out of the background process. The second
executable handles the settings: it loads only when you open the window and completely unloads
from memory the moment you close it.</span>
<span lang="ru">CursorLang разделен на два исполняемых файла. Тот, что находится в системном трее весь
день, создан без стека рендеринга пользовательского интерфейса — всплывающая подсказка, которую
Win32 отрисовывает за восемь мегабайт, не должна стоить сто. Все, что потребляет больше ресурсов,
строго исключено из фонового процесса. Второй исполняемый файл обрабатывает настройки: он загружается
только при открытии окна и полностью выгружается из памяти в момент его закрытия.</span>
</p>
<ul class="bullets two-up">
<li>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 7l8-4 8 4v10l-8 4-8-4V7z"></path><path d="M4 7l8 4 8-4M12 11v10"></path>
</svg>
<span>
<span lang="en"><b>Under 9 MB in the tray.</b> The settings window is a separate
process: it starts when you ask for it and gives its memory back when you
close it.</span>
<span lang="ru"><b>Меньше 9 МБ в трее.</b> Окно настроек — отдельный процесс: он
запускается, когда его просят, и возвращает память, когда окно
закрывают.</span>
</span>
</li>
<li>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 3v9M8.2 6.4a6.5 6.5 0 1 0 7.6 0"></path>
</svg>
<span>
<span lang="en"><b>Starts with Windows if you ask it to</b> — and Windows always keeps the final
word. Turn off startup in Settings → Apps → Startup, and the application respects your choice
instead of quietly re-enabling itself.</span>
<span lang="ru"><b>Запускается вместе с Windows, если попросить</b> — и последнее слово всегда
остается за Windows. Отключите автозагрузку в Параметры → Приложения → Автозагрузка, и
приложение будет уважать ваш выбор, вместо того чтобы незаметно включаться снова.</span>
</span>
</li>
<li>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="8.5"></circle><path d="M12 3.5v17"></path>
</svg>
<span>
<span lang="en"><b>Light and dark themes,</b> following Windows or pinned to one
of them. Available in English and Russian.</span>
<span lang="ru"><b>Светлая и тёмная тема,</b> вслед за Windows или закреплённая.
Интерфейс — русский и английский.</span>
</span>
</li>
<li>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M20 6L9 17l-5-5"></path>
</svg>
<span>
<span lang="en"><b>Nothing else to install.</b> The application carries its own
copy of .NET, and the Microsoft Store keeps it up to date.</span>
<span lang="ru"><b>Ничего дополнительно устанавливать не нужно.</b> Приложение несёт с собой
свою копию .NET, a обновляет его Microsoft Store.</span>
</span>
</li>
</ul>
</div>
</section>
<!-- =========================================================== privacy -->
<section id="privacy">
<div class="wrap">
<h2><span lang="en">Privacy policy</span><span lang="ru">Политика конфиденциальности</span></h2>
<p class="deck">
<span lang="en">CursorLang collects nothing</span>
<span lang="ru">CursorLang ничего не собирает</span>
</p>
<p class="lede">
<span lang="en">It really is that simple. The points below just provide the technical details.</span>
<span lang="ru">Всё действительно так просто. Приведённые ниже пункты содержат лишь технические подробности.</span>
</p>
<div class="panel">
<ul class="bullets">
<li>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M20 6L9 17l-5-5"></path>
</svg>
<span>
<span lang="en"><b>No personal data is collected</b>, stored remotely, or shared with anyone.
There are no accounts, no ads, and no analytics.</span>
<span lang="ru"><b>Персональные данные не собираются,</b> не хранятся удаленно и не
передаются никому. Нет учетных записей, рекламы и аналитики.</span>
</span>
</li>
<li>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M20 6L9 17l-5-5"></path>
</svg>
<span>
<span lang="en"><b>The application makes no network requests.</b> It contains no networking
code, telemetry, or internal update checks — the Microsoft Store handles updates
automatically.</span>
<span lang="ru"><b>Приложение не обращается в сеть.</b> Не содержит сетевого кода, телеметрии
или внутренних проверок обновлений — обновления обрабатываются Microsoft Store
автоматически.</span>
</span>
</li>
<li>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M20 6L9 17l-5-5"></path>
</svg>
<span>
<span lang="en"><b>Settings are stored locally in a single file.</b> The Store version keeps
this in the app's local data folder, which Windows removes entirely upon uninstallation —
leaving no trace behind.</span>
<span lang="ru"><b>Настройки хранятся локально в одном файле.</b> В версии из магазина
приложений они хранятся в локальной папке данных приложения, которую Windows полностью
удаляет при деинсталляции, не оставляя никаких следов.</span>
</span>
</li>
<li>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M20 6L9 17l-5-5"></path>
</svg>
<span>
<span lang="en"><b>The optional Caps Lock shortcut uses a low-level keyboard hook.</b>
It monitors strictly the Caps Lock key and nothing else. Your keystrokes are never
recorded or transmitted, and the feature is disabled by default.</span>
<span lang="ru"><b>Горячая клавиша Caps Lock использует низкоуровневый
перехват клавиатуры.</b> Она отслеживает исключительно нажатие клавиши Caps Lock и
ничего больше. Ваши нажатия клавиш никогда не записываются и не передаются, и эта
функция отсутствует по умолчанию.</span>
</span>
</li>
<li>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M20 6L9 17l-5-5"></path>
</svg>
<span>
<span lang="en"><b>To position the popup, the app asks Windows for the active layout and
caret location.</b> This data never leaves the app's memory, and your actual typed
text is never read or accessed.</span>
<span lang="ru"><b>Для позиционирования всплывающего окна приложение запрашивает у Windows
активную компоновку и положение курсора.</b> Эти данные никогда не покидают память
приложения, и фактический введенный вами текст никогда не считывается и не используется.</span>
</span>
</li>
</ul>
</div>
</div>
</section>
<!-- ========================================================== download -->
<section id="download">
<div class="wrap">
<h2><span lang="en">Download</span><span lang="ru">Загрузка</span></h2>
<p class="deck">
<span lang="en">One click from the Microsoft Store</span>
<span lang="ru">Устанавливается из Microsoft Store в один клик</span>
</p>
<p class="lede">
<span lang="en">CursorLang is distributed through the Microsoft Store, which installs it and keeps it
updated on its own — nothing else is asked of you in between. It runs on Windows 10 version 1809
(build 17763) or later, 64-bit.</span>
<span lang="ru">CursorLang распространяется через Microsoft Store: он же его устанавливает и сам держит
обновлённым — больше от вас ничего не требуется. Работает на Windows 10 версии 1809
(сборка 17763) или новее, 64 бита.</span>
</p>
<div class="cta">
<a class="btn" role="link" href="https://apps.microsoft.com/detail/9nvpp6h49kpp">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M3 3h8.5v8.5H3V3zm9.5 0H21v8.5h-8.5V3zM3 12.5h8.5V21H3v-8.5zm9.5 0H21V21h-8.5v-8.5z"></path>
</svg>
<span lang="en">Download from Microsoft Store</span>
<span lang="ru">Скачать из Microsoft Store</span>
</a>
</div>
<p class="support">
<span lang="en">Something not working, or a question about the application? Write to
<a href="mailto:support@alrakis.kz">support@alrakis.kz</a> and name the version in
the letter — it is in the title of the settings window.</span>
<span lang="ru">Что-то не работает или есть вопрос о приложении? Напишите на
<a href="mailto:support@alrakis.kz">support@alrakis.kz</a> и назовите в письме
версию — она есть в заголовке окна настроек.</span>
</p>
</div>
</section>
</main>
<footer>
<div class="wrap footrow">
<span>
CursorLang · ©&nbsp;2026 Aleksandr&nbsp;Neichev
</span>
<nav>
<a href="#privacy"><span lang="en">Privacy</span><span lang="ru">Конфиденциальность</span></a>
<a href="mailto:support@alrakis.kz"><span lang="en">Support</span><span lang="ru">Поддержка</span></a>
<a href="#top"><span lang="en">Back to top</span><span lang="ru">Наверх</span></a>
</nav>
</div>
</footer>
</body>
</html>
+296
View File
@@ -0,0 +1,296 @@
(function () {
"use strict";
/* ------------------------------------------------------ small helpers */
var root = document.documentElement;
function remember(key, value) {
try { localStorage.setItem(key, value); } catch (error) { /* private mode: never mind */ }
}
/* ------------------------------------------------------- the language */
var description = document.querySelector('meta[name="description"]');
var TITLE_EN = document.title;
var TITLE_RU = "CursorLang — раскладка клавиатуры у курсора";
var DESCRIPTION_EN = description.getAttribute("content");
var DESCRIPTION_RU = description.getAttribute("data-content-ru");
function applyLanguage(lang) {
if (lang !== "ru") { lang = "en"; }
root.setAttribute("data-lang", lang);
root.setAttribute("lang", lang);
document.title = lang === "ru" ? TITLE_RU : TITLE_EN;
description.setAttribute("content", lang === "ru" ? DESCRIPTION_RU : DESCRIPTION_EN);
var labelled = document.querySelectorAll("[data-label-en]");
for (var i = 0; i < labelled.length; i++) {
labelled[i].setAttribute("aria-label", labelled[i].getAttribute("data-label-" + lang));
}
var buttons = document.querySelectorAll("[data-set-lang]");
for (var j = 0; j < buttons.length; j++) {
buttons[j].setAttribute("aria-pressed",
buttons[j].getAttribute("data-set-lang") === lang ? "true" : "false");
}
applyLook();
}
document.addEventListener("click", function (event) {
var button = event.target.closest("[data-set-lang]");
if (!button) { return; }
var lang = button.getAttribute("data-set-lang");
applyLanguage(lang);
remember("cursorlang.lang", lang);
});
/* ---------------------------------------------------------- the theme */
document.getElementById("themeBtn").addEventListener("click", function () {
var pinned = root.getAttribute("data-theme");
var dark = pinned
? pinned === "dark"
: window.matchMedia("(prefers-color-scheme: dark)").matches;
var next = dark ? "light" : "dark";
root.setAttribute("data-theme", next);
remember("cursorlang.theme", next);
});
/* ----------------------------------------------------- the menu button */
var header = document.querySelector("header");
var menuBtn = document.getElementById("menuBtn");
function setMenu(open) {
header.setAttribute("data-menu", open ? "open" : "closed");
menuBtn.setAttribute("aria-expanded", open ? "true" : "false");
}
menuBtn.addEventListener("click", function () {
setMenu(header.getAttribute("data-menu") !== "open");
});
document.addEventListener("click", function (event) {
if (header.getAttribute("data-menu") !== "open") { return; }
if (event.target.closest("nav.links a") || !event.target.closest("header")) {
setMenu(false);
}
});
document.addEventListener("keydown", function (event) {
if (event.key !== "Escape" || header.getAttribute("data-menu") !== "open") { return; }
setMenu(false);
menuBtn.focus();
});
window.matchMedia("(min-width: 901px)").addEventListener("change", function (event) {
if (event.matches) { setMenu(false); }
});
// Back to top, without leaving a fragment behind in the address bar
document.addEventListener("click", function (event) {
var back = event.target.closest('a[href="#top"]');
if (!back) { return; }
event.preventDefault();
window.scrollTo({ top: 0 });
history.replaceState(null, "", location.pathname + location.search);
});
/* ------------------------------------------- the border under the bar */
var onScroll = function () {
header.classList.toggle("scrolled", window.scrollY > 4);
};
window.addEventListener("scroll", onScroll, { passive: true });
onScroll();
/* ------------------------------------------------------- the demo above */
var demoText = document.getElementById("demoText");
var demoChip = document.getElementById("demoChip");
var indicator = document.getElementById("demoIndicator");
// A sentence typed in two layouts, the way one is
var SCRIPT = [
{ layout: "EN", indicator: "ENG", text: "Deploy is green, " },
{ layout: "RU", indicator: "РУС", text: "можно мержить" }
];
var stillMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
var timers = [];
var typing = false;
var onScreen = true;
function later(fn, delay) {
timers.push(setTimeout(fn, delay));
}
function stopTyping() {
for (var i = 0; i < timers.length; i++) { clearTimeout(timers[i]); }
timers = [];
typing = false;
}
function showStill() {
demoText.textContent = SCRIPT[0].text + SCRIPT[1].text;
demoChip.textContent = SCRIPT[1].layout;
demoChip.classList.remove("out");
demoChip.hidden = false;
indicator.textContent = SCRIPT[1].indicator;
}
function showChip(text) {
demoChip.textContent = text;
demoChip.classList.remove("out");
demoChip.hidden = false;
later(function () { demoChip.classList.add("out"); }, 1100);
later(function () { demoChip.hidden = true; }, 1260);
}
function typeSegment(index, done) {
if (index >= SCRIPT.length) { done(); return; }
var segment = SCRIPT[index];
indicator.textContent = segment.indicator;
showChip(segment.layout);
var position = 0;
var step = function () {
if (position >= segment.text.length) {
later(function () { typeSegment(index + 1, done); }, 420);
return;
}
demoText.textContent += segment.text.charAt(position);
position++;
later(step, 52 + Math.random() * 58);
};
later(step, 620);
}
function run() {
demoText.textContent = "";
demoChip.hidden = true;
typeSegment(0, function () {
later(run, 2600);
});
}
function renderDemo() {
if (stillMotion.matches) {
stopTyping();
showStill();
return;
}
if (document.hidden || !onScreen) {
stopTyping();
return;
}
if (typing) { return; }
typing = true;
run();
}
stillMotion.addEventListener("change", renderDemo);
document.addEventListener("visibilitychange", renderDemo);
new IntersectionObserver(function (entries) {
onScreen = entries[0].isIntersecting;
renderDemo();
}).observe(document.querySelector(".stage"));
renderDemo();
/* ------------------------------------------------ the preview below */
var chip = document.getElementById("previewChip");
var offset = document.getElementById("offset");
var fontSize = document.getElementById("fontSize");
var opacity = document.getElementById("opacity");
var bgColor = document.getElementById("bgColor");
var fgColor = document.getElementById("fgColor");
var duration = document.getElementById("duration");
var offsetVal = document.getElementById("offsetVal");
var fontSizeVal = document.getElementById("fontSizeVal");
var opacityVal = document.getElementById("opacityVal");
var durationVal = document.getElementById("durationVal");
function applyLook() {
var onTheLeft = chip.classList.contains("left");
chip.style.fontSize = fontSize.value + "px";
chip.style.opacity = (opacity.value / 100).toFixed(2);
chip.style.background = bgColor.value;
chip.style.color = fgColor.value;
chip.style.marginLeft = onTheLeft ? "0px" : offset.value + "px";
chip.style.marginRight = onTheLeft ? offset.value + "px" : "0px";
offsetVal.textContent = offset.value;
fontSizeVal.textContent = fontSize.value;
opacityVal.textContent = opacity.value + "%";
durationVal.textContent = duration.value + (root.getAttribute("data-lang") === "ru" ? " мс" : " ms");
}
var inputs = [offset, fontSize, opacity, bgColor, fgColor, duration];
for (var k = 0; k < inputs.length; k++) {
inputs[k].addEventListener("input", applyLook);
}
document.getElementById("lookForm").addEventListener("submit", function (event) {
event.preventDefault();
});
document.getElementById("side").addEventListener("click", function (event) {
var button = event.target.closest("[data-side]");
if (!button) { return; }
var buttons = this.querySelectorAll("[data-side]");
for (var i = 0; i < buttons.length; i++) {
buttons[i].setAttribute("aria-pressed", buttons[i] === button ? "true" : "false");
}
var left = button.getAttribute("data-side") === "left";
chip.classList.toggle("left", left);
chip.classList.toggle("right", !left);
applyLook();
});
var previewTimers = [];
document.getElementById("previewBtn").addEventListener("click", function () {
for (var i = 0; i < previewTimers.length; i++) { clearTimeout(previewTimers[i]); }
previewTimers = [];
chip.classList.add("out");
previewTimers.push(setTimeout(function () {
chip.classList.remove("out");
previewTimers.push(setTimeout(function () {
chip.classList.add("out");
previewTimers.push(setTimeout(function () {
chip.classList.remove("out");
}, 900));
}, Number(duration.value)));
}, 420));
});
/* ------------------------------------------------------------ the start */
applyLanguage(root.getAttribute("data-lang"));
}());
+415
View File
@@ -0,0 +1,415 @@
/* ---------------------------------------------------------------- palette */
:root,
:root[data-theme="light"] {
color-scheme: light dark;
--bg: #fbfbfd;
--surface: #ffffff;
--surface-2: #f3f3f7;
--line: #e5e5ec;
--text: #15151a;
--muted: #5c5c6a;
--accent: #0060b0;
--accent-bg: rgba(0, 96, 176, .09);
--shadow: 0 1px 2px rgba(18, 18, 28, .05), 0 14px 40px rgba(18, 18, 28, .07);
--shadow-sm: 0 1px 2px rgba(18, 18, 28, .06), 0 4px 12px rgba(18, 18, 28, .05);
--chip-bg: #202020;
--chip-fg: #ffffff;
--radius: 14px;
--maxw: 1060px;
--bar-h: 62px;
/* The air above and below a section. The anchor arithmetic subtracts it,
so the two have to come from one place */
--section-pad: clamp(56px, 8vw, 96px);
--font: "Segoe UI Variable Display", "Segoe UI", system-ui, -apple-system,
"Helvetica Neue", Arial, sans-serif;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0d0d10;
--surface: #151519;
--surface-2: #1c1c22;
--line: #272730;
--text: #f1f1f4;
--muted: #9d9dab;
--accent: #62c8ff;
--accent-bg: rgba(98, 200, 255, .12);
--shadow: 0 1px 2px rgba(0, 0, 0, .4), 0 18px 44px rgba(0, 0, 0, .45);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, .35), 0 6px 16px rgba(0, 0, 0, .35);
}
}
:root[data-theme="dark"] {
--bg: #0d0d10;
--surface: #151519;
--surface-2: #1c1c22;
--line: #272730;
--text: #f1f1f4;
--muted: #9d9dab;
--accent: #62c8ff;
--accent-bg: rgba(98, 200, 255, .12);
--shadow: 0 1px 2px rgba(0, 0, 0, .4), 0 18px 44px rgba(0, 0, 0, .45);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, .35), 0 6px 16px rgba(0, 0, 0, .35);
}
/* ------------------------------------------------------- the two languages */
html[data-lang="en"] [lang="ru"],
html[data-lang="ru"] [lang="en"] { display: none !important; }
/* ------------------------------------------------------------------- base */
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--font);
font-size: 17px;
line-height: 1.62;
-webkit-font-smoothing: antialiased;
overflow-x: clip;
}
h1, h2, h3 { line-height: 1.16; letter-spacing: -.021em; margin: 0; font-weight: 700; }
h1 { font-size: clamp(2.1rem, 5.4vw, 3.35rem); }
h2 { font-size: clamp(2rem, 4vw, 2.75rem); }
h3 { font-size: 1.2rem; letter-spacing: -.014em; }
p { margin: 0; }
a { color: var(--accent); text-decoration-thickness: 1px; text-underline-offset: 3px; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 4px; }
.wrap { width: 100%; max-width: var(--maxw); margin-inline: auto; padding-inline: 24px; }
.muted { color: var(--muted); }
.skip {
position: absolute; left: -9999px; top: 0;
background: var(--surface); color: var(--text);
padding: 10px 16px; border-radius: 8px; z-index: 10;
}
.skip:focus { left: 12px; top: 12px; }
/* ----------------------------------------------------------------- header */
header {
position: sticky; top: 0; z-index: 5;
background: var(--bg);
background: color-mix(in srgb, var(--bg) 86%, transparent);
-webkit-backdrop-filter: saturate(160%) blur(12px);
backdrop-filter: saturate(160%) blur(12px);
border-bottom: 1px solid transparent;
transition: border-color .2s ease;
}
header.scrolled { border-bottom-color: var(--line); }
.bar { display: flex; align-items: center; gap: 18px; height: var(--bar-h); }
.brand { display: flex; align-items: center; gap: 11px; font-weight: 650; letter-spacing: -.015em; }
.brand svg { display: block; border-radius: 7px; }
nav.links { display: flex; gap: 22px; margin-left: auto; font-size: .94rem; }
nav.links a { color: var(--muted); text-decoration: none; }
nav.links a:hover { color: var(--text); }
.controls { display: flex; align-items: center; gap: 8px; margin-left: auto; }
nav.links + .controls { margin-left: 0; }
.seg {
display: inline-flex; padding: 2px; gap: 2px;
background: var(--surface-2); border: 1px solid var(--line); border-radius: 9px;
}
.seg button {
font: inherit; font-size: .82rem; font-weight: 600; letter-spacing: .02em;
padding: 4px 10px; border: 0; border-radius: 7px;
background: transparent; color: var(--muted); cursor: pointer;
}
.seg button[aria-pressed="true"] { background: var(--surface); color: var(--text); box-shadow: var(--shadow-sm); }
.icon-btn {
display: grid; place-items: center; width: 34px; height: 34px;
border: 1px solid var(--line); border-radius: 9px;
background: var(--surface-2); color: var(--muted); cursor: pointer;
}
.icon-btn:hover { color: var(--text); }
.icon-btn svg { width: 17px; height: 17px; }
#menuBtn { display: none; }
#menuBtn .cross { display: none; }
header[data-menu="open"] #menuBtn .bars { display: none; }
header[data-menu="open"] #menuBtn .cross { display: block; }
.icon-btn .sun { display: none; }
.icon-btn .moon { display: block; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .icon-btn .sun { display: block; }
:root:not([data-theme="light"]) .icon-btn .moon { display: none; }
}
:root[data-theme="dark"] .icon-btn .sun { display: block; }
:root[data-theme="dark"] .icon-btn .moon { display: none; }
.scrim {
display: none;
position: fixed; top: var(--bar-h); left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, .5); z-index: 4;
}
/* --------------------------------------------------------------- sections */
section { padding-block: var(--section-pad); }
section + section { border-top: 1px solid var(--line); }
/* An anchor should show as much of the section as the window holds. Landing on
the top of its box would spend the whole top padding on empty space under
the bar and push the tail off the screen, so the padding is taken out of
the offset and the heading arrives a small gap below the bar */
section { scroll-margin-top: calc(var(--bar-h) + 16px - var(--section-pad)); }
.lede { color: var(--muted); font-size: 1.06rem; margin-top: 14px; }
/* The line under the name of a section: what the section says, in one sentence.
Larger than the text it stands over, smaller than the name above it */
.deck {
margin-top: 12px;
font-size: clamp(1.3rem, 2.3vw, 1.65rem);
font-weight: 600; line-height: 1.28; letter-spacing: -.016em;
}
/* ------------------------------------------------------------------- hero */
.hero { padding-top: clamp(40px, 6vw, 72px); padding-bottom: clamp(48px, 6vw, 80px); }
/* No width cap: both headings are written to stand in one line, and they hold
it down to a window of about 600 px. Narrower than that they wrap, and
`balance` splits them evenly rather than leaving one word below */
.hero h1 { text-wrap: balance; }
.hero .lede { font-size: 1.14rem; }
.cta { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; margin-top: 30px; }
.btn {
display: inline-flex; align-items: center; gap: 10px;
font: inherit; font-size: .97rem; font-weight: 600;
padding: 12px 20px; border-radius: 11px; border: 1px solid transparent;
text-decoration: none; cursor: pointer;
background: var(--text); color: var(--bg);
}
.btn:hover { opacity: .88; }
.btn svg { width: 18px; height: 18px; }
.btn.ghost { background: transparent; color: var(--text); border-color: var(--line); }
.btn.ghost:hover { background: var(--surface-2); opacity: 1; }
.btn.block { width: 100%; justify-content: center; }
.facts {
display: flex; flex-wrap: wrap; gap: 8px 20px;
margin-top: 26px; font-size: .89rem; color: var(--muted);
}
.facts span { display: inline-flex; align-items: center; gap: 8px; }
.facts span::before {
content: ""; width: 4px; height: 4px; border-radius: 50%;
background: currentColor; opacity: .5;
}
/* ------------------------------------------------------- the typing demo */
.stage { margin: clamp(36px, 5vw, 58px) 0 0; }
.window {
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden;
}
.titlebar {
display: flex; align-items: center; gap: 10px;
padding: 9px 14px; border-bottom: 1px solid var(--line);
background: var(--surface-2); font-size: .8rem; color: var(--muted);
}
.titlebar .dots { display: flex; gap: 6px; }
.titlebar .dots i { width: 9px; height: 9px; border-radius: 50%; background: var(--line); }
.titlebar .name { font-weight: 600; }
.screen { position: relative; padding: 30px clamp(18px, 4vw, 40px) 0; min-height: 210px; }
.msg {
max-width: 60ch; margin-bottom: 14px; padding: 10px 14px;
border-radius: 12px; background: var(--surface-2);
font-size: 1rem; color: var(--muted);
}
.field {
display: flex; align-items: center; min-height: 46px;
margin-bottom: 26px; padding: 8px 14px;
border: 1px solid var(--line); border-radius: 11px; background: var(--surface);
font-size: 1rem;
}
.typed { white-space: pre-wrap; }
.caret {
position: relative; display: inline-block;
width: 1.5px; height: 1.25em; margin-left: 1px;
background: var(--text); vertical-align: text-bottom;
animation: blink 1.06s steps(1, end) infinite;
}
@keyframes blink { 0%, 55% { opacity: 1; } 56%, 100% { opacity: 0; } }
.chip {
position: absolute; top: 50%; transform: translateY(-50%);
display: inline-block; padding: .18em .5em;
border-radius: .28em; background: var(--chip-bg); color: var(--chip-fg);
font-family: var(--font); font-size: 20px; font-weight: 600; line-height: 1.25;
letter-spacing: .01em; white-space: nowrap; opacity: .9;
pointer-events: none; user-select: none;
box-shadow: 0 2px 12px rgba(0, 0, 0, .3);
}
.chip[hidden] { display: none; }
.chip.right { left: 100%; margin-left: 10px; }
.chip.left { right: 100%; margin-right: 10px; }
.chip.fade { transition: opacity .12s linear; }
.chip.fade.out { opacity: 0 !important; }
.taskbar {
display: flex; align-items: center; justify-content: flex-end; gap: 16px;
margin-top: 18px; padding: 7px clamp(18px, 4vw, 40px);
border-top: 1px solid var(--line); background: var(--surface-2);
font-size: .74rem; color: var(--muted);
}
.taskbar .ind { padding: 2px 6px; border-radius: 4px; background: var(--bg); font-weight: 600; }
/* ------------------------------------------------------------------ cards */
.cards {
display: grid; gap: 18px; margin-top: 40px;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
}
.card {
padding: 22px; border: 1px solid var(--line); border-radius: var(--radius);
background: var(--surface); box-shadow: var(--shadow-sm);
}
.card h3 { margin-bottom: 8px; }
.card p { font-size: .96rem; color: var(--muted); }
.card figure { margin: 0 0 18px; }
.card svg { display: block; width: 100%; height: auto; }
.sketch .frame { fill: var(--surface-2); stroke: var(--line); }
.sketch .line { stroke: var(--line); stroke-linecap: round; }
.sketch .ink { fill: var(--muted); }
.sketch .mark { fill: var(--text); }
.sketch .plate { fill: var(--accent); }
.sketch .glyph { fill: var(--bg); font: 700 9px var(--font); }
.sketch .ghost { fill: var(--line); }
.aside {
margin-top: 20px; padding: 18px 20px;
border: 1px solid var(--line); border-left: 3px solid var(--accent);
border-radius: 10px; background: var(--surface);
font-size: .95rem; color: var(--muted);
}
.aside b { color: var(--text); font-weight: 600; }
/* ----------------------------------------------------- the live preview */
.playground {
display: grid; gap: 24px; margin-top: 26px;
grid-template-columns: minmax(0, 300px) minmax(0, 1fr);
align-items: stretch;
}
@media (max-width: 760px) { .playground { grid-template-columns: 1fr; } }
.panel {
padding: 20px; border: 1px solid var(--line); border-radius: var(--radius);
background: var(--surface); box-shadow: var(--shadow-sm);
}
.panel h3 { font-size: .82rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); margin-bottom: 16px; }
.row { display: grid; gap: 6px; margin-bottom: 13px; }
.row:last-child, .row.tight { margin-bottom: 0; }
.row > label,
.row > .lbl { font-size: .89rem; display: flex; justify-content: space-between; gap: 12px; }
.row .val { color: var(--muted); font-variant-numeric: tabular-nums; font-size: .85rem; }
input[type="range"] { width: 100%; accent-color: var(--accent); }
input[type="color"] {
width: 100%; height: 32px; padding: 2px;
border: 1px solid var(--line); border-radius: 8px;
background: var(--surface-2); cursor: pointer;
}
.pair { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.preview-stage {
position: relative; display: grid; place-items: center;
min-height: 260px; padding: 28px;
border: 1px solid var(--line); border-radius: var(--radius);
background:
radial-gradient(circle at 1px 1px, var(--line) 1px, transparent 0) 0 0 / 22px 22px,
var(--surface);
}
.preview-line {
display: inline-block;
padding: 12px 16px; border-radius: 11px;
background: var(--surface); border: 1px solid var(--line); box-shadow: var(--shadow-sm);
font-size: 1rem; max-width: 100%;
}
/* --------------------------------------------------------------- bullets */
.bullets { display: grid; gap: 14px; margin: 32px 0 0; padding: 0; list-style: none; }
.bullets li { display: grid; grid-template-columns: 22px 1fr; gap: 12px; color: var(--muted); font-size: .99rem; }
.bullets li b { color: var(--text); font-weight: 600; }
.bullets svg { width: 18px; height: 18px; margin-top: 4px; color: var(--accent); }
/* Four short notes side by side rather than one long ladder down half the page */
.bullets.two-up { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 22px 44px; }
/* --------------------------------------------------------------- privacy */
#privacy .panel { margin-top: 30px; }
#privacy .bullets { margin-top: 0; }
.stamp { margin-top: 22px; font-size: .86rem; color: var(--muted); }
/* ---------------------------------------------------------------- footer */
footer { border-top: 1px solid var(--line); padding-block: 34px; color: var(--muted); font-size: .9rem; }
.footrow { display: flex; flex-wrap: wrap; align-items: center; gap: 14px 26px; }
.footrow nav { display: flex; flex-wrap: wrap; gap: 20px; margin-left: auto; }
.footrow a { color: var(--muted); text-decoration: none; }
.footrow a:hover { color: var(--text); text-decoration: underline; }
.support { margin-top: 26px; color: var(--muted); font-size: .95rem; }
@media (max-width: 900px) {
.bullets.two-up { grid-template-columns: 1fr; }
#menuBtn { display: grid; }
nav.links + .controls { margin-left: auto; }
nav.links {
position: absolute; top: 100%; left: 0; right: 0;
display: none; flex-direction: column; gap: 0;
padding: 6px 24px 12px;
background: var(--surface-2);
border-bottom: 1px solid var(--line);
box-shadow: var(--shadow);
}
header[data-menu="open"] nav.links { display: flex; }
header[data-menu="open"] ~ .scrim { display: block; }
nav.links a { padding: 12px 0; color: var(--text); font-size: 1rem; }
nav.links a + a { border-top: 1px solid var(--line); }
.field { padding-right: 62px; }
#demoChip { font-size: 17px; }
}
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
.caret { animation: none; }
* { transition-duration: .01ms !important; }
}