Compare commits

..

4 Commits

24 changed files with 362 additions and 37 deletions
+6 -2
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.24) cmake_minimum_required(VERSION 3.24)
project(KSwitcher VERSION 0.6.19 LANGUAGES CXX RC) project(KSwitcher VERSION 0.6.21 LANGUAGES CXX RC)
if(NOT WIN32) if(NOT WIN32)
message(FATAL_ERROR "KSwitcher uses native Windows APIs") message(FATAL_ERROR "KSwitcher uses native Windows APIs")
endif() endif()
@@ -17,6 +17,8 @@ set_target_properties(KSwitcherConfig PROPERTIES OUTPUT_NAME "KSwitcher.Config")
target_compile_definitions(KSwitcherConfig PRIVATE KS_CONFIG_APP) target_compile_definitions(KSwitcherConfig PRIVATE KS_CONFIG_APP)
target_link_libraries(KSwitcherConfig PRIVATE kscore) target_link_libraries(KSwitcherConfig PRIVATE kscore)
add_executable(KSwitcherTests tests/tests.cpp) add_executable(KSwitcherTests tests/tests.cpp)
add_executable(KSwitcherScrollTests tests/scroll.cpp)
target_link_libraries(KSwitcherScrollTests PRIVATE kscore)
target_link_libraries(KSwitcherTests 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) 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) 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) 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) target_link_libraries(KSwitcherOcrTests PRIVATE kscore)
if(MSVC) if(MSVC)
set_property(TARGET kscore KSwitcher KSwitcherConfig KSwitcherScreenshot KSwitcherTests KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>") set_property(TARGET kscore KSwitcher KSwitcherConfig KSwitcherScreenshot KSwitcherTests KSwitcherScrollTests KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
target_compile_options(kscore PRIVATE /W4 /utf-8) target_compile_options(kscore PRIVATE /W4 /utf-8)
target_compile_options(KSwitcher PRIVATE /W4 /utf-8) target_compile_options(KSwitcher PRIVATE /W4 /utf-8)
target_compile_options(KSwitcherConfig 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(KSwitcherConfig PRIVATE -municode -static -static-libgcc -static-libstdc++)
target_link_options(KSwitcherScreenshot 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(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(KSwitcherIntegration PRIVATE -static -static-libgcc -static-libstdc++)
target_link_options(KSwitcherInputTests 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++) target_link_options(KSwitcherOcrTests PRIVATE -static -static-libgcc -static-libstdc++)
endif() endif()
enable_testing() enable_testing()
add_test(NAME native-regressions COMMAND KSwitcherTests) add_test(NAME native-regressions COMMAND KSwitcherTests)
add_test(NAME scroll-regressions COMMAND KSwitcherScrollTests)
add_test(NAME input-regressions COMMAND KSwitcherInputTests) add_test(NAME input-regressions COMMAND KSwitcherInputTests)
add_test(NAME screenshot-ui-regressions COMMAND KSwitcherScreenshotUITests) 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 "$<TARGET_FILE:KSwitcher>") add_test(NAME updater-regressions COMMAND powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${CMAKE_CURRENT_SOURCE_DIR}/tests/update.ps1" -Executable "$<TARGET_FILE:KSwitcher>")
+8
View File
@@ -220,6 +220,8 @@ thumbnails and source labels aligned with the scaled fonts and spacing.
## Implemented ## Implemented
- Installed Windows keyboard layouts, configurable cycle/order and direct shortcuts; IMEs are switch-only. - 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.
- Selection conversion and verified recent input (physical scan codes, 2-second typing-run boundary). - 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. - 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. - Teams recent-word conversion uses a single word-selection command and validates the copied range.
@@ -278,6 +280,12 @@ 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, 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. 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.
Pressing the history shortcut a second time pastes the second-most-recent item. Pressing the history shortcut a second time pastes the second-most-recent item.
## Data and migration ## Data and migration
Binary file not shown.
+6 -8
View File
@@ -1,21 +1,19 @@
# Latest Windows release # Latest Windows release
## v0.6.19 — 2026-10-31 ## v0.6.21 — 2026-10-31
## Changes ## 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. - 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.
- 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. - 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.
- 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.
## Validation ## 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 413 native regressions, 473 input/UI checks, screenshot editor UI, notification registration, 48 updater checks and updater progress checks.
## Limitations ## 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. Existing clipboard, accessibility and input-context verification limitations remain. Copy retries and paste readback verification are unchanged.
**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. **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, Tesseract installation or language models.
+1 -1
View File
@@ -1 +1 @@
8b2008e97ca625b7eebdf509c038d6d1f6c5c853dce6da1b939abaa18223e26d KSwitcher-win-x64.zip 5207831872af5522063c1c549ba582aaed534096e6e51a394b6be6a705951f3d KSwitcher-win-x64.zip
+6
View File
@@ -10,6 +10,8 @@ $outDir = Join-Path $root 'build'
New-Item -ItemType Directory -Force $outDir | Out-Null New-Item -ItemType Directory -Force $outDir | Out-Null
$executablePath = if ($Stage) { Join-Path $outDir 'staged\KSwitcher.exe' } else { Join-Path $outDir 'KSwitcher.exe' } $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 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 Push-Location $root
try { 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') $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' } if ($LASTEXITCODE) { throw 'Screenshot link failed' }
Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force
if ($Test) { 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 & $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' } if ($LASTEXITCODE) { throw 'Test build failed' }
& .\build\KSwitcherTests.exe & .\build\KSwitcherTests.exe
+3 -3
View File
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
1 RT_MANIFEST "app.manifest" 1 RT_MANIFEST "app.manifest"
201 RCDATA "update.ps1" 201 RCDATA "update.ps1"
1 VERSIONINFO 1 VERSIONINFO
FILEVERSION 0,6,19,0 FILEVERSION 0,6,21,0
PRODUCTVERSION 0,6,19,0 PRODUCTVERSION 0,6,21,0
FILEOS VOS_NT_WINDOWS32 FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_APP FILETYPE VFT_APP
BEGIN BEGIN
@@ -20,7 +20,7 @@ BEGIN
BEGIN BEGIN
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility" VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
VALUE "ProductName", "KSwitcher" VALUE "ProductName", "KSwitcher"
VALUE "FileVersion", "0.6.19" VALUE "FileVersion", "0.6.21"
END END
END END
BLOCK "VarFileInfo" BLOCK "VarFileInfo"
+2 -6
View File
@@ -433,7 +433,7 @@ void App::addTray()
icon.uCallbackMessage = TrayMessage; icon.uCallbackMessage = TrayMessage;
icon.hIcon = (HICON)LoadImageW(instance, MAKEINTRESOURCEW(IDI_KSWITCHER), IMAGE_ICON, icon.hIcon = (HICON)LoadImageW(instance, MAKEINTRESOURCEW(IDI_KSWITCHER), IMAGE_ICON,
GetSystemMetrics(SM_CXSMICON), GetSystemMetrics(SM_CYSMICON), LR_SHARED); 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); Shell_NotifyIconW(NIM_ADD, &icon);
icon.uVersion = NOTIFYICON_VERSION_4; icon.uVersion = NOTIFYICON_VERSION_4;
Shell_NotifyIconW(NIM_SETVERSION, &icon); Shell_NotifyIconW(NIM_SETVERSION, &icon);
@@ -1202,11 +1202,7 @@ void App::transform(int command, int snippet)
chord(VK_RIGHT); chord(VK_RIGHT);
return; return;
} }
int source = detect(text, available, hint); to = nextConversionLayout(text, available, cycle, hint);
if (source < 0)
source = hint;
auto it = std::find(cycle.begin(), cycle.end(), source);
to = it == cycle.end() || ++it == cycle.end() ? cycle.front() : *it;
} }
result = convertTo(text, available, to, hint); result = convertTo(text, available, to, hint);
} }
+1
View File
@@ -120,6 +120,7 @@ class App
}; };
void showHistory(App &, bool visible = true); void showHistory(App &, bool visible = true);
void launchScreenshot(App &); void launchScreenshot(App &);
void launchScreenshotEditor(App &, const Clip &);
void refreshHistory(App &); void refreshHistory(App &);
void showSettings(App &, bool visible = true); void showSettings(App &, bool visible = true);
void showLogs(App &); void showLogs(App &);
+20
View File
@@ -148,6 +148,26 @@ std::wstring convertTo(const std::wstring &text, const std::vector<Layout> &layo
} }
return best < 0 ? text : convert(text, layouts[best], to); return best < 0 ? text : convert(text, layouts[best], to);
} }
int nextConversionLayout(const std::wstring &text, const std::vector<Layout> &layouts,
const std::vector<int> &cycle, int hint)
{
if (cycle.empty())
return -1;
int source = detect(text, layouts, hint);
if (source < 0)
source = hint;
auto it = std::find(cycle.begin(), cycle.end(), source);
size_t start = it == cycle.end() ? 0 : (size_t(it - cycle.begin()) + 1) % cycle.size();
// Compare every candidate with the original input, never an intermediate conversion.
// Bound the search so digits, whitespace and identical layouts cannot loop forever.
for (size_t offset = 0; offset < cycle.size(); ++offset)
{
int target = cycle[(start + offset) % cycle.size()];
if (target != source && convertTo(text, layouts, target, hint) != text)
return target;
}
return cycle[start];
}
std::wstring cycleCase(const std::wstring &text) std::wstring cycleCase(const std::wstring &text)
{ {
if (text == upper(text)) if (text == upper(text))
+2
View File
@@ -24,6 +24,8 @@ std::wstring upper(std::wstring text);
std::wstring convert(const std::wstring &, const Layout &, const Layout &, bool force = false); std::wstring convert(const std::wstring &, const Layout &, const Layout &, bool force = false);
int detect(const std::wstring &, const std::vector<Layout> &, int preferred = -1); int detect(const std::wstring &, const std::vector<Layout> &, int preferred = -1);
std::wstring convertTo(const std::wstring &, const std::vector<Layout> &, int target, int hint); std::wstring convertTo(const std::wstring &, const std::vector<Layout> &, int target, int hint);
int nextConversionLayout(const std::wstring &, const std::vector<Layout> &,
const std::vector<int> &cycle, int hint);
std::wstring cycleCase(const std::wstring &); std::wstring cycleCase(const std::wstring &);
std::wstring transliterate(const std::wstring &); std::wstring transliterate(const std::wstring &);
struct Snippet struct Snippet
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include <map>
#include <set>
#include <string>
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<class T>
void trimHistoryPreviews(std::map<std::wstring,T> &cache,
const std::set<std::wstring> &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;
}
}
+10
View File
@@ -8,6 +8,16 @@
namespace ks 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<unsigned char>; using Bytes = std::vector<unsigned char>;
struct Format struct Format
{ {
+1 -1
View File
@@ -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_EXSTYLE,(GetWindowLongPtrW(w,GWL_EXSTYLE)|WS_EX_APPWINDOW)&~WS_EX_TOOLWINDOW);
SetWindowLongPtrW(w,GWL_STYLE,WS_OVERLAPPEDWINDOW | WS_CLIPCHILDREN); SetWindowLongPtrW(w,GWL_STYLE,WS_OVERLAPPEDWINDOW | WS_CLIPCHILDREN);
auto title=L"KSwitcher — Screenshot #"+std::to_wstring(nextEditorIndex++); 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{sizeof(statusControls),ICC_BAR_CLASSES};
InitCommonControlsEx(&statusControls); InitCommonControlsEx(&statusControls);
CreateWindowExW(0,STATUSCLASSNAMEW,L"",WS_CHILD|WS_VISIBLE|SBARS_TOOLTIPS, CreateWindowExW(0,STATUSCLASSNAMEW,L"",WS_CHILD|WS_VISIBLE|SBARS_TOOLTIPS,
+7 -3
View File
@@ -1,13 +1,15 @@
#include "app.h" #include "app.h"
#include "screenshot_notifications.h"
namespace ks { 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"; auto path=std::filesystem::path(executable()).parent_path()/L"KSwitcher.Screenshot.exe";
if (!std::filesystem::exists(path)) if (!std::filesystem::exists(path))
throw std::runtime_error("KSwitcher.Screenshot.exe is missing. Extract the complete application package."); throw std::runtime_error("KSwitcher.Screenshot.exe is missing. Extract the complete application package.");
auto command=L"\""+path.wstring()+L"\" --auto-open-editor="+ auto command=L"\""+path.wstring()+L"\" ";
(app.settings.screenshotAutoOpenEditor?L"1":L"0"); 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"; if(app.settings.writeLog) command+=L" --write-log";
STARTUPINFOW startup{sizeof(startup)}; STARTUPINFOW startup{sizeof(startup)};
PROCESS_INFORMATION process{}; PROCESS_INFORMATION process{};
@@ -20,4 +22,6 @@ void launchScreenshot(App &app)
CloseHandle(process.hThread); CloseHandle(process.hThread);
CloseHandle(process.hProcess); CloseHandle(process.hProcess);
} }
void launchScreenshot(App &app) { launch(app,nullptr); }
void launchScreenshotEditor(App &app,const Clip &clip) { launch(app,&clip); }
} }
+13
View File
@@ -112,6 +112,19 @@ std::wstring escapeXml(const std::wstring &s)
return result; 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) Clip loadNotifiedScreenshot(const std::wstring &id)
{ {
if(!validScreenshotToken(id)) throw std::runtime_error("Invalid screenshot notification"); if(!validScreenshotToken(id)) throw std::runtime_error("Invalid screenshot notification");
+1
View File
@@ -8,4 +8,5 @@ int runScreenshotToastActivator();
DWORD startScreenshotActivationListener(); DWORD startScreenshotActivationListener();
bool validScreenshotToken(const std::wstring &); bool validScreenshotToken(const std::wstring &);
Clip loadNotifiedScreenshot(const std::wstring &); Clip loadNotifiedScreenshot(const std::wstring &);
std::wstring cacheScreenshotForEditor(const Clip &);
} }
+70 -11
View File
@@ -1,5 +1,7 @@
#include "../resources/resource.h" #include "../resources/resource.h"
#include "app.h" #include "app.h"
#include "history_preview.h"
#include "ui_scroll.h"
#include "configuration.h" #include "configuration.h"
#include "ocr.h" #include "ocr.h"
#include "richtext.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, scale(parent, width), scale(parent, height), parent, (HMENU)(INT_PTR)id,
GetModuleHandleW(nullptr), nullptr); GetModuleHandleW(nullptr), nullptr);
SendMessageW(w, WM_SETFONT, (WPARAM)GetPropW(parent, L"Font"), TRUE); 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) if (auto *layout = windowLayoutState(parent); layout && layout->scrolling)
{ {
layout->controls.push_back({w, {x, y, x + width, y + height}}); 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); 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)}); SetPropW(w, L"WindowLayout", new WindowLayout{width, height, 0, 0, !(extra & WS_THICKFRAME)});
SetWindowSubclass(w, layoutProc, 2, 0); SetWindowSubclass(w, layoutProc, 2, 0);
if(windowLayoutState(w)->scrolling) enableWheelBatching(w);
RECT initial = fitWindowRect({left, top, left + outerWidth, top + outerHeight}, monitor.rcWork); 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, SetWindowPos(w, nullptr, initial.left, initial.top, initial.right - initial.left, initial.bottom - initial.top,
SWP_NOACTIVATE | SWP_NOZORDER); SWP_NOACTIVATE | SWP_NOZORDER);
arrangeWindow(w); arrangeWindow(w);
SetWindowTextW(w, title); SetWindowTextW(w, localBuildTitle(title).c_str());
auto smallIcon = (HICON)LoadImageW(app.instance, MAKEINTRESOURCEW(IDI_KSWITCHER), IMAGE_ICON, auto smallIcon = (HICON)LoadImageW(app.instance, MAKEINTRESOURCEW(IDI_KSWITCHER), IMAGE_ICON,
GetSystemMetricsForDpi(SM_CXSMICON, dpi), GetSystemMetricsForDpi(SM_CXSMICON, dpi),
GetSystemMetricsForDpi(SM_CYSMICON, dpi), LR_SHARED); GetSystemMetricsForDpi(SM_CYSMICON, dpi), LR_SHARED);
@@ -478,7 +484,17 @@ struct HistoryView
std::map<std::wstring, std::string> richTexts; std::map<std::wstring, std::string> richTexts;
HWND rich{}; HWND rich{};
std::set<std::wstring> pending; std::set<std::wstring> 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) std::wstring selected(HWND w, HistoryView *v)
{ {
int i = (int)SendDlgItemMessageW(w, 102, LB_GETCURSEL, 0, 0); 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) void requestPreview(HWND w, HistoryView *v, const Entry &entry)
{ {
if(v->scrolling) return;
const auto id = entry.id; const auto id = entry.id;
const bool image = entry.image; const bool image = entry.image;
if (v->thumbnails.contains(id) || v->richTexts.contains(id) || !v->pending.insert(id).second) 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) 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: case WM_COMMAND:
{ {
int id = LOWORD(wp); 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) if (id == 102 && HIWORD(wp) == LBN_SELCHANGE)
{ {
updateHistoryActions(w,v);
return 0; return 0;
} }
if (id == 102 && HIWORD(wp) == LBN_DBLCLK) 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) if (result->generation != v->generation)
return 0; return 0;
auto old = selected(w, v); 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); v->items = std::move(result->items);
SendDlgItemMessageW(w, 102, WM_SETREDRAW, FALSE, 0); SendDlgItemMessageW(w, 102, WM_SETREDRAW, FALSE, 0);
SendDlgItemMessageW(w, 102, LB_RESETCONTENT, 0, 0); SendDlgItemMessageW(w, 102, LB_RESETCONTENT, 0, 0);
int select = 0; int select = 0;
int restoreTop=0;
for (size_t i = 0; i < v->items.size(); ++i) for (size_t i = 0; i < v->items.size(); ++i)
{ {
auto &e = v->items[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()); SendDlgItemMessageW(w, 102, LB_ADDSTRING, 0, (LPARAM)text.c_str());
if (e.id == old) if (e.id == old)
select = (int)i; select = (int)i;
if(e.id==topId) restoreTop=(int)i;
} }
SendDlgItemMessageW(w, 102, LB_SETCURSEL, select, 0); SendDlgItemMessageW(w, 102, LB_SETCURSEL, select, 0);
SendDlgItemMessageW(w,102,LB_SETTOPINDEX,restoreTop,0);
updateHistoryActions(w,v);
SendDlgItemMessageW(w, 102, WM_SETREDRAW, TRUE, 0); SendDlgItemMessageW(w, 102, WM_SETREDRAW, TRUE, 0);
InvalidateRect(GetDlgItem(w, 102), nullptr, TRUE); InvalidateRect(GetDlgItem(w, 102), nullptr, TRUE);
return 0; return 0;
@@ -770,16 +800,25 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
if (result->generation != v->generation || !IsWindowVisible(w)) if (result->generation != v->generation || !IsWindowVisible(w))
return 0; return 0;
v->pending.erase(result->id); v->pending.erase(result->id);
// At most ~3.3 MiB, irrespective of history length or source image size. auto list=GetDlgItem(w,102);
if (v->thumbnails.size() >= 32) RECT client{}; GetClientRect(list,&client);
v->thumbnails.erase(v->thumbnails.begin()); std::set<std::wstring> visible;
if (v->richTexts.size() >= 32) RECT changed{}; bool repaint=false;
v->richTexts.erase(v->richTexts.begin()); 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()) if (!result->rtf.empty())
v->richTexts[result->id] = std::move(result->rtf); v->richTexts[result->id] = std::move(result->rtf);
else else
v->thumbnails[result->id] = std::move(result->pixels); 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; return 0;
} }
case WM_MEASUREITEM: case WM_MEASUREITEM:
@@ -798,15 +837,29 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
break; break;
case WM_CONTEXTMENU: 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(); HMENU menu = CreatePopupMenu();
AppendMenuW(menu, MF_STRING, 110, L"Paste"); AppendMenuW(menu, MF_STRING, 110, L"Paste");
AppendMenuW(menu, MF_STRING, 111, L"Paste plain text"); AppendMenuW(menu, MF_STRING, 111, L"Paste plain text");
AppendMenuW(menu, MF_STRING, 112, L"Pin / unpin"); AppendMenuW(menu, MF_STRING, 112, L"Pin / unpin");
AppendMenuW(menu, MF_STRING, 113, L"Delete"); 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)"); 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); int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, p.x, p.y, 0, w, nullptr);
DestroyMenu(menu); DestroyMenu(menu);
if (cmd) if (cmd)
@@ -1700,11 +1753,17 @@ void showEntry(App &app, const std::wstring &id)
try try
{ {
auto clip = app.history->load(id); 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(); auto text = clip.text();
if (text.empty()) if (text.empty())
{ {
MessageBoxW(app.historyWindow, 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"destination application.",
L"Clipboard item", MB_OK); L"Clipboard item", MB_OK);
return; return;
+83
View File
@@ -0,0 +1,83 @@
#pragma once
#include "platform.h"
#include <algorithm>
#include <commctrl.h>
#include <windowsx.h>
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<WheelBatch *>(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<LRESULT>(1,client.bottom/std::max<LRESULT>(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<long long>(top-(delta/WHEEL_DELTA)*lines,0,std::max<LRESULT>(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<long long>(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<long long>(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;
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
#include "platform.h" #include "platform.h"
namespace ks namespace ks
{ {
inline constexpr wchar_t AppVersion[] = L"0.6.19"; inline constexpr wchar_t AppVersion[] = L"0.6.21";
inline constexpr UINT UpdateMessage = WM_APP + 7; inline constexpr UINT UpdateMessage = WM_APP + 7;
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000; inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000; inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
+6
View File
@@ -1,4 +1,5 @@
#include "app.h" #include "app.h"
#include "ui_scroll.h"
#include "ui_geometry.h" #include "ui_geometry.h"
#include "screenshot.h" #include "screenshot.h"
#include <commctrl.h> #include <commctrl.h>
@@ -728,16 +729,21 @@ int main(int argc, char **argv) {
controlMoves=0; controlMoves=0;
ValidateRect(settings,nullptr); ValidateRect(settings,nullptr);
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WHEEL_DELTA),0); 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"); 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_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"); require(controlMoves==0,"partial wheel step does not rearrange controls");
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WORD(short(-WHEEL_DELTA/2))),0); 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"); require(controlMoves==1,"wheel scroll batches each control movement once");
SendMessageW(settings,WM_VSCROLL,SB_BOTTOM,0); SendMessageW(settings,WM_VSCROLL,SB_BOTTOM,0);
SendMessageW(settings,WM_HSCROLL,SB_RIGHT,0); SendMessageW(settings,WM_HSCROLL,SB_RIGHT,0);
controlMoves=0; controlMoves=0;
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,-WHEEL_DELTA),0); SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,-WHEEL_DELTA),0);
SendMessageW(settings,WM_MOUSEHWHEEL,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(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"); require((GetWindowLongPtrW(settings,GWL_STYLE)&WS_CLIPCHILDREN)!=0,"parent background excludes child controls");
RemoveWindowSubclass(trackedControl,countControlMoves,99); RemoveWindowSubclass(trackedControl,countControlMoves,99);
+1 -1
View File
@@ -175,7 +175,7 @@ int main()
} }
c.marks.clear(); SendMessageW(window,WM_COMMAND,27,0); c.marks.clear(); SendMessageW(window,WM_COMMAND,27,0);
wchar_t windowTitle[128]{}; GetWindowTextW(window,windowTitle,128); 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); RECT client{}; GetClientRect(window,&client);
require(c.view.bottom<=client.bottom-c.statusHeight,"image does not overlap status bar"); require(c.view.bottom<=client.bottom-c.statusHeight,"image does not overlap status bar");
ks::updateStatus(c); ks::updateStatus(c);
+66
View File
@@ -0,0 +1,66 @@
#include "ui_scroll.h"
#include <iostream>
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<<e.what()<<'\n'; return 1;
}
}
+31
View File
@@ -1,4 +1,5 @@
#include "autocorrect.h" #include "autocorrect.h"
#include "history_preview.h"
#include "ocr.h" #include "ocr.h"
#include "richtext.h" #include "richtext.h"
#include "storage.h" #include "storage.h"
@@ -22,6 +23,20 @@ void rejects(const std::function<void()> &f, const char *name)
bool thrown = false; bool thrown = false;
try try
{ {
{
std::map<std::wstring,int> previews;
std::set<std::wstring> 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(); f();
} }
catch (...) catch (...)
@@ -322,6 +337,22 @@ int main(int argc, char **argv)
require(ks::convertTo(L"ghbdtn", layouts, 1, 0) == L"привет", "hint conversion"); require(ks::convertTo(L"ghbdtn", layouts, 1, 0) == L"привет", "hint conversion");
require(ks::convertTo(L"привет", layouts, 1, 1) == L"привет", "same source is unchanged"); require(ks::convertTo(L"привет", layouts, 1, 1) == L"привет", "same source is unchanged");
require(ks::convertTo(L"?", layouts, 1, 0) == L",", "punctuation conversion"); require(ks::convertTo(L"?", layouts, 1, 0) == L",", "punctuation conversion");
require(ks::nextConversionLayout(L"рф", layouts, {0, 1, 2}, 1) == 0 &&
ks::convertTo(L"рф", layouts, 0, 1) == L"ha",
"cycle skips unchanged Russian to Ukrainian and wraps to English");
require(ks::nextConversionLayout(L"шььш", layouts, {0, 2, 1}, 2) == 0 &&
ks::convertTo(L"шььш", layouts, 0, 2) == L"immi",
"cycle skips unchanged Ukrainian to Russian");
require(ks::nextConversionLayout(L"і", layouts, {0, 2, 1}, 2) == 1,
"cycle keeps the next language when text changes");
require(ks::nextConversionLayout(L"ha", layouts, {0, 1, 2}, 0) == 1,
"cycle preserves configured order for changed text");
require(ks::nextConversionLayout(L"рф", layouts, {1, 2}, 1) == 2,
"cycle does not use disabled English when all candidates are unchanged");
require(ks::nextConversionLayout(L"123 ", layouts, {0, 1, 2}, 0) == 1,
"unchanged digits finish after a bounded cycle");
require(ks::nextConversionLayout(L"рф", layouts, {2, 0}, 1) == 0,
"source outside cycle still skips unchanged candidates");
require(ks::cycleCase(L"hello world") == L"Hello World", "title case"); require(ks::cycleCase(L"hello world") == L"Hello World", "title case");
require(ks::cycleCase(L"Hello World") == L"HELLO WORLD", "upper case"); require(ks::cycleCase(L"Hello World") == L"HELLO WORLD", "upper case");
require(ks::cycleCase(L"ПРИВІТ") == L"привіт", "Unicode lower case"); require(ks::cycleCase(L"ПРИВІТ") == L"привіт", "Unicode lower case");