Add configurable offline layout correction and latest Windows release
This commit is contained in:
+6
-2
@@ -5,10 +5,10 @@ if(NOT WIN32)
|
||||
endif()
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
add_library(kscore src/core.cpp src/platform.cpp src/autostart.cpp src/storage.cpp src/ocr.cpp src/richtext.cpp)
|
||||
add_library(kscore src/core.cpp src/platform.cpp src/autostart.cpp src/autocorrect.cpp src/storage.cpp src/ocr.cpp src/richtext.cpp)
|
||||
target_include_directories(kscore PUBLIC src PRIVATE vendor)
|
||||
target_compile_definitions(kscore PUBLIC UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX _WIN32_WINNT=0x0A00)
|
||||
target_link_libraries(kscore PUBLIC user32 shell32 advapi32 crypt32 imm32 comctl32 gdi32 ole32 oleaut32 taskschd comdlg32 uuid windowscodecs runtimeobject)
|
||||
target_link_libraries(kscore PUBLIC user32 shell32 advapi32 crypt32 imm32 comctl32 gdi32 ole32 oleaut32 taskschd comdlg32 uuid windowscodecs runtimeobject uiautomationcore)
|
||||
add_executable(KSwitcher WIN32 src/main.cpp src/app.cpp src/ui.cpp resources/app.rc)
|
||||
target_link_libraries(KSwitcher PRIVATE kscore)
|
||||
add_executable(KSwitcherTests tests/tests.cpp)
|
||||
@@ -29,5 +29,9 @@ else()
|
||||
endif()
|
||||
enable_testing()
|
||||
add_test(NAME native-regressions COMMAND KSwitcherTests)
|
||||
set_tests_properties(native-regressions PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}")
|
||||
install(TARGETS KSwitcher RUNTIME DESTINATION .)
|
||||
install(FILES LICENSE vendor/json-LICENSE.txt DESTINATION .)
|
||||
|
||||
add_custom_command(TARGET KSwitcher POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_CURRENT_SOURCE_DIR}/dictionaries" "$<TARGET_FILE_DIR:KSwitcher>/dictionaries")
|
||||
install(DIRECTORY dictionaries DESTINATION .)
|
||||
|
||||
@@ -117,6 +117,58 @@ Alternatively open this directory as a CMake project in Visual Studio with the C
|
||||
and Windows SDK installed, or use CMake with a Windows GCC toolchain. CMake/MSVC is provided but the
|
||||
verified local build uses GCC/w64devkit.
|
||||
|
||||
## Automatic layout correction (experimental)
|
||||
|
||||
Enable **Automatically correct layout (experimental)** in Settings, then use **Auto-correction
|
||||
options…** to select dictionary languages and edit excluded applications or words to never correct.
|
||||
The feature is off by default. English and Ukrainian are selected initially; Russian is optional.
|
||||
Target layouts must also be enabled in the layout cycle.
|
||||
|
||||
**Characters before a word** and **Characters after a word** configure punctuation to preserve around
|
||||
the corrected word. Enter characters directly, without spaces or separators; a comma or semicolon
|
||||
is itself a character. Defaults include parentheses, square/curly brackets, quotation marks and common
|
||||
trailing punctuation, including colon and semicolon. Empty fields disable stripping on that side.
|
||||
Up to three leading and six trailing characters (including the final space) are supported. Recognition
|
||||
still requires a verified complete word; punctuation inside a word is not stripped. Correction runs
|
||||
after Space, not immediately after a punctuation key. Save Settings to apply changes.
|
||||
|
||||
**Frequency rank limit** defaults to **0 (entire dictionary)**. A positive value allows corrections
|
||||
only to words with a rank at or below that number (for example, 10000). All dictionary words still
|
||||
protect already valid input, and ambiguous alternatives remain unchanged regardless of this limit.
|
||||
**Open dictionary folder** opens the files beside the running executable. After editing, click OK
|
||||
in the options window and Save in Settings to validate and reload the dictionaries without restarting.
|
||||
Use UTF-8, one lowercase word followed by a Tab and a positive rank per line; keep words sorted and
|
||||
unique. For a personal addition, rank 1 makes it eligible at every positive limit. Back up your edited
|
||||
files before replacing dictionaries with a new distribution.
|
||||
|
||||
After Space and a 250 ms typing pause, KSwitcher checks the last physically typed word against local
|
||||
dictionaries. For example, `ghbdsn ` can become `привіт `. Continuing to type cancels pending work.
|
||||
It only corrects unknown words when exactly one alternative is a dictionary word.
|
||||
**Minimum word length** defaults to 3 and can be set from 1 to 32 letters. Surrounding punctuation
|
||||
does not count toward this minimum; the maximum word length is 32 letters.
|
||||
Known words, ambiguous alternatives, ALL CAPS, mixed-case identifiers, numbers, URLs and email
|
||||
addresses are left unchanged. Short trailing punctuation is retained when the editor selects it
|
||||
together with the word. Actual selected text is verified before replacement. Layout switching follows
|
||||
the existing switch-layout setting. The configured Undo shortcut restores the original and suppresses
|
||||
that word for the rest of the session (until restart).
|
||||
|
||||
Password fields and inaccessible controls are skipped using Windows UI Automation. Editors, terminals
|
||||
and password managers are excluded by default; process names can be edited in the options window.
|
||||
Controls that do not expose an editable/document element or cannot select a complete word may not
|
||||
support automatic correction. Dictionary recognition is heuristic, not an understanding of the sentence;
|
||||
subtitle vocabulary is incomplete, and uncommon names or jargon can need personal exceptions.
|
||||
|
||||
No network service or language model is used. The three dictionary files occupy about 2.6 MB on disk;
|
||||
the English/Ukrainian lookup data uses less than 3 MiB of RAM and is loaded on first automatic check.
|
||||
Keep `dictionaries/` beside the executable when copying the app. These word lists derive from
|
||||
[FrequencyWords](https://github.com/hermitdave/FrequencyWords) and are separately licensed under
|
||||
CC BY-SA 4.0; attribution, pinned source revision and license are in `dictionaries/`.
|
||||
|
||||
## Latest compiled release
|
||||
|
||||
The [Release folder](Release/README.md) contains the latest portable Windows x64 ZIP and SHA-256 checksum.
|
||||
Run `./package.ps1 -PublishRelease` after building and testing to refresh the tracked release archive.
|
||||
|
||||
## Verification
|
||||
|
||||
```powershell
|
||||
@@ -143,4 +195,5 @@ It still uses the normal instance lock; close a running KSwitcher first if measu
|
||||
- `ocr.*`: Windows WIC and OCR via the native Windows Runtime ABI.
|
||||
- `tests/`: regression checks, real editor integration and OCR fixture.
|
||||
|
||||
MIT licensed; preserves the original Typlo attribution in `LICENSE`.
|
||||
Application code is MIT licensed; preserves the original Typlo attribution in `LICENSE`.
|
||||
Dictionary data has its own CC BY-SA 4.0 license as described above.
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,21 @@
|
||||
# Latest Windows release
|
||||
|
||||
Download **KSwitcher-win-x64.zip**, extract the entire archive to a writable folder,
|
||||
and launch `KSwitcher.exe`. Keep `dictionaries/` next to the executable.
|
||||
The archive contains the application, offline dictionaries, documentation and license notices.
|
||||
It does not contain personal settings or clipboard history.
|
||||
|
||||
For upgrades, exit KSwitcher first. Back up any edited dictionaries before replacing files.
|
||||
Keep your existing `data/` directory to retain settings and clipboard history.
|
||||
`SHA256SUMS.txt` contains the checksum of the archive.
|
||||
|
||||
This folder holds only the latest compiled version; older versions remain in Git history.
|
||||
|
||||
To refresh it from source:
|
||||
|
||||
```powershell
|
||||
.\build.ps1 -Stage -Test
|
||||
.\package.ps1 -PublishRelease
|
||||
```
|
||||
|
||||
Commit the updated ZIP and checksum together with the corresponding source changes.
|
||||
@@ -0,0 +1 @@
|
||||
906de70d92045cc6ff50802841cc357c6b47536fa0cbbb63301d779b33179469 KSwitcher-win-x64.zip
|
||||
@@ -13,25 +13,25 @@ New-Item -ItemType Directory -Force (Split-Path $executablePath) | Out-Null
|
||||
Push-Location $root
|
||||
try {
|
||||
$common = @('-std=c++20','-Os','-DNDEBUG','-DUNICODE','-D_UNICODE','-DWIN32_LEAN_AND_MEAN','-DNOMINMAX','-D_WIN32_WINNT=0x0A00','-Wall','-Wextra','-Wno-missing-field-initializers','-Isrc','-Ivendor','-ffunction-sections','-fdata-sections')
|
||||
$libs = @('-luser32','-lshell32','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject')
|
||||
foreach ($name in @('core','platform','autostart','storage','ocr','richtext','app','ui','main')) {
|
||||
$libs = @('-luser32','-lshell32','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore')
|
||||
foreach ($name in @('core','platform','autostart','autocorrect','storage','ocr','richtext','app','ui','main')) {
|
||||
& $compiler @common -c "src/$name.cpp" -o "build/$name.o"
|
||||
if ($LASTEXITCODE) { throw "Compilation failed: $name" }
|
||||
}
|
||||
& windres resources/app.rc -O coff -o build/app-res.o
|
||||
if ($LASTEXITCODE) { throw 'Resource compilation failed' }
|
||||
New-Item -ItemType Directory -Force build/release | Out-Null
|
||||
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/autostart.o build/storage.o build/ocr.o build/richtext.o build/app.o build/ui.o build/main.o build/app-res.o @libs -o $executablePath
|
||||
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/app.o build/ui.o build/main.o build/app-res.o @libs -o $executablePath
|
||||
if ($LASTEXITCODE) { throw 'Link failed' }
|
||||
Copy-Item -LiteralPath $executablePath -Destination build/release/KSwitcher.exe -Force
|
||||
if ($Test) {
|
||||
& $compiler @common tests/tests.cpp build/core.o build/platform.o build/autostart.o build/storage.o build/ocr.o build/richtext.o @libs -static -s -o build/KSwitcherTests.exe
|
||||
& $compiler @common tests/tests.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o @libs -static -s -o build/KSwitcherTests.exe
|
||||
if ($LASTEXITCODE) { throw 'Test build failed' }
|
||||
& .\build\KSwitcherTests.exe
|
||||
if ($LASTEXITCODE) { throw 'Tests failed' }
|
||||
}
|
||||
if ($Integration) {
|
||||
& $compiler @common tests/integration.cpp build/core.o build/platform.o build/autostart.o build/storage.o build/ocr.o build/richtext.o build/app.o build/ui.o build/app-res.o @libs -static -s -o build/KSwitcherIntegration.exe
|
||||
& $compiler @common tests/integration.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/app.o build/ui.o build/app-res.o @libs -static -s -o build/KSwitcherIntegration.exe
|
||||
if ($LASTEXITCODE) { throw 'Integration test build failed' }
|
||||
& .\build\KSwitcherIntegration.exe
|
||||
if ($LASTEXITCODE) { throw 'Integration tests failed' }
|
||||
@@ -42,5 +42,9 @@ try {
|
||||
& .\build\KSwitcherOcrTests.exe
|
||||
if ($LASTEXITCODE) { throw 'OCR tests failed' }
|
||||
}
|
||||
foreach ($folder in @('build/dictionaries','build/release/dictionaries','build/staged/dictionaries')) {
|
||||
New-Item -ItemType Directory -Force $folder | Out-Null
|
||||
Copy-Item -Path dictionaries/* -Destination $folder -Force
|
||||
}
|
||||
Get-Item build/release/KSwitcher.exe | Select-Object FullName,Length
|
||||
} finally { Pop-Location }
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
Attribution-ShareAlike 4.0 International
|
||||
|
||||
=======================================================================
|
||||
|
||||
Creative Commons Corporation ("Creative Commons") is not a law firm and
|
||||
does not provide legal services or legal advice. Distribution of
|
||||
Creative Commons public licenses does not create a lawyer-client or
|
||||
other relationship. Creative Commons makes its licenses and related
|
||||
information available on an "as-is" basis. Creative Commons gives no
|
||||
warranties regarding its licenses, any material licensed under their
|
||||
terms and conditions, or any related information. Creative Commons
|
||||
disclaims all liability for damages resulting from their use to the
|
||||
fullest extent possible.
|
||||
|
||||
Using Creative Commons Public Licenses
|
||||
|
||||
Creative Commons public licenses provide a standard set of terms and
|
||||
conditions that creators and other rights holders may use to share
|
||||
original works of authorship and other material subject to copyright
|
||||
and certain other rights specified in the public license below. The
|
||||
following considerations are for informational purposes only, are not
|
||||
exhaustive, and do not form part of our licenses.
|
||||
|
||||
Considerations for licensors: Our public licenses are
|
||||
intended for use by those authorized to give the public
|
||||
permission to use material in ways otherwise restricted by
|
||||
copyright and certain other rights. Our licenses are
|
||||
irrevocable. Licensors should read and understand the terms
|
||||
and conditions of the license they choose before applying it.
|
||||
Licensors should also secure all rights necessary before
|
||||
applying our licenses so that the public can reuse the
|
||||
material as expected. Licensors should clearly mark any
|
||||
material not subject to the license. This includes other CC-
|
||||
licensed material, or material used under an exception or
|
||||
limitation to copyright. More considerations for licensors:
|
||||
wiki.creativecommons.org/Considerations_for_licensors
|
||||
|
||||
Considerations for the public: By using one of our public
|
||||
licenses, a licensor grants the public permission to use the
|
||||
licensed material under specified terms and conditions. If
|
||||
the licensor's permission is not necessary for any reason--for
|
||||
example, because of any applicable exception or limitation to
|
||||
copyright--then that use is not regulated by the license. Our
|
||||
licenses grant only permissions under copyright and certain
|
||||
other rights that a licensor has authority to grant. Use of
|
||||
the licensed material may still be restricted for other
|
||||
reasons, including because others have copyright or other
|
||||
rights in the material. A licensor may make special requests,
|
||||
such as asking that all changes be marked or described.
|
||||
Although not required by our licenses, you are encouraged to
|
||||
respect those requests where reasonable. More considerations
|
||||
for the public:
|
||||
wiki.creativecommons.org/Considerations_for_licensees
|
||||
|
||||
=======================================================================
|
||||
|
||||
Creative Commons Attribution-ShareAlike 4.0 International Public
|
||||
License
|
||||
|
||||
By exercising the Licensed Rights (defined below), You accept and agree
|
||||
to be bound by the terms and conditions of this Creative Commons
|
||||
Attribution-ShareAlike 4.0 International Public License ("Public
|
||||
License"). To the extent this Public License may be interpreted as a
|
||||
contract, You are granted the Licensed Rights in consideration of Your
|
||||
acceptance of these terms and conditions, and the Licensor grants You
|
||||
such rights in consideration of benefits the Licensor receives from
|
||||
making the Licensed Material available under these terms and
|
||||
conditions.
|
||||
|
||||
|
||||
Section 1 -- Definitions.
|
||||
|
||||
a. Adapted Material means material subject to Copyright and Similar
|
||||
Rights that is derived from or based upon the Licensed Material
|
||||
and in which the Licensed Material is translated, altered,
|
||||
arranged, transformed, or otherwise modified in a manner requiring
|
||||
permission under the Copyright and Similar Rights held by the
|
||||
Licensor. For purposes of this Public License, where the Licensed
|
||||
Material is a musical work, performance, or sound recording,
|
||||
Adapted Material is always produced where the Licensed Material is
|
||||
synched in timed relation with a moving image.
|
||||
|
||||
b. Adapter's License means the license You apply to Your Copyright
|
||||
and Similar Rights in Your contributions to Adapted Material in
|
||||
accordance with the terms and conditions of this Public License.
|
||||
|
||||
c. BY-SA Compatible License means a license listed at
|
||||
creativecommons.org/compatiblelicenses, approved by Creative
|
||||
Commons as essentially the equivalent of this Public License.
|
||||
|
||||
d. Copyright and Similar Rights means copyright and/or similar rights
|
||||
closely related to copyright including, without limitation,
|
||||
performance, broadcast, sound recording, and Sui Generis Database
|
||||
Rights, without regard to how the rights are labeled or
|
||||
categorized. For purposes of this Public License, the rights
|
||||
specified in Section 2(b)(1)-(2) are not Copyright and Similar
|
||||
Rights.
|
||||
|
||||
e. Effective Technological Measures means those measures that, in the
|
||||
absence of proper authority, may not be circumvented under laws
|
||||
fulfilling obligations under Article 11 of the WIPO Copyright
|
||||
Treaty adopted on December 20, 1996, and/or similar international
|
||||
agreements.
|
||||
|
||||
f. Exceptions and Limitations means fair use, fair dealing, and/or
|
||||
any other exception or limitation to Copyright and Similar Rights
|
||||
that applies to Your use of the Licensed Material.
|
||||
|
||||
g. License Elements means the license attributes listed in the name
|
||||
of a Creative Commons Public License. The License Elements of this
|
||||
Public License are Attribution and ShareAlike.
|
||||
|
||||
h. Licensed Material means the artistic or literary work, database,
|
||||
or other material to which the Licensor applied this Public
|
||||
License.
|
||||
|
||||
i. Licensed Rights means the rights granted to You subject to the
|
||||
terms and conditions of this Public License, which are limited to
|
||||
all Copyright and Similar Rights that apply to Your use of the
|
||||
Licensed Material and that the Licensor has authority to license.
|
||||
|
||||
j. Licensor means the individual(s) or entity(ies) granting rights
|
||||
under this Public License.
|
||||
|
||||
k. Share means to provide material to the public by any means or
|
||||
process that requires permission under the Licensed Rights, such
|
||||
as reproduction, public display, public performance, distribution,
|
||||
dissemination, communication, or importation, and to make material
|
||||
available to the public including in ways that members of the
|
||||
public may access the material from a place and at a time
|
||||
individually chosen by them.
|
||||
|
||||
l. Sui Generis Database Rights means rights other than copyright
|
||||
resulting from Directive 96/9/EC of the European Parliament and of
|
||||
the Council of 11 March 1996 on the legal protection of databases,
|
||||
as amended and/or succeeded, as well as other essentially
|
||||
equivalent rights anywhere in the world.
|
||||
|
||||
m. You means the individual or entity exercising the Licensed Rights
|
||||
under this Public License. Your has a corresponding meaning.
|
||||
|
||||
|
||||
Section 2 -- Scope.
|
||||
|
||||
a. License grant.
|
||||
|
||||
1. Subject to the terms and conditions of this Public License,
|
||||
the Licensor hereby grants You a worldwide, royalty-free,
|
||||
non-sublicensable, non-exclusive, irrevocable license to
|
||||
exercise the Licensed Rights in the Licensed Material to:
|
||||
|
||||
a. reproduce and Share the Licensed Material, in whole or
|
||||
in part; and
|
||||
|
||||
b. produce, reproduce, and Share Adapted Material.
|
||||
|
||||
2. Exceptions and Limitations. For the avoidance of doubt, where
|
||||
Exceptions and Limitations apply to Your use, this Public
|
||||
License does not apply, and You do not need to comply with
|
||||
its terms and conditions.
|
||||
|
||||
3. Term. The term of this Public License is specified in Section
|
||||
6(a).
|
||||
|
||||
4. Media and formats; technical modifications allowed. The
|
||||
Licensor authorizes You to exercise the Licensed Rights in
|
||||
all media and formats whether now known or hereafter created,
|
||||
and to make technical modifications necessary to do so. The
|
||||
Licensor waives and/or agrees not to assert any right or
|
||||
authority to forbid You from making technical modifications
|
||||
necessary to exercise the Licensed Rights, including
|
||||
technical modifications necessary to circumvent Effective
|
||||
Technological Measures. For purposes of this Public License,
|
||||
simply making modifications authorized by this Section 2(a)
|
||||
(4) never produces Adapted Material.
|
||||
|
||||
5. Downstream recipients.
|
||||
|
||||
a. Offer from the Licensor -- Licensed Material. Every
|
||||
recipient of the Licensed Material automatically
|
||||
receives an offer from the Licensor to exercise the
|
||||
Licensed Rights under the terms and conditions of this
|
||||
Public License.
|
||||
|
||||
b. Additional offer from the Licensor -- Adapted Material.
|
||||
Every recipient of Adapted Material from You
|
||||
automatically receives an offer from the Licensor to
|
||||
exercise the Licensed Rights in the Adapted Material
|
||||
under the conditions of the Adapter's License You apply.
|
||||
|
||||
c. No downstream restrictions. You may not offer or impose
|
||||
any additional or different terms or conditions on, or
|
||||
apply any Effective Technological Measures to, the
|
||||
Licensed Material if doing so restricts exercise of the
|
||||
Licensed Rights by any recipient of the Licensed
|
||||
Material.
|
||||
|
||||
6. No endorsement. Nothing in this Public License constitutes or
|
||||
may be construed as permission to assert or imply that You
|
||||
are, or that Your use of the Licensed Material is, connected
|
||||
with, or sponsored, endorsed, or granted official status by,
|
||||
the Licensor or others designated to receive attribution as
|
||||
provided in Section 3(a)(1)(A)(i).
|
||||
|
||||
b. Other rights.
|
||||
|
||||
1. Moral rights, such as the right of integrity, are not
|
||||
licensed under this Public License, nor are publicity,
|
||||
privacy, and/or other similar personality rights; however, to
|
||||
the extent possible, the Licensor waives and/or agrees not to
|
||||
assert any such rights held by the Licensor to the limited
|
||||
extent necessary to allow You to exercise the Licensed
|
||||
Rights, but not otherwise.
|
||||
|
||||
2. Patent and trademark rights are not licensed under this
|
||||
Public License.
|
||||
|
||||
3. To the extent possible, the Licensor waives any right to
|
||||
collect royalties from You for the exercise of the Licensed
|
||||
Rights, whether directly or through a collecting society
|
||||
under any voluntary or waivable statutory or compulsory
|
||||
licensing scheme. In all other cases the Licensor expressly
|
||||
reserves any right to collect such royalties.
|
||||
|
||||
|
||||
Section 3 -- License Conditions.
|
||||
|
||||
Your exercise of the Licensed Rights is expressly made subject to the
|
||||
following conditions.
|
||||
|
||||
a. Attribution.
|
||||
|
||||
1. If You Share the Licensed Material (including in modified
|
||||
form), You must:
|
||||
|
||||
a. retain the following if it is supplied by the Licensor
|
||||
with the Licensed Material:
|
||||
|
||||
i. identification of the creator(s) of the Licensed
|
||||
Material and any others designated to receive
|
||||
attribution, in any reasonable manner requested by
|
||||
the Licensor (including by pseudonym if
|
||||
designated);
|
||||
|
||||
ii. a copyright notice;
|
||||
|
||||
iii. a notice that refers to this Public License;
|
||||
|
||||
iv. a notice that refers to the disclaimer of
|
||||
warranties;
|
||||
|
||||
v. a URI or hyperlink to the Licensed Material to the
|
||||
extent reasonably practicable;
|
||||
|
||||
b. indicate if You modified the Licensed Material and
|
||||
retain an indication of any previous modifications; and
|
||||
|
||||
c. indicate the Licensed Material is licensed under this
|
||||
Public License, and include the text of, or the URI or
|
||||
hyperlink to, this Public License.
|
||||
|
||||
2. You may satisfy the conditions in Section 3(a)(1) in any
|
||||
reasonable manner based on the medium, means, and context in
|
||||
which You Share the Licensed Material. For example, it may be
|
||||
reasonable to satisfy the conditions by providing a URI or
|
||||
hyperlink to a resource that includes the required
|
||||
information.
|
||||
|
||||
3. If requested by the Licensor, You must remove any of the
|
||||
information required by Section 3(a)(1)(A) to the extent
|
||||
reasonably practicable.
|
||||
|
||||
b. ShareAlike.
|
||||
|
||||
In addition to the conditions in Section 3(a), if You Share
|
||||
Adapted Material You produce, the following conditions also apply.
|
||||
|
||||
1. The Adapter's License You apply must be a Creative Commons
|
||||
license with the same License Elements, this version or
|
||||
later, or a BY-SA Compatible License.
|
||||
|
||||
2. You must include the text of, or the URI or hyperlink to, the
|
||||
Adapter's License You apply. You may satisfy this condition
|
||||
in any reasonable manner based on the medium, means, and
|
||||
context in which You Share Adapted Material.
|
||||
|
||||
3. You may not offer or impose any additional or different terms
|
||||
or conditions on, or apply any Effective Technological
|
||||
Measures to, Adapted Material that restrict exercise of the
|
||||
rights granted under the Adapter's License You apply.
|
||||
|
||||
|
||||
Section 4 -- Sui Generis Database Rights.
|
||||
|
||||
Where the Licensed Rights include Sui Generis Database Rights that
|
||||
apply to Your use of the Licensed Material:
|
||||
|
||||
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
|
||||
to extract, reuse, reproduce, and Share all or a substantial
|
||||
portion of the contents of the database;
|
||||
|
||||
b. if You include all or a substantial portion of the database
|
||||
contents in a database in which You have Sui Generis Database
|
||||
Rights, then the database in which You have Sui Generis Database
|
||||
Rights (but not its individual contents) is Adapted Material,
|
||||
including for purposes of Section 3(b); and
|
||||
|
||||
c. You must comply with the conditions in Section 3(a) if You Share
|
||||
all or a substantial portion of the contents of the database.
|
||||
|
||||
For the avoidance of doubt, this Section 4 supplements and does not
|
||||
replace Your obligations under this Public License where the Licensed
|
||||
Rights include other Copyright and Similar Rights.
|
||||
|
||||
|
||||
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
|
||||
|
||||
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
|
||||
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
|
||||
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
|
||||
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
|
||||
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
|
||||
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
||||
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
|
||||
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
|
||||
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
|
||||
ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
|
||||
|
||||
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
|
||||
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
|
||||
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
|
||||
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
|
||||
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
|
||||
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
|
||||
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
|
||||
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
|
||||
IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
|
||||
|
||||
c. The disclaimer of warranties and limitation of liability provided
|
||||
above shall be interpreted in a manner that, to the extent
|
||||
possible, most closely approximates an absolute disclaimer and
|
||||
waiver of all liability.
|
||||
|
||||
|
||||
Section 6 -- Term and Termination.
|
||||
|
||||
a. This Public License applies for the term of the Copyright and
|
||||
Similar Rights licensed here. However, if You fail to comply with
|
||||
this Public License, then Your rights under this Public License
|
||||
terminate automatically.
|
||||
|
||||
b. Where Your right to use the Licensed Material has terminated under
|
||||
Section 6(a), it reinstates:
|
||||
|
||||
1. automatically as of the date the violation is cured, provided
|
||||
it is cured within 30 days of Your discovery of the
|
||||
violation; or
|
||||
|
||||
2. upon express reinstatement by the Licensor.
|
||||
|
||||
For the avoidance of doubt, this Section 6(b) does not affect any
|
||||
right the Licensor may have to seek remedies for Your violations
|
||||
of this Public License.
|
||||
|
||||
c. For the avoidance of doubt, the Licensor may also offer the
|
||||
Licensed Material under separate terms or conditions or stop
|
||||
distributing the Licensed Material at any time; however, doing so
|
||||
will not terminate this Public License.
|
||||
|
||||
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
|
||||
License.
|
||||
|
||||
|
||||
Section 7 -- Other Terms and Conditions.
|
||||
|
||||
a. The Licensor shall not be bound by any additional or different
|
||||
terms or conditions communicated by You unless expressly agreed.
|
||||
|
||||
b. Any arrangements, understandings, or agreements regarding the
|
||||
Licensed Material not stated herein are separate from and
|
||||
independent of the terms and conditions of this Public License.
|
||||
|
||||
|
||||
Section 8 -- Interpretation.
|
||||
|
||||
a. For the avoidance of doubt, this Public License does not, and
|
||||
shall not be interpreted to, reduce, limit, restrict, or impose
|
||||
conditions on any use of the Licensed Material that could lawfully
|
||||
be made without permission under this Public License.
|
||||
|
||||
b. To the extent possible, if any provision of this Public License is
|
||||
deemed unenforceable, it shall be automatically reformed to the
|
||||
minimum extent necessary to make it enforceable. If the provision
|
||||
cannot be reformed, it shall be severed from this Public License
|
||||
without affecting the enforceability of the remaining terms and
|
||||
conditions.
|
||||
|
||||
c. No term or condition of this Public License will be waived and no
|
||||
failure to comply consented to unless expressly agreed to by the
|
||||
Licensor.
|
||||
|
||||
d. Nothing in this Public License constitutes or may be interpreted
|
||||
as a limitation upon, or waiver of, any privileges and immunities
|
||||
that apply to the Licensor or You, including from the legal
|
||||
processes of any jurisdiction or authority.
|
||||
|
||||
|
||||
=======================================================================
|
||||
|
||||
Creative Commons is not a party to its public
|
||||
licenses. Notwithstanding, Creative Commons may elect to apply one of
|
||||
its public licenses to material it publishes and in those instances
|
||||
will be considered the “Licensor.” The text of the Creative Commons
|
||||
public licenses is dedicated to the public domain under the CC0 Public
|
||||
Domain Dedication. Except for the limited purpose of indicating that
|
||||
material is shared under a Creative Commons public license or as
|
||||
otherwise permitted by the Creative Commons policies published at
|
||||
creativecommons.org/policies, Creative Commons does not authorize the
|
||||
use of the trademark "Creative Commons" or any other trademark or logo
|
||||
of Creative Commons without its prior written consent including,
|
||||
without limitation, in connection with any unauthorized modifications
|
||||
to any of its public licenses or any other arrangements,
|
||||
understandings, or agreements concerning use of licensed material. For
|
||||
the avoidance of doubt, this paragraph does not form part of the
|
||||
public licenses.
|
||||
|
||||
Creative Commons may be contacted at creativecommons.org.
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# KSwitcher offline word lists
|
||||
|
||||
Derived from FrequencyWords by Hermit Dave and contributors, using its 2016
|
||||
OpenSubtitles word-frequency lists, revision 525f9b560de45753a5ea01069454e72e9aa541c6.
|
||||
Source: https://github.com/hermitdave/FrequencyWords
|
||||
Underlying corpus: https://opus.nlpl.eu/OpenSubtitles-v2016.php
|
||||
|
||||
These dictionary DATA files are licensed under Creative Commons Attribution-
|
||||
ShareAlike 4.0 International (CC BY-SA 4.0):
|
||||
https://creativecommons.org/licenses/by-sa/4.0/
|
||||
Legal terms: https://creativecommons.org/licenses/by-sa/4.0/legalcode
|
||||
The application's source code remains separately MIT licensed.
|
||||
|
||||
Changes by KSwitcher: lowercase, alphabet/length/minimum-frequency filtering,
|
||||
replace counts with original frequency ranks, sort lexically. Reproduce with
|
||||
`python tools/build-dictionaries.py`; sources.json records upstream and output
|
||||
hashes. The lists contain word forms from subtitles, not a complete spelling
|
||||
or grammar dictionary. Recognition can miss names, jargon and uncommon forms.
|
||||
|
||||
File format: UTF-8 word, TAB, positive original frequency rank, newline.
|
||||
All entries protect known words. The frequency rank limit in Auto-correction options defaults to 0
|
||||
(entire dictionary); a positive limit only permits correction candidates at or below that rank.
|
||||
Personal words may use rank 1. Keep entries lowercase, unique and sorted, with a Tab separator.
|
||||
Save the UTF-8 file, then click OK in Auto-correction options and Save in Settings to reload it.
|
||||
Back up edited files before replacing dictionaries with a new distribution.
|
||||
+49618
File diff suppressed because it is too large
Load Diff
+48770
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"en": {
|
||||
"source": "https://raw.githubusercontent.com/hermitdave/FrequencyWords/525f9b560de45753a5ea01069454e72e9aa541c6/content/2016/en/en_50k.txt",
|
||||
"sourceSha256": "dfe19601eb63ac753864296ba129af131d4ca4bbe0df4a5fca59246307df87f5",
|
||||
"words": 49618,
|
||||
"bytes": 682867,
|
||||
"sha256": "5f267c141c3d6837870e1c098f86b89a113ba300343119432644ead88f720c21"
|
||||
},
|
||||
"uk": {
|
||||
"source": "https://raw.githubusercontent.com/hermitdave/FrequencyWords/525f9b560de45753a5ea01069454e72e9aa541c6/content/2016/uk/uk_50k.txt",
|
||||
"sourceSha256": "9548b79a608d33f39c5546037a69117d679730b1fda08dd0ce67c043dbf3335a",
|
||||
"words": 42549,
|
||||
"bytes": 898210,
|
||||
"sha256": "b4104898dcccbc1331ec01e9cd690e30285fe58481460e47438e973964d09802"
|
||||
},
|
||||
"ru": {
|
||||
"source": "https://raw.githubusercontent.com/hermitdave/FrequencyWords/525f9b560de45753a5ea01069454e72e9aa541c6/content/2016/ru/ru_50k.txt",
|
||||
"sourceSha256": "846d567f9131e1e8ed190041867ba7ff5d19d8f1e7e0d4e77dadfe7531035569",
|
||||
"words": 48770,
|
||||
"bytes": 1060364,
|
||||
"sha256": "9ef922c7699569304a428f6aba0fd13a21341c0d0cb9561f37ef83c41577a28b"
|
||||
}
|
||||
}
|
||||
+42549
File diff suppressed because it is too large
Load Diff
+11
-1
@@ -1,3 +1,4 @@
|
||||
param([switch]$PublishRelease)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = $PSScriptRoot
|
||||
$destination = Join-Path $root 'dist\KSwitcher'
|
||||
@@ -6,8 +7,17 @@ Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.exe') -Destinat
|
||||
Copy-Item -LiteralPath (Join-Path $root 'LICENSE') -Destination $destination
|
||||
Copy-Item -LiteralPath (Join-Path $root 'vendor\json-LICENSE.txt') -Destination $destination
|
||||
Copy-Item -LiteralPath (Join-Path $root 'README.md') -Destination $destination
|
||||
Copy-Item -LiteralPath (Join-Path $root 'dictionaries') -Destination $destination -Recurse -Force
|
||||
# Enumerate the distributable files explicitly; do not include a user's data directory.
|
||||
$files = @('KSwitcher.exe','LICENSE','json-LICENSE.txt','README.md') | ForEach-Object { Join-Path $destination $_ }
|
||||
$files = @('KSwitcher.exe','LICENSE','json-LICENSE.txt','README.md','dictionaries') | ForEach-Object { Join-Path $destination $_ }
|
||||
$archive = Join-Path $root 'dist\KSwitcher-win-x64.zip'
|
||||
Compress-Archive -LiteralPath $files -DestinationPath $archive -Force
|
||||
Get-FileHash -LiteralPath $archive -Algorithm SHA256
|
||||
if ($PublishRelease) {
|
||||
$release = Join-Path $root 'Release'
|
||||
New-Item -ItemType Directory -Force $release | Out-Null
|
||||
$published = Join-Path $release 'KSwitcher-win-x64.zip'
|
||||
Copy-Item -LiteralPath $archive -Destination $published -Force
|
||||
$hash = (Get-FileHash -LiteralPath $published -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
"$hash KSwitcher-win-x64.zip" | Set-Content -LiteralPath (Join-Path $release 'SHA256SUMS.txt') -Encoding ascii
|
||||
}
|
||||
|
||||
+102
-8
@@ -314,6 +314,11 @@ void App::applySettings(const Settings &s)
|
||||
{
|
||||
bool autostartChanged = false;
|
||||
validateSettings(s);
|
||||
if (s.autoCorrect)
|
||||
{
|
||||
AutoCorrector probe;
|
||||
probe.configure(std::filesystem::path(executable()).parent_path() / L"dictionaries", s.autoLanguages);
|
||||
}
|
||||
if (!registerHotkeys(s))
|
||||
{
|
||||
registerHotkeys(settings, true);
|
||||
@@ -348,6 +353,8 @@ void App::applySettings(const Settings &s)
|
||||
last.reset();
|
||||
++inputVersion;
|
||||
}
|
||||
// Clear on the worker so the next check reloads edits to dictionary files.
|
||||
enqueue([this] { autoCorrector.configure({}, {}); });
|
||||
SetThreadExecutionState(ES_CONTINUOUS |
|
||||
(settings.awake ? (ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED) : 0));
|
||||
}
|
||||
@@ -435,6 +442,9 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k)
|
||||
return;
|
||||
}
|
||||
run.record(ch, fg, layout, GetTickCount64());
|
||||
if (settings.autoCorrect && ch == L' ' && run.keys.size() >= (size_t)settings.autoMinLength + 1 &&
|
||||
run.keys.size() <= 41 && !busy && !run.overflow)
|
||||
PostMessageW(window, AutoCorrectMessage, (WPARAM)inputVersion.load(), 0);
|
||||
if (busy || settings.snippets.empty())
|
||||
return;
|
||||
auto keys = lower(run.keys);
|
||||
@@ -611,6 +621,8 @@ void App::transform(int command, int snippet)
|
||||
{ return GetForegroundWindow() == target && inputVersion.load() == version; };
|
||||
for (int i = 0; i < 8 && !modifiersReleased(); ++i)
|
||||
Sleep(10);
|
||||
if (!modifiersReleased() && command == AutoCorrect)
|
||||
return;
|
||||
if (!modifiersReleased())
|
||||
releaseModifiers();
|
||||
if (!valid())
|
||||
@@ -624,7 +636,22 @@ void App::transform(int command, int snippet)
|
||||
return;
|
||||
if (to >= 0 && !available[to].convertible)
|
||||
{
|
||||
PostMessageW(target, WM_INPUTLANGCHANGEREQUEST, 0, (LPARAM)available[to].handle);
|
||||
switchWindowLayout(target, available[to].handle, valid);
|
||||
return;
|
||||
}
|
||||
if (command == AutoCorrect)
|
||||
{
|
||||
if (!config.autoCorrect || recent.window != target || recent.overflow ||
|
||||
recent.keys.empty() || recent.keys.back() != L' ' || hint < 0)
|
||||
return;
|
||||
autoCorrector.configure(std::filesystem::path(executable()).parent_path() /
|
||||
L"dictionaries",
|
||||
config.autoLanguages);
|
||||
std::wstring predicted;
|
||||
for (wchar_t key : recent.keys)
|
||||
predicted += key == L' ' ? L' ' : available[hint].at(physicalLayout().code(key));
|
||||
if (!autoCorrector.suggest(predicted, hint, available, config) ||
|
||||
!autoCorrectionAllowed(target, config) || !valid())
|
||||
return;
|
||||
}
|
||||
Transaction tx(*this, target, version);
|
||||
@@ -634,7 +661,36 @@ void App::transform(int command, int snippet)
|
||||
std::to_wstring(recent.window == target));
|
||||
std::wstring text, result;
|
||||
bool automatic = false;
|
||||
if (command == Undo)
|
||||
if (command == AutoCorrect)
|
||||
{
|
||||
// The editor must select the complete previous word. Unlike manual
|
||||
// conversion, automatic correction must never fall back to a suffix.
|
||||
if (!tx.valid() || !chord(VK_LEFT, MOD_CONTROL | MOD_SHIFT))
|
||||
return;
|
||||
Sleep(25);
|
||||
text = tx.copy();
|
||||
// Some editors first select only ") ". Extend by whole-word
|
||||
// boundaries, then still verify against the complete tracked run.
|
||||
for (int i = 0; i < 3 && autoClosingSelection(text, config) && tx.valid(); ++i)
|
||||
{
|
||||
if (!chord(VK_LEFT, MOD_CONTROL | MOD_SHIFT))
|
||||
break;
|
||||
Sleep(25);
|
||||
text = tx.copy();
|
||||
}
|
||||
hint = matchAutoInput(recent, text, available, physicalLayout(), config);
|
||||
auto suggestion = autoCorrector.suggest(text, hint, available, config);
|
||||
if (!suggestion || !tx.valid() || !autoCorrectionAllowed(target, config))
|
||||
{
|
||||
if (tx.valid())
|
||||
chord(VK_RIGHT);
|
||||
return;
|
||||
}
|
||||
to = suggestion->target;
|
||||
result = suggestion->text;
|
||||
automatic = true;
|
||||
}
|
||||
else if (command == Undo)
|
||||
{
|
||||
if (!previous || previous->window != target || previous->version != version ||
|
||||
GetTickCount64() - previous->at > 120000)
|
||||
@@ -649,6 +705,10 @@ void App::transform(int command, int snippet)
|
||||
return;
|
||||
}
|
||||
result = previous->original;
|
||||
if (previous->autoCorrected)
|
||||
{
|
||||
to = previous->sourceLayout;
|
||||
}
|
||||
automatic = true;
|
||||
}
|
||||
else if (snippet >= 0)
|
||||
@@ -710,10 +770,10 @@ void App::transform(int command, int snippet)
|
||||
if (text.empty())
|
||||
{
|
||||
if (to >= 0 && config.switchLayout)
|
||||
PostMessageW(target, WM_INPUTLANGCHANGEREQUEST, 0, (LPARAM)available[to].handle);
|
||||
switchWindowLayout(target, available[to].handle, valid);
|
||||
return;
|
||||
}
|
||||
if (command != Undo && snippet < 0)
|
||||
if (command != Undo && command != AutoCorrect && snippet < 0)
|
||||
{
|
||||
if (command == Case)
|
||||
result = cycleCase(text);
|
||||
@@ -750,6 +810,8 @@ void App::transform(int command, int snippet)
|
||||
sent = tx.paste(textClip(result));
|
||||
else if (automatic && tx.valid())
|
||||
chord(VK_RIGHT);
|
||||
if (sent && command == Undo && previous && previous->autoCorrected)
|
||||
autoCorrector.reject(previous->original, config);
|
||||
if (tx.valid() && (sent || automatic))
|
||||
{
|
||||
std::lock_guard lock(stateMutex);
|
||||
@@ -758,7 +820,7 @@ void App::transform(int command, int snippet)
|
||||
last.reset();
|
||||
else
|
||||
last = LastTransform{target, text, result, to >= 0 ? to : hint,
|
||||
GetTickCount64(), version};
|
||||
GetTickCount64(), version, hint, command == AutoCorrect};
|
||||
}
|
||||
if (snippet >= 0 && sent && tx.valid())
|
||||
{
|
||||
@@ -770,8 +832,11 @@ void App::transform(int command, int snippet)
|
||||
chord(newlines(suffix) == newlines(t.suffix) ? VK_LEFT : VK_RIGHT);
|
||||
}
|
||||
}
|
||||
if (to >= 0 && config.switchLayout && tx.valid())
|
||||
PostMessageW(target, WM_INPUTLANGCHANGEREQUEST, 0, (LPARAM)available[to].handle);
|
||||
if (to >= 0 && config.switchLayout && tx.valid() && (sent || result == text))
|
||||
{
|
||||
bool switched = switchWindowLayout(target, available[to].handle, valid);
|
||||
log(L"Layout switch confirmed=" + std::to_wstring(switched));
|
||||
}
|
||||
log(L"Text operation completed in " + std::to_wstring(GetTickCount64() - start) + L" ms");
|
||||
}
|
||||
catch (const std::exception &e)
|
||||
@@ -1002,12 +1067,20 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
||||
case TrayMessage:
|
||||
if (LOWORD(lp) == WM_CONTEXTMENU)
|
||||
app->trayMenu();
|
||||
else if (LOWORD(lp) == WM_LBUTTONDBLCLK || LOWORD(lp) == NIN_KEYSELECT)
|
||||
else if (LOWORD(lp) == WM_LBUTTONDBLCLK)
|
||||
// The shell still owns the mouse gesture on the second button-down.
|
||||
// Activate after release, so the tray cannot take focus back on button-up.
|
||||
SetTimer(hwnd, 4, 50, nullptr);
|
||||
else if (LOWORD(lp) == NIN_KEYSELECT)
|
||||
app->dispatch(20); // Settings is the tray's default action.
|
||||
return 0;
|
||||
case WM_CLIPBOARDUPDATE:
|
||||
app->captureHistory();
|
||||
return 0;
|
||||
case AutoCorrectMessage:
|
||||
app->pendingAutoVersion = (uint64_t)wp;
|
||||
SetTimer(hwnd, 3, 250, nullptr);
|
||||
return 0;
|
||||
case SnippetMessage:
|
||||
app->transform(0, (int)wp);
|
||||
return 0;
|
||||
@@ -1040,6 +1113,21 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
||||
return TRUE;
|
||||
}
|
||||
case WM_TIMER:
|
||||
if (wp == 4)
|
||||
{
|
||||
if (GetAsyncKeyState(VK_LBUTTON) & 0x8000)
|
||||
return 0;
|
||||
KillTimer(hwnd, 4);
|
||||
app->dispatch(20);
|
||||
return 0;
|
||||
}
|
||||
if (wp == 3)
|
||||
{
|
||||
KillTimer(hwnd, 3);
|
||||
if (app->settings.autoCorrect && app->inputVersion.load() == app->pendingAutoVersion)
|
||||
app->transform(AutoCorrect);
|
||||
return 0;
|
||||
}
|
||||
if (wp == 2)
|
||||
{
|
||||
PostMessageW(hwnd, WM_CLOSE, 0, 0);
|
||||
@@ -1127,6 +1215,12 @@ int App::execute()
|
||||
MSG msg;
|
||||
while (GetMessageW(&msg, nullptr, 0, 0) > 0)
|
||||
{
|
||||
if (settingsWindow && !IsWindowEnabled(settingsWindow))
|
||||
{
|
||||
HWND popup = GetLastActivePopup(settingsWindow);
|
||||
if (popup != settingsWindow && IsDialogMessageW(popup, &msg))
|
||||
continue;
|
||||
}
|
||||
if (settingsWindow && IsDialogMessageW(settingsWindow, &msg))
|
||||
continue;
|
||||
if (snippetsWindow && IsDialogMessageW(snippetsWindow, &msg))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#pragma once
|
||||
#include "autocorrect.h"
|
||||
#include "storage.h"
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
@@ -11,7 +12,9 @@ namespace ks
|
||||
{
|
||||
inline constexpr UINT TrayMessage = WM_APP + 1, ErrorMessage = WM_APP + 2, HistoryMessage = WM_APP + 3,
|
||||
SnippetMessage = WM_APP + 4;
|
||||
inline constexpr int Toggle = 1, Case = 2, Translit = 3, Undo = 4, ShowHistory = 5, Direct = 100;
|
||||
inline constexpr int Toggle = 1, Case = 2, Translit = 3, Undo = 4, ShowHistory = 5, AutoCorrect = 6,
|
||||
Direct = 100;
|
||||
inline constexpr UINT AutoCorrectMessage = WM_APP + 6;
|
||||
inline constexpr UINT CaptureHotkeyMessage = WM_APP + 5;
|
||||
struct LastTransform
|
||||
{
|
||||
@@ -20,6 +23,8 @@ struct LastTransform
|
||||
int layout = -1;
|
||||
ULONGLONG at{};
|
||||
uint64_t version{};
|
||||
int sourceLayout = -1;
|
||||
bool autoCorrected = false;
|
||||
};
|
||||
class App
|
||||
{
|
||||
@@ -30,6 +35,7 @@ class App
|
||||
std::vector<Layout> layouts;
|
||||
Settings settings;
|
||||
std::unique_ptr<History> history;
|
||||
AutoCorrector autoCorrector;
|
||||
std::atomic<bool> busy{false}, suspended{false};
|
||||
std::atomic<DWORD> ownSequence{0};
|
||||
std::atomic<uint64_t> inputVersion{0};
|
||||
@@ -70,6 +76,7 @@ class App
|
||||
std::thread worker;
|
||||
bool stop{};
|
||||
UINT taskbarCreated{};
|
||||
uint64_t pendingAutoVersion{};
|
||||
std::vector<std::pair<int, Hotkey>> hookBindings;
|
||||
HotkeyHook hotkeyHook;
|
||||
static LRESULT CALLBACK windowProc(HWND, UINT, WPARAM, LPARAM);
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
#include "autocorrect.h"
|
||||
#include <algorithm>
|
||||
#include <charconv>
|
||||
#include <uiautomation.h>
|
||||
namespace ks
|
||||
{
|
||||
static bool openingDelimiter(wchar_t c, const Settings &settings)
|
||||
{
|
||||
return settings.autoPrefixChars.find(c) != std::wstring::npos;
|
||||
}
|
||||
static bool trailingDelimiter(wchar_t c, const Settings &settings)
|
||||
{
|
||||
return c == L' ' || settings.autoSuffixChars.find(c) != std::wstring::npos;
|
||||
}
|
||||
bool autoClosingSelection(const std::wstring &text, const Settings &settings)
|
||||
{
|
||||
return !text.empty() && text.size() <= 6 &&
|
||||
std::all_of(text.begin(), text.end(), [&](wchar_t c) { return trailingDelimiter(c, settings); }) &&
|
||||
text.find_first_not_of(L' ') != text.npos;
|
||||
}
|
||||
std::wstring layoutLanguage(const Layout &layout)
|
||||
{
|
||||
if (layout.id == L"en" || layout.id == L"uk" || layout.id == L"ru")
|
||||
return layout.id;
|
||||
switch (PRIMARYLANGID(LOWORD((uintptr_t)layout.handle)))
|
||||
{
|
||||
case LANG_ENGLISH:
|
||||
return L"en";
|
||||
case LANG_UKRAINIAN:
|
||||
return L"uk";
|
||||
case LANG_RUSSIAN:
|
||||
return L"ru";
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
bool safeAutoWord(const std::wstring &word, int minimumLength)
|
||||
{
|
||||
if (minimumLength < 1 || minimumLength > 32 || word.size() < (size_t)minimumLength || word.size() > 32 ||
|
||||
!std::all_of(word.begin(), word.end(), letter))
|
||||
return false;
|
||||
auto small = lower(word);
|
||||
return word == small || (word.substr(1) == small.substr(1) && word != upper(word));
|
||||
}
|
||||
std::string_view AutoCorrector::Dictionary::word(size_t index) const
|
||||
{
|
||||
auto start = offsets[index];
|
||||
auto end = bytes.find('\t', start);
|
||||
return std::string_view(bytes).substr(start, end - start);
|
||||
}
|
||||
AutoCorrector::Dictionary::Dictionary(const std::filesystem::path &path)
|
||||
{
|
||||
auto raw = readFile(path, 2 * 1024 * 1024);
|
||||
bytes.assign(raw.begin(), raw.end());
|
||||
for (size_t start = 0; start < bytes.size();)
|
||||
{
|
||||
auto end = bytes.find('\n', start);
|
||||
if (end == bytes.npos)
|
||||
end = bytes.size();
|
||||
auto tab = bytes.find('\t', start);
|
||||
if (tab == bytes.npos || tab >= end || tab == start || tab - start > 160)
|
||||
throw std::runtime_error("Invalid auto-correction dictionary");
|
||||
int rank{};
|
||||
auto rankEnd = end > tab + 1 && bytes[end - 1] == '\r' ? end - 1 : end;
|
||||
auto parse = std::from_chars(bytes.data() + tab + 1, bytes.data() + rankEnd, rank);
|
||||
if (parse.ec != std::errc() || parse.ptr != bytes.data() + rankEnd || rank <= 0)
|
||||
throw std::runtime_error("Invalid dictionary rank");
|
||||
offsets.push_back((uint32_t)start);
|
||||
start = end + 1;
|
||||
}
|
||||
if (offsets.empty())
|
||||
throw std::runtime_error("Empty auto-correction dictionary");
|
||||
for (size_t i = 1; i < offsets.size(); ++i)
|
||||
if (word(i - 1) >= word(i))
|
||||
throw std::runtime_error("Dictionary must be uniquely sorted");
|
||||
offsets.shrink_to_fit();
|
||||
bytes.shrink_to_fit();
|
||||
}
|
||||
int AutoCorrector::Dictionary::rank(const std::wstring &text) const
|
||||
{
|
||||
auto key = utf8(lower(text));
|
||||
size_t begin = 0, end = offsets.size();
|
||||
while (begin < end)
|
||||
{
|
||||
auto mid = begin + (end - begin) / 2;
|
||||
if (word(mid) < key)
|
||||
begin = mid + 1;
|
||||
else
|
||||
end = mid;
|
||||
}
|
||||
if (begin == offsets.size() || word(begin) != key)
|
||||
return 0;
|
||||
auto start = offsets[begin] + word(begin).size() + 1;
|
||||
int rank{};
|
||||
std::from_chars(bytes.data() + start, bytes.data() + bytes.size(), rank);
|
||||
return rank;
|
||||
}
|
||||
void AutoCorrector::configure(const std::filesystem::path &folder, const std::vector<std::wstring> &wanted)
|
||||
{
|
||||
if (languages == wanted)
|
||||
return;
|
||||
std::map<std::wstring, Dictionary> next;
|
||||
for (auto &lang : wanted)
|
||||
{
|
||||
if (lang != L"en" && lang != L"uk" && lang != L"ru")
|
||||
throw std::runtime_error("Unsupported dictionary language");
|
||||
next.emplace(lang, Dictionary(folder / (lang + L".txt")));
|
||||
}
|
||||
dictionaries = std::move(next);
|
||||
languages = wanted;
|
||||
}
|
||||
void AutoCorrector::reject(std::wstring word, const Settings &settings)
|
||||
{
|
||||
while (!word.empty() && openingDelimiter(word.front(), settings))
|
||||
word.erase(word.begin());
|
||||
while (!word.empty() && trailingDelimiter(word.back(), settings))
|
||||
word.pop_back();
|
||||
if (rejected.size() >= 128)
|
||||
rejected.clear();
|
||||
rejected.insert(lower(word));
|
||||
}
|
||||
size_t AutoCorrector::memoryBytes() const
|
||||
{
|
||||
size_t bytes = 0;
|
||||
for (auto &[language, d] : dictionaries)
|
||||
bytes += d.bytes.capacity() + d.offsets.capacity() * sizeof(uint32_t);
|
||||
return bytes;
|
||||
}
|
||||
std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed, int source,
|
||||
const std::vector<Layout> &layouts,
|
||||
const Settings &settings) const
|
||||
{
|
||||
if (source < 0 || source >= (int)layouts.size() || !layouts[source].convertible)
|
||||
return {};
|
||||
std::wstring word = typed, suffix, prefix;
|
||||
while (!word.empty() && openingDelimiter(word.front(), settings) && prefix.size() < 3)
|
||||
{
|
||||
prefix += word.front();
|
||||
word.erase(word.begin());
|
||||
}
|
||||
while (!word.empty() && trailingDelimiter(word.back(), settings))
|
||||
{
|
||||
suffix.insert(suffix.begin(), word.back());
|
||||
word.pop_back();
|
||||
}
|
||||
if (!safeAutoWord(word, settings.autoMinLength) || suffix.size() > 6 || rejected.contains(lower(word)))
|
||||
return {};
|
||||
for (auto &never : settings.autoNever)
|
||||
if (lower(never) == lower(word))
|
||||
return {};
|
||||
auto sourceLanguage = layoutLanguage(layouts[source]);
|
||||
if (!dictionaries.contains(sourceLanguage))
|
||||
return {};
|
||||
// Valid words from either selected language are never changed.
|
||||
for (auto &[language, d] : dictionaries)
|
||||
if (d.rank(word))
|
||||
return {};
|
||||
std::optional<AutoSuggestion> candidate;
|
||||
int matches = 0;
|
||||
for (int i = 0; i < (int)layouts.size(); ++i)
|
||||
{
|
||||
if (i == source || !layouts[i].convertible ||
|
||||
std::find(settings.cycle.begin(), settings.cycle.end(), layouts[i].id) == settings.cycle.end())
|
||||
continue;
|
||||
auto dictionary = dictionaries.find(layoutLanguage(layouts[i]));
|
||||
if (dictionary == dictionaries.end())
|
||||
continue;
|
||||
auto converted = convert(word, layouts[source], layouts[i], true);
|
||||
if (converted == word || !safeAutoWord(converted, settings.autoMinLength))
|
||||
continue;
|
||||
int rank = dictionary->second.rank(converted);
|
||||
if (!rank)
|
||||
continue;
|
||||
if (++matches > 1)
|
||||
return {};
|
||||
if (settings.autoMaxRank == 0 || rank <= settings.autoMaxRank)
|
||||
candidate = AutoSuggestion{source, i, prefix + converted + suffix};
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
int matchAutoInput(const Run &run, const std::wstring &text, const std::vector<Layout> &layouts,
|
||||
const Layout &physical, const Settings &settings)
|
||||
{
|
||||
int full = run.match(text, layouts, physical);
|
||||
if (full >= 0 || run.overflow || text.empty())
|
||||
return full;
|
||||
// Ctrl+Shift+Left may omit opening delimiters. Only these delimiters may
|
||||
// be omitted; never accept an arbitrary matching suffix of a longer word.
|
||||
for (int i = 0; i < (int)layouts.size(); ++i)
|
||||
{
|
||||
if (!layouts[i].convertible)
|
||||
continue;
|
||||
std::wstring expected;
|
||||
for (wchar_t c : run.keys)
|
||||
expected += c == L' ' ? L' ' : layouts[i].at(physical.code(c));
|
||||
for (int removed = 0;
|
||||
removed < 3 && !expected.empty() && openingDelimiter(expected.front(), settings); ++removed)
|
||||
{
|
||||
expected.erase(expected.begin());
|
||||
if (lower(expected) == lower(text))
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
bool autoCorrectionAllowed(HWND target, const Settings &settings)
|
||||
{
|
||||
if (!target || GetForegroundWindow() != target)
|
||||
return false;
|
||||
auto process = lower(processName(target));
|
||||
for (auto &excluded : settings.autoExcluded)
|
||||
{
|
||||
auto name = lower(excluded);
|
||||
if (name.ends_with(L".exe"))
|
||||
name.resize(name.size() - 4);
|
||||
if (name == process)
|
||||
return false;
|
||||
}
|
||||
// Fail closed: do not copy from password fields or inaccessible controls.
|
||||
IUIAutomation2 *automation{};
|
||||
if (FAILED(CoCreateInstance(CLSID_CUIAutomation8, nullptr, CLSCTX_INPROC_SERVER, IID_IUIAutomation2,
|
||||
(void **)&automation)))
|
||||
return false;
|
||||
automation->put_ConnectionTimeout(200);
|
||||
automation->put_TransactionTimeout(200);
|
||||
IUIAutomationElement *focused{};
|
||||
HRESULT hr = automation->GetFocusedElement(&focused);
|
||||
automation->Release();
|
||||
if (FAILED(hr) || !focused)
|
||||
return false;
|
||||
BOOL password = TRUE, enabled = FALSE, hasFocus = FALSE;
|
||||
CONTROLTYPEID type{};
|
||||
bool allowed = SUCCEEDED(focused->get_CurrentIsPassword(&password)) && !password &&
|
||||
SUCCEEDED(focused->get_CurrentIsEnabled(&enabled)) && enabled &&
|
||||
SUCCEEDED(focused->get_CurrentHasKeyboardFocus(&hasFocus)) && hasFocus &&
|
||||
SUCCEEDED(focused->get_CurrentControlType(&type)) &&
|
||||
(type == UIA_EditControlTypeId || type == UIA_DocumentControlTypeId);
|
||||
focused->Release();
|
||||
return allowed && GetForegroundWindow() == target;
|
||||
}
|
||||
} // namespace ks
|
||||
@@ -0,0 +1,40 @@
|
||||
#pragma once
|
||||
#include "platform.h"
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <string_view>
|
||||
namespace ks
|
||||
{
|
||||
struct AutoSuggestion
|
||||
{
|
||||
int source{-1}, target{-1};
|
||||
std::wstring text;
|
||||
};
|
||||
std::wstring layoutLanguage(const Layout &);
|
||||
bool safeAutoWord(const std::wstring &, int minimumLength = 3);
|
||||
bool autoClosingSelection(const std::wstring &, const Settings & = {});
|
||||
int matchAutoInput(const Run &, const std::wstring &, const std::vector<Layout> &, const Layout &,
|
||||
const Settings & = {});
|
||||
bool autoCorrectionAllowed(HWND, const Settings &);
|
||||
class AutoCorrector
|
||||
{
|
||||
struct Dictionary
|
||||
{
|
||||
std::string bytes;
|
||||
std::vector<uint32_t> offsets;
|
||||
std::string_view word(size_t) const;
|
||||
explicit Dictionary(const std::filesystem::path &);
|
||||
int rank(const std::wstring &) const;
|
||||
};
|
||||
std::map<std::wstring, Dictionary> dictionaries;
|
||||
std::vector<std::wstring> languages;
|
||||
std::set<std::wstring> rejected;
|
||||
|
||||
public:
|
||||
void configure(const std::filesystem::path &, const std::vector<std::wstring> &);
|
||||
std::optional<AutoSuggestion> suggest(const std::wstring &, int source, const std::vector<Layout> &,
|
||||
const Settings &) const;
|
||||
void reject(std::wstring word, const Settings & = {});
|
||||
size_t memoryBytes() const;
|
||||
};
|
||||
} // namespace ks
|
||||
+10
@@ -46,6 +46,16 @@ struct Settings
|
||||
{
|
||||
bool recent = true, switchLayout = true, history = true, autostart = false, awake = false;
|
||||
bool elevatedAutostart = false;
|
||||
bool autoCorrect = false;
|
||||
int autoMaxRank = 0;
|
||||
int autoMinLength = 3;
|
||||
std::wstring autoPrefixChars = L"([{«“‘\"";
|
||||
std::wstring autoSuffixChars = L")]}»”’\".,!?:;";
|
||||
std::vector<std::wstring> autoLanguages{L"en", L"uk"};
|
||||
std::vector<std::wstring> autoNever;
|
||||
std::vector<std::wstring> autoExcluded{
|
||||
L"Code", L"rider64", L"devenv", L"WindowsTerminal", L"cmd", L"powershell", L"pwsh",
|
||||
L"Far", L"ConEmu64", L"putty", L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
|
||||
int maxItems = 500, maxMiB = 200, maxImageMiB = 20;
|
||||
std::wstring toggle = L"Ctrl+Alt+Shift+Space", casing = L"Ctrl+Alt+Shift+C",
|
||||
translit = L"Ctrl+Alt+Shift+T", undo = L"Ctrl+Alt+Shift+Z", clipboard = L"Ctrl+Shift+V";
|
||||
|
||||
+47
-1
@@ -107,9 +107,55 @@ wchar_t physicalKey(DWORD scan, bool shift)
|
||||
return (shift ? physicalLayout().shifted : physicalLayout().normal)[i];
|
||||
return 0;
|
||||
}
|
||||
static HWND inputWindow(HWND w)
|
||||
{
|
||||
if (!w || !IsWindow(w))
|
||||
return nullptr;
|
||||
GUITHREADINFO info{sizeof(info)};
|
||||
DWORD thread = w == GetForegroundWindow() ? 0 : GetWindowThreadProcessId(w, nullptr);
|
||||
if (GetGUIThreadInfo(thread, &info) && info.hwndFocus &&
|
||||
(info.hwndFocus == w || IsChild(w, info.hwndFocus) || GetAncestor(info.hwndFocus, GA_ROOT) == w))
|
||||
return info.hwndFocus;
|
||||
return w;
|
||||
}
|
||||
HKL windowLayout(HWND w)
|
||||
{
|
||||
return GetKeyboardLayout(GetWindowThreadProcessId(w, nullptr));
|
||||
HWND input = inputWindow(w);
|
||||
return input ? GetKeyboardLayout(GetWindowThreadProcessId(input, nullptr)) : nullptr;
|
||||
}
|
||||
bool switchWindowLayout(HWND target, HKL layout, const std::function<bool()> &valid)
|
||||
{
|
||||
HWND input = inputWindow(target);
|
||||
auto unchanged = [&]
|
||||
{ return input && valid() && GetForegroundWindow() == target && inputWindow(target) == input; };
|
||||
if (!layout || !unchanged())
|
||||
return false;
|
||||
if (windowLayout(target) == layout)
|
||||
return true;
|
||||
// Windows directs layout requests to the focused control, which can belong
|
||||
// to a different UI thread/process than a Teams/WebView top-level window.
|
||||
if (!PostMessageW(input, WM_INPUTLANGCHANGEREQUEST, 0, (LPARAM)layout))
|
||||
return false;
|
||||
for (int i = 0; i < 30; ++i)
|
||||
{
|
||||
if (!unchanged())
|
||||
return false;
|
||||
if (windowLayout(target) == layout)
|
||||
return true;
|
||||
// Some hosts handle this at their top level instead of the focused child.
|
||||
if (i == 14 && input != target)
|
||||
PostMessageW(target, WM_INPUTLANGCHANGEREQUEST, 0, (LPARAM)layout);
|
||||
Sleep(10);
|
||||
}
|
||||
return unchanged() && windowLayout(target) == layout;
|
||||
}
|
||||
void showRequestedWindow(HWND window)
|
||||
{
|
||||
ShowWindow(window, SW_SHOW);
|
||||
// STARTF_USESHOWWINDOW can override the process's first ShowWindow with
|
||||
// SW_HIDE. An explicit user request must still make the window visible.
|
||||
if (!IsWindowVisible(window))
|
||||
ShowWindow(window, SW_SHOW);
|
||||
}
|
||||
bool activateWindow(HWND window)
|
||||
{
|
||||
|
||||
@@ -24,7 +24,9 @@ std::vector<Layout> readLayouts();
|
||||
const Layout &physicalLayout();
|
||||
wchar_t physicalKey(DWORD scan, bool shift);
|
||||
HKL windowLayout(HWND);
|
||||
bool switchWindowLayout(HWND, HKL, const std::function<bool()> &valid);
|
||||
bool activateWindow(HWND);
|
||||
void showRequestedWindow(HWND);
|
||||
bool ownsClipboard(HWND);
|
||||
bool webViewDescendant(DWORD owner, DWORD host,
|
||||
const std::function<std::optional<std::pair<DWORD, std::wstring>>(DWORD)> &read);
|
||||
|
||||
@@ -62,6 +62,29 @@ void validateSettings(const Settings &s)
|
||||
throw std::runtime_error("Keyboard shortcuts must be unique");
|
||||
used.push_back(*key);
|
||||
}
|
||||
if (s.autoMinLength < 1 || s.autoMinLength > 32)
|
||||
throw std::runtime_error("Minimum word length must be between 1 and 32 letters");
|
||||
if (s.autoMaxRank < 0)
|
||||
throw std::runtime_error("Frequency rank limit must be zero or a positive whole number");
|
||||
for (const auto &characters : {s.autoPrefixChars, s.autoSuffixChars})
|
||||
{
|
||||
if (characters.size() > 32)
|
||||
throw std::runtime_error("Use at most 32 punctuation characters in each delimiter field");
|
||||
for (wchar_t c : characters)
|
||||
if (letter(c) || iswalnum(c) || iswspace(c) || c < 32 || (c >= 0xd800 && c <= 0xdfff))
|
||||
throw std::runtime_error(
|
||||
"Word delimiters must be punctuation, without letters, digits or spaces");
|
||||
}
|
||||
if (s.autoCorrect && s.autoLanguages.size() < 2)
|
||||
throw std::runtime_error("Select at least two languages for automatic correction");
|
||||
if (std::set<std::wstring>(s.autoLanguages.begin(), s.autoLanguages.end()).size() !=
|
||||
s.autoLanguages.size())
|
||||
throw std::runtime_error("Dictionary languages must be unique");
|
||||
for (auto &language : s.autoLanguages)
|
||||
if (language != L"en" && language != L"uk" && language != L"ru")
|
||||
throw std::runtime_error("Unsupported auto-correction language");
|
||||
if (s.autoNever.size() > 10000 || s.autoExcluded.size() > 1000)
|
||||
throw std::runtime_error("Too many auto-correction exceptions");
|
||||
if (s.maxItems < 10 || s.maxItems > 10000 || s.maxMiB < 1 || s.maxMiB > 2048 || s.maxImageMiB < 1 ||
|
||||
s.maxImageMiB > 64)
|
||||
throw std::runtime_error("History limits: 10–10000 items, 1–2048 MiB total, 1–64 MiB per image");
|
||||
@@ -100,6 +123,20 @@ Settings loadSettings(const std::filesystem::path &path, const std::vector<Layou
|
||||
s.autostart = j.value("startWithSystem", false);
|
||||
s.elevatedAutostart = j.value("autostartAsAdministrator", false);
|
||||
s.awake = j.value("preventScreenLock", false);
|
||||
s.autoCorrect = j.value("autoCorrectLayout", false);
|
||||
s.autoMaxRank = j.value("autoCorrectMaxRank", 0);
|
||||
s.autoMinLength = j.value("autoCorrectMinLength", 3);
|
||||
s.autoPrefixChars = str(j, "autoCorrectPrefixChars", s.autoPrefixChars);
|
||||
s.autoSuffixChars = str(j, "autoCorrectSuffixChars", s.autoSuffixChars);
|
||||
for (auto pair : {std::pair{"autoCorrectLanguages", &s.autoLanguages},
|
||||
{"autoCorrectNever", &s.autoNever},
|
||||
{"autoCorrectExcludedApps", &s.autoExcluded}})
|
||||
if (j.contains(pair.first))
|
||||
{
|
||||
pair.second->clear();
|
||||
for (auto &item : j.at(pair.first))
|
||||
pair.second->push_back(wide(item.get<std::string>()));
|
||||
}
|
||||
s.maxItems = std::clamp(j.value("maxItems", 500), 10, 10000);
|
||||
s.maxMiB = std::clamp(j.value("maxTotalMegabytes", 200), 1, 2048);
|
||||
s.maxImageMiB = std::clamp(j.value("maxImageMegabytes", 20), 1, 64);
|
||||
@@ -160,6 +197,19 @@ void saveSettings(const std::filesystem::path &path, const Settings &s)
|
||||
j["layouts"] = Json::array();
|
||||
for (auto &id : s.cycle)
|
||||
j["layouts"].push_back(utf8(id));
|
||||
j["autoCorrectLayout"] = s.autoCorrect;
|
||||
j["autoCorrectMaxRank"] = s.autoMaxRank;
|
||||
j["autoCorrectMinLength"] = s.autoMinLength;
|
||||
j["autoCorrectPrefixChars"] = utf8(s.autoPrefixChars);
|
||||
j["autoCorrectSuffixChars"] = utf8(s.autoSuffixChars);
|
||||
for (auto pair : {std::pair{"autoCorrectLanguages", &s.autoLanguages},
|
||||
{"autoCorrectNever", &s.autoNever},
|
||||
{"autoCorrectExcludedApps", &s.autoExcluded}})
|
||||
{
|
||||
j[pair.first] = Json::array();
|
||||
for (auto &item : *pair.second)
|
||||
j[pair.first].push_back(utf8(item));
|
||||
}
|
||||
j["layoutHotkeys"] = Json::array();
|
||||
for (size_t i = 0; i < std::min(s.directIds.size(), s.direct.size()); ++i)
|
||||
j["layoutHotkeys"].push_back({{"layoutId", utf8(s.directIds[i])}, {"hotkey", utf8(s.direct[i])}});
|
||||
|
||||
+170
-5
@@ -8,6 +8,7 @@
|
||||
#include <map>
|
||||
#include <richedit.h>
|
||||
#include <set>
|
||||
#include <shellapi.h>
|
||||
#include <sstream>
|
||||
|
||||
namespace ks
|
||||
@@ -648,6 +649,7 @@ void fillSettings(HWND w, SettingsView *v)
|
||||
check(w, 204, s.autostart);
|
||||
check(w, 206, s.elevatedAutostart);
|
||||
check(w, 205, s.awake);
|
||||
check(w, 207, s.autoCorrect);
|
||||
value(w, 210, s.toggle);
|
||||
value(w, 211, s.casing);
|
||||
value(w, 212, s.translit);
|
||||
@@ -695,6 +697,7 @@ Settings collectSettings(HWND w, SettingsView *v)
|
||||
s.autostart = checked(w, 204);
|
||||
s.elevatedAutostart = checked(w, 206);
|
||||
s.awake = checked(w, 205);
|
||||
s.autoCorrect = checked(w, 207);
|
||||
s.toggle = value(w, 210);
|
||||
s.casing = value(w, 211);
|
||||
s.translit = value(w, 212);
|
||||
@@ -723,6 +726,158 @@ Settings collectSettings(HWND w, SettingsView *v)
|
||||
validateSettings(s);
|
||||
return s;
|
||||
}
|
||||
struct AutoOptionsView
|
||||
{
|
||||
HWND parent;
|
||||
SettingsView *settings;
|
||||
};
|
||||
std::wstring commaList(const std::vector<std::wstring> &items)
|
||||
{
|
||||
std::wstring text;
|
||||
for (auto &item : items)
|
||||
{
|
||||
if (!text.empty())
|
||||
text += L", ";
|
||||
text += item;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
std::vector<std::wstring> parseCommaList(const std::wstring &text)
|
||||
{
|
||||
std::vector<std::wstring> items;
|
||||
std::wistringstream input(text);
|
||||
std::wstring item;
|
||||
while (std::getline(input, item, L','))
|
||||
{
|
||||
auto first = item.find_first_not_of(L" \t\r\n"), last = item.find_last_not_of(L" \t\r\n");
|
||||
if (first != item.npos)
|
||||
items.push_back(item.substr(first, last - first + 1));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
LRESULT CALLBACK autoOptionsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
auto *view = (AutoOptionsView *)GetWindowLongPtrW(w, GWLP_USERDATA);
|
||||
if (!view)
|
||||
return DefWindowProcW(w, msg, wp, lp);
|
||||
if (msg == WM_COMMAND && LOWORD(wp) == 407)
|
||||
{
|
||||
auto folder = std::filesystem::path(executable()).parent_path() / L"dictionaries";
|
||||
if ((INT_PTR)ShellExecuteW(w, L"open", folder.c_str(), nullptr, nullptr, SW_SHOWNORMAL) <= 32)
|
||||
MessageBoxW(w, L"Could not open the dictionary folder.", L"KSwitcher", MB_OK | MB_ICONERROR);
|
||||
return 0;
|
||||
}
|
||||
if (msg == WM_COMMAND && LOWORD(wp) == IDOK)
|
||||
{
|
||||
try
|
||||
{
|
||||
auto draft = view->settings->draft;
|
||||
auto lengthText = value(w, 410);
|
||||
if (lengthText.empty() || lengthText.size() > 2 ||
|
||||
lengthText.find_first_not_of(L"0123456789") != lengthText.npos)
|
||||
throw std::runtime_error("Minimum word length must be between 1 and 32 letters");
|
||||
draft.autoMinLength = std::stoi(lengthText);
|
||||
auto rankText = value(w, 406);
|
||||
if (rankText.empty() || rankText.find_first_not_of(L"0123456789") != rankText.npos)
|
||||
throw std::runtime_error("Enter a non-negative whole number for the frequency rank limit");
|
||||
try
|
||||
{
|
||||
draft.autoMaxRank = std::stoi(rankText);
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
throw std::runtime_error("Frequency rank limit is too large");
|
||||
}
|
||||
draft.autoLanguages.clear();
|
||||
if (checked(w, 401))
|
||||
draft.autoLanguages.push_back(L"en");
|
||||
if (checked(w, 402))
|
||||
draft.autoLanguages.push_back(L"uk");
|
||||
if (checked(w, 403))
|
||||
draft.autoLanguages.push_back(L"ru");
|
||||
draft.autoNever = parseCommaList(value(w, 405));
|
||||
draft.autoExcluded = parseCommaList(value(w, 404));
|
||||
draft.autoPrefixChars = value(w, 408);
|
||||
draft.autoSuffixChars = value(w, 409);
|
||||
if (draft.autoLanguages.size() < 2)
|
||||
throw std::runtime_error("Select at least two languages");
|
||||
validateSettings(draft);
|
||||
view->settings->draft.autoLanguages = draft.autoLanguages;
|
||||
view->settings->draft.autoNever = draft.autoNever;
|
||||
view->settings->draft.autoExcluded = draft.autoExcluded;
|
||||
view->settings->draft.autoMaxRank = draft.autoMaxRank;
|
||||
view->settings->draft.autoMinLength = draft.autoMinLength;
|
||||
view->settings->draft.autoPrefixChars = draft.autoPrefixChars;
|
||||
view->settings->draft.autoSuffixChars = draft.autoSuffixChars;
|
||||
DestroyWindow(w);
|
||||
}
|
||||
catch (const std::exception &e)
|
||||
{
|
||||
error(w, e);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if ((msg == WM_COMMAND && LOWORD(wp) == IDCANCEL) || msg == WM_CLOSE)
|
||||
{
|
||||
DestroyWindow(w);
|
||||
return 0;
|
||||
}
|
||||
if (msg == WM_DPICHANGED)
|
||||
{
|
||||
resizeForDpi(w, wp, lp);
|
||||
return 0;
|
||||
}
|
||||
if (msg == WM_NCDESTROY)
|
||||
{
|
||||
EnableWindow(view->parent, TRUE);
|
||||
SetForegroundWindow(view->parent);
|
||||
disposeFont(w);
|
||||
delete view;
|
||||
SetWindowLongPtrW(w, GWLP_USERDATA, 0);
|
||||
}
|
||||
return DefWindowProcW(w, msg, wp, lp);
|
||||
}
|
||||
void showAutoOptions(HWND parent, SettingsView *v)
|
||||
{
|
||||
HWND w = makeWindow(*v->app, L"KSwitcher.AutoOptions", L"Automatic layout correction", autoOptionsProc,
|
||||
560, 680);
|
||||
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
|
||||
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR) new AutoOptionsView{parent, v});
|
||||
label(w, L"Offline dictionaries (only layouts in your cycle are used)", 18, 16, 525);
|
||||
checkbox(w, L"English", 401, 18, 48, 150);
|
||||
checkbox(w, L"Ukrainian", 402, 188, 48, 150);
|
||||
checkbox(w, L"Russian", 403, 358, 48, 150);
|
||||
for (auto &lang : v->draft.autoLanguages)
|
||||
check(w, lang == L"en" ? 401 : lang == L"uk" ? 402 : 403, true);
|
||||
label(w, L"Excluded applications (process names, comma-separated)", 18, 94, 525);
|
||||
edit(w, 404, 18, 122, 524, 84, ES_MULTILINE | WS_VSCROLL | ES_AUTOVSCROLL);
|
||||
value(w, 404, commaList(v->draft.autoExcluded));
|
||||
label(w, L"Personal dictionary: never correct these words (comma-separated)", 18, 220, 525);
|
||||
edit(w, 405, 18, 248, 524, 84, ES_MULTILINE | WS_VSCROLL | ES_AUTOVSCROLL);
|
||||
value(w, 405, commaList(v->draft.autoNever));
|
||||
label(w, L"Corrects after Space and a short pause.", 18, 344, 525);
|
||||
label(w, L"Undo skips this word until KSwitcher restarts.", 18, 366, 525);
|
||||
label(w, L"Frequency rank limit (0 = entire dictionary)", 18, 400, 390);
|
||||
edit(w, 406, 420, 396, 122, 26, ES_NUMBER);
|
||||
value(w, 406, std::to_wstring(v->draft.autoMaxRank));
|
||||
label(w, L"Minimum word length (1–32 letters)", 18, 434, 390);
|
||||
edit(w, 410, 420, 430, 122, 26, ES_NUMBER);
|
||||
value(w, 410, std::to_wstring(v->draft.autoMinLength));
|
||||
label(w, L"Characters before a word", 18, 474, 250);
|
||||
label(w, L"Characters after a word", 290, 474, 250);
|
||||
edit(w, 408, 18, 500, 252);
|
||||
edit(w, 409, 290, 500, 252);
|
||||
value(w, 408, v->draft.autoPrefixChars);
|
||||
value(w, 409, v->draft.autoSuffixChars);
|
||||
label(w, L"Enter punctuation without spaces or separators. These characters are kept.", 18, 530, 525);
|
||||
button(w, L"Open dictionary folder", 407, 18, 562, 220);
|
||||
label(w, L"After editing files, click OK here, then Save in Settings to reload.", 18, 600, 525);
|
||||
button(w, L"OK", IDOK, 326, 632, 100);
|
||||
button(w, L"Cancel", IDCANCEL, 440, 632, 100);
|
||||
EnableWindow(parent, FALSE);
|
||||
showRequestedWindow(w);
|
||||
SetForegroundWindow(w);
|
||||
}
|
||||
LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
if (msg == WM_DPICHANGED)
|
||||
@@ -802,6 +957,11 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
saveSettings(path, s);
|
||||
return 0;
|
||||
}
|
||||
if (id == 255)
|
||||
{
|
||||
showAutoOptions(w, v);
|
||||
return 0;
|
||||
}
|
||||
if (id == 252)
|
||||
{
|
||||
showSnippets(*v->app);
|
||||
@@ -1040,7 +1200,10 @@ void showSettings(App &app)
|
||||
if (app.settingsWindow)
|
||||
{
|
||||
ShowWindow(app.settingsWindow, SW_RESTORE);
|
||||
SetForegroundWindow(app.settingsWindow);
|
||||
HWND target = app.settingsWindow;
|
||||
if (!IsWindowEnabled(target))
|
||||
target = GetLastActivePopup(target);
|
||||
activateWindow(target);
|
||||
return;
|
||||
}
|
||||
HWND w = makeWindow(app, L"KSwitcher.Settings", L"KSwitcher settings", settingsProc, 780, 660);
|
||||
@@ -1060,6 +1223,7 @@ void showSettings(App &app)
|
||||
label(w, names[i], 420, 48 + i * 30, 115);
|
||||
hotkeyEdit(app, w, 210 + i, 540, 44 + i * 30, 220);
|
||||
}
|
||||
checkbox(w, L"Automatically correct layout (experimental)", 207, 420, 195, 350);
|
||||
label(w, L"Layout cycle — top to bottom", 18, 210, 400);
|
||||
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER, 220,
|
||||
18, 232, 470, 190);
|
||||
@@ -1080,14 +1244,15 @@ void showSettings(App &app)
|
||||
button(w, L"Clear history…", 254, 580, 474, 180);
|
||||
label(w, L"Exclude applications (process names, comma-separated)", 18, 516, 700);
|
||||
edit(w, 243, 18, 544, 742);
|
||||
button(w, L"Auto-correction options…", 255, 18, 575, 240);
|
||||
button(w, L"Import settings…", 250, 18, 606, 145);
|
||||
button(w, L"Export settings…", 251, 173, 606, 145);
|
||||
button(w, L"Snippets…", 252, 328, 606, 110);
|
||||
button(w, L"Save", IDOK, 548, 606, 100);
|
||||
button(w, L"Cancel", IDCANCEL, 660, 606, 100);
|
||||
fillSettings(w, (SettingsView *)GetWindowLongPtrW(w, GWLP_USERDATA));
|
||||
ShowWindow(w, SW_SHOW);
|
||||
SetForegroundWindow(w);
|
||||
showRequestedWindow(w);
|
||||
activateWindow(w);
|
||||
}
|
||||
void showSnippets(App &app)
|
||||
{
|
||||
@@ -1112,7 +1277,7 @@ void showSnippets(App &app)
|
||||
button(w, L"Save", IDOK, 510, 478, 100);
|
||||
button(w, L"Cancel", IDCANCEL, 624, 478, 100);
|
||||
fillSnippets(w, (SnippetView *)GetWindowLongPtrW(w, GWLP_USERDATA));
|
||||
ShowWindow(w, SW_SHOW);
|
||||
showRequestedWindow(w);
|
||||
SetForegroundWindow(w);
|
||||
}
|
||||
void showShortcuts(App &app)
|
||||
@@ -1145,7 +1310,7 @@ void showEntry(App &app, const std::wstring &id)
|
||||
WS_THICKFRAME | WS_MAXIMIZEBOX);
|
||||
auto field = edit(w, 1, 10, 10, 700, 480, ES_MULTILINE | ES_READONLY | ES_AUTOVSCROLL | WS_VSCROLL);
|
||||
SetWindowTextW(field, text.c_str());
|
||||
ShowWindow(w, SW_SHOW);
|
||||
showRequestedWindow(w);
|
||||
}
|
||||
catch (const std::exception &e)
|
||||
{
|
||||
|
||||
+97
-1
@@ -8,6 +8,10 @@ HWND host{}, field{};
|
||||
ks::Clip saved;
|
||||
bool captured = false, failed = false;
|
||||
int stage = 0, ru = -1, en = -1;
|
||||
size_t punctuationCase = 0;
|
||||
const std::vector<std::pair<std::wstring, std::wstring>> punctuationCases{
|
||||
{L"(ljvf) ", L"(дома) "}, {L"[ljvf] ", L"[дома] "}, {L"{ljvf} ", L"{дома} "},
|
||||
{L"ljvf: ", L"дома: "}, {L"ljvf; ", L"дома; "}, {L"#ljvf%; ", L"#дома%; "}};
|
||||
ULONGLONG deadline{};
|
||||
std::wstring sentinel = L"KSwitcher integration clipboard sentinel";
|
||||
void require(bool ok, const char *name)
|
||||
@@ -58,6 +62,9 @@ void finish()
|
||||
}
|
||||
LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
// Model a host that does not forward layout requests to its embedded editor.
|
||||
if (msg == WM_INPUTLANGCHANGEREQUEST)
|
||||
return 0;
|
||||
if (msg == WM_TIMER)
|
||||
{
|
||||
try
|
||||
@@ -119,9 +126,98 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
require(start == end && start == 14, "verified snippet cursor placement");
|
||||
require(ks::readClipboard(app->window).text() == sentinel,
|
||||
"clipboard restored after repeated copy transactions");
|
||||
finish();
|
||||
app->settings.autoCorrect = true;
|
||||
app->settings.switchLayout = true;
|
||||
app->settings.autoLanguages = {L"en", L"ru"};
|
||||
app->settings.cycle = {app->layouts[en].id, app->layouts[ru].id};
|
||||
ActivateKeyboardLayout(app->layouts[en].handle, 0);
|
||||
prepare(L"ghbdtn ", false);
|
||||
record(L"ghbdtn ");
|
||||
app->transform(ks::AutoCorrect);
|
||||
break;
|
||||
}
|
||||
case 8:
|
||||
require(text() == L"привет ", "automatic dictionary correction in native editor");
|
||||
require(ks::windowLayout(host) == app->layouts[ru].handle,
|
||||
"auto-correction switches focused editor layout even when host ignores requests");
|
||||
app->transform(ks::Undo);
|
||||
break;
|
||||
case 9:
|
||||
require(text() == L"ghbdtn ", "automatic correction undo retains space");
|
||||
require(ks::windowLayout(host) == app->layouts[en].handle,
|
||||
"undo restores the original keyboard layout");
|
||||
prepare(L"ghbdtn ", false);
|
||||
record(L"ghbdtn ");
|
||||
app->transform(ks::AutoCorrect);
|
||||
break;
|
||||
case 10:
|
||||
require(text() == L"ghbdtn ", "undo prevents repeated automatic correction");
|
||||
prepare(L"hello ", false);
|
||||
record(L"hello ");
|
||||
app->transform(ks::AutoCorrect);
|
||||
break;
|
||||
case 11:
|
||||
require(text() == L"hello ", "known word stays unchanged in editor");
|
||||
require(ks::readClipboard(app->window).text() == sentinel,
|
||||
"automatic correction restores clipboard");
|
||||
app->autoCorrector = ks::AutoCorrector{};
|
||||
prepare(L"OLDghbdtn ", false);
|
||||
record(L"ghbdtn ");
|
||||
app->transform(ks::AutoCorrect);
|
||||
break;
|
||||
case 12:
|
||||
require(text() == L"OLDghbdtn ", "automatic correction refuses partial word replacement");
|
||||
DestroyWindow(field);
|
||||
field = CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER | ES_PASSWORD, 20, 20,
|
||||
590, 30, host, nullptr, GetModuleHandleW(nullptr), nullptr);
|
||||
prepare(L"ghbdtn ", false);
|
||||
record(L"ghbdtn ");
|
||||
app->transform(ks::AutoCorrect);
|
||||
break;
|
||||
case 13:
|
||||
require(text() == L"ghbdtn ", "password input is never automatically corrected");
|
||||
require(ks::readClipboard(app->window).text() == sentinel,
|
||||
"password input never copied to clipboard");
|
||||
DestroyWindow(field);
|
||||
field = CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER, 20, 20, 590, 30, host,
|
||||
nullptr, GetModuleHandleW(nullptr), nullptr);
|
||||
prepare(L"(ljvf ", false);
|
||||
record(L"(ljvf ");
|
||||
app->transform(ks::AutoCorrect);
|
||||
break;
|
||||
case 14:
|
||||
require(text() == L"(дома ", "automatic correction preserves opening parenthesis in editor");
|
||||
app->transform(ks::Undo);
|
||||
break;
|
||||
case 15:
|
||||
require(text() == L"(ljvf ", "undo preserves opening parenthesis in editor");
|
||||
require(ks::readClipboard(app->window).text() == sentinel,
|
||||
"parenthesized correction restores clipboard");
|
||||
app->settings.autoPrefixChars += L"#";
|
||||
app->settings.autoSuffixChars += L"%";
|
||||
app->autoCorrector = ks::AutoCorrector{};
|
||||
prepare(punctuationCases[0].first.c_str(), false);
|
||||
record(punctuationCases[0].first);
|
||||
app->transform(ks::AutoCorrect);
|
||||
break;
|
||||
case 16:
|
||||
require(text() == punctuationCases[punctuationCase].second,
|
||||
"configured punctuation preserved in editor");
|
||||
if (++punctuationCase < punctuationCases.size())
|
||||
{
|
||||
ActivateKeyboardLayout(app->layouts[en].handle, 0);
|
||||
prepare(punctuationCases[punctuationCase].first.c_str(), false);
|
||||
record(punctuationCases[punctuationCase].first);
|
||||
app->transform(ks::AutoCorrect);
|
||||
stage = 16;
|
||||
}
|
||||
else
|
||||
app->transform(ks::Undo);
|
||||
break;
|
||||
case 17:
|
||||
require(text() == punctuationCases.back().first, "undo preserves custom punctuation");
|
||||
finish();
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (const std::exception &e)
|
||||
|
||||
+153
-1
@@ -1,3 +1,4 @@
|
||||
#include "autocorrect.h"
|
||||
#include "ocr.h"
|
||||
#include "richtext.h"
|
||||
#include "storage.h"
|
||||
@@ -29,10 +30,34 @@ void rejects(const std::function<void()> &f, const char *name)
|
||||
require(thrown, name);
|
||||
}
|
||||
} // namespace
|
||||
int main()
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
if (argc > 1 && std::string(argv[1]) == "--hidden-start-window")
|
||||
{
|
||||
HWND w = CreateWindowExW(0, L"STATIC", L"KSwitcher visibility regression", WS_POPUP, 0, 0, 1, 1,
|
||||
nullptr, nullptr, GetModuleHandleW(nullptr), nullptr);
|
||||
ks::showRequestedWindow(w);
|
||||
bool visible = w && IsWindowVisible(w);
|
||||
if (w)
|
||||
DestroyWindow(w);
|
||||
return visible ? 0 : 1;
|
||||
}
|
||||
try
|
||||
{
|
||||
STARTUPINFOW startup{sizeof(startup)};
|
||||
startup.dwFlags = STARTF_USESHOWWINDOW;
|
||||
startup.wShowWindow = SW_HIDE;
|
||||
PROCESS_INFORMATION child{};
|
||||
std::wstring command = L"\"" + ks::executable() + L"\" --hidden-start-window";
|
||||
require(CreateProcessW(nullptr, command.data(), nullptr, nullptr, FALSE, 0, nullptr, nullptr,
|
||||
&startup, &child),
|
||||
"start visibility test with hidden startup state");
|
||||
DWORD wait = WaitForSingleObject(child.hProcess, 10000), exitCode = 1;
|
||||
GetExitCodeProcess(child.hProcess, &exitCode);
|
||||
CloseHandle(child.hThread);
|
||||
CloseHandle(child.hProcess);
|
||||
require(wait == WAIT_OBJECT_0 && exitCode == 0,
|
||||
"first explicit window request is visible after hidden process launch");
|
||||
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
||||
HWND richParent = CreateWindowExW(0, L"STATIC", L"Rich preview test", WS_POPUP, 0, 0, 300, 200,
|
||||
nullptr, nullptr, GetModuleHandleW(nullptr), nullptr);
|
||||
@@ -93,6 +118,100 @@ int main()
|
||||
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::AutoCorrector corrector;
|
||||
ks::Settings autoSettings;
|
||||
require(!autoSettings.autoCorrect, "auto-correction defaults off");
|
||||
autoSettings.cycle = {L"en", L"uk"};
|
||||
corrector.configure(L"dictionaries", autoSettings.autoLanguages);
|
||||
auto suggested = corrector.suggest(L"ghbdsn ", 0, layouts, autoSettings);
|
||||
require(suggested && suggested->text == L"привіт " && suggested->target == 2,
|
||||
"dictionary corrects Ukrainian and retains trailing space");
|
||||
suggested = corrector.suggest(L"lsv ", 0, layouts, autoSettings);
|
||||
require(suggested && suggested->text == L"дім " && suggested->target == 2,
|
||||
"three-letter Ukrainian word is corrected");
|
||||
autoSettings.autoMinLength = 4;
|
||||
require(!corrector.suggest(L"lsv ", 0, layouts, autoSettings),
|
||||
"configured minimum excludes short words");
|
||||
require(!corrector.suggest(L"(lsv); ", 0, layouts, autoSettings),
|
||||
"punctuation does not count towards minimum length");
|
||||
autoSettings.autoMinLength = 3;
|
||||
require(!corrector.suggest(L"the ", 0, layouts, autoSettings),
|
||||
"known three-letter English word stays unchanged");
|
||||
require(!corrector.suggest(L"ls ", 0, layouts, autoSettings),
|
||||
"two-letter input remains below automatic correction minimum");
|
||||
suggested = corrector.suggest(L"ljvfiyz ", 0, layouts, autoSettings);
|
||||
require(suggested && suggested->text == L"домашня " && suggested->target == 2,
|
||||
"less frequent Ukrainian inflections are eligible for correction");
|
||||
require(autoSettings.autoMaxRank == 0, "default frequency limit includes entire dictionary");
|
||||
autoSettings.autoMaxRank = 21944;
|
||||
require(!corrector.suggest(L"ljvfiyz ", 0, layouts, autoSettings), "rank above limit is excluded");
|
||||
autoSettings.autoMaxRank = 21945;
|
||||
require(corrector.suggest(L"ljvfiyz ", 0, layouts, autoSettings).has_value(),
|
||||
"rank limit is inclusive");
|
||||
autoSettings.autoMaxRank = 1;
|
||||
require(!corrector.suggest(L"hello ", 0, layouts, autoSettings),
|
||||
"rank limit never disables known-word protection");
|
||||
autoSettings.autoMaxRank = 0;
|
||||
suggested = corrector.suggest(L"(ljvf ", 0, layouts, autoSettings);
|
||||
require(suggested && suggested->text == L"(дома ", "opening parenthesis is preserved");
|
||||
for (auto pair : {std::pair{L"(ljvf) ", L"(дома) "},
|
||||
{L"[ljvf] ", L"[дома] "},
|
||||
{L"{ljvf} ", L"{дома} "},
|
||||
{L"«ljvf» ", L"«дома» "},
|
||||
{L"“ljvf” ", L"“дома” "},
|
||||
{L"ljvf) ", L"дома) "},
|
||||
{L"([{ljvf}]), ", L"([{дома}]), "}})
|
||||
{
|
||||
suggested = corrector.suggest(pair.first, 0, layouts, autoSettings);
|
||||
require(suggested && suggested->text == pair.second, "paired and closing delimiters preserved");
|
||||
}
|
||||
require(ks::autoClosingSelection(L")] "), "closing-only selection can be extended");
|
||||
require(!ks::autoClosingSelection(L"word) ") && !ks::autoClosingSelection(L"@ "),
|
||||
"selection extension excludes words and arbitrary punctuation");
|
||||
require(!corrector.suggest(L"user@(ljvf) ", 0, layouts, autoSettings),
|
||||
"wrapped email suffix is not a word");
|
||||
autoSettings.autoPrefixChars += L"#";
|
||||
autoSettings.autoSuffixChars += L"%";
|
||||
suggested = corrector.suggest(L"#ljvf%; ", 0, layouts, autoSettings);
|
||||
require(suggested && suggested->text == L"#дома%; ", "custom delimiters are preserved");
|
||||
require(ks::autoClosingSelection(L"%; ", autoSettings), "custom closing selection is supported");
|
||||
autoSettings.autoPrefixChars.clear();
|
||||
require(!corrector.suggest(L"(ljvf ", 0, layouts, autoSettings),
|
||||
"removed prefix delimiter is respected");
|
||||
autoSettings.autoSuffixChars.clear();
|
||||
require(!corrector.suggest(L"ljvf; ", 0, layouts, autoSettings),
|
||||
"removed suffix delimiter is respected");
|
||||
autoSettings.autoPrefixChars = ks::Settings{}.autoPrefixChars;
|
||||
autoSettings.autoSuffixChars = ks::Settings{}.autoSuffixChars;
|
||||
ks::Run parenthesized;
|
||||
parenthesized.keys = L"(ljvf ";
|
||||
require(ks::matchAutoInput(parenthesized, L"ljvf ", layouts, en) == 0,
|
||||
"editor selection may omit the opening parenthesis");
|
||||
require(ks::matchAutoInput(parenthesized, L"(ljvf ", layouts, en) == 0,
|
||||
"editor selection may include the opening parenthesis");
|
||||
parenthesized.keys = L"OLDljvf ";
|
||||
require(ks::matchAutoInput(parenthesized, L"ljvf ", layouts, en) < 0,
|
||||
"automatic verification never accepts an arbitrary word suffix");
|
||||
suggested = corrector.suggest(L"Ghbdsn! ", 0, layouts, autoSettings);
|
||||
require(suggested && suggested->text == L"Привіт! ", "title case and punctuation retained");
|
||||
suggested = corrector.suggest(L"руддщ ", 2, layouts, autoSettings);
|
||||
require(suggested && suggested->text == L"hello ", "dictionary corrects English");
|
||||
for (auto word : {L"hello ", L"привіт ", L"GHBDSN ", L"ghBdsn ", L"ghbdsn123 ", L"user@ghbdsn ",
|
||||
L"https://ghbdsn ", L"gh ", L"qzxqzxqzx ", L"oldghbdsn "})
|
||||
require(!corrector.suggest(word, 0, layouts, autoSettings), "unsafe or known word unchanged");
|
||||
autoSettings.autoNever = {L"ghbdsn"};
|
||||
require(!corrector.suggest(L"Ghbdsn ", 0, layouts, autoSettings), "personal exception ignores case");
|
||||
autoSettings.autoNever.clear();
|
||||
autoSettings.cycle = {L"en"};
|
||||
require(!corrector.suggest(L"ghbdsn ", 0, layouts, autoSettings), "disabled target layout excluded");
|
||||
autoSettings.cycle = {L"en", L"uk"};
|
||||
corrector.reject(L"Ghbdsn; ");
|
||||
require(!corrector.suggest(L"ghbdsn ", 0, layouts, autoSettings),
|
||||
"undo rejection survives punctuation");
|
||||
require(corrector.memoryBytes() < 3 * 1024 * 1024, "two dictionaries fit within 3 MiB");
|
||||
require(!ks::autoCorrectionAllowed(nullptr, autoSettings), "missing focused editor fails closed");
|
||||
ks::AutoCorrector missing;
|
||||
rejects([&] { missing.configure(L"missing-dictionaries", {L"en"}); }, "missing dictionary rejected");
|
||||
require(ks::convertTo(L"ghbdtn", layouts, 1, 0) == L"привет", "hint conversion");
|
||||
require(ks::convertTo(L"привет", layouts, 1, 1) == L"привет", "same source is unchanged");
|
||||
require(ks::convertTo(L"?", layouts, 1, 0) == L",", "punctuation conversion");
|
||||
@@ -228,6 +347,13 @@ int main()
|
||||
}
|
||||
ks::Settings settings;
|
||||
settings.elevatedAutostart = true;
|
||||
settings.autoCorrect = true;
|
||||
settings.autoMaxRank = 10000;
|
||||
settings.autoMinLength = 5;
|
||||
settings.autoPrefixChars = L"(#";
|
||||
settings.autoSuffixChars = L");%";
|
||||
settings.autoNever = {L"myWord"};
|
||||
settings.autoExcluded = {L"Code.exe"};
|
||||
settings.cycle = {L"en", L"uk"};
|
||||
settings.snippets = {{L";address", L"Line 1\r\n{cursor}Line 2"}};
|
||||
ks::saveSettings(temp / L"settings.json", settings);
|
||||
@@ -235,6 +361,32 @@ int main()
|
||||
require(restored.snippets[0].text == settings.snippets[0].text, "settings snippet roundtrip");
|
||||
require(restored.cycle == settings.cycle, "layout order roundtrip");
|
||||
require(restored.elevatedAutostart, "elevated autostart setting roundtrip");
|
||||
require(restored.autoMaxRank == 10000, "frequency rank limit roundtrip");
|
||||
require(restored.autoMinLength == 5, "minimum word length roundtrip");
|
||||
require(ks::loadSettings(temp / L"defaults.json", {}).autoMinLength == 3,
|
||||
"missing minimum length defaults to three");
|
||||
for (int badLength : {0, 33})
|
||||
{
|
||||
auto invalidLength = settings;
|
||||
invalidLength.autoMinLength = badLength;
|
||||
rejects([&] { ks::validateSettings(invalidLength); }, "invalid minimum word length rejected");
|
||||
}
|
||||
require(restored.autoPrefixChars == settings.autoPrefixChars &&
|
||||
restored.autoSuffixChars == settings.autoSuffixChars,
|
||||
"custom punctuation settings roundtrip");
|
||||
auto invalidCharacters = settings;
|
||||
invalidCharacters.autoPrefixChars = L"a";
|
||||
rejects([&] { ks::validateSettings(invalidCharacters); },
|
||||
"letters cannot be treated as word delimiters");
|
||||
require(ks::loadSettings(temp / L"defaults.json", {}).autoMaxRank == 0,
|
||||
"missing frequency limit defaults to all words");
|
||||
auto invalidRank = settings;
|
||||
invalidRank.autoMaxRank = -1;
|
||||
rejects([&] { ks::validateSettings(invalidRank); }, "negative frequency limit rejected");
|
||||
require(restored.autoCorrect && restored.autoLanguages == settings.autoLanguages &&
|
||||
restored.autoNever == settings.autoNever &&
|
||||
restored.autoExcluded == settings.autoExcluded,
|
||||
"auto-correction settings roundtrip");
|
||||
require(!ks::loadSettings(temp / L"defaults.json", {}).elevatedAutostart,
|
||||
"elevated autostart is opt-in for existing and default settings");
|
||||
auto invalid = settings;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Build compact offline dictionaries from pinned FrequencyWords data (CC BY-SA 4.0)."""
|
||||
from pathlib import Path
|
||||
import urllib.request, hashlib, json
|
||||
ROOT=Path(__file__).resolve().parents[1]/'dictionaries'
|
||||
REV='525f9b560de45753a5ea01069454e72e9aa541c6'
|
||||
ROOT.mkdir(exist_ok=True)
|
||||
manifest={}
|
||||
for lang in ('en','uk','ru'):
|
||||
url=f'https://raw.githubusercontent.com/hermitdave/FrequencyWords/{REV}/content/2016/{lang}/{lang}_50k.txt'
|
||||
data=urllib.request.urlopen(url).read()
|
||||
rows={}
|
||||
for rank,line in enumerate(data.decode('utf-8').splitlines(),1):
|
||||
word,count=line.rsplit(' ',1);word=word.lower()
|
||||
alphabet='abcdefghijklmnopqrstuvwxyz' if lang=='en' else ('абвгґдеєжзиіїйклмнопрстуфхцчшщьюя' if lang=='uk' else 'абвгдеёжзийклмнопрстуфхцчшщъыьэюя')
|
||||
if not (2<=len(word)<=40 and all(c in alphabet for c in word)):continue
|
||||
if int(count)<5:continue
|
||||
rows.setdefault(word,rank)
|
||||
out=''.join(f'{word}\t{rows[word]}\n' for word in sorted(rows))
|
||||
(ROOT/f'{lang}.txt').write_bytes(out.encode('utf-8'))
|
||||
manifest[lang]={'source':url,'sourceSha256':hashlib.sha256(data).hexdigest(),'words':len(rows),'bytes':len(out.encode()),'sha256':hashlib.sha256(out.encode()).hexdigest()}
|
||||
(ROOT/'sources.json').write_text(json.dumps(manifest,indent=2)+'\n',encoding='utf-8')
|
||||
print(json.dumps(manifest,indent=2))
|
||||
Reference in New Issue
Block a user