diff --git a/CMakeLists.txt b/CMakeLists.txt index 2eff0bb..f9a5968 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.24) -project(KSwitcher VERSION 0.6.19 LANGUAGES CXX RC) +project(KSwitcher VERSION 0.6.20 LANGUAGES CXX RC) if(NOT WIN32) message(FATAL_ERROR "KSwitcher uses native Windows APIs") endif() @@ -17,6 +17,8 @@ set_target_properties(KSwitcherConfig PROPERTIES OUTPUT_NAME "KSwitcher.Config") target_compile_definitions(KSwitcherConfig PRIVATE KS_CONFIG_APP) target_link_libraries(KSwitcherConfig PRIVATE kscore) add_executable(KSwitcherTests tests/tests.cpp) +add_executable(KSwitcherScrollTests tests/scroll.cpp) +target_link_libraries(KSwitcherScrollTests PRIVATE kscore) target_link_libraries(KSwitcherTests PRIVATE kscore) add_executable(KSwitcherIntegration tests/integration.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc) target_link_libraries(KSwitcherIntegration PRIVATE kscore) @@ -39,7 +41,7 @@ endif() add_executable(KSwitcherOcrTests tests/ocr.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc) target_link_libraries(KSwitcherOcrTests PRIVATE kscore) if(MSVC) - set_property(TARGET kscore KSwitcher KSwitcherConfig KSwitcherScreenshot KSwitcherTests KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") + set_property(TARGET kscore KSwitcher KSwitcherConfig KSwitcherScreenshot KSwitcherTests KSwitcherScrollTests KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") target_compile_options(kscore PRIVATE /W4 /utf-8) target_compile_options(KSwitcher PRIVATE /W4 /utf-8) target_compile_options(KSwitcherConfig PRIVATE /W4 /utf-8) @@ -49,12 +51,14 @@ else() target_link_options(KSwitcherConfig PRIVATE -municode -static -static-libgcc -static-libstdc++) target_link_options(KSwitcherScreenshot PRIVATE -municode -static -static-libgcc -static-libstdc++) target_link_options(KSwitcherTests PRIVATE -static -static-libgcc -static-libstdc++) + target_link_options(KSwitcherScrollTests PRIVATE -static -static-libgcc -static-libstdc++) target_link_options(KSwitcherIntegration PRIVATE -static -static-libgcc -static-libstdc++) target_link_options(KSwitcherInputTests PRIVATE -static -static-libgcc -static-libstdc++) target_link_options(KSwitcherOcrTests PRIVATE -static -static-libgcc -static-libstdc++) endif() enable_testing() 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 updater-regressions COMMAND powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${CMAKE_CURRENT_SOURCE_DIR}/tests/update.ps1" -Executable "$") diff --git a/README.md b/README.md index 39b1ebb..491e53c 100644 --- a/README.md +++ b/README.md @@ -278,6 +278,21 @@ existing saved shortcuts are preserved. While KSwitcher runs, this binding opens History controls: Enter to paste, Shift+Enter for plain text, Ctrl+1…9 for quick paste, Ctrl+P to pin/unpin, Delete to delete, Ctrl+O for the full item, Escape to hide. +For an image, **Edit image**, **Ctrl+O**, or **Edit image in screenshot editor** +in the context menu opens the original image in the built-in screenshot editor. +Annotate it there, then copy the result. Opening the editor leaves the +clipboard and original history entry unchanged. Text items still open as text; +Enter and double-click still paste the selected item. + +Local builds made with `build.ps1` show **[LOCAL DEV]** in window titles and +the tray tooltip. The marker is enabled by `.local-dev` beside the executable; +release packages do not include that marker. + +Fast/inertial wheel events use a shared batching handler in history, layout and +snippet lists, the log table, multiline text/details fields, combo boxes and +scrollable settings windows. High-resolution partial wheel steps are retained. +History pauses new preview loads while scrolling and keeps visible thumbnails +cached, preventing continuous flicker and needless redraws. Pressing the history shortcut a second time pastes the second-most-recent item. ## Data and migration diff --git a/Release/KSwitcher-win-x64.zip b/Release/KSwitcher-win-x64.zip index 0ac2211..4283e91 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 20b154d..78d529a 100644 --- a/Release/README.md +++ b/Release/README.md @@ -1,23 +1,24 @@ # Latest Windows release -## v0.6.19 — 2026-10-31 +## v0.6.20 — 2026-10-31 ## Changes -- Retry a silently dropped Ctrl+C up to three total attempts, only while the transaction clipboard remains unchanged and the original input context is still valid. Failed snippet verification preserves the trigger and following input without sending Enter. -- Exclude temporary transaction clipboard contents from Windows clipboard history and cloud synchronization. The original clipboard snapshot is still restored after the operation when ownership permits. -- Fix snippet integration test isolation: restore a multiline editor after earlier single-line scenarios, reset layout/correction state, coordinate test-editor clipboard access and disable unrelated background update checks. -- Add deterministic coverage for one dropped copy and exhausted retries. Keep exact assertions for Enter ordering, buffered typing, clipboard restoration and subsequent auto-correction. +- Open clipboard-history images in the built-in screenshot editor with **Edit image**, **Ctrl+O**, or **Edit image in screenshot editor** in the context menu. PNG, DIB and DIBV5 open at full resolution through an encrypted local cache, without requiring notifications or changing the clipboard/original history entry. Copy the annotated result explicitly from the editor. +- Fix continuous history flicker by keeping visible previews cached, invalidating only completed preview rows and preserving the top entry across background refreshes. +- Handle fast/inertial wheels through one shared batching mechanism for history, layout/snippet lists, the log table, multiline text/details fields, combo boxes and scrollable settings windows. Preserve partial wheel deltas, clamp list boundaries and pause new history previews until scrolling stops. +- Mark local developer builds with **[LOCAL DEV]** in window titles and the tray tooltip. Release packages omit the development marker. This does not create a separate data profile. +- Right-clicking a history entry selects that entry before showing its actions. Enter and double-click continue to paste; text items still open in the full-text viewer. ## Validation -The fixes passed 230 native regression checks, 472 input/UI checks, screenshot editor UI and notification registration checks, 48 updater checks and updater progress checks. The full editor integration suite passed all 63 assertions; conversion-specific diagnostics also passed. The final snippet suite passed 20 consecutive runs, including injected copy failures and the three-attempt limit. The v0.6.19 staged Release build also passed the native, input/UI, screenshot and updater checks; the installed application was not replaced or restarted. +Passed 406 native regressions, 473 input/UI checks, shared wheel batching tests, screenshot editor UI, notification registration, 48 updater checks and updater progress checks. Manual testing confirmed image editing from history and resolved clipboard flicker and Logitech inertial-wheel beeping. Shared wheel tests cover lists, edits, tables, combo boxes and windows, partial steps, large bursts, selection preservation and list boundaries. ## Limitations -The fix does not retry paste blindly: an editor may already have changed its selection or text. Clipboard contention, editor capabilities and input privileges can still prevent an operation. Existing accessibility readback limitations and problem-only diagnostic logging remain unchanged. +Image decoding or a normalized image larger than the 128 MiB editor-transfer limit can prevent opening; an error is shown. Existing clipboard, OCR and editor privilege limitations remain. The wheel over screenshot annotations changes size/thickness rather than scrolling. **Upgrade:** choose Update in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation. -For manual installation, exit KSwitcher and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries. +For manual installation, exit KSwitcher, close screenshot editors and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries. -Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains all three executables, a release manifest, offline dictionaries, documentation and license notices. It contains no personal settings, clipboard history, Tesseract installation or language models. +Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains all three executables, a release manifest, offline dictionaries, documentation and license notices. It contains no personal settings, clipboard history, development marker, Tesseract installation or language models. Build and package: diff --git a/Release/SHA256SUMS.txt b/Release/SHA256SUMS.txt index 8af43a8..ebcea9e 100644 --- a/Release/SHA256SUMS.txt +++ b/Release/SHA256SUMS.txt @@ -1 +1 @@ -8b2008e97ca625b7eebdf509c038d6d1f6c5c853dce6da1b939abaa18223e26d KSwitcher-win-x64.zip +0b3c4fd1346d0b4eeb73d276193c5d2789fd32986c3cc75384cf489f23629678 KSwitcher-win-x64.zip diff --git a/build.ps1 b/build.ps1 index ed1ff23..79ce46e 100644 --- a/build.ps1 +++ b/build.ps1 @@ -10,6 +10,8 @@ $outDir = Join-Path $root 'build' New-Item -ItemType Directory -Force $outDir | Out-Null $executablePath = if ($Stage) { Join-Path $outDir 'staged\KSwitcher.exe' } else { Join-Path $outDir 'KSwitcher.exe' } New-Item -ItemType Directory -Force (Split-Path $executablePath) | Out-Null +Set-Content -LiteralPath (Join-Path $outDir '.local-dev') -Value 'Local development build' -Encoding ascii +Set-Content -LiteralPath (Join-Path (Split-Path $executablePath) '.local-dev') -Value 'Local development build' -Encoding ascii Push-Location $root try { $common = @('-std=c++20','-Os','-DNDEBUG','-DUNICODE','-D_UNICODE','-DWIN32_LEAN_AND_MEAN','-DNOMINMAX','-D_WIN32_WINNT=0x0A00','-Wall','-Wextra','-Wno-missing-field-initializers','-Isrc','-Ivendor','-ffunction-sections','-fdata-sections') @@ -39,6 +41,10 @@ try { if ($LASTEXITCODE) { throw 'Screenshot link failed' } Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force if ($Test) { + & $compiler @common tests/scroll.cpp @libs -static -s -o build/KSwitcherScrollTests.exe + if ($LASTEXITCODE) { throw 'Scroll test build failed' } + & .\build\KSwitcherScrollTests.exe + if ($LASTEXITCODE) { throw 'Scroll tests failed' } & $compiler @common tests/tests.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o @libs -static -s -o build/KSwitcherTests.exe if ($LASTEXITCODE) { throw 'Test build failed' } & .\build\KSwitcherTests.exe diff --git a/resources/app.rc b/resources/app.rc index 7fce710..f99b0f1 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,19,0 - PRODUCTVERSION 0,6,19,0 + FILEVERSION 0,6,20,0 + PRODUCTVERSION 0,6,20,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.19" + VALUE "FileVersion", "0.6.20" END END BLOCK "VarFileInfo" diff --git a/src/app.cpp b/src/app.cpp index 9ac5337..f678fd5 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -433,7 +433,7 @@ void App::addTray() icon.uCallbackMessage = TrayMessage; icon.hIcon = (HICON)LoadImageW(instance, MAKEINTRESOURCEW(IDI_KSWITCHER), IMAGE_ICON, GetSystemMetrics(SM_CXSMICON), GetSystemMetrics(SM_CYSMICON), LR_SHARED); - wcscpy_s(icon.szTip, L"KSwitcher — keyboard layouts & clipboard"); + wcscpy_s(icon.szTip, localBuildTitle(L"KSwitcher — keyboard layouts & clipboard").c_str()); Shell_NotifyIconW(NIM_ADD, &icon); icon.uVersion = NOTIFYICON_VERSION_4; Shell_NotifyIconW(NIM_SETVERSION, &icon); diff --git a/src/app.h b/src/app.h index 551f496..ee2ef0f 100644 --- a/src/app.h +++ b/src/app.h @@ -120,6 +120,7 @@ class App }; void showHistory(App &, bool visible = true); void launchScreenshot(App &); +void launchScreenshotEditor(App &, const Clip &); void refreshHistory(App &); void showSettings(App &, bool visible = true); void showLogs(App &); diff --git a/src/history_preview.h b/src/history_preview.h new file mode 100644 index 0000000..69a8373 --- /dev/null +++ b/src/history_preview.h @@ -0,0 +1,17 @@ +#pragma once +#include +#include +#include + +namespace ks { +// Keep the visible working set even in unusually tall windows. Never evict a +// visible preview to admit another visible preview: that creates a paint loop. +template +void trimHistoryPreviews(std::map &cache, + const std::set &visible, size_t limit=32) +{ + for(auto it=cache.begin(); cache.size()>limit && it!=cache.end();) + if(!visible.contains(it->first)) it=cache.erase(it); + else ++it; +} +} diff --git a/src/platform.h b/src/platform.h index ad48a11..744b284 100644 --- a/src/platform.h +++ b/src/platform.h @@ -8,6 +8,16 @@ namespace ks { +inline std::wstring localBuildTitle(const std::wstring &title) +{ + static const bool development=[] { + wchar_t path[32768]{}; + if(!GetModuleFileNameW(nullptr,path,std::size(path))) return false; + std::error_code error; + return std::filesystem::exists(std::filesystem::path(path).parent_path()/L".local-dev",error); + }(); + return development?title+L" [LOCAL DEV]":title; +} using Bytes = std::vector; struct Format { diff --git a/src/screenshot.cpp b/src/screenshot.cpp index 748ef0c..c96adb9 100644 --- a/src/screenshot.cpp +++ b/src/screenshot.cpp @@ -841,7 +841,7 @@ void edit(HWND w, Capture &c) SetWindowLongPtrW(w,GWL_EXSTYLE,(GetWindowLongPtrW(w,GWL_EXSTYLE)|WS_EX_APPWINDOW)&~WS_EX_TOOLWINDOW); SetWindowLongPtrW(w,GWL_STYLE,WS_OVERLAPPEDWINDOW | WS_CLIPCHILDREN); auto title=L"KSwitcher — Screenshot #"+std::to_wstring(nextEditorIndex++); - SetWindowTextW(w,title.c_str()); + SetWindowTextW(w,localBuildTitle(title).c_str()); INITCOMMONCONTROLSEX statusControls{sizeof(statusControls),ICC_BAR_CLASSES}; InitCommonControlsEx(&statusControls); CreateWindowExW(0,STATUSCLASSNAMEW,L"",WS_CHILD|WS_VISIBLE|SBARS_TOOLTIPS, diff --git a/src/screenshot_launcher.cpp b/src/screenshot_launcher.cpp index e911aee..5a8a012 100644 --- a/src/screenshot_launcher.cpp +++ b/src/screenshot_launcher.cpp @@ -1,13 +1,15 @@ #include "app.h" +#include "screenshot_notifications.h" namespace ks { -void launchScreenshot(App &app) +static void launch(App &app, const Clip *clip) { auto path=std::filesystem::path(executable()).parent_path()/L"KSwitcher.Screenshot.exe"; if (!std::filesystem::exists(path)) throw std::runtime_error("KSwitcher.Screenshot.exe is missing. Extract the complete application package."); - auto command=L"\""+path.wstring()+L"\" --auto-open-editor="+ - (app.settings.screenshotAutoOpenEditor?L"1":L"0"); + auto command=L"\""+path.wstring()+L"\" "; + if(clip) command+=L"--open kswitcher-screenshot:"+cacheScreenshotForEditor(*clip); + else command+=std::wstring(L"--auto-open-editor=")+(app.settings.screenshotAutoOpenEditor?L"1":L"0"); if(app.settings.writeLog) command+=L" --write-log"; STARTUPINFOW startup{sizeof(startup)}; PROCESS_INFORMATION process{}; @@ -20,4 +22,6 @@ void launchScreenshot(App &app) CloseHandle(process.hThread); CloseHandle(process.hProcess); } +void launchScreenshot(App &app) { launch(app,nullptr); } +void launchScreenshotEditor(App &app,const Clip &clip) { launch(app,&clip); } } diff --git a/src/screenshot_notifications.cpp b/src/screenshot_notifications.cpp index f7fafe0..d25118e 100644 --- a/src/screenshot_notifications.cpp +++ b/src/screenshot_notifications.cpp @@ -112,6 +112,19 @@ std::wstring escapeXml(const std::wstring &s) return result; } } +std::wstring cacheScreenshotForEditor(const Clip &clip) +{ + // Normalize PNG/DIBV5 and other supported clipboard images at full resolution. + auto pixels=decodeImage(clip); + if(pixels.bgra.empty() || pixels.bgra.size()>128*1024*1024-sizeof(BITMAPINFOHEADER)) + throw std::runtime_error("Image is too large for the screenshot editor"); + auto dib=imageDib(pixels); + auto directory=cacheDirectory(); cleanCache(directory); + GUID guid{}; check(CoCreateGuid(&guid)); wchar_t value[40]{}; StringFromGUID2(guid,value,40); + std::wstring id(value+1,36); + atomicWrite(directory/(id+L".capture"),protect(dib.data)); + return id; +} Clip loadNotifiedScreenshot(const std::wstring &id) { if(!validScreenshotToken(id)) throw std::runtime_error("Invalid screenshot notification"); diff --git a/src/screenshot_notifications.h b/src/screenshot_notifications.h index f1dad16..b5d3bfa 100644 --- a/src/screenshot_notifications.h +++ b/src/screenshot_notifications.h @@ -8,4 +8,5 @@ int runScreenshotToastActivator(); DWORD startScreenshotActivationListener(); bool validScreenshotToken(const std::wstring &); Clip loadNotifiedScreenshot(const std::wstring &); +std::wstring cacheScreenshotForEditor(const Clip &); } diff --git a/src/ui.cpp b/src/ui.cpp index 5e3a8ab..59e64ce 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -1,5 +1,7 @@ #include "../resources/resource.h" #include "app.h" +#include "history_preview.h" +#include "ui_scroll.h" #include "configuration.h" #include "ocr.h" #include "richtext.h" @@ -238,6 +240,9 @@ HWND control(HWND parent, const wchar_t *cls, const wchar_t *title, DWORD style, scale(parent, width), scale(parent, height), parent, (HMENU)(INT_PTR)id, GetModuleHandleW(nullptr), nullptr); SendMessageW(w, WM_SETFONT, (WPARAM)GetPropW(parent, L"Font"), TRUE); + if(wcscmp(cls,L"LISTBOX")==0 || wcscmp(cls,WC_LISTVIEWW)==0 || + wcscmp(cls,L"COMBOBOX")==0 || (wcscmp(cls,L"EDIT")==0 && (style&ES_MULTILINE))) + enableWheelBatching(w,wcscmp(cls,L"LISTBOX")==0); if (auto *layout = windowLayoutState(parent); layout && layout->scrolling) { layout->controls.push_back({w, {x, y, x + width, y + height}}); @@ -368,11 +373,12 @@ HWND makeWindow(App &app, const wchar_t *cls, const wchar_t *title, WNDPROC proc int top = monitor.rcWork.top + std::max(0L, (monitor.rcWork.bottom - monitor.rcWork.top - outerHeight) / 2); SetPropW(w, L"WindowLayout", new WindowLayout{width, height, 0, 0, !(extra & WS_THICKFRAME)}); SetWindowSubclass(w, layoutProc, 2, 0); + if(windowLayoutState(w)->scrolling) enableWheelBatching(w); RECT initial = fitWindowRect({left, top, left + outerWidth, top + outerHeight}, monitor.rcWork); SetWindowPos(w, nullptr, initial.left, initial.top, initial.right - initial.left, initial.bottom - initial.top, SWP_NOACTIVATE | SWP_NOZORDER); arrangeWindow(w); - SetWindowTextW(w, title); + SetWindowTextW(w, localBuildTitle(title).c_str()); auto smallIcon = (HICON)LoadImageW(app.instance, MAKEINTRESOURCEW(IDI_KSWITCHER), IMAGE_ICON, GetSystemMetricsForDpi(SM_CXSMICON, dpi), GetSystemMetricsForDpi(SM_CYSMICON, dpi), LR_SHARED); @@ -478,7 +484,17 @@ struct HistoryView std::map richTexts; HWND rich{}; std::set pending; + bool scrolling{}; }; +bool selectedImage(HWND w, HistoryView *v) +{ + int i=(int)SendDlgItemMessageW(w,102,LB_GETCURSEL,0,0); + return i>=0 && i<(int)v->items.size() && v->items[i].image; +} +void updateHistoryActions(HWND w, HistoryView *v) +{ + SetDlgItemTextW(w,114,selectedImage(w,v)?L"Edit image":L"Open item"); +} std::wstring selected(HWND w, HistoryView *v) { int i = (int)SendDlgItemMessageW(w, 102, LB_GETCURSEL, 0, 0); @@ -507,6 +523,7 @@ void search(HWND w, HistoryView *v) } void requestPreview(HWND w, HistoryView *v, const Entry &entry) { + if(v->scrolling) return; const auto id = entry.id; const bool image = entry.image; if (v->thumbnails.contains(id) || v->richTexts.contains(id) || !v->pending.insert(id).second) @@ -690,6 +707,12 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) { switch (msg) { + case ScrollActivity: + if((HWND)wp==GetDlgItem(w,102)) { + v->scrolling=lp!=0; + if(!v->scrolling) InvalidateRect((HWND)wp,nullptr,FALSE); + } + return 0; case WM_COMMAND: { int id = LOWORD(wp); @@ -702,6 +725,7 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) } if (id == 102 && HIWORD(wp) == LBN_SELCHANGE) { + updateHistoryActions(w,v); return 0; } if (id == 102 && HIWORD(wp) == LBN_DBLCLK) @@ -747,10 +771,13 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) if (result->generation != v->generation) return 0; auto old = selected(w, v); + int top=(int)SendDlgItemMessageW(w,102,LB_GETTOPINDEX,0,0); + auto topId=top>=0 && top<(int)v->items.size()?v->items[top].id:L""; v->items = std::move(result->items); SendDlgItemMessageW(w, 102, WM_SETREDRAW, FALSE, 0); SendDlgItemMessageW(w, 102, LB_RESETCONTENT, 0, 0); int select = 0; + int restoreTop=0; for (size_t i = 0; i < v->items.size(); ++i) { auto &e = v->items[i]; @@ -758,8 +785,11 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) SendDlgItemMessageW(w, 102, LB_ADDSTRING, 0, (LPARAM)text.c_str()); if (e.id == old) select = (int)i; + if(e.id==topId) restoreTop=(int)i; } SendDlgItemMessageW(w, 102, LB_SETCURSEL, select, 0); + SendDlgItemMessageW(w,102,LB_SETTOPINDEX,restoreTop,0); + updateHistoryActions(w,v); SendDlgItemMessageW(w, 102, WM_SETREDRAW, TRUE, 0); InvalidateRect(GetDlgItem(w, 102), nullptr, TRUE); return 0; @@ -770,16 +800,25 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) if (result->generation != v->generation || !IsWindowVisible(w)) return 0; v->pending.erase(result->id); - // At most ~3.3 MiB, irrespective of history length or source image size. - if (v->thumbnails.size() >= 32) - v->thumbnails.erase(v->thumbnails.begin()); - if (v->richTexts.size() >= 32) - v->richTexts.erase(v->richTexts.begin()); + auto list=GetDlgItem(w,102); + RECT client{}; GetClientRect(list,&client); + std::set visible; + RECT changed{}; bool repaint=false; + int top=(int)SendMessageW(list,LB_GETTOPINDEX,0,0); + for(int i=std::max(0,top);i<(int)v->items.size();++i) { + RECT row{}, intersection{}; + if(SendMessageW(list,LB_GETITEMRECT,i,(LPARAM)&row)==LB_ERR || row.top>=client.bottom) break; + if(!IntersectRect(&intersection,&row,&client)) continue; + visible.insert(v->items[i].id); + if(v->items[i].id==result->id) { changed=intersection; repaint=true; } + } if (!result->rtf.empty()) v->richTexts[result->id] = std::move(result->rtf); else v->thumbnails[result->id] = std::move(result->pixels); - InvalidateRect(GetDlgItem(w, 102), nullptr, FALSE); + trimHistoryPreviews(v->thumbnails,visible); + trimHistoryPreviews(v->richTexts,visible); + if(repaint) InvalidateRect(list,&changed,FALSE); return 0; } case WM_MEASUREITEM: @@ -798,15 +837,29 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) break; case WM_CONTEXTMENU: { + auto list=GetDlgItem(w,102); + if((HWND)wp!=list) break; + POINT p{(short)LOWORD(lp),(short)HIWORD(lp)}; + if(lp!=-1) { + POINT hit=p; ScreenToClient(list,&hit); + auto item=SendMessageW(list,LB_ITEMFROMPOINT,0,MAKELPARAM(hit.x,hit.y)); + if(HIWORD(item)) return 0; + SendMessageW(list,LB_SETCURSEL,LOWORD(item),0); + updateHistoryActions(w,v); + } else { + RECT row{}; + auto index=SendMessageW(list,LB_GETCURSEL,0,0); + if(index==LB_ERR) return 0; + SendMessageW(list,LB_GETITEMRECT,index,(LPARAM)&row); + p={row.left,row.top}; ClientToScreen(list,&p); + } HMENU menu = CreatePopupMenu(); AppendMenuW(menu, MF_STRING, 110, L"Paste"); AppendMenuW(menu, MF_STRING, 111, L"Paste plain text"); AppendMenuW(menu, MF_STRING, 112, L"Pin / unpin"); AppendMenuW(menu, MF_STRING, 113, L"Delete"); - AppendMenuW(menu, MF_STRING, 114, L"Open full item"); + AppendMenuW(menu, MF_STRING, 114, selectedImage(w,v)?L"Edit image in screenshot editor":L"Open full item"); AppendMenuW(menu, MF_STRING, 115, L"Copy text from image (OCR)"); - POINT p; - GetCursorPos(&p); int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, p.x, p.y, 0, w, nullptr); DestroyMenu(menu); if (cmd) @@ -1700,11 +1753,17 @@ void showEntry(App &app, const std::wstring &id) try { auto clip = app.history->load(id); + if(std::any_of(clip.formats.begin(),clip.formats.end(),[](const auto &format) { + return format.id==CF_DIB || format.id==CF_DIBV5 || format.name==L"PNG"; + })) { + launchScreenshotEditor(app,clip); + return; + } auto text = clip.text(); if (text.empty()) { MessageBoxW(app.historyWindow, - L"This item contains an image or files. Use the preview or paste it into the " + L"This item contains files. Paste it into the " L"destination application.", L"Clipboard item", MB_OK); return; diff --git a/src/ui_scroll.h b/src/ui_scroll.h new file mode 100644 index 0000000..6e6bcb8 --- /dev/null +++ b/src/ui_scroll.h @@ -0,0 +1,83 @@ +#pragma once +#include "platform.h" +#include +#include +#include + +namespace ks { +inline constexpr UINT ScrollActivity=WM_APP+42; +inline constexpr UINT_PTR ScrollFrameTimer=0x4b5301, ScrollIdleTimer=0x4b5302; +struct WheelBatch { + long long vertical{}, horizontal{}; + WORD keys{}; + LPARAM point{}; + bool frame{}, active{}, listBox{}; + int remainder{}; +}; +inline LRESULT CALLBACK wheelBatchProc(HWND w,UINT msg,WPARAM wp,LPARAM lp,UINT_PTR id,DWORD_PTR data) +{ + auto *batch=reinterpret_cast(data); + if(msg==WM_MOUSEWHEEL || msg==WM_MOUSEHWHEEL) { + (msg==WM_MOUSEWHEEL?batch->vertical:batch->horizontal)+=GET_WHEEL_DELTA_WPARAM(wp); + batch->keys=GET_KEYSTATE_WPARAM(wp); batch->point=lp; + if(!batch->active) { + batch->active=true; + SendMessageW(GetParent(w),ScrollActivity,(WPARAM)w,TRUE); + } + // Do not restart the frame timer: an uninterrupted wheel stream must + // keep moving, rather than wait for a debounce interval to expire. + if(!batch->frame) batch->frame=SetTimer(w,ScrollFrameTimer,16,nullptr)!=0; + SetTimer(w,ScrollIdleTimer,100,nullptr); + return 0; + } + if(msg==WM_TIMER && wp==ScrollFrameTimer) { + KillTimer(w,ScrollFrameTimer); batch->frame=false; + if(batch->listBox && batch->vertical) { + UINT lines=3; SystemParametersInfoW(SPI_GETWHEELSCROLLLINES,0,&lines,0); + if(lines==WHEEL_PAGESCROLL) { + RECT client{}; GetClientRect(w,&client); + auto height=SendMessageW(w,LB_GETITEMHEIGHT,0,0); + lines=std::max(1,client.bottom/std::max(1,height)); + } + auto delta=batch->vertical+batch->remainder; + batch->remainder=(int)(delta%WHEEL_DELTA); + auto top=SendMessageW(w,LB_GETTOPINDEX,0,0); + auto count=SendMessageW(w,LB_GETCOUNT,0,0); + auto next=std::clamp(top-(delta/WHEEL_DELTA)*lines,0,std::max(0,count-1)); + if(next!=top) SendMessageW(w,LB_SETTOPINDEX,(WPARAM)next,0); + batch->vertical=0; + } else if(batch->vertical) { + // Native edit/list-view/window handlers retain their own scrolling + // semantics, including system settings and partial wheel deltas. + auto delta=(short)std::clamp(batch->vertical,-32760,32760); + batch->vertical-=delta; + DefSubclassProc(w,WM_MOUSEWHEEL,MAKEWPARAM(batch->keys,(WORD)delta),batch->point); + } + if(batch->horizontal) { + auto delta=(short)std::clamp(batch->horizontal,-32760,32760); + batch->horizontal-=delta; + DefSubclassProc(w,WM_MOUSEHWHEEL,MAKEWPARAM(batch->keys,(WORD)delta),batch->point); + } + if(batch->vertical || batch->horizontal) + batch->frame=SetTimer(w,ScrollFrameTimer,16,nullptr)!=0; + return 0; + } + if(msg==WM_TIMER && wp==ScrollIdleTimer) { + if(batch->frame) return 0; + KillTimer(w,ScrollIdleTimer); batch->active=false; + SendMessageW(GetParent(w),ScrollActivity,(WPARAM)w,FALSE); + return 0; + } + if(msg==WM_NCDESTROY) { + KillTimer(w,ScrollFrameTimer); KillTimer(w,ScrollIdleTimer); + RemoveWindowSubclass(w,wheelBatchProc,id); delete batch; + } + return DefSubclassProc(w,msg,wp,lp); +} +inline void enableWheelBatching(HWND window,bool listBox=false) +{ + if(!window) return; + auto *batch=new WheelBatch; batch->listBox=listBox; + if(!SetWindowSubclass(window,wheelBatchProc,ScrollFrameTimer,(DWORD_PTR)batch)) delete batch; +} +} diff --git a/src/update.h b/src/update.h index 0f50084..4edb351 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.19"; +inline constexpr wchar_t AppVersion[] = L"0.6.20"; 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/input.cpp b/tests/input.cpp index c23ded4..7e99777 100644 --- a/tests/input.cpp +++ b/tests/input.cpp @@ -1,4 +1,5 @@ #include "app.h" +#include "ui_scroll.h" #include "ui_geometry.h" #include "screenshot.h" #include @@ -728,16 +729,21 @@ int main(int argc, char **argv) { controlMoves=0; ValidateRect(settings,nullptr); SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WHEEL_DELTA),0); + SendMessageW(settings,WM_TIMER,ks::ScrollFrameTimer,0); require(controlMoves==0 && !GetUpdateRect(settings,nullptr,FALSE),"wheel at top does not move controls or erase the window"); SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WORD(short(-WHEEL_DELTA/2))),0); + SendMessageW(settings,WM_TIMER,ks::ScrollFrameTimer,0); require(controlMoves==0,"partial wheel step does not rearrange controls"); SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WORD(short(-WHEEL_DELTA/2))),0); + require(controlMoves==0,"wheel changes wait for the shared frame timer"); + SendMessageW(settings,WM_TIMER,ks::ScrollFrameTimer,0); require(controlMoves==1,"wheel scroll batches each control movement once"); SendMessageW(settings,WM_VSCROLL,SB_BOTTOM,0); SendMessageW(settings,WM_HSCROLL,SB_RIGHT,0); controlMoves=0; SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,-WHEEL_DELTA),0); SendMessageW(settings,WM_MOUSEHWHEEL,MAKEWPARAM(0,WHEEL_DELTA),0); + SendMessageW(settings,WM_TIMER,ks::ScrollFrameTimer,0); require(controlMoves==0,"wheel at bottom and right boundaries does not rearrange controls"); require((GetWindowLongPtrW(settings,GWL_STYLE)&WS_CLIPCHILDREN)!=0,"parent background excludes child controls"); RemoveWindowSubclass(trackedControl,countControlMoves,99); diff --git a/tests/screenshot_ui.cpp b/tests/screenshot_ui.cpp index e5b53c3..d3eadcd 100644 --- a/tests/screenshot_ui.cpp +++ b/tests/screenshot_ui.cpp @@ -175,7 +175,7 @@ int main() } c.marks.clear(); SendMessageW(window,WM_COMMAND,27,0); wchar_t windowTitle[128]{}; GetWindowTextW(window,windowTitle,128); - require(std::wstring(windowTitle)==L"KSwitcher — Screenshot #1","title includes editor index without usage hints"); + require(std::wstring(windowTitle)==ks::localBuildTitle(L"KSwitcher — Screenshot #1"),"title includes editor index and local build marker without usage hints"); RECT client{}; GetClientRect(window,&client); require(c.view.bottom<=client.bottom-c.statusHeight,"image does not overlap status bar"); ks::updateStatus(c); diff --git a/tests/scroll.cpp b/tests/scroll.cpp new file mode 100644 index 0000000..d9473ab --- /dev/null +++ b/tests/scroll.cpp @@ -0,0 +1,66 @@ +#include "ui_scroll.h" +#include + +namespace { +int forwarded{}; +long long totalDelta{}; +LRESULT CALLBACK observe(HWND w,UINT msg,WPARAM wp,LPARAM lp,UINT_PTR,DWORD_PTR) +{ + if(msg==WM_MOUSEWHEEL) { ++forwarded; totalDelta+=GET_WHEEL_DELTA_WPARAM(wp); } + return DefSubclassProc(w,msg,wp,lp); +} +void require(bool ok,const char *message) { if(!ok) throw std::runtime_error(message); } +void wheel(HWND w,int delta) { SendMessageW(w,WM_MOUSEWHEEL,MAKEWPARAM(0,(WORD)(short)delta),0); } +void flush(HWND w) { SendMessageW(w,WM_TIMER,ks::ScrollFrameTimer,0); } +} +int main() +{ + INITCOMMONCONTROLSEX init{sizeof(init),ICC_STANDARD_CLASSES|ICC_LISTVIEW_CLASSES}; + InitCommonControlsEx(&init); + auto instance=GetModuleHandleW(nullptr); + auto parent=CreateWindowW(L"STATIC",L"Scroll regression",WS_OVERLAPPEDWINDOW,0,0,500,400,nullptr,nullptr,instance,nullptr); + try { + auto list=CreateWindowW(L"LISTBOX",L"",WS_CHILD|WS_VSCROLL,0,0,300,200,parent,nullptr,instance,nullptr); + for(int i=0;i<2000;++i) SendMessageW(list,LB_ADDSTRING,0,(LPARAM)L"row"); + SendMessageW(list,LB_SETCURSEL,5,0); + SendMessageW(list,LB_SETTOPINDEX,0,0); + ks::enableWheelBatching(list,true); + for(int i=0;i<1000;++i) wheel(list,-120); + require(SendMessageW(list,LB_GETTOPINDEX,0,0)==0,"burst is deferred to one frame"); + flush(list); + UINT lines{}; SystemParametersInfoW(SPI_GETWHEELSCROLLLINES,0,&lines,0); + require(!lines || SendMessageW(list,LB_GETTOPINDEX,0,0)>0,"burst moves list"); + require(SendMessageW(list,LB_GETCURSEL,0,0)==5,"scroll does not change selection"); + for(int i=0;i<1000;++i) wheel(list,120); + flush(list); + require(SendMessageW(list,LB_GETTOPINDEX,0,0)==0,"reverse burst clamps at top"); + wheel(list,-60); flush(list); + require(SendMessageW(list,LB_GETTOPINDEX,0,0)==0,"partial wheel step is retained"); + wheel(list,-60); flush(list); + require(!lines || SendMessageW(list,LB_GETTOPINDEX,0,0)>0,"partial steps combine across frames"); + DestroyWindow(list); + for(auto cls:{L"EDIT",WC_LISTVIEWW,L"COMBOBOX",L"STATIC"}) { + auto w=CreateWindowW(cls,L"",WS_CHILD|WS_VSCROLL,0,0,300,200,parent,nullptr,instance,nullptr); + require(w!=nullptr,"native scroll control created"); + SetWindowSubclass(w,observe,1,0); ks::enableWheelBatching(w); + forwarded=0; totalDelta=0; + for(int i=0;i<100;++i) wheel(w,-120); + require(forwarded==0,"native wheel burst is batched"); + flush(w); + require(forwarded==1 && totalDelta==-12000,"one native dispatch preserves burst distance"); + wheel(w,60); flush(w); + require(forwarded==2 && totalDelta==-11940,"native partial steps are preserved"); + forwarded=0; totalDelta=0; + for(int i=0;i<400;++i) wheel(w,-120); + flush(w); flush(w); + require(forwarded==2 && totalDelta==-48000,"large native burst is not truncated to 16 bits"); + SendMessageW(w,WM_TIMER,ks::ScrollIdleTimer,0); + DestroyWindow(w); + } + DestroyWindow(parent); + std::cout<<"Passed shared wheel batching regressions (list, edit, table, combo, window)\n"; + return 0; + } catch(const std::exception &e) { + DestroyWindow(parent); std::cerr< &f, const char *name) bool thrown = false; try { + { + std::map previews; + std::set visible; + for(int i=0;i<40;++i) previews[std::to_wstring(i)]=i; + for(int i=0;i<8;++i) visible.insert(std::to_wstring(i)); + ks::trimHistoryPreviews(previews,visible); + require(previews.size()==32,"preview cache stays bounded"); + for(const auto &id:visible) require(previews.contains(id),"visible preview survives eviction"); + for(int i=0;i<40;++i) { auto id=std::to_wstring(i); previews[id]=i; visible.insert(id); } + ks::trimHistoryPreviews(previews,visible); + require(previews.size()==40,"tall viewport does not thrash visible previews"); + visible.clear(); ks::trimHistoryPreviews(previews,visible); + require(previews.size()==32,"cache shrinks after leaving tall viewport"); + } f(); } catch (...)