Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 20f3b72287 | |||
| 9b231146cb | |||
| c0ab02df16 |
+2
-1
@@ -1,5 +1,5 @@
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
project(KSwitcher VERSION 0.6.21 LANGUAGES CXX RC)
|
||||
project(KSwitcher VERSION 0.6.24 LANGUAGES CXX RC)
|
||||
if(NOT WIN32)
|
||||
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
|
||||
endif()
|
||||
@@ -61,6 +61,7 @@ add_test(NAME native-regressions COMMAND KSwitcherTests)
|
||||
add_test(NAME scroll-regressions COMMAND KSwitcherScrollTests)
|
||||
add_test(NAME input-regressions COMMAND KSwitcherInputTests)
|
||||
add_test(NAME screenshot-ui-regressions COMMAND KSwitcherScreenshotUITests)
|
||||
add_test(NAME screenshot-save-regressions COMMAND KSwitcherScreenshotUITests --save-only)
|
||||
add_test(NAME updater-regressions COMMAND powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${CMAKE_CURRENT_SOURCE_DIR}/tests/update.ps1" -Executable "$<TARGET_FILE:KSwitcher>")
|
||||
set_tests_properties(native-regressions PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}")
|
||||
install(TARGETS KSwitcher KSwitcherConfig KSwitcherScreenshot RUNTIME DESTINATION .)
|
||||
|
||||
@@ -90,6 +90,10 @@ broker can load them even when the profile directory is inaccessible to it.
|
||||
Notifications expire after 24 hours. Cache cleanup runs on capture/open; each cache is bounded to
|
||||
40 files and 256 MiB. Expired or evicted notifications cannot reopen their screenshot.
|
||||
In the editor, **Ctrl+C** or **Enter** copies the current result without closing the window.
|
||||
**Save… / Ctrl+S** saves the full-resolution image and annotations as PNG. The first save asks
|
||||
for a file; later saves update that file. **Ctrl+Shift+S (Save As…)** always asks for a new
|
||||
destination. Saving keeps the editor open and leaves the clipboard unchanged. Further edits
|
||||
must be saved or copied again before closing without a warning.
|
||||
The editor starts with **Pencil / Freehand (F)** for drawing. Choose **Pointer (V)** to select objects, move them, and edit geometry, thickness and
|
||||
other properties using handles and controls. Clicking empty space clears selection; dragging empty
|
||||
space adds nothing. Choose a drawing tool to create objects, then return to **V** to edit them.
|
||||
@@ -107,10 +111,10 @@ change its diameter. Wheel up increases the size; wheel down decreases it. These
|
||||
and update linked arrows. **Ctrl+D** clones the selected object with a small offset. A cloned number
|
||||
gets the smallest available positive number, just like a newly placed number: gaps left by deletion
|
||||
are filled first, without renumbering existing objects. A cloned arrow keeps its style but starts without endpoint links.
|
||||
Arrow and line endpoints can attach to numbered markers, rectangles, circles, ellipses, text blocks,
|
||||
Arrow and line endpoints can attach to numbered markers, rectangles, circles, ellipses, triangles, trapezoids, text blocks,
|
||||
and other arrows or strokes. In Arrow or Straight line mode, hold **Ctrl** and drag from one object
|
||||
to another to draw over them and automatically attach both endpoints. Without Ctrl, existing objects
|
||||
can still be selected and edited as before. With Pointer, drag an existing endpoint onto a target to attach it. Attached endpoints follow movement and resizing; text attachments
|
||||
can still be selected and edited as before. With Pointer, drag an existing endpoint onto a target to attach it. Attached endpoints follow movement and resizing; shape and text attachments
|
||||
also follow rotation. Arrow-to-arrow attachments retain their relative position along the target line.
|
||||
Clicking a connector or adding/editing its caption preserves its links. Moving the whole connector
|
||||
intentionally detaches its endpoints; moving one endpoint detaches only that end. Deleting a target
|
||||
@@ -158,7 +162,34 @@ resize; circles keep equal width and height. Move shapes by their outlines, adju
|
||||
the mouse wheel or side handle, recolor, clone with Ctrl+D, and undo/redo as with other objects.
|
||||
Hold Ctrl to start drawing over an existing object. Line style applies to outlines; Ends is disabled
|
||||
for shapes.
|
||||
**Canvas zoom:** use **Ctrl++ / Ctrl+-** (including numpad keys) or **Ctrl+mouse wheel**.
|
||||
Each step changes the view by 25%; the wheel zooms around the cursor, keyboard shortcuts around
|
||||
the viewport center. **Ctrl+0** restores the initial fit. Drag with the **middle mouse button**
|
||||
to pan an enlarged image. Zoom ranges from 0.1× to 16× the initial fit; the status bar shows the
|
||||
actual image scale. Zoom and pan do not change annotations, export resolution or undo history.
|
||||
The wheel without Ctrl still adjusts the object under the cursor.
|
||||
|
||||
**Freehand line (F)** draws an opaque curve by following the mouse. The curve is one object:
|
||||
after mouse release, light smoothing reduces jitter. Confidently recognized closed outlines become
|
||||
rectangles, circles, ellipses, triangles or trapezoids (including tilted outlines), stored as shape objects.
|
||||
Drag either bounding-box corner handle to resize the entire recognized outline; circles keep equal
|
||||
width and height. Shapes can also be moved, recolored and cloned.
|
||||
Every shape has a round rotation handle above its outline. Drag it to rotate around the center;
|
||||
hold **Shift** for 15-degree steps. The handle works for toolbar shapes and recognized shapes,
|
||||
including triangles. Resize handles and attached connectors follow the rotation; Ctrl+Z/Ctrl+Y
|
||||
undo and redo it. The square handle still controls outline thickness.
|
||||
**Triangle (G)** is also available directly on the toolbar: drag to create a triangle.
|
||||
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
|
||||
after rotation. Vertex edits support undo/redo, and the rotation and thickness handles remain available.
|
||||
**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,
|
||||
rotation, styling and undo/redo; Ctrl+Z after recognition restores the original pencil stroke.
|
||||
Oval fitting tolerates uneven contours and small closing gaps or overlaps; broad open arcs and
|
||||
repeated loops remain freehand strokes.
|
||||
Press **Ctrl+Z** once to restore the original stroke, again to remove it; **Ctrl+Y** reapplies the improvement.
|
||||
Small strokes, strokes linked to other objects, and any stroke drawn partly with Shift are left unchanged.
|
||||
Ambiguous shapes are only smoothed. To edit the resulting curve,
|
||||
move it, change its color or thickness, clone it, or drag its endpoints. Hold Ctrl to start a new
|
||||
stroke over an existing object. Hold **Shift** while drawing to constrain the current segment
|
||||
to a horizontal, vertical or 45-degree diagonal line; release Shift to resume freehand drawing in the same stroke.
|
||||
@@ -168,7 +199,7 @@ released repeatedly within one stroke. The **Line style** selector supports Soli
|
||||
Dash-dot; **Ends** supports an arrow at the end, start, both ends, or no arrows. These settings
|
||||
apply to selected straight/freehand lines and set defaults for subsequent lines. Both are disabled
|
||||
for numbered markers and highlights unless a line drawing tool is active. Undo/Redo includes style changes.
|
||||
The window close button asks for confirmation before discarding an uncopied screenshot.
|
||||
The window close button asks for confirmation before discarding changes that have not been saved or copied.
|
||||
**Escape** cancels region selection or inline text edits; it does not close the editor. Copy keeps the editor open and publishes the image
|
||||
at its original pixel size, ready to paste. The preview fits the window without changing export size.
|
||||
The shortcut is configurable in Settings under **Screen capture**.
|
||||
@@ -222,6 +253,8 @@ thumbnails and source labels aligned with the scaled fonts and spacing.
|
||||
- Installed Windows keyboard layouts, configurable cycle/order and direct shortcuts; IMEs are switch-only.
|
||||
Text conversion cycles skip layouts that leave the text unchanged and use the next enabled layout
|
||||
that changes it. If none changes the text, the operation keeps the text and advances one layout.
|
||||
Partial copied suffixes can be reselected with paced keys; replacement still requires verification
|
||||
of the complete expected text. Browser/editor compatibility depends on its handling of simulated input.
|
||||
- Selection conversion and verified recent input (physical scan codes, 2-second typing-run boundary).
|
||||
- Same-process clipboard ownership plus verified WebView2 descendants, including the Teams fix.
|
||||
- Teams recent-word conversion uses a single word-selection command and validates the copied range.
|
||||
@@ -387,6 +420,8 @@ protect already valid input, and ambiguous alternatives remain unchanged regardl
|
||||
**My dictionary** provides separate English, Ukrainian and Russian fields for correct words missing
|
||||
from the bundled dictionaries. Enter words separated by commas or new lines, then click OK and Save
|
||||
in Settings. Case and duplicates are normalized automatically; no file editing or ranks are needed.
|
||||
The v0.6.23 bundled Ukrainian dictionary includes `поточні`. The updater preserves existing dictionary files;
|
||||
if upgrading from an older dictionary, add this word in My dictionary → Ukrainian and click OK → Save.
|
||||
These entries are stored in settings and survive dictionary updates. They protect correct input and
|
||||
are eligible as correction targets at any frequency limit. The language and target layout must still
|
||||
be enabled. **Never correct these words** is a separate exception list and takes precedence.
|
||||
|
||||
Binary file not shown.
+9
-5
@@ -1,16 +1,20 @@
|
||||
# Latest Windows release
|
||||
|
||||
## v0.6.21 — 2026-10-31
|
||||
## v0.6.24 — 2026-11-01
|
||||
|
||||
## Changes
|
||||
- Next layout skips enabled layouts that leave the text unchanged and selects the next layout that changes it. Shared Russian/Ukrainian text such as `рф` and `шььш` becomes `ha` and `immi` when English follows in the cycle.
|
||||
- Configured order is preserved. If no enabled convertible layout changes the text, it remains unchanged and the operation advances one layout. Direct layout shortcuts retain their existing behavior.
|
||||
- Smooth pencil strokes and recognize rectangles, circles, ellipses, triangles and trapezoids, including tilted contours. Ctrl+Z restores the original stroke; Ctrl+Y reapplies the improvement.
|
||||
- Recognized outlines become editable shapes. Add Triangle (G) and Trapezoid (B) toolbar tools; triangles have three independently draggable vertices.
|
||||
- Rotate all shapes with their round handle, with Shift snapping to 15-degree steps. Resizing, cloning, connector attachments and PNG export follow shape rotation.
|
||||
- Zoom with Ctrl++ / Ctrl+- or Ctrl+mouse wheel, reset with Ctrl+0 and pan with middle-button dragging. Zooming does not change image resolution or undo history.
|
||||
|
||||
## Validation
|
||||
Passed 413 native regressions, 473 input/UI checks, screenshot editor UI, notification registration, 48 updater checks and updater progress checks.
|
||||
423 native regressions, 473 input/UI checks, shared wheel handling, screenshot editor and shape/zoom regressions, PNG save regressions, notification registration, 48 updater checks and updater progress checks passed.
|
||||
|
||||
## Limitations
|
||||
Existing clipboard, accessibility and input-context verification limitations remain. Copy retries and paste readback verification are unchanged.
|
||||
Shape recognition is heuristic: ambiguous outlines may remain smoothed strokes or require Ctrl+Z to restore the original. Small, linked and Shift-constrained pencil strokes are not automatically improved. Triangle vertices are edited independently; trapezoids use bounding-box resize handles. Canvas zoom is limited to 0.1×–16× the initial fit. PNG exports are flattened images, not editable projects.
|
||||
|
||||
Existing browser-selection verification and clipboard/accessibility limitations remain unchanged. The bundled Ukrainian dictionary includes `поточні`; updates preserve existing dictionaries. If missing, add it under Settings → Auto-correction options → My dictionary → Ukrainian, then OK → Save.
|
||||
|
||||
**Upgrade:** choose Update in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation.
|
||||
For manual installation, exit KSwitcher, close screenshot editors and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries.
|
||||
|
||||
@@ -1 +1 @@
|
||||
5207831872af5522063c1c549ba582aaed534096e6e51a394b6be6a705951f3d KSwitcher-win-x64.zip
|
||||
5976c1363e4342830f503cba6d17b3ec1386741f636932117d9b08f55004af92 KSwitcher-win-x64.zip
|
||||
|
||||
@@ -57,6 +57,8 @@ try {
|
||||
if ($LASTEXITCODE) { throw 'Screenshot UI test build failed' }
|
||||
& .\build\KSwitcherScreenshotUITests.exe
|
||||
if ($LASTEXITCODE) { throw 'Screenshot UI tests failed' }
|
||||
& .\build\KSwitcherScreenshotUITests.exe --save-only
|
||||
if ($LASTEXITCODE) { throw 'Screenshot save tests failed' }
|
||||
$registrationTest = Join-Path (Split-Path $executablePath) 'KSwitcherRegistrationTests.exe'
|
||||
try {
|
||||
& $compiler @common tests/screenshot_registration.cpp build/core.o build/platform.o @libs -static '-Wl,--gc-sections' -s -o $registrationTest
|
||||
|
||||
@@ -17,7 +17,7 @@ replace counts with original frequency ranks, sort lexically. Reproduce with
|
||||
hashes. The lists contain word forms from subtitles, not a complete spelling
|
||||
or grammar dictionary. Recognition can miss names, jargon and uncommon forms.
|
||||
|
||||
Supplemental Ukrainian vocabulary: `реліз`. These additions use rank 50001,
|
||||
Supplemental Ukrainian vocabulary: `реліз`, `поточні`. These additions use rank 50001,
|
||||
after the upstream 50k list, rather than claiming a measured corpus frequency.
|
||||
They are included with the default unlimited rank setting and recorded in sources.json.
|
||||
|
||||
|
||||
@@ -9,11 +9,12 @@
|
||||
"uk": {
|
||||
"source": "https://raw.githubusercontent.com/hermitdave/FrequencyWords/525f9b560de45753a5ea01069454e72e9aa541c6/content/2016/uk/uk_50k.txt",
|
||||
"sourceSha256": "9548b79a608d33f39c5546037a69117d679730b1fda08dd0ce67c043dbf3335a",
|
||||
"words": 42550,
|
||||
"bytes": 898227,
|
||||
"sha256": "c94605e6be676d2d64f89a3bc16f92b9128f9977d226a97b103cf22a3e6835e3",
|
||||
"words": 42551,
|
||||
"bytes": 898248,
|
||||
"sha256": "caeb097749d4afc84b68ad96b3d7dfb6a6569561cd32ccce0bee94a13b43ba86",
|
||||
"supplementalWords": {
|
||||
"\u0440\u0435\u043b\u0456\u0437": 50001
|
||||
"\u0440\u0435\u043b\u0456\u0437": 50001,
|
||||
"\u043f\u043e\u0442\u043e\u0447\u043d\u0456": 50001
|
||||
}
|
||||
},
|
||||
"ru": {
|
||||
|
||||
@@ -27337,6 +27337,7 @@
|
||||
поторопись 9062
|
||||
поторопитесь 20656
|
||||
поторопиться 15390
|
||||
поточні 50001
|
||||
потрапив 4168
|
||||
потрапила 7383
|
||||
потрапили 6631
|
||||
|
||||
+3
-3
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
|
||||
1 RT_MANIFEST "app.manifest"
|
||||
201 RCDATA "update.ps1"
|
||||
1 VERSIONINFO
|
||||
FILEVERSION 0,6,21,0
|
||||
PRODUCTVERSION 0,6,21,0
|
||||
FILEVERSION 0,6,24,0
|
||||
PRODUCTVERSION 0,6,24,0
|
||||
FILEOS VOS_NT_WINDOWS32
|
||||
FILETYPE VFT_APP
|
||||
BEGIN
|
||||
@@ -20,7 +20,7 @@ BEGIN
|
||||
BEGIN
|
||||
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
|
||||
VALUE "ProductName", "KSwitcher"
|
||||
VALUE "FileVersion", "0.6.21"
|
||||
VALUE "FileVersion", "0.6.24"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
|
||||
+25
-3
@@ -227,7 +227,8 @@ class Transaction
|
||||
return selectPrevious(n, [&] { return valid(); });
|
||||
}
|
||||
std::wstring copyRange(size_t count, const std::wstring &wordCandidate,
|
||||
const std::function<bool(const std::wstring &)> &matches)
|
||||
const std::function<bool(const std::wstring &)> &matches,
|
||||
const std::function<bool(const std::wstring &)> &suffixMatches = {})
|
||||
{
|
||||
// WebView word boundaries are only candidates; exact verification is mandatory.
|
||||
if (needsSelectionDelay(target) && !wordCandidate.empty() &&
|
||||
@@ -247,7 +248,25 @@ class Transaction
|
||||
return {};
|
||||
Sleep(25);
|
||||
}
|
||||
return select(count) ? copy() : std::wstring{};
|
||||
auto text = select(count) ? copy() : std::wstring{};
|
||||
// Chromium editors can apply only part of a burst of Shift+Left events.
|
||||
// Extend only a verified suffix; never replace a partial or unrelated range.
|
||||
if (valid() && !matches(text) && !text.empty() &&
|
||||
(suffixMatches ? suffixMatches(text) : newlines(wordCandidate).ends_with(newlines(text))) &&
|
||||
caretSteps(text) < count)
|
||||
{
|
||||
const auto missing = count - caretSteps(text);
|
||||
diagnosticEvent(L"Selection: extending verified suffix by " + std::to_wstring(missing));
|
||||
for (size_t i = 0; i < missing; ++i)
|
||||
{
|
||||
if (!valid() || !chord(VK_LEFT, MOD_SHIFT))
|
||||
return {};
|
||||
Sleep(25);
|
||||
}
|
||||
text = copy();
|
||||
diagnosticEvent(L"Selection: suffix recovery match=" + std::to_wstring(matches(text)));
|
||||
}
|
||||
return text;
|
||||
}
|
||||
bool paste(const Clip &clip)
|
||||
{
|
||||
@@ -1138,7 +1157,9 @@ void App::transform(int command, int snippet)
|
||||
{
|
||||
text =
|
||||
tx.copyRange(recent.keys.size(), recent.keys, [&](const std::wstring &candidate)
|
||||
{ return recent.match(candidate, available, physicalLayout()) >= 0; });
|
||||
{ return recent.match(candidate, available, physicalLayout()) >= 0; },
|
||||
[&](const std::wstring &candidate)
|
||||
{ return recent.matchSuffix(candidate, available, physicalLayout()) >= 0; });
|
||||
hint = recent.match(text, available, physicalLayout());
|
||||
diagnosticEvent(L"Recent verification copied=" + std::to_wstring(text.size()) + L" keys=" +
|
||||
std::to_wstring(recent.keys.size()) + L" match=" + std::to_wstring(hint));
|
||||
@@ -1155,6 +1176,7 @@ void App::transform(int command, int snippet)
|
||||
previous->version == version)
|
||||
{
|
||||
expected = previous->result;
|
||||
diagnosticEvent(L"Previous result expected=" + diagnosticText(expected));
|
||||
text = tx.copyRange(caretSteps(previous->result), previous->result,
|
||||
[&](const std::wstring &candidate)
|
||||
{ return newlines(candidate) == newlines(previous->result); });
|
||||
|
||||
@@ -479,6 +479,14 @@ void Run::backspace(HWND w, ULONGLONG now)
|
||||
keys.pop_back();
|
||||
at = now;
|
||||
}
|
||||
int Run::matchSuffix(const std::wstring &text, const std::vector<Layout> &layouts, const Layout &physical) const
|
||||
{
|
||||
if (text.empty() || text.size() >= keys.size() || overflow)
|
||||
return -1;
|
||||
Run tail = *this;
|
||||
tail.keys = keys.substr(keys.size() - text.size());
|
||||
return tail.match(text, layouts, physical);
|
||||
}
|
||||
int Run::match(const std::wstring &text, const std::vector<Layout> &layouts, const Layout &physical) const
|
||||
{
|
||||
if (text.size() != keys.size() || overflow)
|
||||
|
||||
@@ -94,5 +94,6 @@ struct Run
|
||||
void record(wchar_t, HWND, HKL, ULONGLONG);
|
||||
void backspace(HWND, ULONGLONG);
|
||||
int match(const std::wstring &, const std::vector<Layout> &, const Layout &physical) const;
|
||||
int matchSuffix(const std::wstring &, const std::vector<Layout> &, const Layout &physical) const;
|
||||
};
|
||||
} // namespace ks
|
||||
|
||||
+37
@@ -186,6 +186,43 @@ Pixels decodeImage(const Clip &clip, UINT maxWidth, UINT maxHeight)
|
||||
check(converter->CopyPixels(nullptr, pixels.width * 4, (UINT)pixels.bgra.size(), pixels.bgra.data()));
|
||||
return pixels;
|
||||
}
|
||||
Bytes encodePng(const Pixels &p)
|
||||
{
|
||||
if (!p.width || !p.height || uint64_t(p.width) * p.height * 4 != p.bgra.size() ||
|
||||
p.bgra.size() > UINT_MAX || p.width > UINT_MAX / 4)
|
||||
throw std::runtime_error("Invalid PNG image dimensions");
|
||||
Com<IWICImagingFactory> factory;
|
||||
check(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER,
|
||||
IID_PPV_ARGS(factory.out())));
|
||||
Com<IStream> stream;
|
||||
check(CreateStreamOnHGlobal(nullptr, TRUE, stream.out()));
|
||||
Com<IWICBitmapEncoder> encoder;
|
||||
check(factory->CreateEncoder(GUID_ContainerFormatPng, nullptr, encoder.out()));
|
||||
check(encoder->Initialize(stream.p, WICBitmapEncoderNoCache));
|
||||
Com<IWICBitmapFrameEncode> frame;
|
||||
check(encoder->CreateNewFrame(frame.out(), nullptr));
|
||||
check(frame->Initialize(nullptr));
|
||||
check(frame->SetSize(p.width, p.height));
|
||||
auto format = GUID_WICPixelFormat32bppBGRA;
|
||||
check(frame->SetPixelFormat(&format));
|
||||
if (format != GUID_WICPixelFormat32bppBGRA)
|
||||
throw std::runtime_error("Unsupported PNG pixel format");
|
||||
check(frame->WritePixels(p.height, p.width * 4, (UINT)p.bgra.size(),
|
||||
const_cast<BYTE *>(p.bgra.data())));
|
||||
check(frame->Commit());
|
||||
check(encoder->Commit());
|
||||
STATSTG stat{};
|
||||
check(stream->Stat(&stat, STATFLAG_NONAME));
|
||||
if (stat.cbSize.QuadPart > UINT_MAX)
|
||||
throw std::runtime_error("PNG image is too large");
|
||||
check(stream->Seek({}, STREAM_SEEK_SET, nullptr));
|
||||
Bytes bytes((size_t)stat.cbSize.QuadPart);
|
||||
ULONG read{};
|
||||
check(stream->Read(bytes.data(), (ULONG)bytes.size(), &read));
|
||||
if (read != bytes.size())
|
||||
throw std::runtime_error("Cannot read encoded PNG image");
|
||||
return bytes;
|
||||
}
|
||||
Format imageDib(const Pixels &p)
|
||||
{
|
||||
BITMAPINFOHEADER h{};
|
||||
|
||||
@@ -9,6 +9,7 @@ struct Pixels
|
||||
};
|
||||
Pixels decodeImage(const Clip &, UINT maxWidth = 0, UINT maxHeight = 0);
|
||||
Format imageDib(const Pixels &);
|
||||
Bytes encodePng(const Pixels &);
|
||||
std::wstring recognizeText(const Clip &);
|
||||
std::wstring recognizeText(const Clip &, const Settings &);
|
||||
std::filesystem::path tesseractExecutable(const Settings &);
|
||||
|
||||
+3
-2
@@ -396,13 +396,14 @@ bool chord(WORD key, UINT mods)
|
||||
bool needsSelectionDelay(HWND target)
|
||||
{
|
||||
const auto name = lower(processName(target));
|
||||
return name == L"ms-teams" || name == L"msedgewebview2";
|
||||
return name == L"ms-teams" || name == L"msedgewebview2" ||
|
||||
name == L"chrome" || name == L"msedge";
|
||||
}
|
||||
bool selectPrevious(size_t count, const std::function<bool()> &valid)
|
||||
{
|
||||
if (count > 100000)
|
||||
return false;
|
||||
// Teams' WebView editor applies selection changes asynchronously. A burst of
|
||||
// Chromium and Teams' WebView editors apply selection changes asynchronously. A burst of
|
||||
// Shift+Left events can all use the same caret position and select only one
|
||||
// character. Give each change time to settle before sending the next one.
|
||||
const auto target = GetForegroundWindow();
|
||||
|
||||
+348
-35
@@ -1,4 +1,5 @@
|
||||
#include "screenshot.h"
|
||||
#include "stroke_improvement.h"
|
||||
#include "ocr.h"
|
||||
#include "../resources/resource.h"
|
||||
#include <algorithm>
|
||||
@@ -28,8 +29,8 @@ bool confirmScreenshotClose(HWND owner)
|
||||
dialog.hMainIcon=icon;
|
||||
dialog.dwCommonButtons=TDCBF_YES_BUTTON|TDCBF_NO_BUTTON;
|
||||
dialog.pszWindowTitle=L"KSwitcher";
|
||||
dialog.pszMainInstruction=L"Close without copying?";
|
||||
dialog.pszContent=L"Your latest screenshot changes have not been copied to the clipboard.";
|
||||
dialog.pszMainInstruction=L"Close without saving or copying?";
|
||||
dialog.pszContent=L"Your latest screenshot changes have not been saved to a file or copied to the clipboard.";
|
||||
dialog.nDefaultButton=IDNO;
|
||||
dialog.lpCallbackData=(LONG_PTR)owner;
|
||||
dialog.pfCallback=[](HWND window,UINT notification,WPARAM,LPARAM,LONG_PTR data)->HRESULT {
|
||||
@@ -53,7 +54,7 @@ bool confirmScreenshotClose(HWND owner)
|
||||
// If Windows cannot display the confirmation, keep the screenshot open.
|
||||
return SUCCEEDED(result) && button==IDYES;
|
||||
}
|
||||
constexpr int ToolIds[]{27,10,11,22,23,24,25,21,19,26,20,12,18,13};
|
||||
constexpr int ToolIds[]{27,10,11,22,23,24,25,36,37,21,19,26,20,12,18,13};
|
||||
struct Surface
|
||||
{
|
||||
HDC dc = CreateCompatibleDC(nullptr);
|
||||
@@ -123,7 +124,7 @@ bool chooseTextFont(HWND owner,Mark &m)
|
||||
m.fontWeight=font.lfWeight; m.fontItalic=font.lfItalic!=0;
|
||||
return true;
|
||||
}
|
||||
constexpr int CursorTools[]{10,11,22,23,24,25,19};
|
||||
constexpr int CursorTools[]{10,11,22,23,24,25,36,37,19};
|
||||
HCURSOR makeToolCursor(int tool,int size)
|
||||
{
|
||||
// Monochrome AND/XOR cursor: white outline keeps the glyph visible on dark images.
|
||||
@@ -148,6 +149,8 @@ HCURSOR makeToolCursor(int tool,int size)
|
||||
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 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 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 19:
|
||||
line(18,25,25,18); line(25,18,29,22); line(29,22,22,29);
|
||||
line(22,29,18,25); line(18,25,17,30); line(17,30,22,29); break;
|
||||
@@ -179,12 +182,18 @@ struct Capture
|
||||
COLORREF customColors[16]{};
|
||||
Mark stroke{};
|
||||
bool strokeConstrained{};
|
||||
bool strokeUsedConstraint{};
|
||||
size_t strokeAnchor{};
|
||||
int strokeAxis{}; // 0 = not chosen, 1 = horizontal, 2 = vertical, 3/4 = diagonals.
|
||||
bool strokeAxisSettled{};
|
||||
std::vector<POINT> strokeMotion;
|
||||
POINT start{}, end{};
|
||||
RECT selection{}, view{};
|
||||
RECT canvas{};
|
||||
double zoom=1,panX=0,panY=0;
|
||||
bool panning=false;
|
||||
POINT panOrigin{};
|
||||
int zoomWheelDelta=0;
|
||||
std::vector<Mark> marks;
|
||||
ScreenshotHistory history;
|
||||
HWND editor{}, inlineText{};
|
||||
@@ -208,6 +217,7 @@ struct Capture
|
||||
int hoveredTool=0;
|
||||
MarkPart hoveredPart=MarkPart::None;
|
||||
std::wstring statusText;
|
||||
std::filesystem::path savedPath;
|
||||
explicit Capture(ScreenshotHost &a) : app(a), autoOpenEditor(a.autoOpenEditor) {}
|
||||
~Capture() {
|
||||
if(pencilCursor) DestroyCursor(pencilCursor);
|
||||
@@ -239,11 +249,13 @@ void updateStatus(Capture &c)
|
||||
if(c.hoveredTool==30) text=L"Line style: solid, dotted, dashed or dash-dot";
|
||||
if(c.hoveredTool==31) text=L"Line ends: start, end, both or no arrowheads";
|
||||
if(c.hoveredTool==35) text=L"Arrow caption: F2 / double-click to edit; drag to reposition";
|
||||
if(c.hoveredTool==28) text=L"Save (Ctrl+S) | Save As... (Ctrl+Shift+S)";
|
||||
}
|
||||
else if(c.inlineText) text=L"Text: Enter = new line | Ctrl+Enter = apply | Esc = cancel";
|
||||
else if(part==MarkPart::Rotation) text=L"Drag to rotate text | Hold Shift for 15-degree steps";
|
||||
else if(part==MarkPart::Rotation) text=L"Drag to rotate | Hold Shift for 15-degree steps";
|
||||
else if(part==MarkPart::Thickness) text=L"Drag to change thickness | Mouse wheel over object = size";
|
||||
else if(part==MarkPart::Radius) text=L"Drag to resize the number | Mouse wheel = diameter";
|
||||
else if(part>=MarkPart::Vertex0 && part<=MarkPart::Vertex2) text=L"Drag triangle vertex | Other vertices stay fixed";
|
||||
else if(part==MarkPart::Start || part==MarkPart::End) text=L"Drag handle to resize | Endpoints can snap to objects and other arrows";
|
||||
else if(c.selected>=0) {
|
||||
const auto &m=c.marks[c.selected];
|
||||
@@ -255,9 +267,11 @@ void updateStatus(Capture &c)
|
||||
else if(c.pointer) text=L"Pointer (V): select and edit objects | Drag handles = geometry or thickness | Empty space clears selection";
|
||||
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.freehand) text=L"Freehand (F): Shift = 45-degree steps; move >50 px off line to turn | 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.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";
|
||||
text=L"Zoom: "+std::to_wstring(int(std::lround(100.0*(c.view.right-c.view.left)/c.image->width)))+
|
||||
L"% (Ctrl +/- / wheel; Ctrl+0: fit; middle drag: pan) | "+text;
|
||||
if(text!=c.statusText) {
|
||||
c.statusText=std::move(text);
|
||||
SendMessageW(status,SB_SETTEXTW,0,(LPARAM)c.statusText.c_str());
|
||||
@@ -297,6 +311,12 @@ void positionInlineText(Capture &c)
|
||||
int width=std::min(std::max(120,MulDiv(bounds.right-bounds.left,viewWidth,c.image->width)+24),std::max(32,int(client.right)-x));
|
||||
int height=std::min(std::max(40,MulDiv(bounds.bottom-bounds.top,viewWidth,c.image->width)+24),std::max(32,int(client.bottom)-c.statusHeight-y));
|
||||
SetWindowPos(c.inlineText,nullptr,x-2,y-2,width,height,SWP_NOZORDER|SWP_NOACTIVATE);
|
||||
RECT editorBounds{x-2,y-2,x-2+width,y-2+height},visible{};
|
||||
IntersectRect(&visible,&editorBounds,&c.canvas);
|
||||
auto clip=IsRectEmpty(&visible)?CreateRectRgn(0,0,0,0):
|
||||
CreateRectRgn(visible.left-editorBounds.left,visible.top-editorBounds.top,
|
||||
visible.right-editorBounds.left,visible.bottom-editorBounds.top);
|
||||
if(!SetWindowRgn(c.inlineText,clip,TRUE)) DeleteObject(clip);
|
||||
}
|
||||
Mark textStyleDefaults(const Mark &source)
|
||||
{
|
||||
@@ -337,6 +357,14 @@ void finishInlineText(HWND w,Capture &c,bool commit)
|
||||
}
|
||||
LRESULT CALLBACK inlineTextProc(HWND w,UINT msg,WPARAM wp,LPARAM lp,UINT_PTR,DWORD_PTR)
|
||||
{
|
||||
if(msg==WM_KEYDOWN && (GetKeyState(VK_CONTROL)&0x8000) &&
|
||||
(wp==VK_OEM_PLUS || wp==VK_ADD || wp==VK_OEM_MINUS || wp==VK_SUBTRACT || wp=='0' || wp==VK_NUMPAD0)) {
|
||||
SendMessageW(GetParent(w),msg,wp,lp); return 0;
|
||||
}
|
||||
if(msg==WM_KEYDOWN && wp=='S' && (GetKeyState(VK_CONTROL)&0x8000)) {
|
||||
PostMessageW(GetParent(w),WM_COMMAND,(GetKeyState(VK_SHIFT)&0x8000)?29:28,0); return 0;
|
||||
}
|
||||
if(msg==WM_CHAR && wp==19) return 0; // Ctrl+S is handled by the editor.
|
||||
if(msg==WM_KEYDOWN && (wp==VK_ESCAPE || (wp==VK_RETURN && (GetKeyState(VK_CONTROL)&0x8000)))) {
|
||||
PostMessageW(GetParent(w),FinishTextMessage,wp==VK_ESCAPE?2:1,(LPARAM)w); return 0;
|
||||
}
|
||||
@@ -366,9 +394,47 @@ void beginMarkText(HWND w,Capture &c,int index)
|
||||
beginInlineText(w,c,label,index); c.editingLabel=true;
|
||||
}
|
||||
}
|
||||
POINT rotateShapePoint(const Mark &m,POINT p,double direction=1)
|
||||
{
|
||||
if(m.shape==ScreenshotShape::None || m.rotation==0) return p;
|
||||
double cx=(m.from.x+m.to.x)/2.0,cy=(m.from.y+m.to.y)/2.0;
|
||||
double a=direction*m.rotation*3.141592653589793/180,cs=std::cos(a),sn=std::sin(a);
|
||||
return {LONG(std::lround(cx+(p.x-cx)*cs-(p.y-cy)*sn)),
|
||||
LONG(std::lround(cy+(p.x-cx)*sn+(p.y-cy)*cs))};
|
||||
}
|
||||
std::vector<POINT> shapeOutline(const Mark &m)
|
||||
{
|
||||
auto points=m.points;
|
||||
if(points.empty()) {
|
||||
auto r=region(m.from,m.to);
|
||||
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}};
|
||||
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}};
|
||||
else if(m.shape==ScreenshotShape::Trapezoid)
|
||||
points={{r.left+(r.right-r.left)/4,r.top},{r.right-(r.right-r.left)/4,r.top},
|
||||
{r.right,r.bottom},{r.left,r.bottom},{r.left+(r.right-r.left)/4,r.top}};
|
||||
else {
|
||||
for(int i=0;i<180;++i) {
|
||||
double a=i*2*3.141592653589793/180;
|
||||
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.top+r.bottom)/2.0+(r.bottom-r.top)/2.0*std::sin(a)))});
|
||||
}
|
||||
points.push_back(points.front());
|
||||
}
|
||||
}
|
||||
for(auto &p:points) p=rotateShapePoint(m,p);
|
||||
return points;
|
||||
}
|
||||
RECT markBounds(const Mark &m)
|
||||
{
|
||||
if(m.isText()) return screenshotTextBounds(m);
|
||||
if(m.shape!=ScreenshotShape::None && m.rotation!=0) {
|
||||
auto points=shapeOutline(m); RECT r{points[0].x,points[0].y,points[0].x,points[0].y};
|
||||
for(auto p:points) { r.left=std::min(r.left,p.x); r.right=std::max(r.right,p.x);
|
||||
r.top=std::min(r.top,p.y); r.bottom=std::max(r.bottom,p.y); }
|
||||
return r;
|
||||
}
|
||||
RECT r=region(m.from,m.to);
|
||||
for(auto p:m.points) {
|
||||
r.left=std::min(r.left,p.x); r.top=std::min(r.top,p.y);
|
||||
@@ -380,7 +446,7 @@ std::pair<POINT,POINT> thicknessSegment(const Mark &m)
|
||||
{
|
||||
if(m.shape!=ScreenshotShape::None) {
|
||||
auto r=region(m.from,m.to);
|
||||
return {{r.right,r.top},{r.left,r.top}};
|
||||
return {rotateShapePoint(m,{r.right,r.top}),rotateShapePoint(m,{r.left,r.top})};
|
||||
}
|
||||
if(m.freehand && m.points.size()>1) {
|
||||
size_t index=(m.points.size()-1)/2;
|
||||
@@ -484,7 +550,10 @@ void drawMarks(HDC dc, const std::vector<Mark> &marks, const Mark *preview = nul
|
||||
if(m.shape!=ScreenshotShape::None) {
|
||||
auto hollow=SelectObject(dc,GetStockObject(HOLLOW_BRUSH));
|
||||
auto r=region(m.from,m.to);
|
||||
if(m.shape==ScreenshotShape::Rectangle) Rectangle(dc,r.left,r.top,r.right+1,r.bottom+1);
|
||||
if(!m.points.empty() || m.rotation!=0 || m.shape==ScreenshotShape::Triangle || m.shape==ScreenshotShape::Trapezoid) {
|
||||
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 Ellipse(dc,r.left,r.top,r.right+1,r.bottom+1);
|
||||
SelectObject(dc,hollow);
|
||||
SelectObject(dc,previousPen); DeleteObject(arrowPen);
|
||||
@@ -599,7 +668,7 @@ void layout(HWND w, Capture &c)
|
||||
RECT statusRect{}; GetWindowRect(status,&statusRect);
|
||||
c.statusHeight=statusRect.bottom-statusRect.top;
|
||||
r.bottom-=c.statusHeight;
|
||||
const int positions[]{8,44,80,116,152,188,224,260,296,332,368,416,452,500};
|
||||
const int positions[]{8,44,80,116,152,188,224,260,296,332,368,404,440,488,524,572};
|
||||
static_assert(std::size(positions)==std::size(ToolIds));
|
||||
for (size_t i = 0; i < std::size(ToolIds); ++i)
|
||||
{
|
||||
@@ -607,6 +676,9 @@ void layout(HWND w, Capture &c)
|
||||
MoveWindow(GetDlgItem(w,ToolIds[i]),MulDiv(positions[i],dpi,96),MulDiv(6,dpi,96),
|
||||
MulDiv(32,dpi,96),MulDiv(32,dpi,96),TRUE);
|
||||
}
|
||||
SendDlgItemMessageW(w,28,WM_SETFONT,(WPARAM)c.font,TRUE);
|
||||
MoveWindow(GetDlgItem(w,28),MulDiv(612,dpi,96),MulDiv(6,dpi,96),
|
||||
MulDiv(78,dpi,96),MulDiv(32,dpi,96),TRUE);
|
||||
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);
|
||||
MoveWindow(GetDlgItem(w,32),MulDiv(12,dpi,96),c.unit+MulDiv(6,dpi,96),MulDiv(64,dpi,96),MulDiv(24,dpi,96),TRUE);
|
||||
@@ -616,13 +688,35 @@ void layout(HWND w, Capture &c)
|
||||
MoveWindow(GetDlgItem(w,34),MulDiv(480,dpi,96),c.unit+MulDiv(2,dpi,96),MulDiv(28,dpi,96),MulDiv(28,dpi,96),TRUE);
|
||||
MoveWindow(GetDlgItem(w,35),MulDiv(516,dpi,96),c.unit+MulDiv(2,dpi,96),MulDiv(28,dpi,96),MulDiv(28,dpi,96),TRUE);
|
||||
syncLineOptions(w,c);
|
||||
double ratio = std::min({1.0, double(std::max(1L,r.right-16))/c.image->width,
|
||||
c.canvas={0,c.panelHeight,r.right,std::max(LONG(c.panelHeight+1),r.bottom)};
|
||||
double ratio = c.zoom*std::min({1.0, double(std::max(1L,r.right-16))/c.image->width,
|
||||
double(std::max(1L,r.bottom-c.panelHeight-16))/c.image->height});
|
||||
int width = int(c.image->width*ratio), height = int(c.image->height*ratio);
|
||||
c.view = {(r.right-width)/2, c.panelHeight+(r.bottom-c.panelHeight-height)/2,
|
||||
(r.right+width)/2, c.panelHeight+(r.bottom-c.panelHeight+height)/2};
|
||||
int width = std::max(1,int(std::lround(c.image->width*ratio))), height = std::max(1,int(std::lround(c.image->height*ratio)));
|
||||
double maxX=std::max(0.0,(width-double(c.canvas.right-c.canvas.left))/2);
|
||||
double maxY=std::max(0.0,(height-double(c.canvas.bottom-c.canvas.top))/2);
|
||||
c.panX=std::clamp(c.panX,-maxX,maxX); c.panY=std::clamp(c.panY,-maxY,maxY);
|
||||
LONG x=LONG(std::lround((r.right-width)/2.0+c.panX));
|
||||
LONG y=LONG(std::lround(c.panelHeight+(r.bottom-c.panelHeight-height)/2.0+c.panY));
|
||||
c.view = {x,y,x+width,y+height};
|
||||
InvalidateRect(w, nullptr, FALSE);
|
||||
}
|
||||
void zoomCanvas(HWND w,Capture &c,double factor,POINT anchor,bool reset=false)
|
||||
{
|
||||
if(!c.editing || c.dragging || c.panning) return;
|
||||
double u=double(anchor.x-c.view.left)/std::max(1L,c.view.right-c.view.left);
|
||||
double v=double(anchor.y-c.view.top)/std::max(1L,c.view.bottom-c.view.top);
|
||||
double next=reset?1.0:std::clamp(c.zoom*factor,.1,16.0);
|
||||
if(next==c.zoom && !reset) return;
|
||||
c.zoom=next;
|
||||
if(reset) c.panX=c.panY=0;
|
||||
layout(w,c);
|
||||
if(!reset) {
|
||||
c.panX+=anchor.x-(c.view.left+u*(c.view.right-c.view.left));
|
||||
c.panY+=anchor.y-(c.view.top+v*(c.view.bottom-c.view.top));
|
||||
layout(w,c);
|
||||
}
|
||||
positionInlineText(c); updateStatus(c); InvalidateRect(w,nullptr,FALSE);
|
||||
}
|
||||
POINT imagePoint(Capture &c, POINT p, bool clamp = true)
|
||||
{
|
||||
POINT result{MulDiv(p.x-c.view.left,c.image->width,std::max(1L,c.view.right-c.view.left)),
|
||||
@@ -650,6 +744,10 @@ std::pair<int,MarkPart> markAt(Capture &c, POINT p)
|
||||
if(c.selected>=0)
|
||||
{
|
||||
const auto &m=c.marks[c.selected];
|
||||
if(m.shape!=ScreenshotShape::None) {
|
||||
auto grip=screenshotShapeRotationHandle(m,double(c.unit)*c.image->width/std::max(1L,c.view.right-c.view.left));
|
||||
if(std::hypot(double(p.x-grip.x),double(p.y-grip.y))<=tolerance) return {c.selected,MarkPart::Rotation};
|
||||
}
|
||||
if(m.isText() || (m.isLine() && !m.label.empty())) {
|
||||
auto text=m.isText()?m:screenshotArrowLabel(m);
|
||||
auto grip=screenshotTextRotationHandle(text,double(c.unit*0.6)*c.image->width/std::max(1L,c.view.right-c.view.left));
|
||||
@@ -673,7 +771,7 @@ HCURSOR toolCursor(const Capture &c)
|
||||
if(c.pointer) return LoadCursorW(nullptr,IDC_ARROW);
|
||||
if(c.textTool) return LoadCursorW(nullptr,IDC_IBEAM);
|
||||
if(c.freehand && c.pencilCursor) return c.pencilCursor;
|
||||
int tool=c.highlight?19:c.straight?22:c.shape!=ScreenshotShape::None?22+int(c.shape):c.arrow?11:10;
|
||||
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;
|
||||
for(size_t i=0;i<std::size(CursorTools);++i)
|
||||
if(CursorTools[i]==tool && c.drawingCursors[i]) return c.drawingCursors[i];
|
||||
return LoadCursorW(nullptr,IDC_CROSS);
|
||||
@@ -684,6 +782,7 @@ HCURSOR actionCursor(const Capture &c,std::pair<int,MarkPart> hit,bool inside)
|
||||
LPCWSTR cursor=nullptr;
|
||||
if(hit.second==MarkPart::Body || hit.second==MarkPart::Label) cursor=IDC_SIZEALL;
|
||||
else if(hit.second==MarkPart::Rotation) cursor=IDC_HAND;
|
||||
else if(hit.second>=MarkPart::Vertex0 && hit.second<=MarkPart::Vertex2) cursor=IDC_SIZEALL;
|
||||
else if(hit.second==MarkPart::Radius) cursor=IDC_SIZEWE;
|
||||
else if(hit.second==MarkPart::Start || hit.second==MarkPart::End || hit.second==MarkPart::Thickness) {
|
||||
const auto &mark=c.marks[hit.first];
|
||||
@@ -699,7 +798,7 @@ HCURSOR actionCursor(const Capture &c,std::pair<int,MarkPart> hit,bool inside)
|
||||
void captureCursor(HWND w, Capture &c)
|
||||
{
|
||||
POINT p; GetCursorPos(&p); ScreenToClient(w,&p);
|
||||
bool inside=PtInRect(&c.view,p)!=FALSE;
|
||||
bool inside=PtInRect(&c.view,p) && PtInRect(&c.canvas,p);
|
||||
auto hit=c.editing?(c.dragging?std::pair{c.selected,c.part}:markAt(c,imagePoint(c,p,false))):std::pair{-1,MarkPart::None};
|
||||
if(!c.dragging && !inside && hit.second!=MarkPart::Thickness && hit.second!=MarkPart::Rotation) hit={-1,MarkPart::None};
|
||||
SetCursor(actionCursor(c,hit,inside || c.dragging));
|
||||
@@ -755,7 +854,18 @@ void drawHandles(HDC dc, const Capture &c)
|
||||
handle({m.from.x+m.radius,m.from.y});
|
||||
}
|
||||
else {
|
||||
handle(m.from); handle(m.to);
|
||||
if(m.shape==ScreenshotShape::Triangle) {
|
||||
auto vertices=shapeOutline(m);
|
||||
for(size_t i=0;i<3;++i) handle(vertices[i]);
|
||||
} else { handle(rotateShapePoint(m,m.from)); handle(rotateShapePoint(m,m.to)); }
|
||||
if(m.shape!=ScreenshotShape::None) {
|
||||
auto r=region(m.from,m.to);
|
||||
auto top=point(rotateShapePoint(m,{(r.left+r.right)/2,r.top}));
|
||||
auto grip=screenshotShapeRotationHandle(m,double(c.unit)*c.image->width/std::max(1L,c.view.right-c.view.left));
|
||||
auto end=point(grip); MoveToEx(dc,top.x,top.y,nullptr); LineTo(dc,end.x,end.y);
|
||||
int radius=std::max(4,c.unit/8);
|
||||
Ellipse(dc,end.x-radius,end.y-radius,end.x+radius+1,end.y+radius+1);
|
||||
}
|
||||
auto [from,to]=thicknessSegment(m);
|
||||
auto mid=point({(from.x+to.x)/2,(from.y+to.y)/2});
|
||||
auto grip=thicknessHandle(c,m),end=point(grip);
|
||||
@@ -771,7 +881,8 @@ void drawTool(const DRAWITEMSTRUCT &item, const Capture &c)
|
||||
RECT r=item.rcItem;
|
||||
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==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);
|
||||
bool disabled=item.itemState&ODS_DISABLED;
|
||||
POINT mouse; GetCursorPos(&mouse); ScreenToClient(item.hwndItem,&mouse);
|
||||
bool hover=PtInRect(&r,mouse) && !disabled;
|
||||
@@ -785,7 +896,16 @@ void drawTool(const DRAWITEMSTRUCT &item, const Capture &c)
|
||||
RoundRect(dc,r.left,r.top,r.right,r.bottom,corner,corner);
|
||||
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;
|
||||
auto icon=item.CtlID==27?(HICON)LoadCursorW(nullptr,IDC_ARROW):c.toolIcons[disabled?2:active?1:0][iconIndex];
|
||||
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];
|
||||
if(item.CtlID==36 || item.CtlID==37) {
|
||||
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)),
|
||||
disabled?RGB(148,163,184):active?RGB(37,99,235):RGB(51,65,85));
|
||||
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); }
|
||||
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);
|
||||
}
|
||||
if(icon) DrawIconEx(dc,(r.right-c.iconSize)/2,(r.bottom-c.iconSize)/2,icon,c.iconSize,c.iconSize,0,nullptr,DI_NORMAL);
|
||||
if(item.CtlID==20) {
|
||||
auto color=c.selected>=0?c.marks[c.selected].color:c.currentColor;
|
||||
@@ -821,6 +941,64 @@ void copy(HWND w, Capture &c, DWORD (*publish)(HWND,const Clip &)=writeClipboard
|
||||
publish(c.app.window, clip);
|
||||
c.copied=true;
|
||||
}
|
||||
void saveToFile(HWND w, Capture &c, const std::filesystem::path &path)
|
||||
{
|
||||
finishInlineText(w,c,true);
|
||||
auto pixels=decodeImage(screenshotClip(c.image->dc,c.image->width,c.image->height,c.marks));
|
||||
// GDI-rendered screenshot pixels are opaque; GDI does not maintain alpha.
|
||||
for(size_t i=3;i<pixels.bgra.size();i+=4) pixels.bgra[i]=255;
|
||||
auto bytes=encodePng(pixels);
|
||||
GUID id{};
|
||||
if(FAILED(CoCreateGuid(&id))) throw std::runtime_error("Cannot prepare screenshot output file");
|
||||
wchar_t unique[40]{}; StringFromGUID2(id,unique,(int)std::size(unique));
|
||||
auto temporary=path.parent_path()/(L".kswitcher-"+std::wstring(unique)+L".png");
|
||||
try {
|
||||
atomicWrite(temporary,bytes);
|
||||
if(!MoveFileExW(temporary.c_str(),path.c_str(),MOVEFILE_REPLACE_EXISTING|MOVEFILE_WRITE_THROUGH))
|
||||
throw std::runtime_error("Cannot save screenshot to the selected file");
|
||||
} catch(...) {
|
||||
std::error_code ignored; std::filesystem::remove(temporary,ignored); throw;
|
||||
}
|
||||
c.savedPath=path;
|
||||
c.copied=true; // Current edits have been exported, either to file or clipboard.
|
||||
}
|
||||
void save(HWND w, Capture &c, bool saveAs=false,
|
||||
BOOL (WINAPI *chooseFile)(LPOPENFILENAMEW)=GetSaveFileNameW)
|
||||
{
|
||||
if(!saveAs && !c.savedPath.empty()) {
|
||||
saveToFile(w,c,c.savedPath);
|
||||
return;
|
||||
}
|
||||
wchar_t filename[32768]{};
|
||||
auto proposed=c.savedPath.wstring();
|
||||
if(proposed.empty()) {
|
||||
SYSTEMTIME time{}; GetLocalTime(&time);
|
||||
wchar_t name[100]{};
|
||||
swprintf_s(name,L"Screenshot-%04u%02u%02u-%02u%02u%02u.png",
|
||||
time.wYear,time.wMonth,time.wDay,time.wHour,time.wMinute,time.wSecond);
|
||||
proposed=name;
|
||||
}
|
||||
if(proposed.size()>=std::size(filename)) throw std::runtime_error("Screenshot file path is too long");
|
||||
std::copy(proposed.begin(),proposed.end(),filename);
|
||||
OPENFILENAMEW dialog{sizeof(dialog)};
|
||||
dialog.hwndOwner=w;
|
||||
dialog.lpstrTitle=L"Save screenshot";
|
||||
dialog.lpstrFilter=L"PNG image (*.png)\0*.png\0\0";
|
||||
dialog.nFilterIndex=1;
|
||||
dialog.lpstrFile=filename;
|
||||
dialog.nMaxFile=(DWORD)std::size(filename);
|
||||
dialog.lpstrDefExt=L"png";
|
||||
dialog.Flags=OFN_OVERWRITEPROMPT|OFN_PATHMUSTEXIST|OFN_NOCHANGEDIR|OFN_EXPLORER;
|
||||
if(!chooseFile(&dialog)) {
|
||||
if(CommDlgExtendedError()) throw std::runtime_error("Cannot open the screenshot save dialog");
|
||||
return;
|
||||
}
|
||||
if(lower(std::filesystem::path(filename).extension().wstring())!=L".png")
|
||||
throw std::runtime_error("Choose a filename ending in .png for the screenshot");
|
||||
saveToFile(w,c,filename);
|
||||
c.statusText=L"Saved: "+c.savedPath.wstring();
|
||||
SendDlgItemMessageW(w,50,SB_SETTEXTW,0,(LPARAM)c.statusText.c_str());
|
||||
}
|
||||
void edit(HWND w, Capture &c)
|
||||
{
|
||||
auto r = region(c.start,c.end);
|
||||
@@ -846,9 +1024,12 @@ void edit(HWND w, Capture &c)
|
||||
InitCommonControlsEx(&statusControls);
|
||||
CreateWindowExW(0,STATUSCLASSNAMEW,L"",WS_CHILD|WS_VISIBLE|SBARS_TOOLTIPS,
|
||||
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"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"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)"};
|
||||
static_assert(std::size(labels)==std::size(ToolIds));
|
||||
auto saveButton=CreateWindowW(L"BUTTON",L"Save...",WS_CHILD|WS_VISIBLE|WS_TABSTOP|BS_PUSHBUTTON,
|
||||
0,0,0,0,w,(HMENU)28,c.app.instance,nullptr);
|
||||
SetWindowSubclass(saveButton,toolHover,1,0);
|
||||
auto tips=CreateWindowExW(WS_EX_TOPMOST,TOOLTIPS_CLASSW,nullptr,WS_POPUP|TTS_ALWAYSTIP|TTS_NOPREFIX,
|
||||
CW_USEDEFAULT,CW_USEDEFAULT,CW_USEDEFAULT,CW_USEDEFAULT,w,nullptr,c.app.instance,nullptr);
|
||||
for (size_t i=0;i<std::size(ToolIds);++i)
|
||||
@@ -938,7 +1119,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
}
|
||||
break;
|
||||
case WM_DRAWITEM:
|
||||
if((wp>=10 && wp<=13) || (wp>=18 && wp<=27) || wp==34 || wp==35) { 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) { drawTool(*reinterpret_cast<DRAWITEMSTRUCT *>(lp),*c); return TRUE; }
|
||||
break;
|
||||
case WM_CTLCOLORSTATIC:
|
||||
SetBkColor((HDC)wp,RGB(255,255,255)); SetTextColor((HDC)wp,RGB(51,65,85));
|
||||
@@ -1023,19 +1204,22 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
if(next!=value) { c->remember(c->marks); value=next; resolveScreenshotLinks(c->marks); }
|
||||
}
|
||||
if (LOWORD(wp)==13) { copy(w,*c); return 0; }
|
||||
if (LOWORD(wp)==28 || LOWORD(wp)==29) {
|
||||
save(w,*c,LOWORD(wp)==29); SetFocus(w); return 0;
|
||||
}
|
||||
if (LOWORD(wp)==12 || LOWORD(wp)==18)
|
||||
{
|
||||
bool changed=LOWORD(wp)==12?c->history.undo(c->marks):c->history.redo(c->marks);
|
||||
if(changed) { c->copied=false; c->selected=-1; c->selectedLabel=false; c->wheelDelta=0; c->wheelTarget=-1; }
|
||||
c->syncHistory();
|
||||
}
|
||||
if (LOWORD(wp)==10 || LOWORD(wp)==11 || LOWORD(wp)==19 || (LOWORD(wp)>=21 && LOWORD(wp)<=27))
|
||||
if (LOWORD(wp)==10 || LOWORD(wp)==11 || LOWORD(wp)==19 || LOWORD(wp)==36 || LOWORD(wp)==37 || (LOWORD(wp)>=21 && LOWORD(wp)<=27))
|
||||
{
|
||||
c->pointer=LOWORD(wp)==27;
|
||||
c->arrow = LOWORD(wp)==11 || (LOWORD(wp)>=22 && LOWORD(wp)<=25);
|
||||
c->arrow = LOWORD(wp)==11 || LOWORD(wp)==36 || LOWORD(wp)==37 || (LOWORD(wp)>=22 && LOWORD(wp)<=25);
|
||||
c->straight=LOWORD(wp)==22;
|
||||
c->textTool=LOWORD(wp)==26;
|
||||
c->shape=LOWORD(wp)>=23 && LOWORD(wp)<=25?ScreenshotShape(LOWORD(wp)-22):ScreenshotShape::None;
|
||||
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->selected=-1; c->selectedLabel=false;
|
||||
c->highlight=LOWORD(wp)==19;
|
||||
c->freehand=LOWORD(wp)==21;
|
||||
@@ -1046,12 +1230,15 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
InvalidateRect(GetDlgItem(w,19),nullptr,FALSE);
|
||||
InvalidateRect(GetDlgItem(w,21),nullptr,FALSE);
|
||||
for(int id=22;id<=27;++id) InvalidateRect(GetDlgItem(w,id),nullptr,FALSE);
|
||||
InvalidateRect(GetDlgItem(w,36),nullptr,FALSE);
|
||||
InvalidateRect(GetDlgItem(w,37),nullptr,FALSE);
|
||||
}
|
||||
|
||||
SetFocus(w); captureCursor(w,*c); InvalidateRect(w,nullptr,FALSE); return 0;
|
||||
case WM_KEYDOWN:
|
||||
if(wp==VK_SHIFT && c->editing && c->dragging && c->freehand && c->part==MarkPart::None && !c->strokeConstrained) {
|
||||
c->strokeConstrained=true;
|
||||
c->strokeUsedConstraint=true;
|
||||
c->strokeAnchor=c->stroke.points.size()-1;
|
||||
c->strokeAxis=0;
|
||||
c->strokeAxisSettled=false;
|
||||
@@ -1063,6 +1250,12 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
}
|
||||
if (c->editing && !c->dragging)
|
||||
{
|
||||
if((GetKeyState(VK_CONTROL)&0x8000) &&
|
||||
(wp==VK_OEM_PLUS || wp==VK_ADD || wp==VK_OEM_MINUS || wp==VK_SUBTRACT || wp=='0' || wp==VK_NUMPAD0)) {
|
||||
POINT center{(c->canvas.left+c->canvas.right)/2,(c->canvas.top+c->canvas.bottom)/2};
|
||||
zoomCanvas(w,*c,wp==VK_OEM_MINUS || wp==VK_SUBTRACT?1/1.25:1.25,center,wp=='0' || wp==VK_NUMPAD0);
|
||||
return 0;
|
||||
}
|
||||
if(wp>=VK_LEFT && wp<=VK_DOWN && c->selected>=0 && !c->inlineText) {
|
||||
POINT delta{wp==VK_LEFT?-1L:wp==VK_RIGHT?1L:0L,wp==VK_UP?-1L:wp==VK_DOWN?1L:0L};
|
||||
const auto &original=c->marks[c->selected];
|
||||
@@ -1079,6 +1272,9 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
beginMarkText(w,*c,c->selected); return 0;
|
||||
}
|
||||
if ((GetKeyState(VK_CONTROL)&0x8000) && wp=='C') { copy(w,*c); return 0; }
|
||||
if ((GetKeyState(VK_CONTROL)&0x8000) && wp=='S') {
|
||||
save(w,*c,(GetKeyState(VK_SHIFT)&0x8000)!=0); SetFocus(w); return 0;
|
||||
}
|
||||
if ((GetKeyState(VK_CONTROL)&0x8000) && (wp=='Z' || wp=='Y'))
|
||||
SendMessageW(w,WM_COMMAND,wp=='Y'||(GetKeyState(VK_SHIFT)&0x8000)?18:12,0);
|
||||
if ((GetKeyState(VK_CONTROL)&0x8000) && wp=='D' && c->selected>=0)
|
||||
@@ -1094,6 +1290,8 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
if(wp=='R') SendMessageW(w,WM_COMMAND,23,0);
|
||||
if(wp=='O') SendMessageW(w,WM_COMMAND,24,0);
|
||||
if(wp=='E') SendMessageW(w,WM_COMMAND,25,0);
|
||||
if(wp=='G') SendMessageW(w,WM_COMMAND,36,0);
|
||||
if(wp=='B') SendMessageW(w,WM_COMMAND,37,0);
|
||||
}
|
||||
if (wp=='N' || wp=='A') SendMessageW(w,WM_COMMAND,wp=='N'?10:11,0);
|
||||
if(wp=='H') SendMessageW(w,WM_COMMAND,19,0);
|
||||
@@ -1118,7 +1316,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
case WM_LBUTTONDBLCLK:
|
||||
if(c->editing && !c->dragging) {
|
||||
POINT p{GET_X_LPARAM(lp),GET_Y_LPARAM(lp)};
|
||||
if(PtInRect(&c->view,p)) {
|
||||
if(PtInRect(&c->view,p) && PtInRect(&c->canvas,p)) {
|
||||
auto [index,part]=markAt(*c,imagePoint(*c,p));
|
||||
if(index>=0 && (c->marks[index].isText() || c->marks[index].isLine())) {
|
||||
c->selected=index; beginMarkText(w,*c,index);
|
||||
@@ -1128,8 +1326,10 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
return 0;
|
||||
case WM_LBUTTONDOWN:
|
||||
{
|
||||
if(c->panning) return 0;
|
||||
finishInlineText(w,*c,true);
|
||||
POINT p{GET_X_LPARAM(lp),GET_Y_LPARAM(lp)};
|
||||
if(c->editing && !PtInRect(&c->canvas,p)) return 0;
|
||||
auto hitPart=c->editing?markAt(*c,imagePoint(*c,p,false)).second:MarkPart::None;
|
||||
bool thickness=hitPart==MarkPart::Thickness || hitPart==MarkPart::Rotation;
|
||||
if (c->editing && !PtInRect(&c->view,p) && !thickness)
|
||||
@@ -1172,6 +1372,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
c->stroke.freehand=c->freehand; c->stroke.style=c->lineStyle; c->stroke.ends=c->freehandEnds;
|
||||
c->stroke.points.push_back(p);
|
||||
c->strokeConstrained=false;
|
||||
c->strokeUsedConstraint=(wp&MK_SHIFT)!=0;
|
||||
}
|
||||
c->dragging=true; SetCapture(w);
|
||||
}
|
||||
@@ -1179,8 +1380,17 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
}
|
||||
case WM_MOUSEWHEEL:
|
||||
{
|
||||
if(!c->editing || c->dragging) return 0;
|
||||
if(!c->editing || c->dragging || c->panning) return 0;
|
||||
POINT p{GET_X_LPARAM(lp),GET_Y_LPARAM(lp)}; ScreenToClient(w,&p);
|
||||
if(!PtInRect(&c->canvas,p)) return 0;
|
||||
if(GET_KEYSTATE_WPARAM(wp)&MK_CONTROL) {
|
||||
c->wheelDelta=0; c->wheelTarget=-1;
|
||||
c->zoomWheelDelta+=GET_WHEEL_DELTA_WPARAM(wp);
|
||||
int steps=c->zoomWheelDelta/WHEEL_DELTA; c->zoomWheelDelta%=WHEEL_DELTA;
|
||||
if(steps) zoomCanvas(w,*c,std::pow(1.25,steps),p);
|
||||
return 0;
|
||||
}
|
||||
c->zoomWheelDelta=0;
|
||||
if(!PtInRect(&c->view,p)) return 0;
|
||||
auto [index,part]=markAt(*c,imagePoint(*c,p));
|
||||
if(index<0) { c->wheelDelta=0; c->wheelTarget=-1; return 0; }
|
||||
@@ -1200,8 +1410,27 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
case WM_MBUTTONDOWN:
|
||||
{
|
||||
POINT p{GET_X_LPARAM(lp),GET_Y_LPARAM(lp)};
|
||||
if(c->editing && !c->dragging && PtInRect(&c->canvas,p)) {
|
||||
c->panning=true; c->panOrigin=p; SetCapture(w); SetCursor(LoadCursorW(nullptr,IDC_SIZEALL));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
case WM_MBUTTONUP:
|
||||
if(c->panning) { c->panning=false; ReleaseCapture(); captureCursor(w,*c); }
|
||||
return 0;
|
||||
case WM_MOUSEMOVE:
|
||||
case WM_LBUTTONUP:
|
||||
if(c->panning) {
|
||||
if(msg==WM_MOUSEMOVE) {
|
||||
POINT p{GET_X_LPARAM(lp),GET_Y_LPARAM(lp)};
|
||||
c->panX+=p.x-c->panOrigin.x; c->panY+=p.y-c->panOrigin.y; c->panOrigin=p;
|
||||
layout(w,*c); positionInlineText(*c); SetCursor(LoadCursorW(nullptr,IDC_SIZEALL));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (c->dragging)
|
||||
{
|
||||
POINT p{GET_X_LPARAM(lp),GET_Y_LPARAM(lp)};
|
||||
@@ -1210,6 +1439,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
c->end=constrainScreenshotShape(c->shape,c->start,c->end,c->image->width,c->image->height);
|
||||
if(c->editing && (c->highlight || c->freehand) && c->part==MarkPart::None) {
|
||||
bool constrained=c->freehand && ((wp&MK_SHIFT) || (GetKeyState(VK_SHIFT)&0x8000));
|
||||
c->strokeUsedConstraint=c->strokeUsedConstraint || constrained;
|
||||
if(constrained) {
|
||||
if(!c->strokeConstrained) {
|
||||
c->strokeAnchor=c->stroke.points.size()-1;
|
||||
@@ -1303,6 +1533,23 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
linkScreenshotEndpoint(mark,false,c->end,c->marks,6);
|
||||
resolveScreenshotLinks(c->marks);
|
||||
}
|
||||
if(c->freehand && !c->strokeUsedConstraint &&
|
||||
!c->marks.back().fromNumber && !c->marks.back().toNumber &&
|
||||
!c->marks.back().fromLink.target && !c->marks.back().toLink.target) {
|
||||
ScreenshotShape recognized{};
|
||||
auto improved=improveScreenshotStroke(c->marks.back().points,&recognized);
|
||||
if(!improved.empty()) {
|
||||
c->remember(c->marks);
|
||||
auto &mark=c->marks.back();
|
||||
mark.points=std::move(improved);
|
||||
mark.from=mark.points.front(); mark.to=mark.points.back();
|
||||
if(recognized!=ScreenshotShape::None) {
|
||||
mark.shape=recognized; mark.freehand=false; mark.ends=LineEnds::None;
|
||||
auto bounds=markBounds(mark);
|
||||
mark.from={bounds.left,bounds.top}; mark.to={bounds.right,bounds.bottom};
|
||||
}
|
||||
}
|
||||
}
|
||||
c->selected=int(c->marks.size())-1; c->selectedLabel=false;
|
||||
}
|
||||
else if (std::hypot(double(c->end.x-c->start.x),double(c->end.y-c->start.y))>=4)
|
||||
@@ -1313,6 +1560,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().shape=c->shape;
|
||||
auto &mark=c->marks.back();
|
||||
if(mark.shape==ScreenshotShape::Triangle || mark.shape==ScreenshotShape::Trapezoid) mark.points=shapeOutline(mark);
|
||||
linkScreenshotEndpoint(mark,true,c->start,c->marks,6);
|
||||
linkScreenshotEndpoint(mark,false,c->end,c->marks,6);
|
||||
resolveScreenshotLinks(c->marks);
|
||||
@@ -1325,6 +1573,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
captureCursor(w,*c);
|
||||
return 0;
|
||||
case WM_CAPTURECHANGED:
|
||||
c->panning=false;
|
||||
if(c->dragging && c->part!=MarkPart::None)
|
||||
{
|
||||
c->marks=std::move(c->beforeDrag); c->part=MarkPart::None;
|
||||
@@ -1334,7 +1583,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
if (c->editing)
|
||||
{
|
||||
auto limits = reinterpret_cast<MINMAXINFO *>(lp);
|
||||
limits->ptMinTrackSize = {MulDiv(650,GetDpiForWindow(w),96),MulDiv(240,GetDpiForWindow(w),96)};
|
||||
limits->ptMinTrackSize = {MulDiv(730,GetDpiForWindow(w),96),MulDiv(240,GetDpiForWindow(w),96)};
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
@@ -1384,7 +1633,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
auto previous=SelectObject(buffer,divider);
|
||||
MoveToEx(buffer,0,c->panelHeight-1,nullptr); LineTo(buffer,client.right,c->panelHeight-1);
|
||||
int dpi=GetDpiForWindow(w);
|
||||
for(int x:{368,452}) {
|
||||
for(int x:{440,524}) {
|
||||
MoveToEx(buffer,MulDiv(x,dpi,96),MulDiv(13,dpi,96),nullptr);
|
||||
LineTo(buffer,MulDiv(x,dpi,96),MulDiv(31,dpi,96));
|
||||
}
|
||||
@@ -1402,9 +1651,12 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
}
|
||||
drawMarks(rendered.dc,visibleMarks,c->dragging && c->part==MarkPart::None?((c->highlight || c->freehand)?&c->stroke:&preview):nullptr);
|
||||
SetStretchBltMode(buffer,HALFTONE); SetBrushOrgEx(buffer,0,0,nullptr);
|
||||
int savedCanvas=SaveDC(buffer);
|
||||
IntersectClipRect(buffer,c->canvas.left,c->canvas.top,c->canvas.right,c->canvas.bottom);
|
||||
StretchBlt(buffer,c->view.left,c->view.top,c->view.right-c->view.left,c->view.bottom-c->view.top,
|
||||
rendered.dc,0,0,rendered.width,rendered.height,SRCCOPY);
|
||||
drawHandles(buffer,*c);
|
||||
RestoreDC(buffer,savedCanvas);
|
||||
BitBlt(dc,0,0,width,height,buffer,0,0,SRCCOPY);
|
||||
}
|
||||
}
|
||||
@@ -1449,6 +1701,11 @@ POINT screenshotTextRotationHandle(const ScreenshotMark &m,double clearance)
|
||||
auto r=plainTextBounds(m);
|
||||
return rotateTextPoint(m,{(r.left+r.right)/2,r.top-LONG(std::lround(clearance))});
|
||||
}
|
||||
POINT screenshotShapeRotationHandle(const ScreenshotMark &m,double clearance)
|
||||
{
|
||||
auto r=region(m.from,m.to);
|
||||
return rotateShapePoint(m,{(r.left+r.right)/2,r.top-LONG(std::lround(clearance))});
|
||||
}
|
||||
ScreenshotMark screenshotArrowLabel(const ScreenshotMark &m)
|
||||
{
|
||||
ScreenshotMark label{{0,0},{0,0},0};
|
||||
@@ -1528,23 +1785,23 @@ void assignMarkIds(std::vector<ScreenshotMark> &marks)
|
||||
}
|
||||
POINT objectAnchor(const ScreenshotMark &m,const ScreenshotLink &link)
|
||||
{
|
||||
if(m.isText() || m.shape!=ScreenshotShape::None) {
|
||||
if(m.isText() || (m.shape!=ScreenshotShape::None && m.points.empty())) {
|
||||
auto r=m.isText()?plainTextBounds(m):region(m.from,m.to);
|
||||
POINT p{LONG(std::lround(r.left+(r.right-r.left)*link.u)),LONG(std::lround(r.top+(r.bottom-r.top)*link.v))};
|
||||
return m.isText()?rotateTextPoint(m,p):p;
|
||||
return m.isText()?rotateTextPoint(m,p):rotateShapePoint(m,p);
|
||||
}
|
||||
size_t segments=m.points.size()>1?m.points.size()-1:1;
|
||||
double along=std::clamp(link.u,0.0,1.0)*segments;
|
||||
size_t i=std::min(size_t(along),segments-1); double t=along-i;
|
||||
POINT a=m.points.size()>1?m.points[i]:m.from,b=m.points.size()>1?m.points[i+1]:m.to;
|
||||
return {LONG(std::lround(a.x+(b.x-a.x)*t)),LONG(std::lround(a.y+(b.y-a.y)*t))};
|
||||
return rotateShapePoint(m,{LONG(std::lround(a.x+(b.x-a.x)*t)),LONG(std::lround(a.y+(b.y-a.y)*t))});
|
||||
}
|
||||
bool findObjectAnchor(const ScreenshotMark &m,POINT drop,double tolerance,ScreenshotLink &link)
|
||||
{
|
||||
link={m.id,0,0};
|
||||
if(m.isText() || m.shape!=ScreenshotShape::None) {
|
||||
if(m.isText() || (m.shape!=ScreenshotShape::None && m.points.empty())) {
|
||||
auto r=m.isText()?plainTextBounds(m):region(m.from,m.to);
|
||||
POINT p=m.isText()?rotateTextPoint(m,drop,-1):drop;
|
||||
POINT p=m.isText()?rotateTextPoint(m,drop,-1):rotateShapePoint(m,drop,-1);
|
||||
double w=std::max(1L,r.right-r.left),h=std::max(1L,r.bottom-r.top);
|
||||
double x=(p.x-r.left)/w,y=(p.y-r.top)/h;
|
||||
bool inside=x>=0 && x<=1 && y>=0 && y<=1;
|
||||
@@ -1564,6 +1821,7 @@ bool findObjectAnchor(const ScreenshotMark &m,POINT drop,double tolerance,Screen
|
||||
auto anchor=objectAnchor(m,link);
|
||||
return inside || std::hypot(double(drop.x-anchor.x),double(drop.y-anchor.y))<=tolerance;
|
||||
}
|
||||
drop=rotateShapePoint(m,drop,-1);
|
||||
double best=1e100;
|
||||
size_t segments=m.points.size()>1?m.points.size()-1:1;
|
||||
for(size_t i=0;i<segments;++i) {
|
||||
@@ -1648,6 +1906,13 @@ void resolveScreenshotLinks(std::vector<ScreenshotMark> &marks)
|
||||
}
|
||||
MarkPart hitScreenshotMark(const ScreenshotMark &m, POINT p, double tolerance)
|
||||
{
|
||||
if(m.shape==ScreenshotShape::Triangle) {
|
||||
auto vertices=shapeOutline(m);
|
||||
for(int i=0;i<3;++i)
|
||||
if(std::hypot(double(p.x-vertices[i].x),double(p.y-vertices[i].y))<=tolerance)
|
||||
return MarkPart(int(MarkPart::Vertex0)+i);
|
||||
}
|
||||
p=rotateShapePoint(m,p,-1);
|
||||
if(m.isText()) {
|
||||
auto r=plainTextBounds(m);
|
||||
auto local=rotateTextPoint(m,p,-1);
|
||||
@@ -1658,8 +1923,10 @@ MarkPart hitScreenshotMark(const ScreenshotMark &m, POINT p, double tolerance)
|
||||
auto distance=[](POINT a, POINT b) { return std::hypot(double(a.x-b.x),double(a.y-b.y)); };
|
||||
if(!m.points.empty())
|
||||
{
|
||||
if(m.freehand && distance(p,m.from)<=tolerance) return MarkPart::Start;
|
||||
if(m.freehand && distance(p,m.to)<=tolerance) return MarkPart::End;
|
||||
if(m.shape!=ScreenshotShape::Triangle) {
|
||||
if((m.freehand || m.shape!=ScreenshotShape::None) && distance(p,m.from)<=tolerance) return MarkPart::Start;
|
||||
if((m.freehand || m.shape!=ScreenshotShape::None) && distance(p,m.to)<=tolerance) return MarkPart::End;
|
||||
}
|
||||
double reach=tolerance+std::clamp(m.thickness,m.isHighlight()?4:1,m.isHighlight()?100:30)/2.0;
|
||||
POINT from=m.points.front();
|
||||
for(auto to:m.points) {
|
||||
@@ -1713,7 +1980,32 @@ ScreenshotMark dragScreenshotMark(const ScreenshotMark &original, MarkPart part,
|
||||
{
|
||||
auto m=original;
|
||||
if(position.x==origin.x && position.y==origin.y) return m;
|
||||
if(part==MarkPart::Rotation && (m.isText() || (m.isLine() && !m.label.empty()))) {
|
||||
if(part>=MarkPart::Vertex0 && part<=MarkPart::Vertex2 && m.shape==ScreenshotShape::Triangle) {
|
||||
if(m.points.empty()) { auto local=m; local.rotation=0; m.points=shapeOutline(local); }
|
||||
size_t index=size_t(int(part)-int(MarkPart::Vertex0)),fixed=(index+1)%3;
|
||||
auto fixedWorld=rotateShapePoint(m,m.points[fixed]);
|
||||
auto vertexWorld=rotateShapePoint(m,m.points[index]);
|
||||
POINT target{std::clamp(vertexWorld.x+position.x-origin.x,0L,LONG(width-1)),
|
||||
std::clamp(vertexWorld.y+position.y-origin.y,0L,LONG(height-1))};
|
||||
m.points[index]=rotateShapePoint(m,target,-1); m.points[3]=m.points[0];
|
||||
RECT bounds{m.points[0].x,m.points[0].y,m.points[0].x,m.points[0].y};
|
||||
for(size_t i=1;i<3;++i) {auto p=m.points[i]; bounds.left=std::min(bounds.left,p.x); bounds.right=std::max(bounds.right,p.x);
|
||||
bounds.top=std::min(bounds.top,p.y); bounds.bottom=std::max(bounds.bottom,p.y);}
|
||||
m.from={bounds.left,bounds.top}; m.to={bounds.right,bounds.bottom};
|
||||
auto shifted=rotateShapePoint(m,m.points[fixed]);
|
||||
LONG dx=fixedWorld.x-shifted.x,dy=fixedWorld.y-shifted.y;
|
||||
m.from.x+=dx; m.to.x+=dx; m.from.y+=dy; m.to.y+=dy;
|
||||
for(auto &p:m.points) {p.x+=dx;p.y+=dy;}
|
||||
}
|
||||
else if(part==MarkPart::Rotation && m.shape!=ScreenshotShape::None) {
|
||||
double cx=(m.from.x+m.to.x)/2.0,cy=(m.from.y+m.to.y)/2.0;
|
||||
if(std::hypot(position.x-cx,position.y-cy)<1) return m;
|
||||
double a=std::atan2(origin.y-cy,origin.x-cx),b=std::atan2(position.y-cy,position.x-cx);
|
||||
m.rotation+=(b-a)*180/3.141592653589793;
|
||||
if(snapRotation) m.rotation=std::round(m.rotation/15)*15;
|
||||
m.rotation=std::remainder(m.rotation,360);
|
||||
}
|
||||
else if(part==MarkPart::Rotation && (m.isText() || (m.isLine() && !m.label.empty()))) {
|
||||
if(position.x==origin.x && position.y==origin.y) return m;
|
||||
auto text=m.isText()?m:screenshotArrowLabel(m);
|
||||
auto r=plainTextBounds(text);
|
||||
@@ -1767,13 +2059,34 @@ ScreenshotMark dragScreenshotMark(const ScreenshotMark &original, MarkPart part,
|
||||
}
|
||||
else if(part==MarkPart::Start || part==MarkPart::End)
|
||||
{
|
||||
origin=rotateShapePoint(original,origin,-1);
|
||||
position=rotateShapePoint(original,position,-1);
|
||||
(part==MarkPart::Start?m.fromNumber:m.toNumber)=0;
|
||||
(part==MarkPart::Start?m.fromLink:m.toLink)={};
|
||||
auto &endpoint=part==MarkPart::Start?m.from:m.to;
|
||||
endpoint.x=std::clamp(endpoint.x+position.x-origin.x,0L,LONG(width-1));
|
||||
endpoint.y=std::clamp(endpoint.y+position.y-origin.y,0L,LONG(height-1));
|
||||
endpoint=constrainScreenshotShape(m.shape,part==MarkPart::Start?m.to:m.from,endpoint,width,height);
|
||||
if(m.shape!=ScreenshotShape::None && !m.points.empty()) {
|
||||
if(m.from.x==m.to.x || m.from.y==m.to.y) return original;
|
||||
// Resize the entire recognized outline, not just a pencil endpoint.
|
||||
// Keep the opposite corner fixed, including when dragging across it.
|
||||
double oldWidth=original.to.x-original.from.x,oldHeight=original.to.y-original.from.y;
|
||||
for(auto &point:m.points) {
|
||||
double u=oldWidth?(point.x-original.from.x)/oldWidth:0;
|
||||
double v=oldHeight?(point.y-original.from.y)/oldHeight:0;
|
||||
point={LONG(std::lround(m.from.x+u*(m.to.x-m.from.x))),
|
||||
LONG(std::lround(m.from.y+v*(m.to.y-m.from.y)))};
|
||||
}
|
||||
}
|
||||
if(m.freehand && !m.points.empty()) (part==MarkPart::Start?m.points.front():m.points.back())=endpoint;
|
||||
if(m.shape!=ScreenshotShape::None && m.rotation!=0) {
|
||||
auto fixedBefore=rotateShapePoint(original,part==MarkPart::Start?original.to:original.from);
|
||||
auto fixedAfter=rotateShapePoint(m,part==MarkPart::Start?m.to:m.from);
|
||||
LONG dx=fixedBefore.x-fixedAfter.x,dy=fixedBefore.y-fixedAfter.y;
|
||||
m.from.x+=dx; m.to.x+=dx; m.from.y+=dy; m.to.y+=dy;
|
||||
for(auto &p:m.points) {p.x+=dx;p.y+=dy;}
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
+4
-3
@@ -15,7 +15,7 @@ void showScreenshot(ScreenshotHost &);
|
||||
void showScreenshotEditor(ScreenshotHost &,const Clip &);
|
||||
enum class LineStyle { Solid, Dotted, Dashed, DashDot };
|
||||
enum class LineEnds { End, Start, Both, None };
|
||||
enum class ScreenshotShape { None, Rectangle, Circle, Ellipse };
|
||||
enum class ScreenshotShape { None, Rectangle, Circle, Ellipse, Triangle, Trapezoid };
|
||||
struct ScreenshotOffset { double x, y; };
|
||||
struct ScreenshotLink {
|
||||
unsigned long long target{};
|
||||
@@ -40,7 +40,7 @@ struct ScreenshotMark {
|
||||
unsigned long long id{};
|
||||
ScreenshotLink fromLink, toLink;
|
||||
bool isText() const { return !text.empty(); }
|
||||
bool isHighlight() const { return !points.empty() && !freehand; }
|
||||
bool isHighlight() const { return !points.empty() && !freehand && shape==ScreenshotShape::None; }
|
||||
bool isLine() const { return !number && !isText() && !isHighlight() && shape==ScreenshotShape::None; }
|
||||
};
|
||||
class ScreenshotHistory {
|
||||
@@ -55,7 +55,7 @@ class ScreenshotHistory {
|
||||
void resolveScreenshotLinks(std::vector<ScreenshotMark> &);
|
||||
void linkScreenshotEndpoint(ScreenshotMark &, bool start, POINT drop,
|
||||
std::vector<ScreenshotMark> &, double tolerance);
|
||||
enum class MarkPart { None, Body, Start, End, Radius, Thickness, Rotation, Label };
|
||||
enum class MarkPart { None, Body, Start, End, Radius, Thickness, Rotation, Label, Vertex0, Vertex1, Vertex2 };
|
||||
POINT screenshotThicknessHandle(const ScreenshotMark &, double clearance = 16);
|
||||
ScreenshotMark cloneScreenshotMark(const ScreenshotMark &, const std::vector<ScreenshotMark> &, int width, int height);
|
||||
int nextScreenshotNumber(const std::vector<ScreenshotMark> &);
|
||||
@@ -65,6 +65,7 @@ ScreenshotMark dragScreenshotMark(const ScreenshotMark &, MarkPart, POINT origin
|
||||
POINT constrainScreenshotShape(ScreenshotShape, POINT anchor, POINT position, int width, int height);
|
||||
RECT screenshotTextBounds(const ScreenshotMark &);
|
||||
POINT screenshotTextRotationHandle(const ScreenshotMark &, double clearance=24);
|
||||
POINT screenshotShapeRotationHandle(const ScreenshotMark &, double clearance=32);
|
||||
ScreenshotMark screenshotArrowLabel(const ScreenshotMark &);
|
||||
// Render at source resolution; never reads or writes the system clipboard.
|
||||
Clip screenshotClip(HDC source, int width, int height, const std::vector<ScreenshotMark> &marks);
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
#pragma once
|
||||
#include "screenshot.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <vector>
|
||||
|
||||
namespace ks::stroke_detail {
|
||||
struct Point { double x, y; };
|
||||
inline double distance(Point a, Point b) { return std::hypot(a.x-b.x,a.y-b.y); }
|
||||
inline double length(const std::vector<Point> &p) {
|
||||
double total=0;
|
||||
for(size_t i=1;i<p.size();++i) total+=distance(p[i-1],p[i]);
|
||||
return total;
|
||||
}
|
||||
// Arc-length sampling makes fitting independent of mouse speed and bounds its cost.
|
||||
inline std::vector<Point> sample(const std::vector<Point> &p, size_t count) {
|
||||
std::vector<Point> out{p.front()};
|
||||
double step=length(p)/(count-1), traversed=0;
|
||||
size_t segment=1;
|
||||
for(size_t i=1;i+1<count;++i) {
|
||||
double target=i*step;
|
||||
while(segment+1<p.size() && traversed+distance(p[segment-1],p[segment])<target) {
|
||||
traversed+=distance(p[segment-1],p[segment]); ++segment;
|
||||
}
|
||||
double span=distance(p[segment-1],p[segment]);
|
||||
double t=span>0?(target-traversed)/span:0;
|
||||
out.push_back({p[segment-1].x+t*(p[segment].x-p[segment-1].x),
|
||||
p[segment-1].y+t*(p[segment].y-p[segment-1].y)});
|
||||
}
|
||||
out.push_back(p.back()); return out;
|
||||
}
|
||||
inline double segmentDistance(Point p, Point a, Point b) {
|
||||
double dx=b.x-a.x,dy=b.y-a.y,den=dx*dx+dy*dy;
|
||||
double t=den?std::clamp(((p.x-a.x)*dx+(p.y-a.y)*dy)/den,0.0,1.0):0;
|
||||
return distance(p,{a.x+t*dx,a.y+t*dy});
|
||||
}
|
||||
inline double cross(Point a, Point b, Point c) { return (b.x-a.x)*(c.y-a.y)-(b.y-a.y)*(c.x-a.x); }
|
||||
}
|
||||
|
||||
namespace ks {
|
||||
// Returns a replacement path; an empty result means no visible improvement.
|
||||
// Report recognized geometry separately from a smoothed freehand path.
|
||||
inline std::vector<POINT> improveScreenshotStroke(const std::vector<POINT> &input, ScreenshotShape *shape=nullptr) {
|
||||
if(shape) *shape=ScreenshotShape::None;
|
||||
using namespace stroke_detail;
|
||||
if(input.size()<5) return {};
|
||||
std::vector<Point> raw;
|
||||
for(auto p:input) if(raw.empty() || distance(raw.back(),{double(p.x),double(p.y)})>0)
|
||||
raw.push_back({double(p.x),double(p.y)});
|
||||
double total=length(raw);
|
||||
if(raw.size()<5 || total<16) return {};
|
||||
auto points=sample(raw,128);
|
||||
double left=points[0].x,right=left,top=points[0].y,bottom=top;
|
||||
for(auto p:points) { left=std::min(left,p.x); right=std::max(right,p.x); top=std::min(top,p.y); bottom=std::max(bottom,p.y); }
|
||||
double scale=std::min(right-left,bottom-top);
|
||||
std::vector<Point> best;
|
||||
double bestScore=1e30;
|
||||
double gap=distance(raw.front(),raw.back());
|
||||
if(scale>=20 && gap<=scale*.5) {
|
||||
auto consider=[&](std::vector<Point> candidate, ScreenshotShape kind=ScreenshotShape::Rectangle) {
|
||||
bool oval=kind==ScreenshotShape::Circle || kind==ScreenshotShape::Ellipse;
|
||||
if(!oval && gap>scale*.22) return;
|
||||
double perimeter=length(candidate);
|
||||
if(total<perimeter*.82 || total>perimeter*1.25) return;
|
||||
auto probes=sample(candidate,128);
|
||||
double sum=0,maximum=0;
|
||||
auto measure=[&](const std::vector<Point> &a,const std::vector<Point> &b) {
|
||||
for(auto p:a) {
|
||||
double d=1e30;
|
||||
for(size_t i=1;i<b.size();++i) d=std::min(d,segmentDistance(p,b[i-1],b[i]));
|
||||
sum+=d*d; maximum=std::max(maximum,d);
|
||||
}
|
||||
};
|
||||
measure(points,candidate); measure(probes,points);
|
||||
double score=std::sqrt(sum/256);
|
||||
if(maximum<=std::max(3.0,scale*(oval?.22:.12)) && score<=std::max(1.5,scale*(oval?.085:.055)) && score<bestScore) {
|
||||
bestScore=score; best=std::move(candidate);
|
||||
if(shape) *shape=kind;
|
||||
}
|
||||
};
|
||||
// Fit oriented bounding boxes; testing angles also handles tilted ovals.
|
||||
for(int degrees=0;degrees<180;degrees+=5) {
|
||||
double angle=degrees*3.141592653589793/180,cs=std::cos(angle),sn=std::sin(angle);
|
||||
double x0=1e30,y0=x0,x1=-x0,y1=-x0;
|
||||
for(auto p:points) {
|
||||
double x=p.x*cs+p.y*sn,y=-p.x*sn+p.y*cs;
|
||||
x0=std::min(x0,x); x1=std::max(x1,x); y0=std::min(y0,y); y1=std::max(y1,y);
|
||||
}
|
||||
auto world=[&](double x,double y)->Point { return {x*cs-y*sn,x*sn+y*cs}; };
|
||||
consider({world(x0,y0),world(x1,y0),world(x1,y1),world(x0,y1),world(x0,y0)});
|
||||
double rx=(x1-x0)/2,ry=(y1-y0)/2;
|
||||
double cx=(x0+x1)/2,cy=(y0+y1)/2;
|
||||
std::vector<Point> local;
|
||||
for(auto p:points) local.push_back({p.x*cs+p.y*sn,-p.x*sn+p.y*cs});
|
||||
// Fit four sides to their supporting samples. Extremal bounds exaggerate
|
||||
// crooked corners and can unfairly make an oval score better than a box.
|
||||
double sides[]{x0,x1,y0,y1};
|
||||
bool supported=true;
|
||||
for(int iteration=0;iteration<6;++iteration) {
|
||||
double sums[4]{}; int counts[4]{};
|
||||
for(auto p:local) {
|
||||
double distances[]{std::abs(p.x-sides[0]),std::abs(p.x-sides[1]),
|
||||
std::abs(p.y-sides[2]),std::abs(p.y-sides[3])};
|
||||
auto side=std::min_element(distances,distances+4)-distances;
|
||||
sums[side]+=side<2?p.x:p.y; ++counts[side];
|
||||
}
|
||||
for(int side=0;side<4;++side) {
|
||||
if(counts[side]<10) { supported=false; break; }
|
||||
sides[side]=sums[side]/counts[side];
|
||||
}
|
||||
if(!supported) break;
|
||||
}
|
||||
if(supported && sides[1]-sides[0]>=16 && sides[3]-sides[2]>=16)
|
||||
consider({world(sides[0],sides[2]),world(sides[1],sides[2]),
|
||||
world(sides[1],sides[3]),world(sides[0],sides[3]),world(sides[0],sides[2])});
|
||||
// Fit the whole gesture instead of letting a single mouse wobble set the bounds.
|
||||
auto error=[&](double x,double y,double a,double b) {
|
||||
if(a<8 || b<8) return 1e30;
|
||||
double sum=0;
|
||||
for(auto p:local) {
|
||||
double u=(p.x-x)/a,v=(p.y-y)/b,r=std::hypot(u,v);
|
||||
double normal=std::hypot(u/a,v/b);
|
||||
double d=normal>1e-9?(r-1)*r/normal:std::min(a,b);
|
||||
sum+=d*d;
|
||||
}
|
||||
return sum;
|
||||
};
|
||||
double fit=error(cx,cy,rx,ry),step=std::max(rx,ry)*.08;
|
||||
for(int iteration=0;iteration<10;++iteration) {
|
||||
for(int parameter=0;parameter<4;++parameter) for(int sign:{-1,1}) {
|
||||
double values[]{cx,cy,rx,ry}; values[parameter]+=sign*step;
|
||||
double next=error(values[0],values[1],values[2],values[3]);
|
||||
if(next<fit) { fit=next; cx=values[0]; cy=values[1]; rx=values[2]; ry=values[3]; }
|
||||
}
|
||||
step*=.65;
|
||||
}
|
||||
// Require almost one complete turn, with little retracing. This admits a small
|
||||
// closing gap or overshoot but rejects C-shaped arcs, spirals and scribbles.
|
||||
double turn=0,travel=0;
|
||||
for(size_t i=1;i<local.size();++i) {
|
||||
double a=std::atan2((local[i-1].y-cy)/ry,(local[i-1].x-cx)/rx);
|
||||
double b=std::atan2((local[i].y-cy)/ry,(local[i].x-cx)/rx);
|
||||
double delta=std::remainder(b-a,2*3.141592653589793);
|
||||
turn+=delta; travel+=std::abs(delta);
|
||||
}
|
||||
if(std::abs(turn)<5.4 || std::abs(turn)>7.1 || travel-std::abs(turn)>.65) continue;
|
||||
// Near-circular gestures become true circles.
|
||||
bool circle=std::abs(rx-ry)<=std::max(rx,ry)*.12;
|
||||
if(circle) rx=ry=(rx+ry)/2;
|
||||
std::vector<Point> oval;
|
||||
for(int i=0;i<96;++i) {
|
||||
double t=i*2*3.141592653589793/96;
|
||||
oval.push_back(world(cx+rx*std::cos(t),cy+ry*std::sin(t)));
|
||||
}
|
||||
oval.push_back(oval.front()); consider(std::move(oval),circle?ScreenshotShape::Circle:ScreenshotShape::Ellipse);
|
||||
}
|
||||
auto sorted=points;
|
||||
std::sort(sorted.begin(),sorted.end(),[](Point a,Point b){return a.x<b.x || (a.x==b.x && a.y<b.y);});
|
||||
std::vector<Point> hull;
|
||||
for(auto p:sorted) {
|
||||
while(hull.size()>1 && cross(hull[hull.size()-2],hull.back(),p)<=0) hull.pop_back();
|
||||
hull.push_back(p);
|
||||
}
|
||||
size_t lower=hull.size();
|
||||
for(size_t i=sorted.size()-1;i-->0;) {
|
||||
auto p=sorted[i];
|
||||
while(hull.size()>lower && cross(hull[hull.size()-2],hull.back(),p)<=0) hull.pop_back();
|
||||
hull.push_back(p);
|
||||
}
|
||||
hull.pop_back();
|
||||
while(hull.size()>3) {
|
||||
if(hull.size()==4 && bestScore>scale*.035) {
|
||||
// Opposite edges must already be nearly parallel; retain their unequal
|
||||
// lengths and fit a shared direction instead of accepting any quadrilateral.
|
||||
for(size_t i=0;i<2;++i) {
|
||||
Point a=hull[i],b=hull[i+1],c=hull[(i+2)%4],d=hull[(i+3)%4];
|
||||
double ab=distance(a,b),cd=distance(c,d);
|
||||
if(std::min(ab,cd)<16 || std::min(ab,cd)/std::max(ab,cd)>.8) continue;
|
||||
Point u{(b.x-a.x)/ab,(b.y-a.y)/ab},v{(c.x-d.x)/cd,(c.y-d.y)/cd};
|
||||
if(u.x*v.x+u.y*v.y<.96) continue;
|
||||
double norm=std::hypot(u.x+v.x,u.y+v.y);
|
||||
Point axis{(u.x+v.x)/norm,(u.y+v.y)/norm};
|
||||
auto along=[&](Point p){return p.x*axis.x+p.y*axis.y;};
|
||||
auto across=[&](Point p){return -p.x*axis.y+p.y*axis.x;};
|
||||
double y0=(across(a)+across(b))/2,y1=(across(c)+across(d))/2;
|
||||
if(std::abs(y1-y0)<20) continue;
|
||||
auto fit=[&](Point p,double y)->Point {double x=along(p);return {x*axis.x-y*axis.y,x*axis.y+y*axis.x};};
|
||||
std::vector<Point> candidate{fit(a,y0),fit(b,y0),fit(c,y1),fit(d,y1),fit(a,y0)};
|
||||
consider(std::move(candidate),ScreenshotShape::Trapezoid);
|
||||
}
|
||||
}
|
||||
size_t remove=0; double area=1e30;
|
||||
for(size_t i=0;i<hull.size();++i) {
|
||||
double a=std::abs(cross(hull[(i+hull.size()-1)%hull.size()],hull[i],hull[(i+1)%hull.size()]));
|
||||
if(a<area) { area=a; remove=i; }
|
||||
}
|
||||
hull.erase(hull.begin()+remove);
|
||||
}
|
||||
if(hull.size()==3) { hull.push_back(hull.front()); consider(hull,ScreenshotShape::Triangle); }
|
||||
}
|
||||
if(best.empty()) {
|
||||
// A small spatial filter suppresses jitter without moving endpoints or flattening broad curves.
|
||||
best=sample(raw,size_t(std::clamp(std::ceil(total/2)+1,5.0,2048.0)));
|
||||
auto original=best;
|
||||
for(size_t i=1;i+1<best.size();++i)
|
||||
best[i]={(original[i-1].x+2*original[i].x+original[i+1].x)/4,
|
||||
(original[i-1].y+2*original[i].y+original[i+1].y)/4};
|
||||
}
|
||||
std::vector<POINT> result;
|
||||
for(auto p:best) {
|
||||
POINT q{LONG(std::lround(p.x)),LONG(std::lround(p.y))};
|
||||
if(result.empty() || result.back().x!=q.x || result.back().y!=q.y) result.push_back(q);
|
||||
}
|
||||
if(result.size()==input.size() && std::equal(result.begin(),result.end(),input.begin(),
|
||||
[](POINT a,POINT b){return a.x==b.x && a.y==b.y;})) return {};
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
#include "platform.h"
|
||||
namespace ks
|
||||
{
|
||||
inline constexpr wchar_t AppVersion[] = L"0.6.21";
|
||||
inline constexpr wchar_t AppVersion[] = L"0.6.24";
|
||||
inline constexpr UINT UpdateMessage = WM_APP + 7;
|
||||
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
|
||||
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
|
||||
|
||||
@@ -26,10 +26,17 @@ bool conversionOnly = false;
|
||||
int pasteBehavior = 0;
|
||||
int enterPresses = 0;
|
||||
int copyFailuresRemaining = 0, copyRequests = 0;
|
||||
int droppedSelectionKeys = 0;
|
||||
bool temporaryClipboardExcluded = true;
|
||||
bool clipboardAccessTimedOut = false;
|
||||
LRESULT CALLBACK observeEnter(HWND w, UINT message, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
|
||||
{
|
||||
if (message == WM_KEYDOWN && wp == VK_LEFT && droppedSelectionKeys > 0 &&
|
||||
(GetKeyState(VK_SHIFT) & 0x8000))
|
||||
{
|
||||
--droppedSelectionKeys;
|
||||
return 0;
|
||||
}
|
||||
// This bare EDIT control is our test fixture, not a full editor with
|
||||
// clipboard retries. Keep clipboard access on its owning UI thread while
|
||||
// it handles Ctrl+C/Ctrl+V, so unrelated Windows clipboard listeners cannot
|
||||
@@ -205,10 +212,12 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
break;
|
||||
case 3:
|
||||
require(text() == L"Hello World", "case conversion");
|
||||
droppedSelectionKeys = 5;
|
||||
app->transform(ks::Case);
|
||||
break;
|
||||
case 4:
|
||||
require(text() == L"HELLO WORLD", "consecutive command reselects verified previous result");
|
||||
require(droppedSelectionKeys == 0, "partial suffix selection recovered after dropped arrow keys");
|
||||
prepare(L"OLDghbdtn", false);
|
||||
record(L"ghbdtn");
|
||||
app->transform(ks::Direct + ru);
|
||||
@@ -392,6 +401,32 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
require(text() == L"привет" && diagnosticLog().size() == diagnosticStart,
|
||||
"disabled diagnostics preserves conversion and writes no records");
|
||||
ks::setLoggingEnabled(true);
|
||||
prepare(L"PuntoSwitcher ", true);
|
||||
app->transform(ks::Direct + ru);
|
||||
break;
|
||||
case 44:
|
||||
app->transform(ks::Direct + en);
|
||||
break;
|
||||
case 45:
|
||||
require(text() == L"PuntoSwitcher ", "previous conversion restores CamelCase and trailing space");
|
||||
droppedSelectionKeys = 5;
|
||||
app->transform(ks::Direct + ru);
|
||||
break;
|
||||
case 46:
|
||||
require(text() == ks::convertTo(L"PuntoSwitcher ", app->layouts, ru, en),
|
||||
"repeat conversion recovers Switcher suffix and preserves full result");
|
||||
require(droppedSelectionKeys == 0, "CamelCase suffix regression dropped five selection keys");
|
||||
require(ks::readClipboard(app->window).text() == sentinel,
|
||||
"suffix recovery restores original clipboard");
|
||||
// Derive the actual text from the physical keys, preserving an older prefix.
|
||||
prepare((L"OLD" + ks::convertTo(L"snipping ", app->layouts, ru, en)).c_str(), false);
|
||||
record(L"snipping ");
|
||||
droppedSelectionKeys = 4;
|
||||
app->transform(ks::Direct + en);
|
||||
break;
|
||||
case 47:
|
||||
require(text() == L"OLDsnipping ", "recent Cyrillic suffix recovery preserves older prefix");
|
||||
require(droppedSelectionKeys == 0, "recent suffix regression dropped four selection keys");
|
||||
finish();
|
||||
break;
|
||||
case 30:
|
||||
|
||||
+268
-1
@@ -2,10 +2,69 @@
|
||||
// Including the implementation lets this integration test drive private editor state directly.
|
||||
#include "../src/screenshot.cpp"
|
||||
#include <iostream>
|
||||
int main()
|
||||
#include "stroke_improvement.h"
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);
|
||||
try {
|
||||
testStrokeImprovement();
|
||||
if(argc>1 && std::string(argv[1])=="--save-only") {
|
||||
auto require=[](bool ok,const char *message){if(!ok) throw std::runtime_error(message);};
|
||||
auto root=std::filesystem::temp_directory_path()/(L"KSwitcher-save-"+std::to_wstring(GetCurrentProcessId()));
|
||||
std::filesystem::create_directories(root);
|
||||
struct Cleanup {std::filesystem::path root; ~Cleanup(){std::error_code ec; std::filesystem::remove_all(root,ec);}} cleanup{root};
|
||||
ks::ScreenshotHost app{GetModuleHandleW(nullptr)};
|
||||
ks::Capture c(app);
|
||||
c.image=std::make_unique<ks::Surface>(320,240);
|
||||
PatBlt(c.image->dc,0,0,320,240,WHITENESS);
|
||||
c.marks={ks::Mark{{40,40},{180,140},0}};
|
||||
auto expected=ks::decodeImage(ks::screenshotClip(c.image->dc,320,240,c.marks));
|
||||
for(size_t i=3;i<expected.bgra.size();i+=4) expected.bgra[i]=255;
|
||||
auto path=root/L"Знімок.png";
|
||||
const auto clipboard=GetClipboardSequenceNumber();
|
||||
ks::saveToFile(nullptr,c,path);
|
||||
ks::Clip encoded{{{0,L"PNG",ks::readFile(path,16*1024*1024)}}};
|
||||
auto actual=ks::decodeImage(encoded);
|
||||
require(actual.width==320 && actual.height==240 && actual.bgra==expected.bgra,
|
||||
"PNG export preserves full resolution, annotations and opaque pixels");
|
||||
require(c.savedPath==path && c.copied,"successful save records destination and exported state");
|
||||
require(GetClipboardSequenceNumber()==clipboard,"saving does not change clipboard");
|
||||
c.remember(c.marks); c.marks.clear();
|
||||
require(!c.copied,"editing after save marks changes unexported");
|
||||
ks::save(nullptr,c);
|
||||
actual=ks::decodeImage(ks::Clip{{{0,L"PNG",ks::readFile(path,16*1024*1024)}}});
|
||||
require(actual.bgra!=expected.bgra && c.copied,"Save overwrites remembered path with current edits without a dialog");
|
||||
c.copied=false;
|
||||
ks::save(nullptr,c,true,[](LPOPENFILENAMEW dialog)->BOOL {
|
||||
if(!(dialog->Flags&OFN_OVERWRITEPROMPT) || std::wstring(dialog->lpstrDefExt)!=L"png" ||
|
||||
!std::wstring(dialog->lpstrFile).ends_with(L"Знімок.png"))
|
||||
throw std::runtime_error("Save As dialog must preserve filename, PNG extension and overwrite confirmation");
|
||||
return FALSE;
|
||||
});
|
||||
require(!c.copied && c.savedPath==path,"cancelled Save As preserves dirty state and remembered path");
|
||||
auto another=root/L"Another.png";
|
||||
ks::save(nullptr,c,true,[](LPOPENFILENAMEW dialog)->BOOL {
|
||||
auto destination=std::filesystem::path(dialog->lpstrFile).parent_path()/L"Another.png";
|
||||
wcscpy_s(dialog->lpstrFile,dialog->nMaxFile,destination.c_str());
|
||||
return TRUE;
|
||||
});
|
||||
require(c.savedPath==another && std::filesystem::exists(path),"new destination preserves previous file");
|
||||
c.copied=false;
|
||||
bool failed=false;
|
||||
try {ks::saveToFile(nullptr,c,root/L"missing"/L"fail.png");} catch(const std::exception &){failed=true;}
|
||||
require(failed && !c.copied && c.savedPath==another,"failed save preserves dirty state and previous destination");
|
||||
require(std::distance(std::filesystem::directory_iterator(root),std::filesystem::directory_iterator{})==2,
|
||||
"saving leaves no temporary files");
|
||||
bool invalid=false;
|
||||
try {ks::save(nullptr,c,true,[](LPOPENFILENAMEW dialog)->BOOL {
|
||||
auto destination=std::filesystem::path(dialog->lpstrFile).parent_path()/L"Wrong.jpg";
|
||||
wcscpy_s(dialog->lpstrFile,dialog->nMaxFile,destination.c_str()); return TRUE;
|
||||
});} catch(const std::exception &){invalid=true;}
|
||||
require(invalid && !std::filesystem::exists(root/L"Wrong.jpg"),"PNG export rejects a misleading file extension");
|
||||
CoUninitialize();
|
||||
std::cout<<"Passed screenshot PNG save regressions without opening windows\n";
|
||||
return 0;
|
||||
}
|
||||
INITCOMMONCONTROLSEX controls{sizeof(controls),ICC_STANDARD_CLASSES}; InitCommonControlsEx(&controls);
|
||||
ks::ScreenshotHost app{GetModuleHandleW(nullptr)};
|
||||
app.window=CreateWindowW(L"STATIC",L"",0,0,0,0,0,HWND_MESSAGE,nullptr,app.instance,nullptr);
|
||||
@@ -24,6 +83,169 @@ int main()
|
||||
require(c.pencilCursor!=nullptr,"pencil drawing cursor loads");
|
||||
require(c.freehand && !c.pointer && ks::toolCursor(c)==c.pencilCursor,
|
||||
"new screenshot editor starts with the pencil tool");
|
||||
{
|
||||
require(GetDlgItem(window,36)!=nullptr,"triangle has a dedicated toolbar button");
|
||||
SendMessageW(window,WM_KEYDOWN,'G',0);
|
||||
require(c.shape==ks::ScreenshotShape::Triangle && c.arrow && !c.freehand,"G selects triangle drawing tool");
|
||||
auto mouse=[&](UINT message,POINT p) {
|
||||
int x=c.view.left+MulDiv(p.x,c.view.right-c.view.left,c.image->width);
|
||||
int y=c.view.top+MulDiv(p.y,c.view.bottom-c.view.top,c.image->height);
|
||||
SendMessageW(window,message,message==WM_LBUTTONUP?0:MK_LBUTTON,MAKELPARAM(x,y));
|
||||
};
|
||||
mouse(WM_LBUTTONDOWN,{80,80}); mouse(WM_LBUTTONUP,{220,180});
|
||||
require(c.marks.size()==1 && c.marks[0].shape==ks::ScreenshotShape::Triangle && c.marks[0].points.size()==4,
|
||||
"triangle tool creates a closed three-vertex shape");
|
||||
auto triangle=c.marks[0];
|
||||
for(double angle:{0.0,37.0,90.0,-125.0}) for(int i=0;i<3;++i) {
|
||||
auto original=triangle; original.rotation=angle;
|
||||
auto vertices=ks::shapeOutline(original);
|
||||
auto part=ks::MarkPart(int(ks::MarkPart::Vertex0)+i);
|
||||
require(ks::hitScreenshotMark(original,vertices[i],4)==part,"each rotated triangle vertex has its own handle");
|
||||
POINT target{vertices[i].x+13,vertices[i].y-9};
|
||||
auto changed=ks::dragScreenshotMark(original,part,vertices[i],target,320,240);
|
||||
auto moved=ks::shapeOutline(changed);
|
||||
for(int j=0;j<3;++j) {
|
||||
auto expected=j==i?target:vertices[j];
|
||||
require(std::abs(moved[j].x-expected.x)<=2 && std::abs(moved[j].y-expected.y)<=2,
|
||||
"dragging one vertex preserves both other world vertices after rotation");
|
||||
}
|
||||
require(changed.points.front().x==changed.points.back().x && changed.points.front().y==changed.points.back().y,
|
||||
"triangle remains closed after editing any vertex");
|
||||
}
|
||||
auto vertices=ks::shapeOutline(triangle);
|
||||
mouse(WM_LBUTTONDOWN,vertices[0]);
|
||||
require(c.part==ks::MarkPart::Vertex0,"mouse selects apex vertex");
|
||||
mouse(WM_LBUTTONUP,{vertices[0].x+20,vertices[0].y+10});
|
||||
require(c.marks[0].points[0].x==triangle.points[0].x+20 && c.marks[0].points[1].x==triangle.points[1].x,
|
||||
"mouse vertex drag changes only requested corner");
|
||||
SendMessageW(window,WM_COMMAND,12,0);
|
||||
require(c.marks[0].points[0].x==triangle.points[0].x,"undo restores triangle vertex");
|
||||
SendMessageW(window,WM_COMMAND,18,0);
|
||||
require(c.marks[0].points[0].x==triangle.points[0].x+20,"redo reapplies triangle vertex edit");
|
||||
c.marks.clear(); c.selected=-1; c.history=ks::ScreenshotHistory{};
|
||||
require(GetDlgItem(window,37)!=nullptr,"trapezoid has a dedicated toolbar button");
|
||||
SendMessageW(window,WM_KEYDOWN,'B',0);
|
||||
require(c.shape==ks::ScreenshotShape::Trapezoid && c.arrow && !c.freehand,"B selects trapezoid tool");
|
||||
mouse(WM_LBUTTONDOWN,{60,70}); mouse(WM_LBUTTONUP,{240,180});
|
||||
require(c.marks.size()==1 && c.marks[0].shape==ks::ScreenshotShape::Trapezoid && c.marks[0].points.size()==5 && !c.marks[0].isHighlight(),
|
||||
"trapezoid tool creates a closed shape with unequal parallel bases");
|
||||
auto trapezoid=c.marks[0];
|
||||
require(trapezoid.points[0].y==trapezoid.points[1].y && trapezoid.points[2].y==trapezoid.points[3].y &&
|
||||
trapezoid.points[1].x-trapezoid.points[0].x<trapezoid.points[2].x-trapezoid.points[3].x,"toolbar trapezoid has parallel unequal bases");
|
||||
mouse(WM_LBUTTONDOWN,trapezoid.to); mouse(WM_LBUTTONUP,{260,200});
|
||||
require(c.marks[0].to.x==260 && c.marks[0].points[0].y==c.marks[0].points[1].y,"trapezoid resizes as a shape");
|
||||
auto grip=ks::screenshotShapeRotationHandle(c.marks[0]);
|
||||
auto rotated=ks::dragScreenshotMark(c.marks[0],ks::MarkPart::Rotation,grip,{280,135},320,240);
|
||||
require(rotated.rotation!=0 && rotated.shape==ks::ScreenshotShape::Trapezoid,"trapezoid supports shape rotation");
|
||||
SendMessageW(window,WM_COMMAND,12,0); require(c.marks[0].to.x==240,"undo trapezoid resize");
|
||||
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");
|
||||
c.marks.clear(); c.selected=-1; c.history=ks::ScreenshotHistory{};
|
||||
SendMessageW(window,WM_COMMAND,21,0);
|
||||
}
|
||||
{
|
||||
for(auto kind:{ks::ScreenshotShape::Rectangle,ks::ScreenshotShape::Circle,ks::ScreenshotShape::Ellipse,ks::ScreenshotShape::Triangle})
|
||||
for(bool recognized:{false,true}) {
|
||||
if(kind==ks::ScreenshotShape::Triangle && !recognized) continue;
|
||||
ks::Mark shape{{100,90},{200,170},0}; shape.shape=kind; shape.ends=ks::LineEnds::None;
|
||||
if(kind==ks::ScreenshotShape::Circle) shape.to.y=190;
|
||||
if(recognized) shape.points=kind==ks::ScreenshotShape::Triangle?
|
||||
std::vector<POINT>{{150,90},{200,170},{100,170},{150,90}}:ks::shapeOutline(shape);
|
||||
auto grip=ks::screenshotShapeRotationHandle(shape);
|
||||
POINT center{(shape.from.x+shape.to.x)/2,(shape.from.y+shape.to.y)/2};
|
||||
POINT right{center.x+center.y-grip.y,center.y};
|
||||
auto rotated=ks::dragScreenshotMark(shape,ks::MarkPart::Rotation,grip,right,320,240);
|
||||
require(std::abs(rotated.rotation-90)<.01 && rotated.from.x==shape.from.x && rotated.to.y==shape.to.y,
|
||||
"all native and recognized shapes rotate around their center");
|
||||
auto corner=ks::rotateShapePoint(rotated,rotated.to);
|
||||
require(ks::hitScreenshotMark(rotated,corner,4)==(kind==ks::ScreenshotShape::Triangle?ks::MarkPart::Vertex1:ks::MarkPart::End),"rotated resize handles remain hittable");
|
||||
auto fixed=ks::rotateShapePoint(rotated,rotated.from);
|
||||
auto resized=ks::dragScreenshotMark(rotated,ks::MarkPart::End,corner,{corner.x-15,corner.y+20},320,240);
|
||||
auto stillFixed=ks::rotateShapePoint(resized,resized.from);
|
||||
require(std::abs(stillFixed.x-fixed.x)<=1 && std::abs(stillFixed.y-fixed.y)<=1 && resized.rotation==90,
|
||||
"resizing rotated shapes keeps the opposite world corner fixed");
|
||||
auto snapped=ks::dragScreenshotMark(shape,ks::MarkPart::Rotation,grip,{center.x+60,center.y-20},320,240,true);
|
||||
require(std::abs(std::remainder(snapped.rotation,15))<.01,"Shift snaps shape rotation to 15 degrees");
|
||||
auto cloned=ks::cloneScreenshotMark(rotated,{rotated},320,240);
|
||||
require(cloned.rotation==rotated.rotation && cloned.shape==kind,"clone preserves shape rotation");
|
||||
auto boundary=ks::shapeOutline(shape).front();
|
||||
std::vector<ks::Mark> linked{shape,{{20,20},boundary,0}};
|
||||
ks::linkScreenshotEndpoint(linked[1],false,boundary,linked,4);
|
||||
require(linked[1].toLink.target!=0,"connector attaches to shape before rotation");
|
||||
linked[0].rotation=90; ks::resolveScreenshotLinks(linked);
|
||||
auto expected=ks::rotateShapePoint(linked[0],boundary);
|
||||
require(std::abs(linked[1].to.x-expected.x)<=1 && std::abs(linked[1].to.y-expected.y)<=1,
|
||||
"connector follows rotated shape boundary");
|
||||
}
|
||||
ks::Mark shape{{100,90},{200,170},0}; shape.shape=ks::ScreenshotShape::Rectangle;
|
||||
c.marks={shape}; c.selected=0;
|
||||
auto mouse=[&](UINT message,POINT p) {
|
||||
auto x=c.view.left+MulDiv(p.x,c.view.right-c.view.left,c.image->width);
|
||||
auto y=c.view.top+MulDiv(p.y,c.view.bottom-c.view.top,c.image->height);
|
||||
SendMessageW(window,message,message==WM_LBUTTONUP?0:MK_LBUTTON,MAKELPARAM(x,y));
|
||||
};
|
||||
auto grip=ks::screenshotShapeRotationHandle(shape,double(c.unit)*c.image->width/(c.view.right-c.view.left));
|
||||
require(ks::markAt(c,grip).second==ks::MarkPart::Rotation,"shape rotation grip is distinct from thickness grip");
|
||||
mouse(WM_LBUTTONDOWN,grip); mouse(WM_LBUTTONUP,{150+130-grip.y,130});
|
||||
require(std::abs(c.marks[0].rotation-90)<1,"mouse rotation updates selected shape");
|
||||
SendMessageW(window,WM_COMMAND,12,0); require(c.marks[0].rotation==0,"undo restores original shape orientation");
|
||||
SendMessageW(window,WM_COMMAND,18,0); require(std::abs(c.marks[0].rotation-90)<1,"redo restores shape rotation");
|
||||
c.selected=0;
|
||||
ks::Surface rotatedPixels(320,240); PatBlt(rotatedPixels.dc,0,0,320,240,WHITENESS);
|
||||
auto rendered=ks::decodeImage(ks::screenshotClip(rotatedPixels.dc,320,240,c.marks));
|
||||
auto pixel=(100*320+190)*4;
|
||||
require(rendered.bgra[pixel+2]==GetRValue(shape.color) && rendered.bgra[pixel+1]==GetGValue(shape.color),
|
||||
"export renders rotated shape at its new boundary");
|
||||
POINT center{(c.canvas.left+c.canvas.right)/2,(c.canvas.top+c.canvas.bottom)/2};
|
||||
ks::zoomCanvas(window,c,2,center);
|
||||
grip=ks::screenshotShapeRotationHandle(c.marks[0],double(c.unit)*c.image->width/(c.view.right-c.view.left));
|
||||
require(ks::markAt(c,grip).second==ks::MarkPart::Rotation,"rotation grip works on zoomed canvas");
|
||||
ks::zoomCanvas(window,c,1,center,true);
|
||||
c.marks.clear(); c.selected=-1; c.history=ks::ScreenshotHistory{};
|
||||
}
|
||||
{
|
||||
BYTE savedKeys[256]{},keys[256]{}; GetKeyboardState(savedKeys);
|
||||
std::copy(savedKeys,savedKeys+256,keys); keys[VK_CONTROL]=0x80; SetKeyboardState(keys);
|
||||
auto fit=c.view;
|
||||
SendMessageW(window,WM_KEYDOWN,VK_OEM_PLUS,0);
|
||||
require(c.zoom==1.25 && c.view.right-c.view.left>fit.right-fit.left,"Ctrl+plus zooms canvas");
|
||||
SendMessageW(window,WM_KEYDOWN,VK_SUBTRACT,0);
|
||||
require(std::abs(c.zoom-1)<.001,"Ctrl+numpad minus zooms out");
|
||||
SendMessageW(window,WM_KEYDOWN,VK_ADD,0);
|
||||
SendMessageW(window,WM_KEYDOWN,VK_OEM_MINUS,0);
|
||||
require(std::abs(c.zoom-1)<.001,"both keyboard plus/minus variants work");
|
||||
POINT center{(c.canvas.left+c.canvas.right)/2,(c.canvas.top+c.canvas.bottom)/2};
|
||||
ks::zoomCanvas(window,c,4,center);
|
||||
POINT anchor{center.x+12,center.y+8},screen=anchor; ClientToScreen(window,&screen);
|
||||
auto before=ks::imagePoint(c,anchor,false); double zoom=c.zoom;
|
||||
SendMessageW(window,WM_MOUSEWHEEL,MAKEWPARAM(MK_CONTROL,60),MAKELPARAM(screen.x,screen.y));
|
||||
require(c.zoom==zoom,"partial wheel delta waits for a full step");
|
||||
SendMessageW(window,WM_MOUSEWHEEL,MAKEWPARAM(MK_CONTROL,60),MAKELPARAM(screen.x,screen.y));
|
||||
auto after=ks::imagePoint(c,anchor,false);
|
||||
require(c.zoom>zoom && std::abs(before.x-after.x)<=1 && std::abs(before.y-after.y)<=1,
|
||||
"Ctrl+wheel zoom keeps image point under cursor");
|
||||
require(!c.history.canUndo(),"zoom does not add image edits to undo history");
|
||||
auto view=c.view;
|
||||
SendMessageW(window,WM_MBUTTONDOWN,MK_MBUTTON,MAKELPARAM(center.x,center.y));
|
||||
SendMessageW(window,WM_MOUSEMOVE,MK_MBUTTON,MAKELPARAM(center.x+15,center.y+10));
|
||||
SendMessageW(window,WM_MBUTTONUP,0,MAKELPARAM(center.x+15,center.y+10));
|
||||
require(c.view.left==view.left+15 && c.view.top==view.top+10 && !c.panning,"middle drag pans enlarged canvas");
|
||||
keys[VK_CONTROL]=0; SetKeyboardState(keys);
|
||||
auto a=ks::imagePoint(c,center),b=ks::imagePoint(c,{center.x+30,center.y+20});
|
||||
SendMessageW(window,WM_LBUTTONDOWN,MK_LBUTTON,MAKELPARAM(center.x,center.y));
|
||||
SendMessageW(window,WM_LBUTTONUP,0,MAKELPARAM(center.x+30,center.y+20));
|
||||
require(c.marks.size()==1 && c.marks[0].from.x==a.x && c.marks[0].to.x==b.x && c.marks[0].to.y==b.y,
|
||||
"drawing after zoom and pan uses image coordinates");
|
||||
auto original=c.marks[0];
|
||||
keys[VK_CONTROL]=0x80; SetKeyboardState(keys);
|
||||
SendMessageW(window,WM_KEYDOWN,'0',0);
|
||||
require(c.zoom==1 && c.panX==0 && c.panY==0 && c.marks[0].from.x==original.from.x && c.marks[0].to.x==original.to.x,
|
||||
"Ctrl+0 resets view without transforming annotations");
|
||||
ks::zoomCanvas(window,c,1e6,center); require(c.zoom==16,"zoom has upper bound");
|
||||
ks::zoomCanvas(window,c,1e-6,center); require(c.zoom==.1,"zoom has lower bound");
|
||||
SendMessageW(window,WM_KEYDOWN,'0',0); SetKeyboardState(savedKeys);
|
||||
c.marks.clear(); c.selected=-1; c.history=ks::ScreenshotHistory{};
|
||||
}
|
||||
{
|
||||
BYTE savedKeys[256]{},keys[256]{}; GetKeyboardState(savedKeys);
|
||||
std::copy(savedKeys,savedKeys+256,keys); keys[VK_SHIFT]=0; SetKeyboardState(keys);
|
||||
@@ -32,6 +254,51 @@ int main()
|
||||
int y=c.view.top+MulDiv(p.y,c.view.bottom-c.view.top,c.image->height);
|
||||
SendMessageW(window,message,(message==WM_LBUTTONUP?0:MK_LBUTTON)|(shift?MK_SHIFT:0),MAKELPARAM(x,y));
|
||||
};
|
||||
mouse(WM_LBUTTONDOWN,{30,190});
|
||||
for(int x=35;x<=200;x+=5) mouse(WM_MOUSEMOVE,{x,190+(x%10?2:-2)});
|
||||
auto original=c.stroke.points;
|
||||
mouse(WM_LBUTTONUP,c.stroke.to);
|
||||
require(c.marks.size()==1,"improved stroke is one object");
|
||||
auto improved=c.marks.back().points;
|
||||
SendMessageW(window,WM_COMMAND,12,0);
|
||||
require(c.marks.size()==1 && c.marks.back().points.size()==original.size() &&
|
||||
std::equal(original.begin(),original.end(),c.marks.back().points.begin(),
|
||||
[](POINT a,POINT b){return a.x==b.x && a.y==b.y;}),"first undo restores exact raw stroke");
|
||||
SendMessageW(window,WM_COMMAND,12,0);
|
||||
require(c.marks.empty(),"second undo removes stroke");
|
||||
SendMessageW(window,WM_COMMAND,18,0); SendMessageW(window,WM_COMMAND,18,0);
|
||||
require(c.marks.size()==1 && c.marks.back().points.size()==improved.size(),"redo restores improvement");
|
||||
c.marks.clear(); c.selected=-1; c.history=ks::ScreenshotHistory{};
|
||||
mouse(WM_LBUTTONDOWN,{180,120});
|
||||
for(int i=1;i<=120;++i) {
|
||||
double a=i*2*3.141592653589793/120;
|
||||
mouse(WM_MOUSEMOVE,{LONG(std::lround(130+50*std::cos(a))),LONG(std::lround(120+50*std::sin(a)))});
|
||||
}
|
||||
mouse(WM_LBUTTONUP,c.stroke.to);
|
||||
require(c.marks.size()==1 && c.marks[0].shape==ks::ScreenshotShape::Circle &&
|
||||
!c.marks[0].freehand && !c.marks[0].isHighlight(),"recognized circle is a shape, not a pencil or highlight");
|
||||
auto circle=c.marks[0];
|
||||
require(ks::hitScreenshotMark(circle,circle.to,6)==ks::MarkPart::End,"recognized shape exposes bounding-box resize handle");
|
||||
mouse(WM_LBUTTONDOWN,circle.to);
|
||||
mouse(WM_MOUSEMOVE,{circle.to.x+30,circle.to.y+20});
|
||||
mouse(WM_LBUTTONUP,{circle.to.x+30,circle.to.y+20});
|
||||
require(c.marks[0].to.x>circle.to.x && c.marks[0].to.x-c.marks[0].from.x==c.marks[0].to.y-c.marks[0].from.y &&
|
||||
c.marks[0].points[0].x>circle.points[0].x,"dragging recognized circle handle scales the whole circle proportionally");
|
||||
SendMessageW(window,WM_COMMAND,12,0);
|
||||
require(c.marks[0].shape==ks::ScreenshotShape::Circle && c.marks[0].to.x==circle.to.x,"undo restores shape size");
|
||||
SendMessageW(window,WM_COMMAND,12,0);
|
||||
require(c.marks[0].freehand && c.marks[0].shape==ks::ScreenshotShape::None,"undo recognition restores editable pencil stroke");
|
||||
SendMessageW(window,WM_COMMAND,18,0);
|
||||
require(c.marks[0].shape==ks::ScreenshotShape::Circle,"redo restores shape type");
|
||||
for(auto kind:{ks::ScreenshotShape::Rectangle,ks::ScreenshotShape::Ellipse,ks::ScreenshotShape::Triangle}) {
|
||||
auto shape=circle; shape.shape=kind;
|
||||
auto resized=ks::dragScreenshotMark(shape,ks::MarkPart::End,shape.to,{shape.to.x+50,shape.to.y+20},320,240);
|
||||
require(resized.points[0].x!=shape.points[0].x && resized.points[24].y!=shape.points[24].y,
|
||||
"shape resize transforms all outline points in both axes");
|
||||
auto moved=ks::cloneScreenshotMark(resized,{resized},320,240);
|
||||
require(moved.shape==kind && !moved.isHighlight(),"clone preserves recognized shape type");
|
||||
}
|
||||
c.marks.clear(); c.selected=-1; c.history=ks::ScreenshotHistory{};
|
||||
mouse(WM_LBUTTONDOWN,{40,40},true);
|
||||
mouse(WM_MOUSEMOVE,{100,50},true);
|
||||
require(c.stroke.to.x==100 && c.stroke.to.y==40,"Shift constrains pencil horizontally");
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
#pragma once
|
||||
inline void testStrokeImprovement() {
|
||||
auto require=[](bool ok,const char *message){if(!ok) throw std::runtime_error(message);};
|
||||
auto path=[](std::vector<POINT> corners) {
|
||||
std::vector<POINT> p;
|
||||
for(size_t i=1;i<corners.size();++i) for(int j=0;j<25;++j) {
|
||||
double t=j/25.0;
|
||||
p.push_back({LONG(std::lround(corners[i-1].x*(1-t)+corners[i].x*t)),
|
||||
LONG(std::lround(corners[i-1].y*(1-t)+corners[i].y*t))});
|
||||
}
|
||||
p.push_back(corners.back()); return p;
|
||||
};
|
||||
for(bool tilted:{false,true}) {
|
||||
auto p=path({{40,40},{240,40},{240,140},{40,140},{40,40}});
|
||||
if(tilted) for(auto &q:p) {double x=q.x,y=q.y;q={LONG(std::lround(x*.866-y*.5+100)),LONG(std::lround(x*.5+y*.866))};}
|
||||
for(size_t i=1;i+1<p.size();++i) p[i].y+=int(i%3)-1;
|
||||
auto result=ks::improveScreenshotStroke(p);
|
||||
require(result.size()==5,"recognize noisy and tilted rectangles");
|
||||
double ax=result[1].x-result[0].x,ay=result[1].y-result[0].y;
|
||||
double bx=result[2].x-result[1].x,by=result[2].y-result[1].y;
|
||||
require(std::abs(ax*bx+ay*by)<300,"rectangle has perpendicular sides");
|
||||
}
|
||||
auto triangle=ks::improveScreenshotStroke(path({{130,30},{240,180},{30,180},{130,30}}));
|
||||
for(auto corners:std::vector<std::vector<POINT>>{
|
||||
{{435,172},{455,135},{481,76},{514,78},{567,78},{605,76},{631,103},{652,135},{681,171},{609,172},{539,174},{435,172}},
|
||||
{{531,311},{553,280},{582,235},{616,236},{672,233},{686,264},{693,287},{710,318},{640,314},{531,311}},
|
||||
{{70,40},{160,40},{220,150},{20,150},{70,40}}})
|
||||
for(double angle:{0.0,.6,2.2}) for(bool reverse:{false,true}) {
|
||||
auto p=path(corners);
|
||||
for(auto &q:p) { double x=q.x,y=q.y; q={LONG(std::lround(x*std::cos(angle)-y*std::sin(angle)+700)),
|
||||
LONG(std::lround(x*std::sin(angle)+y*std::cos(angle)+700))}; }
|
||||
if(reverse) std::reverse(p.begin(),p.end());
|
||||
ks::ScreenshotShape kind{}; auto result=ks::improveScreenshotStroke(p,&kind);
|
||||
require(kind==ks::ScreenshotShape::Trapezoid && result.size()==5,"recognize hand-drawn and tilted trapezoids");
|
||||
double ax=result[1].x-result[0].x,ay=result[1].y-result[0].y;
|
||||
double bx=result[3].x-result[2].x,by=result[3].y-result[2].y;
|
||||
require(std::abs(ax*by-ay*bx)/(std::hypot(ax,ay)*std::hypot(bx,by))<.025,"recognized trapezoid bases are parallel");
|
||||
}
|
||||
// Approximate the reported skewed square, including its small closing gap.
|
||||
auto unevenBox=path({{662,297},{663,213},{659,199},{659,181},{691,181},{718,189},
|
||||
{753,195},{786,195},{775,217},{771,249},{771,289},{766,313},
|
||||
{745,311},{716,304},{689,300},{671,300}});
|
||||
for(double factor:{.5,1.0,2.5}) for(bool reverse:{false,true}) {
|
||||
auto p=unevenBox;
|
||||
for(auto &q:p) {q.x=LONG(std::lround((q.x-650)*factor+50));q.y=LONG(std::lround((q.y-170)*factor+50));}
|
||||
if(reverse) std::reverse(p.begin(),p.end());
|
||||
ks::ScreenshotShape kind{};
|
||||
auto result=ks::improveScreenshotStroke(p,&kind);
|
||||
require(kind==ks::ScreenshotShape::Rectangle && result.size()==5,"uneven square must not become a circle");
|
||||
}
|
||||
require(triangle.size()==4,"recognize triangle");
|
||||
for(double ry:{50.0,90.0}) {
|
||||
std::vector<POINT> p;
|
||||
for(int i=0;i<=120;++i) {double a=i*2*3.141592653589793/120;
|
||||
p.push_back({LONG(std::lround(150+90*std::cos(a))),LONG(std::lround(150+ry*std::sin(a)))});}
|
||||
auto result=ks::improveScreenshotStroke(p);
|
||||
require(result.size()==97 && result.front().x==result.back().x && result.front().y==result.back().y,
|
||||
"recognize ellipse and circle as closed outlines");
|
||||
}
|
||||
auto open=path({{20,20},{200,20},{200,150},{60,150}});
|
||||
// Hand-traced approximations of the uneven loops in the reported screenshot.
|
||||
for(auto corners:std::vector<std::vector<POINT>>{
|
||||
{{424,408},{449,407},{474,418},{487,439},{488,461},{475,485},{453,488},{432,479},{412,458},{410,435},{416,418},{431,409}},
|
||||
{{352,474},{373,482},{389,501},{392,518},{378,529},{351,531},{331,519},{324,504},{326,486},{339,477},{352,474}},
|
||||
{{246,236},{221,242},{192,256},{166,271},{151,289},{154,303},{172,316},{195,315},{226,305},{246,291},{250,272},{252,252},{246,236}}}) {
|
||||
auto result=ks::improveScreenshotStroke(path(corners));
|
||||
require(result.size()==97,"recognize uneven hand-drawn loops from screenshot");
|
||||
}
|
||||
for(double size:{35.0,100.0,300.0}) for(double aspect:{.5,1.0}) for(int direction:{-1,1}) {
|
||||
std::vector<POINT> p;
|
||||
for(int i=0;i<=120;++i) {
|
||||
double a=direction*(.3+i*5.85/120),r=1+.07*std::sin(3*a)+.035*std::cos(7*a);
|
||||
double x=size*r*std::cos(a),y=size*aspect*r*std::sin(a);
|
||||
p.push_back({LONG(std::lround(500+x*.866-y*.5)),LONG(std::lround(500+x*.5+y*.866))});
|
||||
}
|
||||
require(ks::improveScreenshotStroke(p).size()==97,"recognize wobbly tilted ovals with closing gaps at different sizes");
|
||||
}
|
||||
for(double turns:{.72,1.8}) {
|
||||
std::vector<POINT> p;
|
||||
for(int i=0;i<=120;++i) {double a=i*turns*2*3.141592653589793/120;
|
||||
p.push_back({LONG(std::lround(150+90*std::cos(a))),LONG(std::lround(150+70*std::sin(a)))});}
|
||||
auto result=ks::improveScreenshotStroke(p);
|
||||
require(result.size()!=97,"do not close broad arcs or repeated loops into ovals");
|
||||
}
|
||||
auto smoothed=ks::improveScreenshotStroke(open);
|
||||
require(smoothed.size()>5 && smoothed.front().x==20 && smoothed.back().x==60,"open outline stays open");
|
||||
auto scribble=path({{20,20},{200,150},{200,20},{20,150},{20,20}});
|
||||
require(ks::improveScreenshotStroke(scribble).size()>97,"crossed scribble is not a shape");
|
||||
require(ks::improveScreenshotStroke({{1,1},{1,1},{1,1},{1,1},{1,1}}).empty(),"stationary stroke unchanged");
|
||||
std::vector<POINT> jitter;
|
||||
for(int i=0;i<=100;++i) jitter.push_back({20+i*2,100+(i%2?2:-2)});
|
||||
auto smooth=ks::improveScreenshotStroke(jitter);
|
||||
double deviation=0; for(auto p:smooth) deviation+=std::abs(p.y-100);
|
||||
require(deviation/smooth.size()<1.5 && smooth.front().y==jitter.front().y && smooth.back().y==jitter.back().y,
|
||||
"smoothing reduces jitter and preserves endpoints");
|
||||
}
|
||||
@@ -192,6 +192,26 @@ int main(int argc, char **argv)
|
||||
require(ks::convert(L"hello привет", en, ru) == L"руддщ привет", "mixed text preserves target words");
|
||||
require(ks::convert(L"htdm.dftim", en, ru) == L"ревьюваешь", "punctuation in words");
|
||||
std::vector<ks::Layout> layouts{en, ru, uk};
|
||||
ks::Run suffixRun;
|
||||
suffixRun.keys = L"snipping ";
|
||||
require(suffixRun.matchSuffix(L"зштп ", layouts, en) >= 0,
|
||||
"recent Cyrillic suffix matches physical keys across layouts");
|
||||
require(suffixRun.matchSuffix(L"ping ", layouts, en) == 0,
|
||||
"recent English suffix matches physical keys");
|
||||
require(suffixRun.matchSuffix(L"other", layouts, en) < 0 &&
|
||||
suffixRun.matchSuffix(L"", layouts, en) < 0 &&
|
||||
suffixRun.matchSuffix(L"snipping ", layouts, en) < 0,
|
||||
"suffix recovery rejects unrelated, empty and complete ranges");
|
||||
suffixRun.overflow = true;
|
||||
require(suffixRun.matchSuffix(L"зштп ", layouts, en) < 0,
|
||||
"suffix recovery rejects overflowed tracking");
|
||||
suffixRun.overflow = false;
|
||||
suffixRun.keys = L"LinkedIn ";
|
||||
require(suffixRun.matchSuffix(L"лувШт ", layouts, en) >= 0,
|
||||
"ChatGPT partial CamelCase selection matches tracked physical suffix");
|
||||
require(suffixRun.match(L"ДштлувШт ", layouts, en) >= 0 &&
|
||||
suffixRun.match(L"лувШт ", layouts, en) < 0,
|
||||
"CamelCase conversion still requires the complete tracked text after recovery");
|
||||
ks::AutoCorrector corrector;
|
||||
ks::Settings autoSettings;
|
||||
require(!autoSettings.autoCorrect, "auto-correction defaults off");
|
||||
@@ -291,6 +311,14 @@ int main(int argc, char **argv)
|
||||
options.autoLanguages = {L"en", L"uk", L"ru"};
|
||||
options.cycle = {L"en", L"uk", L"ru"};
|
||||
supplemental.configure(L"dictionaries", options.autoLanguages);
|
||||
auto current = supplemental.suggest(L"Gjnjxys ", 0, layouts, options);
|
||||
require(current && current->text == L"Поточні " && current->target == 2,
|
||||
"reported title-case Ukrainian word corrects with all three dictionaries enabled");
|
||||
current = supplemental.suggest(L"Поточны ", 1, layouts, options);
|
||||
require(current && current->text == L"Поточні " && current->target == 2,
|
||||
"reported Ukrainian word corrects from Russian layout");
|
||||
require(!supplemental.suggest(L"Поточні ", 2, layouts, options),
|
||||
"correct Ukrainian word remains unchanged");
|
||||
for (auto [typed, expected] : {std::pair{L"ntcn ", L"тест "}, std::pair{L"vfkj ", L"мало "}})
|
||||
{
|
||||
auto shared = supplemental.suggest(typed, 0, layouts, options);
|
||||
@@ -307,6 +335,8 @@ int main(int argc, char **argv)
|
||||
require(!supplemental.suggest(L"релиз ", 1, layouts, options),
|
||||
"valid Russian release word stays unchanged");
|
||||
options.autoMaxRank = 50000;
|
||||
require(!supplemental.suggest(L"Gjnjxys ", 0, layouts, options),
|
||||
"supplemental current word respects explicit frequency limit");
|
||||
require(!supplemental.suggest(L"релыз ", 1, layouts, options),
|
||||
"supplemental vocabulary respects explicit frequency limit");
|
||||
options.autoWords[L"uk"] = {L"релізик"};
|
||||
|
||||
@@ -4,7 +4,7 @@ import urllib.request, hashlib, json
|
||||
ROOT=Path(__file__).resolve().parents[1]/'dictionaries'
|
||||
REV='525f9b560de45753a5ea01069454e72e9aa541c6'
|
||||
# Supplemental vocabulary has no corpus frequency: rank after the 50k source list.
|
||||
SUPPLEMENTAL={'uk': {'реліз': 50001}}
|
||||
SUPPLEMENTAL={'uk': {'реліз': 50001, 'поточні': 50001}}
|
||||
ROOT.mkdir(exist_ok=True)
|
||||
manifest={}
|
||||
for lang in ('en','uk','ru'):
|
||||
|
||||
Reference in New Issue
Block a user