Compare commits

..

1 Commits

12 changed files with 62 additions and 30 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.24)
project(KSwitcher VERSION 0.6.37 LANGUAGES CXX RC)
project(KSwitcher VERSION 0.6.38 LANGUAGES CXX RC)
if(NOT WIN32)
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
endif()
+6
View File
@@ -719,3 +719,9 @@ Image clipboard capture and automatic/uncached manual history OCR run in short-l
Consecutive duplicates use SHA-256 fingerprints inside the encrypted history index, including pixel fingerprints for equivalent image encodings. Older entries acquire fingerprints on first comparison without a bulk migration. Pins and OCR caches survive duplicate detection; non-consecutive copies still create separate entries. New text no longer loads the preceding image to rule out a duplicate. No shortcut, setting or default changed.
Moving OCR isolates its temporary memory rather than reducing the combined processing peak. A controlled three-run comparison retained about 0.8 MiB less private RAM in the caller after recognition; combined peak RAM was about 55.6 MiB before and 56.6 MiB with the helper. Results depend on the image, OCR engine and system.
### History previews and Escape — v0.6.38
History initializes RichEdit only for HTML/RTF previews. Plain text keeps Unicode and line breaks without RTF conversion; empty history and image-only previews do not load RichEdit unnecessarily. Plain and formatted text previews share the existing bounded text-preview cache.
Esc clears hidden rows/caches and cancels obsolete queued preview work, while retaining the selected/top entry for reopening. Already-needed RichEdit controls are cleared and reused. Clipboard contents, paste formatting, OCR, settings and shortcuts are unchanged. A local isolated comparison saved approximately 0.48–0.72 MiB private working set after Esc depending on content; this is not a fixed application footprint or a guarantee that all UI memory returns to the startup level.
Binary file not shown.
+7 -7
View File
@@ -1,17 +1,17 @@
# Latest Windows release
## v0.6.37 — 2026-11-04
## v0.6.38 — 2026-11-04
## Changes
- Image clipboard history capture runs in KSwitcher.Screenshot.exe, so Windows clipboard image allocations are released when the helper exits. The configured size limit, privacy exclusions and original clipboard formats are preserved.
- Automatic and uncached manual history OCR run in the same helper executable, in a separate short-lived process. Only encrypted requests and recognized text cross the process boundary. Windows OCR and optional Tesseract remain supported; profile changes and shutdown cancel background work, and deleted entries are not recreated by late results.
- Consecutive duplicate detection uses SHA-256 fingerprints stored inside the encrypted index. Exact payloads, image representations and decoded pixels can be compared without loading the previous record. Old entries acquire fingerprints on first comparison; pins and cached OCR are preserved. This does not introduce global deduplication.
- Copying text after an image no longer loads the previous image just to rule out a duplicate. Settings, shortcuts and defaults are unchanged. Keep all three executables together.
- Clipboard history loads RichEdit only when an HTML/RTF preview actually needs it. Empty history, plain text and image-only previews no longer initialize it unnecessarily.
- Plain text previews retain Unicode and line breaks without converting the text to RTF. Plain and formatted preview payloads share the existing bounded text-preview cache.
- Esc still clears hidden history rows and preview caches while preserving the remembered selection/top item. Closing or replacing a search now cancels obsolete preview work before loading/decoding where possible, and discards completed stale results.
- A RichEdit control that was needed for formatted text is cleared and reused, not repeatedly destroyed/recreated. This avoids additional initialization on reopening. Settings, shortcuts, stored clipboard contents, formatting on paste and OCR behavior are unchanged.
## Validation
Staged build: dictionary equivalence for 140,939 words, 105 sync checks, wheel batching, 313 native checks, 559 input checks, screenshot UI/PNG/notification registration, 48 updater checks and updater progress UI. Windows OCR tests cover helper equivalence, cancellation, failure, encrypted temporary-file cleanup, persisted background search and unchanged clipboard. Optional Tesseract and clipboard-capture integration are also verified.
Staged build and tests cover dictionary equivalence for 140,939 words, 105 sync checks, wheel batching, 315 native checks, 561 input checks, screenshot UI/PNG/notification registration, 48 updater checks and updater progress UI. Added checks for plain previews without RichEdit, retained HTML rendering, lazy control creation and cleanup through Esc. Isolated real-window tests also verified formatted previews after reopening.
Three controlled OCR comparisons used the same image and settings: retained private working set in the isolated test process fell by about 0.8 MiB. Combined parent/helper peak RAM did not fall (about 55.6 versus 56.6 MiB); the large peak is now outside the resident process and disappears when the helper exits. A separate live screenshot test finished at 4.41 MiB private working set in the main application. These observations are not fixed memory limits or guarantees for another machine/workload.
A controlled local comparison measured 0.72 MiB less private working set after Esc for empty history, 0.66 MiB for plain text, 0.48 MiB for formatted text and 0.53 MiB for images. These are isolated test-process comparisons, not the application's total RAM or a guaranteed reduction on another computer. The reported 7 MiB plateau on a different laptop was not reproduced. No working-set trimming was used; closing the history window does not promise an exact return to startup memory.
## 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.
+1 -1
View File
@@ -1 +1 @@
c5cfe4c21a8e0631470120dd30be54e5d395abf9ca3bd4069f8bd2c827f2105d KSwitcher-win-x64.zip
f430767d440581018e6adb33c153fb332318fa1eee5a162ddf32690d0b5a3391 KSwitcher-win-x64.zip
+3 -3
View File
@@ -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,37,0
PRODUCTVERSION 0,6,37,0
FILEVERSION 0,6,38,0
PRODUCTVERSION 0,6,38,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.37"
VALUE "FileVersion", "0.6.38"
END
END
BLOCK "VarFileInfo"
+2 -1
View File
@@ -252,7 +252,7 @@ DWORD CALLBACK readStream(DWORD_PTR cookie, LPBYTE buffer, LONG size, LONG *read
return 0;
}
} // namespace
std::string richPreview(const Clip &clip)
std::string richPreview(const Clip &clip, bool plainFallback)
{
for (const auto &f : clip.formats)
if (f.name == L"Rich Text Format" && f.data.size() <= 65536)
@@ -276,6 +276,7 @@ std::string richPreview(const Clip &clip)
{
}
}
if (!plainFallback) return {};
auto text = clip.text();
if (text.size() > 4096)
text.resize(4096);
+1 -1
View File
@@ -2,7 +2,7 @@
#include "platform.h"
namespace ks
{
std::string richPreview(const Clip &);
std::string richPreview(const Clip &, bool plainFallback = true);
HWND createRichText(HWND parent, int id, bool visible);
bool setRichText(HWND, const std::string &);
} // namespace ks
+34 -14
View File
@@ -468,12 +468,17 @@ struct SearchResult
uint64_t generation;
std::vector<Entry> items;
};
struct TextPreview
{
std::string rtf;
std::wstring plain;
};
struct PreviewResult
{
uint64_t generation;
std::wstring id;
Pixels pixels;
std::string rtf;
TextPreview text;
};
struct HistoryView
{
@@ -481,8 +486,9 @@ struct HistoryView
std::vector<Entry> items;
std::wstring rememberedSelection, rememberedTop;
uint64_t generation{};
std::shared_ptr<std::atomic<bool>> previewActive = std::make_shared<std::atomic<bool>>(true);
std::map<std::wstring, Pixels> thumbnails;
std::map<std::wstring, std::string> richTexts;
std::map<std::wstring, TextPreview> richTexts;
HWND rich{};
std::set<std::wstring> pending;
bool scrolling{};
@@ -504,6 +510,8 @@ std::wstring selected(HWND w, HistoryView *v)
}
void search(HWND w, HistoryView *v)
{
v->previewActive->store(false);
v->previewActive = std::make_shared<std::atomic<bool>>(true);
v->closed = false;
auto q = value(w, 101);
auto generation = ++v->generation;
@@ -532,23 +540,31 @@ void requestPreview(HWND w, HistoryView *v, const Entry &entry)
if (v->thumbnails.contains(id) || v->richTexts.contains(id) || !v->pending.insert(id).second)
return;
auto generation = v->generation;
auto active = v->previewActive;
auto *app = v->app;
app->enqueue(
[w, app, id, generation, image]
[w, app, id, generation, image, active]
{
if (!active->load()) return;
auto result = new PreviewResult{generation, id, {}, {}};
try
{
auto clip = app->history->load(id);
if (!active->load()) { delete result; return; }
if (image)
result->pixels = decodeImage(clip, 208, 128);
else
result->rtf = richPreview(clip);
else {
result->text.rtf = richPreview(clip, false);
if (result->text.rtf.empty()) {
result->text.plain = clip.text();
if (result->text.plain.size() > 4096) result->text.plain.resize(4096);
}
}
}
catch (const std::exception &)
{
}
if (!PostMessageW(w, PreviewMessage, 0, (LPARAM)result))
if (!active->load() || !PostMessageW(w, PreviewMessage, 0, (LPARAM)result))
delete result;
});
}
@@ -602,9 +618,13 @@ void drawHistoryRow(HWND w, HistoryView *v, const DRAWITEMSTRUCT &draw)
if (!entry.image && !entry.files)
{
auto rich = v->richTexts.find(entry.id);
if (rich != v->richTexts.end() && v->rich && !rich->second.empty())
if (rich != v->richTexts.end() && rich->second.rtf.empty())
text = (entry.pinned ? L"★ " : L"") + rich->second.plain;
if (rich != v->richTexts.end() && !rich->second.rtf.empty() && !v->rich)
v->rich = createRichText(w, 105, false);
if (rich != v->richTexts.end() && v->rich && !rich->second.rtf.empty())
{
setRichText(v->rich, rich->second);
setRichText(v->rich, rich->second.rtf);
CHARFORMAT2W format{};
format.cbSize = sizeof(format);
format.dwMask = CFM_COLOR;
@@ -825,14 +845,14 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
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);
if (!result->text.rtf.empty() || !result->text.plain.empty())
v->richTexts[result->id] = std::move(result->text);
else
v->thumbnails[result->id] = std::move(result->pixels);
trimHistoryPreviews(v->thumbnails,visible,32,HistoryThumbnailBudget,
[](const Pixels &p) { return p.bgra.capacity(); });
trimHistoryPreviews(v->richTexts,visible,32,HistoryRichTextBudget,
[](const std::string &s) { return s.capacity(); });
[](const TextPreview &s) { return s.rtf.capacity() + s.plain.capacity() * sizeof(wchar_t); });
if(repaint) InvalidateRect(list,&changed,FALSE);
return 0;
}
@@ -889,18 +909,19 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
v->rememberedTop = top >= 0 && top < (int)v->items.size() ? v->items[top].id : L"";
KillTimer(w, 1);
v->closed = true;
v->previewActive->store(false);
++v->generation; // Discard search/preview results queued before closing.
ShowWindow(w, SW_HIDE);
SendDlgItemMessageW(w,102,LB_RESETCONTENT,0,0);
std::vector<Entry>().swap(v->items);
v->thumbnails.clear();
v->richTexts.clear();
if (v->rich)
SetWindowTextW(v->rich, L"");
if (v->rich) SetWindowTextW(v->rich, L"");
v->pending.clear();
return 0;
}
case WM_NCDESTROY:
v->previewActive->store(false);
disposeFont(w);
v->app->historyWindow = nullptr;
delete v;
@@ -1702,7 +1723,6 @@ void showHistory(App &app, bool visible)
HWND w = makeWindow(app, L"KSwitcher.History", L"Clipboard history", historyProc, 760, 500);
app.historyWindow = w;
windowLayoutState(w)->history = true;
((HistoryView *)GetWindowLongPtrW(w, GWLP_USERDATA))->rich = createRichText(w, 105, false);
label(w, L"&Search clipboard history", 16, 12, 500);
edit(w, 101, 16, 38, 728);
auto list = control(w, L"LISTBOX", L"",
+1 -1
View File
@@ -2,7 +2,7 @@
#include "platform.h"
namespace ks
{
inline constexpr wchar_t AppVersion[] = L"0.6.37";
inline constexpr wchar_t AppVersion[] = L"0.6.38";
inline constexpr UINT UpdateMessage = WM_APP + 7;
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
+4 -1
View File
@@ -912,6 +912,7 @@ int main(int argc, char **argv) {
ks::showHistory(app,false);
auto history=app.historyWindow;
require(history&&!IsWindowVisible(history),"history layout tested without changing focus");
require(!GetDlgItem(history,105),"history creates no RichEdit control before formatted content is displayed");
{
app.history=std::make_unique<ks::History>(temp/L"history-search");
app.history->add(ks::textClip(L"згенеруй приклад"),L"test",app.settings);
@@ -941,7 +942,9 @@ int main(int argc, char **argv) {
require(GetFocus()==originalFocus,"history search preserves focus");
SendDlgItemMessageW(history,102,LB_SETCURSEL,1,0);
ks::refreshHistory(app); // A result may still be in flight when closing.
SendMessageW(history,WM_CLOSE,0,0);
SendDlgItemMessageW(history,101,WM_KEYDOWN,VK_ESCAPE,0);
require(!IsWindowVisible(history) && !GetDlgItem(history,105),
"Escape closes history and leaves no RichEdit control");
SendMessageW(history,WM_TIMER,1,0); // KillTimer cannot retract a posted timer message.
require(pumpUntil(0),"closing history releases list strings");
auto until=GetTickCount64()+250;
+2
View File
@@ -190,6 +190,8 @@ int main(int argc, char **argv)
"HTML entities and Unicode survive without script text");
require((effects(0, 6) & CFE_BOLD) != 0, "inline CSS bold is rendered");
auto plain = ks::textClip(L"{\\literal} Привіт");
require(ks::richPreview(plain, false).empty(), "plain history preview does not require RichEdit");
require(!ks::richPreview(styled, false).empty(), "formatted history preview retains HTML rendering");
ks::setRichText(rich, ks::richPreview(plain));
GetWindowTextW(rich, rendered, 256);
require(std::wstring(rendered) == plain.text(), "plain Unicode and RTF metacharacters are escaped");