Compare commits

...

41 Commits

Author SHA1 Message Date
f855fa5606 Fix i18n of key unlock finger hint 2026-04-12 02:57:59 +00:00
232f93e054 Remove trigram anamoly calculation
Decided that this wasn't worth it and bigram anamolies are enough.
2026-04-11 00:11:45 +00:00
d1fde5c0c1 Use better tab indicator characters
Works in more fonts/terminals.
2026-04-10 20:16:12 +00:00
7e13f73b8c Improve repo diversity in some code syntax languages 2026-03-31 05:18:42 +00:00
eeb48157c5 Increase code syntax diversity, use github permalinks 2026-03-31 04:55:55 +00:00
79021db57f Fix random cycling of ALL code lang/book snippets/passages 2026-03-21 19:44:15 +00:00
01fc609f8f Fix terminal-default cursor color
Some terminal colors made it impossible to see the character underneath
the cursor.
2026-03-21 19:16:01 +00:00
84f4aabdff Add full set of locale translations
Generated using Claude Code / Codex so there may be errors
2026-03-17 05:05:32 +00:00
6d5de33f55 Internationalize UI text w/ german as first second lang
Adds rust-i18n and refactors all of the text copy in the app to use the
translation function so that the UI language can be dynamically updated
in the settings.
2026-03-17 04:29:25 +00:00
895e04d6ce Multilingual dictionaries and keyboard layouts 2026-03-06 04:49:51 +00:00
f20fa6110d Licence compliance stuff 2026-03-01 05:13:29 +00:00
5c56a9c3c6 More balanced adaptive drill generation, Tab fixes, mouse control tweaks 2026-02-28 21:11:11 +00:00
8b8703b9b9 Mouse input improvements 2026-02-28 17:56:09 +00:00
7c1aad84af Mouse support & branch milestone popups 2026-02-28 07:25:40 +00:00
8e4f9bf064 Skill Tree page UI tweaks and improvements 2026-02-28 06:03:20 +00:00
b37dc72b45 Various UI fixes, better capital letter injection, paginated history 2026-02-28 05:07:33 +00:00
c67ddf577a Improve synthetic data in test profiles 2026-02-28 03:41:28 +00:00
de236284ea Enhanced paith input with cursor navigation and tab completion in settings import/export 2026-02-28 03:19:38 +00:00
ca2a3507f4 Create test user profiles to test skill progression 2026-02-28 02:02:39 +00:00
da907c0f46 Prevent tests from writing to user data 2026-02-27 05:39:33 +00:00
a088075924 Adaptive auto-continue input lock overlay 2026-02-27 02:31:37 +00:00
3ef433404e Increase adaptive drill word diversity 2026-02-26 21:33:16 +00:00
54ddebf054 N-gram metrics overhaul & UI improvements 2026-02-26 01:26:25 -05:00
e7f57dd497 N-gram error tracking for adaptive drill selection 2026-02-24 14:55:51 -05:00
0c5a70d5c4 Fix some theme colors & drill summary delete continue 2026-02-22 21:23:13 -05:00
f8bcad247b Fix kitty protocol, caps lock, code source desc 2026-02-22 16:51:34 -05:00
9d59c265dd Tweak value display in statistics keyboard visualizer 2026-02-22 15:28:05 -05:00
9deffc3d1d Import/export feature for config and data 2026-02-22 07:36:34 +00:00
9cc8a214ad Split up and clean up Keyboard Explorer detail stats 2026-02-20 23:44:31 +00:00
9e0411e1f4 Key milestone overlays + keyboard diagram improvements
Also splits out a separate store for ranked stats from overall key
stats.
2026-02-20 23:15:13 +00:00
4e39e99732 Some tweaks to pop-up UIs 2026-02-18 05:26:52 +00:00
d0605f8426 Code drill feature parity, downloading snippets from github
Phase 1 and 2. Phase 3 will allow custom github repo input.
2026-02-18 05:16:04 +00:00
2d63cffb33 Passage drill improvements, stats page cleanup 2026-02-18 00:14:37 +00:00
a61ed77ed6 Skill tree integration + tons of random fixes 2026-02-17 04:00:58 +00:00
edd2f7e6b5 Add more themes and rustfmt 2026-02-16 22:12:29 +00:00
6d6815af02 Skill tree progression system & whitespace support 2026-02-15 07:30:34 +00:00
13550505c1 Consistently refer to drills as drills now, rename from lesson/practice
Also fix some issues in the stats screen.
2026-02-15 04:44:49 +00:00
a51adafeb0 Continue into another drill after completing one 2026-02-15 00:41:59 +00:00
a0e8f3cafb Implement six major improvements to typing tutor
1. Start in Adaptive Drill by default: App launches directly into a
   typing lesson instead of the menu screen.

2. Fix error tracking for backspaced corrections: Add typo_flags
   HashSet to LessonState that persists error positions through
   backspace. Errors at a position are counted even if corrected,
   matching keybr.com behavior. Multiple errors at the same position
   count as one.

3. Fix keyboard visualization with depressed keys: Enable crossterm
   keyboard enhancement flags for key Release events. Track depressed
   keys in a HashSet with 150ms fallback clearing. Depressed keys
   render with bright/bold styling at highest priority. Add compact
   keyboard mode for medium-width terminals.

4. Responsive UI for small terminals: Add LayoutTier enum (Wide >=100,
   Medium 60-99, Narrow <60 cols). Medium hides sidebar and shows
   compact stats header and compact keyboard. Narrow hides keyboard
   and progress bar entirely. Short terminals (<20 rows) also hide
   keyboard/progress.

5. Delete sessions from history: Add j/k row navigation in history
   tab, x/Delete to initiate deletion with y/n confirmation dialog.
   Full chronological replay rebuilds key_stats, letter_unlock,
   profile scoring, and streak tracking. Only adaptive sessions update
   key_stats/letter_unlock during rebuild. LessonResult now persists
   lesson_mode for correct replay gating.

6. Improved statistics display: Bordered summary table on dashboard,
   WPM bar graph using block characters (green above goal, red below),
   accuracy Braille trend chart, bordered history table with WPM goal
   indicators and selected-row highlighting, character speed
   distribution with time labels, keyboard accuracy heatmap with
   percentage text per key, worst accuracy keys panel, new 7-month
   activity calendar heatmap widget with theme-derived intensity
   colors, side-by-side panel layout for terminals >170 cols wide.

Also: ignore KeyEventKind::Repeat for typing input, clamp history
selection to visible 20-row range, and suppress dead_code warnings
on now-unused WpmChart.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 00:21:55 +00:00
c78a8a90a3 First improvement pass 2026-02-10 23:32:57 -05:00
f65e3d8413 First one-shot pass 2026-02-10 14:29:23 -05:00
164 changed files with 254364 additions and 2 deletions

2
.gitignore vendored
View File

@@ -1 +1,3 @@
/target
/clones/
/test-profiles/

3577
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,5 +2,40 @@
name = "keydr"
version = "0.1.0"
edition = "2024"
description = "Terminal typing tutor with adaptive learning"
license = "AGPL-3.0-only"
[dependencies]
ratatui = { version = "0.30", features = ["crossterm_0_28"] }
crossterm = "0.28"
clap = { version = "4.5", features = ["derive"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.8"
rand = { version = "0.8", features = ["small_rng"] }
dirs = "6.0"
rust-embed = "8.5"
chrono = { version = "0.4", features = ["serde"] }
anyhow = "1.0"
thiserror = "2.0"
reqwest = { version = "0.12", features = ["blocking"], optional = true }
icu_normalizer = { version = "2.1", default-features = false, features = ["compiled_data"] }
rust-i18n = "3"
[dev-dependencies]
tempfile = "3"
serde_yaml = "0.9"
regex = "1"
criterion = { version = "0.5", features = ["html_reports"] }
[[bench]]
name = "ngram_benchmarks"
harness = false
[[bin]]
name = "generate_test_profiles"
path = "src/bin/generate_test_profiles.rs"
[features]
default = ["network"]
network = ["reqwest"]

661
LICENSE Normal file
View File

@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 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.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are 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.
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.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
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 Affero 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. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
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 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 work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero 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 Affero 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 Affero 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 Affero 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.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero 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 Affero General Public License for more details.
You should have received a copy of the GNU Affero 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 your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
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 AGPL, see
<https://www.gnu.org/licenses/>.

9
NOTICE Normal file
View File

@@ -0,0 +1,9 @@
keydr
Copyright (C) 2026 keydr contributors
This program is licensed under the GNU Affero General Public License,
version 3 only (AGPL-3.0-only). See the LICENSE file for details.
This repository includes third-party content from keybr.com.
See THIRD_PARTY_NOTICES.md for attribution and provenance.
See docs/license-compliance.md for compliance process notes.

68
THIRD_PARTY_NOTICES.md Normal file
View File

@@ -0,0 +1,68 @@
# Third-Party Notices
## Included third-party material in this repository
### keybr.com
- Upstream project: <https://github.com/aradzie/keybr.com>
- Upstream license: GNU Affero General Public License v3.0
- Local upstream license copy (for local research clone): `clones/keybr.com/LICENSE`
1. `assets/dictionaries/words-*.json` (seeded Latin-script set)
- Sources: `clones/keybr.com/packages/keybr-content-words/lib/data/words-<lang>.json`
- Included language keys: `en, de, es, fr, it, pt, nl, sv, da, nb, fi, pl, cs, ro, hr, hu, lt, lv, sl, et, tr`
- Status: included in this repository and available to `src/generator/dictionary.rs`
- Modifications: none (byte-identical at the time of import)
- Integrity metadata:
- `assets/dictionaries/manifest.tsv` (language/file/source mapping)
- `assets/dictionaries/SHA256SUMS` (checksum manifest)
- `assets/dictionaries/words-<lang>.json.license` (per-file provenance/license sidecar)
## Local research clones (not committed to this repository)
The `clones/` directory is gitignored and used for local research only.
### keybr.com
- License file in local clone: `clones/keybr.com/LICENSE`
- Upstream states AGPLv3 in README/license materials.
### typr
- License file in local clone: `clones/typr/LICENSE`
- License text present in clone is GPLv3.
## Runtime-downloaded content (not version-controlled by default)
This project can download third-party source content at runtime:
- Code snippets from repositories listed in `src/generator/code_syntax.rs`
- Passage text from Project Gutenberg URLs in `src/generator/passage.rs`
Downloaded files are stored in user data directories by default (`dirs::data_dir()`),
not in this repository. These downloaded assets keep their original upstream licenses.
When code snippets are downloaded, keydr now writes a sidecar source manifest
(`*_*.sources.txt`) listing exact source URLs to help with attribution and compliance
if cached content is redistributed.
## Research references from planning docs (idea-only unless noted above)
The following projects are referenced in planning/research docs and were used for
architecture/algorithm ideas:
- keybr.com
- typr
- ttyper
- smassh
- gokeybr
- ivan-volnov/keybr
- keybr-code
For these references, no direct code/data inclusion is claimed in this repository
except the explicitly documented `assets/dictionaries/words-*.json` imports from keybr.com.
## Notes
This repository is licensed under AGPL-3.0-only to remain compatible with included
AGPL-licensed upstream material.

View File

@@ -0,0 +1,21 @@
30a78612b478f8f9101e200b96ddf2807720a2b513ec6d05a73abdde99354407 words-cs.json
8098e39c9deb00db59d85f82c9bc791536b51c8fa2a5b688f771f120e83bbc26 words-da.json
014d7ff2f7756b1a0775b975e325bf75076770f0d4e6f9ebed771fa6aacb7ed5 words-de.json
067adf66de5f0a7ca17f3bf187bab378d8ad71e87856e4a25a208905404b949a words-en.json
fffcb910f0012e62215bfa2a8ed34ecc3d54cbf04a658c3bce5bee8148abf634 words-es.json
bfd0d22dbc129c3d693d5afbf39aaa5506c0c723bf5bb51ef10edd2af3f1c71d words-et.json
2530c4a37311fb93d6f687edb08534eca71f4c775e1a01fca405c783361386fa words-fi.json
3b177fcca8f275cce555ac954fcbbb945b14626a8a235993ff9e9d9767005517 words-fr.json
f439f8bf16f65f8600642906a0967dc1f99992f6a2f3b830bd77554ccb6a07de words-hr.json
44ec5436364a162dc7774be3c40a4678247aa2909eaceefac7f49b3bc00811f5 words-hu.json
03361069ce40d08fa931709ce402811d0f484c32d03878706ad4dcc5e709b01a words-it.json
9239f4042d67127859b3a56da29a6f3df4cd458776483adf561b668f3e646579 words-lt.json
ad24ebd9a36c012ebb8db3db78af5e6038d26b64b3f173885c4a606ab17d3d49 words-lv.json
be83a2cff75097db957575425b4dec658006c9b9e43fdcb7a6eb92701818b752 words-nb.json
0f701c9e5c891dd557a0f4f3a6903b4c9762a2a898f749caccb59efbee189271 words-nl.json
d99e00fb85890847ba783354e148ef835d44faee95c4d7ec227d589cf5b072d3 words-pl.json
fa3009988d7be559a78b6b2c2198628750de77d77e0ee360d8bf5cc8eab84368 words-pt.json
76ec930a9b6aaa8092f2179b0918d71ec61f139843d985f5f25eae07bd7093fc words-ro.json
2960c6db414abb22505a4f78d8292df2b45d7332144302296055fc5a8ee07e23 words-sl.json
154e1b905d10130fee0160d3e2f30bd6445e8da1e3251475df37be364a81bd17 words-sv.json
95f6e867ef64d6a1ddd90f82d574d38b4a2be19d550f613ee87fc3e1701a0d8e words-tr.json

View File

@@ -0,0 +1,22 @@
# language_key file license_file source
en words-en.json words-en.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-en.json
de words-de.json words-de.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-de.json
es words-es.json words-es.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-es.json
fr words-fr.json words-fr.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-fr.json
it words-it.json words-it.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-it.json
pt words-pt.json words-pt.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-pt.json
nl words-nl.json words-nl.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-nl.json
sv words-sv.json words-sv.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-sv.json
da words-da.json words-da.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-da.json
nb words-nb.json words-nb.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-nb.json
fi words-fi.json words-fi.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-fi.json
pl words-pl.json words-pl.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-pl.json
cs words-cs.json words-cs.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-cs.json
ro words-ro.json words-ro.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-ro.json
hr words-hr.json words-hr.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-hr.json
hu words-hu.json words-hu.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-hu.json
lt words-lt.json words-lt.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-lt.json
lv words-lv.json words-lv.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-lv.json
sl words-sl.json words-sl.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-sl.json
et words-et.json words-et.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-et.json
tr words-tr.json words-tr.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-tr.json
1 # language_key file license_file source
2 en words-en.json words-en.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-en.json
3 de words-de.json words-de.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-de.json
4 es words-es.json words-es.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-es.json
5 fr words-fr.json words-fr.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-fr.json
6 it words-it.json words-it.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-it.json
7 pt words-pt.json words-pt.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-pt.json
8 nl words-nl.json words-nl.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-nl.json
9 sv words-sv.json words-sv.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-sv.json
10 da words-da.json words-da.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-da.json
11 nb words-nb.json words-nb.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-nb.json
12 fi words-fi.json words-fi.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-fi.json
13 pl words-pl.json words-pl.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-pl.json
14 cs words-cs.json words-cs.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-cs.json
15 ro words-ro.json words-ro.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-ro.json
16 hr words-hr.json words-hr.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-hr.json
17 hu words-hu.json words-hu.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-hu.json
18 lt words-lt.json words-lt.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-lt.json
19 lv words-lv.json words-lv.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-lv.json
20 sl words-sl.json words-sl.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-sl.json
21 et words-et.json words-et.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-et.json
22 tr words-tr.json words-tr.json.license clones/keybr.com/packages/keybr-content-words/lib/data/words-tr.json

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
This file is sourced from keybr.com:
clones/keybr.com/packages/keybr-content-words/lib/data/words-cs.json
Upstream project: https://github.com/aradzie/keybr.com
Upstream license: GNU Affero General Public License v3.0
Local project license: AGPL-3.0-only (see /LICENSE).

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
This file is sourced from keybr.com:
clones/keybr.com/packages/keybr-content-words/lib/data/words-da.json
Upstream project: https://github.com/aradzie/keybr.com
Upstream license: GNU Affero General Public License v3.0
Local project license: AGPL-3.0-only (see /LICENSE).

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
This file is sourced from keybr.com:
clones/keybr.com/packages/keybr-content-words/lib/data/words-de.json
Upstream project: https://github.com/aradzie/keybr.com
Upstream license: GNU Affero General Public License v3.0
Local project license: AGPL-3.0-only (see /LICENSE).

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
This file is sourced from keybr.com:
clones/keybr.com/packages/keybr-content-words/lib/data/words-en.json
Upstream project: https://github.com/aradzie/keybr.com
Upstream license: GNU Affero General Public License v3.0
Local project license: AGPL-3.0-only (see /LICENSE).

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
This file is sourced from keybr.com:
clones/keybr.com/packages/keybr-content-words/lib/data/words-es.json
Upstream project: https://github.com/aradzie/keybr.com
Upstream license: GNU Affero General Public License v3.0
Local project license: AGPL-3.0-only (see /LICENSE).

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
This file is sourced from keybr.com:
clones/keybr.com/packages/keybr-content-words/lib/data/words-et.json
Upstream project: https://github.com/aradzie/keybr.com
Upstream license: GNU Affero General Public License v3.0
Local project license: AGPL-3.0-only (see /LICENSE).

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
This file is sourced from keybr.com:
clones/keybr.com/packages/keybr-content-words/lib/data/words-fi.json
Upstream project: https://github.com/aradzie/keybr.com
Upstream license: GNU Affero General Public License v3.0
Local project license: AGPL-3.0-only (see /LICENSE).

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
This file is sourced from keybr.com:
clones/keybr.com/packages/keybr-content-words/lib/data/words-fr.json
Upstream project: https://github.com/aradzie/keybr.com
Upstream license: GNU Affero General Public License v3.0
Local project license: AGPL-3.0-only (see /LICENSE).

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
This file is sourced from keybr.com:
clones/keybr.com/packages/keybr-content-words/lib/data/words-hr.json
Upstream project: https://github.com/aradzie/keybr.com
Upstream license: GNU Affero General Public License v3.0
Local project license: AGPL-3.0-only (see /LICENSE).

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
This file is sourced from keybr.com:
clones/keybr.com/packages/keybr-content-words/lib/data/words-hu.json
Upstream project: https://github.com/aradzie/keybr.com
Upstream license: GNU Affero General Public License v3.0
Local project license: AGPL-3.0-only (see /LICENSE).

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
This file is sourced from keybr.com:
clones/keybr.com/packages/keybr-content-words/lib/data/words-it.json
Upstream project: https://github.com/aradzie/keybr.com
Upstream license: GNU Affero General Public License v3.0
Local project license: AGPL-3.0-only (see /LICENSE).

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
This file is sourced from keybr.com:
clones/keybr.com/packages/keybr-content-words/lib/data/words-lt.json
Upstream project: https://github.com/aradzie/keybr.com
Upstream license: GNU Affero General Public License v3.0
Local project license: AGPL-3.0-only (see /LICENSE).

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
This file is sourced from keybr.com:
clones/keybr.com/packages/keybr-content-words/lib/data/words-lv.json
Upstream project: https://github.com/aradzie/keybr.com
Upstream license: GNU Affero General Public License v3.0
Local project license: AGPL-3.0-only (see /LICENSE).

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
This file is sourced from keybr.com:
clones/keybr.com/packages/keybr-content-words/lib/data/words-nb.json
Upstream project: https://github.com/aradzie/keybr.com
Upstream license: GNU Affero General Public License v3.0
Local project license: AGPL-3.0-only (see /LICENSE).

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
This file is sourced from keybr.com:
clones/keybr.com/packages/keybr-content-words/lib/data/words-nl.json
Upstream project: https://github.com/aradzie/keybr.com
Upstream license: GNU Affero General Public License v3.0
Local project license: AGPL-3.0-only (see /LICENSE).

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
This file is sourced from keybr.com:
clones/keybr.com/packages/keybr-content-words/lib/data/words-pl.json
Upstream project: https://github.com/aradzie/keybr.com
Upstream license: GNU Affero General Public License v3.0
Local project license: AGPL-3.0-only (see /LICENSE).

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
This file is sourced from keybr.com:
clones/keybr.com/packages/keybr-content-words/lib/data/words-pt.json
Upstream project: https://github.com/aradzie/keybr.com
Upstream license: GNU Affero General Public License v3.0
Local project license: AGPL-3.0-only (see /LICENSE).

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
This file is sourced from keybr.com:
clones/keybr.com/packages/keybr-content-words/lib/data/words-ro.json
Upstream project: https://github.com/aradzie/keybr.com
Upstream license: GNU Affero General Public License v3.0
Local project license: AGPL-3.0-only (see /LICENSE).

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
This file is sourced from keybr.com:
clones/keybr.com/packages/keybr-content-words/lib/data/words-sl.json
Upstream project: https://github.com/aradzie/keybr.com
Upstream license: GNU Affero General Public License v3.0
Local project license: AGPL-3.0-only (see /LICENSE).

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
This file is sourced from keybr.com:
clones/keybr.com/packages/keybr-content-words/lib/data/words-sv.json
Upstream project: https://github.com/aradzie/keybr.com
Upstream license: GNU Affero General Public License v3.0
Local project license: AGPL-3.0-only (see /LICENSE).

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
This file is sourced from keybr.com:
clones/keybr.com/packages/keybr-content-words/lib/data/words-tr.json
Upstream project: https://github.com/aradzie/keybr.com
Upstream license: GNU Affero General Public License v3.0
Local project license: AGPL-3.0-only (see /LICENSE).

View File

@@ -0,0 +1,23 @@
name = "catppuccin-latte"
[colors]
bg = "#eff1f5"
fg = "#4c4f69"
text_correct = "#40a02b"
text_incorrect = "#d20f39"
text_incorrect_bg = "#f5c2cf"
text_pending = "#7c7f93"
text_cursor_bg = "#dc8a78"
text_cursor_fg = "#eff1f5"
focused_key = "#df8e1d"
accent = "#1e66f5"
accent_dim = "#ccd0da"
border = "#ccd0da"
border_focused = "#1e66f5"
header_bg = "#e6e9ef"
header_fg = "#4c4f69"
bar_filled = "#1e66f5"
bar_empty = "#e6e9ef"
error = "#d20f39"
warning = "#df8e1d"
success = "#40a02b"

View File

@@ -0,0 +1,23 @@
name = "catppuccin-mocha"
[colors]
bg = "#1e1e2e"
fg = "#cdd6f4"
text_correct = "#a6e3a1"
text_incorrect = "#f38ba8"
text_incorrect_bg = "#45273a"
text_pending = "#a6adc8"
text_cursor_bg = "#f5e0dc"
text_cursor_fg = "#1e1e2e"
focused_key = "#f9e2af"
accent = "#89b4fa"
accent_dim = "#45475a"
border = "#45475a"
border_focused = "#89b4fa"
header_bg = "#313244"
header_fg = "#cdd6f4"
bar_filled = "#89b4fa"
bar_empty = "#313244"
error = "#f38ba8"
warning = "#f9e2af"
success = "#a6e3a1"

View File

@@ -0,0 +1,23 @@
name = "dracula"
[colors]
bg = "#282a36"
fg = "#f8f8f2"
text_correct = "#50fa7b"
text_incorrect = "#ff5555"
text_incorrect_bg = "#44242a"
text_pending = "#9aadce"
text_cursor_bg = "#f1fa8c"
text_cursor_fg = "#282a36"
focused_key = "#f1fa8c"
accent = "#bd93f9"
accent_dim = "#44475a"
border = "#44475a"
border_focused = "#bd93f9"
header_bg = "#44475a"
header_fg = "#f8f8f2"
bar_filled = "#bd93f9"
bar_empty = "#44475a"
error = "#ff5555"
warning = "#f1fa8c"
success = "#50fa7b"

23
assets/themes/farout.toml Normal file
View File

@@ -0,0 +1,23 @@
name = "farout"
[colors]
bg = "#0f0908"
fg = "#E0CCAE"
text_correct = "#a4896f"
text_incorrect = "#bf472c"
text_incorrect_bg = "#392D2B"
text_pending = "#A67458"
text_cursor_bg = "#0f0908"
text_cursor_fg = "#f2a766"
focused_key = "#f2a766"
accent = "#d47d49"
accent_dim = "#392D2B"
border = "#392D2B"
border_focused = "#d47d49"
header_bg = "#392D2B"
header_fg = "#E0CCAE"
bar_filled = "#a67458"
bar_empty = "#392D2B"
error = "#bf472c"
warning = "#f2a766"
success = "#a4896f"

View File

@@ -0,0 +1,23 @@
name = "gruvbox-dark"
[colors]
bg = "#282828"
fg = "#ebdbb2"
text_correct = "#b8bb26"
text_incorrect = "#fb4934"
text_incorrect_bg = "#462726"
text_pending = "#a89984"
text_cursor_bg = "#fabd2f"
text_cursor_fg = "#282828"
focused_key = "#fabd2f"
accent = "#83a598"
accent_dim = "#3c3836"
border = "#504945"
border_focused = "#83a598"
header_bg = "#3c3836"
header_fg = "#ebdbb2"
bar_filled = "#83a598"
bar_empty = "#3c3836"
error = "#fb4934"
warning = "#fabd2f"
success = "#b8bb26"

View File

@@ -0,0 +1,23 @@
name = "gruvbox-darkest"
[colors]
bg = "#121212"
fg = "#ebdbb2"
text_correct = "#b8bb26"
text_incorrect = "#fb4934"
text_incorrect_bg = "#462726"
text_pending = "#a89984"
text_cursor_bg = "#fabd2f"
text_cursor_fg = "#121212"
focused_key = "#fabd2f"
accent = "#83a598"
accent_dim = "#3c3836"
border = "#504945"
border_focused = "#83a598"
header_bg = "#3c3836"
header_fg = "#ebdbb2"
bar_filled = "#83a598"
bar_empty = "#3c3836"
error = "#fb4934"
warning = "#fabd2f"
success = "#b8bb26"

View File

@@ -0,0 +1,23 @@
name = "kanagawa-dragon"
[colors]
bg = "#181616"
fg = "#c5c9c5"
text_correct = "#8a9a7b"
text_incorrect = "#c4746e"
text_incorrect_bg = "#43242B"
text_pending = "#a6a69c"
text_cursor_bg = "#2d4f67"
text_cursor_fg = "#c8c093"
focused_key = "#c4b28a"
accent = "#8ba4b0"
accent_dim = "#282727"
border = "#625e5a"
border_focused = "#8ba4b0"
header_bg = "#282727"
header_fg = "#c5c9c5"
bar_filled = "#8ea4a2"
bar_empty = "#282727"
error = "#c4746e"
warning = "#c4b28a"
success = "#8a9a7b"

View File

@@ -0,0 +1,23 @@
name = "kanagawa-lotus"
[colors]
bg = "#f2ecbc"
fg = "#545464"
text_correct = "#6f894e"
text_incorrect = "#c84053"
text_incorrect_bg = "#d9a594"
text_pending = "#8a8980"
text_cursor_bg = "#5d57a3"
text_cursor_fg = "#f2ecbc"
focused_key = "#77713f"
accent = "#4d699b"
accent_dim = "#e7dba0"
border = "#a5a37d"
border_focused = "#4d699b"
header_bg = "#e7dba0"
header_fg = "#545464"
bar_filled = "#597b75"
bar_empty = "#d9d0a3"
error = "#c84053"
warning = "#77713f"
success = "#6f894e"

View File

@@ -0,0 +1,23 @@
name = "kanagawa-wave"
[colors]
bg = "#1f1f28"
fg = "#dcd7ba"
text_correct = "#76946a"
text_incorrect = "#c34043"
text_incorrect_bg = "#43242B"
text_pending = "#727169"
text_cursor_bg = "#2d4f67"
text_cursor_fg = "#c8c093"
focused_key = "#c0a36e"
accent = "#7e9cd8"
accent_dim = "#2A2A37"
border = "#54546D"
border_focused = "#7e9cd8"
header_bg = "#2A2A37"
header_fg = "#dcd7ba"
bar_filled = "#7e9cd8"
bar_empty = "#2A2A37"
error = "#c34043"
warning = "#c0a36e"
success = "#76946a"

23
assets/themes/nord.toml Normal file
View File

@@ -0,0 +1,23 @@
name = "nord"
[colors]
bg = "#2e3440"
fg = "#eceff4"
text_correct = "#a3be8c"
text_incorrect = "#bf616a"
text_incorrect_bg = "#3f2e31"
text_pending = "#8fbcbb"
text_cursor_bg = "#ebcb8b"
text_cursor_fg = "#2e3440"
focused_key = "#ebcb8b"
accent = "#88c0d0"
accent_dim = "#3b4252"
border = "#4c566a"
border_focused = "#88c0d0"
header_bg = "#3b4252"
header_fg = "#eceff4"
bar_filled = "#88c0d0"
bar_empty = "#3b4252"
error = "#bf616a"
warning = "#ebcb8b"
success = "#a3be8c"

View File

@@ -0,0 +1,23 @@
name = "one-dark"
[colors]
bg = "#282c34"
fg = "#abb2bf"
text_correct = "#98c379"
text_incorrect = "#e06c75"
text_incorrect_bg = "#3e2a2d"
text_pending = "#848b98"
text_cursor_bg = "#e5c07b"
text_cursor_fg = "#282c34"
focused_key = "#e5c07b"
accent = "#61afef"
accent_dim = "#3e4451"
border = "#3e4451"
border_focused = "#61afef"
header_bg = "#21252b"
header_fg = "#abb2bf"
bar_filled = "#61afef"
bar_empty = "#21252b"
error = "#e06c75"
warning = "#e5c07b"
success = "#98c379"

View File

@@ -0,0 +1,23 @@
name = "solarized-dark"
[colors]
bg = "#002b36"
fg = "#839496"
text_correct = "#859900"
text_incorrect = "#dc322f"
text_incorrect_bg = "#2a1a1a"
text_pending = "#839496"
text_cursor_bg = "#b58900"
text_cursor_fg = "#002b36"
focused_key = "#b58900"
accent = "#268bd2"
accent_dim = "#073642"
border = "#586e75"
border_focused = "#268bd2"
header_bg = "#073642"
header_fg = "#93a1a1"
bar_filled = "#268bd2"
bar_empty = "#073642"
error = "#dc322f"
warning = "#b58900"
success = "#859900"

View File

@@ -0,0 +1,23 @@
name = "terminal-default"
[colors]
bg = "reset"
fg = "reset"
text_correct = "green"
text_incorrect = "red"
text_incorrect_bg = "reset"
text_pending = "darkgray"
text_cursor_bg = "reset"
text_cursor_fg = "reset"
focused_key = "yellow"
accent = "blue"
accent_dim = "darkgray"
border = "darkgray"
border_focused = "blue"
header_bg = "reset"
header_fg = "reset"
bar_filled = "blue"
bar_empty = "darkgray"
error = "red"
warning = "yellow"
success = "green"

View File

@@ -0,0 +1,23 @@
name = "tokyo-night"
[colors]
bg = "#1a1b26"
fg = "#c0caf5"
text_correct = "#9ece6a"
text_incorrect = "#f7768e"
text_incorrect_bg = "#3b2232"
text_pending = "#9aa5ce"
text_cursor_bg = "#e0af68"
text_cursor_fg = "#1a1b26"
focused_key = "#e0af68"
accent = "#7aa2f7"
accent_dim = "#292e42"
border = "#3b4261"
border_focused = "#7aa2f7"
header_bg = "#24283b"
header_fg = "#c0caf5"
bar_filled = "#7aa2f7"
bar_empty = "#24283b"
error = "#f7768e"
warning = "#e0af68"
success = "#9ece6a"

133
benches/ngram_benchmarks.rs Normal file
View File

@@ -0,0 +1,133 @@
use criterion::{Criterion, black_box, criterion_group, criterion_main};
use keydr::engine::key_stats::KeyStatsStore;
use keydr::engine::ngram_stats::{
BigramKey, BigramStatsStore, extract_ngram_events,
};
use keydr::session::result::KeyTime;
fn make_keystrokes(count: usize) -> Vec<KeyTime> {
let chars = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'];
(0..count)
.map(|i| KeyTime {
key: chars[i % chars.len()],
time_ms: 200.0 + (i % 50) as f64,
correct: i % 7 != 0, // ~14% error rate
})
.collect()
}
fn bench_extraction(c: &mut Criterion) {
let keystrokes = make_keystrokes(500);
c.bench_function("extract_bigrams (500 keystrokes)", |b| {
b.iter(|| extract_ngram_events(black_box(&keystrokes), 800.0))
});
}
fn bench_update(c: &mut Criterion) {
let keystrokes = make_keystrokes(500);
let bigram_events = extract_ngram_events(&keystrokes, 800.0);
c.bench_function("bigram_stats update (400 events)", |b| {
b.iter(|| {
let mut store = BigramStatsStore::default();
for ev in bigram_events.iter().take(400) {
store.update(
black_box(ev.key.clone()),
black_box(ev.total_time_ms),
black_box(ev.correct),
black_box(ev.has_hesitation),
0,
);
}
store
})
});
}
fn bench_focus_selection(c: &mut Criterion) {
// Use a-z + A-Z + 0-9 = 62 chars for up to 3844 unique bigrams
let all_chars: Vec<char> = ('a'..='z').chain('A'..='Z').chain('0'..='9').collect();
let mut bigram_stats = BigramStatsStore::default();
let mut char_stats = KeyStatsStore::default();
for &ch in &all_chars {
let stat = char_stats.stats.entry(ch).or_default();
stat.confidence = 0.8;
stat.filtered_time_ms = 430.0;
stat.sample_count = 50;
stat.total_count = 50;
stat.error_count = 3;
}
let mut count: usize = 0;
for &a in &all_chars {
for &b in &all_chars {
if bigram_stats.stats.len() >= 3000 {
break;
}
let key = BigramKey([a, b]);
let stat = bigram_stats.stats.entry(key).or_default();
stat.confidence = 0.5 + (count % 50) as f64 * 0.01;
stat.sample_count = 25 + count % 30;
stat.error_count = 5 + count % 10;
stat.redundancy_streak = if count % 3 == 0 { 3 } else { 1 };
count += 1;
}
}
assert_eq!(bigram_stats.stats.len(), 3000);
let unlocked: Vec<char> = all_chars;
c.bench_function("weakest_bigram (3K entries)", |b| {
b.iter(|| bigram_stats.weakest_bigram(black_box(&char_stats), black_box(&unlocked)))
});
}
fn bench_history_replay(c: &mut Criterion) {
// Build 500 drills of ~300 keystrokes each
let drills: Vec<Vec<KeyTime>> = (0..500).map(|_| make_keystrokes(300)).collect();
c.bench_function("history replay (500 drills x 300 keystrokes)", |b| {
b.iter(|| {
let mut bigram_stats = BigramStatsStore::default();
let mut key_stats = KeyStatsStore::default();
for (drill_idx, keystrokes) in drills.iter().enumerate() {
let bigram_events = extract_ngram_events(keystrokes, 800.0);
for kt in keystrokes {
if kt.correct {
let stat = key_stats.stats.entry(kt.key).or_default();
stat.total_count += 1;
} else {
key_stats.update_key_error(kt.key);
}
}
for ev in &bigram_events {
bigram_stats.update(
ev.key.clone(),
ev.total_time_ms,
ev.correct,
ev.has_hesitation,
drill_idx as u32,
);
}
}
(bigram_stats, key_stats)
})
});
}
criterion_group!(
benches,
bench_extraction,
bench_update,
bench_focus_selection,
bench_history_replay,
);
criterion_main!(benches);

View File

@@ -0,0 +1,42 @@
# License Compliance Notes
This repository includes AGPL-licensed upstream material and is licensed as
`AGPL-3.0-only`.
## What is included in-repo
- `assets/dictionaries/words-*.json` are imported from keybr.com and tracked in
`THIRD_PARTY_NOTICES.md`.
- `assets/dictionaries/words-<lang>.json.license` records source and license for
each imported dictionary file.
- `assets/dictionaries/manifest.tsv` maps language keys to imported files/sources.
- `assets/dictionaries/SHA256SUMS` stores dictionary checksums for integrity verification.
- `scripts/validate_dictionary_manifest.sh` validates manifest entries, sidecars,
and checksums.
- `scripts/derive_primary_letter_sequences.py` derives per-language primary-letter
sequence seed data from dictionary frequency.
- `assets/dictionaries/primary-letter-sequences.tsv` stores the current derived output.
- `docs/unicode-normalization-policy.md` documents NFC normalization policy and
equivalence expectations.
## What is research-only
- The `clones/` directory is gitignored and used for local analysis only.
- References in `docs/plans/` to third-party projects are primarily idea-level
research unless explicitly documented as imported content.
## Runtime downloads
- Code drills can download source files from upstream GitHub repositories.
- Passage drills can download texts from Project Gutenberg.
- Downloaded content is cached in user data directories by default, not in this repo.
- Downloaded code snippet caches include a `*.sources.txt` sidecar with source URLs.
## Ongoing checklist for compliance
1. If you import any third-party file into the repository, add it to
`THIRD_PARTY_NOTICES.md`.
2. Add a sidecar `filename.license` (or equivalent) with source and license.
3. Keep the project license compatible with imported copyleft obligations.
4. If you later commit downloaded snippet caches or passage corpora, include
attribution and the relevant upstream license terms for those files.

View File

@@ -0,0 +1,290 @@
# keydr - Terminal Typing Tutor Architecture Plan
## Context
**Problem**: No terminal-based typing tutor exists that combines keybr.com's adaptive learning algorithm (gradual letter unlocking, per-key confidence tracking, phonetic pseudo-word generation) with code syntax training. Existing tools either lack adaptive learning entirely (ttyper, smassh, typr) or have incomplete implementations (gokeybr intentionally ignores error stats, ivan-volnov/keybr is focused on Anki integration).
**Goal**: Build a full-featured Rust TUI typing tutor that clones keybr.com's core algorithm, extends it to code syntax training, and provides a polished statistics dashboard - all in the terminal.
---
## Research Summary
### keybr.com Algorithm (from reading source: `packages/keybr-lesson/lib/guided.ts`, `keybr-phonetic-model/lib/phoneticmodel.ts`, `keybr-result/lib/keystats.ts`)
**Letter Unlocking**: Letters sorted by frequency. Starts with minimum 6. New letter unlocked only when ALL included keys have `confidence >= 1.0`. Weakest key (lowest confidence) gets "focused" - drills bias heavily toward it.
**Confidence Model**: `confidence = target_time_ms / filtered_time_to_type`, where `target_time_ms = 60000 / target_speed_cpm` (default target: 175 CPM ~ 35 WPM). `filtered_time_to_type` is an exponential moving average (alpha=0.1) of raw per-key typing times.
**Phonetic Word Generation**: Markov chain transition table maps character bigrams to next-character probability distributions. Chain is walked with a `Filter` that restricts to unlocked characters only. Focused letter gets prefix biasing - the generator searches for chain states containing the focused letter and starts from there. Words are 3-10 chars; space probability boosted by `1.3^word_length` to keep words short.
**Scoring**: `score = (speed_cpm * complexity) / (errors + 1) * (length / 50)`
**Learning Rate**: Polynomial regression (degree 1-3 based on sample count) on last 30 per-key time samples, with R^2 threshold of 0.5 for meaningful predictions.
### Key Insights from Prior Art
- **gokeybr**: Trigram-based scoring with `frequency * effort(speed)` is a good complementary approach. Its Bellman-Ford shortest-path for drill generation is clever but complex.
- **ttyper**: Clean Rust/Ratatui architecture to reference. Uses `crossterm` events, `State::Test | State::Results` enum, `Config` from TOML. Dependencies: `ratatui ^0.25`, `crossterm ^0.27`, `clap`, `serde`, `toml`, `rand`, `rust-embed`.
- **keybr-code**: Uses PEG grammars to generate code snippets for 12+ languages. Each grammar produces realistic syntax patterns.
---
## Architecture
### Technology Stack
- **TUI**: Ratatui + Crossterm (the standard Rust TUI stack, battle-tested by ttyper and many others)
- **CLI**: Clap (derive)
- **Serialization**: Serde + serde_json + toml
- **HTTP**: Reqwest (blocking, for GitHub API)
- **Persistence**: JSON files via `dirs` crate (XDG paths)
- **Embedded Assets**: rust-embed
- **Error Handling**: anyhow + thiserror
- **Time**: chrono
### Project Structure
```
src/
main.rs # CLI parsing, terminal init, main event loop
app.rs # App state machine (TEA pattern), message dispatch
event.rs # Crossterm event polling thread -> AppMessage channel
config.rs # Config loading (~/.config/keydr/config.toml)
engine/
mod.rs
letter_unlock.rs # Letter ordering, unlock logic, focus selection
key_stats.rs # Per-key EMA, confidence, best-time tracking
scoring.rs # Lesson score formula, gamification (levels, streaks)
learning_rate.rs # Polynomial regression for speed prediction
filter.rs # Active character set filter
generator/
mod.rs # TextGenerator trait
phonetic.rs # Markov chain pseudo-word generator
transition_table.rs # Binary transition table (de)serialization
code_syntax.rs # PEG grammar interpreter for code snippets
passage.rs # Book passage loading
github_code.rs # GitHub API code fetching + caching
session/
mod.rs
lesson.rs # LessonState: target text, cursor, timing
input.rs # Keystroke processing, match/mismatch, backspace
result.rs # LessonResult computation from raw events
store/
mod.rs # StorageBackend trait
json_store.rs # JSON file persistence with atomic writes
schema.rs # Serializable data models
ui/
mod.rs # Root render dispatcher
theme.rs # Theme TOML parsing, color resolution
layout.rs # Responsive screen layout (ratatui Rect splitting)
components/
mod.rs
typing_area.rs # Main typing widget (correct/incorrect/pending coloring)
stats_sidebar.rs # Live WPM, accuracy, key confidence bars
keyboard_diagram.rs # Visual keyboard with finger colors + focus highlight
progress_bar.rs # Letter unlock progress
chart.rs # WPM-over-time line charts (ratatui Chart widget)
menu.rs # Mode selection menu
dashboard.rs # Post-lesson results view
stats_dashboard.rs # Historical statistics with graphs
keyboard/
mod.rs
layout.rs # KeyboardLayout, key positions, finger assignments
finger.rs # Finger enum, hand assignment
assets/
models/en.bin # Pre-built English phonetic transition table
themes/*.toml # Built-in themes (catppuccin, dracula, gruvbox, nord, etc.)
grammars/*.toml # Code syntax grammars (rust, python, js, go, etc.)
layouts/*.toml # Keyboard layouts (qwerty, dvorak, colemak)
```
### Core Data Flow
```
┌─────────────┐
│ Event Loop │
└──────┬──────┘
│ AppMessage
┌──────────┐ ┌─────────────────┐ ┌───────────┐
│Generator │────▶│ App State │────▶│ UI Layer │
│(phonetic,│ │ (TEA pattern) │ │ (ratatui) │
│ code, │ │ │ │ │
│ passage) │ │ ┌─────────────┐ │ └───────────┘
└──────────┘ │ │ Engine │ │
│ │ (key_stats, │ │ ┌───────────┐
│ │ unlock, │ │────▶│ Store │
│ │ scoring) │ │ │ (JSON) │
│ └─────────────┘ │ └───────────┘
└─────────────────┘
```
### App State Machine
```
Start → Menu
Menu → Lesson (on mode select)
Menu → StatsDashboard (on 's')
Menu → Settings (on 'c')
Lesson → LessonResult (on completion or ESC)
LessonResult → Lesson (on 'r' retry)
LessonResult → Menu (on 'q'/ESC)
LessonResult → StatsDashboard (on 's')
StatsDashboard → Menu (on ESC)
Settings → Menu (on ESC, saves config)
Any → Quit (on Ctrl+C)
```
### The Adaptive Algorithm
**Step 1 - Letter Order**: English frequency order: `e t a o i n s h r d l c u m w f g y p b v k j x q z`
**Step 2 - Unlock Logic** (after each lesson):
```
min_letters = 6
for each letter in frequency_order:
if included.len() < min_letters:
include(letter)
elif all included keys have confidence >= 1.0:
include(letter)
else:
break
```
**Step 3 - Focus Selection**:
```
focused = included_keys
.filter(|k| k.confidence < 1.0)
.min_by(|a, b| a.confidence.cmp(&b.confidence))
```
**Step 4 - Stats Update** (per key, after each lesson):
```
alpha = 0.1
stat.filtered_time = alpha * new_time + (1 - alpha) * stat.filtered_time
stat.best_time = min(stat.best_time, stat.filtered_time)
stat.confidence = (60000.0 / target_speed_cpm) / stat.filtered_time
```
**Step 5 - Text Generation Biasing**:
- Only allow characters in the unlocked set (Filter)
- When a focused letter exists, find Markov chain prefixes containing it and start words from those prefixes
- This naturally creates words heavy in the weak letter
### Code Syntax Extension
After all 26 prose letters are unlocked, the system transitions to code syntax training:
- Introduces code-relevant characters: `{ } [ ] ( ) < > ; : . , = + - * / & | ! ? _ " ' # @ \ ~ ^ %`
- Uses PEG grammars per language to generate realistic code snippets
- Gradual character unlocking continues for syntax characters
- Users select their target programming languages in config
### Theme System
Themes are TOML files with semantic color names:
```toml
[colors]
bg = "#1e1e2e"
text_correct = "#a6e3a1"
text_incorrect = "#f38ba8"
text_pending = "#585b70"
text_cursor_bg = "#f5e0dc"
focused_key = "#f9e2af"
# ... etc
```
Resolution order: CLI flag → config → user themes dir → bundled → default fallback.
Built-in themes: Catppuccin Mocha, Catppuccin Latte, Dracula, Gruvbox Dark, Nord, Tokyo Night, Solarized Dark, One Dark, plus an ANSI-safe default.
### Persistence
JSON files in `~/.local/share/keydr/`:
- `key_stats.json` - Per-key EMA, confidence, sample history
- `lesson_history.json` - Last 500 lesson results
- `profile.json` - Unlock state, settings, gamification data
Atomic writes (temp file → fsync → rename) to prevent corruption. Schema version field for forward-compatible migrations.
---
## Implementation Phases
### Phase 1: Foundation (Core Loop + Basic Typing)
Create the terminal init/restore with crossterm, event polling thread, TEA-based App state machine, basic typing against a hardcoded word list with correct/incorrect coloring.
**Key files**: `main.rs`, `app.rs`, `event.rs`, `session/lesson.rs`, `session/input.rs`, `ui/components/typing_area.rs`, `ui/layout.rs`
### Phase 2: Adaptive Engine + Statistics
Implement per-key stats (EMA, confidence), letter unlocking, focus selection, scoring, live stats sidebar, and progress bar.
**Key files**: `engine/key_stats.rs`, `engine/letter_unlock.rs`, `engine/scoring.rs`, `engine/filter.rs`, `session/result.rs`, `ui/components/stats_sidebar.rs`, `ui/components/progress_bar.rs`
### Phase 3: Phonetic Text Generation
Build the English transition table (offline tool or build script), implement the Markov chain walker with filter and focus biasing, integrate with the lesson system.
**Key files**: `generator/transition_table.rs`, `generator/phonetic.rs`, `generator/mod.rs`, a `build.rs` or `tools/` script for table generation
### Phase 4: Persistence + Theming
JSON storage backend, atomic writes, config loading, theme parsing, built-in theme files, apply themes throughout all UI components.
**Key files**: `store/json_store.rs`, `store/schema.rs`, `config.rs`, `ui/theme.rs`, `assets/themes/*.toml`
### Phase 5: Results + Dashboard
Post-lesson results screen, historical stats dashboard with charts (ratatui Chart widget), learning rate prediction.
**Key files**: `ui/components/dashboard.rs`, `ui/components/stats_dashboard.rs`, `ui/components/chart.rs`, `engine/learning_rate.rs`
### Phase 6: Code Practice + Passages
PEG grammar interpreter for code syntax generation, book passage mode, GitHub code fetching + caching.
**Key files**: `generator/code_syntax.rs`, `generator/passage.rs`, `generator/github_code.rs`, `assets/grammars/*.toml`
### Phase 7: Keyboard Diagram + Layouts
Visual keyboard widget with finger color coding, multiple layout support (QWERTY, Dvorak, Colemak).
**Key files**: `keyboard/layout.rs`, `keyboard/finger.rs`, `ui/components/keyboard_diagram.rs`, `assets/layouts/*.toml`
### Phase 8: Polish + Gamification
Level system, streaks, badges, CLI completeness, error handling, performance, testing, documentation.
---
## Verification
After each phase, verify by:
1. `cargo build` compiles without errors
2. `cargo test` passes all unit tests
3. Manual testing: launch `cargo run`, exercise the new features, verify UI rendering
4. For Phase 2+: verify letter unlocking by typing accurately and watching new letters appear
5. For Phase 3+: verify generated words only contain unlocked letters and bias toward the focused key
6. For Phase 4+: verify stats persist across app restarts
7. For Phase 5+: verify charts render correctly with historical data
---
## Dependencies (Cargo.toml)
```toml
[dependencies]
ratatui = "0.30"
crossterm = "0.28"
clap = { version = "4.5", features = ["derive"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.8"
rand = { version = "0.8", features = ["small_rng"] }
reqwest = { version = "0.12", features = ["json", "blocking"] }
dirs = "6.0"
rust-embed = "8.5"
chrono = { version = "0.4", features = ["serde"] }
anyhow = "1.0"
thiserror = "2.0"
```

View File

@@ -0,0 +1,188 @@
# keydr Improvement Plan
## Context
The app was built in a single first-pass implementation. Six issues need addressing: a broken settings menu, low-contrast pending text, poor phonetic word quality, a bare-bones stats dashboard, an undersized keyboard visualization, and hardcoded passage/code content.
---
## Issue 1: Settings Menu Not Working
**Root cause**: In `main.rs:handle_menu_key`, the `Enter` match handles `0..=3` but Settings is menu item index `4` — it falls through to `_ => {}`. Also no `KeyCode::Char('c')` shortcut handler exists.
**Fix** (`src/main.rs:124-158`):
- Add `4 => app.screen = AppScreen::Settings` in the Enter match arm
- Add `KeyCode::Char('c') => app.screen = AppScreen::Settings` handler
**Additionally**, make the Settings screen functional instead of a stub:
- Make it an interactive form with arrow keys to select fields, Enter to cycle values
- Fields: Target WPM (adjustable ±5), Theme (cycle through available), Word Count, Code Languages
- Save config on ESC via existing `Config::save()`
- New file: no new files needed; extend `render_settings` in `main.rs` and add `handle_settings_key` logic
- Add `settings_selected: usize` and `settings_editing: bool` fields to `App`
---
## Issue 2: Low Contrast Pending Text
**Root cause**: `text_pending` = `#585b70` (Catppuccin overlay0) on bg `#1e1e2e` is too dim for readable upcoming text.
**Fix**: Change `text_pending` in the default theme and all bundled theme files:
- `src/ui/theme.rs:92` default: `#585b70``#a6adc8` (Catppuccin subtext0, much brighter)
- Update all 8 theme TOML files in `assets/themes/` with appropriate brighter pending text colors for each theme
---
## Issue 3: Better Phonetic Word Generation
**Root cause**: Our current approach uses a hand-built order-2 trigram table from ~50 English patterns. keybr.com uses:
1. **Order-4 Markov chain** trained on top 10,000 words from a real frequency dictionary
2. **Pre-built binary model** (~47KB for English)
3. **Real word dictionary** — the `naturalWords` mode (keybr's default) primarily uses real English words filtered by unlocked letters, falling back to phonetic pseudo-words only when <15 words match
**Implementation plan**:
### Step A: Build a proper transition table from a word frequency list
- Create `tools/build_model.rs` (a build-time binary) that:
1. Reads an English word frequency list (we'll embed a curated 10K-word list as `assets/wordfreq-en.csv`)
2. Uses order-4 chain (matching keybr)
3. Appends each word weighted by frequency (like keybr's `builder.append()` loop)
4. Outputs binary `.data` file matching keybr's format
- **OR simpler approach**: Embed the word list directly and build the table at startup (it's fast enough)
### Step B: Upgrade TransitionTable to order-4
- Modify `TransitionTable` to support variable-order chains
- Change the key from `(char, char)` → a `Vec<char>` prefix of length `order - 1`
- Implement `segment(prefix: &[char])` matching keybr's approach
### Step C: Add a word dictionary for "natural words" mode
- Create `src/generator/dictionary.rs` with a `Dictionary` struct
- Embed a 10K English word list (JSON or plain text) via rust-embed
- `Dictionary::find(filter: &CharFilter, focused: Option<char>) -> Vec<&str>` returns real words where all characters are in the allowed set
- If focused letter exists, prefer words containing it
### Step D: Update PhoneticGenerator to use combined approach (like keybr's GuidedLesson)
- When `naturalWords` is enabled (default):
1. Get real words matching the filter from Dictionary
2. If >= 15 real words available, randomly pick from them
3. Otherwise, supplement with phonetic pseudo-words from the Markov chain
- This is what makes keybr's output "feel like real words" — because they mostly ARE real words
**Key files to modify**:
- `src/generator/transition_table.rs` — upgrade to order-4
- `src/generator/phonetic.rs` — update word generation loop
- New: `src/generator/dictionary.rs` — real word dictionary
- New: `assets/words-en.json` — embedded 10K word list (we can extract from keybr's `clones/keybr.com/packages/keybr-content-words/lib/data/words-en.json`)
- `src/app.rs` — wire up dictionary
---
## Issue 4: Comprehensive Statistics Dashboard
**Current state**: Single screen with 4 summary numbers and 1 unlabeled WPM line chart.
**Target** (inspired by typr's three-tab layout):
### Tab navigation
- Add tab state to the stats dashboard: `Dashboard | History | Keystrokes`
- Keyboard: `D`, `H`, `K` to switch tabs, or `Tab` to cycle
- Render tabs as a header row with active tab highlighted
### Dashboard Tab
1. **Summary stats row**: Total lessons, Avg WPM, Best WPM, Avg Accuracy, Total time, Streak
2. **Progress bars** (3 columns): WPM vs goal, Accuracy vs 100%, Level progress
3. **WPM over time chart** (line chart, last 50 lessons) — already exists, add axis labels
4. **Accuracy over time chart** (line chart, last 50 lessons) — new chart
### History Tab
1. **Recent tests table**: Last 20 lessons with columns: #, WPM, Raw WPM, Accuracy, Time, Date
2. **Per-key average speed chart**: Bar chart of all 26 letters by avg typing time
### Keystrokes Tab
1. **Keyboard accuracy heatmap**: Render keyboard layout with per-key accuracy coloring (green=100%, yellow=90-100%, red=<90%)
2. **Slowest/Fastest keys tables**: Top 5 each with average time in ms
3. **Word/Character stats**: Total correct/wrong counts
**Key files to modify/create**:
- `src/ui/components/stats_dashboard.rs` — complete rewrite with tabs
- `src/ui/components/chart.rs` — add AccuracyChart, BarChart widgets
- New: `src/ui/components/keyboard_heatmap.rs` — per-key accuracy visualization
- `src/engine/key_stats.rs` — ensure per-key accuracy tracking exists (not just timing)
- `src/session/result.rs` — ensure per-key accuracy data is persisted
- `src/store/schema.rs` — may need to add per-key accuracy to KeyStatsData
---
## Issue 5: Keyboard Visualization Too Small
**Current state**: Keyboard diagram IS rendered in `render_lesson` (`main.rs:330-335`) but given only `Constraint::Length(4)` — with borders that's 2 inner rows, but QWERTY needs 3 rows.
**Fix**:
- Change keyboard constraint from `Length(4)` to `Length(5)` in `main.rs:316`
- Improve the keyboard rendering in `keyboard_diagram.rs`:
- Use wider keys (5 chars instead of 4) for readability
- Add finger-color coding (reuse existing `keyboard/finger.rs`)
- Show the next key to type highlighted (pass current target char)
- Improve spacing/centering
**Files**: `src/main.rs:311-318`, `src/ui/components/keyboard_diagram.rs`
---
## Issue 6: Embedded + Internet Content (Both Approaches)
### Embedded Baseline (always available, no network)
- Bundle ~50 passages from public domain literature directly in binary (via rust-embed)
- Bundle ~100 code snippets per language (Rust, Python, JS, Go) in embedded assets
- These replace the current ~15 hardcoded passages and ~12 code snippets per language
### Internet Fetching (on top of embedded, with caching)
**Passages: Project Gutenberg**
- Fetch from `https://www.gutenberg.org/cache/epub/{id}/pg{id}.txt`
- Curate ~20 popular book IDs (Pride and Prejudice, Alice in Wonderland, etc.)
- Extract random paragraphs (skip Gutenberg header/footer boilerplate)
- Cache fetched books to `~/.local/share/keydr/passages/`
- Gracefully fall back to embedded passages on network failure
**Code: GitHub Raw Files**
- Fetch raw files from curated popular repos (e.g., `tokio-rs/tokio`, `python/cpython`)
- Use direct raw.githubusercontent.com URLs for specific files (no API auth needed)
- Extract function-length snippets (20-50 lines)
- Cache to `~/.local/share/keydr/code_cache/`
- Gracefully fall back to embedded snippets on failure
**New dependency**: `reqwest = { version = "0.12", features = ["json", "blocking"] }`
**Files to modify**:
- `src/generator/passage.rs` — expand embedded + add Gutenberg fetching
- `src/generator/code_syntax.rs` — expand embedded + add GitHub fetching
- New: `src/generator/cache.rs` — shared disk caching logic
- New: `assets/passages/*.txt` — embedded passage files
- New: `assets/code/*.rs`, `*.py`, etc. — embedded code snippet files
- `Cargo.toml` — add reqwest dependency
---
## Implementation Order
1. **Issue 1** (Settings menu fix) — quick fix, unblocks testing
2. **Issue 2** (Text contrast) — quick theme change
3. **Issue 5** (Keyboard size) — quick layout fix
4. **Issue 3** (Word generation) — medium complexity, core improvement
5. **Issue 4** (Stats dashboard) — large UI rewrite
6. **Issue 6** (Internet content) — medium complexity, requires new dependency
---
## Verification
1. `cargo build` — compiles without errors
2. `cargo test` — all tests pass
3. Manual testing for each issue:
- Settings: navigate to Settings in menu, change target WPM, verify it saves/loads
- Contrast: verify pending text is readable in the typing area
- Keyboard: verify all 3 QWERTY rows visible during lesson
- Words: start adaptive mode, verify words look like real English
- Stats: complete 2-3 lessons, check all three stats tabs render correctly
- Passages: start passage mode, verify it fetches new content (with network), and falls back gracefully (without)

View File

@@ -0,0 +1,357 @@
# Keydr Improvement Plan
## Context
The keydr typing tutor app needs six improvements to bring it closer to the quality of keybr.com and typr. Currently the app starts at a menu screen, doesn't properly count corrected errors, has a confusing keyboard visualization, lacks responsive layout, can't delete sessions, and has basic statistics views.
---
## 1. Start in Adaptive Drill by Default
**Files:** `src/app.rs`
**Implementation:** Change `App::new()` to use a `let mut app = Self { ... }; app.start_lesson(); app` pattern. The struct literal currently at `src/app.rs:99-120` sets `screen: AppScreen::Menu` — change this to construct `Self`, then call `start_lesson()` which sets `screen = AppScreen::Lesson` and generates text. The menu remains accessible via ESC from lesson/result screens (unchanged).
---
## 2. Fix Error Tracking for Backspaced Corrections
**Files:** `src/session/lesson.rs`, `src/session/input.rs`, `src/session/result.rs`, `src/ui/components/stats_sidebar.rs`
**Problem:** When a user types wrong, backspaces, then types correctly, keydr pops `CharStatus::Incorrect` from the input vector and replaces with `CharStatus::Correct`. Final accuracy shows 0 errors. keybr.com counts this as an error (see `packages/keybr-textinput/lib/textinput.ts``typo` flag persists through backspace corrections, and `stats.ts:42-49` counts all steps with `typo: true`).
**Implementation — keybr-style step-based tracking:**
### Two separate tracking systems:
**A. Live display counters (existing, unchanged):**
- `input: Vec<CharStatus>` continues to track current visible state (grows on type, shrinks on backspace)
- `incorrect_count()` and `correct_count()` show current snapshot for the sidebar display
- `accuracy()` on `LessonState` continues using `input.len()` as denominator — only reflects currently-visible chars
**B. Persistent typo tracking (new, for final results):**
- Add `typo_flags: HashSet<usize>` to `LessonState` — tracks positions where ANY incorrect key was ever pressed
**Process flow:**
1. `process_char()` — when `!correct`: insert `lesson.cursor` into `typo_flags`. Push to `input` as before.
2. `process_backspace()` — pop from `input`, decrement cursor. Do NOT remove from `typo_flags`.
3. When the lesson completes (all positions filled with correct/incorrect chars), `LessonResult::from_lesson()` builds the final result using `typo_flags` to determine error count:
- `incorrect = typo_flags.len()` (positions where any error ever occurred)
- `accuracy = (total_chars - typo_flags.len()) / total_chars * 100`
- This avoids the denominator mismatch since we always use `target.len()` as the denominator
**Sidebar display during lesson:**
- Show "Errors: X" using `typo_flags.len()` (accumulated errors, never decreases)
- Live accuracy: count `typo_flags` entries that are `< cursor` (i.e., only count typos at positions already typed past), then: `((cursor - typos_before_cursor).max(0) as f64 / cursor as f64 * 100.0).clamp(0.0, 100.0)` where cursor > 0. This handles the backspace case correctly — if cursor retreats behind a typo'd position, that typo doesn't count in the live denominator.
### Unit tests:
- Type "abc" correctly → typo_flags empty, accuracy 100%
- Type wrong char at pos 0, backspace, type correct → typo_flags = {0}, accuracy < 100%
- Type wrong char, continue without backspace → typo_flags = {pos}, also in input as Incorrect
- Multiple errors at same position (wrong, backspace, wrong again, backspace, correct) → typo_flags = {pos}, counts as 1 error
---
## 3. Fix Keyboard Visualization
**Files:** `src/ui/components/keyboard_diagram.rs`, `src/main.rs`, `src/app.rs`, `src/event.rs`
**Problem:** All key colors shift constantly with no meaning. User expects pressed keys to light up.
**How keybr.com does it:**
- Uses physical key codes (W3C `event.code` like `KeyA`, `KeyQ`) for tracking depressed keys
- `Controller.tsx:99-107`: `onKeyDown` adds to `depressedKeys`, `onKeyUp` removes
- `KeyboardPresenter.tsx:36-39`: passes `depressedKeys` array and `suffixKeys` (next expected) to keyboard UI
- `KeyLayer.tsx`: pre-computes 8 states per key (depressed × toggled × showColors), selects based on current state
**Implementation — crossterm supports key Press/Release events:**
**Scope decision:** We track depressed state for **printable character keys only** (`KeyCode::Char(ch)`). This is intentional non-parity with keybr.com's physical-key-ID model — keybr runs in a browser with W3C key codes, but keydr's keyboard diagram only shows letter keys. Modifier keys (Shift, Ctrl, Alt) are not shown on the diagram and don't need depressed tracking. Characters are lowercased for matching against the diagram.
crossterm 0.28 provides `KeyEventKind::Press`, `KeyEventKind::Release`, and `KeyEventKind::Repeat` via `KeyEvent.kind`. However, terminal key-release support is inconsistent across terminals. We use a **hybrid approach**: track via Release events when available, with a 150ms timed fallback.
1. **Enable enhanced key events** (`src/main.rs`):
- Call `crossterm::event::PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::REPORT_EVENT_TYPES)` on startup (enables Release events on supported terminals)
- Pop the flags on cleanup
2. **Track depressed keys** (`src/app.rs`):
- Add `depressed_keys: HashSet<char>` field (stores lowercase chars)
- Add `last_key_time: Option<Instant>` for fallback clearing
- On `KeyEventKind::Press` with `KeyCode::Char(ch)`: insert `ch.to_ascii_lowercase()` into `depressed_keys`, set `last_key_time`
- On `KeyEventKind::Release` with `KeyCode::Char(ch)`: remove `ch.to_ascii_lowercase()` from `depressed_keys`
- On tick: if `last_key_time` > 150ms ago and no Release was received, clear `depressed_keys` (fallback for terminals without Release support)
3. **Update event handling** (`src/main.rs` `handle_key`):
- Check `key.kind` — only process typing logic on `KeyEventKind::Press`
- On `KeyEventKind::Release`: call `app.depressed_keys.remove(&ch.to_ascii_lowercase())`
- Filter out `KeyEventKind::Repeat` to avoid double-counting (or treat same as Press for depressed tracking)
4. **Update KeyboardDiagram** (`src/ui/components/keyboard_diagram.rs`):
- Accept `depressed_keys: &HashSet<char>` (all lowercase)
- Rendering priority order: **depressed** (bright/inverted style) > **next_expected** (accent bg) > **focused** (yellow bg) > **unlocked** (finger zone color) > **locked** (dim)
- Depressed style: bold white text on brighter version of the finger color
5. **Investigate "constantly shifting colors" bug:**
- Current code at `main.rs:356-359` passes `lesson.target.get(lesson.cursor)` as `next_char` — this correctly changes on each keystroke
- Verify the finger_color mapping is stable (it uses static match arms — should be fine)
- Most likely the "shifting" perception is the `next_key` highlight moving to adjacent keys as user types — this is correct behavior. The depressed-key highlight will make the interaction much clearer.
### Unit tests:
- Verify `depressed_keys` set grows on Press and shrinks on Release
- Verify fallback clearing works after 150ms timeout
---
## 4. Responsive UI for Small Terminals
**Files:** `src/ui/layout.rs`, `src/main.rs`, `src/ui/components/keyboard_diagram.rs`, `src/ui/components/stats_sidebar.rs`, `src/ui/components/typing_area.rs`
**How typr handles it (from `clones/typr/lua/typr/stats/init.lua:15-17`):**
- Base width: `state.w = 80` columns
- Responsive threshold: `vim.o.columns > ((2 * state.w) + 10)` = `> 170` cols → horizontal stats layout
- Below 170 cols → vertical tabbed stats layout
- Drill view: fixed 80-col centered window, doesn't have a sidebar concept
- Window height adapts: `large_screen and state.h or vim.o.lines - 7`
**Implementation — tiered layout for keydr:**
### Drill View Layout Tiers (based on `area` from `AppLayout::new()`):
**Wide (≥100 cols):** Current layout — typing area (70%) + sidebar (30%) side-by-side, keyboard + progress bar below typing area
**Medium (60-99 cols):**
- Typing area takes full width (no sidebar)
- Compact stats in header bar: `WPM: XX | Acc: XX% | Errors: X`
- Keyboard diagram below typing area (compressed 3-char keys `[x]` instead of `[ x ]`)
- Progress bar below keyboard
**Narrow (<60 cols):**
- Typing area full width
- Stats in header bar only
- No keyboard diagram
- No progress bar
**Short (<20 rows):**
- No keyboard diagram (regardless of width)
- No progress bar
- Typing area + single-line header + single-line footer
### Stats View Layout Tiers:
**Wide (>170 cols):** Side-by-side panels (matching typr threshold: `(2 * 80) + 10`)
**Normal (≤170 cols):** Tabbed view (current behavior, improved styling per item 6)
### Implementation:
1. Modify `AppLayout::new()` to accept area and return different constraint sets based on dimensions
2. Add `LayoutTier` enum: `{ Wide, Medium, Narrow }` computed from `area.width` and `area.height`
3. `render_lesson()` checks tier to decide which components to render
4. `KeyboardDiagram` gets a `compact: bool` flag for 3-char key mode
5. Verify `TypingArea` wraps properly at narrow widths (current implementation should handle this via Ratatui's `Paragraph` wrapping)
---
## 5. Delete Sessions from History
**Files:** `src/app.rs`, `src/main.rs`, `src/ui/components/stats_dashboard.rs`
**Implementation — complete recalculation scope:**
### State machine for history tab interaction:
```
Normal browsing → [j/k/Up/Down] → Move selection cursor
Normal browsing → [x/Delete] → Show confirmation dialog
Confirmation dialog → [y] → Delete session, recalculate, return to Normal
Confirmation dialog → [n/ESC] → Cancel, return to Normal
Normal browsing → [Tab/d/h/k/1/2/3] → Switch tabs (existing behavior)
```
### App state additions (`src/app.rs`):
- `history_selected: usize` — selected row index in history view (0 = most recent)
- `history_confirm_delete: bool` — whether confirmation dialog is showing
### Key bindings — full precedence table for `handle_stats_key`:
**When `history_confirm_delete == true` (confirmation dialog active):**
- `y` → call `delete_session()`, set `history_confirm_delete = false`
- `n` / `ESC` → set `history_confirm_delete = false` (cancel)
- All other keys ignored
**When `stats_tab == 1` (history tab, no dialog):**
- `j` / `Down` → increment `history_selected` (clamp to history length)
- `k` / `Up` → decrement `history_selected` (clamp to 0)
- `x` / `Delete` → set `history_confirm_delete = true`
- `d` / `1` → switch to Dashboard tab (`stats_tab = 0`)
- `h` / `2` → switch to History tab (no-op, already there)
- `3` → switch to Keystrokes tab (`stats_tab = 2`). Note: `k` is NOT a Keystrokes tab shortcut when on history tab — it navigates rows instead.
- `Tab` / `BackTab` → cycle tabs
- `ESC` / `q` → back to menu
**When on other tabs (stats_tab == 0 or 2):**
- Existing behavior unchanged: `d`/`1`, `h`/`2`, `k`/`3` switch tabs, `Tab`/`BackTab` cycle, `ESC`/`q` back to menu
### Delete logic (`src/app.rs` `delete_session()`):
Full recalculation via **chronological replay** to make it "as if the session never happened":
1. **Remove the lesson** from `self.lesson_history` at the correct index (history tab shows reverse order, so actual index = `len - 1 - history_selected`)
2. **Chronological state replay** — reset and rebuild from scratch, oldest→newest:
```
// Reset all derived state
self.key_stats = KeyStatsStore::default();
self.key_stats.target_cpm = self.config.target_cpm();
self.letter_unlock = LetterUnlock::new();
self.profile.total_score = 0.0;
self.profile.total_lessons = 0;
self.profile.streak_days = 0;
self.profile.best_streak = 0;
self.profile.last_practice_date = None;
// Replay each remaining session oldest→newest
for result in &self.lesson_history {
// Update key stats (same as finish_lesson does)
for kt in &result.per_key_times {
if kt.correct {
self.key_stats.update_key(kt.key, kt.time_ms);
}
}
// Update letter unlock
self.letter_unlock.update(&self.key_stats);
// Compute score using current unlock state (matches runtime)
let complexity = compute_complexity(self.letter_unlock.unlocked_count());
let score = compute_score(result, complexity);
self.profile.total_score += score;
self.profile.total_lessons += 1;
// Rebuild streak tracking (same logic as finish_lesson)
let day = result.timestamp.format("%Y-%m-%d").to_string();
// ... streak logic identical to App::finish_lesson
}
self.profile.unlocked_letters = self.letter_unlock.included.clone();
```
This exactly reproduces the runtime scoring path (`src/app.rs:186-218`, `src/engine/scoring.rs:3-7`), including complexity that depends on unlock state at each point in progression.
3. **Persist:** Call `self.save_data()` to write all three files (profile, key_stats, lesson_history)
4. **Adjust selection:** Clamp `history_selected` to new valid range
**Implementation note:** Extract the replay logic into a reusable `rebuild_from_history(&mut self)` method on `App`, since it could also be useful for data recovery.
### Rendering (`stats_dashboard.rs`):
- Selected row gets `bg(colors.accent_dim())` highlight background (existing theme color `accent_dim` = `#45475a`, a subtle dark surface color)
- Confirmation dialog: centered overlay box with border: `"Delete session #X? (y/n)"`
### Unit tests:
- Delete last session → history shrinks by 1, total_lessons decremented
- Delete session → key_stats rebuilt without that session's key times
- Delete all sessions → profile reset to defaults, key_stats empty
- Delete session with only practice day → streak recalculated correctly
---
## 6. Improved Statistics Display (Full Typr-Style Overhaul)
**Files:** `src/ui/components/stats_dashboard.rs`, new `src/ui/components/activity_heatmap.rs`
**Data sources (all derivable from existing persisted data):**
- `lesson_history: Vec<LessonResult>` — has `wpm`, `cpm`, `accuracy`, `correct`, `incorrect`, `total_chars`, `elapsed_secs`, `timestamp`, `per_key_times`
- `key_stats: KeyStatsStore` — has per-key `filtered_time_ms`, `best_time_ms`, `confidence`, `sample_count`, `recent_times`
- No schema migration needed — all new visualizations derive from existing fields
### Dashboard Tab Improvements:
**Summary stats as bordered table:**
```
┌─────────────────────────────────────────────┐
│ Lessons: 42 Avg WPM: 65 Best WPM: 82 │
│ Accuracy: 94.2% Total time: 2h 15m │
└─────────────────────────────────────────────┘
```
**Progress bars** using `` filled / dim `` empty:
- WPM progress: `avg_wpm / target_wpm` (green ≥ goal, accent < goal)
- Accuracy progress: (green ≥ 95%, yellow ≥ 85%, red < 85%)
- Level progress to next level
**WPM bar graph** (last 20 sessions) using `▁▂▃▄▅▆▇█` block characters, replacing the Braille line chart. Color-coded: green above goal, red below.
**Keep accuracy trend chart** (Braille line chart works well for this).
### History Tab Improvements:
**Bordered table:**
```
┌────┬──────┬──────┬───────┬───────┬────────────┐
│ # │ WPM │ Raw │ Acc% │ Time │ Date │
├────┼──────┼──────┼───────┼───────┼────────────┤
│ 42 │ 68 │ 72 │ 96.2% │ 45.2s │ 02/14 10:30│
│ 41 │ 63 │ 67 │ 93.1% │ 52.1s │ 02/14 09:15│
└────┴──────┴──────┴───────┴───────┴────────────┘
```
- Selected row highlighted with distinct background
- WPM goal indicator per row: small inline bar or color indicator
**Character speed distribution** (below table): dot/bar graph of all 26 letters (from typr's history view), using per-key `filtered_time_ms` data already available in `key_stats`.
### Keystrokes Tab Improvements:
**Activity heatmap** (new widget in `src/ui/components/activity_heatmap.rs`):
- 7-month calendar grid grouped by week
- Each day cell: `` or `` colored by session count (0 = dim, 1-5 = light green, 6-15 = medium, 16+ = bright)
- Data source: group `lesson_history` by `timestamp.date()`, count per day
- Month labels along top, day-of-week labels on left (M/W/F or all 7)
- Toggle between first/last 6 months (optional, if space allows)
**Key accuracy heatmap:** show accuracy percentage text on each key, not just color. E.g., `[a 97%]` or use color intensity.
**Top 3 worst keys:** highlighted badges showing the keys with lowest accuracy, matching typr's approach.
**Char times analysis:** Slowest 5 / Fastest 5 keys with times (already exists, clean up formatting with box borders).
### Shared visual improvements:
- Unicode box-drawing borders (`┌─┬─┐`, ``, `└─┴─┘`) via Ratatui's `Block::bordered()` with custom border set
- Bar graphs using `▁▂▃▄▅▆▇█` block characters
- Consistent 2-char padding inside bordered sections
- Color gradients for intensity (heatmap, speed distribution)
---
## File Summary
| File | Changes |
|------|---------|
| `src/app.rs` | Start in lesson mode, add `depressed_keys: HashSet<char>`, `last_key_time`, `history_selected`, `history_confirm_delete`, `delete_session()` with chronological replay via `rebuild_from_history()` |
| `src/main.rs` | Enable keyboard enhancement flags, handle Press/Release events, update `render_lesson` for responsive tiers, update `handle_stats_key` for history selection/deletion state machine |
| `src/event.rs` | Filter key events by kind (pass all events, let main.rs handle kind) |
| `src/session/input.rs` | Add `typo_flags` tracking — insert on incorrect, preserve through backspace |
| `src/session/lesson.rs` | Add `typo_flags: HashSet<usize>`, `typo_count()` method. Keep `accuracy()`/`incorrect_count()` for live display. |
| `src/session/result.rs` | Use `typo_flags.len()` for final `incorrect` count and accuracy |
| `src/ui/layout.rs` | Add `LayoutTier` enum, compute from area dimensions, return different constraint sets |
| `src/ui/components/keyboard_diagram.rs` | Accept `depressed_keys: &HashSet<char>`, render depressed state, add compact mode |
| `src/ui/components/stats_dashboard.rs` | Full overhaul: bordered tables, bar graphs, progress bars, row selection, delete confirmation overlay, character speed distribution |
| `src/ui/components/activity_heatmap.rs` | New: 7-month activity calendar heatmap widget |
| `src/ui/components/stats_sidebar.rs` | Compact single-line mode for medium terminals |
| `src/ui/components/typing_area.rs` | Verify wrapping at narrow widths |
---
## Verification
### Manual Testing:
1. **Start in drill:** Launch app → immediately in Adaptive typing lesson, no menu
2. **Error tracking:** Type wrong char, backspace, type correct char → accuracy < 100%, error count ≥ 1. Type wrong at same pos twice, backspace twice, type correct → still only 1 error for that position.
3. **Keyboard:** Type characters → pressed key visually highlights. Next expected key highlighted. Releasing key clears highlight (or after 150ms fallback).
4. **Responsive:** Resize terminal to 50×15, 80×25, 120×40, 200×50 → layout adapts, no panics, no overlapping text
5. **Delete sessions:** Stats → History → select row → press `x` → confirm dialog → press `y` → session gone, all stats recalculated. Verify key_stats and letter_unlock are consistent.
6. **Statistics:** Visual inspection of bordered tables, bar graphs, activity heatmap, progress bars
### Automated Tests:
- `session/lesson.rs`: typo_flags behavior (wrong→backspace→correct counts as error, multiple errors at same pos = 1 typo)
- `session/input.rs`: process_char sets typo_flags, process_backspace preserves them
- `app.rs`: delete_session recalculates total_lessons, total_score, key_stats, letter_unlock, streak fields
- `engine/key_stats.rs`: verify rebuild from scratch produces same results as incremental updates (within EMA tolerance)

View File

@@ -0,0 +1,507 @@
# Skill Tree Progression System & Whitespace Support
## Context
keydr currently tracks only a-z lowercase letters in its adaptive unlock system. Since keydr aims to be a coding-focused typing tutor, it must also train capitals, numbers, punctuation, whitespace (tabs/newlines), and code-specific symbols. The current flat a-z progression needs to be replaced with a branching skill tree that lets players choose their training path after mastering lowercase letters. Additionally, code drills currently strip newlines into spaces, making them unrealistic for real-world code practice.
## Skill Tree Structure
The tree is flat: a-z is the root, and all other branches are direct siblings at the same level. Once a-z is complete, all branches unlock simultaneously and the user can choose any order.
```
┌─────────────────┐
│ a-z Lowercase │ (ROOT - everyone starts here)
│ 26 keys, freq │
│ order unlock │
└────────┬────────┘
┌─────────┬──────────┼──────────┬──────────┐
▼ ▼ ▼ ▼ ▼
┌─────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌──────────┐
│Capitals │ │Numbers │ │ Prose │ │White- │ │ Code │
│ A-Z │ │ 0-9 │ │ Punct. │ │ space │ │ Symbols │
│ 3 lvls │ │ 2 lvls │ │ 3 lvls │ │ 2 lvls │ │ 4 lvls │
└─────────┘ └────────┘ └────────┘ └────────┘ └──────────┘
```
### Prerequisites
- **a-z Lowercase** (root): Always available from start
- **All other branches**: Require a-z complete (all 26 lowercase letters confident). Once a-z is done, all 5 branches unlock simultaneously. User freely chooses which to pursue.
---
## Branch Status State Machine
Each branch has an explicit status:
```rust
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BranchStatus {
Locked, // Prerequisites not met
Available, // Prerequisites met, user hasn't started
InProgress, // User has begun drilling this branch
Complete, // All levels in branch are done
}
```
**Transitions:**
- `Locked → Available`: When a-z branch reaches `Complete`
- `Available → InProgress`: **Only** when user explicitly launches a branch drill from the skill tree (start-on-select model). The global adaptive drill does NOT auto-start branches.
- `InProgress → Complete`: When all keys in all levels of the branch reach confidence >= 1.0
**Multiple branches active**: Yes. The user can have multiple branches `InProgress` simultaneously. Each tracks its own current level independently.
**Global adaptive scope**: Only includes keys from `InProgress` and `Complete` branches. `Available` branches are not included — the user must visit the skill tree to start them.
---
## Detailed Level Breakdown
### Branch: a-z Lowercase (Root)
Uses existing frequency-order system. Starts with 6 keys, unlocks one at a time when all current keys reach confidence >= 1.0. Branch is "complete" when all 26 letters are confident.
Order: `e t a o i n s h r d l c u m w f g y p b v k j x q z`
Total keys: **26**
### Branch: Capital Letters (3 levels)
- **Level 1 — Common Sentence Capitals** (8 keys): `T I A S W H B M`
- **Level 2 — Name Capitals** (10 keys): `J D R C E N P L F G`
- **Level 3 — Remaining Capitals** (8 keys): `O U K V Y X Q Z`
Total keys: **26**
Text generation rules:
- First word of each "sentence" (after `.` `?` `!` or at drill start) gets capitalized
- ~10-15% of words get capitalized as proper-noun-like words
- Focused capital letter is boosted (40% chance to appear in word starts)
### Branch: Numbers (2 levels)
- **Level 1 — Common Digits** (5 keys): `1 2 3 4 5`
- **Level 2 — All Digits** (5 keys): `0 6 7 8 9`
Total keys: **10**
Text generation rules:
- ~15% of words replaced with number expressions using only unlocked digits
- Patterns: counts ("3 items"), years ("2024"), IDs ("room 42"), measurements ("7 miles")
### Branch: Prose Punctuation (3 levels)
- **Level 1 — Essential** (3 keys): `. , '`
- **Level 2 — Common** (4 keys): `; : " -`
- **Level 3 — Expressive** (4 keys): `? ! ( )`
Total keys: **11**
Text generation rules follow natural prose patterns:
- `.` ends sentences (every 5-15 words), `,` separates clauses
- `'` in contractions (don't, it's, we'll)
- `"` wrapping quoted phrases, `;` between clauses, `:` before lists
- `-` in compound words (well-known), `?` for questions, `!` for exclamations
- `( )` for parenthetical asides
### Branch: Whitespace (2 levels)
- **Level 1 — Enter/Return** (1 key): `\n`
- **Level 2 — Tab/Indent** (1 key): `\t`
Total keys: **2**
Text generation rules:
- Line breaks at sentence boundaries (every ~60-80 chars)
- Tabs for indentation in code-like structures
- Once unlocked, **default adaptive drills automatically become multi-line**
### Branch: Code Symbols (4 levels)
- **Level 1 — Arithmetic & Assignment** (5 keys): `= + * /` and `-` (shared with Prose Punct L2)
- **Level 2 — Grouping** (6 keys): `{ } [ ] < >`
- **Level 3 — Logic & Reference** (5 keys): `& | ^ ~` and `!` (shared with Prose Punct L3)
- **Level 4 — Special** (7 keys): `` @ # $ % _ \ ` ``
Total keys: **23** (21 unique + 2 shared with Prose Punctuation)
Text generation rules:
- L1: Prose with simple expressions (`x = a + b`, `total = price * qty`)
- L2: Code-pattern templates (`if (x) { return y; }`, `arr[0]`)
- L3: Bitwise/logical patterns (`a & b`, `!flag`, `*ptr`)
- L4: Language-specific patterns (`@decorator`, `#include`, `snake_case`)
**Grand total**: 98 keys across branches, **96 unique keys** (after deducting 2 shared: `-` and `!`). `TOTAL_UNIQUE_KEYS` is derived at startup by collecting all keys from all branch definitions into a `HashSet` and taking `len()`. Stored as a field on `SkillTree` for use in scoring and UI.
---
## Shared Keys Between Branches
Two keys appear in multiple branches:
- `-` appears in Prose Punctuation L2 and Code Symbols L1
- `!` appears in Prose Punctuation L3 and Code Symbols L3
**Rule**: Confidence is tracked once per character in `KeyStatsStore` (keyed by `char`). If a user masters `-` in Prose Punctuation, it is automatically confident in Code Symbols too. When checking level completion, the branch reads the single confidence value for that char. This is idempotent — no special handling needed.
---
## Focused Key Policy
### Global Adaptive Drill (from menu)
1. Collect all keys from all `InProgress` branches (current level's keys only) plus all `Complete` branch keys
2. Find the key with the **lowest confidence < 1.0** across this entire set
3. If all keys are confident, no focused key (maintenance mode)
4. Boost the focused key in text generation (40% probability)
### Branch-Specific Drill (from skill tree)
1. Collect keys from the selected branch including **all prior completed levels** (as background reinforcement) plus the **current level's keys**, plus all a-z keys
2. Find the key with the **lowest confidence < 1.0** within the **current level keys only** (prior level keys are reinforcement, not focus targets)
3. If all current level keys are confident, advance the level and focus on the weakest new key
4. Boost the focused key in text generation (40% probability)
5. Prior-level keys always appear in generated text for reinforcement but are never the focused key
### Branches with Zero Progress
When a branch is `Available` but user hasn't started it yet:
- Launching a drill from that branch transitions it to `InProgress` at level 1
- The focused key is the weakest among level 1's keys (likely all at 0.0 confidence, so pick the first in definition order)
---
## Scoring
Current formula: `complexity = unlocked_count / 26`
**New formula**: `complexity = total_unlocked_keys / TOTAL_UNIQUE_KEYS`
Where `TOTAL_UNIQUE_KEYS = 96` is computed from branch definitions (deduplicated across shared keys). This scales naturally — the more branches the user has unlocked, the higher the complexity multiplier.
Level formula remains: `level = floor(sqrt(total_score / 100))`.
Menu header changes from `"X/26 letters"` to `"X/96 keys"`.
---
## Skill Tree UI
### New Screen: `AppScreen::SkillTree`
Accessible from menu via `[t] Skill Tree`. Renders **vertically** as a scrollable list.
```
╔══════════════════════════════════════════════════════════════════╗
║ SKILL TREE ║
╠══════════════════════════════════════════════════════════════════╣
║ ║
║ ★ Lowercase a-z COMPLETE 26/26 ║
║ ████████████████████████████████████████ Level 26/26 ║
║ ║
║ ── Branches (unlocked after a-z) ────────────────────────── ║
║ ║
║ ► Capitals A-Z Lvl 2/3 18/26 keys ║
║ ████████████████████░░░░░░░░░░░░ 69% ║
║ ║
║ Numbers 0-9 Lvl 0/2 0/10 keys ║
║ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0% ║
║ ║
║ Prose Punctuation Lvl 1/3 3/11 keys ║
║ ██████████░░░░░░░░░░░░░░░░░░░░░ 27% ║
║ ║
║ Whitespace Lvl 0/2 0/2 keys ║
║ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0% ║
║ ║
║ Code Symbols Lvl 0/4 0/23 keys ║
║ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0% ║
║ ║
╠══════════════════════════════════════════════════════════════════╣
║ ► Capitals A-Z Level 2/3 ║
║ L1: T I A S W H B M (complete) ║
║ L2: J [D] R C E N P L F G (in progress, focused: D) ║
║ L3: O U K V Y X Q Z (locked) ║
║ Avg Confidence: ████████░░ 82% ║
║ ║
║ [Enter] Start Drill [↑↓/jk] Navigate [q] Back ║
╚══════════════════════════════════════════════════════════════════╝
```
**Layout:**
- **Top section**: Vertical list of all branches with status prefix, level, key count, progress bar
- **Bottom section**: Detail panel showing per-level key breakdown, confidence bars, focused key
- **Footer**: Controls
**Node states (prefix):**
- Locked: grayed out, no prefix, not selectable
- Available: normal color, no prefix
- In Progress `►`: accent color
- Complete `★`: gold/green
**Navigation:** `↑↓` / `j/k` move selection. `Enter` launches branch drill. `q` returns to menu.
**Keyboard diagram**: For non-printable keys (`Enter`, `Tab`), show them as labeled keys on the keyboard diagram in their standard positions. No special handling needed — they're physical keys with fixed positions.
---
## Code & Passage Drill Changes (Unranked Modes)
Code and Passage drills remain as separate menu options.
1. **Unranked tagging**: Add `ranked: bool` to `DrillResult` with `#[serde(default = "default_true")]` for backward compat
2. **Derive ranked from DrillContext**: At drill start, set `ranked = (drill_mode == Adaptive)`. Code/Passage → `ranked = false`.
3. **No progression**: `finish_drill()` gates skill tree updates on `result.ranked`
4. **History replay**: `rebuild_from_history()` uses `result.ranked` as the gate. No legacy fallback — since we reset on schema change (WIP policy), old history without `ranked` field won't exist.
5. **Visual indicators**:
- Drill header: "Code Drill (Unranked)" / "Passage Drill (Unranked)" in dimmed/muted color
- Result screen: "Unranked — does not count toward skill tree"
- Stats dashboard history: unranked rows shown with muted styling
---
## Whitespace Handling
### Tokenized Render Model (`typing_area.rs`)
Replace direct char→span rendering with a `RenderToken` approach to handle one-to-many char-to-cell mapping:
```rust
struct RenderToken {
target_idx: usize, // Index into DrillState.target
display: String, // What to show (e.g., "↵", "→···", "a")
style: Style, // Computed style (correct/incorrect/cursor/pending)
}
```
**Display mapper:**
- `\n` → visible `↵` marker token + hard line break (new `Line` in paragraph)
- `\t` → visible `→` marker + padding `·` tokens to next 4-char tab stop
- All other chars → single token with char as display
**Cursor/style mapping:** Maintain a `Vec<(usize, usize)>` mapping from `target_idx` to first display cell position. When highlighting cursor or errors, look up the target index to find which display tokens to style.
**Multi-line rendering:** Change from single `Line` to `Vec<Line>`. Split on newline tokens. Each line is a separate `Line` in the `Paragraph`.
### Input Pipeline (`main.rs` + `session/input.rs`)
Current flow: `main.rs` matches `KeyCode::Char(ch)``app.type_char(ch)`. Enter/Tab are currently consumed by other handlers (menu nav, etc.).
**Changes in `main.rs`:**
- When `screen == Drill` and drill is active:
- `KeyCode::Enter``app.type_char('\n')` **unconditionally** (correctness decided by `process_char()`)
- `KeyCode::Tab``app.type_char('\t')` **unconditionally** (correctness decided by `process_char()`)
- `KeyCode::BackTab` (Shift+Tab) → ignore (no action)
- These must be checked **before** the existing Esc/Enter handlers for drill screen
- If Enter/Tab is typed when not expected, it registers as an error on the current char — same as typing any wrong key
**No changes to `session/input.rs`**: `process_char()` already compares `ch == expected` generically. It will work with `'\n'` and `'\t'` as-is.
### Code Drill Updates (`generator/code_syntax.rs`)
- Embedded snippets change from single-line `&str` to multi-line string literals with preserved indentation
- `extract_code_snippets()`: preserve original newlines and leading whitespace instead of `split_whitespace().join(" ")`
- `generate()`: join snippets with `\n\n` instead of `" "`
---
## Data Model Changes
### Persistence Policy (WIP stage)
**No backward compatibility migration.** On schema mismatch, reset persisted files to defaults. Bump schema version to 2. Add a note in changelog that local progress is intentionally reset for this version. This avoids over-engineering migration logic during early development.
### `ProfileData` (schema v2)
```rust
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ProfileData {
pub schema_version: u32, // 2
pub skill_tree: SkillTreeProgress, // Replaces unlocked_letters
pub total_score: f64,
pub total_drills: u32,
pub streak_days: u32,
pub best_streak: u32,
pub last_practice_date: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SkillTreeProgress {
pub branches: HashMap<String, BranchProgress>, // String keys for stable JSON
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BranchProgress {
pub status: BranchStatus,
pub current_level: usize, // 0-indexed into branch's levels array
// current_level = 0 means working on first level (plan's "Level 1")
// current_level = levels.len() only when status == Complete
}
```
**Indexing invariant**: `current_level` is always 0-indexed into `BranchDefinition.levels`. When the plan says "Level 1", "Level 2", etc. in human-readable text, that maps to `current_level = 0`, `current_level = 1`, etc. in code. A branch with `current_level = 0` and `status = InProgress` is actively working on its first level.
**HashMap uses `String` keys** (e.g., `"lowercase"`, `"capitals"`, `"numbers"`, etc.) for stable JSON serialization. `BranchId` enum has `to_key() -> &'static str` and `from_key()` methods.
### `DrillResult` Addition
```rust
#[serde(default = "default_true")]
pub ranked: bool,
```
### `KeyStatsStore`
No structural change. Already `HashMap<char, KeyStat>` — works for any char.
---
## Skill Tree Definition (Source of Truth)
Hard-coded static definition in `src/engine/skill_tree.rs`:
```rust
pub struct BranchDefinition {
pub id: BranchId,
pub name: &'static str,
pub levels: Vec<LevelDefinition>,
}
pub struct LevelDefinition {
pub name: &'static str,
pub keys: Vec<char>,
}
```
All branch/level/key definitions are `const`/`static` arrays. No data-driven manifest needed at this stage. The `SkillTree` struct holds:
- The static definition (reference)
- The persisted `SkillTreeProgress` (mutable state)
- Methods: `unlocked_keys(scope)`, `focused_key(scope, &KeyStatsStore)`, `update(&KeyStatsStore)`, `branch_status(id)`, `all_branches()`
---
## Implementation Phases
### Phase 1: Skill Tree Core & Data Model
**Goal**: Replace `LetterUnlock` with `SkillTree`, update persistence.
1. Create `src/engine/skill_tree.rs`:
- `BranchId` enum (`Lowercase, Capitals, Numbers, ProsePunctuation, Whitespace, CodeSymbols`)
- `BranchStatus` enum (`Locked, Available, InProgress, Complete`)
- `BranchDefinition`, `LevelDefinition` structs
- Static branch definitions with all keys per level
- `SkillTree` struct with `update()`, `unlocked_keys()`, `focused_key()`, `branch_status()`
2. Update `src/store/schema.rs`: new `ProfileData` with `SkillTreeProgress`, schema v2, reset on mismatch
3. Add `ranked: bool` to `DrillResult` in `src/session/result.rs`
4. Update `src/app.rs`: replace `letter_unlock: LetterUnlock` with `skill_tree: SkillTree`, update `finish_drill()` to gate on `ranked`, update `rebuild_from_history()`, update scoring complexity formula
5. Delete/replace `src/engine/letter_unlock.rs`
**Key files**: `src/engine/skill_tree.rs` (new), `src/engine/letter_unlock.rs` (delete), `src/store/schema.rs`, `src/session/result.rs`, `src/app.rs`
**Tests**:
- Skill tree status transitions (Locked → Available → InProgress → Complete)
- Shared key confidence propagation
- Focused key selection (global vs branch scope)
- Level completion and advancement
- Schema reset on version mismatch
**Acceptance criteria**: `cargo build` passes, `cargo test` passes, existing adaptive drills work with skill tree (a-z only), scoring uses new formula.
### Phase 2: Whitespace Input & Rendering
**Goal**: Support Enter/Tab in typing drills with proper display.
1. Update `src/ui/components/typing_area.rs`: tokenized render model with `RenderToken`, multi-line support, visible `↵` and `→` markers
2. Update `src/main.rs`: route `KeyCode::Enter``'\n'` and `KeyCode::Tab``'\t'` when in drill mode, ignore `BackTab`
3. Update `src/generator/code_syntax.rs`: preserve newlines/indentation in snippets, change embedded snippets to multi-line, fix `extract_code_snippets()` to preserve whitespace
4. Optionally update `src/generator/passage.rs` with multi-line passage variants
**Key files**: `src/ui/components/typing_area.rs`, `src/main.rs`, `src/generator/code_syntax.rs`
**Tests**:
- RenderToken generation for strings with `\n` and `\t`
- Cursor position mapping with expanded tokens
- Enter/Tab input processing (reuse existing `process_char()` — just verify `'\n'` and `'\t'` work)
**Acceptance criteria**: Code drills display multi-line with visible whitespace markers, Enter/Tab advance the cursor correctly, backspace works across line boundaries.
### Phase 3: Text Generation for Capitals & Punctuation
**Goal**: Generate drill text that naturally incorporates capitals and punctuation.
1. Create `src/generator/capitalize.rs`: post-processing pass that capitalizes sentence starts and occasional words, using only unlocked capital letters
2. Create `src/generator/punctuate.rs`: post-processing pass that inserts periods, commas, apostrophes, etc. at natural positions, using only unlocked punctuation
3. Update `src/generator/phonetic.rs` or `src/app.rs` `generate_text()`: apply capitalize/punctuate passes when those branches are active
4. Update `src/engine/filter.rs` `CharFilter`: add awareness of which char types are allowed (lowercase, uppercase, punctuation, etc.)
**Key files**: `src/generator/capitalize.rs` (new), `src/generator/punctuate.rs` (new), `src/generator/phonetic.rs`, `src/app.rs`, `src/engine/filter.rs`
**Acceptance criteria**: Adaptive drills with Capitals branch active produce properly capitalized text. Drills with Prose Punctuation active have natural punctuation placement.
### Phase 4: Text Generation for Numbers & Code Symbols
**Goal**: Generate drill text with numbers and code symbol patterns.
1. Create `src/generator/numbers.rs`: injects number expressions into prose using only unlocked digits
2. Create `src/generator/code_patterns.rs`: code-pattern templates for Code Symbols branch drills (expressions, brackets, operators)
3. Update `src/app.rs` `generate_text()`: apply number/code passes based on active branches
4. For whitespace branch: when active, insert `\n` at sentence boundaries in generated text
**Key files**: `src/generator/numbers.rs` (new), `src/generator/code_patterns.rs` (new), `src/app.rs`
**Acceptance criteria**: Number expressions use only unlocked digits. Code symbol drills produce recognizable code-like patterns. Whitespace branch generates multi-line output.
### Phase 5: Skill Tree UI
**Goal**: Navigable skill tree screen with branch detail and drill launch.
1. Add `AppScreen::SkillTree` to `src/app.rs`
2. Create `src/ui/components/skill_tree.rs`: vertical branch list + detail panel widget
3. Update `src/main.rs`: handle key events for skill tree screen (navigation, drill launch)
4. Update `src/ui/components/menu.rs`: add `[t] Skill Tree` option
5. Update menu header: show `"X/96 keys"` instead of `"X/26 letters"`
6. Add `DrillMode::BranchDrill(BranchId)` or similar to track drill origin for branch-specific focus
**Key files**: `src/ui/components/skill_tree.rs` (new), `src/app.rs`, `src/main.rs`, `src/ui/components/menu.rs`
**Acceptance criteria**: Can navigate to skill tree from menu, see all branches with correct status, launch a branch-specific drill, return to menu.
### Phase 6: Unranked Mode Polish
**Goal**: Clearly distinguish ranked vs unranked drills in UI.
1. Update drill header in `src/main.rs`: show "(Unranked)" for Code/Passage modes
2. Update `src/ui/components/dashboard.rs` result screen: note "does not count toward skill tree"
3. Update `src/ui/components/stats_dashboard.rs`: muted styling for unranked history rows
4. Verify `rebuild_from_history()` correctly uses `ranked` field to gate skill tree updates
**Key files**: `src/main.rs`, `src/ui/components/dashboard.rs`, `src/ui/components/stats_dashboard.rs`, `src/app.rs`
**Acceptance criteria**: Code/Passage drills clearly marked unranked. Stats history shows visual distinction. Ranked drills advance skill tree, unranked don't.
---
## Verification
### Automated Tests
- **Skill tree transitions**: `Locked → Available → InProgress → Complete` for each branch
- **Shared keys**: Mastering `!` in Prose Punct → confident in Code Symbols too
- **Focused key**: Global scope selects weakest across all active branches; branch scope selects within branch
- **Level advancement**: Completing all keys in a level auto-advances to next
- **Ranked/unranked**: Only ranked drills update skill tree in `rebuild_from_history()`
- **Whitespace tokens**: RenderToken expansion for `\n` and `\t` produces correct display strings and index mapping
- **Input routing**: `'\n'` and `'\t'` correctly processed as typed characters
### Manual Testing
1. Launch app → a-z trunk works as before
2. Complete a-z (or edit profile to simulate) → all 5 branches show as Available
3. Navigate skill tree → select Capitals → launch drill → see capitalized text
4. Complete Capitals L1 → L2 keys appear in drills
5. Launch default adaptive with multiple branches active → text mixes all unlocked keys
6. Launch Code/Passage drill → header shows "(Unranked)", no skill tree progress
7. Start Whitespace branch → default adaptive becomes multi-line
8. Type Enter/Tab in code drills → cursor advances correctly, errors tracked
9. Quit and relaunch → progress preserved
10. Delete `~/.local/share/keydr/` → app resets cleanly to fresh state

View File

@@ -0,0 +1,197 @@
# Skill Tree Integration Fixes & UI Improvements
## Context
After adding a skill tree progression system, several parts of the app weren't fully integrated. This plan addresses 7 issues: progress bar confusion, broken skill tree bars, missing selectability, duplicate displays, incomplete keyboard visualization, code drill formatting issues, and a missing menu shortcut.
## Architecture Foundations
### A. Layout-Driven Keyboard Model
**Files:** `src/keyboard/layout.rs`, new `src/keyboard/model.rs`
The existing `KeyboardLayout` in `layout.rs` only stores `Vec<Vec<char>>` (base layer). We need a shared model used by both drill and stats keyboards.
Create `src/keyboard/model.rs`:
- `PhysicalKey { base: char, shifted: char }` - represents one physical key with both layers
- `KeyboardModel { rows: Vec<Vec<PhysicalKey>> }` - full keyboard definition
- Factory methods: `KeyboardModel::qwerty()`, `::dvorak()`, `::colemak()` - each returns the full layout
- Helper: `base_to_shifted(ch) -> Option<char>` and `shifted_to_base(ch) -> Option<char>` derived from the model
- Helper: `physical_key_for(ch) -> Option<&PhysicalKey>` - lookup by either base or shifted char
The QWERTY model:
```
Row 0 (number): (`~) (1!) (2@) (3#) (4$) (5%) (6^) (7&) (8*) (9() (0)) (-_) (=+)
Row 1 (top): (qQ) (wW) (eE) (rR) (tT) (yY) (uU) (iI) (oO) (pP) ([{) (]}) (\|)
Row 2 (home): (aA) (sS) (dD) (fF) (gG) (hH) (jJ) (kK) (lL) (;:) ('")
Row 3 (bottom): (zZ) (xX) (cC) (vV) (bB) (nN) (mM) (,<) (.>) (/?)
```
Update `KeyboardLayout` to use `KeyboardModel` internally (or replace it).
Replace `qwerty_finger(ch)` with a layout-aware API:
- `KeyboardModel::finger_for(&self, key: &PhysicalKey) -> FingerAssignment` - each layout defines finger assignments per physical key position (row, col)
- For shifted chars, callers first resolve to physical key via `physical_key_for(ch)`, then look up finger
- This eliminates the QWERTY-only char match and works for Dvorak/Colemak
Load the active layout from `config.keyboard_layout` and pass it through to all keyboard rendering.
### B. Dual Progress Metrics
**File:** `src/engine/skill_tree.rs`
Add `branch_unlocked_count(id: BranchId) -> usize` method:
- Lowercase: delegates to `lowercase_unlocked_count()`
- Others: sums `keys.len()` for levels `0..=current_level` when InProgress; all keys when Complete; 0 otherwise
All UI uses two metrics per branch:
- **Unlocked**: `branch_unlocked_count(id)` / `branch_total_keys(id)` - how far through the branch
- **Mastered**: `branch_confident_keys(id, stats)` / `branch_total_keys(id)` - how many keys at confidence >= 1.0
### C. Code Language Config
**File:** `src/config.rs`
Replace the implicit `code_languages: Vec<String>` usage with a clearer model:
- Add `code_language: String` field (single language: "rust", "python", "javascript", "go", "all")
- Keep `code_languages` for backwards compat but derive from `code_language`
- Settings cycling and code generation both read `code_language`
- "all" picks a random language per drill in `generate_text()`
---
## Implementation Changes (in order)
### 1. Fix missing `[c] Settings` shortcut in menu footer
**File:** `src/main.rs` (`render_menu` function)
- Change footer string to: `" [1-3] Start [t] Skill Tree [s] Stats [c] Settings [q] Quit "`
- Verify no other footers are missing hints by checking all `render_*` functions
### 2. Fix duplicate fraction display on Lowercase branch
**File:** `src/ui/components/skill_tree.rs` (`render_branch_list`)
- Currently shows `"6/26 0/26 keys"` because status_text and confident/total are concatenated
- Change to single display: `"6/26 unlocked"` when no mastered keys, or `"6/26 unlocked (3 mastered)"` when some exist
- Apply same pattern to all branches: `"Lvl 1/3 5/10 unlocked (2 mastered)"`
### 3. Make Lowercase a-z selectable in skill tree
**Files:** `src/ui/components/skill_tree.rs`, `src/main.rs` (`handle_skill_tree_key`)
- Add `BranchId::Lowercase` to `selectable_branches()` at index 0
- Merge the separate root Lowercase rendering (currently in `render_branch_list` lines 113-170) into the main branch loop
- Apply selection highlighting to Lowercase using same `is_selected` logic as other branches
- Keep "Branches (unlocked after a-z)" separator after Lowercase (index 0) and before Capitals (index 1)
- Detail panel for Lowercase: show progressive unlock state `"Unlocked 6/26 letters"` instead of `"Level 1/1"`. Show each unlocked key with its confidence, locked keys dimmed
- Enter on InProgress Lowercase starts branch drill (existing `start_branch_drill` handles this)
- Update `branch_list_height` calculation to account for the merged layout
### 4. Fix skill tree progress bars - combined unlocked/mastered bar
**Files:** `src/engine/skill_tree.rs`, `src/ui/components/skill_tree.rs`
- Add `branch_unlocked_count()` method (see Architecture B above)
- Change progress bars to a **combined dual-metric bar**: the bar is divided into three segments:
- Filled (accent color): mastered keys (confidence >= 1.0)
- Filled (dimmer color): unlocked but not yet mastered
- Empty (background): locked keys
- This works because mastered <= unlocked <= total always holds
- Update `progress_bar_str` to accept two ratios and render with two fill colors
- **Rounding rule**: compute cell counts from raw counts (not ratios) to avoid rounding violations:
- `mastered_cells = (mastered * width / total)` (floor)
- `unlocked_cells = (unlocked * width / total).max(mastered_cells)` (floor, clamped)
- `empty_cells = width - unlocked_cells`
- This guarantees `mastered_cells <= unlocked_cells <= width` with no overlap
- Text label shows: `"6/26 unlocked, 3 mastered"`
### 5. Add per-key mastery display in skill tree detail panel (phase 2 if time allows)
**File:** `src/ui/components/skill_tree.rs` (`render_detail_panel`)
- In the detail view for the selected branch, show a mini progress bar per key
- Each key shows: `char [====----] 75%` where the bar represents confidence (0-100%)
- Keys already at confidence >= 1.0 show as fully filled with success color
- Keys not yet unlocked show dimmed with "locked" label
- Focused key is highlighted (existing logic already identifies it)
- Layout: keys in their level groups, each on its own line with the mini bar
- Note: This adds UI complexity. Implement after core issues (1-4, 6-8) are stable.
### 6. Replace drill screen progress bar with per-branch progress
**Files:** `src/main.rs` (`render_drill`), new `src/ui/components/branch_progress_list.rs`
Create a new `BranchProgressList` widget (not stretching the existing `ProgressBar`):
- Shows one compact line per active branch (InProgress or Complete), plus an overall line
- Each line: `" ▶ Lowercase [████░░░░] 6/26"`
- Uses the combined dual-metric bar from Issue 4 (mastered vs unlocked segments)
- Active drill branch (from `app.drill_scope`) is highlighted with accent color and `▶` prefix
- Other branches use dimmer color and `·` prefix
Layout budgeting by `LayoutTier` (unbordered, plain lines to maximize density):
- **Wide** (height >= 25): show all active branches (InProgress/Complete). `Constraint::Length(active_count.min(6) as u16 + 1)` (+1 for "Overall" line)
- **Wide** (height 20-24): show active drill branch + overall only. `Constraint::Length(2)`
- **Medium**: show active drill branch only. `Constraint::Length(1)`
- **Narrow**: hide progress (current behavior)
### 7. Full keyboard visualization
**Files:** `src/keyboard/model.rs` (new), `src/keyboard/layout.rs` (update), `src/ui/components/keyboard_diagram.rs`, `src/ui/components/stats_dashboard.rs`, `src/main.rs`, `src/app.rs`
#### 7a. Build KeyboardModel (Architecture A above)
#### 7b. Drill keyboard
- `KeyboardDiagram` takes `&KeyboardModel` instead of hardcoded `ROWS`
- Add `shift_held: bool` field
- **Shift state handling**: Primary source is `key.modifiers.contains(KeyModifiers::SHIFT)` checked on every Press event. Set `app.shift_held = true` when modifier present, `false` when absent. Additionally, on tick (100ms), if `shift_held` is true and no key event has been received in 200ms, clear it as a fallback. This means: shifted display appears when a shifted key is pressed, and naturally clears on the next unshifted keypress or after timeout. Acceptance: brief flicker (1-2 frames) on quick shift+key combos is acceptable; sustained wrong state is not.
- When `shift_held`, display `physical_key.shifted` for each key; otherwise `physical_key.base`
- Full mode: 4 rows (number, top, home, bottom) + visual-only labels for Tab/Backspace/Shift/Enter at row edges
- Compact mode: 3 rows letters only (current behavior, but driven from `KeyboardModel`)
- Height: `Constraint::Length(7)` for full (4 rows + 2 border + label), `Constraint::Length(5)` for compact
- Replace `finger_color(ch)` with layout-aware `finger_for(model, physical_key) -> FingerAssignment` that works for any layout (see 7a)
- `is_unlocked` check: map the displayed char against `unlocked_keys` list
#### 7c. Stats keyboard heatmap
- Two sub-rows per physical row: top = shifted layer (dimmer styling), bottom = base layer
- Each cell shows char + accuracy % (existing format)
- Height: `Constraint::Length(12)` (4 physical rows x 2 sub-rows + 2 borders + header)
- Load from `KeyboardModel` based on `config.keyboard_layout`
- Accuracy lookup: use existing `get_key_accuracy(char)` for each layer independently
- **Width fallback**: if terminal width < 70, collapse to base layer only (hide shifted sub-rows). Existing min-width guard pattern from `render_keyboard_heatmap` (width < 50 => skip) is preserved.
### 8. Code drill improvements
**Files:** `src/generator/code_syntax.rs`, `src/app.rs`, `src/main.rs`, `src/config.rs`
#### 8a. Multi-line embedded snippets
- Reformat all snippets in `rust_snippets()`, `python_snippets()`, `javascript_snippets()`, `go_snippets()` to be multi-line with realistic formatting
- Go: use `\t` for indentation (gofmt convention)
- Rust/Python/JavaScript: use 4 spaces
- Keep Tab key input as literal `\t` (do NOT convert to spaces) - this is needed for whitespace branch progression and the typing area already renders tabs properly
- Add basic validation for fetched snippets: require at least one newline and reject snippets that are all on one line (filter in `extract_code_snippets`)
#### 8b. Language selection screen
- Add `AppScreen::CodeLanguageSelect` to `AppScreen` enum
- Add `code_language_selected: usize` to `App`
- Screen flow: Menu `'2'` or Enter on "Code Drill" -> `CodeLanguageSelect` -> select language -> start drill
- ESC from language select returns to Menu
- Direct hotkeys in language select: `1`=Rust, `2`=Python, `3`=JavaScript, `4`=Go, `5`=All
- Enter confirms selection
- Arrow keys / j/k navigate
- Default selection: whichever language matches current `config.code_language`
- On confirm: update `config.code_language`, save config, set `drill_mode = Code`, start drill
- Render: centered bordered box with language list, highlighting selected item, showing `(current)` next to the default
#### 8c. Config changes
- Add `code_language: String` field to Config with default "rust"
- Settings screen language cycling updates `code_language`
- `generate_text` for Code mode reads `code_language` (if "all", picks random)
---
## Verification
- `cargo build` -- no compilation errors
- `cargo test` -- existing tests pass; add tests for:
- `branch_unlocked_count` returns correct values for each branch state
- `KeyboardModel::qwerty()` covers all skill tree chars
- Selection bounds don't panic with Lowercase in `selectable_branches`
- Manual testing checklist:
- Menu footer shows `[c] Settings`
- Skill tree: Lowercase is selectable with arrow keys, Enter starts drill
- Skill tree: single fraction display, no duplicate numbers
- Skill tree: progress bars show dual unlocked/mastered segments
- Skill tree detail: per-key mastery bars shown
- Drill: branch progress bars visible, active branch highlighted
- Drill keyboard: full layout visible, keys shift on Shift press
- Stats keyboard: both layers shown
- Code drill: language selection appears, snippets have proper newlines/indentation
- Non-adaptive drills: ESC still shows partial result correctly
- Dvorak/Colemak: keyboard renders correctly when layout config changed

View File

@@ -0,0 +1,757 @@
# Code Drill Feature Parity Plan
## Context
The code drill feature is significantly less developed than the passage drill. The passage drill has a full onboarding flow, lazy downloads with progress bars, configurable network/cache settings, and rich content from Project Gutenberg. The code drill only has 4 hardcoded languages with ~20-30 built-in snippets each, a basic language selection screen, and a partially-implemented synchronous GitHub fetch that blocks the UI thread. There's also a completely dead `github_code.rs` file that's never used.
This plan is split into three delivery phases:
1. **Phase 1**: Feature parity with passage drill (onboarding, downloads, progress bar, config)
2. **Phase 2**: Language expansion and extraction improvements
3. **Phase 3**: Custom repo support
## Current Code Drill Analysis
### What exists:
- **`generator/code_syntax.rs`**: `CodeSyntaxGenerator` with built-in snippets for 4 languages (rust, python, javascript, go), a `try_fetch_code()` that synchronously fetches from hardcoded GitHub URLs (blocking UI), `extract_code_snippets()` for parsing functions from source
- **`generator/code_patterns.rs`**: Post-processor that inserts code-like expressions into adaptive drill text (unrelated to code drill mode)
- **`generator/github_code.rs`**: **Dead code** - `GitHubCodeGenerator` struct with `#[allow(dead_code)]`, never referenced outside its own file
- **Config**: Only `code_language: String` - no download/network/onboarding settings
- **Screens**: `CodeLanguageSelect` only - no intro, no download progress
- **Languages**: rust, python, javascript, go, "all"
### What passage drill has that code drill doesn't:
- Onboarding intro screen (`PassageIntro`) with config for downloads/dir/limits
- `passage_onboarding_done` flag (shows intro only on first use)
- `passage_downloads_enabled` toggle
- `passage_download_dir` configurable path
- `passage_paragraphs_per_book` content limit
- Lazy download: on drill start, downloads one book if not cached
- Background download thread with atomic progress reporting
- Download progress screen (`PassageDownloadProgress`) with byte-level progress bar
- Fallback to built-in content when downloads off
### Built-in snippet whitespace review:
- **Rust**: 4-space indent - idiomatic
- **Python**: 4-space indent - idiomatic
- **JavaScript**: 4-space indent - idiomatic
- **Go**: `\t` tab indent - idiomatic
All whitespace is correct. The escaped string format (`\n`, `\t`, `\"`) is hard to read. Converting to raw strings (`r#"..."#`) improves maintainability.
---
## Phase 1: Feature Parity with Passage Drill
Goal: Give code drill the same onboarding, download, caching, and config infrastructure as passage drill. Keep the existing 4 languages. No language expansion yet.
### Step 1.1: Delete dead code
- Delete `src/generator/github_code.rs` entirely
- Remove `pub mod github_code;` from `src/generator/mod.rs`
### Step 1.2: Convert built-in snippets to raw strings
**File**: `src/generator/code_syntax.rs`
Convert all 4 language snippet arrays from escaped strings to `r#"..."#` raw strings. Example:
Before: `"fn main() {\n println!(\"hello\");\n}"`
After:
```rust
r#"fn main() {
println!("hello");
}"#
```
Go snippets: `\t` becomes actual tab characters inside raw strings (correct for Go).
Keep all existing snippets at their current count (~20-30 per language). Do NOT reduce them -- since downloads default to off, these are the primary content source for new users.
Validation: run `cargo test` after conversion. Add a focused test that asserts a sample snippet's char content matches expectations (catches any accidental whitespace changes).
### Step 1.3: Add config fields for code drill
**File**: `src/config.rs`
Add fields mirroring passage drill config:
```rust
#[serde(default = "default_code_downloads_enabled")]
pub code_downloads_enabled: bool, // default: false
#[serde(default = "default_code_download_dir")]
pub code_download_dir: String, // default: dirs::data_dir()/keydr/code/
#[serde(default = "default_code_snippets_per_repo")]
pub code_snippets_per_repo: usize, // default: 50
#[serde(default = "default_code_onboarding_done")]
pub code_onboarding_done: bool, // default: false
```
`code_download_dir` default uses `dirs::data_dir()` (same pattern as `default_passage_download_dir`) for cross-platform portability.
`code_snippets_per_repo` is a **download-time extraction cap**: when fetching from a repo, extract at most this many snippets and write them to cache. The generator reads whatever is in the cache without re-filtering.
Update `Default` impl. Add `default_*` functions.
**Config normalization**: After deserialization in `App::new()` (not `Config::load()`, to avoid coupling config to generator internals), validate `code_language` against `code_language_options()`. If invalid (e.g., old/renamed key), reset to `"rust"`.
**Old cache migration**: The old `DiskCache("code_cache")` entries (in `~/.local/share/keydr/code_cache/`) are simply ignored. They used a different key format (`{lang}_snippets`) and location. No migration or cleanup needed -- they'll be naturally superseded by the new cache in `code_download_dir`.
### Step 1.4: Define language data structures
**File**: `src/generator/code_syntax.rs`
Add structures for the language registry. Phase 1 only populates the 4 existing languages + "all":
```rust
pub struct CodeLanguage {
pub key: &'static str, // filesystem-safe identifier (e.g. "rust", "bash")
pub display_name: &'static str, // UI label (e.g. "Rust", "Shell/Bash")
pub extensions: &'static [&'static str], // e.g. &[".rs"], &[".py", ".pyi"]
pub repos: &'static [CodeRepo],
pub has_builtin: bool,
}
pub struct CodeRepo {
pub key: &'static str, // filesystem-safe identifier for cache naming
pub urls: &'static [&'static str], // raw.githubusercontent.com file URLs to fetch
}
pub const CODE_LANGUAGES: &[CodeLanguage] = &[
CodeLanguage {
key: "rust",
display_name: "Rust",
extensions: &[".rs"],
repos: &[
CodeRepo {
key: "tokio",
urls: &[
"https://raw.githubusercontent.com/tokio-rs/tokio/master/tokio/src/sync/mutex.rs",
"https://raw.githubusercontent.com/tokio-rs/tokio/master/tokio/src/net/tcp/stream.rs",
],
},
CodeRepo {
key: "serde",
urls: &[
"https://raw.githubusercontent.com/serde-rs/serde/master/serde/src/ser/mod.rs",
],
},
],
has_builtin: true,
},
// ... python, javascript, go with similar structure
// Move existing hardcoded URLs from try_fetch_code() into these repo definitions
];
```
Helper functions:
```rust
pub fn code_language_options() -> Vec<(&'static str, String)>
// Returns [("rust", "Rust"), ("python", "Python"), ..., ("all", "All (random)")]
pub fn language_by_key(key: &str) -> Option<&'static CodeLanguage>
pub fn is_language_cached(cache_dir: &str, key: &str) -> bool
// Checks if any {key}_*.txt files exist in cache_dir AND have non-empty content (>0 bytes)
// Uses direct filesystem scanning (NOT DiskCache -- DiskCache has no list/glob API)
```
### Step 1.5: Generalize download job struct
**File**: `src/app.rs`
Rename `PassageDownloadJob` to `DownloadJob`. It's already generic (just `Arc<AtomicU64>`, `Arc<AtomicBool>`, and a thread handle). Update all passage references to use the renamed type. No behavior change.
### Step 1.6: Add code drill app state
**File**: `src/app.rs`
Add `CodeDownloadCompleteAction` enum (parallels `PassageDownloadCompleteAction`):
```rust
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CodeDownloadCompleteAction {
StartCodeDrill,
ReturnToSettings,
}
```
Add screen variants:
```rust
CodeIntro, // Onboarding screen for code drill
CodeDownloadProgress, // Download progress for code files
```
Add app fields:
```rust
pub code_intro_selected: usize,
pub code_intro_downloads_enabled: bool,
pub code_intro_download_dir: String,
pub code_intro_snippets_per_repo: usize,
pub code_intro_downloading: bool,
pub code_intro_download_total: usize,
pub code_intro_downloaded: usize,
pub code_intro_current_repo: String,
pub code_intro_download_bytes: u64,
pub code_intro_download_bytes_total: u64,
pub code_download_queue: Vec<usize>, // repo indices within current language's repos array
pub code_drill_language_override: Option<String>,
pub code_download_action: CodeDownloadCompleteAction,
code_download_job: Option<DownloadJob>,
```
### Step 1.7: Remove blocking fetch from generator
**File**: `src/generator/code_syntax.rs`
Remove `try_fetch_code()` from `CodeSyntaxGenerator`. All network I/O moves to the app layer with background threads.
Update constructor:
```rust
pub fn new(rng: SmallRng, language: &str, cache_dir: &str) -> Self
```
Update `load_cached_snippets()`: scan `cache_dir` for files matching `{language}_*.txt`, read each, split on `---SNIPPET---` delimiter. This replaces the `DiskCache("code_cache")` approach with direct filesystem reads (since `DiskCache` has no listing/glob API and the cache dir is now user-configurable).
### Step 1.8: Add download function
**File**: `src/generator/code_syntax.rs`
```rust
pub fn download_code_repo_to_cache_with_progress<F>(
cache_dir: &str,
language_key: &str,
repo: &CodeRepo,
snippets_limit: usize,
on_progress: F,
) -> bool
where
F: FnMut(u64, Option<u64>),
```
This function:
1. Creates `cache_dir` if needed (`fs::create_dir_all`)
2. Fetches each URL in `repo.urls` using `fetch_url_bytes_with_progress` (already exists in `cache.rs`)
3. Runs `extract_code_snippets()` on each fetched file
4. Combines all snippets, truncates to `snippets_limit`
5. Writes to `{cache_dir}/{language_key}_{repo.key}.txt` with `---SNIPPET---` delimiter
6. Returns `true` on success
**Error handling**: If any individual URL fails (404, timeout, network error), skip it and continue with others. If zero snippets extracted from all URLs, return `false`. The app layer treats `false` as "skip this repo, continue queue" (same as passage drill's failure behavior).
### Step 1.9: Implement code drill flow methods
**File**: `src/app.rs`
**`go_to_code_intro()`**: Initialize intro screen state (downloads toggle, dir, snippets limit from config). Set `code_download_action = CodeDownloadCompleteAction::StartCodeDrill`. Set screen to `CodeIntro`.
**`start_code_drill()`**: Lazy download logic with explicit language resolution:
```rust
pub fn start_code_drill(&mut self) {
// Step 1: Resolve concrete language (never download with "all" selected)
if self.code_drill_language_override.is_none() {
let chosen = if self.config.code_language == "all" {
// Pick from languages with built-in OR cached content only
// Never pick a network-only language that isn't cached
let available = languages_with_content(&self.config.code_download_dir);
if available.is_empty() {
"rust".to_string() // ultimate fallback
} else {
let idx = self.rng.gen_range(0..available.len());
available[idx].to_string()
}
} else {
self.config.code_language.clone()
};
self.code_drill_language_override = Some(chosen);
}
let chosen = self.code_drill_language_override.clone().unwrap();
// Step 2: Check if we need to download
if self.config.code_downloads_enabled
&& !is_language_cached(&self.config.code_download_dir, &chosen)
{
if let Some(lang) = language_by_key(&chosen) {
if !lang.repos.is_empty() {
// Pick one random repo to download
let repo_idx = self.rng.gen_range(0..lang.repos.len());
self.code_download_queue = vec![repo_idx];
self.code_intro_download_total = 1;
self.code_intro_downloaded = 0;
self.code_intro_downloading = true;
self.code_intro_current_repo = format!("{}", lang.repos[repo_idx].key);
self.code_download_action = CodeDownloadCompleteAction::StartCodeDrill;
self.code_download_job = None;
self.screen = AppScreen::CodeDownloadProgress;
return;
}
}
// Language has no repos or unknown: fall through to built-in
}
// Step 3: If language has no built-in AND no cache AND downloads off → fallback
if !is_language_cached(&self.config.code_download_dir, &chosen) {
if let Some(lang) = language_by_key(&chosen) {
if !lang.has_builtin {
// Network-only language with no cache: fall back to "rust"
self.code_drill_language_override = Some("rust".to_string());
}
}
}
// Step 4: Start the drill
self.drill_mode = DrillMode::Code;
self.drill_scope = DrillScope::Global;
self.start_drill();
}
```
Key behavior: `"all"` only selects from `languages_with_content()` (built-in OR cached). This prevents the dead-end loop of repeatedly picking uncached network-only languages and forcing download screens. In Phase 2, once network-only languages get cached via manual download, they are automatically included in `"all"` selection.
**`languages_with_content(cache_dir: &str) -> Vec<&'static str>`**: Returns language keys that have either `has_builtin: true` or non-empty cache files in `cache_dir`.
**`process_code_download_tick()`**, **`spawn_code_download_job()`**: Same pattern as passage equivalents, using `download_code_repo_to_cache_with_progress` and `DownloadJob`.
**`start_code_downloads_from_settings()`**: Mirror `start_passage_downloads_from_settings()` with `CodeDownloadCompleteAction::ReturnToSettings`.
### Step 1.10: Update code language select flow
**File**: `src/main.rs`
Update `handle_code_language_key()` and `render_code_language_select()`:
- Still shows the same 4+1 languages for now (Phase 2 expands this)
- Wire Enter to `confirm_code_language_and_continue()`:
```rust
fn confirm_code_language_and_continue(app: &mut App, langs: &[&str]) {
if app.code_language_selected >= langs.len() { return; }
app.config.code_language = langs[app.code_language_selected].to_string();
let _ = app.config.save();
if app.config.code_onboarding_done {
app.start_code_drill();
} else {
app.go_to_code_intro();
}
}
```
### Step 1.11: Add event handlers and renderers
**File**: `src/main.rs`
Add to screen dispatch in `handle_key()` and `render()`:
**`handle_code_intro_key()`**: Same field navigation as `handle_passage_intro_key()` but operates on `code_intro_*` fields. 4 fields:
1. Enable network downloads (toggle)
2. Download directory (editable text)
3. Snippets per repo (numeric, adjustable)
4. Start code drill (confirm button)
On confirm: save config fields, set `code_onboarding_done = true`, call `start_code_drill()`.
**`handle_code_download_progress_key()`**: Esc/q to cancel. On cancel:
1. Clear `code_download_queue`
2. Set `code_intro_downloading = false`
3. If a `code_download_job` is in-flight, detach it (set to `None` without joining -- the thread will finish and write to cache, which is harmless; the `Arc` atomics keep the thread safe)
4. Reset `code_drill_language_override` to `None`
5. Go to menu
This matches the existing passage download cancel behavior (passage also does not join/abort in-flight threads on Esc).
**`render_code_intro()`**: Mirror `render_passage_intro()` layout. Title: "Code Downloads Setup". Explanatory text: "Configure code source settings before your first code drill." / "Downloads are lazy: code is fetched only when first needed."
**`render_code_download_progress()`**: Mirror `render_passage_download_progress()`. Title: "Downloading Code Source". Show repo name, byte progress bar.
Update tick handler:
```rust
if (app.screen == AppScreen::CodeIntro
|| app.screen == AppScreen::CodeDownloadProgress)
&& app.code_intro_downloading
{
app.process_code_download_tick();
}
```
### Step 1.12: Update generate_text for Code mode
**File**: `src/app.rs`
Update `DrillMode::Code` in `generate_text()`:
```rust
DrillMode::Code => {
let filter = CharFilter::new(('a'..='z').collect());
let lang = self.code_drill_language_override
.clone()
.unwrap_or_else(|| self.config.code_language.clone());
let rng = SmallRng::from_rng(&mut self.rng).unwrap();
let mut generator = CodeSyntaxGenerator::new(
rng, &lang, &self.config.code_download_dir,
);
self.code_drill_language_override = None;
let text = generator.generate(&filter, None, word_count);
(text, Some(generator.last_source().to_string()))
}
```
### Step 1.13: Settings integration
**Files**: `src/main.rs`, `src/app.rs`
Add settings rows after existing code language field (index 3):
- Index 4: Code Downloads: On/Off
- Index 5: Code Download Dir: editable path
- Index 6: Code Snippets per Repo: numeric
- Index 7: Download Code Now: action button
Shift existing passage settings indices up by 4. Update `settings_cycle_forward`/`settings_cycle_backward` and max `settings_selected` bound.
**"Download Code Now" behavior**: Downloads all uncached curated repos for the currently selected `code_language` only. If `code_language == "all"`, downloads all uncached repos for all curated languages. Does NOT include custom repos. Mirrors passage behavior where "Download Passages Now" downloads all uncached books.
**`start_code_downloads()`**: Queues all uncached repos for the currently selected language. Used by intro screen "confirm" flow when downloads are enabled.
### Phase 1 Verification
1. `cargo build` -- compiles
2. `cargo test` -- all existing tests pass, plus new tests:
- `test_languages_with_content_includes_builtin` -- verifies built-in languages appear in `languages_with_content()` even with empty cache dir
- `test_languages_with_content_excludes_uncached_network_only` -- verifies network-only languages without cache are not returned
- `test_config_serde_defaults` -- verifies new config fields deserialize with correct defaults from empty/old configs
- `test_raw_string_snippets_preserved` -- spot-check that raw string conversion didn't alter snippet content
3. `cargo build --no-default-features` -- compiles, network features gated
4. Manual tests:
- Menu → Code Drill → language select → first time shows CodeIntro
- CodeIntro with downloads off → confirms → starts drill with built-in snippets
- CodeIntro with downloads on → confirms → shows CodeDownloadProgress → downloads repo → starts drill with downloaded content
- Subsequent code drills skip onboarding
- "all" language mode only picks from languages with content (never triggers download)
- Settings shows code drill fields, values persist on restart
- Passage drill flow completely unchanged
- Esc during download progress → returns to menu, no crash
---
## Phase 2: Language Expansion and Extraction Improvements
Goal: Add 8 more built-in languages and ~18 network-only languages, improve snippet extraction.
### Step 2.1: Add 8 built-in language snippet sets
**File**: `src/generator/code_syntax.rs`
Add ~10-15 raw-string snippets each for: **typescript, java, c, cpp, ruby, swift, bash, lua**
Language keys: `typescript`/`ts`, `java`, `c`, `cpp`, `ruby`, `swift`, `bash` (display: "Shell/Bash"), `lua`
All with idiomatic whitespace:
- TypeScript: 4-space indent
- Java: 4-space indent
- C: 4-space indent
- C++: 4-space indent
- Ruby: 2-space indent
- Swift: 4-space indent
- Bash: 2-space indent (common convention)
- Lua: 2-space indent
Update `get_snippets()` match to include all 12 languages.
### Step 2.2: Expand language registry to ~30 languages
**File**: `src/generator/code_syntax.rs`
Add ~18 network-only entries to `CODE_LANGUAGES` with curated repos:
kotlin, scala, haskell, elixir, clojure, perl, php, r, dart, zig, nim, ocaml, erlang, julia, objective-c, groovy, csharp, fsharp
Each gets 2-3 repos with specific raw.githubusercontent.com file URLs. **Exclude SQL and CSS** -- their syntax is too different from procedural code for function-level extraction to work well.
This is a significant data curation subtask: for each language, identify 2-3 well-known repos with permissive licenses (MIT/Apache/BSD), select 2-5 representative source files per repo with functions/methods to extract.
**Acceptance threshold**: Each language must yield at least 10 extractable snippets from its curated repos (verified by running `extract_code_snippets` against fetched files). Languages that fall below this threshold should be dropped from the registry rather than shipped with poor content.
### Step 2.3: Improve snippet extraction
**File**: `src/generator/code_syntax.rs`
Add a `func_start_patterns` field to `CodeLanguage`:
```rust
pub struct CodeLanguage {
// ... existing fields ...
pub block_style: BlockStyle,
}
pub enum BlockStyle {
Braces(&'static [&'static str]), // fn/def/func patterns, brace-delimited (C, Java, Go, etc.)
Indentation(&'static [&'static str]), // def/class patterns, indentation-delimited (Python)
EndDelimited(&'static [&'static str]), // def/class patterns, closed by `end` keyword (Ruby, Lua, Elixir)
}
```
Update `extract_code_snippets()` to accept `BlockStyle`:
- `Braces`: current behavior with configurable start patterns (C, Java, Go, JS, etc.)
- `Indentation`: track indent level changes to find block boundaries (Python only)
- `EndDelimited`: scan for matching `end` keyword at same indent level to close blocks (Ruby, Lua, Elixir)
Language-specific patterns:
- Java: `["public ", "private ", "protected ", "static ", "class ", "interface "]`
- Ruby: `["def ", "class ", "module "]` (EndDelimited style -- uses `end` keyword to close blocks)
- C/C++: `["int ", "void ", "char ", "float ", "double ", "struct ", "class ", "template"]`
- Swift: `["func ", "class ", "struct ", "enum ", "protocol "]`
- Bash: `["function ", "() {"]` (Braces style, simple)
- etc.
### Step 2.4: Make language select scrollable
**File**: `src/main.rs`
With 30+ languages, the selection screen needs scrolling. Add `code_language_scroll: usize` to `App`. Show a viewport of ~15 items. Add keybindings:
- Up/Down: navigate
- PageUp/PageDown: jump 10 items
- Home/End or `g`/`G`: jump to top/bottom
- `/`: type-to-filter (optional, nice-to-have)
Mark each language as "(built-in)" or "(download required)" in the list.
### Phase 2 Verification
1. `cargo build && cargo test`
2. Manual: verify all 12 built-in languages produce readable snippets with correct indentation
3. Manual: select a network-only language → triggers download → produces good snippets
4. Manual: scrollable language list works, indicators are accurate
5. Verify each built-in language's snippet whitespace is idiomatic
---
## Phase 3: Custom Repo Support
Goal: Let users specify their own GitHub repos to train on.
### Step 3.1: Design custom repo fetch strategy
Custom repos require solving problems that curated repos don't have:
- **Branch discovery**: Use GitHub API `GET /repos/{owner}/{repo}` to find `default_branch`. Requires `User-Agent` header (GitHub rejects requests without it; use `"keydr/{version}"`). Optionally support a `GITHUB_TOKEN` env var for authenticated requests (raises rate limit from 60 to 5000 req/hour).
- **File discovery**: Use GitHub API `GET /repos/{owner}/{repo}/git/trees/{branch}?recursive=1` to list all files, filter by language extensions. Same `User-Agent` and optional auth headers. If the response has `"truncated": true` (repos with >100k files), reject with a user-facing error: "Repository is too large for automatic file discovery. Please use a smaller repo or fork with fewer files."
- **Rate limiting**: Cache the tree response to disk. On 403/429 responses, show error: "GitHub API rate limit reached. Try again later or set GITHUB_TOKEN env var for higher limits."
- **File selection**: From matching files, randomly select 3-5 files to download via raw.githubusercontent.com (no API needed for file content)
- **Language detection**: Match file extensions against `CodeLanguage.extensions` field. If ambiguous or no match, prompt user.
- **All API requests**: Set `Accept: application/vnd.github.v3+json` header, timeout 10s.
### Step 3.2: Add config field and validation
**File**: `src/config.rs`
```rust
#[serde(default)]
pub code_custom_repos: Vec<String>, // Format: "owner/repo" or "owner/repo@language"
```
Parse function:
```rust
pub fn parse_custom_repo(input: &str) -> Option<CustomRepo> {
// Accepts: "owner/repo", "owner/repo@language", "https://github.com/owner/repo"
// Validates: owner and repo contain only valid GitHub chars
// Returns None on invalid input
}
```
### Step 3.3: Settings UI for custom repos
Add a settings section showing current custom repos as a scrollable list. Keybindings:
- `a`: add new repo (enters text input mode)
- `d`/`x`: delete selected repo
- Up/Down: navigate list
### Step 3.4: Code language select "Add custom repo" option
At the bottom of the language select list, add an "[ + Add custom repo ]" option. Selecting it enters a text input mode for `owner/repo`. On confirm:
1. Validate format
2. Add to `code_custom_repos` config
3. Auto-detect language from repo (via API tree listing file extensions)
4. If language ambiguous, show a small picker
5. Queue download of that repo
### Step 3.5: Integrate custom repos into download flow
When `start_code_drill()` runs for a language, include matching custom repos in the download candidates alongside curated repos.
### Phase 3 Verification
1. Add a custom repo → appears in settings list
2. Start drill → custom repo snippets appear
3. Invalid repo format → shows error, doesn't save
4. GitHub rate limit → shows informative error
5. Remove custom repo → removed from config and future drills
---
## Critical Files Summary
| File | Phase | Changes |
|------|-------|---------|
| `src/generator/github_code.rs` | 1 | Delete |
| `src/generator/mod.rs` | 1 | Remove github_code module |
| `src/generator/code_syntax.rs` | 1, 2 | Raw strings, new constructor, remove blocking fetch, language registry, download fn, new snippet sets, improved extraction |
| `src/config.rs` | 1, 3 | New code drill config fields, validation |
| `src/app.rs` | 1 | DownloadJob rename, new screens/state/flow methods, CodeDownloadCompleteAction |
| `src/main.rs` | 1, 2 | New handlers/renderers, updated settings, scrollable language list |
| `src/generator/cache.rs` | 1 | No changes (reuse existing `fetch_url_bytes_with_progress`) |
## Existing Code to Reuse
- `generator::cache::fetch_url_bytes_with_progress` -- already handles progress callbacks, used for passage downloads
- `generator::cache::DiskCache` -- NOT reused for code cache (no listing API); use direct `fs::read_dir` + `fs::read_to_string` instead
- `PassageDownloadJob` pattern (atomics + thread) -- generalized into `DownloadJob`
- `passage::extract_paragraphs` pattern -- referenced for extraction design but not directly reused
- `passage::download_book_to_cache_with_progress` -- structural template for `download_code_repo_to_cache_with_progress`
---
## Phase 2.5: Improve Snippet Extraction Quality
### Context
After Phase 2, the verification test (`test_verify_repo_urls`) shows many languages producing far fewer than 100 snippets. Root causes:
1. **Per-file cap of 50** in `extract_code_snippets()` (line 1869) limits output even from large source files
2. **Keyword-only matching** — extraction only starts when a line begins with a recognized keyword (e.g. `fn `, `def `, `class `). Many valid code blocks (anonymous functions, method chains, match arms, closures, etc.) are missed.
3. **Narrow keyword lists** — some languages are missing patterns for common constructs (e.g. `macro_rules!` in Rust, `@interface` in Objective-C)
4. **`code_snippets_per_repo` default of 50** caps total output per download
### Goal
Get every language to produce 100+ snippets from its curated repos, without sacrificing snippet quality. Do this by:
1. Widening keyword patterns to capture more language constructs
2. Adding a structural fallback that extracts well-formed code blocks by structure when keywords alone don't find enough
3. Raising the per-file and per-repo snippet caps
### Step 2.5.1: Raise snippet caps
**File**: `src/generator/code_syntax.rs`
Change `snippets.truncate(50)``snippets.truncate(200)` in `extract_code_snippets()`.
**File**: `src/config.rs`
Change `default_code_snippets_per_repo()``200`.
### Step 2.5.2: Widen keyword patterns
**File**: `src/generator/code_syntax.rs`
Add missing start patterns to existing languages. These are patterns that should have been there from the start — they represent common, well-defined constructs that produce good typing drill snippets:
| Language | Add patterns |
|----------|-------------|
| Rust | `"macro_rules! "`, `"mod "`, `"const "`, `"static "`, `"type "` |
| Python | `"async def "` is already there. Add `"@"` (decorators start blocks) |
| JavaScript | `"class "`, `"const "`, `"let "`, `"export "` |
| Go | No changes needed (already has `"func "`, `"type "`) |
| TypeScript | `"class "`, `"const "`, `"let "`, `"export "`, `"interface "` |
| Java | `"abstract "`, `"final "`, `"@"` (annotations start blocks) |
| C | `"typedef "`, `"#define "`, `"enum "` |
| C++ | `"namespace "`, `"typedef "`, `"#define "`, `"enum "`, `"constexpr "`, `"auto "` |
| Ruby | Add `"attr_"`, `"scope "`, `"describe "`, `"it "` |
| Swift | `"var "`, `"let "`, `"init("`, `"deinit "`, `"extension "`, `"typealias "` |
| Bash | `"if "`, `"for "`, `"while "`, `"case "` |
| Kotlin | `"override fun "` already there. Add `"val "`, `"var "`, `"enum "`, `"annotation "`, `"typealias "` |
| Scala | `"val "`, `"var "`, `"type "`, `"implicit "`, `"given "`, `"extension "` |
| PHP | `"class "`, `"interface "`, `"trait "`, `"enum "` |
| Dart | Add `"Widget "`, `"get "`, `"set "`, `"enum "`, `"typedef "`, `"extension "` |
| Elixir | `"defmacro "`, `"defstruct"`, `"defprotocol "`, `"defimpl "` |
| Zig | `"test "`, `"var "` |
| Haskell | Already broad. No changes. |
| Objective-C | `"@interface "`, `"@implementation "`, `"@protocol "`, `"typedef "` |
| Others | Review on a case-by-case basis during implementation |
### Step 2.5.3: Add structural fallback extraction
**File**: `src/generator/code_syntax.rs`
When keyword-based extraction yields fewer than 20 snippets from a file, run a second pass that extracts code blocks purely by structure. This captures anonymous functions, nested blocks, and other constructs that don't start with recognized keywords.
#### Design
Add a `structural_fallback: bool` field to each `BlockStyle` variant:
```rust
pub enum BlockStyle {
Braces {
patterns: &'static [&'static str],
structural_fallback: bool,
},
Indentation {
patterns: &'static [&'static str],
structural_fallback: bool,
},
EndDelimited {
patterns: &'static [&'static str],
structural_fallback: bool,
},
}
```
Set `structural_fallback: true` for all languages. This can be disabled per-language if it produces poor results.
Update `extract_code_snippets()`:
```rust
pub fn extract_code_snippets(source: &str, block_style: &BlockStyle) -> Vec<String> {
let mut snippets = keyword_extract(source, block_style);
if snippets.len() < 20 && has_structural_fallback(block_style) {
let structural = structural_extract(source, block_style);
// Add structural snippets that don't overlap with keyword ones
for s in structural {
if !snippets.contains(&s) {
snippets.push(s);
}
}
}
snippets.truncate(200);
snippets
}
```
#### Structural extraction for Braces languages
`structural_extract_braces(source)`:
1. Scan for lines containing `{` where brace depth transitions from 0→1 or 1→2
2. Capture from that line until depth returns to its starting level
3. Apply the same quality filters: 3-30 lines, 20+ non-whitespace chars, ≤800 bytes
4. Skip noise blocks: reject snippets where first non-blank line is only `{`, or where the block is just imports/use statements
#### Structural extraction for Indentation languages
`structural_extract_indent(source)`:
1. Scan for non-blank lines at indentation level 0 (top-level) that are followed by indented lines
2. Capture the top-level line + all subsequent lines with greater indentation
3. Apply same quality filters
4. Skip noise: reject if all body lines are `import`/`from`/`use`/`#include` statements
#### Structural extraction for EndDelimited languages
`structural_extract_end(source)`:
1. Scan for lines at top-level indentation followed by indented body ending with `end`
2. Same quality filters and noise rejection
#### Noise filtering
A snippet is "noise" and should be rejected if:
- First meaningful line (after stripping comments) is just `{` or `}`
- Body consists entirely of `import`, `use`, `from`, `require`, `include`, or blank lines
- It's a single-statement block (only 1 non-blank body line after the opening)
### Step 2.5.4: Add more source URLs for low-count languages
After implementing the extraction improvements, re-run `test_verify_repo_urls` to identify languages still under 100 snippets. For those, add 1-2 more source file URLs from the same or new repos to increase raw material.
This step is intentionally deferred until after extraction improvements, since better extraction may push many languages over the 100 threshold without needing more URLs.
### Phase 2.5 Verification
1. `cargo test` — all existing tests pass
2. Run `cargo test test_verify_repo_urls -- --ignored --nocapture` — verify all 30 languages produce 50+ snippets (ideally 100+)
3. Spot-check structural fallback snippets for 3-4 languages — verify they contain real code, not just import blocks or noise
4. `cargo build --no-default-features` — compiles without network features
5. Verify no change to built-in snippet behavior (built-in snippets don't go through extraction)

View File

@@ -0,0 +1,188 @@
# Import/Export Feature Plan
## Context
Users need a way to back up and transfer their keydr data between machines. Currently, data is spread across `~/.config/keydr/config.toml` (config) and `~/.local/share/keydr/*.json` (profile, key stats, drill history). This feature adds Export and Import actions to the Settings page, producing/consuming a single combined JSON file.
## Export Format
Canonical filename: `keydr-export-2026-02-21.json` (date is `Utc::now()`).
```json
{
"keydr_export_version": 1,
"exported_at": "2026-02-21T12:00:00Z",
"config": { ... },
"profile": { ... },
"key_stats": { ... },
"ranked_key_stats": { ... },
"drill_history": { ... }
}
```
- `exported_at` uses `DateTime<Utc>` (chrono, serialized as RFC3339).
- On import, `keydr_export_version` is checked: if it does not equal the current supported version (1), import is rejected with the error `"Unsupported export version: {v} (expected 1)"`. Future versions can add migration functions as needed.
## Import Scope
Import applies **everything except machine-local path fields**:
- **Imported**: target_wpm, theme, keyboard_layout, word_count, code_language, passage_book, download toggle booleans, snippets_per_repo, paragraphs_per_book, onboarding flags, and all progress data (profile, key stats, drill history).
- **Preserved from current config**: `code_download_dir`, `passage_download_dir` (machine-local paths stay as-is).
- Theme and keyboard_layout are imported as-is. If the imported theme is unavailable on the target machine, `Theme::load()` falls back to `terminal-default` and the success message includes a note: `"Imported successfully (theme '{name}' not found, using default)"`.
## Changes
### 1. Add export data struct (`src/store/schema.rs`)
Add an `ExportData` struct with all the fields above, deriving `Serialize`/`Deserialize`. Include `keydr_export_version: u32` and `exported_at: DateTime<Utc>` metadata fields.
### 2. Add export/import methods to `JsonStore` (`src/store/json_store.rs`)
- `export_all(&self, config: &Config) -> Result<ExportData>` — loads all data files and bundles with config into `ExportData`.
- `import_all(&self, data: &ExportData) -> Result<()>`**transactional two-phase write** with best-effort rollback:
1. **Stage phase**: write each data file to a `.tmp` sibling (profile.json.tmp, key_stats.json.tmp, etc.). If any `.tmp` write fails, delete all `.tmp` files created so far and return an error. Originals are untouched.
2. **Commit phase**: for each file, rename the existing original to `.bak`, then rename `.tmp` to final. If any rename fails mid-sequence, **rollback**: restore all `.bak` files back to their original names and clean up remaining `.tmp` files. After successful commit, delete all `.bak` files.
**Contract**: this is best-effort, not strictly atomic. If the process is killed or the disk fails during the commit phase, `.bak` files may be left behind. On next app startup, if `.bak` files are detected in the data directory, show a warning in the status message: `"Recovery files found from interrupted import. Data may be inconsistent — consider re-importing."` and clean up the `.bak` files.
### 3. Add config validation on import (`src/config.rs`)
Add a `Config::validate(&mut self, valid_language_keys: &[&str])` method that:
- Clamps `target_wpm` to 10..=200
- Clamps `word_count` to 5..=100
- Calls `normalize_code_language()` for code language validation
- Falls back to defaults for unrecognized theme names (via `Theme::load()` fallback, already handled)
This is called after merging imported config fields, before saving.
### 4. Add status message enum and app state fields (`src/app.rs`)
Add a structured status type:
```rust
pub enum StatusKind { Success, Error }
pub struct StatusMessage { pub kind: StatusKind, pub text: String }
```
New fields on `App`:
- `pub settings_confirm_import: bool` — controls the import warning dialog
- `pub settings_export_conflict: bool` — controls the export overwrite conflict dialog
- `pub settings_status_message: Option<StatusMessage>` — transient status, cleared on next keypress
- `pub settings_export_path: String` — editable export destination path
- `pub settings_import_path: String` — editable import source path
- `pub settings_editing_export_path: bool` — whether export path is being edited
- `pub settings_editing_import_path: bool` — whether import path is being edited
**Invariant**: at most one modal/edit state is active at a time. When entering any modal (confirm_import, export_conflict) or edit mode, clear all other modal/edit flags first.
Default export path: `dirs::download_dir()` / `keydr-export-{YYYY-MM-DD}.json`.
Default import path: same canonical filename (`dirs::download_dir()` / `keydr-export-{YYYY-MM-DD}.json`), editable.
If `dirs::download_dir()` returns `None`, fall back to `dirs::home_dir()`, then `"."`. On export, if the parent directory of the target path doesn't exist, return an error `"Directory does not exist: {parent}"` rather than silently creating it.
### 5. Add app methods (`src/app.rs`)
- `export_data()` — builds `ExportData` from current state, writes JSON to `settings_export_path` via **atomic write** (write to `.tmp` in same directory, then rename to final path). If file already exists at that path, sets `settings_export_conflict = true` instead of writing. Sets `StatusMessage` on success/error.
- `export_data_overwrite()` — calls the same atomic-write logic without the existence check. The rename atomically replaces the old file; no pre-delete needed.
- `export_data_rename()` — delegates to `next_available_path()`, a free function that implements **conditional suffix normalization**: strips a trailing `-N` suffix only when the base file (without suffix) exists in the same directory. This prevents accidental stripping of intrinsic name components (e.g. date segments like `-01`). Then scans for the lowest unused `-N` suffix. Works for any filename. E.g. if `my-backup.json` and `my-backup-1.json` exist, picks `my-backup-2.json`. If called with `my-backup-1.json` (and `my-backup.json` exists), normalizes to `my-backup` then picks `-2`. Updates `settings_export_path` and writes via atomic write.
- `import_data()` — reads file at `settings_import_path`, validates `keydr_export_version` (reject if != 1 with error message), calls `store.import_all()`, then reloads all in-memory state (config with path fields preserved, profile, key_stats, ranked_key_stats, drill_history, skill_tree). Calls `Config::validate()` and `Config::save()`. Checks if imported theme loaded successfully and appends fallback note to success message if not. Sets `StatusMessage` on success/error.
### 6. Add settings entries (`src/main.rs` — `render_settings`)
Add four new rows at the bottom of the settings field list:
- **"Export Path"** — editable path field, shows `settings_export_path` (same pattern as Code Download Dir)
- **"Export Data"** — action button, label: `"Export now"`
- **"Import Path"** — editable path field, shows `settings_import_path`
- **"Import Data"** — action button, label: `"Import now"`
Update `MAX_SETTINGS` accordingly in `handle_settings_key`.
### 7. Handle key input (`src/main.rs` — `handle_settings_key`)
**Priority order at top of function:**
1. If `settings_status_message.is_some()` — any keypress clears it and returns (message dismissed).
2. If `settings_export_conflict` — handle conflict dialog:
- `'d'``export_data_overwrite()`, clear conflict flag
- `'r'``export_data_rename()`, clear conflict flag
- `Esc` → clear conflict flag
- Return early.
3. If `settings_confirm_import` — handle import confirmation:
- `'y'``import_data()`, clear flag
- `'n'` / `Esc` → clear flag
- Return early.
4. If editing export/import path — handle typing (same pattern as `settings_editing_download_dir`).
For the Enter handler on the new indices:
- Export Path → enter editing mode (clear other edit/modal flags first)
- Export Data → call `export_data()`
- Import Path → enter editing mode (clear other edit/modal flags first)
- Import Data → set `settings_confirm_import = true` (clear other flags first)
Add new indices to the exclusion lists for left/right cycling.
### 8. Render dialogs (`src/main.rs` — `render_settings`)
**Import confirmation dialog** (when `settings_confirm_import` is true):
- Dialog size: ~52x7, centered
- Border title: `" Confirm Import "`, border color: `colors.error()`
- Line 1: `"This will erase your current data."`
- Line 2: `"Export first if you want to keep it."`
- Line 3: `"Proceed? (y/n)"`
**Export conflict dialog** (when `settings_export_conflict` is true):
- Dialog size: ~52x7, centered
- Border title: `" File Exists "`, border color: `colors.error()`
- Line 1: `"A file already exists at this path."`
- Line 2: `"[d] Overwrite [r] Rename [Esc] Cancel"`
**Status message dialog** (when `settings_status_message` is `Some`):
- Small centered dialog showing the message text
- `StatusKind::Success` → accent color border. `StatusKind::Error` → error color border.
- Footer: `"Press any key"`
Dialog rendering priority: status message > export conflict > import confirmation (only one shown at a time).
### 9. Automated tests (`src/store/json_store.rs` or new test module)
Add tests for:
- **Round-trip**: export then import produces identical data
- **Transactional safety (supplemental)**: use a `tempdir`, write valid data, then import into a read-only tempdir and verify original files are unchanged
- **Staged write failure**: `import_all` with a poisoned `ExportData` (e.g. containing data that serializes but whose target path is manipulated to fail) verifies `.tmp` cleanup and original file preservation — this provides deterministic failure coverage without platform-dependent permission tricks
- **Version rejection**: import with `keydr_export_version: 99` returns error containing `"Unsupported export version"`
- **Config validation**: import with out-of-range values (target_wpm=0, word_count=999) gets clamped to valid ranges
- **Smart rename suffix**: create files `stem.json`, `stem-1.json` in a tempdir, verify rename picks `stem-2.json`; also test with custom (non-canonical) filenames
- **Modal invariant**: verify that setting any modal/edit flag clears all others
## Key Files to Modify
| File | Changes |
|------|---------|
| `src/store/schema.rs` | Add `ExportData` struct |
| `src/store/json_store.rs` | Add `export_all()`, transactional `import_all()` with rollback, `.bak` cleanup on startup, tests |
| `src/app.rs` | Add `StatusKind`/`StatusMessage`, state fields, export/import/rename methods, `.bak` check on init |
| `src/main.rs` | Settings UI entries, key handling, 3 dialog types, path editing |
| `src/config.rs` | Add `validate()` method |
## Deferred / Out of Scope
- **Settings enum refactor**: The hard-coded index pattern is pre-existing across the entire settings system. Refactoring to an enum/action map is worthwhile but out of scope for this feature.
- **Splitting config into portable vs machine-local structs**: Handled pragmatically by preserving path fields during import rather than restructuring Config.
- **IO abstraction for injectable writers**: The existing codebase uses direct `fs` calls throughout. Adding a trait-based abstraction for testability is a larger refactor. We use a poisoned-data test and a supplemental read-only tempdir test instead.
## Verification
1. `cargo build` — compiles without errors
2. `cargo test` — all new tests pass (round-trip, staged failure, version rejection, validation, rename suffix, modal invariant)
3. Launch app → Settings → verify Export Path / Export Data / Import Path / Import Data rows appear
4. Edit export path → verify typing/backspace works
5. Export → verify JSON file created at specified path with correct structure
6. Export again same day → verify conflict dialog appears; `d` overwrites atomically, `r` renames to `-1`
7. Export a third time → verify `r` renames to `-2` (smart suffix increment)
8. Export with custom filename → verify rename appends `-1` correctly
9. Import with bad version → verify error: `"Unsupported export version: 99 (expected 1)"`
10. Import → verify warning dialog appears; `n`/`Esc` cancels without changes
11. Import → `y` → verify data loaded, config preferences updated, paths preserved
12. Import with unavailable theme → verify success message includes fallback note
13. Verify only one modal/edit state can be active: e.g. while editing export path, pressing a key that would open import confirm does not open it
14. Round-trip: export, change settings, do a drill, import the export, verify original state restored

View File

@@ -0,0 +1,530 @@
# Plan: Key Milestone Overlays + Keyboard Diagram Improvements
## Context
The app progressively unlocks keys as users master them via the skill tree system. Currently, when a key is unlocked or mastered, there's no celebratory feedback. This plan adds encouraging milestone overlays with keyboard visualization and finger guidance. It also improves the keyboard diagram to render modifier keys (shift, tab, enter, space, backspace) as interactive keys rather than static labels, and adds a new Keyboard Explorer screen.
## Implementation Phases
This plan is structured into 5 independent phases that can be implemented and validated separately to reduce regression risk.
---
## Phase 0: Key Display Adapter (prerequisite for all phases)
**File: `src/keyboard/display.rs` (new)**
Add a thin adapter module that centralizes all sentinel-char ↔ display-name conversions. This isolates encoding concerns so that UI, stats, and rendering code never directly match on sentinel chars.
```rust
/// Human-readable display name for a key character (including sentinels).
pub fn key_display_name(ch: char) -> &'static str {
match ch {
'\x08' => "Backspace",
'\t' => "Tab",
'\n' => "Enter",
' ' => "Space",
_ => "", // caller uses ch.to_string() for printable chars
}
}
/// Short label for compact UI contexts (heatmaps, compact keyboard).
pub fn key_short_label(ch: char) -> &'static str {
match ch {
'\x08' => "Bksp",
'\t' => "Tab",
'\n' => "Ent",
' ' => "Spc",
_ => "",
}
}
/// All sentinel chars used for non-printable keys.
pub const MODIFIER_SENTINELS: &[char] = &['\x08', '\t', '\n'];
```
Register in `src/keyboard/mod.rs` with `pub mod display;`.
All subsequent phases use these functions instead of inline sentinel matching. This makes future migration to a typed `KeyId` a single-module change.
**Sentinel boundary policy:** Sentinel chars (`'\x08'`, `'\t'`, `'\n'`) are allowed only at two boundaries:
1. **Input boundary**`handle_key` in `src/main.rs` converts `KeyCode::Backspace/Tab/Enter` to sentinels for `depressed_keys` and drill input.
2. **Storage boundary**`KeyStatsStore` and `drill_history` store sentinels as `char` keys.
All UI rendering, stats display, and business logic must consume the adapter functions (`key_display_name`, `key_short_label`, `MODIFIER_SENTINELS`) rather than matching sentinels directly. Add a code comment at the top of `display.rs` documenting this policy.
**Enforcement:** Add a `#[test]` in `display.rs` that runs `grep -rn '\\\\x08\|\\\\t.*=>\|\\\\n.*=>' src/` (or equivalent) and asserts that direct sentinel matches only appear in allowed files (`display.rs`, `main.rs` input handling, `key_stats.rs`). This is a lightweight lint that catches accidental sentinel leakage in UI/business logic during `cargo test` without requiring CI changes.
---
## Phase 1: Keyboard Diagram — Add Missing Keys & Shift Support
### 1a. Track modifier keys as depressed keys
**File: `src/main.rs` — `handle_key` function (~line 155)**
Currently only `KeyCode::Char(ch)` inserts into `depressed_keys`. Add tracking for:
- `KeyCode::Backspace` → insert `'\x08'` into `depressed_keys`
- `KeyCode::Tab` → insert `'\t'`
- `KeyCode::Enter` → insert `'\n'`
- Shift state is already tracked via `app.shift_held`
On `Release` events, remove these sentinels similarly to how `Char` releases work. The tick-based fallback clear (line 134-143) already handles `depressed_keys.clear()` which covers these sentinels too.
### 1b. Render modifier keys in the keyboard diagram
**File: `src/ui/components/keyboard_diagram.rs`**
Currently, modifiers are rendered as plain text labels (lines 253-286). Change them to rendered key boxes that participate in the highlight/depress system:
- **Row 0 (number row):** Add `[Bksp]` key after `=`/`+`. Highlight when `'\x08'` is in `depressed_keys`. Finger color: Right Pinky.
- **Row 1 (top row):** Add `[Tab]` key before `q`. Highlight when `'\t'` is in `depressed_keys`. Finger color: Left Pinky.
- **Row 2 (home row):** Add `[Enter]` key after `'`/`"`. Highlight when `'\n'` is in `depressed_keys`. Finger color: Right Pinky.
- **Row 3 (bottom row):** Add `[Shft]` at start and end. Highlight when `shift_held` is true. Left Shift = Left Pinky finger color, Right Shift = Right Pinky finger color.
- **Row 4 (new row):** Add `[ Space ]` centered. Highlight when `' '` is in `depressed_keys`. Finger color: Thumb.
Full layout visualization (full mode):
```
[ ` ][ 1 ][ 2 ][ 3 ][ 4 ][ 5 ][ 6 ][ 7 ][ 8 ][ 9 ][ 0 ][ - ][ = ][Bksp]
[Tab][ q ][ w ][ e ][ r ][ t ][ y ][ u ][ i ][ o ][ p ][ [ ][ ] ][ \ ]
[ ][ a ][ s ][ d ][ f ][ g ][ h ][ j ][ k ][ l ][ ; ][ ' ][Enter]
[Shft][ z ][ x ][ c ][ v ][ b ][ n ][ m ][ , ][ . ][ / ][Shft]
[ Space ]
```
Note: Row 2 position before `a` renders as `[ ]` (caps lock, unused).
When `shift_held` is true:
- Shift keys light up with their finger color (brightened)
- All character keys show shifted variants (already implemented via `shift_held` field)
Compact mode: add `[S]` on each side of bottom row, and abbreviated `[T]`, `[E]`, `[B]` for Tab/Enter/Backspace where space permits.
Adaptive breakpoints for overlay/small terminal: if inner height < 6, skip space row; if < 5, use compact mode.
### 1c. Height adjustments
**File: `src/main.rs` — `render_drill` (~line 1011-1019)**
The `kbd_height` calculation needs to increase by 1-2 rows for the space row and modifier keys in full mode. Update:
- Full mode: `kbd_height = 8` (5 rows + 2 border + 1 spacing)
- Compact mode: `kbd_height = 6` (4 rows + 2 border)
### Phase 1 Verification
- `cargo build && cargo test`
- Press backspace during drill → verify `[Bksp]` lights up
- Press tab → verify `[Tab]` lights up
- Press enter → verify `[Enter]` lights up
- Press shift → verify both `[Shft]` keys light up and all keys show shifted variants
- Type space → verify space bar lights up
- Verify compact mode works on narrow terminals
---
## Phase 2: Key Milestone Detection
### 2a. Return change events from `SkillTree::update`
**File: `src/engine/skill_tree.rs`**
Add a return type:
```rust
pub struct SkillTreeUpdate {
pub newly_unlocked: Vec<char>,
pub newly_mastered: Vec<char>,
}
```
Modify `update()` to:
1. Snapshot current unlocked keys (via `unlocked_keys(DrillScope::Global)`) as a `HashSet<char>` before changes
2. Snapshot per-key confidence before changes (for keys currently unlocked)
3. Perform existing update logic
4. Snapshot unlocked keys after
5. `newly_unlocked` = keys in after but not in before
6. `newly_mastered` = keys where confidence was < 1.0 before but >= 1.0 after (only check keys in the before set)
### 2b. Finger info text generation
**File: `src/keyboard/finger.rs`**
Add `description()` method to `FingerAssignment`:
```rust
pub fn description(&self) -> &'static str {
match (self.hand, self.finger) {
(Hand::Left, Finger::Pinky) => "left pinky",
(Hand::Left, Finger::Ring) => "left ring finger",
(Hand::Left, Finger::Middle) => "left middle finger",
(Hand::Left, Finger::Index) => "left index finger",
(Hand::Left, Finger::Thumb) => "left thumb",
(Hand::Right, Finger::Pinky) => "right pinky",
(Hand::Right, Finger::Ring) => "right ring finger",
(Hand::Right, Finger::Middle)=> "right middle finger",
(Hand::Right, Finger::Index) => "right index finger",
(Hand::Right, Finger::Thumb) => "right thumb",
}
}
```
Finger info is looked up via `KeyboardModel::finger_for_char(ch)` which uses position-based mapping that works across all layouts (QWERTY, Dvorak, Colemak).
### 2c. Find key's skill tree location
**File: `src/engine/skill_tree.rs`**
Add helper:
```rust
pub fn find_key_branch(ch: char) -> Option<(&'static BranchDefinition, &'static str, usize)> {
// Returns (branch_def, level_name, 1-based position_in_level)
for branch in ALL_BRANCHES {
for level in branch.levels {
if let Some(pos) = level.keys.iter().position(|&k| k == ch) {
return Some((branch, level.name, pos + 1));
}
}
}
None
}
```
### Phase 2 Verification
- `cargo test` — existing tests pass
- Add unit test: `update()` returns correct `newly_unlocked` when keys are unlocked
- Add unit test: `update()` returns correct `newly_mastered` when confidence crosses 1.0
- Add unit test: `find_key_branch('e')` returns `(Lowercase, "Frequency Order", 1)`
---
## Phase 3: Milestone Overlay UI
### 3a. Milestone data structures
**File: `src/app.rs`**
Add to `App`:
```rust
pub milestone_queue: VecDeque<KeyMilestonePopup>,
```
Types (can live in `app.rs` or a new `src/milestone.rs`):
```rust
pub struct KeyMilestonePopup {
pub kind: MilestoneKind,
pub keys: Vec<char>,
pub finger_info: Vec<(char, String)>, // (key, "left ring finger")
}
pub enum MilestoneKind {
Unlock,
Mastery,
}
```
### 3b. Capture milestone events in `finish_drill`
**File: `src/app.rs` — `finish_drill` (~line 485)**
After `self.skill_tree.update(&self.key_stats)` (line 502), capture the `SkillTreeUpdate`. If `newly_unlocked` is non-empty, push an Unlock milestone to the queue with finger info for each key. If `newly_mastered` is non-empty, push a Mastery milestone to the queue. Both can be queued — they'll show one at a time.
Build finger info using `self.keyboard_model.finger_for_char(ch).description()`.
**Multi-key milestones:** Each `KeyMilestonePopup` can contain multiple keys (e.g., if 3 keys unlock in one drill completion). The overlay shows all keys together: "You unlocked: 'e', 'r', 'i'" with finger info for each. This is preferred over one overlay per key to avoid a long queue of nearly identical overlays. If both unlocks and masteries occur, they are separate milestones in the queue (one Unlock overlay, one Mastery overlay).
For shifted characters, also include shift key guidance:
- Left-hand characters → "Hold Right Shift (right pinky)"
- Right-hand characters → "Hold Left Shift (left pinky)"
### 3c. Milestone overlay rendering
**File: `src/main.rs` — `render_drill`**
After rendering the drill screen, check `app.milestone_queue.front()`. If present, render a centered overlay using `Clear` + bordered block. Layout adapts to terminal size:
- Large terminal (height >= 25): Full keyboard diagram + text
- Medium (height >= 15): Compact keyboard + text
- Small (height < 15): Text only, no keyboard diagram
Overlay content:
- Title: "Key Unlocked!" or "Key Mastered!"
- Key display: "You unlocked: 's'" / "You mastered: 's'"
- Finger info (unlock only): "Use your left ring finger"
- Encouraging message (randomly selected from pool)
- Keyboard diagram with `focused_key` set to the **first key** in the milestone's key list. For multi-key milestones, only the first key is highlighted on the diagram; all keys are listed textually above.
- For shifted characters: `shift_held = true` on diagram
- Footer: "Press any key to continue (Backspace dismisses only)"
Encouraging message pools:
**Unlock:**
- "Nice work! Keep building your typing skills."
- "Another key added to your arsenal!"
- "Your keyboard is growing! Keep it up."
- "One step closer to full keyboard mastery!"
**Mastery:**
- "This key is now at full confidence!"
- "You've got this key down pat!"
- "Muscle memory locked in!"
- "One more key conquered!"
### 3d. Milestone dismissal — per-key-class behavior
**File: `src/main.rs` — `handle_drill_key`**
At the top of `handle_drill_key`, check `app.milestone_queue.front()`. If present, pop the front milestone from the queue, then handle the dismissing key based on its class:
| Key class | Dismiss? | Replay into drill? | Notes |
|---|---|---|---|
| `KeyCode::Char(ch)` | Yes | Yes — fall through to normal input | Most common case; no keystrokes lost |
| `KeyCode::Tab` | Yes | Yes — fall through to tab handling | Tab is valid drill input |
| `KeyCode::Enter` | Yes | Yes — fall through to enter handling | Enter is valid drill input |
| `KeyCode::Backspace` | Yes | No — dismiss only | Replaying backspace would delete progress the user didn't intend to undo |
| `KeyCode::Esc` | Yes | Yes — Esc falls through to drill exit | Clears entire milestone queue and exits drill immediately |
| Other (arrows, etc.) | Yes | No — dismiss only | Non-drill keys just dismiss |
Implementation: after popping the milestone, check the key code. For `Char`, `Tab`, `Enter`, and `Esc`, let the key continue through the existing `handle_drill_key` logic. For `Backspace` and all other keys, return early after dismissal.
### Phase 3 Verification
- Start fresh, type until 7th key unlocks → milestone overlay appears
- Press a letter key → overlay disappears AND that letter is typed into the drill
- Press Tab during overlay → overlay disappears AND tab is processed as drill input
- Press Enter during overlay → overlay disappears AND enter is processed as drill input
- Press Backspace during overlay → overlay disappears, no drill input change
- Press Esc during overlay → overlay disappears AND drill exits
- Master a key → mastery overlay appears
- Multiple milestones in one drill → overlays show sequentially
- Verify correct finger info text
- Shifted character unlock → shift keys highlighted on diagram
- Small terminal → verify overlay degrades gracefully
- Small terminal + multi-key milestone → verify text-only layout shows all keys and finger info without overflow
- Encouraging messages: assert from message pool membership (not exact string) in any UI tests to avoid flaky assertions from randomness
- Multi-key milestone → verify first key is highlighted on keyboard diagram, all keys listed textually
---
## Phase 4: Stats Dashboard — Add Modifier Key Stats
### 4a. Add modifier key stats to keyboard heatmaps
**File: `src/ui/components/stats_dashboard.rs`**
In `render_keyboard_heatmap` (line 654) and `render_keyboard_timing` (line 768), after rendering the 4 character rows, render modifier key stats:
- **Backspace** (`'\x08'`): After number row, render `Bksp` + stat value
- **Tab** (`'\t'`): Before top row, render `Tab` + stat value
- **Enter** (`'\n'`): After home row, render `Ent` + stat value
- **Space** (`' '`): Below bottom row, render `Spc` + stat value
Use the same `get_key_accuracy` / `get_key_time_ms` methods (they work with any `char`).
### 4b. Include modifier keys in key ranking lists
In `render_worst_accuracy_keys` (line 957) and `render_best_accuracy_keys` (line 1030), add `' '`, `'\t'`, `'\n'` to the `all_keys` set so these keys appear in accuracy rankings. The `render_slowest_keys`/`render_fastest_keys` already pull from `key_stats.stats` which includes these keys automatically.
### Phase 4 Verification
- Open Stats → Accuracy tab → keyboard heatmap shows Tab, Enter, Space with stats
- Open Stats → Timing tab → same
- Tab/Space appear in worst/best accuracy lists when they have data
---
## Phase 5: Keyboard Explorer Screen
### 5a. Add `AppScreen::Keyboard` and menu item
**File: `src/app.rs`**
Add `Keyboard` to `AppScreen` enum. Add field:
```rust
pub keyboard_explorer_selected: Option<char>,
```
**File: `src/ui/components/menu.rs`**
Add menu item with key `"b"` (not `"k"` which conflicts with j/k vim navigation):
```rust
MenuItem {
key: "b".to_string(),
label: "Keyboard".to_string(),
description: "Explore keyboard layout and key statistics".to_string(),
}
```
Insert between "Skill Tree" and "Statistics". Final menu order:
- 0: `[1]` Adaptive Drill
- 1: `[2]` Code Drill
- 2: `[3]` Passage Drill
- 3: `[t]` Skill Tree
- 4: `[b]` Keyboard
- 5: `[s]` Statistics
- 6: `[c]` Settings
### 5b. Menu routing
**File: `src/main.rs` — `handle_menu_key`**
Add `KeyCode::Char('b')``app.screen = AppScreen::Keyboard; app.keyboard_explorer_selected = None`. Update Enter handler indices: 4 → Keyboard, 5 → Stats, 6 → Settings.
Update footer hint: `" [1-3] Start [t] Skill Tree [b] Keyboard [s] Stats [c] Settings [q] Quit "`.
### 5c. Keyboard Explorer rendering
**File: `src/main.rs`**
Add `render_keyboard_explorer` function. Layout:
1. **Header** (3 lines): " Keyboard Explorer " + "Press any key to see details"
2. **Keyboard diagram** (8 lines): Full `KeyboardDiagram` with:
- `focused_key`: `app.keyboard_explorer_selected`
- `next_key`: None
- `unlocked_keys`: `app.skill_tree.unlocked_keys(DrillScope::Global)`
- `depressed_keys`: `&app.depressed_keys`
- `shift_held`: `app.shift_held`
3. **Key detail panel** (remaining space): Bordered block showing stats for selected key
4. **Footer** (1 line): "[ESC] Back"
Key detail panel content (when a key is selected):
```
┌─ Key Details: 's' ──────────────────────────────┐
│ Finger: Left ring finger │
│ Unlocked: Yes │
│ Mastery: 87% confidence │
│ Branch: Lowercase a-z │
│ Level: Frequency Order (key #7) │
│ Avg Time: 245ms (best: 198ms) │
│ Accuracy: 96.2% (385/400 correct) │
│ Samples: 400 │
└──────────────────────────────────────────────────┘
```
Data sources:
- Finger: `keyboard_model.finger_for_char(ch).description()`
- Unlocked: check if `ch` is in `skill_tree.unlocked_keys(DrillScope::Global)`
- Mastery: `key_stats.get_confidence(ch)` formatted as percentage
- Branch/Level: `find_key_branch(ch)` from Phase 2
- Avg Time / Best: `key_stats.get_stat(ch)``filtered_time_ms`, `best_time_ms`
- Accuracy: precomputed (see 5e)
- Samples: `key_stats.get_stat(ch)``sample_count`
### 5d. Key handling
**File: `src/main.rs`**
Add `handle_keyboard_explorer_key`:
- `Esc` → go to menu
- `KeyCode::Char('q')` when no key selected → go to menu; when key selected → select 'q' (so user can explore 'q')
- `KeyCode::Char(ch)` → set `keyboard_explorer_selected = Some(ch)` (see normalization below)
- `KeyCode::Tab` → set selected to `'\t'`
- `KeyCode::Enter` → set selected to `'\n'`
- `KeyCode::Backspace` → set selected to `'\x08'`
**Shifted character normalization strategy:** Store the literal `ch` value from the `KeyCode::Char(ch)` event as-is. Do NOT transform using `shift_held` state. crossterm delivers the already-shifted character in the event (e.g., Shift+a → `KeyCode::Char('A')`, Shift+1 → `KeyCode::Char('!')`), so the event `ch` is the correct key identity. The `shift_held` flag is used only for keyboard diagram rendering (to show shifted labels on all keys), not for determining which key was selected. Show shift guidance in the detail panel for any shifted character (uppercase or symbol) using `keyboard_model.finger_for_char(ch)` to determine hand and thus which shift key to recommend.
For Keyboard Explorer, also show shift key guidance for shifted keys in the detail panel:
- Left-hand characters → "Hold Right Shift (right pinky)"
- Right-hand characters → "Hold Left Shift (left pinky)"
### 5e. Precomputed accuracy for explorer
**File: `src/app.rs`**
Add a cached accuracy field to `App`:
```rust
pub explorer_accuracy_cache: Option<(char, usize, usize)>, // (cached_key, correct, total)
```
Add a method `App::key_accuracy(ch: char) -> (usize, usize)` that checks the cache first. If `cached_key == ch`, return cached values. Otherwise, perform a single linear scan of `drill_history`, cache the result, and return it. The cache is invalidated automatically when `keyboard_explorer_selected` changes (set cache to `None` in the key handler). This avoids redundant O(n) scans on every render frame during key hold or rapid redraw.
### Phase 5 Verification
- `cargo build && cargo test`
- Open Keyboard from menu via `b` key → verify diagram shown
- Press any letter → detail panel shows finger, branch, level, stats
- Press shift → shift keys light up, all keys show shifted variants
- Press shifted key (e.g. Shift+a → 'A') → detail panel shows shifted character info with shift key guidance
- Tab/Enter/Backspace/Space → light up and show details
- Key with no stats → "No data yet"
- Esc → return to menu
- Verify `j`/`k` still work for menu navigation (no hotkey conflict)
---
## Finger Assignment Reference Data (informational)
The existing `KeyboardModel::finger_for_position` method (in `src/keyboard/model.rs`) handles finger assignments by physical position for all layouts. The table below is for reference only — the implementation in `finger_for_position` is the source of truth. Add unit tests against that method to validate correctness rather than maintaining this table. **Shifted characters use the same finger as their base key.**
### QWERTY — All 96 Keys by Finger
**Left Pinky (11 keys):**
- Base: `` ` `` `1` `q` `a` `z`
- Shifted: `~` `!` `Q` `A` `Z`
- Modifier: Tab (`\t`)
**Left Ring (8 keys):**
- Base: `2` `w` `s` `x`
- Shifted: `@` `W` `S` `X`
**Left Middle (8 keys):**
- Base: `3` `e` `d` `c`
- Shifted: `#` `E` `D` `C`
**Left Index (16 keys):**
- Base: `4` `5` `r` `t` `f` `g` `v` `b`
- Shifted: `$` `%` `R` `T` `F` `G` `V` `B`
**Right Index (16 keys):**
- Base: `6` `7` `y` `u` `h` `j` `n` `m`
- Shifted: `^` `&` `Y` `U` `H` `J` `N` `M`
**Right Middle (8 keys):**
- Base: `8` `i` `k` `,`
- Shifted: `*` `I` `K` `<`
**Right Ring (8 keys):**
- Base: `9` `o` `l` `.`
- Shifted: `(` `O` `L` `>`
**Right Pinky (21 keys):**
- Base: `0` `-` `=` `p` `[` `]` `\` `;` `'` `/`
- Shifted: `)` `_` `+` `P` `{` `}` `|` `:` `"` `?`
- Modifiers: Backspace (`\x08`), Enter (`\n`)
**Thumb (1 key):**
- Space (` `)
### Dvorak & Colemak
Finger assignments are **position-based** — the same physical key positions use the same fingers. `KeyboardModel::finger_for_char(ch)` looks up a character's physical position via `find_key_position` then calls `finger_for_position`, so it returns the correct finger for any layout automatically.
### Shift Key Guidance for Shifted Characters
- **Left-hand characters**: Hold **Right Shift** (right pinky)
- **Right-hand characters**: Hold **Left Shift** (left pinky)
---
## Critical Files to Modify
1. **`src/keyboard/display.rs`** (new) — Centralized key display adapter for sentinel ↔ display name conversions (Phase 0)
2. **`src/keyboard/finger.rs`** — Add `description()` method (Phase 2)
3. **`src/engine/skill_tree.rs`** — Add `SkillTreeUpdate` return type, `find_key_branch()` helper (Phase 2)
4. **`src/app.rs`** — Add `milestone_queue`, `keyboard_explorer_selected`, `AppScreen::Keyboard`, milestone structs (Phases 3, 5)
5. **`src/ui/components/keyboard_diagram.rs`** — Render Tab, Enter, Shift, Space, Backspace as interactive keys (Phase 1)
6. **`src/main.rs`** — Modifier depressed state tracking, milestone overlay, keyboard explorer screen, menu routing (Phases 1, 3, 5)
7. **`src/ui/components/stats_dashboard.rs`** — Add modifier keys to keyboard heatmaps and ranking lists (Phase 4)
8. **`src/ui/components/menu.rs`** — Add "Keyboard" menu item with key `b` (Phase 5)
## Terminology
Throughout the implementation, use consistent terminology:
- "Milestone" for the unlock/mastery event system (not "popup" or "notification")
- "Milestone overlay" for the UI element shown during a milestone (not "pop-up", "modal", or "dialog")
- "Enter" (not "Return") for the Enter key
- "Keyboard Explorer" for the new menu screen
## Scope Boundaries
- Non-US layouts beyond QWERTY/Dvorak/Colemak are out of scope for this plan
- The `KeyDisplay` adapter (Phase 0) is intentionally thin — a full typed `KeyId` enum migration is deferred to a future plan
- Left/right shift distinction is not tracked separately (both display as "Shift")

View File

@@ -0,0 +1,338 @@
# N-gram Error Tracking for Adaptive Drill Selection
## Context
keydr currently tracks typing errors at the single-character level only. The adaptive algorithm picks the weakest character by confidence score and biases drill text to include words containing that character. This misses **transition difficulties** -- sequences where individual characters are easy but the combination is hard (e.g., same-finger bigrams, awkward hand transitions). Research strongly supports that these transition effects are real and distinct from single-character difficulty.
**Goal:** Add bigram (n=2) and trigram (n=3) error tracking, with a redundancy detection formula that distinguishes genuine transition difficulties from errors that are just proxies for single-character weakness. Integrate problematic bigrams into the adaptive drill selection pipeline. Trigrams are tracked for observation only and not used for drill generation until empirically proven useful.
---
## Research Summary
1. **N-gram tracking is genuinely novel** -- No existing typing tutor does comprehensive n-gram *error* tracking with adaptive drill selection.
2. **Bigrams capture real, distinct information** -- The 136M Keystrokes study (Dhakal et al., CHI 2018) found letter pairs typed by different hands are more predictive of speed than character repetitions. This cannot be inferred from single-char data.
3. **Motor chunking is real** -- The motor cortex plans keystrokes in chunks, not individually. Single-character optimization misses this.
4. **Bigrams are the sweet spot** -- Nearly all keyboard layout research focuses on bigrams. Trigrams likely offer diminishing returns.
---
## Core Innovation: Redundancy Detection
The key question: "Is a high-error bigram just a proxy for a high-error character?"
### Error Rate Estimation (Laplace-smoothed)
Raw error rates are unstable at low sample counts. All error rates use Laplace smoothing:
```
smoothed_error_rate(errors, samples) = (errors + 1) / (samples + 2)
```
This gives a Bayesian prior of 50% error rate that gets pulled toward the true rate as samples accumulate. At 10 samples with 3 errors, this yields 0.333 instead of raw 0.3 -- a small correction. At 2 samples with 1 error, it yields 0.5 instead of raw 0.5 -- stabilizing the estimate.
### Bigram Redundancy Formula
For bigram "ab" with characters `a` and `b`:
```
e_a = smoothed_error_rate(char_a.errors, char_a.samples)
e_b = smoothed_error_rate(char_b.errors, char_b.samples)
e_ab = smoothed_error_rate(bigram_ab.errors, bigram_ab.samples)
expected_ab = 1.0 - (1.0 - e_a) * (1.0 - e_b)
redundancy_ab = e_ab / max(expected_ab, 0.01)
```
### Trigram Redundancy Formula
For trigram "abc", redundancy is computed against BOTH individual chars AND constituent bigrams:
```
// Expected from chars alone (independence assumption)
expected_from_chars = 1.0 - (1.0 - e_a) * (1.0 - e_b) * (1.0 - e_c)
// Expected from bigrams (takes the max -- if either bigram explains the error, no trigram signal)
expected_from_bigrams = max(e_ab, e_bc)
// Use the higher expectation (harder to exceed = more conservative)
expected_abc = max(expected_from_chars, expected_from_bigrams)
redundancy_abc = e_abc / max(expected_abc, 0.01)
```
This ensures trigrams only flag as informative when NEITHER the individual characters NOR constituent bigrams explain the difficulty.
### Focus Eligibility (Stability-Gated)
An n-gram becomes eligible for focus only when ALL conditions hold:
1. `sample_count >= 20` -- minimum statistical reliability
2. `redundancy > 1.5` -- genuine transition difficulty, not a proxy
3. `redundancy_stable == true` -- the redundancy score has been > 1.5 for the last 3 consecutive update checks (prevents focus flapping from noisy estimates)
The **difficulty score** for ranking eligible n-grams:
```
ngram_difficulty = (1.0 - confidence) * redundancy
```
### Worked Examples
**Example 1 -- Proxy (should NOT focus):** User struggles with 's'. `e_s = 0.25`, `e_i = 0.03`. Expected bigram "is" error: `1 - 0.75 * 0.97 = 0.273`. Observed "is" error: `0.28`. Redundancy: `0.28 / 0.273 = 1.03`. This is ~1.0, confirming "is" errors are just 's' errors. Not eligible.
**Example 2 -- Genuine difficulty (should focus):** User is fine with 'e' and 'd' individually. `e_e = 0.04`, `e_d = 0.05`. Expected "ed" error: `1 - 0.96 * 0.95 = 0.088`. Observed "ed" error: `0.22`. Redundancy: `0.22 / 0.088 = 2.5`. This exceeds 1.5 -- the "ed" transition is genuinely hard. Eligible for focus.
**Example 3 -- Trigram vs bigram:** `e_t = 0.03`, `e_h = 0.04`, `e_e = 0.04`. Bigram `e_th = 0.15` (genuine difficulty). Expected trigram "the" from chars: `0.107`. Expected from bigrams: `max(0.15, 0.04) = 0.15`. Observed "the" error: `0.16`. Redundancy: `0.16 / 0.15 = 1.07`. Not significant -- the "th" bigram already explains the trigram difficulty. Trigram NOT eligible.
---
## Confidence Scale
`NgramStat.confidence` uses the same formula as `KeyStat.confidence`:
```
target_time_ms = 60000.0 / target_cpm // 342.86ms at 175 CPM
confidence = target_time_ms / filtered_time_ms
```
- `confidence < 1.0`: Slower than target (needs practice)
- `confidence == 1.0`: Exactly at target speed
- `confidence > 1.0`: Faster than target (mastered)
For n-grams, `target_time_ms` scales linearly with order: a bigram target is `2 * single_char_target`, a trigram target is `3 * single_char_target`. This is approximate but consistent.
---
## Hesitation Tracking
Hesitations indicate cognitive uncertainty even when the correct key is pressed. The threshold is **relative to the user's rolling baseline**:
```
hesitation_threshold = max(800.0, 2.5 * user_median_transition_ms)
```
Where `user_median_transition_ms` is the median of the user's last 200 inter-keystroke intervals across all drills. The 800ms absolute floor prevents the threshold from being too low for fast typists. The 2.5x multiplier flags transitions that are notably slower than the user's norm.
`user_median_transition_ms` is stored as a single rolling value on the App struct, updated from `per_key_times` after each drill.
---
## N-gram Key Representation
N-gram keys use typed arrays instead of strings to avoid encoding/canonicalization issues:
```rust
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct BigramKey(pub [char; 2]);
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct TrigramKey(pub [char; 3]);
```
**Normalization rules** (applied at extraction boundary in `extract_ngram_events`):
- All characters are Unicode scalar values (Rust `char`) -- no grapheme cluster handling needed since the app only supports ASCII typing
- No case folding -- 'A' and 'a' are distinct (they require different motor actions: shift+a vs a)
- Punctuation is included (transitions to/from punctuation are legitimate motor sequences)
- BACKSPACE characters are filtered out before windowing
- Space characters split windows (no cross-word-boundary n-grams)
---
## Implementation
### Phase 1: Core Data Structures & Extraction
**New file: `src/engine/ngram_stats.rs`**
- `BigramKey(pub [char; 2])` and `TrigramKey(pub [char; 3])` -- typed keys with Hash/Eq/Serialize
- `NgramStat` struct:
- `filtered_time_ms: f64` -- EMA-smoothed transition time (alpha=0.1)
- `best_time_ms: f64` -- personal best EMA time
- `confidence: f64` -- `(target_time_ms * order) / filtered_time_ms`
- `sample_count: usize` -- total observations
- `error_count: usize` -- total errors (mistype or hesitation)
- `hesitation_count: usize` -- total hesitations specifically
- `recent_times: Vec<f64>` -- last 30 observations
- `recent_correct: Vec<bool>` -- last 30 correctness values
- `redundancy_streak: u8` -- consecutive updates where redundancy > 1.5 (for stability gate, max 255)
- `BigramStatsStore` -- `HashMap<BigramKey, NgramStat>` (concrete, not generic)
- `update(&mut self, key: BigramKey, time_ms: f64, correct: bool, hesitation: bool)`
- `get_confidence(&self, key: &BigramKey) -> f64`
- `smoothed_error_rate(&self, key: &BigramKey) -> f64` -- Laplace-smoothed
- `redundancy_score(&self, key: &BigramKey, char_stats: &KeyStatsStore) -> f64`
- `weakest_bigram(&self, char_stats: &KeyStatsStore, unlocked: &[char]) -> Option<(BigramKey, f64)>` -- stability-gated
- `TrigramStatsStore` -- `HashMap<TrigramKey, NgramStat>` (concrete, not generic)
- Same update/query methods as BigramStatsStore
- `prune(&mut self, max_entries: usize)` -- composite utility pruning (see below)
- Internal: shared helper functions/trait for the common EMA update logic to avoid duplication between bigram and trigram stores
- `BigramEvent` / `TrigramEvent` structs -- `{ key, total_time_ms, correct, has_hesitation }`
- `extract_ngram_events(per_key_times: &[KeyTime], hesitation_threshold: f64) -> (Vec<BigramEvent>, Vec<TrigramEvent>)` -- single pass, returns both orders
- `FocusTarget` enum -- `Char(char) | Bigram(BigramKey)` -- lives in `src/engine/ngram_stats.rs`, re-exported from `src/engine/mod.rs`
**Note:** `KeyStatsStore` needs a new method `smoothed_error_rate(key: char) -> f64` to provide Laplace-smoothed error rates. This requires adding `error_count` to `KeyStat`. Currently `KeyStat` only tracks timing for correct keystrokes -- we need to also count errors. Add `error_count: usize` and `total_count: usize` fields to `KeyStat`, increment in `update_key()`. Use `#[serde(default)]` for backward compat on deserialization.
**Modify: `src/engine/key_stats.rs`** (additive)
- Add `error_count: usize` and `total_count: usize` to `KeyStat` with `#[serde(default)]`
- Add `update_key_error(&mut self, key: char)` -- increments error/total counts without updating timing
- Add `smoothed_error_rate(&self, key: char) -> f64` -- Laplace-smoothed
**Modify: `src/engine/mod.rs`** (additive) -- add `pub mod ngram_stats`, re-export `FocusTarget`
**Extraction detail:** For bigram "th", transition time = `window[1].time_ms`. For trigram "the", transition time = `window[1].time_ms + window[2].time_ms`. The first element's `time_ms` is the transition FROM the previous character and is NOT part of this n-gram.
### Phase 2: Persistence (Replay-Only, No Caching)
**Architecture:** `drill_history` (lesson_history.json) is the **sole source of truth**. N-gram stats are **always rebuilt from drill history** on startup. There are no separate n-gram cache files in this initial implementation. This eliminates all cache coherency concerns at the cost of ~200-500ms startup replay. Caching can be added later as an optimization if rebuild latency becomes problematic.
**Modify: `src/store/schema.rs`** (additive)
- Add concrete `BigramStatsData { stats: BigramStatsStore }` with Default impl
- Add concrete `TrigramStatsData { stats: TrigramStatsStore }` with Default impl
- These types are used for export/import serialization only, not for runtime caching
**Modify: `src/app.rs`** (additive + modify existing)
- Add 4 fields to `App`: `bigram_stats`, `ranked_bigram_stats`, `trigram_stats`, `ranked_trigram_stats`
- Add `user_median_transition_ms: f64` and `transition_buffer: Vec<f64>` (rolling last 200 intervals)
- On startup: rebuild all n-gram stats + hesitation baseline by replaying `drill_history`
- `save_data()`: no n-gram files to save (stats are always derived)
**Trigram pruning:** Max 5,000 entries. Prune by composite utility score after history replay:
```
utility = recency_weight * (1.0 / (drills_since_last_seen + 1))
+ signal_weight * redundancy_score.min(3.0)
+ data_weight * (sample_count as f64).ln()
```
Where `recency_weight=0.3`, `signal_weight=0.5`, `data_weight=0.2`. Entries with highest utility are kept. This preserves rare-but-informative trigrams over frequent-but-noisy ones.
### Phase 3: Drill Integration
**Modify: `src/app.rs` -- `finish_drill()`** (modify existing, after line 847)
- Compute `hesitation_threshold = max(800.0, 2.5 * self.user_median_transition_ms)`
- Call `extract_ngram_events(&result.per_key_times, hesitation_threshold)`
- Update `bigram_stats` and `trigram_stats` with each event
- For incorrect keystrokes: also call `self.key_stats.update_key_error(kt.key)` to build char-level error counts
- Same pattern for ranked stats in the ranked block (after line 854)
- Update `transition_buffer` and recompute `user_median_transition_ms`
**Modify: `src/app.rs` -- `finish_partial_drill()`** -- same pattern
**Hesitation baseline rebuild:** During startup history replay, also accumulate transition times into `transition_buffer` to rebuild `user_median_transition_ms`. This ensures the hesitation threshold is consistent across restarts.
### Phase 4: Adaptive Focus Selection (Bigram Only)
The focus pipeline uses a **thin adapter at the App boundary** rather than changing generator signatures directly. This minimizes cross-cutting risk.
**Modify: `src/app.rs` -- `generate_text()`** (modify existing, line 628)
```rust
// Adapter: compute focus target, then decompose into existing generator knobs
let focus_target = select_focus_target(
&self.skill_tree, scope, &self.ranked_key_stats, &self.ranked_bigram_stats
);
let (focused_char, focused_bigram) = match &focus_target {
FocusTarget::Char(ch) => (Some(*ch), None),
FocusTarget::Bigram(key) => (Some(key.0[0]), Some(key.clone())),
};
// Existing generators use focused_char unchanged
let mut text = generator.generate(&filter, lowercase_focused_char, word_count);
// ... existing capitalize/punctuate/numbers pipeline unchanged ...
// After all generation: if bigram focus, swap some words for bigram-containing words
if let Some(ref bigram) = focused_bigram {
text = self.apply_bigram_focus(&text, &filter, bigram);
}
```
**New method on `App`: `apply_bigram_focus()`**
- Scans generated words, replaces up to 40% with dictionary words containing the target bigram
- Only replaces when suitable alternatives exist and pass the CharFilter
- Maintains word count and approximate text length
- **Diversity cap:** No more than 3 consecutive bigram-focused words to prevent repetitive feel
This approach keeps ALL existing generator APIs unchanged. If the adapter proves insufficient (e.g., bigram-focused words are too rare in dictionary), we can widen generator APIs in a follow-up.
**Focus selection logic** (new function `select_focus_target()` in `src/engine/ngram_stats.rs`):
1. Compute weakest single character via existing `focused_key()`
2. Compute weakest eligible bigram via `weakest_bigram()` (stability-gated: sample >= 20, redundancy > 1.5 for 3 consecutive checks)
3. If bigram `ngram_difficulty > char_difficulty * 0.8`, focus on bigram
4. Otherwise, fall back to single-char focus
### Phase 5: Information Gain Analysis (Trigram Observation)
**Add to `src/engine/ngram_stats.rs`:**
```rust
pub fn trigram_marginal_gain(
trigram_stats: &TrigramStatsStore,
bigram_stats: &BigramStatsStore,
char_stats: &KeyStatsStore,
) -> f64
```
Computes what fraction of trigrams with >= 20 samples have `redundancy > 1.5` vs their constituent bigrams. Returns a value in `[0.0, 1.0]`.
- Called every 50 drills, result logged to a `trigram_gain_history: Vec<f64>` on the App
- If the most recent 3 measurements all show gain > 10%, trigrams could be promoted to active focus (future work)
- This metric is primarily for analysis -- it answers "are trigrams adding value beyond bigrams for this user?"
### Phase 6: Export/Import
**Modify: `src/store/schema.rs`** (additive) -- add n-gram fields to `ExportData` with `#[serde(default)]`
**Modify: `src/store/json_store.rs`** (additive) -- update `export_all()` to serialize n-gram stats from memory; `import_all()` imports them into drill_history replay pipeline
---
## Performance Budgets
| Operation | Budget | Notes |
|-----------|--------|-------|
| N-gram extraction per drill | < 1ms | Linear scan of ~200-500 keystrokes |
| Stats update per drill | < 1ms | ~400 bigram + ~300 trigram hash map inserts |
| Focus selection | < 5ms | Iterate all bigrams (~2K), filter + rank |
| History replay (full rebuild) | < 500ms | Replay 500 drills x extraction + update (fixture: 500 drills, 300 keystrokes each) |
| Memory for n-gram stores | < 5MB | ~3K bigrams + 5K trigrams x ~200 bytes each |
Benchmark tests enforce extraction (<1ms for 500 keystrokes), update (<1ms for 400 events), and focus selection (<5ms for 3K bigrams) budgets.
---
## Files Summary
| File | Action | Breaking? | What Changes |
|------|--------|-----------|-------------|
| `src/engine/ngram_stats.rs` | **New** | No | All n-gram structs, extraction, redundancy formula, FocusTarget, focus selection |
| `src/engine/mod.rs` | Modify | No (additive) | Add `pub mod ngram_stats`, re-export `FocusTarget` |
| `src/engine/key_stats.rs` | Modify | No (additive) | Add `error_count`/`total_count` to `KeyStat` with `#[serde(default)]`, add `smoothed_error_rate()` |
| `src/store/schema.rs` | Modify | No (additive) | `BigramStatsData`/`TrigramStatsData` types, `ExportData` update with `#[serde(default)]` |
| `src/store/json_store.rs` | Modify | No (additive) | Export/import n-gram data |
| `src/app.rs` | Modify | No (internal) | App fields, `finish_drill()` n-gram extraction, `generate_text()` adapter + `apply_bigram_focus()`, startup replay |
| `src/generator/dictionary.rs` | Unchanged | - | Existing `find_matching` used as-is via adapter |
| `src/generator/phonetic.rs` | Unchanged | - | Existing API used as-is via adapter |
---
## Verification
1. **Unit tests** for `extract_ngram_events` -- verify bigram/trigram extraction from known keystroke sequences, BACKSPACE filtering, space-boundary skipping, hesitation detection at threshold boundary
2. **Unit tests** for `redundancy_score` -- the 3 worked examples above as test cases, plus edge cases (zero samples, all errors, no errors)
3. **Unit tests** for Laplace smoothing -- verify convergence behavior at low and high sample counts
4. **Unit tests** for stability gate -- verify `redundancy_streak` increments/resets correctly, focus eligibility requires 3 consecutive hits
5. **Deterministic integration tests** for focus selection -- seed `SmallRng` with fixed seed, verify tie-breaking behavior between char and bigram focus, verify fallback when no bigrams are eligible
6. **Regression test** -- verify existing single-character focus works unchanged when no bigrams have sufficient samples (cold start path)
7. **Benchmark tests** (non-blocking, `#[bench]` or criterion):
- Extraction: < 1ms for 500 `KeyTime` entries
- Update: < 1ms for 400 bigram events
- Focus selection: < 5ms for 3,000 bigram entries
- History replay: < 500ms for 500 drills of 300 keystrokes each
8. **Manual test** -- deliberately mistype a specific bigram repeatedly, verify it becomes the focus target and subsequent drills contain words with that bigram
## Future Considerations (Not in Scope)
- **N-gram cache files** for faster startup if replay latency becomes problematic (hybrid append-only cursor approach)
- **Per-order empirical confidence targets** instead of linear scaling (calibrate from user data, log diagnostics)
- **Bigram placement control** in phonetic generator (prefix/medial/suffix weighting) if adapter approach proves insufficient
- **Trigram-driven focus** if marginal gain metric consistently shows > 10% incremental value

View File

@@ -0,0 +1,221 @@
# Plan: N-grams Statistics Tab
## Context
The n-gram error tracking system (last commit `e7f57dd`) tracks bigram/trigram transition difficulties and uses them to adapt drill selection. However, there's no visibility into what the system has identified as weak or how it's influencing drills. This plan adds a **[6] N-grams** tab to the Statistics page to surface this data.
---
## Layout
```
[1] Dashboard [2] History [3] Activity [4] Accuracy [5] Timing [6] N-grams
┌─ Active Focus ──────────────────────────────────────────────────────────────┐
│ Focus: Bigram "th" (difficulty: 1.24) │
│ Bigram diff 1.24 > char 'n' diff 0.50 x 0.8 threshold │
└─────────────────────────────────────────────────────────────────────────────┘
┌─ Eligible Bigrams (3) ────────────────┐┌─ Watchlist ─────────────────────────┐
│ Pair Diff Err% Exp% Red Conf N ││ Pair Red Samples Streak │
│ th 1.24 18% 7% 2.10 0.41 32 ││ er 1.82 14/20 2/3 │
│ ed 0.89 22% 9% 1.90 0.53 28 ││ in 1.61 8/20 1/3 │
│ ng 0.72 14% 8% 1.72 0.58 24 ││ ou 1.53 18/20 1/3 │
└────────────────────────────────────────┘└───────────────────────────────────┘
Scope: Global | Bigrams: 142 | Trigrams: 387 | Hesitation: >832ms | Tri-gain: 12.0%
[ESC] Back [Tab] Next tab [1-6] Switch tab
```
---
## Scope Decisions
- **Drill scope**: Tab shows data for `app.drill_scope` (current adaptive scope). A scope label in the summary line makes this explicit (e.g., "Scope: Global" or "Scope: Branch: lowercase").
- **Trigram gain**: Sourced from `app.trigram_gain_history` (computed every 50 ranked drills). Always from ranked stats, consistent with bigram/trigram counts shown. The value is a fraction in `[0.0, 1.0]` (count of signal trigrams / total qualified trigrams), so it is mathematically non-negative. Format: `X.X%` (one decimal). When empty: `--` with note "(computed every 50 drills)".
- **Eligible vs Watchlist**: Strictly disjoint by construction. Watchlist filter explicitly excludes bigrams that pass all eligibility gates.
---
## Layer Boundaries
Domain logic (engine) and presentation (UI) are separated:
- **Engine** (`ngram_stats.rs`): Owns `FocusReasoning` (domain decision explanation), `select_focus_target_with_reasoning()`, filtering/gating/sorting logic for eligible and watchlist bigrams. Returns domain-oriented results.
- **UI** (`stats_dashboard.rs`): Owns `NgramTabData`, `EligibleBigramRow`, `WatchlistBigramRow` (view model structs tailored for rendering columns).
- **Adapter** (`main.rs`): `build_ngram_tab_data()` is the single point that translates engine output → UI view models. All stats store lookups for display columns happen here.
---
## Files to Modify
### 1. `src/engine/ngram_stats.rs` — Domain logic + focus reasoning
**`FocusReasoning` enum** (domain concept — why the target was selected):
```rust
pub enum FocusReasoning {
BigramWins {
bigram_difficulty: f64,
char_difficulty: f64,
char_key: Option<char>, // None when no focused char exists
},
CharWins {
char_key: char,
char_difficulty: f64,
bigram_best: Option<(BigramKey, f64)>,
},
NoBigrams { char_key: char },
Fallback,
}
```
**`select_focus_target_with_reasoning()`** — Unified function returning `(FocusTarget, FocusReasoning)`. Internally calls `focused_key()` and `weakest_bigram()` once. Handles all four match arms without synthetic values.
**`focus_eligible_bigrams()`** on `BigramStatsStore` — Returns `Vec<(BigramKey, f64 /*difficulty*/, f64 /*redundancy*/)>` sorted by `(difficulty desc, redundancy desc, key lexical asc)`. Same gating as `weakest_bigram()`: sample >= `MIN_SAMPLES_FOR_FOCUS`, streak >= `STABILITY_STREAK_REQUIRED`, redundancy > `STABILITY_THRESHOLD`, difficulty > 0. Returns ALL qualifying entries (no truncation — UI handles truncation to available height).
**`watchlist_bigrams()`** on `BigramStatsStore` — Returns `Vec<(BigramKey, f64 /*redundancy*/)>` sorted by `(redundancy desc, key lexical asc)`. Criteria: redundancy > `STABILITY_THRESHOLD`, sample_count >= 3 (noise floor), AND NOT fully eligible. Returns ALL qualifying entries.
**Export constants** — Make `MIN_SAMPLES_FOR_FOCUS` and `STABILITY_STREAK_REQUIRED` `pub(crate)` so the adapter in `main.rs` can pass them into `NgramTabData` without duplicating values.
### 2. `src/ui/components/stats_dashboard.rs` — View models + rendering
**View model structs** (presentation-oriented, mapped from engine data by adapter):
```rust
pub struct EligibleBigramRow {
pub pair: String, // e.g., "th"
pub difficulty: f64,
pub error_rate_pct: f64, // smoothed, as percentage
pub expected_rate_pct: f64,// from char independence, as percentage
pub redundancy: f64,
pub confidence: f64,
pub sample_count: usize,
}
pub struct WatchlistBigramRow {
pub pair: String,
pub redundancy: f64,
pub sample_count: usize,
pub redundancy_streak: u8,
}
```
**`NgramTabData` struct** (assembled by `build_ngram_tab_data()` in main.rs):
```rust
pub struct NgramTabData {
pub focus_target: FocusTarget,
pub focus_reasoning: FocusReasoning,
pub eligible: Vec<EligibleBigramRow>,
pub watchlist: Vec<WatchlistBigramRow>,
pub total_bigrams: usize,
pub total_trigrams: usize,
pub hesitation_threshold_ms: f64,
pub latest_trigram_gain: Option<f64>,
pub scope_label: String,
// Engine thresholds for watchlist progress denominators:
pub min_samples_for_focus: usize, // from ngram_stats::MIN_SAMPLES_FOR_FOCUS
pub stability_streak_required: u8, // from ngram_stats::STABILITY_STREAK_REQUIRED
}
```
**Add field** to `StatsDashboard`: `ngram_data: Option<&'a NgramTabData>`
**Update constructor**, tab header (add `"[6] N-grams"`), footer (`[1-6]`), `render_tab()` dispatch.
**Rendering methods:**
- **`render_ngram_tab()`** — Vertical layout: focus (4 lines), lists (Min 5), summary (2 lines).
- **`render_ngram_focus()`** — Bordered "Active Focus" block.
- Line 1: target name in `colors.focused_key()` + bold
- Line 2: reasoning in `colors.text_pending()`
- When BigramWins + char_key is None: "Bigram selected (no individual char weakness found)"
- Empty state: "Complete some adaptive drills to see focus data"
- **`render_eligible_bigrams()`** — Bordered "Eligible Bigrams (N)" block.
- Header in `colors.accent()` + bold
- Rows colored by difficulty: `error()` (>1.0), `warning()` (>0.5), `success()` (<=0.5)
- Columns: `Pair Diff Err% Exp% Red Conf N`
- Narrow (<38 inner): drop Exp% and Conf
- Truncate rows to available height
- Empty state: "No bigrams meet focus criteria yet"
- **`render_watchlist_bigrams()`** — Bordered "Watchlist" block.
- Columns: `Pair Red Samples Streak`
- Samples rendered as `n/{data.min_samples_for_focus}`, Streak as `n/{data.stability_streak_required}` — denominators sourced from `NgramTabData` (engine constants), never hardcoded in UI
- All rows in `colors.warning()`
- Truncate rows to available height
- Empty state: "No approaching bigrams"
- **`render_ngram_summary()`** — Single line: scope label, bigram/trigram counts, hesitation threshold, trigram gain.
### 3. `src/main.rs` — Input handling + adapter
**`handle_stats_key()`**:
- `STATS_TAB_COUNT`: 5 → 6
- Add `KeyCode::Char('6') => app.stats_tab = 5` in both branches
**`build_ngram_tab_data(app: &App) -> NgramTabData`** — Dedicated adapter function (single point of engine→UI translation):
- Calls `select_focus_target_with_reasoning()`
- Calls `focus_eligible_bigrams()` and `watchlist_bigrams()`
- Maps engine results to `EligibleBigramRow`/`WatchlistBigramRow` by looking up additional per-bigram stats (error rate, expected rate, confidence, streak) from `app.ranked_bigram_stats` and `app.ranked_key_stats`
- Builds scope label from `app.drill_scope`
- Only called when `app.stats_tab == 5`
**`render_stats()`**: Call `build_ngram_tab_data()` when on tab 5, pass `Some(&data)` to StatsDashboard.
---
## Implementation Order
1. Add `FocusReasoning` enum and `select_focus_target_with_reasoning()` to `ngram_stats.rs`
2. Add `focus_eligible_bigrams()` and `watchlist_bigrams()` to `BigramStatsStore`
3. Add unit tests for steps 1-2
4. Add view model structs (`EligibleBigramRow`, `WatchlistBigramRow`, `NgramTabData`) and `ngram_data` field to `stats_dashboard.rs`
5. Add all rendering methods to `stats_dashboard.rs`
6. Update tab header, footer, `render_tab()` dispatch in `stats_dashboard.rs`
7. Add `build_ngram_tab_data()` adapter + update `render_stats()` in `main.rs`
8. Update `handle_stats_key()` in `main.rs`
---
## Verification
### Unit Tests (in `ngram_stats.rs` test module)
**`test_focus_eligible_bigrams_gating`** — BigramStatsStore with bigrams at boundary conditions:
- sample=25, streak=3, redundancy=2.0 → eligible
- sample=15, streak=3, redundancy=2.0 → excluded (samples < 20)
- sample=25, streak=2, redundancy=2.0 → excluded (streak < 3)
- sample=25, streak=3, redundancy=1.2 → excluded (redundancy <= 1.5)
- sample=25, streak=3, redundancy=2.0, confidence=1.5 → excluded (difficulty <= 0)
**`test_focus_eligible_bigrams_ordering_and_tiebreak`** — 3 eligible bigrams: two with same difficulty but different redundancy, one with lower difficulty. Verify sorted by (difficulty desc, redundancy desc, key lexical asc).
**`test_watchlist_bigrams_gating`** — Bigrams at boundary:
- Fully eligible (sample=25, streak=3) → excluded (goes to eligible list)
- High redundancy, low samples (sample=10) → included
- High redundancy, low streak (sample=25, streak=1) → included
- Low redundancy (1.3) → excluded
- Very few samples (sample=2) → excluded (< 3 noise floor)
**`test_watchlist_bigrams_ordering_and_tiebreak`** — 3 watchlist entries: two with same redundancy. Verify sorted by (redundancy desc, key lexical asc).
**`test_select_focus_with_reasoning_bigram_wins`** — Bigram difficulty > char difficulty * 0.8. Returns `BigramWins` with correct values and `char_key: Some(ch)`.
**`test_select_focus_with_reasoning_char_wins`** — Char difficulty high, bigram < threshold. Returns `CharWins` with `bigram_best` populated.
**`test_select_focus_with_reasoning_no_bigrams`** — No eligible bigrams. Returns `NoBigrams`.
**`test_select_focus_with_reasoning_bigram_only`** — No focused char, bigram exists. Returns `BigramWins` with `char_key: None`.
### Build & Existing Tests
- `cargo build` — no compile errors
- `cargo test` — all existing + new tests pass
### Manual Testing
- Navigate to Statistics → press [6] → see N-grams tab
- Tab/BackTab cycles through all 6 tabs
- With no drill history: empty states shown for all panels
- After several adaptive drills: eligible bigrams appear with plausible data
- Scope label reflects current drill scope
- Verify layout at 80x24 terminal size — confirm column drop at narrow widths keeps header/data aligned

View File

@@ -0,0 +1,265 @@
# Plan: Bigram Metrics Overhaul — Error Anomaly & Speed Anomaly
## Context
The current bigram metrics use `difficulty = (1 - confidence) * redundancy` to gate eligibility and focus. This is fundamentally broken: when a user types faster than target WPM (`confidence > 1.0`), difficulty goes negative — even for bigrams with 100% error rate. The root cause is that "confidence" (a speed-vs-target ratio) and "redundancy" (an error-rate ratio) are conflated into a single metric that can cancel out genuine problems.
This overhaul replaces the conflated system with two orthogonal anomaly metrics:
- **`error_anomaly`** — how much worse a bigram's error rate is compared to what's expected from its constituent characters (same math as current `redundancy_score`, reframed as a percentage)
- **`speed_anomaly`** — how much slower a bigram transition is compared to the user's normal speed typing the second character (user-relative, no target WPM dependency)
Both are displayed as percentages where positive = worse than expected. The UI shows two side-by-side columns, one per anomaly type, with confirmed problems highlighted.
---
## Persistence / Migration
**NgramStat is NOT persisted to disk.** N-gram stores are rebuilt from drill history on every startup (see `json_store.rs:104` comment: "N-gram stats are not included — they are always rebuilt from drill history", and `app.rs:1152` `rebuild_ngram_stats()`). The stores are never saved via `save_data()` — only `profile`, `key_stats`, `ranked_key_stats`, and `drill_history` are persisted.
Therefore:
- No serde migration, `#[serde(alias)]`, or backward-compat handling is needed for NgramStat field renames/removals
- `#[serde(default)]` annotations on NgramStat fields are vestigial (the derive exists for in-memory cloning, not disk persistence) but harmless to leave
- The `Serialize`/`Deserialize` derives on NgramStat can stay (used by BigramStatsStore/TrigramStatsStore types which derive them transitively, though the stores themselves are also not persisted)
**KeyStat IS persisted**`confidence` on KeyStat is NOT being changed (used by skill_tree progression). No migration needed there.
---
## Changes
### 1. `src/engine/ngram_stats.rs` — Metrics engine overhaul
**NgramStat struct** (line 34):
- Remove `confidence: f64` field
- Rename `redundancy_streak: u8``error_anomaly_streak: u8`
- Add `speed_anomaly_streak: u8` with `#[serde(default)]`
- **Preserved fields** (explicitly unchanged): `filtered_time_ms`, `best_time_ms`, `sample_count`, `error_count`, `hesitation_count`, `recent_times`, `recent_correct`, `last_seen_drill_index` — all remain and continue to be updated by `update_stat()`
**`update_stat()`** (line 65):
- Remove `confidence = target_time_ms / stat.filtered_time_ms` computation (line 82)
- Remove `target_time_ms` parameter (no longer needed)
- **Keep** `hesitation` parameter and `drill_index` parameter — these update `hesitation_count` (line 72) and `last_seen_drill_index` (line 66) which are used by trigram pruning and other downstream logic
- New signature (module-private, matching current visibility): `fn update_stat(stat: &mut NgramStat, time_ms: f64, correct: bool, hesitation: bool, drill_index: u32)`
- All other field updates remain identical (EMA on filtered_time_ms, best_time_ms, recent_times, recent_correct, error_count, sample_count)
**Constants** (lines 10-16):
- Rename `STABILITY_THRESHOLD``ERROR_ANOMALY_RATIO_THRESHOLD` (value stays 1.5)
- Rename `STABILITY_STREAK_REQUIRED``ANOMALY_STREAK_REQUIRED` (value stays 3)
- Rename `WATCHLIST_MIN_SAMPLES``ANOMALY_MIN_SAMPLES` (value stays 3)
- Add `SPEED_ANOMALY_PCT_THRESHOLD: f64 = 50.0` (50% slower than expected)
- Add `MIN_CHAR_SAMPLES_FOR_SPEED: usize = 10` (EMA alpha=0.1 needs ~10 samples for initial value to decay to ~35% influence; 5 samples still has ~59% initial-value bias, too noisy for baseline)
- Remove `DEFAULT_TARGET_CPM` (no longer used by update_stat or stores)
**`BigramStatsStore` struct** (line 102):
- Remove `target_cpm: f64` field and `default_target_cpm()` helper
- `BigramStatsStore::update()` (line 114): Remove `target_time_ms` calculation. Pass-through to `update_stat()` without it.
**`TrigramStatsStore` struct** (line 285):
- Remove `target_cpm: f64` field
- `TrigramStatsStore::update()` (line 293): Remove `target_time_ms` calculation. Pass-through to `update_stat()` without it.
**Remove `get_confidence()`** methods on both stores (lines 121, 300) — they read the deleted `confidence` field. Both are `#[allow(dead_code)]` already.
**Rename `redundancy_score()`****`error_anomaly_ratio()`** (line 132):
- Same math internally, just renamed. Returns `e_ab / expected_ab`.
**New methods on `BigramStatsStore`**:
```rust
/// Error anomaly as percentage: (ratio - 1.0) * 100
/// Returns None if bigram has no stats.
pub fn error_anomaly_pct(&self, key: &BigramKey, char_stats: &KeyStatsStore) -> Option<f64> {
let _stat = self.stats.get(key)?;
let ratio = self.error_anomaly_ratio(key, char_stats);
Some((ratio - 1.0) * 100.0)
}
/// Speed anomaly: % slower than user types char_b in isolation.
/// Compares bigram filtered_time_ms to char_b's filtered_time_ms.
/// Returns None if bigram has no stats or char_b has < MIN_CHAR_SAMPLES_FOR_SPEED samples.
pub fn speed_anomaly_pct(&self, key: &BigramKey, char_stats: &KeyStatsStore) -> Option<f64> {
let stat = self.stats.get(key)?;
let char_b_stat = char_stats.stats.get(&key.0[1])?;
if char_b_stat.sample_count < MIN_CHAR_SAMPLES_FOR_SPEED { return None; }
let ratio = stat.filtered_time_ms / char_b_stat.filtered_time_ms;
Some((ratio - 1.0) * 100.0)
}
```
**Rename `update_redundancy_streak()`****`update_error_anomaly_streak()`** (line 142):
- Same logic, uses renamed constant and renamed field
**New `update_speed_anomaly_streak()`**:
- Same pattern as error streak: call `speed_anomaly_pct()`, compare against `SPEED_ANOMALY_PCT_THRESHOLD`
- If `speed_anomaly_pct()` returns `None` (char baseline unavailable/under-sampled), **hold previous streak value** — don't reset or increment. The bigram simply can't be evaluated for speed yet.
- Requires both bigram samples >= `ANOMALY_MIN_SAMPLES` AND char_b samples >= `MIN_CHAR_SAMPLES_FOR_SPEED` before any streak update occurs.
**New `BigramAnomaly` struct**:
```rust
pub struct BigramAnomaly {
pub key: BigramKey,
pub anomaly_pct: f64,
pub sample_count: usize,
pub streak: u8,
pub confirmed: bool, // streak >= ANOMALY_STREAK_REQUIRED && samples >= MIN_SAMPLES_FOR_FOCUS
}
```
**Replace `focus_eligible_bigrams()` + `watchlist_bigrams()`** with:
- **`error_anomaly_bigrams(&self, char_stats: &KeyStatsStore, unlocked: &[char]) -> Vec<BigramAnomaly>`** — All bigrams with `error_anomaly_ratio > ERROR_ANOMALY_RATIO_THRESHOLD` and `samples >= ANOMALY_MIN_SAMPLES`, sorted by anomaly_pct desc. Each entry's `confirmed` flag = `error_anomaly_streak >= ANOMALY_STREAK_REQUIRED && samples >= MIN_SAMPLES_FOR_FOCUS`.
- **`speed_anomaly_bigrams(&self, char_stats: &KeyStatsStore, unlocked: &[char]) -> Vec<BigramAnomaly>`** — All bigrams where `speed_anomaly_pct() > Some(SPEED_ANOMALY_PCT_THRESHOLD)` and `samples >= ANOMALY_MIN_SAMPLES`, sorted by anomaly_pct desc. Same confirmed logic using `speed_anomaly_streak`.
**Replace `weakest_bigram()`** with **`worst_confirmed_anomaly()`**:
- Takes `char_stats: &KeyStatsStore` and `unlocked: &[char]`
- Collects all confirmed error anomalies and confirmed speed anomalies into a single candidate pool
- Each candidate is `(BigramKey, anomaly_pct, anomaly_type)` where type is `Error` or `Speed`
- **Dedup per bigram**: If a bigram appears in both error and speed lists, keep whichever has higher anomaly_pct (or prefer error on tie)
- Return the single bigram with highest anomaly_pct, or None if no confirmed anomalies
- This eliminates ambiguity about same-bigram-in-both-lists — each bigram gets at most one candidacy
**Update `FocusReasoning` enum** (line 471):
Current variants are: `BigramWins { bigram_difficulty, char_difficulty, char_key }`, `CharWins { char_key, char_difficulty, bigram_best }`, `NoBigrams { char_key }`, `Fallback`.
Replace with:
```rust
pub enum FocusReasoning {
BigramWins {
bigram_anomaly_pct: f64,
anomaly_type: AnomalyType, // Error or Speed
char_key: Option<char>,
},
CharWins {
char_key: char,
bigram_best: Option<(BigramKey, f64)>,
},
NoBigrams {
char_key: char,
},
Fallback,
}
pub enum AnomalyType { Error, Speed }
```
**Update `select_focus_target_with_reasoning()`** (line 489):
- Call `worst_confirmed_anomaly()` instead of `weakest_bigram()`
- **Focus priority rule**: Any confirmed bigram anomaly always wins over char focus. Rationale: char focus is the default skill-tree progression mechanism; confirmed bigram anomalies are exceptional problems that survived a conservative gate (3 consecutive drills above threshold + 20 samples). No cross-scale score comparison needed — confirmation itself is the signal.
- When no confirmed bigram anomalies exist, fall back to char focus as before.
- Anomaly_pct is unbounded (e.g. 200% = 3x worse than expected) — this is fine because confirmation gating prevents transient spikes from stealing focus, and the value is only used for ranking among confirmed anomalies, not for threshold comparison against char scores.
**Update `select_focus_target()`** (line 545):
- Same delegation change, pass `char_stats` through
### 2. `src/app.rs` — Streak update call sites & store cleanup
**`target_cpm` removal checklist** (complete audit of all references):
| Location | What | Action |
|---|---|---|
| `ngram_stats.rs:105-106` | `BigramStatsStore.target_cpm` field + serde attr | Remove field |
| `ngram_stats.rs:288-289` | `TrigramStatsStore.target_cpm` field + serde attr | Remove field |
| `ngram_stats.rs:109-111` | `fn default_target_cpm()` helper | Remove function |
| `ngram_stats.rs:11` | `const DEFAULT_TARGET_CPM` | Remove constant |
| `ngram_stats.rs:115` | `BigramStatsStore::update()` target_time_ms calc | Remove line |
| `ngram_stats.rs:294` | `TrigramStatsStore::update()` target_time_ms calc | Remove line |
| `ngram_stats.rs:1386` | Test helper `bigram_stats.target_cpm = DEFAULT_TARGET_CPM` | Remove line |
| `app.rs:1155` | `self.bigram_stats.target_cpm = ...` in rebuild_ngram_stats | Remove line |
| `app.rs:1157` | `self.ranked_bigram_stats.target_cpm = ...` | Remove line |
| `app.rs:1159` | `self.trigram_stats.target_cpm = ...` | Remove line |
| `app.rs:1161` | `self.ranked_trigram_stats.target_cpm = ...` | Remove line |
| `key_stats.rs:37` | `KeyStatsStore.target_cpm` | **KEEP** — used by `update_key()` for char confidence |
| `app.rs:330,332,609,611,1320,1322,1897-1898,1964-1965` | `key_stats.target_cpm = ...` | **KEEP** — KeyStatsStore still uses target_cpm |
| `config.rs:142` | `fn target_cpm()` | **KEEP** — still used by KeyStatsStore |
**At all 6 `update_redundancy_streak` call sites** (lines 899, 915, 1044, 1195, 1212, plus rebuild):
- Rename to `update_error_anomaly_streak()`
- Add parallel call to `update_speed_anomaly_streak()` passing the appropriate `&KeyStatsStore`:
- `&self.key_stats` for `self.bigram_stats` updates
- `&self.ranked_key_stats` for `self.ranked_bigram_stats` updates
**Update `select_focus_target` calls** in `generate_drill` (line ~663) and drill header in main.rs:
- Add `ranked_key_stats` parameter (already available at call sites)
### 3. `src/ui/components/stats_dashboard.rs` — Two-column anomaly display
**Replace data structs**:
- Remove `EligibleBigramRow` (line 20) and `WatchlistBigramRow` (line 30)
- Add single `AnomalyBigramRow`:
```rust
pub struct AnomalyBigramRow {
pub pair: String,
pub anomaly_pct: f64,
pub sample_count: usize,
pub streak: u8,
pub confirmed: bool,
}
```
**Replace `NgramTabData` fields** (line 39):
- Remove `eligible_bigrams: Vec<EligibleBigramRow>` and `watchlist_bigrams: Vec<WatchlistBigramRow>`
- Add `error_anomalies: Vec<AnomalyBigramRow>` and `speed_anomalies: Vec<AnomalyBigramRow>`
**Replace render functions**:
- Remove `render_eligible_bigrams()` (line 1473) and `render_watchlist_bigrams()` (line 1560)
- Add `render_error_anomalies()` and `render_speed_anomalies()`
- Each renders a table with columns: `Pair | Anomaly% | Samples | Streak`
- Confirmed rows (`.confirmed == true`) use highlight/accent color
- Unconfirmed rows use dimmer/warning color
- Column titles: `" Error Anomalies ({}) "` and `" Speed Anomalies ({}) "`
- Empty states: `" No error anomalies detected"` / `" No speed anomalies detected"`
**Narrow-width adaptation**:
- Wide mode (width >= 60): 50/50 horizontal split, full columns `Pair | Anomaly% | Samples | Streak`
- Narrow mode (width < 60): Stack vertically (error on top, speed below). Compact columns: `Pair | Anom% | Smp`
- Drop `Streak` column
- Abbreviate headers
- This mirrors the existing pattern used by the current eligible/watchlist tables
- **Vertical space budget** (stacked mode): Each panel gets a minimum of 3 data rows (+ 1 header + 1 border = 5 lines). Remaining vertical space is split evenly. If total available height < 10 lines, show only error anomalies panel (speed anomalies are less actionable). This prevents one panel from starving the other.
**Update `render_ngram_tab()`** (line 1308):
- Split the bottom section into two horizontal chunks (50/50)
- Left: `render_error_anomalies()`, Right: `render_speed_anomalies()`
- On narrow terminals (width < 60), stack vertically instead
### 4. `src/main.rs` — Bridge adapter
**`build_ngram_tab_data()`** (~line 2232):
- Call `error_anomaly_bigrams()` and `speed_anomaly_bigrams()` instead of old functions
- Map `BigramAnomaly` → `AnomalyBigramRow`
- Pass `&ranked_key_stats` for speed anomaly computation
**Drill header** (~line 1133): `select_focus_target()` signature change (adding `char_stats` param) will require updating the call here.
---
## Files Modified
1. **`src/engine/ngram_stats.rs`** — Core metrics overhaul (remove confidence from NgramStat, remove target_cpm from stores, add two anomaly systems, new query functions)
2. **`src/app.rs`** — Update streak calls, remove target_cpm initialization, update select_focus_target calls
3. **`src/ui/components/stats_dashboard.rs`** — Two-column anomaly display, new data structs, narrow-width adaptation
4. **`src/main.rs`** — Bridge adapter, select_focus_target call update
---
## Test Updates
- **Rewrite `test_focus_eligible_bigrams_gating`** → `test_error_anomaly_bigrams`: Test that bigrams above error threshold with sufficient samples appear; confirmed flag set correctly based on streak + samples
- **Rewrite `test_watchlist_bigrams_gating`** → split into `test_error_anomaly_confirmation` and `test_speed_anomaly_bigrams`
- **New `test_speed_anomaly_pct`**: Verify speed anomaly calculation against mock char stats; verify None returned when char_b has < MIN_CHAR_SAMPLES_FOR_SPEED (10) samples; verify correct result at exactly 10 samples (boundary)
- **New `test_speed_anomaly_streak_holds_when_char_unavailable`**: Verify streak is not reset when char baseline is insufficient (samples 0, 5, 9 — all below threshold)
- **New `test_speed_anomaly_borderline_baseline`**: Verify behavior at sample count transitions (9 → None, 10 → Some) and that early-session noise at exactly 10 samples produces reasonable anomaly values (not extreme outliers from EMA initialization bias)
- **Update `test_weakest_bigram*`** → `test_worst_confirmed_anomaly*`: Verify it picks highest anomaly across both types, deduplicates per bigram preferring higher pct (error on tie), returns None when nothing confirmed
- **Update focus reasoning tests**: Update `FocusReasoning` variants to new names (`BigramWins` now carries `anomaly_pct` and `anomaly_type` instead of `bigram_difficulty`)
- **Update `build_ngram_tab_data_maps_fields_correctly`**: Check `error_anomalies`/`speed_anomalies` fields with `AnomalyBigramRow` assertions
---
## Verification
1. `cargo build` — no compile errors
2. `cargo test` — all tests pass
3. Manual: N-grams tab shows two columns (Error Anomalies / Speed Anomalies)
4. Manual: Confirmed problem bigrams appear highlighted; unconfirmed appear dimmer
5. Manual: Drill header still shows `Focus: "th"` for bigram focus
6. Manual: Bigrams previously stuck on watchlist due to negative difficulty now appear as confirmed error anomalies
7. Manual: On narrow terminal (< 60 cols), columns stack vertically with compact headers

View File

@@ -0,0 +1,351 @@
# Plan: EMA Error Decay + Integrated Bigram/Char Focus Generation
## Context
Two problems with the current n-gram focus system:
1. **Focus stickiness**: Bigram anomaly uses cumulative `(error_count+1)/(sample_count+2)` Laplace smoothing. A bigram with 20 errors / 25 samples would need ~54 consecutive correct strokes to drop below the 1.5x threshold. Once confirmed, a bigram dominates focus for many drills even as the user visibly improves, while worse bigrams can't take over.
2. **Post-processing bigram focus causes repetition**: When a bigram is in focus, `apply_bigram_focus()` post-processes finished text by replacing 40% of words with dictionary words containing the bigram. This selects randomly from candidates with no duplicate tracking, causing repeated words. It also means the bigram doesn't influence the actual word selection — it's bolted on after generation and overrides the focused char (the weakest char gets replaced by bigram[0]).
This plan addresses both: (A) switch error rate to EMA so anomalies respond to recent performance, and (B) integrate bigram focus directly into the word selection algorithm alongside char focus, enabling both to be active simultaneously.
---
## Part A: EMA Error Rate Decay
### Approach
Add an `error_rate_ema: f64` field to both `NgramStat` and `KeyStat`, updated via exponential moving average on each keystroke (same pattern as existing `filtered_time_ms`). Use this EMA for all anomaly computations instead of cumulative `(error_count+1)/(sample_count+2)`.
Both bigram AND char error rates must use EMA — `error_anomaly_ratio` divides one by the other, so asymmetric decay would distort the comparison.
**Alpha = 0.1** (same as timing EMA). Half-life ~7 samples. A bigram at 30% error rate recovering with all-correct strokes: drops below 1.5x threshold after ~15 correct (~2 drills). This is responsive without being twitchy.
### Changes
#### `src/engine/ngram_stats.rs`
**NgramStat struct** (line 34):
- Add `error_rate_ema: f64` with `#[serde(default = "default_error_rate_ema")]` and default value `0.5`
- Add `fn default_error_rate_ema() -> f64 { 0.5 }` (Laplace-equivalent neutral prior)
- Remove `recent_correct: Vec<bool>` — superseded by EMA and never read
**`update_stat()`** (line 67):
- After existing `error_count` increment, add EMA update:
```rust
let error_signal = if correct { 0.0 } else { 1.0 };
if stat.sample_count == 1 {
stat.error_rate_ema = error_signal;
} else {
stat.error_rate_ema = EMA_ALPHA * error_signal + (1.0 - EMA_ALPHA) * stat.error_rate_ema;
}
```
- Remove `recent_correct` push/trim logic (lines 89-92)
- Keep `error_count` and `sample_count` (needed for gating thresholds and display)
**`smoothed_error_rate_raw()`** (line 95): Remove. After `smoothed_error_rate()` on both BigramStatsStore and TrigramStatsStore switch to `error_rate_ema`, this function has no callers.
**`BigramStatsStore::smoothed_error_rate()`** (line 120): Change to return `stat.error_rate_ema` instead of `smoothed_error_rate_raw(stat.error_count, stat.sample_count)`.
**`TrigramStatsStore::smoothed_error_rate()`** (line 333): Same change — return `stat.error_rate_ema`.
**`error_anomaly_ratio()`** (line 123): No changes needed — it calls `self.smoothed_error_rate()` and `char_stats.smoothed_error_rate()`, which now both return EMA values.
**Default for NgramStat** (line 50): Set `error_rate_ema: 0.5` (neutral — same as Laplace `(0+1)/(0+2)`).
#### `src/engine/key_stats.rs`
**KeyStat struct** (line 7):
- Add `error_rate_ema: f64` with `#[serde(default = "default_error_rate_ema")]` and default value `0.5`
- Add `fn default_error_rate_ema() -> f64 { 0.5 }` helper
- **Note**: KeyStat IS persisted to disk. The `#[serde(default)]` ensures backward compat — existing data without the field gets 0.5.
**`update_key()`** (line 50) — called for correct strokes:
- Add EMA update: `stat.error_rate_ema = if stat.total_count == 1 { 0.0 } else { EMA_ALPHA * 0.0 + (1.0 - EMA_ALPHA) * stat.error_rate_ema }`
- Use `total_count` (already incremented on the line before) to detect first sample
**`update_key_error()`** (line 83) — called for error strokes:
- Add EMA update: `stat.error_rate_ema = if stat.total_count == 1 { 1.0 } else { EMA_ALPHA * 1.0 + (1.0 - EMA_ALPHA) * stat.error_rate_ema }`
**`smoothed_error_rate()`** (line 90): Change to return `stat.error_rate_ema` (or 0.5 for missing keys).
#### `src/app.rs`
**`rebuild_ngram_stats()`** (line 1155):
- Reset `error_rate_ema` to `0.5` alongside `error_count` and `total_count` for KeyStat stores (lines 1165-1172)
- NgramStat stores already reset to `Default` which has `error_rate_ema: 0.5`
- The replay loop (line 1177) naturally rebuilds EMA by calling `update_stat()` and `update_key()`/`update_key_error()` in order
No other app.rs changes needed — the streak update and focus selection code reads through `error_anomaly_ratio()` which now uses EMA values transparently.
---
## Part B: Integrated Bigram + Char Focus Generation
### Approach
Replace the exclusive `FocusTarget` enum (either char OR bigram) with a `FocusSelection` struct that carries both independently. The weakest char comes from skill_tree progression; the worst bigram anomaly comes from the anomaly system. Both feed into the `PhoneticGenerator` simultaneously. Remove `apply_bigram_focus()` post-processing entirely.
### Changes
#### `src/engine/ngram_stats.rs` — Focus selection
**Replace `FocusTarget` enum** (line 510):
```rust
// Old
pub enum FocusTarget { Char(char), Bigram(BigramKey) }
// New
#[derive(Clone, Debug, PartialEq)]
pub struct FocusSelection {
pub char_focus: Option<char>,
pub bigram_focus: Option<(BigramKey, f64, AnomalyType)>,
}
```
**Replace `FocusReasoning` enum** (line 523):
```rust
// Old
pub enum FocusReasoning {
BigramWins { bigram_anomaly_pct: f64, anomaly_type: AnomalyType, char_key: Option<char> },
CharWins { char_key: char, bigram_best: Option<(BigramKey, f64)> },
NoBigrams { char_key: char },
Fallback,
}
// New — reasoning is now just the selection itself (both fields self-describe)
// FocusReasoning is removed; FocusSelection carries all needed info.
```
**Simplify `select_focus_target_with_reasoning()`** → **`select_focus()`**:
```rust
pub fn select_focus(
skill_tree: &SkillTree,
scope: DrillScope,
ranked_key_stats: &KeyStatsStore,
ranked_bigram_stats: &BigramStatsStore,
) -> FocusSelection {
let unlocked = skill_tree.unlocked_keys(scope);
let char_focus = skill_tree.focused_key(scope, ranked_key_stats);
let bigram_focus = ranked_bigram_stats.worst_confirmed_anomaly(ranked_key_stats, &unlocked);
FocusSelection { char_focus, bigram_focus }
}
```
Remove `select_focus_target()` and `select_focus_target_with_reasoning()` — replaced by `select_focus()`.
#### `src/generator/mod.rs` — Trait update
**Update `TextGenerator` trait** (line 14):
```rust
pub trait TextGenerator {
fn generate(
&mut self,
filter: &CharFilter,
focused_char: Option<char>,
focused_bigram: Option<[char; 2]>,
word_count: usize,
) -> String;
}
```
#### `src/generator/phonetic.rs` — Integrated word selection
**`generate()` method** — rewrite word selection with tiered approach:
Note: `find_matching(filter, None)` is used (not `focused_char`) because we do our own tiering below. `find_matching` returns ALL words matching the CharFilter — the `focused` param only sorts, never filters — but passing `None` avoids an unnecessary sort we'd discard anyway.
```rust
fn generate(
&mut self,
filter: &CharFilter,
focused_char: Option<char>,
focused_bigram: Option<[char; 2]>,
word_count: usize,
) -> String {
let matching_words: Vec<String> = self.dictionary
.find_matching(filter, None) // no char-sort; we tier ourselves
.iter().map(|s| s.to_string()).collect();
let use_real_words = matching_words.len() >= MIN_REAL_WORDS;
// Pre-categorize words into tiers for real-word mode
let bigram_str = focused_bigram.map(|b| format!("{}{}", b[0], b[1]));
let focus_char_lower = focused_char.filter(|ch| ch.is_ascii_lowercase());
let (bigram_indices, char_indices, other_indices) = if use_real_words {
let mut bi = Vec::new();
let mut ci = Vec::new();
let mut oi = Vec::new();
for (i, w) in matching_words.iter().enumerate() {
if bigram_str.as_ref().is_some_and(|b| w.contains(b.as_str())) {
bi.push(i);
} else if focus_char_lower.is_some_and(|ch| w.contains(ch)) {
ci.push(i);
} else {
oi.push(i);
}
}
(bi, ci, oi)
} else {
(vec![], vec![], vec![])
};
let mut words: Vec<String> = Vec::new();
let mut recent: Vec<String> = Vec::new(); // anti-repeat window
for _ in 0..word_count {
if use_real_words {
let word = self.pick_tiered_word(
&matching_words,
&bigram_indices,
&char_indices,
&other_indices,
&recent,
);
recent.push(word.clone());
if recent.len() > 4 { recent.remove(0); }
words.push(word);
} else {
let word = self.generate_phonetic_word(
filter, focused_char, focused_bigram,
);
words.push(word);
}
}
words.join(" ")
}
```
**New `pick_tiered_word()` method**:
```rust
fn pick_tiered_word(
&mut self,
all_words: &[String],
bigram_indices: &[usize],
char_indices: &[usize],
other_indices: &[usize],
recent: &[String],
) -> String {
// Tier selection probabilities:
// Both available: 40% bigram, 30% char, 30% other
// Only bigram: 50% bigram, 50% other
// Only char: 70% char, 30% other (matches current behavior)
// Neither: 100% other
//
// Try up to 6 times to avoid repeating a recent word.
for _ in 0..6 {
let tier = self.select_tier(bigram_indices, char_indices, other_indices);
let idx = tier[self.rng.gen_range(0..tier.len())];
let word = &all_words[idx];
if !recent.contains(word) {
return word.clone();
}
}
// Fallback: accept any non-recent word from full pool
let idx = self.rng.gen_range(0..all_words.len());
all_words[idx].clone()
}
```
**`select_tier()` helper**: Returns reference to the tier to sample from based on availability and probability roll. Only considers a tier "available" if it has >= 2 words (prevents unavoidable repeats when a tier has just 1 word and the anti-repeat window rejects it). Falls through to the next tier when the selected tier is too small.
**`try_generate_word()` / `generate_phonetic_word()`** — add bigram awareness for Markov fallback:
- Accept `focused_bigram: Option<[char; 2]>` parameter
- Only attempt bigram forcing when both chars pass the CharFilter (avoids pathological starts when bigram chars are rare/unavailable in current filter scope)
- When eligible: 30% chance to start word with bigram[0] and force bigram[1] as second char, then continue Markov chain from `[' ', bigram[0], bigram[1]]` prefix
- Falls back to existing focused_char logic otherwise
#### `src/generator/code_syntax.rs` + `src/generator/passage.rs`
Add `_focused_bigram: Option<[char; 2]>` parameter to their `generate()` signatures (ignored, matching trait).
#### `src/app.rs` — Pipeline update
**`generate_text()`** (line 653):
- Call `select_focus()` (new function) instead of `select_focus_target()`
- Extract `focused_char` from `selection.char_focus` (the actual weakest char)
- Extract `focused_bigram` from `selection.bigram_focus.map(|(k, _, _)| k.0)`
- Pass both to `generator.generate(filter, focused_char, focused_bigram, word_count)`
- **Remove** the `apply_bigram_focus()` call (lines 784-787)
- Post-processing passes (capitalize, punctuate, numbers, code_patterns) continue to receive `focused_char` — this is now the real weakest char, not the bigram's first char
**Remove `apply_bigram_focus()`** method (lines 1087-1131) entirely.
**Store `FocusSelection`** on App:
- Add `pub current_focus: Option<FocusSelection>` field to App (default `None`)
- Set in `generate_text()` right after `select_focus()` — captures the focus that was actually used to generate the current drill's text
- **Lifecycle**: Set when drill starts (in `generate_text()`). Persists through the drill result screen (so the user sees what was in focus for the drill they just completed). Cleared to `None` when: starting the next drill (overwritten), leaving drill screen, changing drill scope/mode, or on import/reset. This is a snapshot, not live-recomputed — the header always shows what generated the current text.
- Used by drill header display in main.rs (reads `app.current_focus` instead of re-calling `select_focus()`)
#### `src/main.rs` — Drill header + stats adapter
**Drill header** (line 1134):
- Read `app.current_focus` to build focus_text (no re-computation — shows what generated the text)
- Display format: `Focus: 'n' + "th"` (both), `Focus: 'n'` (char only), `Focus: "th"` (bigram only)
- Replace the current `select_focus_target()` call with reading the stored selection
- When `current_focus` is `None`, show no focus text
**`build_ngram_tab_data()`** (line 2253):
- Call `select_focus()` instead of `select_focus_target_with_reasoning()`
- Update `NgramTabData` struct: replace `focus_target: FocusTarget` and `focus_reasoning: FocusReasoning` with `focus: FocusSelection`
#### `src/ui/components/stats_dashboard.rs` — Focus panel
**`NgramTabData`** (line 28):
- Replace `focus_target: FocusTarget` and `focus_reasoning: FocusReasoning` with `focus: FocusSelection`
- Remove `FocusTarget` and `FocusReasoning` imports
**`render_ngram_focus()`** (line 1352):
- Show both focus targets when both active:
- Line 1: `Focus: Char 'n' + Bigram "th"` (or just one if only one active)
- Line 2: Details — `Char 'n': weakest key | Bigram "th": error anomaly 250%`
- When neither active: show fallback message
- Rendering adapts based on which focuses are present
---
## Files Modified
1. **`src/engine/ngram_stats.rs`** — EMA field on NgramStat, EMA-based smoothed_error_rate, `FocusSelection` struct, `select_focus()`, remove old FocusTarget/FocusReasoning
2. **`src/engine/key_stats.rs`** — EMA field on KeyStat, EMA updates in update_key/update_key_error, EMA-based smoothed_error_rate
3. **`src/generator/mod.rs`** — TextGenerator trait: add `focused_bigram` parameter
4. **`src/generator/phonetic.rs`** — Tiered word selection with bigram+char, anti-repeat window, Markov bigram awareness
5. **`src/generator/code_syntax.rs`** — Add ignored `focused_bigram` parameter
6. **`src/generator/passage.rs`** — Add ignored `focused_bigram` parameter
7. **`src/app.rs`** — Use `select_focus()`, pass both focuses to generator, remove `apply_bigram_focus()`, store `current_focus`
8. **`src/main.rs`** — Update drill header, update `build_ngram_tab_data()` adapter
9. **`src/ui/components/stats_dashboard.rs`** — Update NgramTabData, render_ngram_focus for dual focus display
---
## Test Updates
### Part A (EMA)
- **Update `test_error_anomaly_bigrams`**: Set `error_rate_ema` directly instead of relying on cumulative error_count/sample_count for anomaly ratio computation
- **Update `test_worst_confirmed_anomaly_dedup`** and **`_prefers_error_on_tie`**: Same — set EMA values
- **New `test_error_rate_ema_decay`**: Verify that after N correct strokes, error_rate_ema drops as expected. Verify anomaly ratio crosses below threshold after reasonable recovery (~15 correct strokes from 30% error rate).
- **New `test_error_rate_ema_rebuild_from_history`**: Verify that rebuilding from drill history produces same EMA as live updates (deterministic replay)
- **New `test_ema_ranking_stability_during_recovery`**: Two bigrams both confirmed. Bigram A has higher anomaly. User corrects bigram A over several drills while bigram B stays bad. Verify that A's anomaly drops below B's and B becomes the new worst_confirmed_anomaly — clean handoff without oscillation.
- **Update key_stats tests**: Verify EMA updates in `update_key()` and `update_key_error()`, backward compat (serde default)
### Part B (Integrated focus)
- **Replace focus reasoning tests** (`test_select_focus_with_reasoning_*`): Replace with `test_select_focus_*` testing `FocusSelection` struct — verify both char_focus and bigram_focus are populated independently
- **New `test_phonetic_bigram_focus_increases_bigram_words`**: Generate 1200 words with focused_bigram, verify significantly more words contain the bigram than without
- **New `test_phonetic_dual_focus_no_excessive_repeats`**: Generate text with both focuses, verify no word appears > 3 times consecutively
- **Update `build_ngram_tab_data_maps_fields_correctly`**: Update for `FocusSelection` struct instead of FocusTarget/FocusReasoning
- **New `test_find_matching_focused_is_sort_only`** (in `dictionary.rs` or `phonetic.rs`): Verify that `find_matching(filter, Some('k'))` and `find_matching(filter, None)` return the same set of words (same membership, potentially different order). Guards against future regressions where focused param accidentally becomes a filter.
- No `apply_bigram_focus` tests exist to remove (method was untested)
---
## Verification
1. `cargo build` — no compile errors
2. `cargo test` — all tests pass
3. Manual: Start adaptive drill, observe both char and bigram appearing in focus header
4. Manual: Verify drill text contains focused bigram words AND focused char words mixed naturally
5. Manual: Verify no excessive word repetition (the old apply_bigram_focus problem)
6. Manual: Practice a bigram focus target correctly for 2-3 drills → verify it drops out of focus and a different bigram (or char-only) takes over
7. Manual: N-grams tab shows both focuses in the Active Focus panel
8. Manual: Narrow terminal (<60 cols) stacks anomaly panels vertically; very short terminal (<10 rows available for panels) shows only error anomalies panel; focus panel always shows at least line 1

View File

@@ -0,0 +1,93 @@
# Adaptive Auto-Continue Input Lock Overlay
## Context
In adaptive mode, when a drill completes with no milestone popups to show, the app auto-continues to the next drill immediately (`finish_drill``start_drill()` with no intermediate screen). The existing 800ms input lock (`POST_DRILL_INPUT_LOCK_MS`) is only armed when there IS an intermediate screen (DrillResult or milestone popup). This means trailing keystrokes from the previous drill can bleed into the next drill as unintended inputs.
The fix: arm the same 800ms lock during adaptive auto-continue, block drill input while it's active, and show a small countdown popup overlay on the drill screen so the user knows why their input is temporarily ignored.
## Changes
### 1. Arm the lock on adaptive auto-continue
**`src/app.rs``finish_drill()`**
Currently the auto-continue path does not arm the lock:
```rust
if self.drill_mode == DrillMode::Adaptive && self.milestone_queue.is_empty() {
self.start_drill();
}
```
Add `arm_post_drill_input_lock()` after `start_drill()`. It must come after because `start_drill()` calls `clear_post_drill_input_lock()` as its first action (to clear stale locks from manual continues). Re-arming immediately after means the 800ms window starts from when the new drill begins:
```rust
if self.drill_mode == DrillMode::Adaptive && self.milestone_queue.is_empty() {
self.start_drill();
self.arm_post_drill_input_lock();
}
```
**Event ordering safety**: The event loop in `run_app()` is single-threaded: `draw``events.next()``handle_key` → loop. `finish_drill()` runs inside a `handle_key()` call, so both `start_drill()` and `arm_post_drill_input_lock()` complete within the same event iteration. Any buffered key events are processed in subsequent loop iterations, where the lock is already active.
### 2. Allow Ctrl+C through the lock and add Drill screen to lock guard
**`src/main.rs``handle_key()`**
Move the Ctrl+C quit handler ABOVE the input lock guard so it always works, even during lockout. Then add `AppScreen::Drill` to the lock guard:
```rust
// Ctrl+C always quits, even during input lock
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
app.should_quit = true;
return;
}
// Briefly block all input right after a drill completes to avoid accidental
// popup dismissal or continuation from trailing keystrokes.
if app.post_drill_input_lock_remaining_ms().is_some()
&& (!app.milestone_queue.is_empty()
|| app.screen == AppScreen::DrillResult
|| app.screen == AppScreen::Drill)
{
return;
}
```
This is a behavior change for the existing DrillResult/milestone lock too: previously Ctrl+C was blocked during the 800ms window, now it passes through. All other keys remain blocked. The 800ms window is short enough that blocking everything else is not disruptive.
### 3. Render lock overlay on the drill screen
**`src/main.rs` — end of `render_drill()`**
After all existing drill UI is rendered, if the lock is active, draw a small centered popup overlay on top of the typing area:
- Check `app.post_drill_input_lock_remaining_ms()` — if `None`, skip overlay entirely
- **Size**: 3 rows tall (top border + message + bottom border), width = message length + 4 (border + padding), centered within the full `area` rect
- **Clear** the overlay rect with `ratatui::widgets::Clear`
- **Block**: `Block::bordered()` with `colors.accent()` border style and `colors.bg()` background — same pattern as `render_milestone_overlay`
- **Message**: `"Keys re-enabled in {ms}ms"` as a `Paragraph` with `colors.text_pending()` style — matches the milestone overlay footer color
- Render inside the block's `inner()` area
This overlay is intentionally small (single bordered line) since the drill content should remain visible behind it and it only appears for ≤800ms.
**Countdown repainting**: The event loop (`run_app()`) uses `EventHandler::new(Duration::from_millis(100))` which sends `AppEvent::Tick` every 100ms when idle. Each tick triggers `terminal.draw()`, which re-renders the drill screen. `post_drill_input_lock_remaining_ms()` recomputes the remaining time from `Instant::now()` on each call, so the countdown value updates every ~100ms without any additional machinery.
### 4. Tests
**`src/app.rs`** — add to the existing `#[cfg(test)] mod tests`:
1. **`adaptive_auto_continue_arms_input_lock`**: Create `App`, verify it starts in adaptive mode with a drill. Simulate completing the drill by calling `finish_drill()` (set up drill state as complete first). Assert `post_drill_input_lock_remaining_ms().is_some()` and `screen == AppScreen::Drill` after auto-continue.
2. **`adaptive_auto_continue_lock_not_armed_with_milestones`**: Same setup but push a milestone into `milestone_queue` before calling `finish_drill()`. Assert `screen == AppScreen::DrillResult` (not auto-continued) and lock is armed via the existing milestone path.
## Files to modify
- `src/app.rs` — 1-line addition in `finish_drill()` auto-continue path; 2 tests
- `src/main.rs` — extend input lock guard condition in `handle_key()`; add overlay rendering at end of `render_drill()`
## Verification
1. `cargo test` — all existing and new tests pass
2. Manual: start adaptive drill, complete it. Verify small popup appears briefly over the next drill, countdown decrements every ~100ms, then disappears and typing works normally
3. Manual: complete adaptive drill that triggers a milestone popup. Verify milestone popup still works as before (no double-lock or interference)
4. Manual: complete Code or Passage drill. Verify DrillResult screen lockout still works as before

View File

@@ -0,0 +1,90 @@
# Adaptive Drill Word Diversity
## Context
When adaptive drills focus on characters/bigrams with few matching dictionary words, the same words repeat excessively both within and across drills. Currently:
- **Within-drill dedup** uses a sliding window of only 4 words — too small when the matching word pool is small
- **Cross-drill**: no tracking at all — each drill creates a fresh `PhoneticGenerator` with no memory of previous drills
- **Dictionary vs phonetic is binary**: if `matching_words >= 15` use dictionary only, if `< 15` use phonetic only. A pool of 16 words gets 100% dictionary (lots of repeats), while 14 gets 0% dictionary
## Changes
### 1. Cross-drill word history
Add `adaptive_word_history: VecDeque<HashSet<String>>` to `App` that tracks words from the last 5 adaptive drills. Pass a flattened `HashSet<String>` into `PhoneticGenerator::new()`.
**Word normalization**: Capture words from the generator output *before* capitalization/punctuation/numbers post-processing (the `generator.generate()` call in `generate_text()` produces lowercase-only text). This means words in history are always lowercase ASCII with no punctuation — no normalization function needed since the generator already guarantees this format.
**`src/app.rs`**:
- Add `adaptive_word_history: VecDeque<HashSet<String>>` to `App` struct, initialize empty
- In `generate_text()`, before creating the generator: flatten history into `HashSet` and pass to constructor
- After `generator.generate()` returns (before capitalization/punctuation): `split_whitespace()` into a `HashSet`, push to history, pop front if `len > 5`
**Lifecycle/reset rules**:
- Clear `adaptive_word_history` when `drill_mode` changes away from `Adaptive` (i.e., switching to Code/Passage mode)
- Clear when `drill_scope` changes (switching between branches or global/branch)
- Do NOT persist across app restarts — session-local only (it's a `VecDeque`, not serialized)
- Do NOT clear on gradual key unlocks — as the skill tree progresses one key at a time, history should carry over to maintain cross-drill diversity within the same learning progression
- The effective "adaptive context key" is `(drill_mode, drill_scope)` — history clears when either changes. Other parameters (focus char, focus bigram, filter) change naturally within a learning progression and should not trigger resets
- This prevents cross-contamination between unrelated drill contexts while preserving continuity during normal adaptive flow
**`src/generator/phonetic.rs`**:
- Add `cross_drill_history: HashSet<String>` field to `PhoneticGenerator`
- Update constructor to accept it
- In `pick_tiered_word()`, use weighted suppression instead of hard exclusion:
- When selecting a candidate word, if it's in within-drill `recent`, always reject
- If it's in `cross_drill_history`, accept it with reduced probability based on pool coverage:
- Guard: if pool is empty, skip suppression logic entirely (fall through to phonetic generation in hybrid mode)
- `history_coverage = cross_drill_history.intersection(pool).count() as f64 / pool.len() as f64`
- `accept_prob = 0.15 + 0.60 * history_coverage` (range: 15% when history covers few pool words → 75% when history covers most of the pool)
- This prevents over-suppression in small pools where history covers most words, while still penalizing repeats in large pools
- Scale attempt count to `pool_size.clamp(6, 12)` with final fallback accepting any non-recent word
- Compute `accept_prob` once at the start of `generate()` alongside tier categorization (not per-attempt)
### 2. Hybrid dictionary + phonetic mode
Replace the binary threshold with a gradient that mixes dictionary and phonetic words.
**`src/generator/phonetic.rs`**:
- Change constants: `MIN_REAL_WORDS = 8` (below: phonetic only), add `FULL_DICT_THRESHOLD = 60` (above: dictionary only)
- Calculate `dict_ratio` as linear interpolation: `(count - 8) / (60 - 8)` clamped to `[0.0, 1.0]`
- In the word generation loop, for each word: roll against `dict_ratio` to decide dictionary vs phonetic
- Tier categorization still happens when `count >= MIN_REAL_WORDS` (needed for dictionary picks)
- Phonetic words also participate in the `recent` dedup window (already handled since all words push to `recent`)
### 3. Scale within-drill dedup window
Replace the fixed window of 4 with a window proportional to the **filtered dictionary match count** (the `matching_words` vec computed at the top of `generate()`):
- `pool_size <= 20`: window = `pool_size.saturating_sub(1).max(4)`
- `pool_size > 20`: window = `(pool_size / 4).min(20)`
- In hybrid mode, this is based on the dictionary pool size regardless of phonetic mixing — phonetic words add diversity naturally, so the window governs dictionary repeat pressure
### 4. Tests
All tests use seeded `SmallRng::seed_from_u64()` for determinism (existing pattern in codebase).
**Update existing tests**: Add `HashSet::new()` to `PhoneticGenerator::new()` constructor calls (3 tests).
**New tests** (all use `SmallRng::seed_from_u64()` for determinism):
1. **Cross-drill history suppresses repeats**: Generate drill 1 with seeded RNG and constrained filter (~20 matching words), collect word set. Generate drill 2 with same filter but different seed, no history — compute Jaccard index as baseline. Generate drill 2 again with drill 1's words as history — compute Jaccard index. Assert history Jaccard is at least 0.15 lower than baseline Jaccard (i.e., measurably less overlap). Use 100-word drills.
2. **Hybrid mode produces mixed output**: Use a filter that yields ~30 dictionary matches. Generate 500 words with seeded RNG. Collect output words and check against the dictionary match set. With ~30 matches, `dict_ratio ≈ 0.42`. Since the seed is fixed, the output is deterministic — the band of 25%-65% accommodates potential future seed changes rather than runtime variance. Assert dictionary word percentage is within this range, and document the actual observed value for the chosen seed in a comment.
3. **Boundary conditions**: With 5 matching words → assert 0% dictionary words (all phonetic). With 100+ matching words → assert 100% dictionary words. Seeded RNG.
4. **Weighted suppression graceful degradation**: Create a pool of 10 words with history containing 8 of them. Generate 50 words. Verify no panics, output is non-empty, and history words still appear (suppression is soft, not hard exclusion).
## Files to modify
- `src/generator/phonetic.rs` — core changes: hybrid mixing, cross-drill history field, weighted suppression in `pick_tiered_word`, dedup window scaling
- `src/app.rs` — add `adaptive_word_history` field, wire through `generate_text()`, add reset logic on mode/scope changes
- `src/generator/mod.rs` — no changes (`TextGenerator` trait signature unchanged for API stability; the `cross_drill_history` parameter is internal to `PhoneticGenerator`'s constructor, not the trait interface)
## Verification
1. `cargo test` — all existing and new tests pass
2. Manual test: start adaptive drill on an early skill tree branch (few unlocked letters, ~15-30 matching words). Run 5+ consecutive drills. Measure: unique words across 5 drills should be notably higher than before (target: >70% unique across 5 drills for pools of 20+ words)
3. Full alphabet test: with all keys unlocked, behavior should be essentially unchanged (dict_ratio ≈ 1.0, large pool, no phonetic mixing)
4. Scope change test: switch between branch drill and global drill, verify no stale history leaks

View File

@@ -0,0 +1,35 @@
# Prevent Tests from Writing to Real User Data
## Context
Two tests in `src/app.rs` (`adaptive_auto_continue_arms_input_lock` and `adaptive_does_not_auto_continue_with_milestones`) call `App::new()` which connects to the real `JsonStore` at `~/.local/share/keydr/`. When they call `finish_drill()``save_data()`, fake drill results get persisted to the user's actual history file. All other app tests also use `App::new()` but happen to not call `finish_drill()`.
## Changes
### 1. Add `#[cfg(not(test))]` gate on `App::new()` (`src/app.rs:293`)
Mark `App::new()` with `#[cfg(not(test))]` so it cannot be called from test code at all. This is a compile-time guarantee — any future test that tries `App::new()` will fail to compile.
### 2. Add `App::new_test()` (`src/app.rs`, in `#[cfg(test)]` block)
Add a `pub fn new_test()` constructor inside a `#[cfg(test)] impl App` block that mirrors `App::new()` but sets `store: None`. This prevents any persistence to disk. All existing fields get their default/empty values (no loading from disk either).
Since most test fields just need defaults and a started drill, the test constructor can be minimal:
- `Config::default()`, `Theme::default()` (leaked), `Menu::new()`, `store: None`
- Default key stats, skill tree, profile, empty drill history
- `Dictionary::load()`, `TransitionTable`, `KeyboardModel` — same as production (needed for `start_drill()`)
- Call `start_drill()` at the end (same as `App::new()`)
### 3. Update all existing tests to use `App::new_test()`
Replace every `App::new()` call in the test module with `App::new_test()`. This covers all 7 tests in `#[cfg(test)] mod tests`.
## File to Modify
- `src/app.rs` — gate `new()`, add `new_test()`, update test calls
## Verification
1. `cargo test` — all tests pass
2. `cargo build` — production build still compiles (ungated `new()` available)
3. Temporarily add `App::new()` in a test → should fail to compile

View File

@@ -0,0 +1,150 @@
# Plan: Enhanced Path Input with Cursor Navigation and Tab Completion
## Context
Settings page path fields (code download dir, passage download dir, export path, import path) currently only support appending characters and backspace — no cursor movement, no arrow keys, no tab completion. Users can't easily correct a typo in the middle of a path or navigate to an existing file for import.
## Approach: Custom `LineInput` struct + filesystem tab completion
Neither `tui-input` nor `tui-textarea` provide tab/path completion, and both have crossterm version mismatches with our deps (ratatui 0.30 + crossterm 0.28). A custom struct avoids dependency churn and gives us exactly the features we need.
## New file: `src/ui/line_input.rs`
### Struct
```rust
/// Which settings path field is being edited.
pub enum PathField {
CodeDownloadDir,
PassageDownloadDir,
ExportPath,
ImportPath,
}
pub enum InputResult {
Continue,
Submit,
Cancel,
}
pub struct LineInput {
text: String,
cursor: usize, // char index (NOT byte offset)
completions: Vec<String>,
completion_index: Option<usize>,
completion_seed: String, // text snapshot when Tab first pressed
completion_error: bool, // true if last read_dir failed
}
```
Cursor is stored as a **char index** (0 = before first char, `text.chars().count()` = after last char). Conversion to byte offset happens only at mutation boundaries via `text.char_indices()`.
### Keyboard handling
| Key | Action | Resets completion? |
|-----|--------|--------------------|
| Left | Move cursor one char left | Yes |
| Right | Move cursor one char right | Yes |
| Home / Ctrl+A | Move cursor to start | Yes |
| End / Ctrl+E | Move cursor to end | Yes |
| Backspace | Delete char before cursor | Yes |
| Delete | Delete char at cursor | Yes |
| Ctrl+U | Clear entire line | Yes |
| Ctrl+W | Delete word before cursor (see semantics below) | Yes |
| Tab | Cycle completions forward (end-of-line only) | No |
| BackTab | Cycle completions backward | No |
| Printable char | Insert at cursor | Yes |
| Esc | Return `InputResult::Cancel` | — |
| Enter | Return `InputResult::Submit` | — |
**Only Tab/BackTab preserve** the completion session. All other keys reset it.
**Ctrl+W semantics**: From cursor position, first skip any consecutive whitespace to the left, then delete the contiguous non-whitespace run. This matches standard readline/bash `unix-word-rubout` behavior. Example: `"foo bar |"` → Ctrl+W → `"foo |"`.
### Tab completion
Tab completion **only activates when cursor is at end-of-line**. If cursor is in the middle, Tab is a no-op.
1. **First Tab** (`completion_index` is `None`): Snapshot `text` as `completion_seed`. Split into (directory, partial_filename) using the last path separator. Expand leading `~` to `dirs::home_dir()` for the `read_dir` call only — preserve `~` in output text. Call `std::fs::read_dir` with a **scan budget of 1000 entries** (iterate at most 1000 `DirEntry` results). From the scanned entries, filter those whose name starts with `partial_filename` (always case-sensitive — this is an intentional simplification; case-insensitive matching on macOS HFS+/Windows NTFS is not in scope). Hidden files (names starting with `.`) only included when `partial_filename` starts with `.`. Sort matching candidates: **directories first, then files, alphabetical within each group**. Cap the final candidate list at 100. On any `read_dir` or entry I/O error, produce zero completions and set `completion_error = true` (renders `"(cannot read directory)"` in footer).
2. **Cycling**: Increment/decrement `completion_index`, wrapping. Replace `text` with selected completion. Directories get a trailing `std::path::MAIN_SEPARATOR`. Cursor moves to end.
3. **Reset**: Any non-Tab/BackTab key clears completion state **and** clears `completion_error`. This means the error hint disappears on the next keystroke (text mutation, cursor move, submit, or cancel).
4. **Mixed paths** like `~/../tmp` are not normalized — they're passed through as-is.
5. **Hidden-file filtering** (`.-prefix only` rule) applies identically on all platforms.
### Rendering
```rust
impl LineInput {
/// Returns (before_cursor, cursor_char, after_cursor) for styled rendering.
pub fn render_parts(&self) -> (&str, Option<char>, &str);
pub fn value(&self) -> &str;
}
```
When cursor is at end of text, `cursor_char` is `None` and a **space with inverted background** is rendered as the cursor (avoids font/glyph compatibility issues with block characters across terminals). When cursor is in the middle, the character at cursor position is rendered with inverted colors (swapped fg/bg).
## Changes to existing files
### `src/ui/mod.rs`
- Add `pub mod line_input;`
### `src/app.rs`
- Replace three booleans (`settings_editing_download_dir`, `settings_editing_export_path`, `settings_editing_import_path`) with:
```rust
pub settings_editing_path: Option<(PathField, LineInput)>,
```
- `settings_export_path` and `settings_import_path` remain as `String`. On editing start, `LineInput` is initialized from current value. On `Submit`, value is written back to the field identified by `PathField`.
- `clear_settings_modals()` sets `settings_editing_path` to `None`.
- Add `is_editing_path(&self) -> bool` and `is_editing_field(&self, index: usize) -> bool` helpers.
**Migration checklist** — all sites referencing the old booleans must be updated. Verify by grepping for the removed field names (`settings_editing_download_dir`, `settings_editing_export_path`, `settings_editing_import_path`) — zero hits after migration:
- `src/main.rs` handle_settings_key priority 4 block (~line 550)
- `src/main.rs` Enter handler for fields 5, 9, 12, 14 (~line 605)
- `src/main.rs` render_settings `is_editing_this_path` check (~line 2693)
- `src/main.rs` `any_path_editing` footer check (~line 2724)
- `src/app.rs` field declarations (~line 218)
- `src/app.rs` `clear_settings_modals` (~line 462)
- `src/app.rs` `Default` / `new` initialization
### `src/main.rs` — `handle_settings_key()`
- **Priority 4 block**: Replace with `if let Some((field, ref mut input)) = app.settings_editing_path`. Call `input.handle(key)` and match on result. `Submit` writes value back via `field`, `Cancel` discards.
- **Enter on path fields**: Construct `LineInput::new(current_value)` paired with appropriate `PathField` variant.
### `src/main.rs` — `render_settings()`
- When editing, render via `input.render_parts()` with cursor char in inverted style.
- Footer hints in editing state: `"[←→] Move [Tab] Complete (at end) [Enter] Confirm [Esc] Cancel"`
- If `input.completion_error` is true, append `"(cannot read directory)"` to footer. Clears on next keystroke.
## Key files
- `src/ui/line_input.rs` — new
- `src/ui/mod.rs` — add module
- `src/app.rs` — state fields, `clear_settings_modals()`, helper methods
- `src/main.rs` — key handling, rendering, footer
## Verification
1. `cargo build` — no warnings
2. `cargo test` — all existing + new unit tests pass
3. **Unit tests for `LineInput`** (in `line_input.rs`):
- Insert char at start, middle, end
- Delete/backspace at boundaries (start of line, end, empty string)
- Ctrl+W: `"foo bar "` → `"foo "`, `" foo"` → `" "`, `""` → `""`
- Cursor: left at 0 stays 0, right at end stays at end
- Home/End position correctly
- Ctrl+U clears text and cursor to 0
- Tab at end-of-line with no match → no completions, no panic
- Tab at mid-line → no-op
- Tab cycling wraps around; BackTab cycles reverse
- Non-Tab key resets completion state
- `render_parts()` returns correct slices at start, middle, end positions
4. **Grep verification**: `grep -rn 'settings_editing_download_dir\|settings_editing_export_path\|settings_editing_import_path' src/` returns zero hits
5. Manual testing:
- Navigate to Export Path, press Enter → cursor appears at end
- Arrow left/right moves cursor, Home/End work
- Backspace/Delete at cursor position, Ctrl+U/Ctrl+W
- Type partial path, Tab → completions cycle; Shift+Tab reverses
- Tab on directory appends separator, allows continued completion
- Tab on nonexistent path → footer shows "(cannot read directory)"
- Enter confirms, Esc cancels (value reverts)
- All four path fields (code dir, passage dir, export, import) work identically

View File

@@ -0,0 +1,203 @@
# Plan: Create Test User Profiles at Various Skill Tree Progression Levels
## Context
We need importable JSON test profiles representing users at every meaningful stage of skill tree progression. Each profile must have internally consistent key stats, drill history, and skill tree state so the app behaves as if a real user reached that level. The profiles will be used for manual regression testing of UI and logic at each progression stage.
## Design Decisions
- **Ranked mode**: Only profiles 5+ (multi-branch and beyond) include ranked key stats and ranked drills; earlier profiles have empty ranked stats since new users wouldn't have encountered ranked mode yet.
- **`total_score`**: Synthetic plausible value per profile (not replayed from drill history). The goal is UI/progression testing, not scoring fidelity. Score is set to produce a reasonable `level_from_score()` result for each progression stage.
- **Key stats coverage**: `KeyStatsStore` contains only keys that have been practiced (have at least one correct keystroke in drill history). Unlocked-but-unpracticed keys are absent — this is realistic since a freshly unlocked key has no stats. Locked keys are always absent.
- **Fixtures are committed assets**: Generated once, checked into `test-profiles/`. The generator binary is kept for regeneration if schema evolves. Output is deterministic (no RNG — all values computed from formulas).
- **Timestamps**: Monotonically increasing, spaced ~2 minutes apart within a day, spread across days matching `streak_days`. `last_practice_date` derived from the last drill timestamp.
## Consistency Invariants
Every generated profile must satisfy:
1. `KeyStatsStore` contains only practiced keys (subset of unlocked keys). No locked-branch keys ever appear. Every key in stats must have `sample_count > 0`.
2. `KeyStat.confidence >= 1.0` for all keys in completed levels; `< 1.0` for keys in the current in-progress level that are still being learned.
3. `ProfileData.total_drills == drill_history.drills.len()`
4. `ProfileData.total_score` is a plausible synthetic value producing a reasonable level via `level_from_score()`.
5. `ProfileData.streak_days` and `last_practice_date` are consistent with drill timestamps.
6. `DrillResult.per_key_times` only reference keys from the profile's final unlocked set. (Temporal progression fidelity within drill history is a non-goal — all drills use the final-state key pool for simplicity. The goal is testing UI/import behavior at each progression snapshot, not simulating the exact journey.)
7. `ranked_key_stats` is empty (default) for profiles 1-4; populated for profiles 5-7 with stats for keys appearing in ranked drills.
8. Branch marked `Complete` only if all keys in all levels have `confidence >= 1.0`.
9. Drill timestamps are monotonically increasing across the full history.
## Profiles
All files in `test-profiles/` at project root. Each is a valid `ExportData` JSON.
### 1. `01-brand-new.json` — Fresh Start
- **Skill tree**: Lowercase `InProgress` level 0, all others `Locked`
- **Key stats**: Empty
- **Ranked key stats**: Empty
- **Drill history**: Empty (0 drills)
- **Profile**: 0 drills, 0 score, 0 streak
- **Tests**: Initial onboarding, first-run UI, empty dashboard
### 2. `02-early-lowercase.json` — Early Lowercase (10 keys)
- **Skill tree**: Lowercase `InProgress` level 4 (6 base + 4 unlocked = 10 keys: e,t,a,o,i,n,s,h,r,d)
- **Key stats**: e,t,a,o,i,n at confidence >= 1.0 (mastered); s,h,r,d at confidence 0.3-0.7
- **Ranked key stats**: Empty
- **Drill history**: 15 adaptive drills
- **Profile**: 15 drills, synthetic score, 3-day streak
- **Tests**: Progressive lowercase unlock, focused key targeting weak keys, early dashboard
### 3. `03-mid-lowercase.json` — Mid Lowercase (18 keys)
- **Skill tree**: Lowercase `InProgress` level 12 (6 + 12 = 18 keys, through 'y')
- **Key stats**: First 14 keys mastered, next 4 at confidence 0.4-0.8
- **Ranked key stats**: Empty
- **Drill history**: 50 adaptive drills
- **Profile**: 50 drills, synthetic score, 7-day streak
- **Tests**: Many keys unlocked, skill tree partial progress display
Note: Lowercase level semantics — `current_level` = number of keys unlocked beyond the initial 6 (`LOWERCASE_MIN_KEYS`). So level 12 means 18 total keys.
### 4. `04-lowercase-complete.json` — Lowercase Complete
- **Skill tree**: Lowercase `Complete` (level 20, all 26 keys), all others `Available`
- **Key stats**: All 26 lowercase at confidence >= 1.0
- **Ranked key stats**: Empty
- **Drill history**: 100 adaptive drills
- **Profile**: 100 drills, synthetic score, 14-day streak
- **Tests**: Branch completion, all branches showing Available, branch start UI
### 5. `05-multi-branch.json` — Multiple Branches In Progress
- **Skill tree**:
- Lowercase: `Complete`
- Capitals: `InProgress` level 1 (L1 mastered, working on L2 "Name Capitals")
- Numbers: `InProgress` level 0 (working on L1 "Common Digits")
- Prose Punctuation: `InProgress` level 0 (working on L1 "Essential")
- Whitespace: `Available`
- Code Symbols: `Available`
- **Key stats**: All lowercase mastered; T,I,A,S,W,H,B,M mastered; J,D,R,C,E partial; 1,2,3 partial; period/comma/apostrophe partial
- **Ranked key stats**: Some ranked stats for lowercase keys (from ~20 ranked drills)
- **Drill history**: 200 drills (170 adaptive, 10 passage, 20 ranked adaptive)
- **Profile**: 200 drills, synthetic score, 21-day streak
- **Tests**: Multi-branch progress, branch-specific drills, global vs branch focus selection
### 6. `06-advanced.json` — Most Branches Complete
- **Skill tree**:
- Lowercase: `Complete`
- Capitals: `Complete`
- Numbers: `Complete`
- Prose Punctuation: `Complete`
- Whitespace: `Complete`
- Code Symbols: `InProgress` level 2 (L1+L2 done, working on L3 "Logic & Reference")
- **Key stats**: All mastered except Code Symbols L3 (&,|,^,~,!) at partial confidence and L4 absent
- **Ranked key stats**: Substantial ranked stats across all mastered keys
- **Drill history**: 500 drills (350 adaptive, 50 passage, 50 code, 50 ranked)
- **Profile**: 500 drills, synthetic score, 45-day streak, best_streak: 60
- **Tests**: Near-endgame, almost all keys, code symbols progression
### 7. `07-fully-complete.json` — Everything Mastered
- **Skill tree**: ALL branches `Complete`
- **Key stats**: All keys confidence >= 1.0, high sample counts, low error rates
- **Ranked key stats**: Full ranked stats for all keys
- **Drill history**: 800 drills (400 adaptive, 150 passage, 150 code, 100 ranked)
- **Profile**: 800 drills, synthetic score, 90-day streak
- **Tests**: Endgame, all complete, full dashboard, comprehensive ranked data
## Implementation
### File: `src/bin/generate_test_profiles.rs`
A standalone binary that imports keydr crate types and generates all profiles.
#### Helpers
```rust
/// Generate KeyStat with deterministic values derived from target confidence.
/// filtered_time_ms = target_time_ms / confidence
/// best_time_ms = filtered_time_ms * 0.85
/// sample_count and recent_times scaled to confidence level
fn make_key_stat(confidence: f64, sample_count: usize, target_cpm: f64) -> KeyStat
/// Generate a DrillResult with deterministic per_key_times.
/// Keys are chosen from the provided unlocked set.
fn make_drill_result(
wpm: f64, accuracy: f64, char_count: usize,
keys: &[char], timestamp: DateTime<Utc>,
mode: &str, ranked: bool,
) -> DrillResult
/// Wrap all components into ExportData.
fn make_export(
config: Config,
profile: ProfileData,
key_stats: KeyStatsData,
ranked_key_stats: KeyStatsData,
drill_history: DrillHistoryData,
) -> ExportData
/// Generate monotonic timestamps: base_date + day_offset + drill_offset * 2min
fn drill_timestamp(base: DateTime<Utc>, day: u32, drill_in_day: u32) -> DateTime<Utc>
```
#### Profile builders
One function per profile (`build_profile_01()` through `build_profile_07()`) that:
1. Constructs `SkillTreeProgress` with exact branch statuses and levels
2. Builds `KeyStatsStore` with stats only for unlocked/practiced keys
3. Generates drill history with proper timestamps and key references
4. Sets `total_score` to a synthetic plausible value for the progression stage
5. Derives `last_practice_date` and streak from drill timestamps
6. Returns `ExportData`
#### Main
```rust
fn main() {
fs::create_dir_all("test-profiles").unwrap();
for (name, data) in [
("01-brand-new", build_profile_01()),
("02-early-lowercase", build_profile_02()),
// ...
] {
let json = serde_json::to_string_pretty(&data).unwrap();
fs::write(format!("test-profiles/{name}.json"), json).unwrap();
}
}
```
### Key source files referenced
- `src/store/schema.rs` — ExportData, ProfileData, KeyStatsData, DrillHistoryData
- `src/engine/skill_tree.rs` — SkillTreeProgress, BranchProgress, BranchStatus, level definitions, LOWERCASE_MIN_KEYS=6
- `src/engine/key_stats.rs` — KeyStatsStore, KeyStat, DEFAULT_TARGET_CPM=175.0
- `src/session/result.rs` — DrillResult, KeyTime
- `src/config.rs` — Config defaults
- `src/engine/scoring.rs` — compute_score(), level_from_score()
## Verification
### Automated: `tests/test_profile_fixtures.rs`
Integration tests (separate from the generator binary) that for each generated JSON file:
- Deserializes into `ExportData` successfully
- Asserts `total_drills == drills.len()`
- Asserts no locked-branch keys appear in `KeyStatsStore`
- Asserts all keys in completed levels have `confidence >= 1.0`
- Asserts all keys in stats have `sample_count > 0`
- Asserts timestamps are monotonically increasing
- Asserts `ranked_key_stats` is empty for profiles 1-4
- Imports into a temp `JsonStore` via `import_all()` without error
### Manual smoke test per profile
| Profile | Check |
|---------|-------|
| 01 | Dashboard shows level 1, 0 drills, empty skill tree except lowercase InProgress |
| 02 | Skill tree shows 10/26 lowercase keys, focused key is from the weak-key pool (s,h,r,d) |
| 03 | Skill tree shows 18/26 lowercase keys, dashboard stats populated |
| 04 | All 6 branches visible, 5 show "Available", lowercase shows "Complete" |
| 05 | 3 branches InProgress with level indicators, branch drill selector works |
| 06 | 5 branches Complete, Code Symbols shows L3 in progress |
| 07 | All branches Complete, all stats filled, ranked data visible |
### Generation
`cargo run --bin generate_test_profiles` produces 7 files in `test-profiles/`
Generated JSON files are committed to the repo. CI runs fixture validation tests against the committed files (no regeneration step). If the schema changes, the developer reruns the generator manually and commits the updated fixtures.

View File

@@ -0,0 +1,572 @@
# keydr Multilingual Dictionary + Keyboard Layout Internationalization Plan
## Context
We currently use an English-only dictionary and an ASCII-centric adaptive model:
- Dictionary is hardcoded to `assets/words-en.json` in `src/generator/dictionary.rs`.
- Dictionary ingestion filters to ASCII lowercase only (`is_ascii_lowercase`).
- Transition table building (`src/generator/transition_table.rs`) skips non-ASCII words.
- Adaptive drill generation in `src/app.rs` builds lowercase filter from `is_ascii_lowercase`.
- Skill tree lowercase branch is fixed to English `a-z` frequency in `src/engine/skill_tree.rs`.
- Keyboard rendering/hit-testing logic has hardcoded row offsets and row count assumptions in `src/ui/components/keyboard_diagram.rs` and `src/ui/components/stats_dashboard.rs`.
## Explicit product decision: clean break
This app is currently work-in-progress and has no real user base. We explicitly do
not need to preserve old config/state/export compatibility for this change. If data
must be recreated from scratch, that is acceptable.
## Goals
1. Add user-selectable dictionary language (default `en`) using keybr-provided dictionary files.
2. Add user-selectable keyboard layout profiles for multiple languages.
3. Ensure keyboard visualizations, explorer, and stats heatmaps render correctly for variable row shapes and non-English keycaps.
4. Use a clean-break implementation with no backward-compatibility requirements.
5. Maintain license compliance for newly imported dictionaries.
## Non-goals (first delivery)
1. Full IME/dead-key composition support.
2. Full rewrite of adaptive model for every script from day one.
3. Perfect locale-specific pedagogy for all languages in phase 1.
4. Backward compatibility for old config/profile/export data.
## Execution constraints (must be explicit before implementation)
1. **Unicode normalization policy:** Use NFC as canonical storage/matching form for dictionary ingestion, generated text, keystroke comparison, and persisted stats keys. Do not use NFKC in phase 1 to avoid compatibility-fold surprises.
2. **Character equivalence policy:** Equality is by normalized scalar sequence (NFC), not by glyph appearance. Composed/decomposed equivalents must compare equal after normalization.
3. **Clean-break schema cutover policy:** This rollout uses hard reset semantics for old unscoped stats/profile files. On first run of the new schema version, old files are ignored (optionally archived with `.legacy` suffix); no partial migration path.
4. **Capability gating policy:** Only language/layout pairs marked supported in the registry capability matrix are selectable in UI during phased rollout.
5. **Performance envelope policy:** Keyboard geometry recomputation must be bounded and cached by profile key + render mode + viewport size.
## Upstream data availability
`keybr-content-words` includes dictionaries for:
`ar, be, cs, da, de, el, en, es, et, fa, fi, fr, he, hr, hu, it, ja, lt, lv, nb, nl, pl, pt, ro, ru, sl, sv, th, tr, uk`
Recommended rollout strategy:
- Initial support for Latin-script languages first (`en, de, es, fr, it, pt, nl, sv, da, nb, fi, pl, cs, ro, hr, hu, lt, lv, sl, et, tr`).
- Later support for non-Latin scripts (`el, ru, uk, be, ar, fa, he, ja, th`) after script-specific input/model behavior is in place.
---
## Key Architectural Decisions
### 1) Language Pack registry
Add a registry module (e.g. `src/l10n/language_pack.rs`) containing:
- `language_key`
- `display_name`
- `script`
- `dictionary_asset_id`
- `supported_keyboard_layout_keys`
- `primary_letter_sequence` (for ranked progression)
- `starter_weights` and optional `vowel_set` for generator fallback behavior
- `support_level` (`full`, `experimental`, `blocked`)
- `normalization_form` (phase 1 fixed to `NFC`)
- `input_capabilities` (for example `direct_letters_only`, `needs_ime`)
This becomes the single source of truth for language behavior.
### 2) Runtime dictionary/generator rebuild is required
Changing `dictionary_language` must immediately take effect without restart.
Implement `App::rebuild_language_assets(&mut self)` that rebuilds:
- `Dictionary`
- `TransitionTable`
- any cached generator state derived from language assets
- focused-character transforms derived from language rules
- drill-generation allowlists that depend on language pack data
Call it whenever language or language-dependent layout changes in settings.
`rebuild_language_assets` must also refresh capitalization/case behavior inputs used by adaptive generation.
`rebuild_language_assets` invalidation contract (required):
- always invalidate and rebuild `Dictionary` and `TransitionTable`
- clear adaptive cross-drill dictionary history cache
- clear/refresh any cached language-specific focus mapping
- do **not** mutate in-progress drill text
- all newly generated drills after rebuild must use new language assets
### 3) Asset loading strategy: compile-time embedded assets
For Phase 1 scope, dictionaries will be embedded at compile-time (generated asset map + `include_str!`/equivalent), not runtime file discovery.
Rationale:
- deterministic packaging
- no runtime path resolution complexity
- simpler cross-platform behavior
Tradeoff: larger binary size, acceptable for this phase.
### 4) Transition table fallback strategy
`TransitionTable::build_english()` will be gated to `language_key == "en"` only.
For non-English languages:
- use dictionary-derived transition table only
- if sparse, degrade gracefully to simple dictionary sampling behavior rather than English fallback model
### 5) Keyboard geometry refactor strategy
`src/ui/components/keyboard_diagram.rs` is a substantial refactor (all render and hit-test paths).
Implement shared `KeyboardGeometry` computed once per render context and consumed by:
- compact/full/fallback renderers
- all key hit-testing paths
- shift hit-testing paths
No duplicate hardcoded offsets should remain.
Performance constraints for geometry:
- geometry cache key: `(layout_key, render_mode, viewport_width, viewport_height)`
- recompute only when cache key changes
- hit-testing must be O(number_of_keys) or better per event with no per-key allocation
- include a benchmark/smoke check to detect regressions in repeated render/hit-test loops
### 6) Finger assignment source of truth
Finger assignment must be profile metadata, not inferred by QWERTY column heuristics.
Each keyboard profile defines finger mapping for each physical key position.
### 7) Stats isolation strategy
Stats are language-scoped and layout-scoped.
Adopt per-scope storage files (for example):
- `key_stats_<language>_<layout>.json`
- `key_stats_ranked_<language>_<layout>.json`
- optional scoped drill history files
No mixed-language key stats in a single store.
Profile/scoring scoping policy:
- `skill_tree` progress is language-scoped (at minimum by `language_key`).
- `total_score`, `total_drills`, `streak_days`, and `best_streak` remain global.
- `ProfileData` will separate global fields from language-scoped progression state.
Scoped-file discovery mechanism:
- registry-driven + current-config driven only
- app loads current scope directly and only enumerates scopes from supported language/layout registry pairs
- no unconstrained glob-based discovery of arbitrary stale files
Import/export strategy for scoped stats:
- export bundles all supported scoped stats files present in the data dir
- each bundle entry includes explicit `language_key` and `layout_key` metadata
- import applies two-phase commit per scoped target file
- export/import also includes language-scoped `skill_tree` progress entries with `language_key` metadata
Atomicity requirements for scoped import:
- stage writes to `<target>.tmp`
- flush file contents (`sync_all`) before rename
- rename temp file onto target atomically where supported
- on any failure, remove temp file and keep existing target untouched
- no commit of partially imported scope bundles
### 8) Settings architecture
Current index-based settings handling is fragile.
Phase 1 includes refactor from positional integer indices to enum/struct-based settings entries before adding multilingual controls.
Profile key validation must be registry-backed. Do not rely on `KeyboardModel::from_name()` fallback behavior.
Validation error taxonomy (typed, stable):
- `UnknownLanguage`
- `UnknownLayout`
- `UnsupportedLanguageLayoutPair`
- `LanguageBlockedBySupportLevel`
UI must show deterministic user-facing error text for each class (used by tests).
In-progress drill behavior on language/layout change:
- language/layout changes rebuild assets immediately for future generation
- current in-progress drill text is not mutated mid-drill
- new language/layout applies on the next drill generation
### 9) Unicode handling architecture
Define one shared Unicode utility module used by dictionary ingestion, generators, and input matching:
- normalize all dictionary entries to NFC at load time
- normalize typed characters before comparison against expected text
- normalize persisted per-key identifiers before write/read
- provide helper tests for composed/decomposed equivalence (for example `é` vs `e + ◌́`)
### 10) Rollout capability matrix architecture
Add a single registry-backed capability matrix keyed by `(language_key, layout_key)`:
- `enabled`: selectable and fully supported
- `preview`: selectable with warning banner
- `disabled`: visible but not selectable
Phase-gating must read this matrix in settings and selection screens; no ad-hoc checks.
---
## Phased Implementation
## Phase 0: Data + compliance groundwork
### Tasks
1. Import selected dictionaries to `assets/dictionaries/words-<lang>.json`.
2. Add sidecar license/provenance files for each imported dictionary.
3. Update `THIRD_PARTY_NOTICES.md` with imported assets.
4. Add validation script for dictionary manifest/checksums.
5. Define language pack registry seed data (including temporary `primary_letter_sequence` values).
6. Add `support_level` and capability-matrix seed entries for every language/layout pair.
7. Add a build-time utility that derives letter frequency sequence from each dictionary (seed data source of truth; manual overrides allowed but documented).
8. Write `docs/unicode-normalization-policy.md` (NFC/equivalence rules + examples).
### Verification
1. All imported dictionaries listed in third-party notices.
2. Sidecar license/provenance file exists for each imported dictionary.
3. Manifest validation script passes.
4. Build-time frequency derivation utility emits reproducible output for seeded languages.
5. Unicode policy doc exists and includes composed/decomposed test cases.
---
## Phase 1: Settings and configuration foundation
### Tasks
1. Add `dictionary_language` to `Config`.
2. Refactor settings implementation from raw indices to typed settings entries (enum/descriptor model).
3. Add settings controls for:
- dictionary language
- canonical keyboard layout profile key
4. Implement explicit invalid combination handling (reject with message), not silent fallback.
5. Wire language/layout change actions to `App::rebuild_language_assets(&mut self)`.
6. Introduce clean-break schema/version update for config/profile/store formats with hard-reset behavior for old files.
7. Replace `from_name` wildcard fallback paths with explicit lookup failure handling tied to registry validation.
8. Update import/export schema and transaction flow for scoped stats bundles.
9. Split profile persistence into global fields + language-scoped skill tree progress map.
10. Enforce capability-matrix gating in settings/selectors (`enabled/preview/disabled` states).
11. Add typed validation errors and stable user-facing status messages.
### Code areas
- `src/config.rs`
- `src/main.rs` (settings UI rendering and input handling)
- `src/app.rs` (settings action handlers, rebuild trigger)
- `src/store/schema.rs`
- `src/store/json_store.rs`
### Verification
1. Unit tests for config defaults/validation.
2. Unit tests for settings navigation/editing after index refactor.
3. Runtime test: changing dictionary language updates generated drills without restart.
4. Runtime test: invalid language/layout pair is rejected with visible error/status.
5. Export/import test: scoped stats for multiple language/layout pairs round-trip correctly.
6. Runtime test: changing language mid-drill preserves current drill text and applies new language on next drill.
7. Schema cutover test: old-format files are ignored/archived and never partially loaded.
8. UI test: disabled/preview capability-matrix entries render and behave correctly.
---
## Phase 2: Dictionary, transition table, and generator internationalization
### Tasks
1. Refactor `Dictionary::load(language_key)` with embedded asset map.
2. Remove ASCII-only filtering from dictionary ingestion and transition building.
3. Extend `phonetic.rs` to remove English hardcoding:
- replace hardcoded starter biases with language-pack starter data or derived frequencies
- replace fallback `"the"` with language-aware fallback (for example: top dictionary word)
- make vowel recovery optional/parameterized by language pack
- remove `is_ascii_lowercase` focus filtering and rely on allowed-character logic
4. Implement transition fallback policy:
- `build_english()` only for English
- non-English graceful degradation path without English fallback table
5. Address adaptive and non-adaptive mode filters:
- remove hardcoded `('a'..='z')` filters in code/passage modes
- use language-pack allowed sets where applicable
6. Refactor capitalization pipeline to Unicode-aware behavior:
- replace ASCII-only case checks/conversions in `capitalize.rs`
- use Unicode case mapping and language-pack constraints
- ensure non-ASCII letters (for example `ä/Ä`, `é/É`) are handled correctly
7. Implement shared normalization utility and apply it consistently in:
- dictionary load path
- generated text comparison/matching paths
- persisted key identity paths
8. Multilingual audit checklist (required pass/fail):
- `rg -n "is_ascii" src/app.rs src/generator/*.rs` has no unreviewed hits affecting multilingual behavior
- every remaining `is_ascii*` hit has a documented justification comment or issue reference
### Code areas
- `src/generator/dictionary.rs`
- `src/generator/transition_table.rs`
- `src/generator/phonetic.rs`
- `src/generator/capitalize.rs`
- `src/app.rs` (adaptive/code/passage filter construction)
### Verification
1. Unit tests for dictionary loading per supported language.
2. Unit tests for transition table generation with non-English characters.
3. Unit tests for phonetic fallback behavior per language pack.
4. Unit tests for capitalization correctness on non-ASCII letters.
5. Regression tests for English output quality.
6. Unit tests for NFC normalization and composed/decomposed equivalence.
---
## Phase 3: Keyboard layout profile system
### Tasks
1. Replace ad-hoc constructors with canonical keyboard profile registry.
2. Add language-relevant profiles (`de_qwertz`, `fr_azerty`, etc.).
3. Add profile metadata:
- key rows and shifted/base pairs
- geometry hints
- modifier placement metadata
- per-key finger assignments
4. Remove legacy alias layer and enforce canonical profile keys.
5. Evaluate `src/keyboard/layout.rs` usage:
- if unused, delete it
- otherwise fold it into the new profile registry without duplicate sources of truth
### Code areas
- `src/keyboard/model.rs`
- `src/keyboard/layout.rs`
- `src/keyboard/display.rs` (if locale labels/short labels need extension)
- `src/config.rs`
### Verification
1. Unit tests for all canonical profile keys.
2. Unit tests for profile completeness and unique key mapping.
3. Unit tests for finger assignment coverage/consistency.
---
## Phase 4: Keyboard visualization and hit-testing refactor
### Tasks
1. Implement shared `KeyboardGeometry` used by all keyboard rendering modes.
2. Rewrite keyboard diagram rendering paths to use shared geometry.
3. Rewrite all keyboard hit-testing paths to use shared geometry.
4. Refactor stats dashboard keyboard heatmap/timing rendering to use profile geometry metadata.
5. Ensure explorer and selection logic works for variable row counts and locale keycaps.
6. Update sentinel boundary tests if new files must reference sentinel constants.
7. Remove ASCII shift-display guards in keyboard rendering:
- replace `is_ascii_alphabetic()`-based shifted display checks
- use profile-defined shiftability (`base != shifted` or explicit shiftable set)
8. Audit and replace ASCII-specific input-handling logic in `main.rs`:
- caps-lock inference
- depressed-key normalization
- shift guidance and shifted-key detection in keyboard UI paths
9. Add geometry cache and recompute guards keyed by `(layout_key, render_mode, viewport)` with benchmark coverage.
### Code areas
- `src/ui/components/keyboard_diagram.rs`
- `src/ui/components/stats_dashboard.rs`
- `src/main.rs` keyboard explorer handlers
- `src/main.rs` input handling (`handle_key`, caps/shift logic, keyboard guidance/render helpers)
- `src/app.rs` explorer state/focus use
- `src/keyboard/display.rs` tests
### Verification
1. Snapshot/golden tests for compact/full/fallback rendering per profile.
2. Hit-test roundtrip tests per profile.
3. Manual keyboard explorer smoke tests for US + non-US profiles.
4. Sentinel boundary tests pass with updated policy.
5. Manual test: shifted rendering works for non-ASCII letter keys where profile defines shifted forms.
6. Manual test: caps/shift guidance and depressed-key behavior are correct for non-ASCII key input.
7. Benchmark/smoke test: repeated render + hit-test loops meet baseline without per-frame geometry rebuild when cache key is unchanged.
---
## Phase 5: Skill tree and ranked progression internationalization
### Tasks
1. Replace fixed English lowercase progression with language-pack `primary_letter_sequence`.
2. Replace hardcoded "lowercase as background" branch logic with language-pack primary-letter background behavior.
3. Remove UI copy assumptions of "26 lowercase" and `a-z`.
4. Ensure ranked gating uses language-pack readiness (sequence + profile support).
5. Define letter-frequency derivation approach:
- derive initial sequence from dictionary frequency data (build-time utility), not hand-curated long-term
6. Milestone-copy audit checklist (required pass/fail):
- grep for hardcoded milestone language in `main.rs` (`26`, `a-z`, `A-Z`, `lowercase`)
- replace with language-pack-aware dynamic copy
- add tests asserting copy adjusts with different sequence lengths
### Code areas
- `src/engine/skill_tree.rs`
- `src/app.rs` (focus/background/filter logic)
- `src/main.rs` (milestone/help copy)
### Verification
1. Tests for progression with multiple language sequences.
2. Tests for background-branch selection correctness.
3. Snapshot tests for milestone text across languages.
---
## Phase 6: UX polish, test parameterization, and rollout
### Tasks
1. Add dedicated language/layout selector screens where needed.
- Implemented in `src/main.rs` + `src/app.rs` with `DictionaryLanguageSelect` and `KeyboardLayoutSelect`.
2. Add explicit support-matrix messaging for partially supported scripts.
- Implemented in selector + settings UI copy in `src/main.rs` (`preview`/`disabled` state messaging).
3. Add parameterized test helpers:
- language-aware allowed key sets
- expected progression counts
- profile fixtures
- Implemented via cross-language/layout fixtures and property tests in `src/l10n/language_pack.rs`, `src/engine/skill_tree.rs`, and `src/ui/components/keyboard_diagram.rs`.
4. Document that Phase 2 may temporarily allow language/dictionary mismatch with keyboard visuals until Phase 3/4 is complete.
5. Add explicit note in docs that Phase 2 mismatch window is expected and resolved by Phase 4.
- Implemented in `docs/multilingual-rollout-notes.md`.
6. Add cross-language property tests:
- key uniqueness per profile
- hit-test round-trip invariants
- progression monotonicity per language sequence
- Implemented in `src/keyboard/model.rs`, `src/ui/components/keyboard_diagram.rs`, and `src/engine/skill_tree.rs`.
### Code areas
- `src/main.rs`
- `src/app.rs`
- test modules across `src/*`
- `docs/`
### Verification
1. End-to-end manual flows for language switch + layout switch + drill generation + keyboard explorer + stats.
2. Performance checks for embedded dictionary footprint and startup latency.
3. Test suite passes with parameterized language/profile cases.
4. Property/invariant tests pass for key uniqueness, hit-test round-trip, and progression monotonicity.
---
## File-by-file Impact Matrix
### Core config and app wiring
- `src/config.rs`
- add `dictionary_language` and canonical `keyboard_layout` profile key validation
- `src/app.rs`
- add `rebuild_language_assets`
- remove ASCII-only filters and audit residual ASCII assumptions (`rg is_ascii` pass)
- wire settings actions to runtime rebuild
- `src/main.rs`
- refactor settings UI to typed entries
- add/update selectors and error/status handling
- audit/replace ASCII-specific input/caps/shift handling
### Generators and adaptive engine
- `src/generator/dictionary.rs`
- dynamic, language-aware load via embedded registry
- `src/generator/transition_table.rs`
- non-ASCII support and explicit English-only fallback gating
- `src/generator/phonetic.rs`
- remove hardcoded English starter/vowel/fallback assumptions
- `src/generator/capitalize.rs`
- replace ASCII-only casing logic with Unicode-aware capitalization rules
### Skill progression
- `src/engine/skill_tree.rs`
- language-pack primary sequence
- language-pack background branch behavior
### Keyboard modeling and visualization
- `src/keyboard/model.rs`
- canonical profile registry with per-key finger mapping
- `src/keyboard/layout.rs`
- delete or fold into model registry
- `src/ui/components/keyboard_diagram.rs`
- shared geometry + full hit-test rewrite
- `src/ui/components/stats_dashboard.rs`
- geometry-driven keyboard heatmap/timing rendering
- `src/keyboard/display.rs`
- sentinel boundary test updates as needed
### Persistence/schema
- `src/store/schema.rs`
- clean-break schema/version bump as needed
- split profile data into global fields + language-scoped skill tree progress
- `src/store/json_store.rs`
- scoped stats storage by language/layout
- scoped file discovery based on supported registry pairs
- export/import scoped bundle handling with language/layout metadata
- export/import language-scoped skill tree progress entries
### Assets/compliance/docs
- `assets/dictionaries/*`
- `assets/dictionaries/*.license`
- `THIRD_PARTY_NOTICES.md`
- `docs/license-compliance.md`
- `docs/unicode-normalization-policy.md`
---
## Risks and mitigations
1. **Risk:** Non-Latin scripts break assumptions in multiple modules.
- **Mitigation:** staged rollout by script; support matrix gating.
2. **Risk:** Keyboard visualization regressions during geometry rewrite.
- **Mitigation:** shared geometry abstraction + dedicated hit-test/render tests.
3. **Risk:** Clean-break schema reset discards local data.
- **Mitigation:** explicitly documented and accepted by product decision.
4. **Risk:** Settings refactor increases short-term scope.
- **Mitigation:** do it early to avoid repeated index-cascade bugs.
5. **Risk:** Embedded dictionary set increases binary size/startup memory.
- **Mitigation:** track size/startup metrics per release and switch to hybrid packaging if thresholds are exceeded.
---
## Definition of Done
1. Language switch updates dictionary-driven generation without restart.
2. Keyboard profiles are canonical and language-aware; no legacy alias dependency.
3. Keyboard diagram, explorer, and stats views are geometry-driven and correct for supported profiles.
4. Ranked progression uses language-pack primary sequences and background logic.
5. Code/passage/adaptive modes no longer depend on hardcoded `a-z` filters.
6. Stats are isolated by language/layout scope.
7. Skill tree progression is language-scoped while streak/score totals remain global.
8. Third-party attributions and license sidecars cover all imported dictionary assets.
9. Automated tests cover runtime rebuild, generator behavior, keyboard geometry/hit-testing, progression invariants, and parameterized language/profile cases.
10. Unicode normalization policy is implemented and tested across ingestion, generation, input matching, and persisted stats keys.
11. Clean-break schema cutover behavior is deterministic (hard-reset semantics) and covered by automated tests.
12. Capability matrix gating is enforced consistently across settings/selectors and covered by UI/runtime tests.

View File

@@ -0,0 +1,154 @@
# Skill Tree Milestone Popups
## Context
When users reach major skill tree milestones, they should see celebratory popups explaining what they've achieved and what's next. Four milestone types:
1. **Lowercase complete** — all 26 lowercase keys mastered, other branches become available
2. **Branch complete** — a non-lowercase branch fully mastered
3. **All keys unlocked** — every key on the keyboard is available for practice
4. **All keys mastered** — every key at full confidence, ultimate achievement
These popups appear after key unlock/mastery popups and before the drill summary screen, using the existing `milestone_queue` system. The existing post-drill input lock (800ms) applies to these popups when they're the first popup shown after a drill.
## Implementation
### 1. Extend `SkillTreeUpdate` (`src/engine/skill_tree.rs`)
Add fields to `SkillTreeUpdate`:
```rust
pub branches_newly_available: Vec<BranchId>, // Locked → Available transitions
pub branches_newly_completed: Vec<BranchId>, // → Complete transitions
pub all_keys_unlocked: bool, // every key now in practice pool
pub all_keys_mastered: bool, // every key at confidence >= 1.0
```
**In `update()`:**
- Snapshot non-lowercase branch statuses before the auto-unlock loop. After it, collect `Locked``Available` transitions into `branches_newly_available`.
- Snapshot all branch statuses before updates. After `update_lowercase()` and all `update_branch_level()` calls, collect branches that became `Complete` into `branches_newly_completed`.
- `all_keys_unlocked`: compare `total_unlocked_count()` against `compute_total_unique_keys()`. Set to `true` only if they're equal now AND they weren't equal before (using a before-snapshot of unlocked count).
- `all_keys_mastered`: `true` if every branch in `ALL_BRANCHES` has `BranchStatus::Complete` after updates AND at least one wasn't `Complete` before.
`BranchId` is already used across all layers. Display names come from `get_branch_definition(id).name`.
### 2. Add milestone variants to `MilestoneKind` (`src/app.rs`)
```rust
pub enum MilestoneKind {
Unlock,
Mastery,
BranchesAvailable, // lowercase complete → other branches available
BranchComplete, // a non-lowercase branch fully completed
AllKeysUnlocked, // every key on the keyboard is unlocked
AllKeysMastered, // every key at full confidence
}
```
**In `finish_drill()`, after mastery popup queueing**, check each flag and push popups in order:
1. `branches_newly_available` non-empty → push `BranchesAvailable`
2. `branches_newly_completed` non-empty (excluding `BranchId::Lowercase` since `BranchesAvailable` covers it) → push `BranchComplete`
3. `all_keys_unlocked` → push `AllKeysUnlocked`
4. `all_keys_mastered` → push `AllKeysMastered`
For all four: `keys` and `finger_info` are empty, `message` is unused. The renderer owns all copy.
**Input lock**: These popups are pushed to `milestone_queue`, so the existing check `!self.milestone_queue.is_empty()` at `finish_drill()` already triggers `arm_post_drill_input_lock()`. No changes needed — the lock applies to whatever the first popup is.
### 3. Render popup variants in `render_milestone_overlay()` (`src/main.rs`)
Each variant gets its own rendering branch. No keyboard diagram for any of these. All use the standard footer (input lock remaining / "Press any key to continue").
**`BranchesAvailable`:**
- Title: `"New Skill Branches Available!"`
- Body:
```
Congratulations! You've mastered all 26 lowercase
keys!
New skill branches are now available:
• Capitals A-Z
• Numbers 0-9
• Prose Punctuation
• Whitespace
• Code Symbols
Visit the Skill Tree to unlock a new branch and
start training!
Press [t] from the menu to open the Skill Tree
```
(Branch names rendered dynamically from `get_branch_definition(id).name` for each ID in `branches_newly_available`.)
**`BranchComplete`:**
- Title: `"Branch Complete!"`
- Body:
```
You've fully mastered the {branch_name} branch!
Other branches are waiting to be unlocked in the
Skill Tree. Keep going!
Press [t] from the menu to open the Skill Tree
```
(If multiple branches completed simultaneously, list them all: "You've fully mastered the {name1} and {name2} branches!")
**`AllKeysUnlocked`:**
- Title: `"Every Key Unlocked!"`
- Body:
```
You've unlocked every key on the keyboard!
All keys are now part of your practice drills.
Keep training to build full confidence with each
key!
```
**`AllKeysMastered`:**
- Title: `"Full Keyboard Mastery!"`
- Body:
```
Incredible! You've reached full confidence with
every single key on the keyboard!
You've completed everything keydr has to teach.
Keep practicing to maintain your skills!
```
### 4. Sequencing
Queue order in `finish_drill()`:
1. Key unlock popups (existing)
2. Key mastery popups (existing)
3. `BranchesAvailable` (if applicable)
4. `BranchComplete` (if applicable, excluding lowercase)
5. `AllKeysUnlocked` (if applicable)
6. `AllKeysMastered` (if applicable)
The input lock is armed once when `milestone_queue` is non-empty (existing logic). User dismisses each popup with any keypress.
### 5. Tests
**In `src/engine/skill_tree.rs` tests:**
- `branches_newly_available` non-empty on first `update()` after lowercase completion, empty on second call
- `branches_newly_completed` contains the branch ID when a non-lowercase branch completes
- `all_keys_unlocked` fires when the last key becomes available, not on subsequent calls
- `all_keys_mastered` fires when all branches reach Complete, not on subsequent calls
- `branches_newly_available` only contains the five non-lowercase branch IDs
**In `src/app.rs` tests:**
- Queue order test: last lowercase key mastered → queue contains unlock → mastery → BranchesAvailable (no BranchComplete for lowercase)
- Branch complete test: non-lowercase branch completes → BranchComplete queued
- Helper: `seed_near_complete_lowercase(app)` — 25 keys at confidence 1.0, last key at 0.95
## Files to Modify
1. `src/engine/skill_tree.rs` — Extend `SkillTreeUpdate`, detect transitions in `update()`
2. `src/app.rs` — Add variants to `MilestoneKind`, queue popups in `finish_drill()`
3. `src/main.rs` — Render the four new popup variants in `render_milestone_overlay()`
## Verification
1. `cargo build` — compiles cleanly
2. `cargo test` — all existing + new tests pass
3. Manual testing with test profiles for each milestone scenario

View File

@@ -0,0 +1,217 @@
# Plan: Internationalize UI Text
## Context
keydr supports 21 languages for dictionaries and keyboard layouts, but all UI text is hardcoded English (~200 strings across inline literals, `format!()` templates, `const` arrays, `Display` impls, and prebuilt state like milestone messages). This plan translates all app-owned UI copy via a separate "UI Language" config setting. Source texts from code/passage drills remain untranslated. Nested error details from system/library errors (e.g. IO errors, serde errors) embedded in status messages remain in their original form — only the app-owned wrapper text around them is translated.
This initial change ships **English + German only**. Remaining languages will follow in a separate commit.
## Design Decisions
**Library: `rust-i18n` v3**
- `t!("key")` / `t!("key", var = val)` macro API
- Translations in YAML, compiled into binary — no runtime file loading
**Separate UI language setting:** `ui_language` config field independent of `dictionary_language`. Defaults to `"en"`.
**Separate supported locale list:** UI locale validation uses `SUPPORTED_UI_LOCALES` (initially `["en", "de"]`), decoupled from the dictionary language pack system.
**Language names as autonyms everywhere:** All places that display a language name (selectors, settings summaries, status messages) use the language's autonym ("Deutsch", "Français") via a new `autonym` field on `LanguagePack`. No exonyms or locale-translated language names. Tradeoff: users may not recognize unfamiliar languages by autonym alone (e.g. "Suomi" for Finnish), but this is consistent and avoids translating language names per-locale. The existing English `display_name` field remains available as context.
**Stale text on locale switch:** Already-rendered `StatusMessage.text` and open `KeyMilestonePopup` messages stay in the old language until dismissed. Only newly produced text uses the new locale.
**Domain errors stay UI-agnostic:** `LanguageLayoutValidationError::Display` keeps its current English implementation. Translation happens at the UI boundary via a helper function in the i18n module.
**Canonical import:** All files use `use crate::i18n::t;` as the single import style for the translation macro.
## Text Source Categories
| Category | Example Location | Strategy |
|----------|-----------------|----------|
| **Inline literals** | Render functions in `main.rs`, UI components | Replace with `t!()` |
| **`const` arrays** | `UNLOCK_MESSAGES`, `MASTERY_MESSAGES`, `TAB_LABELS`, `FOOTER_HINTS_*` | Convert to functions returning `Vec<String>` or build inline |
| **`format!()` templates** | `StatusMessage` construction in `app.rs`/`main.rs` | Replace template with `t!("key", var = val)` |
| **`Display` impls** | `LanguageLayoutValidationError` | Keep `Display` stable; translate at UI boundary in i18n module |
| **Domain display names** | `LanguagePack.display_name` | Add `autonym` field; code language names stay English ("Rust", "Python") |
| **Cached `'static` fields** | `KeyMilestonePopup.message: &'static str` | Change to `String` |
## Implementation Steps
### Step 1: Centralized i18n module and dependency setup
Add `rust-i18n = "3"` to `[dependencies]` and `serde_yaml = "0.9"` to `[dev-dependencies]` in `Cargo.toml`.
Create `src/i18n.rs`:
```rust
pub use rust_i18n::t;
rust_i18n::i18n!("locales", fallback = "en");
/// Available UI locale codes. Separate from dictionary language support.
pub const SUPPORTED_UI_LOCALES: &[&str] = &["en", "de"];
pub fn set_ui_locale(locale: &str) {
let effective = if SUPPORTED_UI_LOCALES.contains(&locale) { locale } else { "en" };
rust_i18n::set_locale(effective);
}
/// Translate a LanguageLayoutValidationError for display in the UI.
pub fn localized_language_layout_error(err: &crate::l10n::language_pack::LanguageLayoutValidationError) -> String {
use crate::l10n::language_pack::LanguageLayoutValidationError::*;
match err {
UnknownLanguage(key) => t!("errors.unknown_language", key = key),
UnknownLayout(key) => t!("errors.unknown_layout", key = key),
UnsupportedLanguageLayoutPair { language_key, layout_key } =>
t!("errors.unsupported_pair", language = language_key, layout = layout_key),
LanguageBlockedBySupportLevel(key) =>
t!("errors.language_blocked", key = key),
}
}
```
**Crate root ownership — which targets compile translated modules:**
| Target | Declares `mod i18n` | Compiles modules that call `t!()` | Must call `set_ui_locale()` |
|--------|---------------------|-----------------------------------|----------------------------|
| `src/main.rs` (binary) | Yes | Yes (`app.rs`, UI components, `main.rs` itself) | Yes, at startup |
| `src/lib.rs` (library) | Yes | Yes (`app.rs` is in the lib module tree) | No — lib is for benchmarks/test profiles; locale defaults to English via `fallback = "en"` |
| `src/bin/generate_test_profiles.rs` | No | No — imports from `keydr::` lib but only uses data types, not UI/translated code | No |
**Invariant:** Any module that calls `t!()` must be in a crate whose root declares `mod i18n;` with the `i18n!()` macro. If a future change adds `t!()` calls to a module reachable from `generate_test_profiles`, that binary must also add `mod i18n;`. The `fallback = "en"` default ensures English output when `set_ui_locale()` is never called.
Create `locales/en.yml` with initial structure, verify `cargo build`.
### Step 2: Add `ui_language` config field
**`src/config.rs`:**
- Add `ui_language: String` with `#[serde(default = "default_ui_language")]`, default `"en"`
- Add `normalize_ui_language()` — validates against `i18n::SUPPORTED_UI_LOCALES`, resets to `"en"` if unsupported
- Add to `Default` impl and `validate()`
**`src/app.rs`:**
- Add `UiLanguageSelect` variant to `AppScreen`
- Change `KeyMilestonePopup.message` from `&'static str` to `String`
**`src/main.rs`:**
- Call `i18n::set_ui_locale(&app.config.ui_language)` after `App::new()`
- Add "UI Language" setting item in settings menu (before "Dictionary Language")
- Add `UiLanguageSelect` screen reusing language selection list pattern (filtered to `SUPPORTED_UI_LOCALES`)
- On selection: update `config.ui_language`, call `i18n::set_ui_locale()`
- After data import: call `i18n::set_ui_locale()` again
### Step 3: Add autonym field to LanguagePack
**`src/l10n/language_pack.rs`:**
- Add `autonym: &'static str` field to `LanguagePack`
- Populate for all 21 languages: "English", "Deutsch", "Español", "Français", "Italiano", "Português", "Nederlands", "Svenska", "Dansk", "Norsk bokmål", "Suomi", "Polski", "Čeština", "Română", "Hrvatski", "Magyar", "Lietuvių", "Latviešu", "Slovenščina", "Eesti", "Türkçe"
**Update all language name display sites in `main.rs`:**
- Dictionary language selector: show `pack.autonym` instead of `pack.display_name`
- UI language selector: show `pack.autonym`
- Settings value display for dictionary language: show `pack.autonym`
- Status messages mentioning languages (e.g. "Switched to {}"): use `pack.autonym`
### Step 4: Create English base translation file (`locales/en.yml`)
Populate all ~200 keys organized by component:
```yaml
en:
menu: # menu items, descriptions, subtitle
drill: # mode headers, footers, focus labels
dashboard: # results screen labels, hints
sidebar: # stats sidebar labels
settings: # setting names, toggle values, buttons
status: # import/export/error messages (format templates)
skill_tree: # status labels, hints, notices
milestones: # unlock/mastery messages, congratulations
stats: # tab names, chart titles, hints, empty states
heatmap: # month/day abbreviations, title
keyboard: # explorer labels, detail fields
intro: # passage/code download setup dialogs
dialogs: # confirmation dialogs
errors: # validation error messages (for UI boundary translation)
common: # WPM, CPM, Back, etc.
```
### Step 5: Convert source files to use `t!()` — vertical slice first
**Phase A — Vertical slice (one file per text category to establish patterns):**
1. `src/ui/components/menu.rs` — inline literals (9 strings)
2. `src/ui/components/stats_dashboard.rs` — inline literals + `const` arrays → functions
3. `src/app.rs``StatusMessage` format templates (~20 strings), `UNLOCK_MESSAGES`/`MASTERY_MESSAGES` → functions
4. Update `StatusMessage` creation sites in `main.rs` that reference `LanguageLayoutValidationError` to use `i18n::localized_language_layout_error()` instead of `err.to_string()`
**Phase B — Remaining components:**
5. `src/ui/components/chart.rs` (3 strings)
6. `src/ui/components/activity_heatmap.rs` (14 strings)
7. `src/ui/components/stats_sidebar.rs` (10 strings)
8. `src/ui/components/dashboard.rs` (12 strings)
9. `src/ui/components/skill_tree.rs` (15 strings)
**Phase C — main.rs (largest):**
10. `src/main.rs` (~120+ strings) — settings menu, drill rendering, milestone overlay rendering, keyboard explorer, intro dialogs, footer hints, status messages
**Key patterns:**
- `use crate::i18n::t;` in every file that needs translation
- `t!()` returns `String`; for `&str` contexts: `let label = t!("key"); &label`
- Footer hints like `"[ESC] Back"` — full string in YAML, translators preserve bracket keys: `"[ESC] Zurück"`
- `const` arrays → functions: e.g. `fn unlock_messages() -> Vec<String>`
- `StatusMessage.text` built via `t!()` at creation time
### Step 6: Create German translation file (`locales/de.yml`)
AI-generated translation of all keys from `en.yml`:
- Keep `%{var}` placeholders unchanged
- Keep key names inside `[brackets]` unchanged (`[ESC]`, `[Enter]`, `[Tab]`, etc.)
- Keep technical terms WPM/CPM untranslated
- Be concise — German text tends to run ~20-30% longer; keep terminal width in mind
### Step 7: Tests and validation
- Add `rust_i18n::set_locale("en")` in test setup where tests assert against English output
- Add a test that sets locale to `"de"` and verifies a rendered component uses German text
- Add a test that switches locale mid-run and verifies new `StatusMessage` text uses the new locale
- **Add a catalog parity test** (using `serde_yaml` dev-dependency): parse both `locales/en.yml` and `locales/de.yml` as `serde_yaml::Value`, recursively walk the key trees, verify every key in `en.yml` exists in `de.yml` and vice versa, and that `%{var}` placeholders in each value string match between corresponding entries
- Run `cargo test` and `cargo build`
## Files Modified
| File | Scope |
|------|-------|
| `Cargo.toml` | Add `rust-i18n = "3"`, `serde_yaml = "0.9"` (dev) |
| `src/config.rs` | Add `ui_language` field, default, validation |
| `src/lib.rs` | Add `mod i18n;` |
| `src/main.rs` | Add `mod i18n;`, `set_ui_locale()` calls, UI Language setting/select screen, ~120 string replacements, use `localized_language_layout_error()` |
| `src/app.rs` | Add `UiLanguageSelect` to `AppScreen`, `KeyMilestonePopup.message``String`, ~20 StatusMessage string replacements, convert milestone constants to functions |
| `src/l10n/language_pack.rs` | Add `autonym` field to `LanguagePack` |
| `src/ui/components/menu.rs` | 9 string replacements |
| `src/ui/components/dashboard.rs` | 12 string replacements |
| `src/ui/components/stats_dashboard.rs` | 25 string replacements, refactor `const` arrays to functions |
| `src/ui/components/skill_tree.rs` | 15 string replacements |
| `src/ui/components/stats_sidebar.rs` | 10 string replacements |
| `src/ui/components/activity_heatmap.rs` | 14 string replacements |
| `src/ui/components/chart.rs` | 3 string replacements |
## Files Created
| File | Content |
|------|---------|
| `src/i18n.rs` | Centralized i18n bootstrap, `SUPPORTED_UI_LOCALES`, `set_ui_locale()`, `localized_language_layout_error()` |
| `locales/en.yml` | English base translations (~200 keys) |
| `locales/de.yml` | German translations |
## Verification
1. `cargo build` — rust-i18n checks referenced keys at compile time (not a complete catalog correctness guarantee; parity test and manual checks cover the rest)
2. `cargo test` — including catalog parity test + locale-specific tests
3. Manual testing with UI set to English: navigate all screens, verify identical behavior to pre-i18n
4. Manual testing with UI set to German: navigate all screens, verify German text
5. Verify drill source text (passage/code content) is NOT translated
6. Verify language selectors show autonyms ("Deutsch", not "German")
7. Test locale switch: change UI language in settings, verify new text appears in new language, existing status banner stays in old language
8. Check for layout/truncation issues with German text

View File

@@ -0,0 +1,117 @@
# Plan: Fix Remaining Untranslated UI Strings
## Context
The i18n system is implemented but several categories of strings were missed:
1. Menu item labels/descriptions are cached as `String` at construction and never refreshed when locale changes
2. Skill tree branch names and level names are hardcoded `&'static str` in `BranchDefinition`/`LevelDefinition`
3. Passage selector labels ("All (Built-in + all books)", "Built-in passages only", "Book: ...") are hardcoded
4. Branch progress list (`branch_progress_list.rs`) renders branch names and "Overall Key Progress" / "unlocked" / "mastered" in English
## Fix 1: Menu Items — Translate at Render Time
**Problem:** `Menu::new()` calls `t!()` once during `App::new()`. Even though `set_ui_locale()` runs after construction, the items are cached as `String` and never refreshed when the user changes UI language mid-session.
**Fix:** Define a shared static item list (keys + translation keys) and build rendered strings from it in both `Widget::render()` and navigation code.
**Files:** `src/ui/components/menu.rs`
- Define a `const MENU_ITEMS` array of `(&str, &str, &str)` tuples: `(shortcut_key, label_i18n_key, desc_i18n_key)`. This is the single authoritative definition.
- Remove `MenuItem` struct and the `items: Vec<MenuItem>` field.
- Keep `selected: usize` and `theme` fields. `next()`/`prev()` use `MENU_ITEMS.len()`.
- Add a `Menu::item_count() -> usize` helper returning `MENU_ITEMS.len()`.
- In `Widget::render()`, iterate `MENU_ITEMS` and call `t!()` for label/description each frame.
- Replace `app.menu.items.len()` in `src/main.rs` mouse handler (~line 660) with `Menu::item_count()`.
## Fix 2: Skill Tree Branch and Level Names — Replace `name` with `name_key`
**Problem:** `BranchDefinition.name` and `LevelDefinition.name` are `&'static str` with English text. They are used purely for UI display (confirmed: no serialization, logging, or export uses).
**Fix:** Replace `name` with `name_key` on both structs. The `name_key` holds a translation key (e.g. `"skill_tree.branch_primary_letters"`). All display sites use `t!(def.name_key)`.
Add `BranchDefinition::display_name()` and `LevelDefinition::display_name()` convenience methods that return `t!(self.name_key)` so call sites stay simple.
Change `find_key_branch()` to return `(&'static BranchDefinition, &'static LevelDefinition, usize)` instead of `(&'static BranchDefinition, &'static str, usize)`. This gives callers access to the `LevelDefinition` and its `name_key` so they can localize the level name themselves.
**Complete consumer inventory:**
| File | Lines | Usage |
|------|-------|-------|
| `src/ui/components/skill_tree.rs` | ~366 | Branch name in branch list header |
| `src/ui/components/skill_tree.rs` | ~445 | Branch name in detail header |
| `src/ui/components/skill_tree.rs` | ~483 | Level name in detail level list |
| `src/ui/components/branch_progress_list.rs` | ~95 | Branch name in single-branch drill sidebar |
| `src/ui/components/branch_progress_list.rs` | ~188 | Branch name in multi-branch progress cells |
| `src/main.rs` | ~3931 | Branch name in "branches available" milestone |
| `src/main.rs` | ~3961 | Branch names in "branch complete" milestone text |
| `src/main.rs` | ~6993 | Branch name in unlock confirmation dialog |
| `src/main.rs` | ~7327 | Branch name + level name in keyboard detail panel (via `find_key_branch()`) |
**Files:**
- `src/engine/skill_tree.rs` — Replace `name` with `name_key` on both structs; add `display_name()` methods; change `find_key_branch()` return type; populate `name_key` for all entries
- `src/ui/components/skill_tree.rs` — Use `def.display_name()` / `level.display_name()` at 3 sites
- `src/ui/components/branch_progress_list.rs` — Use `def.display_name()` at 2 sites; also translate "Overall Key Progress", "unlocked", "mastered"
- `src/main.rs` — Use `def.display_name()` at 4 sites; update `find_key_branch()` call site to use `level.display_name()`
- `locales/en.yml` — Add branch/level name keys under `skill_tree:`
- `locales/de.yml` — Add German translations
Note on truncation: `branch_progress_list.rs` uses fixed-width formatting (`{:<14}`, truncation widths 10/12/14). German branch names that exceed these widths will be truncated. This is acceptable for now — the widget already handles this via `truncate_and_pad()`. Proper dynamic-width layout is a separate concern.
Translation keys to add:
```yaml
skill_tree:
branch_primary_letters: 'Primary Letters'
branch_capital_letters: 'Capital Letters'
branch_numbers: 'Numbers 0-9'
branch_prose_punctuation: 'Prose Punctuation'
branch_whitespace: 'Whitespace'
branch_code_symbols: 'Code Symbols'
level_frequency_order: 'Frequency Order'
level_common_sentence_capitals: 'Common Sentence Capitals'
level_name_capitals: 'Name Capitals'
level_remaining_capitals: 'Remaining Capitals'
level_common_digits: 'Common Digits'
level_all_digits: 'All Digits'
level_essential: 'Essential'
level_common: 'Common'
level_expressive: 'Expressive'
level_enter_return: 'Enter/Return'
level_tab_indent: 'Tab/Indent'
level_arithmetic_assignment: 'Arithmetic & Assignment'
level_grouping: 'Grouping'
level_logic_reference: 'Logic & Reference'
level_special: 'Special'
```
Also add to `progress` section (translation values contain only text, no alignment whitespace — padding is applied in rendering code):
```yaml
progress:
overall_key_progress: 'Overall Key Progress'
unlocked_mastered: '%{unlocked}/%{total} unlocked (%{mastered} mastered)'
```
## Fix 3: Passage Book Selector Labels
**Problem:** `passage_options()` returns hardcoded `"All (Built-in + all books)"`, `"Built-in passages only"`, and `"Book: {title}"`.
**Fix:** Add `t!()` calls in `passage_options()`. Book titles (proper nouns like "Pride and Prejudice") stay untranslated per plan.
**Files:**
- `src/generator/passage.rs` — Add `use crate::i18n::t;`, convert the two label strings and the "Book:" prefix
- `locales/en.yml` — Add keys under `select:`:
```yaml
select:
passage_all: 'All (Built-in + all books)'
passage_builtin: 'Built-in passages only'
passage_book_prefix: 'Book: %{title}'
```
- `locales/de.yml` — German translations
## Verification
1. `cargo check` — must compile
2. `cargo test --lib i18n::tests` — catalog parity and placeholder parity tests catch missing keys
3. `cargo test --lib` — no new test failures
4. Add tests for the new translated surfaces. To avoid parallel-test races on global locale state, new tests use `t!("key", locale = "de")` directly on the translation keys rather than calling ambient-locale helpers like `display_name()` or `passage_options()`. This keeps tests deterministic without needing serial execution or locale-parameterized API variants.
- Test that `t!("skill_tree.branch_primary_letters", locale = "de")` returns the expected German text
- Test that `t!("select.passage_all", locale = "de")` returns the expected German text

454
locales/cs.yml Normal file
View File

@@ -0,0 +1,454 @@
# Main menu
menu:
subtitle: 'Terminalovy trenazer psani'
adaptive_drill: 'Adaptivni cviceni'
adaptive_drill_desc: 'Foneticka slova s adaptivnim odemykanim pismen'
code_drill: 'Cviceni kodu'
code_drill_desc: 'Procvicuj psani syntaxe kodu'
passage_drill: 'Cviceni textu'
passage_drill_desc: 'Opisuj pasaze z knih'
skill_tree: 'Strom dovednosti'
skill_tree_desc: 'Zobraz vetev postupu a spust cviceni'
keyboard: 'Klavesnice'
keyboard_desc: 'Prozkoumej rozlozeni klaves a statistiky'
statistics: 'Statistiky'
statistics_desc: 'Zobraz sve statistiky psani'
settings: 'Nastaveni'
settings_desc: 'Konfiguruj keydr'
day_streak: ' | %{days} dni v rade'
key_progress: ' Postup klaves %{unlocked}/%{total} (%{mastered} zvladnutych) | Cil %{target} WPM%{streak}'
hint_start: '[1-3] Spustit'
hint_skill_tree: '[t] Strom'
hint_keyboard: '[b] Klavesnice'
hint_stats: '[s] Statistiky'
hint_settings: '[c] Nastaveni'
hint_quit: '[q] Konec'
# Drill screen
drill:
title: ' Cviceni '
mode_adaptive: 'Adaptivni'
mode_code: 'Kod (bez hodnoceni)'
mode_passage: 'Text (bez hodnoceni)'
focus_char: 'Zamereni: ''%{ch}'''
focus_bigram: 'Zamereni: "%{bigram}"'
focus_both: 'Zamereni: ''%{ch}'' + "%{bigram}"'
header_wpm: 'WPM'
header_acc: 'Presn'
header_err: 'Chyb'
code_source: ' Zdroj kodu '
passage_source: ' Zdroj textu '
footer: '[ESC] Ukoncit cviceni [Backspace] Smazat'
keys_reenabled: 'Klavesy obnoveny za %{ms}ms'
hint_end: '[ESC] Ukoncit cviceni'
hint_backspace: '[Backspace] Smazat'
# Dashboard / drill result
dashboard:
title: ' Cviceni dokonceno '
results: 'Vysledky'
unranked_note_prefix: ' (Bez hodnoceni'
unranked_note_suffix: ' nepocita se do stromu dovednosti)'
speed: ' Rychlost: '
accuracy_label: ' Presnost: '
time_label: ' Cas: '
errors_label: ' Chyby: '
correct_detail: ' (%{correct}/%{total} spravne)'
input_blocked: ' Vstup docasne zablokovany '
input_blocked_ms: '(%{ms}ms zbyva)'
hint_continue: '[c/Enter/Space] Pokracovat'
hint_retry: '[r] Znovu'
hint_menu: '[q] Menu'
hint_stats: '[s] Statistiky'
hint_delete: '[x] Smazat'
# Stats sidebar (during drill)
sidebar:
title: ' Statistiky '
wpm: 'WPM: '
target: 'Cil: '
target_wpm: '%{wpm} WPM'
accuracy: 'Presnost: '
progress: 'Postup: '
correct: 'Spravne: '
errors: 'Chyby: '
time: 'Cas: '
last_drill: ' Posledni cviceni '
vs_avg: ' vs prum: '
# Statistics dashboard
stats:
title: ' Statistiky '
empty: 'Zadna cviceni dosud. Zacni psat!'
tab_dashboard: '[1] Prehled'
tab_history: '[2] Historie'
tab_activity: '[3] Aktivita'
tab_accuracy: '[4] Presnost'
tab_timing: '[5] Casovani'
tab_ngrams: '[6] N-gramy'
hint_back: '[ESC] Zpet'
hint_next_tab: '[Tab] Dalsi karta'
hint_switch_tab: '[1-6] Prepnout kartu'
hint_navigate: '[j/k] Navigovat'
hint_page: '[PgUp/PgDn] Stranka'
hint_delete: '[x] Smazat'
summary_title: ' Souhrn '
drills: ' Cviceni: '
avg_wpm: ' Prum WPM: '
best_wpm: ' Nejlepsi WPM: '
accuracy_label: ' Presnost: '
total_time: ' Celkovy cas: '
wpm_chart_title: ' WPM na cviceni (poslednich 20, cil: %{target}) '
accuracy_chart_title: ' Presnost %% (poslednich 50 cviceni) '
chart_drill: 'Cvic #'
chart_accuracy_pct: 'Presnost %%'
sessions_title: ' Posledni relace '
session_header: ' # WPM Raw Presn%% Cas Datum/Cas Rezim Hodnocen Castecny'
session_separator: ' ─────────────────────────────────────────────────────────────────────'
delete_confirm: 'Smazat relaci #%{idx}? (a/n)'
confirm_title: ' Potvrzeni '
yes: 'ano'
no: 'ne'
keyboard_accuracy_title: ' Presnost klavesnice %% '
keyboard_timing_title: ' Casovani klavesnice (ms) '
slowest_keys_title: ' Nejpomalejsi klavesy (ms) '
fastest_keys_title: ' Nejrychlejsi klavesy (ms) '
worst_accuracy_title: ' Nejhorsi presnost (%%) '
best_accuracy_title: ' Nejlepsi presnost (%%) '
not_enough_data: ' Nedostatek dat'
streaks_title: ' Serie '
current_streak: ' Aktualni: '
best_streak: ' Nejlepsi: '
active_days: ' Aktivni dny: '
top_days_none: ' Nejlepsi dny: zadne'
top_days: ' Nejlepsi dny: %{days}'
wpm_label: ' WPM: %{avg}/%{target} (%{pct}%%)'
acc_label: ' Presn: %{pct}%%'
keys_label: ' Klavesy: %{unlocked}/%{total} (%{mastered} zvladnutych)'
ngram_empty: 'Dokonci adaptivni cviceni pro zobrazeni dat n-gramu'
ngram_header_speed_narrow: ' Bgrm Rychl Ocek Anom%'
ngram_header_error_narrow: ' Bgrm Chyb Vzrk Mira Ocek Anom%'
ngram_header_speed: ' Bigram Rychlost Ocekav Vzorky Anom%'
ngram_header_error: ' Bigram Chyby Vzorky Mira Ocekav Anom%'
focus_title: ' Aktivni zamereni '
focus_char_label: ' Zamereni: '
focus_bigram_value: 'Bigram %{label}'
focus_plus: ' + '
anomaly_error: 'chyba'
anomaly_speed: 'rychlost'
focus_detail_both: ' Znak ''%{ch}'': nejslabsi klavesa | Bigram %{label}: anomalie %{type} %{pct}%%'
focus_detail_char_only: ' Znak ''%{ch}'': nejslabsi klavesa, zadne potvrzene anomalie bigramu'
focus_detail_bigram_only: ' (anomalie %{type}: %{pct}%%)'
focus_empty: ' Dokonci adaptivni cviceni pro zobrazeni dat zamereni'
error_anomalies_title: ' Anomalie chyb (%{count}) '
no_error_anomalies: ' Nebyly detekovany anomalie chyb'
speed_anomalies_title: ' Anomalie rychlosti (%{count}) '
no_speed_anomalies: ' Nebyly detekovany anomalie rychlosti'
scope_label_prefix: ' '
bi_label: ' | Bi: %{count}'
hes_label: ' | Vah: >%{ms}ms'
focus_char_value: 'Znak ''%{ch}'''
# Activity heatmap
heatmap:
title: ' Denni aktivita (relace za den) '
jan: 'Led'
feb: 'Uno'
mar: 'Bre'
apr: 'Dub'
may: 'Kve'
jun: 'Cer'
jul: 'Cvc'
aug: 'Srp'
sep: 'Zar'
oct: 'Rij'
nov: 'Lis'
dec: 'Pro'
# Chart
chart:
wpm_over_time: ' WPM v case '
drill_number: 'Cvic #'
# Settings
settings:
title: ' Nastaveni '
subtitle: 'Sipkami naviguj, Enter/vpravo zmeni, ESC ulozi a zavre'
target_wpm: 'Cilovy WPM'
theme: 'Motiv'
word_count: 'Pocet slov'
ui_language: 'Jazyk rozhrani'
dictionary_language: 'Jazyk slovniku'
keyboard_layout: 'Rozlozeni klaves'
code_language: 'Programovaci jazyk'
code_downloads: 'Stahovani kodu'
on: 'Zapnuto'
off: 'Vypnuto'
code_download_dir: 'Adresar stahovani kodu'
snippets_per_repo: 'Fragmenty na repo'
unlimited: 'Bez limitu'
download_code_now: 'Stahnout kod nyni'
run_downloader: 'Spustit stahovani'
passage_downloads: 'Stahovani textu'
passage_download_dir: 'Adresar stahovani textu'
paragraphs_per_book: 'Odstavce na knihu'
whole_book: 'Cela kniha'
download_passages_now: 'Stahnout texty nyni'
export_path: 'Cesta exportu'
export_data: 'Exportovat data'
export_now: 'Exportovat nyni'
import_path: 'Cesta importu'
import_data: 'Importovat data'
import_now: 'Importovat nyni'
hint_save_back: '[ESC] Ulozit a zpet'
hint_change_value: '[Enter/sipky] Zmenit hodnotu'
hint_edit_path: '[Enter na ceste] Upravit'
hint_move: '[←→] Posunout'
hint_tab_complete: '[Tab] Doplnit (na konci)'
hint_confirm: '[Enter] Potvrdit'
hint_cancel: '[Esc] Zrusit'
success_title: ' Uspech '
error_title: ' Chyba '
press_any_key: 'Stiskni libovolnou klavesu'
file_exists_title: ' Soubor existuje '
file_exists: 'Na teto ceste jiz soubor existuje.'
overwrite_rename: '[d] Prepsat [r] Prejmenovat [Esc] Zrusit'
erase_warning: 'Toto smaze vase aktualni data.'
export_first: 'Nejprve exportujte, pokud je chcete zachovat.'
proceed_yn: 'Pokracovat? (a/n)'
confirm_import_title: ' Potvrdit import '
# Selection screens
select:
dictionary_language_title: ' Vybrat jazyk slovniku '
keyboard_layout_title: ' Vybrat rozlozeni klaves '
code_language_title: ' Vybrat programovaci jazyk '
passage_source_title: ' Vybrat zdroj textu '
ui_language_title: ' Vybrat jazyk rozhrani '
more_above: '... %{count} vice vyse ...'
more_below: '... %{count} vice nize ...'
current: ' (aktualni)'
disabled: ' (vypnuto)'
enabled_default: ' (zapnuto, vychozi: %{layout})'
enabled: ' (zapnuto)'
disabled_blocked: ' (vypnuto: zablokovano)'
built_in: ' (vestaveny)'
cached: ' (v pameti)'
disabled_download: ' (vypnuto: nutne stahnout)'
download_required: ' (nutne stahnout)'
hint_navigate: '[Nahoru/Dolu/PgUp/PgDn] Navigovat'
hint_confirm: '[Enter] Potvrdit'
hint_back: '[ESC] Zpet'
language_resets_layout: 'Vyber jazyka obnovi rozlozeni klaves na vychozi pro dany jazyk.'
layout_no_language_change: 'Zmena rozlozeni nemeni jazyk slovniku.'
disabled_network_notice: 'Nektere jazyky jsou vypnute: povolte sitove stahovani v nastaveni.'
disabled_sources_notice: 'Nektere zdroje jsou vypnute: povolte sitove stahovani v nastaveni.'
passage_all: 'Vse (vestavene + vsechny knihy)'
passage_builtin: 'Pouze vestavene texty'
passage_book_prefix: 'Kniha: %{title}'
# Progress
progress:
overall_key_progress: 'Celkovy postup klaves'
unlocked_mastered: '%{unlocked}/%{total} odemcenych (%{mastered} zvladnutych)'
# Skill tree
skill_tree:
title: ' Strom dovednosti '
locked: 'Zamceny'
unlocked: 'odemceny'
mastered: 'zvladnuty'
in_progress: 'probihajici'
complete: 'dokonceny'
locked_status: 'zamceny'
locked_notice: 'Dokonci %{count} zakladnich pismen pro odemknuti vetvi'
branches_separator: 'Vetve (dostupne po %{count} zakladnich pismenech)'
unlocked_letters: 'Odemceno %{unlocked}/%{total} pismen'
level: 'Uroven %{current}/%{total}'
level_zero: 'Uroven 0/%{total}'
in_focus: ' v zamereni'
hint_navigate: '[↑↓/jk] Navigovat'
hint_scroll: '[PgUp/PgDn nebo Ctrl+U/Ctrl+D] Rolovat'
hint_back: '[q] Zpet'
hint_unlock: '[Enter] Odemknout'
hint_start_drill: '[Enter] Spustit cviceni'
unlock_msg_1: 'Po odemknuti bude vychozi adaptivni cviceni zahrovat klavesy z teto vetve.'
unlock_msg_2: 'Pokud se chces zamerit jen na tuto vetev, spust cviceni primo ze stromu.'
confirm_unlock: 'Odemknout %{branch}?'
confirm_yn: '[y] Odemknout [n/ESC] Zrusit'
lvl_prefix: 'Ur'
branch_primary_letters: 'Zakladni pismena'
branch_capital_letters: 'Velka pismena'
branch_numbers: 'Cislice 0-9'
branch_prose_punctuation: 'Interpunkce'
branch_whitespace: 'Bile znaky'
branch_code_symbols: 'Symboly kodu'
level_frequency_order: 'Poradi cetnosti'
level_common_sentence_capitals: 'Bezna velka pismena vet'
level_name_capitals: 'Velka pismena jmen'
level_remaining_capitals: 'Zbyvajici velka pismena'
level_common_digits: 'Bezne cislice'
level_all_digits: 'Vsechny cislice'
level_essential: 'Zakladni'
level_common: 'Bezne'
level_expressive: 'Expresivni'
level_enter_return: 'Enter/Return'
level_tab_indent: 'Tab/odsazeni'
level_arithmetic_assignment: 'Aritmetika a prirazeni'
level_grouping: 'Seskupovani'
level_logic_reference: 'Logika a reference'
level_special: 'Specialni'
# Milestones
milestones:
unlock_title: ' Klavesa odemcena! '
mastery_title: ' Klavesa zvladnuta! '
branches_title: ' Nove vetve dostupne! '
branch_complete_title: ' Vetev dokoncena! '
all_unlocked_title: ' Vsechny klavesy odemceny! '
all_mastered_title: ' Uplne zvladnuti klavesnice! '
unlocked: 'odemcena'
mastered: 'zvladnuta'
use_finger: 'Pouzij %{finger}'
hold_right_shift: 'Drz pravy Shift (pravy malicek)'
hold_left_shift: 'Drz levy Shift (levy malicek)'
congratulations_all_letters: 'Gratulujeme! Zvladl jsi vsech %{count} zakladnich pismen'
new_branches_available: 'Nove vetve dovednosti jsou nyni dostupne:'
visit_skill_tree: 'Navstiv strom dovednosti pro odemknuti nove vetve'
and_start_training: 'a zacni trenovat!'
open_skill_tree: 'Stiskni [t] pro otevreni stromu dovednosti'
branch_complete_msg: 'Dokoncil jsi vetev %{branch}!'
all_levels_mastered: 'Vsech %{count} urovni zvladnuto.'
all_keys_confident: 'Kazda klavesa v teto vetvi je na plne jistote.'
all_unlocked_msg: 'Odemkl jsi kazdou klavesu na klavesnici!'
all_unlocked_desc: 'Kazdy znak, symbol a modifikator je nyni dostupny ve tvych cvicenich.'
keep_practicing_mastery: 'Pokracuj v cviceni pro budovani zbehlosti — az kazda klavesa dosahne plne'
confidence_complete: 'jistoty, dosahnes uplneho zvladnuti klavesnice!'
all_mastered_msg: 'Gratulujeme — dosahl jsi uplneho zvladnuti klavesnice!'
all_mastered_desc: 'Kazda klavesa na klavesnici je na maximalni jistote.'
mastery_takes_practice: 'Zbehlost neni cil — vyzaduje prubezne cviceni.'
keep_drilling: 'Pokracuj v cviceni pro udrzeni sve urovne.'
hint_skill_tree_continue: '[t] Otevrit strom [Jina klavesa] Pokracovat'
hint_any_key: 'Stiskni libovolnou klavesu pro pokracovani'
input_blocked: 'Vstup docasne zablokovany (%{ms}ms zbyva)'
unlock_msg_1: 'Skvela prace! Pokracuj v rozvoji svych dovednosti.'
unlock_msg_2: 'Dalsi klavesa ve tvem arsenalu!'
unlock_msg_3: 'Tvoje klavesnice roste! Tak drzet.'
unlock_msg_4: 'O krok bliz k uplnemu zvladnuti klavesnice!'
mastery_msg_1: 'Tato klavesa je nyni na plne jistote!'
mastery_msg_2: 'Tuto klavesu mas v malicku!'
mastery_msg_3: 'Svalova pamet uzamcena!'
mastery_msg_4: 'Dalsi klavesa pokorena!'
# Keyboard explorer
keyboard:
title: ' Klavesnice '
subtitle: 'Stiskni libovolnou klavesu nebo klikni na klavesu'
hint_navigate: '[←→↑↓/hjkl/Tab] Navigovat'
hint_back: '[q/ESC] Zpet'
key_label: 'Klavesa: '
finger_label: 'Prst: '
hand_left: 'Levy'
hand_right: 'Pravy'
finger_index: 'Ukazovacek'
finger_middle: 'Prostrednicek'
finger_ring: 'Prstenik'
finger_pinky: 'Malicek'
finger_thumb: 'Palec'
overall_accuracy: ' Celkova presnost: %{correct}/%{total} (%{pct}%%)'
ranked_accuracy: ' Hodnocena presnost: %{correct}/%{total} (%{pct}%%)'
confidence: 'Jistota: '
no_data: 'Zatim zadna data'
no_data_short: 'Zadna data'
key_details: ' Detaily klavesy '
key_details_char: ' Detaily klavesy: ''%{ch}'' '
key_details_name: ' Detaily klavesy: %{name} '
press_key_hint: 'Stiskni klavesu pro zobrazeni detailu'
shift_label: 'Shift: '
shift_no: 'Ne'
overall_avg_time: 'Prumerny cas: '
overall_best_time: 'Nejlepsi cas: '
overall_samples: 'Vzorky: '
overall_accuracy_label: 'Celkova presnost: '
branch_label: 'Vetev: '
level_label: 'Uroven: '
built_in_key: 'Vestavena klavesa'
unlocked_label: 'Odemcena: '
yes: 'Ano'
no: 'Ne'
in_focus_label: 'V zamereni?: '
mastery_label: 'Zbehlost: '
mastery_locked: 'Zamcena'
ranked_avg_time: 'Hodnoceny prum cas: '
ranked_best_time: 'Hodnoceny nejl cas: '
ranked_samples: 'Hodnocene vzorky: '
ranked_accuracy_label: 'Hodnocena presnost: '
# Intro dialogs
intro:
passage_title: ' Nastaveni stahovani textu '
code_title: ' Nastaveni stahovani kodu '
enable_downloads: 'Povolit sitove stahovani'
download_dir: 'Adresar stahovani'
paragraphs_per_book: 'Odstavce na knihu (0 = cela)'
whole_book: 'cela kniha'
snippets_per_repo: 'Fragmenty na repo (0 = bez limitu)'
unlimited: 'bez limitu'
start_passage_drill: 'Spustit cviceni textu'
start_code_drill: 'Spustit cviceni kodu'
confirm: 'Potvrdit'
hint_navigate: '[Nahoru/Dolu] Navigovat'
hint_adjust: '[Vlevo/Vpravo] Upravit'
hint_edit: '[Pis/Backspace] Editovat'
hint_confirm: '[Enter] Potvrdit'
hint_cancel: '[ESC] Zrusit'
preparing_download: 'Pripravuji stahovani...'
download_passage_title: ' Stahuji zdroj textu '
download_code_title: ' Stahuji zdroj kodu '
book_label: ' Kniha: %{name}'
repo_label: ' Repo: %{name}'
progress_bytes: '[%{name}] %{downloaded}/%{total} bajtu'
downloaded_bytes: 'Stazeno: %{bytes} bajtu'
downloading_book_progress: 'Stahuji knihu: [%{bar}] %{downloaded}/%{total} bajtu'
downloading_book_bytes: 'Stahuji knihu: %{bytes} bajtu'
downloading_code_progress: 'Stahuji: [%{bar}] %{downloaded}/%{total} bajtu'
downloading_code_bytes: 'Stahuji: %{bytes} bajtu'
current_book: 'Aktualni: %{name} (kniha %{done}/%{total})'
current_repo: 'Aktualni: %{name} (repo %{done}/%{total})'
passage_instructions_1: 'keydr muze stahovat texty z Project Gutenberg pro cviceni psani.'
passage_instructions_2: 'Knihy se stahuji jednou a ukladaji lokalne.'
passage_instructions_3: 'Nastav moznosti stahovani nize a pak spust cviceni textu.'
code_instructions_1: 'keydr muze stahovat open-source kod z GitHubu pro cviceni psani.'
code_instructions_2: 'Kod se stahuje jednou a uklada lokalne.'
code_instructions_3: 'Nastav moznosti stahovani nize a pak spust cviceni kodu.'
# Status messages (from app.rs)
status:
recovery_files: 'Nalezeny obnovovaci soubory z preruseneho importu. Data mohou byt nekonzistentni — zvaz opetovny import.'
dir_not_exist: 'Adresar neexistuje: %{path}'
no_data_store: 'Datove uloziste neni k dispozici'
serialization_error: 'Chyba serializace: %{error}'
exported_to: 'Exportovano do %{path}'
export_failed: 'Export selhal: %{error}'
could_not_read: 'Nelze precist soubor: %{error}'
invalid_export: 'Neplatny exportni soubor: %{error}'
unsupported_version: 'Nepodporovana verze exportu: %{got} (ocekavana %{expected})'
import_failed: 'Import selhal: %{error}'
imported_theme_fallback: 'Importovano uspesne (motiv ''%{theme}'' nenalezen, pouzivam vychozi)'
imported_success: 'Importovano uspesne'
adaptive_unavailable: 'Adaptivni hodnoceny rezim nedostupny: %{error}'
switched_to: 'Prepnuto na %{name}'
layout_changed: 'Rozlozeni zmeneno na %{name}'
# Errors (for UI boundary translation)
errors:
unknown_language: 'Neznamy jazyk: %{key}'
unknown_layout: 'Nezname rozlozeni klaves: %{key}'
unsupported_pair: 'Nepodporovany par jazyk/rozlozeni: %{language} + %{layout}'
language_blocked: 'Jazyk blokovan urovni podpory: %{key}'
# Common
common:
wpm: 'WPM'
cpm: 'CPM'
back: 'Zpet'

454
locales/da.yml Normal file
View File

@@ -0,0 +1,454 @@
# Main menu
menu:
subtitle: 'Terminal Skrivetraener'
adaptive_drill: 'Adaptiv oevelse'
adaptive_drill_desc: 'Fonetiske ord med adaptiv bogstavoplasning'
code_drill: 'Kodeoevelse'
code_drill_desc: 'Oev kodersyntaks'
passage_drill: 'Tekstoevelse'
passage_drill_desc: 'Skriv passager fra boeger'
skill_tree: 'Faerdighedstrae'
skill_tree_desc: 'Se fremskridtsgrene og start oevelser'
keyboard: 'Tastatur'
keyboard_desc: 'Udforsk tastaturlayout og tasterstatistik'
statistics: 'Statistik'
statistics_desc: 'Se din skrivestatistik'
settings: 'Indstillinger'
settings_desc: 'Konfigurer keydr'
day_streak: ' | %{days} dages raekke'
key_progress: ' Tastefremskridt %{unlocked}/%{total} (%{mastered} mestrede) | Maal %{target} WPM%{streak}'
hint_start: '[1-3] Start'
hint_skill_tree: '[t] Faerdighedstrae'
hint_keyboard: '[b] Tastatur'
hint_stats: '[s] Statistik'
hint_settings: '[c] Indstillinger'
hint_quit: '[q] Afslut'
# Drill screen
drill:
title: ' Oevelse '
mode_adaptive: 'Adaptiv'
mode_code: 'Kode (Urangeret)'
mode_passage: 'Tekst (Urangeret)'
focus_char: 'Fokus: ''%{ch}'''
focus_bigram: 'Fokus: "%{bigram}"'
focus_both: 'Fokus: ''%{ch}'' + "%{bigram}"'
header_wpm: 'WPM'
header_acc: 'Noej'
header_err: 'Fejl'
code_source: ' Kodekilde '
passage_source: ' Tekstkilde '
footer: '[ESC] Afslut oevelse [Backspace] Slet'
keys_reenabled: 'Taster genaktiveret efter %{ms}ms'
hint_end: '[ESC] Afslut oevelse'
hint_backspace: '[Backspace] Slet'
# Dashboard / drill result
dashboard:
title: ' Oevelse faerdig '
results: 'Resultater'
unranked_note_prefix: ' (Urangeret'
unranked_note_suffix: ' taeller ikke i faerdighedstraeet)'
speed: ' Hastighed: '
accuracy_label: ' Noejagtighed: '
time_label: ' Tid: '
errors_label: ' Fejl: '
correct_detail: ' (%{correct}/%{total} korrekte)'
input_blocked: ' Indtastning midlertidigt blokeret '
input_blocked_ms: '(%{ms}ms tilbage)'
hint_continue: '[c/Enter/Space] Fortsaet'
hint_retry: '[r] Proev igen'
hint_menu: '[q] Menu'
hint_stats: '[s] Statistik'
hint_delete: '[x] Slet'
# Stats sidebar (during drill)
sidebar:
title: ' Statistik '
wpm: 'WPM: '
target: 'Maal: '
target_wpm: '%{wpm} WPM'
accuracy: 'Noejagtighed: '
progress: 'Fremskridt: '
correct: 'Korrekte: '
errors: 'Fejl: '
time: 'Tid: '
last_drill: ' Seneste oevelse '
vs_avg: ' vs gns: '
# Statistics dashboard
stats:
title: ' Statistik '
empty: 'Ingen oevelser gennemfoert endnu. Begynd at skrive!'
tab_dashboard: '[1] Dashboard'
tab_history: '[2] Historik'
tab_activity: '[3] Aktivitet'
tab_accuracy: '[4] Noejagtighed'
tab_timing: '[5] Timing'
tab_ngrams: '[6] N-grammer'
hint_back: '[ESC] Tilbage'
hint_next_tab: '[Tab] Naeste fane'
hint_switch_tab: '[1-6] Skift fane'
hint_navigate: '[j/k] Naviger'
hint_page: '[PgUp/PgDn] Side'
hint_delete: '[x] Slet'
summary_title: ' Oversigt '
drills: ' Oevelser: '
avg_wpm: ' Gns. WPM: '
best_wpm: ' Bedste WPM: '
accuracy_label: ' Noejagtighed: '
total_time: ' Samlet tid: '
wpm_chart_title: ' WPM per oevelse (Seneste 20, Maal: %{target}) '
accuracy_chart_title: ' Noejagtighed %% (Seneste 50 oevelser) '
chart_drill: 'Oevelse #'
chart_accuracy_pct: 'Noejagtighed %%'
sessions_title: ' Seneste sessioner '
session_header: ' # WPM Raa Noej%% Tid Dato/Tid Tilstand Rangeret Delvis'
session_separator: ' ─────────────────────────────────────────────────────────────────────'
delete_confirm: 'Slet session #%{idx}? (y/n)'
confirm_title: ' Bekraeft '
yes: 'ja'
no: 'nej'
keyboard_accuracy_title: ' Tastatur noejagtighed %% '
keyboard_timing_title: ' Tastatur timing (ms) '
slowest_keys_title: ' Langsomste taster (ms) '
fastest_keys_title: ' Hurtigste taster (ms) '
worst_accuracy_title: ' Vaerste noejagtighed (%%) '
best_accuracy_title: ' Bedste noejagtighed (%%) '
not_enough_data: ' Ikke nok data'
streaks_title: ' Raekker '
current_streak: ' Nuvaerende: '
best_streak: ' Bedste: '
active_days: ' Aktive dage: '
top_days_none: ' Topdage: ingen'
top_days: ' Topdage: %{days}'
wpm_label: ' WPM: %{avg}/%{target} (%{pct}%%)'
acc_label: ' Noej: %{pct}%%'
keys_label: ' Taster: %{unlocked}/%{total} (%{mastered} mestrede)'
ngram_empty: 'Gennemfoer nogle adaptive oevelser for at se n-gram data'
ngram_header_speed_narrow: ' Bgrm Hast Forv Anom%'
ngram_header_error_narrow: ' Bgrm Fejl Stp Freq Forv Anom%'
ngram_header_speed: ' Bigram Hast Forvent Stikpr. Anom%'
ngram_header_error: ' Bigram Fejl Stikpr. Freq Forvent Anom%'
focus_title: ' Aktivt fokus '
focus_char_label: ' Fokus: '
focus_bigram_value: 'Bigram %{label}'
focus_plus: ' + '
anomaly_error: 'fejl'
anomaly_speed: 'hastighed'
focus_detail_both: ' Tegn ''%{ch}'': svageste tast | Bigram %{label}: %{type}-anomali %{pct}%%'
focus_detail_char_only: ' Tegn ''%{ch}'': svageste tast, ingen bekraeftede bigram-anomalier'
focus_detail_bigram_only: ' (%{type}-anomali: %{pct}%%)'
focus_empty: ' Gennemfoer nogle adaptive oevelser for at se fokusdata'
error_anomalies_title: ' Fejl-anomalier (%{count}) '
no_error_anomalies: ' Ingen fejl-anomalier opdaget'
speed_anomalies_title: ' Hastigheds-anomalier (%{count}) '
no_speed_anomalies: ' Ingen hastigheds-anomalier opdaget'
scope_label_prefix: ' '
bi_label: ' | Bi: %{count}'
hes_label: ' | Hes: >%{ms}ms'
focus_char_value: 'Tegn ''%{ch}'''
# Activity heatmap
heatmap:
title: ' Daglig aktivitet (Sessioner per dag) '
jan: 'Jan'
feb: 'Feb'
mar: 'Mar'
apr: 'Apr'
may: 'Maj'
jun: 'Jun'
jul: 'Jul'
aug: 'Aug'
sep: 'Sep'
oct: 'Okt'
nov: 'Nov'
dec: 'Dec'
# Chart
chart:
wpm_over_time: ' WPM over tid '
drill_number: 'Oevelse #'
# Settings
settings:
title: ' Indstillinger '
subtitle: 'Piletaster til navigation, Enter/Hoejre for at aendre, ESC for at gemme'
target_wpm: 'Maal-WPM'
theme: 'Tema'
word_count: 'Antal ord'
ui_language: 'Sprog (UI)'
dictionary_language: 'Ordbogssprog'
keyboard_layout: 'Tastaturlayout'
code_language: 'Kodesprog'
code_downloads: 'Kode-downloads'
on: 'Til'
off: 'Fra'
code_download_dir: 'Kode-downloadmappe'
snippets_per_repo: 'Uddrag per repo'
unlimited: 'Ubegreanset'
download_code_now: 'Download kode nu'
run_downloader: 'Start download'
passage_downloads: 'Tekst-downloads'
passage_download_dir: 'Tekst-downloadmappe'
paragraphs_per_book: 'Afsnit per bog'
whole_book: 'Hele bogen'
download_passages_now: 'Download tekster nu'
export_path: 'Eksportsti'
export_data: 'Eksporter data'
export_now: 'Eksporter nu'
import_path: 'Importsti'
import_data: 'Importer data'
import_now: 'Importer nu'
hint_save_back: '[ESC] Gem & tilbage'
hint_change_value: '[Enter/pile] AEndr vaerdi'
hint_edit_path: '[Enter paa sti] Rediger'
hint_move: '[←→] Flyt'
hint_tab_complete: '[Tab] Fuldfoer (i slutningen)'
hint_confirm: '[Enter] Bekraeft'
hint_cancel: '[Esc] Annuller'
success_title: ' Succes '
error_title: ' Fejl '
press_any_key: 'Tryk paa en tast'
file_exists_title: ' Filen findes '
file_exists: 'Der findes allerede en fil paa denne sti.'
overwrite_rename: '[d] Overskriv [r] Omdoeb [Esc] Annuller'
erase_warning: 'Dette vil slette dine nuvaerende data.'
export_first: 'Eksporter foerst, hvis du vil beholde dem.'
proceed_yn: 'Fortsaet? (y/n)'
confirm_import_title: ' Bekraeft import '
# Selection screens
select:
dictionary_language_title: ' Vaelg ordbogssprog '
keyboard_layout_title: ' Vaelg tastaturlayout '
code_language_title: ' Vaelg kodesprog '
passage_source_title: ' Vaelg tekstkilde '
ui_language_title: ' Vaelg sprog (UI) '
more_above: '... %{count} flere ovenfor ...'
more_below: '... %{count} flere nedenfor ...'
current: ' (nuvaerende)'
disabled: ' (deaktiveret)'
enabled_default: ' (aktiveret, standard: %{layout})'
enabled: ' (aktiveret)'
disabled_blocked: ' (deaktiveret: blokeret)'
built_in: ' (indbygget)'
cached: ' (gemt)'
disabled_download: ' (deaktiveret: download kraeves)'
download_required: ' (download kraeves)'
hint_navigate: '[Op/Ned/PgUp/PgDn] Naviger'
hint_confirm: '[Enter] Bekraeft'
hint_back: '[ESC] Tilbage'
language_resets_layout: 'Sprogvalg nulstiller tastaturlayoutet til sprogets standard.'
layout_no_language_change: 'Layoutaendringer aendrer ikke ordbogssproget.'
disabled_network_notice: 'Nogle sprog er deaktiverede: aktiver netvaerksdownloads i intro/indstillinger.'
disabled_sources_notice: 'Nogle kilder er deaktiverede: aktiver netvaerksdownloads i intro/indstillinger.'
passage_all: 'Alle (Indbyggede + alle boeger)'
passage_builtin: 'Kun indbyggede passager'
passage_book_prefix: 'Bog: %{title}'
# Progress
progress:
overall_key_progress: 'Samlet tastefremskridt'
unlocked_mastered: '%{unlocked}/%{total} laast op (%{mastered} mestrede)'
# Skill tree
skill_tree:
title: ' Faerdighedstrae '
locked: 'Laast'
unlocked: 'laast op'
mastered: 'mestret'
in_progress: 'igangvaerende'
complete: 'faerdig'
locked_status: 'laast'
locked_notice: 'Fuldfoor %{count} grundbogstaver for at laase grene op'
branches_separator: 'Grene (tilgaengelige efter %{count} grundbogstaver)'
unlocked_letters: '%{unlocked}/%{total} bogstaver laast op'
level: 'Niveau %{current}/%{total}'
level_zero: 'Niveau 0/%{total}'
in_focus: ' i fokus'
hint_navigate: '[↑↓/jk] Naviger'
hint_scroll: '[PgUp/PgDn eller Ctrl+U/Ctrl+D] Rul'
hint_back: '[q] Tilbage'
hint_unlock: '[Enter] Laas op'
hint_start_drill: '[Enter] Start oevelse'
unlock_msg_1: 'Efter oplaasning blandes oplaaste taster fra denne gren ind i den adaptive oevelse.'
unlock_msg_2: 'Vil du kun oeve denne gren, start en oevelse direkte fra denne gren i Faerdighedstraeet.'
confirm_unlock: 'Laas %{branch} op?'
confirm_yn: '[y] Laas op [n/ESC] Annuller'
lvl_prefix: 'Niv'
branch_primary_letters: 'Grundbogstaver'
branch_capital_letters: 'Store bogstaver'
branch_numbers: 'Tal 0-9'
branch_prose_punctuation: 'Tegnsaetning'
branch_whitespace: 'Mellemrum'
branch_code_symbols: 'Kodesymboler'
level_frequency_order: 'Frekvensraekkefoelge'
level_common_sentence_capitals: 'Almindelige saetningsstorbogst.'
level_name_capitals: 'Navnestorbogstaver'
level_remaining_capitals: 'Oevrige storbogstaver'
level_common_digits: 'Almindelige cifre'
level_all_digits: 'Alle cifre'
level_essential: 'Essentielle'
level_common: 'Almindelige'
level_expressive: 'Udtryksfulde'
level_enter_return: 'Enter/Return'
level_tab_indent: 'Tab/Indrykning'
level_arithmetic_assignment: 'Aritmetik & Tildeling'
level_grouping: 'Gruppering'
level_logic_reference: 'Logik & Reference'
level_special: 'Special'
# Milestones
milestones:
unlock_title: ' Tast laast op! '
mastery_title: ' Tast mestret! '
branches_title: ' Nye faerdighetsgrene tilgaengelige! '
branch_complete_title: ' Gren faerdig! '
all_unlocked_title: ' Alle taster laast op! '
all_mastered_title: ' Fuld tastaturmestring! '
unlocked: 'laast op'
mastered: 'mestret'
use_finger: 'Brug din %{finger}'
hold_right_shift: 'Hold hoejre Shift (hoejre lillefinger)'
hold_left_shift: 'Hold venstre Shift (venstre lillefinger)'
congratulations_all_letters: 'Tillykke! Du har mestret alle %{count} grundbogstaver'
new_branches_available: 'Nye faerdighetsgrene er nu tilgaengelige:'
visit_skill_tree: 'Besoeg Faerdighedstraeet for at laase en ny gren op'
and_start_training: 'og begynd at traene!'
open_skill_tree: 'Tryk [t] for at aabne Faerdighedstraeet nu'
branch_complete_msg: 'Du har fuldfaort grenen %{branch}!'
all_levels_mastered: 'Alle %{count} niveauer mestrede.'
all_keys_confident: 'Hver tast i denne gren har fuld tillid.'
all_unlocked_msg: 'Du har laast hver tast paa tastaturet op!'
all_unlocked_desc: 'Hvert tegn, symbol og modifikator er nu tilgaengelig i dine oevelser.'
keep_practicing_mastery: 'Bliv ved med at oeve for at opbygge mestring — naar hver tast naar fuld'
confidence_complete: 'tillid, har du opnaaat fuldstaendig tastaturmestring!'
all_mastered_msg: 'Tillykke — du har opnaaat fuldstaendig tastaturmestring!'
all_mastered_desc: 'Hver tast paa tastaturet har maksimal tillid.'
mastery_takes_practice: 'Mestring er ikke en destination — det kraever vedvarende oevelse.'
keep_drilling: 'Bliv ved med at oeve for at bevare dit niveau.'
hint_skill_tree_continue: '[t] Faerdighedstrae [Anden tast] Fortsaet'
hint_any_key: 'Tryk paa en tast for at fortsaette'
input_blocked: 'Indtastning midlertidigt blokeret (%{ms}ms tilbage)'
unlock_msg_1: 'Godt klaret! Bliv ved med at opbygge dine skrivefaerdigheder.'
unlock_msg_2: 'Endnu en tast i dit arsenal!'
unlock_msg_3: 'Dit tastatur vokser! Bliv ved.'
unlock_msg_4: 'Et skridt naermere fuld tastaturmestring!'
mastery_msg_1: 'Denne tast har nu fuld tillid!'
mastery_msg_2: 'Du mestrer denne tast perfekt!'
mastery_msg_3: 'Muskelhukommelse forankret!'
mastery_msg_4: 'Endnu en tast erobret!'
# Keyboard explorer
keyboard:
title: ' Tastatur '
subtitle: 'Tryk paa en tast eller klik paa en tast'
hint_navigate: '[←→↑↓/hjkl/Tab] Naviger'
hint_back: '[q/ESC] Tilbage'
key_label: 'Tast: '
finger_label: 'Finger: '
hand_left: 'Venstre'
hand_right: 'Hoejre'
finger_index: 'Pegefinger'
finger_middle: 'Langfinger'
finger_ring: 'Ringfinger'
finger_pinky: 'Lillefinger'
finger_thumb: 'Tommelfinger'
overall_accuracy: ' Samlet noejagtighed: %{correct}/%{total} (%{pct}%%)'
ranked_accuracy: ' Rangeret noejagtighed: %{correct}/%{total} (%{pct}%%)'
confidence: 'Tillid: '
no_data: 'Ingen data endnu'
no_data_short: 'Ingen data'
key_details: ' Tastdetaljer '
key_details_char: ' Tastdetaljer: ''%{ch}'' '
key_details_name: ' Tastdetaljer: %{name} '
press_key_hint: 'Tryk paa en tast for detaljer'
shift_label: 'Shift: '
shift_no: 'Nej'
overall_avg_time: 'Samlet gns. tid: '
overall_best_time: 'Samlet bedste tid: '
overall_samples: 'Samlet stikproever: '
overall_accuracy_label: 'Samlet noejagtighed: '
branch_label: 'Gren: '
level_label: 'Niveau: '
built_in_key: 'Indbygget tast'
unlocked_label: 'Laast op: '
yes: 'Ja'
no: 'Nej'
in_focus_label: 'I fokus?: '
mastery_label: 'Mestring: '
mastery_locked: 'Laast'
ranked_avg_time: 'Rangeret gns. tid: '
ranked_best_time: 'Rangeret bedste tid: '
ranked_samples: 'Rangerede stikproever: '
ranked_accuracy_label: 'Rangeret noejagtighed: '
# Intro dialogs
intro:
passage_title: ' Tekst-download opsaetning '
code_title: ' Kode-download opsaetning '
enable_downloads: 'Aktiver netvaerksdownloads'
download_dir: 'Downloadmappe'
paragraphs_per_book: 'Afsnit per bog (0 = hele)'
whole_book: 'hele bogen'
snippets_per_repo: 'Uddrag per repo (0 = ubegreanset)'
unlimited: 'ubegreanset'
start_passage_drill: 'Start tekstoevelse'
start_code_drill: 'Start kodeoevelse'
confirm: 'Bekraeft'
hint_navigate: '[Op/Ned] Naviger'
hint_adjust: '[Venstre/Hoejre] Juster'
hint_edit: '[Skriv/Backspace] Rediger'
hint_confirm: '[Enter] Bekraeft'
hint_cancel: '[ESC] Annuller'
preparing_download: 'Forbereder download...'
download_passage_title: ' Downloader tekstkilde '
download_code_title: ' Downloader kodekilde '
book_label: ' Bog: %{name}'
repo_label: ' Repo: %{name}'
progress_bytes: '[%{name}] %{downloaded}/%{total} bytes'
downloaded_bytes: 'Downloadet: %{bytes} bytes'
downloading_book_progress: 'Downloader aktuel bog: [%{bar}] %{downloaded}/%{total} bytes'
downloading_book_bytes: 'Downloader aktuel bog: %{bytes} bytes'
downloading_code_progress: 'Downloader: [%{bar}] %{downloaded}/%{total} bytes'
downloading_code_bytes: 'Downloader: %{bytes} bytes'
current_book: 'Aktuel: %{name} (bog %{done}/%{total})'
current_repo: 'Aktuel: %{name} (repo %{done}/%{total})'
passage_instructions_1: 'keydr kan downloade passager fra Project Gutenberg til skriveoeving.'
passage_instructions_2: 'Boeger downloades een gang og gemmes lokalt.'
passage_instructions_3: 'Konfigurer downloadindstillinger nedenfor og start en tekstoevelse.'
code_instructions_1: 'keydr kan downloade open source-kode fra GitHub til skriveoeving.'
code_instructions_2: 'Kode downloades een gang og gemmes lokalt.'
code_instructions_3: 'Konfigurer downloadindstillinger nedenfor og start en kodeoevelse.'
# Status messages (from app.rs)
status:
recovery_files: 'Gendannelsesfiler fundet fra afbrudt import. Data kan vaere inkonsistent — overvaej at importere igen.'
dir_not_exist: 'Mappe findes ikke: %{path}'
no_data_store: 'Intet datalager tilgaengeligt'
serialization_error: 'Serialiseringsfejl: %{error}'
exported_to: 'Eksporteret til %{path}'
export_failed: 'Eksport mislykkedes: %{error}'
could_not_read: 'Kunne ikke laese filen: %{error}'
invalid_export: 'Ugyldig eksportfil: %{error}'
unsupported_version: 'Eksportversion ikke understottet: %{got} (forventet %{expected})'
import_failed: 'Import mislykkedes: %{error}'
imported_theme_fallback: 'Importeret (tema ''%{theme}'' ikke fundet, standard bruges)'
imported_success: 'Importeret'
adaptive_unavailable: 'Adaptiv rangeret tilstand ikke tilgaengelig: %{error}'
switched_to: 'Skiftet til %{name}'
layout_changed: 'Layout aendret til %{name}'
# Errors (for UI boundary translation)
errors:
unknown_language: 'Ukendt sprog: %{key}'
unknown_layout: 'Ukendt tastaturlayout: %{key}'
unsupported_pair: 'Ikke-understottet sprog-/layoutpar: %{language} + %{layout}'
language_blocked: 'Sprog blokeret af supportniveau: %{key}'
# Common
common:
wpm: 'WPM'
cpm: 'CPM'
back: 'Tilbage'

454
locales/de.yml Normal file
View File

@@ -0,0 +1,454 @@
# Main menu
menu:
subtitle: 'Terminal-Tipptrainer'
adaptive_drill: 'Adaptive Lektion'
adaptive_drill_desc: 'Phonetische Woerter mit adaptiver Buchstabenfreischaltung'
code_drill: 'Code-Lektion'
code_drill_desc: 'Code-Syntax tippen ueben'
passage_drill: 'Textpassagen-Lektion'
passage_drill_desc: 'Passagen aus Buechern abtippen'
skill_tree: 'Faehigkeitenbaum'
skill_tree_desc: 'Fortschrittszweige ansehen und Lektionen starten'
keyboard: 'Tastatur'
keyboard_desc: 'Tastaturlayout und Tastenstatistiken erkunden'
statistics: 'Statistik'
statistics_desc: 'Tippstatistiken ansehen'
settings: 'Einstellungen'
settings_desc: 'keydr konfigurieren'
day_streak: ' | %{days} Tage Serie'
key_progress: ' Tastenfortschritt %{unlocked}/%{total} (%{mastered} gemeistert) | Ziel %{target} WPM%{streak}'
hint_start: '[1-3] Start'
hint_skill_tree: '[t] Faehigkeitenbaum'
hint_keyboard: '[b] Tastatur'
hint_stats: '[s] Statistik'
hint_settings: '[c] Einstellungen'
hint_quit: '[q] Beenden'
# Drill screen
drill:
title: ' Lektion '
mode_adaptive: 'Adaptiv'
mode_code: 'Code (ohne Wertung)'
mode_passage: 'Textpassage (ohne Wertung)'
focus_char: 'Fokus: ''%{ch}'''
focus_bigram: 'Fokus: "%{bigram}"'
focus_both: 'Fokus: ''%{ch}'' + "%{bigram}"'
header_wpm: 'WPM'
header_acc: 'Gen'
header_err: 'Feh'
code_source: ' Code-Quelle '
passage_source: ' Textquelle '
footer: '[ESC] Lektion beenden [Backspace] Loeschen'
keys_reenabled: 'Tasten nach %{ms}ms wieder aktiv'
hint_end: '[ESC] Lektion beenden'
hint_backspace: '[Backspace] Loeschen'
# Dashboard / drill result
dashboard:
title: ' Lektion abgeschlossen '
results: 'Ergebnisse'
unranked_note_prefix: ' (Ohne Wertung'
unranked_note_suffix: ' zaehlt nicht fuer den Faehigkeitenbaum)'
speed: ' Tempo: '
accuracy_label: ' Genauigkeit: '
time_label: ' Zeit: '
errors_label: ' Fehler: '
correct_detail: ' (%{correct}/%{total} korrekt)'
input_blocked: ' Eingabe voruebergehend blockiert '
input_blocked_ms: '(%{ms}ms verbleibend)'
hint_continue: '[c/Enter/Space] Weiter'
hint_retry: '[r] Wiederholen'
hint_menu: '[q] Menue'
hint_stats: '[s] Statistik'
hint_delete: '[x] Loeschen'
# Stats sidebar (during drill)
sidebar:
title: ' Statistik '
wpm: 'WPM: '
target: 'Ziel: '
target_wpm: '%{wpm} WPM'
accuracy: 'Genauigkeit: '
progress: 'Fortschritt: '
correct: 'Korrekt: '
errors: 'Fehler: '
time: 'Zeit: '
last_drill: ' Letzte Lektion '
vs_avg: ' vs Schnitt: '
# Statistics dashboard
stats:
title: ' Statistik '
empty: 'Noch keine Lektionen abgeschlossen. Fang an zu tippen!'
tab_dashboard: '[1] Dashboard'
tab_history: '[2] Verlauf'
tab_activity: '[3] Aktivitaet'
tab_accuracy: '[4] Genauigkeit'
tab_timing: '[5] Timing'
tab_ngrams: '[6] N-Gramme'
hint_back: '[ESC] Zurueck'
hint_next_tab: '[Tab] Naechster Tab'
hint_switch_tab: '[1-6] Tab wechseln'
hint_navigate: '[j/k] Navigieren'
hint_page: '[PgUp/PgDn] Seite'
hint_delete: '[x] Loeschen'
summary_title: ' Zusammenfassung '
drills: ' Lektionen: '
avg_wpm: ' Schnitt WPM: '
best_wpm: ' Bestes WPM: '
accuracy_label: ' Genauigkeit: '
total_time: ' Gesamtzeit: '
wpm_chart_title: ' WPM pro Lektion (Letzte 20, Ziel: %{target}) '
accuracy_chart_title: ' Genauigkeit %% (Letzte 50 Lektionen) '
chart_drill: 'Lektion #'
chart_accuracy_pct: 'Genauigkeit %%'
sessions_title: ' Letzte Sitzungen '
session_header: ' # WPM Roh Gen%% Zeit Datum/Uhrzeit Modus Gewertet Teilw.'
session_separator: ' ─────────────────────────────────────────────────────────────────────'
delete_confirm: 'Sitzung #%{idx} loeschen? (y/n)'
confirm_title: ' Bestaetigen '
yes: 'ja'
no: 'nein'
keyboard_accuracy_title: ' Tastatur-Genauigkeit %% '
keyboard_timing_title: ' Tastatur-Timing (ms) '
slowest_keys_title: ' Langsamste Tasten (ms) '
fastest_keys_title: ' Schnellste Tasten (ms) '
worst_accuracy_title: ' Schlechteste Genauigkeit (%%) '
best_accuracy_title: ' Beste Genauigkeit (%%) '
not_enough_data: ' Nicht genug Daten'
streaks_title: ' Serien '
current_streak: ' Aktuell: '
best_streak: ' Beste: '
active_days: ' Aktive Tage: '
top_days_none: ' Top-Tage: keine'
top_days: ' Top-Tage: %{days}'
wpm_label: ' WPM: %{avg}/%{target} (%{pct}%%)'
acc_label: ' Gen: %{pct}%%'
keys_label: ' Tasten: %{unlocked}/%{total} (%{mastered} gemeistert)'
ngram_empty: 'Schliesse einige adaptive Lektionen ab, um N-Gramm-Daten zu sehen'
ngram_header_speed_narrow: ' Bgrm Tempo Erw. Anom%'
ngram_header_error_narrow: ' Bgrm Feh Stp Rate Erw Anom%'
ngram_header_speed: ' Bigramm Tempo Erwartet Stichpr. Anom%'
ngram_header_error: ' Bigramm Fehler Stichpr. Rate Erwartet Anom%'
focus_title: ' Aktiver Fokus '
focus_char_label: ' Fokus: '
focus_bigram_value: 'Bigramm %{label}'
focus_plus: ' + '
anomaly_error: 'Fehler'
anomaly_speed: 'Tempo'
focus_detail_both: ' Zeichen ''%{ch}'': schwaechste Taste | Bigramm %{label}: %{type}-Anomalie %{pct}%%'
focus_detail_char_only: ' Zeichen ''%{ch}'': schwaechste Taste, keine bestaetigten Bigramm-Anomalien'
focus_detail_bigram_only: ' (%{type}-Anomalie: %{pct}%%)'
focus_empty: ' Schliesse einige adaptive Lektionen ab, um Fokusdaten zu sehen'
error_anomalies_title: ' Fehler-Anomalien (%{count}) '
no_error_anomalies: ' Keine Fehler-Anomalien erkannt'
speed_anomalies_title: ' Tempo-Anomalien (%{count}) '
no_speed_anomalies: ' Keine Tempo-Anomalien erkannt'
scope_label_prefix: ' '
bi_label: ' | Bi: %{count}'
hes_label: ' | Hes: >%{ms}ms'
focus_char_value: 'Zeichen ''%{ch}'''
# Activity heatmap
heatmap:
title: ' Taegliche Aktivitaet (Sitzungen pro Tag) '
jan: 'Jan'
feb: 'Feb'
mar: 'Mär'
apr: 'Apr'
may: 'Mai'
jun: 'Jun'
jul: 'Jul'
aug: 'Aug'
sep: 'Sep'
oct: 'Okt'
nov: 'Nov'
dec: 'Dez'
# Chart
chart:
wpm_over_time: ' WPM im Zeitverlauf '
drill_number: 'Lektion #'
# Settings
settings:
title: ' Einstellungen '
subtitle: 'Pfeiltasten zum Navigieren, Enter/Rechts zum Aendern, ESC zum Speichern'
target_wpm: 'Ziel-WPM'
theme: 'Farbschema'
word_count: 'Wortanzahl'
ui_language: 'UI-Sprache'
dictionary_language: 'Woerterbuchsprache'
keyboard_layout: 'Tastaturlayout'
code_language: 'Codesprache'
code_downloads: 'Code-Downloads'
on: 'An'
off: 'Aus'
code_download_dir: 'Code-Downloadverz.'
snippets_per_repo: 'Schnipsel pro Repo'
unlimited: 'Unbegrenzt'
download_code_now: 'Code jetzt laden'
run_downloader: 'Download starten'
passage_downloads: 'Text-Downloads'
passage_download_dir: 'Text-Downloadverz.'
paragraphs_per_book: 'Absaetze pro Buch'
whole_book: 'Ganzes Buch'
download_passages_now: 'Texte jetzt laden'
export_path: 'Exportpfad'
export_data: 'Daten exportieren'
export_now: 'Jetzt exportieren'
import_path: 'Importpfad'
import_data: 'Daten importieren'
import_now: 'Jetzt importieren'
hint_save_back: '[ESC] Speichern & zurueck'
hint_change_value: '[Enter/Pfeile] Wert aendern'
hint_edit_path: '[Enter auf Pfad] Bearbeiten'
hint_move: '[←→] Bewegen'
hint_tab_complete: '[Tab] Vervollstaendigen (am Ende)'
hint_confirm: '[Enter] Bestaetigen'
hint_cancel: '[Esc] Abbrechen'
success_title: ' Erfolg '
error_title: ' Fehler '
press_any_key: 'Beliebige Taste druecken'
file_exists_title: ' Datei existiert '
file_exists: 'An diesem Pfad existiert bereits eine Datei.'
overwrite_rename: '[d] Ueberschreiben [r] Umbenennen [Esc] Abbrechen'
erase_warning: 'Dies wird Ihre aktuellen Daten loeschen.'
export_first: 'Exportieren Sie zuerst, wenn Sie sie behalten moechten.'
proceed_yn: 'Fortfahren? (y/n)'
confirm_import_title: ' Import bestaetigen '
# Selection screens
select:
dictionary_language_title: ' Woerterbuchsprache waehlen '
keyboard_layout_title: ' Tastaturlayout waehlen '
code_language_title: ' Codesprache waehlen '
passage_source_title: ' Textquelle waehlen '
ui_language_title: ' UI-Sprache waehlen '
more_above: '... %{count} weitere oben ...'
more_below: '... %{count} weitere unten ...'
current: ' (aktuell)'
disabled: ' (deaktiviert)'
enabled_default: ' (aktiviert, Standard: %{layout})'
enabled: ' (aktiviert)'
disabled_blocked: ' (deaktiviert: gesperrt)'
built_in: ' (eingebaut)'
cached: ' (gespeichert)'
disabled_download: ' (deaktiviert: Download erforderlich)'
download_required: ' (Download erforderlich)'
hint_navigate: '[Auf/Ab/BildAuf/BildAb] Navigieren'
hint_confirm: '[Enter] Bestaetigen'
hint_back: '[ESC] Zurueck'
language_resets_layout: 'Die Sprachauswahl setzt das Tastaturlayout auf den Standard der Sprache zurueck.'
layout_no_language_change: 'Layoutaenderungen aendern nicht die Woerterbuchsprache.'
disabled_network_notice: 'Einige Sprachen sind deaktiviert: Netzwerk-Downloads in Intro/Einstellungen aktivieren.'
disabled_sources_notice: 'Einige Quellen sind deaktiviert: Netzwerk-Downloads in Intro/Einstellungen aktivieren.'
passage_all: 'Alle (Eingebaut + alle Buecher)'
passage_builtin: 'Nur eingebaute Passagen'
passage_book_prefix: 'Buch: %{title}'
# Progress
progress:
overall_key_progress: 'Gesamter Tastenfortschritt'
unlocked_mastered: '%{unlocked}/%{total} freigeschaltet (%{mastered} gemeistert)'
# Skill tree
skill_tree:
title: ' Faehigkeitenbaum '
locked: 'Gesperrt'
unlocked: 'freigeschaltet'
mastered: 'gemeistert'
in_progress: 'in Bearbeitung'
complete: 'abgeschlossen'
locked_status: 'gesperrt'
locked_notice: '%{count} Grundbuchstaben abschliessen, um Zweige freizuschalten'
branches_separator: 'Zweige (verfuegbar nach %{count} Grundbuchstaben)'
unlocked_letters: '%{unlocked}/%{total} Buchstaben freigeschaltet'
level: 'Stufe %{current}/%{total}'
level_zero: 'Stufe 0/%{total}'
in_focus: ' im Fokus'
hint_navigate: '[↑↓/jk] Navigieren'
hint_scroll: '[BildAuf/BildAb oder Strg+U/Strg+D] Scrollen'
hint_back: '[q] Zurueck'
hint_unlock: '[Enter] Freischalten'
hint_start_drill: '[Enter] Lektion starten'
unlock_msg_1: 'Nach dem Freischalten werden freigeschaltete Tasten dieses Zweigs in die adaptive Lektion eingemischt.'
unlock_msg_2: 'Um nur diesen Zweig zu ueben, starte eine Lektion direkt aus diesem Zweig im Faehigkeitenbaum.'
confirm_unlock: '%{branch} freischalten?'
confirm_yn: '[y] Freischalten [n/ESC] Abbrechen'
lvl_prefix: 'Lvl'
branch_primary_letters: 'Grundbuchstaben'
branch_capital_letters: 'Grossbuchstaben'
branch_numbers: 'Zahlen 0-9'
branch_prose_punctuation: 'Interpunktion'
branch_whitespace: 'Leerzeichen'
branch_code_symbols: 'Code-Symbole'
level_frequency_order: 'Haeufigkeitsfolge'
level_common_sentence_capitals: 'Haeufige Satzanfaenge'
level_name_capitals: 'Namensgrossbuchst.'
level_remaining_capitals: 'Restl. Grossbuchst.'
level_common_digits: 'Haeufige Ziffern'
level_all_digits: 'Alle Ziffern'
level_essential: 'Grundlegend'
level_common: 'Haeufig'
level_expressive: 'Ausdruck'
level_enter_return: 'Enter/Return'
level_tab_indent: 'Tab/Einrueckung'
level_arithmetic_assignment: 'Arithmetik & Zuweisung'
level_grouping: 'Gruppierung'
level_logic_reference: 'Logik & Referenz'
level_special: 'Spezial'
# Milestones
milestones:
unlock_title: ' Taste freigeschaltet! '
mastery_title: ' Taste gemeistert! '
branches_title: ' Neue Faehigkeitenzweige verfuegbar! '
branch_complete_title: ' Zweig abgeschlossen! '
all_unlocked_title: ' Alle Tasten freigeschaltet! '
all_mastered_title: ' Volle Tastaturbeherrschung! '
unlocked: 'freigeschaltet'
mastered: 'gemeistert'
use_finger: 'Benutze deinen %{finger}'
hold_right_shift: 'Rechte Umschalttaste halten (rechter kleiner Finger)'
hold_left_shift: 'Linke Umschalttaste halten (linker kleiner Finger)'
congratulations_all_letters: 'Glueckwunsch! Du hast alle %{count} Grundbuchstaben gemeistert'
new_branches_available: 'Neue Faehigkeitenzweige sind jetzt verfuegbar:'
visit_skill_tree: 'Besuche den Faehigkeitenbaum, um einen neuen Zweig'
and_start_training: 'freizuschalten und zu trainieren!'
open_skill_tree: 'Druecke [t], um den Faehigkeitenbaum zu oeffnen'
branch_complete_msg: 'Du hast den Zweig %{branch} abgeschlossen!'
all_levels_mastered: 'Alle %{count} Stufen gemeistert.'
all_keys_confident: 'Jede Taste in diesem Zweig hat volle Sicherheit.'
all_unlocked_msg: 'Du hast jede Taste auf der Tastatur freigeschaltet!'
all_unlocked_desc: 'Jedes Zeichen, Symbol und jeder Modifikator ist jetzt in deinen Lektionen verfuegbar.'
keep_practicing_mastery: 'Uebe weiter, um Meisterschaft aufzubauen — wenn jede Taste volle'
confidence_complete: 'Sicherheit erreicht hat, hast du die volle Tastaturbeherrschung!'
all_mastered_msg: 'Glueckwunsch — du hast volle Tastaturbeherrschung erreicht!'
all_mastered_desc: 'Jede Taste auf der Tastatur hat maximale Sicherheit.'
mastery_takes_practice: 'Meisterschaft ist kein Ziel — sie erfordert staendiges Ueben.'
keep_drilling: 'Uebe weiter, um dein Koennen zu erhalten.'
hint_skill_tree_continue: '[t] Faehigkeitenbaum [Andere Taste] Weiter'
hint_any_key: 'Beliebige Taste zum Fortfahren'
input_blocked: 'Eingabe voruebergehend blockiert (%{ms}ms verbleibend)'
unlock_msg_1: 'Gut gemacht! Baue deine Tippfaehigkeiten weiter aus.'
unlock_msg_2: 'Eine weitere Taste in deinem Arsenal!'
unlock_msg_3: 'Deine Tastatur waechst! Weiter so.'
unlock_msg_4: 'Einen Schritt naeher an voller Tastaturbeherrschung!'
mastery_msg_1: 'Diese Taste hat jetzt volle Sicherheit!'
mastery_msg_2: 'Diese Taste sitzt perfekt!'
mastery_msg_3: 'Muskelgedaechtnis verankert!'
mastery_msg_4: 'Eine weitere Taste bezwungen!'
# Keyboard explorer
keyboard:
title: ' Tastatur '
subtitle: 'Druecke eine Taste oder klicke darauf'
hint_navigate: '[←→↑↓/hjkl/Tab] Navigieren'
hint_back: '[q/ESC] Zurueck'
key_label: 'Taste: '
finger_label: 'Finger: '
hand_left: 'Links'
hand_right: 'Rechts'
finger_index: 'Zeigefinger'
finger_middle: 'Mittelfinger'
finger_ring: 'Ringfinger'
finger_pinky: 'Kleiner Finger'
finger_thumb: 'Daumen'
overall_accuracy: ' Gesamtgenauigkeit: %{correct}/%{total} (%{pct}%%)'
ranked_accuracy: ' Gewertete Genauigkeit: %{correct}/%{total} (%{pct}%%)'
confidence: 'Sicherheit: '
no_data: 'Noch keine Daten'
no_data_short: 'Keine Daten'
key_details: ' Tastendetails '
key_details_char: ' Tastendetails: ''%{ch}'' '
key_details_name: ' Tastendetails: %{name} '
press_key_hint: 'Druecke eine Taste fuer Details'
shift_label: 'Umschalt: '
shift_no: 'Nein'
overall_avg_time: 'Gesamt Schnittzeit: '
overall_best_time: 'Gesamt Bestzeit: '
overall_samples: 'Gesamt Stichproben: '
overall_accuracy_label: 'Gesamt Genauigkeit: '
branch_label: 'Zweig: '
level_label: 'Stufe: '
built_in_key: 'Eingebaute Taste'
unlocked_label: 'Freigeschaltet: '
yes: 'Ja'
no: 'Nein'
in_focus_label: 'Im Fokus?: '
mastery_label: 'Meisterschaft: '
mastery_locked: 'Gesperrt'
ranked_avg_time: 'Gewertete Schnittzeit: '
ranked_best_time: 'Gewertete Bestzeit: '
ranked_samples: 'Gewertete Stichproben: '
ranked_accuracy_label: 'Gewertete Genauigkeit: '
# Intro dialogs
intro:
passage_title: ' Textpassagen-Download Einrichtung '
code_title: ' Code-Download Einrichtung '
enable_downloads: 'Netzwerk-Downloads aktivieren'
download_dir: 'Download-Verzeichnis'
paragraphs_per_book: 'Absaetze pro Buch (0 = ganz)'
whole_book: 'ganzes Buch'
snippets_per_repo: 'Schnipsel pro Repo (0 = unbegrenzt)'
unlimited: 'unbegrenzt'
start_passage_drill: 'Textpassagen-Lektion starten'
start_code_drill: 'Code-Lektion starten'
confirm: 'Bestaetigen'
hint_navigate: '[Auf/Ab] Navigieren'
hint_adjust: '[Links/Rechts] Anpassen'
hint_edit: '[Tippen/Backspace] Bearbeiten'
hint_confirm: '[Enter] Bestaetigen'
hint_cancel: '[ESC] Abbrechen'
preparing_download: 'Download wird vorbereitet...'
download_passage_title: ' Textquelle wird heruntergeladen '
download_code_title: ' Code-Quelle wird heruntergeladen '
book_label: ' Buch: %{name}'
repo_label: ' Repo: %{name}'
progress_bytes: '[%{name}] %{downloaded}/%{total} Bytes'
downloaded_bytes: 'Heruntergeladen: %{bytes} Bytes'
downloading_book_progress: 'Aktuelles Buch wird geladen: [%{bar}] %{downloaded}/%{total} Bytes'
downloading_book_bytes: 'Aktuelles Buch wird geladen: %{bytes} Bytes'
downloading_code_progress: 'Wird heruntergeladen: [%{bar}] %{downloaded}/%{total} Bytes'
downloading_code_bytes: 'Wird heruntergeladen: %{bytes} Bytes'
current_book: 'Aktuell: %{name} (Buch %{done}/%{total})'
current_repo: 'Aktuell: %{name} (Repo %{done}/%{total})'
passage_instructions_1: 'keydr kann Textpassagen von Project Gutenberg zum Tippueben herunterladen.'
passage_instructions_2: 'Buecher werden einmal heruntergeladen und lokal gespeichert.'
passage_instructions_3: 'Konfiguriere die Download-Einstellungen unten und starte eine Textpassagen-Lektion.'
code_instructions_1: 'keydr kann Open-Source-Code von GitHub zum Tippueben herunterladen.'
code_instructions_2: 'Code wird einmal heruntergeladen und lokal gespeichert.'
code_instructions_3: 'Konfiguriere die Download-Einstellungen unten und starte eine Code-Lektion.'
# Status messages (from app.rs)
status:
recovery_files: 'Wiederherstellungsdateien von unterbrochenem Import gefunden. Daten koennten inkonsistent sein — erneuter Import empfohlen.'
dir_not_exist: 'Verzeichnis existiert nicht: %{path}'
no_data_store: 'Kein Datenspeicher verfuegbar'
serialization_error: 'Serialisierungsfehler: %{error}'
exported_to: 'Exportiert nach %{path}'
export_failed: 'Export fehlgeschlagen: %{error}'
could_not_read: 'Datei konnte nicht gelesen werden: %{error}'
invalid_export: 'Ungueltige Exportdatei: %{error}'
unsupported_version: 'Nicht unterstuetzte Exportversion: %{got} (erwartet %{expected})'
import_failed: 'Import fehlgeschlagen: %{error}'
imported_theme_fallback: 'Erfolgreich importiert (Farbschema ''%{theme}'' nicht gefunden, Standard wird verwendet)'
imported_success: 'Erfolgreich importiert'
adaptive_unavailable: 'Adaptiver gewerteter Modus nicht verfuegbar: %{error}'
switched_to: 'Gewechselt zu %{name}'
layout_changed: 'Layout geaendert zu %{name}'
# Errors (for UI boundary translation)
errors:
unknown_language: 'Unbekannte Sprache: %{key}'
unknown_layout: 'Unbekanntes Tastaturlayout: %{key}'
unsupported_pair: 'Nicht unterstuetztes Sprach-/Layout-Paar: %{language} + %{layout}'
language_blocked: 'Sprache durch Unterstuetzungsstufe gesperrt: %{key}'
# Common
common:
wpm: 'WPM'
cpm: 'ZPM'
back: 'Zurueck'

454
locales/en.yml Normal file
View File

@@ -0,0 +1,454 @@
# Main menu
menu:
subtitle: 'Terminal Typing Tutor'
adaptive_drill: 'Adaptive Drill'
adaptive_drill_desc: 'Phonetic words with adaptive letter unlocking'
code_drill: 'Code Drill'
code_drill_desc: 'Practice typing code syntax'
passage_drill: 'Passage Drill'
passage_drill_desc: 'Type passages from books'
skill_tree: 'Skill Tree'
skill_tree_desc: 'View progression branches and launch drills'
keyboard: 'Keyboard'
keyboard_desc: 'Explore keyboard layout and key statistics'
statistics: 'Statistics'
statistics_desc: 'View your typing statistics'
settings: 'Settings'
settings_desc: 'Configure keydr'
day_streak: ' | %{days} day streak'
key_progress: ' Key Progress %{unlocked}/%{total} (%{mastered} mastered) | Target %{target} WPM%{streak}'
hint_start: '[1-3] Start'
hint_skill_tree: '[t] Skill Tree'
hint_keyboard: '[b] Keyboard'
hint_stats: '[s] Stats'
hint_settings: '[c] Settings'
hint_quit: '[q] Quit'
# Drill screen
drill:
title: ' Drill '
mode_adaptive: 'Adaptive'
mode_code: 'Code (Unranked)'
mode_passage: 'Passage (Unranked)'
focus_char: 'Focus: ''%{ch}'''
focus_bigram: 'Focus: "%{bigram}"'
focus_both: 'Focus: ''%{ch}'' + "%{bigram}"'
header_wpm: 'WPM'
header_acc: 'Acc'
header_err: 'Err'
code_source: ' Code source '
passage_source: ' Passage source '
footer: '[ESC] End drill [Backspace] Delete'
keys_reenabled: 'Keys re-enabled in %{ms}ms'
hint_end: '[ESC] End drill'
hint_backspace: '[Backspace] Delete'
# Dashboard / drill result
dashboard:
title: ' Drill Complete '
results: 'Results'
unranked_note_prefix: ' (Unranked'
unranked_note_suffix: ' does not count toward skill tree)'
speed: ' Speed: '
accuracy_label: ' Accuracy: '
time_label: ' Time: '
errors_label: ' Errors: '
correct_detail: ' (%{correct}/%{total} correct)'
input_blocked: ' Input temporarily blocked '
input_blocked_ms: '(%{ms}ms remaining)'
hint_continue: '[c/Enter/Space] Continue'
hint_retry: '[r] Retry'
hint_menu: '[q] Menu'
hint_stats: '[s] Stats'
hint_delete: '[x] Delete'
# Stats sidebar (during drill)
sidebar:
title: ' Stats '
wpm: 'WPM: '
target: 'Target: '
target_wpm: '%{wpm} WPM'
accuracy: 'Accuracy: '
progress: 'Progress: '
correct: 'Correct: '
errors: 'Errors: '
time: 'Time: '
last_drill: ' Last Drill '
vs_avg: ' vs avg: '
# Statistics dashboard
stats:
title: ' Statistics '
empty: 'No drills completed yet. Start typing!'
tab_dashboard: '[1] Dashboard'
tab_history: '[2] History'
tab_activity: '[3] Activity'
tab_accuracy: '[4] Accuracy'
tab_timing: '[5] Timing'
tab_ngrams: '[6] N-grams'
hint_back: '[ESC] Back'
hint_next_tab: '[Tab] Next tab'
hint_switch_tab: '[1-6] Switch tab'
hint_navigate: '[j/k] Navigate'
hint_page: '[PgUp/PgDn] Page'
hint_delete: '[x] Delete'
summary_title: ' Summary '
drills: ' Drills: '
avg_wpm: ' Avg WPM: '
best_wpm: ' Best WPM: '
accuracy_label: ' Accuracy: '
total_time: ' Total time: '
wpm_chart_title: ' WPM per Drill (Last 20, Target: %{target}) '
accuracy_chart_title: ' Accuracy %% (Last 50 Drills) '
chart_drill: 'Drill #'
chart_accuracy_pct: 'Accuracy %%'
sessions_title: ' Recent Sessions '
session_header: ' # WPM Raw Acc%% Time Date/Time Mode Ranked Partial'
session_separator: ' ─────────────────────────────────────────────────────────────────────'
delete_confirm: 'Delete session #%{idx}? (y/n)'
confirm_title: ' Confirm '
yes: 'yes'
no: 'no'
keyboard_accuracy_title: ' Keyboard Accuracy %% '
keyboard_timing_title: ' Keyboard Timing (ms) '
slowest_keys_title: ' Slowest Keys (ms) '
fastest_keys_title: ' Fastest Keys (ms) '
worst_accuracy_title: ' Worst Accuracy (%%) '
best_accuracy_title: ' Best Accuracy (%%) '
not_enough_data: ' Not enough data'
streaks_title: ' Streaks '
current_streak: ' Current: '
best_streak: ' Best: '
active_days: ' Active Days: '
top_days_none: ' Top Days: none'
top_days: ' Top Days: %{days}'
wpm_label: ' WPM: %{avg}/%{target} (%{pct}%%)'
acc_label: ' Acc: %{pct}%%'
keys_label: ' Keys: %{unlocked}/%{total} (%{mastered} mastered)'
ngram_empty: 'Complete some adaptive drills to see n-gram data'
ngram_header_speed_narrow: ' Bgrm Speed Expct Anom%'
ngram_header_error_narrow: ' Bgrm Err Smp Rate Exp Anom%'
ngram_header_speed: ' Bigram Speed Expect Samples Anom%'
ngram_header_error: ' Bigram Errors Samples Rate Expect Anom%'
focus_title: ' Active Focus '
focus_char_label: ' Focus: '
focus_bigram_value: 'Bigram %{label}'
focus_plus: ' + '
anomaly_error: 'error'
anomaly_speed: 'speed'
focus_detail_both: ' Char ''%{ch}'': weakest key | Bigram %{label}: %{type} anomaly %{pct}%%'
focus_detail_char_only: ' Char ''%{ch}'': weakest key, no confirmed bigram anomalies'
focus_detail_bigram_only: ' (%{type} anomaly: %{pct}%%)'
focus_empty: ' Complete some adaptive drills to see focus data'
error_anomalies_title: ' Error Anomalies (%{count}) '
no_error_anomalies: ' No error anomalies detected'
speed_anomalies_title: ' Speed Anomalies (%{count}) '
no_speed_anomalies: ' No speed anomalies detected'
scope_label_prefix: ' '
bi_label: ' | Bi: %{count}'
hes_label: ' | Hes: >%{ms}ms'
focus_char_value: 'Char ''%{ch}'''
# Activity heatmap
heatmap:
title: ' Daily Activity (Sessions per Day) '
jan: 'Jan'
feb: 'Feb'
mar: 'Mar'
apr: 'Apr'
may: 'May'
jun: 'Jun'
jul: 'Jul'
aug: 'Aug'
sep: 'Sep'
oct: 'Oct'
nov: 'Nov'
dec: 'Dec'
# Chart
chart:
wpm_over_time: ' WPM Over Time '
drill_number: 'Drill #'
# Settings
settings:
title: ' Settings '
subtitle: 'Use arrows to navigate, Enter/Right to change, ESC to save & exit'
target_wpm: 'Target WPM'
theme: 'Theme'
word_count: 'Word Count'
ui_language: 'UI Language'
dictionary_language: 'Dictionary Language'
keyboard_layout: 'Keyboard Layout'
code_language: 'Code Language'
code_downloads: 'Code Downloads'
on: 'On'
off: 'Off'
code_download_dir: 'Code Download Dir'
snippets_per_repo: 'Snippets per Repo'
unlimited: 'Unlimited'
download_code_now: 'Download Code Now'
run_downloader: 'Run downloader'
passage_downloads: 'Passage Downloads'
passage_download_dir: 'Passage Download Dir'
paragraphs_per_book: 'Paragraphs per Book'
whole_book: 'Whole book'
download_passages_now: 'Download Passages Now'
export_path: 'Export Path'
export_data: 'Export Data'
export_now: 'Export now'
import_path: 'Import Path'
import_data: 'Import Data'
import_now: 'Import now'
hint_save_back: '[ESC] Save & back'
hint_change_value: '[Enter/arrows] Change value'
hint_edit_path: '[Enter on path] Edit'
hint_move: '[←→] Move'
hint_tab_complete: '[Tab] Complete (at end)'
hint_confirm: '[Enter] Confirm'
hint_cancel: '[Esc] Cancel'
success_title: ' Success '
error_title: ' Error '
press_any_key: 'Press any key'
file_exists_title: ' File Exists '
file_exists: 'A file already exists at this path.'
overwrite_rename: '[d] Overwrite [r] Rename [Esc] Cancel'
erase_warning: 'This will erase your current data.'
export_first: 'Export first if you want to keep it.'
proceed_yn: 'Proceed? (y/n)'
confirm_import_title: ' Confirm Import '
# Selection screens
select:
dictionary_language_title: ' Select Dictionary Language '
keyboard_layout_title: ' Select Keyboard Layout '
code_language_title: ' Select Code Language '
passage_source_title: ' Select Passage Source '
ui_language_title: ' Select UI Language '
more_above: '... %{count} more above ...'
more_below: '... %{count} more below ...'
current: ' (current)'
disabled: ' (disabled)'
enabled_default: ' (enabled, default: %{layout})'
enabled: ' (enabled)'
disabled_blocked: ' (disabled: blocked)'
built_in: ' (built-in)'
cached: ' (cached)'
disabled_download: ' (disabled: download required)'
download_required: ' (download required)'
hint_navigate: '[Up/Down/PgUp/PgDn] Navigate'
hint_confirm: '[Enter] Confirm'
hint_back: '[ESC] Back'
language_resets_layout: 'Selecting a language resets keyboard layout to that language''s default.'
layout_no_language_change: 'Layout changes do not change dictionary language.'
disabled_network_notice: 'Some languages are disabled: enable network downloads in intro/settings.'
disabled_sources_notice: 'Some sources are disabled: enable network downloads in intro/settings.'
passage_all: 'All (Built-in + all books)'
passage_builtin: 'Built-in passages only'
passage_book_prefix: 'Book: %{title}'
# Progress
progress:
overall_key_progress: 'Overall Key Progress'
unlocked_mastered: '%{unlocked}/%{total} unlocked (%{mastered} mastered)'
# Skill tree
skill_tree:
title: ' Skill Tree '
locked: 'Locked'
unlocked: 'unlocked'
mastered: 'mastered'
in_progress: 'in progress'
complete: 'complete'
locked_status: 'locked'
locked_notice: 'Complete %{count} primary letters to unlock branches'
branches_separator: 'Branches (available after %{count} primary letters)'
unlocked_letters: 'Unlocked %{unlocked}/%{total} letters'
level: 'Level %{current}/%{total}'
level_zero: 'Level 0/%{total}'
in_focus: ' in focus'
hint_navigate: '[↑↓/jk] Navigate'
hint_scroll: '[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll'
hint_back: '[q] Back'
hint_unlock: '[Enter] Unlock'
hint_start_drill: '[Enter] Start Drill'
unlock_msg_1: 'Once unlocked, the default adaptive drill will mix in keys in this branch that are unlocked.'
unlock_msg_2: 'If you want to focus only on this branch, launch a drill directly from this branch in the Skill Tree.'
confirm_unlock: 'Unlock %{branch}?'
confirm_yn: '[y] Unlock [n/ESC] Cancel'
lvl_prefix: 'Lvl'
branch_primary_letters: 'Primary Letters'
branch_capital_letters: 'Capital Letters'
branch_numbers: 'Numbers 0-9'
branch_prose_punctuation: 'Prose Punctuation'
branch_whitespace: 'Whitespace'
branch_code_symbols: 'Code Symbols'
level_frequency_order: 'Frequency Order'
level_common_sentence_capitals: 'Common Sentence Capitals'
level_name_capitals: 'Name Capitals'
level_remaining_capitals: 'Remaining Capitals'
level_common_digits: 'Common Digits'
level_all_digits: 'All Digits'
level_essential: 'Essential'
level_common: 'Common'
level_expressive: 'Expressive'
level_enter_return: 'Enter/Return'
level_tab_indent: 'Tab/Indent'
level_arithmetic_assignment: 'Arithmetic & Assignment'
level_grouping: 'Grouping'
level_logic_reference: 'Logic & Reference'
level_special: 'Special'
# Milestones
milestones:
unlock_title: ' Key Unlocked! '
mastery_title: ' Key Mastered! '
branches_title: ' New Skill Branches Available! '
branch_complete_title: ' Branch Complete! '
all_unlocked_title: ' Every Key Unlocked! '
all_mastered_title: ' Full Keyboard Mastery! '
unlocked: 'unlocked'
mastered: 'mastered'
use_finger: 'Use your %{finger} finger'
hold_right_shift: 'Hold Right Shift (right pinky)'
hold_left_shift: 'Hold Left Shift (left pinky)'
congratulations_all_letters: 'Congratulations! You''ve mastered all %{count} primary letters'
new_branches_available: 'New skill branches are now available:'
visit_skill_tree: 'Visit the Skill Tree to unlock a new branch'
and_start_training: 'and start training!'
open_skill_tree: 'Press [t] to open the Skill Tree now'
branch_complete_msg: 'You''ve completed the %{branch} branch!'
all_levels_mastered: 'All %{count} levels mastered.'
all_keys_confident: 'Every key in this branch is at full confidence.'
all_unlocked_msg: 'You''ve unlocked every key on the keyboard!'
all_unlocked_desc: 'Every character, symbol, and modifier is now available in your drills.'
keep_practicing_mastery: 'Keep practicing to build mastery — once every key reaches full'
confidence_complete: 'confidence, you''ll have achieved complete keyboard mastery!'
all_mastered_msg: 'Congratulations — you''ve reached full keyboard mastery!'
all_mastered_desc: 'Every key on the keyboard is at maximum confidence.'
mastery_takes_practice: 'Mastery is not a destination — it takes ongoing practice.'
keep_drilling: 'Keep drilling to maintain your edge.'
hint_skill_tree_continue: '[t] Open Skill Tree [Any other key] Continue'
hint_any_key: 'Press any key to continue'
input_blocked: 'Input temporarily blocked (%{ms}ms remaining)'
unlock_msg_1: 'Nice work! Keep building your typing skills.'
unlock_msg_2: 'Another key added to your arsenal!'
unlock_msg_3: 'Your keyboard is growing! Keep it up.'
unlock_msg_4: 'One step closer to full keyboard mastery!'
mastery_msg_1: 'This key is now at full confidence!'
mastery_msg_2: 'You''ve got this key down pat!'
mastery_msg_3: 'Muscle memory locked in!'
mastery_msg_4: 'One more key conquered!'
# Keyboard explorer
keyboard:
title: ' Keyboard '
subtitle: 'Press any key or click a key'
hint_navigate: '[←→↑↓/hjkl/Tab] Navigate'
hint_back: '[q/ESC] Back'
key_label: 'Key: '
finger_label: 'Finger: '
hand_left: 'Left'
hand_right: 'Right'
finger_index: 'Index'
finger_middle: 'Middle'
finger_ring: 'Ring'
finger_pinky: 'Pinky'
finger_thumb: 'Thumb'
overall_accuracy: ' Overall accuracy: %{correct}/%{total} (%{pct}%%)'
ranked_accuracy: ' Ranked accuracy: %{correct}/%{total} (%{pct}%%)'
confidence: 'Confidence: '
no_data: 'No data yet'
no_data_short: 'No data'
key_details: ' Key Details '
key_details_char: ' Key Details: ''%{ch}'' '
key_details_name: ' Key Details: %{name} '
press_key_hint: 'Press a key to see its details'
shift_label: 'Shift: '
shift_no: 'No'
overall_avg_time: 'Overall Avg Time: '
overall_best_time: 'Overall Best Time: '
overall_samples: 'Overall Samples: '
overall_accuracy_label: 'Overall Accuracy: '
branch_label: 'Branch: '
level_label: 'Level: '
built_in_key: 'Built-in Key'
unlocked_label: 'Unlocked: '
yes: 'Yes'
no: 'No'
in_focus_label: 'In Focus?: '
mastery_label: 'Mastery: '
mastery_locked: 'Locked'
ranked_avg_time: 'Ranked Avg Time: '
ranked_best_time: 'Ranked Best Time: '
ranked_samples: 'Ranked Samples: '
ranked_accuracy_label: 'Ranked Accuracy: '
# Intro dialogs
intro:
passage_title: ' Passage Downloads Setup '
code_title: ' Code Downloads Setup '
enable_downloads: 'Enable network downloads'
download_dir: 'Download directory'
paragraphs_per_book: 'Paragraphs per book (0 = whole)'
whole_book: 'whole book'
snippets_per_repo: 'Snippets per repo (0 = unlimited)'
unlimited: 'unlimited'
start_passage_drill: 'Start passage drill'
start_code_drill: 'Start code drill'
confirm: 'Confirm'
hint_navigate: '[Up/Down] Navigate'
hint_adjust: '[Left/Right] Adjust'
hint_edit: '[Type/Backspace] Edit'
hint_confirm: '[Enter] Confirm'
hint_cancel: '[ESC] Cancel'
preparing_download: 'Preparing download...'
download_passage_title: ' Downloading Passage Source '
download_code_title: ' Downloading Code Source '
book_label: ' Book: %{name}'
repo_label: ' Repo: %{name}'
progress_bytes: '[%{name}] %{downloaded}/%{total} bytes'
downloaded_bytes: 'Downloaded: %{bytes} bytes'
downloading_book_progress: 'Downloading current book: [%{bar}] %{downloaded}/%{total} bytes'
downloading_book_bytes: 'Downloading current book: %{bytes} bytes'
downloading_code_progress: 'Downloading: [%{bar}] %{downloaded}/%{total} bytes'
downloading_code_bytes: 'Downloading: %{bytes} bytes'
current_book: 'Current: %{name} (book %{done}/%{total})'
current_repo: 'Current: %{name} (repo %{done}/%{total})'
passage_instructions_1: 'keydr can download passages from Project Gutenberg for typing practice.'
passage_instructions_2: 'Books are downloaded once and cached locally.'
passage_instructions_3: 'Configure download settings below, then start a passage drill.'
code_instructions_1: 'keydr can download open-source code from GitHub for typing practice.'
code_instructions_2: 'Code is downloaded once and cached locally.'
code_instructions_3: 'Configure download settings below, then start a code drill.'
# Status messages (from app.rs)
status:
recovery_files: 'Recovery files found from interrupted import. Data may be inconsistent — consider re-importing.'
dir_not_exist: 'Directory does not exist: %{path}'
no_data_store: 'No data store available'
serialization_error: 'Serialization error: %{error}'
exported_to: 'Exported to %{path}'
export_failed: 'Export failed: %{error}'
could_not_read: 'Could not read file: %{error}'
invalid_export: 'Invalid export file: %{error}'
unsupported_version: 'Unsupported export version: %{got} (expected %{expected})'
import_failed: 'Import failed: %{error}'
imported_theme_fallback: 'Imported successfully (theme ''%{theme}'' not found, using default)'
imported_success: 'Imported successfully'
adaptive_unavailable: 'Adaptive ranked mode unavailable: %{error}'
switched_to: 'Switched to %{name}'
layout_changed: 'Layout changed to %{name}'
# Errors (for UI boundary translation)
errors:
unknown_language: 'Unknown language: %{key}'
unknown_layout: 'Unknown keyboard layout: %{key}'
unsupported_pair: 'Unsupported language/layout pair: %{language} + %{layout}'
language_blocked: 'Language is blocked by support level: %{key}'
# Common
common:
wpm: 'WPM'
cpm: 'CPM'
back: 'Back'

454
locales/es.yml Normal file
View File

@@ -0,0 +1,454 @@
# Menú principal
menu:
subtitle: 'Tutor de Mecanografía en Terminal'
adaptive_drill: 'Ejercicio Adaptativo'
adaptive_drill_desc: 'Palabras fonéticas con desbloqueo adaptativo de teclas'
code_drill: 'Ejercicio de Código'
code_drill_desc: 'Practica escribiendo sintaxis de código'
passage_drill: 'Ejercicio de Pasaje'
passage_drill_desc: 'Escribe pasajes de libros'
skill_tree: 'Árbol de Habilidades'
skill_tree_desc: 'Ver ramas de progresión e iniciar ejercicios'
keyboard: 'Teclado'
keyboard_desc: 'Explora la distribución del teclado y estadísticas'
statistics: 'Estadísticas'
statistics_desc: 'Ver tus estadísticas de escritura'
settings: 'Configuración'
settings_desc: 'Configurar keydr'
day_streak: ' | %{days} días seguidos'
key_progress: ' Progreso de Teclas %{unlocked}/%{total} (%{mastered} dominadas) | Objetivo %{target} WPM%{streak}'
hint_start: '[1-3] Iniciar'
hint_skill_tree: '[t] Árbol de Habilidades'
hint_keyboard: '[b] Teclado'
hint_stats: '[s] Estadísticas'
hint_settings: '[c] Configuración'
hint_quit: '[q] Salir'
# Pantalla de ejercicio
drill:
title: ' Ejercicio '
mode_adaptive: 'Adaptativo'
mode_code: 'Código (Sin rango)'
mode_passage: 'Pasaje (Sin rango)'
focus_char: 'Foco: ''%{ch}'''
focus_bigram: 'Foco: "%{bigram}"'
focus_both: 'Foco: ''%{ch}'' + "%{bigram}"'
header_wpm: 'WPM'
header_acc: 'Pre'
header_err: 'Err'
code_source: ' Fuente de código '
passage_source: ' Fuente del pasaje '
footer: '[ESC] Fin [Backspace] Borrar'
keys_reenabled: 'Teclas reactivadas en %{ms}ms'
hint_end: '[ESC] Fin del ejercicio'
hint_backspace: '[Backspace] Borrar'
# Panel / resultado del ejercicio
dashboard:
title: ' Ejercicio Completo '
results: 'Resultados'
unranked_note_prefix: ' (Sin rango'
unranked_note_suffix: ' no cuenta para el árbol de habilidades)'
speed: ' Velocidad: '
accuracy_label: ' Precisión: '
time_label: ' Tiempo: '
errors_label: ' Errores: '
correct_detail: ' (%{correct}/%{total} correctos)'
input_blocked: ' Entrada bloqueada temporalmente '
input_blocked_ms: '(%{ms}ms restantes)'
hint_continue: '[c/Enter/Space] Continuar'
hint_retry: '[r] Reintentar'
hint_menu: '[q] Menú'
hint_stats: '[s] Estadísticas'
hint_delete: '[x] Eliminar'
# Barra lateral de estadísticas (durante el ejercicio)
sidebar:
title: ' Estadísticas '
wpm: 'WPM: '
target: 'Objetivo: '
target_wpm: '%{wpm} WPM'
accuracy: 'Precisión: '
progress: 'Progreso: '
correct: 'Correcto: '
errors: 'Errores: '
time: 'Tiempo: '
last_drill: ' Último Ejercicio '
vs_avg: ' vs prom: '
# Panel de estadísticas
stats:
title: ' Estadísticas '
empty: 'Aún no hay ejercicios completados. ¡Empieza a escribir!'
tab_dashboard: '[1] Panel'
tab_history: '[2] Historial'
tab_activity: '[3] Actividad'
tab_accuracy: '[4] Precisión'
tab_timing: '[5] Tiempos'
tab_ngrams: '[6] N-gramas'
hint_back: '[ESC] Volver'
hint_next_tab: '[Tab] Siguiente pestaña'
hint_switch_tab: '[1-6] Cambiar pestaña'
hint_navigate: '[j/k] Navegar'
hint_page: '[PgUp/PgDn] Página'
hint_delete: '[x] Eliminar'
summary_title: ' Resumen '
drills: ' Ejercicios: '
avg_wpm: ' WPM Prom: '
best_wpm: ' Mejor WPM: '
accuracy_label: ' Precisión: '
total_time: ' Tiempo total: '
wpm_chart_title: ' WPM por Ejercicio (Últimos 20, Objetivo: %{target}) '
accuracy_chart_title: ' Precisión %% (Últimos 50 Ejercicios) '
chart_drill: 'Ejercicio #'
chart_accuracy_pct: 'Precisión %%'
sessions_title: ' Sesiones Recientes '
session_header: ' # WPM Raw Pre%% Tiempo Fecha/Hora Modo Rango Parcial'
session_separator: ' ─────────────────────────────────────────────────────────────────────'
delete_confirm: '¿Eliminar sesión #%{idx}? (y/n)'
confirm_title: ' Confirmar '
yes: 'sí'
no: 'no'
keyboard_accuracy_title: ' Precisión del Teclado %% '
keyboard_timing_title: ' Tiempos del Teclado (ms) '
slowest_keys_title: ' Teclas más Lentas (ms) '
fastest_keys_title: ' Teclas más Rápidas (ms) '
worst_accuracy_title: ' Peor Precisión (%%) '
best_accuracy_title: ' Mejor Precisión (%%) '
not_enough_data: ' Datos insuficientes'
streaks_title: ' Rachas '
current_streak: ' Actual: '
best_streak: ' Mejor: '
active_days: ' Días activos: '
top_days_none: ' Mejores días: ninguno'
top_days: ' Mejores días: %{days}'
wpm_label: ' WPM: %{avg}/%{target} (%{pct}%%)'
acc_label: ' Pre: %{pct}%%'
keys_label: ' Teclas: %{unlocked}/%{total} (%{mastered} dominadas)'
ngram_empty: 'Completa ejercicios adaptativos para ver datos de n-gramas'
ngram_header_speed_narrow: ' Bgrm Vel Esper Anom%'
ngram_header_error_narrow: ' Bgrm Err Mst Tasa Esp Anom%'
ngram_header_speed: ' Bigrama Vel Esper Muestras Anom%'
ngram_header_error: ' Bigrama Errores Muestras Tasa Esper Anom%'
focus_title: ' Foco Activo '
focus_char_label: ' Foco: '
focus_bigram_value: 'Bigrama %{label}'
focus_plus: ' + '
anomaly_error: 'errores'
anomaly_speed: 'velocidad'
focus_detail_both: ' Carácter ''%{ch}'': tecla más débil | Bigrama %{label}: anomalía de %{type} %{pct}%%'
focus_detail_char_only: ' Carácter ''%{ch}'': tecla más débil, sin anomalías de bigrama confirmadas'
focus_detail_bigram_only: ' (anomalía de %{type}: %{pct}%%)'
focus_empty: ' Completa ejercicios adaptativos para ver datos de foco'
error_anomalies_title: ' Anomalías de Error (%{count}) '
no_error_anomalies: ' No se detectaron anomalías de error'
speed_anomalies_title: ' Anomalías de Velocidad (%{count}) '
no_speed_anomalies: ' No se detectaron anomalías de velocidad'
scope_label_prefix: ' '
bi_label: ' | Bi: %{count}'
hes_label: ' | Hes: >%{ms}ms'
focus_char_value: 'Carácter ''%{ch}'''
# Mapa de actividad
heatmap:
title: ' Actividad Diaria (Sesiones por Día) '
jan: 'Ene'
feb: 'Feb'
mar: 'Mar'
apr: 'Abr'
may: 'May'
jun: 'Jun'
jul: 'Jul'
aug: 'Ago'
sep: 'Sep'
oct: 'Oct'
nov: 'Nov'
dec: 'Dic'
# Gráfico
chart:
wpm_over_time: ' WPM a lo largo del tiempo '
drill_number: 'Ejercicio #'
# Configuración
settings:
title: ' Configuración '
subtitle: 'Usa las flechas para navegar, Enter/Derecha para cambiar, ESC para guardar y salir'
target_wpm: 'WPM Objetivo'
theme: 'Tema'
word_count: 'Cantidad de Palabras'
ui_language: 'Idioma de Interfaz'
dictionary_language: 'Idioma del Diccionario'
keyboard_layout: 'Distribución de Teclado'
code_language: 'Lenguaje de Código'
code_downloads: 'Descargas de Código'
on: 'Sí'
off: 'No'
code_download_dir: 'Dir. Descarga de Código'
snippets_per_repo: 'Fragmentos por Repo'
unlimited: 'Ilimitado'
download_code_now: 'Descargar Código Ahora'
run_downloader: 'Ejecutar descargador'
passage_downloads: 'Descargas de Pasajes'
passage_download_dir: 'Dir. Descarga de Pasajes'
paragraphs_per_book: 'Párrafos por Libro'
whole_book: 'Libro completo'
download_passages_now: 'Descargar Pasajes Ahora'
export_path: 'Ruta de Exportación'
export_data: 'Exportar Datos'
export_now: 'Exportar ahora'
import_path: 'Ruta de Importación'
import_data: 'Importar Datos'
import_now: 'Importar ahora'
hint_save_back: '[ESC] Guardar y volver'
hint_change_value: '[Enter/flechas] Cambiar valor'
hint_edit_path: '[Enter en ruta] Editar'
hint_move: '[←→] Mover'
hint_tab_complete: '[Tab] Completar (al final)'
hint_confirm: '[Enter] Confirmar'
hint_cancel: '[Esc] Cancelar'
success_title: ' Éxito '
error_title: ' Fallo '
press_any_key: 'Presiona cualquier tecla'
file_exists_title: ' Archivo Existente '
file_exists: 'Ya existe un archivo en esta ruta.'
overwrite_rename: '[d] Sobrescribir [r] Renombrar [Esc] Cancelar'
erase_warning: 'Esto borrará tus datos actuales.'
export_first: 'Exporta primero si deseas conservarlos.'
proceed_yn: '¿Continuar? (y/n)'
confirm_import_title: ' Confirmar Importación '
# Pantallas de selección
select:
dictionary_language_title: ' Seleccionar Idioma del Diccionario '
keyboard_layout_title: ' Seleccionar Distribución de Teclado '
code_language_title: ' Seleccionar Lenguaje de Código '
passage_source_title: ' Seleccionar Fuente de Pasajes '
ui_language_title: ' Seleccionar Idioma de Interfaz '
more_above: '... %{count} más arriba ...'
more_below: '... %{count} más abajo ...'
current: ' (actual)'
disabled: ' (desactivado)'
enabled_default: ' (activado, predeterminado: %{layout})'
enabled: ' (activado)'
disabled_blocked: ' (desactivado: bloqueado)'
built_in: ' (incluido)'
cached: ' (en caché)'
disabled_download: ' (desactivado: requiere descarga)'
download_required: ' (requiere descarga)'
hint_navigate: '[Up/Down/PgUp/PgDn] Navegar'
hint_confirm: '[Enter] Confirmar'
hint_back: '[ESC] Volver'
language_resets_layout: 'Seleccionar un idioma restablece la distribución a la predeterminada de ese idioma.'
layout_no_language_change: 'Cambiar distribución no cambia el idioma del diccionario.'
disabled_network_notice: 'Algunos idiomas están desactivados: activa las descargas en intro/configuración.'
disabled_sources_notice: 'Algunas fuentes están desactivadas: activa las descargas en intro/configuración.'
passage_all: 'Todos (Incluidos + todos los libros)'
passage_builtin: 'Solo pasajes incluidos'
passage_book_prefix: 'Libro: %{title}'
# Progreso
progress:
overall_key_progress: 'Progreso General de Teclas'
unlocked_mastered: '%{unlocked}/%{total} desbloqueadas (%{mastered} dominadas)'
# Árbol de habilidades
skill_tree:
title: ' Árbol de Habilidades '
locked: 'Bloqueado'
unlocked: 'desbloqueado'
mastered: 'dominado'
in_progress: 'en progreso'
complete: 'completo'
locked_status: 'bloqueado'
locked_notice: 'Completa %{count} letras primarias para desbloquear ramas'
branches_separator: 'Ramas (disponibles tras %{count} letras primarias)'
unlocked_letters: 'Desbloqueadas %{unlocked}/%{total} letras'
level: 'Nivel %{current}/%{total}'
level_zero: 'Nivel 0/%{total}'
in_focus: ' en foco'
hint_navigate: '[↑↓/jk] Navegar'
hint_scroll: '[PgUp/PgDn o Ctrl+U/Ctrl+D] Desplazar'
hint_back: '[q] Volver'
hint_unlock: '[Enter] Desbloquear'
hint_start_drill: '[Enter] Iniciar Ejercicio'
unlock_msg_1: 'Una vez desbloqueado, el ejercicio adaptativo incluirá teclas de esta rama que estén desbloqueadas.'
unlock_msg_2: 'Si quieres enfocarte solo en esta rama, inicia un ejercicio directamente desde esta rama en el Árbol de Habilidades.'
confirm_unlock: '¿Desbloquear %{branch}?'
confirm_yn: '[y] Desbloquear [n/ESC] Cancelar'
lvl_prefix: 'Niv'
branch_primary_letters: 'Letras Primarias'
branch_capital_letters: 'Letras Mayúsculas'
branch_numbers: 'Números 0-9'
branch_prose_punctuation: 'Puntuación de Prosa'
branch_whitespace: 'Espacios en Blanco'
branch_code_symbols: 'Símbolos de Código'
level_frequency_order: 'Orden por Frecuencia'
level_common_sentence_capitals: 'Mayúsculas Comunes de Oración'
level_name_capitals: 'Mayúsculas de Nombres'
level_remaining_capitals: 'Mayúsculas Restantes'
level_common_digits: 'Dígitos Comunes'
level_all_digits: 'Todos los Dígitos'
level_essential: 'Esencial'
level_common: 'Común'
level_expressive: 'Expresivo'
level_enter_return: 'Enter/Retorno'
level_tab_indent: 'Tab/Sangría'
level_arithmetic_assignment: 'Aritmética y Asignación'
level_grouping: 'Agrupación'
level_logic_reference: 'Lógica y Referencia'
level_special: 'Especial'
# Hitos
milestones:
unlock_title: ' ¡Tecla Desbloqueada! '
mastery_title: ' ¡Tecla Dominada! '
branches_title: ' ¡Nuevas Ramas Disponibles! '
branch_complete_title: ' ¡Rama Completada! '
all_unlocked_title: ' ¡Todas las Teclas Desbloqueadas! '
all_mastered_title: ' ¡Dominio Total del Teclado! '
unlocked: 'desbloqueada'
mastered: 'dominada'
use_finger: 'Usa tu dedo %{finger}'
hold_right_shift: 'Mantén Shift Derecho (meñique derecho)'
hold_left_shift: 'Mantén Shift Izquierdo (meñique izquierdo)'
congratulations_all_letters: '¡Felicidades! Has dominado las %{count} letras primarias'
new_branches_available: 'Nuevas ramas de habilidades están disponibles:'
visit_skill_tree: 'Visita el Árbol de Habilidades para desbloquear una nueva rama'
and_start_training: '¡y empieza a entrenar!'
open_skill_tree: 'Presiona [t] para abrir el Árbol de Habilidades'
branch_complete_msg: '¡Has completado la rama %{branch}!'
all_levels_mastered: 'Los %{count} niveles dominados.'
all_keys_confident: 'Cada tecla en esta rama está a máxima confianza.'
all_unlocked_msg: '¡Has desbloqueado todas las teclas del teclado!'
all_unlocked_desc: 'Cada carácter, símbolo y modificador está disponible en tus ejercicios.'
keep_practicing_mastery: 'Sigue practicando para alcanzar el dominio — cuando cada tecla llegue a'
confidence_complete: 'máxima confianza, ¡habrás logrado el dominio total del teclado!'
all_mastered_msg: '¡Felicidades — has alcanzado el dominio total del teclado!'
all_mastered_desc: 'Cada tecla del teclado está a máxima confianza.'
mastery_takes_practice: 'El dominio no es un destino — requiere práctica continua.'
keep_drilling: 'Sigue practicando para mantener tu nivel.'
hint_skill_tree_continue: '[t] Abrir Árbol de Habilidades [Otra tecla] Continuar'
hint_any_key: 'Presiona cualquier tecla para continuar'
input_blocked: 'Entrada bloqueada temporalmente (%{ms}ms restantes)'
unlock_msg_1: '¡Buen trabajo! Sigue mejorando tus habilidades.'
unlock_msg_2: '¡Otra tecla añadida a tu arsenal!'
unlock_msg_3: '¡Tu teclado crece! Sigue así.'
unlock_msg_4: '¡Un paso más cerca del dominio total!'
mastery_msg_1: '¡Esta tecla está a máxima confianza!'
mastery_msg_2: '¡Dominas esta tecla a la perfección!'
mastery_msg_3: '¡Memoria muscular asegurada!'
mastery_msg_4: '¡Una tecla más conquistada!'
# Explorador de teclado
keyboard:
title: ' Teclado '
subtitle: 'Presiona o haz clic en una tecla'
hint_navigate: '[←→↑↓/hjkl/Tab] Navegar'
hint_back: '[q/ESC] Volver'
key_label: 'Tecla: '
finger_label: 'Dedo: '
hand_left: 'Izquierda'
hand_right: 'Derecha'
finger_index: 'Índice'
finger_middle: 'Medio'
finger_ring: 'Anular'
finger_pinky: 'Meñique'
finger_thumb: 'Pulgar'
overall_accuracy: ' Precisión general: %{correct}/%{total} (%{pct}%%)'
ranked_accuracy: ' Precisión clasificada: %{correct}/%{total} (%{pct}%%)'
confidence: 'Confianza: '
no_data: 'Sin datos aún'
no_data_short: 'Sin datos'
key_details: ' Detalles de Tecla '
key_details_char: ' Detalles de Tecla: ''%{ch}'' '
key_details_name: ' Detalles de Tecla: %{name} '
press_key_hint: 'Presiona una tecla para ver sus detalles'
shift_label: 'Shift: '
shift_no: 'No'
overall_avg_time: 'Tiempo Prom. General: '
overall_best_time: 'Mejor Tiempo General: '
overall_samples: 'Muestras Generales: '
overall_accuracy_label: 'Precisión General: '
branch_label: 'Rama: '
level_label: 'Nivel: '
built_in_key: 'Tecla Integrada'
unlocked_label: 'Desbloqueada: '
yes: 'Sí'
no: 'No'
in_focus_label: '¿En Foco?: '
mastery_label: 'Dominio: '
mastery_locked: 'Bloqueado'
ranked_avg_time: 'Tiempo Prom. Clasificado: '
ranked_best_time: 'Mejor Tiempo Clasificado: '
ranked_samples: 'Muestras Clasificadas: '
ranked_accuracy_label: 'Precisión Clasificada: '
# Diálogos de introducción
intro:
passage_title: ' Configurar Descarga de Pasajes '
code_title: ' Configurar Descarga de Código '
enable_downloads: 'Activar descargas de red'
download_dir: 'Directorio de descarga'
paragraphs_per_book: 'Párrafos por libro (0 = completo)'
whole_book: 'libro completo'
snippets_per_repo: 'Fragmentos por repo (0 = ilimitado)'
unlimited: 'ilimitado'
start_passage_drill: 'Iniciar ejercicio de pasaje'
start_code_drill: 'Iniciar ejercicio de código'
confirm: 'Confirmar'
hint_navigate: '[Up/Down] Navegar'
hint_adjust: '[Left/Right] Ajustar'
hint_edit: '[Type/Backspace] Editar'
hint_confirm: '[Enter] Confirmar'
hint_cancel: '[ESC] Cancelar'
preparing_download: 'Preparando descarga...'
download_passage_title: ' Descargando Fuente de Pasaje '
download_code_title: ' Descargando Fuente de Código '
book_label: ' Libro: %{name}'
repo_label: ' Repo: %{name}'
progress_bytes: '[%{name}] %{downloaded}/%{total} bytes'
downloaded_bytes: 'Descargado: %{bytes} bytes'
downloading_book_progress: 'Descargando libro actual: [%{bar}] %{downloaded}/%{total} bytes'
downloading_book_bytes: 'Descargando libro actual: %{bytes} bytes'
downloading_code_progress: 'Descargando: [%{bar}] %{downloaded}/%{total} bytes'
downloading_code_bytes: 'Descargando: %{bytes} bytes'
current_book: 'Actual: %{name} (libro %{done}/%{total})'
current_repo: 'Actual: %{name} (repo %{done}/%{total})'
passage_instructions_1: 'keydr puede descargar pasajes de Project Gutenberg para práctica de escritura.'
passage_instructions_2: 'Los libros se descargan una vez y se almacenan localmente.'
passage_instructions_3: 'Configura los ajustes de descarga abajo, luego inicia un ejercicio de pasaje.'
code_instructions_1: 'keydr puede descargar código abierto de GitHub para práctica de escritura.'
code_instructions_2: 'El código se descarga una vez y se almacena localmente.'
code_instructions_3: 'Configura los ajustes de descarga abajo, luego inicia un ejercicio de código.'
# Mensajes de estado (de app.rs)
status:
recovery_files: 'Se encontraron archivos de recuperación de una importación interrumpida. Los datos pueden ser inconsistentes — considera reimportar.'
dir_not_exist: 'El directorio no existe: %{path}'
no_data_store: 'No hay almacén de datos disponible'
serialization_error: 'Error de serialización: %{error}'
exported_to: 'Exportado a %{path}'
export_failed: 'Exportación fallida: %{error}'
could_not_read: 'No se pudo leer el archivo: %{error}'
invalid_export: 'Archivo de exportación inválido: %{error}'
unsupported_version: 'Versión de exportación no soportada: %{got} (se esperaba %{expected})'
import_failed: 'Importación fallida: %{error}'
imported_theme_fallback: 'Importado exitosamente (tema ''%{theme}'' no encontrado, usando predeterminado)'
imported_success: 'Importado exitosamente'
adaptive_unavailable: 'Modo adaptativo clasificado no disponible: %{error}'
switched_to: 'Cambiado a %{name}'
layout_changed: 'Distribución cambiada a %{name}'
# Errores (para traducción de límites de UI)
errors:
unknown_language: 'Idioma desconocido: %{key}'
unknown_layout: 'Distribución de teclado desconocida: %{key}'
unsupported_pair: 'Par idioma/distribución no soportado: %{language} + %{layout}'
language_blocked: 'Idioma bloqueado por nivel de soporte: %{key}'
# Común
common:
wpm: 'WPM'
cpm: 'CPM'
back: 'Volver'

454
locales/et.yml Normal file
View File

@@ -0,0 +1,454 @@
# Peamenüü
menu:
subtitle: 'Terminali trükkimise tuutor'
adaptive_drill: 'Kohanduv harjutus'
adaptive_drill_desc: 'Foneetilised sõnad kohanduva tähtede avamisega'
code_drill: 'Koodi harjutus'
code_drill_desc: 'Harjuta koodi süntaksi trükkimist'
passage_drill: 'Tekstiharjutus'
passage_drill_desc: 'Trüki lõike raamatutest'
skill_tree: 'Oskuste puu'
skill_tree_desc: 'Vaata edenemisharusid ja käivita harjutusi'
keyboard: 'Klaviatuur'
keyboard_desc: 'Uuri klahvipaigutust ja klahvistatistikat'
statistics: 'Statistika'
statistics_desc: 'Vaata oma trükkimisstatistikat'
settings: 'Seaded'
settings_desc: 'Seadista keydr'
day_streak: ' | %{days} päeva järjest'
key_progress: ' Klahvide edenemine %{unlocked}/%{total} (%{mastered} omandatud) | Siht %{target} WPM%{streak}'
hint_start: '[1-3] Alusta'
hint_skill_tree: '[t] Oskuste puu'
hint_keyboard: '[b] Klaviatuur'
hint_stats: '[s] Statistika'
hint_settings: '[c] Seaded'
hint_quit: '[q] Välju'
# Harjutuse kuva
drill:
title: ' Harjutus '
mode_adaptive: 'Kohanduv'
mode_code: 'Kood (hindamata)'
mode_passage: 'Tekst (hindamata)'
focus_char: 'Fookus: ''%{ch}'''
focus_bigram: 'Fookus: "%{bigram}"'
focus_both: 'Fookus: ''%{ch}'' + "%{bigram}"'
header_wpm: 'WPM'
header_acc: 'Täps'
header_err: 'Vead'
code_source: ' Koodi allikas '
passage_source: ' Teksti allikas '
footer: '[ESC] Lõpeta harjutus [Backspace] Kustuta'
keys_reenabled: 'Klahvid taas lubatud %{ms}ms pärast'
hint_end: '[ESC] Lõpeta harjutus'
hint_backspace: '[Backspace] Kustuta'
# Tulemuste paneel / harjutuse tulemus
dashboard:
title: ' Harjutus lõpetatud '
results: 'Tulemused'
unranked_note_prefix: ' (Hindamata'
unranked_note_suffix: ' ei lähe oskuste puu arvestusse)'
speed: ' Kiirus: '
accuracy_label: ' Täpsus: '
time_label: ' Aeg: '
errors_label: ' Vead: '
correct_detail: ' (%{correct}/%{total} õiget)'
input_blocked: ' Sisend ajutiselt blokeeritud '
input_blocked_ms: '(%{ms}ms jäänud)'
hint_continue: '[c/Enter/Space] Jätka'
hint_retry: '[r] Uuesti'
hint_menu: '[q] Menüü'
hint_stats: '[s] Statistika'
hint_delete: '[x] Kustuta'
# Statistika külgriba (harjutuse ajal)
sidebar:
title: ' Statistika '
wpm: 'WPM: '
target: 'Siht: '
target_wpm: '%{wpm} WPM'
accuracy: 'Täpsus: '
progress: 'Edenemine: '
correct: 'Õiged: '
errors: 'Vead: '
time: 'Aeg: '
last_drill: ' Viimane harjutus '
vs_avg: ' vs kesk: '
# Statistika paneel
stats:
title: ' Statistika '
empty: 'Ühtegi harjutust pole tehtud. Alusta trükkimist!'
tab_dashboard: '[1] Ülevaade'
tab_history: '[2] Ajalugu'
tab_activity: '[3] Aktiivsus'
tab_accuracy: '[4] Täpsus'
tab_timing: '[5] Ajastus'
tab_ngrams: '[6] N-grammid'
hint_back: '[ESC] Tagasi'
hint_next_tab: '[Tab] Järgmine vahekaart'
hint_switch_tab: '[1-6] Vaheta vahekaart'
hint_navigate: '[j/k] Navigeeri'
hint_page: '[PgUp/PgDn] Lehekülg'
hint_delete: '[x] Kustuta'
summary_title: ' Kokkuvõte '
drills: ' Harjutused: '
avg_wpm: ' Kesk WPM: '
best_wpm: ' Parim WPM: '
accuracy_label: ' Täpsus: '
total_time: ' Koguaeg: '
wpm_chart_title: ' WPM harjutuse kohta (viimased 20, siht: %{target}) '
accuracy_chart_title: ' Täpsus %% (viimased 50 harjutust) '
chart_drill: 'Harjutus #'
chart_accuracy_pct: 'Täpsus %%'
sessions_title: ' Hiljutised seansid '
session_header: ' # WPM Toores Täps%% Aeg Kuupäev/Aeg Režiim Hind Osaline'
session_separator: ' ─────────────────────────────────────────────────────────────────────'
delete_confirm: 'Kustuta seanss #%{idx}? (y/n)'
confirm_title: ' Kinnita '
yes: 'jah'
no: 'ei'
keyboard_accuracy_title: ' Klaviatuuri täpsus %% '
keyboard_timing_title: ' Klaviatuuri ajastus (ms) '
slowest_keys_title: ' Aeglaseimad klahvid (ms) '
fastest_keys_title: ' Kiireimad klahvid (ms) '
worst_accuracy_title: ' Halvim täpsus (%%) '
best_accuracy_title: ' Parim täpsus (%%) '
not_enough_data: ' Pole piisavalt andmeid'
streaks_title: ' Seeriad '
current_streak: ' Praegune: '
best_streak: ' Parim: '
active_days: ' Aktiivsed päevad: '
top_days_none: ' Parimad päevad: puudub'
top_days: ' Parimad päevad: %{days}'
wpm_label: ' WPM: %{avg}/%{target} (%{pct}%%)'
acc_label: ' Täps: %{pct}%%'
keys_label: ' Klahvid: %{unlocked}/%{total} (%{mastered} omandatud)'
ngram_empty: 'Tee mõned kohanduvad harjutused n-grammi andmete nägemiseks'
ngram_header_speed_narrow: ' Bgrm Kiir Ooda Anom%'
ngram_header_error_narrow: ' Bgrm Vead Prv Määr Ooda Anom%'
ngram_header_speed: ' Bigramm Kiirus Oodatav Proovid Anom%'
ngram_header_error: ' Bigramm Vead Proovid Määr Oodatav Anom%'
focus_title: ' Aktiivne fookus '
focus_char_label: ' Fookus: '
focus_bigram_value: 'Bigramm %{label}'
focus_plus: ' + '
anomaly_error: 'viga'
anomaly_speed: 'kiirus'
focus_detail_both: ' Märk ''%{ch}'': nõrgim klahv | Bigramm %{label}: %{type} anomaalia %{pct}%%'
focus_detail_char_only: ' Märk ''%{ch}'': nõrgim klahv, kinnitatud bigrammi anomaaliaid pole'
focus_detail_bigram_only: ' (%{type} anomaalia: %{pct}%%)'
focus_empty: ' Tee mõned kohanduvad harjutused fookuse andmete nägemiseks'
error_anomalies_title: ' Vigade anomaaliad (%{count}) '
no_error_anomalies: ' Vigade anomaaliaid ei tuvastatud'
speed_anomalies_title: ' Kiiruse anomaaliad (%{count}) '
no_speed_anomalies: ' Kiiruse anomaaliaid ei tuvastatud'
scope_label_prefix: ' '
bi_label: ' | Bi: %{count}'
hes_label: ' | Kõhk: >%{ms}ms'
focus_char_value: 'Märk ''%{ch}'''
# Aktiivsuse soojuskaart
heatmap:
title: ' Igapäevane aktiivsus (seansid päevas) '
jan: 'Jaan'
feb: 'Veebr'
mar: 'Märts'
apr: 'Apr'
may: 'Mai'
jun: 'Juuni'
jul: 'Juuli'
aug: 'Aug'
sep: 'Sept'
oct: 'Okt'
nov: 'Nov'
dec: 'Dets'
# Diagramm
chart:
wpm_over_time: ' WPM aja jooksul '
drill_number: 'Harjutus #'
# Seaded
settings:
title: ' Seaded '
subtitle: 'Navigeerimiseks kasuta nooli, Enter/Paremale muutmiseks, ESC salvestamiseks'
target_wpm: 'Siht-WPM'
theme: 'Teema'
word_count: 'Sõnade arv'
ui_language: 'Liidese keel'
dictionary_language: 'Sõnastiku keel'
keyboard_layout: 'Klahvipaigutus'
code_language: 'Programmeerimiskeel'
code_downloads: 'Koodi allalaadimised'
on: 'Sees'
off: 'Väljas'
code_download_dir: 'Koodi allalaadimiskaust'
snippets_per_repo: 'Katkendeid repo kohta'
unlimited: 'Piiramatu'
download_code_now: 'Laadi kood alla kohe'
run_downloader: 'Käivita allalaadimine'
passage_downloads: 'Teksti allalaadimised'
passage_download_dir: 'Teksti allalaadimiskaust'
paragraphs_per_book: 'Lõike raamatu kohta'
whole_book: 'Terve raamat'
download_passages_now: 'Laadi tekstid alla kohe'
export_path: 'Ekspordi tee'
export_data: 'Ekspordi andmed'
export_now: 'Ekspordi kohe'
import_path: 'Impordi tee'
import_data: 'Impordi andmed'
import_now: 'Impordi kohe'
hint_save_back: '[ESC] Salvesta ja tagasi'
hint_change_value: '[Enter/nooled] Muuda väärtust'
hint_edit_path: '[Enter teel] Muuda'
hint_move: '[←→] Liigu'
hint_tab_complete: '[Tab] Täienda (lõpus)'
hint_confirm: '[Enter] Kinnita'
hint_cancel: '[Esc] Tühista'
success_title: ' Õnnestus '
error_title: ' Viga '
press_any_key: 'Vajuta suvalist klahvi'
file_exists_title: ' Fail on olemas '
file_exists: 'Sellel teel on juba fail olemas.'
overwrite_rename: '[d] Kirjuta üle [r] Nimeta ümber [Esc] Tühista'
erase_warning: 'See kustutab teie praegused andmed.'
export_first: 'Eksportige esmalt, kui soovite neid säilitada.'
proceed_yn: 'Jätkata? (y/n)'
confirm_import_title: ' Kinnita import '
# Valikukuvad
select:
dictionary_language_title: ' Vali sõnastiku keel '
keyboard_layout_title: ' Vali klahvipaigutus '
code_language_title: ' Vali programmeerimiskeel '
passage_source_title: ' Vali teksti allikas '
ui_language_title: ' Vali liidese keel '
more_above: '... veel %{count} üleval ...'
more_below: '... veel %{count} all ...'
current: ' (praegune)'
disabled: ' (keelatud)'
enabled_default: ' (lubatud, vaikimisi: %{layout})'
enabled: ' (lubatud)'
disabled_blocked: ' (keelatud: blokeeritud)'
built_in: ' (sisseehitatud)'
cached: ' (puhverdatud)'
disabled_download: ' (keelatud: allalaadimine vajalik)'
download_required: ' (allalaadimine vajalik)'
hint_navigate: '[Üles/Alla/PgUp/PgDn] Navigeeri'
hint_confirm: '[Enter] Kinnita'
hint_back: '[ESC] Tagasi'
language_resets_layout: 'Keele valimine lähtestab klahvipaigutuse selle keele vaikimisi paigutusele.'
layout_no_language_change: 'Paigutuse muutmine ei muuda sõnastiku keelt.'
disabled_network_notice: 'Mõned keeled on keelatud: lubage võrgu allalaadimised sissejuhatuses/seadetes.'
disabled_sources_notice: 'Mõned allikad on keelatud: lubage võrgu allalaadimised sissejuhatuses/seadetes.'
passage_all: 'Kõik (sisseehitatud + kõik raamatud)'
passage_builtin: 'Ainult sisseehitatud tekstid'
passage_book_prefix: 'Raamat: %{title}'
# Edenemine
progress:
overall_key_progress: 'Üldine klahvide edenemine'
unlocked_mastered: '%{unlocked}/%{total} avatud (%{mastered} omandatud)'
# Oskuste puu
skill_tree:
title: ' Oskuste puu '
locked: 'Lukus'
unlocked: 'avatud'
mastered: 'omandatud'
in_progress: 'pooleli'
complete: 'lõpetatud'
locked_status: 'lukus'
locked_notice: 'Lõpeta %{count} põhitähte harude avamiseks'
branches_separator: 'Harud (saadaval pärast %{count} põhitähte)'
unlocked_letters: 'Avatud %{unlocked}/%{total} tähte'
level: 'Tase %{current}/%{total}'
level_zero: 'Tase 0/%{total}'
in_focus: ' fookuses'
hint_navigate: '[↑↓/jk] Navigeeri'
hint_scroll: '[PgUp/PgDn või Ctrl+U/Ctrl+D] Keri'
hint_back: '[q] Tagasi'
hint_unlock: '[Enter] Ava'
hint_start_drill: '[Enter] Alusta harjutust'
unlock_msg_1: 'Pärast avamist segab vaikimisi kohanduv harjutus selle haru avatud klahve.'
unlock_msg_2: 'Kui soovite keskenduda ainult sellele harule, käivitage harjutus otse oskuste puust.'
confirm_unlock: 'Avada %{branch}?'
confirm_yn: '[y] Ava [n/ESC] Tühista'
lvl_prefix: 'Tase'
branch_primary_letters: 'Põhitähed'
branch_capital_letters: 'Suurtähed'
branch_numbers: 'Numbrid 0-9'
branch_prose_punctuation: 'Kirjavahemärgid'
branch_whitespace: 'Tühimärgid'
branch_code_symbols: 'Koodi sümbolid'
level_frequency_order: 'Sageduse järjekord'
level_common_sentence_capitals: 'Tavalised lause suurtähed'
level_name_capitals: 'Nimede suurtähed'
level_remaining_capitals: 'Ülejäänud suurtähed'
level_common_digits: 'Tavalised numbrid'
level_all_digits: 'Kõik numbrid'
level_essential: 'Hädavajalik'
level_common: 'Tavaline'
level_expressive: 'Väljenduslik'
level_enter_return: 'Enter/Return'
level_tab_indent: 'Tab/Taane'
level_arithmetic_assignment: 'Aritmeetika ja omistamine'
level_grouping: 'Rühmitamine'
level_logic_reference: 'Loogika ja viitamine'
level_special: 'Eriline'
# Verstapostid
milestones:
unlock_title: ' Klahv avatud! '
mastery_title: ' Klahv omandatud! '
branches_title: ' Uued oskuste harud saadaval! '
branch_complete_title: ' Haru lõpetatud! '
all_unlocked_title: ' Kõik klahvid avatud! '
all_mastered_title: ' Täielik klaviatuuri valdamine! '
unlocked: 'avatud'
mastered: 'omandatud'
use_finger: 'Kasuta oma %{finger}'
hold_right_shift: 'Hoia paremat Shifti (parem väike sõrm)'
hold_left_shift: 'Hoia vasakut Shifti (vasak väike sõrm)'
congratulations_all_letters: 'Palju õnne! Olete omandanud kõik %{count} põhitähte'
new_branches_available: 'Uued oskuste harud on nüüd saadaval:'
visit_skill_tree: 'Külastage oskuste puud uue haru avamiseks'
and_start_training: 'ja alustage treenimist!'
open_skill_tree: 'Vajutage [t] oskuste puu avamiseks'
branch_complete_msg: 'Olete lõpetanud haru %{branch}!'
all_levels_mastered: 'Kõik %{count} taset omandatud.'
all_keys_confident: 'Iga klahv selles harus on täielikul tasemel.'
all_unlocked_msg: 'Olete avanud kõik klahvid klaviatuuril!'
all_unlocked_desc: 'Iga märk, sümbol ja muuteklahv on nüüd harjutustes saadaval.'
keep_practicing_mastery: 'Jätkake harjutamist valdamise saavutamiseks — kui iga klahv jõuab täieliku'
confidence_complete: 'kindluseni, olete saavutanud täieliku klaviatuuri valdamise!'
all_mastered_msg: 'Palju õnne — olete saavutanud täieliku klaviatuuri valdamise!'
all_mastered_desc: 'Iga klahv klaviatuuril on maksimaalsel tasemel.'
mastery_takes_practice: 'Valdamine pole sihtkoht — see nõuab pidevat harjutamist.'
keep_drilling: 'Jätkake harjutamist oma taseme hoidmiseks.'
hint_skill_tree_continue: '[t] Ava oskuste puu [Suvaline klahv] Jätka'
hint_any_key: 'Vajuta suvalist klahvi jätkamiseks'
input_blocked: 'Sisend ajutiselt blokeeritud (%{ms}ms jäänud)'
unlock_msg_1: 'Tubli! Jätkake oma trükkimisoskuste arendamist.'
unlock_msg_2: 'Veel üks klahv teie arsenali!'
unlock_msg_3: 'Teie klaviatuur kasvab! Jätkake!'
unlock_msg_4: 'Samm lähemale täielikule klaviatuuri valdamisele!'
mastery_msg_1: 'See klahv on nüüd täielikul tasemel!'
mastery_msg_2: 'See klahv on teil selge!'
mastery_msg_3: 'Lihasmälu lukustatud!'
mastery_msg_4: 'Veel üks klahv vallutatud!'
# Klaviatuuri uurija
keyboard:
title: ' Klaviatuur '
subtitle: 'Vajuta suvalist klahvi või klõpsa klahvi'
hint_navigate: '[←→↑↓/hjkl/Tab] Navigeeri'
hint_back: '[q/ESC] Tagasi'
key_label: 'Klahv: '
finger_label: 'Sõrm: '
hand_left: 'Vasak'
hand_right: 'Parem'
finger_index: 'Nimetissõrm'
finger_middle: 'Keskmine sõrm'
finger_ring: 'Nimesõrm'
finger_pinky: 'Väike sõrm'
finger_thumb: 'Pöial'
overall_accuracy: ' Üldine täpsus: %{correct}/%{total} (%{pct}%%)'
ranked_accuracy: ' Hinnatud täpsus: %{correct}/%{total} (%{pct}%%)'
confidence: 'Kindlus: '
no_data: 'Andmed puuduvad'
no_data_short: 'Andmeid pole'
key_details: ' Klahvi üksikasjad '
key_details_char: ' Klahvi üksikasjad: ''%{ch}'' '
key_details_name: ' Klahvi üksikasjad: %{name} '
press_key_hint: 'Vajuta klahvi üksikasjade nägemiseks'
shift_label: 'Shift: '
shift_no: 'Ei'
overall_avg_time: 'Üldine kesk. aeg: '
overall_best_time: 'Üldine parim aeg: '
overall_samples: 'Üldised proovid: '
overall_accuracy_label: 'Üldine täpsus: '
branch_label: 'Haru: '
level_label: 'Tase: '
built_in_key: 'Sisseehitatud klahv'
unlocked_label: 'Avatud: '
yes: 'Jah'
no: 'Ei'
in_focus_label: 'Fookuses?: '
mastery_label: 'Valdamine: '
mastery_locked: 'Lukus'
ranked_avg_time: 'Hinnatud kesk. aeg: '
ranked_best_time: 'Hinnatud parim aeg: '
ranked_samples: 'Hinnatud proovid: '
ranked_accuracy_label: 'Hinnatud täpsus: '
# Sissejuhatuse dialoogid
intro:
passage_title: ' Teksti allalaadimise seadistus '
code_title: ' Koodi allalaadimise seadistus '
enable_downloads: 'Luba võrgu allalaadimised'
download_dir: 'Allalaadimiskaust'
paragraphs_per_book: 'Lõike raamatu kohta (0 = terve)'
whole_book: 'terve raamat'
snippets_per_repo: 'Katkendeid repo kohta (0 = piiramatu)'
unlimited: 'piiramatu'
start_passage_drill: 'Alusta tekstiharjutust'
start_code_drill: 'Alusta koodi harjutust'
confirm: 'Kinnita'
hint_navigate: '[Üles/Alla] Navigeeri'
hint_adjust: '[Vasakule/Paremale] Kohanda'
hint_edit: '[Trüki/Backspace] Muuda'
hint_confirm: '[Enter] Kinnita'
hint_cancel: '[ESC] Tühista'
preparing_download: 'Valmistan allalaadimist ette...'
download_passage_title: ' Teksti allika allalaadimine '
download_code_title: ' Koodi allika allalaadimine '
book_label: ' Raamat: %{name}'
repo_label: ' Repo: %{name}'
progress_bytes: '[%{name}] %{downloaded}/%{total} baiti'
downloaded_bytes: 'Alla laaditud: %{bytes} baiti'
downloading_book_progress: 'Laadin raamatut: [%{bar}] %{downloaded}/%{total} baiti'
downloading_book_bytes: 'Laadin raamatut: %{bytes} baiti'
downloading_code_progress: 'Laadin alla: [%{bar}] %{downloaded}/%{total} baiti'
downloading_code_bytes: 'Laadin alla: %{bytes} baiti'
current_book: 'Praegune: %{name} (raamat %{done}/%{total})'
current_repo: 'Praegune: %{name} (repo %{done}/%{total})'
passage_instructions_1: 'keydr saab alla laadida lõike Project Gutenbergist trükkimisharjutuseks.'
passage_instructions_2: 'Raamatud laaditakse alla üks kord ja salvestatakse kohalikult.'
passage_instructions_3: 'Seadistage allalaadimised allpool, seejärel alustage tekstiharjutust.'
code_instructions_1: 'keydr saab alla laadida avatud lähtekoodiga koodi GitHubist trükkimisharjutuseks.'
code_instructions_2: 'Kood laaditakse alla üks kord ja salvestatakse kohalikult.'
code_instructions_3: 'Seadistage allalaadimised allpool, seejärel alustage koodi harjutust.'
# Olekuteated (failist app.rs)
status:
recovery_files: 'Leitud taastefailid katkestatud impordist. Andmed võivad olla ebajärjekindlad — kaaluge uuesti importimist.'
dir_not_exist: 'Kausta ei eksisteeri: %{path}'
no_data_store: 'Andmehoidla pole saadaval'
serialization_error: 'Serialiseerimisviga: %{error}'
exported_to: 'Eksporditud asukohta %{path}'
export_failed: 'Eksport ebaõnnestus: %{error}'
could_not_read: 'Faili ei saanud lugeda: %{error}'
invalid_export: 'Vigane ekspordifail: %{error}'
unsupported_version: 'Toetamata ekspordi versioon: %{got} (oodatud %{expected})'
import_failed: 'Import ebaõnnestus: %{error}'
imported_theme_fallback: 'Imporditud edukalt (teemat ''%{theme}'' ei leitud, kasutan vaikimisi)'
imported_success: 'Imporditud edukalt'
adaptive_unavailable: 'Kohanduv hinnatud režiim pole saadaval: %{error}'
switched_to: 'Lülitatud režiimile %{name}'
layout_changed: 'Paigutus muudetud: %{name}'
# Vead (liidese piiri tõlke jaoks)
errors:
unknown_language: 'Tundmatu keel: %{key}'
unknown_layout: 'Tundmatu klahvipaigutus: %{key}'
unsupported_pair: 'Toetamata keele/paigutuse paar: %{language} + %{layout}'
language_blocked: 'Keel on blokeeritud toe taseme tõttu: %{key}'
# Üldine
common:
wpm: 'WPM'
cpm: 'CPM'
back: 'Tagasi'

454
locales/fi.yml Normal file
View File

@@ -0,0 +1,454 @@
# Main menu
menu:
subtitle: 'Terminaalin kirjoitusharjoittelija'
adaptive_drill: 'Mukautuva harjoitus'
adaptive_drill_desc: 'Foneettiset sanat mukautuvalla kirjainten avauksella'
code_drill: 'Koodiharjoitus'
code_drill_desc: 'Harjoittele koodisyntaksin kirjoittamista'
passage_drill: 'Tekstiharjoitus'
passage_drill_desc: 'Kirjoita katkelmia kirjoista'
skill_tree: 'Taitopuu'
skill_tree_desc: 'Tarkastele etenemispolkuja ja aloita harjoituksia'
keyboard: 'Näppäimistö'
keyboard_desc: 'Tutustu näppäinasetteluun ja tilastoihin'
statistics: 'Tilastot'
statistics_desc: 'Tarkastele kirjoitustilastojasi'
settings: 'Asetukset'
settings_desc: 'Määritä keydr-asetukset'
day_streak: ' | %{days} päivän putki'
key_progress: ' Näppäinedistyminen %{unlocked}/%{total} (%{mastered} hallittu) | Tavoite %{target} WPM%{streak}'
hint_start: '[1-3] Aloita'
hint_skill_tree: '[t] Taitopuu'
hint_keyboard: '[b] Näppäimistö'
hint_stats: '[s] Tilastot'
hint_settings: '[c] Asetukset'
hint_quit: '[q] Lopeta'
# Drill screen
drill:
title: ' Harjoitus '
mode_adaptive: 'Mukautuva'
mode_code: 'Koodi (ei sijoitettu)'
mode_passage: 'Teksti (ei sijoitettu)'
focus_char: 'Fokus: ''%{ch}'''
focus_bigram: 'Fokus: "%{bigram}"'
focus_both: 'Fokus: ''%{ch}'' + "%{bigram}"'
header_wpm: 'WPM'
header_acc: 'Tark'
header_err: 'Virh'
code_source: ' Koodilähde '
passage_source: ' Tekstilähde '
footer: '[ESC] Lopeta harjoitus [Backspace] Poista'
keys_reenabled: 'Näppäimet palautettu %{ms}ms:ssa'
hint_end: '[ESC] Lopeta harjoitus'
hint_backspace: '[Backspace] Poista'
# Dashboard / drill result
dashboard:
title: ' Harjoitus valmis '
results: 'Tulokset'
unranked_note_prefix: ' (Ei sijoitettu'
unranked_note_suffix: ' ei lasketa taitopuuhun)'
speed: ' Nopeus: '
accuracy_label: ' Tarkkuus: '
time_label: ' Aika: '
errors_label: ' Virheet: '
correct_detail: ' (%{correct}/%{total} oikein)'
input_blocked: ' Syöte estetty väliaikaisesti '
input_blocked_ms: '(%{ms}ms jäljellä)'
hint_continue: '[c/Enter/Space] Jatka'
hint_retry: '[r] Uudelleen'
hint_menu: '[q] Valikko'
hint_stats: '[s] Tilastot'
hint_delete: '[x] Poista'
# Stats sidebar (during drill)
sidebar:
title: ' Tilastot '
wpm: 'WPM: '
target: 'Tavoite: '
target_wpm: '%{wpm} WPM'
accuracy: 'Tarkkuus: '
progress: 'Edistyminen: '
correct: 'Oikein: '
errors: 'Virheet: '
time: 'Aika: '
last_drill: ' Edellinen harjoitus '
vs_avg: ' vs ka: '
# Statistics dashboard
stats:
title: ' Tilastot '
empty: 'Ei harjoituksia vielä. Aloita kirjoittaminen!'
tab_dashboard: '[1] Yhteenveto'
tab_history: '[2] Historia'
tab_activity: '[3] Aktiivisuus'
tab_accuracy: '[4] Tarkkuus'
tab_timing: '[5] Ajoitus'
tab_ngrams: '[6] N-grammit'
hint_back: '[ESC] Takaisin'
hint_next_tab: '[Tab] Seuraava välilehti'
hint_switch_tab: '[1-6] Vaihda välilehteä'
hint_navigate: '[j/k] Navigoi'
hint_page: '[PgUp/PgDn] Sivu'
hint_delete: '[x] Poista'
summary_title: ' Yhteenveto '
drills: ' Harjoitukset: '
avg_wpm: ' Ka WPM: '
best_wpm: ' Paras WPM: '
accuracy_label: ' Tarkkuus: '
total_time: ' Kokonaisaika: '
wpm_chart_title: ' WPM per harjoitus (viimeiset 20, tavoite: %{target}) '
accuracy_chart_title: ' Tarkkuus %% (viimeiset 50 harjoitusta) '
chart_drill: 'Harj #'
chart_accuracy_pct: 'Tarkkuus %%'
sessions_title: ' Viimeisimmät istunnot '
session_header: ' # WPM Raw Tark%% Aika Pvm/Aika Tila Sijoitettu Ositt.'
session_separator: ' ─────────────────────────────────────────────────────────────────────'
delete_confirm: 'Poista istunto #%{idx}? (k/e)'
confirm_title: ' Vahvista '
yes: 'kyllä'
no: 'ei'
keyboard_accuracy_title: ' Näppäimistön tarkkuus %% '
keyboard_timing_title: ' Näppäimistön ajoitus (ms) '
slowest_keys_title: ' Hitaimmat näppäimet (ms) '
fastest_keys_title: ' Nopeimmat näppäimet (ms) '
worst_accuracy_title: ' Heikoin tarkkuus (%%) '
best_accuracy_title: ' Paras tarkkuus (%%) '
not_enough_data: ' Ei tarpeeksi dataa'
streaks_title: ' Putket '
current_streak: ' Nykyinen: '
best_streak: ' Paras: '
active_days: ' Aktiiviset päivät: '
top_days_none: ' Parhaat päivät: ei yhtään'
top_days: ' Parhaat päivät: %{days}'
wpm_label: ' WPM: %{avg}/%{target} (%{pct}%%)'
acc_label: ' Tark: %{pct}%%'
keys_label: ' Näppäimet: %{unlocked}/%{total} (%{mastered} hallittu)'
ngram_empty: 'Suorita mukautuvia harjoituksia nähdäksesi n-grammidataa'
ngram_header_speed_narrow: ' Bgrm Nop Odot Poikk%'
ngram_header_error_narrow: ' Bgrm Virh Näyt Tih Odot Poikk%'
ngram_header_speed: ' Bigrammi Nopeus Odotettu Näytteet Poikk%'
ngram_header_error: ' Bigrammi Virheet Näytteet Tiheys Odotettu Poikk%'
focus_title: ' Aktiivinen fokus '
focus_char_label: ' Fokus: '
focus_bigram_value: 'Bigrammi %{label}'
focus_plus: ' + '
anomaly_error: 'virhe'
anomaly_speed: 'nopeus'
focus_detail_both: ' Merkki ''%{ch}'': heikoin näppäin | Bigrammi %{label}: %{type}-poikkeama %{pct}%%'
focus_detail_char_only: ' Merkki ''%{ch}'': heikoin näppäin, ei vahvistettuja bigrammipoikkeamia'
focus_detail_bigram_only: ' (%{type}-poikkeama: %{pct}%%)'
focus_empty: ' Suorita mukautuvia harjoituksia nähdäksesi fokusdataa'
error_anomalies_title: ' Virhepoikkeamat (%{count}) '
no_error_anomalies: ' Virhepoikkeamia ei havaittu'
speed_anomalies_title: ' Nopeuspoikkeamat (%{count}) '
no_speed_anomalies: ' Nopeuspoikkeamia ei havaittu'
scope_label_prefix: ' '
bi_label: ' | Bi: %{count}'
hes_label: ' | Epär: >%{ms}ms'
focus_char_value: 'Merkki ''%{ch}'''
# Activity heatmap
heatmap:
title: ' Päivittäinen aktiivisuus (istunnot per päivä) '
jan: 'Tam'
feb: 'Hel'
mar: 'Maa'
apr: 'Huh'
may: 'Tou'
jun: 'Kes'
jul: 'Hei'
aug: 'Elo'
sep: 'Syy'
oct: 'Lok'
nov: 'Mar'
dec: 'Jou'
# Chart
chart:
wpm_over_time: ' WPM ajan kuluessa '
drill_number: 'Harj #'
# Settings
settings:
title: ' Asetukset '
subtitle: 'Käytä nuolia navigointiin, Enter/oikea muuttaa, ESC tallentaa ja poistuu'
target_wpm: 'Tavoite WPM'
theme: 'Teema'
word_count: 'Sanamäärä'
ui_language: 'Käyttöliittymän kieli'
dictionary_language: 'Sanakirjan kieli'
keyboard_layout: 'Näppäinasettelu'
code_language: 'Ohjelmointikieli'
code_downloads: 'Koodilataukset'
on: 'Päällä'
off: 'Pois'
code_download_dir: 'Koodilatauskansio'
snippets_per_repo: 'Katkelmat per repo'
unlimited: 'Rajaton'
download_code_now: 'Lataa koodi nyt'
run_downloader: 'Käynnistä lataaja'
passage_downloads: 'Tekstilataukset'
passage_download_dir: 'Tekstilatauskansio'
paragraphs_per_book: 'Kappaleet per kirja'
whole_book: 'Koko kirja'
download_passages_now: 'Lataa tekstit nyt'
export_path: 'Vientipolku'
export_data: 'Vie data'
export_now: 'Vie nyt'
import_path: 'Tuontipolku'
import_data: 'Tuo data'
import_now: 'Tuo nyt'
hint_save_back: '[ESC] Tallenna ja takaisin'
hint_change_value: '[Enter/nuolet] Muuta arvoa'
hint_edit_path: '[Enter polulla] Muokkaa'
hint_move: '[←→] Siirrä'
hint_tab_complete: '[Tab] Täydennä (lopussa)'
hint_confirm: '[Enter] Vahvista'
hint_cancel: '[Esc] Peruuta'
success_title: ' Onnistui '
error_title: ' Virhe '
press_any_key: 'Paina mitä tahansa näppäintä'
file_exists_title: ' Tiedosto on olemassa '
file_exists: 'Tiedosto on jo olemassa tässä polussa.'
overwrite_rename: '[d] Korvaa [r] Nimeä uudelleen [Esc] Peruuta'
erase_warning: 'Tämä poistaa nykyisen datasi.'
export_first: 'Vie data ensin, jos haluat säilyttää sen.'
proceed_yn: 'Jatketaanko? (k/e)'
confirm_import_title: ' Vahvista tuonti '
# Selection screens
select:
dictionary_language_title: ' Valitse sanakirjan kieli '
keyboard_layout_title: ' Valitse näppäinasettelu '
code_language_title: ' Valitse ohjelmointikieli '
passage_source_title: ' Valitse tekstilähde '
ui_language_title: ' Valitse käyttöliittymän kieli '
more_above: '... %{count} lisää ylhäällä ...'
more_below: '... %{count} lisää alhaalla ...'
current: ' (nykyinen)'
disabled: ' (pois käytöstä)'
enabled_default: ' (käytössä, oletus: %{layout})'
enabled: ' (käytössä)'
disabled_blocked: ' (pois käytöstä: estetty)'
built_in: ' (sisäänrakennettu)'
cached: ' (välimuistissa)'
disabled_download: ' (pois käytöstä: lataus vaaditaan)'
download_required: ' (lataus vaaditaan)'
hint_navigate: '[Ylös/Alas/PgUp/PgDn] Navigoi'
hint_confirm: '[Enter] Vahvista'
hint_back: '[ESC] Takaisin'
language_resets_layout: 'Kielen valinta palauttaa näppäinasettelun kielen oletukseen.'
layout_no_language_change: 'Asettelun muutos ei vaihda sanakirjan kieltä.'
disabled_network_notice: 'Jotkin kielet ovat pois käytöstä: ota verkkolataukset käyttöön asetuksissa.'
disabled_sources_notice: 'Jotkin lähteet ovat pois käytöstä: ota verkkolataukset käyttöön asetuksissa.'
passage_all: 'Kaikki (sisäänrakennetut + kaikki kirjat)'
passage_builtin: 'Vain sisäänrakennetut tekstit'
passage_book_prefix: 'Kirja: %{title}'
# Progress
progress:
overall_key_progress: 'Yleinen näppäinedistyminen'
unlocked_mastered: '%{unlocked}/%{total} avattu (%{mastered} hallittu)'
# Skill tree
skill_tree:
title: ' Taitopuu '
locked: 'Lukittu'
unlocked: 'avattu'
mastered: 'hallittu'
in_progress: 'käynnissä'
complete: 'valmis'
locked_status: 'lukittu'
locked_notice: 'Suorita %{count} peruskirjainta avataksesi haarat'
branches_separator: 'Haarat (käytettävissä %{count} peruskirjaimen jälkeen)'
unlocked_letters: 'Avattu %{unlocked}/%{total} kirjainta'
level: 'Taso %{current}/%{total}'
level_zero: 'Taso 0/%{total}'
in_focus: ' fokuksessa'
hint_navigate: '[↑↓/jk] Navigoi'
hint_scroll: '[PgUp/PgDn tai Ctrl+U/Ctrl+D] Vieritä'
hint_back: '[q] Takaisin'
hint_unlock: '[Enter] Avaa'
hint_start_drill: '[Enter] Aloita harjoitus'
unlock_msg_1: 'Avaamisen jälkeen mukautuva oletusharjoitus sisällyttää tämän haaran avattuja näppäimiä.'
unlock_msg_2: 'Jos haluat keskittyä vain tähän haaraan, aloita harjoitus suoraan taitopuusta.'
confirm_unlock: 'Avaa %{branch}?'
confirm_yn: '[y] Avaa [n/ESC] Peruuta'
lvl_prefix: 'Taso'
branch_primary_letters: 'Peruskirjaimet'
branch_capital_letters: 'Isot kirjaimet'
branch_numbers: 'Numerot 0-9'
branch_prose_punctuation: 'Välimerkit'
branch_whitespace: 'Tyhjämerkit'
branch_code_symbols: 'Koodisymbolit'
level_frequency_order: 'Yleisyysjärjestys'
level_common_sentence_capitals: 'Yleiset lauseen isot kirjaimet'
level_name_capitals: 'Nimien isot kirjaimet'
level_remaining_capitals: 'Loput isot kirjaimet'
level_common_digits: 'Yleiset numerot'
level_all_digits: 'Kaikki numerot'
level_essential: 'Välttämättömät'
level_common: 'Yleiset'
level_expressive: 'Ilmaisevat'
level_enter_return: 'Enter/Return'
level_tab_indent: 'Tab/sisennys'
level_arithmetic_assignment: 'Laskutoimitukset ja sijoitus'
level_grouping: 'Ryhmittely'
level_logic_reference: 'Logiikka ja viittaus'
level_special: 'Erikoismerkit'
# Milestones
milestones:
unlock_title: ' Näppäin avattu! '
mastery_title: ' Näppäin hallittu! '
branches_title: ' Uusia taitohaarat käytettävissä! '
branch_complete_title: ' Haara valmis! '
all_unlocked_title: ' Kaikki näppäimet avattu! '
all_mastered_title: ' Täysi näppäimistöhallinta! '
unlocked: 'avattu'
mastered: 'hallittu'
use_finger: 'Käytä %{finger}asi'
hold_right_shift: 'Pidä oikeaa Shiftiä (oikea pikkusormi)'
hold_left_shift: 'Pidä vasenta Shiftiä (vasen pikkusormi)'
congratulations_all_letters: 'Onnittelut! Olet hallinnut kaikki %{count} peruskirjainta'
new_branches_available: 'Uusia taitohaaroja on nyt käytettävissä:'
visit_skill_tree: 'Käy taitopuussa avataksesi uuden haaran'
and_start_training: 'ja aloita harjoittelu!'
open_skill_tree: 'Paina [t] avataksesi taitopuun nyt'
branch_complete_msg: 'Olet suorittanut haaran %{branch}!'
all_levels_mastered: 'Kaikki %{count} tasoa hallittu.'
all_keys_confident: 'Jokainen näppäin tässä haarassa on täydellä varmuudella.'
all_unlocked_msg: 'Olet avannut jokaisen näppäimen näppäimistöllä!'
all_unlocked_desc: 'Jokainen merkki, symboli ja muokkain on nyt käytettävissä harjoituksissasi.'
keep_practicing_mastery: 'Jatka harjoittelua hallinnan rakentamiseksi — kun jokainen näppäin saavuttaa täyden'
confidence_complete: 'varmuuden, olet saavuttanut täydellisen näppäimistöhallinnan!'
all_mastered_msg: 'Onnittelut — olet saavuttanut täyden näppäimistöhallinnan!'
all_mastered_desc: 'Jokainen näppäin näppäimistöllä on maksimivarmuudella.'
mastery_takes_practice: 'Hallinta ei ole päämäärä — se vaatii jatkuvaa harjoittelua.'
keep_drilling: 'Jatka harjoittelua säilyttääksesi taitosi.'
hint_skill_tree_continue: '[t] Avaa taitopuu [Muu näppäin] Jatka'
hint_any_key: 'Paina mitä tahansa näppäintä jatkaaksesi'
input_blocked: 'Syöte estetty väliaikaisesti (%{ms}ms jäljellä)'
unlock_msg_1: 'Hienoa! Jatka kirjoitustaitojesi kehittämistä.'
unlock_msg_2: 'Taas yksi näppäin arsenaalissasi!'
unlock_msg_3: 'Näppäimistösi kasvaa! Jatka samaan malliin.'
unlock_msg_4: 'Askel lähempänä täyttä näppäimistöhallintaa!'
mastery_msg_1: 'Tämä näppäin on nyt täydellä varmuudella!'
mastery_msg_2: 'Tämä näppäin on hallussa!'
mastery_msg_3: 'Lihasmuisti lukittuna!'
mastery_msg_4: 'Taas yksi näppäin valloitettu!'
# Keyboard explorer
keyboard:
title: ' Näppäimistö '
subtitle: 'Paina mitä tahansa näppäintä tai klikkaa näppäintä'
hint_navigate: '[←→↑↓/hjkl/Tab] Navigoi'
hint_back: '[q/ESC] Takaisin'
key_label: 'Näppäin: '
finger_label: 'Sormi: '
hand_left: 'Vasen'
hand_right: 'Oikea'
finger_index: 'Etusormi'
finger_middle: 'Keskisormi'
finger_ring: 'Nimetön'
finger_pinky: 'Pikkusormi'
finger_thumb: 'Peukalo'
overall_accuracy: ' Kokonaistarkkuus: %{correct}/%{total} (%{pct}%%)'
ranked_accuracy: ' Sijoitettu tarkkuus: %{correct}/%{total} (%{pct}%%)'
confidence: 'Varmuus: '
no_data: 'Ei dataa vielä'
no_data_short: 'Ei dataa'
key_details: ' Näppäintiedot '
key_details_char: ' Näppäintiedot: ''%{ch}'' '
key_details_name: ' Näppäintiedot: %{name} '
press_key_hint: 'Paina näppäintä nähdäksesi sen tiedot'
shift_label: 'Shift: '
shift_no: 'Ei'
overall_avg_time: 'Keskimääräinen aika: '
overall_best_time: 'Paras aika: '
overall_samples: 'Näytteet: '
overall_accuracy_label: 'Kokonaistarkkuus: '
branch_label: 'Haara: '
level_label: 'Taso: '
built_in_key: 'Sisäänrakennettu näppäin'
unlocked_label: 'Avattu: '
yes: 'Kyllä'
no: 'Ei'
in_focus_label: 'Fokuksessa?: '
mastery_label: 'Hallinta: '
mastery_locked: 'Lukittu'
ranked_avg_time: 'Sijoitettu ka aika: '
ranked_best_time: 'Sijoitettu paras aika: '
ranked_samples: 'Sijoitetut näytteet: '
ranked_accuracy_label: 'Sijoitettu tarkkuus: '
# Intro dialogs
intro:
passage_title: ' Tekstilatausten asetukset '
code_title: ' Koodilatausten asetukset '
enable_downloads: 'Ota verkkolataukset käyttöön'
download_dir: 'Latauskansio'
paragraphs_per_book: 'Kappaleet per kirja (0 = koko)'
whole_book: 'koko kirja'
snippets_per_repo: 'Katkelmat per repo (0 = rajaton)'
unlimited: 'rajaton'
start_passage_drill: 'Aloita tekstiharjoitus'
start_code_drill: 'Aloita koodiharjoitus'
confirm: 'Vahvista'
hint_navigate: '[Ylös/Alas] Navigoi'
hint_adjust: '[Vasen/Oikea] Säädä'
hint_edit: '[Kirjoita/Backspace] Muokkaa'
hint_confirm: '[Enter] Vahvista'
hint_cancel: '[ESC] Peruuta'
preparing_download: 'Valmistellaan latausta...'
download_passage_title: ' Ladataan tekstilähdettä '
download_code_title: ' Ladataan koodilähdettä '
book_label: ' Kirja: %{name}'
repo_label: ' Repo: %{name}'
progress_bytes: '[%{name}] %{downloaded}/%{total} tavua'
downloaded_bytes: 'Ladattu: %{bytes} tavua'
downloading_book_progress: 'Ladataan kirjaa: [%{bar}] %{downloaded}/%{total} tavua'
downloading_book_bytes: 'Ladataan kirjaa: %{bytes} tavua'
downloading_code_progress: 'Ladataan: [%{bar}] %{downloaded}/%{total} tavua'
downloading_code_bytes: 'Ladataan: %{bytes} tavua'
current_book: 'Nykyinen: %{name} (kirja %{done}/%{total})'
current_repo: 'Nykyinen: %{name} (repo %{done}/%{total})'
passage_instructions_1: 'keydr voi ladata tekstejä Project Gutenbergistä kirjoitusharjoitteluun.'
passage_instructions_2: 'Kirjat ladataan kerran ja tallennetaan paikallisesti.'
passage_instructions_3: 'Määritä latausasetukset alla ja aloita tekstiharjoitus.'
code_instructions_1: 'keydr voi ladata avoimen lähdekoodin koodia GitHubista kirjoitusharjoitteluun.'
code_instructions_2: 'Koodi ladataan kerran ja tallennetaan paikallisesti.'
code_instructions_3: 'Määritä latausasetukset alla ja aloita koodiharjoitus.'
# Status messages (from app.rs)
status:
recovery_files: 'Palautustiedostoja löydetty keskeytyneestä tuonnista. Data voi olla epäjohdonmukaista — harkitse uudelleentuontia.'
dir_not_exist: 'Kansiota ei ole olemassa: %{path}'
no_data_store: 'Datavarasto ei käytettävissä'
serialization_error: 'Sarjallistamisvirhe: %{error}'
exported_to: 'Viety kohteeseen %{path}'
export_failed: 'Vienti epäonnistui: %{error}'
could_not_read: 'Tiedostoa ei voitu lukea: %{error}'
invalid_export: 'Virheellinen vientitiedosto: %{error}'
unsupported_version: 'Ei-tuettu vientiversion: %{got} (odotettu %{expected})'
import_failed: 'Tuonti epäonnistui: %{error}'
imported_theme_fallback: 'Tuotu onnistuneesti (teemaa ''%{theme}'' ei löytynyt, käytetään oletusta)'
imported_success: 'Tuotu onnistuneesti'
adaptive_unavailable: 'Mukautuva sijoitettu tila ei käytettävissä: %{error}'
switched_to: 'Vaihdettu: %{name}'
layout_changed: 'Asettelu vaihdettu: %{name}'
# Errors (for UI boundary translation)
errors:
unknown_language: 'Tuntematon kieli: %{key}'
unknown_layout: 'Tuntematon näppäinasettelu: %{key}'
unsupported_pair: 'Ei-tuettu kieli/asettelu-pari: %{language} + %{layout}'
language_blocked: 'Kieli estetty tukitason vuoksi: %{key}'
# Common
common:
wpm: 'WPM'
cpm: 'CPM'
back: 'Takaisin'

454
locales/fr.yml Normal file
View File

@@ -0,0 +1,454 @@
# Menu principal
menu:
subtitle: 'Tuteur de Frappe en Terminal'
adaptive_drill: 'Exercice Adaptatif'
adaptive_drill_desc: 'Mots phonétiques avec déverrouillage adaptatif des touches'
code_drill: 'Exercice de Code'
code_drill_desc: 'Entraînez-vous à taper la syntaxe du code'
passage_drill: 'Exercice de Passage'
passage_drill_desc: 'Tapez des passages de livres'
skill_tree: 'Arbre de Compétences'
skill_tree_desc: 'Voir les branches de progression et lancer des exercices'
keyboard: 'Clavier'
keyboard_desc: 'Explorer la disposition du clavier et les statistiques'
statistics: 'Statistiques'
statistics_desc: 'Voir vos statistiques de frappe'
settings: 'Paramètres'
settings_desc: 'Configurer keydr'
day_streak: ' | %{days} jours consécutifs'
key_progress: ' Progression %{unlocked}/%{total} (%{mastered} maîtrisées) | Objectif %{target} WPM%{streak}'
hint_start: '[1-3] Démarrer'
hint_skill_tree: '[t] Arbre de Compétences'
hint_keyboard: '[b] Clavier'
hint_stats: '[s] Statistiques'
hint_settings: '[c] Paramètres'
hint_quit: '[q] Quitter'
# Écran d'exercice
drill:
title: ' Exercice '
mode_adaptive: 'Adaptatif'
mode_code: 'Code (Non classé)'
mode_passage: 'Passage (Non classé)'
focus_char: 'Focus : ''%{ch}'''
focus_bigram: 'Focus : "%{bigram}"'
focus_both: 'Focus : ''%{ch}'' + "%{bigram}"'
header_wpm: 'WPM'
header_acc: 'Pré'
header_err: 'Err'
code_source: ' Source du code '
passage_source: ' Source du passage '
footer: '[ESC] Fin [Backspace] Effacer'
keys_reenabled: 'Touches réactivées en %{ms}ms'
hint_end: '[ESC] Fin de l''exercice'
hint_backspace: '[Backspace] Effacer'
# Tableau de bord / résultat de l'exercice
dashboard:
title: ' Exercice Terminé '
results: 'Résultats'
unranked_note_prefix: ' (Non classé'
unranked_note_suffix: ' ne compte pas pour l''arbre de compétences)'
speed: ' Vitesse : '
accuracy_label: ' Précision : '
time_label: ' Temps : '
errors_label: ' Erreurs : '
correct_detail: ' (%{correct}/%{total} corrects)'
input_blocked: ' Saisie temporairement bloquée '
input_blocked_ms: '(%{ms}ms restantes)'
hint_continue: '[c/Enter/Space] Continuer'
hint_retry: '[r] Réessayer'
hint_menu: '[q] Menu'
hint_stats: '[s] Statistiques'
hint_delete: '[x] Supprimer'
# Barre latérale de statistiques (pendant l'exercice)
sidebar:
title: ' Statistiques '
wpm: 'WPM : '
target: 'Objectif : '
target_wpm: '%{wpm} WPM'
accuracy: 'Précision : '
progress: 'Progression : '
correct: 'Corrects : '
errors: 'Erreurs : '
time: 'Temps : '
last_drill: ' Dernier Exercice '
vs_avg: ' vs moy : '
# Tableau de bord des statistiques
stats:
title: ' Statistiques '
empty: 'Aucun exercice terminé. Commencez à taper !'
tab_dashboard: '[1] Tableau de bord'
tab_history: '[2] Historique'
tab_activity: '[3] Activité'
tab_accuracy: '[4] Précision'
tab_timing: '[5] Chronométrage'
tab_ngrams: '[6] N-grammes'
hint_back: '[ESC] Retour'
hint_next_tab: '[Tab] Onglet suivant'
hint_switch_tab: '[1-6] Changer d''onglet'
hint_navigate: '[j/k] Naviguer'
hint_page: '[PgUp/PgDn] Défiler'
hint_delete: '[x] Supprimer'
summary_title: ' Résumé '
drills: ' Exercices : '
avg_wpm: ' WPM Moy : '
best_wpm: ' Meilleur WPM : '
accuracy_label: ' Précision : '
total_time: ' Temps total : '
wpm_chart_title: ' WPM par Exercice (20 derniers, Objectif : %{target}) '
accuracy_chart_title: ' Précision %% (50 derniers Exercices) '
chart_drill: 'Exercice #'
chart_accuracy_pct: 'Précision %%'
sessions_title: ' Sessions Récentes '
session_header: ' # WPM Raw Pré%% Temps Date/Heure Mode Classé Partiel'
session_separator: ' ─────────────────────────────────────────────────────────────────────'
delete_confirm: 'Supprimer la session #%{idx} ? (y/n)'
confirm_title: ' Confirmer '
yes: 'oui'
no: 'non'
keyboard_accuracy_title: ' Précision du Clavier %% '
keyboard_timing_title: ' Chronométrage du Clavier (ms) '
slowest_keys_title: ' Touches les plus Lentes (ms) '
fastest_keys_title: ' Touches les plus Rapides (ms) '
worst_accuracy_title: ' Pire Précision (%%) '
best_accuracy_title: ' Meilleure Précision (%%) '
not_enough_data: ' Données insuffisantes'
streaks_title: ' Séries '
current_streak: ' Actuelle : '
best_streak: ' Meilleure : '
active_days: ' Jours actifs : '
top_days_none: ' Meilleurs jours : aucun'
top_days: ' Meilleurs jours : %{days}'
wpm_label: ' WPM : %{avg}/%{target} (%{pct}%%)'
acc_label: ' Pré : %{pct}%%'
keys_label: ' Touches : %{unlocked}/%{total} (%{mastered} maîtrisées)'
ngram_empty: 'Terminez des exercices adaptatifs pour voir les données de n-grammes'
ngram_header_speed_narrow: ' Bgrm Vit Att Anom%'
ngram_header_error_narrow: ' Bgrm Err Éch Taux Att Anom%'
ngram_header_speed: ' Bigramme Vit Att Échan. Anom%'
ngram_header_error: ' Bigramme Erreurs Échan. Taux Att Anom%'
focus_title: ' Focus Actif '
focus_char_label: ' Focus : '
focus_bigram_value: 'Bigramme %{label}'
focus_plus: ' + '
anomaly_error: 'erreur'
anomaly_speed: 'vitesse'
focus_detail_both: ' Caractère ''%{ch}'' : touche la plus faible | Bigramme %{label} : anomalie de %{type} %{pct}%%'
focus_detail_char_only: ' Caractère ''%{ch}'' : touche la plus faible, aucune anomalie de bigramme confirmée'
focus_detail_bigram_only: ' (anomalie de %{type} : %{pct}%%)'
focus_empty: ' Terminez des exercices adaptatifs pour voir les données de focus'
error_anomalies_title: ' Anomalies d''Erreur (%{count}) '
no_error_anomalies: ' Aucune anomalie d''erreur détectée'
speed_anomalies_title: ' Anomalies de Vitesse (%{count}) '
no_speed_anomalies: ' Aucune anomalie de vitesse détectée'
scope_label_prefix: ' '
bi_label: ' | Bi : %{count}'
hes_label: ' | Hés : >%{ms}ms'
focus_char_value: 'Caractère ''%{ch}'''
# Carte d'activité
heatmap:
title: ' Activité Quotidienne (Sessions par Jour) '
jan: 'Jan'
feb: 'Fév'
mar: 'Mar'
apr: 'Avr'
may: 'Mai'
jun: 'Jun'
jul: 'Jul'
aug: 'Aoû'
sep: 'Sep'
oct: 'Oct'
nov: 'Nov'
dec: 'Déc'
# Graphique
chart:
wpm_over_time: ' WPM au fil du temps '
drill_number: 'Exercice #'
# Paramètres
settings:
title: ' Paramètres '
subtitle: 'Utilisez les flèches pour naviguer, Entrée/Droite pour changer, ESC pour sauvegarder et quitter'
target_wpm: 'WPM Objectif'
theme: 'Thème'
word_count: 'Nombre de Mots'
ui_language: 'Langue de l''Interface'
dictionary_language: 'Langue du Dictionnaire'
keyboard_layout: 'Disposition du Clavier'
code_language: 'Langage de Code'
code_downloads: 'Téléchargements de Code'
on: 'Oui'
off: 'Non'
code_download_dir: 'Rép. Téléchargement Code'
snippets_per_repo: 'Extraits par Dépôt'
unlimited: 'Illimité'
download_code_now: 'Télécharger Code Maintenant'
run_downloader: 'Lancer le téléchargeur'
passage_downloads: 'Téléchargements de Passages'
passage_download_dir: 'Rép. Téléchargement Passages'
paragraphs_per_book: 'Paragraphes par Livre'
whole_book: 'Livre entier'
download_passages_now: 'Télécharger Passages Maintenant'
export_path: 'Chemin d''Export'
export_data: 'Exporter les Données'
export_now: 'Exporter maintenant'
import_path: 'Chemin d''Import'
import_data: 'Importer les Données'
import_now: 'Importer maintenant'
hint_save_back: '[ESC] Sauvegarder et retour'
hint_change_value: '[Enter/flèches] Changer la valeur'
hint_edit_path: '[Enter sur chemin] Éditer'
hint_move: '[←→] Déplacer'
hint_tab_complete: '[Tab] Compléter (à la fin)'
hint_confirm: '[Enter] Confirmer'
hint_cancel: '[Esc] Annuler'
success_title: ' Succès '
error_title: ' Erreur '
press_any_key: 'Appuyez sur une touche'
file_exists_title: ' Fichier Existant '
file_exists: 'Un fichier existe déjà à ce chemin.'
overwrite_rename: '[d] Écraser [r] Renommer [Esc] Annuler'
erase_warning: 'Ceci effacera vos données actuelles.'
export_first: 'Exportez d''abord si vous voulez les conserver.'
proceed_yn: 'Continuer ? (y/n)'
confirm_import_title: ' Confirmer l''Importation '
# Écrans de sélection
select:
dictionary_language_title: ' Sélectionner la Langue du Dictionnaire '
keyboard_layout_title: ' Sélectionner la Disposition du Clavier '
code_language_title: ' Sélectionner le Langage de Code '
passage_source_title: ' Sélectionner la Source de Passages '
ui_language_title: ' Sélectionner la Langue de l''Interface '
more_above: '... %{count} de plus au-dessus ...'
more_below: '... %{count} de plus en-dessous ...'
current: ' (actuel)'
disabled: ' (désactivé)'
enabled_default: ' (activé, par défaut : %{layout})'
enabled: ' (activé)'
disabled_blocked: ' (désactivé : bloqué)'
built_in: ' (intégré)'
cached: ' (en cache)'
disabled_download: ' (désactivé : téléchargement requis)'
download_required: ' (téléchargement requis)'
hint_navigate: '[Up/Down/PgUp/PgDn] Naviguer'
hint_confirm: '[Enter] Confirmer'
hint_back: '[ESC] Retour'
language_resets_layout: 'Sélectionner une langue réinitialise la disposition à celle par défaut de cette langue.'
layout_no_language_change: 'Changer la disposition ne change pas la langue du dictionnaire.'
disabled_network_notice: 'Certaines langues sont désactivées : activez les téléchargements dans intro/paramètres.'
disabled_sources_notice: 'Certaines sources sont désactivées : activez les téléchargements dans intro/paramètres.'
passage_all: 'Tous (Intégrés + tous les livres)'
passage_builtin: 'Passages intégrés uniquement'
passage_book_prefix: 'Livre : %{title}'
# Progression
progress:
overall_key_progress: 'Progression Globale des Touches'
unlocked_mastered: '%{unlocked}/%{total} déverrouillées (%{mastered} maîtrisées)'
# Arbre de compétences
skill_tree:
title: ' Arbre de Compétences '
locked: 'Verrouillé'
unlocked: 'déverrouillé'
mastered: 'maîtrisé'
in_progress: 'en cours'
complete: 'terminé'
locked_status: 'verrouillé'
locked_notice: 'Terminez %{count} lettres primaires pour débloquer les branches'
branches_separator: 'Branches (disponibles après %{count} lettres primaires)'
unlocked_letters: 'Déverrouillées %{unlocked}/%{total} lettres'
level: 'Niveau %{current}/%{total}'
level_zero: 'Niveau 0/%{total}'
in_focus: ' en focus'
hint_navigate: '[↑↓/jk] Naviguer'
hint_scroll: '[PgUp/PgDn ou Ctrl+U/Ctrl+D] Défiler'
hint_back: '[q] Retour'
hint_unlock: '[Enter] Déverrouiller'
hint_start_drill: '[Enter] Lancer l''Exercice'
unlock_msg_1: 'Une fois déverrouillé, l''exercice adaptatif inclura les touches de cette branche qui sont déverrouillées.'
unlock_msg_2: 'Si vous voulez vous concentrer sur cette branche, lancez un exercice directement depuis cette branche dans l''Arbre de Compétences.'
confirm_unlock: 'Déverrouiller %{branch} ?'
confirm_yn: '[y] Déverrouiller [n/ESC] Annuler'
lvl_prefix: 'Niv'
branch_primary_letters: 'Lettres Primaires'
branch_capital_letters: 'Lettres Majuscules'
branch_numbers: 'Chiffres 0-9'
branch_prose_punctuation: 'Ponctuation de Prose'
branch_whitespace: 'Espaces Blancs'
branch_code_symbols: 'Symboles de Code'
level_frequency_order: 'Ordre de Fréquence'
level_common_sentence_capitals: 'Majuscules de Phrases Courantes'
level_name_capitals: 'Majuscules de Noms'
level_remaining_capitals: 'Majuscules Restantes'
level_common_digits: 'Chiffres Courants'
level_all_digits: 'Tous les Chiffres'
level_essential: 'Essentiel'
level_common: 'Courant'
level_expressive: 'Expressif'
level_enter_return: 'Entrée/Retour'
level_tab_indent: 'Tab/Indentation'
level_arithmetic_assignment: 'Arithmétique et Affectation'
level_grouping: 'Groupement'
level_logic_reference: 'Logique et Référence'
level_special: 'Spécial'
# Jalons
milestones:
unlock_title: ' Touche Déverrouillée ! '
mastery_title: ' Touche Maîtrisée ! '
branches_title: ' Nouvelles Branches Disponibles ! '
branch_complete_title: ' Branche Terminée ! '
all_unlocked_title: ' Toutes les Touches Déverrouillées ! '
all_mastered_title: ' Maîtrise Totale du Clavier ! '
unlocked: 'déverrouillée'
mastered: 'maîtrisée'
use_finger: 'Utilisez votre %{finger}'
hold_right_shift: 'Maintenez Shift Droit (auriculaire droit)'
hold_left_shift: 'Maintenez Shift Gauche (auriculaire gauche)'
congratulations_all_letters: 'Félicitations ! Vous avez maîtrisé les %{count} lettres primaires'
new_branches_available: 'De nouvelles branches de compétences sont disponibles :'
visit_skill_tree: 'Visitez l''Arbre de Compétences pour déverrouiller une nouvelle branche'
and_start_training: 'et commencez l''entraînement !'
open_skill_tree: 'Appuyez sur [t] pour ouvrir l''Arbre de Compétences'
branch_complete_msg: 'Vous avez terminé la branche %{branch} !'
all_levels_mastered: 'Les %{count} niveaux sont maîtrisés.'
all_keys_confident: 'Chaque touche de cette branche est à confiance maximale.'
all_unlocked_msg: 'Vous avez déverrouillé toutes les touches du clavier !'
all_unlocked_desc: 'Chaque caractère, symbole et modificateur est disponible dans vos exercices.'
keep_practicing_mastery: 'Continuez à pratiquer pour atteindre la maîtrise — quand chaque touche atteindra'
confidence_complete: 'la confiance maximale, vous aurez atteint la maîtrise totale du clavier !'
all_mastered_msg: 'Félicitations — vous avez atteint la maîtrise totale du clavier !'
all_mastered_desc: 'Chaque touche du clavier est à confiance maximale.'
mastery_takes_practice: 'La maîtrise n''est pas une destination — elle nécessite une pratique continue.'
keep_drilling: 'Continuez à vous entraîner pour garder votre niveau.'
hint_skill_tree_continue: '[t] Ouvrir l''Arbre de Compétences [Autre touche] Continuer'
hint_any_key: 'Appuyez sur une touche pour continuer'
input_blocked: 'Saisie temporairement bloquée (%{ms}ms restantes)'
unlock_msg_1: 'Bon travail ! Continuez à améliorer vos compétences.'
unlock_msg_2: 'Une touche de plus dans votre arsenal !'
unlock_msg_3: 'Votre clavier s''agrandit ! Continuez !'
unlock_msg_4: 'Un pas de plus vers la maîtrise totale !'
mastery_msg_1: 'Cette touche est à confiance maximale !'
mastery_msg_2: 'Vous maîtrisez cette touche parfaitement !'
mastery_msg_3: 'Mémoire musculaire acquise !'
mastery_msg_4: 'Une touche de plus conquise !'
# Explorateur de clavier
keyboard:
title: ' Clavier '
subtitle: 'Appuyez ou cliquez sur une touche'
hint_navigate: '[←→↑↓/hjkl/Tab] Naviguer'
hint_back: '[q/ESC] Retour'
key_label: 'Touche : '
finger_label: 'Doigt : '
hand_left: 'Gauche'
hand_right: 'Droite'
finger_index: 'Indicateur'
finger_middle: 'Majeur'
finger_ring: 'Annulaire'
finger_pinky: 'Auriculaire'
finger_thumb: 'Pouce'
overall_accuracy: ' Précision globale : %{correct}/%{total} (%{pct}%%)'
ranked_accuracy: ' Précision classée : %{correct}/%{total} (%{pct}%%)'
confidence: 'Confiance : '
no_data: 'Pas encore de données'
no_data_short: 'Pas de données'
key_details: ' Détails de la Touche '
key_details_char: ' Détails de la Touche : ''%{ch}'' '
key_details_name: ' Détails de la Touche : %{name} '
press_key_hint: 'Appuyez sur une touche pour voir ses détails'
shift_label: 'Shift : '
shift_no: 'Non'
overall_avg_time: 'Temps Moy. Global : '
overall_best_time: 'Meilleur Temps Global : '
overall_samples: 'Échantillons Globaux : '
overall_accuracy_label: 'Précision Globale : '
branch_label: 'Branche : '
level_label: 'Niveau : '
built_in_key: 'Touche Intégrée'
unlocked_label: 'Déverrouillée : '
yes: 'Oui'
no: 'Non'
in_focus_label: 'En Focus ? : '
mastery_label: 'Maîtrise : '
mastery_locked: 'Verrouillé'
ranked_avg_time: 'Temps Moy. Classé : '
ranked_best_time: 'Meilleur Temps Classé : '
ranked_samples: 'Échantillons Classés : '
ranked_accuracy_label: 'Précision Classée : '
# Dialogues d'introduction
intro:
passage_title: ' Configuration Téléchargement de Passages '
code_title: ' Configuration Téléchargement de Code '
enable_downloads: 'Activer les téléchargements réseau'
download_dir: 'Répertoire de téléchargement'
paragraphs_per_book: 'Paragraphes par livre (0 = entier)'
whole_book: 'livre entier'
snippets_per_repo: 'Extraits par dépôt (0 = illimité)'
unlimited: 'illimité'
start_passage_drill: 'Lancer l''exercice de passage'
start_code_drill: 'Lancer l''exercice de code'
confirm: 'Confirmer'
hint_navigate: '[Up/Down] Naviguer'
hint_adjust: '[Left/Right] Ajuster'
hint_edit: '[Type/Backspace] Éditer'
hint_confirm: '[Enter] Confirmer'
hint_cancel: '[ESC] Annuler'
preparing_download: 'Préparation du téléchargement...'
download_passage_title: ' Téléchargement de la Source de Passage '
download_code_title: ' Téléchargement de la Source de Code '
book_label: ' Livre : %{name}'
repo_label: ' Dépôt : %{name}'
progress_bytes: '[%{name}] %{downloaded}/%{total} bytes'
downloaded_bytes: 'Téléchargé : %{bytes} bytes'
downloading_book_progress: 'Téléchargement du livre : [%{bar}] %{downloaded}/%{total} bytes'
downloading_book_bytes: 'Téléchargement du livre : %{bytes} bytes'
downloading_code_progress: 'Téléchargement : [%{bar}] %{downloaded}/%{total} bytes'
downloading_code_bytes: 'Téléchargement : %{bytes} bytes'
current_book: 'Actuel : %{name} (livre %{done}/%{total})'
current_repo: 'Actuel : %{name} (dépôt %{done}/%{total})'
passage_instructions_1: 'keydr peut télécharger des passages de Project Gutenberg pour la pratique de frappe.'
passage_instructions_2: 'Les livres sont téléchargés une fois et mis en cache localement.'
passage_instructions_3: 'Configurez les paramètres ci-dessous, puis lancez un exercice de passage.'
code_instructions_1: 'keydr peut télécharger du code open-source de GitHub pour la pratique de frappe.'
code_instructions_2: 'Le code est téléchargé une fois et mis en cache localement.'
code_instructions_3: 'Configurez les paramètres ci-dessous, puis lancez un exercice de code.'
# Messages de statut (de app.rs)
status:
recovery_files: 'Fichiers de récupération trouvés suite à une importation interrompue. Les données peuvent être incohérentes — envisagez de réimporter.'
dir_not_exist: 'Le répertoire n''existe pas : %{path}'
no_data_store: 'Aucun stockage de données disponible'
serialization_error: 'Erreur de sérialisation : %{error}'
exported_to: 'Exporté vers %{path}'
export_failed: 'Échec de l''exportation : %{error}'
could_not_read: 'Impossible de lire le fichier : %{error}'
invalid_export: 'Fichier d''export invalide : %{error}'
unsupported_version: 'Version d''export non supportée : %{got} (attendue %{expected})'
import_failed: 'Échec de l''importation : %{error}'
imported_theme_fallback: 'Importé avec succès (thème ''%{theme}'' introuvable, utilisation du défaut)'
imported_success: 'Importé avec succès'
adaptive_unavailable: 'Mode adaptatif classé non disponible : %{error}'
switched_to: 'Basculé vers %{name}'
layout_changed: 'Disposition changée en %{name}'
# Erreurs (pour traduction des limites d'UI)
errors:
unknown_language: 'Langue inconnue : %{key}'
unknown_layout: 'Disposition de clavier inconnue : %{key}'
unsupported_pair: 'Paire langue/disposition non supportée : %{language} + %{layout}'
language_blocked: 'Langue bloquée par le niveau de support : %{key}'
# Commun
common:
wpm: 'WPM'
cpm: 'CPM'
back: 'Retour'

454
locales/hr.yml Normal file
View File

@@ -0,0 +1,454 @@
# Main menu
menu:
subtitle: 'Terminalni trener tipkanja'
adaptive_drill: 'Prilagodljiva vježba'
adaptive_drill_desc: 'Fonetske riječi s prilagodljivim otključavanjem slova'
code_drill: 'Vježba koda'
code_drill_desc: 'Vježbajte tipkanje sintakse koda'
passage_drill: 'Vježba teksta'
passage_drill_desc: 'Tipkajte odlomke iz knjiga'
skill_tree: 'Stablo vještina'
skill_tree_desc: 'Pregledajte grane napretka i pokrenite vježbe'
keyboard: 'Tipkovnica'
keyboard_desc: 'Istražite raspored tipkovnice i statistiku tipki'
statistics: 'Statistika'
statistics_desc: 'Pregledajte svoju statistiku tipkanja'
settings: 'Postavke'
settings_desc: 'Konfigurirajte keydr'
day_streak: ' | %{days} dana zaredom'
key_progress: ' Napredak tipki %{unlocked}/%{total} (%{mastered} savladano) | Cilj %{target} WPM%{streak}'
hint_start: '[1-3] Pokreni'
hint_skill_tree: '[t] Stablo vještina'
hint_keyboard: '[b] Tipkovnica'
hint_stats: '[s] Statistika'
hint_settings: '[c] Postavke'
hint_quit: '[q] Izlaz'
# Drill screen
drill:
title: ' Vježba '
mode_adaptive: 'Prilagodljiva'
mode_code: 'Kod (bez ocjene)'
mode_passage: 'Tekst (bez ocjene)'
focus_char: 'Fokus: ''%{ch}'''
focus_bigram: 'Fokus: "%{bigram}"'
focus_both: 'Fokus: ''%{ch}'' + "%{bigram}"'
header_wpm: 'WPM'
header_acc: 'Toč'
header_err: 'Greš'
code_source: ' Izvor koda '
passage_source: ' Izvor teksta '
footer: '[ESC] Završi vježbu [Backspace] Obriši'
keys_reenabled: 'Tipke ponovo aktivne za %{ms}ms'
hint_end: '[ESC] Završi vježbu'
hint_backspace: '[Backspace] Obriši'
# Dashboard / drill result
dashboard:
title: ' Vježba završena '
results: 'Rezultati'
unranked_note_prefix: ' (Bez ocjene'
unranked_note_suffix: ' ne broji se za stablo vještina)'
speed: ' Brzina: '
accuracy_label: ' Točnost: '
time_label: ' Vrijeme: '
errors_label: ' Greške: '
correct_detail: ' (%{correct}/%{total} točno)'
input_blocked: ' Unos privremeno blokiran '
input_blocked_ms: '(%{ms}ms preostalo)'
hint_continue: '[c/Enter/Space] Nastavi'
hint_retry: '[r] Ponovi'
hint_menu: '[q] Izbornik'
hint_stats: '[s] Statistika'
hint_delete: '[x] Obriši'
# Stats sidebar (during drill)
sidebar:
title: ' Statistika '
wpm: 'WPM: '
target: 'Cilj: '
target_wpm: '%{wpm} WPM'
accuracy: 'Točnost: '
progress: 'Napredak: '
correct: 'Točno: '
errors: 'Greške: '
time: 'Vrijeme: '
last_drill: ' Zadnja vježba '
vs_avg: ' vs prosjek: '
# Statistics dashboard
stats:
title: ' Statistika '
empty: 'Nema završenih vježbi. Počnite tipkati!'
tab_dashboard: '[1] Pregled'
tab_history: '[2] Povijest'
tab_activity: '[3] Aktivnost'
tab_accuracy: '[4] Točnost'
tab_timing: '[5] Tajming'
tab_ngrams: '[6] N-grami'
hint_back: '[ESC] Natrag'
hint_next_tab: '[Tab] Sljedeća kartica'
hint_switch_tab: '[1-6] Kartica'
hint_navigate: '[j/k] Navigacija'
hint_page: '[PgUp/PgDn] Stranica'
hint_delete: '[x] Obriši'
summary_title: ' Sažetak '
drills: ' Vježbe: '
avg_wpm: ' Prosj. WPM: '
best_wpm: ' Najbolji WPM: '
accuracy_label: ' Točnost: '
total_time: ' Ukupno vrijeme: '
wpm_chart_title: ' WPM po vježbi (Zadnjih 20, Cilj: %{target}) '
accuracy_chart_title: ' Točnost %% (Zadnjih 50 vježbi) '
chart_drill: 'Vježba #'
chart_accuracy_pct: 'Točnost %%'
sessions_title: ' Nedavne sesije '
session_header: ' # WPM Raw Toč%% Vrijeme Datum/Vrijeme Način Ocjena Djelom.'
session_separator: ' ─────────────────────────────────────────────────────────────────────'
delete_confirm: 'Obrisati sesiju #%{idx}? (y/n)'
confirm_title: ' Potvrda '
yes: 'da'
no: 'ne'
keyboard_accuracy_title: ' Točnost tipkovnice %% '
keyboard_timing_title: ' Tajming tipkovnice (ms) '
slowest_keys_title: ' Najsporije tipke (ms) '
fastest_keys_title: ' Najbrže tipke (ms) '
worst_accuracy_title: ' Najgora točnost (%%) '
best_accuracy_title: ' Najbolja točnost (%%) '
not_enough_data: ' Nedovoljno podataka'
streaks_title: ' Nizovi '
current_streak: ' Trenutni: '
best_streak: ' Najbolji: '
active_days: ' Aktivni dani: '
top_days_none: ' Najbolji dani: nema'
top_days: ' Najbolji dani: %{days}'
wpm_label: ' WPM: %{avg}/%{target} (%{pct}%%)'
acc_label: ' Toč: %{pct}%%'
keys_label: ' Tipke: %{unlocked}/%{total} (%{mastered} savladano)'
ngram_empty: 'Završite nekoliko prilagodljivih vježbi za prikaz n-gram podataka'
ngram_header_speed_narrow: ' Bgrm Brzina Očekiv Anom%'
ngram_header_error_narrow: ' Bgrm Greš Uzrk Stopa Očk Anom%'
ngram_header_speed: ' Bigram Brzina Očekiv Uzorci Anom%'
ngram_header_error: ' Bigram Greške Uzorci Stopa Očekiv Anom%'
focus_title: ' Aktivni fokus '
focus_char_label: ' Fokus: '
focus_bigram_value: 'Bigram %{label}'
focus_plus: ' + '
anomaly_error: 'greška'
anomaly_speed: 'brzina'
focus_detail_both: ' Znak ''%{ch}'': najslabija tipka | Bigram %{label}: %{type} anomalija %{pct}%%'
focus_detail_char_only: ' Znak ''%{ch}'': najslabija tipka, nema potvrđenih bigram anomalija'
focus_detail_bigram_only: ' (%{type} anomalija: %{pct}%%)'
focus_empty: ' Završite nekoliko prilagodljivih vježbi za prikaz fokus podataka'
error_anomalies_title: ' Anomalije grešaka (%{count}) '
no_error_anomalies: ' Nema otkrivenih anomalija grešaka'
speed_anomalies_title: ' Anomalije brzine (%{count}) '
no_speed_anomalies: ' Nema otkrivenih anomalija brzine'
scope_label_prefix: ' '
bi_label: ' | Bi: %{count}'
hes_label: ' | Hes: >%{ms}ms'
focus_char_value: 'Znak ''%{ch}'''
# Activity heatmap
heatmap:
title: ' Dnevna aktivnost (Sesije po danu) '
jan: 'Sij'
feb: 'Velj'
mar: 'Ožu'
apr: 'Tra'
may: 'Svi'
jun: 'Lip'
jul: 'Srp'
aug: 'Kol'
sep: 'Ruj'
oct: 'Lis'
nov: 'Stu'
dec: 'Pro'
# Chart
chart:
wpm_over_time: ' WPM kroz vrijeme '
drill_number: 'Vježba #'
# Settings
settings:
title: ' Postavke '
subtitle: 'Strelicama navigirajte, Enter/Desno za promjenu, ESC za spremanje'
target_wpm: 'Ciljni WPM'
theme: 'Tema'
word_count: 'Broj riječi'
ui_language: 'Jezik sučelja'
dictionary_language: 'Jezik rječnika'
keyboard_layout: 'Raspored tipkovnice'
code_language: 'Programski jezik'
code_downloads: 'Preuzimanje koda'
on: 'Uklj.'
off: 'Isklj.'
code_download_dir: 'Mapa za preuzimanje koda'
snippets_per_repo: 'Isječaka po repozitoriju'
unlimited: 'Neograničeno'
download_code_now: 'Preuzmi kod sada'
run_downloader: 'Pokreni preuzimanje'
passage_downloads: 'Preuzimanje tekstova'
passage_download_dir: 'Mapa za preuzimanje tekstova'
paragraphs_per_book: 'Odlomaka po knjizi'
whole_book: 'Cijela knjiga'
download_passages_now: 'Preuzmi tekstove sada'
export_path: 'Putanja izvoza'
export_data: 'Izvezi podatke'
export_now: 'Izvezi sada'
import_path: 'Putanja uvoza'
import_data: 'Uvezi podatke'
import_now: 'Uvezi sada'
hint_save_back: '[ESC] Spremi i natrag'
hint_change_value: '[Enter/strelice] Promijeni'
hint_edit_path: '[Enter na putanju] Uredi'
hint_move: '[←→] Pomakni'
hint_tab_complete: '[Tab] Dovrši (na kraju)'
hint_confirm: '[Enter] Potvrdi'
hint_cancel: '[Esc] Odustani'
success_title: ' Uspjeh '
error_title: ' Greška '
press_any_key: 'Pritisnite bilo koju tipku'
file_exists_title: ' Datoteka postoji '
file_exists: 'Datoteka već postoji na ovoj putanji.'
overwrite_rename: '[d] Prepiši [r] Preimenuj [Esc] Odustani'
erase_warning: 'Ovo će izbrisati vaše trenutne podatke.'
export_first: 'Prvo izvezite ako želite sačuvati.'
proceed_yn: 'Nastaviti? (y/n)'
confirm_import_title: ' Potvrda uvoza '
# Selection screens
select:
dictionary_language_title: ' Odaberite jezik rječnika '
keyboard_layout_title: ' Odaberite raspored tipkovnice '
code_language_title: ' Odaberite programski jezik '
passage_source_title: ' Odaberite izvor teksta '
ui_language_title: ' Odaberite jezik sučelja '
more_above: '... još %{count} iznad ...'
more_below: '... još %{count} ispod ...'
current: ' (trenutni)'
disabled: ' (onemogućeno)'
enabled_default: ' (omogućeno, zadano: %{layout})'
enabled: ' (omogućeno)'
disabled_blocked: ' (onemogućeno: blokirano)'
built_in: ' (ugrađeno)'
cached: ' (u predmemoriji)'
disabled_download: ' (onemogućeno: potrebno preuzimanje)'
download_required: ' (potrebno preuzimanje)'
hint_navigate: '[Gore/Dolje/PgUp/PgDn] Navigacija'
hint_confirm: '[Enter] Potvrdi'
hint_back: '[ESC] Natrag'
language_resets_layout: 'Odabir jezika resetira raspored tipkovnice na zadani za taj jezik.'
layout_no_language_change: 'Promjena rasporeda ne mijenja jezik rječnika.'
disabled_network_notice: 'Neki jezici su onemogućeni: omogućite mrežna preuzimanja u uvodu/postavkama.'
disabled_sources_notice: 'Neki izvori su onemogućeni: omogućite mrežna preuzimanja u uvodu/postavkama.'
passage_all: 'Sve (Ugrađeno + sve knjige)'
passage_builtin: 'Samo ugrađeni tekstovi'
passage_book_prefix: 'Knjiga: %{title}'
# Progress
progress:
overall_key_progress: 'Ukupni napredak tipki'
unlocked_mastered: '%{unlocked}/%{total} otključano (%{mastered} savladano)'
# Skill tree
skill_tree:
title: ' Stablo vještina '
locked: 'Zaključano'
unlocked: 'otključano'
mastered: 'savladano'
in_progress: 'u tijeku'
complete: 'završeno'
locked_status: 'zaključano'
locked_notice: 'Završite %{count} primarnih slova za otključavanje grana'
branches_separator: 'Grane (dostupne nakon %{count} primarnih slova)'
unlocked_letters: 'Otključano %{unlocked}/%{total} slova'
level: 'Razina %{current}/%{total}'
level_zero: 'Razina 0/%{total}'
in_focus: ' u fokusu'
hint_navigate: '[↑↓/jk] Navigacija'
hint_scroll: '[PgUp/PgDn ili Ctrl+U/Ctrl+D] Pomicanje'
hint_back: '[q] Natrag'
hint_unlock: '[Enter] Otključaj'
hint_start_drill: '[Enter] Pokreni vježbu'
unlock_msg_1: 'Nakon otključavanja, zadana prilagodljiva vježba uključit će otključane tipke iz ove grane.'
unlock_msg_2: 'Ako se želite usredotočiti samo na ovu granu, pokrenite vježbu izravno iz stabla vještina.'
confirm_unlock: 'Otključati %{branch}?'
confirm_yn: '[y] Otključaj [n/ESC] Odustani'
lvl_prefix: 'Raz'
branch_primary_letters: 'Primarna slova'
branch_capital_letters: 'Velika slova'
branch_numbers: 'Brojevi 0-9'
branch_prose_punctuation: 'Interpunkcija'
branch_whitespace: 'Bjeline'
branch_code_symbols: 'Simboli koda'
level_frequency_order: 'Poredak po učestalosti'
level_common_sentence_capitals: 'Uobičajena velika slova'
level_name_capitals: 'Velika slova za imena'
level_remaining_capitals: 'Preostala velika slova'
level_common_digits: 'Uobičajene znamenke'
level_all_digits: 'Sve znamenke'
level_essential: 'Osnovno'
level_common: 'Uobičajeno'
level_expressive: 'Izražajno'
level_enter_return: 'Enter/Return'
level_tab_indent: 'Tab/Uvlačenje'
level_arithmetic_assignment: 'Aritmetika i pridruživanje'
level_grouping: 'Grupiranje'
level_logic_reference: 'Logika i reference'
level_special: 'Specijalno'
# Milestones
milestones:
unlock_title: ' Tipka otključana! '
mastery_title: ' Tipka savladana! '
branches_title: ' Nove grane vještina dostupne! '
branch_complete_title: ' Grana završena! '
all_unlocked_title: ' Sve tipke otključane! '
all_mastered_title: ' Potpuno vladanje tipkovnicom! '
unlocked: 'otključano'
mastered: 'savladano'
use_finger: 'Koristite %{finger}'
hold_right_shift: 'Držite desni Shift (desni mali prst)'
hold_left_shift: 'Držite lijevi Shift (lijevi mali prst)'
congratulations_all_letters: 'Čestitamo! Savladali ste svih %{count} primarnih slova'
new_branches_available: 'Nove grane vještina su sada dostupne:'
visit_skill_tree: 'Posjetite stablo vještina za otključavanje nove grane'
and_start_training: 'i počnite vježbati!'
open_skill_tree: 'Pritisnite [t] za otvaranje stabla vještina'
branch_complete_msg: 'Završili ste granu %{branch}!'
all_levels_mastered: 'Svih %{count} razina savladano.'
all_keys_confident: 'Svaka tipka u ovoj grani je na punoj razini pouzdanosti.'
all_unlocked_msg: 'Otključali ste svaku tipku na tipkovnici!'
all_unlocked_desc: 'Svaki znak, simbol i modifikator je sada dostupan u vašim vježbama.'
keep_practicing_mastery: 'Nastavite vježbati za izgradnju majstorstva — kada svaka tipka dosegne punu'
confidence_complete: 'razinu pouzdanosti, postigli ste potpuno vladanje tipkovnicom!'
all_mastered_msg: 'Čestitamo — postigli ste potpuno vladanje tipkovnicom!'
all_mastered_desc: 'Svaka tipka na tipkovnici je na maksimalnoj razini pouzdanosti.'
mastery_takes_practice: 'Majstorstvo nije odredište — zahtijeva stalnu vježbu.'
keep_drilling: 'Nastavite vježbati kako biste održali formu.'
hint_skill_tree_continue: '[t] Otvori stablo vještina [Bilo koja tipka] Nastavi'
hint_any_key: 'Pritisnite bilo koju tipku za nastavak'
input_blocked: 'Unos privremeno blokiran (%{ms}ms preostalo)'
unlock_msg_1: 'Odličan posao! Nastavite graditi vještine tipkanja.'
unlock_msg_2: 'Još jedna tipka dodana u vaš arsenal!'
unlock_msg_3: 'Vaša tipkovnica raste! Samo naprijed.'
unlock_msg_4: 'Korak bliže potpunom vladanju tipkovnicom!'
mastery_msg_1: 'Ova tipka je sada na punoj razini pouzdanosti!'
mastery_msg_2: 'Ovu tipku imate u malom prstu!'
mastery_msg_3: 'Mišićna memorija zaključana!'
mastery_msg_4: 'Još jedna tipka osvojena!'
# Keyboard explorer
keyboard:
title: ' Tipkovnica '
subtitle: 'Pritisnite bilo koju tipku ili kliknite tipku'
hint_navigate: '[←→↑↓/hjkl/Tab] Navigacija'
hint_back: '[q/ESC] Natrag'
key_label: 'Tipka: '
finger_label: 'Prst: '
hand_left: 'Lijeva'
hand_right: 'Desna'
finger_index: 'Kažiprst'
finger_middle: 'Srednji'
finger_ring: 'Prstenjak'
finger_pinky: 'Mali prst'
finger_thumb: 'Palac'
overall_accuracy: ' Ukupna točnost: %{correct}/%{total} (%{pct}%%)'
ranked_accuracy: ' Ocjenjena točnost: %{correct}/%{total} (%{pct}%%)'
confidence: 'Pouzdanost: '
no_data: 'Još nema podataka'
no_data_short: 'Nema podat.'
key_details: ' Detalji tipke '
key_details_char: ' Detalji tipke: ''%{ch}'' '
key_details_name: ' Detalji tipke: %{name} '
press_key_hint: 'Pritisnite tipku za prikaz detalja'
shift_label: 'Shift: '
shift_no: 'Ne'
overall_avg_time: 'Ukupno prosj. vrijeme: '
overall_best_time: 'Ukupno najbolje vrijeme: '
overall_samples: 'Ukupno uzoraka: '
overall_accuracy_label: 'Ukupna točnost: '
branch_label: 'Grana: '
level_label: 'Razina: '
built_in_key: 'Ugrađena tipka'
unlocked_label: 'Otključano: '
yes: 'Da'
no: 'Ne'
in_focus_label: 'U fokusu?: '
mastery_label: 'Majstorstvo: '
mastery_locked: 'Zaključano'
ranked_avg_time: 'Ocj. prosj. vrijeme: '
ranked_best_time: 'Ocj. najbolje vrijeme: '
ranked_samples: 'Ocj. uzoraka: '
ranked_accuracy_label: 'Ocj. točnost: '
# Intro dialogs
intro:
passage_title: ' Postavke preuzimanja tekstova '
code_title: ' Postavke preuzimanja koda '
enable_downloads: 'Omogući mrežna preuzimanja'
download_dir: 'Mapa za preuzimanje'
paragraphs_per_book: 'Odlomaka po knjizi (0 = cijela)'
whole_book: 'cijela knjiga'
snippets_per_repo: 'Isječaka po repozitoriju (0 = neograničeno)'
unlimited: 'neograničeno'
start_passage_drill: 'Pokreni vježbu teksta'
start_code_drill: 'Pokreni vježbu koda'
confirm: 'Potvrdi'
hint_navigate: '[Gore/Dolje] Navigacija'
hint_adjust: '[Lijevo/Desno] Prilagodi'
hint_edit: '[Tipkanje/Backspace] Uredi'
hint_confirm: '[Enter] Potvrdi'
hint_cancel: '[ESC] Odustani'
preparing_download: 'Priprema preuzimanja...'
download_passage_title: ' Preuzimanje izvora teksta '
download_code_title: ' Preuzimanje izvora koda '
book_label: ' Knjiga: %{name}'
repo_label: ' Repozitorij: %{name}'
progress_bytes: '[%{name}] %{downloaded}/%{total} bajtova'
downloaded_bytes: 'Preuzeto: %{bytes} bajtova'
downloading_book_progress: 'Preuzimanje knjige: [%{bar}] %{downloaded}/%{total} bajtova'
downloading_book_bytes: 'Preuzimanje knjige: %{bytes} bajtova'
downloading_code_progress: 'Preuzimanje: [%{bar}] %{downloaded}/%{total} bajtova'
downloading_code_bytes: 'Preuzimanje: %{bytes} bajtova'
current_book: 'Trenutno: %{name} (knjiga %{done}/%{total})'
current_repo: 'Trenutno: %{name} (repozitorij %{done}/%{total})'
passage_instructions_1: 'keydr može preuzeti tekstove iz Project Gutenberga za vježbu tipkanja.'
passage_instructions_2: 'Knjige se preuzimaju jednom i lokalno pohranjuju.'
passage_instructions_3: 'Konfigurirajte postavke preuzimanja, zatim pokrenite vježbu teksta.'
code_instructions_1: 'keydr može preuzeti otvoreni kod s GitHuba za vježbu tipkanja.'
code_instructions_2: 'Kod se preuzima jednom i lokalno pohranjuje.'
code_instructions_3: 'Konfigurirajte postavke preuzimanja, zatim pokrenite vježbu koda.'
# Status messages (from app.rs)
status:
recovery_files: 'Pronađene datoteke za oporavak od prekinutog uvoza. Podaci mogu biti nekonzistentni — razmislite o ponovnom uvozu.'
dir_not_exist: 'Mapa ne postoji: %{path}'
no_data_store: 'Nema dostupnog spremišta podataka'
serialization_error: 'Greška serijalizacije: %{error}'
exported_to: 'Izvezeno u %{path}'
export_failed: 'Izvoz neuspješan: %{error}'
could_not_read: 'Nije moguće pročitati datoteku: %{error}'
invalid_export: 'Nevaljana izvozna datoteka: %{error}'
unsupported_version: 'Nepodržana verzija izvoza: %{got} (očekivano %{expected})'
import_failed: 'Uvoz neuspješan: %{error}'
imported_theme_fallback: 'Uvoz uspješan (tema ''%{theme}'' nije pronađena, koristi se zadana)'
imported_success: 'Uvoz uspješan'
adaptive_unavailable: 'Prilagodljivi ocijenjeni način nedostupan: %{error}'
switched_to: 'Prebačeno na %{name}'
layout_changed: 'Raspored promijenjen na %{name}'
# Errors (for UI boundary translation)
errors:
unknown_language: 'Nepoznat jezik: %{key}'
unknown_layout: 'Nepoznat raspored tipkovnice: %{key}'
unsupported_pair: 'Nepodržani par jezik/raspored: %{language} + %{layout}'
language_blocked: 'Jezik je blokiran razinom podrške: %{key}'
# Common
common:
wpm: 'WPM'
cpm: 'CPM'
back: 'Natrag'

454
locales/hu.yml Normal file
View File

@@ -0,0 +1,454 @@
# Main menu
menu:
subtitle: 'Terminál gépelésgyakorló'
adaptive_drill: 'Adaptív gyakorlat'
adaptive_drill_desc: 'Fonetikus szavak adaptív betűfeloldással'
code_drill: 'Kód gyakorlat'
code_drill_desc: 'Kódszintaxis gépelés gyakorlása'
passage_drill: 'Szöveg gyakorlat'
passage_drill_desc: 'Könyvekből származó szövegek gépelése'
skill_tree: 'Képességfa'
skill_tree_desc: 'Haladási ágak megtekintése és gyakorlatok indítása'
keyboard: 'Billentyűzet'
keyboard_desc: 'Billentyűzetkiosztás és billentyűstatisztikák felfedezése'
statistics: 'Statisztika'
statistics_desc: 'Gépelési statisztikák megtekintése'
settings: 'Beállítások'
settings_desc: 'keydr konfigurálása'
day_streak: ' | %{days} napos sorozat'
key_progress: ' Billentyűhaladás %{unlocked}/%{total} (%{mastered} elsajátítva) | Cél %{target} WPM%{streak}'
hint_start: '[1-3] Indítás'
hint_skill_tree: '[t] Képességfa'
hint_keyboard: '[b] Billentyűzet'
hint_stats: '[s] Statisztika'
hint_settings: '[c] Beállítások'
hint_quit: '[q] Kilépés'
# Drill screen
drill:
title: ' Gyakorlat '
mode_adaptive: 'Adaptív'
mode_code: 'Kód (nem értékelt)'
mode_passage: 'Szöveg (nem értékelt)'
focus_char: 'Fókusz: ''%{ch}'''
focus_bigram: 'Fókusz: "%{bigram}"'
focus_both: 'Fókusz: ''%{ch}'' + "%{bigram}"'
header_wpm: 'WPM'
header_acc: 'Pont'
header_err: 'Hiba'
code_source: ' Kódforrás '
passage_source: ' Szövegforrás '
footer: '[ESC] Gyakorlat vége [Backspace] Törlés'
keys_reenabled: 'Billentyűk újra aktívak %{ms}ms múlva'
hint_end: '[ESC] Gyakorlat vége'
hint_backspace: '[Backspace] Törlés'
# Dashboard / drill result
dashboard:
title: ' Gyakorlat kész '
results: 'Eredmények'
unranked_note_prefix: ' (Nem értékelt'
unranked_note_suffix: ' nem számít a képességfába)'
speed: ' Sebesség: '
accuracy_label: ' Pontosság:'
time_label: ' Idő: '
errors_label: ' Hibák: '
correct_detail: ' (%{correct}/%{total} helyes)'
input_blocked: ' Bevitel ideiglenesen blokkolva '
input_blocked_ms: '(%{ms}ms hátra)'
hint_continue: '[c/Enter/Space] Tovább'
hint_retry: '[r] Újra'
hint_menu: '[q] Menü'
hint_stats: '[s] Statisztika'
hint_delete: '[x] Törlés'
# Stats sidebar (during drill)
sidebar:
title: ' Statisztika '
wpm: 'WPM: '
target: 'Cél: '
target_wpm: '%{wpm} WPM'
accuracy: 'Pontosság: '
progress: 'Haladás: '
correct: 'Helyes: '
errors: 'Hibák: '
time: 'Idő: '
last_drill: ' Utolsó gyakorlat '
vs_avg: ' vs átlag: '
# Statistics dashboard
stats:
title: ' Statisztika '
empty: 'Még nincs befejezett gyakorlat. Kezdjen gépelni!'
tab_dashboard: '[1] Áttekintés'
tab_history: '[2] Előzmények'
tab_activity: '[3] Aktivitás'
tab_accuracy: '[4] Pontosság'
tab_timing: '[5] Időzítés'
tab_ngrams: '[6] N-gramok'
hint_back: '[ESC] Vissza'
hint_next_tab: '[Tab] Következő fül'
hint_switch_tab: '[1-6] Fül váltás'
hint_navigate: '[j/k] Navigáció'
hint_page: '[PgUp/PgDn] Lap'
hint_delete: '[x] Törlés'
summary_title: ' Összefoglaló '
drills: ' Gyakorlatok: '
avg_wpm: ' Átl. WPM: '
best_wpm: ' Legjobb WPM: '
accuracy_label: ' Pontosság: '
total_time: ' Összes idő: '
wpm_chart_title: ' WPM gyakorlatonként (Utolsó 20, Cél: %{target}) '
accuracy_chart_title: ' Pontosság %% (Utolsó 50 gyakorlat) '
chart_drill: 'Gyakorlat #'
chart_accuracy_pct: 'Pontosság %%'
sessions_title: ' Legutóbbi munkamenetek '
session_header: ' # WPM Raw Pont%% Idő Dátum/Idő Mód Értékelt Részl.'
session_separator: ' ─────────────────────────────────────────────────────────────────────'
delete_confirm: 'Munkamenet #%{idx} törlése? (y/n)'
confirm_title: ' Megerősítés '
yes: 'igen'
no: 'nem'
keyboard_accuracy_title: ' Billentyűzet pontosság %% '
keyboard_timing_title: ' Billentyűzet időzítés (ms) '
slowest_keys_title: ' Leglassabb billentyűk (ms) '
fastest_keys_title: ' Leggyorsabb billentyűk (ms) '
worst_accuracy_title: ' Legrosszabb pontosság (%%) '
best_accuracy_title: ' Legjobb pontosság (%%) '
not_enough_data: ' Nincs elég adat'
streaks_title: ' Sorozatok '
current_streak: ' Jelenlegi: '
best_streak: ' Legjobb: '
active_days: ' Aktív napok: '
top_days_none: ' Legjobb napok: nincs'
top_days: ' Legjobb napok: %{days}'
wpm_label: ' WPM: %{avg}/%{target} (%{pct}%%)'
acc_label: ' Pont: %{pct}%%'
keys_label: ' Billentyűk: %{unlocked}/%{total} (%{mastered} elsajátítva)'
ngram_empty: 'Végezzen el néhány adaptív gyakorlatot az n-gram adatok megjelenítéséhez'
ngram_header_speed_narrow: ' Bgrm Seb Várh Anom%'
ngram_header_error_narrow: ' Bgrm Hib Mnt Arány Vrh Anom%'
ngram_header_speed: ' Bigram Seb Várható Minták Anom%'
ngram_header_error: ' Bigram Hibák Minták Arány Várható Anom%'
focus_title: ' Aktív fókusz '
focus_char_label: ' Fókusz: '
focus_bigram_value: 'Bigram %{label}'
focus_plus: ' + '
anomaly_error: 'hiba'
anomaly_speed: 'sebesség'
focus_detail_both: ' Kar. ''%{ch}'': leggyengébb billentyű | Bigram %{label}: %{type} anomália %{pct}%%'
focus_detail_char_only: ' Kar. ''%{ch}'': leggyengébb billentyű, nincs megerősített bigram anomália'
focus_detail_bigram_only: ' (%{type} anomália: %{pct}%%)'
focus_empty: ' Végezzen el néhány adaptív gyakorlatot a fókusz adatok megjelenítéséhez'
error_anomalies_title: ' Hiba anomáliák (%{count}) '
no_error_anomalies: ' Nem észlelt hiba anomáliák'
speed_anomalies_title: ' Sebesség anomáliák (%{count}) '
no_speed_anomalies: ' Nem észlelt sebesség anomáliák'
scope_label_prefix: ' '
bi_label: ' | Bi: %{count}'
hes_label: ' | Hes: >%{ms}ms'
focus_char_value: 'Kar. ''%{ch}'''
# Activity heatmap
heatmap:
title: ' Napi aktivitás (munkamenetek naponta) '
jan: 'Jan'
feb: 'Feb'
mar: 'Már'
apr: 'Ápr'
may: 'Máj'
jun: 'Jún'
jul: 'Júl'
aug: 'Aug'
sep: 'Sze'
oct: 'Okt'
nov: 'Nov'
dec: 'Dec'
# Chart
chart:
wpm_over_time: ' WPM időben '
drill_number: 'Gyakorlat #'
# Settings
settings:
title: ' Beállítások '
subtitle: 'Nyilakkal navigáljon, Enter/Jobbra a módosításhoz, ESC a mentéshez'
target_wpm: 'Cél WPM'
theme: 'Téma'
word_count: 'Szószám'
ui_language: 'Felület nyelve'
dictionary_language: 'Szótár nyelve'
keyboard_layout: 'Billentyűzetkiosztás'
code_language: 'Programnyelv'
code_downloads: 'Kód letöltések'
on: 'Be'
off: 'Ki'
code_download_dir: 'Kód letöltési mappa'
snippets_per_repo: 'Részletek repónként'
unlimited: 'Korlátlan'
download_code_now: 'Kód letöltése most'
run_downloader: 'Letöltő futtatása'
passage_downloads: 'Szöveg letöltések'
passage_download_dir: 'Szöveg letöltési mappa'
paragraphs_per_book: 'Bekezdések könyvenként'
whole_book: 'Teljes könyv'
download_passages_now: 'Szövegek letöltése most'
export_path: 'Exportálási útvonal'
export_data: 'Adatok exportálása'
export_now: 'Exportálás most'
import_path: 'Importálási útvonal'
import_data: 'Adatok importálása'
import_now: 'Importálás most'
hint_save_back: '[ESC] Mentés és vissza'
hint_change_value: '[Enter/nyilak] Érték módosítása'
hint_edit_path: '[Enter az útvonalon] Szerkesztés'
hint_move: '[←→] Mozgatás'
hint_tab_complete: '[Tab] Kiegészítés (végén)'
hint_confirm: '[Enter] Megerősítés'
hint_cancel: '[Esc] Mégse'
success_title: ' Sikeres '
error_title: ' Hiba '
press_any_key: 'Nyomjon egy billentyűt'
file_exists_title: ' Fájl létezik '
file_exists: 'Egy fájl már létezik ezen az útvonalon.'
overwrite_rename: '[d] Felülírás [r] Átnevezés [Esc] Mégse'
erase_warning: 'Ez törli a jelenlegi adatokat.'
export_first: 'Előbb exportáljon, ha meg szeretné tartani.'
proceed_yn: 'Folytatja? (y/n)'
confirm_import_title: ' Importálás megerősítése '
# Selection screens
select:
dictionary_language_title: ' Szótár nyelv kiválasztása '
keyboard_layout_title: ' Billentyűzetkiosztás kiválasztása '
code_language_title: ' Programnyelv kiválasztása '
passage_source_title: ' Szövegforrás kiválasztása '
ui_language_title: ' Felület nyelvének kiválasztása '
more_above: '... még %{count} feljebb ...'
more_below: '... még %{count} lejjebb ...'
current: ' (jelenlegi)'
disabled: ' (letiltva)'
enabled_default: ' (engedélyezve, alapért.: %{layout})'
enabled: ' (engedélyezve)'
disabled_blocked: ' (letiltva: blokkolva)'
built_in: ' (beépített)'
cached: ' (gyorsítótárazva)'
disabled_download: ' (letiltva: letöltés szükséges)'
download_required: ' (letöltés szükséges)'
hint_navigate: '[Fel/Le/PgUp/PgDn] Navigáció'
hint_confirm: '[Enter] Megerősítés'
hint_back: '[ESC] Vissza'
language_resets_layout: 'A nyelv kiválasztása visszaállítja a billentyűzetkiosztást az adott nyelv alapértelmezésére.'
layout_no_language_change: 'A kiosztás módosítása nem változtatja meg a szótár nyelvét.'
disabled_network_notice: 'Egyes nyelvek letiltva: engedélyezze a hálózati letöltéseket a bevezetőben/beállításokban.'
disabled_sources_notice: 'Egyes források letiltva: engedélyezze a hálózati letöltéseket a bevezetőben/beállításokban.'
passage_all: 'Összes (Beépített + minden könyv)'
passage_builtin: 'Csak beépített szövegek'
passage_book_prefix: 'Könyv: %{title}'
# Progress
progress:
overall_key_progress: 'Általános billentyűhaladás'
unlocked_mastered: '%{unlocked}/%{total} feloldva (%{mastered} elsajátítva)'
# Skill tree
skill_tree:
title: ' Képességfa '
locked: 'Zárolva'
unlocked: 'feloldva'
mastered: 'elsajátítva'
in_progress: 'folyamatban'
complete: 'kész'
locked_status: 'zárolva'
locked_notice: 'Végezzen el %{count} elsődleges betűt az ágak feloldásához'
branches_separator: 'Ágak (elérhető %{count} elsődleges betű után)'
unlocked_letters: 'Feloldva %{unlocked}/%{total} betű'
level: 'Szint %{current}/%{total}'
level_zero: 'Szint 0/%{total}'
in_focus: ' fókuszban'
hint_navigate: '[↑↓/jk] Navigáció'
hint_scroll: '[PgUp/PgDn vagy Ctrl+U/Ctrl+D] Görgetés'
hint_back: '[q] Vissza'
hint_unlock: '[Enter] Feloldás'
hint_start_drill: '[Enter] Gyakorlat indítása'
unlock_msg_1: 'Feloldás után az alapértelmezett adaptív gyakorlat bevonja az ág feloldott billentyűit.'
unlock_msg_2: 'Ha csak erre az ágra szeretne összpontosítani, indítson gyakorlatot közvetlenül a képességfából.'
confirm_unlock: '%{branch} feloldása?'
confirm_yn: '[y] Feloldás [n/ESC] Mégse'
lvl_prefix: 'Szint'
branch_primary_letters: 'Elsődleges betűk'
branch_capital_letters: 'Nagybetűk'
branch_numbers: 'Számok 0-9'
branch_prose_punctuation: 'Írásjelek'
branch_whitespace: 'Szóközök'
branch_code_symbols: 'Kódszimbólumok'
level_frequency_order: 'Gyakoriság szerinti sorrend'
level_common_sentence_capitals: 'Gyakori mondatkezdő nagybetűk'
level_name_capitals: 'Névkezdő nagybetűk'
level_remaining_capitals: 'Fennmaradó nagybetűk'
level_common_digits: 'Gyakori számjegyek'
level_all_digits: 'Összes számjegy'
level_essential: 'Alapvető'
level_common: 'Gyakori'
level_expressive: 'Kifejező'
level_enter_return: 'Enter/Return'
level_tab_indent: 'Tab/Behúzás'
level_arithmetic_assignment: 'Aritmetika és hozzárendelés'
level_grouping: 'Csoportosítás'
level_logic_reference: 'Logika és hivatkozás'
level_special: 'Speciális'
# Milestones
milestones:
unlock_title: ' Billentyű feloldva! '
mastery_title: ' Billentyű elsajátítva! '
branches_title: ' Új képességágak elérhetők! '
branch_complete_title: ' Ág befejezve! '
all_unlocked_title: ' Minden billentyű feloldva! '
all_mastered_title: ' Teljes billentyűzet elsajátítva! '
unlocked: 'feloldva'
mastered: 'elsajátítva'
use_finger: 'Használja a %{finger} ujját'
hold_right_shift: 'Tartsa a jobb Shift-et (jobb kisujj)'
hold_left_shift: 'Tartsa a bal Shift-et (bal kisujj)'
congratulations_all_letters: 'Gratulálunk! Elsajátította mind a %{count} elsődleges betűt'
new_branches_available: 'Új képességágak elérhetők:'
visit_skill_tree: 'Látogasson el a képességfába egy új ág feloldásához'
and_start_training: 'és kezdje el a gyakorlást!'
open_skill_tree: 'Nyomja meg a [t] gombot a képességfa megnyitásához'
branch_complete_msg: 'Befejezte a %{branch} ágat!'
all_levels_mastered: 'Mind a %{count} szint elsajátítva.'
all_keys_confident: 'Minden billentyű ebben az ágban teljes megbízhatóságú.'
all_unlocked_msg: 'Feloldotta a billentyűzet összes billentyűjét!'
all_unlocked_desc: 'Minden karakter, szimbólum és módosító elérhető a gyakorlatokban.'
keep_practicing_mastery: 'Folytassa a gyakorlást a mesteri szint eléréséhez — ha minden billentyű eléri a teljes'
confidence_complete: 'megbízhatóságot, elérte a teljes billentyűzet elsajátítását!'
all_mastered_msg: 'Gratulálunk — elérte a teljes billentyűzet elsajátítását!'
all_mastered_desc: 'A billentyűzet minden billentyűje maximális megbízhatóságú.'
mastery_takes_practice: 'Az elsajátítás nem végállomás — folyamatos gyakorlást igényel.'
keep_drilling: 'Folytassa a gyakorlást, hogy megőrizze szintjét.'
hint_skill_tree_continue: '[t] Képességfa megnyitása [Bármely billentyű] Tovább'
hint_any_key: 'Nyomjon bármely billentyűt a folytatáshoz'
input_blocked: 'Bevitel ideiglenesen blokkolva (%{ms}ms hátra)'
unlock_msg_1: 'Szép munka! Fejlessze tovább gépelési készségeit.'
unlock_msg_2: 'Újabb billentyű az arzenáljában!'
unlock_msg_3: 'A billentyűzete bővül! Így tovább.'
unlock_msg_4: 'Egy lépéssel közelebb a teljes elsajátításhoz!'
mastery_msg_1: 'Ez a billentyű most teljes megbízhatóságú!'
mastery_msg_2: 'Ezt a billentyűt tökéletesen tudja!'
mastery_msg_3: 'Izommemória rögzítve!'
mastery_msg_4: 'Még egy billentyű meghódítva!'
# Keyboard explorer
keyboard:
title: ' Billentyűzet '
subtitle: 'Nyomjon meg bármely billentyűt vagy kattintson'
hint_navigate: '[←→↑↓/hjkl/Tab] Navigáció'
hint_back: '[q/ESC] Vissza'
key_label: 'Billentyű: '
finger_label: 'Ujj: '
hand_left: 'Bal'
hand_right: 'Jobb'
finger_index: 'Mutatóujj'
finger_middle: 'Középső ujj'
finger_ring: 'Gyűrűsujj'
finger_pinky: 'Kisujj'
finger_thumb: 'Hüvelykujj'
overall_accuracy: ' Összesített pontosság: %{correct}/%{total} (%{pct}%%)'
ranked_accuracy: ' Értékelt pontosság: %{correct}/%{total} (%{pct}%%)'
confidence: 'Megbízhatóság: '
no_data: 'Még nincs adat'
no_data_short: 'Nincs adat'
key_details: ' Billentyű részletei '
key_details_char: ' Billentyű részletei: ''%{ch}'' '
key_details_name: ' Billentyű részletei: %{name} '
press_key_hint: 'Nyomjon egy billentyűt a részletekhez'
shift_label: 'Shift: '
shift_no: 'Nem'
overall_avg_time: 'Össz. átl. idő: '
overall_best_time: 'Össz. legjobb idő: '
overall_samples: 'Össz. minták: '
overall_accuracy_label: 'Össz. pontosság: '
branch_label: 'Ág: '
level_label: 'Szint: '
built_in_key: 'Beépített billentyű'
unlocked_label: 'Feloldva: '
yes: 'Igen'
no: 'Nem'
in_focus_label: 'Fókuszban?: '
mastery_label: 'Elsajátítás: '
mastery_locked: 'Zárolva'
ranked_avg_time: 'Ért. átl. idő: '
ranked_best_time: 'Ért. legjobb idő: '
ranked_samples: 'Ért. minták: '
ranked_accuracy_label: 'Ért. pontosság: '
# Intro dialogs
intro:
passage_title: ' Szövegletöltés beállítása '
code_title: ' Kódletöltés beállítása '
enable_downloads: 'Hálózati letöltések engedélyezése'
download_dir: 'Letöltési mappa'
paragraphs_per_book: 'Bekezdések könyvenként (0 = teljes)'
whole_book: 'teljes könyv'
snippets_per_repo: 'Részletek repónként (0 = korlátlan)'
unlimited: 'korlátlan'
start_passage_drill: 'Szöveg gyakorlat indítása'
start_code_drill: 'Kód gyakorlat indítása'
confirm: 'Megerősítés'
hint_navigate: '[Fel/Le] Navigáció'
hint_adjust: '[Bal/Jobb] Állítás'
hint_edit: '[Gépelés/Backspace] Szerkesztés'
hint_confirm: '[Enter] Megerősítés'
hint_cancel: '[ESC] Mégse'
preparing_download: 'Letöltés előkészítése...'
download_passage_title: ' Szövegforrás letöltése '
download_code_title: ' Kódforrás letöltése '
book_label: ' Könyv: %{name}'
repo_label: ' Repó: %{name}'
progress_bytes: '[%{name}] %{downloaded}/%{total} bájt'
downloaded_bytes: 'Letöltve: %{bytes} bájt'
downloading_book_progress: 'Könyv letöltése: [%{bar}] %{downloaded}/%{total} bájt'
downloading_book_bytes: 'Könyv letöltése: %{bytes} bájt'
downloading_code_progress: 'Letöltés: [%{bar}] %{downloaded}/%{total} bájt'
downloading_code_bytes: 'Letöltés: %{bytes} bájt'
current_book: 'Jelenlegi: %{name} (könyv %{done}/%{total})'
current_repo: 'Jelenlegi: %{name} (repó %{done}/%{total})'
passage_instructions_1: 'A keydr letölthet szövegeket a Project Gutenbergből gépelésgyakorláshoz.'
passage_instructions_2: 'A könyvek egyszer töltődnek le és helyben tárolódnak.'
passage_instructions_3: 'Állítsa be a letöltési beállításokat, majd indítson szöveg gyakorlatot.'
code_instructions_1: 'A keydr letölthet nyílt forráskódot a GitHubról gépelésgyakorláshoz.'
code_instructions_2: 'A kód egyszer töltődik le és helyben tárolódik.'
code_instructions_3: 'Állítsa be a letöltési beállításokat, majd indítson kód gyakorlatot.'
# Status messages (from app.rs)
status:
recovery_files: 'Helyreállítási fájlok találhatók megszakított importálásból. Az adatok inkonzisztensek lehetnek — fontolja meg az újraimportálást.'
dir_not_exist: 'A mappa nem létezik: %{path}'
no_data_store: 'Nincs elérhető adattár'
serialization_error: 'Sorosítási hiba: %{error}'
exported_to: 'Exportálva ide: %{path}'
export_failed: 'Exportálás sikertelen: %{error}'
could_not_read: 'Nem sikerült olvasni a fájlt: %{error}'
invalid_export: 'Érvénytelen exportfájl: %{error}'
unsupported_version: 'Nem támogatott exportverzió: %{got} (várt: %{expected})'
import_failed: 'Importálás sikertelen: %{error}'
imported_theme_fallback: 'Importálás sikeres (''%{theme}'' téma nem található, alapértelmezett használva)'
imported_success: 'Importálás sikeres'
adaptive_unavailable: 'Adaptív értékelt mód nem elérhető: %{error}'
switched_to: 'Átváltva erre: %{name}'
layout_changed: 'Kiosztás megváltoztatva: %{name}'
# Errors (for UI boundary translation)
errors:
unknown_language: 'Ismeretlen nyelv: %{key}'
unknown_layout: 'Ismeretlen billentyűzetkiosztás: %{key}'
unsupported_pair: 'Nem támogatott nyelv/kiosztás pár: %{language} + %{layout}'
language_blocked: 'A nyelv blokkolva a támogatási szint által: %{key}'
# Common
common:
wpm: 'WPM'
cpm: 'CPM'
back: 'Vissza'

454
locales/it.yml Normal file
View File

@@ -0,0 +1,454 @@
# Menu principale
menu:
subtitle: 'Tutor di Digitazione nel Terminale'
adaptive_drill: 'Esercizio Adattivo'
adaptive_drill_desc: 'Parole fonetiche con sblocco adattivo dei tasti'
code_drill: 'Esercizio di Codice'
code_drill_desc: 'Esercitati a digitare sintassi di codice'
passage_drill: 'Esercizio di Brano'
passage_drill_desc: 'Digita brani da libri'
skill_tree: 'Albero delle Abilità'
skill_tree_desc: 'Visualizza rami di progressione e avvia esercizi'
keyboard: 'Tastiera'
keyboard_desc: 'Esplora il layout della tastiera e le statistiche'
statistics: 'Statistiche'
statistics_desc: 'Visualizza le tue statistiche di digitazione'
settings: 'Impostazioni'
settings_desc: 'Configura keydr'
day_streak: ' | %{days} giorni consecutivi'
key_progress: ' Progresso Tasti %{unlocked}/%{total} (%{mastered} padroneggiati) | Obiettivo %{target} WPM%{streak}'
hint_start: '[1-3] Avvia'
hint_skill_tree: '[t] Albero delle Abilità'
hint_keyboard: '[b] Tastiera'
hint_stats: '[s] Statistiche'
hint_settings: '[c] Impostazioni'
hint_quit: '[q] Esci'
# Schermata esercizio
drill:
title: ' Esercizio '
mode_adaptive: 'Adattivo'
mode_code: 'Codice (Non classificato)'
mode_passage: 'Brano (Non classificato)'
focus_char: 'Focus: ''%{ch}'''
focus_bigram: 'Focus: "%{bigram}"'
focus_both: 'Focus: ''%{ch}'' + "%{bigram}"'
header_wpm: 'WPM'
header_acc: 'Pre'
header_err: 'Err'
code_source: ' Sorgente codice '
passage_source: ' Sorgente brano '
footer: '[ESC] Fine [Backspace] Cancella'
keys_reenabled: 'Tasti riattivati in %{ms}ms'
hint_end: '[ESC] Fine esercizio'
hint_backspace: '[Backspace] Cancella'
# Pannello / risultato dell'esercizio
dashboard:
title: ' Esercizio Completato '
results: 'Risultati'
unranked_note_prefix: ' (Non classificato'
unranked_note_suffix: ' non conta per l''albero delle abilità)'
speed: ' Velocità: '
accuracy_label: ' Precisione: '
time_label: ' Tempo: '
errors_label: ' Errori: '
correct_detail: ' (%{correct}/%{total} corretti)'
input_blocked: ' Input temporaneamente bloccato '
input_blocked_ms: '(%{ms}ms rimanenti)'
hint_continue: '[c/Enter/Space] Continua'
hint_retry: '[r] Riprova'
hint_menu: '[q] Menu'
hint_stats: '[s] Statistiche'
hint_delete: '[x] Elimina'
# Barra laterale statistiche (durante l'esercizio)
sidebar:
title: ' Statistiche '
wpm: 'WPM: '
target: 'Obiettivo: '
target_wpm: '%{wpm} WPM'
accuracy: 'Precisione: '
progress: 'Progresso: '
correct: 'Corretti: '
errors: 'Errori: '
time: 'Tempo: '
last_drill: ' Ultimo Esercizio '
vs_avg: ' vs med: '
# Pannello statistiche
stats:
title: ' Statistiche '
empty: 'Nessun esercizio completato. Inizia a digitare!'
tab_dashboard: '[1] Pannello'
tab_history: '[2] Cronologia'
tab_activity: '[3] Attività'
tab_accuracy: '[4] Precisione'
tab_timing: '[5] Tempistica'
tab_ngrams: '[6] N-grammi'
hint_back: '[ESC] Indietro'
hint_next_tab: '[Tab] Scheda successiva'
hint_switch_tab: '[1-6] Cambia scheda'
hint_navigate: '[j/k] Naviga'
hint_page: '[PgUp/PgDn] Pagina'
hint_delete: '[x] Elimina'
summary_title: ' Riepilogo '
drills: ' Esercizi: '
avg_wpm: ' WPM Med: '
best_wpm: ' Miglior WPM: '
accuracy_label: ' Precisione: '
total_time: ' Tempo totale: '
wpm_chart_title: ' WPM per Esercizio (Ultimi 20, Obiettivo: %{target}) '
accuracy_chart_title: ' Precisione %% (Ultimi 50 Esercizi) '
chart_drill: 'Esercizio #'
chart_accuracy_pct: 'Precisione %%'
sessions_title: ' Sessioni Recenti '
session_header: ' # WPM Raw Pre%% Tempo Data/Ora Modo Class. Parziale'
session_separator: ' ─────────────────────────────────────────────────────────────────────'
delete_confirm: 'Eliminare sessione #%{idx}? (y/n)'
confirm_title: ' Conferma '
yes: 'sì'
no: 'no'
keyboard_accuracy_title: ' Precisione Tastiera %% '
keyboard_timing_title: ' Tempistica Tastiera (ms) '
slowest_keys_title: ' Tasti più Lenti (ms) '
fastest_keys_title: ' Tasti più Veloci (ms) '
worst_accuracy_title: ' Peggiore Precisione (%%) '
best_accuracy_title: ' Migliore Precisione (%%) '
not_enough_data: ' Dati insufficienti'
streaks_title: ' Serie '
current_streak: ' Attuale: '
best_streak: ' Migliore: '
active_days: ' Giorni attivi: '
top_days_none: ' Giorni migliori: nessuno'
top_days: ' Giorni migliori: %{days}'
wpm_label: ' WPM: %{avg}/%{target} (%{pct}%%)'
acc_label: ' Pre: %{pct}%%'
keys_label: ' Tasti: %{unlocked}/%{total} (%{mastered} padroneggiati)'
ngram_empty: 'Completa esercizi adattivi per vedere dati n-grammi'
ngram_header_speed_narrow: ' Bgrm Vel Att Anom%'
ngram_header_error_narrow: ' Bgrm Err Cmp Tasso Att Anom%'
ngram_header_speed: ' Bigramma Vel Att Camp. Anom%'
ngram_header_error: ' Bigramma Errori Camp. Tasso Att Anom%'
focus_title: ' Focus attivo '
focus_char_label: ' Focus: '
focus_bigram_value: 'Bigramma %{label}'
focus_plus: ' + '
anomaly_error: 'errore'
anomaly_speed: 'velocità'
focus_detail_both: ' Carattere ''%{ch}'': tasto più debole | Bigramma %{label}: anomalia di %{type} %{pct}%%'
focus_detail_char_only: ' Carattere ''%{ch}'': tasto più debole, nessuna anomalia di bigramma confermata'
focus_detail_bigram_only: ' (anomalia di %{type}: %{pct}%%)'
focus_empty: ' Completa esercizi adattivi per vedere dati di focus'
error_anomalies_title: ' Anomalie di Errore (%{count}) '
no_error_anomalies: ' Nessuna anomalia di errore rilevata'
speed_anomalies_title: ' Anomalie di Velocità (%{count}) '
no_speed_anomalies: ' Nessuna anomalia di velocità rilevata'
scope_label_prefix: ' '
bi_label: ' | Bi: %{count}'
hes_label: ' | Esi: >%{ms}ms'
focus_char_value: 'Carattere ''%{ch}'''
# Mappa di attività
heatmap:
title: ' Attività Giornaliera (Sessioni per Giorno) '
jan: 'Gen'
feb: 'Feb'
mar: 'Mar'
apr: 'Apr'
may: 'Mag'
jun: 'Giu'
jul: 'Lug'
aug: 'Ago'
sep: 'Set'
oct: 'Ott'
nov: 'Nov'
dec: 'Dic'
# Grafico
chart:
wpm_over_time: ' WPM nel Tempo '
drill_number: 'Esercizio #'
# Impostazioni
settings:
title: ' Impostazioni '
subtitle: 'Usa le frecce per navigare, Invio/Destra per cambiare, ESC per salvare e uscire'
target_wpm: 'WPM Obiettivo'
theme: 'Tema'
word_count: 'Numero di Parole'
ui_language: 'Lingua dell''Interfaccia'
dictionary_language: 'Lingua del Dizionario'
keyboard_layout: 'Layout della Tastiera'
code_language: 'Linguaggio di Codice'
code_downloads: 'Download di Codice'
on: 'Sì'
off: 'No'
code_download_dir: 'Dir. Download Codice'
snippets_per_repo: 'Frammenti per Repo'
unlimited: 'Illimitato'
download_code_now: 'Scarica Codice Ora'
run_downloader: 'Avvia downloader'
passage_downloads: 'Download di Brani'
passage_download_dir: 'Dir. Download Brani'
paragraphs_per_book: 'Paragrafi per Libro'
whole_book: 'Libro intero'
download_passages_now: 'Scarica Brani Ora'
export_path: 'Percorso di Esportazione'
export_data: 'Esporta Dati'
export_now: 'Esporta ora'
import_path: 'Percorso di Importazione'
import_data: 'Importa Dati'
import_now: 'Importa ora'
hint_save_back: '[ESC] Salva e indietro'
hint_change_value: '[Enter/frecce] Cambia valore'
hint_edit_path: '[Enter su percorso] Modifica'
hint_move: '[←→] Sposta'
hint_tab_complete: '[Tab] Completa (alla fine)'
hint_confirm: '[Enter] Conferma'
hint_cancel: '[Esc] Annulla'
success_title: ' Successo '
error_title: ' Errore '
press_any_key: 'Premi un tasto qualsiasi'
file_exists_title: ' File Esistente '
file_exists: 'Un file esiste già in questo percorso.'
overwrite_rename: '[d] Sovrascrivi [r] Rinomina [Esc] Annulla'
erase_warning: 'Questo cancellerà i tuoi dati attuali.'
export_first: 'Esporta prima se vuoi conservarli.'
proceed_yn: 'Procedere? (y/n)'
confirm_import_title: ' Conferma Importazione '
# Schermate di selezione
select:
dictionary_language_title: ' Seleziona Lingua del Dizionario '
keyboard_layout_title: ' Seleziona Layout della Tastiera '
code_language_title: ' Seleziona Linguaggio di Codice '
passage_source_title: ' Seleziona Sorgente Brani '
ui_language_title: ' Seleziona Lingua dell''Interfaccia '
more_above: '... %{count} altri sopra ...'
more_below: '... %{count} altri sotto ...'
current: ' (attuale)'
disabled: ' (disattivato)'
enabled_default: ' (attivato, predefinito: %{layout})'
enabled: ' (attivato)'
disabled_blocked: ' (disattivato: bloccato)'
built_in: ' (integrato)'
cached: ' (in cache)'
disabled_download: ' (disattivato: download richiesto)'
download_required: ' (download richiesto)'
hint_navigate: '[Up/Down/PgUp/PgDn] Naviga'
hint_confirm: '[Enter] Conferma'
hint_back: '[ESC] Indietro'
language_resets_layout: 'Selezionare una lingua reimposta il layout a quello predefinito di quella lingua.'
layout_no_language_change: 'Cambiare layout non cambia la lingua del dizionario.'
disabled_network_notice: 'Alcune lingue sono disattivate: attiva i download in intro/impostazioni.'
disabled_sources_notice: 'Alcune sorgenti sono disattivate: attiva i download in intro/impostazioni.'
passage_all: 'Tutti (Integrati + tutti i libri)'
passage_builtin: 'Solo brani integrati'
passage_book_prefix: 'Libro: %{title}'
# Progresso
progress:
overall_key_progress: 'Progresso Globale dei Tasti'
unlocked_mastered: '%{unlocked}/%{total} sbloccati (%{mastered} padroneggiati)'
# Albero delle abilità
skill_tree:
title: ' Albero delle Abilità '
locked: 'Bloccato'
unlocked: 'sbloccato'
mastered: 'padroneggiato'
in_progress: 'in corso'
complete: 'completato'
locked_status: 'bloccato'
locked_notice: 'Completa %{count} lettere primarie per sbloccare i rami'
branches_separator: 'Rami (disponibili dopo %{count} lettere primarie)'
unlocked_letters: 'Sbloccate %{unlocked}/%{total} lettere'
level: 'Livello %{current}/%{total}'
level_zero: 'Livello 0/%{total}'
in_focus: ' in focus'
hint_navigate: '[↑↓/jk] Naviga'
hint_scroll: '[PgUp/PgDn o Ctrl+U/Ctrl+D] Scorri'
hint_back: '[q] Indietro'
hint_unlock: '[Enter] Sblocca'
hint_start_drill: '[Enter] Avvia Esercizio'
unlock_msg_1: 'Una volta sbloccato, l''esercizio adattivo includerà i tasti di questo ramo che sono sbloccati.'
unlock_msg_2: 'Se vuoi concentrarti solo su questo ramo, avvia un esercizio direttamente da questo ramo nell''Albero delle Abilità.'
confirm_unlock: 'Sbloccare %{branch}?'
confirm_yn: '[y] Sblocca [n/ESC] Annulla'
lvl_prefix: 'Liv'
branch_primary_letters: 'Lettere Primarie'
branch_capital_letters: 'Lettere Maiuscole'
branch_numbers: 'Numeri 0-9'
branch_prose_punctuation: 'Punteggiatura di Prosa'
branch_whitespace: 'Spazi Bianchi'
branch_code_symbols: 'Simboli di Codice'
level_frequency_order: 'Ordine di Frequenza'
level_common_sentence_capitals: 'Maiuscole di Frase Comuni'
level_name_capitals: 'Maiuscole di Nomi'
level_remaining_capitals: 'Maiuscole Rimanenti'
level_common_digits: 'Cifre Comuni'
level_all_digits: 'Tutte le Cifre'
level_essential: 'Essenziale'
level_common: 'Comune'
level_expressive: 'Espressivo'
level_enter_return: 'Invio/Ritorno'
level_tab_indent: 'Tab/Rientro'
level_arithmetic_assignment: 'Aritmetica e Assegnazione'
level_grouping: 'Raggruppamento'
level_logic_reference: 'Logica e Riferimento'
level_special: 'Speciale'
# Traguardi
milestones:
unlock_title: ' Tasto Sbloccato! '
mastery_title: ' Tasto Padroneggiato! '
branches_title: ' Nuovi Rami Disponibili! '
branch_complete_title: ' Ramo Completato! '
all_unlocked_title: ' Tutti i Tasti Sbloccati! '
all_mastered_title: ' Padronanza Totale della Tastiera! '
unlocked: 'sbloccato'
mastered: 'padroneggiato'
use_finger: 'Usa il tuo %{finger}'
hold_right_shift: 'Tieni premuto Shift Destro (mignolo destro)'
hold_left_shift: 'Tieni premuto Shift Sinistro (mignolo sinistro)'
congratulations_all_letters: 'Congratulazioni! Hai padroneggiato tutte le %{count} lettere primarie'
new_branches_available: 'Nuovi rami di abilità sono disponibili:'
visit_skill_tree: 'Visita l''Albero delle Abilità per sbloccare un nuovo ramo'
and_start_training: 'e inizia ad allenarti!'
open_skill_tree: 'Premi [t] per aprire l''Albero delle Abilità'
branch_complete_msg: 'Hai completato il ramo %{branch}!'
all_levels_mastered: 'Tutti i %{count} livelli padroneggiati.'
all_keys_confident: 'Ogni tasto in questo ramo è a confidenza massima.'
all_unlocked_msg: 'Hai sbloccato tutti i tasti della tastiera!'
all_unlocked_desc: 'Ogni carattere, simbolo e modificatore è disponibile nei tuoi esercizi.'
keep_practicing_mastery: 'Continua a esercitarti per raggiungere la padronanza — quando ogni tasto raggiungerà'
confidence_complete: 'la confidenza massima, avrai raggiunto la padronanza totale della tastiera!'
all_mastered_msg: 'Congratulazioni — hai raggiunto la padronanza totale della tastiera!'
all_mastered_desc: 'Ogni tasto della tastiera è a confidenza massima.'
mastery_takes_practice: 'La padronanza non è una destinazione — richiede pratica continua.'
keep_drilling: 'Continua ad esercitarti per mantenere il tuo livello.'
hint_skill_tree_continue: '[t] Apri Albero delle Abilità [Altro tasto] Continua'
hint_any_key: 'Premi un tasto qualsiasi per continuare'
input_blocked: 'Input temporaneamente bloccato (%{ms}ms rimanenti)'
unlock_msg_1: 'Ottimo lavoro! Continua a migliorare le tue abilità.'
unlock_msg_2: 'Un altro tasto aggiunto al tuo arsenale!'
unlock_msg_3: 'La tua tastiera cresce! Continua così.'
unlock_msg_4: 'Un passo più vicino alla padronanza totale!'
mastery_msg_1: 'Questo tasto è a confidenza massima!'
mastery_msg_2: 'Hai questo tasto sotto controllo!'
mastery_msg_3: 'Memoria muscolare acquisita!'
mastery_msg_4: 'Un altro tasto conquistato!'
# Esploratore tastiera
keyboard:
title: ' Tastiera '
subtitle: 'Premi o clicca su un tasto'
hint_navigate: '[←→↑↓/hjkl/Tab] Naviga'
hint_back: '[q/ESC] Indietro'
key_label: 'Tasto: '
finger_label: 'Dito: '
hand_left: 'Sinistra'
hand_right: 'Destra'
finger_index: 'Indice'
finger_middle: 'Medio'
finger_ring: 'Anulare'
finger_pinky: 'Mignolo'
finger_thumb: 'Pollice'
overall_accuracy: ' Precisione globale: %{correct}/%{total} (%{pct}%%)'
ranked_accuracy: ' Precisione classificata: %{correct}/%{total} (%{pct}%%)'
confidence: 'Confidenza: '
no_data: 'Nessun dato ancora'
no_data_short: 'Nessun dato'
key_details: ' Dettagli Tasto '
key_details_char: ' Dettagli Tasto: ''%{ch}'' '
key_details_name: ' Dettagli Tasto: %{name} '
press_key_hint: 'Premi un tasto per vedere i suoi dettagli'
shift_label: 'Shift: '
shift_no: 'No'
overall_avg_time: 'Tempo Med. Globale: '
overall_best_time: 'Miglior Tempo Globale: '
overall_samples: 'Campioni Globali: '
overall_accuracy_label: 'Precisione Globale: '
branch_label: 'Ramo: '
level_label: 'Livello: '
built_in_key: 'Tasto Integrato'
unlocked_label: 'Sbloccato: '
yes: 'Sì'
no: 'No'
in_focus_label: 'In Focus?: '
mastery_label: 'Padronanza: '
mastery_locked: 'Bloccato'
ranked_avg_time: 'Tempo Med. Classificato: '
ranked_best_time: 'Miglior Tempo Classificato: '
ranked_samples: 'Campioni Classificati: '
ranked_accuracy_label: 'Precisione Classificata: '
# Dialoghi di introduzione
intro:
passage_title: ' Configurazione Download Brani '
code_title: ' Configurazione Download Codice '
enable_downloads: 'Attiva download di rete'
download_dir: 'Directory di download'
paragraphs_per_book: 'Paragrafi per libro (0 = intero)'
whole_book: 'libro intero'
snippets_per_repo: 'Frammenti per repo (0 = illimitato)'
unlimited: 'illimitato'
start_passage_drill: 'Avvia esercizio di brano'
start_code_drill: 'Avvia esercizio di codice'
confirm: 'Conferma'
hint_navigate: '[Up/Down] Naviga'
hint_adjust: '[Left/Right] Regola'
hint_edit: '[Type/Backspace] Modifica'
hint_confirm: '[Enter] Conferma'
hint_cancel: '[ESC] Annulla'
preparing_download: 'Preparazione download...'
download_passage_title: ' Download Sorgente Brano '
download_code_title: ' Download Sorgente Codice '
book_label: ' Libro: %{name}'
repo_label: ' Repo: %{name}'
progress_bytes: '[%{name}] %{downloaded}/%{total} bytes'
downloaded_bytes: 'Scaricato: %{bytes} bytes'
downloading_book_progress: 'Download libro attuale: [%{bar}] %{downloaded}/%{total} bytes'
downloading_book_bytes: 'Download libro attuale: %{bytes} bytes'
downloading_code_progress: 'Download: [%{bar}] %{downloaded}/%{total} bytes'
downloading_code_bytes: 'Download: %{bytes} bytes'
current_book: 'Attuale: %{name} (libro %{done}/%{total})'
current_repo: 'Attuale: %{name} (repo %{done}/%{total})'
passage_instructions_1: 'keydr può scaricare brani da Project Gutenberg per la pratica di digitazione.'
passage_instructions_2: 'I libri vengono scaricati una volta e salvati localmente.'
passage_instructions_3: 'Configura le impostazioni di download qui sotto, poi avvia un esercizio di brano.'
code_instructions_1: 'keydr può scaricare codice open-source da GitHub per la pratica di digitazione.'
code_instructions_2: 'Il codice viene scaricato una volta e salvato localmente.'
code_instructions_3: 'Configura le impostazioni di download qui sotto, poi avvia un esercizio di codice.'
# Messaggi di stato (da app.rs)
status:
recovery_files: 'Trovati file di recupero da un''importazione interrotta. I dati potrebbero essere incoerenti — considera di reimportare.'
dir_not_exist: 'La directory non esiste: %{path}'
no_data_store: 'Nessun archivio dati disponibile'
serialization_error: 'Errore di serializzazione: %{error}'
exported_to: 'Esportato in %{path}'
export_failed: 'Esportazione fallita: %{error}'
could_not_read: 'Impossibile leggere il file: %{error}'
invalid_export: 'File di esportazione non valido: %{error}'
unsupported_version: 'Versione di esportazione non supportata: %{got} (prevista %{expected})'
import_failed: 'Importazione fallita: %{error}'
imported_theme_fallback: 'Importato con successo (tema ''%{theme}'' non trovato, usando predefinito)'
imported_success: 'Importato con successo'
adaptive_unavailable: 'Modalità adattiva classificata non disponibile: %{error}'
switched_to: 'Passato a %{name}'
layout_changed: 'Layout cambiato in %{name}'
# Errori (per traduzione limiti UI)
errors:
unknown_language: 'Lingua sconosciuta: %{key}'
unknown_layout: 'Layout tastiera sconosciuto: %{key}'
unsupported_pair: 'Coppia lingua/layout non supportata: %{language} + %{layout}'
language_blocked: 'Lingua bloccata dal livello di supporto: %{key}'
# Comune
common:
wpm: 'WPM'
cpm: 'CPM'
back: 'Indietro'

454
locales/lt.yml Normal file
View File

@@ -0,0 +1,454 @@
# Main menu
menu:
subtitle: 'Terminalo spausdinimo treniruoklis'
adaptive_drill: 'Adaptyvi pratybos'
adaptive_drill_desc: 'Fonetiniai žodžiai su adaptyviniu raidžių atrakinimo'
code_drill: 'Kodo pratybos'
code_drill_desc: 'Praktikuokite kodo sintaksės spausdinimą'
passage_drill: 'Teksto pratybos'
passage_drill_desc: 'Spausdinkite ištraukas iš knygų'
skill_tree: 'Įgūdžių medis'
skill_tree_desc: 'Peržiūrėkite pažangos šakas ir pradėkite pratybas'
keyboard: 'Klaviatūra'
keyboard_desc: 'Naršykite klaviatūros išdėstymą ir klavišų statistiką'
statistics: 'Statistika'
statistics_desc: 'Peržiūrėkite spausdinimo statistiką'
settings: 'Nustatymai'
settings_desc: 'Konfigūruokite keydr'
day_streak: ' | %{days} d. serija'
key_progress: ' Klavišų pažanga %{unlocked}/%{total} (%{mastered} įvaldyta) | Tikslas %{target} WPM%{streak}'
hint_start: '[1-3] Pradėti'
hint_skill_tree: '[t] Įgūdžių medis'
hint_keyboard: '[b] Klaviatūra'
hint_stats: '[s] Statistika'
hint_settings: '[c] Nustatymai'
hint_quit: '[q] Išeiti'
# Drill screen
drill:
title: ' Pratybos '
mode_adaptive: 'Adaptyvi'
mode_code: 'Kodas (be vertinimo)'
mode_passage: 'Tekstas (be vertinimo)'
focus_char: 'Fokusuotis: ''%{ch}'''
focus_bigram: 'Fokusuotis: "%{bigram}"'
focus_both: 'Fokusuotis: ''%{ch}'' + "%{bigram}"'
header_wpm: 'WPM'
header_acc: 'Tiksl'
header_err: 'Kld'
code_source: ' Kodo šaltinis '
passage_source: ' Teksto šaltinis '
footer: '[ESC] Baigti pratybas [Backspace] Trinti'
keys_reenabled: 'Klavišai vėl aktyvūs po %{ms}ms'
hint_end: '[ESC] Baigti pratybas'
hint_backspace: '[Backspace] Trinti'
# Dashboard / drill result
dashboard:
title: ' Pratybos baigtos '
results: 'Rezultatai'
unranked_note_prefix: ' (Be vertinimo'
unranked_note_suffix: ' neskaičiuojama įgūdžių medyje)'
speed: ' Greitis: '
accuracy_label: ' Tikslumas:'
time_label: ' Laikas: '
errors_label: ' Klaidos: '
correct_detail: ' (%{correct}/%{total} teisingai)'
input_blocked: ' Įvestis laikinai blokuota '
input_blocked_ms: '(%{ms}ms liko)'
hint_continue: '[c/Enter/Space] Tęsti'
hint_retry: '[r] Iš naujo'
hint_menu: '[q] Meniu'
hint_stats: '[s] Statistika'
hint_delete: '[x] Trinti'
# Stats sidebar (during drill)
sidebar:
title: ' Statistika '
wpm: 'WPM: '
target: 'Tikslas: '
target_wpm: '%{wpm} WPM'
accuracy: 'Tikslumas: '
progress: 'Pažanga: '
correct: 'Teisingai: '
errors: 'Klaidos: '
time: 'Laikas: '
last_drill: ' Paskutinės pratybos '
vs_avg: ' vs vidurk.: '
# Statistics dashboard
stats:
title: ' Statistika '
empty: 'Nėra baigtų pratybų. Pradėkite spausdinti!'
tab_dashboard: '[1] Suvestinė'
tab_history: '[2] Istorija'
tab_activity: '[3] Aktyvumas'
tab_accuracy: '[4] Tikslumas'
tab_timing: '[5] Laikas'
tab_ngrams: '[6] N-gramos'
hint_back: '[ESC] Atgal'
hint_next_tab: '[Tab] Kita kortelė'
hint_switch_tab: '[1-6] Kortelė'
hint_navigate: '[j/k] Navigacija'
hint_page: '[PgUp/PgDn] Puslapis'
hint_delete: '[x] Trinti'
summary_title: ' Santrauka '
drills: ' Pratybos: '
avg_wpm: ' Vid. WPM: '
best_wpm: ' Geriausias WPM: '
accuracy_label: ' Tikslumas: '
total_time: ' Bendras laikas: '
wpm_chart_title: ' WPM per pratybas (Paskutinės 20, Tikslas: %{target}) '
accuracy_chart_title: ' Tikslumas %% (Paskutinės 50 pratybų) '
chart_drill: 'Pratybos #'
chart_accuracy_pct: 'Tikslumas %%'
sessions_title: ' Naujausios sesijos '
session_header: ' # WPM Raw Tiksl%% Laikas Data/Laikas Režimas Vertin. Dalin.'
session_separator: ' ─────────────────────────────────────────────────────────────────────'
delete_confirm: 'Trinti sesiją #%{idx}? (y/n)'
confirm_title: ' Patvirtinimas '
yes: 'taip'
no: 'ne'
keyboard_accuracy_title: ' Klaviatūros tikslumas %% '
keyboard_timing_title: ' Klaviatūros laikas (ms) '
slowest_keys_title: ' Lėčiausi klavišai (ms) '
fastest_keys_title: ' Greičiausi klavišai (ms) '
worst_accuracy_title: ' Blogiausias tikslumas (%%) '
best_accuracy_title: ' Geriausias tikslumas (%%) '
not_enough_data: ' Nepakanka duomenų'
streaks_title: ' Serijos '
current_streak: ' Dabartinė: '
best_streak: ' Geriausia: '
active_days: ' Aktyvios dienos: '
top_days_none: ' Geriausios dienos: nėra'
top_days: ' Geriausios dienos: %{days}'
wpm_label: ' WPM: %{avg}/%{target} (%{pct}%%)'
acc_label: ' Tiksl: %{pct}%%'
keys_label: ' Klavišai: %{unlocked}/%{total} (%{mastered} įvaldyta)'
ngram_empty: 'Atlikite keletą adaptyvių pratybų n-gramų duomenims pamatyti'
ngram_header_speed_narrow: ' Bgrm Greit Tikėt Anom%'
ngram_header_error_narrow: ' Bgrm Kld Imč Norma Tkt Anom%'
ngram_header_speed: ' Bigrama Greit Tikėtina Imčiai Anom%'
ngram_header_error: ' Bigrama Klaidos Imčiai Norma Tikėtina Anom%'
focus_title: ' Aktyvus fokusas '
focus_char_label: ' Fokusas: '
focus_bigram_value: 'Bigrama %{label}'
focus_plus: ' + '
anomaly_error: 'klaida'
anomaly_speed: 'greitis'
focus_detail_both: ' Simb. ''%{ch}'': silpniausias klavišas | Bigrama %{label}: %{type} anomalija %{pct}%%'
focus_detail_char_only: ' Simb. ''%{ch}'': silpniausias klavišas, nėra patvirtintų bigramų anomalijų'
focus_detail_bigram_only: ' (%{type} anomalija: %{pct}%%)'
focus_empty: ' Atlikite keletą adaptyvių pratybų fokuso duomenims pamatyti'
error_anomalies_title: ' Klaidų anomalijos (%{count}) '
no_error_anomalies: ' Klaidų anomalijų nerasta'
speed_anomalies_title: ' Greičio anomalijos (%{count}) '
no_speed_anomalies: ' Greičio anomalijų nerasta'
scope_label_prefix: ' '
bi_label: ' | Bi: %{count}'
hes_label: ' | Hes: >%{ms}ms'
focus_char_value: 'Simb. ''%{ch}'''
# Activity heatmap
heatmap:
title: ' Dienos aktyvumas (sesijų per dieną) '
jan: 'Sau'
feb: 'Vas'
mar: 'Kov'
apr: 'Bal'
may: 'Geg'
jun: 'Bir'
jul: 'Lie'
aug: 'Rgp'
sep: 'Rgs'
oct: 'Spa'
nov: 'Lap'
dec: 'Grd'
# Chart
chart:
wpm_over_time: ' WPM per laiką '
drill_number: 'Pratybos #'
# Settings
settings:
title: ' Nustatymai '
subtitle: 'Rodyklėmis naršykite, Enter/Dešinėn keisti, ESC išsaugoti'
target_wpm: 'Tikslinis WPM'
theme: 'Tema'
word_count: 'Žodžių skaičius'
ui_language: 'Sąsajos kalba'
dictionary_language: 'Žodyno kalba'
keyboard_layout: 'Klaviatūros išdėstymas'
code_language: 'Programavimo kalba'
code_downloads: 'Kodo atsisiuntimai'
on: 'Įjungta'
off: 'Išjungta'
code_download_dir: 'Kodo atsisiuntimų aplankas'
snippets_per_repo: 'Fragmentų per repozitoriją'
unlimited: 'Neribota'
download_code_now: 'Atsisiųsti kodą dabar'
run_downloader: 'Paleisti atsisiuntimą'
passage_downloads: 'Tekstų atsisiuntimai'
passage_download_dir: 'Tekstų atsisiuntimų aplankas'
paragraphs_per_book: 'Pastraipų per knygą'
whole_book: 'Visa knyga'
download_passages_now: 'Atsisiųsti tekstus dabar'
export_path: 'Eksporto kelias'
export_data: 'Eksportuoti duomenis'
export_now: 'Eksportuoti dabar'
import_path: 'Importo kelias'
import_data: 'Importuoti duomenis'
import_now: 'Importuoti dabar'
hint_save_back: '[ESC] Išsaugoti ir atgal'
hint_change_value: '[Enter/rodyklės] Keisti reikšmę'
hint_edit_path: '[Enter ant kelio] Redaguoti'
hint_move: '[←→] Judėti'
hint_tab_complete: '[Tab] Užbaigti (gale)'
hint_confirm: '[Enter] Patvirtinti'
hint_cancel: '[Esc] Atšaukti'
success_title: ' Sėkmė '
error_title: ' Klaida '
press_any_key: 'Paspauskite bet kurį klavišą'
file_exists_title: ' Failas egzistuoja '
file_exists: 'Failas jau egzistuoja šiuo keliu.'
overwrite_rename: '[d] Perrašyti [r] Pervadinti [Esc] Atšaukti'
erase_warning: 'Tai ištrins jūsų dabartinius duomenis.'
export_first: 'Pirmiausia eksportuokite, jei norite išsaugoti.'
proceed_yn: 'Tęsti? (y/n)'
confirm_import_title: ' Importo patvirtinimas '
# Selection screens
select:
dictionary_language_title: ' Pasirinkite žodyno kalbą '
keyboard_layout_title: ' Pasirinkite klaviatūros išdėstymą '
code_language_title: ' Pasirinkite programavimo kalbą '
passage_source_title: ' Pasirinkite teksto šaltinį '
ui_language_title: ' Pasirinkite sąsajos kalbą '
more_above: '... dar %{count} aukščiau ...'
more_below: '... dar %{count} žemiau ...'
current: ' (dabartinis)'
disabled: ' (išjungta)'
enabled_default: ' (įjungta, numatytasis: %{layout})'
enabled: ' (įjungta)'
disabled_blocked: ' (išjungta: blokuota)'
built_in: ' (integruota)'
cached: ' (podėlyje)'
disabled_download: ' (išjungta: reikia atsisiųsti)'
download_required: ' (reikia atsisiųsti)'
hint_navigate: '[Aukštyn/Žemyn/PgUp/PgDn] Navigacija'
hint_confirm: '[Enter] Patvirtinti'
hint_back: '[ESC] Atgal'
language_resets_layout: 'Kalbos pasirinkimas atstato klaviatūros išdėstymą į tos kalbos numatytąjį.'
layout_no_language_change: 'Išdėstymo pakeitimas nekeičia žodyno kalbos.'
disabled_network_notice: 'Kai kurios kalbos išjungtos: įjunkite tinklo atsisiuntimus įvade/nustatymuose.'
disabled_sources_notice: 'Kai kurie šaltiniai išjungti: įjunkite tinklo atsisiuntimus įvade/nustatymuose.'
passage_all: 'Visos (Integruotos + visos knygos)'
passage_builtin: 'Tik integruoti tekstai'
passage_book_prefix: 'Knyga: %{title}'
# Progress
progress:
overall_key_progress: 'Bendra klavišų pažanga'
unlocked_mastered: '%{unlocked}/%{total} atrakinta (%{mastered} įvaldyta)'
# Skill tree
skill_tree:
title: ' Įgūdžių medis '
locked: 'Užrakinta'
unlocked: 'atrakinta'
mastered: 'įvaldyta'
in_progress: 'vykdoma'
complete: 'baigta'
locked_status: 'užrakinta'
locked_notice: 'Užbaikite %{count} pirminių raidžių šakoms atrakinti'
branches_separator: 'Šakos (prieinamos po %{count} pirminių raidžių)'
unlocked_letters: 'Atrakinta %{unlocked}/%{total} raidžių'
level: 'Lygis %{current}/%{total}'
level_zero: 'Lygis 0/%{total}'
in_focus: ' fokuse'
hint_navigate: '[↑↓/jk] Navigacija'
hint_scroll: '[PgUp/PgDn arba Ctrl+U/Ctrl+D] Slinkti'
hint_back: '[q] Atgal'
hint_unlock: '[Enter] Atrakinti'
hint_start_drill: '[Enter] Pradėti pratybas'
unlock_msg_1: 'Atrakinus, numatytosios adaptyvios pratybos įtrauks šios šakos atrakintus klavišus.'
unlock_msg_2: 'Jei norite sutelkti dėmesį tik į šią šaką, pradėkite pratybas tiesiogiai iš įgūdžių medžio.'
confirm_unlock: 'Atrakinti %{branch}?'
confirm_yn: '[y] Atrakinti [n/ESC] Atšaukti'
lvl_prefix: 'Lyg'
branch_primary_letters: 'Pirminės raidės'
branch_capital_letters: 'Didžiosios raidės'
branch_numbers: 'Skaičiai 0-9'
branch_prose_punctuation: 'Skyrybos ženklai'
branch_whitespace: 'Tarpai'
branch_code_symbols: 'Kodo simboliai'
level_frequency_order: 'Dažnumo tvarka'
level_common_sentence_capitals: 'Dažnos sakinio didžiosios'
level_name_capitals: 'Vardų didžiosios'
level_remaining_capitals: 'Likusios didžiosios'
level_common_digits: 'Dažni skaitmenys'
level_all_digits: 'Visi skaitmenys'
level_essential: 'Būtina'
level_common: 'Dažna'
level_expressive: 'Išraiškinga'
level_enter_return: 'Enter/Return'
level_tab_indent: 'Tab/Įtrauka'
level_arithmetic_assignment: 'Aritmetika ir priskyrimas'
level_grouping: 'Grupavimas'
level_logic_reference: 'Logika ir nuorodos'
level_special: 'Specialūs'
# Milestones
milestones:
unlock_title: ' Klavišas atrakintas! '
mastery_title: ' Klavišas įvaldytas! '
branches_title: ' Naujos įgūdžių šakos prieinamos! '
branch_complete_title: ' Šaka baigta! '
all_unlocked_title: ' Visi klavišai atrakinti! '
all_mastered_title: ' Visiškas klaviatūros įvaldymas! '
unlocked: 'atrakinta'
mastered: 'įvaldyta'
use_finger: 'Naudokite %{finger}'
hold_right_shift: 'Laikykite dešinį Shift (dešinysis mažylis)'
hold_left_shift: 'Laikykite kairį Shift (kairysis mažylis)'
congratulations_all_letters: 'Sveikiname! Įvaldėte visas %{count} pirminių raidžių'
new_branches_available: 'Naujos įgūdžių šakos dabar prieinamos:'
visit_skill_tree: 'Aplankykite įgūdžių medį naujai šakai atrakinti'
and_start_training: 'ir pradėkite treniruotis!'
open_skill_tree: 'Paspauskite [t] įgūdžių medžiui atidaryti'
branch_complete_msg: 'Baigėte %{branch} šaką!'
all_levels_mastered: 'Visi %{count} lygiai įvaldyti.'
all_keys_confident: 'Kiekvienas šios šakos klavišas pasiekė pilną patikimumą.'
all_unlocked_msg: 'Atrakinote kiekvieną klaviatūros klavišą!'
all_unlocked_desc: 'Kiekvienas simbolis, ženklas ir modifikatorius dabar prieinamas pratybose.'
keep_practicing_mastery: 'Tęskite praktiką meistriškumui ugdyti — kai kiekvienas klavišas pasieks pilną'
confidence_complete: 'patikimumą, būsite pasiekę visišką klaviatūros įvaldymą!'
all_mastered_msg: 'Sveikiname — pasiekėte visišką klaviatūros įvaldymą!'
all_mastered_desc: 'Kiekvienas klaviatūros klavišas yra maksimalaus patikimumo.'
mastery_takes_practice: 'Meistriškumas nėra tikslas — jis reikalauja nuolatinės praktikos.'
keep_drilling: 'Tęskite pratybas, kad išlaikytumėte formą.'
hint_skill_tree_continue: '[t] Atidaryti įgūdžių medį [Bet kuris klavišas] Tęsti'
hint_any_key: 'Paspauskite bet kurį klavišą tęsti'
input_blocked: 'Įvestis laikinai blokuota (%{ms}ms liko)'
unlock_msg_1: 'Puiku! Toliau tobulinkite spausdinimo įgūdžius.'
unlock_msg_2: 'Dar vienas klavišas jūsų arsenale!'
unlock_msg_3: 'Jūsų klaviatūra auga! Taip ir toliau.'
unlock_msg_4: 'Vienu žingsniu arčiau visiško klaviatūros įvaldymo!'
mastery_msg_1: 'Šis klavišas dabar pilno patikimumo!'
mastery_msg_2: 'Šį klavišą mokate puikiai!'
mastery_msg_3: 'Raumenų atmintis užfiksuota!'
mastery_msg_4: 'Dar vienas klavišas užkariauta!'
# Keyboard explorer
keyboard:
title: ' Klaviatūra '
subtitle: 'Paspauskite bet kurį klavišą arba spustelėkite'
hint_navigate: '[←→↑↓/hjkl/Tab] Navigacija'
hint_back: '[q/ESC] Atgal'
key_label: 'Klavišas: '
finger_label: 'Pirštas: '
hand_left: 'Kairė'
hand_right: 'Dešinė'
finger_index: 'Smilius'
finger_middle: 'Didysis'
finger_ring: 'Bevardis'
finger_pinky: 'Mažylis'
finger_thumb: 'Nykštys'
overall_accuracy: ' Bendras tikslumas: %{correct}/%{total} (%{pct}%%)'
ranked_accuracy: ' Vertintas tikslumas: %{correct}/%{total} (%{pct}%%)'
confidence: 'Patikimumas: '
no_data: 'Dar nėra duomenų'
no_data_short: 'Nėra duom.'
key_details: ' Klavišo detalės '
key_details_char: ' Klavišo detalės: ''%{ch}'' '
key_details_name: ' Klavišo detalės: %{name} '
press_key_hint: 'Paspauskite klavišą detalėms pamatyti'
shift_label: 'Shift: '
shift_no: 'Ne'
overall_avg_time: 'Bend. vid. laikas: '
overall_best_time: 'Bend. geriausias: '
overall_samples: 'Bend. imčių: '
overall_accuracy_label: 'Bend. tikslumas: '
branch_label: 'Šaka: '
level_label: 'Lygis: '
built_in_key: 'Integruotas klavišas'
unlocked_label: 'Atrakinta: '
yes: 'Taip'
no: 'Ne'
in_focus_label: 'Fokuse?: '
mastery_label: 'Įvaldymas: '
mastery_locked: 'Užrakinta'
ranked_avg_time: 'Vert. vid. laikas: '
ranked_best_time: 'Vert. geriausias: '
ranked_samples: 'Vert. imčių: '
ranked_accuracy_label: 'Vert. tikslumas: '
# Intro dialogs
intro:
passage_title: ' Tekstų atsisiuntimo nustatymai '
code_title: ' Kodo atsisiuntimo nustatymai '
enable_downloads: 'Įjungti tinklo atsisiuntimus'
download_dir: 'Atsisiuntimų aplankas'
paragraphs_per_book: 'Pastraipų per knygą (0 = visa)'
whole_book: 'visa knyga'
snippets_per_repo: 'Fragmentų per repozitoriją (0 = neribota)'
unlimited: 'neribota'
start_passage_drill: 'Pradėti teksto pratybas'
start_code_drill: 'Pradėti kodo pratybas'
confirm: 'Patvirtinti'
hint_navigate: '[Aukštyn/Žemyn] Navigacija'
hint_adjust: '[Kairėn/Dešinėn] Reguliuoti'
hint_edit: '[Rašymas/Backspace] Redaguoti'
hint_confirm: '[Enter] Patvirtinti'
hint_cancel: '[ESC] Atšaukti'
preparing_download: 'Ruošiamas atsisiuntimas...'
download_passage_title: ' Atsisiunčiamas teksto šaltinis '
download_code_title: ' Atsisiunčiamas kodo šaltinis '
book_label: ' Knyga: %{name}'
repo_label: ' Repozitorija: %{name}'
progress_bytes: '[%{name}] %{downloaded}/%{total} baitų'
downloaded_bytes: 'Atsisiųsta: %{bytes} baitų'
downloading_book_progress: 'Atsisiunčiama knyga: [%{bar}] %{downloaded}/%{total} baitų'
downloading_book_bytes: 'Atsisiunčiama knyga: %{bytes} baitų'
downloading_code_progress: 'Atsisiunčiama: [%{bar}] %{downloaded}/%{total} baitų'
downloading_code_bytes: 'Atsisiunčiama: %{bytes} baitų'
current_book: 'Dabartinė: %{name} (knyga %{done}/%{total})'
current_repo: 'Dabartinė: %{name} (repozitorija %{done}/%{total})'
passage_instructions_1: 'keydr gali atsisiųsti tekstus iš Project Gutenberg spausdinimo praktikai.'
passage_instructions_2: 'Knygos atsisiunčiamos vieną kartą ir saugomos vietoje.'
passage_instructions_3: 'Sukonfigūruokite atsisiuntimo nustatymus, tada pradėkite teksto pratybas.'
code_instructions_1: 'keydr gali atsisiųsti atvirąjį kodą iš GitHub spausdinimo praktikai.'
code_instructions_2: 'Kodas atsisiunčiamas vieną kartą ir saugomas vietoje.'
code_instructions_3: 'Sukonfigūruokite atsisiuntimo nustatymus, tada pradėkite kodo pratybas.'
# Status messages (from app.rs)
status:
recovery_files: 'Rasti atkūrimo failai iš nutraukto importo. Duomenys gali būti nesuderinti — apsvarstykite pakartotinį importą.'
dir_not_exist: 'Aplankas neegzistuoja: %{path}'
no_data_store: 'Nėra prieinamos duomenų saugyklos'
serialization_error: 'Serializacijos klaida: %{error}'
exported_to: 'Eksportuota į %{path}'
export_failed: 'Eksportas nepavyko: %{error}'
could_not_read: 'Nepavyko perskaityti failo: %{error}'
invalid_export: 'Neteisingas eksporto failas: %{error}'
unsupported_version: 'Nepalaikoma eksporto versija: %{got} (tikėtasi %{expected})'
import_failed: 'Importas nepavyko: %{error}'
imported_theme_fallback: 'Importas sėkmingas (tema ''%{theme}'' nerasta, naudojama numatytoji)'
imported_success: 'Importas sėkmingas'
adaptive_unavailable: 'Adaptyvus vertintas režimas neprieinamas: %{error}'
switched_to: 'Perjungta į %{name}'
layout_changed: 'Išdėstymas pakeistas į %{name}'
# Errors (for UI boundary translation)
errors:
unknown_language: 'Nežinoma kalba: %{key}'
unknown_layout: 'Nežinomas klaviatūros išdėstymas: %{key}'
unsupported_pair: 'Nepalaikoma kalbos/išdėstymo pora: %{language} + %{layout}'
language_blocked: 'Kalba blokuota pagal palaikymo lygį: %{key}'
# Common
common:
wpm: 'WPM'
cpm: 'CPM'
back: 'Atgal'

454
locales/lv.yml Normal file
View File

@@ -0,0 +1,454 @@
# Main menu
menu:
subtitle: 'Termināļa rakstīšanas treneris'
adaptive_drill: 'Adaptīvs vingrinājums'
adaptive_drill_desc: 'Fonētiski vārdi ar adaptīvu burtu atbloķēšanu'
code_drill: 'Koda vingrinājums'
code_drill_desc: 'Praktizējiet koda sintakses rakstīšanu'
passage_drill: 'Teksta vingrinājums'
passage_drill_desc: 'Rakstiet fragmentus no grāmatām'
skill_tree: 'Prasmju koks'
skill_tree_desc: 'Skatiet progresa zarus un sāciet vingrinājumus'
keyboard: 'Tastatūra'
keyboard_desc: 'Izpētiet tastatūras izkārtojumu un taustiņu statistiku'
statistics: 'Statistika'
statistics_desc: 'Skatiet rakstīšanas statistiku'
settings: 'Iestatījumi'
settings_desc: 'Konfigurēt keydr'
day_streak: ' | %{days} dienu sērija'
key_progress: ' Taustiņu progress %{unlocked}/%{total} (%{mastered} apgūti) | Mērķis %{target} WPM%{streak}'
hint_start: '[1-3] Sākt'
hint_skill_tree: '[t] Prasmju koks'
hint_keyboard: '[b] Tastatūra'
hint_stats: '[s] Statistika'
hint_settings: '[c] Iestatījumi'
hint_quit: '[q] Iziet'
# Drill screen
drill:
title: ' Vingrinājums '
mode_adaptive: 'Adaptīvs'
mode_code: 'Kods (bez vērtējuma)'
mode_passage: 'Teksts (bez vērtējuma)'
focus_char: 'Fokuss: ''%{ch}'''
focus_bigram: 'Fokuss: "%{bigram}"'
focus_both: 'Fokuss: ''%{ch}'' + "%{bigram}"'
header_wpm: 'WPM'
header_acc: 'Prec'
header_err: 'Kļūd'
code_source: ' Koda avots '
passage_source: ' Teksta avots '
footer: '[ESC] Beigt vingrinājumu [Backspace] Dzēst'
keys_reenabled: 'Taustiņi atkal aktīvi pēc %{ms}ms'
hint_end: '[ESC] Beigt vingrinājumu'
hint_backspace: '[Backspace] Dzēst'
# Dashboard / drill result
dashboard:
title: ' Vingrinājums pabeigts '
results: 'Rezultāti'
unranked_note_prefix: ' (Bez vērtējuma'
unranked_note_suffix: ' netiek ieskaitīts prasmju kokā)'
speed: ' Ātrums: '
accuracy_label: ' Precizitāte:'
time_label: ' Laiks: '
errors_label: ' Kļūdas: '
correct_detail: ' (%{correct}/%{total} pareizi)'
input_blocked: ' Ievade īslaicīgi bloķēta '
input_blocked_ms: '(%{ms}ms atlicis)'
hint_continue: '[c/Enter/Space] Turpināt'
hint_retry: '[r] Atkārtot'
hint_menu: '[q] Izvēlne'
hint_stats: '[s] Statistika'
hint_delete: '[x] Dzēst'
# Stats sidebar (during drill)
sidebar:
title: ' Statistika '
wpm: 'WPM: '
target: 'Mērķis: '
target_wpm: '%{wpm} WPM'
accuracy: 'Precizitāte: '
progress: 'Progress: '
correct: 'Pareizi: '
errors: 'Kļūdas: '
time: 'Laiks: '
last_drill: ' Pēdējais vingrinājums '
vs_avg: ' vs vidēji: '
# Statistics dashboard
stats:
title: ' Statistika '
empty: 'Nav pabeigtu vingrinājumu. Sāciet rakstīt!'
tab_dashboard: '[1] Pārskats'
tab_history: '[2] Vēsture'
tab_activity: '[3] Aktivitāte'
tab_accuracy: '[4] Precizitāte'
tab_timing: '[5] Laiki'
tab_ngrams: '[6] N-gramas'
hint_back: '[ESC] Atpakaļ'
hint_next_tab: '[Tab] Nākamā cilne'
hint_switch_tab: '[1-6] Cilne'
hint_navigate: '[j/k] Navigācija'
hint_page: '[PgUp/PgDn] Lapa'
hint_delete: '[x] Dzēst'
summary_title: ' Kopsavilkums '
drills: ' Vingrinājumi: '
avg_wpm: ' Vid. WPM: '
best_wpm: ' Labākais WPM: '
accuracy_label: ' Precizitāte: '
total_time: ' Kopējais laiks: '
wpm_chart_title: ' WPM pa vingrinājumiem (Pēdējie 20, Mērķis: %{target}) '
accuracy_chart_title: ' Precizitāte %% (Pēdējie 50 vingrinājumi) '
chart_drill: 'Vingrinājums #'
chart_accuracy_pct: 'Precizitāte %%'
sessions_title: ' Nesenās sesijas '
session_header: ' # WPM Raw Prec%% Laiks Datums/Laiks Režīms Vērtēts Daļējs'
session_separator: ' ─────────────────────────────────────────────────────────────────────'
delete_confirm: 'Dzēst sesiju #%{idx}? (y/n)'
confirm_title: ' Apstiprināt '
yes: 'jā'
no: 'nē'
keyboard_accuracy_title: ' Tastatūras precizitāte %% '
keyboard_timing_title: ' Tastatūras laiki (ms) '
slowest_keys_title: ' Lēnākie taustiņi (ms) '
fastest_keys_title: ' Ātrākie taustiņi (ms) '
worst_accuracy_title: ' Sliktākā precizitāte (%%) '
best_accuracy_title: ' Labākā precizitāte (%%) '
not_enough_data: ' Nepietiek datu'
streaks_title: ' Sērijas '
current_streak: ' Pašreizējā: '
best_streak: ' Labākā: '
active_days: ' Aktīvās dienas: '
top_days_none: ' Labākās dienas: nav'
top_days: ' Labākās dienas: %{days}'
wpm_label: ' WPM: %{avg}/%{target} (%{pct}%%)'
acc_label: ' Prec: %{pct}%%'
keys_label: ' Taustiņi: %{unlocked}/%{total} (%{mastered} apgūti)'
ngram_empty: 'Pabeidziet dažus adaptīvus vingrinājumus n-gramu datu attēlošanai'
ngram_header_speed_narrow: ' Bgrm Ātr Sagaid Anom%'
ngram_header_error_narrow: ' Bgrm Kļūd Par Likme Sag Anom%'
ngram_header_speed: ' Bigrama Ātrums Sagaid Paraugi Anom%'
ngram_header_error: ' Bigrama Kļūdas Paraugi Likme Sagaid Anom%'
focus_title: ' Aktīvais fokuss '
focus_char_label: ' Fokuss: '
focus_bigram_value: 'Bigrama %{label}'
focus_plus: ' + '
anomaly_error: 'kļūda'
anomaly_speed: 'ātrums'
focus_detail_both: ' Simb. ''%{ch}'': vājākais taustiņš | Bigrama %{label}: %{type} anomālija %{pct}%%'
focus_detail_char_only: ' Simb. ''%{ch}'': vājākais taustiņš, nav apstiprinātu bigramu anomāliju'
focus_detail_bigram_only: ' (%{type} anomālija: %{pct}%%)'
focus_empty: ' Pabeidziet dažus adaptīvus vingrinājumus fokusa datu attēlošanai'
error_anomalies_title: ' Kļūdu anomālijas (%{count}) '
no_error_anomalies: ' Kļūdu anomālijas nav atrastas'
speed_anomalies_title: ' Ātruma anomālijas (%{count}) '
no_speed_anomalies: ' Ātruma anomālijas nav atrastas'
scope_label_prefix: ' '
bi_label: ' | Bi: %{count}'
hes_label: ' | Hes: >%{ms}ms'
focus_char_value: 'Simb. ''%{ch}'''
# Activity heatmap
heatmap:
title: ' Dienas aktivitāte (sesijas dienā) '
jan: 'Jan'
feb: 'Feb'
mar: 'Mar'
apr: 'Apr'
may: 'Mai'
jun: 'Jūn'
jul: 'Jūl'
aug: 'Aug'
sep: 'Sep'
oct: 'Okt'
nov: 'Nov'
dec: 'Dec'
# Chart
chart:
wpm_over_time: ' WPM laika gaitā '
drill_number: 'Vingrinājums #'
# Settings
settings:
title: ' Iestatījumi '
subtitle: 'Bultiņām navigēt, Enter/Pa labi mainīt, ESC saglabāt'
target_wpm: 'Mērķa WPM'
theme: 'Tēma'
word_count: 'Vārdu skaits'
ui_language: 'Saskarnes valoda'
dictionary_language: 'Vārdnīcas valoda'
keyboard_layout: 'Tastatūras izkārtojums'
code_language: 'Programmēšanas valoda'
code_downloads: 'Koda lejupielādes'
on: 'Ieslēgts'
off: 'Izslēgts'
code_download_dir: 'Koda lejupielādes mape'
snippets_per_repo: 'Fragmenti uz repozitoriju'
unlimited: 'Neierobežots'
download_code_now: 'Lejupielādēt kodu tagad'
run_downloader: 'Palaist lejupielādi'
passage_downloads: 'Tekstu lejupielādes'
passage_download_dir: 'Tekstu lejupielādes mape'
paragraphs_per_book: 'Rindkopas uz grāmatu'
whole_book: 'Visa grāmata'
download_passages_now: 'Lejupielādēt tekstus tagad'
export_path: 'Eksporta ceļš'
export_data: 'Eksportēt datus'
export_now: 'Eksportēt tagad'
import_path: 'Importa ceļš'
import_data: 'Importēt datus'
import_now: 'Importēt tagad'
hint_save_back: '[ESC] Saglabāt un atpakaļ'
hint_change_value: '[Enter/bultiņas] Mainīt vērtību'
hint_edit_path: '[Enter uz ceļa] Rediģēt'
hint_move: '[←→] Pārvietot'
hint_tab_complete: '[Tab] Pabeigt (beigās)'
hint_confirm: '[Enter] Apstiprināt'
hint_cancel: '[Esc] Atcelt'
success_title: ' Veiksmīgi '
error_title: ' Kļūda '
press_any_key: 'Nospiediet jebkuru taustiņu'
file_exists_title: ' Fails eksistē '
file_exists: 'Fails jau eksistē šajā ceļā.'
overwrite_rename: '[d] Pārrakstīt [r] Pārdēvēt [Esc] Atcelt'
erase_warning: 'Tas izdzēsīs jūsu pašreizējos datus.'
export_first: 'Vispirms eksportējiet, ja vēlaties saglabāt.'
proceed_yn: 'Turpināt? (y/n)'
confirm_import_title: ' Importa apstiprināšana '
# Selection screens
select:
dictionary_language_title: ' Izvēlieties vārdnīcas valodu '
keyboard_layout_title: ' Izvēlieties tastatūras izkārtojumu '
code_language_title: ' Izvēlieties programmēšanas valodu '
passage_source_title: ' Izvēlieties teksta avotu '
ui_language_title: ' Izvēlieties saskarnes valodu '
more_above: '... vēl %{count} augstāk ...'
more_below: '... vēl %{count} zemāk ...'
current: ' (pašreizējais)'
disabled: ' (atspējots)'
enabled_default: ' (iespējots, noklusējums: %{layout})'
enabled: ' (iespējots)'
disabled_blocked: ' (atspējots: bloķēts)'
built_in: ' (iebūvēts)'
cached: ' (kešots)'
disabled_download: ' (atspējots: nepieciešama lejupielāde)'
download_required: ' (nepieciešama lejupielāde)'
hint_navigate: '[Augšup/Lejup/PgUp/PgDn] Navigācija'
hint_confirm: '[Enter] Apstiprināt'
hint_back: '[ESC] Atpakaļ'
language_resets_layout: 'Valodas izvēle atjauno tastatūras izkārtojumu uz šīs valodas noklusējumu.'
layout_no_language_change: 'Izkārtojuma maiņa nemaina vārdnīcas valodu.'
disabled_network_notice: 'Dažas valodas atspējotas: iespējojiet tīkla lejupielādes ievadā/iestatījumos.'
disabled_sources_notice: 'Daži avoti atspējoti: iespējojiet tīkla lejupielādes ievadā/iestatījumos.'
passage_all: 'Visi (Iebūvētie + visas grāmatas)'
passage_builtin: 'Tikai iebūvētie teksti'
passage_book_prefix: 'Grāmata: %{title}'
# Progress
progress:
overall_key_progress: 'Kopējais taustiņu progress'
unlocked_mastered: '%{unlocked}/%{total} atbloķēti (%{mastered} apgūti)'
# Skill tree
skill_tree:
title: ' Prasmju koks '
locked: 'Bloķēts'
unlocked: 'atbloķēts'
mastered: 'apgūts'
in_progress: 'procesā'
complete: 'pabeigts'
locked_status: 'bloķēts'
locked_notice: 'Pabeidziet %{count} primāros burtus zaru atbloķēšanai'
branches_separator: 'Zari (pieejami pēc %{count} primārajiem burtiem)'
unlocked_letters: 'Atbloķēti %{unlocked}/%{total} burti'
level: 'Līmenis %{current}/%{total}'
level_zero: 'Līmenis 0/%{total}'
in_focus: ' fokusā'
hint_navigate: '[↑↓/jk] Navigācija'
hint_scroll: '[PgUp/PgDn vai Ctrl+U/Ctrl+D] Ritināt'
hint_back: '[q] Atpakaļ'
hint_unlock: '[Enter] Atbloķēt'
hint_start_drill: '[Enter] Sākt vingrinājumu'
unlock_msg_1: 'Pēc atbloķēšanas noklusējuma adaptīvais vingrinājums iekļaus šī zara atbloķētos taustiņus.'
unlock_msg_2: 'Ja vēlaties fokusēties tikai uz šo zaru, sāciet vingrinājumu tieši no prasmju koka.'
confirm_unlock: 'Atbloķēt %{branch}?'
confirm_yn: '[y] Atbloķēt [n/ESC] Atcelt'
lvl_prefix: 'Līm'
branch_primary_letters: 'Primārie burti'
branch_capital_letters: 'Lielie burti'
branch_numbers: 'Cipari 0-9'
branch_prose_punctuation: 'Pieturzīmes'
branch_whitespace: 'Atstarpes'
branch_code_symbols: 'Koda simboli'
level_frequency_order: 'Biežuma secībā'
level_common_sentence_capitals: 'Bieži teikumu lielie burti'
level_name_capitals: 'Vārdu lielie burti'
level_remaining_capitals: 'Atlikušie lielie burti'
level_common_digits: 'Biežie cipari'
level_all_digits: 'Visi cipari'
level_essential: 'Būtiski'
level_common: 'Bieži'
level_expressive: 'Izteiksmīgi'
level_enter_return: 'Enter/Return'
level_tab_indent: 'Tab/Atkāpe'
level_arithmetic_assignment: 'Aritmētika un piešķiršana'
level_grouping: 'Grupēšana'
level_logic_reference: 'Loģika un atsauces'
level_special: 'Speciāli'
# Milestones
milestones:
unlock_title: ' Taustiņš atbloķēts! '
mastery_title: ' Taustiņš apgūts! '
branches_title: ' Jauni prasmju zari pieejami! '
branch_complete_title: ' Zars pabeigts! '
all_unlocked_title: ' Visi taustiņi atbloķēti! '
all_mastered_title: ' Pilnīga tastatūras apguve! '
unlocked: 'atbloķēts'
mastered: 'apgūts'
use_finger: 'Izmantojiet %{finger}'
hold_right_shift: 'Turiet labo Shift (labais mazais pirksts)'
hold_left_shift: 'Turiet kreiso Shift (kreisais mazais pirksts)'
congratulations_all_letters: 'Apsveicam! Esat apguvis visus %{count} primāros burtus'
new_branches_available: 'Jauni prasmju zari tagad pieejami:'
visit_skill_tree: 'Apmeklējiet prasmju koku jauna zara atbloķēšanai'
and_start_training: 'un sāciet trenēties!'
open_skill_tree: 'Nospiediet [t] prasmju koka atvēršanai'
branch_complete_msg: 'Esat pabeidzis %{branch} zaru!'
all_levels_mastered: 'Visi %{count} līmeņi apgūti.'
all_keys_confident: 'Katrs taustiņš šajā zarā ir pilnā uzticamībā.'
all_unlocked_msg: 'Esat atbloķējis katru tastatūras taustiņu!'
all_unlocked_desc: 'Katra rakstzīme, simbols un modifikators tagad pieejams vingrinājumos.'
keep_practicing_mastery: 'Turpiniet praktizēt meistarības veidošanai — kad katrs taustiņš sasniegs pilnu'
confidence_complete: 'uzticamību, būsiet sasniedzis pilnīgu tastatūras apguvi!'
all_mastered_msg: 'Apsveicam — esat sasniedzis pilnīgu tastatūras apguvi!'
all_mastered_desc: 'Katrs tastatūras taustiņš ir maksimālā uzticamībā.'
mastery_takes_practice: 'Meistarība nav galamērķis — tā prasa pastāvīgu praksi.'
keep_drilling: 'Turpiniet vingrinājumus, lai uzturētu formu.'
hint_skill_tree_continue: '[t] Atvērt prasmju koku [Jebkurš taustiņš] Turpināt'
hint_any_key: 'Nospiediet jebkuru taustiņu lai turpinātu'
input_blocked: 'Ievade īslaicīgi bloķēta (%{ms}ms atlicis)'
unlock_msg_1: 'Lielisks darbs! Turpiniet veidot rakstīšanas prasmes.'
unlock_msg_2: 'Vēl viens taustiņš jūsu arsenālā!'
unlock_msg_3: 'Jūsu tastatūra aug! Tā turpiniet.'
unlock_msg_4: 'Soli tuvāk pilnīgai tastatūras apguvei!'
mastery_msg_1: 'Šis taustiņš tagad ir pilnā uzticamībā!'
mastery_msg_2: 'Šo taustiņu jūs protat lieliski!'
mastery_msg_3: 'Muskuļu atmiņa nostiprināta!'
mastery_msg_4: 'Vēl viens taustiņš iekarots!'
# Keyboard explorer
keyboard:
title: ' Tastatūra '
subtitle: 'Nospiediet jebkuru taustiņu vai noklikšķiniet'
hint_navigate: '[←→↑↓/hjkl/Tab] Navigācija'
hint_back: '[q/ESC] Atpakaļ'
key_label: 'Taustiņš: '
finger_label: 'Pirksts: '
hand_left: 'Kreisā'
hand_right: 'Labā'
finger_index: 'Rādītājpirksts'
finger_middle: 'Vidējais'
finger_ring: 'Zeltnesis'
finger_pinky: 'Mazais pirksts'
finger_thumb: 'Īkšķis'
overall_accuracy: ' Kopējā precizitāte: %{correct}/%{total} (%{pct}%%)'
ranked_accuracy: ' Vērtētā precizitāte: %{correct}/%{total} (%{pct}%%)'
confidence: 'Uzticamība: '
no_data: 'Vēl nav datu'
no_data_short: 'Nav datu'
key_details: ' Taustiņa detaļas '
key_details_char: ' Taustiņa detaļas: ''%{ch}'' '
key_details_name: ' Taustiņa detaļas: %{name} '
press_key_hint: 'Nospiediet taustiņu detaļu apskatei'
shift_label: 'Shift: '
shift_no: 'Nē'
overall_avg_time: 'Kop. vid. laiks: '
overall_best_time: 'Kop. labākais laiks: '
overall_samples: 'Kop. paraugi: '
overall_accuracy_label: 'Kop. precizitāte: '
branch_label: 'Zars: '
level_label: 'Līmenis: '
built_in_key: 'Iebūvēts taustiņš'
unlocked_label: 'Atbloķēts: '
yes: 'Jā'
no: 'Nē'
in_focus_label: 'Fokusā?: '
mastery_label: 'Apguve: '
mastery_locked: 'Bloķēts'
ranked_avg_time: 'Vērt. vid. laiks: '
ranked_best_time: 'Vērt. labākais laiks: '
ranked_samples: 'Vērt. paraugi: '
ranked_accuracy_label: 'Vērt. precizitāte: '
# Intro dialogs
intro:
passage_title: ' Tekstu lejupielādes iestatīšana '
code_title: ' Koda lejupielādes iestatīšana '
enable_downloads: 'Iespējot tīkla lejupielādes'
download_dir: 'Lejupielādes mape'
paragraphs_per_book: 'Rindkopas uz grāmatu (0 = visa)'
whole_book: 'visa grāmata'
snippets_per_repo: 'Fragmenti uz repozitoriju (0 = neierobežots)'
unlimited: 'neierobežots'
start_passage_drill: 'Sākt teksta vingrinājumu'
start_code_drill: 'Sākt koda vingrinājumu'
confirm: 'Apstiprināt'
hint_navigate: '[Augšup/Lejup] Navigācija'
hint_adjust: '[Pa kreisi/Pa labi] Pielāgot'
hint_edit: '[Rakstīšana/Backspace] Rediģēt'
hint_confirm: '[Enter] Apstiprināt'
hint_cancel: '[ESC] Atcelt'
preparing_download: 'Gatavo lejupielādi...'
download_passage_title: ' Lejupielādē teksta avotu '
download_code_title: ' Lejupielādē koda avotu '
book_label: ' Grāmata: %{name}'
repo_label: ' Repozitorijs: %{name}'
progress_bytes: '[%{name}] %{downloaded}/%{total} baiti'
downloaded_bytes: 'Lejupielādēts: %{bytes} baiti'
downloading_book_progress: 'Lejupielādē grāmatu: [%{bar}] %{downloaded}/%{total} baiti'
downloading_book_bytes: 'Lejupielādē grāmatu: %{bytes} baiti'
downloading_code_progress: 'Lejupielādē: [%{bar}] %{downloaded}/%{total} baiti'
downloading_code_bytes: 'Lejupielādē: %{bytes} baiti'
current_book: 'Pašreizējā: %{name} (grāmata %{done}/%{total})'
current_repo: 'Pašreizējais: %{name} (repozitorijs %{done}/%{total})'
passage_instructions_1: 'keydr var lejupielādēt tekstus no Project Gutenberg rakstīšanas praksei.'
passage_instructions_2: 'Grāmatas tiek lejupielādētas vienreiz un glabātas lokāli.'
passage_instructions_3: 'Konfigurējiet lejupielādes iestatījumus, tad sāciet teksta vingrinājumu.'
code_instructions_1: 'keydr var lejupielādēt atvērtā koda projektus no GitHub rakstīšanas praksei.'
code_instructions_2: 'Kods tiek lejupielādēts vienreiz un glabāts lokāli.'
code_instructions_3: 'Konfigurējiet lejupielādes iestatījumus, tad sāciet koda vingrinājumu.'
# Status messages (from app.rs)
status:
recovery_files: 'Atrasti atkopšanas faili no pārtraukta importa. Dati var būt nekonsekventi — apsveriet atkārtotu importu.'
dir_not_exist: 'Mape neeksistē: %{path}'
no_data_store: 'Nav pieejama datu krātuve'
serialization_error: 'Serializācijas kļūda: %{error}'
exported_to: 'Eksportēts uz %{path}'
export_failed: 'Eksports neizdevās: %{error}'
could_not_read: 'Nevarēja nolasīt failu: %{error}'
invalid_export: 'Nederīgs eksporta fails: %{error}'
unsupported_version: 'Neatbalstīta eksporta versija: %{got} (gaidīta %{expected})'
import_failed: 'Imports neizdevās: %{error}'
imported_theme_fallback: 'Imports veiksmīgs (tēma ''%{theme}'' nav atrasta, izmanto noklusējumu)'
imported_success: 'Imports veiksmīgs'
adaptive_unavailable: 'Adaptīvais vērtētais režīms nav pieejams: %{error}'
switched_to: 'Pārslēgts uz %{name}'
layout_changed: 'Izkārtojums mainīts uz %{name}'
# Errors (for UI boundary translation)
errors:
unknown_language: 'Nezināma valoda: %{key}'
unknown_layout: 'Nezināms tastatūras izkārtojums: %{key}'
unsupported_pair: 'Neatbalstīts valodas/izkārtojuma pāris: %{language} + %{layout}'
language_blocked: 'Valoda bloķēta atbalsta līmeņa dēļ: %{key}'
# Common
common:
wpm: 'WPM'
cpm: 'CPM'
back: 'Atpakaļ'

Some files were not shown because too many files have changed in this diff Show More