Compare commits

..

No commits in common. "master" and "main" have entirely different histories.
master ... main

56 changed files with 229 additions and 16680 deletions

View file

@ -1,66 +0,0 @@
name: Release
on:
push:
tags: ['v*']
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
name: KeSp_controller-linux-x86_64
- os: windows-latest
target: x86_64-pc-windows-msvc
name: KeSp_controller-windows-x86_64.exe
- os: macos-latest
target: aarch64-apple-darwin
name: KeSp_controller-macos-arm64
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install system deps (Linux)
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libudev-dev libfontconfig1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev libssl-dev libdbus-1-dev
- name: Build
run: cargo build --release --target ${{ matrix.target }}
- name: Rename binary
shell: bash
run: |
src="target/${{ matrix.target }}/release/KeSp_controller"
[ -f "${src}.exe" ] && src="${src}.exe"
cp "$src" "${{ matrix.name }}"
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.name }}
path: ${{ matrix.name }}
release:
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v4
- uses: softprops/action-gh-release@v2
with:
files: |
KeSp_controller-linux-x86_64/KeSp_controller-linux-x86_64
KeSp_controller-windows-x86_64.exe/KeSp_controller-windows-x86_64.exe
KeSp_controller-macos-arm64/KeSp_controller-macos-arm64

5
.gitignore vendored
View file

@ -1,5 +0,0 @@
/target
*.swp
*.swo
*~
.DS_Store

6511
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,22 +0,0 @@
[package]
name = "KeSp_controller"
version = "1.0.0"
edition = "2021"
license = "GPL-3.0"
description = "Cross-platform configurator for the KeSp split ergonomic keyboard"
repository = "https://github.com/mornepousse/KeSp_controller"
[dependencies]
slint = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serialport = "4"
rfd = "0.15"
[build-dependencies]
slint-build = "1"
[profile.release]
opt-level = "s"
lto = true
strip = true

244
LICENSE
View file

@ -1,22 +1,232 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Copyright © 2007 Free Software Foundation, Inc. <https://fsf.org/>
Preamble
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
Preamble
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
The GNU General Public License is a free, copyleft license for software and other kinds of works.
For the complete license text, see <https://www.gnu.org/licenses/gpl-3.0.txt>
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and modification follow.
TERMS AND CONDITIONS
0. Definitions.
“This License” refers to version 3 of the GNU General Public License.
“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.
To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.
A “covered work” means either the unmodified Program or a work based on the Program.
To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
1. Source Code.
The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
The Corresponding Source for a work in source code form is that same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
7. Additional Terms.
“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
11. Patents.
A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
KeSp_controller
Copyright (C) 2026 harrael
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
KeSp_controller Copyright (C) 2026 harrael
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.
You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see <https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read <https://www.gnu.org/philosophy/why-not-lgpl.html>.

View file

@ -1,63 +1,3 @@
# KeSp Controller
# KeSp_controller
Cross-platform configurator for the KeSp split ergonomic keyboard.
Built with [Rust](https://www.rust-lang.org/) and [Slint](https://slint.dev/) UI framework.
![License: GPL-3.0](https://img.shields.io/badge/License-GPLv3-blue.svg)
## Features
- **Keymap editor** with visual keyboard layout (loaded from firmware)
- **Key selector** with categorized grid, Mod-Tap/Layer-Tap builders, hex input
- **Heatmap overlay** showing key press frequency (blue to red gradient)
- **Layer management** with switch, rename, and active indicator
- **Tap Dance** editing (4 actions per slot)
- **Combos** creation with visual key picker
- **Key Overrides** with modifier checkboxes (Ctrl/Shift/Alt)
- **Leader Keys** with sequence builder
- **Macros** with visual step builder (key presses + delays)
- **Statistics** (hand balance, finger load, row usage, top keys, bigrams)
- **OTA firmware update** via USB (no programming cable needed)
- **ESP32 flasher** (esptool-like, via programming port)
- **Settings** with keyboard layout selector (QWERTY, AZERTY, DVORAK, etc.)
- **Dracula theme** throughout
## Download
Pre-built binaries for Linux, Windows, and macOS are available on the [Releases](https://github.com/mornepousse/KeSp_controller/releases) page.
## Build from source
### Requirements
- Rust toolchain (1.75+)
- Linux: `libudev-dev libfontconfig1-dev`
### Build
```bash
cargo build --release
```
Binary will be at `target/release/KeSp_controller`.
## Usage
1. Plug in your KeSp keyboard via USB
2. Launch KeSp Controller
3. The app auto-connects to the keyboard
4. Use the tabs to configure: Keymap, Advanced, Macros, Stats, Settings, Flash
## Keyboard compatibility
Designed for the KeSp/KaSe split keyboard with:
- USB CDC serial (VID: 0xCAFE, PID: 0x4001)
- Binary protocol v2
- ESP32-S3 MCU
## License
GPL-3.0 - See [LICENSE](LICENSE)
Made with [Slint](https://slint.dev/)
KeSp split keyboard configurator — Slint UI port

View file

@ -1,3 +0,0 @@
fn main() {
slint_build::compile("ui/main.slint").unwrap();
}

View file

@ -1,133 +0,0 @@
{
"name": "KaSe V2 Debug",
"rows": 5,
"cols": 13,
"keys": [
{ "row": 0, "col": 6, "x": 6.48, "y": 3.4, "r": 10 },
{ "row": 4, "col": 2, "x": 2.2, "y": 4.8, "w": 1.2 },
{ "row": 4, "col": 3, "x": 3.48, "y": 4.8, "r": 10 },
{ "row": 4, "col": 4, "x": 4.6, "y": 5.0, "r": 20 },
{ "row": 4, "col": 5, "x": 5.6, "y": 5.6, "r": 40 },
{ "row": 4, "col": 6, "x": 6.4, "y": 6.3, "r": 40 },
{ "row": 3, "col": 6, "x": 7.66, "y": 6.3, "r": -40 },
{ "row": 4, "col": 7, "x": 8.49, "y": 5.61, "r": -40 },
{ "row": 4, "col": 8, "x": 9.46, "y": 5.0, "r": -20 },
{ "row": 4, "col": 9, "x": 10.56, "y": 4.7, "r": -10 },
{ "row": 4, "col": 10, "x": 11.76, "y": 4.7, "w": 1.2 },
{ "row": 2, "col": 6, "x": 7.96, "y": 3.4, "r": -10 }
],
"groups": [
{
"x": 0, "y": 0,
"keys": [
{ "row": 4, "col": 0, "x": 0, "y": 0 },
{ "row": 0, "col": 0, "x": 0, "y": 1.08 },
{ "row": 1, "col": 0, "x": 0, "y": 2.16 },
{ "row": 2, "col": 0, "x": 0, "y": 3.24 },
{ "row": 3, "col": 0, "x": 0, "y": 4.32 }
]
},
{
"x": 1.08, "y": 0,
"keys": [
{ "row": 4, "col": 1, "x": 0, "y": 0 },
{ "row": 0, "col": 1, "x": 0, "y": 1.08 },
{ "row": 1, "col": 1, "x": 0, "y": 2.16 },
{ "row": 2, "col": 1, "x": 0, "y": 3.24 },
{ "row": 3, "col": 1, "x": 0, "y": 4.32 }
]
},
{
"x": 2.46, "y": 0.5, "r": 5,
"keys": [
{ "row": 0, "col": 2, "x": 0, "y": 0 },
{ "row": 1, "col": 2, "x": 0, "y": 1.08 },
{ "row": 2, "col": 2, "x": 0, "y": 2.16 },
{ "row": 3, "col": 2, "x": 0, "y": 3.24 }
]
},
{
"x": 3.84, "y": 0.1, "r": 10,
"keys": [
{ "row": 0, "col": 3, "x": 0, "y": 0 },
{ "row": 1, "col": 3, "x": 0, "y": 1.08 },
{ "row": 2, "col": 3, "x": 0, "y": 2.16 },
{ "row": 3, "col": 3, "x": 0, "y": 3.24 }
]
},
{
"x": 4.82, "y": 0.5, "r": 10,
"keys": [
{ "row": 0, "col": 4, "x": 0, "y": 0 },
{ "row": 1, "col": 4, "x": 0, "y": 1.08 },
{ "row": 2, "col": 4, "x": 0, "y": 2.16 },
{ "row": 3, "col": 4, "x": 0, "y": 3.24 }
]
},
{
"x": 5.8, "y": 0.76, "r": 10,
"keys": [
{ "row": 0, "col": 5, "x": 0, "y": 0 },
{ "row": 1, "col": 5, "x": 0, "y": 1.08 },
{ "row": 2, "col": 5, "x": 0, "y": 2.16 },
{ "row": 3, "col": 5, "x": 0, "y": 3.24 }
]
},
{
"x": 8.54, "y": 0.76, "r": -10,
"keys": [
{ "row": 0, "col": 7, "x": 0, "y": 0 },
{ "row": 1, "col": 7, "x": 0, "y": 1.08 },
{ "row": 2, "col": 7, "x": 0, "y": 2.16 },
{ "row": 3, "col": 7, "x": 0, "y": 3.24 }
]
},
{
"x": 9.62, "y": 0.5, "r": -10,
"keys": [
{ "row": 0, "col": 8, "x": 0, "y": 0 },
{ "row": 1, "col": 8, "x": 0, "y": 1.08 },
{ "row": 2, "col": 8, "x": 0, "y": 2.16 },
{ "row": 3, "col": 8, "x": 0, "y": 3.24 }
]
},
{
"x": 10.6, "y": 0.1, "r": -10,
"keys": [
{ "row": 0, "col": 9, "x": 0, "y": 0 },
{ "row": 1, "col": 9, "x": 0, "y": 1.08 },
{ "row": 2, "col": 9, "x": 0, "y": 2.16 },
{ "row": 3, "col": 9, "x": 0, "y": 3.24 }
]
},
{
"x": 11.98, "y": 0.4, "r": -5,
"keys": [
{ "row": 0, "col": 10, "x": 0, "y": 0 },
{ "row": 1, "col": 10, "x": 0, "y": 1.08 },
{ "row": 2, "col": 10, "x": 0, "y": 2.16 },
{ "row": 3, "col": 10, "x": 0, "y": 3.24 }
]
},
{
"x": 13.36, "y": 0,
"keys": [
{ "row": 4, "col": 11, "x": 0, "y": 0 },
{ "row": 0, "col": 11, "x": 0, "y": 1.08 },
{ "row": 1, "col": 11, "x": 0, "y": 2.16 },
{ "row": 2, "col": 11, "x": 0, "y": 3.24 },
{ "row": 3, "col": 11, "x": 0, "y": 4.32 }
]
},
{
"x": 14.44, "y": 0,
"keys": [
{ "row": 4, "col": 12, "x": 0, "y": 0 },
{ "row": 0, "col": 12, "x": 0, "y": 1.08 },
{ "row": 1, "col": 12, "x": 0, "y": 2.16 },
{ "row": 2, "col": 12, "x": 0, "y": 3.24 },
{ "row": 3, "col": 12, "x": 0, "y": 4.32 }
]
}
]
}

View file

@ -1,56 +0,0 @@
{
description = "KeSp split keyboard configurator Slint UI";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
nativeBuildInputs = with pkgs; [
pkg-config
cmake
];
buildInputs = with pkgs; [
fontconfig
libxkbcommon
wayland
udev
] ++ pkgs.lib.optionals pkgs.stdenv.isLinux [
xorg.libX11
xorg.libXcursor
xorg.libXrandr
xorg.libXi
];
in
{
packages.default = pkgs.rustPlatform.buildRustPackage {
pname = "kesp-controller";
version = "1.0.0";
src = ./.;
cargoLock.lockFile = ./Cargo.lock;
inherit nativeBuildInputs buildInputs;
# Slint needs to find fontconfig at runtime
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath buildInputs;
meta = with pkgs.lib; {
description = "Cross-platform configurator for the KeSp split ergonomic keyboard";
license = licenses.gpl3Only;
mainProgram = "KeSp_controller";
};
};
devShells.default = pkgs.mkShell {
inherit nativeBuildInputs buildInputs;
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath buildInputs;
packages = with pkgs; [ cargo rustc rust-analyzer clippy ];
};
});
}

View file

@ -1,283 +0,0 @@
use crate::context::{mod_idx_to_byte, AppContext, BgMsg};
use crate::protocol;
use crate::{AdvancedBridge, AppState, MainWindow};
use slint::{ComponentHandle, Model, SharedString};
/// Wire up all advanced callbacks: refresh, delete combo/leader/KO,
/// set trilayer, BT switch, TAMA action, toggle autoshift, create combo/KO/leader.
pub fn setup(window: &MainWindow, ctx: &AppContext) {
// --- Advanced: refresh ---
{
let serial = ctx.serial.clone();
let tx = ctx.bg_tx.clone();
window.global::<AdvancedBridge>().on_refresh_advanced(move || {
let serial = serial.clone();
let tx = tx.clone();
std::thread::spawn(move || {
use protocol::binary::cmd;
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
if let Ok(r) = ser.send_binary(cmd::TD_LIST, &[]) {
let _ = tx.send(BgMsg::TdList(protocol::parsers::parse_td_binary(&r.payload)));
}
if let Ok(r) = ser.send_binary(cmd::COMBO_LIST, &[]) {
let _ = tx.send(BgMsg::ComboList(protocol::parsers::parse_combo_binary(&r.payload)));
}
if let Ok(r) = ser.send_binary(cmd::LEADER_LIST, &[]) {
let _ = tx.send(BgMsg::LeaderList(protocol::parsers::parse_leader_binary(&r.payload)));
}
if let Ok(r) = ser.send_binary(cmd::KO_LIST, &[]) {
let _ = tx.send(BgMsg::KoList(protocol::parsers::parse_ko_binary(&r.payload)));
}
if let Ok(r) = ser.send_binary(cmd::BT_QUERY, &[]) {
let _ = tx.send(BgMsg::BtStatus(protocol::parsers::parse_bt_binary(&r.payload)));
}
});
});
}
// --- Advanced: delete combo ---
{
let serial = ctx.serial.clone();
let tx = ctx.bg_tx.clone();
window.global::<AdvancedBridge>().on_delete_combo(move |idx| {
let serial = serial.clone();
let tx = tx.clone();
std::thread::spawn(move || {
use protocol::binary::cmd;
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
let _ = ser.send_binary(cmd::COMBO_DELETE, &[idx as u8]);
if let Ok(r) = ser.send_binary(cmd::COMBO_LIST, &[]) {
let _ = tx.send(BgMsg::ComboList(protocol::parsers::parse_combo_binary(&r.payload)));
}
});
});
}
// --- Advanced: delete leader ---
{
let serial = ctx.serial.clone();
let tx = ctx.bg_tx.clone();
window.global::<AdvancedBridge>().on_delete_leader(move |idx| {
let serial = serial.clone();
let tx = tx.clone();
std::thread::spawn(move || {
use protocol::binary::cmd;
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
let _ = ser.send_binary(cmd::LEADER_DELETE, &[idx as u8]);
if let Ok(r) = ser.send_binary(cmd::LEADER_LIST, &[]) {
let _ = tx.send(BgMsg::LeaderList(protocol::parsers::parse_leader_binary(&r.payload)));
}
});
});
}
// --- Advanced: delete KO ---
{
let serial = ctx.serial.clone();
let tx = ctx.bg_tx.clone();
window.global::<AdvancedBridge>().on_delete_ko(move |idx| {
let serial = serial.clone();
let tx = tx.clone();
std::thread::spawn(move || {
use protocol::binary::cmd;
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
let _ = ser.send_binary(cmd::KO_DELETE, &[idx as u8]);
if let Ok(r) = ser.send_binary(cmd::KO_LIST, &[]) {
let _ = tx.send(BgMsg::KoList(protocol::parsers::parse_ko_binary(&r.payload)));
}
});
});
}
// --- Advanced: set trilayer ---
{
let serial = ctx.serial.clone();
let window_weak = window.as_weak();
window.global::<AdvancedBridge>().on_set_trilayer(move |l1, l2, l3| {
let payload = vec![l1 as u8, l2 as u8, l3 as u8];
let serial = serial.clone();
std::thread::spawn(move || {
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
let _ = ser.send_binary(protocol::binary::cmd::TRILAYER_SET, &payload);
});
if let Some(w) = window_weak.upgrade() {
w.global::<AppState>().set_status_text(
SharedString::from(format!("Tri-layer: {} + {}{}", l1, l2, l3))
);
}
});
}
// --- Advanced: BT switch ---
{
let serial = ctx.serial.clone();
window.global::<AdvancedBridge>().on_bt_switch(move |slot| {
let serial = serial.clone();
std::thread::spawn(move || {
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
let _ = ser.send_binary(protocol::binary::cmd::BT_SWITCH, &[slot as u8]);
});
});
}
// --- Advanced: TAMA action ---
{
let serial = ctx.serial.clone();
let tx = ctx.bg_tx.clone();
window.global::<AdvancedBridge>().on_tama_action(move |action| {
use protocol::binary::cmd;
let action_cmd = match action.as_str() {
"feed" => cmd::TAMA_FEED,
"play" => cmd::TAMA_PLAY,
"sleep" => cmd::TAMA_SLEEP,
"meds" => cmd::TAMA_MEDICINE,
"toggle" => cmd::TAMA_ENABLE, // toggle handled by firmware
_ => return,
};
let serial = serial.clone();
let tx = tx.clone();
std::thread::spawn(move || {
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
let _ = ser.send_binary(action_cmd, &[]);
if let Ok(r) = ser.send_binary(cmd::TAMA_QUERY, &[]) {
let _ = tx.send(BgMsg::TamaStatus(protocol::parsers::parse_tama_binary(&r.payload)));
}
});
});
}
// --- Advanced: toggle autoshift ---
{
let serial = ctx.serial.clone();
let tx = ctx.bg_tx.clone();
window.global::<AdvancedBridge>().on_toggle_autoshift(move || {
let serial = serial.clone();
let tx = tx.clone();
std::thread::spawn(move || {
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
match ser.send_binary(protocol::binary::cmd::AUTOSHIFT_TOGGLE, &[]) {
Ok(r) => {
let enabled = r.payload.first().copied().unwrap_or(0);
let status = if enabled != 0 { "Autoshift: ON" } else { "Autoshift: OFF" };
let _ = tx.send(BgMsg::AutoshiftStatus(status.to_string()));
}
Err(e) => {
let _ = tx.send(BgMsg::AutoshiftStatus(format!("Error: {}", e)));
}
}
});
});
}
// --- Advanced: create combo ---
{
let serial = ctx.serial.clone();
let tx = ctx.bg_tx.clone();
let window_weak = window.as_weak();
window.global::<AdvancedBridge>().on_create_combo(move || {
let Some(w) = window_weak.upgrade() else { return };
let adv = w.global::<AdvancedBridge>();
let r1 = adv.get_new_combo_r1() as u8;
let c1 = adv.get_new_combo_c1() as u8;
let r2 = adv.get_new_combo_r2() as u8;
let c2 = adv.get_new_combo_c2() as u8;
let result = adv.get_new_combo_result_code() as u8;
let key1_name = adv.get_new_combo_key1_name();
let key2_name = adv.get_new_combo_key2_name();
if key1_name == "Pick..." || key2_name == "Pick..." {
w.global::<AppState>().set_status_text("Pick both keys first".into());
return;
}
let next_idx = adv.get_combos().row_count() as u8;
let payload = protocol::binary::combo_set_payload(next_idx, r1, c1, r2, c2, result);
let serial = serial.clone();
let tx = tx.clone();
std::thread::spawn(move || {
use protocol::binary::cmd;
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
let _ = ser.send_binary(cmd::COMBO_SET, &payload);
if let Ok(r) = ser.send_binary(cmd::COMBO_LIST, &[]) {
let _ = tx.send(BgMsg::ComboList(protocol::parsers::parse_combo_binary(&r.payload)));
}
});
w.global::<AppState>().set_status_text("Creating combo...".into());
});
}
// --- Advanced: create KO ---
{
let serial = ctx.serial.clone();
let tx = ctx.bg_tx.clone();
let window_weak = window.as_weak();
window.global::<AdvancedBridge>().on_create_ko(move || {
let Some(w) = window_weak.upgrade() else { return };
let adv = w.global::<AdvancedBridge>();
let trig = adv.get_new_ko_trigger_code() as u8;
let trig_mod = (adv.get_new_ko_trig_ctrl() as u8)
| ((adv.get_new_ko_trig_shift() as u8) << 1)
| ((adv.get_new_ko_trig_alt() as u8) << 2);
let result = adv.get_new_ko_result_code() as u8;
let res_mod = (adv.get_new_ko_res_ctrl() as u8)
| ((adv.get_new_ko_res_shift() as u8) << 1)
| ((adv.get_new_ko_res_alt() as u8) << 2);
let next_idx = adv.get_key_overrides().row_count() as u8;
let payload = protocol::binary::ko_set_payload(next_idx, trig, trig_mod, result, res_mod);
let serial = serial.clone();
let tx = tx.clone();
std::thread::spawn(move || {
use protocol::binary::cmd;
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
let _ = ser.send_binary(cmd::KO_SET, &payload);
if let Ok(r) = ser.send_binary(cmd::KO_LIST, &[]) {
let _ = tx.send(BgMsg::KoList(protocol::parsers::parse_ko_binary(&r.payload)));
}
});
w.global::<AppState>().set_status_text("Creating key override...".into());
});
}
// --- Advanced: create leader ---
{
let serial = ctx.serial.clone();
let tx = ctx.bg_tx.clone();
let window_weak = window.as_weak();
window.global::<AdvancedBridge>().on_create_leader(move |result_code: i32, mod_idx: i32| {
let Some(w) = window_weak.upgrade() else { return };
let adv = w.global::<AdvancedBridge>();
let count = adv.get_new_leader_seq_count() as usize;
let mut sequence = Vec::new();
if count > 0 { sequence.push(adv.get_new_leader_seq0_code() as u8); }
if count > 1 { sequence.push(adv.get_new_leader_seq1_code() as u8); }
if count > 2 { sequence.push(adv.get_new_leader_seq2_code() as u8); }
if count > 3 { sequence.push(adv.get_new_leader_seq3_code() as u8); }
let result = result_code as u8;
let result_mod = mod_idx_to_byte(mod_idx);
let next_idx = adv.get_leaders().row_count() as u8;
let payload = protocol::binary::leader_set_payload(next_idx, &sequence, result, result_mod);
let serial = serial.clone();
let tx = tx.clone();
std::thread::spawn(move || {
use protocol::binary::cmd;
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
let _ = ser.send_binary(cmd::LEADER_SET, &payload);
if let Ok(r) = ser.send_binary(cmd::LEADER_LIST, &[]) {
let _ = tx.send(BgMsg::LeaderList(protocol::parsers::parse_leader_binary(&r.payload)));
}
});
if let Some(w) = window_weak.upgrade() {
w.global::<AppState>().set_status_text("Creating leader key...".into());
}
});
}
}

View file

@ -1,211 +0,0 @@
use crate::context::BgMsg;
use crate::protocol;
use crate::protocol::serial::SerialManager;
use std::sync::mpsc;
use std::sync::{Arc, Mutex};
/// Export all keyboard config to JSON file via rfd save dialog.
pub fn export_config(
serial: &Arc<Mutex<SerialManager>>,
tx: &mpsc::Sender<BgMsg>,
) -> Result<String, String> {
use protocol::binary::cmd;
use protocol::config_io::*;
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
let _ = tx.send(BgMsg::ConfigProgress(0.05, "Reading layer names...".into()));
let layer_names = ser.get_layer_names().unwrap_or_default();
let num_layers = layer_names.len().max(1);
let mut keymaps = Vec::new();
for layer in 0..num_layers {
let progress = 0.05 + (layer as f32 / num_layers as f32) * 0.30;
let _ = tx.send(BgMsg::ConfigProgress(progress, format!("Reading layer {}...", layer)));
let km = ser.get_keymap(layer as u8).unwrap_or_default();
keymaps.push(km);
}
let _ = tx.send(BgMsg::ConfigProgress(0.40, "Reading tap dances...".into()));
let tap_dances = match ser.send_binary(cmd::TD_LIST, &[]) {
Ok(resp) => {
let td_raw = protocol::parsers::parse_td_binary(&resp.payload);
td_raw.iter().enumerate()
.filter(|(_, actions)| actions.iter().any(|&a| a != 0))
.map(|(i, actions)| TdConfig { index: i as u8, actions: *actions })
.collect()
}
Err(_) => Vec::new(),
};
let _ = tx.send(BgMsg::ConfigProgress(0.50, "Reading combos...".into()));
let combos = match ser.send_binary(cmd::COMBO_LIST, &[]) {
Ok(resp) => {
protocol::parsers::parse_combo_binary(&resp.payload).iter().map(|c| ComboConfig {
index: c.index, r1: c.r1, c1: c.c1, r2: c.r2, c2: c.c2, result: c.result,
}).collect()
}
Err(_) => Vec::new(),
};
let _ = tx.send(BgMsg::ConfigProgress(0.60, "Reading key overrides...".into()));
let key_overrides = match ser.send_binary(cmd::KO_LIST, &[]) {
Ok(resp) => {
protocol::parsers::parse_ko_binary(&resp.payload).iter().map(|ko| KoConfig {
trigger_key: ko[0], trigger_mod: ko[1], result_key: ko[2], result_mod: ko[3],
}).collect()
}
Err(_) => Vec::new(),
};
let _ = tx.send(BgMsg::ConfigProgress(0.70, "Reading leaders...".into()));
let leaders = match ser.send_binary(cmd::LEADER_LIST, &[]) {
Ok(resp) => {
protocol::parsers::parse_leader_binary(&resp.payload).iter().map(|l| LeaderConfig {
index: l.index, sequence: l.sequence.clone(), result: l.result, result_mod: l.result_mod,
}).collect()
}
Err(_) => Vec::new(),
};
let _ = tx.send(BgMsg::ConfigProgress(0.80, "Reading macros...".into()));
let macros = match ser.send_binary(cmd::LIST_MACROS, &[]) {
Ok(resp) => {
protocol::parsers::parse_macros_binary(&resp.payload).iter().map(|m| {
let steps_str: Vec<String> = m.steps.iter()
.map(|s| format!("{:02X}:{:02X}", s.keycode, s.modifier))
.collect();
MacroConfig { slot: m.slot, name: m.name.clone(), steps: steps_str.join(",") }
}).collect()
}
Err(_) => Vec::new(),
};
drop(ser);
let _ = tx.send(BgMsg::ConfigProgress(0.90, "Saving file...".into()));
let config = KeyboardConfig {
version: 1,
layer_names,
keymaps,
tap_dances,
combos,
key_overrides,
leaders,
macros,
};
let json = config.to_json()?;
let file = rfd::FileDialog::new()
.add_filter("KeSp Config", &["json"])
.set_file_name("kesp_config.json")
.save_file();
match file {
Some(path) => {
std::fs::write(&path, &json).map_err(|e| format!("Write error: {}", e))?;
Ok(format!("Exported to {}", path.display()))
}
None => Ok("Export cancelled".into()),
}
}
/// Import keyboard config using binary protocol v2.
pub fn import_config(
serial: &Arc<Mutex<SerialManager>>,
tx: &mpsc::Sender<BgMsg>,
config: &protocol::config_io::KeyboardConfig,
) -> Result<String, String> {
use protocol::binary as bp;
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
let mut errors = 0usize;
let total_steps = (config.layer_names.len()
+ config.keymaps.len()
+ config.tap_dances.len()
+ config.combos.len()
+ config.key_overrides.len()
+ config.leaders.len()
+ config.macros.len())
.max(1) as f32;
let mut done = 0usize;
let _ = tx.send(BgMsg::ConfigProgress(0.0, "Setting layer names...".into()));
for (i, name) in config.layer_names.iter().enumerate() {
let payload = bp::set_layout_name_payload(i as u8, name);
if ser.send_binary(bp::cmd::SET_LAYOUT_NAME, &payload).is_err() { errors += 1; }
done += 1;
}
for (layer, km) in config.keymaps.iter().enumerate() {
let progress = done as f32 / total_steps;
let _ = tx.send(BgMsg::ConfigProgress(progress, format!("Writing layer {}...", layer)));
let payload = bp::setlayer_payload(layer as u8, km);
if let Err(e) = ser.send_binary(bp::cmd::SETLAYER, &payload) {
eprintln!("SETLAYER {} failed: {}", layer, e);
errors += 1;
}
done += 1;
}
let _ = tx.send(BgMsg::ConfigProgress(done as f32 / total_steps, "Setting tap dances...".into()));
for td in &config.tap_dances {
let payload = bp::td_set_payload(td.index, &td.actions);
if ser.send_binary(bp::cmd::TD_SET, &payload).is_err() { errors += 1; }
done += 1;
}
let _ = tx.send(BgMsg::ConfigProgress(done as f32 / total_steps, "Setting combos...".into()));
for combo in &config.combos {
let payload = bp::combo_set_payload(combo.index, combo.r1, combo.c1, combo.r2, combo.c2, combo.result as u8);
if ser.send_binary(bp::cmd::COMBO_SET, &payload).is_err() { errors += 1; }
done += 1;
}
let _ = tx.send(BgMsg::ConfigProgress(done as f32 / total_steps, "Setting key overrides...".into()));
for (i, ko) in config.key_overrides.iter().enumerate() {
let payload = bp::ko_set_payload(i as u8, ko.trigger_key, ko.trigger_mod, ko.result_key, ko.result_mod);
if ser.send_binary(bp::cmd::KO_SET, &payload).is_err() { errors += 1; }
done += 1;
}
let _ = tx.send(BgMsg::ConfigProgress(done as f32 / total_steps, "Setting leaders...".into()));
for leader in &config.leaders {
let payload = bp::leader_set_payload(leader.index, &leader.sequence, leader.result, leader.result_mod);
if ser.send_binary(bp::cmd::LEADER_SET, &payload).is_err() { errors += 1; }
done += 1;
}
let _ = tx.send(BgMsg::ConfigProgress(done as f32 / total_steps, "Setting macros...".into()));
for m in &config.macros {
let payload = bp::macro_add_seq_payload(m.slot, &m.name, &m.steps);
if ser.send_binary(bp::cmd::MACRO_ADD_SEQ, &payload).is_err() { errors += 1; }
}
let _ = tx.send(BgMsg::ConfigProgress(0.95, "Refreshing...".into()));
let names = ser.get_layer_names().unwrap_or_default();
let km = ser.get_keymap(0).unwrap_or_default();
let _ = tx.send(BgMsg::LayerNames(names));
let _ = tx.send(BgMsg::Keymap(km));
let total_keys: usize = config.keymaps.iter()
.map(|l| l.iter().map(|r| r.len()).sum::<usize>())
.sum();
if errors > 0 {
Ok(format!("Import done with {} errors (check stderr)", errors))
} else {
Ok(format!("Imported: {} layers, {} keys, {} TD, {} combos, {} KO, {} leaders, {} macros",
config.layer_names.len(),
total_keys,
config.tap_dances.len(),
config.combos.len(),
config.key_overrides.len(),
config.leaders.len(),
config.macros.len(),
))
}
}

View file

@ -1,155 +0,0 @@
use crate::context::{AppContext, BgMsg};
use crate::protocol;
use crate::{AppState, ConnectionBridge, ConnectionState, MainWindow};
use slint::ComponentHandle;
/// Auto-connect to the keyboard on startup.
pub fn auto_connect(window: &MainWindow, ctx: &AppContext) {
let serial = ctx.serial.clone();
let tx = ctx.bg_tx.clone();
window.global::<AppState>().set_status_text("Scanning ports...".into());
window.global::<AppState>().set_connection(ConnectionState::Connecting);
std::thread::spawn(move || {
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
match ser.auto_connect() {
Ok(port_name) => {
let fw = ser.get_firmware_version().unwrap_or_default();
let names = ser.get_layer_names().unwrap_or_default();
let km = ser.get_keymap(0).unwrap_or_default();
let _ = tx.send(BgMsg::Connected(port_name, fw, names, km));
// Fetch physical layout from firmware
match ser.get_layout_json() {
Ok(json) => {
match protocol::layout::parse_json(&json) {
Ok(keys) => { let _ = tx.send(BgMsg::LayoutJson(keys)); }
Err(e) => eprintln!("Layout parse error: {}", e),
}
}
Err(e) => eprintln!("get_layout_json error: {}", e),
}
}
Err(e) => {
let _ = tx.send(BgMsg::ConnectError(e));
}
}
});
}
/// Wire up Connect, Disconnect, refresh_ports, and tab-change auto-refresh.
pub fn setup(window: &MainWindow, ctx: &AppContext) {
// --- Connect callback ---
{
let serial_c = ctx.serial.clone();
let tx_c = ctx.bg_tx.clone();
let window_weak = window.as_weak();
window.global::<ConnectionBridge>().on_connect(move || {
if let Some(w) = window_weak.upgrade() {
w.global::<AppState>().set_status_text("Scanning ports...".into());
w.global::<AppState>().set_connection(ConnectionState::Connecting);
}
let serial = serial_c.clone();
let tx = tx_c.clone();
std::thread::spawn(move || {
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
match ser.auto_connect() {
Ok(port_name) => {
let fw = ser.get_firmware_version().unwrap_or_default();
let names = ser.get_layer_names().unwrap_or_default();
let km = ser.get_keymap(0).unwrap_or_default();
let _ = tx.send(BgMsg::Connected(port_name, fw, names, km));
match ser.get_layout_json() {
Ok(json) => {
match protocol::layout::parse_json(&json) {
Ok(keys) => { let _ = tx.send(BgMsg::LayoutJson(keys)); }
Err(e) => eprintln!("Layout parse error: {}", e),
}
}
Err(e) => eprintln!("get_layout_json error: {}", e),
}
}
Err(e) => { let _ = tx.send(BgMsg::ConnectError(e)); }
}
});
});
}
// --- Disconnect callback ---
{
let serial_d = ctx.serial.clone();
let tx_d = ctx.bg_tx.clone();
window.global::<ConnectionBridge>().on_disconnect(move || {
let mut ser = serial_d.lock().unwrap_or_else(|e| e.into_inner());
ser.disconnect();
let _ = tx_d.send(BgMsg::Disconnected);
});
}
window.global::<ConnectionBridge>().on_refresh_ports(|| {});
// --- Auto-refresh on tab change ---
{
let serial = ctx.serial.clone();
let tx = ctx.bg_tx.clone();
let window_weak = window.as_weak();
window.global::<AppState>().on_tab_changed(move |tab_idx| {
let Some(w) = window_weak.upgrade() else { return };
if w.global::<AppState>().get_connection() != ConnectionState::Connected { return; }
let serial = serial.clone();
let tx = tx.clone();
match tab_idx {
1 => {
// Advanced: refresh TD, combo, leader, KO, BT via binary
std::thread::spawn(move || {
use protocol::binary::cmd;
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
if let Ok(r) = ser.send_binary(cmd::TD_LIST, &[]) {
let _ = tx.send(BgMsg::TdList(protocol::parsers::parse_td_binary(&r.payload)));
}
if let Ok(r) = ser.send_binary(cmd::COMBO_LIST, &[]) {
let _ = tx.send(BgMsg::ComboList(protocol::parsers::parse_combo_binary(&r.payload)));
}
if let Ok(r) = ser.send_binary(cmd::LEADER_LIST, &[]) {
let _ = tx.send(BgMsg::LeaderList(protocol::parsers::parse_leader_binary(&r.payload)));
}
if let Ok(r) = ser.send_binary(cmd::KO_LIST, &[]) {
let _ = tx.send(BgMsg::KoList(protocol::parsers::parse_ko_binary(&r.payload)));
}
if let Ok(r) = ser.send_binary(cmd::BT_QUERY, &[]) {
let _ = tx.send(BgMsg::BtStatus(protocol::parsers::parse_bt_binary(&r.payload)));
}
});
}
2 => {
// Macros: refresh via binary
std::thread::spawn(move || {
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
if let Ok(resp) = ser.send_binary(protocol::binary::cmd::LIST_MACROS, &[]) {
let macros = protocol::parsers::parse_macros_binary(&resp.payload);
let _ = tx.send(BgMsg::MacroList(macros));
}
});
}
3 => {
// Stats: refresh heatmap + bigrams via binary
std::thread::spawn(move || {
use protocol::binary::cmd;
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
if let Ok(r) = ser.send_binary(cmd::KEYSTATS_BIN, &[]) {
let (data, max) = protocol::parsers::parse_keystats_binary(&r.payload);
let _ = tx.send(BgMsg::HeatmapData(data, max));
}
// Bigrams: keep text query (binary format needs dedicated parser)
let bigram_lines = if let Ok(r) = ser.send_binary(protocol::binary::cmd::BIGRAMS_TEXT, &[]) {
String::from_utf8_lossy(&r.payload).lines().map(|l| l.to_string()).collect()
} else { Vec::new() };
let _ = tx.send(BgMsg::BigramLines(bigram_lines));
});
}
_ => {}
}
});
}
}

View file

@ -1,81 +0,0 @@
use crate::protocol::layout::KeycapPos;
use crate::protocol::layout_remap::KeyboardLayout;
use crate::protocol::parsers;
use crate::protocol::serial::SerialManager;
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::sync::mpsc;
use std::sync::{Arc, Mutex};
/// Shared application state passed to all bridge setup functions.
///
/// Thread-safe fields (`serial`, `bg_tx`) are cloned into background threads.
/// Main-thread fields (`Rc<RefCell<…>>`) stay on the UI thread only.
pub struct AppContext {
pub serial: Arc<Mutex<SerialManager>>,
pub bg_tx: mpsc::Sender<BgMsg>,
pub keys: Rc<RefCell<Vec<KeycapPos>>>,
pub current_keymap: Rc<RefCell<Vec<Vec<u16>>>>,
pub current_layer: Rc<Cell<usize>>,
pub keyboard_layout: Rc<RefCell<KeyboardLayout>>,
pub heatmap_data: Rc<RefCell<Vec<Vec<u32>>>>,
pub macro_steps: Rc<RefCell<Vec<(u8, u8)>>>,
}
/// Messages sent from background serial threads to the UI event loop.
pub enum BgMsg {
Connected(String, String, Vec<String>, Vec<Vec<u16>>), // port, fw_version, layer_names, keymap
ConnectError(String),
Keymap(Vec<Vec<u16>>),
LayerNames(Vec<String>),
Disconnected,
#[allow(dead_code)]
TextLines(String, Vec<String>),
HeatmapData(Vec<Vec<u32>>, u32), // counts, max
BigramLines(Vec<String>),
LayoutJson(Vec<KeycapPos>),
MacroList(Vec<parsers::MacroEntry>),
TdList(Vec<[u16; 4]>),
ComboList(Vec<parsers::ComboEntry>),
LeaderList(Vec<parsers::LeaderEntry>),
KoList(Vec<[u8; 4]>),
BtStatus(Vec<String>),
TamaStatus(Vec<String>),
AutoshiftStatus(String),
Wpm(u16),
FlashProgress(f32, String),
FlashDone(Result<(), String>),
OtaProgress(f32, String),
OtaDone(Result<(), String>),
ConfigProgress(f32, String),
ConfigDone(Result<String, String>),
MatrixTestToggled(bool, u8, u8), // enabled, rows, cols
MatrixTestEvent(u8, u8, u8), // row, col, state (1=pressed, 0=released)
MatrixTestError(String),
}
/// Spawn a background thread that locks the serial port and runs `f`.
#[allow(dead_code)]
///
/// Eliminates the 4-line clone+spawn+lock boilerplate repeated 30+ times.
pub fn serial_spawn<F>(ctx: &AppContext, f: F)
where
F: FnOnce(&mut SerialManager, &mpsc::Sender<BgMsg>) + Send + 'static,
{
let serial = ctx.serial.clone();
let tx = ctx.bg_tx.clone();
std::thread::spawn(move || {
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
f(&mut ser, &tx);
});
}
/// Map ComboBox modifier index to HID modifier byte.
/// [None, Ctrl, Shift, Alt, GUI, RCtrl, RShift, RAlt, RGUI]
pub fn mod_idx_to_byte(idx: i32) -> u8 {
match idx {
1 => 0x01, 2 => 0x02, 3 => 0x04, 4 => 0x08,
5 => 0x10, 6 => 0x20, 7 => 0x40, 8 => 0x80,
_ => 0x00,
}
}

View file

@ -1,417 +0,0 @@
use crate::context::{AppContext, BgMsg};
use crate::protocol::{self as protocol, keycode};
use crate::models;
use crate::{
AdvancedBridge, AppState, BigramData, ComboData, ConnectionState, FingerLoadData,
FlasherBridge, HandBalanceData, KeyOverrideData, KeymapBridge, LayoutBridge, LeaderData,
MacroBridge, MacroData, MainWindow, RowUsageData, SettingsBridge, StatsBridge,
TapDanceAction, TapDanceData, ToolsBridge, TopKeyData, LayerInfo,
};
use slint::{ComponentHandle, Model, ModelRc, SharedString, VecModel};
use std::rc::Rc;
use std::sync::mpsc;
/// Start the event loop with BgMsg polling, WPM timer, and layout auto-refresh.
pub fn run(window: &MainWindow, ctx: &AppContext, bg_rx: mpsc::Receiver<BgMsg>) {
let window_weak = window.as_weak();
let keys_arc = ctx.keys.clone();
let current_keymap = ctx.current_keymap.clone();
let keyboard_layout = ctx.keyboard_layout.clone();
let heatmap_data = ctx.heatmap_data.clone();
let timer = slint::Timer::default();
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(50),
move || {
let Some(window) = window_weak.upgrade() else { return };
while let Ok(msg) = bg_rx.try_recv() {
handle_msg(&window, msg, &keys_arc, &current_keymap, &keyboard_layout, &heatmap_data);
}
},
);
// WPM polling timer (5s)
let wpm_timer = slint::Timer::default();
{
let serial = ctx.serial.clone();
let tx = ctx.bg_tx.clone();
let window_weak2 = window.as_weak();
wpm_timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_secs(5),
move || {
let Some(w) = window_weak2.upgrade() else { return };
if w.global::<AppState>().get_connection() != ConnectionState::Connected { return; }
let serial = serial.clone();
let tx = tx.clone();
std::thread::spawn(move || {
let Ok(mut ser) = serial.try_lock() else { return };
if let Ok(r) = ser.send_binary(protocol::binary::cmd::WPM_QUERY, &[]) {
let wpm = if r.payload.len() >= 2 {
u16::from_le_bytes([r.payload[0], r.payload[1]])
} else { 0 };
let _ = tx.send(BgMsg::Wpm(wpm));
}
});
},
);
}
// Layout auto-refresh timer (5s)
let layout_timer = slint::Timer::default();
{
let window_weak3 = window.as_weak();
layout_timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_secs(5),
move || {
let Some(w) = window_weak3.upgrade() else { return };
let lb = w.global::<LayoutBridge>();
if !lb.get_auto_refresh() { return; }
let path = lb.get_file_path().to_string();
if path.is_empty() { return; }
if let Ok(json) = std::fs::read_to_string(&path) {
models::populate_layout_preview(&w, &json);
}
},
);
}
let _keep_timer = timer;
let _keep_wpm = wpm_timer;
let _keep_layout = layout_timer;
window.run().unwrap();
}
fn handle_msg(
window: &MainWindow,
msg: BgMsg,
keys_arc: &Rc<std::cell::RefCell<Vec<crate::protocol::layout::KeycapPos>>>,
current_keymap: &Rc<std::cell::RefCell<Vec<Vec<u16>>>>,
keyboard_layout: &Rc<std::cell::RefCell<protocol::layout_remap::KeyboardLayout>>,
heatmap_data: &Rc<std::cell::RefCell<Vec<Vec<u32>>>>,
) {
match msg {
BgMsg::Connected(port, fw, names, km) => {
let app = window.global::<AppState>();
app.set_connection(ConnectionState::Connected);
app.set_firmware_version(SharedString::from(&fw));
app.set_status_text(SharedString::from(format!("Connected to {}", port)));
let new_layers = models::build_layer_model(&names);
window.global::<KeymapBridge>().set_layers(ModelRc::from(new_layers));
*current_keymap.borrow_mut() = km.clone();
let keycaps = window.global::<KeymapBridge>().get_keycaps();
let layout = keyboard_layout.borrow();
let keys = keys_arc.borrow();
models::update_keycap_labels(&keycaps, &keys, &km, &layout);
}
BgMsg::ConnectError(e) => {
let app = window.global::<AppState>();
app.set_connection(ConnectionState::Disconnected);
app.set_status_text(SharedString::from(format!("Error: {}", e)));
}
BgMsg::Keymap(km) => {
*current_keymap.borrow_mut() = km.clone();
let keycaps = window.global::<KeymapBridge>().get_keycaps();
let layout = keyboard_layout.borrow();
let keys = keys_arc.borrow();
models::update_keycap_labels(&keycaps, &keys, &km, &layout);
window.global::<AppState>().set_status_text("Keymap loaded".into());
}
BgMsg::LayerNames(names) => {
let active = window.global::<KeymapBridge>().get_active_layer() as usize;
let layers: Vec<LayerInfo> = names.iter().enumerate().map(|(i, name)| LayerInfo {
index: i as i32,
name: SharedString::from(name.as_str()),
active: i == active,
}).collect();
window.global::<KeymapBridge>().set_layers(
ModelRc::from(Rc::new(VecModel::from(layers)))
);
}
BgMsg::Disconnected => {
let app = window.global::<AppState>();
app.set_connection(ConnectionState::Disconnected);
app.set_firmware_version(SharedString::default());
app.set_status_text("Disconnected".into());
}
BgMsg::LayoutJson(new_keys) => {
*keys_arc.borrow_mut() = new_keys.clone();
let new_model = models::build_keycap_model(&new_keys);
let km = current_keymap.borrow();
if !km.is_empty() {
let layout = keyboard_layout.borrow();
models::update_keycap_labels(&new_model, &new_keys, &km, &layout);
}
let mut max_x: f32 = 0.0;
let mut max_y: f32 = 0.0;
for kp in &new_keys {
if kp.x + kp.w > max_x { max_x = kp.x + kp.w; }
if kp.y + kp.h > max_y { max_y = kp.y + kp.h; }
}
let bridge = window.global::<KeymapBridge>();
bridge.set_content_width(max_x);
bridge.set_content_height(max_y);
bridge.set_keycaps(ModelRc::from(new_model));
window.global::<AppState>().set_status_text(
SharedString::from(format!("Layout loaded ({} keys)", new_keys.len()))
);
}
BgMsg::BigramLines(lines) => {
let entries = protocol::stats::parse_bigram_lines(&lines);
let analysis = protocol::stats::analyze_bigrams(&entries);
window.global::<StatsBridge>().set_bigrams(BigramData {
alt_hand_pct: analysis.alt_hand_pct,
same_hand_pct: analysis.same_hand_pct,
sfb_pct: analysis.sfb_pct,
total: analysis.total as i32,
});
}
BgMsg::FlashProgress(progress, msg) => {
let f = window.global::<FlasherBridge>();
f.set_flash_progress(progress);
f.set_flash_status(SharedString::from(msg));
}
BgMsg::FlashDone(result) => {
let f = window.global::<FlasherBridge>();
f.set_flashing(false);
match result {
Ok(()) => {
f.set_flash_progress(1.0);
f.set_flash_status(SharedString::from("Flash complete!"));
window.global::<AppState>().set_status_text("Flash complete!".into());
}
Err(e) => {
f.set_flash_status(SharedString::from(format!("Error: {}", e)));
window.global::<AppState>().set_status_text(
SharedString::from(format!("Flash error: {}", e))
);
}
}
}
BgMsg::HeatmapData(data, max) => {
*heatmap_data.borrow_mut() = data.clone();
let keycaps = window.global::<KeymapBridge>().get_keycaps();
let keys = keys_arc.borrow();
for i in 0..keycaps.row_count() {
if i >= keys.len() { break; }
let mut item = keycaps.row_data(i).unwrap();
let kp = &keys[i];
let count = data.get(kp.row).and_then(|r| r.get(kp.col)).copied().unwrap_or(0);
item.heat = if max > 0 { count as f32 / max as f32 } else { 0.0 };
keycaps.set_row_data(i, item);
}
drop(keys);
let km = current_keymap.borrow();
let balance = protocol::stats::hand_balance(&data);
let fingers = protocol::stats::finger_load(&data);
let rows = protocol::stats::row_usage(&data);
let top = protocol::stats::top_keys(&data, &km, 10);
let dead = protocol::stats::dead_keys(&data, &km);
let stats = window.global::<StatsBridge>();
stats.set_hand_balance(HandBalanceData {
left_pct: balance.left_pct, right_pct: balance.right_pct, total: balance.total as i32,
});
stats.set_total_presses(balance.total as i32);
stats.set_finger_load(ModelRc::from(Rc::new(VecModel::from(
fingers.iter().map(|f| FingerLoadData {
name: SharedString::from(&f.name), pct: f.pct, count: f.count as i32,
}).collect::<Vec<_>>()
))));
stats.set_row_usage(ModelRc::from(Rc::new(VecModel::from(
rows.iter().map(|r| RowUsageData {
name: SharedString::from(&r.name), pct: r.pct, count: r.count as i32,
}).collect::<Vec<_>>()
))));
stats.set_top_keys(ModelRc::from(Rc::new(VecModel::from(
top.iter().map(|t| TopKeyData {
name: SharedString::from(&t.name), finger: SharedString::from(&t.finger),
count: t.count as i32, pct: t.pct,
}).collect::<Vec<_>>()
))));
stats.set_dead_keys(ModelRc::from(Rc::new(VecModel::from(
dead.iter().map(|d| SharedString::from(d.as_str())).collect::<Vec<_>>()
))));
window.global::<AppState>().set_status_text(
SharedString::from(format!("Stats loaded ({} total presses, max {})", balance.total, max))
);
}
BgMsg::TextLines(_tag, _lines) => {}
BgMsg::TdList(td_data) => {
let model: Vec<TapDanceData> = td_data.iter().enumerate()
.filter(|(_, actions)| actions.iter().any(|&a| a != 0))
.map(|(i, actions)| TapDanceData {
index: i as i32,
actions: ModelRc::from(Rc::new(VecModel::from(
actions.iter().map(|&a| TapDanceAction {
name: SharedString::from(keycode::decode_keycode(a)),
code: a as i32,
}).collect::<Vec<_>>()
))),
}).collect();
window.global::<AdvancedBridge>().set_tap_dances(
ModelRc::from(Rc::new(VecModel::from(model)))
);
}
BgMsg::ComboList(combo_data) => {
let model: Vec<ComboData> = combo_data.iter().map(|c| ComboData {
index: c.index as i32,
key1: SharedString::from(format!("R{}C{}", c.r1, c.c1)),
key2: SharedString::from(format!("R{}C{}", c.r2, c.c2)),
result: SharedString::from(keycode::decode_keycode(c.result)),
}).collect();
window.global::<AdvancedBridge>().set_combos(
ModelRc::from(Rc::new(VecModel::from(model)))
);
}
BgMsg::LeaderList(leader_data) => {
let model: Vec<LeaderData> = leader_data.iter().map(|l| {
let seq: Vec<String> = l.sequence.iter().map(|&k| keycode::hid_key_name(k)).collect();
LeaderData {
index: l.index as i32,
sequence: SharedString::from(seq.join("")),
result: SharedString::from(keycode::hid_key_name(l.result)),
}
}).collect();
window.global::<AdvancedBridge>().set_leaders(
ModelRc::from(Rc::new(VecModel::from(model)))
);
}
BgMsg::KoList(ko_data) => {
let model: Vec<KeyOverrideData> = ko_data.iter().enumerate().map(|(i, ko)| {
let trig_key = keycode::hid_key_name(ko[0]);
let trig_mod = keycode::mod_name(ko[1]);
let res_key = keycode::hid_key_name(ko[2]);
let res_mod = keycode::mod_name(ko[3]);
let trigger = if ko[1] != 0 { format!("{}+{}", trig_mod, trig_key) } else { trig_key };
let result = if ko[3] != 0 { format!("{}+{}", res_mod, res_key) } else { res_key };
KeyOverrideData {
index: i as i32,
trigger: SharedString::from(trigger),
result: SharedString::from(result),
}
}).collect();
window.global::<AdvancedBridge>().set_key_overrides(
ModelRc::from(Rc::new(VecModel::from(model)))
);
}
BgMsg::BtStatus(lines) => {
window.global::<AdvancedBridge>().set_bt_status(SharedString::from(lines.join("\n")));
}
BgMsg::TamaStatus(lines) => {
window.global::<AdvancedBridge>().set_tama_status(SharedString::from(lines.join("\n")));
}
BgMsg::AutoshiftStatus(text) => {
window.global::<AdvancedBridge>().set_autoshift_status(SharedString::from(text));
}
BgMsg::Wpm(wpm) => {
window.global::<AppState>().set_wpm(wpm as i32);
}
BgMsg::OtaProgress(progress, msg) => {
let s = window.global::<SettingsBridge>();
s.set_ota_progress(progress);
s.set_ota_status(SharedString::from(msg));
}
BgMsg::OtaDone(result) => {
let s = window.global::<SettingsBridge>();
s.set_ota_flashing(false);
match result {
Ok(()) => { s.set_ota_progress(1.0); s.set_ota_status("OTA complete!".into()); }
Err(e) => { s.set_ota_status(SharedString::from(format!("OTA error: {}", e))); }
}
}
BgMsg::ConfigProgress(progress, msg) => {
let s = window.global::<SettingsBridge>();
s.set_config_progress(progress);
s.set_config_status(SharedString::from(msg));
}
BgMsg::ConfigDone(result) => {
let s = window.global::<SettingsBridge>();
s.set_config_busy(false);
match result {
Ok(msg) => { s.set_config_progress(1.0); s.set_config_status(SharedString::from(msg)); }
Err(e) => { s.set_config_progress(0.0); s.set_config_status(SharedString::from(format!("Error: {}", e))); }
}
}
BgMsg::MatrixTestToggled(enabled, _rows, _cols) => {
let tb = window.global::<ToolsBridge>();
tb.set_matrix_test_active(enabled);
if enabled {
// Build matrix keycap model from current keys
let keys = keys_arc.borrow();
let model = models::build_keycap_model(&keys);
// Reset all colors to default (grey)
for i in 0..model.row_count() {
let mut item = model.row_data(i).unwrap();
item.color = slint::Color::from_argb_u8(255, 0x44, 0x47, 0x5a);
item.label = SharedString::from(format!("R{}C{}", keys[i].row, keys[i].col));
item.heat = 0.0;
model.set_row_data(i, item);
}
let mut max_x: f32 = 0.0;
let mut max_y: f32 = 0.0;
for kp in keys.iter() {
if kp.x + kp.w > max_x { max_x = kp.x + kp.w; }
if kp.y + kp.h > max_y { max_y = kp.y + kp.h; }
}
tb.set_matrix_content_width(max_x);
tb.set_matrix_content_height(max_y);
tb.set_matrix_keycaps(ModelRc::from(model));
tb.set_matrix_test_status(SharedString::from("Matrix test active — press keys"));
} else {
tb.set_matrix_test_status(SharedString::from("Matrix test stopped"));
}
}
BgMsg::MatrixTestEvent(row, col, state) => {
let tb = window.global::<ToolsBridge>();
let keycaps = tb.get_matrix_keycaps();
let keys = keys_arc.borrow();
// Find the keycap matching this row/col
for i in 0..keycaps.row_count() {
if i >= keys.len() { break; }
if keys[i].row == row as usize && keys[i].col == col as usize {
let mut item = keycaps.row_data(i).unwrap();
if state == 1 {
item.color = slint::Color::from_argb_u8(255, 0x50, 0xFA, 0x7B); // green = pressed
} else {
item.color = slint::Color::from_argb_u8(255, 0xBD, 0x93, 0xF9); // purple = was activated
}
keycaps.set_row_data(i, item);
break;
}
}
}
BgMsg::MatrixTestError(e) => {
let tb = window.global::<ToolsBridge>();
tb.set_matrix_test_active(false);
tb.set_matrix_test_status(SharedString::from(format!("Error: {}", e)));
}
BgMsg::MacroList(macros) => {
let model: Vec<MacroData> = macros.iter().map(|m| {
let steps_str: Vec<String> = m.steps.iter().map(|s| {
if s.is_delay() { format!("T({})", s.delay_ms()) }
else { keycode::hid_key_name(s.keycode).to_string() }
}).collect();
MacroData {
slot: m.slot as i32,
name: SharedString::from(&m.name),
steps: SharedString::from(steps_str.join(" ")),
}
}).collect();
let max_slot = model.iter().fold(-1i32, |acc, m| acc.max(m.slot));
let mb = window.global::<MacroBridge>();
mb.set_macros(ModelRc::from(Rc::new(VecModel::from(model))));
let next_slot = max_slot + 1;
if next_slot > mb.get_new_slot_idx() {
mb.set_new_slot_idx(next_slot);
}
}
}
}

View file

@ -1,119 +0,0 @@
use crate::context::{AppContext, BgMsg};
use crate::protocol;
use crate::protocol::serial::SerialManager;
use crate::{FlasherBridge, MainWindow};
use slint::{ComponentHandle, ModelRc, SharedString, VecModel};
use std::rc::Rc;
use std::sync::mpsc;
pub fn setup(window: &MainWindow, ctx: &AppContext) {
init_prog_ports(window);
setup_refresh_prog_ports(window);
setup_browse_firmware(window);
setup_flash(window, ctx);
}
// Init prog ports list on startup
fn init_prog_ports(window: &MainWindow) {
let ports = SerialManager::list_prog_ports();
if let Some(first) = ports.first() {
window.global::<FlasherBridge>().set_selected_prog_port(SharedString::from(first.as_str()));
}
let model: Vec<SharedString> = ports.iter().map(|p| SharedString::from(p.as_str())).collect();
window.global::<FlasherBridge>().set_prog_ports(
ModelRc::from(Rc::new(VecModel::from(model)))
);
}
// --- Flasher: refresh prog ports ---
fn setup_refresh_prog_ports(window: &MainWindow) {
let window_weak = window.as_weak();
window.global::<FlasherBridge>().on_refresh_prog_ports(move || {
let ports = SerialManager::list_prog_ports();
if let Some(w) = window_weak.upgrade() {
if let Some(first) = ports.first() {
w.global::<FlasherBridge>().set_selected_prog_port(SharedString::from(first.as_str()));
}
let model: Vec<SharedString> = ports.iter().map(|p| SharedString::from(p.as_str())).collect();
w.global::<FlasherBridge>().set_prog_ports(
ModelRc::from(Rc::new(VecModel::from(model)))
);
}
});
}
// --- Flasher: browse firmware ---
fn setup_browse_firmware(window: &MainWindow) {
let window_weak = window.as_weak();
window.global::<FlasherBridge>().on_browse_firmware(move || {
let window_weak = window_weak.clone();
std::thread::spawn(move || {
let file = rfd::FileDialog::new()
.add_filter("Firmware", &["bin"])
.pick_file();
if let Some(path) = file {
let path_str = path.to_string_lossy().to_string();
let _ = slint::invoke_from_event_loop(move || {
if let Some(w) = window_weak.upgrade() {
w.global::<FlasherBridge>().set_firmware_path(
SharedString::from(path_str.as_str())
);
}
});
}
});
});
}
// --- Flasher: flash ---
fn setup_flash(window: &MainWindow, ctx: &AppContext) {
let tx = ctx.bg_tx.clone();
let window_weak = window.as_weak();
window.global::<FlasherBridge>().on_flash(move || {
let Some(w) = window_weak.upgrade() else { return };
let flasher = w.global::<FlasherBridge>();
let port = flasher.get_selected_prog_port().to_string();
let path = flasher.get_firmware_path().to_string();
let offset: u32 = match flasher.get_flash_offset_index() {
0 => 0x0, // full flash
1 => 0x20000, // factory
2 => 0x220000, // ota_0
_ => 0x20000,
};
if port.is_empty() || path.is_empty() { return; }
// Read firmware file
let firmware = match std::fs::read(&path) {
Ok(data) => data,
Err(e) => {
let _ = tx.send(BgMsg::FlashDone(Err(format!("Cannot read {}: {}", path, e))));
return;
}
};
flasher.set_flashing(true);
flasher.set_flash_progress(0.0);
flasher.set_flash_status(SharedString::from("Starting..."));
let tx = tx.clone();
std::thread::spawn(move || {
let (ftx, frx) = mpsc::channel();
// Forward flash progress to main bg channel
let tx2 = tx.clone();
let progress_thread = std::thread::spawn(move || {
while let Ok(protocol::flasher::FlashProgress::OtaProgress(p, msg)) = frx.recv() {
let _ = tx2.send(BgMsg::FlashProgress(p, msg));
}
});
let result = protocol::flasher::flash_firmware(&port, &firmware, offset, &ftx);
drop(ftx); // close channel so progress_thread exits
let _ = progress_thread.join();
let _ = tx.send(BgMsg::FlashDone(result.map_err(|e| e.to_string())));
});
});
}

View file

@ -1,247 +0,0 @@
use crate::context::AppContext;
use crate::protocol::{self as protocol, keycode};
use crate::models;
use crate::{
AdvancedBridge, AppState, KeySelectorBridge, KeymapBridge, MainWindow,
};
use slint::{ComponentHandle, Model, SharedString};
use std::rc::Rc;
pub fn setup(window: &MainWindow, ctx: &AppContext) {
setup_filter(window, ctx);
let apply_keycode = build_apply_keycode(window, ctx);
let refresh_macro_display = crate::macros::make_refresh_display(window, ctx);
let dispatch_keycode = build_dispatch_keycode(window, ctx, apply_keycode, refresh_macro_display);
setup_callbacks(window, dispatch_keycode);
}
fn setup_filter(window: &MainWindow, ctx: &AppContext) {
let keyboard_layout = ctx.keyboard_layout.clone();
let window_weak = window.as_weak();
window.global::<KeySelectorBridge>().on_apply_filter(move |search| {
if let Some(w) = window_weak.upgrade() {
let layout = *keyboard_layout.borrow();
let all_keys = models::build_key_entries_with_layout(&layout);
models::populate_key_categories(&w, &all_keys, &search);
}
});
}
fn build_apply_keycode(window: &MainWindow, ctx: &AppContext) -> Rc<dyn Fn(u16)> {
let serial = ctx.serial.clone();
let keys_arc = ctx.keys.clone();
let current_keymap = ctx.current_keymap.clone();
let current_layer = ctx.current_layer.clone();
let keyboard_layout = ctx.keyboard_layout.clone();
let window_weak = window.as_weak();
Rc::new(move |code: u16| {
let Some(w) = window_weak.upgrade() else { return };
let key_idx = w.global::<KeymapBridge>().get_selected_key_index();
if key_idx < 0 { return; }
let key_idx = key_idx as usize;
let keys = keys_arc.borrow();
if key_idx >= keys.len() { return; }
let kp = &keys[key_idx];
let row = kp.row;
let col = kp.col;
drop(keys);
let layer = current_layer.get() as u8;
{
let mut km = current_keymap.borrow_mut();
if row < km.len() && col < km[row].len() {
km[row][col] = code;
}
}
let layout = *keyboard_layout.borrow();
let km = current_keymap.borrow().clone();
let keys = keys_arc.borrow().clone();
let keycaps = w.global::<KeymapBridge>().get_keycaps();
models::update_keycap_labels(&keycaps, &keys, &km, &layout);
let payload = protocol::binary::setkey_payload(layer, row as u8, col as u8, code);
let serial = serial.clone();
std::thread::spawn(move || {
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
let _ = ser.send_binary(protocol::binary::cmd::SETKEY, &payload);
});
w.global::<AppState>().set_status_text(
SharedString::from(format!("[{},{}] = 0x{:04X}", row, col, code))
);
})
}
fn build_dispatch_keycode(
window: &MainWindow,
ctx: &AppContext,
apply_keycode: Rc<dyn Fn(u16)>,
refresh_macro_display: Rc<dyn Fn()>,
) -> Rc<dyn Fn(u16)> {
let keys_arc = ctx.keys.clone();
let serial = ctx.serial.clone();
let macro_steps = ctx.macro_steps.clone();
let window_weak = window.as_weak();
Rc::new(move |code: u16| {
let Some(w) = window_weak.upgrade() else { return };
let target = w.global::<KeymapBridge>().get_selector_target();
let name = SharedString::from(keycode::decode_keycode(code));
match target.as_str() {
"keymap" => { apply_keycode(code); }
"combo-result" => {
let adv = w.global::<AdvancedBridge>();
adv.set_new_combo_result_code(code as i32);
adv.set_new_combo_result_name(name);
}
"ko-trigger" => {
let adv = w.global::<AdvancedBridge>();
adv.set_new_ko_trigger_code(code as i32);
adv.set_new_ko_trigger_name(name);
}
"ko-result" => {
let adv = w.global::<AdvancedBridge>();
adv.set_new_ko_result_code(code as i32);
adv.set_new_ko_result_name(name);
}
"leader-result" => {
let adv = w.global::<AdvancedBridge>();
adv.set_new_leader_result_code(code as i32);
adv.set_new_leader_result_name(name);
}
"combo-key1" | "combo-key2" => {
let keys = keys_arc.borrow();
let idx = code as usize;
if idx < keys.len() {
let kp = &keys[idx];
let adv = w.global::<AdvancedBridge>();
let label = SharedString::from(format!("R{}C{}", kp.row, kp.col));
if target.as_str() == "combo-key1" {
adv.set_new_combo_r1(kp.row as i32);
adv.set_new_combo_c1(kp.col as i32);
adv.set_new_combo_key1_name(label);
} else {
adv.set_new_combo_r2(kp.row as i32);
adv.set_new_combo_c2(kp.col as i32);
adv.set_new_combo_key2_name(label);
}
}
}
"leader-seq" => {
let adv = w.global::<AdvancedBridge>();
let count = adv.get_new_leader_seq_count();
match count {
0 => { adv.set_new_leader_seq0_code(code as i32); adv.set_new_leader_seq0_name(name); }
1 => { adv.set_new_leader_seq1_code(code as i32); adv.set_new_leader_seq1_name(name); }
2 => { adv.set_new_leader_seq2_code(code as i32); adv.set_new_leader_seq2_name(name); }
3 => { adv.set_new_leader_seq3_code(code as i32); adv.set_new_leader_seq3_name(name); }
_ => {}
}
if count < 4 { adv.set_new_leader_seq_count(count + 1); }
}
"td-action" => {
let adv = w.global::<AdvancedBridge>();
let td_idx = adv.get_editing_td_index();
let slot = adv.get_editing_td_slot() as usize;
if td_idx >= 0 && slot < 4 {
let tds = adv.get_tap_dances();
for i in 0..tds.row_count() {
let td = tds.row_data(i).unwrap();
if td.index == td_idx {
let actions = td.actions;
let mut a = actions.row_data(slot).unwrap();
a.name = name.clone();
a.code = code as i32;
actions.set_row_data(slot, a);
let mut codes = [0u16; 4];
for (j, code) in codes.iter_mut().enumerate().take(4.min(actions.row_count())) {
*code = actions.row_data(j).unwrap().code as u16;
}
let payload = protocol::binary::td_set_payload(td_idx as u8, &codes);
let serial = serial.clone();
std::thread::spawn(move || {
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
let _ = ser.send_binary(protocol::binary::cmd::TD_SET, &payload);
});
w.global::<AppState>().set_status_text(
SharedString::from(format!("TD{} slot {} = {}", td_idx, slot, name))
);
break;
}
}
}
}
"macro-step" => {
let mut steps = macro_steps.borrow_mut();
steps.push((code as u8, 0x00));
drop(steps);
refresh_macro_display();
}
_ => { apply_keycode(code); }
}
})
}
fn setup_callbacks(window: &MainWindow, dispatch_keycode: Rc<dyn Fn(u16)>) {
{
let dispatch = dispatch_keycode.clone();
window.global::<KeySelectorBridge>().on_select_keycode(move |code| {
dispatch(code as u16);
});
}
{
let dispatch = dispatch_keycode.clone();
window.global::<KeySelectorBridge>().on_apply_hex(move |hex_str| {
if let Ok(code) = u16::from_str_radix(hex_str.trim(), 16) {
dispatch(code);
}
});
}
{
let window_weak = window.as_weak();
window.global::<KeySelectorBridge>().on_preview_hex(move |hex_str| {
let preview = u16::from_str_radix(hex_str.trim(), 16)
.map(keycode::decode_keycode)
.unwrap_or_default();
if let Some(w) = window_weak.upgrade() {
w.global::<KeySelectorBridge>().set_hex_preview(SharedString::from(preview));
}
});
}
{
let dispatch = dispatch_keycode.clone();
window.global::<KeySelectorBridge>().on_apply_mt(move |mod_idx, key_idx| {
let mod_nibble: u16 = match mod_idx {
0 => 0x01, 1 => 0x02, 2 => 0x04, 3 => 0x08,
4 => 0x10, 5 => 0x20, 6 => 0x40, 7 => 0x80,
_ => 0x02,
};
let hid: u16 = match key_idx {
0..=25 => 0x04 + key_idx as u16,
26..=35 => 0x1E + (key_idx - 26) as u16,
36 => 0x2C, 37 => 0x28, 38 => 0x29, 39 => 0x2B, 40 => 0x2A,
_ => 0x04,
};
let code = 0x5000 | (mod_nibble << 8) | hid;
dispatch(code);
});
}
{
let dispatch = dispatch_keycode.clone();
window.global::<KeySelectorBridge>().on_apply_lt(move |layer_idx, key_idx| {
let layer = (layer_idx as u16) & 0x0F;
let hid: u16 = match key_idx {
0 => 0x2C, 1 => 0x28, 2 => 0x29, 3 => 0x2A, 4 => 0x2B,
5..=9 => 0x04 + (key_idx - 5) as u16,
_ => 0x2C,
};
let code = 0x4000 | (layer << 8) | hid;
dispatch(code);
});
}
}

View file

@ -1,110 +0,0 @@
use crate::context::{AppContext, BgMsg};
use crate::protocol;
use crate::{AppState, KeymapBridge, MainWindow};
use slint::{ComponentHandle, Model, SharedString};
/// Wire up key selection, layer switch, layer rename, and heatmap toggle.
pub fn setup(window: &MainWindow, ctx: &AppContext) {
// --- Key selection callback ---
{
let window_weak = window.as_weak();
window.global::<KeymapBridge>().on_select_key(move |key_index| {
let Some(w) = window_weak.upgrade() else { return };
let keycaps = w.global::<KeymapBridge>().get_keycaps();
let idx = key_index as usize;
if idx >= keycaps.row_count() { return; }
for i in 0..keycaps.row_count() {
let mut item = keycaps.row_data(i).unwrap();
let should_select = i == idx;
if item.selected != should_select {
item.selected = should_select;
keycaps.set_row_data(i, item);
}
}
let bridge = w.global::<KeymapBridge>();
bridge.set_selected_key_index(key_index);
let item = keycaps.row_data(idx).unwrap();
bridge.set_selected_key_label(item.label.clone());
});
}
// --- Layer switch callback ---
{
let serial = ctx.serial.clone();
let tx = ctx.bg_tx.clone();
let current_layer = ctx.current_layer.clone();
let window_weak = window.as_weak();
window.global::<KeymapBridge>().on_switch_layer(move |layer_index| {
let idx = layer_index as usize;
current_layer.set(idx);
// Update active flag on the CURRENT model (not a captured stale ref)
if let Some(w) = window_weak.upgrade() {
let layers = w.global::<KeymapBridge>().get_layers();
for i in 0..layers.row_count() {
let mut item = layers.row_data(i).unwrap();
let should_be_active = item.index == layer_index;
if item.active != should_be_active {
item.active = should_be_active;
layers.set_row_data(i, item);
}
}
w.global::<KeymapBridge>().set_active_layer(layer_index);
w.global::<AppState>().set_status_text(SharedString::from(format!("Loading layer {}...", idx)));
}
let serial = serial.clone();
let tx = tx.clone();
std::thread::spawn(move || {
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
match ser.get_keymap(idx as u8) {
Ok(km) => { let _ = tx.send(BgMsg::Keymap(km)); }
Err(e) => { let _ = tx.send(BgMsg::ConnectError(e)); }
}
});
});
}
// --- Layer rename callback ---
{
let serial = ctx.serial.clone();
let tx = ctx.bg_tx.clone();
let window_weak = window.as_weak();
window.global::<KeymapBridge>().on_rename_layer(move |layer_idx, new_name| {
let payload = protocol::binary::set_layout_name_payload(layer_idx as u8, &new_name);
let serial = serial.clone();
let tx = tx.clone();
std::thread::spawn(move || {
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
let _ = ser.send_binary(protocol::binary::cmd::SET_LAYOUT_NAME, &payload);
if let Ok(names) = ser.get_layer_names() {
let _ = tx.send(BgMsg::LayerNames(names));
}
});
if let Some(w) = window_weak.upgrade() {
w.global::<AppState>().set_status_text(
SharedString::from(format!("Renamed layer {}{}", layer_idx, new_name))
);
}
});
}
// --- Heatmap toggle: auto-load data ---
{
let serial = ctx.serial.clone();
let tx = ctx.bg_tx.clone();
window.global::<KeymapBridge>().on_toggle_heatmap(move || {
let serial = serial.clone();
let tx = tx.clone();
std::thread::spawn(move || {
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
if let Ok(resp) = ser.send_binary(protocol::binary::cmd::KEYSTATS_BIN, &[]) {
let (data, max) = protocol::parsers::parse_keystats_binary(&resp.payload);
let _ = tx.send(BgMsg::HeatmapData(data, max));
}
});
});
}
}

View file

@ -1,113 +0,0 @@
use crate::context::AppContext;
use crate::models;
use crate::{LayoutBridge, MainWindow};
use slint::{ComponentHandle, SharedString};
pub fn setup(window: &MainWindow, ctx: &AppContext) {
setup_load_from_file(window);
setup_load_from_keyboard(window, ctx);
setup_export_json(window);
}
// --- Layout preview: load from file ---
fn setup_load_from_file(window: &MainWindow) {
let window_weak = window.as_weak();
window.global::<LayoutBridge>().on_load_from_file(move || {
let window_weak = window_weak.clone();
std::thread::spawn(move || {
let file = rfd::FileDialog::new()
.add_filter("Layout JSON", &["json"])
.pick_file();
let Some(path) = file else { return };
let json = match std::fs::read_to_string(&path) {
Ok(j) => j,
Err(e) => {
let err = format!("Read error: {}", e);
let _ = slint::invoke_from_event_loop({
let window_weak = window_weak.clone();
move || {
if let Some(w) = window_weak.upgrade() {
w.global::<LayoutBridge>().set_status(SharedString::from(err));
}
}
});
return;
}
};
let path_str = path.to_string_lossy().to_string();
let _ = slint::invoke_from_event_loop({
let window_weak = window_weak.clone();
move || {
if let Some(w) = window_weak.upgrade() {
w.global::<LayoutBridge>().set_file_path(SharedString::from(&path_str));
models::populate_layout_preview(&w, &json);
}
}
});
});
});
}
// --- Layout preview: load from keyboard ---
fn setup_load_from_keyboard(window: &MainWindow, ctx: &AppContext) {
let serial = ctx.serial.clone();
let window_weak = window.as_weak();
window.global::<LayoutBridge>().on_load_from_keyboard(move || {
let serial = serial.clone();
let window_weak = window_weak.clone();
std::thread::spawn(move || {
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
let json = match ser.get_layout_json() {
Ok(j) => j,
Err(e) => {
let err = format!("Error: {}", e);
let _ = slint::invoke_from_event_loop({
let window_weak = window_weak.clone();
move || {
if let Some(w) = window_weak.upgrade() {
w.global::<LayoutBridge>().set_status(SharedString::from(err));
}
}
});
return;
}
};
let _ = slint::invoke_from_event_loop({
let window_weak = window_weak.clone();
move || {
if let Some(w) = window_weak.upgrade() {
models::populate_layout_preview(&w, &json);
}
}
});
});
});
}
// --- Layout preview: export JSON ---
fn setup_export_json(window: &MainWindow) {
let window_weak = window.as_weak();
window.global::<LayoutBridge>().on_export_json(move || {
let Some(w) = window_weak.upgrade() else { return };
let json = w.global::<LayoutBridge>().get_json_text().to_string();
if json.is_empty() { return; }
let window_weak = window_weak.clone();
std::thread::spawn(move || {
let file = rfd::FileDialog::new()
.add_filter("Layout JSON", &["json"])
.set_file_name("layout.json")
.save_file();
if let Some(path) = file {
let msg = match std::fs::write(&path, &json) {
Ok(()) => format!("Exported to {}", path.display()),
Err(e) => format!("Write error: {}", e),
};
let _ = slint::invoke_from_event_loop(move || {
if let Some(w) = window_weak.upgrade() {
w.global::<LayoutBridge>().set_status(SharedString::from(msg));
}
});
}
});
});
}

View file

@ -1,182 +0,0 @@
use crate::context::{AppContext, BgMsg};
use crate::protocol;
use crate::protocol::keycode;
use crate::{AppState, MacroBridge, MacroStepDisplay, MainWindow};
use slint::{ComponentHandle, ModelRc, SharedString, VecModel};
use std::rc::Rc;
/// Build a closure that refreshes the macro step display from `ctx.macro_steps`.
pub fn make_refresh_display(window: &MainWindow, ctx: &AppContext) -> Rc<dyn Fn()> {
let macro_steps = ctx.macro_steps.clone();
let window_weak = window.as_weak();
Rc::new(move || {
let Some(w) = window_weak.upgrade() else { return };
let steps = macro_steps.borrow();
let display: Vec<MacroStepDisplay> = steps.iter().map(|&(kc, _md)| {
if kc == 0xFF {
MacroStepDisplay {
label: SharedString::from(format!("T {}ms", _md as u32 * 10)),
is_delay: true,
}
} else {
MacroStepDisplay {
label: SharedString::from(keycode::hid_key_name(kc)),
is_delay: false,
}
}
}).collect();
let text: Vec<String> = steps.iter().map(|&(kc, md)| {
if kc == 0xFF { format!("T({})", md as u32 * 10) }
else { format!("D({:02X})", kc) }
}).collect();
let mb = w.global::<MacroBridge>();
mb.set_new_steps(ModelRc::from(Rc::new(VecModel::from(display))));
mb.set_new_steps_text(SharedString::from(text.join(" ")));
})
}
/// Wire up all macro callbacks: refresh, add delay, add shortcut, remove last, clear, save, delete.
pub fn setup(window: &MainWindow, ctx: &AppContext) {
// --- Macros: refresh ---
{
let serial = ctx.serial.clone();
let tx = ctx.bg_tx.clone();
window.global::<MacroBridge>().on_refresh_macros(move || {
let serial = serial.clone();
let tx = tx.clone();
std::thread::spawn(move || {
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
if ser.v2 {
if let Ok(resp) = ser.send_binary(protocol::binary::cmd::LIST_MACROS, &[]) {
let macros = protocol::parsers::parse_macros_binary(&resp.payload);
let _ = tx.send(BgMsg::MacroList(macros));
}
} else {
// Legacy fallback — should not happen with v2 firmware
let _ = ser.send_binary(protocol::binary::cmd::LIST_MACROS, &[]);
}
});
});
}
// --- Macros: add delay step ---
{
let macro_steps = ctx.macro_steps.clone();
let refresh = make_refresh_display(window, ctx);
window.global::<MacroBridge>().on_add_delay_step(move |ms| {
let units = (ms as u8) / 10;
macro_steps.borrow_mut().push((0xFF, units));
refresh();
});
}
// --- Macros: add shortcut preset ---
{
let macro_steps = ctx.macro_steps.clone();
let refresh = make_refresh_display(window, ctx);
window.global::<MacroBridge>().on_add_shortcut(move |shortcut| {
let ctrl = |key: u8| vec![(0xE0u8, 0u8), (key, 0)];
let steps: Vec<(u8, u8)> = match shortcut.as_str() {
"ctrl+c" => ctrl(0x06),
"ctrl+v" => ctrl(0x19),
"ctrl+x" => ctrl(0x1B),
"ctrl+z" => ctrl(0x1D),
"ctrl+y" => ctrl(0x1C),
"ctrl+s" => ctrl(0x16),
"ctrl+a" => ctrl(0x04),
"alt+f4" => vec![(0xE2, 0), (0x3D, 0)],
_ => return,
};
macro_steps.borrow_mut().extend(steps);
refresh();
});
}
// --- Macros: remove last step ---
{
let macro_steps = ctx.macro_steps.clone();
let refresh = make_refresh_display(window, ctx);
window.global::<MacroBridge>().on_remove_last_step(move || {
macro_steps.borrow_mut().pop();
refresh();
});
}
// --- Macros: clear steps ---
{
let macro_steps = ctx.macro_steps.clone();
let refresh = make_refresh_display(window, ctx);
window.global::<MacroBridge>().on_clear_steps(move || {
macro_steps.borrow_mut().clear();
refresh();
});
}
// --- Macros: save ---
{
let serial = ctx.serial.clone();
let tx = ctx.bg_tx.clone();
let macro_steps = ctx.macro_steps.clone();
let window_weak = window.as_weak();
window.global::<MacroBridge>().on_save_macro(move || {
let Some(w) = window_weak.upgrade() else { return };
let mb = w.global::<MacroBridge>();
let slot_num = mb.get_new_slot_idx() as u8;
mb.set_new_slot_idx(slot_num as i32 + 1);
let name = mb.get_new_name().to_string();
let steps = macro_steps.borrow();
let steps_str: Vec<String> = steps.iter().map(|&(kc, md)| {
format!("{:02X}:{:02X}", kc, md)
}).collect();
let steps_text = steps_str.join(",");
drop(steps);
let payload = protocol::binary::macro_add_seq_payload(slot_num, &name, &steps_text);
macro_steps.borrow_mut().clear();
mb.set_new_name(SharedString::default());
mb.set_new_steps(ModelRc::from(Rc::new(VecModel::<MacroStepDisplay>::default())));
mb.set_new_steps_text(SharedString::default());
let serial = serial.clone();
let tx = tx.clone();
std::thread::spawn(move || {
use protocol::binary::cmd;
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
let _ = ser.send_binary(cmd::MACRO_ADD_SEQ, &payload);
if let Ok(resp) = ser.send_binary(cmd::LIST_MACROS, &[]) {
let macros = protocol::parsers::parse_macros_binary(&resp.payload);
let _ = tx.send(BgMsg::MacroList(macros));
}
});
w.global::<AppState>().set_status_text(
SharedString::from(format!("Saving macro #{}...", slot_num))
);
});
}
// --- Macros: delete ---
{
let serial = ctx.serial.clone();
let tx = ctx.bg_tx.clone();
window.global::<MacroBridge>().on_delete_macro(move |slot| {
let serial = serial.clone();
let tx = tx.clone();
std::thread::spawn(move || {
use protocol::binary::cmd;
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
let _ = ser.send_binary(cmd::MACRO_DELETE, &protocol::binary::macro_delete_payload(slot as u8));
if let Ok(resp) = ser.send_binary(cmd::LIST_MACROS, &[]) {
let macros = protocol::parsers::parse_macros_binary(&resp.payload);
let _ = tx.send(BgMsg::MacroList(macros));
}
});
});
}
}

View file

@ -1,60 +0,0 @@
mod protocol;
mod context;
mod models;
mod config;
mod dispatch;
mod keymap;
mod macros;
mod advanced;
mod stats;
mod settings;
mod flasher;
mod layout;
mod connection;
mod key_selector;
mod tools;
slint::include_modules!();
use context::{AppContext, BgMsg};
use std::sync::mpsc;
use std::sync::{Arc, Mutex};
use std::rc::Rc;
fn main() {
let keys = protocol::layout::default_layout();
let window = MainWindow::new().unwrap();
let saved_settings = protocol::settings::load();
models::init_models(&window, &keys, &saved_settings);
let (bg_tx, bg_rx) = mpsc::channel::<BgMsg>();
let ctx = AppContext {
serial: Arc::new(Mutex::new(protocol::serial::SerialManager::new())),
bg_tx,
keys: Rc::new(std::cell::RefCell::new(keys)),
current_keymap: Rc::new(std::cell::RefCell::new(Vec::new())),
current_layer: Rc::new(std::cell::Cell::new(0)),
keyboard_layout: Rc::new(std::cell::RefCell::new(
protocol::layout_remap::KeyboardLayout::from_name(&saved_settings.keyboard_layout),
)),
heatmap_data: Rc::new(std::cell::RefCell::new(Vec::new())),
macro_steps: Rc::new(std::cell::RefCell::new(Vec::new())),
};
connection::auto_connect(&window, &ctx);
connection::setup(&window, &ctx);
keymap::setup(&window, &ctx);
stats::setup(&window, &ctx);
macros::setup(&window, &ctx);
advanced::setup(&window, &ctx);
key_selector::setup(&window, &ctx);
settings::setup(&window, &ctx);
flasher::setup(&window, &ctx);
layout::setup(&window, &ctx);
tools::setup(&window, &ctx);
dispatch::run(&window, &ctx, bg_rx);
}

View file

@ -1,270 +0,0 @@
use crate::protocol::{self as protocol, keycode, layout::KeycapPos, layout_remap};
use crate::{
KeycapData, KeyEntry, KeySelectorBridge, KeymapBridge, LayoutBridge, LayerInfo, MainWindow,
SettingsBridge,
};
use slint::{ComponentHandle, Model, ModelRc, SharedString, VecModel};
use std::rc::Rc;
/// Initialize all UI models from default state.
pub fn init_models(
window: &MainWindow,
keys: &[KeycapPos],
saved_settings: &crate::protocol::settings::Settings,
) {
let kb = window.global::<KeymapBridge>();
kb.set_keycaps(ModelRc::from(build_keycap_model(keys)));
kb.set_layers(ModelRc::from(build_layer_model(&[
"Layer 0".into(), "Layer 1".into(), "Layer 2".into(), "Layer 3".into(),
])));
let mut max_x: f32 = 0.0;
let mut max_y: f32 = 0.0;
for kp in keys {
if kp.x + kp.w > max_x { max_x = kp.x + kp.w; }
if kp.y + kp.h > max_y { max_y = kp.y + kp.h; }
}
kb.set_content_width(max_x);
kb.set_content_height(max_y);
// Available keyboard layouts
let layouts: Vec<SharedString> = layout_remap::KeyboardLayout::all()
.iter()
.map(|l| SharedString::from(l.name()))
.collect();
window.global::<SettingsBridge>().set_available_layouts(
ModelRc::from(Rc::new(VecModel::from(layouts))),
);
// Initial layout index
let current = layout_remap::KeyboardLayout::from_name(&saved_settings.keyboard_layout);
let idx = layout_remap::KeyboardLayout::all()
.iter()
.position(|l| *l == current)
.unwrap_or(0);
window.global::<SettingsBridge>().set_selected_layout_index(idx as i32);
// Key selector
let all_keys = build_key_entries_with_layout(&current);
window.global::<KeySelectorBridge>().set_all_keys(ModelRc::from(all_keys.clone()));
populate_key_categories(window, &all_keys, "");
}
pub fn build_keycap_model(keys: &[KeycapPos]) -> Rc<VecModel<KeycapData>> {
let keycaps: Vec<KeycapData> = keys
.iter()
.enumerate()
.map(|(idx, kp)| KeycapData {
x: kp.x, y: kp.y, w: kp.w, h: kp.h,
rotation: kp.angle,
rotation_cx: kp.w / 2.0,
rotation_cy: kp.h / 2.0,
label: SharedString::from(format!("{},{}", kp.col, kp.row)),
sublabel: SharedString::default(),
keycode: 0,
color: slint::Color::from_argb_u8(255, 0x44, 0x47, 0x5a),
heat: 0.0,
selected: false,
index: idx as i32,
})
.collect();
Rc::new(VecModel::from(keycaps))
}
pub fn build_layer_model(names: &[String]) -> Rc<VecModel<LayerInfo>> {
let layers: Vec<LayerInfo> = names
.iter()
.enumerate()
.map(|(i, name)| LayerInfo {
index: i as i32,
name: SharedString::from(name.as_str()),
active: i == 0,
})
.collect();
Rc::new(VecModel::from(layers))
}
pub fn update_keycap_labels(
keycap_model: &impl Model<Data = KeycapData>,
keys: &[KeycapPos],
keymap: &[Vec<u16>],
layout: &layout_remap::KeyboardLayout,
) {
for i in 0..keycap_model.row_count() {
if i >= keys.len() { break; }
let mut item = keycap_model.row_data(i).unwrap();
let kp = &keys[i];
let row = kp.row;
let col = kp.col;
if row < keymap.len() && col < keymap[row].len() {
let code = keymap[row][col];
let decoded = keycode::decode_keycode(code);
let remapped = layout_remap::remap_key_label(layout, &decoded);
let label = remapped.unwrap_or(&decoded).to_string();
item.keycode = code as i32;
item.label = SharedString::from(label);
item.sublabel = if decoded != format!("0x{:04X}", code) {
SharedString::default()
} else {
SharedString::from(format!("0x{:04X}", code))
};
}
keycap_model.set_row_data(i, item);
}
}
pub fn build_key_entries_with_layout(layout: &layout_remap::KeyboardLayout) -> Rc<VecModel<KeyEntry>> {
let hid_entry = |code: u16, category: &str| -> KeyEntry {
let base = keycode::hid_key_name(code as u8);
let name = layout_remap::remap_key_label(layout, &base)
.map(|s| s.to_string())
.unwrap_or(base);
KeyEntry { name: SharedString::from(name), code: code as i32, category: SharedString::from(category) }
};
let mut entries = Vec::new();
for code in 0x04u16..=0x1D { entries.push(hid_entry(code, "Letter")); }
for code in 0x1Eu16..=0x27 { entries.push(hid_entry(code, "Number")); }
for code in [0x28u16, 0x29, 0x2A, 0x2B, 0x2C] { entries.push(hid_entry(code, "Control")); }
for code in 0x2Du16..=0x38 { entries.push(hid_entry(code, "Symbol")); }
for code in 0x3Au16..=0x45 { entries.push(hid_entry(code, "Function")); }
for code in [0x46u16, 0x47, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, 0x50, 0x51, 0x52] {
entries.push(hid_entry(code, "Navigation"));
}
for code in 0xE0u16..=0xE7 { entries.push(hid_entry(code, "Modifier")); }
entries.push(KeyEntry {
name: SharedString::from("Caps Lock"),
code: 0x39,
category: SharedString::from("Control"),
});
for code in 0x53u16..=0x63 { entries.push(hid_entry(code, "Keypad")); }
for code in 0x68u16..=0x73 { entries.push(hid_entry(code, "Function")); }
for code in [0x7Fu16, 0x80, 0x81] { entries.push(hid_entry(code, "Media")); }
for (code, name) in [
(0x2900u16, "BT Next"), (0x2A00, "BT Prev"), (0x2B00, "BT Pair"),
(0x2C00, "BT Disc"), (0x2E00, "USB/BT"), (0x2F00, "BT On/Off"),
] {
entries.push(KeyEntry {
name: SharedString::from(name),
code: code as i32,
category: SharedString::from("Bluetooth"),
});
}
for i in 0u16..=7 {
let code = (0x60 | i) << 8;
entries.push(KeyEntry {
name: SharedString::from(format!("TD {}", i)),
code: code as i32,
category: SharedString::from("Tap Dance"),
});
}
for i in 0u16..=9 {
let code = (i + 0x15) << 8;
entries.push(KeyEntry {
name: SharedString::from(format!("M{}", i)),
code: code as i32,
category: SharedString::from("Macro"),
});
}
for i in 0u16..=9 {
entries.push(KeyEntry {
name: SharedString::from(format!("OSL {}", i)),
code: 0x3100 + i as i32,
category: SharedString::from("Layer"),
});
}
for layer in 0u16..=9 {
let code = (layer + 1) << 8;
entries.push(KeyEntry {
name: SharedString::from(format!("MO {}", layer)),
code: code as i32,
category: SharedString::from("Layer"),
});
}
for layer in 0u16..=9 {
let code = (layer + 0x0B) << 8;
entries.push(KeyEntry {
name: SharedString::from(format!("TO {}", layer)),
code: code as i32,
category: SharedString::from("Layer"),
});
}
for (code, name) in [
(0x3200u16, "Caps Word"), (0x3300, "Repeat"), (0x3400, "Leader"),
(0x3900, "GEsc"), (0x3A00, "Layer Lock"), (0x3C00, "AS Toggle"),
] {
entries.push(KeyEntry {
name: SharedString::from(name),
code: code as i32,
category: SharedString::from("Special"),
});
}
entries.insert(0, KeyEntry {
name: SharedString::from("None"),
code: 0,
category: SharedString::from("Special"),
});
Rc::new(VecModel::from(entries))
}
pub fn populate_key_categories(window: &MainWindow, all_keys: &VecModel<KeyEntry>, search: &str) {
let search_lower = search.to_lowercase();
let filter = |cat: &str| -> Vec<KeyEntry> {
(0..all_keys.row_count())
.filter_map(|i| {
let e = all_keys.row_data(i).unwrap();
let cat_match = e.category.as_str() == cat
|| (cat == "Navigation" && (e.category.as_str() == "Control" || e.category.as_str() == "Navigation"))
|| (cat == "Special" && (e.category.as_str() == "Special" || e.category.as_str() == "Bluetooth" || e.category.as_str() == "Media"))
|| (cat == "TDMacro" && (e.category.as_str() == "Tap Dance" || e.category.as_str() == "Macro"));
let search_match = search_lower.is_empty()
|| e.name.to_lowercase().contains(&search_lower)
|| e.category.to_lowercase().contains(&search_lower);
if cat_match && search_match { Some(e) } else { None }
})
.collect()
};
let set = |model: Vec<KeyEntry>| ModelRc::from(Rc::new(VecModel::from(model)));
let ks = window.global::<KeySelectorBridge>();
ks.set_cat_letters(set(filter("Letter")));
ks.set_cat_numbers(set(filter("Number")));
ks.set_cat_modifiers(set(filter("Modifier")));
ks.set_cat_nav(set(filter("Navigation")));
ks.set_cat_function(set(filter("Function")));
ks.set_cat_symbols(set(filter("Symbol")));
ks.set_cat_layers(set(filter("Layer")));
ks.set_cat_special(set(filter("Special")));
ks.set_cat_td_macro(set(filter("TDMacro")));
}
pub fn populate_layout_preview(window: &MainWindow, json: &str) {
let lb = window.global::<LayoutBridge>();
match protocol::layout::parse_json(json) {
Ok(keys) => {
let keycaps: Vec<KeycapData> = keys.iter().enumerate().map(|(idx, kp)| KeycapData {
x: kp.x, y: kp.y, w: kp.w, h: kp.h,
rotation: kp.angle,
rotation_cx: kp.w / 2.0, rotation_cy: kp.h / 2.0,
label: SharedString::from(format!("R{}C{}", kp.row, kp.col)),
sublabel: SharedString::default(),
keycode: 0, color: slint::Color::from_argb_u8(255, 0x44, 0x47, 0x5a),
heat: 0.0, selected: false, index: idx as i32,
}).collect();
let max_x = keys.iter().map(|k| k.x + k.w).fold(0.0f32, f32::max);
let max_y = keys.iter().map(|k| k.y + k.h).fold(0.0f32, f32::max);
lb.set_content_width(max_x + 20.0);
lb.set_content_height(max_y + 20.0);
lb.set_keycaps(ModelRc::from(std::rc::Rc::new(VecModel::from(keycaps))));
lb.set_status(SharedString::from(format!("{} keys loaded", keys.len())));
let pretty_json = serde_json::from_str::<serde_json::Value>(json)
.and_then(|v| serde_json::to_string_pretty(&v))
.unwrap_or_else(|_| json.to_string());
lb.set_json_text(SharedString::from(pretty_json));
}
Err(e) => {
lb.set_status(SharedString::from(format!("Parse error: {}", e)));
lb.set_json_text(SharedString::from(json));
}
}
}

View file

@ -1,308 +0,0 @@
/// KaSe Binary CDC Protocol v2
/// Frame: KS(2) + cmd(1) + len(2 LE) + payload(N) + crc8(1)
/// Response: KR(2) + cmd(1) + status(1) + len(2 LE) + payload(N) + crc8(1)
#[allow(dead_code)]
pub mod cmd {
// System
pub const VERSION: u8 = 0x01;
pub const FEATURES: u8 = 0x02;
pub const DFU: u8 = 0x03;
pub const PING: u8 = 0x04;
// Keymap
pub const SETLAYER: u8 = 0x10;
pub const SETKEY: u8 = 0x11;
pub const KEYMAP_CURRENT: u8 = 0x12;
pub const KEYMAP_GET: u8 = 0x13;
pub const LAYER_INDEX: u8 = 0x14;
pub const LAYER_NAME: u8 = 0x15;
// Layout
pub const SET_LAYOUT_NAME: u8 = 0x20;
pub const LIST_LAYOUTS: u8 = 0x21;
pub const GET_LAYOUT_JSON: u8 = 0x22;
// Macros
pub const LIST_MACROS: u8 = 0x30;
pub const MACRO_ADD: u8 = 0x31;
pub const MACRO_ADD_SEQ: u8 = 0x32;
pub const MACRO_DELETE: u8 = 0x33;
// Statistics
pub const KEYSTATS_BIN: u8 = 0x40;
pub const KEYSTATS_TEXT: u8 = 0x41;
pub const KEYSTATS_RESET: u8 = 0x42;
pub const BIGRAMS_BIN: u8 = 0x43;
pub const BIGRAMS_TEXT: u8 = 0x44;
pub const BIGRAMS_RESET: u8 = 0x45;
// Tap Dance
pub const TD_SET: u8 = 0x50;
pub const TD_LIST: u8 = 0x51;
pub const TD_DELETE: u8 = 0x52;
// Combos
pub const COMBO_SET: u8 = 0x60;
pub const COMBO_LIST: u8 = 0x61;
pub const COMBO_DELETE: u8 = 0x62;
// Leader
pub const LEADER_SET: u8 = 0x70;
pub const LEADER_LIST: u8 = 0x71;
pub const LEADER_DELETE: u8 = 0x72;
// Bluetooth
pub const BT_QUERY: u8 = 0x80;
pub const BT_SWITCH: u8 = 0x81;
pub const BT_PAIR: u8 = 0x82;
pub const BT_DISCONNECT: u8 = 0x83;
pub const BT_NEXT: u8 = 0x84;
pub const BT_PREV: u8 = 0x85;
// Features
pub const AUTOSHIFT_TOGGLE: u8 = 0x90;
pub const KO_SET: u8 = 0x91;
pub const KO_LIST: u8 = 0x92;
pub const KO_DELETE: u8 = 0x93;
pub const WPM_QUERY: u8 = 0x94;
pub const TRILAYER_SET: u8 = 0x95;
// Tamagotchi
pub const TAMA_QUERY: u8 = 0xA0;
pub const TAMA_ENABLE: u8 = 0xA1;
pub const TAMA_DISABLE: u8 = 0xA2;
pub const TAMA_FEED: u8 = 0xA3;
pub const TAMA_PLAY: u8 = 0xA4;
pub const TAMA_SLEEP: u8 = 0xA5;
pub const TAMA_MEDICINE: u8 = 0xA6;
pub const TAMA_SAVE: u8 = 0xA7;
// Diagnostics
pub const MATRIX_TEST: u8 = 0xB0;
// OTA
pub const OTA_START: u8 = 0xF0;
pub const OTA_DATA: u8 = 0xF1;
pub const OTA_ABORT: u8 = 0xF2;
}
#[allow(dead_code)]
pub mod status {
pub const OK: u8 = 0x00;
pub const ERR_UNKNOWN: u8 = 0x01;
pub const ERR_CRC: u8 = 0x02;
pub const ERR_INVALID: u8 = 0x03;
pub const ERR_RANGE: u8 = 0x04;
pub const ERR_BUSY: u8 = 0x05;
pub const ERR_OVERFLOW: u8 = 0x06;
}
/// CRC-8/MAXIM (polynomial 0x31, init 0x00)
pub fn crc8(data: &[u8]) -> u8 {
let mut crc: u8 = 0x00;
for &b in data {
crc ^= b;
for _ in 0..8 {
crc = if crc & 0x80 != 0 {
(crc << 1) ^ 0x31
} else {
crc << 1
};
}
}
crc
}
/// Build a KS request frame.
pub fn ks_frame(cmd_id: u8, payload: &[u8]) -> Vec<u8> {
let len = payload.len() as u16;
let mut frame = Vec::with_capacity(6 + payload.len());
frame.push(0x4B); // 'K'
frame.push(0x53); // 'S'
frame.push(cmd_id);
frame.push((len & 0xFF) as u8);
frame.push((len >> 8) as u8);
frame.extend_from_slice(payload);
frame.push(crc8(payload));
frame
}
/// Build MACRO_ADD_SEQ payload: [slot][name_len][name...][step_count][{kc,mod}...]
pub fn macro_add_seq_payload(slot: u8, name: &str, steps_hex: &str) -> Vec<u8> {
let name_bytes = name.as_bytes();
let name_len = name_bytes.len().min(255) as u8;
// Parse hex steps "06:01,FF:0A,19:01" into (kc, mod) pairs
let mut step_pairs: Vec<(u8, u8)> = Vec::new();
if !steps_hex.is_empty() {
for part in steps_hex.split(',') {
let trimmed = part.trim();
let kv: Vec<&str> = trimmed.split(':').collect();
let has_two = kv.len() == 2;
if !has_two {
continue;
}
let kc = u8::from_str_radix(kv[0].trim(), 16).unwrap_or(0);
let md = u8::from_str_radix(kv[1].trim(), 16).unwrap_or(0);
step_pairs.push((kc, md));
}
}
let step_count = step_pairs.len().min(255) as u8;
let mut payload = Vec::new();
payload.push(slot);
payload.push(name_len);
payload.extend_from_slice(&name_bytes[..name_len as usize]);
payload.push(step_count);
for (kc, md) in &step_pairs {
payload.push(*kc);
payload.push(*md);
}
payload
}
/// Build MACRO_DELETE payload: [slot]
pub fn macro_delete_payload(slot: u8) -> Vec<u8> {
vec![slot]
}
/// Build COMBO_SET payload: [index][r1][c1][r2][c2][result]
pub fn combo_set_payload(index: u8, r1: u8, c1: u8, r2: u8, c2: u8, result: u8) -> Vec<u8> {
vec![index, r1, c1, r2, c2, result]
}
/// Build TD_SET payload: [index][a1][a2][a3][a4]
pub fn td_set_payload(index: u8, actions: &[u16; 4]) -> Vec<u8> {
vec![index, actions[0] as u8, actions[1] as u8, actions[2] as u8, actions[3] as u8]
}
/// Build KO_SET payload: [index][trigger_key][trigger_mod][result_key][result_mod]
pub fn ko_set_payload(index: u8, trig_key: u8, trig_mod: u8, res_key: u8, res_mod: u8) -> Vec<u8> {
vec![index, trig_key, trig_mod, res_key, res_mod]
}
/// Build LEADER_SET payload: [index][seq_len][seq...][result][result_mod]
pub fn leader_set_payload(index: u8, sequence: &[u8], result: u8, result_mod: u8) -> Vec<u8> {
let seq_len = sequence.len().min(4) as u8;
let mut payload = Vec::with_capacity(4 + sequence.len());
payload.push(index);
payload.push(seq_len);
payload.extend_from_slice(&sequence[..seq_len as usize]);
payload.push(result);
payload.push(result_mod);
payload
}
/// Build SETLAYER payload: [layer:u8][keycodes: ROWS*COLS * u16 LE]
pub fn setlayer_payload(layer: u8, keymap: &[Vec<u16>]) -> Vec<u8> {
let mut payload = Vec::with_capacity(1 + keymap.len() * keymap.first().map_or(0, |r| r.len()) * 2);
payload.push(layer);
for row in keymap {
for &kc in row {
payload.push((kc & 0xFF) as u8);
payload.push((kc >> 8) as u8);
}
}
payload
}
/// Build SET_LAYOUT_NAME payload: [layer:u8][name bytes]
pub fn set_layout_name_payload(layer: u8, name: &str) -> Vec<u8> {
let mut payload = Vec::with_capacity(1 + name.len());
payload.push(layer);
payload.extend_from_slice(name.as_bytes());
payload
}
/// Build SETKEY payload: [layer:u8][row:u8][col:u8][value:u16 LE]
pub fn setkey_payload(layer: u8, row: u8, col: u8, keycode: u16) -> Vec<u8> {
vec![layer, row, col, (keycode & 0xFF) as u8, (keycode >> 8) as u8]
}
/// Parsed KR response.
#[derive(Debug)]
pub struct KrResponse {
#[allow(dead_code)]
pub cmd: u8,
pub status: u8,
pub payload: Vec<u8>,
}
impl KrResponse {
pub fn is_ok(&self) -> bool {
self.status == status::OK
}
pub fn status_name(&self) -> &str {
match self.status {
status::OK => "OK",
status::ERR_UNKNOWN => "ERR_UNKNOWN",
status::ERR_CRC => "ERR_CRC",
status::ERR_INVALID => "ERR_INVALID",
status::ERR_RANGE => "ERR_RANGE",
status::ERR_BUSY => "ERR_BUSY",
status::ERR_OVERFLOW => "ERR_OVERFLOW",
_ => "UNKNOWN",
}
}
}
/// Parse a KR response from raw bytes. Returns (response, bytes_consumed).
pub fn parse_kr(data: &[u8]) -> Result<(KrResponse, usize), String> {
// Find KR magic
let pos = data
.windows(2)
.position(|w| w[0] == 0x4B && w[1] == 0x52)
.ok_or("No KR header found")?;
if data.len() < pos + 7 {
return Err("Response too short".into());
}
let cmd = data[pos + 2];
let status = data[pos + 3];
let plen = data[pos + 4] as u16 | ((data[pos + 5] as u16) << 8);
let payload_start = pos + 6;
let payload_end = payload_start + plen as usize;
if data.len() < payload_end + 1 {
return Err(format!(
"Incomplete response: need {} bytes, got {}",
payload_end + 1,
data.len()
));
}
let payload = data[payload_start..payload_end].to_vec();
let expected_crc = data[payload_end];
let actual_crc = crc8(&payload);
if expected_crc != actual_crc {
return Err(format!(
"CRC mismatch: expected 0x{:02X}, got 0x{:02X}",
expected_crc, actual_crc
));
}
let consumed = payload_end + 1 - pos;
Ok((KrResponse { cmd, status, payload }, consumed))
}
/// Parse all KR frames from a byte buffer (for unsolicited events).
pub fn parse_all_kr(data: &[u8]) -> Vec<KrResponse> {
let mut results = Vec::new();
let mut offset = 0;
while offset < data.len() {
match parse_kr(&data[offset..]) {
Ok((resp, consumed)) => {
offset += consumed;
results.push(resp);
}
Err(_) => {
offset += 1; // skip garbage byte
}
}
}
results
}

View file

@ -1,65 +0,0 @@
/// Import/export keyboard configuration as JSON.
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct KeyboardConfig {
pub version: u32,
pub layer_names: Vec<String>,
/// keymaps[layer][row][col] = keycode (u16)
pub keymaps: Vec<Vec<Vec<u16>>>,
pub tap_dances: Vec<TdConfig>,
pub combos: Vec<ComboConfig>,
pub key_overrides: Vec<KoConfig>,
pub leaders: Vec<LeaderConfig>,
pub macros: Vec<MacroConfig>,
}
#[derive(Serialize, Deserialize)]
pub struct TdConfig {
pub index: u8,
pub actions: [u16; 4],
}
#[derive(Serialize, Deserialize)]
pub struct ComboConfig {
pub index: u8,
pub r1: u8,
pub c1: u8,
pub r2: u8,
pub c2: u8,
pub result: u16,
}
#[derive(Serialize, Deserialize)]
pub struct KoConfig {
pub trigger_key: u8,
pub trigger_mod: u8,
pub result_key: u8,
pub result_mod: u8,
}
#[derive(Serialize, Deserialize)]
pub struct LeaderConfig {
pub index: u8,
pub sequence: Vec<u8>,
pub result: u8,
pub result_mod: u8,
}
#[derive(Serialize, Deserialize)]
pub struct MacroConfig {
pub slot: u8,
pub name: String,
/// Steps as "kc:mod,kc:mod,..." hex string
pub steps: String,
}
impl KeyboardConfig {
pub fn to_json(&self) -> Result<String, String> {
serde_json::to_string_pretty(self).map_err(|e| e.to_string())
}
pub fn from_json(json: &str) -> Result<Self, String> {
serde_json::from_str(json).map_err(|e| e.to_string())
}
}

View file

@ -1,939 +0,0 @@
/// ESP32 ROM bootloader flasher via serial (CH340/CP2102 programming port).
/// Implements minimal SLIP-framed bootloader protocol for firmware flashing
/// without requiring esptool.
///
/// Targets the ESP32-S3 ROM bootloader directly (no stub loader upload).
/// Reference: esptool.py source, ESP32-S3 Technical Reference Manual.
#[cfg(not(target_arch = "wasm32"))]
use serialport::SerialPort;
#[cfg(not(target_arch = "wasm32"))]
use std::sync::mpsc;
#[cfg(not(target_arch = "wasm32"))]
use std::time::{Duration, Instant};
/// Progress message sent back to the UI during flashing.
#[cfg(not(target_arch = "wasm32"))]
pub enum FlashProgress {
OtaProgress(f32, String),
}
// ==================== SLIP framing ====================
const SLIP_END: u8 = 0xC0;
const SLIP_ESC: u8 = 0xDB;
const SLIP_ESC_END: u8 = 0xDC;
const SLIP_ESC_ESC: u8 = 0xDD;
#[cfg(not(target_arch = "wasm32"))]
fn slip_encode(data: &[u8]) -> Vec<u8> {
let mut frame = Vec::with_capacity(data.len() + 10);
frame.push(SLIP_END);
for &byte in data {
match byte {
SLIP_END => {
frame.push(SLIP_ESC);
frame.push(SLIP_ESC_END);
}
SLIP_ESC => {
frame.push(SLIP_ESC);
frame.push(SLIP_ESC_ESC);
}
_ => frame.push(byte),
}
}
frame.push(SLIP_END);
frame
}
#[cfg(not(target_arch = "wasm32"))]
fn slip_decode(frame: &[u8]) -> Vec<u8> {
let mut data = Vec::with_capacity(frame.len());
let mut escaped = false;
for &byte in frame {
if escaped {
match byte {
SLIP_ESC_END => data.push(SLIP_END),
SLIP_ESC_ESC => data.push(SLIP_ESC),
_ => data.push(byte),
}
escaped = false;
} else if byte == SLIP_ESC {
escaped = true;
} else if byte != SLIP_END {
data.push(byte);
}
}
data
}
// ==================== Bootloader commands ====================
const CMD_SYNC: u8 = 0x08;
const CMD_CHANGE_BAUDRATE: u8 = 0x0F;
const CMD_SPI_SET_PARAMS: u8 = 0x0B; // Set SPI flash geometry (required before flash_begin)
const CMD_SPI_ATTACH: u8 = 0x0D;
const CMD_FLASH_BEGIN: u8 = 0x02;
const CMD_FLASH_DATA: u8 = 0x03;
const CMD_FLASH_END: u8 = 0x04;
const CMD_SPI_FLASH_MD5: u8 = 0x13; // Post-write integrity check
/// Write block size.
/// Must match esptool FLASH_WRITE_SIZE = 0x400. The ROM rejects any other value
/// in the FLASH_BEGIN num_blocks field if it doesn't divide evenly.
const FLASH_BLOCK_SIZE: u32 = 0x400;
/// Flash sector size — minimum erase unit (4 KB).
const FLASH_SECTOR_SIZE: u32 = 0x1000;
const INITIAL_BAUD: u32 = 115200;
const FLASH_BAUD: u32 = 460800;
/// Number of retries for each FLASH_DATA block.
/// esptool uses WRITE_BLOCK_ATTEMPTS = 3.
const WRITE_BLOCK_ATTEMPTS: usize = 3;
#[cfg(not(target_arch = "wasm32"))]
fn xor_checksum(data: &[u8]) -> u32 {
let mut chk: u8 = 0xEF;
for &b in data {
chk ^= b;
}
chk as u32
}
/// Build a bootloader command packet (before SLIP encoding).
/// Packet format matches esptool struct.pack("<BBHI", dir, cmd, len, chk) + data:
/// [0x00][cmd][size:u16 LE][checksum:u32 LE][data...]
#[cfg(not(target_arch = "wasm32"))]
fn build_command(cmd: u8, data: &[u8], checksum: u32) -> Vec<u8> {
let size = data.len() as u16;
let mut pkt = Vec::with_capacity(8 + data.len());
pkt.push(0x00); // direction: command
pkt.push(cmd);
pkt.push((size & 0xFF) as u8);
pkt.push((size >> 8) as u8);
pkt.push((checksum & 0xFF) as u8);
pkt.push(((checksum >> 8) & 0xFF) as u8);
pkt.push(((checksum >> 16) & 0xFF) as u8);
pkt.push(((checksum >> 24) & 0xFF) as u8);
pkt.extend_from_slice(data);
pkt
}
/// Extract complete SLIP frames from a raw byte buffer.
#[cfg(not(target_arch = "wasm32"))]
fn extract_slip_frames(raw: &[u8]) -> Vec<Vec<u8>> {
let mut frames = Vec::new();
let mut in_frame = false;
let mut current = Vec::new();
for &byte in raw {
if byte == SLIP_END {
if in_frame && !current.is_empty() {
frames.push(current.clone());
current.clear();
in_frame = false;
} else {
// Start of frame (or consecutive 0xC0)
in_frame = true;
current.clear();
}
} else if in_frame {
current.push(byte);
}
// Bytes outside a frame are garbage — skip
}
frames
}
/// Send a command and wait for the matching response.
/// Handles boot log garbage and multiple SYNC echo responses.
///
/// Response packet layout (from ROM): [0x01][cmd][size:u16][val:u32][data...]
/// "data" for most commands is just [status:u8][error:u8][pad:u8][pad:u8].
/// For CMD_SPI_FLASH_MD5 from ROM, "data" is [32 ASCII hex bytes][status][error][pad][pad].
#[cfg(not(target_arch = "wasm32"))]
fn send_command(
port: &mut Box<dyn SerialPort>,
cmd: u8,
data: &[u8],
checksum: u32,
timeout_ms: u64,
) -> Result<Vec<u8>, String> {
let pkt = build_command(cmd, data, checksum);
let frame = slip_encode(&pkt);
port.write_all(&frame)
.map_err(|e| format!("Write error: {}", e))?;
port.flush()
.map_err(|e| format!("Flush error: {}", e))?;
let mut raw = Vec::new();
let mut buf = [0u8; 512];
let start = Instant::now();
let timeout = Duration::from_millis(timeout_ms);
loop {
let elapsed = start.elapsed();
if elapsed > timeout {
let got = if raw.is_empty() {
"nothing".to_string()
} else {
format!("{} raw bytes, no valid response", raw.len())
};
return Err(format!("Response timeout cmd=0x{:02X} ({})", cmd, got));
}
match port.read(&mut buf) {
Ok(n) if n > 0 => raw.extend_from_slice(&buf[..n]),
_ => {
std::thread::sleep(Duration::from_millis(1));
if raw.is_empty() {
continue;
}
}
}
let frames = extract_slip_frames(&raw);
for slip_data in &frames {
let decoded = slip_decode(slip_data);
if decoded.len() < 8 {
continue;
}
let direction = decoded[0];
let resp_cmd = decoded[1];
if direction != 0x01 || resp_cmd != cmd {
continue;
}
// Standard response: status at decoded[8], error at decoded[9].
// MD5 response has 32 extra bytes before status, but we handle that
// in flash_md5sum() by parsing decoded[8..40] separately.
if decoded.len() >= 10 {
let status = decoded[8];
let error = decoded[9];
if status != 0 {
return Err(format!(
"Bootloader error: cmd=0x{:02X} status={} error={} (0x{:02X})",
cmd, status, error, error
));
}
}
return Ok(decoded);
}
}
}
// ==================== Bootloader entry ====================
/// Toggle DTR/RTS to reset ESP32 into bootloader mode.
/// Standard auto-reset circuit: DTR→EN, RTS→GPIO0.
#[cfg(not(target_arch = "wasm32"))]
fn enter_bootloader(port: &mut Box<dyn SerialPort>) -> Result<(), String> {
// Hold GPIO0 low (RTS=true) while pulsing EN low via DTR
port.write_data_terminal_ready(false)
.map_err(|e| format!("DTR error: {}", e))?;
port.write_request_to_send(true)
.map_err(|e| format!("RTS error: {}", e))?;
std::thread::sleep(Duration::from_millis(100));
// Release EN (DTR=true) while keeping GPIO0 low
port.write_data_terminal_ready(true)
.map_err(|e| format!("DTR error: {}", e))?;
port.write_request_to_send(false)
.map_err(|e| format!("RTS error: {}", e))?;
std::thread::sleep(Duration::from_millis(50));
// Release all
port.write_data_terminal_ready(false)
.map_err(|e| format!("DTR error: {}", e))?;
// esptool DEFAULT_RESET_DELAY = 500 ms — wait for ROM banner before draining
std::thread::sleep(Duration::from_millis(500));
let _ = port.clear(serialport::ClearBuffer::All);
Ok(())
}
// ==================== High-level commands ====================
#[cfg(not(target_arch = "wasm32"))]
fn sync(port: &mut Box<dyn SerialPort>) -> Result<(), String> {
// SYNC payload: magic header + 32 x 0x55
let mut payload = vec![0x07, 0x07, 0x12, 0x20];
payload.extend_from_slice(&[0x55; 32]);
for attempt in 0..10 {
let result = send_command(port, CMD_SYNC, &payload, 0, 500);
match result {
Ok(_) => return Ok(()),
Err(_) if attempt < 9 => {
let _ = port.clear(serialport::ClearBuffer::Input);
std::thread::sleep(Duration::from_millis(50));
}
Err(e) => return Err(format!("SYNC failed after 10 attempts: {}", e)),
}
}
Err("SYNC failed".into())
}
/// Tell the bootloader to switch baud rate, then mirror the change on the host side.
#[cfg(not(target_arch = "wasm32"))]
fn change_baudrate(port: &mut Box<dyn SerialPort>, new_baud: u32) -> Result<(), String> {
// Payload: [new_baud:u32 LE][old_baud:u32 LE]
// old_baud=0 means "current baud" for ROM (not stub — stub passes the real current baud)
let mut payload = Vec::with_capacity(8);
payload.extend_from_slice(&new_baud.to_le_bytes());
payload.extend_from_slice(&0u32.to_le_bytes());
send_command(port, CMD_CHANGE_BAUDRATE, &payload, 0, 3000)?;
// Switch host side — ROM will already be running at new baud after ACK
port.set_baud_rate(new_baud)
.map_err(|e| format!("Set baud error: {}", e))?;
// esptool sleeps 50ms + flush after baud change to discard garbage sent during transition
std::thread::sleep(Duration::from_millis(50));
let _ = port.clear(serialport::ClearBuffer::All);
Ok(())
}
#[cfg(not(target_arch = "wasm32"))]
fn spi_attach(port: &mut Box<dyn SerialPort>) -> Result<(), String> {
// Payload: [hspi_arg:u32 LE=0][is_legacy:u8=0][pad:u8=0][pad:u8=0][pad:u8=0]
// 8 bytes total, all zeros for standard SPI attach (not legacy)
let payload = [0u8; 8];
send_command(port, CMD_SPI_ATTACH, &payload, 0, 3000)?;
Ok(())
}
/// Inform the ROM bootloader of the SPI flash chip geometry.
///
/// This is CMD 0x0B (ESP_SPI_SET_PARAMS). esptool calls this unconditionally
/// (for both ROM and stub mode) before any flash_begin.
/// Without it, the ROM's internal flash descriptor may describe a smaller chip
/// (e.g. 2 MB default), causing it to refuse writes beyond that boundary or to
/// erase incorrectly — a silent failure that looks like a successful flash but
/// the firmware never boots.
///
/// Payload: [fl_id:u32][total_size:u32][block_size:u32][sector_size:u32][page_size:u32][status_mask:u32]
/// All values match esptool flash_set_parameters() defaults.
#[cfg(not(target_arch = "wasm32"))]
fn spi_set_params(port: &mut Box<dyn SerialPort>, flash_size_bytes: u32) -> Result<(), String> {
let fl_id: u32 = 0;
let block_size: u32 = 64 * 1024; // 64 KB erase block
let sector_size: u32 = 4 * 1024; // 4 KB sector (minimum erase unit)
let page_size: u32 = 256; // 256 byte write page
let status_mask: u32 = 0xFFFF;
let mut payload = Vec::with_capacity(24);
payload.extend_from_slice(&fl_id.to_le_bytes());
payload.extend_from_slice(&flash_size_bytes.to_le_bytes());
payload.extend_from_slice(&block_size.to_le_bytes());
payload.extend_from_slice(&sector_size.to_le_bytes());
payload.extend_from_slice(&page_size.to_le_bytes());
payload.extend_from_slice(&status_mask.to_le_bytes());
send_command(port, CMD_SPI_SET_PARAMS, &payload, 0, 3000)?;
Ok(())
}
#[cfg(not(target_arch = "wasm32"))]
fn flash_begin(
port: &mut Box<dyn SerialPort>,
offset: u32,
total_size: u32,
block_size: u32,
) -> Result<(), String> {
let num_blocks = (total_size + block_size - 1) / block_size;
// erase_size must align to sector boundary (4 KB).
// Passing raw file size causes the ROM to skip erasing the last partial sector.
let erase_size = (total_size + FLASH_SECTOR_SIZE - 1) & !(FLASH_SECTOR_SIZE - 1);
let mut payload = Vec::with_capacity(20);
payload.extend_from_slice(&erase_size.to_le_bytes());
payload.extend_from_slice(&num_blocks.to_le_bytes());
payload.extend_from_slice(&block_size.to_le_bytes());
payload.extend_from_slice(&offset.to_le_bytes());
// 5th field: begin_rom_encrypted flag. ESP32-S3 SUPPORTS_ENCRYPTED_FLASH=true,
// so the ROM expects this field. 0 = not using ROM-encrypted write mode.
payload.extend_from_slice(&0u32.to_le_bytes());
// Flash erase can take several seconds — generous timeout
send_command(port, CMD_FLASH_BEGIN, &payload, 0, 30_000)?;
Ok(())
}
/// Write one 1024-byte block to flash with up to WRITE_BLOCK_ATTEMPTS retries.
///
/// Payload format: [data_len:u32][seq:u32][reserved:u32=0][reserved:u32=0][data...]
/// Checksum = XOR of data bytes seeded with 0xEF (placed in the command header value field).
#[cfg(not(target_arch = "wasm32"))]
fn flash_data(
port: &mut Box<dyn SerialPort>,
seq: u32,
data: &[u8],
) -> Result<(), String> {
let data_len = data.len() as u32;
let mut payload = Vec::with_capacity(16 + data.len());
payload.extend_from_slice(&data_len.to_le_bytes());
payload.extend_from_slice(&seq.to_le_bytes());
payload.extend_from_slice(&0u32.to_le_bytes()); // reserved
payload.extend_from_slice(&0u32.to_le_bytes()); // reserved
payload.extend_from_slice(data);
let checksum = xor_checksum(data);
// With the ROM (not stub), the chip writes the block to flash synchronously
// before ACKing. 10 s covers the worst case (slow flash chip + full sector erase).
for attempt in 0..WRITE_BLOCK_ATTEMPTS {
match send_command(port, CMD_FLASH_DATA, &payload, checksum, 10_000) {
Ok(_) => return Ok(()),
Err(e) if attempt < WRITE_BLOCK_ATTEMPTS - 1 => {
// Drain input before retry
let _ = port.clear(serialport::ClearBuffer::Input);
std::thread::sleep(Duration::from_millis(10));
let _ = e; // suppress unused warning
}
Err(e) => {
return Err(format!("FLASH_DATA seq={} failed after {} attempts: {}", seq, WRITE_BLOCK_ATTEMPTS, e));
}
}
}
// Unreachable but required for the type checker
Err(format!("FLASH_DATA seq={} failed", seq))
}
#[cfg(not(target_arch = "wasm32"))]
fn flash_end(port: &mut Box<dyn SerialPort>, reboot: bool) -> Result<(), String> {
// flag=0 → run app (reboot); flag=1 → stay in bootloader
let flag: u32 = if reboot { 0 } else { 1 };
let payload = flag.to_le_bytes();
// May not get a response if the device reboots before ACKing
let _ = send_command(port, CMD_FLASH_END, &payload, 0, 2000);
if reboot {
// Hard reset: pulse RTS low to trigger EN pin (like esptool --after hard_reset)
std::thread::sleep(Duration::from_millis(100));
port.write_request_to_send(true)
.map_err(|e| format!("RTS error: {}", e))?;
std::thread::sleep(Duration::from_millis(100));
port.write_request_to_send(false)
.map_err(|e| format!("RTS error: {}", e))?;
}
Ok(())
}
/// Verify flash contents using the ROM MD5 command (CMD 0x13).
///
/// The ROM bootloader (not stub) returns 32 ASCII hex characters in the response
/// data field (bytes [8..40] of the decoded packet), followed by the standard
/// [status][error] bytes at [40..42].
///
/// The stub returns 16 binary bytes instead. Since we talk to the ROM directly
/// we parse the 32-char ASCII format.
#[cfg(not(target_arch = "wasm32"))]
fn flash_md5sum(
port: &mut Box<dyn SerialPort>,
addr: u32,
size: u32,
) -> Result<String, String> {
let mut payload = Vec::with_capacity(16);
payload.extend_from_slice(&addr.to_le_bytes());
payload.extend_from_slice(&size.to_le_bytes());
payload.extend_from_slice(&0u32.to_le_bytes()); // reserved
payload.extend_from_slice(&0u32.to_le_bytes()); // reserved
// MD5 of a 1 MB image takes ~8 s on the ROM. Scale generously.
let timeout_ms = 8_000 + (size as u64 * 8 / 1_000_000).max(3_000);
// The ROM's MD5 response carries 32 extra bytes before the status word,
// so the standard send_command() status check at decoded[8] would read into
// the MD5 data. We must use a longer response path. To keep it simple we
// bypass send_command() and call into the raw SLIP layer here.
let pkt = build_command(CMD_SPI_FLASH_MD5, &payload, 0);
let frame = slip_encode(&pkt);
port.write_all(&frame)
.map_err(|e| format!("Write error (MD5): {}", e))?;
port.flush()
.map_err(|e| format!("Flush error (MD5): {}", e))?;
let mut raw = Vec::new();
let mut buf = [0u8; 512];
let start = Instant::now();
let timeout = Duration::from_millis(timeout_ms);
loop {
if start.elapsed() > timeout {
return Err("MD5 command timeout".to_string());
}
match port.read(&mut buf) {
Ok(n) if n > 0 => raw.extend_from_slice(&buf[..n]),
_ => {
std::thread::sleep(Duration::from_millis(5));
if raw.is_empty() {
continue;
}
}
}
let frames = extract_slip_frames(&raw);
for slip_data in &frames {
let decoded = slip_decode(slip_data);
// Minimum: 8-byte header + 32 ASCII hex bytes + 2 status bytes = 42 bytes
if decoded.len() < 42 {
continue;
}
if decoded[0] != 0x01 || decoded[1] != CMD_SPI_FLASH_MD5 {
continue;
}
// Status bytes are at offset 40 (after 8-byte header + 32 MD5 bytes)
let status = decoded[40];
let error = decoded[41];
if status != 0 {
return Err(format!("MD5 command error: status={} error=0x{:02X}", status, error));
}
// Extract 32 ASCII hex chars from decoded[8..40]
let md5_ascii = &decoded[8..40];
let md5_str = std::str::from_utf8(md5_ascii)
.map_err(|_| "MD5 response is not valid UTF-8".to_string())?
.to_lowercase();
return Ok(md5_str);
}
}
}
// ==================== Main entry point ====================
/// Flash firmware to ESP32 via programming port (CH340/CP2102).
///
/// Sequence (mirrors what esptool does in --no-stub ROM mode):
/// 1. Enter bootloader via DTR/RTS reset sequence
/// 2. SYNC at 115200
/// 3. CHANGE_BAUDRATE to 460800
/// 4. SPI_ATTACH
/// 5. SPI_SET_PARAMS (16 MB flash geometry) — critical, was missing
/// 6. FLASH_BEGIN (erases target region)
/// 7. FLASH_DATA (1024-byte blocks, with per-block retry)
/// 8. FLASH_END (reboot)
/// 9. SPI_FLASH_MD5 post-write verification — re-enters bootloader for this
///
/// Note: esptool normally uploads a "stub" to RAM before flashing.
/// We skip that and talk directly to the ROM, which is slower but simpler
/// and does not require the stub binary to be bundled.
#[cfg(not(target_arch = "wasm32"))]
pub fn flash_firmware(
port_name: &str,
firmware: &[u8],
offset: u32,
tx: &mpsc::Sender<FlashProgress>,
) -> Result<(), String> {
let send_progress = |progress: f32, msg: String| {
let _ = tx.send(FlashProgress::OtaProgress(progress, msg));
};
// ------------------------------------------------------------------ //
// Precondition: firmware must be a multiple of 4 bytes. //
// esptool pads to 4 bytes: pad_to(image, 4) //
// ------------------------------------------------------------------ //
let total_size_raw = firmware.len() as u32;
let padded_len = ((total_size_raw + 3) & !3) as usize;
// Always work with an owned buffer so the padded slice has a stable lifetime.
let mut firmware_padded = firmware.to_vec();
firmware_padded.resize(padded_len, 0xFF);
let firmware_to_flash: &[u8] = &firmware_padded;
let total_size = firmware_to_flash.len() as u32;
// Compute the reference MD5 before we start (over the padded data)
let expected_md5 = md5_of(firmware_to_flash);
// ------------------------------------------------------------------ //
// Step 1: Open port + enter bootloader //
// ------------------------------------------------------------------ //
send_progress(0.0, "Opening port...".into());
let builder = serialport::new(port_name, INITIAL_BAUD);
let builder_timeout = builder.timeout(Duration::from_millis(500));
let mut port = builder_timeout.open()
.map_err(|e| format!("Cannot open {}: {}", port_name, e))?;
send_progress(0.0, "Resetting into bootloader...".into());
enter_bootloader(&mut port)?;
// ------------------------------------------------------------------ //
// Step 2: SYNC at 115200 //
// ------------------------------------------------------------------ //
send_progress(0.01, "Syncing with bootloader...".into());
sync(&mut port)?;
send_progress(0.02, "Bootloader sync OK".into());
// ------------------------------------------------------------------ //
// Step 3: Switch to 460800 baud //
// ------------------------------------------------------------------ //
send_progress(0.03, format!("Switching to {} baud...", FLASH_BAUD));
change_baudrate(&mut port, FLASH_BAUD)?;
send_progress(0.04, format!("Baud: {}", FLASH_BAUD));
// ------------------------------------------------------------------ //
// Step 4: SPI attach //
// ------------------------------------------------------------------ //
send_progress(0.05, "Attaching SPI flash...".into());
spi_attach(&mut port)?;
// ------------------------------------------------------------------ //
// Step 5: SPI_SET_PARAMS — inform ROM of flash chip geometry //
// //
// THIS IS THE MISSING STEP. Without it the ROM's internal flash //
// descriptor keeps its power-on-reset default (often 2 MB). Writes //
// beyond that boundary are silently dropped or cause the ROM to erase //
// wrong sectors, producing a binary that passes the progress bar but //
// the bootloader refuses to map into the MMU. //
// ------------------------------------------------------------------ //
send_progress(0.06, "Setting SPI flash parameters (16 MB)...".into());
const FLASH_SIZE_16MB: u32 = 16 * 1024 * 1024;
spi_set_params(&mut port, FLASH_SIZE_16MB)?;
send_progress(0.07, "SPI flash configured".into());
// ------------------------------------------------------------------ //
// Step 6: FLASH_BEGIN (erases the target region) //
// ------------------------------------------------------------------ //
let num_blocks = (total_size + FLASH_BLOCK_SIZE - 1) / FLASH_BLOCK_SIZE;
send_progress(0.08, format!("Erasing flash region ({} KB at 0x{:X})...", total_size / 1024, offset));
flash_begin(&mut port, offset, total_size, FLASH_BLOCK_SIZE)?;
send_progress(0.10, "Flash erased, writing...".into());
// ------------------------------------------------------------------ //
// Step 7: FLASH_DATA blocks //
// ------------------------------------------------------------------ //
for (i, chunk) in firmware_to_flash.chunks(FLASH_BLOCK_SIZE as usize).enumerate() {
// Pad the last block to exactly FLASH_BLOCK_SIZE with 0xFF
let mut block = chunk.to_vec();
let pad_needed = FLASH_BLOCK_SIZE as usize - block.len();
if pad_needed > 0 {
block.extend(std::iter::repeat(0xFF).take(pad_needed));
}
flash_data(&mut port, i as u32, &block)?;
let blocks_done = (i + 1) as f32;
let progress = 0.10 + 0.82 * (blocks_done / num_blocks as f32);
let written_kb = ((i as u32 + 1) * FLASH_BLOCK_SIZE).min(total_size) / 1024;
let total_kb = total_size / 1024;
send_progress(progress, format!(
"Writing block {}/{} ({}/{} KB)",
i + 1, num_blocks, written_kb, total_kb
));
}
// ------------------------------------------------------------------ //
// Step 8: Erase otadata when flashing factory //
// //
// If we just wrote to the factory partition, the bootloader might //
// still have otadata pointing to ota_0. We erase otadata to force //
// the bootloader to fall back to factory on next boot. //
// //
// The ROM accepts consecutive flash_begin calls without flash_end in //
// between — no need to end+re-sync between the two flash sequences. //
// ------------------------------------------------------------------ //
if offset == 0x20000 {
send_progress(0.92, "Erasing otadata (force factory boot)...".into());
const OTADATA_OFFSET: u32 = 0xF000;
const OTADATA_SIZE: u32 = 0x2000; // 8 KB
flash_begin(&mut port, OTADATA_OFFSET, OTADATA_SIZE, FLASH_BLOCK_SIZE)?;
let empty_block = vec![0xFFu8; FLASH_BLOCK_SIZE as usize];
let otadata_blocks = (OTADATA_SIZE + FLASH_BLOCK_SIZE - 1) / FLASH_BLOCK_SIZE;
for i in 0..otadata_blocks {
flash_data(&mut port, i, &empty_block)?;
}
}
// ------------------------------------------------------------------ //
// Step 9: FLASH_END — stay in bootloader for MD5 verification //
// ------------------------------------------------------------------ //
send_progress(0.93, "Finalizing write...".into());
flash_end(&mut port, false)?;
// ------------------------------------------------------------------ //
// Step 9: MD5 post-write verification //
// //
// The ROM computes MD5 over the flash region we just wrote and returns //
// it as 32 ASCII hex characters. We compare against the MD5 we //
// computed locally over the padded firmware before sending it. //
// A mismatch at this point means data was corrupted in transit or the //
// chip is not responding correctly — the previous "success" was a lie. //
// ------------------------------------------------------------------ //
send_progress(0.94, "Verifying flash MD5...".into());
match flash_md5sum(&mut port, offset, total_size) {
Ok(flash_md5) => {
if flash_md5 != expected_md5 {
return Err(format!(
"MD5 mismatch — flash corrupt!\n expected: {}\n got: {}",
expected_md5, flash_md5
));
}
send_progress(0.97, format!("MD5 OK: {}", flash_md5));
}
Err(e) => {
// Non-fatal: log the warning but don't abort the flash.
// Some boards reset before we can query MD5.
send_progress(0.97, format!("Warning: MD5 check failed ({}), rebooting anyway", e));
}
}
// ------------------------------------------------------------------ //
// Step 10: Hard reset to run the new firmware //
// ------------------------------------------------------------------ //
send_progress(0.98, "Rebooting...".into());
// Pulse RTS to trigger EN (same as esptool HardReset)
port.write_request_to_send(true)
.map_err(|e| format!("RTS error: {}", e))?;
std::thread::sleep(Duration::from_millis(100));
port.write_request_to_send(false)
.map_err(|e| format!("RTS error: {}", e))?;
send_progress(1.0, format!(
"Flash OK — {} KB written at 0x{:X}, MD5 verified",
total_size / 1024, offset
));
Ok(())
}
// ==================== Internal helpers ====================
/// Pure-Rust MD5 implementation (no external crate required).
/// Based on RFC 1321. Returns a lowercase 32-character hex string.
/// Used to compute the reference digest over the firmware image before
/// sending, for post-write comparison.
#[cfg(not(target_arch = "wasm32"))]
fn md5_of(data: &[u8]) -> String {
// Per-round shift amounts
const S: [u32; 64] = [
7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22,
5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20,
4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23,
6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
];
// Precomputed table K[i] = floor(abs(sin(i+1)) * 2^32)
const K: [u32; 64] = [
0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee,
0xf57c0faf, 0x4787c62a, 0xa8304613, 0xfd469501,
0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be,
0x6b901122, 0xfd987193, 0xa679438e, 0x49b40821,
0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa,
0xd62f105d, 0x02441453, 0xd8a1e681, 0xe7d3fbc8,
0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed,
0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a,
0xfffa3942, 0x8771f681, 0x6d9d6122, 0xfde5380c,
0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70,
0x289b7ec6, 0xeaa127fa, 0xd4ef3085, 0x04881d05,
0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665,
0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039,
0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1,
0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1,
0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391,
];
let mut a0: u32 = 0x67452301;
let mut b0: u32 = 0xefcdab89;
let mut c0: u32 = 0x98badcfe;
let mut d0: u32 = 0x10325476;
// Pre-processing: add bit-length suffix per RFC 1321
let bit_len = (data.len() as u64).wrapping_mul(8);
let mut msg = data.to_vec();
msg.push(0x80);
while msg.len() % 64 != 56 {
msg.push(0x00);
}
msg.extend_from_slice(&bit_len.to_le_bytes());
// Process each 512-bit (64-byte) chunk
for chunk in msg.chunks(64) {
let mut m = [0u32; 16];
for (i, word_bytes) in chunk.chunks(4).enumerate() {
m[i] = u32::from_le_bytes([word_bytes[0], word_bytes[1], word_bytes[2], word_bytes[3]]);
}
let (mut a, mut b, mut c, mut d) = (a0, b0, c0, d0);
for i in 0usize..64 {
let (f, g): (u32, usize) = match i {
0..=15 => ((b & c) | ((!b) & d), i),
16..=31 => ((d & b) | ((!d) & c), (5 * i + 1) % 16),
32..=47 => (b ^ c ^ d, (3 * i + 5) % 16),
_ => (c ^ (b | (!d)), (7 * i) % 16),
};
let temp = d;
d = c;
c = b;
b = b.wrapping_add(
a.wrapping_add(f)
.wrapping_add(K[i])
.wrapping_add(m[g])
.rotate_left(S[i])
);
a = temp;
}
a0 = a0.wrapping_add(a);
b0 = b0.wrapping_add(b);
c0 = c0.wrapping_add(c);
d0 = d0.wrapping_add(d);
}
// Serialize as standard MD5 hex string (bytes left-to-right in little-endian word order)
let mut result = [0u8; 16];
result[0..4].copy_from_slice(&a0.to_le_bytes());
result[4..8].copy_from_slice(&b0.to_le_bytes());
result[8..12].copy_from_slice(&c0.to_le_bytes());
result[12..16].copy_from_slice(&d0.to_le_bytes());
let mut hex = String::with_capacity(32);
for byte in &result {
hex.push_str(&format!("{:02x}", byte));
}
hex
}
// ==================== Tests ====================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slip_encode_no_special() {
let data = vec![0x01, 0x02, 0x03];
let encoded = slip_encode(&data);
assert_eq!(encoded, vec![0xC0, 0x01, 0x02, 0x03, 0xC0]);
}
#[test]
fn slip_encode_with_end_byte() {
let data = vec![0x01, 0xC0, 0x03];
let encoded = slip_encode(&data);
assert_eq!(encoded, vec![0xC0, 0x01, 0xDB, 0xDC, 0x03, 0xC0]);
}
#[test]
fn slip_encode_with_esc_byte() {
let data = vec![0x01, 0xDB, 0x03];
let encoded = slip_encode(&data);
assert_eq!(encoded, vec![0xC0, 0x01, 0xDB, 0xDD, 0x03, 0xC0]);
}
#[test]
fn slip_roundtrip() {
let original = vec![0xC0, 0xDB, 0x00, 0xFF, 0xC0];
let encoded = slip_encode(&original);
let decoded = slip_decode(&encoded);
assert_eq!(decoded, original);
}
#[test]
fn xor_checksum_basic() {
let data = vec![0x01, 0x02, 0x03];
let chk = xor_checksum(&data);
let expected = 0xEF ^ 0x01 ^ 0x02 ^ 0x03;
assert_eq!(chk, expected as u32);
}
#[test]
fn xor_checksum_empty() {
let chk = xor_checksum(&[]);
assert_eq!(chk, 0xEF);
}
#[test]
fn build_command_format() {
let data = vec![0xAA, 0xBB];
let pkt = build_command(0x08, &data, 0x12345678);
assert_eq!(pkt[0], 0x00); // direction
assert_eq!(pkt[1], 0x08); // command
assert_eq!(pkt[2], 0x02); // size low
assert_eq!(pkt[3], 0x00); // size high
assert_eq!(pkt[4], 0x78); // checksum byte 0
assert_eq!(pkt[5], 0x56); // checksum byte 1
assert_eq!(pkt[6], 0x34); // checksum byte 2
assert_eq!(pkt[7], 0x12); // checksum byte 3
assert_eq!(pkt[8], 0xAA); // data
assert_eq!(pkt[9], 0xBB);
}
#[test]
fn spi_set_params_payload_length() {
// Payload must be exactly 24 bytes (6 x u32)
let flash_size: u32 = 16 * 1024 * 1024;
let mut payload = Vec::with_capacity(24);
payload.extend_from_slice(&0u32.to_le_bytes()); // fl_id
payload.extend_from_slice(&flash_size.to_le_bytes()); // total_size
payload.extend_from_slice(&(64u32 * 1024).to_le_bytes()); // block_size
payload.extend_from_slice(&(4u32 * 1024).to_le_bytes()); // sector_size
payload.extend_from_slice(&256u32.to_le_bytes()); // page_size
payload.extend_from_slice(&0xFFFFu32.to_le_bytes()); // status_mask
assert_eq!(payload.len(), 24);
}
#[test]
fn flash_begin_payload_has_5_fields() {
// ESP32-S3 flash_begin must have 5 fields (20 bytes), not 4 (16 bytes)
let erase_size: u32 = 0x1000;
let num_blocks: u32 = 1;
let block_size: u32 = FLASH_BLOCK_SIZE;
let offset: u32 = 0x20000;
let encrypted: u32 = 0;
let mut payload = Vec::with_capacity(20);
payload.extend_from_slice(&erase_size.to_le_bytes());
payload.extend_from_slice(&num_blocks.to_le_bytes());
payload.extend_from_slice(&block_size.to_le_bytes());
payload.extend_from_slice(&offset.to_le_bytes());
payload.extend_from_slice(&encrypted.to_le_bytes());
assert_eq!(payload.len(), 20);
}
#[test]
fn md5_empty_string() {
// RFC 1321 test vector: MD5("") = d41d8cd98f00b204e9800998ecf8427e
assert_eq!(md5_of(b""), "d41d8cd98f00b204e9800998ecf8427e");
}
#[test]
fn md5_abc() {
// RFC 1321 test vector: MD5("abc") = 900150983cd24fb0d6963f7d28e17f72
assert_eq!(md5_of(b"abc"), "900150983cd24fb0d6963f7d28e17f72");
}
#[test]
fn md5_known_long() {
// RFC 1321: MD5("abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq")
// = 8215ef0796a20bcaaae116d3876c664a
let input = b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq";
assert_eq!(md5_of(input), "8215ef0796a20bcaaae116d3876c664a");
}
#[test]
fn firmware_padding_to_4_bytes() {
// Firmware of odd length must be padded to 4-byte boundary with 0xFF
let fw = vec![0xE9u8; 5]; // 5 bytes, not aligned
let padded_len = (fw.len() as u32 + 3) & !3;
assert_eq!(padded_len, 8);
}
}

View file

@ -1,390 +0,0 @@
/// Decode a raw 16-bit keycode into a human-readable string.
///
/// Covers all KaSe firmware keycode ranges: HID basic keys, layer switches,
/// macros, Bluetooth, one-shot, mod-tap, layer-tap, tap-dance, and more.
pub fn decode_keycode(raw: u16) -> String {
// --- HID basic keycodes 0x00..=0xE7 ---
if raw <= 0x00E7 {
return hid_key_name(raw as u8);
}
// --- MO (Momentary Layer): 0x0100..=0x0A00, low byte == 0 ---
if raw >= 0x0100 && raw <= 0x0A00 && (raw & 0xFF) == 0 {
let layer = (raw >> 8) - 1;
return format!("MO {layer}");
}
// --- TO (Toggle Layer): 0x0B00..=0x1400, low byte == 0 ---
if raw >= 0x0B00 && raw <= 0x1400 && (raw & 0xFF) == 0 {
let layer = (raw >> 8) - 0x0B;
return format!("TO {layer}");
}
// --- MACRO: 0x1500..=0x2800, low byte == 0 ---
if raw >= 0x1500 && raw <= 0x2800 && (raw & 0xFF) == 0 {
let idx = (raw >> 8) - 0x15;
return format!("M{idx}");
}
// --- BT keycodes ---
match raw {
0x2900 => return "BT Next".into(),
0x2A00 => return "BT Prev".into(),
0x2B00 => return "BT Pair".into(),
0x2C00 => return "BT Disc".into(),
0x2E00 => return "USB/BT".into(),
0x2F00 => return "BT On/Off".into(),
_ => {}
}
// --- OSM (One-Shot Mod): 0x3000..=0x30FF ---
if raw >= 0x3000 && raw <= 0x30FF {
let mods = (raw & 0xFF) as u8;
return format!("OSM {}", mod_name(mods));
}
// --- OSL (One-Shot Layer): 0x3100..=0x310F ---
if raw >= 0x3100 && raw <= 0x310F {
let layer = raw & 0x0F;
return format!("OSL {layer}");
}
// --- Fixed special codes ---
match raw {
0x3200 => return "Caps Word".into(),
0x3300 => return "Repeat".into(),
0x3400 => return "Leader".into(),
0x3500 => return "Feed".into(),
0x3600 => return "Play".into(),
0x3700 => return "Sleep".into(),
0x3800 => return "Meds".into(),
0x3900 => return "GEsc".into(),
0x3A00 => return "Layer Lock".into(),
0x3C00 => return "AS Toggle".into(),
_ => {}
}
// --- KO (Key Override) slots: 0x3D00..=0x3DFF ---
if raw >= 0x3D00 && raw <= 0x3DFF {
let slot = raw & 0xFF;
return format!("KO {slot}");
}
// --- LT (Layer-Tap): 0x4000..=0x4FFF ---
// layout: 0x4LKK where L = layer (0..F), KK = HID keycode
if raw >= 0x4000 && raw <= 0x4FFF {
let layer = (raw >> 8) & 0x0F;
let kc = (raw & 0xFF) as u8;
return format!("LT {} {}", layer, hid_key_name(kc));
}
// --- MT (Mod-Tap): 0x5000..=0x5FFF ---
// layout: 0x5MKK where M = mod nibble (4 bits), KK = HID keycode
if raw >= 0x5000 && raw <= 0x5FFF {
let mods = ((raw >> 8) & 0x0F) as u8;
let kc = (raw & 0xFF) as u8;
return format!("MT {} {}", mod_name(mods), hid_key_name(kc));
}
// --- TD (Tap Dance): 0x6000..=0x6FFF ---
if raw >= 0x6000 && raw <= 0x6FFF {
let index = (raw >> 8) & 0x0F;
return format!("TD {index}");
}
// --- Unknown ---
format!("0x{raw:04X}")
}
/// Decode a modifier bitmask into a human-readable string.
///
/// Bits: 0x01=Ctrl, 0x02=Shift, 0x04=Alt, 0x08=GUI,
/// 0x10=RCtrl, 0x20=RShift, 0x40=RAlt, 0x80=RGUI.
/// Multiple modifiers are joined with "+".
pub fn mod_name(mod_mask: u8) -> String {
let mut parts = Vec::new();
if mod_mask & 0x01 != 0 { parts.push("Ctrl"); }
if mod_mask & 0x02 != 0 { parts.push("Shift"); }
if mod_mask & 0x04 != 0 { parts.push("Alt"); }
if mod_mask & 0x08 != 0 { parts.push("GUI"); }
if mod_mask & 0x10 != 0 { parts.push("RCtrl"); }
if mod_mask & 0x20 != 0 { parts.push("RShift"); }
if mod_mask & 0x40 != 0 { parts.push("RAlt"); }
if mod_mask & 0x80 != 0 { parts.push("RGUI"); }
if parts.is_empty() {
format!("0x{mod_mask:02X}")
} else {
parts.join("+")
}
}
/// Map a single HID usage code (0x00..=0xE7) to a short readable name.
pub fn hid_key_name(code: u8) -> String {
match code {
// No key / transparent
0x00 => "None",
// 0x01 = ErrorRollOver, 0x02 = POSTFail, 0x03 = ErrorUndefined (not user-facing)
0x01 => "ErrRollOver",
0x02 => "POSTFail",
0x03 => "ErrUndef",
// Letters
0x04 => "A",
0x05 => "B",
0x06 => "C",
0x07 => "D",
0x08 => "E",
0x09 => "F",
0x0A => "G",
0x0B => "H",
0x0C => "I",
0x0D => "J",
0x0E => "K",
0x0F => "L",
0x10 => "M",
0x11 => "N",
0x12 => "O",
0x13 => "P",
0x14 => "Q",
0x15 => "R",
0x16 => "S",
0x17 => "T",
0x18 => "U",
0x19 => "V",
0x1A => "W",
0x1B => "X",
0x1C => "Y",
0x1D => "Z",
// Number row
0x1E => "1",
0x1F => "2",
0x20 => "3",
0x21 => "4",
0x22 => "5",
0x23 => "6",
0x24 => "7",
0x25 => "8",
0x26 => "9",
0x27 => "0",
// Common control keys
0x28 => "Enter",
0x29 => "Esc",
0x2A => "Backspace",
0x2B => "Tab",
0x2C => "Space",
// Punctuation / symbols
0x2D => "-",
0x2E => "=",
0x2F => "[",
0x30 => "]",
0x31 => "\\",
0x32 => "Europe1",
0x33 => ";",
0x34 => "'",
0x35 => "`",
0x36 => ",",
0x37 => ".",
0x38 => "/",
// Caps Lock
0x39 => "Caps Lock",
// Function keys
0x3A => "F1",
0x3B => "F2",
0x3C => "F3",
0x3D => "F4",
0x3E => "F5",
0x3F => "F6",
0x40 => "F7",
0x41 => "F8",
0x42 => "F9",
0x43 => "F10",
0x44 => "F11",
0x45 => "F12",
// Navigation / editing cluster
0x46 => "PrtSc",
0x47 => "ScrLk",
0x48 => "Pause",
0x49 => "Ins",
0x4A => "Home",
0x4B => "PgUp",
0x4C => "Del",
0x4D => "End",
0x4E => "PgDn",
// Arrow keys
0x4F => "Right",
0x50 => "Left",
0x51 => "Down",
0x52 => "Up",
// Keypad
0x53 => "NumLk",
0x54 => "Num /",
0x55 => "Num *",
0x56 => "Num -",
0x57 => "Num +",
0x58 => "Num Enter",
0x59 => "Num 1",
0x5A => "Num 2",
0x5B => "Num 3",
0x5C => "Num 4",
0x5D => "Num 5",
0x5E => "Num 6",
0x5F => "Num 7",
0x60 => "Num 8",
0x61 => "Num 9",
0x62 => "Num 0",
0x63 => "Num .",
0x64 => "Europe2",
0x65 => "Menu",
0x66 => "Power",
0x67 => "Num =",
// F13-F24
0x68 => "F13",
0x69 => "F14",
0x6A => "F15",
0x6B => "F16",
0x6C => "F17",
0x6D => "F18",
0x6E => "F19",
0x6F => "F20",
0x70 => "F21",
0x71 => "F22",
0x72 => "F23",
0x73 => "F24",
// Misc system keys
0x74 => "Execute",
0x75 => "Help",
0x76 => "Menu2",
0x77 => "Select",
0x78 => "Stop",
0x79 => "Again",
0x7A => "Undo",
0x7B => "Cut",
0x7C => "Copy",
0x7D => "Paste",
0x7E => "Find",
0x7F => "Mute",
0x80 => "Vol Up",
0x81 => "Vol Down",
// Locking keys
0x82 => "Lock Caps",
0x83 => "Lock Num",
0x84 => "Lock Scroll",
// Keypad extras
0x85 => "Num ,",
0x86 => "Num =2",
// International / Kanji
0x87 => "Kanji1",
0x88 => "Kanji2",
0x89 => "Kanji3",
0x8A => "Kanji4",
0x8B => "Kanji5",
0x8C => "Kanji6",
0x8D => "Kanji7",
0x8E => "Kanji8",
0x8F => "Kanji9",
// Language keys
0x90 => "Lang1",
0x91 => "Lang2",
0x92 => "Lang3",
0x93 => "Lang4",
0x94 => "Lang5",
0x95 => "Lang6",
0x96 => "Lang7",
0x97 => "Lang8",
0x98 => "Lang9",
// Rare system keys
0x99 => "Alt Erase",
0x9A => "SysReq",
0x9B => "Cancel",
0x9C => "Clear",
0x9D => "Prior",
0x9E => "Return",
0x9F => "Separator",
0xA0 => "Out",
0xA1 => "Oper",
0xA2 => "Clear Again",
0xA3 => "CrSel",
0xA4 => "ExSel",
// 0xA5..=0xAF reserved / not defined in standard HID tables
// Extended keypad
0xB0 => "Num 00",
0xB1 => "Num 000",
0xB2 => "Thousands Sep",
0xB3 => "Decimal Sep",
0xB4 => "Currency",
0xB5 => "Currency Sub",
0xB6 => "Num (",
0xB7 => "Num )",
0xB8 => "Num {",
0xB9 => "Num }",
0xBA => "Num Tab",
0xBB => "Num Bksp",
0xBC => "Num A",
0xBD => "Num B",
0xBE => "Num C",
0xBF => "Num D",
0xC0 => "Num E",
0xC1 => "Num F",
0xC2 => "Num XOR",
0xC3 => "Num ^",
0xC4 => "Num %",
0xC5 => "Num <",
0xC6 => "Num >",
0xC7 => "Num &",
0xC8 => "Num &&",
0xC9 => "Num |",
0xCA => "Num ||",
0xCB => "Num :",
0xCC => "Num #",
0xCD => "Num Space",
0xCE => "Num @",
0xCF => "Num !",
0xD0 => "Num M Store",
0xD1 => "Num M Recall",
0xD2 => "Num M Clear",
0xD3 => "Num M+",
0xD4 => "Num M-",
0xD5 => "Num M*",
0xD6 => "Num M/",
0xD7 => "Num +/-",
0xD8 => "Num Clear",
0xD9 => "Num ClrEntry",
0xDA => "Num Binary",
0xDB => "Num Octal",
0xDC => "Num Decimal",
0xDD => "Num Hex",
// 0xDE..=0xDF reserved
// Modifier keys
0xE0 => "LCtrl",
0xE1 => "LShift",
0xE2 => "LAlt",
0xE3 => "LGUI",
0xE4 => "RCtrl",
0xE5 => "RShift",
0xE6 => "RAlt",
0xE7 => "RGUI",
// Anything else in 0x00..=0xFF not covered above
_ => return format!("0x{code:02X}"),
}
.into()
}

View file

@ -1,104 +0,0 @@
use serde::Deserialize;
/// A keycap with computed absolute position.
#[derive(Clone, Debug, PartialEq)]
pub struct KeycapPos {
pub row: usize,
pub col: usize,
pub x: f32,
pub y: f32,
pub w: f32,
pub h: f32,
pub angle: f32, // degrees
}
const UNIT_PX: f32 = 50.0; // 1u = 50 pixels
#[derive(Deserialize)]
struct LayoutJson {
#[allow(dead_code)]
name: Option<String>,
#[allow(dead_code)]
rows: Option<usize>,
#[allow(dead_code)]
cols: Option<usize>,
#[serde(default)]
keys: Vec<KeyJson>,
#[serde(default)]
groups: Vec<GroupJson>,
}
#[derive(Deserialize)]
struct GroupJson {
#[serde(default)]
x: f32,
#[serde(default)]
y: f32,
#[serde(default)]
r: f32,
keys: Vec<KeyJson>,
}
#[derive(Deserialize)]
struct KeyJson {
row: usize,
col: usize,
#[serde(default)]
x: f32,
#[serde(default)]
y: f32,
#[serde(default = "default_one")]
w: f32,
#[serde(default = "default_one")]
h: f32,
#[serde(default)]
r: f32,
}
fn default_one() -> f32 { 1.0 }
/// Parse a layout JSON into absolute key positions.
pub fn parse_json(json: &str) -> Result<Vec<KeycapPos>, String> {
let layout: LayoutJson = serde_json::from_str(json)
.map_err(|e| format!("Invalid layout JSON: {}", e))?;
let mut out = Vec::new();
// Top-level keys: absolute positions
for k in &layout.keys {
out.push(KeycapPos {
row: k.row, col: k.col,
x: k.x * UNIT_PX, y: k.y * UNIT_PX,
w: k.w * UNIT_PX, h: k.h * UNIT_PX,
angle: k.r,
});
}
// Groups: apply rotation + translation to local coords
for g in &layout.groups {
let rad = g.r.to_radians();
let cos_a = rad.cos();
let sin_a = rad.sin();
for k in &g.keys {
let ax = g.x + k.x * cos_a - k.y * sin_a;
let ay = g.y + k.x * sin_a + k.y * cos_a;
out.push(KeycapPos {
row: k.row, col: k.col,
x: ax * UNIT_PX, y: ay * UNIT_PX,
w: k.w * UNIT_PX, h: k.h * UNIT_PX,
angle: g.r + k.r,
});
}
}
if out.is_empty() {
return Err("No keys found in layout".into());
}
Ok(out)
}
/// Default layout embedded at compile time.
pub fn default_layout() -> Vec<KeycapPos> {
let json = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/default.json"));
parse_json(json).unwrap_or_default()
}

View file

@ -1,339 +0,0 @@
/// Remaps HID key names to their visual representation based on the user's
/// keyboard layout (language). Ported from the C# `KeyConverter.LayoutOverrides`.
// Display implementation for the keyboard layout picker in settings.
impl std::fmt::Display for KeyboardLayout {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.name())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum KeyboardLayout {
Qwerty,
Azerty,
Qwertz,
Dvorak,
Colemak,
Bepo,
QwertyEs,
QwertyPt,
QwertyIt,
QwertyNordic,
QwertyBr,
QwertyTr,
QwertyUk,
}
impl KeyboardLayout {
/// All known layout variants.
pub fn all() -> &'static [KeyboardLayout] {
&[
KeyboardLayout::Qwerty,
KeyboardLayout::Azerty,
KeyboardLayout::Qwertz,
KeyboardLayout::Dvorak,
KeyboardLayout::Colemak,
KeyboardLayout::Bepo,
KeyboardLayout::QwertyEs,
KeyboardLayout::QwertyPt,
KeyboardLayout::QwertyIt,
KeyboardLayout::QwertyNordic,
KeyboardLayout::QwertyBr,
KeyboardLayout::QwertyTr,
KeyboardLayout::QwertyUk,
]
}
/// Human-readable display name.
pub fn name(&self) -> &str {
match self {
KeyboardLayout::Qwerty => "QWERTY",
KeyboardLayout::Azerty => "AZERTY",
KeyboardLayout::Qwertz => "QWERTZ",
KeyboardLayout::Dvorak => "DVORAK",
KeyboardLayout::Colemak => "COLEMAK",
KeyboardLayout::Bepo => "BEPO",
KeyboardLayout::QwertyEs => "QWERTY_ES",
KeyboardLayout::QwertyPt => "QWERTY_PT",
KeyboardLayout::QwertyIt => "QWERTY_IT",
KeyboardLayout::QwertyNordic => "QWERTY_NORDIC",
KeyboardLayout::QwertyBr => "QWERTY_BR",
KeyboardLayout::QwertyTr => "QWERTY_TR",
KeyboardLayout::QwertyUk => "QWERTY_UK",
}
}
/// Parse a layout name (case-insensitive). Falls back to `Qwerty`.
pub fn from_name(s: &str) -> Self {
match s.to_ascii_uppercase().as_str() {
"QWERTY" => KeyboardLayout::Qwerty,
"AZERTY" => KeyboardLayout::Azerty,
"QWERTZ" => KeyboardLayout::Qwertz,
"DVORAK" => KeyboardLayout::Dvorak,
"COLEMAK" => KeyboardLayout::Colemak,
"BEPO" | "BÉPO" => KeyboardLayout::Bepo,
"QWERTY_ES" => KeyboardLayout::QwertyEs,
"QWERTY_PT" => KeyboardLayout::QwertyPt,
"QWERTY_IT" => KeyboardLayout::QwertyIt,
"QWERTY_NORDIC" => KeyboardLayout::QwertyNordic,
"QWERTY_BR" => KeyboardLayout::QwertyBr,
"QWERTY_TR" => KeyboardLayout::QwertyTr,
"QWERTY_UK" => KeyboardLayout::QwertyUk,
_ => KeyboardLayout::Qwerty,
}
}
}
/// Given a `layout` and an HID key name (e.g. `"A"`, `"COMMA"`, `"SEMICOLON"`),
/// returns the visual label for that key on the given layout, or `None` when no
/// override exists (meaning the default / QWERTY label applies).
///
/// The lookup is **case-insensitive** on `hid_name`.
pub fn remap_key_label(layout: &KeyboardLayout, hid_name: &str) -> Option<&'static str> {
// Normalise to uppercase for matching.
let key = hid_name.to_ascii_uppercase();
let key = key.as_str();
match layout {
// QWERTY has no overrides — it *is* the reference layout.
KeyboardLayout::Qwerty => None,
KeyboardLayout::Azerty => match key {
"COMMA" | "COMM" | "COMA" => Some(";"),
"SEMICOLON" | "SCOLON" | "SCLN" => Some(","),
"PERIOD" | "DOT" => Some(":"),
"SLASH" | "SLSH" | "/" => Some("!"),
"M" => Some(","),
"W" => Some("Z"),
"Z" => Some("W"),
"Q" => Some("A"),
"A" => Some("Q"),
"," => Some(";"),
"." => Some(":"),
";" => Some("M"),
"-" | "MINUS" | "MIN" => Some(")"),
"BRACKET_LEFT" | "LBRCKT" | "LBRC" | "[" => Some("^"),
"BRACKET_RIGHT" | "RBRCKT" | "RBRC" | "]" => Some("$"),
"BACKSLASH" | "BSLSH" | "\\" => Some("<"),
"APOSTROPHE" | "APO" | "QUOT" | "'" => Some("\u{00f9}"), // ù
"1" => Some("& 1"),
"2" => Some("\u{00e9} 2 ~"), // é 2 ~
"3" => Some("\" 3 #"),
"4" => Some("' 4 }"),
"5" => Some("( 5 ["),
"6" => Some("- 6 |"),
"7" => Some("\u{00e8} 7 `"), // è 7 `
"8" => Some("_ 8 \\"),
"9" => Some("\u{00e7} 9 ^"), // ç 9 ^
"0" => Some("\u{00e0} 0 @"), // à 0 @
_ => None,
},
KeyboardLayout::Qwertz => match key {
"Y" => Some("Z"),
"Z" => Some("Y"),
"MINUS" | "MIN" => Some("\u{00df}"), // ß
"EQUAL" | "EQL" => Some("'"),
"BRACKET_LEFT" | "LBRCKT" | "LBRC" => Some("\u{00fc}"), // ü
"BRACKET_RIGHT" | "RBRCKT" | "RBRC" => Some("+"),
"SEMICOLON" | "SCOLON" | "SCLN" => Some("\u{00f6}"), // ö
"APOSTROPHE" | "APO" | "QUOT" => Some("\u{00e4}"), // ä
"GRAVE" | "GRV" => Some("^"),
"SLASH" | "SLSH" => Some("-"),
"BACKSLASH" | "BSLSH" => Some("#"),
_ => None,
},
KeyboardLayout::Dvorak => match key {
"Q" => Some("'"),
"W" => Some(","),
"E" => Some("."),
"R" => Some("P"),
"T" => Some("Y"),
"Y" => Some("F"),
"U" => Some("G"),
"I" => Some("C"),
"O" => Some("R"),
"P" => Some("L"),
"A" => Some("A"),
"S" => Some("O"),
"D" => Some("E"),
"F" => Some("U"),
"G" => Some("I"),
"H" => Some("D"),
"J" => Some("H"),
"K" => Some("T"),
"L" => Some("N"),
"SEMICOLON" | "SCOLON" | "SCLN" => Some("S"),
"APOSTROPHE" | "APO" | "QUOT" => Some("-"),
"Z" => Some(";"),
"X" => Some("Q"),
"C" => Some("J"),
"V" => Some("K"),
"B" => Some("X"),
"N" => Some("B"),
"M" => Some("M"),
"COMMA" | "COMM" | "COMA" => Some("W"),
"PERIOD" | "DOT" => Some("V"),
"SLASH" | "SLSH" => Some("Z"),
"MINUS" | "MIN" => Some("["),
"EQUAL" | "EQL" => Some("]"),
"BRACKET_LEFT" | "LBRCKT" | "LBRC" => Some("/"),
"BRACKET_RIGHT" | "RBRCKT" | "RBRC" => Some("="),
_ => None,
},
KeyboardLayout::Colemak => match key {
"E" => Some("F"),
"R" => Some("P"),
"T" => Some("G"),
"Y" => Some("J"),
"U" => Some("L"),
"I" => Some("U"),
"O" => Some("Y"),
"P" => Some(";"),
"S" => Some("R"),
"D" => Some("S"),
"F" => Some("T"),
"G" => Some("D"),
"J" => Some("N"),
"K" => Some("E"),
"L" => Some("I"),
"SEMICOLON" | "SCOLON" | "SCLN" => Some("O"),
"N" => Some("K"),
_ => None,
},
KeyboardLayout::Bepo => match key {
"Q" => Some("B"),
"W" => Some("E"),
"E" => Some("P"),
"R" => Some("O"),
"T" => Some("E"),
"Y" => Some("^"),
"U" => Some("V"),
"I" => Some("D"),
"O" => Some("L"),
"P" => Some("J"),
"A" => Some("A"),
"S" => Some("U"),
"D" => Some("I"),
"F" => Some("E"),
"G" => Some(","),
"H" => Some("C"),
"J" => Some("T"),
"K" => Some("S"),
"L" => Some("R"),
"SEMICOLON" | "SCOLON" | "SCLN" => Some("N"),
"Z" => Some("A"),
"X" => Some("Y"),
"C" => Some("X"),
"V" => Some("."),
"B" => Some("K"),
"N" => Some("'"),
"M" => Some("Q"),
"COMMA" | "COMM" | "COMA" => Some("G"),
"PERIOD" | "DOT" => Some("H"),
"SLASH" | "SLSH" => Some("F"),
"1" => Some("\""),
"2" => Some("<"),
"3" => Some(">"),
"4" => Some("("),
"5" => Some(")"),
"6" => Some("@"),
"7" => Some("+"),
"8" => Some("-"),
"9" => Some("/"),
"0" => Some("*"),
_ => None,
},
KeyboardLayout::QwertyEs => match key {
"MINUS" | "MIN" => Some("'"),
"EQUAL" | "EQL" => Some("\u{00a1}"), // ¡
"BRACKET_LEFT" | "LBRCKT" | "LBRC" => Some("`"),
"BRACKET_RIGHT" | "RBRCKT" | "RBRC" => Some("+"),
"SEMICOLON" | "SCOLON" | "SCLN" => Some("\u{00f1}"), // ñ
"APOSTROPHE" | "APO" | "QUOT" => Some("'"),
"GRAVE" | "GRV" => Some("\u{00ba}"), // º
"SLASH" | "SLSH" => Some("-"),
"BACKSLASH" | "BSLSH" => Some("\u{00e7}"), // ç
_ => None,
},
KeyboardLayout::QwertyPt => match key {
"MINUS" | "MIN" => Some("'"),
"EQUAL" | "EQL" => Some("\u{00ab}"), // «
"BRACKET_LEFT" | "LBRCKT" | "LBRC" => Some("+"),
"BRACKET_RIGHT" | "RBRCKT" | "RBRC" => Some("'"),
"SEMICOLON" | "SCOLON" | "SCLN" => Some("\u{00e7}"), // ç
"APOSTROPHE" | "APO" | "QUOT" => Some("\u{00ba}"), // º
"GRAVE" | "GRV" => Some("\\"),
"SLASH" | "SLSH" => Some("-"),
"BACKSLASH" | "BSLSH" => Some("~"),
_ => None,
},
KeyboardLayout::QwertyIt => match key {
"MINUS" | "MIN" => Some("'"),
"EQUAL" | "EQL" => Some("\u{00ec}"), // ì
"BRACKET_LEFT" | "LBRCKT" | "LBRC" => Some("\u{00e8}"), // è
"BRACKET_RIGHT" | "RBRCKT" | "RBRC" => Some("+"),
"SEMICOLON" | "SCOLON" | "SCLN" => Some("\u{00f2}"), // ò
"APOSTROPHE" | "APO" | "QUOT" => Some("\u{00e0}"), // à
"GRAVE" | "GRV" => Some("\\"),
"SLASH" | "SLSH" => Some("-"),
"BACKSLASH" | "BSLSH" => Some("\u{00f9}"), // ù
_ => None,
},
KeyboardLayout::QwertyNordic => match key {
"MINUS" | "MIN" => Some("+"),
"EQUAL" | "EQL" => Some("'"),
"BRACKET_LEFT" | "LBRCKT" | "LBRC" => Some("\u{00e5}"), // å
"BRACKET_RIGHT" | "RBRCKT" | "RBRC" => Some("\u{00a8}"), // ¨
"SEMICOLON" | "SCOLON" | "SCLN" => Some("\u{00f6}"), // ö
"APOSTROPHE" | "APO" | "QUOT" => Some("\u{00e4}"), // ä
"GRAVE" | "GRV" => Some("\u{00a7}"), // §
"SLASH" | "SLSH" => Some("-"),
"BACKSLASH" | "BSLSH" => Some("'"),
_ => None,
},
KeyboardLayout::QwertyBr => match key {
"MINUS" | "MIN" => Some("-"),
"EQUAL" | "EQL" => Some("="),
"BRACKET_LEFT" | "LBRCKT" | "LBRC" => Some("'"),
"BRACKET_RIGHT" | "RBRCKT" | "RBRC" => Some("["),
"SEMICOLON" | "SCOLON" | "SCLN" => Some("\u{00e7}"), // ç
"APOSTROPHE" | "APO" | "QUOT" => Some("~"),
"GRAVE" | "GRV" => Some("'"),
"SLASH" | "SLSH" => Some(";"),
"BACKSLASH" | "BSLSH" => Some("]"),
_ => None,
},
KeyboardLayout::QwertyTr => match key {
"MINUS" | "MIN" => Some("*"),
"EQUAL" | "EQL" => Some("-"),
"BRACKET_LEFT" | "LBRCKT" | "LBRC" => Some("\u{011f}"), // ğ
"BRACKET_RIGHT" | "RBRCKT" | "RBRC" => Some("\u{00fc}"), // ü
"SEMICOLON" | "SCOLON" | "SCLN" => Some("\u{015f}"), // ş
"APOSTROPHE" | "APO" | "QUOT" => Some("i"),
"GRAVE" | "GRV" => Some("\""),
"SLASH" | "SLSH" => Some("."),
"BACKSLASH" | "BSLSH" => Some(","),
_ => None,
},
KeyboardLayout::QwertyUk => match key {
"GRAVE" | "GRV" => Some("`"),
"MINUS" | "MIN" => Some("-"),
"EQUAL" | "EQL" => Some("="),
"BACKSLASH" | "BSLSH" => Some("#"),
"APOSTROPHE" | "APO" | "QUOT" => Some("'"),
_ => None,
},
}
}

View file

@ -1,20 +0,0 @@
#[allow(dead_code)]
pub mod binary;
pub mod config_io;
#[allow(dead_code)]
pub mod flasher;
#[allow(dead_code)]
pub mod keycode;
pub mod layout;
#[allow(dead_code)]
pub mod layout_remap;
#[allow(dead_code)]
pub mod parsers;
#[allow(dead_code)]
pub mod text_commands;
#[allow(dead_code)]
pub mod serial;
#[allow(dead_code)]
pub mod settings;
#[allow(dead_code)]
pub mod stats;

View file

@ -1,899 +0,0 @@
/// Parsing functions for firmware text and binary responses.
/// Separated for testability.
/// Keyboard physical dimensions (must match firmware).
pub const ROWS: usize = 5;
pub const COLS: usize = 13;
/// Parse "TD0: 04,05,06,29" lines into an array of 8 tap dance slots.
/// Each slot has 4 actions: [1-tap, 2-tap, 3-tap, hold].
pub fn parse_td_lines(lines: &[String]) -> Vec<[u16; 4]> {
let mut result = vec![[0u16; 4]; 8];
for line in lines {
// Only process lines starting with "TD"
let starts_with_td = line.starts_with("TD");
if !starts_with_td {
continue;
}
// Find the colon separator: "TD0: ..."
let colon = match line.find(':') {
Some(i) => i,
None => continue,
};
// Extract the index between "TD" and ":"
let index_str = &line[2..colon];
let idx: usize = match index_str.parse() {
Ok(i) if i < 8 => i,
_ => continue,
};
// Parse the comma-separated hex values after the colon
let after_colon = &line[colon + 1..];
let trimmed_values = after_colon.trim();
let split_parts = trimmed_values.split(',');
let vals: Vec<u16> = split_parts
.filter_map(|s| {
let trimmed_part = s.trim();
u16::from_str_radix(trimmed_part, 16).ok()
})
.collect();
// We need exactly 4 values
let has_four_values = vals.len() == 4;
if has_four_values {
result[idx] = [vals[0], vals[1], vals[2], vals[3]];
}
}
result
}
/// Parse KO (Key Override) lines into arrays of [trigger, mod, result, res_mod].
/// Format: "KO2: 0B+02->4C+00"
pub fn parse_ko_lines(lines: &[String]) -> Vec<[u8; 4]> {
let mut result = Vec::new();
for line in lines {
let trimmed = line.trim();
// Only process lines matching "KO<digit>:"
if !trimmed.starts_with("KO") { continue; }
let colon = match trimmed.find(':') {
Some(i) => i,
None => continue,
};
// Skip non-data lines like "KO 0 deleted", "KOSET 1:OK"
let index_str = &trimmed[2..colon];
if index_str.contains(' ') || index_str.parse::<u8>().is_err() { continue; }
let after_colon = trimmed[colon + 1..].trim();
// Format: "0B+02->4C+00"
let arrow = match after_colon.find("->") {
Some(i) => i,
None => continue,
};
let left = &after_colon[..arrow]; // "0B+02"
let right = &after_colon[arrow + 2..]; // "4C+00"
let parse_pair = |s: &str| -> (u8, u8) {
let parts: Vec<&str> = s.split('+').collect();
if parts.len() == 2 {
let a = u8::from_str_radix(parts[0].trim(), 16).unwrap_or(0);
let b = u8::from_str_radix(parts[1].trim(), 16).unwrap_or(0);
(a, b)
} else {
(0, 0)
}
};
let (trigger, trig_mod) = parse_pair(left);
let (result_key, res_mod) = parse_pair(right);
result.push([trigger, trig_mod, result_key, res_mod]);
}
result
}
/// Parse heatmap lines (KEYSTATS? response).
/// Format: "R0: 100 50 30 20 10 5 0 15 25 35 45 55 65"
/// Returns (data[5][13], max_value).
pub fn parse_heatmap_lines(lines: &[String]) -> (Vec<Vec<u32>>, u32) {
let mut data: Vec<Vec<u32>> = vec![vec![0u32; COLS]; ROWS];
let mut max = 0u32;
for line in lines {
let trimmed = line.trim();
// Only process lines starting with "R"
if !trimmed.starts_with('R') {
continue;
}
// Find the colon
let colon = match trimmed.find(':') {
Some(i) => i,
None => continue,
};
// Extract row number between "R" and ":"
let row_str = &trimmed[1..colon];
let row: usize = match row_str.parse() {
Ok(r) if r < ROWS => r,
_ => continue,
};
// Parse space-separated values after the colon
let values_str = &trimmed[colon + 1..];
for (col, token) in values_str.split_whitespace().enumerate() {
if col >= COLS {
break;
}
let count: u32 = token.parse().unwrap_or(0);
data[row][col] = count;
if count > max {
max = count;
}
}
}
(data, max)
}
/// Parsed combo: [index, row1, col1, row2, col2, result_keycode]
#[derive(Clone)]
pub struct ComboEntry {
pub index: u8,
pub r1: u8,
pub c1: u8,
pub r2: u8,
pub c2: u8,
pub result: u16,
}
/// Parse "COMBO0: r3c3+r3c4=29" lines.
pub fn parse_combo_lines(lines: &[String]) -> Vec<ComboEntry> {
let mut result = Vec::new();
for line in lines {
let starts_with_combo = line.starts_with("COMBO");
if !starts_with_combo {
continue;
}
// Find colon: "COMBO0: ..."
let colon = match line.find(':') {
Some(i) => i,
None => continue,
};
// Index between "COMBO" and ":"
let index_str = &line[5..colon];
let index: u8 = match index_str.parse() {
Ok(i) => i,
_ => continue,
};
// After colon: "r3c3+r3c4=29"
let rest = line[colon + 1..].trim();
// Split by "="
let eq_parts: Vec<&str> = rest.split('=').collect();
let has_two_parts = eq_parts.len() == 2;
if !has_two_parts {
continue;
}
// Split left side by "+"
let key_parts: Vec<&str> = eq_parts[0].split('+').collect();
let has_two_keys = key_parts.len() == 2;
if !has_two_keys {
continue;
}
// Parse "r3c4" format
let pos1 = parse_rc(key_parts[0].trim());
let pos2 = parse_rc(key_parts[1].trim());
let (r1, c1) = match pos1 {
Some(rc) => rc,
None => continue,
};
let (r2, c2) = match pos2 {
Some(rc) => rc,
None => continue,
};
// Parse result keycode (hex)
let result_str = eq_parts[1].trim();
let result_code: u16 = match u16::from_str_radix(result_str, 16) {
Ok(v) => v,
_ => continue,
};
result.push(ComboEntry {
index,
r1,
c1,
r2,
c2,
result: result_code,
});
}
result
}
/// Parse "r3c4" into (row, col).
fn parse_rc(s: &str) -> Option<(u8, u8)> {
let lower = s.to_lowercase();
let r_pos = lower.find('r');
let c_pos = lower.find('c');
let r_idx = match r_pos {
Some(i) => i,
None => return None,
};
let c_idx = match c_pos {
Some(i) => i,
None => return None,
};
let row_str = &lower[r_idx + 1..c_idx];
let col_str = &lower[c_idx + 1..];
let row: u8 = match row_str.parse() {
Ok(v) => v,
_ => return None,
};
let col: u8 = match col_str.parse() {
Ok(v) => v,
_ => return None,
};
Some((row, col))
}
/// Parsed leader sequence entry.
#[derive(Clone)]
pub struct LeaderEntry {
pub index: u8,
pub sequence: Vec<u8>, // HID keycodes
pub result: u8,
pub result_mod: u8,
}
/// Parse "LEADER0: 04,->29+00" lines.
pub fn parse_leader_lines(lines: &[String]) -> Vec<LeaderEntry> {
let mut result = Vec::new();
for line in lines {
let starts_with_leader = line.starts_with("LEADER");
if !starts_with_leader {
continue;
}
let colon = match line.find(':') {
Some(i) => i,
None => continue,
};
let index_str = &line[6..colon];
let index: u8 = match index_str.parse() {
Ok(i) => i,
_ => continue,
};
// After colon: "04,->29+00"
let rest = line[colon + 1..].trim();
// Split by "->"
let arrow_parts: Vec<&str> = rest.split("->").collect();
let has_two_parts = arrow_parts.len() == 2;
if !has_two_parts {
continue;
}
// Sequence: comma-separated hex keycodes (trailing comma OK)
let seq_str = arrow_parts[0].trim().trim_end_matches(',');
let sequence: Vec<u8> = seq_str
.split(',')
.filter_map(|s| {
let trimmed = s.trim();
u8::from_str_radix(trimmed, 16).ok()
})
.collect();
// Result: "29+00" = keycode + modifier
let result_parts: Vec<&str> = arrow_parts[1].trim().split('+').collect();
let has_result = result_parts.len() == 2;
if !has_result {
continue;
}
let result_key = match u8::from_str_radix(result_parts[0].trim(), 16) {
Ok(v) => v,
_ => continue,
};
let result_mod = match u8::from_str_radix(result_parts[1].trim(), 16) {
Ok(v) => v,
_ => continue,
};
result.push(LeaderEntry {
index,
sequence,
result: result_key,
result_mod,
});
}
result
}
/// A single macro step: keycode + modifier, or delay.
#[derive(Clone)]
pub struct MacroStep {
pub keycode: u8,
pub modifier: u8,
}
impl MacroStep {
/// Returns true if this step is a delay (keycode 0xFF).
pub fn is_delay(&self) -> bool {
self.keycode == 0xFF
}
/// Delay in milliseconds (modifier * 10).
pub fn delay_ms(&self) -> u32 {
self.modifier as u32 * 10
}
}
/// A parsed macro entry.
#[derive(Clone)]
pub struct MacroEntry {
pub slot: u8,
pub name: String,
pub steps: Vec<MacroStep>,
}
/// Parse MACROS? text response.
/// Lines can be like:
/// "MACRO 0: CopyPaste [06:01,FF:0A,19:01]"
/// "M0: name=CopyPaste steps=06:01,FF:0A,19:01"
/// or just raw text lines
pub fn parse_macro_lines(lines: &[String]) -> Vec<MacroEntry> {
let mut result = Vec::new();
for line in lines {
// Try format: "MACRO 0: name [steps]" or "M0: ..."
let trimmed = line.trim();
// Skip empty or header lines
let is_empty = trimmed.is_empty();
if is_empty {
continue;
}
// Try to find slot number
let has_macro_prefix = trimmed.starts_with("MACRO") || trimmed.starts_with("M");
if !has_macro_prefix {
continue;
}
let colon = match trimmed.find(':') {
Some(i) => i,
None => continue,
};
// Extract slot number from prefix
let prefix_end = trimmed[..colon].trim();
let digits_start = prefix_end
.find(|c: char| c.is_ascii_digit())
.unwrap_or(prefix_end.len());
let slot_str = &prefix_end[digits_start..];
let slot: u8 = match slot_str.trim().parse() {
Ok(s) => s,
_ => continue,
};
let after_colon = trimmed[colon + 1..].trim();
// Try to parse name and steps from brackets: "CopyPaste [06:01,FF:0A,19:01]"
let bracket_start = after_colon.find('[');
let bracket_end = after_colon.find(']');
let (name, steps_str) = match (bracket_start, bracket_end) {
(Some(bs), Some(be)) => {
let name_part = after_colon[..bs].trim().to_string();
let steps_part = &after_colon[bs + 1..be];
(name_part, steps_part.to_string())
}
_ => {
// Try "name=X steps=Y" format
let name_eq = after_colon.find("name=");
let steps_eq = after_colon.find("steps=");
match (name_eq, steps_eq) {
(Some(ni), Some(si)) => {
let name_start = ni + 5;
let name_end = si;
let n = after_colon[name_start..name_end].trim().to_string();
let s = after_colon[si + 6..].trim().to_string();
(n, s)
}
_ => {
// Just use the whole thing as name, no steps
(after_colon.to_string(), String::new())
}
}
}
};
// Parse steps: "06:01,FF:0A,19:01"
let mut steps = Vec::new();
let has_steps = !steps_str.is_empty();
if has_steps {
let step_parts = steps_str.split(',');
for part in step_parts {
let step_trimmed = part.trim();
let kv: Vec<&str> = step_trimmed.split(':').collect();
let has_two = kv.len() == 2;
if !has_two {
continue;
}
let key_byte = u8::from_str_radix(kv[0].trim(), 16).unwrap_or(0);
let mod_byte = u8::from_str_radix(kv[1].trim(), 16).unwrap_or(0);
steps.push(MacroStep {
keycode: key_byte,
modifier: mod_byte,
});
}
}
result.push(MacroEntry {
slot,
name,
steps,
});
}
result
}
// ---------------------------------------------------------------------------
// Binary payload parsers (protocol v2)
// ---------------------------------------------------------------------------
// These functions parse the *payload* bytes extracted from a KR response frame.
// They produce the same data types as the text parsers above.
/// Parse TD_LIST (0x51) binary payload.
/// Format: [count:u8] then count entries of [idx:u8][a1:u8][a2:u8][a3:u8][a4:u8].
/// Returns Vec<[u16; 4]> with 8 slots (same shape as parse_td_lines).
pub fn parse_td_binary(payload: &[u8]) -> Vec<[u16; 4]> {
let mut result = vec![[0u16; 4]; 8];
// Need at least 1 byte for count
let has_count = !payload.is_empty();
if !has_count {
return result;
}
let count = payload[0] as usize;
let entry_size = 5; // idx(1) + actions(4)
let mut offset = 1;
for _ in 0..count {
// Bounds check: need 5 bytes for this entry
let remaining = payload.len().saturating_sub(offset);
let enough_bytes = remaining >= entry_size;
if !enough_bytes {
break;
}
let idx = payload[offset] as usize;
let action1 = payload[offset + 1] as u16;
let action2 = payload[offset + 2] as u16;
let action3 = payload[offset + 3] as u16;
let action4 = payload[offset + 4] as u16;
let valid_index = idx < 8;
if valid_index {
result[idx] = [action1, action2, action3, action4];
}
offset += entry_size;
}
result
}
/// Parse COMBO_LIST (0x61) binary payload.
/// Format: [count:u8] then count entries of [idx:u8][r1:u8][c1:u8][r2:u8][c2:u8][result:u8].
pub fn parse_combo_binary(payload: &[u8]) -> Vec<ComboEntry> {
let mut result = Vec::new();
let has_count = !payload.is_empty();
if !has_count {
return result;
}
let count = payload[0] as usize;
let entry_size = 6; // idx + r1 + c1 + r2 + c2 + result
let mut offset = 1;
for _ in 0..count {
let remaining = payload.len().saturating_sub(offset);
let enough_bytes = remaining >= entry_size;
if !enough_bytes {
break;
}
let index = payload[offset];
let r1 = payload[offset + 1];
let c1 = payload[offset + 2];
let r2 = payload[offset + 3];
let c2 = payload[offset + 4];
let result_code = payload[offset + 5] as u16;
result.push(ComboEntry {
index,
r1,
c1,
r2,
c2,
result: result_code,
});
offset += entry_size;
}
result
}
/// Parse LEADER_LIST (0x71) binary payload.
/// Format: [count:u8] then per entry: [idx:u8][seq_len:u8][seq: seq_len bytes][result:u8][result_mod:u8].
pub fn parse_leader_binary(payload: &[u8]) -> Vec<LeaderEntry> {
let mut result = Vec::new();
let has_count = !payload.is_empty();
if !has_count {
return result;
}
let count = payload[0] as usize;
let mut offset = 1;
for _ in 0..count {
// Need at least idx(1) + seq_len(1)
let remaining = payload.len().saturating_sub(offset);
let enough_for_header = remaining >= 2;
if !enough_for_header {
break;
}
let index = payload[offset];
let seq_len = payload[offset + 1] as usize;
offset += 2;
// Need seq_len bytes for sequence + 2 bytes for result+result_mod
let remaining_after_header = payload.len().saturating_sub(offset);
let enough_for_body = remaining_after_header >= seq_len + 2;
if !enough_for_body {
break;
}
let sequence = payload[offset..offset + seq_len].to_vec();
offset += seq_len;
let result_key = payload[offset];
let result_mod = payload[offset + 1];
offset += 2;
result.push(LeaderEntry {
index,
sequence,
result: result_key,
result_mod,
});
}
result
}
/// Parse KO_LIST (0x92) binary payload.
/// Format: [count:u8] then count entries of [idx:u8][trigger_key:u8][trigger_mod:u8][result_key:u8][result_mod:u8].
/// Returns Vec<[u8; 4]> = [trigger_key, trigger_mod, result_key, result_mod] (same as parse_ko_lines).
pub fn parse_ko_binary(payload: &[u8]) -> Vec<[u8; 4]> {
let mut result = Vec::new();
let has_count = !payload.is_empty();
if !has_count {
return result;
}
let count = payload[0] as usize;
let entry_size = 5; // idx + trigger_key + trigger_mod + result_key + result_mod
let mut offset = 1;
for _ in 0..count {
let remaining = payload.len().saturating_sub(offset);
let enough_bytes = remaining >= entry_size;
if !enough_bytes {
break;
}
// idx is payload[offset], but we skip it (not stored in output)
let trigger_key = payload[offset + 1];
let trigger_mod = payload[offset + 2];
let result_key = payload[offset + 3];
let result_mod = payload[offset + 4];
result.push([trigger_key, trigger_mod, result_key, result_mod]);
offset += entry_size;
}
result
}
/// Parse BT_QUERY (0x80) binary payload.
/// Format: [active_slot:u8][initialized:u8][connected:u8][pairing:u8]
/// then 3 slot entries: [slot_idx:u8][valid:u8][addr:6 bytes][name_len:u8][name: name_len bytes]
/// Returns Vec<String> of text lines compatible with the UI (same shape as legacy text parsing).
pub fn parse_bt_binary(payload: &[u8]) -> Vec<String> {
let mut lines = Vec::new();
// Need at least 4 bytes for the global state header
let enough_for_header = payload.len() >= 4;
if !enough_for_header {
return lines;
}
let active_slot = payload[0];
let initialized = payload[1];
let connected = payload[2];
let pairing = payload[3];
let status_line = format!(
"BT: slot={} init={} conn={} pairing={}",
active_slot, initialized, connected, pairing
);
lines.push(status_line);
let mut offset = 4;
let slot_count = 3;
for _ in 0..slot_count {
// Each slot: slot_idx(1) + valid(1) + addr(6) + name_len(1) = 9 bytes minimum
let remaining = payload.len().saturating_sub(offset);
let enough_for_slot_header = remaining >= 9;
if !enough_for_slot_header {
break;
}
let slot_idx = payload[offset];
let valid = payload[offset + 1];
let addr_bytes = &payload[offset + 2..offset + 8];
let name_len = payload[offset + 8] as usize;
offset += 9;
// Read the name string
let remaining_for_name = payload.len().saturating_sub(offset);
let enough_for_name = remaining_for_name >= name_len;
if !enough_for_name {
break;
}
let name_bytes = &payload[offset..offset + name_len];
let name = String::from_utf8_lossy(name_bytes).to_string();
offset += name_len;
// Format address as "XX:XX:XX:XX:XX:XX"
let addr_str = format!(
"{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}",
addr_bytes[0], addr_bytes[1], addr_bytes[2],
addr_bytes[3], addr_bytes[4], addr_bytes[5]
);
let slot_line = format!(
"BT slot {}: valid={} addr={} name={}",
slot_idx, valid, addr_str, name
);
lines.push(slot_line);
}
lines
}
/// Parse TAMA_QUERY (0xA0) binary payload (22 bytes fixed).
/// Format: [enabled:u8][state:u8][hunger:u16 LE][happiness:u16 LE][energy:u16 LE]
/// [health:u16 LE][level:u16 LE][xp:u16 LE][total_keys:u32 LE][max_kpm:u32 LE]
/// Returns Vec<String> with one summary line.
pub fn parse_tama_binary(payload: &[u8]) -> Vec<String> {
let expected_size = 22;
let enough_bytes = payload.len() >= expected_size;
if !enough_bytes {
return vec!["TAMA: invalid payload".to_string()];
}
let enabled = payload[0];
let _state = payload[1];
let hunger = u16::from_le_bytes([payload[2], payload[3]]);
let happiness = u16::from_le_bytes([payload[4], payload[5]]);
let energy = u16::from_le_bytes([payload[6], payload[7]]);
let health = u16::from_le_bytes([payload[8], payload[9]]);
let level = u16::from_le_bytes([payload[10], payload[11]]);
let _xp = u16::from_le_bytes([payload[12], payload[13]]);
let total_keys = u32::from_le_bytes([payload[14], payload[15], payload[16], payload[17]]);
let _max_kpm = u32::from_le_bytes([payload[18], payload[19], payload[20], payload[21]]);
let line = format!(
"TAMA: Lv{} hunger={} happy={} energy={} health={} keys={} enabled={}",
level, hunger, happiness, energy, health, total_keys, enabled
);
vec![line]
}
/// Parse WPM_QUERY (0x93) binary payload (2 bytes fixed).
/// Format: [wpm:u16 LE]
pub fn parse_wpm_binary(payload: &[u8]) -> String {
let enough_bytes = payload.len() >= 2;
if !enough_bytes {
return "WPM: 0".to_string();
}
let wpm = u16::from_le_bytes([payload[0], payload[1]]);
format!("WPM: {}", wpm)
}
/// Parse LIST_MACROS (0x30) binary payload.
/// Format: [count:u8] then per entry:
/// [idx:u8][keycode:u16 LE][name_len:u8][name: name_len bytes]
/// [keys_len:u8][keys: keys_len bytes][step_count:u8][{kc:u8,mod:u8}... step_count*2 bytes]
pub fn parse_macros_binary(payload: &[u8]) -> Vec<MacroEntry> {
let mut result = Vec::new();
let has_count = !payload.is_empty();
if !has_count {
return result;
}
let count = payload[0] as usize;
let mut offset = 1;
for _ in 0..count {
// Need at least: idx(1) + keycode(2) + name_len(1) = 4
let remaining = payload.len().saturating_sub(offset);
let enough_for_prefix = remaining >= 4;
if !enough_for_prefix {
break;
}
let slot = payload[offset];
// keycode is stored but not used in MacroEntry (it's the trigger keycode)
let _keycode = u16::from_le_bytes([payload[offset + 1], payload[offset + 2]]);
let name_len = payload[offset + 3] as usize;
offset += 4;
// Read name
let remaining_for_name = payload.len().saturating_sub(offset);
let enough_for_name = remaining_for_name >= name_len;
if !enough_for_name {
break;
}
let name_bytes = &payload[offset..offset + name_len];
let name = String::from_utf8_lossy(name_bytes).to_string();
offset += name_len;
// Read keys_len + keys (raw key bytes, skipped for MacroEntry)
let remaining_for_keys_len = payload.len().saturating_sub(offset);
let enough_for_keys_len = remaining_for_keys_len >= 1;
if !enough_for_keys_len {
break;
}
let keys_len = payload[offset] as usize;
offset += 1;
let remaining_for_keys = payload.len().saturating_sub(offset);
let enough_for_keys = remaining_for_keys >= keys_len;
if !enough_for_keys {
break;
}
// Skip the raw keys bytes
offset += keys_len;
// Read step_count + steps
let remaining_for_step_count = payload.len().saturating_sub(offset);
let enough_for_step_count = remaining_for_step_count >= 1;
if !enough_for_step_count {
break;
}
let step_count = payload[offset] as usize;
offset += 1;
let steps_byte_size = step_count * 2;
let remaining_for_steps = payload.len().saturating_sub(offset);
let enough_for_steps = remaining_for_steps >= steps_byte_size;
if !enough_for_steps {
break;
}
let mut steps = Vec::with_capacity(step_count);
for i in 0..step_count {
let step_offset = offset + i * 2;
let kc = payload[step_offset];
let md = payload[step_offset + 1];
steps.push(MacroStep {
keycode: kc,
modifier: md,
});
}
offset += steps_byte_size;
result.push(MacroEntry {
slot,
name,
steps,
});
}
result
}
/// Parse KEYSTATS_BIN (0x40) binary payload.
/// Format: [rows:u8][cols:u8][counts: rows*cols * u32 LE]
/// Returns (heatmap_data, max_value) — same shape as parse_heatmap_lines.
pub fn parse_keystats_binary(payload: &[u8]) -> (Vec<Vec<u32>>, u32) {
// Need at least 2 bytes for rows and cols
let enough_for_header = payload.len() >= 2;
if !enough_for_header {
return (vec![], 0);
}
let rows = payload[0] as usize;
let cols = payload[1] as usize;
let total_cells = rows * cols;
let data_byte_size = total_cells * 4; // each count is u32 LE
let remaining = payload.len().saturating_sub(2);
let enough_for_data = remaining >= data_byte_size;
if !enough_for_data {
return (vec![], 0);
}
let mut data: Vec<Vec<u32>> = vec![vec![0u32; cols]; rows];
let mut max_value = 0u32;
let mut offset = 2;
for row in 0..rows {
for col in 0..cols {
let count = u32::from_le_bytes([
payload[offset],
payload[offset + 1],
payload[offset + 2],
payload[offset + 3],
]);
data[row][col] = count;
let is_new_max = count > max_value;
if is_new_max {
max_value = count;
}
offset += 4;
}
}
(data, max_value)
}

View file

@ -1,15 +0,0 @@
/// Serial communication module.
/// Dispatches to native (serialport crate) or web (WebSerial API)
/// depending on the target architecture.
#[cfg(not(target_arch = "wasm32"))]
mod native;
#[cfg(target_arch = "wasm32")]
mod web;
#[cfg(not(target_arch = "wasm32"))]
pub use native::*;
#[cfg(target_arch = "wasm32")]
pub use web::*;

View file

@ -1,573 +0,0 @@
use serialport::SerialPort;
use std::io::{BufRead, BufReader, Read, Write};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use crate::protocol::binary::{self as bp, KrResponse};
use crate::protocol::parsers::{ROWS, COLS};
const BAUD_RATE: u32 = 115200;
const CONNECT_TIMEOUT_MS: u64 = 300;
const QUERY_TIMEOUT_MS: u64 = 800;
const BINARY_READ_TIMEOUT_MS: u64 = 1500;
const LEGACY_BINARY_SETTLE_MS: u64 = 50;
const BINARY_SETTLE_MS: u64 = 30;
const JSON_TIMEOUT_SECS: u64 = 3;
pub struct SerialManager {
port: Option<Box<dyn SerialPort>>,
pub port_name: String,
pub connected: bool,
pub v2: bool, // true if firmware supports binary protocol v2
}
impl SerialManager {
pub fn new() -> Self {
Self {
port: None,
port_name: String::new(),
connected: false,
v2: false,
}
}
#[allow(dead_code)]
pub fn list_ports() -> Vec<String> {
let available = serialport::available_ports();
let ports = available.unwrap_or_default();
ports.into_iter().map(|p| p.port_name).collect()
}
/// List only ports that look like ESP32 programming ports (CH340, CP210x, FTDI).
pub fn list_prog_ports() -> Vec<String> {
const CH340_VID: u16 = 0x1A86;
const CP210X_VID: u16 = 0x10C4;
const FTDI_VID: u16 = 0x0403;
let available = serialport::available_ports();
let ports = available.unwrap_or_default();
ports.into_iter()
.filter(|p| {
matches!(&p.port_type, serialport::SerialPortType::UsbPort(usb)
if usb.vid == CH340_VID || usb.vid == CP210X_VID || usb.vid == FTDI_VID)
})
.map(|p| p.port_name)
.collect()
}
pub fn connect(&mut self, port_name: &str) -> Result<(), String> {
let builder = serialport::new(port_name, BAUD_RATE);
let builder_with_timeout = builder.timeout(Duration::from_millis(CONNECT_TIMEOUT_MS));
let open_result = builder_with_timeout.open();
let port = open_result.map_err(|e| format!("Failed to open {}: {}", port_name, e))?;
self.port = Some(port);
self.port_name = port_name.to_string();
self.connected = true;
self.v2 = false;
// Detect v2: try PING
if let Some(p) = self.port.as_mut() {
let _ = p.clear(serialport::ClearBuffer::All);
}
std::thread::sleep(Duration::from_millis(LEGACY_BINARY_SETTLE_MS));
let ping_result = self.send_binary(bp::cmd::PING, &[]);
if ping_result.is_ok() {
self.v2 = true;
}
Ok(())
}
pub fn auto_connect(&mut self) -> Result<String, String> {
let port_name = Self::find_kase_port()?;
self.connect(&port_name)?;
Ok(port_name)
}
pub fn find_kase_port() -> Result<String, String> {
const TARGET_VID: u16 = 0xCAFE;
const TARGET_PID: u16 = 0x4001;
let available = serialport::available_ports();
let ports = available.unwrap_or_default();
if ports.is_empty() {
return Err("No serial ports found".into());
}
// First pass: check USB VID/PID and product name
for port in &ports {
let port_type = &port.port_type;
match port_type {
serialport::SerialPortType::UsbPort(usb) => {
let vid_matches = usb.vid == TARGET_VID;
let pid_matches = usb.pid == TARGET_PID;
if vid_matches && pid_matches {
return Ok(port.port_name.clone());
}
match &usb.product {
Some(product) => {
let is_kase = product.contains("KaSe");
let is_kesp = product.contains("KeSp");
if is_kase || is_kesp {
return Ok(port.port_name.clone());
}
}
None => {}
}
}
_ => {}
}
}
// Second pass (Linux only): check udevadm info
#[cfg(target_os = "linux")]
for port in &ports {
let udevadm_result = std::process::Command::new("udevadm")
.args(["info", "-n", &port.port_name])
.output();
match udevadm_result {
Ok(output) => {
let stdout_bytes = &output.stdout;
let text = String::from_utf8_lossy(stdout_bytes);
let has_kase = text.contains("KaSe");
let has_kesp = text.contains("KeSp");
if has_kase || has_kesp {
return Ok(port.port_name.clone());
}
}
Err(_) => {}
}
}
let scanned_count = ports.len();
Err(format!("No KaSe keyboard found ({} port(s) scanned)", scanned_count))
}
pub fn port_mut(&mut self) -> Option<&mut Box<dyn SerialPort>> {
self.port.as_mut()
}
pub fn disconnect(&mut self) {
self.port = None;
self.port_name.clear();
self.connected = false;
self.v2 = false;
}
// ==================== LOW-LEVEL: ASCII LEGACY ====================
pub fn send_command(&mut self, cmd: &str) -> Result<(), String> {
let port = self.port.as_mut().ok_or("Not connected")?;
let data = format!("{}\r\n", cmd);
let bytes = data.as_bytes();
let write_result = port.write_all(bytes);
write_result.map_err(|e| format!("Write: {}", e))?;
let flush_result = port.flush();
flush_result.map_err(|e| format!("Flush: {}", e))?;
Ok(())
}
pub fn query_command(&mut self, cmd: &str) -> Result<Vec<String>, String> {
self.send_command(cmd)?;
let port = self.port.as_mut().ok_or("Not connected")?;
let cloned_port = port.try_clone();
let port_clone = cloned_port.map_err(|e| e.to_string())?;
let mut reader = BufReader::new(port_clone);
let mut lines = Vec::new();
let start = Instant::now();
let max_wait = Duration::from_millis(QUERY_TIMEOUT_MS);
loop {
let elapsed = start.elapsed();
if elapsed > max_wait {
break;
}
let mut line = String::new();
let read_result = reader.read_line(&mut line);
match read_result {
Ok(0) => break,
Ok(_) => {
let trimmed = line.trim().to_string();
let is_terminal = trimmed == "OK" || trimmed == "ERROR";
if is_terminal {
break;
}
let is_not_empty = !trimmed.is_empty();
if is_not_empty {
lines.push(trimmed);
}
}
Err(_) => break,
}
}
Ok(lines)
}
fn read_raw(&mut self, timeout_ms: u64) -> Result<Vec<u8>, String> {
let port = self.port.as_mut().ok_or("Not connected")?;
let mut buf = vec![0u8; 4096];
let mut result = Vec::new();
let start = Instant::now();
let timeout = Duration::from_millis(timeout_ms);
while start.elapsed() < timeout {
let read_result = port.read(&mut buf);
match read_result {
Ok(n) if n > 0 => {
let received_bytes = &buf[..n];
result.extend_from_slice(received_bytes);
std::thread::sleep(Duration::from_millis(5));
}
_ => {
let got_something = !result.is_empty();
if got_something {
break;
}
std::thread::sleep(Duration::from_millis(5));
}
}
}
Ok(result)
}
/// Legacy C> binary protocol
fn query_legacy_binary(&mut self, cmd: &str) -> Result<(u8, Vec<u8>), String> {
self.send_command(cmd)?;
std::thread::sleep(Duration::from_millis(LEGACY_BINARY_SETTLE_MS));
let raw = self.read_raw(BINARY_READ_TIMEOUT_MS)?;
// Look for the "C>" header in the raw bytes
let mut windows = raw.windows(2);
let header_search = windows.position(|w| w == b"C>");
let pos = header_search.ok_or("No C> header found")?;
let min_packet_size = pos + 5;
if raw.len() < min_packet_size {
return Err("Packet too short".into());
}
let cmd_type = raw[pos + 2];
let low_byte = raw[pos + 3] as u16;
let high_byte = (raw[pos + 4] as u16) << 8;
let data_len = low_byte | high_byte;
let data_start = pos + 5;
let data_end = data_start.checked_add(data_len as usize)
.ok_or("Data length overflow")?;
if raw.len() < data_end {
return Err(format!("Incomplete: need {}, got {}", data_end, raw.len()));
}
let payload = raw[data_start..data_end].to_vec();
Ok((cmd_type, payload))
}
// ==================== LOW-LEVEL: BINARY V2 ====================
/// Send a KS frame, read KR response.
pub fn send_binary(&mut self, cmd_id: u8, payload: &[u8]) -> Result<KrResponse, String> {
let frame = bp::ks_frame(cmd_id, payload);
let port = self.port.as_mut().ok_or("Not connected")?;
let write_result = port.write_all(&frame);
write_result.map_err(|e| format!("Write: {}", e))?;
let flush_result = port.flush();
flush_result.map_err(|e| format!("Flush: {}", e))?;
std::thread::sleep(Duration::from_millis(BINARY_SETTLE_MS));
let raw = self.read_raw(BINARY_READ_TIMEOUT_MS)?;
let (resp, _remaining) = bp::parse_kr(&raw)?;
let firmware_ok = resp.is_ok();
if !firmware_ok {
let status = resp.status_name();
return Err(format!("Firmware error: {}", status));
}
Ok(resp)
}
// ==================== HIGH-LEVEL: AUTO V2/LEGACY ====================
pub fn get_firmware_version(&mut self) -> Option<String> {
// Try v2 binary first
if self.v2 {
let binary_result = self.send_binary(bp::cmd::VERSION, &[]);
match binary_result {
Ok(resp) => {
let raw_bytes = &resp.payload;
let lossy_string = String::from_utf8_lossy(raw_bytes);
let version = lossy_string.to_string();
return Some(version);
}
Err(_) => {}
}
}
// Legacy fallback
let query_result = self.query_command("VERSION?");
let lines = match query_result {
Ok(l) => l,
Err(_) => return None,
};
let mut line_iter = lines.into_iter();
let first_line = line_iter.next();
first_line
}
pub fn get_keymap(&mut self, layer: u8) -> Result<Vec<Vec<u16>>, String> {
// Try v2 binary first
if self.v2 {
let resp = self.send_binary(bp::cmd::KEYMAP_GET, &[layer])?;
let keymap = self.parse_keymap_payload(&resp.payload)?;
return Ok(keymap);
}
// Legacy
let cmd = format!("KEYMAP{}", layer);
let (cmd_type, data) = self.query_legacy_binary(&cmd)?;
if cmd_type != 1 {
return Err(format!("Unexpected cmd type: {}", cmd_type));
}
if data.len() < 2 {
return Err("Data too short".into());
}
// skip 2-byte layer index in legacy
let data_without_header = &data[2..];
self.parse_keymap_payload(data_without_header)
}
fn parse_keymap_payload(&self, data: &[u8]) -> Result<Vec<Vec<u16>>, String> {
// v2: [layer:u8][keycodes...] -- skip first byte
// legacy: already stripped
let expected_with_layer_byte = 1 + ROWS * COLS * 2;
let has_layer_byte = data.len() >= expected_with_layer_byte;
let offset = if has_layer_byte { 1 } else { 0 };
let kc_data = &data[offset..];
let needed_bytes = ROWS * COLS * 2;
if kc_data.len() < needed_bytes {
return Err(format!("Keymap data too short: {} bytes (need {})", kc_data.len(), needed_bytes));
}
let mut keymap = Vec::with_capacity(ROWS);
for row_index in 0..ROWS {
let mut row = Vec::with_capacity(COLS);
for col_index in 0..COLS {
let idx = (row_index * COLS + col_index) * 2;
let low_byte = kc_data[idx] as u16;
let high_byte = (kc_data[idx + 1] as u16) << 8;
let keycode = low_byte | high_byte;
row.push(keycode);
}
keymap.push(row);
}
Ok(keymap)
}
pub fn get_layer_names(&mut self) -> Result<Vec<String>, String> {
// Try v2 binary first
if self.v2 {
let resp = self.send_binary(bp::cmd::LIST_LAYOUTS, &[])?;
let payload = &resp.payload;
if payload.is_empty() {
return Err("Empty response".into());
}
let count = payload[0] as usize;
let mut names = Vec::with_capacity(count);
let mut i = 1;
for _ in 0..count {
let remaining = payload.len();
let need_header = i + 2;
if need_header > remaining {
break;
}
let _layer_index = payload[i];
let name_len = payload[i + 1] as usize;
i += 2;
let need_name = i + name_len;
if need_name > payload.len() {
break;
}
let name_bytes = &payload[i..i + name_len];
let name_lossy = String::from_utf8_lossy(name_bytes);
let name = name_lossy.to_string();
names.push(name);
i += name_len;
}
let found_names = !names.is_empty();
if found_names {
return Ok(names);
}
}
// Legacy fallback: try C> binary protocol
let legacy_result = self.query_legacy_binary("LAYOUTS?");
match legacy_result {
Ok((cmd_type, data)) => {
let is_layout_type = cmd_type == 4;
let has_data = !data.is_empty();
if is_layout_type && has_data {
let text = String::from_utf8_lossy(&data);
let parts = text.split(';');
let non_empty = parts.filter(|s| !s.is_empty());
let trimmed_names = non_empty.map(|s| {
let long_enough = s.len() > 1;
if long_enough {
s[1..].to_string()
} else {
s.to_string()
}
});
let names: Vec<String> = trimmed_names.collect();
let found_names = !names.is_empty();
if found_names {
return Ok(names);
}
}
}
Err(_) => {}
}
// Last resort: raw text
self.send_command("LAYOUTS?")?;
std::thread::sleep(Duration::from_millis(LEGACY_BINARY_SETTLE_MS * 2));
let raw = self.read_raw(BINARY_READ_TIMEOUT_MS)?;
let text = String::from_utf8_lossy(&raw);
let split_by_delimiters = text.split(|c: char| c == ';' || c == '\n');
let cleaned = split_by_delimiters.map(|s| {
let step1 = s.trim();
let step2 = step1.trim_matches(|c: char| c.is_control() || c == '"');
step2
});
let valid_names = cleaned.filter(|s| {
let is_not_empty = !s.is_empty();
let is_short_enough = s.len() < 30;
let no_header_marker = !s.contains("C>");
let not_ok = *s != "OK";
is_not_empty && is_short_enough && no_header_marker && not_ok
});
let as_strings = valid_names.map(|s| s.to_string());
let names: Vec<String> = as_strings.collect();
let found_any = !names.is_empty();
if found_any {
Ok(names)
} else {
Err("No layer names found".into())
}
}
pub fn get_layout_json(&mut self) -> Result<String, String> {
// Try v2 binary first
if self.v2 {
let resp = self.send_binary(bp::cmd::GET_LAYOUT_JSON, &[])?;
let has_payload = !resp.payload.is_empty();
if has_payload {
let raw_bytes = &resp.payload;
let lossy_string = String::from_utf8_lossy(raw_bytes);
let json = lossy_string.to_string();
return Ok(json);
}
}
// Legacy: text brace-counting
self.send_command("LAYOUT?")?;
std::thread::sleep(Duration::from_millis(LEGACY_BINARY_SETTLE_MS));
let port = self.port.as_mut().ok_or("Not connected")?;
let mut result = String::new();
let mut buf = [0u8; 4096];
let start = Instant::now();
let max_wait = Duration::from_secs(JSON_TIMEOUT_SECS);
let mut brace_count: i32 = 0;
let mut started = false;
while start.elapsed() < max_wait {
let read_result = port.read(&mut buf);
match read_result {
Ok(n) if n > 0 => {
let received_bytes = &buf[..n];
let chunk = String::from_utf8_lossy(received_bytes);
for ch in chunk.chars() {
let is_open_brace = ch == '{';
if is_open_brace {
started = true;
brace_count += 1;
}
if started {
result.push(ch);
}
let is_close_brace = ch == '}';
if is_close_brace && started {
brace_count -= 1;
let json_complete = brace_count == 0;
if json_complete {
return Ok(result);
}
}
}
}
_ => {
std::thread::sleep(Duration::from_millis(10));
}
}
}
let got_nothing = result.is_empty();
if got_nothing {
Err("No JSON".into())
} else {
Err("Incomplete JSON".into())
}
}
}
/// Thread-safe wrapper
pub type SharedSerial = Arc<Mutex<SerialManager>>;
pub fn new_shared() -> SharedSerial {
let manager = SerialManager::new();
let mutex = Mutex::new(manager);
let shared = Arc::new(mutex);
shared
}

View file

@ -1,118 +0,0 @@
/// Persistent settings for KaSe Controller.
///
/// - **Native**: `kase_settings.json` next to the executable.
/// - **WASM**: `localStorage` under key `"kase_settings"`.
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
#[serde(default = "default_layout")]
pub keyboard_layout: String,
}
fn default_layout() -> String {
"QWERTY".to_string()
}
impl Default for Settings {
fn default() -> Self {
Self {
keyboard_layout: default_layout(),
}
}
}
// =============================================================================
// Native: JSON file next to executable
// =============================================================================
#[cfg(not(target_arch = "wasm32"))]
mod native_settings {
use super::Settings;
use std::path::PathBuf;
/// Config file path: next to the executable.
fn settings_path() -> PathBuf {
let exe = std::env::current_exe().unwrap_or_default();
let parent_dir = exe.parent().unwrap_or(std::path::Path::new("."));
parent_dir.join("kase_settings.json")
}
pub fn load() -> Settings {
let path = settings_path();
let json_content = std::fs::read_to_string(&path).ok();
let parsed = json_content.and_then(|s| serde_json::from_str(&s).ok());
parsed.unwrap_or_default()
}
pub fn save(settings: &Settings) {
let path = settings_path();
let json_result = serde_json::to_string_pretty(settings);
if let Ok(json) = json_result {
let _ = std::fs::write(path, json);
}
}
}
// =============================================================================
// WASM: browser localStorage
// =============================================================================
#[cfg(target_arch = "wasm32")]
mod web_settings {
use super::Settings;
const STORAGE_KEY: &str = "kase_settings";
/// Get localStorage. Returns None if not in a browser context.
fn get_storage() -> Option<web_sys::Storage> {
let window = web_sys::window()?;
window.local_storage().ok().flatten()
}
pub fn load() -> Settings {
let storage = match get_storage() {
Some(s) => s,
None => return Settings::default(),
};
let json_option = storage.get_item(STORAGE_KEY).ok().flatten();
let parsed = json_option.and_then(|s| serde_json::from_str(&s).ok());
parsed.unwrap_or_default()
}
pub fn save(settings: &Settings) {
let storage = match get_storage() {
Some(s) => s,
None => return,
};
let json_result = serde_json::to_string(settings);
if let Ok(json) = json_result {
let _ = storage.set_item(STORAGE_KEY, &json);
}
}
}
// =============================================================================
// Public interface
// =============================================================================
/// Load settings from persistent storage.
/// Returns `Settings::default()` if none found.
pub fn load() -> Settings {
#[cfg(not(target_arch = "wasm32"))]
return native_settings::load();
#[cfg(target_arch = "wasm32")]
return web_settings::load();
}
/// Save settings to persistent storage. Fails silently.
pub fn save(settings: &Settings) {
#[cfg(not(target_arch = "wasm32"))]
native_settings::save(settings);
#[cfg(target_arch = "wasm32")]
web_settings::save(settings);
}

View file

@ -1,335 +0,0 @@
/// Stats analysis: hand balance, finger load, row usage, top keys, bigrams.
/// Transforms raw heatmap data into structured analysis for the stats tab.
use super::keycode;
use super::parsers::ROWS;
/// Which hand a column belongs to.
#[derive(Clone, Copy, PartialEq)]
pub enum Hand {
Left,
Right,
}
/// Which finger a column belongs to.
#[derive(Clone, Copy, PartialEq)]
pub enum Finger {
Pinky,
Ring,
Middle,
Index,
Thumb,
}
/// Row names for the 5 rows.
const ROW_NAMES: [&str; 5] = ["Number", "Upper", "Home", "Lower", "Thumb"];
/// Finger names (French).
const FINGER_NAMES: [&str; 5] = ["Pinky", "Ring", "Middle", "Index", "Thumb"];
/// Map column index → (Hand, Finger).
/// KaSe layout: cols 0-5 = left hand, cols 6 (gap), cols 7-12 = right hand.
fn col_to_hand_finger(col: usize) -> (Hand, Finger) {
match col {
0 => (Hand::Left, Finger::Pinky),
1 => (Hand::Left, Finger::Ring),
2 => (Hand::Left, Finger::Middle),
3 => (Hand::Left, Finger::Index),
4 => (Hand::Left, Finger::Index), // inner column, still index
5 => (Hand::Left, Finger::Thumb),
6 => (Hand::Left, Finger::Thumb), // center / gap
7 => (Hand::Right, Finger::Thumb),
8 => (Hand::Right, Finger::Index),
9 => (Hand::Right, Finger::Index), // inner column
10 => (Hand::Right, Finger::Middle),
11 => (Hand::Right, Finger::Ring),
12 => (Hand::Right, Finger::Pinky),
_ => (Hand::Left, Finger::Pinky),
}
}
/// Hand balance result.
#[allow(dead_code)]
pub struct HandBalance {
pub left_count: u32,
pub right_count: u32,
pub total: u32,
pub left_pct: f32,
pub right_pct: f32,
}
/// Finger load for one finger.
#[allow(dead_code)]
pub struct FingerLoad {
pub name: String,
pub hand: Hand,
pub count: u32,
pub pct: f32,
}
/// Row usage for one row.
#[allow(dead_code)]
pub struct RowUsage {
pub name: String,
pub row: usize,
pub count: u32,
pub pct: f32,
}
/// A key in the top keys ranking.
#[allow(dead_code)]
pub struct TopKey {
pub name: String,
pub finger: String,
pub count: u32,
pub pct: f32,
}
/// Compute hand balance from heatmap data.
pub fn hand_balance(heatmap: &[Vec<u32>]) -> HandBalance {
let mut left: u32 = 0;
let mut right: u32 = 0;
for row in heatmap {
for (c, &count) in row.iter().enumerate() {
let (hand, _) = col_to_hand_finger(c);
match hand {
Hand::Left => left += count,
Hand::Right => right += count,
}
}
}
let total = left + right;
let left_pct = if total > 0 { left as f32 / total as f32 * 100.0 } else { 0.0 };
let right_pct = if total > 0 { right as f32 / total as f32 * 100.0 } else { 0.0 };
HandBalance { left_count: left, right_count: right, total, left_pct, right_pct }
}
/// Compute finger load (10 fingers: 5 left + 5 right).
pub fn finger_load(heatmap: &[Vec<u32>]) -> Vec<FingerLoad> {
let mut counts = [[0u32; 5]; 2]; // [hand][finger]
for row in heatmap {
for (c, &count) in row.iter().enumerate() {
let (hand, finger) = col_to_hand_finger(c);
let hi = if hand == Hand::Left { 0 } else { 1 };
let fi = match finger {
Finger::Pinky => 0,
Finger::Ring => 1,
Finger::Middle => 2,
Finger::Index => 3,
Finger::Thumb => 4,
};
counts[hi][fi] += count;
}
}
let total: u32 = counts[0].iter().sum::<u32>() + counts[1].iter().sum::<u32>();
let mut result = Vec::with_capacity(10);
// Left hand fingers
for fi in 0..5 {
let count = counts[0][fi];
let pct = if total > 0 { count as f32 / total as f32 * 100.0 } else { 0.0 };
let name = format!("{} L", FINGER_NAMES[fi]);
result.push(FingerLoad { name, hand: Hand::Left, count, pct });
}
// Right hand fingers
for fi in 0..5 {
let count = counts[1][fi];
let pct = if total > 0 { count as f32 / total as f32 * 100.0 } else { 0.0 };
let name = format!("{} R", FINGER_NAMES[fi]);
result.push(FingerLoad { name, hand: Hand::Right, count, pct });
}
result
}
/// Compute row usage.
pub fn row_usage(heatmap: &[Vec<u32>]) -> Vec<RowUsage> {
let mut row_counts = [0u32; ROWS];
for (r, row) in heatmap.iter().enumerate() {
if r >= ROWS { break; }
let row_sum: u32 = row.iter().sum();
row_counts[r] = row_sum;
}
let total: u32 = row_counts.iter().sum();
let mut result = Vec::with_capacity(ROWS);
for r in 0..ROWS {
let count = row_counts[r];
let pct = if total > 0 { count as f32 / total as f32 * 100.0 } else { 0.0 };
let name = ROW_NAMES[r].to_string();
result.push(RowUsage { name, row: r, count, pct });
}
result
}
/// Compute top N keys by press count.
pub fn top_keys(heatmap: &[Vec<u32>], keymap: &[Vec<u16>], n: usize) -> Vec<TopKey> {
let mut all_keys: Vec<(u32, usize, usize)> = Vec::new(); // (count, row, col)
for (r, row) in heatmap.iter().enumerate() {
for (c, &count) in row.iter().enumerate() {
if count > 0 {
all_keys.push((count, r, c));
}
}
}
all_keys.sort_by(|a, b| b.0.cmp(&a.0));
all_keys.truncate(n);
let total: u32 = heatmap.iter().flat_map(|r| r.iter()).sum();
let mut result = Vec::with_capacity(n);
for (count, r, c) in all_keys {
let code = keymap.get(r).and_then(|row| row.get(c)).copied().unwrap_or(0);
let name = keycode::decode_keycode(code);
let (hand, finger) = col_to_hand_finger(c);
let hand_str = if hand == Hand::Left { "L" } else { "R" };
let finger_str = match finger {
Finger::Pinky => "Pinky",
Finger::Ring => "Ring",
Finger::Middle => "Middle",
Finger::Index => "Index",
Finger::Thumb => "Thumb",
};
let finger_label = format!("{} {}", finger_str, hand_str);
let pct = if total > 0 { count as f32 / total as f32 * 100.0 } else { 0.0 };
result.push(TopKey { name, finger: finger_label, count, pct });
}
result
}
/// Find keys that have never been pressed (count = 0, keycode != 0).
pub fn dead_keys(heatmap: &[Vec<u32>], keymap: &[Vec<u16>]) -> Vec<String> {
let mut result = Vec::new();
for (r, row) in heatmap.iter().enumerate() {
for (c, &count) in row.iter().enumerate() {
if count > 0 { continue; }
let code = keymap.get(r).and_then(|row| row.get(c)).copied().unwrap_or(0);
let is_mapped = code != 0;
if is_mapped {
let name = keycode::decode_keycode(code);
result.push(name);
}
}
}
result
}
// ==================== Bigram analysis ====================
/// A parsed bigram entry.
pub struct BigramEntry {
pub from_row: u8,
pub from_col: u8,
pub to_row: u8,
pub to_col: u8,
pub count: u32,
}
/// Bigram analysis results.
#[allow(dead_code)]
pub struct BigramAnalysis {
pub total: u32,
pub alt_hand: u32,
pub same_hand: u32,
pub sfb: u32,
pub alt_hand_pct: f32,
pub same_hand_pct: f32,
pub sfb_pct: f32,
}
/// Parse bigram text lines from firmware.
/// Format: " R2C3 -> R2C4 : 150"
pub fn parse_bigram_lines(lines: &[String]) -> Vec<BigramEntry> {
let mut entries = Vec::new();
for line in lines {
let trimmed = line.trim();
let has_arrow = trimmed.contains("->");
if !has_arrow { continue; }
let parts: Vec<&str> = trimmed.split("->").collect();
if parts.len() != 2 { continue; }
let left = parts[0].trim();
let right_and_count = parts[1].trim();
let right_parts: Vec<&str> = right_and_count.split(':').collect();
if right_parts.len() != 2 { continue; }
let right = right_parts[0].trim();
let count_str = right_parts[1].trim();
let from = parse_rc(left);
let to = parse_rc(right);
let count: u32 = count_str.parse().unwrap_or(0);
if let (Some((fr, fc)), Some((tr, tc))) = (from, to) {
entries.push(BigramEntry {
from_row: fr, from_col: fc,
to_row: tr, to_col: tc,
count,
});
}
}
entries
}
/// Parse "R2C3" into (row, col).
fn parse_rc(s: &str) -> Option<(u8, u8)> {
let s = s.trim();
let r_pos = s.find('R')?;
let c_pos = s.find('C')?;
if c_pos <= r_pos { return None; }
let row_str = &s[r_pos + 1..c_pos];
let col_str = &s[c_pos + 1..];
let row: u8 = row_str.parse().ok()?;
let col: u8 = col_str.parse().ok()?;
Some((row, col))
}
/// Analyze bigram entries for hand alternation and SFB.
pub fn analyze_bigrams(entries: &[BigramEntry]) -> BigramAnalysis {
let mut alt_hand: u32 = 0;
let mut same_hand: u32 = 0;
let mut sfb: u32 = 0;
let mut total: u32 = 0;
for entry in entries {
let (hand_from, finger_from) = col_to_hand_finger(entry.from_col as usize);
let (hand_to, finger_to) = col_to_hand_finger(entry.to_col as usize);
total += entry.count;
if hand_from != hand_to {
alt_hand += entry.count;
} else {
same_hand += entry.count;
if finger_from == finger_to {
sfb += entry.count;
}
}
}
let alt_hand_pct = if total > 0 { alt_hand as f32 / total as f32 * 100.0 } else { 0.0 };
let same_hand_pct = if total > 0 { same_hand as f32 / total as f32 * 100.0 } else { 0.0 };
let sfb_pct = if total > 0 { sfb as f32 / total as f32 * 100.0 } else { 0.0 };
BigramAnalysis {
total, alt_hand, same_hand, sfb,
alt_hand_pct, same_hand_pct, sfb_pct,
}
}

View file

@ -1,65 +0,0 @@
#![allow(dead_code)]
/// CDC protocol command helpers for KaSe keyboard firmware.
// Text-based query commands
pub const CMD_TAP_DANCE: &str = "TD?";
pub const CMD_COMBOS: &str = "COMBO?";
pub const CMD_LEADER: &str = "LEADER?";
pub const CMD_KEY_OVERRIDE: &str = "KO?";
pub const CMD_BT_STATUS: &str = "BT?";
pub const CMD_WPM: &str = "WPM?";
pub const CMD_TAMA: &str = "TAMA?";
pub const CMD_MACROS_TEXT: &str = "MACROS?";
pub const CMD_FEATURES: &str = "FEATURES?";
pub const CMD_KEYSTATS: &str = "KEYSTATS?";
pub const CMD_BIGRAMS: &str = "BIGRAMS?";
pub fn cmd_set_key(layer: u8, row: u8, col: u8, keycode: u16) -> String {
format!("SETKEY {},{},{},{:04X}", layer, row, col, keycode)
}
pub fn cmd_set_layer_name(layer: u8, name: &str) -> String {
format!("LAYOUTNAME{}:{}", layer, name)
}
pub fn cmd_bt_switch(slot: u8) -> String {
format!("BT SWITCH {}", slot)
}
pub fn cmd_trilayer(l1: u8, l2: u8, l3: u8) -> String {
format!("TRILAYER {},{},{}", l1, l2, l3)
}
pub fn cmd_macroseq(slot: u8, name: &str, steps: &str) -> String {
format!("MACROSEQ {};{};{}", slot, name, steps)
}
pub fn cmd_macro_del(slot: u8) -> String {
format!("MACRODEL {}", slot)
}
pub fn cmd_comboset(index: u8, r1: u8, c1: u8, r2: u8, c2: u8, result: u8) -> String {
format!("COMBOSET {};{},{},{},{},{:02X}", index, r1, c1, r2, c2, result)
}
pub fn cmd_combodel(index: u8) -> String {
format!("COMBODEL {}", index)
}
pub fn cmd_koset(index: u8, trig_key: u8, trig_mod: u8, res_key: u8, res_mod: u8) -> String {
format!("KOSET {};{:02X},{:02X},{:02X},{:02X}", index, trig_key, trig_mod, res_key, res_mod)
}
pub fn cmd_kodel(index: u8) -> String {
format!("KODEL {}", index)
}
pub fn cmd_leaderset(index: u8, sequence: &[u8], result: u8, result_mod: u8) -> String {
let seq_hex: Vec<String> = sequence.iter().map(|k| format!("{:02X}", k)).collect();
let seq_str = seq_hex.join(",");
format!("LEADERSET {};{};{:02X},{:02X}", index, seq_str, result, result_mod)
}
pub fn cmd_leaderdel(index: u8) -> String {
format!("LEADERDEL {}", index)
}

View file

@ -1,263 +0,0 @@
use crate::context::{AppContext, BgMsg};
use crate::protocol;
use crate::{config, MainWindow, SettingsBridge};
use slint::{ComponentHandle, SharedString};
pub fn setup(window: &MainWindow, ctx: &AppContext) {
setup_change_layout(window, ctx);
setup_ota_browse(window);
setup_ota_start(window, ctx);
setup_config_export(window, ctx);
setup_config_import(window, ctx);
}
// --- Change keyboard layout (AZERTY / QWERTZ / ...) ---
fn setup_change_layout(window: &MainWindow, ctx: &AppContext) {
let keyboard_layout = ctx.keyboard_layout.clone();
let keys = ctx.keys.clone();
let current_keymap = ctx.current_keymap.clone();
let window_weak = window.as_weak();
window.global::<SettingsBridge>().on_change_layout(move |idx| {
let all = protocol::layout_remap::KeyboardLayout::all();
let new_layout = all.get(idx as usize).copied().unwrap_or(all[0]);
// Update shared state
*keyboard_layout.borrow_mut() = new_layout;
// Persist to disk
let mut s = protocol::settings::load();
s.keyboard_layout = new_layout.name().to_string();
protocol::settings::save(&s);
// Refresh keycap labels + key selector popup
if let Some(w) = window_weak.upgrade() {
let keycaps = w.global::<crate::KeymapBridge>().get_keycaps();
let km = current_keymap.borrow();
let k = keys.borrow();
if !km.is_empty() {
crate::models::update_keycap_labels(&keycaps, &k, &km, &new_layout);
}
let all_keys = crate::models::build_key_entries_with_layout(&new_layout);
w.global::<crate::KeySelectorBridge>().set_all_keys(slint::ModelRc::from(all_keys.clone()));
crate::models::populate_key_categories(&w, &all_keys, "");
}
});
}
// --- OTA: browse ---
fn setup_ota_browse(window: &MainWindow) {
let window_weak = window.as_weak();
window.global::<SettingsBridge>().on_ota_browse(move || {
let window_weak = window_weak.clone();
std::thread::spawn(move || {
let file = rfd::FileDialog::new()
.add_filter("Firmware", &["bin"])
.pick_file();
if let Some(path) = file {
let path_str = path.to_string_lossy().to_string();
let _ = slint::invoke_from_event_loop(move || {
if let Some(w) = window_weak.upgrade() {
w.global::<SettingsBridge>().set_ota_path(SharedString::from(path_str.as_str()));
}
});
}
});
});
}
// --- OTA: start ---
fn setup_ota_start(window: &MainWindow, ctx: &AppContext) {
let serial = ctx.serial.clone();
let tx = ctx.bg_tx.clone();
let window_weak = window.as_weak();
window.global::<SettingsBridge>().on_ota_start(move || {
let Some(w) = window_weak.upgrade() else { return };
let settings = w.global::<SettingsBridge>();
let path = settings.get_ota_path().to_string();
if path.is_empty() { return; }
let firmware = match std::fs::read(&path) {
Ok(data) => data,
Err(e) => {
let _ = tx.send(BgMsg::OtaDone(Err(format!("Cannot read {}: {}", path, e))));
return;
}
};
settings.set_ota_flashing(true);
settings.set_ota_progress(0.0);
settings.set_ota_status(SharedString::from("Starting OTA..."));
let serial = serial.clone();
let tx = tx.clone();
std::thread::spawn(move || {
use std::io::{Read, Write, BufRead, BufReader};
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
let total = firmware.len();
let chunk_size = 4096usize;
// Step 1: Send OTA <size> command
let cmd = format!("OTA {}", total);
if let Err(e) = ser.send_command(&cmd) {
let _ = tx.send(BgMsg::OtaDone(Err(format!("Send OTA cmd failed: {}", e))));
return;
}
// Step 2: Wait for OTA_READY (read lines with timeout)
let _ = tx.send(BgMsg::OtaProgress(0.0, "Waiting for OTA_READY...".into()));
let port = match ser.port_mut() {
Some(p) => p,
None => {
let _ = tx.send(BgMsg::OtaDone(Err("Port not available".into())));
return;
}
};
// Read until we get OTA_READY or timeout
let old_timeout = port.timeout();
let _ = port.set_timeout(std::time::Duration::from_secs(5));
let mut got_ready = false;
let port_clone = port.try_clone().unwrap();
let mut reader = BufReader::new(port_clone);
for _ in 0..20 {
let mut line = String::new();
if reader.read_line(&mut line).is_ok() && line.contains("OTA_READY") {
got_ready = true;
break;
}
}
drop(reader);
if !got_ready {
let _ = port.set_timeout(old_timeout);
let _ = tx.send(BgMsg::OtaDone(Err("Firmware did not respond OTA_READY".into())));
return;
}
// Step 3: Send chunks and wait for ACK after each
let num_chunks = total.div_ceil(chunk_size);
let _ = port.set_timeout(std::time::Duration::from_secs(5));
for (i, chunk) in firmware.chunks(chunk_size).enumerate() {
// Send chunk
if let Err(e) = port.write_all(chunk) {
let _ = port.set_timeout(old_timeout);
let _ = tx.send(BgMsg::OtaDone(Err(format!("Write chunk {} failed: {}", i, e))));
return;
}
let _ = port.flush();
let progress = (i + 1) as f32 / num_chunks as f32;
let _ = tx.send(BgMsg::OtaProgress(progress * 0.95, format!(
"Chunk {}/{} ({} KB / {} KB)",
i + 1, num_chunks,
((i + 1) * chunk_size).min(total) / 1024,
total / 1024
)));
// Wait for ACK line
let mut ack_buf = [0u8; 256];
let mut ack = String::new();
let start = std::time::Instant::now();
while start.elapsed() < std::time::Duration::from_secs(5) {
match port.read(&mut ack_buf) {
Ok(n) if n > 0 => {
ack.push_str(&String::from_utf8_lossy(&ack_buf[..n]));
if ack.contains("OTA_OK") || ack.contains("OTA_DONE") || ack.contains("OTA_FAIL") {
break;
}
}
_ => std::thread::sleep(std::time::Duration::from_millis(10)),
}
}
if ack.contains("OTA_FAIL") {
let _ = port.set_timeout(old_timeout);
let _ = tx.send(BgMsg::OtaDone(Err(format!("Firmware error: {}", ack.trim()))));
return;
}
if ack.contains("OTA_DONE") {
break; // Firmware signals all received
}
}
let _ = port.set_timeout(old_timeout);
let _ = tx.send(BgMsg::OtaProgress(1.0, "OTA complete, rebooting...".into()));
let _ = tx.send(BgMsg::OtaDone(Ok(())));
});
});
}
// --- Config Export ---
fn setup_config_export(window: &MainWindow, ctx: &AppContext) {
let serial = ctx.serial.clone();
let tx = ctx.bg_tx.clone();
let window_weak = window.as_weak();
window.global::<SettingsBridge>().on_config_export(move || {
let Some(w) = window_weak.upgrade() else { return };
w.global::<SettingsBridge>().set_config_busy(true);
w.global::<SettingsBridge>().set_config_progress(0.0);
w.global::<SettingsBridge>().set_config_status(SharedString::from("Reading config..."));
let serial = serial.clone();
let tx = tx.clone();
std::thread::spawn(move || {
let result = config::export_config(&serial, &tx);
let _ = tx.send(BgMsg::ConfigDone(result));
});
});
}
// --- Config Import ---
fn setup_config_import(window: &MainWindow, ctx: &AppContext) {
let serial = ctx.serial.clone();
let tx = ctx.bg_tx.clone();
let window_weak = window.as_weak();
window.global::<SettingsBridge>().on_config_import(move || {
let serial = serial.clone();
let tx = tx.clone();
let window_weak = window_weak.clone();
std::thread::spawn(move || {
let file = rfd::FileDialog::new()
.add_filter("KeSp Config", &["json"])
.pick_file();
let Some(path) = file else { return };
let _ = slint::invoke_from_event_loop({
let window_weak = window_weak.clone();
move || {
if let Some(w) = window_weak.upgrade() {
let s = w.global::<SettingsBridge>();
s.set_config_busy(true);
s.set_config_progress(0.0);
s.set_config_status(SharedString::from("Importing config..."));
}
}
});
let json = match std::fs::read_to_string(&path) {
Ok(j) => j,
Err(e) => {
let _ = tx.send(BgMsg::ConfigDone(Err(format!("Read error: {}", e))));
return;
}
};
let config = match protocol::config_io::KeyboardConfig::from_json(&json) {
Ok(c) => c,
Err(e) => {
let _ = tx.send(BgMsg::ConfigDone(Err(format!("Parse error: {}", e))));
return;
}
};
let result = config::import_config(&serial, &tx, &config);
let _ = tx.send(BgMsg::ConfigDone(result));
});
});
}

View file

@ -1,28 +0,0 @@
use crate::context::AppContext;
use crate::{MainWindow, StatsBridge};
use crate::protocol;
use slint::ComponentHandle;
/// Wire up the stats refresh callback.
pub fn setup(window: &MainWindow, ctx: &AppContext) {
let serial = ctx.serial.clone();
let tx = ctx.bg_tx.clone();
window.global::<StatsBridge>().on_refresh_stats(move || {
let serial = serial.clone();
let tx = tx.clone();
std::thread::spawn(move || {
use protocol::binary::cmd;
use crate::context::BgMsg;
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
if let Ok(r) = ser.send_binary(cmd::KEYSTATS_BIN, &[]) {
let (data, max) = protocol::parsers::parse_keystats_binary(&r.payload);
let _ = tx.send(BgMsg::HeatmapData(data, max));
}
let bigram_lines = if let Ok(r) = ser.send_binary(protocol::binary::cmd::BIGRAMS_TEXT, &[]) {
String::from_utf8_lossy(&r.payload).lines().map(|l| l.to_string()).collect()
} else { Vec::new() };
let _ = tx.send(BgMsg::BigramLines(bigram_lines));
});
});
}

View file

@ -1,101 +0,0 @@
use crate::context::{AppContext, BgMsg};
use crate::protocol::binary::{self as bp};
use crate::{MainWindow, ToolsBridge};
use slint::ComponentHandle;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
/// Shared flag to stop the matrix test polling thread.
static MATRIX_POLLING: AtomicBool = AtomicBool::new(false);
pub fn setup(window: &MainWindow, ctx: &AppContext) {
setup_toggle_matrix_test(window, ctx);
}
fn setup_toggle_matrix_test(window: &MainWindow, ctx: &AppContext) {
let serial = ctx.serial.clone();
let tx = ctx.bg_tx.clone();
window.global::<ToolsBridge>().on_toggle_matrix_test(move || {
let serial = serial.clone();
let tx = tx.clone();
std::thread::spawn(move || {
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
// Send toggle command
match ser.send_binary(bp::cmd::MATRIX_TEST, &[]) {
Ok(resp) => {
if resp.payload.len() >= 3 {
let enabled = resp.payload[0];
let rows = resp.payload[1];
let cols = resp.payload[2];
let _ = tx.send(BgMsg::MatrixTestToggled(enabled != 0, rows, cols));
if enabled != 0 {
// Start polling thread for unsolicited events
MATRIX_POLLING.store(true, Ordering::SeqCst);
let serial2 = serial.clone();
let tx2 = tx.clone();
drop(ser); // release lock before spawning poller
std::thread::spawn(move || {
poll_matrix_events(serial2, tx2);
});
} else {
MATRIX_POLLING.store(false, Ordering::SeqCst);
}
}
}
Err(e) => {
let _ = tx.send(BgMsg::MatrixTestError(e));
}
}
});
});
}
/// Poll serial for unsolicited KR [0xB0] events.
fn poll_matrix_events(
serial: Arc<std::sync::Mutex<crate::protocol::serial::SerialManager>>,
tx: std::sync::mpsc::Sender<BgMsg>,
) {
let mut buf = vec![0u8; 256];
while MATRIX_POLLING.load(Ordering::SeqCst) {
let read_result = {
let mut ser = match serial.try_lock() {
Ok(s) => s,
Err(_) => {
std::thread::sleep(Duration::from_millis(5));
continue;
}
};
let port = match ser.port_mut() {
Some(p) => p,
None => {
MATRIX_POLLING.store(false, Ordering::SeqCst);
let _ = tx.send(BgMsg::MatrixTestError("Port disconnected".into()));
break;
}
};
port.read(&mut buf)
};
match read_result {
Ok(n) if n > 0 => {
let frames = bp::parse_all_kr(&buf[..n]);
for frame in frames {
if frame.cmd == bp::cmd::MATRIX_TEST && frame.is_ok() && frame.payload.len() >= 3 {
let row = frame.payload[0];
let col = frame.payload[1];
let state = frame.payload[2];
let _ = tx.send(BgMsg::MatrixTestEvent(row, col, state));
}
}
}
_ => {
std::thread::sleep(Duration::from_millis(2));
}
}
}
}

View file

@ -1,56 +0,0 @@
import { Theme } from "../theme.slint";
import { DarkButton } from "dark_button.slint";
import { AppState, ConnectionBridge, ConnectionState } from "../globals.slint";
export component ConnectionBar inherits Rectangle {
height: 48px;
background: Theme.bg-secondary;
HorizontalLayout {
padding: 8px;
spacing: 12px;
alignment: start;
// Status LED (wrapped in a VerticalLayout to center it)
VerticalLayout {
alignment: center;
Rectangle {
width: 16px;
height: 16px;
border-radius: 8px;
background: AppState.connection == ConnectionState.connected ? Theme.connected : Theme.disconnected;
}
}
// Port selector (placeholder - ComboBox needs string model)
Text {
text: ConnectionBridge.selected-port != "" ? ConnectionBridge.selected-port : "No port selected";
color: Theme.fg-primary;
vertical-alignment: center;
}
// Connect/Disconnect button
DarkButton {
text: AppState.connection == ConnectionState.connected ? "Disconnect" : "Connect";
enabled: AppState.connection == ConnectionState.connected || AppState.connection == ConnectionState.disconnected;
clicked => {
if AppState.connection == ConnectionState.connected {
ConnectionBridge.disconnect();
} else {
ConnectionBridge.connect();
}
}
}
// Spacer
Rectangle {
horizontal-stretch: 1;
}
// Firmware version
Text {
text: AppState.firmware-version;
color: Theme.fg-secondary;
vertical-alignment: center;
}
}
}

View file

@ -1,37 +0,0 @@
import { Theme } from "../theme.slint";
export component DarkButton inherits Rectangle {
in property <string> text;
in property <bool> enabled: true;
in property <bool> primary: false;
callback clicked();
min-width: btn-text.preferred-width + 24px;
height: 28px;
border-radius: 4px;
border-width: 1px;
border-color: !root.enabled ? transparent
: root.primary ? Theme.accent-purple.darker(0.1)
: Theme.button-border;
background: !root.enabled ? Theme.bg-primary
: root.primary && ta.has-hover ? Theme.accent-purple.darker(0.2)
: root.primary ? Theme.accent-purple
: ta.has-hover ? Theme.button-hover
: Theme.button-bg;
opacity: root.enabled ? 1.0 : 0.4;
btn-text := Text {
text: root.text;
color: root.primary ? #282a36 : root.enabled ? Theme.fg-primary : Theme.fg-secondary;
font-size: 12px;
font-weight: 500;
horizontal-alignment: center;
vertical-alignment: center;
}
ta := TouchArea {
enabled: root.enabled;
clicked => { root.clicked(); }
mouse-cursor: root.enabled ? pointer : default;
}
}

View file

@ -1,45 +0,0 @@
import { Theme } from "../theme.slint";
export component DarkCheckbox inherits Rectangle {
in property <string> text;
in-out property <bool> checked: false;
height: 24px;
min-width: box.width + label.preferred-width + 10px;
HorizontalLayout {
spacing: 4px;
alignment: start;
box := Rectangle {
width: 16px;
height: 16px;
y: (parent.height - self.height) / 2;
border-radius: 3px;
border-width: 1px;
border-color: root.checked ? Theme.accent-purple : Theme.button-border;
background: root.checked ? Theme.accent-purple : Theme.button-bg;
Text {
text: root.checked ? "v" : "";
color: #282a36;
font-size: 11px;
font-weight: 700;
horizontal-alignment: center;
vertical-alignment: center;
}
}
label := Text {
text: root.text;
color: Theme.fg-primary;
font-size: 11px;
vertical-alignment: center;
}
}
TouchArea {
clicked => { root.checked = !root.checked; }
mouse-cursor: pointer;
}
}

View file

@ -1,83 +0,0 @@
import { Theme } from "../theme.slint";
export component DarkComboBox inherits Rectangle {
in property <[string]> model;
in-out property <int> current-index: 0;
in-out property <string> current-value: model.length > 0 && current-index >= 0 && current-index < model.length ? model[current-index] : "";
callback selected(string);
height: 28px;
min-width: 60px;
border-radius: 4px;
background: ta.has-hover ? Theme.button-hover : Theme.button-bg;
border-width: 1px;
border-color: Theme.button-border;
HorizontalLayout {
padding-left: 8px;
padding-right: 6px;
spacing: 4px;
Text {
text: root.current-value;
color: Theme.fg-primary;
font-size: 11px;
vertical-alignment: center;
horizontal-stretch: 1;
overflow: elide;
}
Text {
text: "v";
color: Theme.fg-secondary;
font-size: 9px;
vertical-alignment: center;
}
}
ta := TouchArea {
clicked => { popup.show(); }
mouse-cursor: pointer;
}
popup := PopupWindow {
x: 0;
y: root.height;
width: root.width;
Rectangle {
background: Theme.bg-primary;
border-radius: 4px;
border-width: 1px;
border-color: Theme.accent-purple;
VerticalLayout {
padding: 2px;
for item[idx] in root.model : Rectangle {
height: 26px;
border-radius: 3px;
background: item-ta.has-hover ? Theme.button-hover : idx == root.current-index ? Theme.bg-secondary : transparent;
Text {
text: item;
color: idx == root.current-index ? Theme.accent-purple : Theme.fg-primary;
font-size: 11px;
vertical-alignment: center;
horizontal-alignment: left;
x: 8px;
}
item-ta := TouchArea {
clicked => {
root.current-index = idx;
root.current-value = item;
root.selected(item);
}
mouse-cursor: pointer;
}
}
}
}
}
}

View file

@ -1,41 +0,0 @@
import { Theme } from "../theme.slint";
export component DarkLineEdit inherits Rectangle {
in property <string> placeholder-text: "";
in-out property <string> text: "";
callback accepted(string);
callback edited(string);
forward-focus: inner;
height: 28px;
min-width: 60px;
border-radius: 4px;
border-width: 1px;
border-color: inner.has-focus ? Theme.accent-purple : Theme.button-border;
background: Theme.button-bg;
// Placeholder
if root.text == "" && !inner.has-focus : Text {
x: 6px;
y: 0;
width: root.width - 12px;
height: root.height;
text: root.placeholder-text;
color: Theme.fg-secondary;
font-size: 12px;
vertical-alignment: center;
}
inner := TextInput {
x: 6px;
y: 0;
width: root.width - 12px;
height: root.height;
text <=> root.text;
color: Theme.fg-primary;
font-size: 12px;
vertical-alignment: center;
accepted => { root.accepted(root.text); }
edited => { root.edited(root.text); }
}
}

View file

@ -1,62 +0,0 @@
import { Theme } from "../theme.slint";
import { KeycapData, KeymapBridge } from "../globals.slint";
export component KeyButton inherits Rectangle {
in property <KeycapData> data;
in property <float> scale: 1.0;
callback clicked(int);
// Heat color: stepped gradient for clarity
property <color> heat-color:
data.heat > 0.8 ? #ff0000 // bright red
: data.heat > 0.6 ? #ff4400 // red-orange
: data.heat > 0.4 ? #ff8800 // orange
: data.heat > 0.2 ? #ffcc00 // yellow
: data.heat > 0.05 ? #446688 // cool blue
: #2d2d44; // very cold
property <bool> is-heatmap: KeymapBridge.heatmap-enabled && data.heat > 0;
property <color> base-color: root.is-heatmap ? root.heat-color : data.color;
property <color> text-color: root.is-heatmap && data.heat > 0.2 ? #1a1a2e : Theme.fg-primary;
width: data.w;
height: data.h;
background: transparent;
inner := Rectangle {
width: 100%;
height: 100%;
border-radius: 4px;
background: data.selected ? Theme.accent-purple : root.base-color;
border-width: 1px;
border-color: data.selected ? Theme.accent-cyan : Theme.bg-primary;
transform-rotation: data.rotation * 1deg;
transform-origin: {
x: self.width / 2,
y: self.height / 2,
};
Text {
text: data.label;
color: root.text-color;
font-size: max(7px, 11px * root.scale);
font-weight: root.is-heatmap ? 700 : 400;
horizontal-alignment: center;
vertical-alignment: center;
}
if data.sublabel != "" : Text {
y: parent.height - 14px * root.scale;
text: data.sublabel;
color: root.is-heatmap ? root.text-color.with-alpha(0.7) : Theme.fg-secondary;
font-size: max(5px, 8px * root.scale);
horizontal-alignment: center;
width: 100%;
}
TouchArea {
clicked => { root.clicked(data.index); }
mouse-cursor: pointer;
}
}
}

View file

@ -1,273 +0,0 @@
import { ScrollView } from "std-widgets.slint";
import { DarkLineEdit } from "dark_line_edit.slint";
import { Theme } from "../theme.slint";
import { DarkButton } from "dark_button.slint";
import { DarkComboBox } from "dark_combo_box.slint";
import { KeySelectorBridge, KeymapBridge, KeyEntry, KeycapData } from "../globals.slint";
import { KeyButton } from "key_button.slint";
component KeyTile inherits Rectangle {
in property <string> label;
in property <int> code;
callback picked(int);
width: 52px;
height: 30px;
border-radius: 4px;
background: ta.has-hover ? Theme.accent-purple : Theme.button-bg;
Text {
text: root.label;
color: Theme.fg-primary;
font-size: 11px;
horizontal-alignment: center;
vertical-alignment: center;
}
ta := TouchArea {
clicked => { root.picked(root.code); }
mouse-cursor: pointer;
}
}
component SectionLabel inherits Text {
color: Theme.accent-cyan;
font-size: 12px;
font-weight: 600;
}
component KeySection inherits VerticalLayout {
in property <string> title;
in property <[KeyEntry]> keys;
in property <int> cols: 7;
callback picked(int);
property <length> tile-w: 55px;
property <length> tile-h: 30px;
property <length> gap: 3px;
spacing: 3px;
if keys.length > 0 : SectionLabel { text: root.title; }
if keys.length > 0 : Rectangle {
height: (Math.ceil(keys.length / root.cols) ) * (root.tile-h + root.gap);
for key[idx] in root.keys : KeyTile {
x: mod(idx, root.cols) * (root.tile-w + root.gap);
y: floor(idx / root.cols) * (root.tile-h + root.gap);
width: root.tile-w;
label: key.name;
code: key.code;
picked(c) => { root.picked(c); }
}
}
}
export component KeySelector inherits Rectangle {
visible: KeymapBridge.key-selector-open;
background: #000000aa;
TouchArea { clicked => { KeymapBridge.key-selector-open = false; } }
function pick(code: int) {
KeySelectorBridge.select-keycode(code);
KeymapBridge.key-selector-open = false;
}
Rectangle {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
width: min(480px, parent.width - 40px);
height: min(520px, parent.height - 40px);
background: Theme.bg-primary;
border-radius: 12px;
border-width: 1px;
border-color: Theme.accent-purple;
clip: true;
TouchArea { }
VerticalLayout {
padding: 14px;
spacing: 8px;
// Header
HorizontalLayout {
Text { text: "Select Key"; color: Theme.fg-primary; font-size: 16px; font-weight: 700; vertical-alignment: center; }
Rectangle { horizontal-stretch: 1; }
Rectangle {
width: 26px; height: 26px; border-radius: 4px;
background: close-ta.has-hover ? Theme.accent-red : Theme.button-bg;
Text { text: "X"; color: Theme.fg-primary; font-size: 13px; horizontal-alignment: center; vertical-alignment: center; }
close-ta := TouchArea { clicked => { KeymapBridge.key-selector-open = false; } mouse-cursor: pointer; }
}
}
// Keyboard mode for combo key picking
property <bool> keyboard-mode: KeymapBridge.selector-target == "combo-key1" || KeymapBridge.selector-target == "combo-key2";
if keyboard-mode : Text {
text: "Click a key on the keyboard:";
color: Theme.fg-secondary;
font-size: 12px;
}
if keyboard-mode : Rectangle {
vertical-stretch: 1;
background: Theme.bg-surface;
border-radius: 8px;
clip: true;
property <float> kb-scale-x: self.width / KeymapBridge.content-width;
property <float> kb-scale-y: self.height / KeymapBridge.content-height;
property <float> kb-scale: min(kb-scale-x, kb-scale-y) * 0.95;
property <length> kb-offset-x: (self.width - KeymapBridge.content-width * kb-scale) / 2;
property <length> kb-offset-y: (self.height - KeymapBridge.content-height * kb-scale) / 2;
for keycap[idx] in KeymapBridge.keycaps : KeyButton {
x: parent.kb-offset-x + keycap.x * parent.kb-scale;
y: parent.kb-offset-y + keycap.y * parent.kb-scale;
width: keycap.w * parent.kb-scale;
height: keycap.h * parent.kb-scale;
scale: parent.kb-scale;
data: keycap;
clicked(key-index) => {
// Pass key index as the code — dispatch reads keys_arc[code] for row/col
KeySelectorBridge.select-keycode(key-index);
KeymapBridge.key-selector-open = false;
}
}
}
// Normal mode: search + key grid
if !keyboard-mode : DarkLineEdit {
placeholder-text: "Search...";
text <=> KeySelectorBridge.search-text;
edited(text) => { KeySelectorBridge.apply-filter(text); }
}
if !keyboard-mode : ScrollView {
vertical-stretch: 1;
VerticalLayout {
spacing: 6px;
padding-right: 8px;
KeySection { title: "Letters"; keys: KeySelectorBridge.cat-letters; picked(c) => { root.pick(c); } }
KeySection { title: "Numbers"; keys: KeySelectorBridge.cat-numbers; picked(c) => { root.pick(c); } }
KeySection { title: "Modifiers"; keys: KeySelectorBridge.cat-modifiers; picked(c) => { root.pick(c); } }
KeySection { title: "Navigation"; keys: KeySelectorBridge.cat-nav; picked(c) => { root.pick(c); } }
KeySection { title: "Function Keys"; keys: KeySelectorBridge.cat-function; picked(c) => { root.pick(c); } }
KeySection { title: "Symbols"; keys: KeySelectorBridge.cat-symbols; picked(c) => { root.pick(c); } }
KeySection { title: "Layers"; keys: KeySelectorBridge.cat-layers; picked(c) => { root.pick(c); } }
KeySection { title: "Special / BT / Media"; keys: KeySelectorBridge.cat-special; picked(c) => { root.pick(c); } }
KeySection { title: "Tap Dance / Macros"; keys: KeySelectorBridge.cat-td-macro; picked(c) => { root.pick(c); } }
// Mod-Tap builder
SectionLabel { text: "Mod-Tap"; }
HorizontalLayout {
spacing: 6px;
height: 32px;
Text { text: "Mod:"; color: Theme.fg-secondary; font-size: 11px; vertical-alignment: center; }
mt-mod := DarkComboBox {
width: 80px;
model: ["Ctrl", "Shift", "Alt", "GUI", "RCtrl", "RShift", "RAlt", "RGUI"];
current-index: 1;
}
Text { text: "Key:"; color: Theme.fg-secondary; font-size: 11px; vertical-alignment: center; }
mt-key := DarkComboBox {
width: 70px;
model: ["A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z","1","2","3","4","5","6","7","8","9","0","Space","Enter","Esc","Tab","Bksp"];
current-index: 0;
}
Text {
text: "= MT " + mt-mod.current-value + " " + mt-key.current-value;
color: Theme.accent-green;
font-size: 11px;
vertical-alignment: center;
}
DarkButton {
text: "Set";
clicked => {
KeySelectorBridge.apply-mt(mt-mod.current-index, mt-key.current-index);
KeymapBridge.key-selector-open = false;
}
}
}
// Layer-Tap builder
SectionLabel { text: "Layer-Tap"; }
HorizontalLayout {
spacing: 6px;
height: 32px;
Text { text: "Layer:"; color: Theme.fg-secondary; font-size: 11px; vertical-alignment: center; }
lt-layer := DarkComboBox {
width: 50px;
model: ["0","1","2","3","4","5","6","7","8","9"];
current-index: 1;
}
Text { text: "Key:"; color: Theme.fg-secondary; font-size: 11px; vertical-alignment: center; }
lt-key := DarkComboBox {
width: 80px;
model: ["Space","Enter","Esc","Bksp","Tab","A","B","C","D","E"];
current-index: 0;
}
Text {
text: "= LT " + lt-layer.current-value + " " + lt-key.current-value;
color: Theme.accent-green;
font-size: 11px;
vertical-alignment: center;
}
DarkButton {
text: "Set";
clicked => {
KeySelectorBridge.apply-lt(lt-layer.current-index, lt-key.current-index);
KeymapBridge.key-selector-open = false;
}
}
}
// Hex input
SectionLabel { text: "Hex Code"; }
HorizontalLayout {
spacing: 6px;
height: 32px;
Text { text: "0x"; color: Theme.accent-orange; font-size: 12px; font-weight: 600; vertical-alignment: center; }
hex-edit := DarkLineEdit {
width: 80px;
text <=> KeySelectorBridge.hex-input;
placeholder-text: "5204";
edited(text) => { KeySelectorBridge.preview-hex(text); }
accepted(text) => {
KeySelectorBridge.apply-hex(text);
KeymapBridge.key-selector-open = false;
}
}
Text {
text: KeySelectorBridge.hex-preview != "" ? "= " + KeySelectorBridge.hex-preview : "";
color: Theme.accent-green;
font-size: 11px;
vertical-alignment: center;
}
DarkButton {
text: "Set";
clicked => {
KeySelectorBridge.apply-hex(KeySelectorBridge.hex-input);
KeymapBridge.key-selector-open = false;
}
}
}
// None
KeyTile {
width: 120px;
label: "None (transparent)";
code: 0;
picked(c) => { root.pick(c); }
}
}
}
}
}
}

View file

@ -1,32 +0,0 @@
import { Theme } from "../theme.slint";
import { KeymapBridge, KeycapData } from "../globals.slint";
import { KeyButton } from "key_button.slint";
export component KeyboardView inherits Rectangle {
background: Theme.bg-surface;
border-radius: 8px;
min-height: 150px;
clip: true;
// Scale to fit: min(available / content) so it always fits
property <float> scale-x: self.width / KeymapBridge.content-width;
property <float> scale-y: self.height / KeymapBridge.content-height;
property <float> scale: min(root.scale-x, root.scale-y) * 0.95;
// Center offset
property <length> offset-x: (self.width - KeymapBridge.content-width * root.scale) / 2;
property <length> offset-y: (self.height - KeymapBridge.content-height * root.scale) / 2;
for keycap[idx] in KeymapBridge.keycaps : KeyButton {
x: root.offset-x + keycap.x * root.scale;
y: root.offset-y + keycap.y * root.scale;
width: keycap.w * root.scale;
height: keycap.h * root.scale;
scale: root.scale;
data: keycap;
clicked(key-index) => {
KeymapBridge.select-key(key-index);
KeymapBridge.selector-target = "keymap";
KeymapBridge.key-selector-open = true;
}
}
}

View file

@ -1,39 +0,0 @@
import { Theme } from "../theme.slint";
import { AppState } from "../globals.slint";
export component StatusBar inherits Rectangle {
height: 32px;
background: Theme.bg-secondary;
HorizontalLayout {
padding-left: 12px;
padding-right: 12px;
spacing: 16px;
// Status text
Text {
text: AppState.status-text;
color: Theme.fg-secondary;
font-size: 12px;
vertical-alignment: center;
}
Rectangle { horizontal-stretch: 1; }
// WPM
Text {
text: AppState.wpm > 0 ? "WPM: " + AppState.wpm : "";
color: Theme.accent-cyan;
font-size: 12px;
vertical-alignment: center;
}
// Version
Text {
text: "v1.0.0";
color: Theme.fg-secondary;
font-size: 11px;
vertical-alignment: center;
}
}
}

View file

@ -1,332 +0,0 @@
// Shared data structures
export struct KeycapData {
x: length,
y: length,
w: length,
h: length,
rotation: float,
rotation-cx: length,
rotation-cy: length,
label: string,
sublabel: string,
keycode: int,
color: color,
heat: float, // 0.0 = cold, 1.0 = hottest
selected: bool,
index: int,
}
export struct LayerInfo {
index: int,
name: string,
active: bool,
}
export struct PortInfo {
name: string,
path: string,
}
export enum ConnectionState { disconnected, connecting, connected }
export enum ActiveTab { keymap, advanced, macros, stats, settings }
// Global singletons for Rust<->Slint bridge
export global AppState {
in-out property <ConnectionState> connection: ConnectionState.disconnected;
in-out property <ActiveTab> active-tab: ActiveTab.keymap;
callback tab-changed(int);
in-out property <string> status-text: "Disconnected";
in-out property <bool> spinner-visible: false;
in-out property <string> firmware-version: "";
in-out property <int> wpm: 0;
}
export global ConnectionBridge {
in property <[PortInfo]> ports;
in-out property <string> selected-port: "";
callback connect();
callback disconnect();
callback refresh-ports();
}
export global KeymapBridge {
in property <[KeycapData]> keycaps;
in property <[LayerInfo]> layers;
in property <length> content-width: 860px;
in property <length> content-height: 360px;
in-out property <int> selected-key-index: -1;
in-out property <int> active-layer: 0;
in-out property <string> selected-key-label: "";
in-out property <bool> heatmap-enabled: false;
in-out property <bool> key-selector-open: false;
// Target for key selector: "keymap", "combo-result", "ko-trigger", "ko-result", "leader-result"
in-out property <string> selector-target: "keymap";
callback select-key(int);
callback switch-layer(int);
callback change-key(int, int);
callback toggle-heatmap();
callback rename-layer(int, string); // layer-index, new-name
}
// ---- Settings ----
export global SettingsBridge {
in property <[string]> available-layouts;
in-out property <int> selected-layout-index: 0;
callback change-layout(int);
// OTA
in-out property <string> ota-path: "";
in property <float> ota-progress: 0;
in property <string> ota-status: "";
in property <bool> ota-flashing: false;
callback ota-browse();
callback ota-start();
// Config import/export
in property <string> config-status: "";
in property <bool> config-busy: false;
in property <float> config-progress: 0;
callback config-export();
callback config-import();
}
// ---- Stats ----
export struct HandBalanceData {
left-pct: float,
right-pct: float,
total: int,
}
export struct FingerLoadData {
name: string,
pct: float,
count: int,
}
export struct RowUsageData {
name: string,
pct: float,
count: int,
}
export struct TopKeyData {
name: string,
finger: string,
count: int,
pct: float,
}
export struct BigramData {
alt-hand-pct: float,
same-hand-pct: float,
sfb-pct: float,
total: int,
}
export global StatsBridge {
in property <HandBalanceData> hand-balance;
in property <[FingerLoadData]> finger-load;
in property <[RowUsageData]> row-usage;
in property <[TopKeyData]> top-keys;
in property <[string]> dead-keys;
in property <int> total-presses;
in property <BigramData> bigrams;
callback refresh-stats();
}
// ---- Advanced ----
export struct TapDanceAction {
name: string,
code: int,
}
export struct TapDanceData {
index: int,
actions: [TapDanceAction],
}
export struct ComboData {
index: int,
key1: string,
key2: string,
result: string,
}
export struct LeaderData {
index: int,
sequence: string,
result: string,
}
export struct KeyOverrideData {
index: int,
trigger: string,
result: string,
}
export global AdvancedBridge {
in property <[TapDanceData]> tap-dances;
in property <[ComboData]> combos;
in property <[LeaderData]> leaders;
in property <[KeyOverrideData]> key-overrides;
in-out property <string> bt-status: "";
in-out property <int> tri-l1-idx: 1;
in-out property <int> tri-l2-idx: 2;
in-out property <int> tri-l3-idx: 3;
// Combo creation: physical key positions
in-out property <int> new-combo-r1: 0;
in-out property <int> new-combo-c1: 0;
in-out property <string> new-combo-key1-name: "Pick...";
in-out property <int> new-combo-r2: 0;
in-out property <int> new-combo-c2: 0;
in-out property <string> new-combo-key2-name: "Pick...";
in-out property <int> new-combo-result-code: 0;
in-out property <string> new-combo-result-name: "Pick...";
// KO creation: keycodes for trigger and result
in-out property <int> new-ko-trigger-code: 0;
in-out property <string> new-ko-trigger-name: "Pick...";
in-out property <bool> new-ko-trig-ctrl: false;
in-out property <bool> new-ko-trig-shift: false;
in-out property <bool> new-ko-trig-alt: false;
in-out property <int> new-ko-result-code: 0;
in-out property <string> new-ko-result-name: "Pick...";
in-out property <bool> new-ko-res-ctrl: false;
in-out property <bool> new-ko-res-shift: false;
in-out property <bool> new-ko-res-alt: false;
// Leader creation
in-out property <int> new-leader-seq0-code: 0;
in-out property <string> new-leader-seq0-name: "";
in-out property <int> new-leader-seq1-code: 0;
in-out property <string> new-leader-seq1-name: "";
in-out property <int> new-leader-seq2-code: 0;
in-out property <string> new-leader-seq2-name: "";
in-out property <int> new-leader-seq3-code: 0;
in-out property <string> new-leader-seq3-name: "";
in-out property <int> new-leader-seq-count: 0;
in-out property <int> new-leader-result-code: 0;
in-out property <string> new-leader-result-name: "Pick...";
in-out property <int> new-leader-mod-idx: 0;
// TD editing: stores which TD slot+action is being edited
in-out property <int> editing-td-index: -1;
in-out property <int> editing-td-slot: -1; // 0-3 = which action
callback save-td(int, int, int, int, int); // index, a1, a2, a3, a4 (keycodes)
callback edit-td-action(int, int); // td_index, action_slot -> opens popup
callback refresh-advanced();
callback delete-combo(int);
callback delete-leader(int);
callback delete-ko(int);
callback set-trilayer(int, int, int);
callback bt-switch(int);
callback create-combo(); // reads r1,c1,r2,c2,result from properties
callback create-ko(); // reads all fields from properties
callback create-leader(int, int); // result_code, result_mod_idx (sequence comes from seq0-3 properties)
// TAMA
in property <string> tama-status: "";
callback tama-action(string); // "feed", "play", "sleep", "meds", "toggle"
// Autoshift
in property <string> autoshift-status: "";
callback toggle-autoshift();
}
// ---- Macros ----
export struct MacroData {
slot: int,
name: string,
steps: string,
}
export struct MacroStepDisplay {
label: string, // e.g. "A", "T 100ms"
is-delay: bool,
}
export global MacroBridge {
in property <[MacroData]> macros;
in-out property <int> new-slot-idx: 0;
in-out property <string> new-name: "";
// Visual step builder
in property <[MacroStepDisplay]> new-steps;
in-out property <string> new-steps-text: ""; // built text for display
callback refresh-macros();
callback save-macro(); // reads slot, name, steps from properties
callback delete-macro(int);
callback add-delay-step(int); // delay in ms (50, 100, 200, 500)
callback add-shortcut(string); // e.g. "ctrl+c", "ctrl+shift+z"
callback remove-last-step();
callback clear-steps();
}
// ---- OTA / Flasher ----
export global FlasherBridge {
in property <[string]> prog-ports;
in-out property <string> selected-prog-port: "";
in-out property <string> firmware-path: "";
in-out property <int> flash-offset-index: 1; // 0=full(0x0), 1=factory(0x20000), 2=ota_0(0x220000)
in property <float> flash-progress: 0;
in property <string> flash-status: "";
in property <bool> flashing: false;
callback refresh-prog-ports();
callback browse-firmware();
callback flash();
}
// ---- Layout Preview ----
export global LayoutBridge {
in property <[KeycapData]> keycaps;
in property <length> content-width: 860px;
in property <length> content-height: 360px;
in property <string> status: "";
in property <string> json-text: "";
in-out property <string> file-path: "";
in-out property <bool> auto-refresh: false;
callback load-from-file();
callback load-from-keyboard();
callback load-json(string);
callback export-json();
}
// ---- Matrix Test ----
export global ToolsBridge {
in-out property <bool> matrix-test-active: false;
in property <string> matrix-test-status: "";
// Keycap model with live press state (reuses KeycapData, color = press state)
in property <[KeycapData]> matrix-keycaps;
in property <length> matrix-content-width: 860px;
in property <length> matrix-content-height: 360px;
callback toggle-matrix-test();
}
// ---- Key Selector ----
export struct KeyEntry {
name: string,
code: int,
category: string,
}
export global KeySelectorBridge {
in property <[KeyEntry]> all-keys;
in property <[KeyEntry]> cat-letters;
in property <[KeyEntry]> cat-numbers;
in property <[KeyEntry]> cat-modifiers;
in property <[KeyEntry]> cat-nav;
in property <[KeyEntry]> cat-function;
in property <[KeyEntry]> cat-symbols;
in property <[KeyEntry]> cat-layers;
in property <[KeyEntry]> cat-special;
in property <[KeyEntry]> cat-td-macro;
in-out property <string> search-text: "";
in-out property <string> hex-input: "";
in property <string> hex-preview: "";
callback apply-filter(string);
callback select-keycode(int);
callback apply-hex(string);
callback apply-mt(int, int);
callback apply-lt(int, int);
callback preview-hex(string); // updates hex-preview
}

View file

@ -1,98 +0,0 @@
import { Theme } from "theme.slint";
import { AppState, ActiveTab } from "globals.slint";
import { ConnectionBar } from "components/connection_bar.slint";
import { StatusBar } from "components/status_bar.slint";
import { KeySelector } from "components/key_selector.slint";
import { TabKeymap } from "tabs/tab_keymap.slint";
import { TabAdvanced } from "tabs/tab_advanced.slint";
import { TabMacros } from "tabs/tab_macros.slint";
import { TabStats } from "tabs/tab_stats.slint";
import { TabSettings } from "tabs/tab_settings.slint";
import { TabTools } from "tabs/tab_tools.slint";
export { AppState, Theme }
export { ConnectionBridge, KeymapBridge, SettingsBridge, StatsBridge, AdvancedBridge, MacroBridge, FlasherBridge, KeySelectorBridge, LayoutBridge, ToolsBridge } from "globals.slint";
component DarkTab inherits Rectangle {
in property <string> title;
in property <bool> active;
callback clicked();
height: 32px;
horizontal-stretch: 1;
border-radius: 4px;
background: root.active ? Theme.bg-secondary : transparent;
Text {
text: root.title;
color: root.active ? Theme.fg-primary : #9a9ebb;
font-size: 13px;
font-weight: root.active ? 700 : 400;
horizontal-alignment: center;
vertical-alignment: center;
}
Rectangle {
y: parent.height - 2px;
height: 2px;
background: root.active ? Theme.accent-purple : transparent;
}
TouchArea {
clicked => { root.clicked(); }
mouse-cursor: pointer;
}
}
export component MainWindow inherits Window {
title: "KeSp Controller";
preferred-width: 1000px;
preferred-height: 700px;
min-width: 600px;
min-height: 450px;
background: Theme.bg-primary;
in-out property <int> current-tab: 0;
VerticalLayout {
ConnectionBar { }
// Tab bar
Rectangle {
height: 34px;
background: Theme.bg-primary;
HorizontalLayout {
padding-left: 8px;
padding-right: 8px;
spacing: 2px;
DarkTab { title: "Keymap"; active: root.current-tab == 0; clicked => { root.current-tab = 0; AppState.tab-changed(0); } }
DarkTab { title: "Advanced"; active: root.current-tab == 1; clicked => { root.current-tab = 1; AppState.tab-changed(1); } }
DarkTab { title: "Macros"; active: root.current-tab == 2; clicked => { root.current-tab = 2; AppState.tab-changed(2); } }
DarkTab { title: "Stats"; active: root.current-tab == 3; clicked => { root.current-tab = 3; AppState.tab-changed(3); } }
DarkTab { title: "Settings"; active: root.current-tab == 4; clicked => { root.current-tab = 4; AppState.tab-changed(4); } }
DarkTab { title: "Tools"; active: root.current-tab == 5; clicked => { root.current-tab = 5; AppState.tab-changed(5); } }
}
}
// Tab content
Rectangle {
vertical-stretch: 1;
background: Theme.bg-secondary;
border-radius: 4px;
if root.current-tab == 0 : TabKeymap { }
if root.current-tab == 1 : TabAdvanced { }
if root.current-tab == 2 : TabMacros { }
if root.current-tab == 3 : TabStats { }
if root.current-tab == 4 : TabSettings { }
if root.current-tab == 5 : TabTools { }
}
StatusBar { }
}
// Modal overlay (above everything)
KeySelector { }
}

View file

@ -1,369 +0,0 @@
import { ScrollView } from "std-widgets.slint";
import { Theme } from "../theme.slint";
import { DarkButton } from "../components/dark_button.slint";
import { DarkComboBox } from "../components/dark_combo_box.slint";
import { DarkCheckbox } from "../components/dark_checkbox.slint";
import { AdvancedBridge, AppState, ConnectionState, KeymapBridge } from "../globals.slint";
component SectionHeader inherits Text {
color: Theme.accent-cyan;
font-size: 14px;
font-weight: 600;
}
component ItemRow inherits Rectangle {
in property <string> prefix;
in property <string> left;
in property <string> right;
callback delete();
background: Theme.bg-primary;
border-radius: 4px;
height: 30px;
HorizontalLayout {
padding-left: 8px;
padding-right: 8px;
spacing: 6px;
Text { text: root.prefix; color: Theme.accent-purple; font-size: 11px; vertical-alignment: center; width: 35px; }
Text { text: root.left; color: Theme.fg-primary; font-size: 11px; vertical-alignment: center; horizontal-stretch: 1; }
Text { text: "->"; color: Theme.fg-secondary; font-size: 11px; vertical-alignment: center; }
Text { text: root.right; color: Theme.accent-green; font-size: 11px; vertical-alignment: center; }
DarkButton { text: "Del"; clicked => { root.delete(); } }
}
}
component PickButton inherits Rectangle {
in property <string> label: "Pick...";
in property <string> target;
width: 100px;
height: 28px;
border-radius: 4px;
background: pick-ta.has-hover ? Theme.button-hover : Theme.button-bg;
Text { text: root.label; color: Theme.fg-primary; font-size: 11px; horizontal-alignment: center; vertical-alignment: center; }
pick-ta := TouchArea {
clicked => { KeymapBridge.selector-target = root.target; KeymapBridge.key-selector-open = true; }
mouse-cursor: pointer;
}
}
component ModComboBox inherits DarkComboBox {
model: ["None", "Ctrl", "Shift", "Alt", "GUI", "RCtrl", "RShift", "RAlt", "RGUI"];
width: 90px;
}
export component TabAdvanced inherits Rectangle {
background: Theme.bg-primary;
ScrollView {
VerticalLayout {
padding: 16px;
spacing: 12px;
// Header
HorizontalLayout {
spacing: 8px;
Text { text: "Advanced Features"; color: Theme.fg-primary; font-size: 20px; font-weight: 700; vertical-alignment: center; }
Rectangle { horizontal-stretch: 1; }
DarkButton {
text: "Refresh All";
enabled: AppState.connection == ConnectionState.connected;
clicked => { AdvancedBridge.refresh-advanced(); }
}
}
// Two columns
HorizontalLayout {
spacing: 12px;
// ==================== Left column ====================
VerticalLayout {
horizontal-stretch: 1;
spacing: 12px;
// --- Tap Dance ---
Rectangle {
background: Theme.bg-secondary;
border-radius: 8px;
VerticalLayout {
padding: 12px;
spacing: 4px;
SectionHeader { text: "Tap Dance"; }
if AdvancedBridge.tap-dances.length == 0 : Text { text: "No tap dances"; color: Theme.fg-secondary; font-size: 11px; }
Text { text: "1-tap 2-tap 3-tap hold"; color: Theme.fg-secondary; font-size: 10px; }
for td in AdvancedBridge.tap-dances : Rectangle {
background: Theme.bg-primary;
border-radius: 4px;
HorizontalLayout {
padding: 4px;
padding-left: 8px;
spacing: 4px;
alignment: start;
Text { text: "TD" + td.index; color: Theme.accent-purple; font-size: 11px; vertical-alignment: center; width: 35px; }
for action[a-idx] in td.actions : DarkButton {
text: action.name;
clicked => {
AdvancedBridge.editing-td-index = td.index;
AdvancedBridge.editing-td-slot = a-idx;
KeymapBridge.selector-target = "td-action";
KeymapBridge.key-selector-open = true;
}
}
}
}
}
}
// --- Key Overrides ---
Rectangle {
background: Theme.bg-secondary;
border-radius: 8px;
VerticalLayout {
padding: 12px;
spacing: 4px;
SectionHeader { text: "Key Overrides"; }
if AdvancedBridge.key-overrides.length == 0 : Text { text: "No key overrides"; color: Theme.fg-secondary; font-size: 11px; }
for ko in AdvancedBridge.key-overrides : ItemRow {
prefix: "KO" + ko.index;
left: ko.trigger;
right: ko.result;
delete => { AdvancedBridge.delete-ko(ko.index); }
}
// --- Add KO ---
Rectangle {
background: Theme.bg-primary;
border-radius: 4px;
VerticalLayout {
padding: 10px;
spacing: 8px;
Text { text: "Add Key Override"; color: Theme.fg-secondary; font-size: 12px; font-weight: 600; }
Text { text: "When you press [From] key, output [To] key instead"; color: Theme.fg-secondary; font-size: 10px; }
HorizontalLayout {
spacing: 8px;
alignment: start;
Text { text: "From:"; color: Theme.fg-secondary; font-size: 11px; vertical-alignment: center; }
PickButton { label: AdvancedBridge.new-ko-trigger-name; target: "ko-trigger"; }
DarkCheckbox { text: "Ctrl"; checked <=> AdvancedBridge.new-ko-trig-ctrl; }
DarkCheckbox { text: "Shift"; checked <=> AdvancedBridge.new-ko-trig-shift; }
DarkCheckbox { text: "Alt"; checked <=> AdvancedBridge.new-ko-trig-alt; }
}
HorizontalLayout {
spacing: 8px;
alignment: start;
Text { text: "To:"; color: Theme.fg-secondary; font-size: 11px; vertical-alignment: center; }
PickButton { label: AdvancedBridge.new-ko-result-name; target: "ko-result"; }
DarkCheckbox { text: "Ctrl"; checked <=> AdvancedBridge.new-ko-res-ctrl; }
DarkCheckbox { text: "Shift"; checked <=> AdvancedBridge.new-ko-res-shift; }
DarkCheckbox { text: "Alt"; checked <=> AdvancedBridge.new-ko-res-alt; }
}
DarkButton {
text: "Add Key Override";
clicked => { AdvancedBridge.create-ko(); }
}
}
}
}
}
// --- Autoshift ---
Rectangle {
background: Theme.bg-secondary;
border-radius: 8px;
VerticalLayout {
padding: 12px;
spacing: 6px;
SectionHeader { text: "Autoshift"; }
HorizontalLayout {
spacing: 8px;
Text { text: AdvancedBridge.autoshift-status != "" ? AdvancedBridge.autoshift-status : "Unknown"; color: Theme.fg-primary; font-size: 11px; vertical-alignment: center; }
DarkButton { text: "Toggle"; clicked => { AdvancedBridge.toggle-autoshift(); } }
}
}
}
}
// ==================== Right column ====================
VerticalLayout {
horizontal-stretch: 1;
spacing: 12px;
// --- Combos ---
Rectangle {
background: Theme.bg-secondary;
border-radius: 8px;
VerticalLayout {
padding: 12px;
spacing: 4px;
SectionHeader { text: "Combos"; }
if AdvancedBridge.combos.length == 0 : Text { text: "No combos"; color: Theme.fg-secondary; font-size: 11px; }
for combo in AdvancedBridge.combos : ItemRow {
prefix: "#" + combo.index;
left: combo.key1 + " + " + combo.key2;
right: combo.result;
delete => { AdvancedBridge.delete-combo(combo.index); }
}
// --- Add Combo ---
Rectangle {
background: Theme.bg-primary;
border-radius: 4px;
VerticalLayout {
padding: 10px;
spacing: 8px;
Text { text: "Add Combo"; color: Theme.fg-secondary; font-size: 12px; font-weight: 600; }
HorizontalLayout {
spacing: 8px;
alignment: start;
Text { text: "Key 1:"; color: Theme.fg-secondary; font-size: 11px; vertical-alignment: center; }
PickButton { label: AdvancedBridge.new-combo-key1-name; target: "combo-key1"; }
Text { text: "Key 2:"; color: Theme.fg-secondary; font-size: 11px; vertical-alignment: center; }
PickButton { label: AdvancedBridge.new-combo-key2-name; target: "combo-key2"; }
}
HorizontalLayout {
spacing: 8px;
alignment: start;
Text { text: "Result:"; color: Theme.fg-secondary; font-size: 11px; vertical-alignment: center; }
PickButton { label: AdvancedBridge.new-combo-result-name; target: "combo-result"; }
DarkButton {
text: "Add Combo";
clicked => { AdvancedBridge.create-combo(); }
}
}
}
}
}
}
// --- Leader Keys ---
Rectangle {
background: Theme.bg-secondary;
border-radius: 8px;
VerticalLayout {
padding: 12px;
spacing: 4px;
SectionHeader { text: "Leader Keys"; }
if AdvancedBridge.leaders.length == 0 : Text { text: "No leader keys"; color: Theme.fg-secondary; font-size: 11px; }
for leader in AdvancedBridge.leaders : ItemRow {
prefix: "#" + leader.index;
left: leader.sequence;
right: leader.result;
delete => { AdvancedBridge.delete-leader(leader.index); }
}
// --- Add Leader ---
Rectangle {
background: Theme.bg-primary;
border-radius: 4px;
VerticalLayout {
padding: 10px;
spacing: 8px;
Text { text: "Add Leader Key"; color: Theme.fg-secondary; font-size: 12px; font-weight: 600; }
// Sequence: show picked keys + Add button
HorizontalLayout {
spacing: 6px;
alignment: start;
Text { text: "Sequence:"; color: Theme.fg-secondary; font-size: 11px; vertical-alignment: center; }
if AdvancedBridge.new-leader-seq0-name != "" : Rectangle {
width: 60px; height: 26px; border-radius: 3px; background: Theme.button-bg;
Text { text: AdvancedBridge.new-leader-seq0-name; color: Theme.fg-primary; font-size: 10px; horizontal-alignment: center; vertical-alignment: center; }
}
if AdvancedBridge.new-leader-seq1-name != "" : Rectangle {
width: 60px; height: 26px; border-radius: 3px; background: Theme.button-bg;
Text { text: AdvancedBridge.new-leader-seq1-name; color: Theme.fg-primary; font-size: 10px; horizontal-alignment: center; vertical-alignment: center; }
}
if AdvancedBridge.new-leader-seq2-name != "" : Rectangle {
width: 60px; height: 26px; border-radius: 3px; background: Theme.button-bg;
Text { text: AdvancedBridge.new-leader-seq2-name; color: Theme.fg-primary; font-size: 10px; horizontal-alignment: center; vertical-alignment: center; }
}
if AdvancedBridge.new-leader-seq3-name != "" : Rectangle {
width: 60px; height: 26px; border-radius: 3px; background: Theme.button-bg;
Text { text: AdvancedBridge.new-leader-seq3-name; color: Theme.fg-primary; font-size: 10px; horizontal-alignment: center; vertical-alignment: center; }
}
if AdvancedBridge.new-leader-seq-count < 4 : PickButton {
width: 40px;
label: "+";
target: "leader-seq";
}
}
HorizontalLayout {
spacing: 8px;
alignment: start;
Text { text: "Result:"; color: Theme.fg-secondary; font-size: 11px; vertical-alignment: center; }
PickButton { label: AdvancedBridge.new-leader-result-name; target: "leader-result"; }
Text { text: "Mod:"; color: Theme.fg-secondary; font-size: 11px; vertical-alignment: center; }
ModComboBox { current-index <=> AdvancedBridge.new-leader-mod-idx; }
}
DarkButton {
text: "Add Leader Key";
clicked => { AdvancedBridge.create-leader(AdvancedBridge.new-leader-result-code, AdvancedBridge.new-leader-mod-idx); }
}
}
}
}
}
// --- Tri-Layer ---
Rectangle {
background: Theme.bg-secondary;
border-radius: 8px;
VerticalLayout {
padding: 12px;
spacing: 8px;
SectionHeader { text: "Tri-Layer"; }
HorizontalLayout {
spacing: 8px;
alignment: start;
Text { text: "L1:"; color: Theme.fg-secondary; font-size: 12px; vertical-alignment: center; }
DarkComboBox { width: 50px; model: ["0","1","2","3","4","5","6","7","8","9"]; current-index <=> AdvancedBridge.tri-l1-idx; }
Text { text: "L2:"; color: Theme.fg-secondary; font-size: 12px; vertical-alignment: center; }
DarkComboBox { width: 50px; model: ["0","1","2","3","4","5","6","7","8","9"]; current-index <=> AdvancedBridge.tri-l2-idx; }
Text { text: "-> L3:"; color: Theme.fg-secondary; font-size: 12px; vertical-alignment: center; }
DarkComboBox { width: 50px; model: ["0","1","2","3","4","5","6","7","8","9"]; current-index <=> AdvancedBridge.tri-l3-idx; }
DarkButton { text: "Set"; clicked => { AdvancedBridge.set-trilayer(AdvancedBridge.tri-l1-idx, AdvancedBridge.tri-l2-idx, AdvancedBridge.tri-l3-idx); } }
}
}
}
// --- BT ---
if AdvancedBridge.bt-status != "" : Rectangle {
background: Theme.bg-secondary;
border-radius: 8px;
VerticalLayout {
padding: 12px;
spacing: 4px;
SectionHeader { text: "Bluetooth"; }
Text { text: AdvancedBridge.bt-status; color: Theme.fg-primary; font-size: 11px; }
}
}
// --- TAMA ---
if AdvancedBridge.tama-status != "" : Rectangle {
background: Theme.bg-secondary;
border-radius: 8px;
VerticalLayout {
padding: 12px;
spacing: 6px;
SectionHeader { text: "Tamagotchi"; }
Text { text: AdvancedBridge.tama-status; color: Theme.fg-primary; font-size: 11px; wrap: word-wrap; }
HorizontalLayout {
spacing: 4px;
DarkButton { text: "Feed"; clicked => { AdvancedBridge.tama-action("feed"); } }
DarkButton { text: "Play"; clicked => { AdvancedBridge.tama-action("play"); } }
DarkButton { text: "Sleep"; clicked => { AdvancedBridge.tama-action("sleep"); } }
DarkButton { text: "Meds"; clicked => { AdvancedBridge.tama-action("meds"); } }
DarkButton { text: "On/Off"; clicked => { AdvancedBridge.tama-action("toggle"); } }
}
}
}
}
}
}
}
}

View file

@ -1,217 +0,0 @@
import { DarkLineEdit } from "../components/dark_line_edit.slint";
import { Theme } from "../theme.slint";
import { DarkButton } from "../components/dark_button.slint";
import { DarkComboBox } from "../components/dark_combo_box.slint";
import { FlasherBridge } from "../globals.slint";
export component TabFlasher inherits Rectangle {
background: Theme.bg-primary;
VerticalLayout {
padding: 20px;
spacing: 16px;
alignment: start;
Text {
text: "Firmware Flasher";
color: Theme.fg-primary;
font-size: 20px;
font-weight: 700;
}
Text {
text: "Flash firmware via ESP32 programming port (CH340/CP2102).";
color: Theme.fg-secondary;
font-size: 12px;
wrap: word-wrap;
}
// Port selection
Rectangle {
background: Theme.bg-secondary;
border-radius: 8px;
VerticalLayout {
padding: 16px;
spacing: 10px;
Text {
text: "Programming Port (CH340/CP210x only)";
color: Theme.accent-cyan;
font-size: 14px;
font-weight: 600;
}
HorizontalLayout {
spacing: 8px;
if FlasherBridge.prog-ports.length > 0 : DarkComboBox {
horizontal-stretch: 1;
model: FlasherBridge.prog-ports;
selected(value) => {
FlasherBridge.selected-prog-port = value;
}
}
if FlasherBridge.prog-ports.length == 0 : DarkLineEdit {
horizontal-stretch: 1;
text <=> FlasherBridge.selected-prog-port;
placeholder-text: "/dev/ttyUSB0";
}
DarkButton {
text: "Refresh";
clicked => { FlasherBridge.refresh-prog-ports(); }
}
}
if FlasherBridge.prog-ports.length == 0 : Text {
text: "No CH340/CP210x port detected. Enter path manually or plug in the programming cable.";
color: Theme.accent-yellow;
font-size: 11px;
wrap: word-wrap;
}
}
}
// Flash target partition
Rectangle {
background: Theme.bg-secondary;
border-radius: 8px;
VerticalLayout {
padding: 16px;
spacing: 10px;
Text {
text: "Target Partition";
color: Theme.accent-cyan;
font-size: 14px;
font-weight: 600;
}
DarkComboBox {
model: ["full (0x0)", "factory (0x20000)", "ota_0 (0x220000)"];
current-index <=> FlasherBridge.flash-offset-index;
}
}
}
// Firmware file
Rectangle {
background: Theme.bg-secondary;
border-radius: 8px;
VerticalLayout {
padding: 16px;
spacing: 10px;
Text {
text: "Firmware File";
color: Theme.accent-cyan;
font-size: 14px;
font-weight: 600;
}
HorizontalLayout {
spacing: 8px;
Text {
horizontal-stretch: 1;
text: FlasherBridge.firmware-path != "" ? FlasherBridge.firmware-path : "No file selected";
color: FlasherBridge.firmware-path != "" ? Theme.fg-primary : Theme.fg-secondary;
font-size: 12px;
vertical-alignment: center;
overflow: elide;
}
DarkButton {
text: "Browse...";
clicked => { FlasherBridge.browse-firmware(); }
}
}
}
}
// Flash button + progress
Rectangle {
background: Theme.bg-secondary;
border-radius: 8px;
VerticalLayout {
padding: 16px;
spacing: 10px;
HorizontalLayout {
spacing: 12px;
DarkButton {
text: FlasherBridge.flashing ? "Flashing..." : "Flash Firmware";
enabled: !FlasherBridge.flashing
&& FlasherBridge.firmware-path != ""
&& FlasherBridge.selected-prog-port != "";
clicked => { FlasherBridge.flash(); }
}
Text {
text: FlasherBridge.flash-status;
color: Theme.fg-primary;
font-size: 12px;
vertical-alignment: center;
horizontal-stretch: 1;
}
}
if FlasherBridge.flashing || FlasherBridge.flash-progress > 0 : Rectangle {
height: 20px;
background: Theme.bg-primary;
border-radius: 4px;
Rectangle {
x: 0;
width: parent.width * clamp(FlasherBridge.flash-progress, 0, 1);
height: 100%;
background: FlasherBridge.flash-progress >= 1.0 ? Theme.accent-green : Theme.accent-purple;
border-radius: 4px;
}
Text {
text: round(FlasherBridge.flash-progress * 100) + "%";
color: Theme.fg-primary;
font-size: 11px;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
}
// Warning
Rectangle {
background: #ff555520;
border-radius: 8px;
border-width: 1px;
border-color: Theme.accent-red;
VerticalLayout {
padding: 12px;
spacing: 4px;
Text {
text: "Warning";
color: Theme.accent-red;
font-size: 13px;
font-weight: 600;
}
Text {
text: "Do not disconnect the keyboard during flashing. The device will reboot automatically when done.";
color: Theme.fg-secondary;
font-size: 11px;
wrap: word-wrap;
}
}
}
Rectangle { vertical-stretch: 1; }
}
}

View file

@ -1,201 +0,0 @@
import { DarkLineEdit } from "../components/dark_line_edit.slint";
import { DarkComboBox } from "../components/dark_combo_box.slint";
import { Theme } from "../theme.slint";
import { DarkButton } from "../components/dark_button.slint";
import { KeymapBridge, AppState, ConnectionState, SettingsBridge } from "../globals.slint";
import { KeyboardView } from "../components/keyboard_view.slint";
export component TabKeymap inherits Rectangle {
in-out property <bool> renaming: false;
VerticalLayout {
padding: 8px;
spacing: 6px;
// Main area: layers sidebar + keyboard
HorizontalLayout {
vertical-stretch: 1;
spacing: 6px;
// Layer sidebar (vertical)
VerticalLayout {
width: 90px;
spacing: 4px;
alignment: start;
Text {
text: "Layers";
color: Theme.fg-secondary;
font-size: 11px;
horizontal-alignment: center;
}
Flickable {
vertical-stretch: 1;
VerticalLayout {
spacing: 4px;
for layer[idx] in KeymapBridge.layers : Rectangle {
height: 30px;
border-radius: 4px;
background: layer.active ? Theme.accent-purple : Theme.button-bg;
Text {
text: layer.name;
color: Theme.fg-primary;
font-size: 11px;
horizontal-alignment: center;
vertical-alignment: center;
}
TouchArea {
clicked => { KeymapBridge.switch-layer(layer.index); }
mouse-cursor: pointer;
}
}
}
}
if !root.renaming && AppState.connection == ConnectionState.connected : Rectangle {
height: 24px;
border-radius: 4px;
background: rename-ta.has-hover ? Theme.button-hover : Theme.button-bg;
Text {
text: "Rename";
color: Theme.fg-secondary;
font-size: 10px;
horizontal-alignment: center;
vertical-alignment: center;
}
rename-ta := TouchArea {
clicked => { root.renaming = true; }
mouse-cursor: pointer;
}
}
Rectangle { vertical-stretch: 1; }
// Heatmap toggle
Rectangle {
height: 30px;
border-radius: 4px;
background: KeymapBridge.heatmap-enabled ? Theme.accent-orange : Theme.button-bg;
Text {
text: "Heatmap";
color: Theme.fg-primary;
font-size: 11px;
horizontal-alignment: center;
vertical-alignment: center;
}
TouchArea {
clicked => {
KeymapBridge.heatmap-enabled = !KeymapBridge.heatmap-enabled;
if KeymapBridge.heatmap-enabled { KeymapBridge.toggle-heatmap(); }
}
mouse-cursor: pointer;
}
}
// Keyboard language
DarkComboBox {
width: 90px;
model: SettingsBridge.available-layouts;
current-index <=> SettingsBridge.selected-layout-index;
selected(value) => { SettingsBridge.change-layout(self.current-index); }
}
}
// Keyboard view
KeyboardView {
horizontal-stretch: 1;
}
}
// Selected key info bar
if KeymapBridge.selected-key-index >= 0 : Rectangle {
height: 36px;
background: Theme.bg-secondary;
border-radius: 4px;
HorizontalLayout {
padding: 8px;
spacing: 12px;
Text {
text: "Selected: " + KeymapBridge.selected-key-label;
color: Theme.accent-cyan;
font-size: 12px;
vertical-alignment: center;
}
Rectangle { horizontal-stretch: 1; }
DarkButton {
text: "Change Key...";
clicked => {
KeymapBridge.key-selector-open = true;
}
}
}
}
}
// Rename popup overlay
if root.renaming : Rectangle {
background: #000000aa;
TouchArea {
clicked => { root.renaming = false; }
}
Rectangle {
width: 260px;
height: 120px;
border-radius: 8px;
background: Theme.bg-secondary;
VerticalLayout {
padding: 16px;
spacing: 12px;
alignment: center;
Text {
text: "Rename layer";
color: Theme.fg-primary;
font-size: 13px;
horizontal-alignment: center;
}
rename-input := DarkLineEdit {
placeholder-text: "New name";
accepted(text) => {
KeymapBridge.rename-layer(KeymapBridge.active-layer, text);
root.renaming = false;
}
}
HorizontalLayout {
spacing: 8px;
alignment: center;
DarkButton {
text: "Cancel";
clicked => { root.renaming = false; }
}
DarkButton {
text: "Rename";
clicked => {
KeymapBridge.rename-layer(KeymapBridge.active-layer, rename-input.text);
root.renaming = false;
}
}
}
}
}
}
}

View file

@ -1,178 +0,0 @@
import { ScrollView } from "std-widgets.slint";
import { DarkLineEdit } from "../components/dark_line_edit.slint";
import { Theme } from "../theme.slint";
import { DarkButton } from "../components/dark_button.slint";
import { DarkComboBox } from "../components/dark_combo_box.slint";
import { MacroBridge, AppState, ConnectionState, KeymapBridge } from "../globals.slint";
export component TabMacros inherits Rectangle {
background: Theme.bg-primary;
VerticalLayout {
padding: 16px;
spacing: 12px;
// Header
HorizontalLayout {
spacing: 8px;
Text { text: "Macros"; color: Theme.fg-primary; font-size: 20px; font-weight: 700; vertical-alignment: center; }
Rectangle { horizontal-stretch: 1; }
DarkButton {
text: "Refresh";
enabled: AppState.connection == ConnectionState.connected;
clicked => { MacroBridge.refresh-macros(); }
}
}
// New macro builder
Rectangle {
background: Theme.bg-secondary;
border-radius: 8px;
VerticalLayout {
padding: 12px;
spacing: 10px;
Text { text: "Add / Edit Macro"; color: Theme.accent-cyan; font-size: 13px; font-weight: 600; }
HorizontalLayout {
spacing: 8px;
Text { text: "Slot:"; color: Theme.fg-secondary; font-size: 12px; vertical-alignment: center; }
Text { text: "#" + MacroBridge.new-slot-idx; color: Theme.accent-purple; font-size: 12px; font-weight: 600; vertical-alignment: center; }
Text { text: "Name:"; color: Theme.fg-secondary; font-size: 12px; vertical-alignment: center; }
DarkLineEdit {
horizontal-stretch: 1;
text <=> MacroBridge.new-name;
placeholder-text: "macro name";
}
}
// Steps display
Text { text: "Steps:"; color: Theme.fg-secondary; font-size: 12px; }
// Show added steps as tags
HorizontalLayout {
spacing: 4px;
height: 30px;
for step in MacroBridge.new-steps : Rectangle {
width: self.preferred-width + 12px;
preferred-width: step-text.preferred-width;
height: 26px;
border-radius: 4px;
background: step.is-delay ? Theme.accent-orange : Theme.accent-purple;
step-text := Text {
text: step.label;
color: Theme.fg-primary;
font-size: 10px;
horizontal-alignment: center;
vertical-alignment: center;
}
}
if MacroBridge.new-steps.length == 0 : Text {
text: "(empty - add keys and delays below)";
color: Theme.fg-secondary;
font-size: 11px;
vertical-alignment: center;
}
}
// Action buttons
HorizontalLayout {
spacing: 6px;
alignment: start;
DarkButton {
text: "Add Key";
clicked => {
KeymapBridge.selector-target = "macro-step";
KeymapBridge.key-selector-open = true;
}
}
DarkButton { text: "T 50ms"; clicked => { MacroBridge.add-delay-step(50); } }
DarkButton { text: "T 100ms"; clicked => { MacroBridge.add-delay-step(100); } }
DarkButton { text: "T 200ms"; clicked => { MacroBridge.add-delay-step(200); } }
Rectangle { horizontal-stretch: 1; }
DarkButton { text: "Undo"; clicked => { MacroBridge.remove-last-step(); } }
DarkButton { text: "Clear"; clicked => { MacroBridge.clear-steps(); } }
}
// Common shortcuts
HorizontalLayout {
spacing: 6px;
alignment: start;
Text { text: "Shortcuts:"; color: Theme.fg-secondary; font-size: 11px; vertical-alignment: center; }
DarkButton { text: "Ctrl+C"; clicked => { MacroBridge.add-shortcut("ctrl+c"); } }
DarkButton { text: "Ctrl+V"; clicked => { MacroBridge.add-shortcut("ctrl+v"); } }
DarkButton { text: "Ctrl+X"; clicked => { MacroBridge.add-shortcut("ctrl+x"); } }
DarkButton { text: "Ctrl+Z"; clicked => { MacroBridge.add-shortcut("ctrl+z"); } }
DarkButton { text: "Ctrl+Y"; clicked => { MacroBridge.add-shortcut("ctrl+y"); } }
DarkButton { text: "Ctrl+S"; clicked => { MacroBridge.add-shortcut("ctrl+s"); } }
DarkButton { text: "Ctrl+A"; clicked => { MacroBridge.add-shortcut("ctrl+a"); } }
DarkButton { text: "Alt+F4"; clicked => { MacroBridge.add-shortcut("alt+f4"); } }
}
// Save
HorizontalLayout {
spacing: 8px;
alignment: start;
DarkButton {
text: "Save Macro";
enabled: AppState.connection == ConnectionState.connected;
clicked => { MacroBridge.save-macro(); }
}
if MacroBridge.new-steps-text != "" : Text {
text: MacroBridge.new-steps-text;
color: Theme.fg-secondary;
font-size: 10px;
vertical-alignment: center;
overflow: elide;
}
}
}
}
// Macro list
ScrollView {
vertical-stretch: 1;
VerticalLayout {
spacing: 4px;
if MacroBridge.macros.length == 0 : Text {
text: "No macros configured";
color: Theme.fg-secondary;
font-size: 11px;
}
for macro in MacroBridge.macros : Rectangle {
background: Theme.bg-secondary;
border-radius: 4px;
height: 36px;
HorizontalLayout {
padding-left: 8px;
padding-right: 8px;
spacing: 8px;
Text { text: "#" + macro.slot; color: Theme.accent-purple; font-size: 12px; vertical-alignment: center; width: 30px; }
Text { text: macro.name; color: Theme.fg-primary; font-size: 12px; font-weight: 600; vertical-alignment: center; width: 100px; }
Text { text: macro.steps; color: Theme.fg-secondary; font-size: 10px; vertical-alignment: center; horizontal-stretch: 1; overflow: elide; }
DarkButton { text: "Del"; clicked => { MacroBridge.delete-macro(macro.slot); } }
}
}
}
}
}
}

View file

@ -1,176 +0,0 @@
import { Theme } from "../theme.slint";
import { DarkButton } from "../components/dark_button.slint";
import { SettingsBridge, AppState, ConnectionState } from "../globals.slint";
export component TabSettings inherits Rectangle {
background: Theme.bg-primary;
VerticalLayout {
padding: 20px;
spacing: 16px;
alignment: start;
Text {
text: "Settings";
color: Theme.fg-primary;
font-size: 20px;
font-weight: 700;
}
// OTA Firmware Update
Rectangle {
background: Theme.bg-secondary;
border-radius: 8px;
VerticalLayout {
padding: 16px;
spacing: 10px;
Text { text: "OTA Firmware Update"; color: Theme.accent-cyan; font-size: 14px; font-weight: 600; }
Text { text: "Update firmware via USB (no programming cable needed)"; color: Theme.fg-secondary; font-size: 11px; }
HorizontalLayout {
spacing: 8px;
Text {
horizontal-stretch: 1;
text: SettingsBridge.ota-path != "" ? SettingsBridge.ota-path : "No firmware file selected";
color: SettingsBridge.ota-path != "" ? Theme.fg-primary : Theme.fg-secondary;
font-size: 12px;
vertical-alignment: center;
overflow: elide;
}
DarkButton {
text: "Browse...";
clicked => { SettingsBridge.ota-browse(); }
}
}
HorizontalLayout {
spacing: 12px;
DarkButton {
text: SettingsBridge.ota-flashing ? "Flashing..." : "Flash OTA";
primary: true;
enabled: !SettingsBridge.ota-flashing
&& SettingsBridge.ota-path != ""
&& AppState.connection == ConnectionState.connected;
clicked => { SettingsBridge.ota-start(); }
}
Text {
text: SettingsBridge.ota-status;
color: Theme.fg-primary;
font-size: 12px;
vertical-alignment: center;
horizontal-stretch: 1;
}
}
if SettingsBridge.ota-flashing || SettingsBridge.ota-progress > 0 : Rectangle {
height: 20px;
background: Theme.bg-primary;
border-radius: 4px;
Rectangle {
x: 0;
width: parent.width * clamp(SettingsBridge.ota-progress, 0, 1);
height: 100%;
background: SettingsBridge.ota-progress >= 1.0 ? Theme.accent-green : Theme.accent-purple;
border-radius: 4px;
}
Text {
text: round(SettingsBridge.ota-progress * 100) + "%";
color: Theme.fg-primary;
font-size: 11px;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
}
// Config backup / restore
Rectangle {
background: Theme.bg-secondary;
border-radius: 8px;
VerticalLayout {
padding: 16px;
spacing: 10px;
Text { text: "Configuration Backup"; color: Theme.accent-cyan; font-size: 14px; font-weight: 600; }
Text { text: "Export or import your full keyboard configuration (keymaps, macros, combos, etc.)"; color: Theme.fg-secondary; font-size: 11px; }
HorizontalLayout {
spacing: 12px;
DarkButton {
text: SettingsBridge.config-busy ? "Working..." : "Export Config";
primary: true;
enabled: !SettingsBridge.config-busy
&& AppState.connection == ConnectionState.connected;
clicked => { SettingsBridge.config-export(); }
}
DarkButton {
text: SettingsBridge.config-busy ? "Working..." : "Import Config";
enabled: !SettingsBridge.config-busy
&& AppState.connection == ConnectionState.connected;
clicked => { SettingsBridge.config-import(); }
}
Text {
text: SettingsBridge.config-status;
color: Theme.fg-primary;
font-size: 12px;
vertical-alignment: center;
horizontal-stretch: 1;
}
}
if SettingsBridge.config-busy : Rectangle {
height: 20px;
background: Theme.bg-primary;
border-radius: 4px;
Rectangle {
x: 0;
width: parent.width * clamp(SettingsBridge.config-progress, 0, 1);
height: 100%;
background: SettingsBridge.config-progress >= 1.0 ? Theme.accent-green : Theme.accent-purple;
border-radius: 4px;
}
Text {
text: round(SettingsBridge.config-progress * 100) + "%";
color: Theme.fg-primary;
font-size: 11px;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
}
// About
Rectangle {
background: Theme.bg-secondary;
border-radius: 8px;
VerticalLayout {
padding: 16px;
spacing: 8px;
Text { text: "About"; color: Theme.fg-primary; font-size: 14px; font-weight: 600; }
Text { text: "KeSp Controller v1.0.0"; color: Theme.fg-secondary; font-size: 12px; }
Text { text: "Split keyboard configurator"; color: Theme.fg-secondary; font-size: 12px; }
Text { text: "Made with Slint"; color: Theme.accent-purple; font-size: 11px; }
}
}
Rectangle { vertical-stretch: 1; }
}
}

View file

@ -1,333 +0,0 @@
import { ScrollView } from "std-widgets.slint";
import { Theme } from "../theme.slint";
import { DarkButton } from "../components/dark_button.slint";
import { StatsBridge, AppState, ConnectionState } from "../globals.slint";
component BarChart inherits Rectangle {
in property <float> value; // 0-100
in property <string> label;
in property <color> bar-color: Theme.accent-purple;
height: 28px;
HorizontalLayout {
spacing: 8px;
Text {
width: 80px;
text: root.label;
color: Theme.fg-secondary;
font-size: 11px;
vertical-alignment: center;
horizontal-alignment: right;
}
Rectangle {
horizontal-stretch: 1;
background: Theme.bg-primary;
border-radius: 3px;
Rectangle {
x: 0;
width: parent.width * clamp(root.value / 100, 0, 1);
height: 100%;
background: root.bar-color;
border-radius: 3px;
}
}
Text {
width: 50px;
text: round(root.value * 10) / 10 + "%";
color: Theme.fg-primary;
font-size: 11px;
vertical-alignment: center;
}
}
}
export component TabStats inherits Rectangle {
background: Theme.bg-primary;
ScrollView {
VerticalLayout {
padding: 16px;
spacing: 12px;
// Header
HorizontalLayout {
spacing: 8px;
Text {
text: "Typing Statistics";
color: Theme.fg-primary;
font-size: 20px;
font-weight: 700;
vertical-alignment: center;
}
Rectangle { horizontal-stretch: 1; }
DarkButton {
text: "Refresh";
enabled: AppState.connection == ConnectionState.connected;
clicked => { StatsBridge.refresh-stats(); }
}
}
// Content in two columns
HorizontalLayout {
spacing: 12px;
vertical-stretch: 1;
// Left column
VerticalLayout {
horizontal-stretch: 1;
spacing: 12px;
// Hand balance
Rectangle {
background: Theme.bg-secondary;
border-radius: 8px;
vertical-stretch: 0;
VerticalLayout {
padding: 12px;
spacing: 6px;
Text {
text: "Hand Balance";
color: Theme.accent-cyan;
font-size: 13px;
font-weight: 600;
}
HorizontalLayout {
spacing: 4px;
// Left bar
Rectangle {
horizontal-stretch: StatsBridge.hand-balance.left-pct > 0 ? round(StatsBridge.hand-balance.left-pct) : 1;
height: 24px;
background: Theme.accent-purple;
border-radius: 3px;
Text {
text: "L " + round(StatsBridge.hand-balance.left-pct) + "%";
color: Theme.fg-primary;
font-size: 11px;
horizontal-alignment: center;
vertical-alignment: center;
}
}
// Right bar
Rectangle {
horizontal-stretch: StatsBridge.hand-balance.right-pct > 0 ? round(StatsBridge.hand-balance.right-pct) : 1;
height: 24px;
background: Theme.accent-green;
border-radius: 3px;
Text {
text: "R " + round(StatsBridge.hand-balance.right-pct) + "%";
color: Theme.bg-primary;
font-size: 11px;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
Text {
text: "Total: " + StatsBridge.hand-balance.total + " presses";
color: Theme.fg-secondary;
font-size: 11px;
}
}
}
// Bigrams
if StatsBridge.bigrams.total > 0 : Rectangle {
background: Theme.bg-secondary;
border-radius: 8px;
vertical-stretch: 0;
VerticalLayout {
padding: 12px;
spacing: 6px;
Text {
text: "Bigram Analysis";
color: Theme.accent-cyan;
font-size: 13px;
font-weight: 600;
}
BarChart {
label: "Alt Hand";
value: StatsBridge.bigrams.alt-hand-pct;
bar-color: Theme.accent-green;
}
BarChart {
label: "Same Hand";
value: StatsBridge.bigrams.same-hand-pct;
bar-color: Theme.accent-orange;
}
BarChart {
label: "SFB";
value: StatsBridge.bigrams.sfb-pct;
bar-color: Theme.accent-red;
}
}
}
// Finger load
Rectangle {
background: Theme.bg-secondary;
border-radius: 8px;
vertical-stretch: 1;
VerticalLayout {
padding: 12px;
spacing: 4px;
Text {
text: "Finger Load";
color: Theme.accent-cyan;
font-size: 13px;
font-weight: 600;
}
for finger in StatsBridge.finger-load : BarChart {
label: finger.name;
value: finger.pct;
bar-color: Theme.accent-orange;
}
}
}
}
// Right column
VerticalLayout {
horizontal-stretch: 1;
spacing: 12px;
// Row usage
Rectangle {
background: Theme.bg-secondary;
border-radius: 8px;
vertical-stretch: 0;
VerticalLayout {
padding: 12px;
spacing: 4px;
Text {
text: "Row Usage";
color: Theme.accent-cyan;
font-size: 13px;
font-weight: 600;
}
for row in StatsBridge.row-usage : BarChart {
label: row.name;
value: row.pct;
bar-color: Theme.accent-green;
}
}
}
// Top keys
Rectangle {
background: Theme.bg-secondary;
border-radius: 8px;
vertical-stretch: 1;
VerticalLayout {
padding: 12px;
spacing: 4px;
Text {
text: "Top Keys";
color: Theme.accent-cyan;
font-size: 13px;
font-weight: 600;
}
for key in StatsBridge.top-keys : HorizontalLayout {
height: 22px;
spacing: 8px;
Text {
width: 80px;
text: key.name;
color: Theme.fg-primary;
font-size: 11px;
vertical-alignment: center;
}
Text {
width: 60px;
text: key.finger;
color: Theme.fg-secondary;
font-size: 10px;
vertical-alignment: center;
}
Rectangle {
horizontal-stretch: 1;
background: Theme.bg-primary;
border-radius: 3px;
Rectangle {
x: 0;
width: parent.width * clamp(key.pct / 20, 0, 1);
height: 100%;
background: Theme.accent-yellow;
border-radius: 3px;
}
}
Text {
width: 50px;
text: key.count;
color: Theme.fg-primary;
font-size: 11px;
vertical-alignment: center;
horizontal-alignment: right;
}
}
}
}
// Dead keys
if StatsBridge.dead-keys.length > 0 : Rectangle {
background: Theme.bg-secondary;
border-radius: 8px;
vertical-stretch: 0;
VerticalLayout {
padding: 12px;
spacing: 4px;
Text {
text: "Dead Keys (never pressed)";
color: Theme.accent-red;
font-size: 13px;
font-weight: 600;
}
HorizontalLayout {
spacing: 4px;
for dk in StatsBridge.dead-keys : Rectangle {
width: self.preferred-width + 12px;
preferred-width: dk-text.preferred-width;
height: 22px;
background: Theme.bg-primary;
border-radius: 3px;
dk-text := Text {
text: dk;
color: Theme.accent-red;
font-size: 10px;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
}
}
}
}
}
}
}

View file

@ -1,399 +0,0 @@
import { DarkLineEdit } from "../components/dark_line_edit.slint";
import { Theme } from "../theme.slint";
import { DarkButton } from "../components/dark_button.slint";
import { DarkCheckbox } from "../components/dark_checkbox.slint";
import { DarkComboBox } from "../components/dark_combo_box.slint";
import { FlasherBridge, LayoutBridge, ToolsBridge, KeycapData, AppState, ConnectionState } from "../globals.slint";
import { ScrollView } from "std-widgets.slint";
export component TabTools inherits Rectangle {
background: Theme.bg-primary;
ScrollView {
VerticalLayout {
padding: 20px;
spacing: 16px;
Text {
text: "Tools";
color: Theme.fg-primary;
font-size: 20px;
font-weight: 700;
}
// ===================== LAYOUT PREVIEW =====================
Rectangle {
background: Theme.bg-secondary;
border-radius: 8px;
VerticalLayout {
padding: 16px;
spacing: 10px;
HorizontalLayout {
spacing: 12px;
Text {
text: "Layout Preview";
color: Theme.accent-cyan;
font-size: 14px;
font-weight: 600;
vertical-alignment: center;
}
Rectangle { horizontal-stretch: 1; }
DarkButton {
text: "From keyboard";
primary: true;
enabled: AppState.connection == ConnectionState.connected;
clicked => { LayoutBridge.load-from-keyboard(); }
}
DarkButton {
text: "Load file...";
clicked => { LayoutBridge.load-from-file(); }
}
DarkButton {
text: "Export...";
enabled: LayoutBridge.json-text != "";
clicked => { LayoutBridge.export-json(); }
}
}
if LayoutBridge.file-path != "" : HorizontalLayout {
spacing: 12px;
DarkCheckbox {
text: "Auto-refresh (5s)";
checked <=> LayoutBridge.auto-refresh;
}
Text {
text: LayoutBridge.file-path;
color: Theme.fg-secondary;
font-size: 10px;
vertical-alignment: center;
overflow: elide;
horizontal-stretch: 1;
}
}
if LayoutBridge.status != "" : Text {
text: LayoutBridge.status;
color: Theme.fg-secondary;
font-size: 11px;
}
// Keyboard render
preview-area := Rectangle {
height: 300px;
background: Theme.bg-surface;
border-radius: 8px;
clip: true;
property <float> scale-x: self.width / LayoutBridge.content-width;
property <float> scale-y: self.height / LayoutBridge.content-height;
property <float> scale: min(scale-x, scale-y) * 0.95;
property <length> offset-x: (self.width - LayoutBridge.content-width * scale) / 2;
property <length> offset-y: (self.height - LayoutBridge.content-height * scale) / 2;
if LayoutBridge.keycaps.length == 0 : Text {
text: "No layout loaded";
color: Theme.fg-secondary;
font-size: 13px;
horizontal-alignment: center;
vertical-alignment: center;
}
for keycap[idx] in LayoutBridge.keycaps : Rectangle {
x: preview-area.offset-x + keycap.x * preview-area.scale;
y: preview-area.offset-y + keycap.y * preview-area.scale;
width: keycap.w * preview-area.scale;
height: keycap.h * preview-area.scale;
background: transparent;
Rectangle {
width: 100%;
height: 100%;
border-radius: 4px;
background: #44475a;
border-width: 1px;
border-color: Theme.bg-primary;
transform-rotation: keycap.rotation * 1deg;
transform-origin: {
x: self.width / 2,
y: self.height / 2,
};
Text {
text: keycap.label;
color: Theme.fg-primary;
font-size: max(7px, 11px * preview-area.scale);
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
}
// JSON preview
Rectangle {
height: 100px;
background: Theme.bg-primary;
border-radius: 4px;
clip: true;
ScrollView {
Text {
text: LayoutBridge.json-text != "" ? LayoutBridge.json-text : "// JSON will appear here";
color: LayoutBridge.json-text != "" ? Theme.fg-primary : Theme.fg-secondary;
font-size: 10px;
font-family: "monospace";
wrap: word-wrap;
}
}
}
}
}
// ===================== MATRIX TEST =====================
Rectangle {
background: Theme.bg-secondary;
border-radius: 8px;
VerticalLayout {
padding: 16px;
spacing: 10px;
HorizontalLayout {
spacing: 12px;
Text {
text: "Matrix Test";
color: Theme.accent-cyan;
font-size: 14px;
font-weight: 600;
vertical-alignment: center;
}
Rectangle { horizontal-stretch: 1; }
DarkButton {
text: ToolsBridge.matrix-test-active ? "Stop Test" : "Start Test";
primary: !ToolsBridge.matrix-test-active;
enabled: AppState.connection == ConnectionState.connected;
clicked => { ToolsBridge.toggle-matrix-test(); }
}
}
if ToolsBridge.matrix-test-status != "" : Text {
text: ToolsBridge.matrix-test-status;
color: ToolsBridge.matrix-test-active ? Theme.accent-green : Theme.fg-secondary;
font-size: 11px;
}
// Keyboard render for matrix test
matrix-area := Rectangle {
height: 260px;
background: Theme.bg-surface;
border-radius: 8px;
clip: true;
property <float> scale-x: self.width / ToolsBridge.matrix-content-width;
property <float> scale-y: self.height / ToolsBridge.matrix-content-height;
property <float> scale: min(scale-x, scale-y) * 0.95;
property <length> offset-x: (self.width - ToolsBridge.matrix-content-width * scale) / 2;
property <length> offset-y: (self.height - ToolsBridge.matrix-content-height * scale) / 2;
if ToolsBridge.matrix-keycaps.length == 0 : Text {
text: "Press \"Start Test\" to begin";
color: Theme.fg-secondary;
font-size: 13px;
horizontal-alignment: center;
vertical-alignment: center;
}
for keycap[idx] in ToolsBridge.matrix-keycaps : Rectangle {
x: matrix-area.offset-x + keycap.x * matrix-area.scale;
y: matrix-area.offset-y + keycap.y * matrix-area.scale;
width: keycap.w * matrix-area.scale;
height: keycap.h * matrix-area.scale;
background: transparent;
Rectangle {
width: 100%;
height: 100%;
border-radius: 4px;
background: keycap.color;
border-width: 1px;
border-color: Theme.bg-primary;
transform-rotation: keycap.rotation * 1deg;
transform-origin: {
x: self.width / 2,
y: self.height / 2,
};
Text {
text: keycap.label;
color: Theme.fg-primary;
font-size: max(7px, 10px * matrix-area.scale);
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
}
}
}
// ===================== ESP32 FLASHER =====================
Rectangle {
background: Theme.bg-secondary;
border-radius: 8px;
VerticalLayout {
padding: 16px;
spacing: 10px;
Text {
text: "ESP32 Firmware Flasher";
color: Theme.accent-cyan;
font-size: 14px;
font-weight: 600;
}
Text {
text: "Flash via programming port (CH340/CP2102)";
color: Theme.fg-secondary;
font-size: 11px;
}
// Port
HorizontalLayout {
spacing: 8px;
if FlasherBridge.prog-ports.length > 0 : DarkComboBox {
horizontal-stretch: 1;
model: FlasherBridge.prog-ports;
selected(value) => { FlasherBridge.selected-prog-port = value; }
}
if FlasherBridge.prog-ports.length == 0 : DarkLineEdit {
horizontal-stretch: 1;
text <=> FlasherBridge.selected-prog-port;
placeholder-text: "/dev/ttyUSB0";
}
DarkButton {
text: "Refresh";
clicked => { FlasherBridge.refresh-prog-ports(); }
}
}
if FlasherBridge.prog-ports.length == 0 : Text {
text: "No CH340/CP210x port detected. Enter path manually.";
color: Theme.accent-yellow;
font-size: 11px;
wrap: word-wrap;
}
// Partition + firmware
HorizontalLayout {
spacing: 12px;
DarkComboBox {
width: 200px;
model: ["full (0x0)", "factory (0x20000)", "ota\\_0 (0x220000)"];
current-index <=> FlasherBridge.flash-offset-index;
}
Text {
horizontal-stretch: 1;
text: FlasherBridge.firmware-path != "" ? FlasherBridge.firmware-path : "No file selected";
color: FlasherBridge.firmware-path != "" ? Theme.fg-primary : Theme.fg-secondary;
font-size: 12px;
vertical-alignment: center;
overflow: elide;
}
DarkButton {
text: "Browse...";
clicked => { FlasherBridge.browse-firmware(); }
}
}
// Flash button + progress
HorizontalLayout {
spacing: 12px;
DarkButton {
text: FlasherBridge.flashing ? "Flashing..." : "Flash";
primary: true;
enabled: !FlasherBridge.flashing
&& FlasherBridge.firmware-path != ""
&& FlasherBridge.selected-prog-port != "";
clicked => { FlasherBridge.flash(); }
}
Text {
text: FlasherBridge.flash-status;
color: Theme.fg-primary;
font-size: 12px;
vertical-alignment: center;
horizontal-stretch: 1;
}
}
if FlasherBridge.flashing || FlasherBridge.flash-progress > 0 : Rectangle {
height: 20px;
background: Theme.bg-primary;
border-radius: 4px;
Rectangle {
x: 0;
width: parent.width * clamp(FlasherBridge.flash-progress, 0, 1);
height: 100%;
background: FlasherBridge.flash-progress >= 1.0 ? Theme.accent-green : Theme.accent-purple;
border-radius: 4px;
}
Text {
text: round(FlasherBridge.flash-progress * 100) + "%";
color: Theme.fg-primary;
font-size: 11px;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
}
// Warning
Rectangle {
background: #ff555520;
border-radius: 8px;
border-width: 1px;
border-color: Theme.accent-red;
VerticalLayout {
padding: 12px;
spacing: 4px;
Text {
text: "Warning";
color: Theme.accent-red;
font-size: 13px;
font-weight: 600;
}
Text {
text: "Do not disconnect the keyboard during flashing. The device will reboot automatically when done.";
color: Theme.fg-secondary;
font-size: 11px;
wrap: word-wrap;
}
}
}
}
}
}

View file

@ -1,24 +0,0 @@
// Dracula theme colors (matching the egui version)
export global Theme {
// Backgrounds
out property <color> bg-primary: #282a36;
out property <color> bg-secondary: #44475a;
out property <color> bg-surface: #1e1e2e;
// Text
out property <color> fg-primary: #f8f8f2;
out property <color> fg-secondary: #6272a4;
// Accents
out property <color> accent-purple: #bd93f9;
out property <color> accent-green: #50fa7b;
out property <color> accent-cyan: #8be9fd;
out property <color> accent-red: #ff5555;
out property <color> accent-yellow: #f1fa8c;
out property <color> accent-orange: #ffb86c;
out property <color> accent-pink: #ff79c6;
// UI elements
out property <color> connected: #50fa7b;
out property <color> disconnected: #ff5555;
out property <color> button-bg: #565970;
out property <color> button-hover: #6272a4;
out property <color> button-border: #6272a4;
}