Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4eb9f6c671 | |||
| a830f55575 | |||
| 07e31228d7 | |||
| 39d3876fd2 | |||
| 4cdcc92eb7 | |||
| 77f27ea087 |
+1
-1
@@ -1,5 +1,5 @@
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
project(KSwitcher VERSION 0.6.11 LANGUAGES CXX RC)
|
||||
project(KSwitcher VERSION 0.6.17 LANGUAGES CXX RC)
|
||||
if(NOT WIN32)
|
||||
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
|
||||
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 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.
|
||||
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.
|
||||
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
|
||||
**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
|
||||
@@ -160,7 +160,11 @@ Hold Ctrl to start drawing over an existing object. Line style applies to outlin
|
||||
for shapes.
|
||||
**Freehand line (F)** draws an opaque curve by following the mouse. The curve is one object:
|
||||
move it, change its color or thickness, clone it, or drag its endpoints. Hold Ctrl to start a new
|
||||
stroke over an existing object. The **Line style** selector supports Solid, Dotted, Dashed and
|
||||
stroke over an existing object. Hold **Shift** while drawing to constrain the current segment
|
||||
to a horizontal, vertical or 45-degree diagonal line; release Shift to resume freehand drawing in the same stroke.
|
||||
While holding Shift, moving more than 50 image pixels away from the current line selects a new
|
||||
direction at the last drawn endpoint, preserving earlier segments. Shift can be pressed and
|
||||
released repeatedly within one stroke. The **Line style** selector supports Solid, Dotted, Dashed and
|
||||
Dash-dot; **Ends** supports an arrow at the end, start, both ends, or no arrows. These settings
|
||||
apply to selected straight/freehand lines and set defaults for subsequent lines. Both are disabled
|
||||
for numbered markers and highlights unless a line drawing tool is active. Undo/Redo includes style changes.
|
||||
@@ -224,7 +228,10 @@ thumbnails and source labels aligned with the scaled fonts and spacing.
|
||||
- Clipboard snapshot/restoration with concurrent-copy, foreground and input-version checks.
|
||||
- Case cycling, Russian/Ukrainian transliteration, verified undo and repeated conversion.
|
||||
- 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;
|
||||
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
|
||||
@@ -291,6 +298,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.
|
||||
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.
|
||||
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,
|
||||
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.
|
||||
@@ -363,12 +372,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
|
||||
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
|
||||
dictionaries. For example, `ghbdsn ` can become `привіт `. Continuing to type before selection cancels
|
||||
pending work. During the replacement itself, key presses and releases are queued and replayed after
|
||||
On Space, KSwitcher checks the last physically typed word against preloaded local dictionaries.
|
||||
For example, `ghbdsn ` can become `привіт `. A confirmed candidate immediately queues subsequent
|
||||
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.
|
||||
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
|
||||
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
|
||||
|
||||
Binary file not shown.
+6
-7
@@ -1,16 +1,15 @@
|
||||
# Latest Windows release
|
||||
|
||||
## v0.6.11 — 2026-10-29
|
||||
## v0.6.17 — 2026-10-30
|
||||
|
||||
## Changes
|
||||
- Show an update progress window for manual checks and installation. Downloads display percentage and received/total size; verification, unpacking, installation and restart have distinct status messages.
|
||||
- Keep the progress window running independently while KSwitcher exits and restarts. After the new application confirms startup, show the installed version and a Done button in the same window, without a separate completion notification.
|
||||
- Show recovery and installation errors in the same window after the application exits. Background update checks remain quiet.
|
||||
|
||||
The progress UI is provided by the currently installed updater. Updating from v0.6.10 to this release still uses the older interface; subsequent updates use the new window.
|
||||
- Hold Shift with the screenshot pencil to draw horizontal, vertical or 45-degree diagonal segments. Release Shift to continue freehand within the same stroke.
|
||||
- Move more than 50 image pixels away from the active line to change direction while preserving earlier segments. Smooth diagonal gestures use the actual mouse trajectory instead of producing stair steps.
|
||||
- Keep diagonal strokes inside image boundaries without changing their angle. Ctrl retains its existing draw-over-objects behavior; undo/redo treats the complete stroke as one object.
|
||||
- Disable snippet fields, Delete and marker insertion until a snippet is selected or added. Deleting the selected snippet clears and disables the editor.
|
||||
|
||||
## Validation
|
||||
214 native regression checks, 466 input/UI checks, screenshot editor UI checks, isolated notification registration, 45 updater regression checks, and progress-window checks for download, installation, recovery and completion states. Network tests verify real download progress and release download/checksum validation. Startup acknowledgement tests prevent reporting success for ordinary startup or a missing updater handshake.
|
||||
Passed 223 native regression checks, 472 input/UI checks, screenshot editor UI and notification registration checks, 48 updater checks, updater progress checks, and 45 isolated configurator process checks. Screenshot UI coverage includes smooth diagonal gestures in all four directions, direction changes, image boundaries, Shift transitions, Ctrl behavior and undo/redo. Configurator checks cover disabled snippet editing, Add, Delete, reselection and preservation of snippet content.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -1 +1 @@
|
||||
d12994e3038e3a10f76b6eb4dba4aca0fac4c4d5f3805ba16f132f890a941bcf KSwitcher-win-x64.zip
|
||||
d5813a42105a2aebb5b6ef414c0546a44f18e4d7f3d45e0f32830097781b9677 KSwitcher-win-x64.zip
|
||||
|
||||
+3
-3
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
|
||||
1 RT_MANIFEST "app.manifest"
|
||||
201 RCDATA "update.ps1"
|
||||
1 VERSIONINFO
|
||||
FILEVERSION 0,6,11,0
|
||||
PRODUCTVERSION 0,6,11,0
|
||||
FILEVERSION 0,6,17,0
|
||||
PRODUCTVERSION 0,6,17,0
|
||||
FILEOS VOS_NT_WINDOWS32
|
||||
FILETYPE VFT_APP
|
||||
BEGIN
|
||||
@@ -20,7 +20,7 @@ BEGIN
|
||||
BEGIN
|
||||
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
|
||||
VALUE "ProductName", "KSwitcher"
|
||||
VALUE "FileVersion", "0.6.11"
|
||||
VALUE "FileVersion", "0.6.17"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
|
||||
+111
-34
@@ -12,6 +12,11 @@ namespace ks
|
||||
App *App::current = nullptr;
|
||||
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>> 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());
|
||||
}
|
||||
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
|
||||
{
|
||||
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)
|
||||
{
|
||||
bool autostartChanged = false;
|
||||
AutoCorrector predictor;
|
||||
validateSettings(s);
|
||||
validateTesseract(s);
|
||||
if (s.autoCorrect)
|
||||
{
|
||||
AutoCorrector probe;
|
||||
probe.configure(std::filesystem::path(executable()).parent_path() / L"dictionaries", s.autoLanguages);
|
||||
predictor.configure(std::filesystem::path(executable()).parent_path() / L"dictionaries", s.autoLanguages);
|
||||
}
|
||||
if (!registerHotkeys(s))
|
||||
{
|
||||
@@ -454,6 +466,7 @@ void App::applySettings(const Settings &s)
|
||||
{
|
||||
std::lock_guard lock(stateMutex);
|
||||
settings = s;
|
||||
autoPredictor = std::move(predictor);
|
||||
setLoggingEnabled(s.writeLog);
|
||||
run.reset();
|
||||
last.reset();
|
||||
@@ -517,6 +530,7 @@ void App::resetInput()
|
||||
run.reset();
|
||||
last.reset();
|
||||
}
|
||||
void App::clearLog(bool previous) { clearDiagnosticLog(previous); }
|
||||
void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
|
||||
{
|
||||
if (ownKeyboardInput(k))
|
||||
@@ -557,9 +571,33 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
|
||||
return;
|
||||
}
|
||||
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)
|
||||
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())
|
||||
return;
|
||||
auto keys = lower(run.keys);
|
||||
@@ -587,7 +625,18 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
|
||||
for (auto &trigger : variants)
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -661,12 +710,13 @@ bool App::handleKeyboard(const KBDLLHOOKSTRUCT &key, bool down, UINT mods)
|
||||
return true;
|
||||
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);
|
||||
if (inputVersion != version || GetForegroundWindow() != target || !modifiersReleased())
|
||||
return false;
|
||||
typingTarget = target;
|
||||
snippetTyping = snippet;
|
||||
return true;
|
||||
}
|
||||
void App::replayTyping()
|
||||
@@ -677,7 +727,13 @@ void App::replayTyping()
|
||||
std::lock_guard lock(stateMutex);
|
||||
target = typingTarget;
|
||||
typingTarget = nullptr;
|
||||
autoPending = false;
|
||||
snippetPending = -1;
|
||||
inputs.swap(deferredTyping);
|
||||
if (snippetTyping)
|
||||
for (auto &input : inputs)
|
||||
input.ki.dwExtraInfo = snippetReplayMarker();
|
||||
snippetTyping = false;
|
||||
}
|
||||
busy = false;
|
||||
if (inputs.empty())
|
||||
@@ -793,12 +849,19 @@ void App::transform(int command, int snippet)
|
||||
L" version=" + std::to_wstring(currentVersion) +
|
||||
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)
|
||||
diagnosticEvent(L"Transform outcome id=" + std::to_wstring(attempt) + L" reason=busy", true);
|
||||
return;
|
||||
}
|
||||
if (preDeferred)
|
||||
{
|
||||
autoPending = false;
|
||||
snippetPending = -1;
|
||||
}
|
||||
Run recent;
|
||||
std::optional<LastTransform> previous;
|
||||
Settings config;
|
||||
@@ -813,7 +876,7 @@ void App::transform(int command, int snippet)
|
||||
version = inputVersion;
|
||||
}
|
||||
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
|
||||
{
|
||||
@@ -827,7 +890,7 @@ void App::transform(int command, int snippet)
|
||||
else
|
||||
b = false;
|
||||
}
|
||||
} reset{busy, window};
|
||||
} reset{busy, window, preDeferred};
|
||||
auto start = GetTickCount64();
|
||||
std::wstring reason = L"cancelled-before-selection";
|
||||
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"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
|
||||
{
|
||||
auto valid = [&]
|
||||
@@ -905,14 +968,15 @@ void App::transform(int command, int snippet)
|
||||
std::wstring predicted;
|
||||
for (wchar_t key : recent.keys)
|
||||
predicted += key == L' ' ? L' ' : available[hint].at(physicalLayout().code(key));
|
||||
expected = predicted;
|
||||
if (!autoCorrector.suggest(predicted, hint, available, config) ||
|
||||
!autoCorrectionAllowed(target, config) || !valid())
|
||||
return;
|
||||
}
|
||||
reason = L"clipboard-snapshot";
|
||||
if (command == AutoCorrect)
|
||||
if (command == AutoCorrect || snippet >= 0)
|
||||
{
|
||||
if (!beginTypingDeferral(target, version))
|
||||
if (!beginTypingDeferral(target, version, snippet >= 0))
|
||||
return;
|
||||
reset.deferred = true;
|
||||
}
|
||||
@@ -944,9 +1008,12 @@ void App::transform(int command, int snippet)
|
||||
autoSelected = true;
|
||||
Sleep(25);
|
||||
text = tx.copy();
|
||||
// Some editors first select only ") ". Extend by whole-word
|
||||
// boundaries, then still verify against the complete tracked run.
|
||||
for (int i = 0; i < 3 && autoClosingSelection(text, config) && tx.valid(); ++i)
|
||||
// Editors may split hj,bnm at the comma. Extend only a verified
|
||||
// suffix of the tracked input, and still require a complete match.
|
||||
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))
|
||||
break;
|
||||
@@ -1004,13 +1071,11 @@ void App::transform(int command, int snippet)
|
||||
tail.keys = tail.keys.substr(tail.keys.size() - sn.trigger.size());
|
||||
if (!tx.select(sn.trigger.size()))
|
||||
return;
|
||||
autoSelected = true;
|
||||
reason = L"snippet-verification-mismatch";
|
||||
text = tx.copy();
|
||||
if (tail.match(text, available, physicalLayout()) < 0)
|
||||
{
|
||||
if (tx.valid())
|
||||
chord(VK_RIGHT);
|
||||
return;
|
||||
}
|
||||
result = parseTemplate(sn.text).text;
|
||||
automatic = true;
|
||||
}
|
||||
@@ -1123,12 +1188,22 @@ void App::transform(int command, int snippet)
|
||||
if (snippet >= 0 && sent && tx.valid())
|
||||
{
|
||||
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();
|
||||
if (tx.valid())
|
||||
chord(newlines(suffix) == newlines(t.suffix) ? VK_LEFT : VK_RIGHT);
|
||||
if (!tx.valid()) return;
|
||||
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))
|
||||
{
|
||||
@@ -1476,13 +1551,22 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
||||
app->captureHistory();
|
||||
return 0;
|
||||
case AutoCorrectMessage:
|
||||
if (app->installingUpdate) return 0;
|
||||
app->pendingAutoVersion = (uint64_t)wp;
|
||||
SetTimer(hwnd, 3, 250, nullptr);
|
||||
if (app->autoPending)
|
||||
{
|
||||
if (!app->installingUpdate && !app->snippetTyping && app->settings.autoCorrect && app->inputVersion.load() == (uint64_t)wp)
|
||||
app->transform(AutoCorrect);
|
||||
else
|
||||
app->replayTyping();
|
||||
}
|
||||
return 0;
|
||||
case SnippetMessage:
|
||||
if (app->installingUpdate) return 0;
|
||||
if (app->snippetPending == (int)wp)
|
||||
{
|
||||
if (!app->installingUpdate && app->inputVersion.load() == (uint64_t)lp)
|
||||
app->transform(0, (int)wp);
|
||||
else
|
||||
app->replayTyping();
|
||||
}
|
||||
return 0;
|
||||
case HistoryMessage:
|
||||
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:
|
||||
{
|
||||
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;
|
||||
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);
|
||||
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)
|
||||
{
|
||||
PostMessageW(hwnd, WM_CLOSE, 0, 0);
|
||||
|
||||
@@ -38,6 +38,7 @@ class App
|
||||
Settings settings;
|
||||
std::unique_ptr<History> history;
|
||||
AutoCorrector autoCorrector;
|
||||
AutoCorrector autoPredictor; // Preloaded, hook-thread-only lookup; no file access while typing.
|
||||
std::atomic<bool> busy{false}, suspended{false};
|
||||
std::atomic<DWORD> ownSequence{0};
|
||||
std::atomic<uint64_t> inputVersion{0};
|
||||
@@ -70,6 +71,7 @@ class App
|
||||
void applySettings(const Settings &);
|
||||
void reloadLayouts();
|
||||
void clearHistory();
|
||||
void clearLog(bool previous);
|
||||
void setHotkeyCapture(HWND);
|
||||
bool isHotkey(DWORD, UINT);
|
||||
void keyboard(const KBDLLHOOKSTRUCT &, UINT modifiers);
|
||||
@@ -99,7 +101,6 @@ class App
|
||||
bool (*updateNotifier)(const std::wstring &){};
|
||||
bool stop{};
|
||||
UINT taskbarCreated{};
|
||||
uint64_t pendingAutoVersion{};
|
||||
uint64_t transformAttempt{}, previousAttemptVersion{};
|
||||
ULONGLONG previousAttemptAt{};
|
||||
HWND previousAttemptWindow{};
|
||||
@@ -107,8 +108,11 @@ class App
|
||||
std::vector<std::pair<int, Hotkey>> hookBindings;
|
||||
HotkeyHook hotkeyHook;
|
||||
HWND typingTarget{};
|
||||
bool autoPending{};
|
||||
int snippetPending = -1;
|
||||
bool snippetTyping{};
|
||||
std::vector<INPUT> deferredTyping;
|
||||
bool beginTypingDeferral(HWND, uint64_t);
|
||||
bool beginTypingDeferral(HWND, uint64_t, bool snippet = false);
|
||||
void replayTyping();
|
||||
static LRESULT CALLBACK windowProc(HWND, UINT, 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());
|
||||
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 {};
|
||||
for (auto &never : settings.autoNever)
|
||||
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))
|
||||
return {};
|
||||
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)
|
||||
{
|
||||
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);
|
||||
if (!rank)
|
||||
continue;
|
||||
if (++matches > 1)
|
||||
if (matchedText && *matchedText != converted)
|
||||
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};
|
||||
}
|
||||
return candidate;
|
||||
|
||||
+12
-6
@@ -57,6 +57,7 @@ LRESULT CALLBACK controller(HWND window, UINT message, WPARAM wp, LPARAM lp)
|
||||
try
|
||||
{
|
||||
if (wp == 21) showSnippets(*app);
|
||||
else if (wp == 22) showLogs(*app);
|
||||
else showSettings(*app);
|
||||
}
|
||||
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->snippetsWindow) DestroyWindow(app->snippetsWindow);
|
||||
if (app->logWindow) DestroyWindow(app->logWindow);
|
||||
PostQuitMessage(0);
|
||||
return 0;
|
||||
}
|
||||
@@ -86,6 +88,7 @@ App::~App()
|
||||
{
|
||||
if (settingsWindow) DestroyWindow(settingsWindow);
|
||||
if (snippetsWindow) DestroyWindow(snippetsWindow);
|
||||
if (logWindow) DestroyWindow(logWindow);
|
||||
if (window) DestroyWindow(window);
|
||||
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);
|
||||
}
|
||||
}
|
||||
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");
|
||||
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.");
|
||||
DWORD pid{}; GetWindowThreadProcessId(window, &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;
|
||||
}
|
||||
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);
|
||||
if (!app.window) throw std::runtime_error("Cannot create configurator window.");
|
||||
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;
|
||||
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))
|
||||
{
|
||||
@@ -180,6 +184,7 @@ int runConfigurator(HINSTANCE instance, bool snippets)
|
||||
}
|
||||
if (app.settingsWindow && IsDialogMessageW(app.settingsWindow, &message)) continue;
|
||||
if (app.snippetsWindow && IsDialogMessageW(app.snippetsWindow, &message)) continue;
|
||||
if (app.logWindow && IsDialogMessageW(app.logWindow, &message)) continue;
|
||||
TranslateMessage(&message); DispatchMessageW(&message);
|
||||
}
|
||||
return 0;
|
||||
@@ -193,8 +198,9 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int)
|
||||
{
|
||||
int count{}; auto args = CommandLineToArgvW(GetCommandLineW(), &count);
|
||||
bool snippets = count > 1 && std::wstring(args[1]) == L"--snippets";
|
||||
bool logs = count > 1 && std::wstring(args[1]) == L"--logs";
|
||||
LocalFree(args);
|
||||
result = ks::runConfigurator(instance, snippets);
|
||||
result = ks::runConfigurator(instance, snippets, logs);
|
||||
}
|
||||
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;
|
||||
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";
|
||||
if (!std::filesystem::exists(path))
|
||||
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)};
|
||||
PROCESS_INFORMATION process{};
|
||||
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:
|
||||
PostMessageW(app.window, WM_HOTKEY, 22, 0);
|
||||
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;
|
||||
}
|
||||
ok = true;
|
||||
|
||||
+2
-2
@@ -5,9 +5,9 @@ namespace ks
|
||||
inline constexpr ULONG_PTR ConfigRead = 0x4b530100, ConfigSave = ConfigRead + 1,
|
||||
ConfigLayouts = ConfigRead + 2, ConfigClearHistory = ConfigRead + 3,
|
||||
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;
|
||||
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 &);
|
||||
}
|
||||
|
||||
+11
-1
@@ -261,7 +261,17 @@ Template parseTemplate(const std::wstring &text)
|
||||
size_t cursor = std::wstring::npos;
|
||||
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}";
|
||||
i += 10;
|
||||
|
||||
@@ -34,6 +34,7 @@ struct Template
|
||||
{
|
||||
std::wstring text, suffix;
|
||||
int cursors = 0;
|
||||
int enters = 0;
|
||||
};
|
||||
Template parseTemplate(const std::wstring &);
|
||||
struct Hotkey
|
||||
|
||||
+75
-2
@@ -178,6 +178,11 @@ struct Capture
|
||||
COLORREF currentColor=RGB(225,42,50);
|
||||
COLORREF customColors[16]{};
|
||||
Mark stroke{};
|
||||
bool strokeConstrained{};
|
||||
size_t strokeAnchor{};
|
||||
int strokeAxis{}; // 0 = not chosen, 1 = horizontal, 2 = vertical, 3/4 = diagonals.
|
||||
bool strokeAxisSettled{};
|
||||
std::vector<POINT> strokeMotion;
|
||||
POINT start{}, end{};
|
||||
RECT selection{}, view{};
|
||||
std::vector<Mark> marks;
|
||||
@@ -250,7 +255,7 @@ void updateStatus(Capture &c)
|
||||
else if(c.pointer) text=L"Pointer (V): select and edit objects | Drag handles = geometry or thickness | Empty space clears selection";
|
||||
else if(c.textTool) text=L"Text (T): click the image to type | Double-click existing text to edit";
|
||||
else if(c.highlight) text=L"Highlight (H): drag to paint | Ctrl+drag = paint over an object | V = pointer";
|
||||
else if(c.freehand) text=L"Freehand (F): drag to draw | Ctrl+drag = draw over an object | V = pointer";
|
||||
else if(c.freehand) text=L"Freehand (F): Shift = 45-degree steps; move >50 px off line to turn | Ctrl = draw over objects";
|
||||
else if(c.arrow) text=L"Drag to draw | Ctrl+drag over objects = linked connector | V = pointer";
|
||||
else text=L"Number (N): click to add | V = select and edit | Ctrl+C = copy screenshot";
|
||||
if(text!=c.statusText) {
|
||||
@@ -841,7 +846,7 @@ void edit(HWND w, Capture &c)
|
||||
InitCommonControlsEx(&statusControls);
|
||||
CreateWindowExW(0,STATUSCLASSNAMEW,L"",WS_CHILD|WS_VISIBLE|SBARS_TOOLTIPS,
|
||||
0,0,0,0,w,(HMENU)50,c.app.instance,nullptr);
|
||||
const wchar_t *labels[]{L"Pointer (V): select and edit objects", L"Number (N)", L"Arrow (A); Ctrl+drag between objects to link", L"Straight line (L); Ctrl+drag between objects to link", L"Rectangle (R)", L"Circle (O)", L"Ellipse (E)", L"Freehand line (F); Ctrl+drag to draw over an object", L"Highlight (H); Ctrl+drag to paint over an object",
|
||||
const wchar_t *labels[]{L"Pointer (V): select and edit objects", L"Number (N)", L"Arrow (A); Ctrl+drag between objects to link", L"Straight line (L); Ctrl+drag between objects to link", L"Rectangle (R)", L"Circle (O)", L"Ellipse (E)", L"Freehand line (F); Shift for 45-degree steps; move >50 px off line to turn; Ctrl to draw over objects", L"Highlight (H); Ctrl+drag to paint over an object",
|
||||
L"Text (T); double-click or F2 to edit", L"Color — selected object and new objects", L"Undo (Ctrl+Z)", L"Redo (Ctrl+Y / Ctrl+Shift+Z)", L"Copy (Ctrl+C)"};
|
||||
static_assert(std::size(labels)==std::size(ToolIds));
|
||||
auto tips=CreateWindowExW(WS_EX_TOPMOST,TOOLTIPS_CLASSW,nullptr,WS_POPUP|TTS_ALWAYSTIP|TTS_NOPREFIX,
|
||||
@@ -1045,6 +1050,13 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
|
||||
SetFocus(w); captureCursor(w,*c); InvalidateRect(w,nullptr,FALSE); return 0;
|
||||
case WM_KEYDOWN:
|
||||
if(wp==VK_SHIFT && c->editing && c->dragging && c->freehand && c->part==MarkPart::None && !c->strokeConstrained) {
|
||||
c->strokeConstrained=true;
|
||||
c->strokeAnchor=c->stroke.points.size()-1;
|
||||
c->strokeAxis=0;
|
||||
c->strokeAxisSettled=false;
|
||||
c->strokeMotion={c->stroke.points.back()};
|
||||
}
|
||||
if (wp==VK_ESCAPE) {
|
||||
if(!c->editing) DestroyWindow(w); // Cancel region selection only.
|
||||
return 0;
|
||||
@@ -1100,6 +1112,9 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
case WM_KEYUP:
|
||||
if(wp==VK_SHIFT) c->strokeConstrained=false;
|
||||
return 0;
|
||||
case WM_LBUTTONDBLCLK:
|
||||
if(c->editing && !c->dragging) {
|
||||
POINT p{GET_X_LPARAM(lp),GET_Y_LPARAM(lp)};
|
||||
@@ -1156,6 +1171,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
c->stroke={p,p,0}; c->stroke.thickness=c->highlight?24:3; c->stroke.color=c->currentColor;
|
||||
c->stroke.freehand=c->freehand; c->stroke.style=c->lineStyle; c->stroke.ends=c->freehandEnds;
|
||||
c->stroke.points.push_back(p);
|
||||
c->strokeConstrained=false;
|
||||
}
|
||||
c->dragging=true; SetCapture(w);
|
||||
}
|
||||
@@ -1193,6 +1209,63 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
if(c->editing && c->part==MarkPart::None && c->shape!=ScreenshotShape::None)
|
||||
c->end=constrainScreenshotShape(c->shape,c->start,c->end,c->image->width,c->image->height);
|
||||
if(c->editing && (c->highlight || c->freehand) && c->part==MarkPart::None) {
|
||||
bool constrained=c->freehand && ((wp&MK_SHIFT) || (GetKeyState(VK_SHIFT)&0x8000));
|
||||
if(constrained) {
|
||||
if(!c->strokeConstrained) {
|
||||
c->strokeAnchor=c->stroke.points.size()-1;
|
||||
c->strokeAxis=0;
|
||||
c->strokeAxisSettled=false;
|
||||
c->strokeMotion={c->stroke.points.back()};
|
||||
}
|
||||
auto anchor=c->stroke.points[c->strokeAnchor];
|
||||
// Measure the actual mouse trajectory, not its snapped projection.
|
||||
auto raw=c->end;
|
||||
if(c->strokeMotion.back().x!=raw.x || c->strokeMotion.back().y!=raw.y)
|
||||
c->strokeMotion.push_back(raw);
|
||||
while(c->strokeMotion.size()>2 && (c->strokeMotion.size()>64 ||
|
||||
std::hypot(double(raw.x-c->strokeMotion[1].x),double(raw.y-c->strokeMotion[1].y))>=16))
|
||||
c->strokeMotion.erase(c->strokeMotion.begin());
|
||||
auto chooseAxis=[](POINT from,POINT to) {
|
||||
LONG dx=to.x-from.x,dy=to.y-from.y;
|
||||
constexpr double diagonalBoundary=2.414213562373095; // tan(67.5 degrees)
|
||||
if(std::abs(dx)>std::abs(dy)*diagonalBoundary) return 1;
|
||||
if(std::abs(dy)>std::abs(dx)*diagonalBoundary) return 2;
|
||||
return (dx<0)==(dy<0)?3:4;
|
||||
};
|
||||
auto distance=[](int axis,POINT from,POINT to) {
|
||||
double dx=to.x-from.x,dy=to.y-from.y;
|
||||
return axis==1?std::abs(dy):axis==2?std::abs(dx):
|
||||
std::abs(axis==3?dy-dx:dy+dx)/std::sqrt(2.0);
|
||||
};
|
||||
if(!c->strokeAxisSettled && (c->end.x!=anchor.x || c->end.y!=anchor.y)) {
|
||||
c->strokeAxis=chooseAxis(anchor,c->end);
|
||||
// A one-pixel first movement must not lock a diagonal gesture to an axis.
|
||||
c->strokeAxisSettled=std::hypot(double(raw.x-anchor.x),double(raw.y-anchor.y))>=8;
|
||||
}
|
||||
else if(c->strokeAxis && distance(c->strokeAxis,anchor,c->end)>50) {
|
||||
// Keep the completed segment and turn at its endpoint.
|
||||
auto corner=c->stroke.points.back();
|
||||
int nextAxis=chooseAxis(c->strokeMotion.front(),raw);
|
||||
if(nextAxis!=c->strokeAxis) {
|
||||
c->strokeAnchor=c->stroke.points.size()-1;
|
||||
anchor=corner;
|
||||
c->strokeAxis=nextAxis;
|
||||
}
|
||||
}
|
||||
if(c->strokeAxis==1) c->end.y=anchor.y;
|
||||
else if(c->strokeAxis==2) c->end.x=anchor.x;
|
||||
else if(c->strokeAxis>=3) {
|
||||
int sign=c->strokeAxis==3?1:-1;
|
||||
LONG delta=std::lround(((c->end.x-anchor.x)+sign*(c->end.y-anchor.y))/2.0);
|
||||
LONG minY=sign==1?-anchor.y:anchor.y-(c->image->height-1);
|
||||
LONG maxY=sign==1?c->image->height-1-anchor.y:anchor.y;
|
||||
delta=std::clamp(delta,std::max(-anchor.x,minY),std::min(LONG(c->image->width-1)-anchor.x,maxY));
|
||||
c->end={anchor.x+delta,anchor.y+sign*delta};
|
||||
}
|
||||
// Only the current segment is a preview; earlier corners stay fixed.
|
||||
c->stroke.points.resize(c->strokeAnchor+1);
|
||||
}
|
||||
c->strokeConstrained=constrained;
|
||||
auto last=c->stroke.points.back();
|
||||
if(last.x!=c->end.x || last.y!=c->end.y) c->stroke.points.push_back(c->end);
|
||||
c->stroke.to=c->end;
|
||||
|
||||
+101
-58
@@ -34,6 +34,8 @@ struct WindowLayout
|
||||
bool scrolling{}, arranging{}, history{};
|
||||
int wheelRemainder{}, horizontalWheelRemainder{};
|
||||
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)
|
||||
{
|
||||
@@ -69,8 +71,9 @@ void arrangeWindow(HWND w)
|
||||
layout->y = SetScrollInfo(w, SB_VERT, &vertical, TRUE);
|
||||
}
|
||||
RECT client{}; GetClientRect(w, &client);
|
||||
int extraWidth = layout->history ? std::max(0, int(client.right) - scale(w, layout->width)) : 0;
|
||||
int extraHeight = layout->history ? std::max(0, int(client.bottom) - scale(w, layout->height)) : 0;
|
||||
bool expandable = layout->history || layout->stretch || layout->autoOptions;
|
||||
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; };
|
||||
std::vector<Move> moves;
|
||||
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)};
|
||||
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);
|
||||
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);
|
||||
MapWindowPoints(nullptr, w, reinterpret_cast<POINT *>(¤t), 2);
|
||||
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,
|
||||
560, 680);
|
||||
windowLayoutState(w)->autoOptions = true;
|
||||
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
|
||||
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);
|
||||
@@ -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);
|
||||
button(w, L"OK", IDOK, 326, 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);
|
||||
showRequestedWindow(w);
|
||||
SetForegroundWindow(w);
|
||||
@@ -1337,6 +1375,14 @@ struct SnippetView
|
||||
std::vector<Snippet> snippets;
|
||||
int selection = -1;
|
||||
};
|
||||
void updateSnippetEditor(HWND w, SnippetView *v)
|
||||
{
|
||||
bool selected = v->selection >= 0 && v->selection < (int)v->snippets.size();
|
||||
value(w, 302, selected ? v->snippets[v->selection].trigger : L"");
|
||||
value(w, 303, selected ? v->snippets[v->selection].text : L"");
|
||||
for (int id : {302, 303, 311, 312, 313})
|
||||
EnableWindow(GetDlgItem(w, id), selected);
|
||||
}
|
||||
void fillSnippets(HWND w, SnippetView *v)
|
||||
{
|
||||
SendDlgItemMessageW(w, 301, LB_RESETCONTENT, 0, 0);
|
||||
@@ -1344,6 +1390,7 @@ void fillSnippets(HWND w, SnippetView *v)
|
||||
SendDlgItemMessageW(w, 301, LB_ADDSTRING, 0, (LPARAM)sn.trigger.c_str());
|
||||
if (v->selection >= 0)
|
||||
SendDlgItemMessageW(w, 301, LB_SETCURSEL, v->selection, 0);
|
||||
updateSnippetEditor(w, v);
|
||||
}
|
||||
void collectSnippet(HWND w, SnippetView *v)
|
||||
{
|
||||
@@ -1380,11 +1427,7 @@ LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
collectSnippet(w, v);
|
||||
v->selection = (int)SendDlgItemMessageW(w, 301, LB_GETCURSEL, 0, 0);
|
||||
if (v->selection >= 0)
|
||||
{
|
||||
value(w, 302, v->snippets[v->selection].trigger);
|
||||
value(w, 303, v->snippets[v->selection].text);
|
||||
}
|
||||
updateSnippetEditor(w, v);
|
||||
return 0;
|
||||
}
|
||||
if (id == 310)
|
||||
@@ -1393,8 +1436,6 @@ LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
v->snippets.push_back({L";new", L""});
|
||||
v->selection = (int)v->snippets.size() - 1;
|
||||
fillSnippets(w, v);
|
||||
value(w, 302, L";new");
|
||||
value(w, 303, L"");
|
||||
SetFocus(GetDlgItem(w, 302));
|
||||
return 0;
|
||||
}
|
||||
@@ -1405,14 +1446,14 @@ LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
v->snippets.erase(v->snippets.begin() + v->selection);
|
||||
v->selection = -1;
|
||||
fillSnippets(w, v);
|
||||
value(w, 302, L"");
|
||||
value(w, 303, L"");
|
||||
SetFocus(GetDlgItem(w, 301));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (id == 312)
|
||||
if (id == 312 || id == 313)
|
||||
{
|
||||
SendDlgItemMessageW(w, 303, EM_REPLACESEL, TRUE, (LPARAM)L"{cursor}");
|
||||
if (v->selection < 0 || v->selection >= (int)v->snippets.size()) return 0;
|
||||
SendDlgItemMessageW(w, 303, EM_REPLACESEL, TRUE, (LPARAM)(id == 312 ? L"{cursor}" : L"{enter}"));
|
||||
return 0;
|
||||
}
|
||||
if (id == IDOK)
|
||||
@@ -1530,53 +1571,41 @@ void showHistory(App &app, bool visible)
|
||||
SetFocus(GetDlgItem(app.historyWindow, 101));
|
||||
refreshHistory(app);
|
||||
}
|
||||
#endif
|
||||
#ifndef KS_RESIDENT_APP
|
||||
namespace
|
||||
{
|
||||
constexpr int LogChoice = 601, LogRows = 602, LogRefresh = 603, LogStatus = 604, LogCopy = 605,
|
||||
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 (msg == WM_NOTIFY && ((NMHDR *)lp)->hwndFrom == ListView_GetHeader(w) &&
|
||||
((NMHDR *)lp)->code == NM_CUSTOMDRAW)
|
||||
if (GetPropW(list, L"FittingLogDetails") || Header_GetItemCount(ListView_GetHeader(list)) != 4) return;
|
||||
SetPropW(list, L"FittingLogDetails", (HANDLE)1);
|
||||
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;
|
||||
if (draw->dwDrawStage == CDDS_PREPAINT)
|
||||
{
|
||||
FillRect(draw->hdc, &draw->rc, GetSysColorBrush(COLOR_BTNFACE));
|
||||
return CDRF_NOTIFYITEMDRAW;
|
||||
auto *header = reinterpret_cast<NMHEADERW *>(lp);
|
||||
if (header->hdr.hwndFrom == ListView_GetHeader(w) &&
|
||||
(header->hdr.code == HDN_ITEMCHANGEDW || header->hdr.code == HDN_ITEMCHANGEDA) &&
|
||||
header->iItem >= 0 && header->iItem < 3)
|
||||
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)
|
||||
RemoveWindowSubclass(w, logListProc, 1);
|
||||
return DefSubclassProc(w, msg, wp, lp);
|
||||
if (msg == WM_NCDESTROY) RemoveWindowSubclass(w, logListResizeProc, 3);
|
||||
return result;
|
||||
}
|
||||
void copyLogRows(HWND w, App &app)
|
||||
{
|
||||
@@ -1596,7 +1625,11 @@ void copyLogRows(HWND w, App &app)
|
||||
copied += L"\r\n";
|
||||
}
|
||||
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)
|
||||
{
|
||||
@@ -1664,6 +1697,7 @@ void refreshLogView(HWND w, App &app)
|
||||
InvalidateRect(list, nullptr, TRUE);
|
||||
if (row)
|
||||
ListView_EnsureVisible(list, row - 1, FALSE);
|
||||
fitLogDetails(list);
|
||||
value(w, LogStatus, app.settings.writeLog ? L"Logging enabled: failed operations only."
|
||||
: L"Logging disabled. Showing previously saved entries.");
|
||||
}
|
||||
@@ -1696,7 +1730,7 @@ LRESULT CALLBACK logProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
try
|
||||
{
|
||||
clearDiagnosticLog(SendDlgItemMessageW(w, LogChoice, CB_GETCURSEL, 0, 0) == 1);
|
||||
app->clearLog(SendDlgItemMessageW(w, LogChoice, CB_GETCURSEL, 0, 0) == 1);
|
||||
refreshLogView(w, *app);
|
||||
}
|
||||
catch (const std::exception &e) { error(w, e); }
|
||||
@@ -1758,7 +1792,7 @@ void showLogs(App &app)
|
||||
auto list = control(w, WC_LISTVIEWW, L"", WS_TABSTOP | WS_BORDER | LVS_REPORT | LVS_SHOWSELALWAYS,
|
||||
LogRows, 16, 82, 868, 454);
|
||||
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 int widths[] = {155, 65, 195, 900};
|
||||
for (int i = 0; i < 4; ++i)
|
||||
@@ -1769,12 +1803,20 @@ void showLogs(App &app)
|
||||
column.cx = scale(w, widths[i]);
|
||||
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);
|
||||
auto *layout = windowLayoutState(w);
|
||||
layout->stretch = list;
|
||||
layout->bottomLeft = hint;
|
||||
layout->bottomRight = GetDlgItem(w, IDCANCEL);
|
||||
arrangeWindow(w);
|
||||
refreshLogView(w, app);
|
||||
showRequestedWindow(w);
|
||||
activateWindow(w);
|
||||
}
|
||||
#else
|
||||
void showLogs(App &app) { launchConfiguration(app, false, true); }
|
||||
#endif
|
||||
#ifndef KS_RESIDENT_APP
|
||||
void showSettings(App &app, bool visible)
|
||||
@@ -1861,8 +1903,9 @@ void showSnippets(App &app)
|
||||
edit(w, 302, 236, 44, 488);
|
||||
label(w, L"Replacement text", 236, 82, 480);
|
||||
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 Enter marker", 313, 436, 430, 190);
|
||||
button(w, L"Save", IDOK, 510, 478, 100);
|
||||
button(w, L"Cancel", IDCANCEL, 624, 478, 100);
|
||||
fillSnippets(w, (SnippetView *)GetWindowLongPtrW(w, GWLP_USERDATA));
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
#include "platform.h"
|
||||
namespace ks
|
||||
{
|
||||
inline constexpr wchar_t AppVersion[] = L"0.6.11";
|
||||
inline constexpr wchar_t AppVersion[] = L"0.6.17";
|
||||
inline constexpr UINT UpdateMessage = WM_APP + 7;
|
||||
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
|
||||
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
|
||||
|
||||
+52
-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'
|
||||
if (Get-Process KSwitcher,'KSwitcher.Config' -ErrorAction SilentlyContinue) {
|
||||
throw 'Close KSwitcher and its configurator before this isolated process test.'
|
||||
@@ -18,6 +18,7 @@ public static class ConfigurationTestUi {
|
||||
[DllImport("user32.dll", EntryPoint="SendMessageW", CharSet=CharSet.Unicode)] public static extern IntPtr GetText(IntPtr w,uint msg,IntPtr count,System.Text.StringBuilder text);
|
||||
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern IntPtr FindWindow(string cls,string title);
|
||||
[DllImport("user32.dll")] public static extern IntPtr GetDlgItem(IntPtr w,int id);
|
||||
[DllImport("user32.dll")] public static extern bool IsWindowEnabled(IntPtr w);
|
||||
[DllImport("user32.dll")] public static extern bool PostMessage(IntPtr w,uint msg,IntPtr wp,IntPtr lp);
|
||||
[DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr w,uint msg,IntPtr wp,IntPtr lp);
|
||||
[DllImport("user32.dll", EntryPoint="SendMessageW", CharSet=CharSet.Unicode)] public static extern IntPtr SetText(IntPtr w,uint msg,IntPtr wp,string text);
|
||||
@@ -38,7 +39,7 @@ $checks=0
|
||||
function Assert($ok,$label) { if (!$ok) { throw $label }; $script:checks++ }
|
||||
function Window($class) {
|
||||
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)
|
||||
if($window -ne [IntPtr]::Zero){return $window}
|
||||
Start-Sleep -Milliseconds 50
|
||||
@@ -56,6 +57,24 @@ function Launch($file,$argument) {
|
||||
function Click($window,$id) {
|
||||
$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 {
|
||||
New-Item -ItemType Directory -Path (Join-Path $root 'data') -Force | Out-Null
|
||||
foreach($file in @('KSwitcher.exe','KSwitcher.Config.exe')) {
|
||||
@@ -67,6 +86,13 @@ try {
|
||||
ConvertTo-Json | Set-Content -LiteralPath $settingsPath -Encoding utf8
|
||||
$resident=Launch 'KSwitcher.exe' ''
|
||||
$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' ''
|
||||
$settings=Window 'KSwitcher.Settings'
|
||||
[uint32]$ownerId=0
|
||||
@@ -102,6 +128,29 @@ try {
|
||||
Assert ((Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json).maxItems -eq 321) 'cancel preserves saved settings'
|
||||
$config=Launch 'KSwitcher.Config.exe' '--snippets'
|
||||
$snippets=Window 'KSwitcher.Snippets'
|
||||
$null=[ConfigurationTestUi]::SendMessage($snippets,0,[IntPtr]::Zero,[IntPtr]::Zero)
|
||||
foreach($id in @(302,303,311,312,313)) {
|
||||
Assert (![ConfigurationTestUi]::IsWindowEnabled([ConfigurationTestUi]::GetDlgItem($snippets,$id))) "snippet control $id disabled without selection"
|
||||
}
|
||||
$null=[ConfigurationTestUi]::SendMessage($snippets,0x111,[IntPtr]310,[IntPtr]::Zero)
|
||||
foreach($id in @(302,303,311,312,313)) {
|
||||
Assert ([ConfigurationTestUi]::IsWindowEnabled([ConfigurationTestUi]::GetDlgItem($snippets,$id))) 'Add enables snippet editing'
|
||||
}
|
||||
$null=[ConfigurationTestUi]::SetText([ConfigurationTestUi]::GetDlgItem($snippets,303),0x0c,[IntPtr]::Zero,'preserved snippet')
|
||||
$null=[ConfigurationTestUi]::SendMessage($snippets,0x111,[IntPtr]310,[IntPtr]::Zero)
|
||||
$null=[ConfigurationTestUi]::SendMessage($snippets,0x111,[IntPtr]311,[IntPtr]::Zero)
|
||||
foreach($id in @(302,303,311,312,313)) {
|
||||
Assert (![ConfigurationTestUi]::IsWindowEnabled([ConfigurationTestUi]::GetDlgItem($snippets,$id))) 'deleting selection disables snippet editing'
|
||||
}
|
||||
$null=[ConfigurationTestUi]::SendMessage($snippets,0x111,[IntPtr]312,[IntPtr]::Zero)
|
||||
$snippetText=[Text.StringBuilder]::new(128)
|
||||
$null=[ConfigurationTestUi]::GetText([ConfigurationTestUi]::GetDlgItem($snippets,303),0x0d,[IntPtr]128,$snippetText)
|
||||
Assert ($snippetText.Length -eq 0) 'marker command cannot edit an unselected snippet'
|
||||
$null=[ConfigurationTestUi]::SendMessage([ConfigurationTestUi]::GetDlgItem($snippets,301),0x186,[IntPtr]0,[IntPtr]::Zero)
|
||||
$null=[ConfigurationTestUi]::SendMessage($snippets,0x111,[IntPtr](301 -bor (1 -shl 16)),[IntPtr]::Zero)
|
||||
Assert ([ConfigurationTestUi]::IsWindowEnabled([ConfigurationTestUi]::GetDlgItem($snippets,303))) 'selecting existing snippet enables editing'
|
||||
$null=[ConfigurationTestUi]::GetText([ConfigurationTestUi]::GetDlgItem($snippets,303),0x0d,[IntPtr]128,$snippetText)
|
||||
Assert ($snippetText.ToString() -eq 'preserved snippet') 'selection restores the retained snippet content'
|
||||
$second=Launch 'KSwitcher.Config.exe' ''
|
||||
Assert ($second.WaitForExit(6000) -and $second.ExitCode -eq 0) 'settings and snippets share a single process'
|
||||
$settings=Window 'KSwitcher.Settings'
|
||||
@@ -110,6 +159,7 @@ try {
|
||||
$config.Refresh(); Assert (!$config.HasExited) 'closing settings keeps open snippets alive'
|
||||
Click $snippets 2
|
||||
Assert ($config.WaitForExit(6000)) 'closing last window exits configurator'
|
||||
TestLogs
|
||||
$config=Launch 'KSwitcher.Config.exe' ''
|
||||
$null=Window 'KSwitcher.Settings'
|
||||
$null=[ConfigurationTestUi]::PostMessage($main,0x10,[IntPtr]::Zero,[IntPtr]::Zero)
|
||||
|
||||
@@ -598,6 +598,35 @@ int main(int argc, char **argv) {
|
||||
auto autoOptions=FindWindowW(L"KSwitcher.AutoOptions",nullptr);
|
||||
require(autoOptions!=nullptr,"auto-correction options open");
|
||||
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}) {
|
||||
RECT control{},client{};
|
||||
GetWindowRect(GetDlgItem(autoOptions,id),&control);
|
||||
|
||||
+115
-2
@@ -1,4 +1,5 @@
|
||||
#include "app.h"
|
||||
#include <algorithm>
|
||||
#include <iostream>
|
||||
#include <fstream>
|
||||
#include <commctrl.h>
|
||||
@@ -20,6 +21,12 @@ ks::App *app{};
|
||||
HWND host{}, field{};
|
||||
ks::Clip saved;
|
||||
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;
|
||||
size_t punctuationCase = 0;
|
||||
bool typeDuringCorrection = false, typedDuringCorrection = false;
|
||||
@@ -280,7 +287,73 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
break;
|
||||
case 17:
|
||||
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:
|
||||
if (!captured)
|
||||
{
|
||||
@@ -315,7 +388,44 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
case 19:
|
||||
require(typedDuringCorrection && text() == L"OLDghbdtn fF",
|
||||
"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;
|
||||
}
|
||||
}
|
||||
@@ -439,8 +549,11 @@ int main(int argc, char **argv)
|
||||
CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER | ES_MULTILINE | ES_WANTRETURN,
|
||||
20, 20, 590, 230, host, nullptr, cls.hInstance, nullptr);
|
||||
deadline = GetTickCount64() + 30000;
|
||||
SetWindowSubclass(field, observeEnter, 1, 0);
|
||||
if (argc > 1 && std::string(argv[1]) == "--typing-only")
|
||||
stage = 20;
|
||||
if (argc > 1 && std::string(argv[1]) == "--snippets-only")
|
||||
stage = 30;
|
||||
SetTimer(host, 1, 100, nullptr);
|
||||
app->execute();
|
||||
}
|
||||
|
||||
@@ -24,6 +24,105 @@ int main()
|
||||
require(c.pencilCursor!=nullptr,"pencil drawing cursor loads");
|
||||
require(c.freehand && !c.pointer && ks::toolCursor(c)==c.pencilCursor,
|
||||
"new screenshot editor starts with the pencil tool");
|
||||
{
|
||||
BYTE savedKeys[256]{},keys[256]{}; GetKeyboardState(savedKeys);
|
||||
std::copy(savedKeys,savedKeys+256,keys); keys[VK_SHIFT]=0; SetKeyboardState(keys);
|
||||
auto mouse=[&](UINT message,POINT p,bool shift=false) {
|
||||
int x=c.view.left+MulDiv(p.x,c.view.right-c.view.left,c.image->width);
|
||||
int y=c.view.top+MulDiv(p.y,c.view.bottom-c.view.top,c.image->height);
|
||||
SendMessageW(window,message,(message==WM_LBUTTONUP?0:MK_LBUTTON)|(shift?MK_SHIFT:0),MAKELPARAM(x,y));
|
||||
};
|
||||
mouse(WM_LBUTTONDOWN,{40,40},true);
|
||||
mouse(WM_MOUSEMOVE,{100,50},true);
|
||||
require(c.stroke.to.x==100 && c.stroke.to.y==40,"Shift constrains pencil horizontally");
|
||||
mouse(WM_MOUSEMOVE,{100,90},true);
|
||||
require(c.stroke.to.x==100 && c.stroke.to.y==40 && c.stroke.points.size()==2,
|
||||
"50 pixel deviation keeps the current axis");
|
||||
mouse(WM_MOUSEMOVE,{100,91},true);
|
||||
require(c.stroke.to.x==100 && c.stroke.to.y==91 && c.stroke.points.size()==3 && c.stroke.points[1].y==40,
|
||||
"51 pixel deviation turns vertically and preserves the horizontal segment");
|
||||
mouse(WM_MOUSEMOVE,{70,140});
|
||||
require(c.stroke.to.x==70 && c.stroke.to.y==140 && c.stroke.points.size()==4,
|
||||
"releasing Shift resumes freehand in the same stroke");
|
||||
SendMessageW(window,WM_KEYDOWN,VK_SHIFT,0);
|
||||
mouse(WM_MOUSEMOVE,{140,150},true);
|
||||
require(c.stroke.to.x==140 && c.stroke.to.y==140 && c.stroke.points[3].x==70,
|
||||
"pressing Shift mid-stroke anchors at the last drawn point");
|
||||
SendMessageW(window,WM_KEYUP,VK_SHIFT,0);
|
||||
SendMessageW(window,WM_KEYDOWN,VK_SHIFT,0);
|
||||
mouse(WM_MOUSEMOVE,{150,200},true);
|
||||
mouse(WM_LBUTTONUP,{150,210},true);
|
||||
SetKeyboardState(savedKeys);
|
||||
require(c.marks.size()==1 && c.marks[0].to.x==140 && c.marks[0].to.y==210,
|
||||
"repressing Shift without mouse movement starts a new segment and constrains mouse-up");
|
||||
SendMessageW(window,WM_COMMAND,12,0);
|
||||
require(c.marks.empty(),"mixed freehand and constrained segments undo as one stroke");
|
||||
SendMessageW(window,WM_COMMAND,18,0);
|
||||
require(c.marks.size()==1 && c.marks[0].points.size()==6,"redo preserves constrained stroke geometry");
|
||||
c.marks.clear(); c.selected=-1;
|
||||
for(int sx:{-1,1}) for(int sy:{-1,1}) {
|
||||
mouse(WM_LBUTTONDOWN,{160,120},true);
|
||||
mouse(WM_MOUSEMOVE,{160+sx,120},true);
|
||||
for(int step=2;step<=90;++step)
|
||||
mouse(WM_MOUSEMOVE,{160+sx*step,120+sy*step},true);
|
||||
require(c.stroke.points.size()==2 && c.stroke.to.x==160+sx*90 && c.stroke.to.y==120+sy*90,
|
||||
"smooth diagonal after one-pixel horizontal jitter stays diagonal without stairs");
|
||||
mouse(WM_LBUTTONUP,{160+sx*90,120+sy*90},true);
|
||||
c.marks.clear(); c.selected=-1;
|
||||
mouse(WM_LBUTTONDOWN,{160-sx*40,120-sy*70},true);
|
||||
mouse(WM_MOUSEMOVE,{160,120-sy*70},true);
|
||||
for(int step=1;step<=90;++step)
|
||||
mouse(WM_MOUSEMOVE,{160+sx*step,120-sy*70+sy*step},true);
|
||||
require(c.stroke.points.size()==3 && c.strokeAxis>=3 &&
|
||||
std::abs(c.stroke.to.x-c.stroke.points[1].x)==std::abs(c.stroke.to.y-c.stroke.points[1].y),
|
||||
"smooth turn from horizontal into diagonal uses raw mouse direction without stairs");
|
||||
mouse(WM_LBUTTONUP,{160+sx*90,120-sy*70+sy*90},true);
|
||||
c.marks.clear(); c.selected=-1;
|
||||
}
|
||||
for(int sx:{-1,1}) for(int sy:{-1,1}) {
|
||||
mouse(WM_LBUTTONDOWN,{160,120},true);
|
||||
mouse(WM_MOUSEMOVE,{160+sx*60,120+sy*50},true);
|
||||
require(c.stroke.to.x==160+sx*55 && c.stroke.to.y==120+sy*55,
|
||||
"Shift snaps to exact 45 degree diagonals in all four directions");
|
||||
mouse(WM_LBUTTONUP,{160+sx*60,120+sy*50},true);
|
||||
c.marks.clear(); c.selected=-1;
|
||||
}
|
||||
mouse(WM_LBUTTONDOWN,{40,40},true);
|
||||
mouse(WM_MOUSEMOVE,{100,40},true);
|
||||
mouse(WM_MOUSEMOVE,{160,100},true);
|
||||
require(c.stroke.points.size()==3 && c.stroke.to.x==160 && c.stroke.to.y==100 &&
|
||||
c.stroke.points[1].x==100 && c.stroke.points[1].y==40,
|
||||
"large deviation turns a horizontal segment diagonally without losing its corner");
|
||||
mouse(WM_MOUSEMOVE,{200,100},true);
|
||||
require(c.stroke.to.x==180 && c.stroke.to.y==120 && c.stroke.points.size()==3,
|
||||
"small perpendicular deviation keeps the diagonal axis");
|
||||
mouse(WM_MOUSEMOVE,{260,120},true);
|
||||
require(c.stroke.to.x==260 && c.stroke.to.y==120 && c.stroke.points.size()==4,
|
||||
"diagonal deviation beyond 50 pixels turns back to horizontal");
|
||||
mouse(WM_LBUTTONUP,{260,120},true);
|
||||
c.marks.clear(); c.selected=-1;
|
||||
for(int sx:{-1,1}) for(int sy:{-1,1}) {
|
||||
POINT start{sx>0?310L:9L,120};
|
||||
mouse(WM_LBUTTONDOWN,start,true);
|
||||
mouse(WM_MOUSEMOVE,{start.x+sx*8,start.y+sy*8},true);
|
||||
mouse(WM_MOUSEMOVE,{sx>0?319L:0L,start.y+sy*25},true);
|
||||
auto end=c.stroke.to;
|
||||
require(end.x>=0 && end.x<320 && end.y>=0 && end.y<240 &&
|
||||
std::abs(end.x-start.x)==std::abs(end.y-start.y),
|
||||
"diagonal stays at 45 degrees and inside image boundaries");
|
||||
mouse(WM_LBUTTONUP,{sx>0?319L:0L,start.y+sy*25},true);
|
||||
c.marks.clear(); c.selected=-1;
|
||||
}
|
||||
keys[VK_CONTROL]=0x80; SetKeyboardState(keys);
|
||||
mouse(WM_LBUTTONDOWN,{40,40});
|
||||
mouse(WM_MOUSEMOVE,{100,70});
|
||||
mouse(WM_LBUTTONUP,{120,90});
|
||||
SetKeyboardState(savedKeys);
|
||||
require(c.marks.size()==1 && c.marks[0].points[1].x==100 && c.marks[0].points[1].y==70 &&
|
||||
c.marks[0].to.x==120 && c.marks[0].to.y==90,
|
||||
"Ctrl keeps its existing drawing behavior without constraining the pencil");
|
||||
c.marks.clear(); c.selected=-1;
|
||||
}
|
||||
for(int size:{32,48,64,96}) {
|
||||
auto cursor=(HCURSOR)LoadImageW(app.instance,MAKEINTRESOURCEW(IDC_SCREENSHOT_PENCIL),IMAGE_CURSOR,size,size,0);
|
||||
ICONINFO info{};
|
||||
|
||||
@@ -247,6 +247,14 @@ int main(int argc, char **argv)
|
||||
options.autoLanguages = {L"en", L"uk", L"ru"};
|
||||
options.cycle = {L"en", L"uk", L"ru"};
|
||||
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);
|
||||
require(release && release->text == L"реліз " && release->target == 2,
|
||||
"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),
|
||||
"never-correct list takes precedence over personal word");
|
||||
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"};
|
||||
require(!supplemental.suggest(L"релызик ", 1, layouts, options),
|
||||
"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::parseTemplate(L"a{cursor}b").suffix == L"b", "snippet suffix");
|
||||
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::Hotkey::parse(L"Ctrl+Alt+Shift+Space")->key == VK_SPACE, "hotkey parse");
|
||||
require(!ks::Hotkey::parse(L"Ctrl+F25"), "invalid function key");
|
||||
|
||||
@@ -10,6 +10,12 @@ try {
|
||||
$binaryInfo = [Diagnostics.FileVersionInfo]::GetVersionInfo((Resolve-Path -LiteralPath $Executable).Path)
|
||||
$binaryVersion = Get-StableVersion $binaryInfo.FileVersion
|
||||
Assert ($null -ne $binaryVersion) 'executable has a stable version'
|
||||
$header = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../src/update.h') -Raw
|
||||
$match = [regex]::Match($header, 'AppVersion\[\]\s*=\s*L"([0-9.]+)"')
|
||||
Assert ($match.Success -and (Get-StableVersion $match.Groups[1].Value) -eq $binaryVersion) 'updater runtime version matches packaged executable'
|
||||
$cmake = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../CMakeLists.txt') -Raw
|
||||
$projectVersion = [regex]::Match($cmake, 'project\(KSwitcher VERSION ([0-9.]+)')
|
||||
Assert ($projectVersion.Success -and (Get-StableVersion $projectVersion.Groups[1].Value) -eq $binaryVersion) 'CMake version matches packaged executable'
|
||||
Assert ($binaryInfo.FileDescription -ceq ('KSwitcher ' + [char]0x2014 + ' native keyboard and clipboard utility')) 'executable description preserves Unicode em dash'
|
||||
Assert ((Get-StableVersion 'v0.10.0') -gt (Get-StableVersion 'v0.2.9')) 'numeric versions'
|
||||
Assert ($null -eq (Get-StableVersion 'v1.0.0-beta')) 'prerelease rejected'
|
||||
@@ -24,6 +30,8 @@ try {
|
||||
Assert ($null -eq (Select-Release @($release) ([version]'0.3.0'))) 'no downgrade'
|
||||
Assert ((Get-StableVersion 'release-v0.5.0') -eq [version]'0.5.0') 'modern stable release tag'
|
||||
$modern=@{draft=$false;prerelease=$false;tag_name='release-v0.5.0';assets=$assets}
|
||||
$installedRelease=@{draft=$false;prerelease=$false;tag_name=('release-v'+$binaryInfo.FileVersion);assets=$assets}
|
||||
Assert ($null -eq (Select-Release @($installedRelease) (Get-StableVersion $match.Groups[1].Value))) 'runtime updater does not offer the installed modern release again'
|
||||
Assert ((Select-Release @($release,$modern) ([version]'0.4.2')).version -eq 'release-v0.5.0') 'bridge discovers modern channel'
|
||||
$draft=@{draft=$true;prerelease=$false;tag_name='v9.0.0';assets=$assets}
|
||||
Assert ((Select-Release @($draft,$release) ([version]'0.2.0')).version -eq 'v0.2.1') 'draft ignored'
|
||||
|
||||
Reference in New Issue
Block a user