Release v0.6.20: edit history images and share smooth wheel handling
This commit is contained in:
+6
-2
@@ -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$<$<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(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 "$<TARGET_FILE:KSwitcher>")
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
+10
-9
@@ -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:
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
8b2008e97ca625b7eebdf509c038d6d1f6c5c853dce6da1b939abaa18223e26d KSwitcher-win-x64.zip
|
||||
0b3c4fd1346d0b4eeb73d276193c5d2789fd32986c3cc75384cf489f23629678 KSwitcher-win-x64.zip
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-3
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
|
||||
1 RT_MANIFEST "app.manifest"
|
||||
201 RCDATA "update.ps1"
|
||||
1 VERSIONINFO
|
||||
FILEVERSION 0,6,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"
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
@@ -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 &);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<unsigned char>;
|
||||
struct Format
|
||||
{
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -8,4 +8,5 @@ int runScreenshotToastActivator();
|
||||
DWORD startScreenshotActivationListener();
|
||||
bool validScreenshotToken(const std::wstring &);
|
||||
Clip loadNotifiedScreenshot(const std::wstring &);
|
||||
std::wstring cacheScreenshotForEditor(const Clip &);
|
||||
}
|
||||
|
||||
+70
-11
@@ -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<std::wstring, std::string> richTexts;
|
||||
HWND rich{};
|
||||
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)
|
||||
{
|
||||
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<std::wstring> 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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "app.h"
|
||||
#include "ui_scroll.h"
|
||||
#include "ui_geometry.h"
|
||||
#include "screenshot.h"
|
||||
#include <commctrl.h>
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "autocorrect.h"
|
||||
#include "history_preview.h"
|
||||
#include "ocr.h"
|
||||
#include "richtext.h"
|
||||
#include "storage.h"
|
||||
@@ -22,6 +23,20 @@ void rejects(const std::function<void()> &f, const char *name)
|
||||
bool thrown = false;
|
||||
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();
|
||||
}
|
||||
catch (...)
|
||||
|
||||
Reference in New Issue
Block a user