Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b231146cb | |||
| c0ab02df16 |
+2
-1
@@ -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 .)
|
||||
|
||||
@@ -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
@@ -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 @@
|
||||
5207831872af5522063c1c549ba582aaed534096e6e51a394b6be6a705951f3d KSwitcher-win-x64.zip
|
||||
414604b295912ad466c1a6c6b6ff85b032b9e4480bf115c249885cef60817475 KSwitcher-win-x64.zip
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -27337,6 +27337,7 @@
|
||||
поторопись 9062
|
||||
поторопитесь 20656
|
||||
поторопиться 15390
|
||||
поточні 50001
|
||||
потрапив 4168
|
||||
потрапила 7383
|
||||
потрапили 6631
|
||||
|
||||
+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,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
@@ -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); });
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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{};
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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"релізик"};
|
||||
|
||||
@@ -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'):
|
||||
|
||||
Reference in New Issue
Block a user