Compare commits
12
Commits
v0.2.2
...
9b4ac00da9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b4ac00da9 | ||
|
|
936e53d55b | ||
|
|
42e93346ab | ||
|
|
9ab26afc16 | ||
|
|
743974a0c1 | ||
|
|
9c6ec489e1 | ||
|
|
b2c7b31eb5 | ||
|
|
5670ad0e64 | ||
|
|
1540be3b79 | ||
|
|
88dd931cb4 | ||
|
|
70b83a5fec | ||
|
|
0682f2b875 |
@@ -138,9 +138,22 @@ jobs:
|
|||||||
- name: Build the installer
|
- name: Build the installer
|
||||||
run: ./Packaging/build-installer.ps1 -Version ${{ steps.version.outputs.plain }}
|
run: ./Packaging/build-installer.ps1 -Version ${{ steps.version.outputs.plain }}
|
||||||
|
|
||||||
# The artifact is where the package waits to be uploaded to Partner Center
|
# The artifact is where the package waits to be uploaded to Partner Center.
|
||||||
|
#
|
||||||
|
# The action comes from this Gitea rather than from github.com, and it is
|
||||||
|
# a fork of actions/upload-artifact with one line changed. Upstream reads
|
||||||
|
# GITHUB_SERVER_URL, takes every host but github.com and its enterprise
|
||||||
|
# ones for a GitHub Enterprise Server — where the v4 artifact backend does
|
||||||
|
# not exist — and throws before it makes a request. Here that backend does
|
||||||
|
# exist and answers, but the check runs on the runner ahead of the network
|
||||||
|
# and never lets it prove itself; nor can the name be corrected from this
|
||||||
|
# file, as the runner sets the GITHUB_ ones over the env of a step. The
|
||||||
|
# fork carries the reasoning in full in its GITEA-PATCH.md.
|
||||||
|
#
|
||||||
|
# The tag is a fixed one. A floating tag is how this broke before: the
|
||||||
|
# action is fetched at run time, and what arrives can change on its own
|
||||||
- name: Keep the package
|
- name: Keep the package
|
||||||
uses: actions/upload-artifact@v4
|
uses: https://git.alrakis.kz/actions/upload-artifact@v4-gitea
|
||||||
with:
|
with:
|
||||||
name: msix-${{ steps.version.outputs.version }}
|
name: msix-${{ steps.version.outputs.version }}
|
||||||
path: artifacts/packages/
|
path: artifacts/packages/
|
||||||
@@ -149,7 +162,7 @@ jobs:
|
|||||||
# The .wixpdb next to each installer is left out on purpose: it is of use
|
# The .wixpdb next to each installer is left out on purpose: it is of use
|
||||||
# only when something has to be traced back to the WiX source
|
# only when something has to be traced back to the WiX source
|
||||||
- name: Keep the installer
|
- name: Keep the installer
|
||||||
uses: actions/upload-artifact@v4
|
uses: https://git.alrakis.kz/actions/upload-artifact@v4-gitea
|
||||||
with:
|
with:
|
||||||
name: installer-${{ steps.version.outputs.plain }}
|
name: installer-${{ steps.version.outputs.plain }}
|
||||||
path: artifacts/installers/*.msi
|
path: artifacts/installers/*.msi
|
||||||
@@ -167,10 +180,8 @@ jobs:
|
|||||||
|
|
||||||
# The GITHUB_ names are what Gitea itself hands to the workflow — its
|
# The GITHUB_ names are what Gitea itself hands to the workflow — its
|
||||||
# actions repeat those of GitHub, and the addresses in them point at
|
# actions repeat those of GitHub, and the addresses in them point at
|
||||||
# this Gitea instance. GITHUB_API_URL used not to reach the steps at
|
# this Gitea instance
|
||||||
# all, so the address is put together from the server one when empty
|
$api = "$env:GITHUB_API_URL/repos/$env:GITHUB_REPOSITORY/releases"
|
||||||
$root = if ($env:GITHUB_API_URL) { $env:GITHUB_API_URL } else { "$env:GITHUB_SERVER_URL/api/v1" }
|
|
||||||
$api = "$root/repos/$env:GITHUB_REPOSITORY/releases"
|
|
||||||
$headers = @{ Authorization = "token $env:GITEA_TOKEN" }
|
$headers = @{ Authorization = "token $env:GITEA_TOKEN" }
|
||||||
|
|
||||||
# Gitea makes a release of its own for a pushed tag, so the release is
|
# Gitea makes a release of its own for a pushed tag, so the release is
|
||||||
|
|||||||
@@ -133,6 +133,34 @@ public sealed partial class EndToEndTests
|
|||||||
Assert.False(launch.Process.HasExited);
|
Assert.False(launch.Process.HasExited);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// "Exit" in the tray menu ends the application whole: the settings window goes with
|
||||||
|
/// the agent instead of staying on the screen belonging to nothing.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The menu itself is out of reach of a test — it is a <c>TrackPopupMenuEx</c> menu
|
||||||
|
/// with a modal loop of its own — so what is checked is the request the menu makes.
|
||||||
|
/// The settings window here is the real one, started by the agent, and it must be
|
||||||
|
/// listening by the time it is on the screen.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void The_agents_exit_closes_the_settings_window()
|
||||||
|
{
|
||||||
|
using Launch launch = Launch.Start();
|
||||||
|
launch.WaitForSettingsWindow();
|
||||||
|
|
||||||
|
Process settings = Launch.SettingsProcesses().Single();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(SettingsCloseSignal.RequestClose(), "the settings window was not listening");
|
||||||
|
Assert.True(settings.WaitForExit(ExitTimeout), "the settings window outlived the agent's exit");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
settings.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>A started application that shuts down together with the check.</summary>
|
/// <summary>A started application that shuts down together with the check.</summary>
|
||||||
private sealed class Launch : IDisposable
|
private sealed class Launch : IDisposable
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -125,5 +125,12 @@ internal sealed class Agent : IDisposable
|
|||||||
|
|
||||||
private void OnOpenRequested(object? sender, EventArgs e) => SettingsLauncher.Open();
|
private void OnOpenRequested(object? sender, EventArgs e) => SettingsLauncher.Open();
|
||||||
|
|
||||||
private void OnExitRequested(object? sender, EventArgs e) => _window.Quit();
|
// "Exit" means the application, not just the background half of it. A settings
|
||||||
|
// window left open would outlive the tray icon it was opened from, so it is asked
|
||||||
|
// to close first — it may be the very window the user is looking at
|
||||||
|
private void OnExitRequested(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
SettingsCloseSignal.RequestClose();
|
||||||
|
_window.Quit();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,8 @@
|
|||||||
<PublishTrimmed>false</PublishTrimmed>
|
<PublishTrimmed>false</PublishTrimmed>
|
||||||
<PublishReadyToRun Condition="'$(RuntimeIdentifier)' != ''">true</PublishReadyToRun>
|
<PublishReadyToRun Condition="'$(RuntimeIdentifier)' != ''">true</PublishReadyToRun>
|
||||||
<Version>1.0.0</Version>
|
<Version>1.0.0</Version>
|
||||||
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
<AssemblyVersion>$(Version)</AssemblyVersion>
|
||||||
<FileVersion>1.0.0.0</FileVersion>
|
<FileVersion>$(Version)</FileVersion>
|
||||||
<Product>CursorLang</Product>
|
<Product>CursorLang</Product>
|
||||||
<Company>Aleksandr Neichev</Company>
|
<Company>Aleksandr Neichev</Company>
|
||||||
<Description>Shows the keyboard layout at the cursor</Description>
|
<Description>Shows the keyboard layout at the cursor</Description>
|
||||||
|
|||||||
@@ -68,42 +68,42 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool atFixedPoint = _settings.PlacementMode == PopupPlacementMode.FixedPoint;
|
PopupWindowNative.Rect? anchor = TryGetAnchor();
|
||||||
|
PopupModeSettings mode = ModeFor(anchor);
|
||||||
|
|
||||||
PopupWindowNative.Rect work = default;
|
PopupWindowNative.Rect work = default;
|
||||||
PopupWindowNative.Rect anchor = default;
|
|
||||||
double scale;
|
double scale;
|
||||||
|
|
||||||
if (atFixedPoint)
|
if (anchor is { } at)
|
||||||
{
|
{
|
||||||
(work, scale) = PopupWindowNative.GetActiveMonitorWorkArea();
|
scale = PopupWindowNative.GetScaleAt(new PopupWindowNative.Point { X = at.Left, Y = at.Top });
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
anchor = GetAnchor();
|
(work, scale) = PopupWindowNative.GetActiveMonitorWorkArea();
|
||||||
scale = PopupWindowNative.GetScaleAt(new PopupWindowNative.Point { X = anchor.Left, Y = anchor.Top });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
EnsureFont(scale);
|
EnsureFont(mode, scale);
|
||||||
|
|
||||||
Size measured = MeasureText(text);
|
Size measured = MeasureText(text);
|
||||||
int width = measured.Width + (2 * PopupLayout.ToPixels(PaddingX, scale));
|
int width = measured.Width + (2 * PopupLayout.ToPixels(PaddingX, scale));
|
||||||
int height = measured.Height + (2 * PopupLayout.ToPixels(PaddingY, scale));
|
int height = measured.Height + (2 * PopupLayout.ToPixels(PaddingY, scale));
|
||||||
|
|
||||||
PopupWindowNative.Point position = atFixedPoint
|
PopupWindowNative.Point position = anchor is { } near
|
||||||
? PopupLayout.OnScreen(
|
? PopupLayout.NearAnchor(
|
||||||
work,
|
near,
|
||||||
_settings.FixedPoint.Position,
|
SideForMode(),
|
||||||
PopupLayout.ToPixels(_settings.FixedPoint.Offset, scale),
|
PopupLayout.ToPixels(mode.Offset, scale),
|
||||||
width,
|
width,
|
||||||
height)
|
height)
|
||||||
: PopupLayout.NearAnchor(
|
: PopupLayout.OnScreen(
|
||||||
anchor,
|
work,
|
||||||
SideForMode(),
|
_settings.FixedPoint.Position,
|
||||||
PopupLayout.ToPixels(_settings.Current.Offset, scale),
|
PopupLayout.ToPixels(mode.Offset, scale),
|
||||||
width,
|
width,
|
||||||
height);
|
height);
|
||||||
|
|
||||||
if (!Draw(text, position, width, height, PopupLayout.ToPixels(CornerRadius, scale)))
|
if (!Draw(mode, text, position, width, height, PopupLayout.ToPixels(CornerRadius, scale)))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -139,7 +139,8 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
|
|||||||
/// kilobytes for the length of one call, the popup is shown rarely, and a cached one
|
/// kilobytes for the length of one call, the popup is shown rarely, and a cached one
|
||||||
/// would have to be rebuilt on every change of size, colour or scale anyway.
|
/// would have to be rebuilt on every change of size, colour or scale anyway.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private bool Draw(string text, PopupWindowNative.Point at, int width, int height, int radius)
|
private bool Draw(
|
||||||
|
PopupModeSettings mode, string text, PopupWindowNative.Point at, int width, int height, int radius)
|
||||||
{
|
{
|
||||||
IntPtr screen = GdiNative.GetDC(IntPtr.Zero);
|
IntPtr screen = GdiNative.GetDC(IntPtr.Zero);
|
||||||
if (screen == IntPtr.Zero)
|
if (screen == IntPtr.Zero)
|
||||||
@@ -166,8 +167,8 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
|
|||||||
|
|
||||||
GdiNative.SelectObject(memory, surface);
|
GdiNative.SelectObject(memory, surface);
|
||||||
|
|
||||||
Fill(bits, width, height);
|
Fill(mode, bits, width, height);
|
||||||
DrawText(memory, text, width, height);
|
DrawText(mode, memory, text, width, height);
|
||||||
|
|
||||||
// GDI writes nothing into the alpha channel, so the letters it just drew are
|
// GDI writes nothing into the alpha channel, so the letters it just drew are
|
||||||
// sitting at zero alpha and would come out invisible. The inside of the
|
// sitting at zero alpha and would come out invisible. The inside of the
|
||||||
@@ -178,7 +179,7 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
|
|||||||
RoundTheCorners(bits, width, height, radius);
|
RoundTheCorners(bits, width, height, radius);
|
||||||
|
|
||||||
var size = new WindowNative.Size { Width = width, Height = height };
|
var size = new WindowNative.Size { Width = width, Height = height };
|
||||||
var alpha = (byte)Math.Clamp(Math.Round(_settings.Current.Opacity * 255), 0, 255);
|
var alpha = (byte)Math.Clamp(Math.Round(mode.Opacity * 255), 0, 255);
|
||||||
|
|
||||||
return WindowNative.SetContent(Handle, at, size, memory, alpha);
|
return WindowNative.SetContent(Handle, at, size, memory, alpha);
|
||||||
}
|
}
|
||||||
@@ -200,9 +201,9 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Fill(IntPtr bits, int width, int height)
|
private static void Fill(PopupModeSettings mode, IntPtr bits, int width, int height)
|
||||||
{
|
{
|
||||||
Color background = _settings.Current.BackgroundColor;
|
Color background = mode.BackgroundColor;
|
||||||
|
|
||||||
// Straight into the bitmap rather than through a brush: the pixels have to be
|
// Straight into the bitmap rather than through a brush: the pixels have to be
|
||||||
// written anyway to carry an alpha channel GDI would not touch
|
// written anyway to carry an alpha channel GDI would not touch
|
||||||
@@ -216,7 +217,8 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DrawText(IntPtr deviceContext, string text, int width, int height)
|
private void DrawText(
|
||||||
|
PopupModeSettings mode, IntPtr deviceContext, string text, int width, int height)
|
||||||
{
|
{
|
||||||
if (_font == IntPtr.Zero || text.Length == 0)
|
if (_font == IntPtr.Zero || text.Length == 0)
|
||||||
{
|
{
|
||||||
@@ -227,7 +229,7 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
|
|||||||
|
|
||||||
IntPtr previousFont = GdiNative.SelectObject(deviceContext, _font);
|
IntPtr previousFont = GdiNative.SelectObject(deviceContext, _font);
|
||||||
GdiNative.SetBkMode(deviceContext, GdiNative.TRANSPARENT);
|
GdiNative.SetBkMode(deviceContext, GdiNative.TRANSPARENT);
|
||||||
GdiNative.SetTextColor(deviceContext, GdiNative.ToColorRef(_settings.Current.ForegroundColor));
|
GdiNative.SetTextColor(deviceContext, GdiNative.ToColorRef(mode.ForegroundColor));
|
||||||
|
|
||||||
GdiNative.DrawText(deviceContext, text, text.Length, ref bounds,
|
GdiNative.DrawText(deviceContext, text, text.Length, ref bounds,
|
||||||
GdiNative.DT_SINGLELINE | GdiNative.DT_CENTER | GdiNative.DT_VCENTER |
|
GdiNative.DT_SINGLELINE | GdiNative.DT_CENTER | GdiNative.DT_VCENTER |
|
||||||
@@ -329,18 +331,40 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
|
|||||||
return (int)((alpha << 24) | (red << 16) | (green << 8) | blue);
|
return (int)((alpha << 24) | (red << 16) | (green << 8) | blue);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The anchor point: the caret in the input field or the mouse cursor. The cursor
|
/// <summary>
|
||||||
// is a rectangle of zero size, so the corner computation is shared by both
|
/// What the popup is placed next to, or <c>null</c> when there is nothing: the fixed
|
||||||
private PopupWindowNative.Rect GetAnchor()
|
/// point mode, and the caret mode where the application reports no caret.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The cursor is a rectangle of zero size, so the corner arithmetic is shared by it
|
||||||
|
/// and the caret.
|
||||||
|
/// </remarks>
|
||||||
|
private PopupWindowNative.Rect? TryGetAnchor() => _settings.PlacementMode switch
|
||||||
{
|
{
|
||||||
if (_settings.PlacementMode == PopupPlacementMode.AtCaret &&
|
PopupPlacementMode.FixedPoint => null,
|
||||||
CaretNative.TryGetCaretRect() is { } caret)
|
PopupPlacementMode.AtCaret => CaretNative.TryGetCaretRect(),
|
||||||
{
|
_ => PopupLayout.AsAnchor(PopupWindowNative.GetCursorPosition()),
|
||||||
return caret;
|
};
|
||||||
}
|
|
||||||
|
|
||||||
return PopupLayout.AsAnchor(PopupWindowNative.GetCursorPosition());
|
/// <summary>
|
||||||
}
|
/// The settings the popup is shown with: those of the mode chosen, or those of the
|
||||||
|
/// fixed point when there is no anchor to stand next to.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The caret mode falls back to the fixed point rather than to the mouse cursor:
|
||||||
|
/// the cursor is wherever it was last left — off to a side, on another monitor, or
|
||||||
|
/// over the very text being typed — and a popup that lands there while the eyes are
|
||||||
|
/// on the caret is one that is looked for and not found. The fixed point is always
|
||||||
|
/// in the same place, so it is known where to look.
|
||||||
|
///
|
||||||
|
/// The look comes from the fixed point mode too, not just the place. The two are set
|
||||||
|
/// up together for a reason: the popup by the caret is small and quiet because it
|
||||||
|
/// sits inside a text being read, while the one in the corner of the monitor is
|
||||||
|
/// looked for on purpose and is set larger. Keeping the caret look at the corner
|
||||||
|
/// would put a popup meant to go unnoticed where nothing else draws the eye.
|
||||||
|
/// </remarks>
|
||||||
|
private PopupModeSettings ModeFor(PopupWindowNative.Rect? anchor) =>
|
||||||
|
anchor is null ? _settings.FixedPoint : _settings.Current;
|
||||||
|
|
||||||
// The caret has two sides to choose from and the cursor has six, so each mode names
|
// The caret has two sides to choose from and the cursor has six, so each mode names
|
||||||
// its own side in its own terms
|
// its own side in its own terms
|
||||||
@@ -373,10 +397,10 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
|
|||||||
// The font is rebuilt only when the size in the settings or the monitor scale
|
// The font is rebuilt only when the size in the settings or the monitor scale
|
||||||
// changes: it is the one expensive thing a show does. A switch of the placement
|
// changes: it is the one expensive thing a show does. A switch of the placement
|
||||||
// mode counts as a change of the size, since the size belongs to the mode
|
// mode counts as a change of the size, since the size belongs to the mode
|
||||||
private void EnsureFont(double scale)
|
private void EnsureFont(PopupModeSettings mode, double scale)
|
||||||
{
|
{
|
||||||
if (_font != IntPtr.Zero &&
|
if (_font != IntPtr.Zero &&
|
||||||
Math.Abs(_fontSize - _settings.Current.FontSize) < 0.01 &&
|
Math.Abs(_fontSize - mode.FontSize) < 0.01 &&
|
||||||
Math.Abs(_fontScale - scale) < 0.01)
|
Math.Abs(_fontScale - scale) < 0.01)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -384,7 +408,7 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
|
|||||||
|
|
||||||
ReleaseFont();
|
ReleaseFont();
|
||||||
|
|
||||||
_fontSize = _settings.Current.FontSize;
|
_fontSize = mode.FontSize;
|
||||||
_fontScale = scale;
|
_fontScale = scale;
|
||||||
_font = GdiNative.CreateFont(_fontSize, scale);
|
_font = GdiNative.CreateFont(_fontSize, scale);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using CursorLang.Core.Services;
|
||||||
|
using CursorLang.Tests.Shared;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.Tests.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The one thing the agent says to the settings window: quit with me.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The event names here are the tests' own. The application's name is fixed, and a test
|
||||||
|
/// listening on it would answer for a settings window someone is using — or, signalling,
|
||||||
|
/// close it.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class SettingsCloseSignalTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void The_request_reaches_the_settings_window()
|
||||||
|
{
|
||||||
|
string suffix = UniqueSuffix();
|
||||||
|
var signal = new SettingsCloseSignal(suffix);
|
||||||
|
|
||||||
|
ConcurrentQueue<EventArgs> requests = new();
|
||||||
|
signal.CloseRequested += (_, e) => requests.Enqueue(e);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
signal.Listen();
|
||||||
|
|
||||||
|
Assert.True(RequestApart(suffix), "the request found nobody listening");
|
||||||
|
Pump.WaitFor(() => !requests.IsEmpty, "the settings window got the request to close");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
signal.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The usual case: the user quits from the tray with no settings window on the screen
|
||||||
|
[Fact]
|
||||||
|
public void A_request_with_no_settings_window_open_passes_without_consequence()
|
||||||
|
{
|
||||||
|
Assert.False(RequestApart(UniqueSuffix()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The window has closed on its own, and the process is on its way out anyway
|
||||||
|
[Fact]
|
||||||
|
public void No_request_arrives_after_the_window_is_gone()
|
||||||
|
{
|
||||||
|
string suffix = UniqueSuffix();
|
||||||
|
var signal = new SettingsCloseSignal(suffix);
|
||||||
|
|
||||||
|
ConcurrentQueue<EventArgs> requests = new();
|
||||||
|
signal.CloseRequested += (_, e) => requests.Enqueue(e);
|
||||||
|
|
||||||
|
signal.Listen();
|
||||||
|
signal.Dispose();
|
||||||
|
|
||||||
|
RequestApart(suffix);
|
||||||
|
Pump.Pause(TimeSpan.FromMilliseconds(80));
|
||||||
|
|
||||||
|
Assert.Empty(requests);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Listening_twice_leaves_one_listener()
|
||||||
|
{
|
||||||
|
string suffix = UniqueSuffix();
|
||||||
|
var signal = new SettingsCloseSignal(suffix);
|
||||||
|
|
||||||
|
var requests = 0;
|
||||||
|
signal.CloseRequested += (_, _) => Interlocked.Increment(ref requests);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
signal.Listen();
|
||||||
|
signal.Listen();
|
||||||
|
|
||||||
|
Assert.True(RequestApart(suffix));
|
||||||
|
Pump.WaitFor(() => Volatile.Read(ref requests) > 0, "the request arrived");
|
||||||
|
Pump.Pause(TimeSpan.FromMilliseconds(80));
|
||||||
|
|
||||||
|
Assert.Equal(1, Volatile.Read(ref requests));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
signal.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Closing_without_listening_passes_without_consequence()
|
||||||
|
{
|
||||||
|
var signal = new SettingsCloseSignal(UniqueSuffix());
|
||||||
|
|
||||||
|
signal.Dispose();
|
||||||
|
signal.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every test gets a namespace of kernel objects of its own
|
||||||
|
private static string UniqueSuffix() => "." + Guid.NewGuid().ToString("N");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Asks for the close the way the agent does it — from another process, and here
|
||||||
|
/// from another thread, which is as foreign as a test can get.
|
||||||
|
/// </summary>
|
||||||
|
private static bool RequestApart(string suffix)
|
||||||
|
{
|
||||||
|
var heard = false;
|
||||||
|
|
||||||
|
Pump.RunApart(() => heard = SettingsCloseSignal.RequestClose(suffix));
|
||||||
|
|
||||||
|
return heard;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -113,7 +113,7 @@ internal static class CaretNative
|
|||||||
private static PopupWindowNative.Rect? TryGetAutomationCaret()
|
private static PopupWindowNative.Rect? TryGetAutomationCaret()
|
||||||
{
|
{
|
||||||
// A hung application must not hang the popup along with it: we wait for the
|
// A hung application must not hang the popup along with it: we wait for the
|
||||||
// answer for a limited time, otherwise we show the popup at the cursor
|
// answer for a limited time, otherwise the popup goes to the fixed point
|
||||||
Task<PopupWindowNative.Rect?> query = Task.Run(QueryAutomationCaret);
|
Task<PopupWindowNative.Rect?> query = Task.Run(QueryAutomationCaret);
|
||||||
return query.Wait(AutomationTimeout) ? query.Result : null;
|
return query.Wait(AutomationTimeout) ? query.Result : null;
|
||||||
}
|
}
|
||||||
@@ -165,7 +165,7 @@ internal static class CaretNative
|
|||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
// Built without UI Automation: Chromium and Electron keep the system caret and MSAA
|
// Built without UI Automation: Chromium and Electron keep the system caret and MSAA
|
||||||
// steps above, and where those stay silent the popup falls back to the cursor
|
// steps above, and where those stay silent the popup falls back to the fixed point
|
||||||
private static PopupWindowNative.Rect? TryGetAutomationCaret() => null;
|
private static PopupWindowNative.Rect? TryGetAutomationCaret() => null;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ public enum PopupPlacementMode
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Next to the caret in the active input field. When the application does not
|
/// Next to the caret in the active input field. When the application does not
|
||||||
/// report its position, the popup is shown at the mouse cursor.
|
/// report its position, the popup is shown as in <see cref="FixedPoint"/> — at the
|
||||||
|
/// place and with the look that mode is set up with.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
AtCaret,
|
AtCaret,
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
namespace CursorLang.Core.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tells the settings window that the agent is quitting and it is to close with it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The one thing the agent says to the settings window, and the mirror image of
|
||||||
|
/// <see cref="SettingsSignal"/>. "Exit" in the tray menu means the application is done
|
||||||
|
/// with, and a settings window left alone on the screen after it is a window belonging
|
||||||
|
/// to nothing: the tray icon it was opened from is gone, and closing it would be the
|
||||||
|
/// user's only remaining move.
|
||||||
|
///
|
||||||
|
/// A named event rather than a window message, because the agent has no handle to send
|
||||||
|
/// one to: the settings window lives in a process the agent starts and deliberately
|
||||||
|
/// does not keep hold of. The name has no <c>Global</c> prefix, so it lives in the
|
||||||
|
/// session namespace — same reasoning as <see cref="SingleInstanceGate"/>, and the same
|
||||||
|
/// consequence: with fast user switching each user's halves talk to their own.
|
||||||
|
///
|
||||||
|
/// Only the settings window creates the object; the agent opens what is already there
|
||||||
|
/// and stays silent when there is nothing. Were it the other way round, the request
|
||||||
|
/// would sit in an auto-reset event waiting for the next settings window to open and
|
||||||
|
/// close it the moment it did.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class SettingsCloseSignal : IDisposable
|
||||||
|
{
|
||||||
|
private const string EventName = "CursorLang.CloseSettings";
|
||||||
|
|
||||||
|
private readonly string _eventName;
|
||||||
|
|
||||||
|
private EventWaitHandle? _request;
|
||||||
|
private RegisteredWaitHandle? _wait;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Listens on the name the two halves agree on.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="nameSuffix">
|
||||||
|
/// A namespace of its own. Empty for the application; the tests pass one so that
|
||||||
|
/// they do not answer for — or worse, close — a settings window someone is using.
|
||||||
|
/// </param>
|
||||||
|
internal SettingsCloseSignal(string nameSuffix = "") => _eventName = EventName + nameSuffix;
|
||||||
|
|
||||||
|
/// <summary>The agent asks for the window to be closed.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Raised on a thread pool thread, wherever the wait happened to be answered — a
|
||||||
|
/// window obeys only its own, so the handler has to get back to it.
|
||||||
|
/// </remarks>
|
||||||
|
internal event EventHandler? CloseRequested;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Asks the settings window of this session, if one is open, to close. Silence is
|
||||||
|
/// a normal answer: most of the time the user quits with no window on the screen.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Whether there was anybody to hear it.</returns>
|
||||||
|
internal static bool RequestClose(string nameSuffix = "")
|
||||||
|
{
|
||||||
|
if (!EventWaitHandle.TryOpenExisting(EventName + nameSuffix, out EventWaitHandle? request))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
using (request)
|
||||||
|
{
|
||||||
|
return request.Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Starts waiting for the request. Called once, by the settings window.</summary>
|
||||||
|
internal void Listen()
|
||||||
|
{
|
||||||
|
if (_request is not null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_request = new EventWaitHandle(false, EventResetMode.AutoReset, _eventName);
|
||||||
|
|
||||||
|
// As in the gate: the thread pool holds the wait, there is no reason to keep a
|
||||||
|
// thread of our own for a request that may never come
|
||||||
|
_wait = ThreadPool.RegisterWaitForSingleObject(
|
||||||
|
_request,
|
||||||
|
OnCloseSignalled,
|
||||||
|
state: null,
|
||||||
|
Timeout.Infinite,
|
||||||
|
executeOnlyOnce: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_wait?.Unregister(null);
|
||||||
|
_wait = null;
|
||||||
|
|
||||||
|
_request?.Dispose();
|
||||||
|
_request = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCloseSignalled(object? state, bool timedOut) =>
|
||||||
|
CloseRequested?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
using CursorLang.Core.Models;
|
using CursorLang.Core.Models;
|
||||||
using CursorLang.Core.Services;
|
using CursorLang.Core.Services;
|
||||||
using CursorLang.Settings.Tests.Infrastructure;
|
|
||||||
using CursorLang.Settings.ViewModels;
|
using CursorLang.Settings.ViewModels;
|
||||||
using CursorLang.Tests.Shared;
|
using CursorLang.Tests.Shared;
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ public partial class App : Application
|
|||||||
{
|
{
|
||||||
private ServiceProvider? _services;
|
private ServiceProvider? _services;
|
||||||
private SingleInstanceGate? _instanceGate;
|
private SingleInstanceGate? _instanceGate;
|
||||||
|
private SettingsCloseSignal? _closeSignal;
|
||||||
|
|
||||||
protected override void OnStartup(StartupEventArgs e)
|
protected override void OnStartup(StartupEventArgs e)
|
||||||
{
|
{
|
||||||
@@ -42,6 +43,10 @@ public partial class App : Application
|
|||||||
_instanceGate = gate;
|
_instanceGate = gate;
|
||||||
_instanceGate.ActivationRequested += OnActivationRequested;
|
_instanceGate.ActivationRequested += OnActivationRequested;
|
||||||
|
|
||||||
|
_closeSignal = new SettingsCloseSignal();
|
||||||
|
_closeSignal.CloseRequested += OnCloseRequested;
|
||||||
|
_closeSignal.Listen();
|
||||||
|
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
ConfigureServices(services);
|
ConfigureServices(services);
|
||||||
_services = services.BuildServiceProvider();
|
_services = services.BuildServiceProvider();
|
||||||
@@ -61,6 +66,12 @@ public partial class App : Application
|
|||||||
{
|
{
|
||||||
_services?.Dispose();
|
_services?.Dispose();
|
||||||
|
|
||||||
|
if (_closeSignal is not null)
|
||||||
|
{
|
||||||
|
_closeSignal.CloseRequested -= OnCloseRequested;
|
||||||
|
_closeSignal.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
if (_instanceGate is not null)
|
if (_instanceGate is not null)
|
||||||
{
|
{
|
||||||
_instanceGate.ActivationRequested -= OnActivationRequested;
|
_instanceGate.ActivationRequested -= OnActivationRequested;
|
||||||
@@ -95,6 +106,27 @@ public partial class App : Application
|
|||||||
private void OnActivationRequested(object? sender, EventArgs e) =>
|
private void OnActivationRequested(object? sender, EventArgs e) =>
|
||||||
Dispatcher.BeginInvoke(ShowMainWindow);
|
Dispatcher.BeginInvoke(ShowMainWindow);
|
||||||
|
|
||||||
|
// The agent is quitting. Answered on a thread pool thread, and the window is closed
|
||||||
|
// on its own one
|
||||||
|
private void OnCloseRequested(object? sender, EventArgs e) =>
|
||||||
|
Dispatcher.BeginInvoke(CloseMainWindow);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Closes the window the way the title bar button does, so that everything hanging
|
||||||
|
/// off closing happens; <see cref="ShutdownMode.OnMainWindowClose"/> ends the process
|
||||||
|
/// after it. Without a window there is nothing to close and the process simply ends.
|
||||||
|
/// </summary>
|
||||||
|
private void CloseMainWindow()
|
||||||
|
{
|
||||||
|
if (MainWindow is { } window)
|
||||||
|
{
|
||||||
|
window.Close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
private void ShowMainWindow()
|
private void ShowMainWindow()
|
||||||
{
|
{
|
||||||
if (MainWindow is not { } window)
|
if (MainWindow is not { } window)
|
||||||
|
|||||||
@@ -21,8 +21,8 @@
|
|||||||
<PublishTrimmed>false</PublishTrimmed>
|
<PublishTrimmed>false</PublishTrimmed>
|
||||||
<PublishReadyToRun Condition="'$(RuntimeIdentifier)' != ''">true</PublishReadyToRun>
|
<PublishReadyToRun Condition="'$(RuntimeIdentifier)' != ''">true</PublishReadyToRun>
|
||||||
<Version>1.0.0</Version>
|
<Version>1.0.0</Version>
|
||||||
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
<AssemblyVersion>$(Version)</AssemblyVersion>
|
||||||
<FileVersion>1.0.0.0</FileVersion>
|
<FileVersion>$(Version)</FileVersion>
|
||||||
<Product>CursorLang</Product>
|
<Product>CursorLang</Product>
|
||||||
<Company>Aleksandr Neichev</Company>
|
<Company>Aleksandr Neichev</Company>
|
||||||
<Description>Settings window of CursorLang</Description>
|
<Description>Settings window of CursorLang</Description>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
mc:Ignorable="d"
|
mc:Ignorable="d"
|
||||||
d:DataContext="{d:DesignInstance Type=vm:SettingsViewModel}"
|
d:DataContext="{d:DesignInstance Type=vm:SettingsViewModel}"
|
||||||
Title="{Binding Title}"
|
Title="{Binding Title}"
|
||||||
Width="1000" SizeToContent="Height" MaxHeight="900"
|
Width="560" SizeToContent="Height" MaxHeight="940"
|
||||||
ResizeMode="CanMinimize"
|
ResizeMode="CanMinimize"
|
||||||
Background="{DynamicResource Theme.WindowBackground}"
|
Background="{DynamicResource Theme.WindowBackground}"
|
||||||
Foreground="{DynamicResource Theme.Foreground}">
|
Foreground="{DynamicResource Theme.Foreground}">
|
||||||
@@ -59,19 +59,7 @@
|
|||||||
</Style>
|
</Style>
|
||||||
</Window.Resources>
|
</Window.Resources>
|
||||||
|
|
||||||
<!-- The settings are laid out in two columns: this way the window fits on the
|
|
||||||
screen entirely and does without scrolling. Scrolling is kept for the case of
|
|
||||||
a large system font, with which the content is taller than the monitor after all.
|
|
||||||
|
|
||||||
The left column holds what the popup does; the right one holds the single
|
|
||||||
section describing how and where the popup looks -->
|
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="16">
|
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="16">
|
||||||
<Grid>
|
|
||||||
<Grid.ColumnDefinitions>
|
|
||||||
<ColumnDefinition Width="*" />
|
|
||||||
<ColumnDefinition Width="*" />
|
|
||||||
</Grid.ColumnDefinitions>
|
|
||||||
|
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
|
|
||||||
<GroupBox Header="{Binding Localization[SectionInterface]}">
|
<GroupBox Header="{Binding Localization[SectionInterface]}">
|
||||||
@@ -104,105 +92,6 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</GroupBox>
|
</GroupBox>
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<StackPanel Grid.Column="1" Margin="16,0,0,0">
|
|
||||||
|
|
||||||
<!-- 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]}">
|
<GroupBox Header="{Binding Localization[SectionPopup]}">
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
@@ -358,8 +247,6 @@
|
|||||||
Converter={StaticResource EnumToVisibility},
|
Converter={StaticResource EnumToVisibility},
|
||||||
ConverterParameter=FixedPoint}" />
|
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"
|
<Border Grid.Row="7" Grid.ColumnSpan="3"
|
||||||
Margin="0,16,0,0" Height="1"
|
Margin="0,16,0,0" Height="1"
|
||||||
Background="{DynamicResource Theme.ControlBorder}" />
|
Background="{DynamicResource Theme.ControlBorder}" />
|
||||||
@@ -422,7 +309,98 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</GroupBox>
|
</GroupBox>
|
||||||
|
|
||||||
|
<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>
|
</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>
|
</Grid>
|
||||||
|
</GroupBox>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</Window>
|
</Window>
|
||||||
|
|||||||
@@ -136,8 +136,8 @@
|
|||||||
Condition="REMOVE="ALL" AND NOT UPGRADINGPRODUCTCODE" />
|
Condition="REMOVE="ALL" AND NOT UPGRADINGPRODUCTCODE" />
|
||||||
</InstallExecuteSequence>
|
</InstallExecuteSequence>
|
||||||
|
|
||||||
<!-- Offered at the end of the wizard, the way an installer usually does -->
|
<SetProperty Id="WixShellExecTarget" Value="[INSTALLFOLDER]CursorLang.exe"
|
||||||
<Property Id="WixShellExecTarget" Value="[#CursorLang.exe]" />
|
After="CostFinalize" Sequence="ui" />
|
||||||
<CustomAction Id="LaunchApplication" BinaryRef="Wix4UtilCA_$(sys.BUILDARCHSHORT)"
|
<CustomAction Id="LaunchApplication" BinaryRef="Wix4UtilCA_$(sys.BUILDARCHSHORT)"
|
||||||
DllEntry="WixShellExec" Impersonate="yes" Return="ignore" />
|
DllEntry="WixShellExec" Impersonate="yes" Return="ignore" />
|
||||||
|
|
||||||
|
|||||||
+73
-4
@@ -84,20 +84,28 @@ MSIX всегда выполняются в контексте вошедшег
|
|||||||
раскладкой. Без UI, и так должно остаться: всё, что попадёт туда, попадёт и
|
раскладкой. Без UI, и так должно остаться: всё, что попадёт туда, попадёт и
|
||||||
в фоновый процесс.
|
в фоновый процесс.
|
||||||
|
|
||||||
Связь между ними — только `settings.json`. Окно пишет его целиком, во временный
|
Всё, что окно сообщает агенту, идёт только через `settings.json`. Окно пишет его
|
||||||
файл, который одним движением встаёт на место, и посылает агенту
|
целиком, во временный файл, который одним движением встаёт на место, и посылает
|
||||||
зарегистрированное оконное сообщение, по которому тот перечитывает файл.
|
агенту зарегистрированное оконное сообщение, по которому тот перечитывает файл.
|
||||||
Сообщение не несёт данных: пересылка самих изменений лишила бы файл роли
|
Сообщение не несёт данных: пересылка самих изменений лишила бы файл роли
|
||||||
единственного источника правды. Работающий агент необязателен — без него окно
|
единственного источника правды. Работающий агент необязателен — без него окно
|
||||||
работает так же. За файлом никто не следит: писатель у него один, и он сам
|
работает так же. За файлом никто не следит: писатель у него один, и он сам
|
||||||
сообщает о записи.
|
сообщает о записи.
|
||||||
|
|
||||||
|
В обратную сторону агент говорит одно слово, и то последнее: пункт «Выход» в меню
|
||||||
|
трея просит открытое окно настроек закрыться, прежде чем агент уйдёт, — чтобы на
|
||||||
|
экране не осталось окна, которому больше ничего не принадлежит. Просьба идёт
|
||||||
|
именованным событием сессии, а не оконным сообщением: агент запускает этот процесс
|
||||||
|
и намеренно не держит на него ссылки. Чаще всего слушать её некому, и это
|
||||||
|
нормальный ответ.
|
||||||
|
|
||||||
## Трей
|
## Трей
|
||||||
|
|
||||||
Окно настроек — гость на экране, а не само приложение: оно показывается после
|
Окно настроек — гость на экране, а не само приложение: оно показывается после
|
||||||
установки и всякий раз, когда его просят иконка или её меню. Обе кнопки в
|
установки и всякий раз, когда его просят иконка или её меню. Обе кнопки в
|
||||||
заголовке окна означают ровно то, что написано: окно закрывается, а его процесс
|
заголовке окна означают ровно то, что написано: окно закрывается, а его процесс
|
||||||
завершается. Выход из самого приложения — пункт «Выход» в меню трея.
|
завершается. Выход из самого приложения — пункт «Выход» в меню трея, и открытое
|
||||||
|
окно настроек он закрывает вместе с агентом.
|
||||||
|
|
||||||
Меню иконки системное, его рисует Windows. Надписи по-прежнему следуют языку,
|
Меню иконки системное, его рисует Windows. Надписи по-прежнему следуют языку,
|
||||||
выбранному в настройках, а тема до меню больше не дотягивается: меню на WPF
|
выбранному в настройках, а тема до меню больше не дотягивается: меню на WPF
|
||||||
@@ -161,6 +169,24 @@ MSIX всегда выполняются в контексте вошедшег
|
|||||||
на месте, а не исчезает из раздела — появляющаяся и исчезающая строка сдвигала бы
|
на месте, а не исчезает из раздела — появляющаяся и исчезающая строка сдвигала бы
|
||||||
всё, что ниже, при каждой смене места.
|
всё, что ниже, при каждой смене места.
|
||||||
|
|
||||||
|
Положение каретки сообщает не всякое приложение, и тогда рядом с курсором ввода —
|
||||||
|
нигде. Подсказка уходит на фиксированную точку: место, отступ и оформление берутся
|
||||||
|
оттуда, из режима каретки не берётся ничего. Курсор мыши был бы запасным вариантом
|
||||||
|
поближе — и неверным: он там, где его оставили, сбоку или на другом мониторе, тогда
|
||||||
|
как смотрят на каретку. Фиксированная точка всегда на одном и том же месте, и потому
|
||||||
|
известно, куда смотреть, — а вместе с местом должно приходить и оформление: подсказка,
|
||||||
|
настроенная мелкой и незаметной для середины текста, в углу монитора останется
|
||||||
|
незамеченной.
|
||||||
|
|
||||||
|
Какие-то приложения, решает не то, на чём они написаны, а то, что окно о себе
|
||||||
|
сообщает. Обычный элемент Win32 заводит системную каретку, и её читают сразу;
|
||||||
|
Chrome и Electron рисуют свою и сообщают о ней через интерфейсы доступности.
|
||||||
|
Окну, которое рисует текст на голом холсте, сообщать нечего — и оба случая
|
||||||
|
уживаются в одном приложении: редакторы JetBrains отвечают, потому что каретку
|
||||||
|
для них держит их среда выполнения, а панель инструментов, нарисованная Compose,
|
||||||
|
не содержит текстового элемента, который нашли бы UI Automation или Java Access
|
||||||
|
Bridge. Ради такого окна фиксированная точка и нужна.
|
||||||
|
|
||||||
К какому режиму относится оформление, объясняет всплывающая подсказка рядом с самим
|
К какому режиму относится оформление, объясняет всплывающая подсказка рядом с самим
|
||||||
режимом, а не строка текста в разделе: ползунки ниже показывают другие числа после
|
режимом, а не строка текста в разделе: ползунки ниже показывают другие числа после
|
||||||
переключения режима, и это вопрос, который задают один раз.
|
переключения режима, и это вопрос, который задают один раз.
|
||||||
@@ -351,3 +377,46 @@ dotnet build Packaging\Installer\CursorLang.wixproj -p:SuppressValidation=false
|
|||||||
Три правила остаются подавленными и тогда. MSI исходит из установки на всю
|
Три правила остаются подавленными и тогда. 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`: адрес стоит в разделе о данных, под кнопками
|
||||||
|
загрузки и в подвале.
|
||||||
|
|||||||
@@ -81,19 +81,26 @@ the next time the window is asked for.
|
|||||||
the layout tracking. No UI, and it must stay that way — whatever lands there lands
|
the layout tracking. No UI, and it must stay that way — whatever lands there lands
|
||||||
in the background process.
|
in the background process.
|
||||||
|
|
||||||
The connection between the two is `settings.json` and nothing else. The window
|
Everything the window has to say to the agent goes through `settings.json` and
|
||||||
writes it — whole, into a temporary file moved into place in one step — and then
|
nothing else. The window writes it — whole, into a temporary file moved into place in one step — and then
|
||||||
posts a registered window message to the agent, which re-reads. The message carries
|
posts a registered window message to the agent, which re-reads. The message carries
|
||||||
no data: sending the changed values along would make the file stop being the only
|
no data: sending the changed values along would make the file stop being the only
|
||||||
source of truth. An agent that is not running is a normal case — the window works the
|
source of truth. An agent that is not running is a normal case — the window works the
|
||||||
same. Nobody watches the file: it has one writer, and that writer speaks up.
|
same. Nobody watches the file: it has one writer, and that writer speaks up.
|
||||||
|
|
||||||
|
The one word in the other direction is the last one: Exit in the tray menu asks an
|
||||||
|
open settings window to close before the agent goes, so that no window is left
|
||||||
|
belonging to nothing. It travels as a named event of the session rather than a
|
||||||
|
window message — the agent starts that process and deliberately keeps no handle to
|
||||||
|
it. Nobody listening is the usual case, and it is a normal answer.
|
||||||
|
|
||||||
## The tray
|
## The tray
|
||||||
|
|
||||||
The settings window is a guest on the screen rather than the app itself: it shows
|
The settings window is a guest on the screen rather than the app itself: it shows
|
||||||
up after the installation and whenever the icon or its menu is asked for. Both
|
up after the installation and whenever the icon or its menu is asked for. Both
|
||||||
buttons in its title bar mean what they say — the window closes and its process
|
buttons in its title bar mean what they say — the window closes and its process
|
||||||
ends. The way out of the app itself is the Exit item of the tray menu.
|
ends. The way out of the app itself is the Exit item of the tray menu — and it
|
||||||
|
closes an open settings window along with the agent.
|
||||||
|
|
||||||
The menu of the icon is a system one, drawn by Windows. Its captions still follow
|
The menu of the icon is a system one, drawn by Windows. Its captions still follow
|
||||||
the language chosen in the settings, but the theme no longer reaches it: a WPF
|
the language chosen in the settings, but the theme no longer reaches it: a WPF
|
||||||
@@ -156,6 +163,24 @@ zero and the settings window shows it greyed out. It stays in place rather than
|
|||||||
leaving the section — a row that comes and goes would move everything below it on
|
leaving the section — a row that comes and goes would move everything below it on
|
||||||
every switch of the place.
|
every switch of the place.
|
||||||
|
|
||||||
|
Not every application reports where its caret is, and next to the caret is then
|
||||||
|
nowhere. The popup goes to the fixed point instead — the place, the offset and the
|
||||||
|
look all from that mode, none of them from the caret one. The mouse cursor would be
|
||||||
|
the nearer fallback and is the wrong one: it is wherever it was last left, off to a
|
||||||
|
side or on another monitor, while the eyes are on the caret. The fixed point is
|
||||||
|
always in the same place, so it is known where to look — and the look must come with
|
||||||
|
it, since a popup set small and quiet for the middle of a text goes unnoticed in the
|
||||||
|
corner of a monitor.
|
||||||
|
|
||||||
|
Some applications those are is decided by what a window exposes rather than by
|
||||||
|
what it is written in. A Win32 control creates a system caret and is read at once;
|
||||||
|
Chromium and Electron draw their own and report it over the accessibility
|
||||||
|
interfaces. A window that paints its text into a bare canvas has nothing to report
|
||||||
|
at all — and both kinds live in the same application: the editors of a JetBrains
|
||||||
|
IDE answer, since the runtime keeps a caret for them, while a tool window whose
|
||||||
|
interface is painted by Compose holds no text component for UI Automation or the
|
||||||
|
Java Access Bridge to find. That window is where the fixed point earns its keep.
|
||||||
|
|
||||||
Which mode the look belongs to is explained in a tooltip next to the mode itself
|
Which mode the look belongs to is explained in a tooltip next to the mode itself
|
||||||
rather than by a line of text in the section: the sliders below show other numbers
|
rather than by a line of text in the section: the sliders below show other numbers
|
||||||
after a switch of the mode, and that is a question asked once.
|
after a switch of the mode, and that is a question asked once.
|
||||||
@@ -345,3 +370,47 @@ dotnet build Packaging\Installer\CursorLang.wixproj -p:SuppressValidation=false
|
|||||||
Three of its rules stay suppressed even then. MSI assumes an installation for the
|
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
|
whole machine, and installing into the user's own profile trips rules that
|
||||||
describe exactly what was intended here.
|
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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}());
|
||||||
@@ -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 |
+676
@@ -0,0 +1,676 @@
|
|||||||
|
<!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 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">
|
||||||
|
<span class="btn disabled" role="link" aria-disabled="true">
|
||||||
|
<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>
|
||||||
|
</span>
|
||||||
|
<span class="cta-note">
|
||||||
|
<span lang="en">Pending Microsoft Store certification — the download link is coming soon.</span>
|
||||||
|
<span lang="ru">Ожидается сертификация в Microsoft Store — ссылка для скачивания появится в ближайшее время.</span>
|
||||||
|
</span>
|
||||||
|
</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">
|
||||||
|
<span class="btn disabled" role="link" aria-disabled="true">
|
||||||
|
<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>
|
||||||
|
</span>
|
||||||
|
<span class="cta-note">
|
||||||
|
<span lang="en">Pending Microsoft Store certification — the download link is coming soon.</span>
|
||||||
|
<span lang="ru">Ожидается сертификация в Microsoft Store — ссылка для скачивания появится в ближайшее время.</span>
|
||||||
|
</span>
|
||||||
|
</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 · © 2026 Aleksandr 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
@@ -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"));
|
||||||
|
}());
|
||||||
+421
@@ -0,0 +1,421 @@
|
|||||||
|
/* ---------------------------------------------------------------- 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.disabled {
|
||||||
|
background: var(--surface-2); color: var(--muted);
|
||||||
|
border-color: var(--line); cursor: default; pointer-events: none;
|
||||||
|
}
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
.cta-note { font-size: .9rem; color: var(--muted); }
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user