Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 07e31228d7 | |||
| 39d3876fd2 | |||
| 4cdcc92eb7 | |||
| 77f27ea087 | |||
| c326db7dc5 |
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
cmake_minimum_required(VERSION 3.24)
|
cmake_minimum_required(VERSION 3.24)
|
||||||
project(KSwitcher VERSION 0.6.10 LANGUAGES CXX RC)
|
project(KSwitcher VERSION 0.6.15 LANGUAGES CXX RC)
|
||||||
if(NOT WIN32)
|
if(NOT WIN32)
|
||||||
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
|
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
|
||||||
endif()
|
endif()
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ Run `build/KSwitcher.exe`, or extract `dist/KSwitcher-win-x64.zip` and run the e
|
|||||||
The build also refreshes the release copy in `build/release/`. Exit KSwitcher before rebuilding.
|
The build also refreshes the release copy in `build/release/`. Exit KSwitcher before rebuilding.
|
||||||
The application lives in the notification area. Right-click its icon for Settings, Snippets and history.
|
The application lives in the notification area. Right-click its icon for Settings, Snippets and history.
|
||||||
Double-click the tray icon to open Settings, the bold default menu item.
|
Double-click the tray icon to open Settings, the bold default menu item.
|
||||||
Settings and the snippet editor run on demand in **KSwitcher.Config.exe**, beside the main executable.
|
Settings, the snippet editor and log viewer run on demand in **KSwitcher.Config.exe**, beside the main executable.
|
||||||
Repeated launches activate the existing configurator; closing its last window exits that process.
|
Repeated launches activate the existing configurator; closing its last window exits that process.
|
||||||
Saving applies changes immediately through the resident KSwitcher process. The configurator does not
|
Saving applies changes immediately through the resident KSwitcher process. The configurator does not
|
||||||
load clipboard history, dictionaries or global input hooks. Clipboard history and the log viewer remain
|
load clipboard history, dictionaries or global input hooks. Clipboard history remains
|
||||||
in the resident app. Screen capture and all screenshot editors run on demand in
|
in the resident app. Screen capture and all screenshot editors run on demand in
|
||||||
**KSwitcher.Screenshot.exe**. The capture hotkey starts it; subsequent captures reuse the same
|
**KSwitcher.Screenshot.exe**. The capture hotkey starts it; subsequent captures reuse the same
|
||||||
process. Closing the last editor (or cancelling the only selection) exits that process. It does not
|
process. Closing the last editor (or cancelling the only selection) exits that process. It does not
|
||||||
@@ -184,7 +184,15 @@ Click the notification or **Review update** to check the latest release and conf
|
|||||||
Failed notification submissions are logged and retried after five minutes instead of being marked
|
Failed notification submissions are logged and retried after five minutes instead of being marked
|
||||||
as delivered. Check results and successful submissions are recorded in the log when logging is enabled.
|
as delivered. Check results and successful submissions are recorded in the log when logging is enabled.
|
||||||
Network failures stay in diagnostics; the next scheduled check retries. You can also check manually at any time.
|
Network failures stay in diagnostics; the next scheduled check retries. You can also check manually at any time.
|
||||||
Confirm the offered version to download and install it. The updater uses the existing
|
Confirm the offered version to download and install it. A progress window shows
|
||||||
|
download percentage and received/total size, package verification,
|
||||||
|
installation and restart. After the new process confirms startup, that window shows the installed
|
||||||
|
version and a Done button; it does not send a separate completion notification. If installation
|
||||||
|
fails after KSwitcher closes, the same window shows recovery and the error. Background checks
|
||||||
|
remain silent unless a new version is available. This progress UI is supplied by the updater in
|
||||||
|
the currently installed build, so it becomes available for subsequent updates after installing it.
|
||||||
|
|
||||||
|
The updater uses the existing
|
||||||
`KSwitcher-win-x64.zip` and `SHA256SUMS.txt` assets, checks the checksum, package contents, x64
|
`KSwitcher-win-x64.zip` and `SHA256SUMS.txt` assets, checks the checksum, package contents, x64
|
||||||
executable identity and version, then closes and restarts KSwitcher. Downloads and staging happen
|
executable identity and version, then closes and restarts KSwitcher. Downloads and staging happen
|
||||||
before the application exits. If replacement or startup fails, it restores the previous files;
|
before the application exits. If replacement or startup fails, it restores the previous files;
|
||||||
@@ -216,7 +224,10 @@ thumbnails and source labels aligned with the scaled fonts and spacing.
|
|||||||
- Clipboard snapshot/restoration with concurrent-copy, foreground and input-version checks.
|
- Clipboard snapshot/restoration with concurrent-copy, foreground and input-version checks.
|
||||||
- Case cycling, Russian/Ukrainian transliteration, verified undo and repeated conversion.
|
- Case cycling, Russian/Ukrainian transliteration, verified undo and repeated conversion.
|
||||||
- Text snippets across layouts, multiline insertion and a verified `{cursor}` position;
|
- Text snippets across layouts, multiline insertion and a verified `{cursor}` position;
|
||||||
`{{cursor}}` inserts the literal marker. Snippets are stored as plaintext settings, as in [Typlo](https://github.com/bitcodepro/Typlo).
|
`{enter}` presses Enter after insertion and cursor placement (once per marker), before buffered typing resumes.
|
||||||
|
For example, `rider .{enter}` inserts `rider .` and presses Enter.
|
||||||
|
Automatic correction is suppressed during snippet expansion and for typing buffered by that expansion; the global setting is preserved.
|
||||||
|
`{{cursor}}` and `{{enter}}` insert literal markers. Snippets are stored as plaintext settings, as in [Typlo](https://github.com/bitcodepro/Typlo).
|
||||||
- Clipboard history for text, HTML/RTF, file lists, DIB and PNG images; plain-text paste;
|
- Clipboard history for text, HTML/RTF, file lists, DIB and PNG images; plain-text paste;
|
||||||
full-text search, pinning, deletion, full text viewing and image previews.
|
full-text search, pinning, deletion, full text viewing and image previews.
|
||||||
- Image thumbnails and text/file icons appear directly in the history list. Only visible rows
|
- Image thumbnails and text/file icons appear directly in the history list. Only visible rows
|
||||||
@@ -283,6 +294,8 @@ to the detected current layout, is treated as expected. `paste-sent-unverified`
|
|||||||
accepted the paste shortcut; it does not prove that the editor applied the replacement.
|
accepted the paste shortcut; it does not prove that the editor applied the replacement.
|
||||||
The previous 256 KiB log is retained as `kswitcher.log.1` when the current log rotates.
|
The previous 256 KiB log is retained as `kswitcher.log.1` when the current log rotates.
|
||||||
Open **View log…** from Settings or the tray menu to read logs inside KSwitcher.
|
Open **View log…** from Settings or the tray menu to read logs inside KSwitcher.
|
||||||
|
The viewer runs in the configurator process so log loading, painting and pointer interaction do not
|
||||||
|
block the resident keyboard/mouse hook thread. Clear requests are still serialized by the resident.
|
||||||
Choose the current or previous log and press **Refresh** to reload. The ListView shows Time,
|
Choose the current or previous log and press **Refresh** to reload. The ListView shows Time,
|
||||||
Attempt, Event, and Details columns. Select rows and use **Copy selected** or Ctrl+C to copy
|
Attempt, Event, and Details columns. Select rows and use **Copy selected** or Ctrl+C to copy
|
||||||
tab-separated entries; Ctrl+A selects all rows. Viewing saved logs also works when logging is disabled.
|
tab-separated entries; Ctrl+A selects all rows. Viewing saved logs also works when logging is disabled.
|
||||||
@@ -355,12 +368,15 @@ Use UTF-8, one lowercase word followed by a Tab and a positive rank per line. En
|
|||||||
loading sorts them and merges duplicates using the lowest rank. Back up your edited
|
loading sorts them and merges duplicates using the lowest rank. Back up your edited
|
||||||
files before replacing dictionaries with a new distribution.
|
files before replacing dictionaries with a new distribution.
|
||||||
|
|
||||||
After Space and a 250 ms typing pause, KSwitcher checks the last physically typed word against local
|
On Space, KSwitcher checks the last physically typed word against preloaded local dictionaries.
|
||||||
dictionaries. For example, `ghbdsn ` can become `привіт `. Continuing to type before selection cancels
|
For example, `ghbdsn ` can become `привіт `. A confirmed candidate immediately queues subsequent
|
||||||
pending work. During the replacement itself, key presses and releases are queued and replayed after
|
key presses and releases; no typing pause is required. Queued input is replayed after
|
||||||
clipboard restoration and layout switching, preserving typing order, modifiers and Backspace.
|
clipboard restoration and layout switching, preserving typing order, modifiers and Backspace.
|
||||||
If the active window changes, queued typing is not sent to another window and a warning is shown.
|
If the active window changes, queued typing is not sent to another window and a warning is shown.
|
||||||
It only corrects unknown words when exactly one alternative is a dictionary word.
|
It only corrects unknown words when the dictionary alternatives agree on the resulting text.
|
||||||
|
Identical Ukrainian/Russian spellings such as `ntcn` → `тест` are accepted, using the first matching
|
||||||
|
language in the configured dictionary order (the UI uses English, Ukrainian, Russian).
|
||||||
|
Different valid spellings remain ambiguous and are left unchanged.
|
||||||
**Minimum word length** defaults to 3 and can be set from 1 to 32 letters. Surrounding punctuation
|
**Minimum word length** defaults to 3 and can be set from 1 to 32 letters. Surrounding punctuation
|
||||||
does not count toward this minimum; the maximum word length is 32 letters.
|
does not count toward this minimum; the maximum word length is 32 letters.
|
||||||
Known words, ambiguous alternatives, ALL CAPS, mixed-case identifiers, numbers, URLs and email
|
Known words, ambiguous alternatives, ALL CAPS, mixed-case identifiers, numbers, URLs and email
|
||||||
|
|||||||
Binary file not shown.
+10
-7
@@ -1,24 +1,27 @@
|
|||||||
# Latest Windows release
|
# Latest Windows release
|
||||||
|
|
||||||
## v0.6.10 — 2026-10-29
|
## v0.6.15 — 2026-10-30
|
||||||
|
|
||||||
## Changes
|
## Changes
|
||||||
- Preserve typing during automatic layout correction: queue key presses and releases until replacement, clipboard restoration and layout switching finish. Preserve modifiers and Backspace; collapse temporary selection before replay when a correction is rejected.
|
- Preserve typing that arrives while a snippet is being expanded.
|
||||||
- Add My dictionary fields for English, Ukrainian and Russian in Auto-correction options. Enter correct words separated by commas or new lines; save them with settings without editing dictionary files, sorting entries or assigning ranks.
|
- Add `{enter}` to press Enter after insertion and cursor placement, with an Insert Enter marker button. Use `{{enter}}` for literal text.
|
||||||
- Accept unsorted dictionary files and duplicate entries, retaining the lowest rank for duplicates. Include Ukrainian `реліз` in the bundled vocabulary so `релыз` can be corrected when Russian and Ukrainian are enabled.
|
- Suppress automatic correction during snippet expansion and buffered typing, without changing the global setting. Normal typing resumes automatic correction afterward.
|
||||||
|
- Restore the caret when snippet verification fails and record snippet failures in diagnostic logs.
|
||||||
|
|
||||||
|
Includes the earlier automatic-correction and log-viewer improvements. Existing modern releases update directly to this version without installing intermediate releases.
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
Native regression checks cover personal words, settings persistence, language validation, dictionary ordering and duplicate ranks. Input/UI checks cover personal-word entry and normalization. Native editor integration covers typing with Shift and Backspace during successful and rejected corrections. Release checks also cover screenshot UI, isolated notification registration and updater validation.
|
223 native regression checks, 472 input/UI checks, screenshot editor and notification registration checks, 45 updater checks and updater progress checks. Targeted native integration checks cover continuous snippet typing, clipboard restoration, actual Enter key events, punctuation preservation, suppression of automatic correction and its resumption afterward. The updated development build was also confirmed by the user in Notepad. The broader integration suite showed intermittent failures during earlier interactive runs; it is not claimed as fully passing.
|
||||||
|
|
||||||
**Upgrade:** choose Update in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation.
|
**Upgrade:** choose Update in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation.
|
||||||
For manual installation, exit KSwitcher and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries. Existing customized dictionary files may be preserved by the updater; use My dictionary to add missing words without replacing those files.
|
For manual installation, exit KSwitcher and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries.
|
||||||
|
|
||||||
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains all three executables, a release manifest, offline dictionaries, documentation and license notices. It contains no personal settings, clipboard history, Tesseract installation or language models.
|
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains all three executables, a release manifest, offline dictionaries, documentation and license notices. It contains no personal settings, clipboard history, Tesseract installation or language models.
|
||||||
|
|
||||||
Build and package:
|
Build and package:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\build.ps1 -Stage -Test -Integration
|
.\build.ps1 -Stage -Test
|
||||||
.\package.ps1 -PublishRelease
|
.\package.ps1 -PublishRelease
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
36966eb0b2b0af47c245aec6337a16703e0fbdca4d3eda7a8f25fa14a20f347e KSwitcher-win-x64.zip
|
04229e97b239683db7c113342f7c1e95e6f50b8fcbd1994e7b0920987766b9ab KSwitcher-win-x64.zip
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ try {
|
|||||||
|
|
||||||
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/update.ps1 -Executable $executablePath
|
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/update.ps1 -Executable $executablePath
|
||||||
if ($LASTEXITCODE) { throw 'Updater tests failed' }
|
if ($LASTEXITCODE) { throw 'Updater tests failed' }
|
||||||
|
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/update_progress.ps1
|
||||||
|
if ($LASTEXITCODE) { throw 'Updater progress UI tests failed' }
|
||||||
}
|
}
|
||||||
if ($Integration) {
|
if ($Integration) {
|
||||||
& $compiler @common tests/integration.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherIntegration.exe
|
& $compiler @common tests/integration.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherIntegration.exe
|
||||||
|
|||||||
+3
-3
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
|
|||||||
1 RT_MANIFEST "app.manifest"
|
1 RT_MANIFEST "app.manifest"
|
||||||
201 RCDATA "update.ps1"
|
201 RCDATA "update.ps1"
|
||||||
1 VERSIONINFO
|
1 VERSIONINFO
|
||||||
FILEVERSION 0,6,10,0
|
FILEVERSION 0,6,15,0
|
||||||
PRODUCTVERSION 0,6,10,0
|
PRODUCTVERSION 0,6,15,0
|
||||||
FILEOS VOS_NT_WINDOWS32
|
FILEOS VOS_NT_WINDOWS32
|
||||||
FILETYPE VFT_APP
|
FILETYPE VFT_APP
|
||||||
BEGIN
|
BEGIN
|
||||||
@@ -20,7 +20,7 @@ BEGIN
|
|||||||
BEGIN
|
BEGIN
|
||||||
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
|
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
|
||||||
VALUE "ProductName", "KSwitcher"
|
VALUE "ProductName", "KSwitcher"
|
||||||
VALUE "FileVersion", "0.6.10"
|
VALUE "FileVersion", "0.6.15"
|
||||||
END
|
END
|
||||||
END
|
END
|
||||||
BLOCK "VarFileInfo"
|
BLOCK "VarFileInfo"
|
||||||
|
|||||||
+215
-5
@@ -3,10 +3,192 @@
|
|||||||
[string]$CurrentVersion, [string]$ExpectedVersion,
|
[string]$CurrentVersion, [string]$ExpectedVersion,
|
||||||
[string]$InstallDirectory, [string]$WorkDirectory,
|
[string]$InstallDirectory, [string]$WorkDirectory,
|
||||||
[int]$ParentProcessId, [string]$ReadyEvent,
|
[int]$ParentProcessId, [string]$ReadyEvent,
|
||||||
|
[switch]$ShowProgress,
|
||||||
[switch]$Library
|
[switch]$Library
|
||||||
)
|
)
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
Set-StrictMode -Version Latest
|
Set-StrictMode -Version Latest
|
||||||
|
$script:progressWindow = $null
|
||||||
|
|
||||||
|
function Start-UpdateProgress {
|
||||||
|
Add-Type -AssemblyName System.Windows.Forms
|
||||||
|
Add-Type -AssemblyName System.Drawing
|
||||||
|
if (!('KSwitcherUpdateProgress' -as [type])) {
|
||||||
|
Add-Type -ReferencedAssemblies System.Windows.Forms,System.Drawing -TypeDefinition @'
|
||||||
|
using System;
|
||||||
|
using System.Drawing;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Windows.Forms;
|
||||||
|
using System.Drawing.Drawing2D;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
public sealed class UpdateMeter : Control {
|
||||||
|
public int Percent = -1;
|
||||||
|
private int position;
|
||||||
|
private readonly System.Windows.Forms.Timer timer = new System.Windows.Forms.Timer { Interval = 35 };
|
||||||
|
public UpdateMeter() {
|
||||||
|
DoubleBuffered = true;
|
||||||
|
timer.Tick += delegate { if (Percent < 0) { position = (position + 5) % Math.Max(1,Width); Invalidate(); } };
|
||||||
|
timer.Start();
|
||||||
|
}
|
||||||
|
protected override void OnPaint(PaintEventArgs e) {
|
||||||
|
using(var track = new SolidBrush(Color.FromArgb(232,234,241))) e.Graphics.FillRectangle(track,0,0,Width,Height);
|
||||||
|
using(var fill = new SolidBrush(Color.FromArgb(79,70,229))) {
|
||||||
|
if(Percent < 0) e.Graphics.FillRectangle(fill,position-Width/4,0,Width/4,Height);
|
||||||
|
else e.Graphics.FillRectangle(fill,0,0,Width*Percent/100,Height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
protected override void Dispose(bool disposing) { if(disposing) timer.Dispose(); base.Dispose(disposing); }
|
||||||
|
}
|
||||||
|
public sealed class UpdateSteps : Control {
|
||||||
|
public int Current = -1;
|
||||||
|
public bool Complete;
|
||||||
|
public UpdateSteps() { DoubleBuffered = true; }
|
||||||
|
protected override void OnPaint(PaintEventArgs e) {
|
||||||
|
e.Graphics.SmoothingMode = SmoothingMode.AntiAlias;
|
||||||
|
string[] names = {"Download","Verify","Install","Restart"};
|
||||||
|
for(int i=0;i<4;i++) {
|
||||||
|
int x = i*(Width-80)/3;
|
||||||
|
Color color = Complete || i<=Current ? Color.FromArgb(79,70,229) : Color.FromArgb(135,141,158);
|
||||||
|
using(var pen = new Pen(Color.FromArgb(231,233,240)))
|
||||||
|
if(i<3) e.Graphics.DrawLine(pen,x+22,8,x+(Width-80)/3-10,8);
|
||||||
|
using(var brush = new SolidBrush(color)) {
|
||||||
|
if(Complete || i<Current) {
|
||||||
|
e.Graphics.FillEllipse(brush,x,0,16,16);
|
||||||
|
using(var pen = new Pen(Color.White,1.6f)) e.Graphics.DrawLines(pen,new Point[]{new Point(x+4,8),new Point(x+7,11),new Point(x+12,5)});
|
||||||
|
} else {
|
||||||
|
using(var pen = new Pen(color,i==Current?2:1)) e.Graphics.DrawEllipse(pen,x+1,1,14,14);
|
||||||
|
if(i==Current) e.Graphics.FillEllipse(brush,x+5,5,6,6);
|
||||||
|
}
|
||||||
|
e.Graphics.DrawString(names[i],Font,brush,x,26);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public sealed class KSwitcherUpdateProgress : IDisposable {
|
||||||
|
private Form form;
|
||||||
|
private Label stage, detail, percentage, footer;
|
||||||
|
private UpdateMeter progress;
|
||||||
|
private UpdateSteps steps;
|
||||||
|
private Button done, close;
|
||||||
|
private bool closing;
|
||||||
|
private Exception failure;
|
||||||
|
private readonly ManualResetEvent ready = new ManualResetEvent(false);
|
||||||
|
private readonly Thread thread;
|
||||||
|
[DllImport("user32.dll")] private static extern bool ReleaseCapture();
|
||||||
|
[DllImport("user32.dll")] private static extern IntPtr SendMessage(IntPtr h,int m,IntPtr w,IntPtr l);
|
||||||
|
[DllImport("dwmapi.dll")] private static extern int DwmSetWindowAttribute(IntPtr h,int a,ref int v,int size);
|
||||||
|
private void Drag(object sender,MouseEventArgs e) { if(e.Button==MouseButtons.Left) { ReleaseCapture(); SendMessage(form.Handle,0xA1,(IntPtr)2,IntPtr.Zero); } }
|
||||||
|
public KSwitcherUpdateProgress() {
|
||||||
|
thread = new Thread(delegate() {
|
||||||
|
try {
|
||||||
|
Application.EnableVisualStyles();
|
||||||
|
form = new Form { Text = "KSwitcher Update", ClientSize = new Size(540,350), BackColor = Color.White,
|
||||||
|
ForeColor = Color.FromArgb(32,36,48), StartPosition = FormStartPosition.CenterScreen,
|
||||||
|
FormBorderStyle = FormBorderStyle.None, Font = new Font("Segoe UI",10), AutoScaleMode = AutoScaleMode.Dpi };
|
||||||
|
var logo = new Panel { Location = new Point(28,24), Size = new Size(34,34), BackColor = Color.FromArgb(79,70,229) };
|
||||||
|
logo.Paint += delegate(object sender,PaintEventArgs e) {
|
||||||
|
e.Graphics.SmoothingMode = SmoothingMode.AntiAlias;
|
||||||
|
using(var pen = new Pen(Color.White,4)) { e.Graphics.DrawLine(pen,10,8,10,26); e.Graphics.DrawLine(pen,11,17,25,7); }
|
||||||
|
using(var pen = new Pen(Color.FromArgb(94,234,212),4)) e.Graphics.DrawLine(pen,12,17,25,27);
|
||||||
|
};
|
||||||
|
var brand = new Label { Text = "KSwitcher", Location = new Point(74,22), Size = new Size(260,24), Font = new Font("Segoe UI Semibold",11) };
|
||||||
|
var caption = new Label { Text = "Application update", Location = new Point(74,45), Size = new Size(260,20), ForeColor = Color.FromArgb(111,117,134), Font = new Font("Segoe UI",9) };
|
||||||
|
var minimize = new Button { Text = "\u2013", AccessibleName = "Minimize", Location = new Point(452,20), Size = new Size(32,32), FlatStyle = FlatStyle.Flat, TabStop = false };
|
||||||
|
minimize.FlatAppearance.BorderSize = 0; minimize.Click += delegate { form.WindowState = FormWindowState.Minimized; };
|
||||||
|
close = new Button { Text = "\u00d7", AccessibleName = "Close", Location = new Point(490,20), Size = new Size(32,32), FlatStyle = FlatStyle.Flat, Enabled = false };
|
||||||
|
close.FlatAppearance.BorderSize = 0; close.Click += delegate { form.Close(); };
|
||||||
|
stage = new Label { Text = "Checking for updates", Location = new Point(28,98), Size = new Size(414,36), Font = new Font("Segoe UI Semibold",20) };
|
||||||
|
percentage = new Label { Location = new Point(440,104), Size = new Size(72,30), TextAlign = ContentAlignment.MiddleRight, ForeColor = Color.FromArgb(79,70,229), Font = new Font("Segoe UI Semibold",16) };
|
||||||
|
detail = new Label { Text = "Connecting to the release server", Location = new Point(28,148), Size = new Size(484,42), ForeColor = Color.FromArgb(111,117,134) };
|
||||||
|
progress = new UpdateMeter { Location = new Point(28,202), Size = new Size(484,6), AccessibleName = "Update progress" };
|
||||||
|
steps = new UpdateSteps { Location = new Point(28,230), Size = new Size(484,48), Font = new Font("Segoe UI",9) };
|
||||||
|
var bottom = new Panel { Location = new Point(1,296), Size = new Size(538,53), BackColor = Color.FromArgb(247,248,251) };
|
||||||
|
footer = new Label { Text = "Your settings and history stay with you.", Location = new Point(27,18), Size = new Size(350,22), ForeColor = Color.FromArgb(111,117,134), Font = new Font("Segoe UI",9) };
|
||||||
|
done = new Button { Text = "Done", Location = new Point(410,9), Size = new Size(100,34), BackColor = Color.FromArgb(79,70,229), ForeColor = Color.White, FlatStyle = FlatStyle.Flat, Visible = false };
|
||||||
|
done.FlatAppearance.BorderSize = 0; done.Click += delegate { form.Close(); };
|
||||||
|
bottom.Controls.AddRange(new Control[]{footer,done});
|
||||||
|
form.Controls.AddRange(new Control[]{logo,brand,caption,minimize,close,stage,percentage,detail,progress,steps,bottom});
|
||||||
|
form.MouseDown += Drag; logo.MouseDown += Drag; brand.MouseDown += Drag; caption.MouseDown += Drag;
|
||||||
|
form.Paint += delegate(object sender,PaintEventArgs e) { using(var pen = new Pen(Color.FromArgb(220,223,232))) e.Graphics.DrawRectangle(pen,0,0,form.ClientSize.Width-1,form.ClientSize.Height-1); };
|
||||||
|
form.FormClosing += delegate(object sender, FormClosingEventArgs e) { if (!closing) e.Cancel = true; };
|
||||||
|
form.Shown += delegate { int rounded=2; try { DwmSetWindowAttribute(form.Handle,33,ref rounded,4); } catch { } ready.Set(); };
|
||||||
|
Application.Run(form);
|
||||||
|
} catch(Exception e) { failure = e; ready.Set(); }
|
||||||
|
});
|
||||||
|
thread.IsBackground = true;
|
||||||
|
thread.SetApartmentState(ApartmentState.STA);
|
||||||
|
thread.Start();
|
||||||
|
if (!ready.WaitOne(10000)) throw new Exception("Update progress window did not start.");
|
||||||
|
if (failure != null) throw new Exception("Cannot display update progress.",failure);
|
||||||
|
}
|
||||||
|
public void Report(string title, string text, int percent) {
|
||||||
|
if (form == null || form.IsDisposed) return;
|
||||||
|
form.BeginInvoke((Action)delegate {
|
||||||
|
stage.Text = title; detail.Text = text;
|
||||||
|
progress.Percent = percent < 0 ? -1 : Math.Max(0,Math.Min(100,percent));
|
||||||
|
percentage.Text = percent < 0 ? "" : progress.Percent+"%";
|
||||||
|
int separator = text.IndexOf("% - ");
|
||||||
|
if(separator>=0) detail.Text = text.Substring(separator+4)+" downloaded";
|
||||||
|
if(title.StartsWith("Downloading")) steps.Current=0;
|
||||||
|
else if(title.StartsWith("Verifying") || title.StartsWith("Preparing")) steps.Current=1;
|
||||||
|
else if(title.StartsWith("Installing") || title.StartsWith("Closing")) steps.Current=2;
|
||||||
|
else if(title.StartsWith("Restarting")) steps.Current=3;
|
||||||
|
progress.Invalidate(); steps.Invalidate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
public void SavePreview(string path) {
|
||||||
|
form.Invoke((Action)delegate {
|
||||||
|
using(var bitmap = new Bitmap(form.Width,form.Height)) {
|
||||||
|
form.DrawToBitmap(bitmap,new Rectangle(0,0,bitmap.Width,bitmap.Height));
|
||||||
|
bitmap.Save(path,System.Drawing.Imaging.ImageFormat.Png);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
public string Status { get { return (string)form.Invoke((Func<string>)delegate { return stage.Text; }); } }
|
||||||
|
public int Percent { get { return (int)form.Invoke((Func<int>)delegate { return progress.Percent; }); } }
|
||||||
|
public void Finish(string title, string text, bool success) {
|
||||||
|
form.Invoke((Action)delegate {
|
||||||
|
closing = true;
|
||||||
|
stage.Text = title; detail.Text = text;
|
||||||
|
progress.Percent = success ? 100 : 0; percentage.Text = success ? "100%" : "";
|
||||||
|
steps.Complete = success;
|
||||||
|
if(!success) {
|
||||||
|
detail.Visible = false; steps.Visible = false;
|
||||||
|
var result = new TextBox { Text = text, Location = new Point(28,148), Size = new Size(484,126),
|
||||||
|
Multiline = true, ReadOnly = true, BorderStyle = BorderStyle.None,
|
||||||
|
BackColor = Color.White, ScrollBars = ScrollBars.Vertical };
|
||||||
|
form.Controls.Add(result); result.BringToFront(); progress.Visible = false;
|
||||||
|
}
|
||||||
|
footer.Text = success ? "Your settings and history are preserved." : "Review the details above.";
|
||||||
|
close.Enabled = true; done.Visible = true;
|
||||||
|
form.AcceptButton = done; form.CancelButton = done;
|
||||||
|
progress.Invalidate(); steps.Invalidate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
public void WaitForClose() { thread.Join(); }
|
||||||
|
public void Dispose() {
|
||||||
|
if (form != null && !form.IsDisposed)
|
||||||
|
form.BeginInvoke((Action)delegate { closing = true; form.Close(); });
|
||||||
|
thread.Join(3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
}
|
||||||
|
$script:progressWindow = New-Object KSwitcherUpdateProgress
|
||||||
|
}
|
||||||
|
function Show-UpdateProgress([string]$Stage,[string]$Detail,[int]$Percent = -1) {
|
||||||
|
if ($script:progressWindow) {
|
||||||
|
try { $script:progressWindow.Report($Stage,$Detail,$Percent) }
|
||||||
|
catch { Write-Warning 'Could not refresh update progress.' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function Complete-UpdateProgress([string]$Title,[string]$Detail,[bool]$Success) {
|
||||||
|
if ($script:progressWindow) {
|
||||||
|
try { $script:progressWindow.Finish($Title,$Detail,$Success); return $true }
|
||||||
|
catch { Write-Warning 'Could not display the update result.' }
|
||||||
|
}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
function Get-StableVersion([string]$Tag) {
|
function Get-StableVersion([string]$Tag) {
|
||||||
if ($Tag -notmatch '^(?:v|release-v)?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') { return $null }
|
if ($Tag -notmatch '^(?:v|release-v)?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') { return $null }
|
||||||
@@ -20,7 +202,7 @@ function Assert-ReleaseUrl([string]$Url) {
|
|||||||
throw 'Release asset URL is outside the KSwitcher repository.'
|
throw 'Release asset URL is outside the KSwitcher repository.'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function Receive-File([string]$Url,[string]$Path,[long]$Limit) {
|
function Receive-File([string]$Url,[string]$Path,[long]$Limit,[string]$ProgressLabel = '') {
|
||||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||||
$request = [Net.HttpWebRequest]::Create($Url)
|
$request = [Net.HttpWebRequest]::Create($Url)
|
||||||
$request.AllowAutoRedirect = $false
|
$request.AllowAutoRedirect = $false
|
||||||
@@ -36,11 +218,20 @@ function Receive-File([string]$Url,[string]$Path,[long]$Limit) {
|
|||||||
$buffer = New-Object byte[] 65536
|
$buffer = New-Object byte[] 65536
|
||||||
$total = 0L
|
$total = 0L
|
||||||
$deadline = [DateTime]::UtcNow.AddSeconds(60)
|
$deadline = [DateTime]::UtcNow.AddSeconds(60)
|
||||||
|
$lastProgress = [DateTime]::MinValue
|
||||||
while (($count = $inputStream.Read($buffer,0,$buffer.Length)) -gt 0) {
|
while (($count = $inputStream.Read($buffer,0,$buffer.Length)) -gt 0) {
|
||||||
$total += $count
|
$total += $count
|
||||||
if ($total -gt $Limit -or [DateTime]::UtcNow -gt $deadline) { throw 'Download exceeded size or time limit.' }
|
if ($total -gt $Limit -or [DateTime]::UtcNow -gt $deadline) { throw 'Download exceeded size or time limit.' }
|
||||||
$outputStream.Write($buffer,0,$count)
|
$outputStream.Write($buffer,0,$count)
|
||||||
|
if ($ProgressLabel -and ([DateTime]::UtcNow - $lastProgress).TotalMilliseconds -ge 100) {
|
||||||
|
$percent = if ($response.ContentLength -gt 0) { [int][Math]::Min(100,100.0*$total/$response.ContentLength) } else { -1 }
|
||||||
|
$detail = if ($percent -ge 0) { '{0}% - {1:N1} / {2:N1} MB' -f $percent,($total/1MB),($response.ContentLength/1MB) } else { '{0:N1} MB received' -f ($total/1MB) }
|
||||||
|
Show-UpdateProgress $ProgressLabel $detail $percent
|
||||||
|
$lastProgress = [DateTime]::UtcNow
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if ($response.ContentLength -ge 0 -and $total -ne $response.ContentLength) { throw 'The download ended before the complete file was received.' }
|
||||||
|
if ($ProgressLabel) { Show-UpdateProgress $ProgressLabel ('Downloaded {0:N1} MB' -f ($total/1MB)) 100 }
|
||||||
} finally {
|
} finally {
|
||||||
if ($outputStream) { $outputStream.Dispose() }
|
if ($outputStream) { $outputStream.Dispose() }
|
||||||
if ($inputStream) { $inputStream.Dispose() }
|
if ($inputStream) { $inputStream.Dispose() }
|
||||||
@@ -220,8 +411,9 @@ function Write-Result($Value) {
|
|||||||
if ($Library) { return }
|
if ($Library) { return }
|
||||||
$transaction = $null; $parentProcess = $null; $ready = $null; $started = $null
|
$transaction = $null; $parentProcess = $null; $ready = $null; $started = $null
|
||||||
$changed = [Collections.Generic.List[string]]::new()
|
$changed = [Collections.Generic.List[string]]::new()
|
||||||
$parentExited = $false; $success = $false; $preserveBackup = $false; $child = $null
|
$parentExited = $false; $success = $false; $preserveBackup = $false; $child = $null; $uiFinished = $false
|
||||||
try {
|
try {
|
||||||
|
if ($ShowProgress -or $Mode -eq 'Install') { Start-UpdateProgress }
|
||||||
$current = Get-StableVersion $CurrentVersion
|
$current = Get-StableVersion $CurrentVersion
|
||||||
if (!$current) { throw 'Invalid installed version.' }
|
if (!$current) { throw 'Invalid installed version.' }
|
||||||
if ($Mode -eq 'Install') {
|
if ($Mode -eq 'Install') {
|
||||||
@@ -242,25 +434,35 @@ try {
|
|||||||
$stage = Join-Path $transaction 'stage'; $backup = Join-Path $transaction 'backup'
|
$stage = Join-Path $transaction 'stage'; $backup = Join-Path $transaction 'backup'
|
||||||
[IO.Directory]::CreateDirectory($stage) | Out-Null
|
[IO.Directory]::CreateDirectory($stage) | Out-Null
|
||||||
$archive = Join-Path $WorkDirectory 'release.zip'; $checksum = Join-Path $WorkDirectory 'SHA256SUMS.txt'
|
$archive = Join-Path $WorkDirectory 'release.zip'; $checksum = Join-Path $WorkDirectory 'SHA256SUMS.txt'
|
||||||
Receive-File $release.zip $archive 50MB
|
Show-UpdateProgress 'Downloading update' $release.version 0
|
||||||
|
Receive-File $release.zip $archive 50MB 'Downloading update'
|
||||||
|
Show-UpdateProgress 'Verifying download' 'Checking the archive SHA-256 checksum'
|
||||||
Receive-File $release.checksum $checksum 64KB
|
Receive-File $release.checksum $checksum 64KB
|
||||||
Assert-Checksum $archive $checksum
|
Assert-Checksum $archive $checksum
|
||||||
|
Show-UpdateProgress 'Preparing installation' 'Validating and unpacking the release files'
|
||||||
Expand-Release $archive $stage (Get-StableVersion $release.version)
|
Expand-Release $archive $stage (Get-StableVersion $release.version)
|
||||||
$screenshotPath = [IO.Path]::GetFullPath((Join-Path $InstallDirectory 'KSwitcher.Screenshot.exe'))
|
$screenshotPath = [IO.Path]::GetFullPath((Join-Path $InstallDirectory 'KSwitcher.Screenshot.exe'))
|
||||||
if (Get-Process 'KSwitcher.Screenshot' -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $screenshotPath }) { throw 'Close screenshot editors before installing an update.' }
|
if (Get-Process 'KSwitcher.Screenshot' -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $screenshotPath }) { throw 'Close screenshot editors before installing an update.' }
|
||||||
Write-Result @{state='prepared';version=$release.version}
|
Write-Result @{state='prepared';version=$release.version}
|
||||||
|
Show-UpdateProgress 'Closing KSwitcher' 'Waiting for the running application to exit safely'
|
||||||
$ready.Set() | Out-Null
|
$ready.Set() | Out-Null
|
||||||
if (!$parentProcess.WaitForExit(120000)) { throw 'KSwitcher did not exit; the installed files were not changed.' }
|
if (!$parentProcess.WaitForExit(120000)) { throw 'KSwitcher did not exit; the installed files were not changed.' }
|
||||||
$parentExited = $true
|
$parentExited = $true
|
||||||
|
Show-UpdateProgress 'Installing update' 'Replacing application files; keeping your personal data'
|
||||||
Install-Files $stage $InstallDirectory $backup $changed
|
Install-Files $stage $InstallDirectory $backup $changed
|
||||||
$startedName = 'Local\KSwitcher.Update.Started.'+[guid]::NewGuid().ToString('N')
|
$startedName = 'Local\KSwitcher.Update.Started.'+[guid]::NewGuid().ToString('N')
|
||||||
$started = [Threading.EventWaitHandle]::new($false,[Threading.EventResetMode]::ManualReset,$startedName)
|
$started = [Threading.EventWaitHandle]::new($false,[Threading.EventResetMode]::ManualReset,$startedName)
|
||||||
|
Show-UpdateProgress 'Restarting KSwitcher' 'Waiting for the updated application to confirm startup'
|
||||||
$child = Start-Process -FilePath (Join-Path $InstallDirectory 'KSwitcher.exe') -ArgumentList "--update-ready-event=$startedName" -WorkingDirectory $InstallDirectory -WindowStyle Hidden -PassThru
|
$child = Start-Process -FilePath (Join-Path $InstallDirectory 'KSwitcher.exe') -ArgumentList "--update-ready-event=$startedName" -WorkingDirectory $InstallDirectory -WindowStyle Hidden -PassThru
|
||||||
if (!$started.WaitOne(30000) -or $child.HasExited) { throw 'The updated application did not confirm startup.' }
|
if (!$started.WaitOne(30000) -or $child.HasExited) { throw 'The updated application did not confirm startup.' }
|
||||||
$success = $true
|
$success = $true
|
||||||
|
if ($script:progressWindow) {
|
||||||
|
$uiFinished = Complete-UpdateProgress 'Update completed' "KSwitcher $(Get-StableVersion $release.version) is now running." $true
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
$message = $_.Exception.Message
|
$message = $_.Exception.Message
|
||||||
if ($parentExited) {
|
if ($parentExited) {
|
||||||
|
Show-UpdateProgress 'Restoring previous version' 'The update failed; recovering the previous installation'
|
||||||
try {
|
try {
|
||||||
if ($child -and !$child.HasExited) { $child.Kill(); $child.WaitForExit() }
|
if ($child -and !$child.HasExited) { $child.Kill(); $child.WaitForExit() }
|
||||||
Undo-Files $InstallDirectory $backup $changed
|
Undo-Files $InstallDirectory $backup $changed
|
||||||
@@ -273,8 +475,12 @@ try {
|
|||||||
}
|
}
|
||||||
Write-Result @{state='error';error=$message}
|
Write-Result @{state='error';error=$message}
|
||||||
if ($ready -and $ready.WaitOne(0)) {
|
if ($ready -and $ready.WaitOne(0)) {
|
||||||
Add-Type -AssemblyName System.Windows.Forms
|
if ($script:progressWindow) {
|
||||||
[Windows.Forms.MessageBox]::Show($message,'KSwitcher update failed') | Out-Null
|
$uiFinished = Complete-UpdateProgress 'Update failed' $message $false
|
||||||
|
} else {
|
||||||
|
Add-Type -AssemblyName System.Windows.Forms
|
||||||
|
[Windows.Forms.MessageBox]::Show($message,'KSwitcher update failed') | Out-Null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
exit 1
|
exit 1
|
||||||
} finally {
|
} finally {
|
||||||
@@ -291,4 +497,8 @@ try {
|
|||||||
Remove-Item -LiteralPath $WorkDirectory -Recurse -Force -ErrorAction SilentlyContinue
|
Remove-Item -LiteralPath $WorkDirectory -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if ($script:progressWindow) {
|
||||||
|
if ($uiFinished) { $script:progressWindow.WaitForClose() }
|
||||||
|
$script:progressWindow.Dispose(); $script:progressWindow = $null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+115
-38
@@ -12,6 +12,11 @@ namespace ks
|
|||||||
App *App::current = nullptr;
|
App *App::current = nullptr;
|
||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
|
ULONG_PTR snippetReplayMarker()
|
||||||
|
{
|
||||||
|
static const char marker{};
|
||||||
|
return reinterpret_cast<ULONG_PTR>(&marker);
|
||||||
|
}
|
||||||
std::vector<std::pair<int, std::wstring>> shortcuts(const Settings &s)
|
std::vector<std::pair<int, std::wstring>> shortcuts(const Settings &s)
|
||||||
{
|
{
|
||||||
std::vector<std::pair<int, std::wstring>> out{{Toggle, s.toggle},
|
std::vector<std::pair<int, std::wstring>> out{{Toggle, s.toggle},
|
||||||
@@ -248,6 +253,13 @@ App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instan
|
|||||||
registrationWarning = L"Settings were not loaded: " + wide(e.what());
|
registrationWarning = L"Settings were not loaded: " + wide(e.what());
|
||||||
}
|
}
|
||||||
setLoggingEnabled(settings.writeLog);
|
setLoggingEnabled(settings.writeLog);
|
||||||
|
if (settings.autoCorrect)
|
||||||
|
try { autoPredictor.configure(std::filesystem::path(executable()).parent_path() / L"dictionaries", settings.autoLanguages); }
|
||||||
|
catch (const std::exception &e)
|
||||||
|
{
|
||||||
|
settings.autoCorrect = false;
|
||||||
|
registrationWarning += L"\nAutomatic correction unavailable: " + wide(e.what());
|
||||||
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
history = std::make_unique<History>(data / L"history");
|
history = std::make_unique<History>(data / L"history");
|
||||||
@@ -417,12 +429,12 @@ bool App::registerHotkeys(const Settings &s, bool rollback)
|
|||||||
void App::applySettings(const Settings &s)
|
void App::applySettings(const Settings &s)
|
||||||
{
|
{
|
||||||
bool autostartChanged = false;
|
bool autostartChanged = false;
|
||||||
|
AutoCorrector predictor;
|
||||||
validateSettings(s);
|
validateSettings(s);
|
||||||
validateTesseract(s);
|
validateTesseract(s);
|
||||||
if (s.autoCorrect)
|
if (s.autoCorrect)
|
||||||
{
|
{
|
||||||
AutoCorrector probe;
|
predictor.configure(std::filesystem::path(executable()).parent_path() / L"dictionaries", s.autoLanguages);
|
||||||
probe.configure(std::filesystem::path(executable()).parent_path() / L"dictionaries", s.autoLanguages);
|
|
||||||
}
|
}
|
||||||
if (!registerHotkeys(s))
|
if (!registerHotkeys(s))
|
||||||
{
|
{
|
||||||
@@ -454,6 +466,7 @@ void App::applySettings(const Settings &s)
|
|||||||
{
|
{
|
||||||
std::lock_guard lock(stateMutex);
|
std::lock_guard lock(stateMutex);
|
||||||
settings = s;
|
settings = s;
|
||||||
|
autoPredictor = std::move(predictor);
|
||||||
setLoggingEnabled(s.writeLog);
|
setLoggingEnabled(s.writeLog);
|
||||||
run.reset();
|
run.reset();
|
||||||
last.reset();
|
last.reset();
|
||||||
@@ -517,6 +530,7 @@ void App::resetInput()
|
|||||||
run.reset();
|
run.reset();
|
||||||
last.reset();
|
last.reset();
|
||||||
}
|
}
|
||||||
|
void App::clearLog(bool previous) { clearDiagnosticLog(previous); }
|
||||||
void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
|
void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
|
||||||
{
|
{
|
||||||
if (ownKeyboardInput(k))
|
if (ownKeyboardInput(k))
|
||||||
@@ -557,9 +571,33 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
run.record(ch, fg, layout, GetTickCount64());
|
run.record(ch, fg, layout, GetTickCount64());
|
||||||
if (settings.autoCorrect && ch == L' ' && run.keys.size() >= (size_t)settings.autoMinLength + 1 &&
|
if (settings.autoCorrect && !snippetTyping && k.dwExtraInfo != snippetReplayMarker() &&
|
||||||
|
mods == 0 && ch == L' ' && run.keys.size() >= (size_t)settings.autoMinLength + 1 &&
|
||||||
run.keys.size() <= 41 && !busy && !run.overflow)
|
run.keys.size() <= 41 && !busy && !run.overflow)
|
||||||
PostMessageW(window, AutoCorrectMessage, (WPARAM)inputVersion.load(), 0);
|
{
|
||||||
|
for (int source = 0; source < (int)layouts.size(); ++source)
|
||||||
|
if (layouts[source].handle == layout)
|
||||||
|
{
|
||||||
|
std::wstring predicted;
|
||||||
|
for (wchar_t key : run.keys)
|
||||||
|
predicted += key == L' ' ? L' ' : layouts[source].at(physicalLayout().code(key));
|
||||||
|
if (autoPredictor.suggest(predicted, source, layouts, settings))
|
||||||
|
{
|
||||||
|
// Reserve the transaction before the next physical key arrives.
|
||||||
|
// The delimiter itself passes through; subsequent input is queued.
|
||||||
|
typingTarget = fg;
|
||||||
|
autoPending = true;
|
||||||
|
busy = true;
|
||||||
|
if (!PostMessageW(window, AutoCorrectMessage, (WPARAM)inputVersion.load(), 0))
|
||||||
|
{
|
||||||
|
typingTarget = nullptr;
|
||||||
|
autoPending = false;
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (busy || settings.snippets.empty())
|
if (busy || settings.snippets.empty())
|
||||||
return;
|
return;
|
||||||
auto keys = lower(run.keys);
|
auto keys = lower(run.keys);
|
||||||
@@ -587,7 +625,18 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
|
|||||||
for (auto &trigger : variants)
|
for (auto &trigger : variants)
|
||||||
if (keys.size() >= trigger.size() && keys.ends_with(trigger))
|
if (keys.size() >= trigger.size() && keys.ends_with(trigger))
|
||||||
{
|
{
|
||||||
PostMessageW(window, SnippetMessage, (WPARAM)i, 0);
|
// Keep subsequent typing out of the selection/paste transaction.
|
||||||
|
typingTarget = fg;
|
||||||
|
snippetPending = (int)i;
|
||||||
|
snippetTyping = true;
|
||||||
|
busy = true;
|
||||||
|
if (!PostMessageW(window, SnippetMessage, (WPARAM)i, (LPARAM)inputVersion.load()))
|
||||||
|
{
|
||||||
|
typingTarget = nullptr;
|
||||||
|
snippetPending = -1;
|
||||||
|
snippetTyping = false;
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -661,12 +710,13 @@ bool App::handleKeyboard(const KBDLLHOOKSTRUCT &key, bool down, UINT mods)
|
|||||||
return true;
|
return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
bool App::beginTypingDeferral(HWND target, uint64_t version)
|
bool App::beginTypingDeferral(HWND target, uint64_t version, bool snippet)
|
||||||
{
|
{
|
||||||
std::lock_guard lock(stateMutex);
|
std::lock_guard lock(stateMutex);
|
||||||
if (inputVersion != version || GetForegroundWindow() != target || !modifiersReleased())
|
if (inputVersion != version || GetForegroundWindow() != target || !modifiersReleased())
|
||||||
return false;
|
return false;
|
||||||
typingTarget = target;
|
typingTarget = target;
|
||||||
|
snippetTyping = snippet;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
void App::replayTyping()
|
void App::replayTyping()
|
||||||
@@ -677,7 +727,13 @@ void App::replayTyping()
|
|||||||
std::lock_guard lock(stateMutex);
|
std::lock_guard lock(stateMutex);
|
||||||
target = typingTarget;
|
target = typingTarget;
|
||||||
typingTarget = nullptr;
|
typingTarget = nullptr;
|
||||||
|
autoPending = false;
|
||||||
|
snippetPending = -1;
|
||||||
inputs.swap(deferredTyping);
|
inputs.swap(deferredTyping);
|
||||||
|
if (snippetTyping)
|
||||||
|
for (auto &input : inputs)
|
||||||
|
input.ki.dwExtraInfo = snippetReplayMarker();
|
||||||
|
snippetTyping = false;
|
||||||
}
|
}
|
||||||
busy = false;
|
busy = false;
|
||||||
if (inputs.empty())
|
if (inputs.empty())
|
||||||
@@ -793,12 +849,19 @@ void App::transform(int command, int snippet)
|
|||||||
L" version=" + std::to_wstring(currentVersion) +
|
L" version=" + std::to_wstring(currentVersion) +
|
||||||
L" repeat=" + std::to_wstring(repeatAttempts));
|
L" repeat=" + std::to_wstring(repeatAttempts));
|
||||||
}
|
}
|
||||||
if (busy.exchange(true))
|
const bool preDeferred = (command == AutoCorrect && autoPending) ||
|
||||||
|
(snippet >= 0 && snippetPending == snippet);
|
||||||
|
if (busy.exchange(true) && !preDeferred)
|
||||||
{
|
{
|
||||||
if (manual)
|
if (manual)
|
||||||
diagnosticEvent(L"Transform outcome id=" + std::to_wstring(attempt) + L" reason=busy", true);
|
diagnosticEvent(L"Transform outcome id=" + std::to_wstring(attempt) + L" reason=busy", true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (preDeferred)
|
||||||
|
{
|
||||||
|
autoPending = false;
|
||||||
|
snippetPending = -1;
|
||||||
|
}
|
||||||
Run recent;
|
Run recent;
|
||||||
std::optional<LastTransform> previous;
|
std::optional<LastTransform> previous;
|
||||||
Settings config;
|
Settings config;
|
||||||
@@ -813,7 +876,7 @@ void App::transform(int command, int snippet)
|
|||||||
version = inputVersion;
|
version = inputVersion;
|
||||||
}
|
}
|
||||||
enqueue(
|
enqueue(
|
||||||
[this, target, recent, previous, config, available, version, command, snippet, attempt, manual, requestedAt]
|
[this, target, recent, previous, config, available, version, command, snippet, attempt, manual, requestedAt, preDeferred]
|
||||||
{
|
{
|
||||||
struct BusyReset
|
struct BusyReset
|
||||||
{
|
{
|
||||||
@@ -827,7 +890,7 @@ void App::transform(int command, int snippet)
|
|||||||
else
|
else
|
||||||
b = false;
|
b = false;
|
||||||
}
|
}
|
||||||
} reset{busy, window};
|
} reset{busy, window, preDeferred};
|
||||||
auto start = GetTickCount64();
|
auto start = GetTickCount64();
|
||||||
std::wstring reason = L"cancelled-before-selection";
|
std::wstring reason = L"cancelled-before-selection";
|
||||||
std::wstring text, result, expected;
|
std::wstring text, result, expected;
|
||||||
@@ -869,7 +932,7 @@ void App::transform(int command, int snippet)
|
|||||||
reason != L"paste-sent-unverified" && reason != L"layout-only" &&
|
reason != L"paste-sent-unverified" && reason != L"layout-only" &&
|
||||||
reason != L"unchanged-expected");
|
reason != L"unchanged-expected");
|
||||||
}
|
}
|
||||||
} audit{*this, target, version, attempt, requestedAt, manual, reason, text, result, expected, hint, to};
|
} audit{*this, target, version, attempt, requestedAt, manual || snippet >= 0, reason, text, result, expected, hint, to};
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
auto valid = [&]
|
auto valid = [&]
|
||||||
@@ -905,14 +968,15 @@ void App::transform(int command, int snippet)
|
|||||||
std::wstring predicted;
|
std::wstring predicted;
|
||||||
for (wchar_t key : recent.keys)
|
for (wchar_t key : recent.keys)
|
||||||
predicted += key == L' ' ? L' ' : available[hint].at(physicalLayout().code(key));
|
predicted += key == L' ' ? L' ' : available[hint].at(physicalLayout().code(key));
|
||||||
|
expected = predicted;
|
||||||
if (!autoCorrector.suggest(predicted, hint, available, config) ||
|
if (!autoCorrector.suggest(predicted, hint, available, config) ||
|
||||||
!autoCorrectionAllowed(target, config) || !valid())
|
!autoCorrectionAllowed(target, config) || !valid())
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
reason = L"clipboard-snapshot";
|
reason = L"clipboard-snapshot";
|
||||||
if (command == AutoCorrect)
|
if (command == AutoCorrect || snippet >= 0)
|
||||||
{
|
{
|
||||||
if (!beginTypingDeferral(target, version))
|
if (!beginTypingDeferral(target, version, snippet >= 0))
|
||||||
return;
|
return;
|
||||||
reset.deferred = true;
|
reset.deferred = true;
|
||||||
}
|
}
|
||||||
@@ -944,9 +1008,12 @@ void App::transform(int command, int snippet)
|
|||||||
autoSelected = true;
|
autoSelected = true;
|
||||||
Sleep(25);
|
Sleep(25);
|
||||||
text = tx.copy();
|
text = tx.copy();
|
||||||
// Some editors first select only ") ". Extend by whole-word
|
// Editors may split hj,bnm at the comma. Extend only a verified
|
||||||
// boundaries, then still verify against the complete tracked run.
|
// suffix of the tracked input, and still require a complete match.
|
||||||
for (int i = 0; i < 3 && autoClosingSelection(text, config) && tx.valid(); ++i)
|
for (int i = 0; i < 3 && tx.valid() &&
|
||||||
|
matchAutoInput(recent, text, available, physicalLayout(), config) < 0 &&
|
||||||
|
(autoClosingSelection(text, config) ||
|
||||||
|
(!text.empty() && text.size() < expected.size() && expected.ends_with(text))); ++i)
|
||||||
{
|
{
|
||||||
if (!chord(VK_LEFT, MOD_CONTROL | MOD_SHIFT))
|
if (!chord(VK_LEFT, MOD_CONTROL | MOD_SHIFT))
|
||||||
break;
|
break;
|
||||||
@@ -1004,13 +1071,11 @@ void App::transform(int command, int snippet)
|
|||||||
tail.keys = tail.keys.substr(tail.keys.size() - sn.trigger.size());
|
tail.keys = tail.keys.substr(tail.keys.size() - sn.trigger.size());
|
||||||
if (!tx.select(sn.trigger.size()))
|
if (!tx.select(sn.trigger.size()))
|
||||||
return;
|
return;
|
||||||
|
autoSelected = true;
|
||||||
|
reason = L"snippet-verification-mismatch";
|
||||||
text = tx.copy();
|
text = tx.copy();
|
||||||
if (tail.match(text, available, physicalLayout()) < 0)
|
if (tail.match(text, available, physicalLayout()) < 0)
|
||||||
{
|
|
||||||
if (tx.valid())
|
|
||||||
chord(VK_RIGHT);
|
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
result = parseTemplate(sn.text).text;
|
result = parseTemplate(sn.text).text;
|
||||||
automatic = true;
|
automatic = true;
|
||||||
}
|
}
|
||||||
@@ -1123,12 +1188,22 @@ void App::transform(int command, int snippet)
|
|||||||
if (snippet >= 0 && sent && tx.valid())
|
if (snippet >= 0 && sent && tx.valid())
|
||||||
{
|
{
|
||||||
auto t = parseTemplate(config.snippets[snippet].text);
|
auto t = parseTemplate(config.snippets[snippet].text);
|
||||||
if (!t.suffix.empty() && tx.select(caretSteps(t.suffix)))
|
if (!t.suffix.empty())
|
||||||
{
|
{
|
||||||
|
if (!tx.select(caretSteps(t.suffix))) return;
|
||||||
auto suffix = tx.copy();
|
auto suffix = tx.copy();
|
||||||
if (tx.valid())
|
if (!tx.valid()) return;
|
||||||
chord(newlines(suffix) == newlines(t.suffix) ? VK_LEFT : VK_RIGHT);
|
if (newlines(suffix) != newlines(t.suffix))
|
||||||
|
{
|
||||||
|
chord(VK_RIGHT);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!chord(VK_LEFT)) return;
|
||||||
}
|
}
|
||||||
|
// Action markers run after insertion and cursor placement,
|
||||||
|
// before any physical typing buffered during expansion.
|
||||||
|
for (int i = 0; i < t.enters && tx.valid(); ++i)
|
||||||
|
if (!chord(VK_RETURN)) break;
|
||||||
}
|
}
|
||||||
if (to >= 0 && config.switchLayout && tx.valid() && (sent || result == text))
|
if (to >= 0 && config.switchLayout && tx.valid() && (sent || result == text))
|
||||||
{
|
{
|
||||||
@@ -1387,7 +1462,7 @@ void App::checkUpdate(bool interactive, bool install, const std::wstring &versio
|
|||||||
updateWorker = std::thread([this, interactive, install, version]
|
updateWorker = std::thread([this, interactive, install, version]
|
||||||
{
|
{
|
||||||
auto result = std::make_unique<UpdateResult>();
|
auto result = std::make_unique<UpdateResult>();
|
||||||
try { *result = runUpdate(install, version); }
|
try { *result = runUpdate(install, version, interactive); }
|
||||||
catch (const std::exception &e) { result->state = L"error"; result->error = wide(e.what()); }
|
catch (const std::exception &e) { result->state = L"error"; result->error = wide(e.what()); }
|
||||||
result->interactive = interactive;
|
result->interactive = interactive;
|
||||||
result->install = install;
|
result->install = install;
|
||||||
@@ -1476,13 +1551,22 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
app->captureHistory();
|
app->captureHistory();
|
||||||
return 0;
|
return 0;
|
||||||
case AutoCorrectMessage:
|
case AutoCorrectMessage:
|
||||||
if (app->installingUpdate) return 0;
|
if (app->autoPending)
|
||||||
app->pendingAutoVersion = (uint64_t)wp;
|
{
|
||||||
SetTimer(hwnd, 3, 250, nullptr);
|
if (!app->installingUpdate && !app->snippetTyping && app->settings.autoCorrect && app->inputVersion.load() == (uint64_t)wp)
|
||||||
|
app->transform(AutoCorrect);
|
||||||
|
else
|
||||||
|
app->replayTyping();
|
||||||
|
}
|
||||||
return 0;
|
return 0;
|
||||||
case SnippetMessage:
|
case SnippetMessage:
|
||||||
if (app->installingUpdate) return 0;
|
if (app->snippetPending == (int)wp)
|
||||||
app->transform(0, (int)wp);
|
{
|
||||||
|
if (!app->installingUpdate && app->inputVersion.load() == (uint64_t)lp)
|
||||||
|
app->transform(0, (int)wp);
|
||||||
|
else
|
||||||
|
app->replayTyping();
|
||||||
|
}
|
||||||
return 0;
|
return 0;
|
||||||
case HistoryMessage:
|
case HistoryMessage:
|
||||||
if (app->historyWindow && IsWindowVisible(app->historyWindow))
|
if (app->historyWindow && IsWindowVisible(app->historyWindow))
|
||||||
@@ -1504,7 +1588,7 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
case WM_COPYDATA:
|
case WM_COPYDATA:
|
||||||
{
|
{
|
||||||
auto *data = (COPYDATASTRUCT *)lp;
|
auto *data = (COPYDATASTRUCT *)lp;
|
||||||
if (data && data->dwData >= ConfigRead && data->dwData < ConfigReply)
|
if (data && data->dwData >= ConfigRead && data->dwData < ConfigEnd)
|
||||||
{
|
{
|
||||||
if (app->installingUpdate) return FALSE;
|
if (app->installingUpdate) return FALSE;
|
||||||
return configurationRequest(*app, (HWND)wp, *data);
|
return configurationRequest(*app, (HWND)wp, *data);
|
||||||
@@ -1534,13 +1618,6 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
app->dispatch(20);
|
app->dispatch(20);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (wp == 3)
|
|
||||||
{
|
|
||||||
KillTimer(hwnd, 3);
|
|
||||||
if (!app->installingUpdate && app->settings.autoCorrect && app->inputVersion.load() == app->pendingAutoVersion)
|
|
||||||
app->transform(AutoCorrect);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (wp == 2)
|
if (wp == 2)
|
||||||
{
|
{
|
||||||
PostMessageW(hwnd, WM_CLOSE, 0, 0);
|
PostMessageW(hwnd, WM_CLOSE, 0, 0);
|
||||||
@@ -1635,8 +1712,8 @@ int App::execute()
|
|||||||
SetTimer(window, 2, 10000, nullptr);
|
SetTimer(window, 2, 10000, nullptr);
|
||||||
else
|
else
|
||||||
SetTimer(window, 5, 5000, nullptr);
|
SetTimer(window, 5, 5000, nullptr);
|
||||||
if (keyboardHook && mouseHook)
|
if (keyboardHook && mouseHook && confirmUpdatedStartup(updateStartupEvent))
|
||||||
confirmUpdatedStartup(updateStartupEvent);
|
log(std::wstring(L"Update completed; running version ") + AppVersion);
|
||||||
if (startCommand)
|
if (startCommand)
|
||||||
PostMessageW(window, WM_HOTKEY, startCommand, 0);
|
PostMessageW(window, WM_HOTKEY, startCommand, 0);
|
||||||
try { registerScreenshotNotifications(); }
|
try { registerScreenshotNotifications(); }
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class App
|
|||||||
Settings settings;
|
Settings settings;
|
||||||
std::unique_ptr<History> history;
|
std::unique_ptr<History> history;
|
||||||
AutoCorrector autoCorrector;
|
AutoCorrector autoCorrector;
|
||||||
|
AutoCorrector autoPredictor; // Preloaded, hook-thread-only lookup; no file access while typing.
|
||||||
std::atomic<bool> busy{false}, suspended{false};
|
std::atomic<bool> busy{false}, suspended{false};
|
||||||
std::atomic<DWORD> ownSequence{0};
|
std::atomic<DWORD> ownSequence{0};
|
||||||
std::atomic<uint64_t> inputVersion{0};
|
std::atomic<uint64_t> inputVersion{0};
|
||||||
@@ -70,6 +71,7 @@ class App
|
|||||||
void applySettings(const Settings &);
|
void applySettings(const Settings &);
|
||||||
void reloadLayouts();
|
void reloadLayouts();
|
||||||
void clearHistory();
|
void clearHistory();
|
||||||
|
void clearLog(bool previous);
|
||||||
void setHotkeyCapture(HWND);
|
void setHotkeyCapture(HWND);
|
||||||
bool isHotkey(DWORD, UINT);
|
bool isHotkey(DWORD, UINT);
|
||||||
void keyboard(const KBDLLHOOKSTRUCT &, UINT modifiers);
|
void keyboard(const KBDLLHOOKSTRUCT &, UINT modifiers);
|
||||||
@@ -99,7 +101,6 @@ class App
|
|||||||
bool (*updateNotifier)(const std::wstring &){};
|
bool (*updateNotifier)(const std::wstring &){};
|
||||||
bool stop{};
|
bool stop{};
|
||||||
UINT taskbarCreated{};
|
UINT taskbarCreated{};
|
||||||
uint64_t pendingAutoVersion{};
|
|
||||||
uint64_t transformAttempt{}, previousAttemptVersion{};
|
uint64_t transformAttempt{}, previousAttemptVersion{};
|
||||||
ULONGLONG previousAttemptAt{};
|
ULONGLONG previousAttemptAt{};
|
||||||
HWND previousAttemptWindow{};
|
HWND previousAttemptWindow{};
|
||||||
@@ -107,8 +108,11 @@ class App
|
|||||||
std::vector<std::pair<int, Hotkey>> hookBindings;
|
std::vector<std::pair<int, Hotkey>> hookBindings;
|
||||||
HotkeyHook hotkeyHook;
|
HotkeyHook hotkeyHook;
|
||||||
HWND typingTarget{};
|
HWND typingTarget{};
|
||||||
|
bool autoPending{};
|
||||||
|
int snippetPending = -1;
|
||||||
|
bool snippetTyping{};
|
||||||
std::vector<INPUT> deferredTyping;
|
std::vector<INPUT> deferredTyping;
|
||||||
bool beginTypingDeferral(HWND, uint64_t);
|
bool beginTypingDeferral(HWND, uint64_t, bool snippet = false);
|
||||||
void replayTyping();
|
void replayTyping();
|
||||||
static LRESULT CALLBACK windowProc(HWND, UINT, WPARAM, LPARAM);
|
static LRESULT CALLBACK windowProc(HWND, UINT, WPARAM, LPARAM);
|
||||||
static LRESULT CALLBACK keyProc(int, WPARAM, LPARAM);
|
static LRESULT CALLBACK keyProc(int, WPARAM, LPARAM);
|
||||||
|
|||||||
+15
-4
@@ -152,7 +152,11 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
|
|||||||
suffix.insert(suffix.begin(), word.back());
|
suffix.insert(suffix.begin(), word.back());
|
||||||
word.pop_back();
|
word.pop_back();
|
||||||
}
|
}
|
||||||
if (!safeAutoWord(word, settings.autoMinLength) || suffix.size() > 6 || rejected.contains(lower(word)))
|
// Punctuation keys can be letters in the target layout (for example ',' -> 'б').
|
||||||
|
// Validate letters and capitalization after conversion, not before it.
|
||||||
|
if (settings.autoMinLength < 1 || settings.autoMinLength > 32 ||
|
||||||
|
word.size() < (size_t)settings.autoMinLength || word.size() > 32 ||
|
||||||
|
suffix.size() > 6 || rejected.contains(lower(word)))
|
||||||
return {};
|
return {};
|
||||||
for (auto &never : settings.autoNever)
|
for (auto &never : settings.autoNever)
|
||||||
if (lower(never) == lower(word))
|
if (lower(never) == lower(word))
|
||||||
@@ -172,7 +176,12 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
|
|||||||
if (d.rank(word) || personal(language, word))
|
if (d.rank(word) || personal(language, word))
|
||||||
return {};
|
return {};
|
||||||
std::optional<AutoSuggestion> candidate;
|
std::optional<AutoSuggestion> candidate;
|
||||||
int matches = 0;
|
std::optional<std::wstring> matchedText;
|
||||||
|
auto priority = [&](int layout)
|
||||||
|
{
|
||||||
|
auto language = layoutLanguage(layouts[layout]);
|
||||||
|
return std::find(settings.autoLanguages.begin(), settings.autoLanguages.end(), language) - settings.autoLanguages.begin();
|
||||||
|
};
|
||||||
for (int i = 0; i < (int)layouts.size(); ++i)
|
for (int i = 0; i < (int)layouts.size(); ++i)
|
||||||
{
|
{
|
||||||
if (i == source || !layouts[i].convertible ||
|
if (i == source || !layouts[i].convertible ||
|
||||||
@@ -187,9 +196,11 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
|
|||||||
int rank = personal(dictionary->first, converted) ? 1 : dictionary->second.rank(converted);
|
int rank = personal(dictionary->first, converted) ? 1 : dictionary->second.rank(converted);
|
||||||
if (!rank)
|
if (!rank)
|
||||||
continue;
|
continue;
|
||||||
if (++matches > 1)
|
if (matchedText && *matchedText != converted)
|
||||||
return {};
|
return {};
|
||||||
if (settings.autoMaxRank == 0 || rank <= settings.autoMaxRank)
|
matchedText = converted;
|
||||||
|
if ((settings.autoMaxRank == 0 || rank <= settings.autoMaxRank) &&
|
||||||
|
(!candidate || priority(i) < priority(candidate->target)))
|
||||||
candidate = AutoSuggestion{source, i, prefix + converted + suffix};
|
candidate = AutoSuggestion{source, i, prefix + converted + suffix};
|
||||||
}
|
}
|
||||||
return candidate;
|
return candidate;
|
||||||
|
|||||||
+12
-6
@@ -57,6 +57,7 @@ LRESULT CALLBACK controller(HWND window, UINT message, WPARAM wp, LPARAM lp)
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (wp == 21) showSnippets(*app);
|
if (wp == 21) showSnippets(*app);
|
||||||
|
else if (wp == 22) showLogs(*app);
|
||||||
else showSettings(*app);
|
else showSettings(*app);
|
||||||
}
|
}
|
||||||
catch (const std::exception &e)
|
catch (const std::exception &e)
|
||||||
@@ -69,6 +70,7 @@ LRESULT CALLBACK controller(HWND window, UINT message, WPARAM wp, LPARAM lp)
|
|||||||
{
|
{
|
||||||
if (app->settingsWindow) DestroyWindow(app->settingsWindow);
|
if (app->settingsWindow) DestroyWindow(app->settingsWindow);
|
||||||
if (app->snippetsWindow) DestroyWindow(app->snippetsWindow);
|
if (app->snippetsWindow) DestroyWindow(app->snippetsWindow);
|
||||||
|
if (app->logWindow) DestroyWindow(app->logWindow);
|
||||||
PostQuitMessage(0);
|
PostQuitMessage(0);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -86,6 +88,7 @@ App::~App()
|
|||||||
{
|
{
|
||||||
if (settingsWindow) DestroyWindow(settingsWindow);
|
if (settingsWindow) DestroyWindow(settingsWindow);
|
||||||
if (snippetsWindow) DestroyWindow(snippetsWindow);
|
if (snippetsWindow) DestroyWindow(snippetsWindow);
|
||||||
|
if (logWindow) DestroyWindow(logWindow);
|
||||||
if (window) DestroyWindow(window);
|
if (window) DestroyWindow(window);
|
||||||
current = nullptr;
|
current = nullptr;
|
||||||
}
|
}
|
||||||
@@ -120,9 +123,9 @@ void App::setHotkeyCapture(HWND field)
|
|||||||
if (field) MessageBoxW(settingsWindow, wide(e.what()).c_str(), L"Shortcut capture unavailable", MB_OK | MB_ICONWARNING);
|
if (field) MessageBoxW(settingsWindow, wide(e.what()).c_str(), L"Shortcut capture unavailable", MB_OK | MB_ICONWARNING);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
void showLogs(App &app) { request(app, ConfigLogs); }
|
void App::clearLog(bool previous) { request(*this, ConfigClearLog, Bytes{static_cast<unsigned char>(previous)}); }
|
||||||
|
|
||||||
int runConfigurator(HINSTANCE instance, bool snippets)
|
int runConfigurator(HINSTANCE instance, bool snippets, bool logs)
|
||||||
{
|
{
|
||||||
HANDLE singleton = CreateMutexW(nullptr, TRUE, L"Local\\KSwitcher.Config.Singleton");
|
HANDLE singleton = CreateMutexW(nullptr, TRUE, L"Local\\KSwitcher.Config.Singleton");
|
||||||
if (!singleton) throw std::runtime_error("Cannot create configurator instance lock.");
|
if (!singleton) throw std::runtime_error("Cannot create configurator instance lock.");
|
||||||
@@ -140,7 +143,7 @@ int runConfigurator(HINSTANCE instance, bool snippets)
|
|||||||
throw std::runtime_error("The configurator is starting or belongs to another installation. Try again shortly.");
|
throw std::runtime_error("The configurator is starting or belongs to another installation. Try again shortly.");
|
||||||
DWORD pid{}; GetWindowThreadProcessId(window, &pid);
|
DWORD pid{}; GetWindowThreadProcessId(window, &pid);
|
||||||
AllowSetForegroundWindow(pid);
|
AllowSetForegroundWindow(pid);
|
||||||
PostMessageW(window, WM_APP + 20, snippets ? 21 : 20, 0);
|
PostMessageW(window, WM_APP + 20, logs ? 22 : snippets ? 21 : 20, 0);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
service = FindWindowW(L"KSwitcher.Main", L"KSwitcher");
|
service = FindWindowW(L"KSwitcher.Main", L"KSwitcher");
|
||||||
@@ -169,9 +172,10 @@ int runConfigurator(HINSTANCE instance, bool snippets)
|
|||||||
0, 0, 0, 0, nullptr, nullptr, instance, nullptr);
|
0, 0, 0, 0, nullptr, nullptr, instance, nullptr);
|
||||||
if (!app.window) throw std::runtime_error("Cannot create configurator window.");
|
if (!app.window) throw std::runtime_error("Cannot create configurator window.");
|
||||||
app.settings = decodeSettings(request(app, ConfigRead), app.layouts);
|
app.settings = decodeSettings(request(app, ConfigRead), app.layouts);
|
||||||
if (snippets) showSnippets(app); else showSettings(app);
|
setLoggingEnabled(app.settings.writeLog);
|
||||||
|
if (logs) showLogs(app); else if (snippets) showSnippets(app); else showSettings(app);
|
||||||
MSG message;
|
MSG message;
|
||||||
while ((app.settingsWindow || app.snippetsWindow) && GetMessageW(&message, nullptr, 0, 0) > 0)
|
while ((app.settingsWindow || app.snippetsWindow || app.logWindow) && GetMessageW(&message, nullptr, 0, 0) > 0)
|
||||||
{
|
{
|
||||||
if (app.settingsWindow && !IsWindowEnabled(app.settingsWindow))
|
if (app.settingsWindow && !IsWindowEnabled(app.settingsWindow))
|
||||||
{
|
{
|
||||||
@@ -180,6 +184,7 @@ int runConfigurator(HINSTANCE instance, bool snippets)
|
|||||||
}
|
}
|
||||||
if (app.settingsWindow && IsDialogMessageW(app.settingsWindow, &message)) continue;
|
if (app.settingsWindow && IsDialogMessageW(app.settingsWindow, &message)) continue;
|
||||||
if (app.snippetsWindow && IsDialogMessageW(app.snippetsWindow, &message)) continue;
|
if (app.snippetsWindow && IsDialogMessageW(app.snippetsWindow, &message)) continue;
|
||||||
|
if (app.logWindow && IsDialogMessageW(app.logWindow, &message)) continue;
|
||||||
TranslateMessage(&message); DispatchMessageW(&message);
|
TranslateMessage(&message); DispatchMessageW(&message);
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
@@ -193,8 +198,9 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int)
|
|||||||
{
|
{
|
||||||
int count{}; auto args = CommandLineToArgvW(GetCommandLineW(), &count);
|
int count{}; auto args = CommandLineToArgvW(GetCommandLineW(), &count);
|
||||||
bool snippets = count > 1 && std::wstring(args[1]) == L"--snippets";
|
bool snippets = count > 1 && std::wstring(args[1]) == L"--snippets";
|
||||||
|
bool logs = count > 1 && std::wstring(args[1]) == L"--logs";
|
||||||
LocalFree(args);
|
LocalFree(args);
|
||||||
result = ks::runConfigurator(instance, snippets);
|
result = ks::runConfigurator(instance, snippets, logs);
|
||||||
}
|
}
|
||||||
catch (const std::exception &e)
|
catch (const std::exception &e)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,12 +14,12 @@ bool configurationPeer(HWND window, const wchar_t *filename)
|
|||||||
auto expected = std::filesystem::path(executable()).parent_path() / filename;
|
auto expected = std::filesystem::path(executable()).parent_path() / filename;
|
||||||
return ok && _wcsicmp(path, expected.c_str()) == 0;
|
return ok && _wcsicmp(path, expected.c_str()) == 0;
|
||||||
}
|
}
|
||||||
void launchConfiguration(App &app, bool snippets)
|
void launchConfiguration(App &app, bool snippets, bool logs)
|
||||||
{
|
{
|
||||||
auto path = std::filesystem::path(executable()).parent_path() / L"KSwitcher.Config.exe";
|
auto path = std::filesystem::path(executable()).parent_path() / L"KSwitcher.Config.exe";
|
||||||
if (!std::filesystem::exists(path))
|
if (!std::filesystem::exists(path))
|
||||||
throw std::runtime_error("KSwitcher.Config.exe is missing. Extract the complete application package.");
|
throw std::runtime_error("KSwitcher.Config.exe is missing. Extract the complete application package.");
|
||||||
auto command = L"\"" + path.wstring() + L"\"" + (snippets ? L" --snippets" : L"");
|
auto command = L"\"" + path.wstring() + L"\"" + (logs ? L" --logs" : snippets ? L" --snippets" : L"");
|
||||||
STARTUPINFOW startup{sizeof(startup)};
|
STARTUPINFOW startup{sizeof(startup)};
|
||||||
PROCESS_INFORMATION process{};
|
PROCESS_INFORMATION process{};
|
||||||
if (!CreateProcessW(path.c_str(), command.data(), nullptr, nullptr, FALSE, 0, nullptr,
|
if (!CreateProcessW(path.c_str(), command.data(), nullptr, nullptr, FALSE, 0, nullptr,
|
||||||
@@ -80,6 +80,11 @@ LRESULT configurationRequest(App &app, HWND sender, const COPYDATASTRUCT &messag
|
|||||||
case ConfigLogs:
|
case ConfigLogs:
|
||||||
PostMessageW(app.window, WM_HOTKEY, 22, 0);
|
PostMessageW(app.window, WM_HOTKEY, 22, 0);
|
||||||
break;
|
break;
|
||||||
|
case ConfigClearLog:
|
||||||
|
if (message.cbData != 1 || *static_cast<const unsigned char *>(message.lpData) > 1)
|
||||||
|
throw std::runtime_error("Invalid log selection.");
|
||||||
|
app.clearLog(*static_cast<const unsigned char *>(message.lpData) != 0);
|
||||||
|
break;
|
||||||
default: return FALSE;
|
default: return FALSE;
|
||||||
}
|
}
|
||||||
ok = true;
|
ok = true;
|
||||||
|
|||||||
+2
-2
@@ -5,9 +5,9 @@ namespace ks
|
|||||||
inline constexpr ULONG_PTR ConfigRead = 0x4b530100, ConfigSave = ConfigRead + 1,
|
inline constexpr ULONG_PTR ConfigRead = 0x4b530100, ConfigSave = ConfigRead + 1,
|
||||||
ConfigLayouts = ConfigRead + 2, ConfigClearHistory = ConfigRead + 3,
|
ConfigLayouts = ConfigRead + 2, ConfigClearHistory = ConfigRead + 3,
|
||||||
ConfigCapture = ConfigRead + 4, ConfigLogs = ConfigRead + 5,
|
ConfigCapture = ConfigRead + 4, ConfigLogs = ConfigRead + 5,
|
||||||
ConfigReply = ConfigRead + 6;
|
ConfigReply = ConfigRead + 6, ConfigClearLog = ConfigRead + 7, ConfigEnd = ConfigRead + 8;
|
||||||
inline constexpr size_t ConfigLimit = 8 * 1024 * 1024;
|
inline constexpr size_t ConfigLimit = 8 * 1024 * 1024;
|
||||||
bool configurationPeer(HWND, const wchar_t *filename);
|
bool configurationPeer(HWND, const wchar_t *filename);
|
||||||
void launchConfiguration(App &, bool snippets = false);
|
void launchConfiguration(App &, bool snippets = false, bool logs = false);
|
||||||
LRESULT configurationRequest(App &, HWND sender, const COPYDATASTRUCT &);
|
LRESULT configurationRequest(App &, HWND sender, const COPYDATASTRUCT &);
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-1
@@ -261,7 +261,17 @@ Template parseTemplate(const std::wstring &text)
|
|||||||
size_t cursor = std::wstring::npos;
|
size_t cursor = std::wstring::npos;
|
||||||
for (size_t i = 0; i < text.size();)
|
for (size_t i = 0; i < text.size();)
|
||||||
{
|
{
|
||||||
if (text.compare(i, 10, L"{{cursor}}\0", 10) == 0)
|
if (text.compare(i, 9, L"{{enter}}") == 0)
|
||||||
|
{
|
||||||
|
t.text += L"{enter}";
|
||||||
|
i += 9;
|
||||||
|
}
|
||||||
|
else if (text.compare(i, 7, L"{enter}") == 0)
|
||||||
|
{
|
||||||
|
++t.enters;
|
||||||
|
i += 7;
|
||||||
|
}
|
||||||
|
else if (text.compare(i, 10, L"{{cursor}}\0", 10) == 0)
|
||||||
{
|
{
|
||||||
t.text += L"{cursor}";
|
t.text += L"{cursor}";
|
||||||
i += 10;
|
i += 10;
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ struct Template
|
|||||||
{
|
{
|
||||||
std::wstring text, suffix;
|
std::wstring text, suffix;
|
||||||
int cursors = 0;
|
int cursors = 0;
|
||||||
|
int enters = 0;
|
||||||
};
|
};
|
||||||
Template parseTemplate(const std::wstring &);
|
Template parseTemplate(const std::wstring &);
|
||||||
struct Hotkey
|
struct Hotkey
|
||||||
|
|||||||
+89
-49
@@ -34,6 +34,8 @@ struct WindowLayout
|
|||||||
bool scrolling{}, arranging{}, history{};
|
bool scrolling{}, arranging{}, history{};
|
||||||
int wheelRemainder{}, horizontalWheelRemainder{};
|
int wheelRemainder{}, horizontalWheelRemainder{};
|
||||||
std::vector<std::pair<HWND, RECT>> controls; // Original 96-DPI geometry; never rescale rounded pixels.
|
std::vector<std::pair<HWND, RECT>> controls; // Original 96-DPI geometry; never rescale rounded pixels.
|
||||||
|
HWND stretch{}, bottomLeft{}, bottomRight{};
|
||||||
|
bool autoOptions{};
|
||||||
};
|
};
|
||||||
WindowLayout *windowLayoutState(HWND w)
|
WindowLayout *windowLayoutState(HWND w)
|
||||||
{
|
{
|
||||||
@@ -69,8 +71,9 @@ void arrangeWindow(HWND w)
|
|||||||
layout->y = SetScrollInfo(w, SB_VERT, &vertical, TRUE);
|
layout->y = SetScrollInfo(w, SB_VERT, &vertical, TRUE);
|
||||||
}
|
}
|
||||||
RECT client{}; GetClientRect(w, &client);
|
RECT client{}; GetClientRect(w, &client);
|
||||||
int extraWidth = layout->history ? std::max(0, int(client.right) - scale(w, layout->width)) : 0;
|
bool expandable = layout->history || layout->stretch || layout->autoOptions;
|
||||||
int extraHeight = layout->history ? std::max(0, int(client.bottom) - scale(w, layout->height)) : 0;
|
int extraWidth = expandable ? std::max(0, int(client.right) - scale(w, layout->width)) : 0;
|
||||||
|
int extraHeight = expandable ? std::max(0, int(client.bottom) - scale(w, layout->height)) : 0;
|
||||||
struct Move { HWND child; RECT rect; };
|
struct Move { HWND child; RECT rect; };
|
||||||
std::vector<Move> moves;
|
std::vector<Move> moves;
|
||||||
for (const auto &[child, r] : layout->controls)
|
for (const auto &[child, r] : layout->controls)
|
||||||
@@ -80,6 +83,35 @@ void arrangeWindow(HWND w)
|
|||||||
scale(w, r.top) - layout->y + (id >= 110 && id <= 114 ? extraHeight : 0)};
|
scale(w, r.top) - layout->y + (id >= 110 && id <= 114 ? extraHeight : 0)};
|
||||||
next.right = next.left + scale(w, r.right - r.left) + (id == 101 || id == 102 ? extraWidth : 0);
|
next.right = next.left + scale(w, r.right - r.left) + (id == 101 || id == 102 ? extraWidth : 0);
|
||||||
next.bottom = next.top + scale(w, r.bottom - r.top) + (id == 102 ? extraHeight : 0);
|
next.bottom = next.top + scale(w, r.bottom - r.top) + (id == 102 ? extraHeight : 0);
|
||||||
|
if (child == layout->stretch)
|
||||||
|
{
|
||||||
|
next.right += extraWidth;
|
||||||
|
next.bottom += extraHeight;
|
||||||
|
}
|
||||||
|
if (child == layout->bottomLeft || child == layout->bottomRight)
|
||||||
|
OffsetRect(&next, child == layout->bottomRight ? extraWidth : 0, extraHeight);
|
||||||
|
if (layout->autoOptions)
|
||||||
|
{
|
||||||
|
if (r.top >= 396) OffsetRect(&next, 0, extraHeight);
|
||||||
|
if (r.top == 302 || r.top == 328 || (id >= 401 && id <= 403))
|
||||||
|
{
|
||||||
|
int column = id >= 401 && id <= 403 ? id - 401 : (r.left - 18) / 178;
|
||||||
|
int leftExtra = extraWidth * column / 3;
|
||||||
|
OffsetRect(&next, leftExtra, 0);
|
||||||
|
next.right += extraWidth * (column + 1) / 3 - leftExtra;
|
||||||
|
if (r.top == 328) next.bottom += extraHeight;
|
||||||
|
}
|
||||||
|
else if (r.top == 474 || r.top == 500)
|
||||||
|
{
|
||||||
|
bool rightColumn = r.left >= 290;
|
||||||
|
OffsetRect(&next, rightColumn ? extraWidth / 2 : 0, 0);
|
||||||
|
next.right += rightColumn ? extraWidth - extraWidth / 2 : extraWidth / 2;
|
||||||
|
}
|
||||||
|
else if (id == 406 || id == 410 || id == IDOK || id == IDCANCEL)
|
||||||
|
OffsetRect(&next, extraWidth, 0);
|
||||||
|
else if (r.right - r.left >= 500)
|
||||||
|
next.right += extraWidth;
|
||||||
|
}
|
||||||
RECT current{}; GetWindowRect(child, ¤t);
|
RECT current{}; GetWindowRect(child, ¤t);
|
||||||
MapWindowPoints(nullptr, w, reinterpret_cast<POINT *>(¤t), 2);
|
MapWindowPoints(nullptr, w, reinterpret_cast<POINT *>(¤t), 2);
|
||||||
if (!EqualRect(¤t, &next)) moves.push_back({child, next});
|
if (!EqualRect(¤t, &next)) moves.push_back({child, next});
|
||||||
@@ -1064,6 +1096,7 @@ void showAutoOptions(HWND parent, SettingsView *v)
|
|||||||
{
|
{
|
||||||
HWND w = makeWindow(*v->app, L"KSwitcher.AutoOptions", L"Automatic layout correction", autoOptionsProc,
|
HWND w = makeWindow(*v->app, L"KSwitcher.AutoOptions", L"Automatic layout correction", autoOptionsProc,
|
||||||
560, 680);
|
560, 680);
|
||||||
|
windowLayoutState(w)->autoOptions = true;
|
||||||
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
|
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
|
||||||
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR) new AutoOptionsView{parent, v});
|
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR) new AutoOptionsView{parent, v});
|
||||||
label(w, L"Offline dictionaries (only layouts in your cycle are used)", 18, 16, 525);
|
label(w, L"Offline dictionaries (only layouts in your cycle are used)", 18, 16, 525);
|
||||||
@@ -1104,6 +1137,11 @@ void showAutoOptions(HWND parent, SettingsView *v)
|
|||||||
label(w, L"After editing files, click OK here, then Save in Settings to reload.", 18, 600, 525);
|
label(w, L"After editing files, click OK here, then Save in Settings to reload.", 18, 600, 525);
|
||||||
button(w, L"OK", IDOK, 326, 632, 100);
|
button(w, L"OK", IDOK, 326, 632, 100);
|
||||||
button(w, L"Cancel", IDCANCEL, 440, 632, 100);
|
button(w, L"Cancel", IDCANCEL, 440, 632, 100);
|
||||||
|
RECT initial{};
|
||||||
|
GetWindowRect(w, &initial);
|
||||||
|
initial.right += scale(w, 200);
|
||||||
|
initial.bottom += scale(w, 140);
|
||||||
|
fitToMonitor(w, initial);
|
||||||
EnableWindow(parent, FALSE);
|
EnableWindow(parent, FALSE);
|
||||||
showRequestedWindow(w);
|
showRequestedWindow(w);
|
||||||
SetForegroundWindow(w);
|
SetForegroundWindow(w);
|
||||||
@@ -1410,9 +1448,9 @@ LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (id == 312)
|
if (id == 312 || id == 313)
|
||||||
{
|
{
|
||||||
SendDlgItemMessageW(w, 303, EM_REPLACESEL, TRUE, (LPARAM)L"{cursor}");
|
SendDlgItemMessageW(w, 303, EM_REPLACESEL, TRUE, (LPARAM)(id == 312 ? L"{cursor}" : L"{enter}"));
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (id == IDOK)
|
if (id == IDOK)
|
||||||
@@ -1530,53 +1568,41 @@ void showHistory(App &app, bool visible)
|
|||||||
SetFocus(GetDlgItem(app.historyWindow, 101));
|
SetFocus(GetDlgItem(app.historyWindow, 101));
|
||||||
refreshHistory(app);
|
refreshHistory(app);
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
#ifndef KS_RESIDENT_APP
|
||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
constexpr int LogChoice = 601, LogRows = 602, LogRefresh = 603, LogStatus = 604, LogCopy = 605,
|
constexpr int LogChoice = 601, LogRows = 602, LogRefresh = 603, LogStatus = 604, LogCopy = 605,
|
||||||
LogClear = 606;
|
LogClear = 606;
|
||||||
LRESULT CALLBACK logListProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
|
void fitLogDetails(HWND list)
|
||||||
{
|
{
|
||||||
// Header notifications go to the ListView, not its top-level parent.
|
if (GetPropW(list, L"FittingLogDetails") || Header_GetItemCount(ListView_GetHeader(list)) != 4) return;
|
||||||
if (msg == WM_NOTIFY && ((NMHDR *)lp)->hwndFrom == ListView_GetHeader(w) &&
|
SetPropW(list, L"FittingLogDetails", (HANDLE)1);
|
||||||
((NMHDR *)lp)->code == NM_CUSTOMDRAW)
|
RECT client{};
|
||||||
|
GetClientRect(list, &client);
|
||||||
|
int remaining = client.right;
|
||||||
|
for (int column = 0; column < 3; ++column)
|
||||||
|
remaining -= ListView_GetColumnWidth(list, column);
|
||||||
|
int width = std::max(scale(list, 120), remaining);
|
||||||
|
if (ListView_GetColumnWidth(list, 3) != width)
|
||||||
|
ListView_SetColumnWidth(list, 3, width);
|
||||||
|
RemovePropW(list, L"FittingLogDetails");
|
||||||
|
}
|
||||||
|
LRESULT CALLBACK logListResizeProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
|
||||||
|
{
|
||||||
|
auto result = DefSubclassProc(w, msg, wp, lp);
|
||||||
|
if (msg == WM_SIZE)
|
||||||
|
fitLogDetails(w);
|
||||||
|
if (msg == WM_NOTIFY && lp)
|
||||||
{
|
{
|
||||||
auto *draw = (NMCUSTOMDRAW *)lp;
|
auto *header = reinterpret_cast<NMHEADERW *>(lp);
|
||||||
if (draw->dwDrawStage == CDDS_PREPAINT)
|
if (header->hdr.hwndFrom == ListView_GetHeader(w) &&
|
||||||
{
|
(header->hdr.code == HDN_ITEMCHANGEDW || header->hdr.code == HDN_ITEMCHANGEDA) &&
|
||||||
FillRect(draw->hdc, &draw->rc, GetSysColorBrush(COLOR_BTNFACE));
|
header->iItem >= 0 && header->iItem < 3)
|
||||||
return CDRF_NOTIFYITEMDRAW;
|
fitLogDetails(w);
|
||||||
}
|
|
||||||
if (draw->dwDrawStage == CDDS_ITEMPREPAINT)
|
|
||||||
{
|
|
||||||
int saved = SaveDC(draw->hdc);
|
|
||||||
FillRect(draw->hdc, &draw->rc, GetSysColorBrush(COLOR_BTNFACE));
|
|
||||||
RECT edge = draw->rc;
|
|
||||||
edge.top = edge.bottom - 1;
|
|
||||||
FillRect(draw->hdc, &edge, GetSysColorBrush(COLOR_3DSHADOW));
|
|
||||||
edge = draw->rc;
|
|
||||||
edge.left = edge.right - 1;
|
|
||||||
FillRect(draw->hdc, &edge, GetSysColorBrush(COLOR_3DSHADOW));
|
|
||||||
wchar_t title[128]{};
|
|
||||||
HDITEMW item{};
|
|
||||||
item.mask = HDI_TEXT;
|
|
||||||
item.pszText = title;
|
|
||||||
item.cchTextMax = 128;
|
|
||||||
Header_GetItem(draw->hdr.hwndFrom, (int)draw->dwItemSpec, &item);
|
|
||||||
auto headerFont = (HFONT)SendMessageW(draw->hdr.hwndFrom, WM_GETFONT, 0, 0);
|
|
||||||
if (headerFont)
|
|
||||||
SelectObject(draw->hdc, headerFont);
|
|
||||||
SetBkMode(draw->hdc, TRANSPARENT);
|
|
||||||
SetTextColor(draw->hdc, GetSysColor(COLOR_BTNTEXT));
|
|
||||||
RECT text = draw->rc;
|
|
||||||
InflateRect(&text, -scale(w, 6), 0);
|
|
||||||
DrawTextW(draw->hdc, title, -1, &text, DT_SINGLELINE | DT_VCENTER | DT_END_ELLIPSIS | DT_NOPREFIX);
|
|
||||||
RestoreDC(draw->hdc, saved);
|
|
||||||
return CDRF_SKIPDEFAULT;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (msg == WM_NCDESTROY)
|
if (msg == WM_NCDESTROY) RemoveWindowSubclass(w, logListResizeProc, 3);
|
||||||
RemoveWindowSubclass(w, logListProc, 1);
|
return result;
|
||||||
return DefSubclassProc(w, msg, wp, lp);
|
|
||||||
}
|
}
|
||||||
void copyLogRows(HWND w, App &app)
|
void copyLogRows(HWND w, App &app)
|
||||||
{
|
{
|
||||||
@@ -1596,7 +1622,11 @@ void copyLogRows(HWND w, App &app)
|
|||||||
copied += L"\r\n";
|
copied += L"\r\n";
|
||||||
}
|
}
|
||||||
if (!copied.empty())
|
if (!copied.empty())
|
||||||
app.ownSequence = writeClipboard(app.window, textClip(copied));
|
{
|
||||||
|
auto clip = textClip(copied);
|
||||||
|
clip.formats.push_back({0, L"ExcludeClipboardContentFromMonitorProcessing", {1, 0, 0, 0}});
|
||||||
|
app.ownSequence = writeClipboard(app.window, clip);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
void refreshLogView(HWND w, App &app)
|
void refreshLogView(HWND w, App &app)
|
||||||
{
|
{
|
||||||
@@ -1664,6 +1694,7 @@ void refreshLogView(HWND w, App &app)
|
|||||||
InvalidateRect(list, nullptr, TRUE);
|
InvalidateRect(list, nullptr, TRUE);
|
||||||
if (row)
|
if (row)
|
||||||
ListView_EnsureVisible(list, row - 1, FALSE);
|
ListView_EnsureVisible(list, row - 1, FALSE);
|
||||||
|
fitLogDetails(list);
|
||||||
value(w, LogStatus, app.settings.writeLog ? L"Logging enabled: failed operations only."
|
value(w, LogStatus, app.settings.writeLog ? L"Logging enabled: failed operations only."
|
||||||
: L"Logging disabled. Showing previously saved entries.");
|
: L"Logging disabled. Showing previously saved entries.");
|
||||||
}
|
}
|
||||||
@@ -1696,7 +1727,7 @@ LRESULT CALLBACK logProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
clearDiagnosticLog(SendDlgItemMessageW(w, LogChoice, CB_GETCURSEL, 0, 0) == 1);
|
app->clearLog(SendDlgItemMessageW(w, LogChoice, CB_GETCURSEL, 0, 0) == 1);
|
||||||
refreshLogView(w, *app);
|
refreshLogView(w, *app);
|
||||||
}
|
}
|
||||||
catch (const std::exception &e) { error(w, e); }
|
catch (const std::exception &e) { error(w, e); }
|
||||||
@@ -1758,7 +1789,7 @@ void showLogs(App &app)
|
|||||||
auto list = control(w, WC_LISTVIEWW, L"", WS_TABSTOP | WS_BORDER | LVS_REPORT | LVS_SHOWSELALWAYS,
|
auto list = control(w, WC_LISTVIEWW, L"", WS_TABSTOP | WS_BORDER | LVS_REPORT | LVS_SHOWSELALWAYS,
|
||||||
LogRows, 16, 82, 868, 454);
|
LogRows, 16, 82, 868, 454);
|
||||||
ListView_SetExtendedListViewStyle(list, LVS_EX_FULLROWSELECT | LVS_EX_GRIDLINES | LVS_EX_DOUBLEBUFFER);
|
ListView_SetExtendedListViewStyle(list, LVS_EX_FULLROWSELECT | LVS_EX_GRIDLINES | LVS_EX_DOUBLEBUFFER);
|
||||||
SetWindowSubclass(list, logListProc, 1, 0);
|
SetWindowSubclass(list, logListResizeProc, 3, 0);
|
||||||
const wchar_t *titles[] = {L"Time", L"Attempt", L"Event", L"Details"};
|
const wchar_t *titles[] = {L"Time", L"Attempt", L"Event", L"Details"};
|
||||||
const int widths[] = {155, 65, 195, 900};
|
const int widths[] = {155, 65, 195, 900};
|
||||||
for (int i = 0; i < 4; ++i)
|
for (int i = 0; i < 4; ++i)
|
||||||
@@ -1769,12 +1800,20 @@ void showLogs(App &app)
|
|||||||
column.cx = scale(w, widths[i]);
|
column.cx = scale(w, widths[i]);
|
||||||
ListView_InsertColumn(list, i, &column);
|
ListView_InsertColumn(list, i, &column);
|
||||||
}
|
}
|
||||||
label(w, L"Select rows to copy. Ctrl+A selects all; Ctrl+C copies selected rows.", 16, 551, 720);
|
auto hint = control(w, L"STATIC", L"Select rows to copy. Ctrl+A selects all; Ctrl+C copies selected rows.",
|
||||||
|
0, 0, 16, 551, 720, 22);
|
||||||
button(w, L"Close", IDCANCEL, 774, 548, 110);
|
button(w, L"Close", IDCANCEL, 774, 548, 110);
|
||||||
|
auto *layout = windowLayoutState(w);
|
||||||
|
layout->stretch = list;
|
||||||
|
layout->bottomLeft = hint;
|
||||||
|
layout->bottomRight = GetDlgItem(w, IDCANCEL);
|
||||||
|
arrangeWindow(w);
|
||||||
refreshLogView(w, app);
|
refreshLogView(w, app);
|
||||||
showRequestedWindow(w);
|
showRequestedWindow(w);
|
||||||
activateWindow(w);
|
activateWindow(w);
|
||||||
}
|
}
|
||||||
|
#else
|
||||||
|
void showLogs(App &app) { launchConfiguration(app, false, true); }
|
||||||
#endif
|
#endif
|
||||||
#ifndef KS_RESIDENT_APP
|
#ifndef KS_RESIDENT_APP
|
||||||
void showSettings(App &app, bool visible)
|
void showSettings(App &app, bool visible)
|
||||||
@@ -1861,8 +1900,9 @@ void showSnippets(App &app)
|
|||||||
edit(w, 302, 236, 44, 488);
|
edit(w, 302, 236, 44, 488);
|
||||||
label(w, L"Replacement text", 236, 82, 480);
|
label(w, L"Replacement text", 236, 82, 480);
|
||||||
edit(w, 303, 236, 110, 488, 270, ES_MULTILINE | ES_WANTRETURN | ES_AUTOVSCROLL | WS_VSCROLL);
|
edit(w, 303, 236, 110, 488, 270, ES_MULTILINE | ES_WANTRETURN | ES_AUTOVSCROLL | WS_VSCROLL);
|
||||||
label(w, L"Use {cursor} once; {{cursor}} inserts the literal marker.", 236, 392, 488);
|
control(w, L"STATIC", L"{cursor}: caret; {enter}: press Enter after insertion.\nUse {{cursor}} / {{enter}} for literal markers.", 0, 0, 236, 392, 488, 36);
|
||||||
button(w, L"Insert cursor marker", 312, 236, 430, 190);
|
button(w, L"Insert cursor marker", 312, 236, 430, 190);
|
||||||
|
button(w, L"Insert Enter marker", 313, 436, 430, 190);
|
||||||
button(w, L"Save", IDOK, 510, 478, 100);
|
button(w, L"Save", IDOK, 510, 478, 100);
|
||||||
button(w, L"Cancel", IDCANCEL, 624, 478, 100);
|
button(w, L"Cancel", IDCANCEL, 624, 478, 100);
|
||||||
fillSnippets(w, (SnippetView *)GetWindowLongPtrW(w, GWLP_USERDATA));
|
fillSnippets(w, (SnippetView *)GetWindowLongPtrW(w, GWLP_USERDATA));
|
||||||
|
|||||||
+5
-4
@@ -26,7 +26,7 @@ std::wstring quoteArgument(const std::wstring &value)
|
|||||||
return out + L'"';
|
return out + L'"';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
UpdateResult runUpdate(bool install, const std::wstring &expectedVersion)
|
UpdateResult runUpdate(bool install, const std::wstring &expectedVersion, bool showProgress)
|
||||||
{
|
{
|
||||||
UpdateResult result;
|
UpdateResult result;
|
||||||
GUID guid{};
|
GUID guid{};
|
||||||
@@ -56,6 +56,7 @@ UpdateResult runUpdate(bool install, const std::wstring &expectedVersion)
|
|||||||
L" -InstallDirectory " + quoteArgument(std::filesystem::path(executable()).parent_path().wstring()) +
|
L" -InstallDirectory " + quoteArgument(std::filesystem::path(executable()).parent_path().wstring()) +
|
||||||
L" -ParentProcessId " + std::to_wstring(GetCurrentProcessId()) + L" -ReadyEvent " + quoteArgument(readyName);
|
L" -ParentProcessId " + std::to_wstring(GetCurrentProcessId()) + L" -ReadyEvent " + quoteArgument(readyName);
|
||||||
if (install) command += L" -ExpectedVersion " + quoteArgument(expectedVersion);
|
if (install) command += L" -ExpectedVersion " + quoteArgument(expectedVersion);
|
||||||
|
if (showProgress) command += L" -ShowProgress";
|
||||||
STARTUPINFOW startup{sizeof(startup)};
|
STARTUPINFOW startup{sizeof(startup)};
|
||||||
PROCESS_INFORMATION process{};
|
PROCESS_INFORMATION process{};
|
||||||
if (!CreateProcessW(powershell.c_str(), command.data(), nullptr, nullptr, FALSE, CREATE_NO_WINDOW,
|
if (!CreateProcessW(powershell.c_str(), command.data(), nullptr, nullptr, FALSE, CREATE_NO_WINDOW,
|
||||||
@@ -94,10 +95,10 @@ UpdateResult runUpdate(bool install, const std::wstring &expectedVersion)
|
|||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
void confirmUpdatedStartup(const std::wstring &eventName)
|
bool confirmUpdatedStartup(const std::wstring &eventName)
|
||||||
{
|
{
|
||||||
if (!eventName.starts_with(L"Local\\KSwitcher.Update.Started.")) return;
|
if (!eventName.starts_with(L"Local\\KSwitcher.Update.Started.")) return false;
|
||||||
Handle event{OpenEventW(EVENT_MODIFY_STATE, FALSE, eventName.c_str())};
|
Handle event{OpenEventW(EVENT_MODIFY_STATE, FALSE, eventName.c_str())};
|
||||||
if (event.value) SetEvent(event.value);
|
return event.value && SetEvent(event.value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -2,7 +2,7 @@
|
|||||||
#include "platform.h"
|
#include "platform.h"
|
||||||
namespace ks
|
namespace ks
|
||||||
{
|
{
|
||||||
inline constexpr wchar_t AppVersion[] = L"0.6.10";
|
inline constexpr wchar_t AppVersion[] = L"0.6.14";
|
||||||
inline constexpr UINT UpdateMessage = WM_APP + 7;
|
inline constexpr UINT UpdateMessage = WM_APP + 7;
|
||||||
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
|
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
|
||||||
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
|
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
|
||||||
@@ -13,6 +13,6 @@ struct UpdateResult
|
|||||||
std::wstring state, version, error;
|
std::wstring state, version, error;
|
||||||
bool interactive{}, install{};
|
bool interactive{}, install{};
|
||||||
};
|
};
|
||||||
UpdateResult runUpdate(bool install, const std::wstring &expectedVersion);
|
UpdateResult runUpdate(bool install, const std::wstring &expectedVersion, bool showProgress = false);
|
||||||
void confirmUpdatedStartup(const std::wstring &eventName);
|
bool confirmUpdatedStartup(const std::wstring &eventName);
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-2
@@ -1,4 +1,4 @@
|
|||||||
param([string]$BuildDirectory = (Join-Path $PSScriptRoot '../build/staged'))
|
param([string]$BuildDirectory = (Join-Path $PSScriptRoot '../build/staged'), [switch]$LogsOnly)
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
if (Get-Process KSwitcher,'KSwitcher.Config' -ErrorAction SilentlyContinue) {
|
if (Get-Process KSwitcher,'KSwitcher.Config' -ErrorAction SilentlyContinue) {
|
||||||
throw 'Close KSwitcher and its configurator before this isolated process test.'
|
throw 'Close KSwitcher and its configurator before this isolated process test.'
|
||||||
@@ -38,7 +38,7 @@ $checks=0
|
|||||||
function Assert($ok,$label) { if (!$ok) { throw $label }; $script:checks++ }
|
function Assert($ok,$label) { if (!$ok) { throw $label }; $script:checks++ }
|
||||||
function Window($class) {
|
function Window($class) {
|
||||||
for($i=0;$i -lt 100;$i++) {
|
for($i=0;$i -lt 100;$i++) {
|
||||||
$title=switch($class){'KSwitcher.Main'{'KSwitcher'};'KSwitcher.Settings'{'KSwitcher settings'};'KSwitcher.Snippets'{'Text snippets'}}
|
$title=switch($class){'KSwitcher.Main'{'KSwitcher'};'KSwitcher.Settings'{'KSwitcher settings'};'KSwitcher.Snippets'{'Text snippets'};'KSwitcher.Logs'{'KSwitcher log'}}
|
||||||
$window=[ConfigurationTestUi]::FindWindow($class,$title)
|
$window=[ConfigurationTestUi]::FindWindow($class,$title)
|
||||||
if($window -ne [IntPtr]::Zero){return $window}
|
if($window -ne [IntPtr]::Zero){return $window}
|
||||||
Start-Sleep -Milliseconds 50
|
Start-Sleep -Milliseconds 50
|
||||||
@@ -56,6 +56,24 @@ function Launch($file,$argument) {
|
|||||||
function Click($window,$id) {
|
function Click($window,$id) {
|
||||||
$null=[ConfigurationTestUi]::PostMessage($window,0x111,[IntPtr]$id,[IntPtr]::Zero)
|
$null=[ConfigurationTestUi]::PostMessage($window,0x111,[IntPtr]$id,[IntPtr]::Zero)
|
||||||
}
|
}
|
||||||
|
function TestLogs {
|
||||||
|
'test-marker' | Set-Content -LiteralPath (Join-Path $root 'data/kswitcher.log')
|
||||||
|
$config=Launch 'KSwitcher.Config.exe' '--logs'
|
||||||
|
$logs=Window 'KSwitcher.Logs'
|
||||||
|
[uint32]$logOwner=0
|
||||||
|
$null=[ConfigurationTestUi]::GetWindowThreadProcessId($logs,[ref]$logOwner)
|
||||||
|
Assert ($logOwner -eq $config.Id -and $logOwner -ne $resident.Id) 'logs run outside the resident input-hook process'
|
||||||
|
$second=Launch 'KSwitcher.Config.exe' '--logs'
|
||||||
|
Assert ($second.WaitForExit(6000)) 'opening logs again reuses the configurator'
|
||||||
|
Assert ((Window 'KSwitcher.Logs') -eq $logs) 'log window is reused'
|
||||||
|
Click $logs 603
|
||||||
|
Assert (!$resident.HasExited) 'refreshing logs leaves resident running'
|
||||||
|
Click $logs 606
|
||||||
|
for($i=0;$i -lt 50 -and (Test-Path -LiteralPath (Join-Path $root 'data/kswitcher.log'));$i++) { Start-Sleep -Milliseconds 50 }
|
||||||
|
Assert (!(Test-Path -LiteralPath (Join-Path $root 'data/kswitcher.log'))) 'clear log is committed through resident process'
|
||||||
|
Click $logs 2
|
||||||
|
Assert ($config.WaitForExit(6000)) 'closing standalone logs exits configurator'
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
New-Item -ItemType Directory -Path (Join-Path $root 'data') -Force | Out-Null
|
New-Item -ItemType Directory -Path (Join-Path $root 'data') -Force | Out-Null
|
||||||
foreach($file in @('KSwitcher.exe','KSwitcher.Config.exe')) {
|
foreach($file in @('KSwitcher.exe','KSwitcher.Config.exe')) {
|
||||||
@@ -67,6 +85,13 @@ try {
|
|||||||
ConvertTo-Json | Set-Content -LiteralPath $settingsPath -Encoding utf8
|
ConvertTo-Json | Set-Content -LiteralPath $settingsPath -Encoding utf8
|
||||||
$resident=Launch 'KSwitcher.exe' ''
|
$resident=Launch 'KSwitcher.exe' ''
|
||||||
$main=Window 'KSwitcher.Main'
|
$main=Window 'KSwitcher.Main'
|
||||||
|
if ($LogsOnly) {
|
||||||
|
TestLogs
|
||||||
|
$null=[ConfigurationTestUi]::PostMessage($main,0x10,[IntPtr]::Zero,[IntPtr]::Zero)
|
||||||
|
Assert ($resident.WaitForExit(10000)) 'fixture resident exits normally'
|
||||||
|
"Passed $checks log process checks"
|
||||||
|
return
|
||||||
|
}
|
||||||
$config=Launch 'KSwitcher.Config.exe' ''
|
$config=Launch 'KSwitcher.Config.exe' ''
|
||||||
$settings=Window 'KSwitcher.Settings'
|
$settings=Window 'KSwitcher.Settings'
|
||||||
[uint32]$ownerId=0
|
[uint32]$ownerId=0
|
||||||
@@ -110,6 +135,7 @@ try {
|
|||||||
$config.Refresh(); Assert (!$config.HasExited) 'closing settings keeps open snippets alive'
|
$config.Refresh(); Assert (!$config.HasExited) 'closing settings keeps open snippets alive'
|
||||||
Click $snippets 2
|
Click $snippets 2
|
||||||
Assert ($config.WaitForExit(6000)) 'closing last window exits configurator'
|
Assert ($config.WaitForExit(6000)) 'closing last window exits configurator'
|
||||||
|
TestLogs
|
||||||
$config=Launch 'KSwitcher.Config.exe' ''
|
$config=Launch 'KSwitcher.Config.exe' ''
|
||||||
$null=Window 'KSwitcher.Settings'
|
$null=Window 'KSwitcher.Settings'
|
||||||
$null=[ConfigurationTestUi]::PostMessage($main,0x10,[IntPtr]::Zero,[IntPtr]::Zero)
|
$null=[ConfigurationTestUi]::PostMessage($main,0x10,[IntPtr]::Zero,[IntPtr]::Zero)
|
||||||
|
|||||||
@@ -55,6 +55,14 @@ int main(int argc, char **argv) {
|
|||||||
// Message-only window: no hooks, synthetic input, clipboard changes or visible editor.
|
// Message-only window: no hooks, synthetic input, clipboard changes or visible editor.
|
||||||
app.window=CreateWindowExW(0,L"STATIC",L"Input tests",0,0,0,0,0,HWND_MESSAGE,nullptr,nullptr,nullptr);
|
app.window=CreateWindowExW(0,L"STATIC",L"Input tests",0,0,0,0,0,HWND_MESSAGE,nullptr,nullptr,nullptr);
|
||||||
require(app.window!=nullptr,"message-only test window");
|
require(app.window!=nullptr,"message-only test window");
|
||||||
|
require(!ks::confirmUpdatedStartup(L""),"normal startup does not report update success");
|
||||||
|
require(!ks::confirmUpdatedStartup(L"Local.Other.Event"),"unrelated event cannot report update success");
|
||||||
|
auto startedName=L"Local\\KSwitcher.Update.Started.Test."+std::to_wstring(GetCurrentProcessId());
|
||||||
|
require(!ks::confirmUpdatedStartup(startedName),"missing updater handshake does not report success");
|
||||||
|
auto started=CreateEventW(nullptr,TRUE,FALSE,startedName.c_str());
|
||||||
|
require(started&&ks::confirmUpdatedStartup(startedName)&&WaitForSingleObject(started,0)==WAIT_OBJECT_0,
|
||||||
|
"successful startup signals the updater before reporting completion");
|
||||||
|
CloseHandle(started);
|
||||||
require(ks::UpdateCheckIntervalMs==21600000,"automatic update interval is six hours");
|
require(ks::UpdateCheckIntervalMs==21600000,"automatic update interval is six hours");
|
||||||
require(ks::InputRegressionAccess::updateTimer(app),"update timer rearms and avoids overlapping checks");
|
require(ks::InputRegressionAccess::updateTimer(app),"update timer rearms and avoids overlapping checks");
|
||||||
require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.0")==L"99.0.0","background update records announced version");
|
require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.0")==L"99.0.0","background update records announced version");
|
||||||
@@ -590,6 +598,35 @@ int main(int argc, char **argv) {
|
|||||||
auto autoOptions=FindWindowW(L"KSwitcher.AutoOptions",nullptr);
|
auto autoOptions=FindWindowW(L"KSwitcher.AutoOptions",nullptr);
|
||||||
require(autoOptions!=nullptr,"auto-correction options open");
|
require(autoOptions!=nullptr,"auto-correction options open");
|
||||||
ShowWindow(autoOptions,SW_HIDE);
|
ShowWindow(autoOptions,SW_HIDE);
|
||||||
|
{
|
||||||
|
require((GetWindowLongPtrW(autoOptions,GWL_STYLE)&WS_THICKFRAME)!=0,
|
||||||
|
"auto-correction dialog supports resizing");
|
||||||
|
RECT original{}, before[3]{}, after[3]{}, buttonBefore{}, buttonAfter{};
|
||||||
|
GetWindowRect(autoOptions,&original);
|
||||||
|
// Use a client size above the scrolling baseline at every DPI.
|
||||||
|
UINT dpi=GetDpiForWindow(autoOptions);
|
||||||
|
SetWindowPos(autoOptions,nullptr,0,0,MulDiv(800,dpi,96),MulDiv(900,dpi,96),
|
||||||
|
SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
|
||||||
|
RECT baseline{}; GetWindowRect(autoOptions,&baseline);
|
||||||
|
for(int i=0;i<3;++i) GetWindowRect(GetDlgItem(autoOptions,411+i),&before[i]);
|
||||||
|
GetWindowRect(GetDlgItem(autoOptions,IDOK),&buttonBefore);
|
||||||
|
SetWindowPos(autoOptions,nullptr,0,0,baseline.right-baseline.left+240,
|
||||||
|
baseline.bottom-baseline.top+120,SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
|
||||||
|
for(int i=0;i<3;++i) {
|
||||||
|
GetWindowRect(GetDlgItem(autoOptions,411+i),&after[i]);
|
||||||
|
require(after[i].right-after[i].left==before[i].right-before[i].left+80 &&
|
||||||
|
after[i].bottom-after[i].top==before[i].bottom-before[i].top+120,
|
||||||
|
"dictionary columns share extra width and receive extra height");
|
||||||
|
}
|
||||||
|
GetWindowRect(GetDlgItem(autoOptions,IDOK),&buttonAfter);
|
||||||
|
require(buttonAfter.left-buttonBefore.left==240 && buttonAfter.top-buttonBefore.top==120,
|
||||||
|
"auto-correction buttons follow bottom-right corner");
|
||||||
|
require(after[0].right<after[1].left && after[1].right<after[2].left &&
|
||||||
|
after[2].bottom<buttonAfter.top,
|
||||||
|
"resized dictionary fields and footer do not overlap");
|
||||||
|
SetWindowPos(autoOptions,nullptr,0,0,original.right-original.left,original.bottom-original.top,
|
||||||
|
SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
|
||||||
|
}
|
||||||
for(int id:{411,412,413}) {
|
for(int id:{411,412,413}) {
|
||||||
RECT control{},client{};
|
RECT control{},client{};
|
||||||
GetWindowRect(GetDlgItem(autoOptions,id),&control);
|
GetWindowRect(GetDlgItem(autoOptions,id),&control);
|
||||||
|
|||||||
+115
-2
@@ -1,4 +1,5 @@
|
|||||||
#include "app.h"
|
#include "app.h"
|
||||||
|
#include <algorithm>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <commctrl.h>
|
#include <commctrl.h>
|
||||||
@@ -20,6 +21,12 @@ ks::App *app{};
|
|||||||
HWND host{}, field{};
|
HWND host{}, field{};
|
||||||
ks::Clip saved;
|
ks::Clip saved;
|
||||||
bool captured = false, failed = false;
|
bool captured = false, failed = false;
|
||||||
|
int enterPresses = 0;
|
||||||
|
LRESULT CALLBACK observeEnter(HWND w, UINT message, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
|
||||||
|
{
|
||||||
|
if (message == WM_KEYDOWN && wp == VK_RETURN) ++enterPresses;
|
||||||
|
return DefSubclassProc(w, message, wp, lp);
|
||||||
|
}
|
||||||
int stage = 0, ru = -1, en = -1;
|
int stage = 0, ru = -1, en = -1;
|
||||||
size_t punctuationCase = 0;
|
size_t punctuationCase = 0;
|
||||||
bool typeDuringCorrection = false, typedDuringCorrection = false;
|
bool typeDuringCorrection = false, typedDuringCorrection = false;
|
||||||
@@ -280,7 +287,73 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
break;
|
break;
|
||||||
case 17:
|
case 17:
|
||||||
require(text() == punctuationCases.back().first, "undo preserves custom punctuation");
|
require(text() == punctuationCases.back().first, "undo preserves custom punctuation");
|
||||||
[[fallthrough]];
|
stage = 20;
|
||||||
|
break;
|
||||||
|
case 30:
|
||||||
|
case 32:
|
||||||
|
case 34:
|
||||||
|
if (!captured)
|
||||||
|
{
|
||||||
|
saved = ks::readClipboard(app->window, 64 * 1024 * 1024);
|
||||||
|
captured = true;
|
||||||
|
}
|
||||||
|
app->ownSequence = ks::writeClipboard(app->window, ks::textClip(sentinel));
|
||||||
|
app->settings.autoCorrect = stage == 35;
|
||||||
|
app->settings.autoLanguages = {L"en", L"ru"};
|
||||||
|
app->autoPredictor.configure(L"dictionaries", app->settings.autoLanguages);
|
||||||
|
app->settings.snippets = {{L";r", stage == 31 ? L"rider .{cursor}" : L"rider .{cursor}{enter}"}};
|
||||||
|
enterPresses = 0;
|
||||||
|
ActivateKeyboardLayout(app->layouts[en].handle, 0);
|
||||||
|
prepare(L"prefix ", false);
|
||||||
|
{
|
||||||
|
std::vector<INPUT> inputs;
|
||||||
|
const std::vector<WORD> keys = stage == 35
|
||||||
|
? std::vector<WORD>{VK_OEM_1, 'R', 'G', 'H', 'B', 'D', 'T', 'N', VK_SPACE}
|
||||||
|
: std::vector<WORD>{VK_OEM_1, 'R', VK_SPACE, 'X'};
|
||||||
|
for (WORD key : keys)
|
||||||
|
for (DWORD flags : {DWORD(0), DWORD(KEYEVENTF_KEYUP)})
|
||||||
|
{
|
||||||
|
auto input = ks::keyboardInput(key, flags);
|
||||||
|
input.ki.dwExtraInfo = 123;
|
||||||
|
inputs.push_back(input);
|
||||||
|
}
|
||||||
|
require(SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)) == inputs.size(),
|
||||||
|
"snippet trigger and following text submitted without a pause");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 31:
|
||||||
|
require(text() == L"prefix rider . x", "snippet expands through keyboard hook and preserves following typing");
|
||||||
|
require(ks::readClipboard(app->window).text() == sentinel, "hook snippet restores clipboard");
|
||||||
|
require(enterPresses == 0, "snippet without Enter marker sends no Enter");
|
||||||
|
stage = 32;
|
||||||
|
break;
|
||||||
|
case 33:
|
||||||
|
require(text() == L"prefix rider .\r\n x", "Enter action precedes buffered typing");
|
||||||
|
require(enterPresses == 1, "Enter marker sends an actual Enter key press");
|
||||||
|
require(ks::readClipboard(app->window).text() == sentinel, "Enter snippet restores clipboard");
|
||||||
|
stage = 34;
|
||||||
|
break;
|
||||||
|
case 35:
|
||||||
|
require(text() == L"prefix rider .\r\nghbdtn ", "snippet replay does not trigger automatic correction");
|
||||||
|
require(app->settings.autoCorrect, "snippet preserves global auto-correction setting");
|
||||||
|
require(enterPresses == 1, "snippet punctuation and Enter survive with auto-correction enabled");
|
||||||
|
{
|
||||||
|
std::vector<INPUT> inputs;
|
||||||
|
for (WORD key : std::wstring(L"GHBDTN "))
|
||||||
|
for (DWORD flags : {DWORD(0), DWORD(KEYEVENTF_KEYUP)})
|
||||||
|
{
|
||||||
|
auto input = ks::keyboardInput(key == L' ' ? VK_SPACE : key, flags);
|
||||||
|
input.ki.dwExtraInfo = 123;
|
||||||
|
inputs.push_back(input);
|
||||||
|
}
|
||||||
|
require(SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)) == inputs.size(),
|
||||||
|
"normal typing submitted after snippet completes");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 36:
|
||||||
|
require(text() == L"prefix rider .\r\nghbdtn привет ", "automatic correction resumes for subsequent normal typing");
|
||||||
|
finish();
|
||||||
|
break;
|
||||||
case 20:
|
case 20:
|
||||||
if (!captured)
|
if (!captured)
|
||||||
{
|
{
|
||||||
@@ -315,7 +388,44 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
case 19:
|
case 19:
|
||||||
require(typedDuringCorrection && text() == L"OLDghbdtn fF",
|
require(typedDuringCorrection && text() == L"OLDghbdtn fF",
|
||||||
"rejected correction collapses selection before replaying typing in original layout");
|
"rejected correction collapses selection before replaying typing in original layout");
|
||||||
finish();
|
ActivateKeyboardLayout(app->layouts[en].handle, 0);
|
||||||
|
prepare(L"", false);
|
||||||
|
app->autoPredictor.configure(L"dictionaries", app->settings.autoLanguages);
|
||||||
|
{
|
||||||
|
std::vector<INPUT> inputs;
|
||||||
|
for (WORD key : std::wstring(L"GHBDTN F"))
|
||||||
|
for (DWORD flags : {DWORD(0), DWORD(KEYEVENTF_KEYUP)})
|
||||||
|
{
|
||||||
|
auto input = ks::keyboardInput(key == L' ' ? VK_SPACE : key, flags);
|
||||||
|
input.ki.dwExtraInfo = 123;
|
||||||
|
inputs.push_back(input);
|
||||||
|
}
|
||||||
|
require(SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)) == inputs.size(),
|
||||||
|
"continuous typing submitted in a single batch without a pause after Space");
|
||||||
|
}
|
||||||
|
stage = 21;
|
||||||
|
break;
|
||||||
|
case 21:
|
||||||
|
{
|
||||||
|
require(text() == L"привет а", "automatic correction preserves immediate next-word typing without a pause");
|
||||||
|
auto uk = std::find_if(app->layouts.begin(), app->layouts.end(), [](const auto &layout) {
|
||||||
|
return ks::layoutLanguage(layout) == L"uk";
|
||||||
|
});
|
||||||
|
require(uk != app->layouts.end(), "Ukrainian layout available for punctuation-key regression");
|
||||||
|
app->settings.autoLanguages = {L"en", L"uk"};
|
||||||
|
app->settings.cycle = {app->layouts[en].id, uk->id};
|
||||||
|
app->settings.autoWords[L"uk"] = {L"робить"};
|
||||||
|
app->autoCorrector = ks::AutoCorrector{};
|
||||||
|
ActivateKeyboardLayout(app->layouts[en].handle, 0);
|
||||||
|
prepare(L"hj,bnm ", false);
|
||||||
|
record(L"hj,bnm ");
|
||||||
|
app->transform(ks::AutoCorrect);
|
||||||
|
stage = 22;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 22:
|
||||||
|
require(text() == L"робить ", "automatic correction selects and converts the whole word across an internal comma");
|
||||||
|
stage = 30;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -439,8 +549,11 @@ int main(int argc, char **argv)
|
|||||||
CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER | ES_MULTILINE | ES_WANTRETURN,
|
CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER | ES_MULTILINE | ES_WANTRETURN,
|
||||||
20, 20, 590, 230, host, nullptr, cls.hInstance, nullptr);
|
20, 20, 590, 230, host, nullptr, cls.hInstance, nullptr);
|
||||||
deadline = GetTickCount64() + 30000;
|
deadline = GetTickCount64() + 30000;
|
||||||
|
SetWindowSubclass(field, observeEnter, 1, 0);
|
||||||
if (argc > 1 && std::string(argv[1]) == "--typing-only")
|
if (argc > 1 && std::string(argv[1]) == "--typing-only")
|
||||||
stage = 20;
|
stage = 20;
|
||||||
|
if (argc > 1 && std::string(argv[1]) == "--snippets-only")
|
||||||
|
stage = 30;
|
||||||
SetTimer(host, 1, 100, nullptr);
|
SetTimer(host, 1, 100, nullptr);
|
||||||
app->execute();
|
app->execute();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -247,6 +247,14 @@ int main(int argc, char **argv)
|
|||||||
options.autoLanguages = {L"en", L"uk", L"ru"};
|
options.autoLanguages = {L"en", L"uk", L"ru"};
|
||||||
options.cycle = {L"en", L"uk", L"ru"};
|
options.cycle = {L"en", L"uk", L"ru"};
|
||||||
supplemental.configure(L"dictionaries", options.autoLanguages);
|
supplemental.configure(L"dictionaries", options.autoLanguages);
|
||||||
|
for (auto [typed, expected] : {std::pair{L"ntcn ", L"тест "}, std::pair{L"vfkj ", L"мало "}})
|
||||||
|
{
|
||||||
|
auto shared = supplemental.suggest(typed, 0, layouts, options);
|
||||||
|
require(shared && shared->text == expected && shared->target == 2,
|
||||||
|
"identical Russian and Ukrainian spelling uses preferred Ukrainian layout");
|
||||||
|
}
|
||||||
|
require(!supplemental.suggest(L"lsv ", 0, layouts, options),
|
||||||
|
"different dictionary results remain ambiguous");
|
||||||
auto release = supplemental.suggest(L"релыз ", 1, layouts, options);
|
auto release = supplemental.suggest(L"релыз ", 1, layouts, options);
|
||||||
require(release && release->text == L"реліз " && release->target == 2,
|
require(release && release->text == L"реліз " && release->target == 2,
|
||||||
"supplemental Ukrainian release word corrects from Russian layout");
|
"supplemental Ukrainian release word corrects from Russian layout");
|
||||||
@@ -268,6 +276,15 @@ int main(int argc, char **argv)
|
|||||||
require(!supplemental.suggest(L"релызик ", 1, layouts, options),
|
require(!supplemental.suggest(L"релызик ", 1, layouts, options),
|
||||||
"never-correct list takes precedence over personal word");
|
"never-correct list takes precedence over personal word");
|
||||||
options.autoNever.clear();
|
options.autoNever.clear();
|
||||||
|
options.autoWords[L"uk"].push_back(L"робить");
|
||||||
|
auto commaWord = supplemental.suggest(L"hj,bnm ", 0, layouts, options);
|
||||||
|
require(commaWord && commaWord->text == L"робить " && commaWord->target == 2,
|
||||||
|
"personal Ukrainian word accepts a punctuation key that maps to a letter");
|
||||||
|
require(!supplemental.suggest(L"hj,bnm123 ", 0, layouts, options),
|
||||||
|
"punctuation-key correction still rejects digits");
|
||||||
|
options.autoMinLength = 1;
|
||||||
|
require(!supplemental.suggest(L"ye ", 0, layouts, options),
|
||||||
|
"known English ye remains protected at minimum length one");
|
||||||
options.cycle = {L"en", L"ru"};
|
options.cycle = {L"en", L"ru"};
|
||||||
require(!supplemental.suggest(L"релызик ", 1, layouts, options),
|
require(!supplemental.suggest(L"релызик ", 1, layouts, options),
|
||||||
"personal dictionary respects disabled target layouts");
|
"personal dictionary respects disabled target layouts");
|
||||||
@@ -284,6 +301,12 @@ int main(int argc, char **argv)
|
|||||||
require(ks::transliterate(L"Shchuka") == L"Щука", "reverse transliteration");
|
require(ks::transliterate(L"Shchuka") == L"Щука", "reverse transliteration");
|
||||||
require(ks::parseTemplate(L"a{cursor}b").suffix == L"b", "snippet suffix");
|
require(ks::parseTemplate(L"a{cursor}b").suffix == L"b", "snippet suffix");
|
||||||
require(ks::parseTemplate(L"{{cursor}}").text == L"{cursor}", "escaped cursor");
|
require(ks::parseTemplate(L"{{cursor}}").text == L"{cursor}", "escaped cursor");
|
||||||
|
require(ks::parseTemplate(L"rider .{enter}").text == L"rider ." &&
|
||||||
|
ks::parseTemplate(L"rider .{enter}").enters == 1, "Enter is an action, not inserted text");
|
||||||
|
require(ks::parseTemplate(L"a{cursor}b{enter}{enter}").suffix == L"b" &&
|
||||||
|
ks::parseTemplate(L"a{cursor}b{enter}{enter}").enters == 2, "Enter actions preserve cursor suffix");
|
||||||
|
require(ks::parseTemplate(L"{{enter}}").text == L"{enter}" &&
|
||||||
|
ks::parseTemplate(L"{{enter}}").enters == 0, "escaped Enter is literal");
|
||||||
require(ks::parseTemplate(L"{cursor}{cursor}").cursors == 2, "duplicate cursor detection");
|
require(ks::parseTemplate(L"{cursor}{cursor}").cursors == 2, "duplicate cursor detection");
|
||||||
require(ks::Hotkey::parse(L"Ctrl+Alt+Shift+Space")->key == VK_SPACE, "hotkey parse");
|
require(ks::Hotkey::parse(L"Ctrl+Alt+Shift+Space")->key == VK_SPACE, "hotkey parse");
|
||||||
require(!ks::Hotkey::parse(L"Ctrl+F25"), "invalid function key");
|
require(!ks::Hotkey::parse(L"Ctrl+F25"), "invalid function key");
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
param([string]$PreviewDirectory = (Join-Path $PSScriptRoot '../build/update-preview'), [switch]$Network)
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
. (Join-Path $PSScriptRoot '../resources/update.ps1') -Library
|
||||||
|
[IO.Directory]::CreateDirectory($PreviewDirectory) | Out-Null
|
||||||
|
Start-UpdateProgress
|
||||||
|
try {
|
||||||
|
if ($script:progressWindow.Status -ne 'Checking for updates') { throw 'Initial progress stage missing' }
|
||||||
|
Show-UpdateProgress 'Downloading update' '42% - 1.6 / 3.9 MB' 42
|
||||||
|
if ($script:progressWindow.Percent -ne 42) { throw 'Determinate download progress missing' }
|
||||||
|
$script:progressWindow.SavePreview((Join-Path $PreviewDirectory 'download.png'))
|
||||||
|
if ($Network) {
|
||||||
|
Receive-File 'https://gitea.hrynco.com/api/v1/repos/hrynco/hrynco-kswitcher/releases?limit=100' (Join-Path $PreviewDirectory 'releases.json') 2MB 'Downloading update'
|
||||||
|
if ($script:progressWindow.Percent -ne 100) { throw 'Real download did not finish at 100 percent' }
|
||||||
|
}
|
||||||
|
foreach ($stage in @('Verifying download','Preparing installation','Closing KSwitcher','Installing update','Restarting KSwitcher','Restoring previous version')) {
|
||||||
|
Show-UpdateProgress $stage 'Update stage under test'
|
||||||
|
if ($script:progressWindow.Status -ne $stage -or $script:progressWindow.Percent -ne -1) { throw "Invalid progress stage: $stage" }
|
||||||
|
}
|
||||||
|
$script:progressWindow.SavePreview((Join-Path $PreviewDirectory 'rollback.png'))
|
||||||
|
$script:progressWindow.Finish('Update completed','KSwitcher 0.6.11 is now running.',$true)
|
||||||
|
if ($script:progressWindow.Status -ne 'Update completed' -or $script:progressWindow.Percent -ne 100) { throw 'Completion state missing' }
|
||||||
|
Start-Sleep -Milliseconds 800 # Let the native progress-bar animation finish before capturing it.
|
||||||
|
$script:progressWindow.SavePreview((Join-Path $PreviewDirectory 'complete.png'))
|
||||||
|
$script:progressWindow.Finish('Update failed',"The updated application did not confirm startup.`r`nThe previous version has been restored and restarted.",$false)
|
||||||
|
if ($script:progressWindow.Status -ne 'Update failed' -or $script:progressWindow.Percent -ne 0) { throw 'Failure state missing' }
|
||||||
|
$script:progressWindow.SavePreview((Join-Path $PreviewDirectory 'failed.png'))
|
||||||
|
'Passed updater progress UI checks'
|
||||||
|
} finally {
|
||||||
|
$script:progressWindow.Dispose()
|
||||||
|
$script:progressWindow = $null
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user