diff --git a/CMakeLists.txt b/CMakeLists.txt index f72321b..7f23c5c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.24) -project(KSwitcher VERSION 0.6.22 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 "$") set_tests_properties(native-regressions PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}") install(TARGETS KSwitcher KSwitcherConfig KSwitcherScreenshot RUNTIME DESTINATION .) diff --git a/README.md b/README.md index 1b3417f..26fb5bc 100644 --- a/README.md +++ b/README.md @@ -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**. @@ -389,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. diff --git a/Release/KSwitcher-win-x64.zip b/Release/KSwitcher-win-x64.zip index 97a5195..45d189f 100644 Binary files a/Release/KSwitcher-win-x64.zip and b/Release/KSwitcher-win-x64.zip differ diff --git a/Release/README.md b/Release/README.md index f32a66e..1dc6e1e 100644 --- a/Release/README.md +++ b/Release/README.md @@ -1,17 +1,19 @@ # Latest Windows release -## v0.6.22 — 2026-10-31 +## v0.6.23 — 2026-11-01 ## Changes -- Recover partial selections during recent-text conversion, repeat conversion and undo: when copying returns a verified suffix of the expected text, extend the selection with paced Shift+Left presses and copy again. Replacement still requires a complete verified match. -- Recent-text suffix checks map physical keys through convertible layouts, including Cyrillic and mixed case. This covers reported suffixes such as `Switcher ` from `PuntoSwitcher `, `зштп ` from the layout equivalent of `snipping `, and `лувШт ` from `LinkedIn `. -- Diagnostic context records the expected previous result and selection-recovery stages. Unrelated or empty copied ranges are not extended; input/context changes still cancel replacement. +- 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 -419 native regressions, 473 input/UI checks, screenshot editor UI, notification registration, 48 updater checks and updater progress checks passed, including Cyrillic and mixed-case suffix verification. The isolated editor integration test passed partial-selection recovery, the `PuntoSwitcher ` repeat-conversion case, clipboard restoration and rejection of unrelated text. Direct validation in LinkedIn and ChatGPT is still outstanding; native-editor tests do not establish browser-editor compatibility. +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 -Recovery is attempted once and still depends on the editor processing simulated selection keys. A failed full-text check cancels replacement. Existing clipboard, accessibility and input-context restrictions remain; this patch does not change paste readback verification. +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. diff --git a/Release/SHA256SUMS.txt b/Release/SHA256SUMS.txt index fe0b1f4..ed6690b 100644 --- a/Release/SHA256SUMS.txt +++ b/Release/SHA256SUMS.txt @@ -1 +1 @@ -b80632cc93abadf2a23921c3275b8ff37d98c92b8b583a9621d0c2b589ed6a35 KSwitcher-win-x64.zip +414604b295912ad466c1a6c6b6ff85b032b9e4480bf115c249885cef60817475 KSwitcher-win-x64.zip diff --git a/build.ps1 b/build.ps1 index 79ce46e..2741c8e 100644 --- a/build.ps1 +++ b/build.ps1 @@ -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 diff --git a/dictionaries/README.md b/dictionaries/README.md index 16e6a3e..7245c4c 100644 --- a/dictionaries/README.md +++ b/dictionaries/README.md @@ -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. diff --git a/dictionaries/sources.json b/dictionaries/sources.json index 8dee1ca..f741199 100644 --- a/dictionaries/sources.json +++ b/dictionaries/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": { diff --git a/dictionaries/uk.txt b/dictionaries/uk.txt index 602eed7..1f7ae5e 100644 --- a/dictionaries/uk.txt +++ b/dictionaries/uk.txt @@ -27337,6 +27337,7 @@ поторопись 9062 поторопитесь 20656 поторопиться 15390 +поточні 50001 потрапив 4168 потрапила 7383 потрапили 6631 diff --git a/resources/app.rc b/resources/app.rc index ba6b709..3af1d7c 100644 --- a/resources/app.rc +++ b/resources/app.rc @@ -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,22,0 - PRODUCTVERSION 0,6,22,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.22" + VALUE "FileVersion", "0.6.23" END END BLOCK "VarFileInfo" diff --git a/src/ocr.cpp b/src/ocr.cpp index 7f8a2b8..d76bfb7 100644 --- a/src/ocr.cpp +++ b/src/ocr.cpp @@ -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 factory; + check(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, + IID_PPV_ARGS(factory.out()))); + Com stream; + check(CreateStreamOnHGlobal(nullptr, TRUE, stream.out())); + Com encoder; + check(factory->CreateEncoder(GUID_ContainerFormatPng, nullptr, encoder.out())); + check(encoder->Initialize(stream.p, WICBitmapEncoderNoCache)); + Com 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(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{}; diff --git a/src/ocr.h b/src/ocr.h index 56d5d65..10b3448 100644 --- a/src/ocr.h +++ b/src/ocr.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 &); diff --git a/src/platform.cpp b/src/platform.cpp index e1c26b7..4235571 100644 --- a/src/platform.cpp +++ b/src/platform.cpp @@ -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 &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(); diff --git a/src/screenshot.cpp b/src/screenshot.cpp index c96adb9..41bf54c 100644 --- a/src/screenshot.cpp +++ b/src/screenshot.cpp @@ -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=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;iremember(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) diff --git a/src/update.h b/src/update.h index ab4ce07..ea6eca6 100644 --- a/src/update.h +++ b/src/update.h @@ -2,7 +2,7 @@ #include "platform.h" namespace ks { -inline constexpr wchar_t AppVersion[] = L"0.6.22"; +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; diff --git a/tests/screenshot_ui.cpp b/tests/screenshot_ui.cpp index d3eadcd..701b0f0 100644 --- a/tests/screenshot_ui.cpp +++ b/tests/screenshot_ui.cpp @@ -2,10 +2,67 @@ // Including the implementation lets this integration test drive private editor state directly. #include "../src/screenshot.cpp" #include -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(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;iBOOL { + 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); diff --git a/tests/tests.cpp b/tests/tests.cpp index b3a3cce..fdbcb5f 100644 --- a/tests/tests.cpp +++ b/tests/tests.cpp @@ -311,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); @@ -327,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"релізик"}; diff --git a/tools/build-dictionaries.py b/tools/build-dictionaries.py index 7ff983e..138e78a 100644 --- a/tools/build-dictionaries.py +++ b/tools/build-dictionaries.py @@ -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'):