Release v0.6.20: edit history images and share smooth wheel handling

This commit is contained in:
2026-10-31 09:12:59 +02:00
parent 6603d28f68
commit 70ea9b10bc
22 changed files with 334 additions and 33 deletions
+1 -1
View File
@@ -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);
+1
View File
@@ -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 &);
+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
{
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
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_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,
+7 -3
View File
@@ -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); }
}
+13
View File
@@ -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");
+1
View File
@@ -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
View File
@@ -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;
+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"
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;