Compare commits

...

2 Commits

22 changed files with 310 additions and 26 deletions
+2 -1
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.24)
project(KSwitcher VERSION 0.6.21 LANGUAGES CXX RC)
project(KSwitcher VERSION 0.6.23 LANGUAGES CXX RC)
if(NOT WIN32)
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
endif()
@@ -61,6 +61,7 @@ add_test(NAME native-regressions COMMAND KSwitcherTests)
add_test(NAME scroll-regressions COMMAND KSwitcherScrollTests)
add_test(NAME input-regressions COMMAND KSwitcherInputTests)
add_test(NAME screenshot-ui-regressions COMMAND KSwitcherScreenshotUITests)
add_test(NAME screenshot-save-regressions COMMAND KSwitcherScreenshotUITests --save-only)
add_test(NAME updater-regressions COMMAND powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${CMAKE_CURRENT_SOURCE_DIR}/tests/update.ps1" -Executable "$<TARGET_FILE:KSwitcher>")
set_tests_properties(native-regressions PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}")
install(TARGETS KSwitcher KSwitcherConfig KSwitcherScreenshot RUNTIME DESTINATION .)
+9 -1
View File
@@ -90,6 +90,10 @@ broker can load them even when the profile directory is inaccessible to it.
Notifications expire after 24 hours. Cache cleanup runs on capture/open; each cache is bounded to
40 files and 256 MiB. Expired or evicted notifications cannot reopen their screenshot.
In the editor, **Ctrl+C** or **Enter** copies the current result without closing the window.
**Save… / Ctrl+S** saves the full-resolution image and annotations as PNG. The first save asks
for a file; later saves update that file. **Ctrl+Shift+S (Save As…)** always asks for a new
destination. Saving keeps the editor open and leaves the clipboard unchanged. Further edits
must be saved or copied again before closing without a warning.
The editor starts with **Pencil / Freehand (F)** for drawing. Choose **Pointer (V)** to select objects, move them, and edit geometry, thickness and
other properties using handles and controls. Clicking empty space clears selection; dragging empty
space adds nothing. Choose a drawing tool to create objects, then return to **V** to edit them.
@@ -168,7 +172,7 @@ released repeatedly within one stroke. The **Line style** selector supports Soli
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.
The window close button asks for confirmation before discarding an uncopied screenshot.
The window close button asks for confirmation before discarding changes that have not been saved or copied.
**Escape** cancels region selection or inline text edits; it does not close the editor. Copy keeps the editor open and publishes the image
at its original pixel size, ready to paste. The preview fits the window without changing export size.
The shortcut is configurable in Settings under **Screen capture**.
@@ -222,6 +226,8 @@ thumbnails and source labels aligned with the scaled fonts and spacing.
- Installed Windows keyboard layouts, configurable cycle/order and direct shortcuts; IMEs are switch-only.
Text conversion cycles skip layouts that leave the text unchanged and use the next enabled layout
that changes it. If none changes the text, the operation keeps the text and advances one layout.
Partial copied suffixes can be reselected with paced keys; replacement still requires verification
of the complete expected text. Browser/editor compatibility depends on its handling of simulated input.
- Selection conversion and verified recent input (physical scan codes, 2-second typing-run boundary).
- Same-process clipboard ownership plus verified WebView2 descendants, including the Teams fix.
- Teams recent-word conversion uses a single word-selection command and validates the copied range.
@@ -387,6 +393,8 @@ protect already valid input, and ambiguous alternatives remain unchanged regardl
**My dictionary** provides separate English, Ukrainian and Russian fields for correct words missing
from the bundled dictionaries. Enter words separated by commas or new lines, then click OK and Save
in Settings. Case and duplicates are normalized automatically; no file editing or ranks are needed.
The v0.6.23 bundled Ukrainian dictionary includes `поточні`. The updater preserves existing dictionary files;
if upgrading from an older dictionary, add this word in My dictionary → Ukrainian and click OK → Save.
These entries are stored in settings and survive dictionary updates. They protect correct input and
are eligible as correction targets at any frequency limit. The language and target layout must still
be enabled. **Never correct these words** is a separate exception list and takes precedence.
Binary file not shown.
+8 -5
View File
@@ -1,16 +1,19 @@
# Latest Windows release
## v0.6.21 — 2026-10-31
## v0.6.23 — 2026-11-01
## Changes
- Next layout skips enabled layouts that leave the text unchanged and selects the next layout that changes it. Shared Russian/Ukrainian text such as `рф` and `шььш` becomes `ha` and `immi` when English follows in the cycle.
- Configured order is preserved. If no enabled convertible layout changes the text, it remains unchanged and the operation advances one layout. Direct layout shortcuts retain their existing behavior.
- Add Ukrainian `поточні` to the bundled dictionary: `Gjnjxys ` and `Поточны ` can become `Поточні ` with Ukrainian enabled. Supplemental rank 50001 is included by the default unlimited frequency setting.
- Use paced selection in Chrome and Edge, as already used for Teams/WebView, to reduce incomplete selections during recent-text conversion, repeat conversion and Undo. Full verification remains mandatory.
- Save annotated screenshots as full-resolution PNG with **Save… / Ctrl+S**. The first save asks for a destination; later saves update it. **Ctrl+Shift+S** opens Save As. Saving keeps the editor open and does not change the clipboard. Closing warns about changes not saved or copied.
## Validation
Passed 413 native regressions, 473 input/UI checks, screenshot editor UI, notification registration, 48 updater checks and updater progress checks.
423 native regressions, 473 input/UI checks, shared wheel handling, screenshot editor UI, PNG save regressions, notification registration, 48 updater checks and updater progress checks passed.
## Limitations
Existing clipboard, accessibility and input-context verification limitations remain. Copy retries and paste readback verification are unchanged.
The reported Chrome editor failure has not yet been verified live after the selection change. Existing clipboard/accessibility restrictions and paste readback limitations remain. Automatic correction runs after Space and requires a dictionary match.
**Dictionary upgrade:** the updater preserves existing dictionaries, so an older installation may not receive the new word automatically. Add `поточні` under **Settings → Auto-correction options → My dictionary → Ukrainian**, then click **OK → Save**. Personal words work at any frequency rank limit. Back up custom dictionary files before replacing them manually.
**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, close screenshot editors and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries.
+1 -1
View File
@@ -1 +1 @@
5207831872af5522063c1c549ba582aaed534096e6e51a394b6be6a705951f3d KSwitcher-win-x64.zip
414604b295912ad466c1a6c6b6ff85b032b9e4480bf115c249885cef60817475 KSwitcher-win-x64.zip
+2
View File
@@ -57,6 +57,8 @@ try {
if ($LASTEXITCODE) { throw 'Screenshot UI test build failed' }
& .\build\KSwitcherScreenshotUITests.exe
if ($LASTEXITCODE) { throw 'Screenshot UI tests failed' }
& .\build\KSwitcherScreenshotUITests.exe --save-only
if ($LASTEXITCODE) { throw 'Screenshot save tests failed' }
$registrationTest = Join-Path (Split-Path $executablePath) 'KSwitcherRegistrationTests.exe'
try {
& $compiler @common tests/screenshot_registration.cpp build/core.o build/platform.o @libs -static '-Wl,--gc-sections' -s -o $registrationTest
+1 -1
View File
@@ -17,7 +17,7 @@ replace counts with original frequency ranks, sort lexically. Reproduce with
hashes. The lists contain word forms from subtitles, not a complete spelling
or grammar dictionary. Recognition can miss names, jargon and uncommon forms.
Supplemental Ukrainian vocabulary: `реліз`. These additions use rank 50001,
Supplemental Ukrainian vocabulary: `реліз`, `поточні`. These additions use rank 50001,
after the upstream 50k list, rather than claiming a measured corpus frequency.
They are included with the default unlimited rank setting and recorded in sources.json.
+5 -4
View File
@@ -9,11 +9,12 @@
"uk": {
"source": "https://raw.githubusercontent.com/hermitdave/FrequencyWords/525f9b560de45753a5ea01069454e72e9aa541c6/content/2016/uk/uk_50k.txt",
"sourceSha256": "9548b79a608d33f39c5546037a69117d679730b1fda08dd0ce67c043dbf3335a",
"words": 42550,
"bytes": 898227,
"sha256": "c94605e6be676d2d64f89a3bc16f92b9128f9977d226a97b103cf22a3e6835e3",
"words": 42551,
"bytes": 898248,
"sha256": "caeb097749d4afc84b68ad96b3d7dfb6a6569561cd32ccce0bee94a13b43ba86",
"supplementalWords": {
"\u0440\u0435\u043b\u0456\u0437": 50001
"\u0440\u0435\u043b\u0456\u0437": 50001,
"\u043f\u043e\u0442\u043e\u0447\u043d\u0456": 50001
}
},
"ru": {
+1
View File
@@ -27337,6 +27337,7 @@
поторопись 9062
поторопитесь 20656
поторопиться 15390
поточні 50001
потрапив 4168
потрапила 7383
потрапили 6631
+3 -3
View File
@@ -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,21,0
PRODUCTVERSION 0,6,21,0
FILEVERSION 0,6,23,0
PRODUCTVERSION 0,6,23,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.21"
VALUE "FileVersion", "0.6.23"
END
END
BLOCK "VarFileInfo"
+25 -3
View File
@@ -227,7 +227,8 @@ class Transaction
return selectPrevious(n, [&] { return valid(); });
}
std::wstring copyRange(size_t count, const std::wstring &wordCandidate,
const std::function<bool(const std::wstring &)> &matches)
const std::function<bool(const std::wstring &)> &matches,
const std::function<bool(const std::wstring &)> &suffixMatches = {})
{
// WebView word boundaries are only candidates; exact verification is mandatory.
if (needsSelectionDelay(target) && !wordCandidate.empty() &&
@@ -247,7 +248,25 @@ class Transaction
return {};
Sleep(25);
}
return select(count) ? copy() : std::wstring{};
auto text = select(count) ? copy() : std::wstring{};
// Chromium editors can apply only part of a burst of Shift+Left events.
// Extend only a verified suffix; never replace a partial or unrelated range.
if (valid() && !matches(text) && !text.empty() &&
(suffixMatches ? suffixMatches(text) : newlines(wordCandidate).ends_with(newlines(text))) &&
caretSteps(text) < count)
{
const auto missing = count - caretSteps(text);
diagnosticEvent(L"Selection: extending verified suffix by " + std::to_wstring(missing));
for (size_t i = 0; i < missing; ++i)
{
if (!valid() || !chord(VK_LEFT, MOD_SHIFT))
return {};
Sleep(25);
}
text = copy();
diagnosticEvent(L"Selection: suffix recovery match=" + std::to_wstring(matches(text)));
}
return text;
}
bool paste(const Clip &clip)
{
@@ -1138,7 +1157,9 @@ void App::transform(int command, int snippet)
{
text =
tx.copyRange(recent.keys.size(), recent.keys, [&](const std::wstring &candidate)
{ return recent.match(candidate, available, physicalLayout()) >= 0; });
{ return recent.match(candidate, available, physicalLayout()) >= 0; },
[&](const std::wstring &candidate)
{ return recent.matchSuffix(candidate, available, physicalLayout()) >= 0; });
hint = recent.match(text, available, physicalLayout());
diagnosticEvent(L"Recent verification copied=" + std::to_wstring(text.size()) + L" keys=" +
std::to_wstring(recent.keys.size()) + L" match=" + std::to_wstring(hint));
@@ -1155,6 +1176,7 @@ void App::transform(int command, int snippet)
previous->version == version)
{
expected = previous->result;
diagnosticEvent(L"Previous result expected=" + diagnosticText(expected));
text = tx.copyRange(caretSteps(previous->result), previous->result,
[&](const std::wstring &candidate)
{ return newlines(candidate) == newlines(previous->result); });
+8
View File
@@ -479,6 +479,14 @@ void Run::backspace(HWND w, ULONGLONG now)
keys.pop_back();
at = now;
}
int Run::matchSuffix(const std::wstring &text, const std::vector<Layout> &layouts, const Layout &physical) const
{
if (text.empty() || text.size() >= keys.size() || overflow)
return -1;
Run tail = *this;
tail.keys = keys.substr(keys.size() - text.size());
return tail.match(text, layouts, physical);
}
int Run::match(const std::wstring &text, const std::vector<Layout> &layouts, const Layout &physical) const
{
if (text.size() != keys.size() || overflow)
+1
View File
@@ -94,5 +94,6 @@ struct Run
void record(wchar_t, HWND, HKL, ULONGLONG);
void backspace(HWND, ULONGLONG);
int match(const std::wstring &, const std::vector<Layout> &, const Layout &physical) const;
int matchSuffix(const std::wstring &, const std::vector<Layout> &, const Layout &physical) const;
};
} // namespace ks
+37
View File
@@ -186,6 +186,43 @@ Pixels decodeImage(const Clip &clip, UINT maxWidth, UINT maxHeight)
check(converter->CopyPixels(nullptr, pixels.width * 4, (UINT)pixels.bgra.size(), pixels.bgra.data()));
return pixels;
}
Bytes encodePng(const Pixels &p)
{
if (!p.width || !p.height || uint64_t(p.width) * p.height * 4 != p.bgra.size() ||
p.bgra.size() > UINT_MAX || p.width > UINT_MAX / 4)
throw std::runtime_error("Invalid PNG image dimensions");
Com<IWICImagingFactory> factory;
check(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER,
IID_PPV_ARGS(factory.out())));
Com<IStream> stream;
check(CreateStreamOnHGlobal(nullptr, TRUE, stream.out()));
Com<IWICBitmapEncoder> encoder;
check(factory->CreateEncoder(GUID_ContainerFormatPng, nullptr, encoder.out()));
check(encoder->Initialize(stream.p, WICBitmapEncoderNoCache));
Com<IWICBitmapFrameEncode> frame;
check(encoder->CreateNewFrame(frame.out(), nullptr));
check(frame->Initialize(nullptr));
check(frame->SetSize(p.width, p.height));
auto format = GUID_WICPixelFormat32bppBGRA;
check(frame->SetPixelFormat(&format));
if (format != GUID_WICPixelFormat32bppBGRA)
throw std::runtime_error("Unsupported PNG pixel format");
check(frame->WritePixels(p.height, p.width * 4, (UINT)p.bgra.size(),
const_cast<BYTE *>(p.bgra.data())));
check(frame->Commit());
check(encoder->Commit());
STATSTG stat{};
check(stream->Stat(&stat, STATFLAG_NONAME));
if (stat.cbSize.QuadPart > UINT_MAX)
throw std::runtime_error("PNG image is too large");
check(stream->Seek({}, STREAM_SEEK_SET, nullptr));
Bytes bytes((size_t)stat.cbSize.QuadPart);
ULONG read{};
check(stream->Read(bytes.data(), (ULONG)bytes.size(), &read));
if (read != bytes.size())
throw std::runtime_error("Cannot read encoded PNG image");
return bytes;
}
Format imageDib(const Pixels &p)
{
BITMAPINFOHEADER h{};
+1
View File
@@ -9,6 +9,7 @@ struct Pixels
};
Pixels decodeImage(const Clip &, UINT maxWidth = 0, UINT maxHeight = 0);
Format imageDib(const Pixels &);
Bytes encodePng(const Pixels &);
std::wstring recognizeText(const Clip &);
std::wstring recognizeText(const Clip &, const Settings &);
std::filesystem::path tesseractExecutable(const Settings &);
+3 -2
View File
@@ -396,13 +396,14 @@ bool chord(WORD key, UINT mods)
bool needsSelectionDelay(HWND target)
{
const auto name = lower(processName(target));
return name == L"ms-teams" || name == L"msedgewebview2";
return name == L"ms-teams" || name == L"msedgewebview2" ||
name == L"chrome" || name == L"msedge";
}
bool selectPrevious(size_t count, const std::function<bool()> &valid)
{
if (count > 100000)
return false;
// Teams' WebView editor applies selection changes asynchronously. A burst of
// Chromium and Teams' WebView editors apply selection changes asynchronously. A burst of
// Shift+Left events can all use the same caret position and select only one
// character. Give each change time to settle before sending the next one.
const auto target = GetForegroundWindow();
+78 -2
View File
@@ -28,8 +28,8 @@ bool confirmScreenshotClose(HWND owner)
dialog.hMainIcon=icon;
dialog.dwCommonButtons=TDCBF_YES_BUTTON|TDCBF_NO_BUTTON;
dialog.pszWindowTitle=L"KSwitcher";
dialog.pszMainInstruction=L"Close without copying?";
dialog.pszContent=L"Your latest screenshot changes have not been copied to the clipboard.";
dialog.pszMainInstruction=L"Close without saving or copying?";
dialog.pszContent=L"Your latest screenshot changes have not been saved to a file or copied to the clipboard.";
dialog.nDefaultButton=IDNO;
dialog.lpCallbackData=(LONG_PTR)owner;
dialog.pfCallback=[](HWND window,UINT notification,WPARAM,LPARAM,LONG_PTR data)->HRESULT {
@@ -208,6 +208,7 @@ struct Capture
int hoveredTool=0;
MarkPart hoveredPart=MarkPart::None;
std::wstring statusText;
std::filesystem::path savedPath;
explicit Capture(ScreenshotHost &a) : app(a), autoOpenEditor(a.autoOpenEditor) {}
~Capture() {
if(pencilCursor) DestroyCursor(pencilCursor);
@@ -239,6 +240,7 @@ void updateStatus(Capture &c)
if(c.hoveredTool==30) text=L"Line style: solid, dotted, dashed or dash-dot";
if(c.hoveredTool==31) text=L"Line ends: start, end, both or no arrowheads";
if(c.hoveredTool==35) text=L"Arrow caption: F2 / double-click to edit; drag to reposition";
if(c.hoveredTool==28) text=L"Save (Ctrl+S) | Save As... (Ctrl+Shift+S)";
}
else if(c.inlineText) text=L"Text: Enter = new line | Ctrl+Enter = apply | Esc = cancel";
else if(part==MarkPart::Rotation) text=L"Drag to rotate text | Hold Shift for 15-degree steps";
@@ -337,6 +339,10 @@ void finishInlineText(HWND w,Capture &c,bool commit)
}
LRESULT CALLBACK inlineTextProc(HWND w,UINT msg,WPARAM wp,LPARAM lp,UINT_PTR,DWORD_PTR)
{
if(msg==WM_KEYDOWN && wp=='S' && (GetKeyState(VK_CONTROL)&0x8000)) {
PostMessageW(GetParent(w),WM_COMMAND,(GetKeyState(VK_SHIFT)&0x8000)?29:28,0); return 0;
}
if(msg==WM_CHAR && wp==19) return 0; // Ctrl+S is handled by the editor.
if(msg==WM_KEYDOWN && (wp==VK_ESCAPE || (wp==VK_RETURN && (GetKeyState(VK_CONTROL)&0x8000)))) {
PostMessageW(GetParent(w),FinishTextMessage,wp==VK_ESCAPE?2:1,(LPARAM)w); return 0;
}
@@ -607,6 +613,9 @@ void layout(HWND w, Capture &c)
MoveWindow(GetDlgItem(w,ToolIds[i]),MulDiv(positions[i],dpi,96),MulDiv(6,dpi,96),
MulDiv(32,dpi,96),MulDiv(32,dpi,96),TRUE);
}
SendDlgItemMessageW(w,28,WM_SETFONT,(WPARAM)c.font,TRUE);
MoveWindow(GetDlgItem(w,28),MulDiv(540,dpi,96),MulDiv(6,dpi,96),
MulDiv(78,dpi,96),MulDiv(32,dpi,96),TRUE);
c.panelHeight=c.unit+MulDiv(40,dpi,96);
for(int id:{30,31,32,33,34,35}) SendDlgItemMessageW(w,id,WM_SETFONT,(WPARAM)c.font,TRUE);
MoveWindow(GetDlgItem(w,32),MulDiv(12,dpi,96),c.unit+MulDiv(6,dpi,96),MulDiv(64,dpi,96),MulDiv(24,dpi,96),TRUE);
@@ -821,6 +830,64 @@ void copy(HWND w, Capture &c, DWORD (*publish)(HWND,const Clip &)=writeClipboard
publish(c.app.window, clip);
c.copied=true;
}
void saveToFile(HWND w, Capture &c, const std::filesystem::path &path)
{
finishInlineText(w,c,true);
auto pixels=decodeImage(screenshotClip(c.image->dc,c.image->width,c.image->height,c.marks));
// GDI-rendered screenshot pixels are opaque; GDI does not maintain alpha.
for(size_t i=3;i<pixels.bgra.size();i+=4) pixels.bgra[i]=255;
auto bytes=encodePng(pixels);
GUID id{};
if(FAILED(CoCreateGuid(&id))) throw std::runtime_error("Cannot prepare screenshot output file");
wchar_t unique[40]{}; StringFromGUID2(id,unique,(int)std::size(unique));
auto temporary=path.parent_path()/(L".kswitcher-"+std::wstring(unique)+L".png");
try {
atomicWrite(temporary,bytes);
if(!MoveFileExW(temporary.c_str(),path.c_str(),MOVEFILE_REPLACE_EXISTING|MOVEFILE_WRITE_THROUGH))
throw std::runtime_error("Cannot save screenshot to the selected file");
} catch(...) {
std::error_code ignored; std::filesystem::remove(temporary,ignored); throw;
}
c.savedPath=path;
c.copied=true; // Current edits have been exported, either to file or clipboard.
}
void save(HWND w, Capture &c, bool saveAs=false,
BOOL (WINAPI *chooseFile)(LPOPENFILENAMEW)=GetSaveFileNameW)
{
if(!saveAs && !c.savedPath.empty()) {
saveToFile(w,c,c.savedPath);
return;
}
wchar_t filename[32768]{};
auto proposed=c.savedPath.wstring();
if(proposed.empty()) {
SYSTEMTIME time{}; GetLocalTime(&time);
wchar_t name[100]{};
swprintf_s(name,L"Screenshot-%04u%02u%02u-%02u%02u%02u.png",
time.wYear,time.wMonth,time.wDay,time.wHour,time.wMinute,time.wSecond);
proposed=name;
}
if(proposed.size()>=std::size(filename)) throw std::runtime_error("Screenshot file path is too long");
std::copy(proposed.begin(),proposed.end(),filename);
OPENFILENAMEW dialog{sizeof(dialog)};
dialog.hwndOwner=w;
dialog.lpstrTitle=L"Save screenshot";
dialog.lpstrFilter=L"PNG image (*.png)\0*.png\0\0";
dialog.nFilterIndex=1;
dialog.lpstrFile=filename;
dialog.nMaxFile=(DWORD)std::size(filename);
dialog.lpstrDefExt=L"png";
dialog.Flags=OFN_OVERWRITEPROMPT|OFN_PATHMUSTEXIST|OFN_NOCHANGEDIR|OFN_EXPLORER;
if(!chooseFile(&dialog)) {
if(CommDlgExtendedError()) throw std::runtime_error("Cannot open the screenshot save dialog");
return;
}
if(lower(std::filesystem::path(filename).extension().wstring())!=L".png")
throw std::runtime_error("Choose a filename ending in .png for the screenshot");
saveToFile(w,c,filename);
c.statusText=L"Saved: "+c.savedPath.wstring();
SendDlgItemMessageW(w,50,SB_SETTEXTW,0,(LPARAM)c.statusText.c_str());
}
void edit(HWND w, Capture &c)
{
auto r = region(c.start,c.end);
@@ -849,6 +916,9 @@ void edit(HWND w, Capture &c)
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 saveButton=CreateWindowW(L"BUTTON",L"Save...",WS_CHILD|WS_VISIBLE|WS_TABSTOP|BS_PUSHBUTTON,
0,0,0,0,w,(HMENU)28,c.app.instance,nullptr);
SetWindowSubclass(saveButton,toolHover,1,0);
auto tips=CreateWindowExW(WS_EX_TOPMOST,TOOLTIPS_CLASSW,nullptr,WS_POPUP|TTS_ALWAYSTIP|TTS_NOPREFIX,
CW_USEDEFAULT,CW_USEDEFAULT,CW_USEDEFAULT,CW_USEDEFAULT,w,nullptr,c.app.instance,nullptr);
for (size_t i=0;i<std::size(ToolIds);++i)
@@ -1023,6 +1093,9 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
if(next!=value) { c->remember(c->marks); value=next; resolveScreenshotLinks(c->marks); }
}
if (LOWORD(wp)==13) { copy(w,*c); return 0; }
if (LOWORD(wp)==28 || LOWORD(wp)==29) {
save(w,*c,LOWORD(wp)==29); SetFocus(w); return 0;
}
if (LOWORD(wp)==12 || LOWORD(wp)==18)
{
bool changed=LOWORD(wp)==12?c->history.undo(c->marks):c->history.redo(c->marks);
@@ -1079,6 +1152,9 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
beginMarkText(w,*c,c->selected); return 0;
}
if ((GetKeyState(VK_CONTROL)&0x8000) && wp=='C') { copy(w,*c); return 0; }
if ((GetKeyState(VK_CONTROL)&0x8000) && wp=='S') {
save(w,*c,(GetKeyState(VK_SHIFT)&0x8000)!=0); SetFocus(w); return 0;
}
if ((GetKeyState(VK_CONTROL)&0x8000) && (wp=='Z' || wp=='Y'))
SendMessageW(w,WM_COMMAND,wp=='Y'||(GetKeyState(VK_SHIFT)&0x8000)?18:12,0);
if ((GetKeyState(VK_CONTROL)&0x8000) && wp=='D' && c->selected>=0)
+1 -1
View File
@@ -2,7 +2,7 @@
#include "platform.h"
namespace ks
{
inline constexpr wchar_t AppVersion[] = L"0.6.21";
inline constexpr wchar_t AppVersion[] = L"0.6.23";
inline constexpr UINT UpdateMessage = WM_APP + 7;
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
+35
View File
@@ -26,10 +26,17 @@ bool conversionOnly = false;
int pasteBehavior = 0;
int enterPresses = 0;
int copyFailuresRemaining = 0, copyRequests = 0;
int droppedSelectionKeys = 0;
bool temporaryClipboardExcluded = true;
bool clipboardAccessTimedOut = false;
LRESULT CALLBACK observeEnter(HWND w, UINT message, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
{
if (message == WM_KEYDOWN && wp == VK_LEFT && droppedSelectionKeys > 0 &&
(GetKeyState(VK_SHIFT) & 0x8000))
{
--droppedSelectionKeys;
return 0;
}
// This bare EDIT control is our test fixture, not a full editor with
// clipboard retries. Keep clipboard access on its owning UI thread while
// it handles Ctrl+C/Ctrl+V, so unrelated Windows clipboard listeners cannot
@@ -205,10 +212,12 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
break;
case 3:
require(text() == L"Hello World", "case conversion");
droppedSelectionKeys = 5;
app->transform(ks::Case);
break;
case 4:
require(text() == L"HELLO WORLD", "consecutive command reselects verified previous result");
require(droppedSelectionKeys == 0, "partial suffix selection recovered after dropped arrow keys");
prepare(L"OLDghbdtn", false);
record(L"ghbdtn");
app->transform(ks::Direct + ru);
@@ -392,6 +401,32 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
require(text() == L"привет" && diagnosticLog().size() == diagnosticStart,
"disabled diagnostics preserves conversion and writes no records");
ks::setLoggingEnabled(true);
prepare(L"PuntoSwitcher ", true);
app->transform(ks::Direct + ru);
break;
case 44:
app->transform(ks::Direct + en);
break;
case 45:
require(text() == L"PuntoSwitcher ", "previous conversion restores CamelCase and trailing space");
droppedSelectionKeys = 5;
app->transform(ks::Direct + ru);
break;
case 46:
require(text() == ks::convertTo(L"PuntoSwitcher ", app->layouts, ru, en),
"repeat conversion recovers Switcher suffix and preserves full result");
require(droppedSelectionKeys == 0, "CamelCase suffix regression dropped five selection keys");
require(ks::readClipboard(app->window).text() == sentinel,
"suffix recovery restores original clipboard");
// Derive the actual text from the physical keys, preserving an older prefix.
prepare((L"OLD" + ks::convertTo(L"snipping ", app->layouts, ru, en)).c_str(), false);
record(L"snipping ");
droppedSelectionKeys = 4;
app->transform(ks::Direct + en);
break;
case 47:
require(text() == L"OLDsnipping ", "recent Cyrillic suffix recovery preserves older prefix");
require(droppedSelectionKeys == 0, "recent suffix regression dropped four selection keys");
finish();
break;
case 30:
+58 -1
View File
@@ -2,10 +2,67 @@
// Including the implementation lets this integration test drive private editor state directly.
#include "../src/screenshot.cpp"
#include <iostream>
int main()
int main(int argc, char **argv)
{
CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);
try {
if(argc>1 && std::string(argv[1])=="--save-only") {
auto require=[](bool ok,const char *message){if(!ok) throw std::runtime_error(message);};
auto root=std::filesystem::temp_directory_path()/(L"KSwitcher-save-"+std::to_wstring(GetCurrentProcessId()));
std::filesystem::create_directories(root);
struct Cleanup {std::filesystem::path root; ~Cleanup(){std::error_code ec; std::filesystem::remove_all(root,ec);}} cleanup{root};
ks::ScreenshotHost app{GetModuleHandleW(nullptr)};
ks::Capture c(app);
c.image=std::make_unique<ks::Surface>(320,240);
PatBlt(c.image->dc,0,0,320,240,WHITENESS);
c.marks={ks::Mark{{40,40},{180,140},0}};
auto expected=ks::decodeImage(ks::screenshotClip(c.image->dc,320,240,c.marks));
for(size_t i=3;i<expected.bgra.size();i+=4) expected.bgra[i]=255;
auto path=root/L"Знімок.png";
const auto clipboard=GetClipboardSequenceNumber();
ks::saveToFile(nullptr,c,path);
ks::Clip encoded{{{0,L"PNG",ks::readFile(path,16*1024*1024)}}};
auto actual=ks::decodeImage(encoded);
require(actual.width==320 && actual.height==240 && actual.bgra==expected.bgra,
"PNG export preserves full resolution, annotations and opaque pixels");
require(c.savedPath==path && c.copied,"successful save records destination and exported state");
require(GetClipboardSequenceNumber()==clipboard,"saving does not change clipboard");
c.remember(c.marks); c.marks.clear();
require(!c.copied,"editing after save marks changes unexported");
ks::save(nullptr,c);
actual=ks::decodeImage(ks::Clip{{{0,L"PNG",ks::readFile(path,16*1024*1024)}}});
require(actual.bgra!=expected.bgra && c.copied,"Save overwrites remembered path with current edits without a dialog");
c.copied=false;
ks::save(nullptr,c,true,[](LPOPENFILENAMEW dialog)->BOOL {
if(!(dialog->Flags&OFN_OVERWRITEPROMPT) || std::wstring(dialog->lpstrDefExt)!=L"png" ||
!std::wstring(dialog->lpstrFile).ends_with(L"Знімок.png"))
throw std::runtime_error("Save As dialog must preserve filename, PNG extension and overwrite confirmation");
return FALSE;
});
require(!c.copied && c.savedPath==path,"cancelled Save As preserves dirty state and remembered path");
auto another=root/L"Another.png";
ks::save(nullptr,c,true,[](LPOPENFILENAMEW dialog)->BOOL {
auto destination=std::filesystem::path(dialog->lpstrFile).parent_path()/L"Another.png";
wcscpy_s(dialog->lpstrFile,dialog->nMaxFile,destination.c_str());
return TRUE;
});
require(c.savedPath==another && std::filesystem::exists(path),"new destination preserves previous file");
c.copied=false;
bool failed=false;
try {ks::saveToFile(nullptr,c,root/L"missing"/L"fail.png");} catch(const std::exception &){failed=true;}
require(failed && !c.copied && c.savedPath==another,"failed save preserves dirty state and previous destination");
require(std::distance(std::filesystem::directory_iterator(root),std::filesystem::directory_iterator{})==2,
"saving leaves no temporary files");
bool invalid=false;
try {ks::save(nullptr,c,true,[](LPOPENFILENAMEW dialog)->BOOL {
auto destination=std::filesystem::path(dialog->lpstrFile).parent_path()/L"Wrong.jpg";
wcscpy_s(dialog->lpstrFile,dialog->nMaxFile,destination.c_str()); return TRUE;
});} catch(const std::exception &){invalid=true;}
require(invalid && !std::filesystem::exists(root/L"Wrong.jpg"),"PNG export rejects a misleading file extension");
CoUninitialize();
std::cout<<"Passed screenshot PNG save regressions without opening windows\n";
return 0;
}
INITCOMMONCONTROLSEX controls{sizeof(controls),ICC_STANDARD_CLASSES}; InitCommonControlsEx(&controls);
ks::ScreenshotHost app{GetModuleHandleW(nullptr)};
app.window=CreateWindowW(L"STATIC",L"",0,0,0,0,0,HWND_MESSAGE,nullptr,app.instance,nullptr);
+30
View File
@@ -192,6 +192,26 @@ int main(int argc, char **argv)
require(ks::convert(L"hello привет", en, ru) == L"руддщ привет", "mixed text preserves target words");
require(ks::convert(L"htdm.dftim", en, ru) == L"ревьюваешь", "punctuation in words");
std::vector<ks::Layout> layouts{en, ru, uk};
ks::Run suffixRun;
suffixRun.keys = L"snipping ";
require(suffixRun.matchSuffix(L"зштп ", layouts, en) >= 0,
"recent Cyrillic suffix matches physical keys across layouts");
require(suffixRun.matchSuffix(L"ping ", layouts, en) == 0,
"recent English suffix matches physical keys");
require(suffixRun.matchSuffix(L"other", layouts, en) < 0 &&
suffixRun.matchSuffix(L"", layouts, en) < 0 &&
suffixRun.matchSuffix(L"snipping ", layouts, en) < 0,
"suffix recovery rejects unrelated, empty and complete ranges");
suffixRun.overflow = true;
require(suffixRun.matchSuffix(L"зштп ", layouts, en) < 0,
"suffix recovery rejects overflowed tracking");
suffixRun.overflow = false;
suffixRun.keys = L"LinkedIn ";
require(suffixRun.matchSuffix(L"лувШт ", layouts, en) >= 0,
"ChatGPT partial CamelCase selection matches tracked physical suffix");
require(suffixRun.match(L"ДштлувШт ", layouts, en) >= 0 &&
suffixRun.match(L"лувШт ", layouts, en) < 0,
"CamelCase conversion still requires the complete tracked text after recovery");
ks::AutoCorrector corrector;
ks::Settings autoSettings;
require(!autoSettings.autoCorrect, "auto-correction defaults off");
@@ -291,6 +311,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);
auto current = supplemental.suggest(L"Gjnjxys ", 0, layouts, options);
require(current && current->text == L"Поточні " && current->target == 2,
"reported title-case Ukrainian word corrects with all three dictionaries enabled");
current = supplemental.suggest(L"Поточны ", 1, layouts, options);
require(current && current->text == L"Поточні " && current->target == 2,
"reported Ukrainian word corrects from Russian layout");
require(!supplemental.suggest(L"Поточні ", 2, layouts, options),
"correct Ukrainian word remains unchanged");
for (auto [typed, expected] : {std::pair{L"ntcn ", L"тест "}, std::pair{L"vfkj ", L"мало "}})
{
auto shared = supplemental.suggest(typed, 0, layouts, options);
@@ -307,6 +335,8 @@ int main(int argc, char **argv)
require(!supplemental.suggest(L"релиз ", 1, layouts, options),
"valid Russian release word stays unchanged");
options.autoMaxRank = 50000;
require(!supplemental.suggest(L"Gjnjxys ", 0, layouts, options),
"supplemental current word respects explicit frequency limit");
require(!supplemental.suggest(L"релыз ", 1, layouts, options),
"supplemental vocabulary respects explicit frequency limit");
options.autoWords[L"uk"] = {L"релізик"};
+1 -1
View File
@@ -4,7 +4,7 @@ import urllib.request, hashlib, json
ROOT=Path(__file__).resolve().parents[1]/'dictionaries'
REV='525f9b560de45753a5ea01069454e72e9aa541c6'
# Supplemental vocabulary has no corpus frequency: rank after the 50k source list.
SUPPLEMENTAL={'uk': {'реліз': 50001}}
SUPPLEMENTAL={'uk': {'реліз': 50001, 'поточні': 50001}}
ROOT.mkdir(exist_ok=True)
manifest={}
for lang in ('en','uk','ru'):