Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ede4ce47c0 | ||
|
|
3b8b8f26fe | ||
|
|
19f1e4531c | ||
|
|
a124e030ab | ||
|
|
c18148a574 |
No files matched your search
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
cmake_minimum_required(VERSION 3.24)
|
cmake_minimum_required(VERSION 3.24)
|
||||||
project(KSwitcher VERSION 0.6.50 LANGUAGES CXX RC)
|
project(KSwitcher VERSION 0.6.52 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()
|
||||||
|
|||||||
@@ -202,6 +202,10 @@ snapping and shape constraints are unchanged.
|
|||||||
Both drawn and recognized triangles have three independent vertex handles instead of bounding-box
|
Both drawn and recognized triangles have three independent vertex handles instead of bounding-box
|
||||||
resize handles. Drag any vertex to reshape the triangle while keeping the other two fixed, even
|
resize handles. Drag any vertex to reshape the triangle while keeping the other two fixed, even
|
||||||
after rotation. Vertex edits support undo/redo, and the rotation and thickness handles remain available.
|
after rotation. Vertex edits support undo/redo, and the rotation and thickness handles remain available.
|
||||||
|
**Arc (U)** draws an open half ellipse inside the dragged rectangle. Use the corner handles
|
||||||
|
to resize it and the round handle to rotate it (Shift snaps to 15 degrees). Arcs support
|
||||||
|
color, thickness, line style, moving, cloning, undo/redo and PNG export.
|
||||||
|
|
||||||
**Trapezoid (B)** on the toolbar creates a trapezoid with parallel bases. Pencil recognition also
|
**Trapezoid (B)** on the toolbar creates a trapezoid with parallel bases. Pencil recognition also
|
||||||
fits nearly parallel, unequal bases into a clean trapezoid. Trapezoids support bounding-box resizing,
|
fits nearly parallel, unequal bases into a clean trapezoid. Trapezoids support bounding-box resizing,
|
||||||
rotation, styling and undo/redo; Ctrl+Z after recognition restores the original pencil stroke.
|
rotation, styling and undo/redo; Ctrl+Z after recognition restores the original pencil stroke.
|
||||||
@@ -317,6 +321,26 @@ Telegram ігнорується. Teams підтримує зазначений
|
|||||||
Профілі локальні, входять до ручного експорту/імпорту, але не до
|
Профілі локальні, входять до ручного експорту/імпорту, але не до
|
||||||
хмарної синхронізації. Доступно з v0.6.48.
|
хмарної синхронізації. Доступно з v0.6.48.
|
||||||
|
|
||||||
|
|
||||||
|
### Telegram Web in Chrome — v0.6.51
|
||||||
|
|
||||||
|
Open a chat in Telegram Web K (`https://web.telegram.org/k/`) in Chrome, then use **Settings → Languages → Languages for a chat...** and select **Telegram Web: name**. Choose languages, then **Use languages → Save**. The same subset applies to Alt+Shift, Win+Space, Win+Shift+Space and Next layout. Web profiles are separate from desktop Telegram profiles. Same-name chats across browser profiles/accounts share a web profile.
|
||||||
|
|
||||||
|
Recognition uses Chrome accessibility: the address bar and active document must both identify a Telegram chat, then the visible header supplies the name. The app watches foreground/focus and address/header-change events; it does not use OCR, a polling timer, an extension, or message-history extraction. The address bar, home page and unsupported URLs do not match. Web A and other browsers have not been validated and are not advertised as supported. Page or accessibility changes can make a chat unavailable instead of guessing.
|
||||||
|
|
||||||
|
**Українською.** З v0.6.51 підтримано Telegram Web K у Chrome. Відкрий чат, потім **Settings → Languages → Languages for a chat...**, вибери **Telegram Web: ім’я**, мови та **Use languages → Save**. Набір діє для Alt+Shift, Win+Space, Win+Shift+Space і Next layout. Вебпрофілі окремі від Telegram Desktop; однакові назви в різних акаунтах/профілях браузера мають спільний вебпрофіль. Адреса й назва беруться через accessibility, без OCR, таймера, розширення та читання історії повідомлень. В адресному рядку, на головній сторінці й для непідтримуваних адрес профіль не діє. Web A та інші браузери не перевірені й не заявлені як підтримувані.
|
||||||
|
|
||||||
|
### Application-specific snippets — v0.6.51
|
||||||
|
|
||||||
|
In **Tools → Snippets**, select a snippet and enable **Only in these applications**. Enter executable names separated by commas, or select a currently open application's process and choose **Add app**. For example: `ms-teams.exe, outlook.exe`. Uncheck the option for a global snippet. A checked but empty restriction cannot be saved. Save applies the changes; Cancel discards them.
|
||||||
|
|
||||||
|
A matching application-specific snippet takes precedence over a global snippet with the same trigger. The same trigger can be used for different applications; overlapping scopes for the same trigger are rejected. Existing snippets without a scope remain global. Matching is case-insensitive by process name, with or without `.exe`, and is checked before insertion. No OCR or timer is involved. Chrome scope means the entire browser, not one website.
|
||||||
|
|
||||||
|
Scopes are saved with snippets and included in manual import/export and synchronization; merge conflicts consider the application scopes. Use v0.6.51 or newer on every synchronizing PC before sharing scoped snippets: older versions do not understand the restriction and may treat the snippet as global.
|
||||||
|
|
||||||
|
**Українською.** У **Tools → Snippets** вибери сніпет, увімкни **Only in these applications** та задай назви процесів через кому або вибери відкриту програму зі списку й натисни **Add app**. Наприклад: `ms-teams.exe, outlook.exe`. Вимкнений прапорець означає всі програми. Увімкнений прапорець із порожнім списком не можна зберегти. **Save** зберігає, **Cancel** відкидає зміни.
|
||||||
|
|
||||||
|
Сніпет конкретної програми має пріоритет над глобальним із тим самим тригером. Для різних програм однакові тригери дозволені, для перетинних областей — ні. Старі сніпети залишаються глобальними. Зіставлення за назвою процесу не залежить від регістру та суфікса `.exe`; OCR і таймерів немає. Для Chrome це весь браузер, а не окремий сайт. Обмеження входять до експорту/імпорту та синхронізації. Перед синхронізацією онови всі ПК до v0.6.51 або новішої: старі версії не розуміють обмеження та можуть застосувати сніпет глобально.
|
||||||
### Existing features
|
### Existing features
|
||||||
|
|
||||||
- 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.
|
||||||
|
|||||||
Binary file not shown.
+8
-7
@@ -1,20 +1,21 @@
|
|||||||
# Latest Windows release
|
# Latest Windows release
|
||||||
|
|
||||||
## v0.6.50 — 2026-11-06
|
## v0.6.52 — 2026-11-06
|
||||||
|
|
||||||
## Changes
|
## Changes
|
||||||
- Disable experimental Viber chat OCR to remove repeated recognition and its language-switching delays. No Viber OCR runs in the background, on layout switching/conversion, or when opening the chat-profile dialog.
|
- Add **Arc (U)** to the screenshot editor toolbar, with its own cursor and drawing hint. Drag a rectangle to draw an open half ellipse; the endpoints are not joined by a straight chord.
|
||||||
- Keep saved Viber profiles and show them as inactive. Viber uses ordinary Windows layout switching and the global KSwitcher Next layout cycle. No settings reset is needed.
|
- Resize arcs using the corner handles, rotate them with the round handle (Shift snaps to 15 degrees), move or clone them, and adjust their color, thickness and line style. Undo/redo, connector attachment and full-resolution PNG export preserve the open curve.
|
||||||
- Preserve Telegram Desktop and Microsoft Teams chat profiles, their shortcuts and behavior. Clipboard/image OCR and Tesseract options remain available.
|
- Widen the editor's minimum window width to fit the additional toolbar button.
|
||||||
|
- Retain v0.6.51 Telegram Web K chat profiles in Chrome and application-specific snippets, including their compatibility requirements below.
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
Passed 653 chat-profile/UI checks including saved Viber-profile persistence, 332 native checks, 564 input checks, 105 sync checks, complete dictionary equivalence (140,939 words), wheel, StayHere, Utilities, screenshot UI/save/registration, 54 updater checks and updater progress checks. The disabled Viber OCR module is not compiled into the application. Release versions, manifest, archive and published downloads are checked during publication.
|
Passed 332 native checks, 568 input checks, 674 chat-profile checks, 108 sync checks, dictionary equivalence (140,939 words), wheel, StayHere, Utilities, screenshot UI/save/registration, 54 updater checks and updater progress checks. Arc checks cover toolbar selection, drawing, open-curve hit testing and rendering, resizing, rotation, connector attachment, undo/redo and PNG export. Inspected an actual editor rendering with the Arc tool selected. Published package checksums, manifests, executable versions, update selection and English/Ukrainian Wiki pages are verified during publication.
|
||||||
|
|
||||||
## Upgrade
|
## Upgrade and compatibility
|
||||||
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.
|
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, close its configurator and screenshot editors, and extract the entire ZIP into its installation folder. Keep personal `data/`, customized dictionaries and your own tools. Keep the three KSwitcher executables together; StayHere runs independently.
|
For manual installation, exit KSwitcher, close its configurator and screenshot editors, and extract the entire ZIP into its installation folder. Keep personal `data/`, customized dictionaries and your own tools. Keep the three KSwitcher executables together; StayHere runs independently.
|
||||||
|
|
||||||
Chat profiles remain local, are included in manual export/import and excluded from cloud synchronization. Saved Viber profiles require v0.6.49 or newer to load, even while inactive; retain a backup before downgrading. Phrase replacement synchronization requires v0.6.47 or newer on participating PCs.
|
**Update every synchronizing PC to v0.6.51 or newer before sharing application-specific snippets. Older versions do not understand application restrictions and may treat scoped snippets as global.** Keep a backup before downgrading. Settings with Telegram Web profiles require v0.6.51 or newer. Chat profiles remain local, included in manual export/import and excluded from cloud synchronization.
|
||||||
|
|
||||||
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains no personal settings, credentials or clipboard history.
|
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains no personal settings, credentials or clipboard history.
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
250752bd26227f4ba43f956eda73ec6ab4d909a35300e6d1c72222892cd03db7 KSwitcher-win-x64.zip
|
fdc093c4a45c55fcee7ae4c9b597d56ea95293bd8995ed84cc2c0460e8855d58 KSwitcher-win-x64.zip
|
||||||
+35
-35
@@ -1,81 +1,81 @@
|
|||||||
{
|
{
|
||||||
"version": "0.6.50",
|
"version": "0.6.51",
|
||||||
"schema": 1,
|
"schema": 1,
|
||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
"sha256": "3c57a24174112daa795e10d1cbbd50d405d453bc93c0fa43e83788ffcca1de51",
|
"size": 1847296,
|
||||||
"size": 1834496,
|
"path": "KSwitcher.exe",
|
||||||
"path": "KSwitcher.exe"
|
"sha256": "c40f90944169ec6f1b92803ccaec734573bf798939f13ab3553b982b709e479c"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha256": "f569f21076f85993ed7a6d3f1fb48fbff26046b73215c2fd36d8b0afb8c0aafb",
|
"size": 1716224,
|
||||||
"size": 1698304,
|
"path": "KSwitcher.Config.exe",
|
||||||
"path": "KSwitcher.Config.exe"
|
"sha256": "8e8a5fee9be8f318b78516b82415c6b098ac67f7c374f4667ef271576a77f9fa"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha256": "a6c3113d06185b4fd405d34618ba5fa1c0a1a9996d9288af04effb823c8c7635",
|
"size": 2897408,
|
||||||
"size": 2885120,
|
"path": "KSwitcher.Screenshot.exe",
|
||||||
"path": "KSwitcher.Screenshot.exe"
|
"sha256": "bb5ffc3a26955c752d593ba06f69476985d9902f2ed0345fe3efe9bfc2eb63c8"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha256": "d3c81140cce01c26bf6cbf52aa40e0825e1fa35b9a91d1387170a65d2597082c",
|
|
||||||
"size": 4401,
|
"size": 4401,
|
||||||
"path": "LICENSE"
|
"path": "LICENSE",
|
||||||
|
"sha256": "d3c81140cce01c26bf6cbf52aa40e0825e1fa35b9a91d1387170a65d2597082c"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha256": "61e08da79c44061654015ce6b0c4061c23ee1dd18237fbf09e9bfda3c0ea2831",
|
|
||||||
"size": 1096,
|
"size": 1096,
|
||||||
"path": "json-LICENSE.txt"
|
"path": "json-LICENSE.txt",
|
||||||
|
"sha256": "61e08da79c44061654015ce6b0c4061c23ee1dd18237fbf09e9bfda3c0ea2831"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha256": "41a4e4a38cb31fed543f11f92b554206ff1014b4fcd27431c518e488e5c5df1a",
|
"size": 74601,
|
||||||
"size": 69865,
|
"path": "README.md",
|
||||||
"path": "README.md"
|
"sha256": "818f81e6c4bfaa6b014ac50351ec22f68ee6eb3c094ad5ecb7bac98d605e0918"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha256": "d0c022b27b9fbc2ff7b6b8bec723cc1adc4eef517202f9ff77eed5d8bea3acb9",
|
|
||||||
"size": 732485,
|
"size": 732485,
|
||||||
"path": "dictionaries/en.txt"
|
"path": "dictionaries/en.txt",
|
||||||
|
"sha256": "d0c022b27b9fbc2ff7b6b8bec723cc1adc4eef517202f9ff77eed5d8bea3acb9"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha256": "0bdfb0201d1803634f268cdff0993b8349d2c70e956a06dbb667c8dd65254ec5",
|
|
||||||
"size": 20566,
|
"size": 20566,
|
||||||
"path": "dictionaries/LICENSE-CC-BY-SA-4.0.txt"
|
"path": "dictionaries/LICENSE-CC-BY-SA-4.0.txt",
|
||||||
|
"sha256": "0bdfb0201d1803634f268cdff0993b8349d2c70e956a06dbb667c8dd65254ec5"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha256": "3017bf44d3be9d9dfab21bd6778aa52b7947e61acf9ce2bc86b41d6e949ac311",
|
|
||||||
"size": 2265,
|
"size": 2265,
|
||||||
"path": "dictionaries/README.md"
|
"path": "dictionaries/README.md",
|
||||||
|
"sha256": "3017bf44d3be9d9dfab21bd6778aa52b7947e61acf9ce2bc86b41d6e949ac311"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha256": "80e6b6a27815973535f7facb636c597eb845dfee45cfabfc4e9b3341b9332849",
|
|
||||||
"size": 1109134,
|
"size": 1109134,
|
||||||
"path": "dictionaries/ru.txt"
|
"path": "dictionaries/ru.txt",
|
||||||
|
"sha256": "80e6b6a27815973535f7facb636c597eb845dfee45cfabfc4e9b3341b9332849"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha256": "98e5b3fe7304e37705c9e5ff48b0b399ccb16e806736d21f09921b99e5f2a408",
|
|
||||||
"size": 1280,
|
"size": 1280,
|
||||||
"path": "dictionaries/sources.json"
|
"path": "dictionaries/sources.json",
|
||||||
|
"sha256": "98e5b3fe7304e37705c9e5ff48b0b399ccb16e806736d21f09921b99e5f2a408"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha256": "d30e990d6ec2983c04f3644a3c61c6c668e8c542323f5931916fdd55b916eebf",
|
|
||||||
"size": 940799,
|
"size": 940799,
|
||||||
"path": "dictionaries/uk.txt"
|
"path": "dictionaries/uk.txt",
|
||||||
|
"sha256": "d30e990d6ec2983c04f3644a3c61c6c668e8c542323f5931916fdd55b916eebf"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha256": "a34b5161a8315af037e1ec71d816bfc688f7c364141d4ee2347cf0515ef0c82a",
|
|
||||||
"size": 993,
|
"size": 993,
|
||||||
"path": "tools/README.txt"
|
"path": "tools/README.txt",
|
||||||
|
"sha256": "a34b5161a8315af037e1ec71d816bfc688f7c364141d4ee2347cf0515ef0c82a"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha256": "6ef5cf64708d62b7fc63c3c793cc99188a513433ff4774cae01dc014f6664376",
|
|
||||||
"size": 134656,
|
"size": 134656,
|
||||||
"path": "tools/StayHere.exe"
|
"path": "tools/StayHere.exe",
|
||||||
|
"sha256": "6ef5cf64708d62b7fc63c3c793cc99188a513433ff4774cae01dc014f6664376"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha256": "ba214e2aca7405e698b3ab21ceb1f41ce088edd996b3e30e4a9d58ff496f9413",
|
|
||||||
"size": 2796,
|
"size": 2796,
|
||||||
"path": "tools/StayHere-README.md"
|
"path": "tools/StayHere-README.md",
|
||||||
|
"sha256": "ba214e2aca7405e698b3ab21ceb1f41ce088edd996b3e30e4a9d58ff496f9413"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+3
-3
@@ -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,50,0
|
FILEVERSION 0,6,52,0
|
||||||
PRODUCTVERSION 0,6,50,0
|
PRODUCTVERSION 0,6,52,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.50"
|
VALUE "FileVersion", "0.6.52"
|
||||||
END
|
END
|
||||||
END
|
END
|
||||||
BLOCK "VarFileInfo"
|
BLOCK "VarFileInfo"
|
||||||
|
|||||||
@@ -559,6 +559,7 @@ App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot, HWND ser
|
|||||||
}
|
}
|
||||||
App::~App()
|
App::~App()
|
||||||
{
|
{
|
||||||
|
stopBrowserChatMonitor();
|
||||||
if (auto host = historyController(historyProcess)) PostMessageW(host, WM_CLOSE, 0, 0);
|
if (auto host = historyController(historyProcess)) PostMessageW(host, WM_CLOSE, 0, 0);
|
||||||
if (historyProcess) CloseHandle(historyProcess);
|
if (historyProcess) CloseHandle(historyProcess);
|
||||||
if (notificationRegistration) CloseHandle(notificationRegistration);
|
if (notificationRegistration) CloseHandle(notificationRegistration);
|
||||||
@@ -918,9 +919,12 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
|
|||||||
if (busy || settings.snippets.empty())
|
if (busy || settings.snippets.empty())
|
||||||
return;
|
return;
|
||||||
auto keys = lower(run.keys);
|
auto keys = lower(run.keys);
|
||||||
|
auto snippetProcess = processName(fg);
|
||||||
|
for (int priority : {2, 1})
|
||||||
for (size_t i = 0; i < settings.snippets.size(); ++i)
|
for (size_t i = 0; i < settings.snippets.size(); ++i)
|
||||||
{
|
{
|
||||||
auto &sn = settings.snippets[i];
|
auto &sn = settings.snippets[i];
|
||||||
|
if (snippetAppPriority(sn, snippetProcess) != priority) continue;
|
||||||
std::vector<std::wstring> variants{lower(sn.trigger)};
|
std::vector<std::wstring> variants{lower(sn.trigger)};
|
||||||
for (auto &l : layouts)
|
for (auto &l : layouts)
|
||||||
{
|
{
|
||||||
@@ -978,6 +982,7 @@ void App::switchChatLayout(bool reverse)
|
|||||||
{
|
{
|
||||||
struct Reset { std::atomic<bool> &busy; ~Reset() { busy = false; } } reset{busy};
|
struct Reset { std::atomic<bool> &busy; ~Reset() { busy = false; } } reset{busy};
|
||||||
auto valid = [&] { return GetForegroundWindow() == target && windowChatKey(target) == key; };
|
auto valid = [&] { return GetForegroundWindow() == target && windowChatKey(target) == key; };
|
||||||
|
if (key.starts_with(L"telegram-web\n") && readChromeChat(target) != key) return;
|
||||||
if (!valid()) return;
|
if (!valid()) return;
|
||||||
auto next = nextChatLayout(cycle, available, windowLayout(target), reverse);
|
auto next = nextChatLayout(cycle, available, windowLayout(target), reverse);
|
||||||
if (next) switchWindowLayout(target, next, valid);
|
if (next) switchWindowLayout(target, next, valid);
|
||||||
@@ -1275,6 +1280,7 @@ void App::transform(int command, int snippet)
|
|||||||
}
|
}
|
||||||
} reset{busy, window, preDeferred};
|
} reset{busy, window, preDeferred};
|
||||||
auto effectiveChat = requestedChat;
|
auto effectiveChat = requestedChat;
|
||||||
|
if (lower(processName(target)) == L"chrome") effectiveChat = readChromeChat(target);
|
||||||
config.cycle = chatCycle(config, effectiveChat, available);
|
config.cycle = chatCycle(config, effectiveChat, available);
|
||||||
auto start = GetTickCount64();
|
auto start = GetTickCount64();
|
||||||
std::wstring reason = L"cancelled-before-selection";
|
std::wstring reason = L"cancelled-before-selection";
|
||||||
@@ -1470,6 +1476,7 @@ void App::transform(int command, int snippet)
|
|||||||
if (snippet >= (int)config.snippets.size() || recent.window != target)
|
if (snippet >= (int)config.snippets.size() || recent.window != target)
|
||||||
return;
|
return;
|
||||||
const auto &sn = config.snippets[snippet];
|
const auto &sn = config.snippets[snippet];
|
||||||
|
if (!snippetAppPriority(sn, processName(target))) return;
|
||||||
if (recent.keys.size() < sn.trigger.size())
|
if (recent.keys.size() < sn.trigger.size())
|
||||||
return;
|
return;
|
||||||
result = parseTemplate(sn.text).text;
|
result = parseTemplate(sn.text).text;
|
||||||
@@ -2318,6 +2325,7 @@ int App::execute()
|
|||||||
// Resume pending OCR, including older images without a saved OCR state.
|
// Resume pending OCR, including older images without a saved OCR state.
|
||||||
scheduleHistoryOcr(settings);
|
scheduleHistoryOcr(settings);
|
||||||
addTray();
|
addTray();
|
||||||
|
if (!smoke) startBrowserChatMonitor();
|
||||||
SetTimer(window, 1, 60000, nullptr);
|
SetTimer(window, 1, 60000, nullptr);
|
||||||
if (!smoke) SetTimer(window, 6, 1000, nullptr);
|
if (!smoke) SetTimer(window, 6, 1000, nullptr);
|
||||||
SetThreadExecutionState(ES_CONTINUOUS |
|
SetThreadExecutionState(ES_CONTINUOUS |
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
// Chrome Telegram Web integration: native accessibility, no OCR or polling timer.
|
||||||
|
#include "chat_context.h"
|
||||||
|
#include <uiautomation.h>
|
||||||
|
#include <atomic>
|
||||||
|
#include <thread>
|
||||||
|
namespace ks {
|
||||||
|
namespace {
|
||||||
|
template<class T> struct BrowserCom {
|
||||||
|
T *p{}; ~BrowserCom(){if(p)p->Release();}
|
||||||
|
T** out(){return &p;} T* operator->(){return p;}
|
||||||
|
};
|
||||||
|
std::wstring browserString(IUIAutomationElement* e, PROPERTYID id) {
|
||||||
|
VARIANT v;VariantInit(&v);std::wstring s;
|
||||||
|
if(SUCCEEDED(e->GetCurrentPropertyValue(id,&v)) && v.vt==VT_BSTR && v.bstrVal) s.assign(v.bstrVal,SysStringLen(v.bstrVal));
|
||||||
|
VariantClear(&v);return s;
|
||||||
|
}
|
||||||
|
struct ChromeElements { BrowserCom<IUIAutomationElement> address,document,header; int addresses{},documents{}; };
|
||||||
|
void findChromeElements(IUIAutomationTreeWalker* walker,IUIAutomationElement* e,ChromeElements& found,int depth,int& budget) {
|
||||||
|
if(depth>12 || --budget<0)return;
|
||||||
|
CONTROLTYPEID type{};BOOL offscreen=TRUE;e->get_CurrentControlType(&type);e->get_CurrentIsOffscreen(&offscreen);
|
||||||
|
if(type==UIA_DocumentControlTypeId){
|
||||||
|
if(!offscreen && browserString(e,UIA_AutomationIdPropertyId)==L"RootWebArea"){
|
||||||
|
if(++found.documents==1){e->AddRef();found.document.p=e;}}
|
||||||
|
return; // Never traverse message content.
|
||||||
|
}
|
||||||
|
if(type==UIA_EditControlTypeId && !offscreen && browserString(e,UIA_ClassNamePropertyId)==L"OmniboxViewViews"){
|
||||||
|
if(++found.addresses==1){e->AddRef();found.address.p=e;}}
|
||||||
|
BrowserCom<IUIAutomationElement> child;walker->GetFirstChildElement(e,child.out());
|
||||||
|
while(child.p && budget>0){findChromeElements(walker,child.p,found,depth+1,budget);BrowserCom<IUIAutomationElement> next;walker->GetNextSiblingElement(child.p,next.out());child.p->Release();child.p=next.p;next.p=nullptr;}
|
||||||
|
}
|
||||||
|
IUIAutomationElement* chromeHeader(IUIAutomationTreeWalker* walker,IUIAutomationElement* document) {
|
||||||
|
// Web K's center pane is separate from its chat list and message history.
|
||||||
|
std::function<IUIAutomationElement*(IUIAutomationElement*,int)> center;
|
||||||
|
center=[&](IUIAutomationElement* e,int depth)->IUIAutomationElement* {
|
||||||
|
if(depth>3)return nullptr;
|
||||||
|
if(browserString(e,UIA_AutomationIdPropertyId)==L"column-center"){e->AddRef();return e;}
|
||||||
|
auto id=browserString(e,UIA_AutomationIdPropertyId);if(id==L"column-left"||id==L"column-right")return nullptr;
|
||||||
|
BrowserCom<IUIAutomationElement> c;walker->GetFirstChildElement(e,c.out());int limit=12;
|
||||||
|
while(c.p && --limit>0){if(auto result=center(c.p,depth+1))return result;BrowserCom<IUIAutomationElement> next;walker->GetNextSiblingElement(c.p,next.out());c.p->Release();c.p=next.p;next.p=nullptr;}return nullptr;
|
||||||
|
};
|
||||||
|
BrowserCom<IUIAutomationElement> pane;pane.p=center(document,0);if(!pane.p)return nullptr;
|
||||||
|
BrowserCom<IUIAutomationElement> header;walker->GetFirstChildElement(pane.p,header.out());if(!header.p)return nullptr;
|
||||||
|
BrowserCom<IUIAutomationElement> c;walker->GetFirstChildElement(header.p,c.out());int limit=16,buttons=0;IUIAutomationElement* title{};
|
||||||
|
while(c.p && --limit>0){CONTROLTYPEID type{};c->get_CurrentControlType(&type);if(type==UIA_ButtonControlTypeId)++buttons;
|
||||||
|
if(type==UIA_TextControlTypeId && !title){title=c.p;title->AddRef();}
|
||||||
|
BrowserCom<IUIAutomationElement> next;walker->GetNextSiblingElement(c.p,next.out());c.p->Release();c.p=next.p;next.p=nullptr;}
|
||||||
|
if(buttons<2 && title){title->Release();title=nullptr;}return title;
|
||||||
|
}
|
||||||
|
std::wstring chromeTitleKey(IUIAutomationElement* header){
|
||||||
|
if(!header)return {};
|
||||||
|
auto name=browserString(header,UIA_NamePropertyId);
|
||||||
|
if(name.empty()||name.size()>512||name.find_first_of(L"\r\n")!=name.npos)return {};
|
||||||
|
return L"telegram-web\n"+name;
|
||||||
|
}
|
||||||
|
std::wstring chromeSnapshot(IUIAutomation* uia,HWND window,ChromeElements& found) {
|
||||||
|
if(!IsWindow(window)||IsIconic(window)||lower(processName(window))!=L"chrome")return {};
|
||||||
|
BrowserCom<IUIAutomationElement> root;BrowserCom<IUIAutomationTreeWalker> walker;
|
||||||
|
if(FAILED(uia->ElementFromHandle(window,root.out()))||!root.p||FAILED(uia->get_ControlViewWalker(walker.out())))return {};
|
||||||
|
int budget=250;findChromeElements(walker.p,root.p,found,0,budget);
|
||||||
|
if(found.addresses!=1||found.documents!=1)return {};
|
||||||
|
auto address=telegramWebKey(browserString(found.address.p,UIA_ValueValuePropertyId));
|
||||||
|
auto document=telegramWebKey(browserString(found.document.p,UIA_ValueValuePropertyId));
|
||||||
|
BOOL editing=FALSE;found.address->get_CurrentHasKeyboardFocus(&editing);
|
||||||
|
if(editing || address.empty() || address!=document)return {};
|
||||||
|
found.header.p=chromeHeader(walker.p,found.document.p);
|
||||||
|
return chromeTitleKey(found.header.p);
|
||||||
|
}
|
||||||
|
std::mutex browserMutex;
|
||||||
|
HWND browserWindow{};std::wstring browserKey;
|
||||||
|
std::atomic<DWORD> browserThreadId{};
|
||||||
|
std::atomic<bool> browserPending{}, browserStopping{};
|
||||||
|
std::thread browserThread;
|
||||||
|
void invalidateBrowser(){std::lock_guard lock(browserMutex);browserKey.clear();}
|
||||||
|
void requestBrowser(){invalidateBrowser();auto id=browserThreadId.load();if(id && !browserPending.exchange(true))PostThreadMessageW(id,WM_APP+61,0,0);}
|
||||||
|
void CALLBACK browserForeground(HWINEVENTHOOK,DWORD,HWND,LONG,LONG,DWORD,DWORD){requestBrowser();}
|
||||||
|
class BrowserChanged final: public IUIAutomationPropertyChangedEventHandler {
|
||||||
|
std::atomic<ULONG> refs{1};
|
||||||
|
public:
|
||||||
|
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID iid,void** out) override {if(!out)return E_POINTER;*out=nullptr;if(iid==IID_IUnknown||iid==IID_IUIAutomationPropertyChangedEventHandler){*out=static_cast<IUIAutomationPropertyChangedEventHandler*>(this);AddRef();return S_OK;}return E_NOINTERFACE;}
|
||||||
|
ULONG STDMETHODCALLTYPE AddRef() override{return ++refs;}
|
||||||
|
ULONG STDMETHODCALLTYPE Release() override{auto n=--refs;if(!n)delete this;return n;}
|
||||||
|
HRESULT STDMETHODCALLTYPE HandlePropertyChangedEvent(IUIAutomationElement*,PROPERTYID,VARIANT) override {requestBrowser();return S_OK;}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
std::wstring readChromeChat(HWND window) {
|
||||||
|
auto hr=CoInitializeEx(nullptr,COINIT_MULTITHREADED);
|
||||||
|
struct Apartment{bool own;~Apartment(){if(own)CoUninitialize();}} apartment{SUCCEEDED(hr)};
|
||||||
|
BrowserCom<IUIAutomation2> uia;
|
||||||
|
if(FAILED(CoCreateInstance(CLSID_CUIAutomation8,nullptr,CLSCTX_INPROC_SERVER,IID_IUIAutomation2,(void**)uia.out())))return {};
|
||||||
|
uia->put_ConnectionTimeout(150);uia->put_TransactionTimeout(150);
|
||||||
|
ChromeElements elements;return chromeSnapshot(uia.p,window,elements);
|
||||||
|
}
|
||||||
|
std::wstring cachedChromeChat(HWND window){std::lock_guard lock(browserMutex);return window==browserWindow?browserKey:L"";}
|
||||||
|
void startBrowserChatMonitor(){
|
||||||
|
if(browserThread.joinable())return;
|
||||||
|
browserStopping=false;
|
||||||
|
browserThread=std::thread([] {
|
||||||
|
CoInitializeEx(nullptr,COINIT_MULTITHREADED);MSG message{};PeekMessageW(&message,nullptr,0,0,PM_NOREMOVE);
|
||||||
|
browserThreadId=GetCurrentThreadId();
|
||||||
|
auto foreground=SetWinEventHook(EVENT_SYSTEM_FOREGROUND,EVENT_SYSTEM_FOREGROUND,nullptr,browserForeground,0,0,WINEVENT_OUTOFCONTEXT);
|
||||||
|
// Focus changes cover tab selection and entering/leaving the address bar.
|
||||||
|
auto focus=SetWinEventHook(EVENT_OBJECT_FOCUS,EVENT_OBJECT_FOCUS,nullptr,browserForeground,0,0,WINEVENT_OUTOFCONTEXT);
|
||||||
|
BrowserCom<IUIAutomation2> uia;CoCreateInstance(CLSID_CUIAutomation8,nullptr,CLSCTX_INPROC_SERVER,IID_IUIAutomation2,(void**)uia.out());
|
||||||
|
BrowserCom<IUIAutomationElement> address,document,header;BrowserCom<BrowserChanged> handler;handler.p=new BrowserChanged;
|
||||||
|
if(uia.p){uia->put_ConnectionTimeout(150);uia->put_TransactionTimeout(150);}
|
||||||
|
requestBrowser();
|
||||||
|
if(browserStopping)PostThreadMessageW(GetCurrentThreadId(),WM_QUIT,0,0);
|
||||||
|
while(GetMessageW(&message,nullptr,0,0)>0){
|
||||||
|
if(message.message!=WM_APP+61){TranslateMessage(&message);DispatchMessageW(&message);continue;}
|
||||||
|
browserPending=false;
|
||||||
|
if(!uia.p)continue;
|
||||||
|
if(address.p){uia->RemovePropertyChangedEventHandler(address.p,handler.p);address.p->Release();address.p=nullptr;}
|
||||||
|
if(document.p){uia->RemovePropertyChangedEventHandler(document.p,handler.p);document.p->Release();document.p=nullptr;}
|
||||||
|
if(header.p){uia->RemovePropertyChangedEventHandler(header.p,handler.p);header.p->Release();header.p=nullptr;}
|
||||||
|
auto window=GetForegroundWindow();ChromeElements elements;auto key=chromeSnapshot(uia.p,window,elements);
|
||||||
|
PROPERTYID properties[]={UIA_ValueValuePropertyId};
|
||||||
|
bool subscribed=elements.address.p && elements.document.p;
|
||||||
|
if(subscribed){address.p=elements.address.p;elements.address.p=nullptr;document.p=elements.document.p;elements.document.p=nullptr;
|
||||||
|
subscribed=SUCCEEDED(uia->AddPropertyChangedEventHandlerNativeArray(address.p,TreeScope_Element,nullptr,handler.p,properties,1));
|
||||||
|
subscribed=SUCCEEDED(uia->AddPropertyChangedEventHandlerNativeArray(document.p,TreeScope_Element,nullptr,handler.p,properties,1)) && subscribed;
|
||||||
|
if(elements.header.p){header.p=elements.header.p;elements.header.p=nullptr;PROPERTYID nameProperty=UIA_NamePropertyId;
|
||||||
|
subscribed=SUCCEEDED(uia->AddPropertyChangedEventHandlerNativeArray(header.p,TreeScope_Element,nullptr,handler.p,&nameProperty,1)) && subscribed;}
|
||||||
|
// Close the read/subscribe race without scanning the content tree.
|
||||||
|
key=telegramWebKey(browserString(address.p,UIA_ValueValuePropertyId));
|
||||||
|
if(key.empty() || key!=telegramWebKey(browserString(document.p,UIA_ValueValuePropertyId)))key.clear();
|
||||||
|
else key=chromeTitleKey(header.p);
|
||||||
|
BOOL editing=FALSE;address->get_CurrentHasKeyboardFocus(&editing);if(editing)key.clear();
|
||||||
|
}
|
||||||
|
std::lock_guard lock(browserMutex);browserWindow=window;browserKey=subscribed && !browserPending && window==GetForegroundWindow()?key:L"";
|
||||||
|
}
|
||||||
|
browserThreadId=0;
|
||||||
|
if(foreground)UnhookWinEvent(foreground);
|
||||||
|
if(focus)UnhookWinEvent(focus);
|
||||||
|
if(uia.p){if(header.p)uia->RemovePropertyChangedEventHandler(header.p,handler.p);if(address.p)uia->RemovePropertyChangedEventHandler(address.p,handler.p);if(document.p)uia->RemovePropertyChangedEventHandler(document.p,handler.p);}
|
||||||
|
invalidateBrowser();
|
||||||
|
// COM objects release before uninitializing the apartment below.
|
||||||
|
if(header.p){header.p->Release();header.p=nullptr;}
|
||||||
|
if(address.p){address.p->Release();address.p=nullptr;}if(document.p){document.p->Release();document.p=nullptr;}
|
||||||
|
if(uia.p){uia.p->Release();uia.p=nullptr;}CoUninitialize();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
void stopBrowserChatMonitor(){browserStopping=true;auto id=browserThreadId.load();if(id)PostThreadMessageW(id,WM_QUIT,0,0);if(browserThread.joinable())browserThread.join();browserPending=false;}
|
||||||
|
}
|
||||||
@@ -4,6 +4,31 @@
|
|||||||
|
|
||||||
namespace ks
|
namespace ks
|
||||||
{
|
{
|
||||||
|
inline std::wstring telegramWebKey(std::wstring url)
|
||||||
|
{
|
||||||
|
if (url.starts_with(L"https://")) url.erase(0, 8);
|
||||||
|
const std::wstring host = L"web.telegram.org/";
|
||||||
|
if (!url.starts_with(host)) return {};
|
||||||
|
url.erase(0, host.size());
|
||||||
|
if (!(url.starts_with(L"k/#") || url.starts_with(L"a/#"))) return {};
|
||||||
|
auto peer = url.substr(3);
|
||||||
|
if (peer.empty() || peer.size() > 128) return {};
|
||||||
|
if (peer.front() == L'@')
|
||||||
|
{
|
||||||
|
peer = lower(peer);
|
||||||
|
if (peer.size() < 2 || peer.find_first_not_of(L"abcdefghijklmnopqrstuvwxyz0123456789_", 1) != peer.npos) return {};
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
auto first = peer.front() == L'-' ? 1u : 0u;
|
||||||
|
if (first == peer.size() || peer.find_first_not_of(L"0123456789", first) != peer.npos) return {};
|
||||||
|
}
|
||||||
|
return L"telegram-web\n" + peer;
|
||||||
|
}
|
||||||
|
std::wstring readChromeChat(HWND);
|
||||||
|
std::wstring cachedChromeChat(HWND);
|
||||||
|
void startBrowserChatMonitor();
|
||||||
|
void stopBrowserChatMonitor();
|
||||||
struct ChatSwitchHook
|
struct ChatSwitchHook
|
||||||
{
|
{
|
||||||
bool pressed[256]{};
|
bool pressed[256]{};
|
||||||
@@ -64,6 +89,7 @@ inline std::wstring chatKey(std::wstring process, std::wstring title)
|
|||||||
}
|
}
|
||||||
inline std::wstring windowChatKey(HWND window)
|
inline std::wstring windowChatKey(HWND window)
|
||||||
{
|
{
|
||||||
|
if (window && lower(processName(window)) == L"chrome") return cachedChromeChat(window);
|
||||||
return window ? chatKey(processName(window), chatTitle(window)) : L"";
|
return window ? chatKey(processName(window), chatTitle(window)) : L"";
|
||||||
}
|
}
|
||||||
inline std::vector<std::wstring> chatCycle(const Settings &settings, const std::wstring &key,
|
inline std::vector<std::wstring> chatCycle(const Settings &settings, const std::wstring &key,
|
||||||
|
|||||||
@@ -6,6 +6,21 @@
|
|||||||
|
|
||||||
namespace ks
|
namespace ks
|
||||||
{
|
{
|
||||||
|
std::wstring snippetAppName(std::wstring name)
|
||||||
|
{
|
||||||
|
auto first = name.find_first_not_of(L" \t"), last = name.find_last_not_of(L" \t");
|
||||||
|
if (first == name.npos) return {};
|
||||||
|
name = lower(name.substr(first, last - first + 1));
|
||||||
|
if (name.ends_with(L".exe")) name.resize(name.size() - 4);
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
int snippetAppPriority(const Snippet &snippet, const std::wstring &process)
|
||||||
|
{
|
||||||
|
if (snippet.apps.empty()) return 1;
|
||||||
|
auto name = snippetAppName(process);
|
||||||
|
for (const auto &app : snippet.apps) if (snippetAppName(app) == name && !name.empty()) return 2;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
int HotkeyHook::update(UINT key, bool down, UINT modifiers,
|
int HotkeyHook::update(UINT key, bool down, UINT modifiers,
|
||||||
const std::vector<std::pair<int, Hotkey>> &bindings)
|
const std::vector<std::pair<int, Hotkey>> &bindings)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -32,7 +32,10 @@ struct Snippet
|
|||||||
{
|
{
|
||||||
std::wstring trigger, text;
|
std::wstring trigger, text;
|
||||||
std::wstring id;
|
std::wstring id;
|
||||||
|
std::vector<std::wstring> apps; // Empty means everywhere; executable names without .exe.
|
||||||
};
|
};
|
||||||
|
std::wstring snippetAppName(std::wstring);
|
||||||
|
int snippetAppPriority(const Snippet &, const std::wstring &process);
|
||||||
std::wstring snippetId(const Snippet &);
|
std::wstring snippetId(const Snippet &);
|
||||||
std::wstring newSnippetId();
|
std::wstring newSnippetId();
|
||||||
struct Template
|
struct Template
|
||||||
|
|||||||
@@ -733,3 +733,5 @@ void log(const std::wstring &message)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} // namespace ks
|
} // namespace ks
|
||||||
|
|
||||||
|
#include "browser_chat.inc"
|
||||||
+30
-17
@@ -54,7 +54,7 @@ bool confirmScreenshotClose(HWND owner)
|
|||||||
// If Windows cannot display the confirmation, keep the screenshot open.
|
// If Windows cannot display the confirmation, keep the screenshot open.
|
||||||
return SUCCEEDED(result) && button==IDYES;
|
return SUCCEEDED(result) && button==IDYES;
|
||||||
}
|
}
|
||||||
constexpr int ToolIds[]{27,10,11,22,23,24,25,36,37,21,19,26,20,12,18,13};
|
constexpr int ToolIds[]{27,10,11,22,23,24,25,36,37,38,21,19,26,20,12,18,13};
|
||||||
struct Surface
|
struct Surface
|
||||||
{
|
{
|
||||||
HDC dc = CreateCompatibleDC(nullptr);
|
HDC dc = CreateCompatibleDC(nullptr);
|
||||||
@@ -124,7 +124,7 @@ bool chooseTextFont(HWND owner,Mark &m)
|
|||||||
m.fontWeight=font.lfWeight; m.fontItalic=font.lfItalic!=0;
|
m.fontWeight=font.lfWeight; m.fontItalic=font.lfItalic!=0;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
constexpr int CursorTools[]{10,11,22,23,24,25,36,37,19};
|
constexpr int CursorTools[]{10,11,22,23,24,25,36,37,38,19};
|
||||||
HCURSOR makeToolCursor(int tool,int size)
|
HCURSOR makeToolCursor(int tool,int size)
|
||||||
{
|
{
|
||||||
// Monochrome AND/XOR cursor: white outline keeps the glyph visible on dark images.
|
// Monochrome AND/XOR cursor: white outline keeps the glyph visible on dark images.
|
||||||
@@ -148,6 +148,7 @@ HCURSOR makeToolCursor(int tool,int size)
|
|||||||
case 22: line(18,29,29,18); break;
|
case 22: line(18,29,29,18); break;
|
||||||
case 23: Rectangle(dc,x(17),y(18),x(30),y(29)); break;
|
case 23: Rectangle(dc,x(17),y(18),x(30),y(29)); break;
|
||||||
case 24: Ellipse(dc,x(17),y(17),x(30),y(30)); break;
|
case 24: Ellipse(dc,x(17),y(17),x(30),y(30)); break;
|
||||||
|
case 38: Arc(dc,x(16),y(18),x(31),y(38),x(31),y(28),x(16),y(28)); break;
|
||||||
case 25: Ellipse(dc,x(16),y(20),x(31),y(28)); break;
|
case 25: Ellipse(dc,x(16),y(20),x(31),y(28)); break;
|
||||||
case 36: { POINT p[]{{x(23),y(17)},{x(31),y(30)},{x(16),y(30)},{x(23),y(17)}}; Polyline(dc,p,4); break; }
|
case 36: { POINT p[]{{x(23),y(17)},{x(31),y(30)},{x(16),y(30)},{x(23),y(17)}}; Polyline(dc,p,4); break; }
|
||||||
case 37: { POINT p[]{{x(20),y(18)},{x(27),y(18)},{x(31),y(30)},{x(16),y(30)},{x(20),y(18)}}; Polyline(dc,p,5); break; }
|
case 37: { POINT p[]{{x(20),y(18)},{x(27),y(18)},{x(31),y(30)},{x(16),y(30)},{x(20),y(18)}}; Polyline(dc,p,5); break; }
|
||||||
@@ -268,6 +269,7 @@ void updateStatus(Capture &c)
|
|||||||
else if(c.textTool) text=L"Text (T): click the image to type | Double-click existing text to edit";
|
else if(c.textTool) text=L"Text (T): click the image to type | Double-click existing text to edit";
|
||||||
else if(c.highlight) text=L"Highlight (H): drag to paint | Ctrl+drag = paint over an object | V = pointer";
|
else if(c.highlight) text=L"Highlight (H): drag to paint | Ctrl+drag = paint over an object | V = pointer";
|
||||||
else if(c.freehand) text=L"Freehand (F): auto-smooth / shapes; Ctrl+Z restores original | Shift = 45-degree steps | Ctrl = draw over objects";
|
else if(c.freehand) text=L"Freehand (F): auto-smooth / shapes; Ctrl+Z restores original | Shift = 45-degree steps | Ctrl = draw over objects";
|
||||||
|
else if(c.shape==ScreenshotShape::Arc) text=L"Arc (U): drag to draw a half ellipse | Handles = resize / rotate | V = pointer";
|
||||||
else if(c.arrow) text=L"Drag to draw | Ctrl+drag over objects = linked connector | V = pointer";
|
else if(c.arrow) text=L"Drag to draw | Ctrl+drag over objects = linked connector | V = pointer";
|
||||||
else text=L"Number (N): click to add | V = select and edit | Ctrl+C = copy screenshot";
|
else text=L"Number (N): click to add | V = select and edit | Ctrl+C = copy screenshot";
|
||||||
text=L"Zoom: "+std::to_wstring(int(std::lround(100.0*(c.view.right-c.view.left)/c.image->width)))+
|
text=L"Zoom: "+std::to_wstring(int(std::lround(100.0*(c.view.right-c.view.left)/c.image->width)))+
|
||||||
@@ -409,6 +411,14 @@ std::vector<POINT> shapeOutline(const Mark &m)
|
|||||||
auto r=region(m.from,m.to);
|
auto r=region(m.from,m.to);
|
||||||
if(m.shape==ScreenshotShape::Rectangle)
|
if(m.shape==ScreenshotShape::Rectangle)
|
||||||
points={{r.left,r.top},{r.right,r.top},{r.right,r.bottom},{r.left,r.bottom},{r.left,r.top}};
|
points={{r.left,r.top},{r.right,r.top},{r.right,r.bottom},{r.left,r.bottom},{r.left,r.top}};
|
||||||
|
else if(m.shape==ScreenshotShape::Arc) {
|
||||||
|
// An open half ellipse spanning the drag rectangle, with no closing chord.
|
||||||
|
for(int i=0;i<=90;++i) {
|
||||||
|
double a=i*3.141592653589793/90;
|
||||||
|
points.push_back({LONG(std::lround((r.left+r.right)/2.0-(r.right-r.left)/2.0*std::cos(a))),
|
||||||
|
LONG(std::lround(r.bottom-(r.bottom-r.top)*std::sin(a)))});
|
||||||
|
}
|
||||||
|
}
|
||||||
else if(m.shape==ScreenshotShape::Triangle)
|
else if(m.shape==ScreenshotShape::Triangle)
|
||||||
points={{(r.left+r.right)/2,r.top},{r.right,r.bottom},{r.left,r.bottom},{(r.left+r.right)/2,r.top}};
|
points={{(r.left+r.right)/2,r.top},{r.right,r.bottom},{r.left,r.bottom},{(r.left+r.right)/2,r.top}};
|
||||||
else if(m.shape==ScreenshotShape::Trapezoid)
|
else if(m.shape==ScreenshotShape::Trapezoid)
|
||||||
@@ -550,7 +560,7 @@ void drawMarks(HDC dc, const std::vector<Mark> &marks, const Mark *preview = nul
|
|||||||
if(m.shape!=ScreenshotShape::None) {
|
if(m.shape!=ScreenshotShape::None) {
|
||||||
auto hollow=SelectObject(dc,GetStockObject(HOLLOW_BRUSH));
|
auto hollow=SelectObject(dc,GetStockObject(HOLLOW_BRUSH));
|
||||||
auto r=region(m.from,m.to);
|
auto r=region(m.from,m.to);
|
||||||
if(!m.points.empty() || m.rotation!=0 || m.shape==ScreenshotShape::Triangle || m.shape==ScreenshotShape::Trapezoid) {
|
if(!m.points.empty() || m.rotation!=0 || m.shape==ScreenshotShape::Triangle || m.shape==ScreenshotShape::Trapezoid || m.shape==ScreenshotShape::Arc) {
|
||||||
auto points=shapeOutline(m); Polyline(dc,points.data(),int(points.size()));
|
auto points=shapeOutline(m); Polyline(dc,points.data(),int(points.size()));
|
||||||
}
|
}
|
||||||
else if(m.shape==ScreenshotShape::Rectangle) Rectangle(dc,r.left,r.top,r.right+1,r.bottom+1);
|
else if(m.shape==ScreenshotShape::Rectangle) Rectangle(dc,r.left,r.top,r.right+1,r.bottom+1);
|
||||||
@@ -668,7 +678,7 @@ void layout(HWND w, Capture &c)
|
|||||||
RECT statusRect{}; GetWindowRect(status,&statusRect);
|
RECT statusRect{}; GetWindowRect(status,&statusRect);
|
||||||
c.statusHeight=statusRect.bottom-statusRect.top;
|
c.statusHeight=statusRect.bottom-statusRect.top;
|
||||||
r.bottom-=c.statusHeight;
|
r.bottom-=c.statusHeight;
|
||||||
const int positions[]{8,44,80,116,152,188,224,260,296,332,368,404,440,488,524,572};
|
const int positions[]{8,44,80,116,152,188,224,260,296,332,368,404,440,476,524,560,608};
|
||||||
static_assert(std::size(positions)==std::size(ToolIds));
|
static_assert(std::size(positions)==std::size(ToolIds));
|
||||||
for (size_t i = 0; i < std::size(ToolIds); ++i)
|
for (size_t i = 0; i < std::size(ToolIds); ++i)
|
||||||
{
|
{
|
||||||
@@ -677,7 +687,7 @@ void layout(HWND w, Capture &c)
|
|||||||
MulDiv(32,dpi,96),MulDiv(32,dpi,96),TRUE);
|
MulDiv(32,dpi,96),MulDiv(32,dpi,96),TRUE);
|
||||||
}
|
}
|
||||||
SendDlgItemMessageW(w,28,WM_SETFONT,(WPARAM)c.font,TRUE);
|
SendDlgItemMessageW(w,28,WM_SETFONT,(WPARAM)c.font,TRUE);
|
||||||
MoveWindow(GetDlgItem(w,28),MulDiv(612,dpi,96),MulDiv(6,dpi,96),
|
MoveWindow(GetDlgItem(w,28),MulDiv(648,dpi,96),MulDiv(6,dpi,96),
|
||||||
MulDiv(78,dpi,96),MulDiv(32,dpi,96),TRUE);
|
MulDiv(78,dpi,96),MulDiv(32,dpi,96),TRUE);
|
||||||
c.panelHeight=c.unit+MulDiv(40,dpi,96);
|
c.panelHeight=c.unit+MulDiv(40,dpi,96);
|
||||||
for(int id:{30,31,32,33,34,35}) SendDlgItemMessageW(w,id,WM_SETFONT,(WPARAM)c.font,TRUE);
|
for(int id:{30,31,32,33,34,35}) SendDlgItemMessageW(w,id,WM_SETFONT,(WPARAM)c.font,TRUE);
|
||||||
@@ -771,7 +781,7 @@ HCURSOR toolCursor(const Capture &c)
|
|||||||
if(c.pointer) return LoadCursorW(nullptr,IDC_ARROW);
|
if(c.pointer) return LoadCursorW(nullptr,IDC_ARROW);
|
||||||
if(c.textTool) return LoadCursorW(nullptr,IDC_IBEAM);
|
if(c.textTool) return LoadCursorW(nullptr,IDC_IBEAM);
|
||||||
if(c.freehand && c.pencilCursor) return c.pencilCursor;
|
if(c.freehand && c.pencilCursor) return c.pencilCursor;
|
||||||
int tool=c.highlight?19:c.straight?22:c.shape==ScreenshotShape::Trapezoid?37:c.shape==ScreenshotShape::Triangle?36:c.shape!=ScreenshotShape::None?22+int(c.shape):c.arrow?11:10;
|
int tool=c.highlight?19:c.straight?22:c.shape==ScreenshotShape::Arc?38:c.shape==ScreenshotShape::Trapezoid?37:c.shape==ScreenshotShape::Triangle?36:c.shape!=ScreenshotShape::None?22+int(c.shape):c.arrow?11:10;
|
||||||
for(size_t i=0;i<std::size(CursorTools);++i)
|
for(size_t i=0;i<std::size(CursorTools);++i)
|
||||||
if(CursorTools[i]==tool && c.drawingCursors[i]) return c.drawingCursors[i];
|
if(CursorTools[i]==tool && c.drawingCursors[i]) return c.drawingCursors[i];
|
||||||
return LoadCursorW(nullptr,IDC_CROSS);
|
return LoadCursorW(nullptr,IDC_CROSS);
|
||||||
@@ -883,7 +893,7 @@ void drawTool(const DRAWITEMSTRUCT &item, const Capture &c)
|
|||||||
bool active=(item.CtlID==27 && c.pointer)||(item.CtlID==10 && !c.pointer && !c.arrow && !c.highlight && !c.freehand && !c.textTool)||(item.CtlID==11 && c.arrow && !c.straight && c.shape==ScreenshotShape::None)||
|
bool active=(item.CtlID==27 && c.pointer)||(item.CtlID==10 && !c.pointer && !c.arrow && !c.highlight && !c.freehand && !c.textTool)||(item.CtlID==11 && c.arrow && !c.straight && c.shape==ScreenshotShape::None)||
|
||||||
(item.CtlID==19 && c.highlight)||(item.CtlID==21 && c.freehand)||
|
(item.CtlID==19 && c.highlight)||(item.CtlID==21 && c.freehand)||
|
||||||
(item.CtlID==22 && c.straight)||(item.CtlID>=23 && item.CtlID<=25 && int(c.shape)==int(item.CtlID)-22)||(item.CtlID==26 && c.textTool)||
|
(item.CtlID==22 && c.straight)||(item.CtlID>=23 && item.CtlID<=25 && int(c.shape)==int(item.CtlID)-22)||(item.CtlID==26 && c.textTool)||
|
||||||
(item.CtlID==36 && c.shape==ScreenshotShape::Triangle)||(item.CtlID==37 && c.shape==ScreenshotShape::Trapezoid);
|
(item.CtlID==36 && c.shape==ScreenshotShape::Triangle)||(item.CtlID==37 && c.shape==ScreenshotShape::Trapezoid)||(item.CtlID==38 && c.shape==ScreenshotShape::Arc);
|
||||||
bool disabled=item.itemState&ODS_DISABLED;
|
bool disabled=item.itemState&ODS_DISABLED;
|
||||||
POINT mouse; GetCursorPos(&mouse); ScreenToClient(item.hwndItem,&mouse);
|
POINT mouse; GetCursorPos(&mouse); ScreenToClient(item.hwndItem,&mouse);
|
||||||
bool hover=PtInRect(&r,mouse) && !disabled;
|
bool hover=PtInRect(&r,mouse) && !disabled;
|
||||||
@@ -897,13 +907,14 @@ void drawTool(const DRAWITEMSTRUCT &item, const Capture &c)
|
|||||||
RoundRect(dc,r.left,r.top,r.right,r.bottom,corner,corner);
|
RoundRect(dc,r.left,r.top,r.right,r.bottom,corner,corner);
|
||||||
SelectObject(dc,oldPen); SelectObject(dc,oldBrush); DeleteObject(pen); DeleteObject(brush);
|
SelectObject(dc,oldPen); SelectObject(dc,oldBrush); DeleteObject(pen); DeleteObject(brush);
|
||||||
int iconIndex=item.CtlID==35?13:item.CtlID==34?14:item.CtlID>=22?int(item.CtlID)-13:item.CtlID==18?5:item.CtlID==19?6:item.CtlID==20?7:item.CtlID==21?8:item.CtlID-10;
|
int iconIndex=item.CtlID==35?13:item.CtlID==34?14:item.CtlID>=22?int(item.CtlID)-13:item.CtlID==18?5:item.CtlID==19?6:item.CtlID==20?7:item.CtlID==21?8:item.CtlID-10;
|
||||||
auto icon=(item.CtlID==36 || item.CtlID==37)?nullptr:item.CtlID==27?(HICON)LoadCursorW(nullptr,IDC_ARROW):c.toolIcons[disabled?2:active?1:0][iconIndex];
|
auto icon=(item.CtlID==36 || item.CtlID==37 || item.CtlID==38)?nullptr:item.CtlID==27?(HICON)LoadCursorW(nullptr,IDC_ARROW):c.toolIcons[disabled?2:active?1:0][iconIndex];
|
||||||
if(item.CtlID==36 || item.CtlID==37) {
|
if(item.CtlID==36 || item.CtlID==37 || item.CtlID==38) {
|
||||||
int cx=(r.left+r.right)/2,cy=(r.top+r.bottom)/2,s=c.iconSize/2;
|
int cx=(r.left+r.right)/2,cy=(r.top+r.bottom)/2,s=c.iconSize/2;
|
||||||
auto trianglePen=CreatePen(PS_SOLID,std::max(1,MulDiv(2,GetDpiForWindow(item.hwndItem),96)),
|
auto trianglePen=CreatePen(PS_SOLID,std::max(1,MulDiv(2,GetDpiForWindow(item.hwndItem),96)),
|
||||||
disabled?RGB(148,163,184):active?RGB(37,99,235):RGB(51,65,85));
|
disabled?RGB(148,163,184):active?RGB(37,99,235):RGB(51,65,85));
|
||||||
auto previous=SelectObject(dc,trianglePen);
|
auto previous=SelectObject(dc,trianglePen);
|
||||||
if(item.CtlID==36) { POINT vertices[]{{cx,cy-s},{cx+s,cy+s},{cx-s,cy+s},{cx,cy-s}}; Polyline(dc,vertices,4); }
|
if(item.CtlID==38) Arc(dc,cx-s,cy-s,cx+s,cy+3*s,cx+s,cy+s,cx-s,cy+s);
|
||||||
|
else if(item.CtlID==36) { POINT vertices[]{{cx,cy-s},{cx+s,cy+s},{cx-s,cy+s},{cx,cy-s}}; Polyline(dc,vertices,4); }
|
||||||
else { POINT vertices[]{{cx-s/2,cy-s},{cx+s/2,cy-s},{cx+s,cy+s},{cx-s,cy+s},{cx-s/2,cy-s}}; Polyline(dc,vertices,5); }
|
else { POINT vertices[]{{cx-s/2,cy-s},{cx+s/2,cy-s},{cx+s,cy+s},{cx-s,cy+s},{cx-s/2,cy-s}}; Polyline(dc,vertices,5); }
|
||||||
SelectObject(dc,previous); DeleteObject(trianglePen);
|
SelectObject(dc,previous); DeleteObject(trianglePen);
|
||||||
}
|
}
|
||||||
@@ -1025,7 +1036,7 @@ void edit(HWND w, Capture &c)
|
|||||||
InitCommonControlsEx(&statusControls);
|
InitCommonControlsEx(&statusControls);
|
||||||
CreateWindowExW(0,STATUSCLASSNAMEW,L"",WS_CHILD|WS_VISIBLE|SBARS_TOOLTIPS,
|
CreateWindowExW(0,STATUSCLASSNAMEW,L"",WS_CHILD|WS_VISIBLE|SBARS_TOOLTIPS,
|
||||||
0,0,0,0,w,(HMENU)50,c.app.instance,nullptr);
|
0,0,0,0,w,(HMENU)50,c.app.instance,nullptr);
|
||||||
const wchar_t *labels[]{L"Pointer (V): select and edit objects", L"Number (N)", L"Arrow (A); Ctrl+drag between objects to link", L"Straight line (L); Ctrl+drag between objects to link", L"Rectangle (R)", L"Circle (O)", L"Ellipse (E)", L"Triangle (G): drag to draw; drag each vertex to reshape", L"Trapezoid (B): drag to draw", L"Freehand line (F); Shift for 45-degree steps; move >50 px off line to turn; Ctrl to draw over objects", L"Highlight (H); Ctrl+drag to paint over an object",
|
const wchar_t *labels[]{L"Pointer (V): select and edit objects", L"Number (N)", L"Arrow (A); Ctrl+drag between objects to link", L"Straight line (L); Ctrl+drag between objects to link", L"Rectangle (R)", L"Circle (O)", L"Ellipse (E)", L"Triangle (G): drag to draw; drag each vertex to reshape", L"Trapezoid (B): drag to draw", L"Arc (U): drag to draw a half ellipse; use handles to resize or rotate", L"Freehand line (F); Shift for 45-degree steps; move >50 px off line to turn; Ctrl to draw over objects", L"Highlight (H); Ctrl+drag to paint over an object",
|
||||||
L"Text (T); double-click or F2 to edit", L"Color — selected object and new objects", L"Undo (Ctrl+Z)", L"Redo (Ctrl+Y / Ctrl+Shift+Z)", L"Copy (Ctrl+C)"};
|
L"Text (T); double-click or F2 to edit", L"Color — selected object and new objects", L"Undo (Ctrl+Z)", L"Redo (Ctrl+Y / Ctrl+Shift+Z)", L"Copy (Ctrl+C)"};
|
||||||
static_assert(std::size(labels)==std::size(ToolIds));
|
static_assert(std::size(labels)==std::size(ToolIds));
|
||||||
auto saveButton=CreateWindowW(L"BUTTON",L"Save...",WS_CHILD|WS_VISIBLE|WS_TABSTOP|BS_PUSHBUTTON,
|
auto saveButton=CreateWindowW(L"BUTTON",L"Save...",WS_CHILD|WS_VISIBLE|WS_TABSTOP|BS_PUSHBUTTON,
|
||||||
@@ -1120,7 +1131,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case WM_DRAWITEM:
|
case WM_DRAWITEM:
|
||||||
if((wp>=10 && wp<=13) || (wp>=18 && wp<=27) || wp==34 || wp==35 || wp==36 || wp==37) { drawTool(*reinterpret_cast<DRAWITEMSTRUCT *>(lp),*c); return TRUE; }
|
if((wp>=10 && wp<=13) || (wp>=18 && wp<=27) || wp==34 || wp==35 || wp==36 || wp==37 || wp==38) { drawTool(*reinterpret_cast<DRAWITEMSTRUCT *>(lp),*c); return TRUE; }
|
||||||
break;
|
break;
|
||||||
case WM_CTLCOLORSTATIC:
|
case WM_CTLCOLORSTATIC:
|
||||||
SetBkColor((HDC)wp,RGB(255,255,255)); SetTextColor((HDC)wp,RGB(51,65,85));
|
SetBkColor((HDC)wp,RGB(255,255,255)); SetTextColor((HDC)wp,RGB(51,65,85));
|
||||||
@@ -1214,13 +1225,13 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
if(changed) { c->copied=false; c->selected=-1; c->selectedLabel=false; c->wheelDelta=0; c->wheelTarget=-1; }
|
if(changed) { c->copied=false; c->selected=-1; c->selectedLabel=false; c->wheelDelta=0; c->wheelTarget=-1; }
|
||||||
c->syncHistory();
|
c->syncHistory();
|
||||||
}
|
}
|
||||||
if (LOWORD(wp)==10 || LOWORD(wp)==11 || LOWORD(wp)==19 || LOWORD(wp)==36 || LOWORD(wp)==37 || (LOWORD(wp)>=21 && LOWORD(wp)<=27))
|
if (LOWORD(wp)==10 || LOWORD(wp)==11 || LOWORD(wp)==19 || LOWORD(wp)==36 || LOWORD(wp)==37 || LOWORD(wp)==38 || (LOWORD(wp)>=21 && LOWORD(wp)<=27))
|
||||||
{
|
{
|
||||||
c->pointer=LOWORD(wp)==27;
|
c->pointer=LOWORD(wp)==27;
|
||||||
c->arrow = LOWORD(wp)==11 || LOWORD(wp)==36 || LOWORD(wp)==37 || (LOWORD(wp)>=22 && LOWORD(wp)<=25);
|
c->arrow = LOWORD(wp)==11 || LOWORD(wp)==36 || LOWORD(wp)==37 || LOWORD(wp)==38 || (LOWORD(wp)>=22 && LOWORD(wp)<=25);
|
||||||
c->straight=LOWORD(wp)==22;
|
c->straight=LOWORD(wp)==22;
|
||||||
c->textTool=LOWORD(wp)==26;
|
c->textTool=LOWORD(wp)==26;
|
||||||
c->shape=LOWORD(wp)==37?ScreenshotShape::Trapezoid:LOWORD(wp)==36?ScreenshotShape::Triangle:LOWORD(wp)>=23 && LOWORD(wp)<=25?ScreenshotShape(LOWORD(wp)-22):ScreenshotShape::None;
|
c->shape=LOWORD(wp)==38?ScreenshotShape::Arc:LOWORD(wp)==37?ScreenshotShape::Trapezoid:LOWORD(wp)==36?ScreenshotShape::Triangle:LOWORD(wp)>=23 && LOWORD(wp)<=25?ScreenshotShape(LOWORD(wp)-22):ScreenshotShape::None;
|
||||||
c->selected=-1; c->selectedLabel=false;
|
c->selected=-1; c->selectedLabel=false;
|
||||||
c->highlight=LOWORD(wp)==19;
|
c->highlight=LOWORD(wp)==19;
|
||||||
c->freehand=LOWORD(wp)==21;
|
c->freehand=LOWORD(wp)==21;
|
||||||
@@ -1233,6 +1244,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
for(int id=22;id<=27;++id) InvalidateRect(GetDlgItem(w,id),nullptr,FALSE);
|
for(int id=22;id<=27;++id) InvalidateRect(GetDlgItem(w,id),nullptr,FALSE);
|
||||||
InvalidateRect(GetDlgItem(w,36),nullptr,FALSE);
|
InvalidateRect(GetDlgItem(w,36),nullptr,FALSE);
|
||||||
InvalidateRect(GetDlgItem(w,37),nullptr,FALSE);
|
InvalidateRect(GetDlgItem(w,37),nullptr,FALSE);
|
||||||
|
InvalidateRect(GetDlgItem(w,38),nullptr,FALSE);
|
||||||
}
|
}
|
||||||
|
|
||||||
SetFocus(w); captureCursor(w,*c); InvalidateRect(w,nullptr,FALSE); return 0;
|
SetFocus(w); captureCursor(w,*c); InvalidateRect(w,nullptr,FALSE); return 0;
|
||||||
@@ -1293,6 +1305,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
if(wp=='E') SendMessageW(w,WM_COMMAND,25,0);
|
if(wp=='E') SendMessageW(w,WM_COMMAND,25,0);
|
||||||
if(wp=='G') SendMessageW(w,WM_COMMAND,36,0);
|
if(wp=='G') SendMessageW(w,WM_COMMAND,36,0);
|
||||||
if(wp=='B') SendMessageW(w,WM_COMMAND,37,0);
|
if(wp=='B') SendMessageW(w,WM_COMMAND,37,0);
|
||||||
|
if(wp=='U') SendMessageW(w,WM_COMMAND,38,0);
|
||||||
}
|
}
|
||||||
if (wp=='N' || wp=='A') SendMessageW(w,WM_COMMAND,wp=='N'?10:11,0);
|
if (wp=='N' || wp=='A') SendMessageW(w,WM_COMMAND,wp=='N'?10:11,0);
|
||||||
if(wp=='H') SendMessageW(w,WM_COMMAND,19,0);
|
if(wp=='H') SendMessageW(w,WM_COMMAND,19,0);
|
||||||
@@ -1563,7 +1576,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
c->marks.back().style=c->lineStyle; c->marks.back().ends=c->straight || c->shape!=ScreenshotShape::None?LineEnds::None:c->arrowEnds;
|
c->marks.back().style=c->lineStyle; c->marks.back().ends=c->straight || c->shape!=ScreenshotShape::None?LineEnds::None:c->arrowEnds;
|
||||||
c->marks.back().shape=c->shape;
|
c->marks.back().shape=c->shape;
|
||||||
auto &mark=c->marks.back();
|
auto &mark=c->marks.back();
|
||||||
if(mark.shape==ScreenshotShape::Triangle || mark.shape==ScreenshotShape::Trapezoid) mark.points=shapeOutline(mark);
|
if(mark.shape==ScreenshotShape::Triangle || mark.shape==ScreenshotShape::Trapezoid || mark.shape==ScreenshotShape::Arc) mark.points=shapeOutline(mark);
|
||||||
linkScreenshotEndpoint(mark,true,c->start,c->marks,6);
|
linkScreenshotEndpoint(mark,true,c->start,c->marks,6);
|
||||||
linkScreenshotEndpoint(mark,false,c->end,c->marks,6);
|
linkScreenshotEndpoint(mark,false,c->end,c->marks,6);
|
||||||
resolveScreenshotLinks(c->marks);
|
resolveScreenshotLinks(c->marks);
|
||||||
@@ -1586,7 +1599,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
if (c->editing)
|
if (c->editing)
|
||||||
{
|
{
|
||||||
auto limits = reinterpret_cast<MINMAXINFO *>(lp);
|
auto limits = reinterpret_cast<MINMAXINFO *>(lp);
|
||||||
limits->ptMinTrackSize = {MulDiv(730,GetDpiForWindow(w),96),MulDiv(240,GetDpiForWindow(w),96)};
|
limits->ptMinTrackSize = {MulDiv(766,GetDpiForWindow(w),96),MulDiv(240,GetDpiForWindow(w),96)};
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@ void showScreenshot(ScreenshotHost &);
|
|||||||
void showScreenshotEditor(ScreenshotHost &,const Clip &);
|
void showScreenshotEditor(ScreenshotHost &,const Clip &);
|
||||||
enum class LineStyle { Solid, Dotted, Dashed, DashDot };
|
enum class LineStyle { Solid, Dotted, Dashed, DashDot };
|
||||||
enum class LineEnds { End, Start, Both, None };
|
enum class LineEnds { End, Start, Both, None };
|
||||||
enum class ScreenshotShape { None, Rectangle, Circle, Ellipse, Triangle, Trapezoid };
|
enum class ScreenshotShape { None, Rectangle, Circle, Ellipse, Triangle, Trapezoid, Arc };
|
||||||
struct ScreenshotOffset { double x, y; };
|
struct ScreenshotOffset { double x, y; };
|
||||||
struct ScreenshotLink {
|
struct ScreenshotLink {
|
||||||
unsigned long long target{};
|
unsigned long long target{};
|
||||||
|
|||||||
+29
-4
@@ -67,7 +67,7 @@ void validateSettings(const Settings &s)
|
|||||||
auto separator = key.find(L'\n');
|
auto separator = key.find(L'\n');
|
||||||
if (separator == key.npos || separator + 1 == key.size() || key.size() > 1024 ||
|
if (separator == key.npos || separator + 1 == key.size() || key.size() > 1024 ||
|
||||||
key.find(L'\n', separator + 1) != key.npos || key.find(L'\r') != key.npos ||
|
key.find(L'\n', separator + 1) != key.npos || key.find(L'\r') != key.npos ||
|
||||||
(key.substr(0, separator) != L"telegram" && key.substr(0, separator) != L"ms-teams" && key.substr(0, separator) != L"viber"))
|
(key.substr(0, separator) != L"telegram" && key.substr(0, separator) != L"ms-teams" && key.substr(0, separator) != L"viber" && key.substr(0, separator) != L"telegram-web"))
|
||||||
throw std::runtime_error("Invalid chat language profile");
|
throw std::runtime_error("Invalid chat language profile");
|
||||||
if (cycle.empty() || cycle.size() > 64) throw std::runtime_error("Select at least one chat language");
|
if (cycle.empty() || cycle.size() > 64) throw std::runtime_error("Select at least one chat language");
|
||||||
std::vector<std::wstring> seen;
|
std::vector<std::wstring> seen;
|
||||||
@@ -169,8 +169,19 @@ void validateSettings(const Settings &s)
|
|||||||
if (snippet.trigger.size() < 2 || snippet.trigger.size() > 64 ||
|
if (snippet.trigger.size() < 2 || snippet.trigger.size() > 64 ||
|
||||||
snippet.trigger.find_first_of(L"\r\n\t ") != snippet.trigger.npos)
|
snippet.trigger.find_first_of(L"\r\n\t ") != snippet.trigger.npos)
|
||||||
throw std::runtime_error("Snippet triggers must contain 2–64 characters without whitespace");
|
throw std::runtime_error("Snippet triggers must contain 2–64 characters without whitespace");
|
||||||
if (!seen.insert(lower(snippet.trigger)).second)
|
if (snippet.apps.size() > 64) throw std::runtime_error("Too many snippet applications");
|
||||||
throw std::runtime_error("Duplicate snippet trigger");
|
std::vector<std::wstring> scopes;
|
||||||
|
for (const auto &app : snippet.apps)
|
||||||
|
{
|
||||||
|
auto normalized = snippetAppName(app);
|
||||||
|
if (normalized.empty() || normalized.size() > 250 || normalized.find_first_of(L"\\/:*?\"<>|,\r\n\t") != normalized.npos)
|
||||||
|
throw std::runtime_error("Use application names such as chrome.exe, not paths or wildcards");
|
||||||
|
scopes.push_back(normalized);
|
||||||
|
}
|
||||||
|
if (scopes.empty()) scopes.push_back(L"");
|
||||||
|
for (const auto &scope : scopes)
|
||||||
|
if (!seen.insert(lower(snippet.trigger) + L"\n" + scope).second)
|
||||||
|
throw std::runtime_error("Duplicate snippet trigger for the same application or global scope");
|
||||||
auto t = parseTemplate(snippet.text);
|
auto t = parseTemplate(snippet.text);
|
||||||
if (t.text.empty() || t.cursors > 1)
|
if (t.text.empty() || t.cursors > 1)
|
||||||
throw std::runtime_error("Each snippet needs text and at most one {cursor} marker");
|
throw std::runtime_error("Each snippet needs text and at most one {cursor} marker");
|
||||||
@@ -281,6 +292,12 @@ Settings decodeSettings(const Bytes &bytes, const std::vector<Layout> &layouts)
|
|||||||
for (auto &sn : j.at("snippets"))
|
for (auto &sn : j.at("snippets"))
|
||||||
{
|
{
|
||||||
Snippet item{str(sn, "trigger"), str(sn, "text"), str(sn, "id")};
|
Snippet item{str(sn, "trigger"), str(sn, "text"), str(sn, "id")};
|
||||||
|
if (sn.contains("apps"))
|
||||||
|
{
|
||||||
|
if (!sn.at("apps").is_array()) throw std::runtime_error("Snippet apps must be a list");
|
||||||
|
for (const auto &app : sn.at("apps")) item.apps.push_back(snippetAppName(wide(app.get<std::string>())));
|
||||||
|
}
|
||||||
|
if (item.id.empty() && !item.apps.empty()) item.id = newSnippetId();
|
||||||
item.id = snippetId(item);
|
item.id = snippetId(item);
|
||||||
s.snippets.push_back(std::move(item));
|
s.snippets.push_back(std::move(item));
|
||||||
}
|
}
|
||||||
@@ -354,7 +371,15 @@ Bytes encodeSettings(const Settings &s)
|
|||||||
j["layoutHotkeys"].push_back({{"layoutId", utf8(s.directIds[i])}, {"hotkey", utf8(s.direct[i])}});
|
j["layoutHotkeys"].push_back({{"layoutId", utf8(s.directIds[i])}, {"hotkey", utf8(s.direct[i])}});
|
||||||
j["snippets"] = Json::array();
|
j["snippets"] = Json::array();
|
||||||
for (auto &sn : s.snippets)
|
for (auto &sn : s.snippets)
|
||||||
j["snippets"].push_back({{"trigger", utf8(sn.trigger)}, {"text", utf8(sn.text)}, {"id", utf8(snippetId(sn))}});
|
{
|
||||||
|
Json item{{"trigger", utf8(sn.trigger)}, {"text", utf8(sn.text)}, {"id", utf8(snippetId(sn))}};
|
||||||
|
if (!sn.apps.empty())
|
||||||
|
{
|
||||||
|
item["apps"] = Json::array();
|
||||||
|
for (const auto &app : sn.apps) item["apps"].push_back(utf8(snippetAppName(app)));
|
||||||
|
}
|
||||||
|
j["snippets"].push_back(std::move(item));
|
||||||
|
}
|
||||||
j["snippetTombstones"] = Json::array();
|
j["snippetTombstones"] = Json::array();
|
||||||
for (const auto &id : s.snippetTombstones) j["snippetTombstones"].push_back(utf8(id));
|
for (const auto &id : s.snippetTombstones) j["snippetTombstones"].push_back(utf8(id));
|
||||||
j["excludedApps"] = Json::array();
|
j["excludedApps"] = Json::array();
|
||||||
|
|||||||
+7
-1
@@ -158,9 +158,15 @@ void collisionPaths(const Json &j, std::set<std::string> &paths)
|
|||||||
if (sn.is_null() || tombstone(sn)) continue;
|
if (sn.is_null() || tombstone(sn)) continue;
|
||||||
auto path = "/snippets/" + pointerKey(id);
|
auto path = "/snippets/" + pointerKey(id);
|
||||||
auto key = lower(wide(sn.at("trigger").get<std::string>()));
|
auto key = lower(wide(sn.at("trigger").get<std::string>()));
|
||||||
auto [it, inserted] = triggers.emplace(key, path);
|
std::vector<std::wstring> scopes;
|
||||||
|
if (sn.contains("apps")) for (const auto &app : sn.at("apps")) scopes.push_back(snippetAppName(wide(app.get<std::string>())));
|
||||||
|
if (scopes.empty()) scopes.push_back(L"");
|
||||||
|
for (const auto &scope : scopes)
|
||||||
|
{
|
||||||
|
auto [it, inserted] = triggers.emplace(key + L"\n" + scope, path);
|
||||||
if (!inserted) { paths.insert(path); paths.insert(it->second); }
|
if (!inserted) { paths.insert(path); paths.insert(it->second); }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
std::map<std::pair<UINT, UINT>, std::string> shortcuts;
|
std::map<std::pair<UINT, UINT>, std::string> shortcuts;
|
||||||
auto add = [&](const Json &text, const std::string &path) {
|
auto add = [&](const Json &text, const std::string &path) {
|
||||||
if (!text.is_string()) return;
|
if (!text.is_string()) return;
|
||||||
|
|||||||
+73
-18
@@ -1013,7 +1013,7 @@ LRESULT CALLBACK chatLanguagesProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
{
|
{
|
||||||
int selected = (int)SendDlgItemMessageW(w, 710, LB_GETCURSEL, 0, 0);
|
int selected = (int)SendDlgItemMessageW(w, 710, LB_GETCURSEL, 0, 0);
|
||||||
if (selected < 0 || selected >= (int)v->keys.size())
|
if (selected < 0 || selected >= (int)v->keys.size())
|
||||||
throw std::runtime_error("Open a Telegram or Teams chat, then reopen this dialog. Viber OCR is disabled.");
|
throw std::runtime_error("Open Telegram, Telegram Web K in Chrome, or Teams, then reopen this dialog. Viber OCR is disabled.");
|
||||||
auto next = v->settings->draft;
|
auto next = v->settings->draft;
|
||||||
if (id == 713) next.chatCycles.erase(v->keys[selected]);
|
if (id == 713) next.chatCycles.erase(v->keys[selected]);
|
||||||
else
|
else
|
||||||
@@ -1055,7 +1055,7 @@ void showChatLanguages(HWND parent, SettingsView *settings)
|
|||||||
{
|
{
|
||||||
if (!IsWindowVisible(candidate)) return TRUE;
|
if (!IsWindowVisible(candidate)) return TRUE;
|
||||||
auto &data = *(Context *)context;
|
auto &data = *(Context *)context;
|
||||||
auto key = windowChatKey(candidate);
|
auto key = lower(processName(candidate)) == L"chrome" ? readChromeChat(candidate) : windowChatKey(candidate);
|
||||||
if (!key.empty()) data.keys.insert(std::move(key));
|
if (!key.empty()) data.keys.insert(std::move(key));
|
||||||
return TRUE;
|
return TRUE;
|
||||||
}, (LPARAM)&context);
|
}, (LPARAM)&context);
|
||||||
@@ -1063,13 +1063,13 @@ void showChatLanguages(HWND parent, SettingsView *settings)
|
|||||||
auto *v = new ChatLanguagesView{parent, settings, {keys.begin(), keys.end()}};
|
auto *v = new ChatLanguagesView{parent, settings, {keys.begin(), keys.end()}};
|
||||||
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
|
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
|
||||||
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)v);
|
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)v);
|
||||||
label(w, L"Telegram / Teams profiles; saved Viber profiles are inactive", 18, 16, 604);
|
label(w, L"Telegram / Telegram Web (Chrome) / Teams; Viber inactive", 18, 16, 604);
|
||||||
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER,
|
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER,
|
||||||
710, 18, 44, 604, 140);
|
710, 18, 44, 604, 140);
|
||||||
for (const auto &key : v->keys)
|
for (const auto &key : v->keys)
|
||||||
{
|
{
|
||||||
auto split = key.find(L'\n');
|
auto split = key.find(L'\n');
|
||||||
auto name = std::wstring(key.starts_with(L"telegram\n") ? L"Telegram: " : key.starts_with(L"viber\n") ? L"Viber (inactive): " : L"Teams: ") + key.substr(split + 1);
|
auto name = std::wstring(key.starts_with(L"telegram\n") ? L"Telegram: " : key.starts_with(L"telegram-web\n") ? L"Telegram Web: " : key.starts_with(L"viber\n") ? L"Viber (inactive): " : L"Teams: ") + key.substr(split + 1);
|
||||||
SendDlgItemMessageW(w, 710, LB_ADDSTRING, 0, (LPARAM)name.c_str());
|
SendDlgItemMessageW(w, 710, LB_ADDSTRING, 0, (LPARAM)name.c_str());
|
||||||
}
|
}
|
||||||
control(w, L"STATIC", L"Open a chat, then reopen this dialog to add it.", 0, 712, 18, 194, 604, 26);
|
control(w, L"STATIC", L"Open a chat, then reopen this dialog to add it.", 0, 712, 18, 194, 604, 26);
|
||||||
@@ -1712,14 +1712,27 @@ void updateSnippetEditor(HWND w, SnippetView *v)
|
|||||||
bool selected = v->selection >= 0 && v->selection < (int)v->snippets.size();
|
bool selected = v->selection >= 0 && v->selection < (int)v->snippets.size();
|
||||||
value(w, 302, selected ? v->snippets[v->selection].trigger : L"");
|
value(w, 302, selected ? v->snippets[v->selection].trigger : L"");
|
||||||
value(w, 303, selected ? v->snippets[v->selection].text : L"");
|
value(w, 303, selected ? v->snippets[v->selection].text : L"");
|
||||||
for (int id : {302, 303, 311, 312, 313})
|
std::wstring apps;
|
||||||
|
if (selected) for (const auto &app : v->snippets[v->selection].apps)
|
||||||
|
{
|
||||||
|
if (!apps.empty()) apps += L", ";
|
||||||
|
if (!app.empty()) apps += app + L".exe";
|
||||||
|
}
|
||||||
|
value(w, 315, apps);
|
||||||
|
bool restricted = selected && !v->snippets[v->selection].apps.empty();
|
||||||
|
CheckDlgButton(w, 314, restricted ? BST_CHECKED : BST_UNCHECKED);
|
||||||
|
for (int id : {302, 303, 311, 312, 313, 314})
|
||||||
EnableWindow(GetDlgItem(w, id), selected);
|
EnableWindow(GetDlgItem(w, id), selected);
|
||||||
|
for (int id : {315, 316, 317}) EnableWindow(GetDlgItem(w, id), selected && restricted);
|
||||||
}
|
}
|
||||||
void fillSnippets(HWND w, SnippetView *v)
|
void fillSnippets(HWND w, SnippetView *v)
|
||||||
{
|
{
|
||||||
SendDlgItemMessageW(w, 301, LB_RESETCONTENT, 0, 0);
|
SendDlgItemMessageW(w, 301, LB_RESETCONTENT, 0, 0);
|
||||||
for (auto &sn : v->snippets)
|
for (auto &sn : v->snippets)
|
||||||
SendDlgItemMessageW(w, 301, LB_ADDSTRING, 0, (LPARAM)sn.trigger.c_str());
|
{
|
||||||
|
auto label = sn.trigger + (sn.apps.empty() ? L" [All apps]" : L" [" + sn.apps.front() + (sn.apps.size() > 1 ? L", ...]" : L"]"));
|
||||||
|
SendDlgItemMessageW(w, 301, LB_ADDSTRING, 0, (LPARAM)label.c_str());
|
||||||
|
}
|
||||||
if (v->selection >= 0)
|
if (v->selection >= 0)
|
||||||
SendDlgItemMessageW(w, 301, LB_SETCURSEL, v->selection, 0);
|
SendDlgItemMessageW(w, 301, LB_SETCURSEL, v->selection, 0);
|
||||||
updateSnippetEditor(w, v);
|
updateSnippetEditor(w, v);
|
||||||
@@ -1732,6 +1745,18 @@ void collectSnippet(HWND w, SnippetView *v)
|
|||||||
if (snippet.id.empty()) snippet.id = snippetId(snippet);
|
if (snippet.id.empty()) snippet.id = snippetId(snippet);
|
||||||
snippet.trigger = value(w, 302);
|
snippet.trigger = value(w, 302);
|
||||||
snippet.text = value(w, 303);
|
snippet.text = value(w, 303);
|
||||||
|
snippet.apps.clear();
|
||||||
|
if (IsDlgButtonChecked(w, 314) == BST_CHECKED)
|
||||||
|
{
|
||||||
|
std::wistringstream input(value(w, 315)); std::wstring item;
|
||||||
|
while (std::getline(input, item, L','))
|
||||||
|
{
|
||||||
|
auto app = snippetAppName(item);
|
||||||
|
if (!app.empty()) snippet.apps.push_back(app);
|
||||||
|
}
|
||||||
|
// An explicitly restricted but empty list must not become global.
|
||||||
|
if (snippet.apps.empty()) snippet.apps.push_back(L"");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||||
@@ -1758,6 +1783,24 @@ LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
case WM_COMMAND:
|
case WM_COMMAND:
|
||||||
{
|
{
|
||||||
int id = LOWORD(wp);
|
int id = LOWORD(wp);
|
||||||
|
if (id == 314)
|
||||||
|
{
|
||||||
|
bool restricted = IsDlgButtonChecked(w, 314) == BST_CHECKED;
|
||||||
|
for (int field : {315, 316, 317}) EnableWindow(GetDlgItem(w, field), restricted);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (id == 317)
|
||||||
|
{
|
||||||
|
auto app = value(w, 316);
|
||||||
|
if (!app.empty())
|
||||||
|
{
|
||||||
|
auto current = value(w, 315);
|
||||||
|
std::wistringstream input(current); std::wstring entry;
|
||||||
|
while (std::getline(input, entry, L',')) if (snippetAppName(entry) == snippetAppName(app)) return 0;
|
||||||
|
value(w, 315, current.empty() ? app : current + L", " + app);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
if (id == 301 && HIWORD(wp) == LBN_SELCHANGE)
|
if (id == 301 && HIWORD(wp) == LBN_SELCHANGE)
|
||||||
{
|
{
|
||||||
collectSnippet(w, v);
|
collectSnippet(w, v);
|
||||||
@@ -1988,7 +2031,7 @@ void showSettings(App &app, bool visible)
|
|||||||
label(w, L"Backspace / Delete clears it", 454, 270, 230);
|
label(w, L"Backspace / Delete clears it", 454, 270, 230);
|
||||||
button(w, L"Re&fresh layouts", 253, 454, 322, 230);
|
button(w, L"Re&fresh layouts", 253, 454, 322, 230);
|
||||||
button(w, L"Languages for a &chat...", 259, 24, 378, 230);
|
button(w, L"Languages for a &chat...", 259, 24, 378, 230);
|
||||||
label(w, L"Chat languages: Telegram / Teams. Viber OCR disabled.", 272, 382, 412);
|
label(w, L"Telegram / Telegram Web (Chrome) / Teams", 272, 382, 412);
|
||||||
page();
|
page();
|
||||||
|
|
||||||
label(w, L"Click a shortcut field and press the desired keys", 24, 76, 660);
|
label(w, L"Click a shortcut field and press the desired keys", 24, 76, 660);
|
||||||
@@ -2046,22 +2089,34 @@ void showSnippets(App &app)
|
|||||||
SetForegroundWindow(app.snippetsWindow);
|
SetForegroundWindow(app.snippetsWindow);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
HWND w = makeWindow(app, L"KSwitcher.Snippets", L"Text snippets", snippetProc, 740, 520);
|
HWND w = makeWindow(app, L"KSwitcher.Snippets", L"Text snippets", snippetProc, 740, 620);
|
||||||
app.snippetsWindow = w;
|
app.snippetsWindow = w;
|
||||||
label(w, L"Triggers", 16, 16);
|
label(w, L"Triggers", 16, 16);
|
||||||
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER, 301,
|
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER, 301,
|
||||||
16, 44, 200, 380);
|
16, 44, 200, 480);
|
||||||
button(w, L"&Add", 310, 16, 438, 90);
|
button(w, L"&Add", 310, 16, 538, 90);
|
||||||
button(w, L"&Delete", 311, 116, 438, 100);
|
button(w, L"&Delete", 311, 116, 538, 100);
|
||||||
label(w, L"&Trigger (for example ;addr)", 236, 16, 470);
|
label(w, L"&Trigger (for example ;addr)", 236, 16, 470);
|
||||||
edit(w, 302, 236, 44, 488);
|
edit(w, 302, 236, 44, 488);
|
||||||
label(w, L"&Replacement text", 236, 82, 480);
|
control(w, L"BUTTON", L"Only in these &applications (unchecked: everywhere)", WS_TABSTOP | BS_AUTOCHECKBOX, 314, 236, 82, 488, 24);
|
||||||
edit(w, 303, 236, 110, 488, 270, ES_MULTILINE | ES_WANTRETURN | ES_AUTOVSCROLL | WS_VSCROLL);
|
edit(w, 315, 236, 112, 488);
|
||||||
control(w, L"STATIC", L"{cursor}: caret; {enter}: press Enter after insertion.\nUse {{cursor}} / {{enter}} for literal markers.", 0, 0, 236, 392, 488, 36);
|
control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST | WS_VSCROLL, 316, 236, 148, 364, 200);
|
||||||
button(w, L"Insert cursor &marker", 312, 236, 430, 190);
|
button(w, L"Add a&pp", 317, 612, 145, 112);
|
||||||
button(w, L"Insert &Enter marker", 313, 436, 430, 190);
|
std::set<std::wstring> apps;
|
||||||
button(w, L"&Save", IDOK, 510, 478, 100);
|
EnumWindows([](HWND candidate, LPARAM context)->BOOL {
|
||||||
button(w, L"&Cancel", IDCANCEL, 624, 478, 100);
|
if (IsWindowVisible(candidate)) { auto name = processName(candidate); if (!name.empty()) ((std::set<std::wstring>*)context)->insert(lower(name) + L".exe"); }
|
||||||
|
return TRUE;
|
||||||
|
}, (LPARAM)&apps);
|
||||||
|
for (const auto &name : apps) SendDlgItemMessageW(w, 316, CB_ADDSTRING, 0, (LPARAM)name.c_str());
|
||||||
|
SendDlgItemMessageW(w, 316, CB_SETCURSEL, 0, 0);
|
||||||
|
label(w, L"Process names, comma-separated; e.g. chrome.exe, ms-teams.exe", 236, 182, 488);
|
||||||
|
label(w, L"&Replacement text", 236, 212, 480);
|
||||||
|
edit(w, 303, 236, 240, 488, 240, ES_MULTILINE | ES_WANTRETURN | ES_AUTOVSCROLL | WS_VSCROLL);
|
||||||
|
control(w, L"STATIC", L"{cursor}: caret; {enter}: press Enter after insertion.\nUse {{cursor}} / {{enter}} for literal markers.", 0, 0, 236, 492, 488, 36);
|
||||||
|
button(w, L"Insert cursor &marker", 312, 236, 530, 190);
|
||||||
|
button(w, L"Insert &Enter marker", 313, 436, 530, 190);
|
||||||
|
button(w, L"&Save", IDOK, 510, 578, 100);
|
||||||
|
button(w, L"&Cancel", IDCANCEL, 624, 578, 100);
|
||||||
fillSnippets(w, (SnippetView *)GetWindowLongPtrW(w, GWLP_USERDATA));
|
fillSnippets(w, (SnippetView *)GetWindowLongPtrW(w, GWLP_USERDATA));
|
||||||
showRequestedWindow(w);
|
showRequestedWindow(w);
|
||||||
SetForegroundWindow(w);
|
SetForegroundWindow(w);
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
#include "platform.h"
|
#include "platform.h"
|
||||||
namespace ks
|
namespace ks
|
||||||
{
|
{
|
||||||
inline constexpr wchar_t AppVersion[] = L"0.6.50";
|
inline constexpr wchar_t AppVersion[] = L"0.6.52";
|
||||||
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;
|
||||||
|
|||||||
@@ -14,6 +14,30 @@ void require(bool ok, const char *message)
|
|||||||
}
|
}
|
||||||
void contextChecks()
|
void contextChecks()
|
||||||
{
|
{
|
||||||
|
ks::Settings scoped;
|
||||||
|
scoped.snippets = {{L";sig", L"Global", L"global"},
|
||||||
|
{L";sig", L"Work", L"teams", {L"MS-Teams.EXE", L"OUTLOOK.exe"}},
|
||||||
|
{L";sig", L"Personal", L"telegram", {L"telegram"}}};
|
||||||
|
ks::validateSettings(scoped);
|
||||||
|
require(ks::snippetAppPriority(scoped.snippets[1], L"ms-teams") > ks::snippetAppPriority(scoped.snippets[0], L"ms-teams"), "app snippet precedes global regardless of list order");
|
||||||
|
require(ks::snippetAppPriority(scoped.snippets[1], L"Telegram.exe") == 0, "restricted snippet cannot run in another app");
|
||||||
|
require(ks::snippetAppPriority(scoped.snippets[0], L"") == 1, "legacy/global snippets remain available");
|
||||||
|
auto scopedRoundtrip = ks::decodeSettings(ks::encodeSettings(scoped), {});
|
||||||
|
require(scopedRoundtrip.snippets[1].apps == std::vector<std::wstring>{L"ms-teams", L"outlook"}, "scopes normalize and persist");
|
||||||
|
require(scopedRoundtrip.snippets[0].apps.empty(), "absent scope remains global");
|
||||||
|
auto collision = scoped; collision.snippets.push_back({L";SIG", L"Ambiguous", L"other", {L"outlook.exe"}});
|
||||||
|
bool collisionRejected=false;try { ks::validateSettings(collision); } catch(...) { collisionRejected=true; }
|
||||||
|
require(collisionRejected, "overlapping app lists with same trigger rejected");
|
||||||
|
auto emptyScope = scoped; emptyScope.snippets[1].apps={L""};
|
||||||
|
bool emptyRejected=false;try { ks::validateSettings(emptyScope); } catch(...) { emptyRejected=true; }
|
||||||
|
require(emptyRejected, "empty explicit restriction cannot become global");
|
||||||
|
require(ks::telegramWebKey(L"https://web.telegram.org/k/#@Example") == L"telegram-web\n@example", "Telegram Web username normalized");
|
||||||
|
require(ks::telegramWebKey(L"web.telegram.org/a/#-123456") == L"telegram-web\n-123456", "Telegram Web numeric peer");
|
||||||
|
for (const auto *url : {L"https://web.telegram.org/k/", L"https://web.telegram.org.evil/k/#@example",
|
||||||
|
L"http://web.telegram.org/k/#@example", L"https://evil/web.telegram.org/k/#@example",
|
||||||
|
L"https://web.telegram.org/k/#@example?foo", L"https://web.telegram.org/k/#@",
|
||||||
|
L"https://web.telegram.org/k/#-", L"https://web.telegram.org/k/#settings"})
|
||||||
|
require(ks::telegramWebKey(url).empty(), "Reject non-chat or untrusted Telegram Web URL");
|
||||||
const auto key = ks::chatKey(L"Telegram.exe", L"\u200eТестовий чат \u2013 (12)");
|
const auto key = ks::chatKey(L"Telegram.exe", L"\u200eТестовий чат \u2013 (12)");
|
||||||
require(key == L"telegram\nТестовий чат", "Telegram unread suffix");
|
require(key == L"telegram\nТестовий чат", "Telegram unread suffix");
|
||||||
require(ks::chatKey(L"Telegram", L"Тестовий чат") == key, "stable unread profile");
|
require(ks::chatKey(L"Telegram", L"Тестовий чат") == key, "stable unread profile");
|
||||||
@@ -28,6 +52,7 @@ void contextChecks()
|
|||||||
settings.cycle = {L"win:04090409", L"win:04220422", L"win:04190419"};
|
settings.cycle = {L"win:04090409", L"win:04220422", L"win:04190419"};
|
||||||
settings.chatCycles[key] = {settings.cycle[1], settings.cycle[0]};
|
settings.chatCycles[key] = {settings.cycle[1], settings.cycle[0]};
|
||||||
settings.chatCycles[L"viber\nТестовий контакт"] = {settings.cycle[1], settings.cycle[0]};
|
settings.chatCycles[L"viber\nТестовий контакт"] = {settings.cycle[1], settings.cycle[0]};
|
||||||
|
settings.chatCycles[L"telegram-web\n@example"] = {settings.cycle[0], settings.cycle[1]};
|
||||||
std::vector<ks::Layout> installed(3);
|
std::vector<ks::Layout> installed(3);
|
||||||
for (size_t i = 0; i < installed.size(); ++i)
|
for (size_t i = 0; i < installed.size(); ++i)
|
||||||
{
|
{
|
||||||
@@ -166,6 +191,30 @@ int main()
|
|||||||
SendMessageW(dialog, WM_COMMAND, 713, 0);
|
SendMessageW(dialog, WM_COMMAND, 713, 0);
|
||||||
SendMessageW(parent, WM_COMMAND, IDOK, 0);
|
SendMessageW(parent, WM_COMMAND, IDOK, 0);
|
||||||
require(!app.settings.chatCycles.contains(key), "remove profile restores default");
|
require(!app.settings.chatCycles.contains(key), "remove profile restores default");
|
||||||
|
app.settings.snippets = {{L";scope", L"Example", L"scope-fixture"}};
|
||||||
|
ks::showSnippets(app);
|
||||||
|
HWND snippets = app.snippetsWindow;
|
||||||
|
SendDlgItemMessageW(snippets,301,LB_SETCURSEL,0,0);
|
||||||
|
SendMessageW(snippets,WM_COMMAND,MAKEWPARAM(301,LBN_SELCHANGE),0);
|
||||||
|
require(IsDlgButtonChecked(snippets,314)==BST_UNCHECKED, "old snippet shows everywhere");
|
||||||
|
CheckDlgButton(snippets,314,BST_CHECKED);
|
||||||
|
SendMessageW(snippets,WM_COMMAND,314,0);
|
||||||
|
SetDlgItemTextW(snippets,315,L"Notepad.exe, MS-Teams.EXE");
|
||||||
|
SendMessageW(snippets,WM_COMMAND,IDOK,0);
|
||||||
|
require(!app.snippetsWindow && app.settings.snippets[0].apps==std::vector<std::wstring>{L"notepad",L"ms-teams"}, "snippet editor saves selected applications");
|
||||||
|
ks::showSnippets(app);snippets=app.snippetsWindow;
|
||||||
|
SendDlgItemMessageW(snippets,301,LB_SETCURSEL,0,0);
|
||||||
|
SendMessageW(snippets,WM_COMMAND,MAKEWPARAM(301,LBN_SELCHANGE),0);
|
||||||
|
require(IsDlgButtonChecked(snippets,314)==BST_CHECKED, "scope restored when reopening editor");
|
||||||
|
RedrawWindow(snippets,nullptr,nullptr,RDW_INVALIDATE|RDW_UPDATENOW|RDW_ALLCHILDREN);
|
||||||
|
RECT snippetRect{};GetClientRect(snippets,&snippetRect);
|
||||||
|
HDC snippetDc=GetDC(snippets);
|
||||||
|
auto snippetImage=ks::screenshotClip(snippetDc,snippetRect.right,snippetRect.bottom,{});
|
||||||
|
ReleaseDC(snippets,snippetDc);
|
||||||
|
ks::atomicWrite(std::filesystem::path(ks::executable()).parent_path()/L"snippet-scopes.png",ks::encodePng(ks::decodeImage(snippetImage)));
|
||||||
|
SetDlgItemTextW(snippets,315,L"chrome.exe");
|
||||||
|
SendMessageW(snippets,WM_COMMAND,IDCANCEL,0);
|
||||||
|
require(app.settings.snippets[0].apps==std::vector<std::wstring>{L"notepad",L"ms-teams"}, "cancel preserves existing scope");
|
||||||
}
|
}
|
||||||
std::filesystem::remove_all(root);
|
std::filesystem::remove_all(root);
|
||||||
std::cout << "Passed " << passed << " chat profile checks\n";
|
std::cout << "Passed " << passed << " chat profile checks\n";
|
||||||
|
|||||||
@@ -26,6 +26,17 @@ LRESULT CALLBACK countControlMoves(HWND w, UINT message, WPARAM wp, LPARAM lp, U
|
|||||||
}
|
}
|
||||||
namespace ks {
|
namespace ks {
|
||||||
struct InputRegressionAccess {
|
struct InputRegressionAccess {
|
||||||
|
static int snippetChoice(App &a, const std::vector<Snippet> &snippets) {
|
||||||
|
a.settings.snippets=snippets; a.settings.autoCorrect=false; a.busy=false;
|
||||||
|
a.snippetPending=-1; a.typingTarget=nullptr; a.snippetTyping=false;
|
||||||
|
auto target=GetForegroundWindow();
|
||||||
|
a.run.keys=L";si"; a.run.window=target; a.run.layout=windowLayout(target); a.run.at=GetTickCount64();
|
||||||
|
KBDLLHOOKSTRUCT key{};key.vkCode='G';key.scanCode=0x22;
|
||||||
|
a.keyboard(key,0);
|
||||||
|
int chosen=a.snippetPending;
|
||||||
|
a.busy=false;a.snippetPending=-1;a.typingTarget=nullptr;a.snippetTyping=false;
|
||||||
|
return chosen;
|
||||||
|
}
|
||||||
static bool idleUndo(App &a) {
|
static bool idleUndo(App &a) {
|
||||||
a.last=LastTransform{}; a.last->at=100;
|
a.last=LastTransform{}; a.last->at=100;
|
||||||
a.releaseIdleMemory(120100);
|
a.releaseIdleMemory(120100);
|
||||||
@@ -130,6 +141,19 @@ int main(int argc, char **argv) {
|
|||||||
try {
|
try {
|
||||||
INITCOMMONCONTROLSEX controls{sizeof(controls),ICC_STANDARD_CLASSES|ICC_LISTVIEW_CLASSES};
|
INITCOMMONCONTROLSEX controls{sizeof(controls),ICC_STANDARD_CLASSES|ICC_LISTVIEW_CLASSES};
|
||||||
InitCommonControlsEx(&controls);
|
InitCommonControlsEx(&controls);
|
||||||
|
{
|
||||||
|
ks::App app(GetModuleHandleW(nullptr),true,temp/L"snippet-scopes");
|
||||||
|
app.window=CreateWindowExW(0,L"STATIC",L"Snippet scope test",0,0,0,0,0,HWND_MESSAGE,nullptr,GetModuleHandleW(nullptr),nullptr);
|
||||||
|
auto process=ks::processName(GetForegroundWindow());
|
||||||
|
require(!process.empty(),"scope test has foreground process");
|
||||||
|
std::vector<ks::Snippet> snippets{{L";sig",L"global",L"global"},{L";sig",L"scoped",L"scoped",{process}}};
|
||||||
|
require(ks::InputRegressionAccess::snippetChoice(app,snippets)==1,"app-specific trigger wins even when global is first");
|
||||||
|
snippets[1].apps={L"kswitcher-no-such-process"};
|
||||||
|
require(ks::InputRegressionAccess::snippetChoice(app,snippets)==0,"unmatched app falls back to global snippet");
|
||||||
|
snippets.erase(snippets.begin());
|
||||||
|
require(ks::InputRegressionAccess::snippetChoice(app,snippets)==-1,"scoped snippet is not queued in another application");
|
||||||
|
DestroyWindow(app.window);app.window=nullptr;
|
||||||
|
}
|
||||||
{
|
{
|
||||||
HMENU menu = CreatePopupMenu(), child = CreatePopupMenu();
|
HMENU menu = CreatePopupMenu(), child = CreatePopupMenu();
|
||||||
AppendMenuW(menu,MF_STRING | MF_CHECKED,1,L"&Settings…");
|
AppendMenuW(menu,MF_STRING | MF_CHECKED,1,L"&Settings…");
|
||||||
|
|||||||
@@ -141,6 +141,38 @@ int main(int argc, char **argv)
|
|||||||
SendMessageW(window,WM_COMMAND,12,0); require(c.marks.empty(),"undo trapezoid creation");
|
SendMessageW(window,WM_COMMAND,12,0); require(c.marks.empty(),"undo trapezoid creation");
|
||||||
SendMessageW(window,WM_COMMAND,18,0); require(c.marks[0].shape==ks::ScreenshotShape::Trapezoid,"redo trapezoid creation");
|
SendMessageW(window,WM_COMMAND,18,0); require(c.marks[0].shape==ks::ScreenshotShape::Trapezoid,"redo trapezoid creation");
|
||||||
c.marks.clear(); c.selected=-1; c.history=ks::ScreenshotHistory{};
|
c.marks.clear(); c.selected=-1; c.history=ks::ScreenshotHistory{};
|
||||||
|
require(GetDlgItem(window,38)!=nullptr,"arc has a dedicated toolbar button");
|
||||||
|
SendMessageW(window,WM_KEYDOWN,'U',0);
|
||||||
|
require(c.shape==ks::ScreenshotShape::Arc && c.arrow && !c.freehand,"U selects arc tool");
|
||||||
|
mouse(WM_LBUTTONDOWN,{60,70}); mouse(WM_LBUTTONUP,{240,180});
|
||||||
|
require(c.marks.size()==1 && c.marks[0].shape==ks::ScreenshotShape::Arc && !c.marks[0].isHighlight(),
|
||||||
|
"arc tool creates an editable shape");
|
||||||
|
auto arc=c.marks[0];
|
||||||
|
require(ks::hitScreenshotMark(arc,{150,70},3)==ks::MarkPart::Body &&
|
||||||
|
ks::hitScreenshotMark(arc,{150,180},3)==ks::MarkPart::None,
|
||||||
|
"arc selects its curve but has no closing chord");
|
||||||
|
ks::Surface rendered(320,240); PatBlt(rendered.dc,0,0,320,240,WHITENESS);
|
||||||
|
ks::drawMarks(rendered.dc,{arc});
|
||||||
|
require(GetPixel(rendered.dc,150,70)==arc.color && GetPixel(rendered.dc,150,180)==RGB(255,255,255),
|
||||||
|
"arc renders an open curve without a closing chord");
|
||||||
|
auto exported=ks::decodeImage(ks::screenshotClip(c.image->dc,320,240,{arc}));
|
||||||
|
auto channel=[&](int x,int y,int component){return exported.bgra[(y*exported.width+x)*4+component];};
|
||||||
|
require(channel(150,70,2)==GetRValue(arc.color) && channel(150,70,1)==GetGValue(arc.color) &&
|
||||||
|
channel(150,180,0)==255 && channel(150,180,1)==255 && channel(150,180,2)==255,
|
||||||
|
"PNG export preserves the arc and its open chord");
|
||||||
|
mouse(WM_LBUTTONDOWN,arc.to); mouse(WM_LBUTTONUP,{260,200});
|
||||||
|
require(c.marks[0].to.x==260 && c.marks[0].points.back().y==200,"arc resizes with its bounds");
|
||||||
|
auto arcGrip=ks::screenshotShapeRotationHandle(arc);
|
||||||
|
auto turned=ks::dragScreenshotMark(arc,ks::MarkPart::Rotation,arcGrip,{280,135},320,240);
|
||||||
|
require(turned.rotation!=0 && ks::hitScreenshotMark(turned,ks::rotateShapePoint(turned,{150,70}),3)==ks::MarkPart::Body,
|
||||||
|
"rotated arc remains selectable");
|
||||||
|
std::vector<ks::Mark> linked{arc}; ks::Mark connector{{10,10},{150,70},0};
|
||||||
|
ks::linkScreenshotEndpoint(connector,false,{150,70},linked,4);
|
||||||
|
require(connector.toLink.target!=0,"connector can attach to the arc curve");
|
||||||
|
SendMessageW(window,WM_COMMAND,12,0); require(c.marks[0].to.x==240,"undo arc resize");
|
||||||
|
SendMessageW(window,WM_COMMAND,12,0); require(c.marks.empty(),"undo arc creation");
|
||||||
|
SendMessageW(window,WM_COMMAND,18,0); require(c.marks[0].shape==ks::ScreenshotShape::Arc,"redo arc creation");
|
||||||
|
c.marks.clear(); c.selected=-1; c.history=ks::ScreenshotHistory{};
|
||||||
SendMessageW(window,WM_COMMAND,21,0);
|
SendMessageW(window,WM_COMMAND,21,0);
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -70,6 +70,18 @@ int main()
|
|||||||
reordered.directIds = {L"00000422", L"00000409"}; reordered.direct = {L"Ctrl+2", L"Ctrl+1"};
|
reordered.directIds = {L"00000422", L"00000409"}; reordered.direct = {L"Ctrl+2", L"Ctrl+1"};
|
||||||
require(sharedSyncSettings(ordered) == sharedSyncSettings(reordered), "layout enumeration does not cause sync churn");
|
require(sharedSyncSettings(ordered) == sharedSyncSettings(reordered), "layout enumeration does not cause sync churn");
|
||||||
auto j = parse(base);
|
auto j = parse(base);
|
||||||
|
{
|
||||||
|
Settings scoped = local;
|
||||||
|
scoped.snippets.push_back({L"test", L"Only Teams", L"teams-scope", {L"ms-teams"}});
|
||||||
|
auto wire=sharedSyncSettings(scoped);
|
||||||
|
require(restoreSyncSettings(wire, local, {}).snippets[1].apps==std::vector<std::wstring>{L"ms-teams"}, "sync preserves app scope");
|
||||||
|
auto leftScope=j,rightScope=j;
|
||||||
|
leftScope["snippets"].push_back({{"id","left-scope"},{"trigger","same"},{"text","left"},{"apps",Json::array({"notepad"})}});
|
||||||
|
rightScope["snippets"].push_back({{"id","right-scope"},{"trigger","same"},{"text","right"},{"apps",Json::array({"ms-teams"})}});
|
||||||
|
require(parse(mergeSyncSettings(base,bytes(leftScope),bytes(rightScope)))["snippets"].size()==3, "same trigger in different apps merges");
|
||||||
|
rightScope["snippets"].back()["apps"]=Json::array({"NOTEPAD.exe"});
|
||||||
|
rejects([&]{mergeSyncSettings(base,bytes(leftScope),bytes(rightScope));}, "sync detects overlapping application scopes");
|
||||||
|
}
|
||||||
require(!j.contains("startWithSystem") && !j.contains("tesseractPath") && !j.contains("historyEnabled"), "local-only fields excluded");
|
require(!j.contains("startWithSystem") && !j.contains("tesseractPath") && !j.contains("historyEnabled"), "local-only fields excluded");
|
||||||
auto restored = restoreSyncSettings(base, local, {});
|
auto restored = restoreSyncSettings(base, local, {});
|
||||||
require(restored.autostart && restored.elevatedAutostart && !restored.history && restored.tesseractPath == local.tesseractPath, "preserve device settings");
|
require(restored.autostart && restored.elevatedAutostart && !restored.history && restored.tesseractPath == local.tesseractPath, "preserve device settings");
|
||||||
|
|||||||
Reference in new issue
Block a user