diff --git a/.gitignore b/.gitignore
index e8267e02..6d226165 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,9 +1,9 @@
__pycache__/
-*\.pyc
-fenrir.egg-info/
-fenrir_screenreader.egg-info/
-dist/
-build/
+*.pyc
+.pytest_cache/
+/fenrir
+/fenrir.sh
+/coverage.out
*.kate-swp
.directory
CLAUDE.md
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 00000000..f288702d
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,674 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ Copyright (C)
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+.
diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md
deleted file mode 100644
index 55c669f3..00000000
--- a/RELEASE_CHECKLIST.md
+++ /dev/null
@@ -1,192 +0,0 @@
-# Fenrir Release Validation Checklist
-
-This checklist ensures thorough validation before releasing Fenrir packages.
-
-## š§ Setup Tools (One-time setup)
-
-### Install Pre-commit Hook
-```bash
-# Safely install composite hook (preserves existing version management)
-./tools/install_validation_hook.sh
-
-# Test the hook
-./.git/hooks/pre-commit
-```
-
-### Validation Scripts
-- `tools/validate_syntax.py` - Python syntax validation
-- `tools/validate_pep8.py` - PEP8 compliance checking with safe auto-fix
-- `tools/validate_release.py` - Comprehensive release validation
-- `tools/cleanup_cache.py` - Remove Python cache files and directories
-- `tools/pre-commit-hook` - Git pre-commit validation
-
-## š Pre-Release Checklist
-
-### 1. Code Quality Validation ā
-```bash
-# Comprehensive release validation (includes syntax, imports, structure)
-python3 tools/validate_release.py
-
-# If issues found, try auto-fix
-python3 tools/validate_release.py --fix
-
-# Quick validation (skips slow dependency checks)
-python3 tools/validate_release.py --quick
-```
-
-**Expected Result**: All tests pass, no syntax errors
-
-### 2. Dependency Validation ā
-```bash
-# Validate all dependencies are available
-python3 check-dependencies.py
-```
-
-**Expected Result**: All required dependencies reported as available
-
-### 3. Core Functionality Test ā
-```bash
-# Test core imports (safe to run without sudo)
-cd src
-python3 -c "
-import fenrirscreenreader.core.fenrirManager
-import fenrirscreenreader.core.commandManager
-import fenrirscreenreader.core.eventManager
-print('Core imports successful')
-"
-cd ..
-```
-
-**Expected Result**: No import errors
-
-### 4. Installation Script Validation ā
-```bash
-# Validate setup.py syntax
-python3 -m py_compile setup.py
-
-# Check setup.py can be parsed
-python3 setup.py --help-commands >/dev/null
-```
-
-**Expected Result**: No syntax errors, setup.py functional
-
-### 5. Configuration Validation ā
-```bash
-# Verify config files exist and are parseable
-ls -la config/settings/settings.conf
-ls -la config/keyboard/desktop.conf
-ls -la config/punctuation/default.conf
-```
-
-**Expected Result**: All core config files present
-
-### 6. Manual Testing (User/Package Maintainer) ā ļø
-
-**Important**: These require user interaction as they need sudo access or specific hardware.
-
-```bash
-# Test basic functionality (ask user to run)
-sudo ./src/fenrir --help
-
-# Test version output
-./src/fenrir --version
-
-# Quick console functionality test (3-5 seconds, ask user to run on a TTY)
-sudo timeout 5 ./src/fenrir -f || echo "Timeout reached (expected)"
-```
-
-**Expected Result**: No immediate crashes, basic help/version output works
-
-### 7. Package-Specific Validation ā
-```bash
-# Test the same compilation process used by package managers
-python3 -m compileall src/fenrirscreenreader/ -q
-
-# Verify no __pycache__ permission issues
-find src/ -name "*.pyc" -delete
-find src/ -name "__pycache__" -delete
-```
-
-**Expected Result**: Clean compilation, no permission errors
-
-## šØ Known Issue Categories
-
-### Critical Issues (Block Release)
-- **Python syntax errors** (SyntaxError, unterminated strings)
-- **Missing core dependencies** (dbus-python, evdev, etc.)
-- **Import failures in core modules** (fenrirManager, commandManager)
-- **Missing critical config files** (settings.conf, desktop.conf)
-
-### Warning Issues (Address if Possible)
-- **PEP8 violations** (cosmetic, don't block release)
-- **Missing optional dependencies** (for specific features)
-- **Command structure issues** (missing methods in command files)
-- **Very long lines** (>120 characters)
-
-## š Root Cause Analysis
-
-### Why These Errors Weren't Caught Previously
-
-1. **No automated syntax validation** - The codebase relied on manual testing
-2. **No pre-commit hooks** - Syntax errors could be committed
-3. **No CI/CD pipeline** - Package compilation happens only during release
-4. **Manual PEP8 cleanup** - F-string refactoring introduced syntax errors during batch cleanup
-
-## š Usage Instructions
-
-### For Developers
-```bash
-# Before committing changes
-git add .
-git commit # Pre-commit hook will run automatically
-
-# Before creating tags/releases
-python3 tools/validate_release.py
-```
-
-### For Package Maintainers
-```bash
-# Before packaging
-python3 tools/validate_release.py
-
-# If validation fails
-python3 tools/validate_release.py --fix
-
-# Quick check (if dependencies are known good)
-python3 tools/validate_release.py --quick
-```
-
-### For Release Managers
-```bash
-# Complete validation before tagging
-python3 tools/validate_release.py
-
-# Manual verification (requires sudo)
-sudo ./src/fenrir --version
-
-# Tag release only after all validations pass
-git tag -a v2.x.x -m "Release v2.x.x"
-```
-
-## šÆ Future Improvements
-
-### Recommended Additions
-1. **GitHub Actions CI/CD** - Automated validation on every push
-2. **Automated testing** - Unit tests for core functionality
-3. **Integration testing** - Test driver interactions
-4. **Package testing** - Validate actual package installation
-
-### Modern Python Packaging
-- Consider migrating to `pyproject.toml` (PEP 621)
-- Use `build` instead of `setup.py` directly
-- Add `tox.ini` for multi-environment testing
-
-## š Support
-
-If validation fails and auto-fix doesn't resolve issues:
-
-1. **Check the specific error messages** in validation output
-2. **Review recent commits** that might have introduced issues
-3. **Run individual validation steps** to isolate problems
-
-Remember: **Working code is better than perfect code** - especially for accessibility software where reliability is critical.
diff --git a/autostart/runit/fenrir/run b/autostart/runit/fenrir/run
deleted file mode 100644
index 4fbf6c16..00000000
--- a/autostart/runit/fenrir/run
+++ /dev/null
@@ -1,3 +0,0 @@
-#!/bin/sh
-[ -r ./conf ] && . ./conf
-exec fenrir -f
diff --git a/autostart/systemd/Arch/fenrir.service b/autostart/systemd/Arch/fenrir.service
deleted file mode 100644
index 737c7cd5..00000000
--- a/autostart/systemd/Arch/fenrir.service
+++ /dev/null
@@ -1,13 +0,0 @@
-[Unit]
-Description=Fenrir screenreader
-Wants=systemd-udev-settle.service
-After=systemd-udev-settle.service getty.target
-[Service]
-Type=forking
-PIDFile=/run/fenrir.pid
-ExecStart=/usr/bin/fenrir
-ExecReload=/usr/bin/kill -HUP $MAINPID
-Restart=always
-
-[Install]
-WantedBy=getty.target
diff --git a/autostart/systemd/Debian/fenrir.service b/autostart/systemd/Debian/fenrir.service
deleted file mode 100644
index fdaee789..00000000
--- a/autostart/systemd/Debian/fenrir.service
+++ /dev/null
@@ -1,18 +0,0 @@
-[Unit]
-Description=Fenrir screenreader
-Wants=systemd-udev-settle.service
-After=systemd-udev-settle.service sound.target
-[Service]
-Type=forking
-PIDFile=/run/fenrir.pid
-ExecStart=/usr/local/bin/fenrir
-ExecReload=/usr/bin/kill -HUP $MAINPID
-Restart=always
-#Group=fenrirscreenreader
-#User=fenrirscreenreader
-
-[Install]
-# start as early as possible in boot process
-#WantedBy=sound.target
-# start as soon the login prompt is available
-WantedBy=getty.target
diff --git a/check-dependencies.py b/check-dependencies.py
deleted file mode 100755
index dc62a78a..00000000
--- a/check-dependencies.py
+++ /dev/null
@@ -1,137 +0,0 @@
-#!/usr/bin/env python3
-
-import os
-import sys
-from dataclasses import dataclass
-from typing import List, Optional
-
-@dataclass
-class Dependency:
- name: str
- depType: str # screen, input, sound, speech, core
- moduleName: str
- checkCommands: Optional[List[str]] = None # Command-line tools to check
- pythonImports: Optional[List[str]] = None # Python packages to check
- devicePaths: Optional[List[str]] = None # Device files to check
-
-def check_dependency(dep: Dependency) -> bool:
- """Check if a single dependency is satisfied."""
- isAvailable = True
-
- if dep.pythonImports:
- for package in dep.pythonImports:
- try:
- moduleName = package.split('.')[0]
- __import__(moduleName)
- print(f'{package}: OK')
- except ImportError:
- print(f'{package}: FAIL')
- isAvailable = False
-
- if dep.checkCommands:
- for cmd in dep.checkCommands:
- if os.path.exists(f'/usr/bin/{cmd}') or os.path.exists(f'/bin/{cmd}'):
- print(f'{cmd}: OK')
- else:
- print(f'{cmd}: FAIL')
- isAvailable = False
-
- if dep.devicePaths:
- for path in dep.devicePaths:
- if os.path.exists(path):
- print(f'{path}: OK')
- else:
- print(f'{path}: FAIL')
- isAvailable = False
-
- return isAvailable
-
-# Define all dependencies
-dependencyList = [
- # Core dependencies
- Dependency('FenrirCore', 'core', 'core',
- pythonImports=[
- 'daemonize',
- 'enchant',
- 'pyperclip',
- 'setproctitle',
- 'xdg'
- ]),
-
- # Screen drivers
- Dependency('DummyScreen', 'screen', 'dummyDriver'),
- Dependency('VCSA', 'screen', 'vcsaDriver',
- pythonImports=['dbus'],
- devicePaths=['/dev/vcsa']),
- Dependency('PTY', 'screen', 'ptyDriver',
- pythonImports=['pyte']),
-
- # Input drivers
- Dependency('DummyInput', 'input', 'dummyDriver'),
- Dependency('DebugInput', 'input', 'debugDriver'),
- Dependency('Evdev', 'input', 'evdevDriver',
- pythonImports=['evdev', 'evdev.InputDevice', 'evdev.UInput', 'pyudev']),
- # Sound drivers
- Dependency('DummySound', 'sound', 'dummyDriver'),
- Dependency('DebugSound', 'sound', 'debugDriver'),
- Dependency('GenericSound', 'sound', 'genericDriver',
- checkCommands=['play', 'sox']),
- Dependency('GStreamer', 'sound', 'gstreamerDriver',
- pythonImports=['gi', 'gi.repository.GLib', 'gi.repository.Gst']),
-
- # Speech drivers
- Dependency('DummySpeech', 'speech', 'dummyDriver'),
- Dependency('DebugSpeech', 'speech', 'debugDriver'),
- Dependency('Speechd', 'speech', 'speechdDriver',
- pythonImports=['speechd']),
- Dependency('GenericSpeech', 'speech', 'genericDriver',
- checkCommands=['espeak-ng'])
-]
-
-defaultModules = {
- 'FenrirCore',
- 'VCSA',
- 'Evdev',
- 'GenericSpeech',
- 'GenericSound'
-}
-
-def check_all_dependencies():
- print('Checking dependencies...\n')
- availableModules = []
-
- # Group dependencies by type for organized output
- for depType in ['core', 'screen', 'input', 'sound', 'speech']:
- print(f'{depType.upper()} DRIVERS')
- print('-' * 20)
-
- depsOfType = [d for d in dependencyList if d.depType == depType]
- for dep in depsOfType:
- print(f'\nChecking {dep.name}:')
- if check_dependency(dep):
- availableModules.append(dep.name)
- print('')
-
- print_summary(availableModules)
-
-def print_summary(availableModules: List[str]):
- print('=' * 20)
- print('SUMMARY')
- print('=' * 20)
-
- missingModules = defaultModules - set(availableModules)
- if missingModules:
- print('Default Setup: FAIL')
- print('\nUnavailable Default Modules:')
- for module in missingModules:
- print(f'- {module}')
- print('\nYou may need to install the missing dependencies for the modules above or reconfigure fenrir to not use them.')
- else:
- print('Default Setup: OK')
-
- print('\nAvailable Modules:')
- for module in availableModules:
- print(f'- {module}')
-
-if __name__ == '__main__':
- check_all_dependencies()
diff --git a/cmd/fenrir/main.go b/cmd/fenrir/main.go
new file mode 100644
index 00000000..09056884
--- /dev/null
+++ b/cmd/fenrir/main.go
@@ -0,0 +1,232 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "io"
+ "log/slog"
+ "os"
+ "os/signal"
+ "path/filepath"
+ "syscall"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/app"
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/i18n"
+ "git.stormux.org/storm/fenrir/internal/input"
+ "git.stormux.org/storm/fenrir/internal/punctuation"
+ "git.stormux.org/storm/fenrir/internal/version"
+)
+
+func main() {
+ i18n.Use(i18n.New(i18n.LocaleDir, i18n.Language()))
+ os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
+}
+
+func run(args []string, stdout, stderr io.Writer) int {
+ flags := flag.NewFlagSet("fenrir", flag.ContinueOnError)
+ flags.SetOutput(stderr)
+ showVersion := flags.Bool("version", false, "print the version and release channel")
+ terminalMode := flags.Bool("x", false, "run an unprivileged shell in this X11 terminal")
+ configPath := flags.String("config", "", "settings.toml path (default: Fenrir user/system settings directories)")
+ keyboardLayout := flags.String("keyboard-layout", "", "keyboard layout name or TOML path (overrides keyboard.layout)")
+ punctuationProfile := flags.String("punctuation-profile", "", "punctuation profile name or TOML path (overrides reading.punctuation_profile)")
+ checkConfig := flags.Bool("check-config", false, "validate settings and exit without opening devices")
+ writeConfig := flags.String("write-config", "", "atomically write effective settings to PATH and exit")
+ driver := flags.String("speech-driver", "speech-dispatcher", "speech driver ID (overrides presentation.speech.driver)")
+ rate := flags.Int("speech-rate", 80, "Speech Dispatcher rate (-100 to 100)")
+ module := flags.String("speech-module", "", "Speech Dispatcher output module (empty uses server default)")
+ debugFile := flags.String("debug-file", "", "append timing and reader-decision diagnostics without text or key values")
+ var devices []string
+ flags.Func("input-device", "keyboard event path; repeat to select devices (default: discover keyboards)", func(value string) error {
+ if value == "" {
+ return fmt.Errorf("input device path cannot be empty")
+ }
+ devices = append(devices, value)
+ return nil
+ })
+ flags.Usage = func() {
+ fmt.Fprintln(flags.Output(), "Usage: fenrir [options]")
+ fmt.Fprintln(flags.Output(), "\nFenrir is a Linux console screen reader.")
+ fmt.Fprintln(flags.Output(), "TTY mode requires console, evdev and uinput access. Use -x in an X11 terminal without sudo.")
+ fmt.Fprintln(flags.Output(), "NumLock off: keypad 7/8/9 lines, 4/5/6 words, 1/2/3 characters.")
+ fmt.Fprintln(flags.Output(), "Insert or keypad 0 is the Fenrir key. Fenrir+F menu; Fenrir+H help; Fenrir+Q quit.")
+ fmt.Fprintln(flags.Output(), "\nOptions:")
+ flags.PrintDefaults()
+ }
+ if err := flags.Parse(args); err != nil {
+ if err == flag.ErrHelp {
+ return 0
+ }
+ return 2
+ }
+ if flags.NArg() != 0 {
+ fmt.Fprintln(stderr, "fenrir: unexpected positional arguments")
+ return 2
+ }
+ if *showVersion {
+ fmt.Fprintln(stdout, "fenrir", version.Label())
+ return 0
+ }
+ settings, path, err := load_settings(flags, *configPath, *rate, *module, devices, *keyboardLayout, *driver, *punctuationProfile)
+ if err != nil {
+ fmt.Fprintln(stderr, "fenrir: settings:", path, err)
+ return 2
+ }
+ if *writeConfig != "" {
+ if err := config.Save(*writeConfig, settings); err != nil {
+ fmt.Fprintln(stderr, "fenrir: save settings:", err)
+ return 1
+ }
+ fmt.Fprintln(stdout, "Settings written to", *writeConfig)
+ return 0
+ }
+ if !*checkConfig && *terminalMode && len(settings.Keyboard.Devices) != 0 {
+ fmt.Fprintln(stderr, "fenrir: -x does not use keyboard.devices or --input-device")
+ return 2
+ }
+ bindings, bindingPath, err := load_keyboard(settings.Keyboard.Layout)
+ if err != nil {
+ fmt.Fprintln(stderr, "fenrir: keyboard layout:", bindingPath, err)
+ return 2
+ }
+ if *checkConfig {
+ if _, profilePath, err := punctuation.Open(settings.Reading.PunctuationProfile); err != nil {
+ fmt.Fprintln(stderr, "fenrir: punctuation profile:", profilePath, err)
+ return 2
+ }
+ fmt.Fprintln(stdout, "Settings valid. Keyboard layout:", bindingPath)
+ return 0
+ }
+ logOutput, logLevel := stderr, slog.LevelInfo
+ if *debugFile != "" {
+ file, err := os.OpenFile(*debugFile, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0600)
+ if err != nil {
+ fmt.Fprintln(stderr, "fenrir: open debug file:", err)
+ return 1
+ }
+ defer file.Close()
+ logOutput, logLevel = file, slog.LevelDebug
+ }
+ previousLogger := slog.Default()
+ defer slog.SetDefault(previousLogger)
+ slog.SetDefault(slog.New(slog.NewTextHandler(logOutput, &slog.HandlerOptions{
+ Level: logLevel,
+ ReplaceAttr: func(groups []string, attribute slog.Attr) slog.Attr {
+ // Warnings and timing records put timestamps after the message.
+ if attribute.Key == slog.TimeKey {
+ return slog.Attr{}
+ }
+ return attribute
+ },
+ })))
+ slog.Info("reader starting", "pid", os.Getpid(), "terminal_mode", *terminalMode, "at", time.Now())
+ ctx, stop := termination_context()
+ defer stop()
+ start := app.Start
+ if *terminalMode {
+ start = app.Start_terminal
+ }
+ if path == "" {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ fmt.Fprintln(stderr, "fenrir:", err)
+ return 1
+ }
+ path = config.Paths(home, os.Geteuid())[0]
+ }
+ if err := start(ctx, settings, path, bindings); err != nil {
+ if *debugFile != "" {
+ slog.Error("reader stopped", "pid", os.Getpid(), "error", err, "at", time.Now())
+ }
+ fmt.Fprintln(stderr, "fenrir:", err)
+ return 1
+ }
+ slog.Info("reader stopped", "pid", os.Getpid(), "at", time.Now())
+ return 0
+}
+
+func termination_context() (context.Context, func()) {
+ ctx, cancel := context.WithCancel(context.Background())
+ signals := make(chan os.Signal, 1)
+ signal.Notify(signals, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGQUIT, syscall.SIGTSTP)
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ select {
+ case received := <-signals:
+ slog.Info("reader received signal", "signal", received.String(), "pid", os.Getpid(), "at", time.Now())
+ cancel()
+ case <-ctx.Done():
+ }
+ }()
+ return ctx, func() {
+ signal.Stop(signals)
+ cancel()
+ <-done
+ }
+}
+
+func load_keyboard(layout string) ([]input.Binding, string, error) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return nil, "", err
+ }
+ executable, err := os.Executable()
+ if err != nil {
+ return nil, "", err
+ }
+ bundledDirectory := filepath.Join(filepath.Dir(executable), "config", "keyboard")
+ paths, err := input.Layout_paths(layout, home, os.Geteuid(), bundledDirectory)
+ if err != nil {
+ return nil, "", err
+ }
+ return input.Load_bindings(paths)
+}
+
+func load_settings(flags *flag.FlagSet, requested string, rate int, module string, devices []string, keyboardLayout string, driver string, punctuationProfile string) (config.Settings, string, error) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return config.Settings{}, "", err
+ }
+ settings, path, err := config.Load(requested, config.Paths(home, os.Geteuid()))
+ if err != nil {
+ return config.Settings{}, path, err
+ }
+ flags.Visit(func(option *flag.Flag) {
+ switch option.Name {
+ case "punctuation-profile":
+ settings.Reading.PunctuationProfile = punctuationProfile
+ case "speech-driver":
+ settings.Presentation.Speech.Driver = driver
+ case "speech-rate":
+ settings.Presentation.Speech.Rate = rate
+ case "speech-module":
+ settings.Presentation.Speech.Module = module
+ case "keyboard-layout":
+ settings.Keyboard.Layout = keyboardLayout
+ case "input-device":
+ settings.Keyboard.Devices = devices
+ }
+ })
+ return settings, path, settings.Validate()
+}
diff --git a/cmd/fenrir/main_test.go b/cmd/fenrir/main_test.go
new file mode 100644
index 00000000..418ed37f
--- /dev/null
+++ b/cmd/fenrir/main_test.go
@@ -0,0 +1,149 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package main
+
+import (
+ "bytes"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/version"
+)
+
+func TestCLIWithoutOpeningDevices(t *testing.T) {
+ for _, testCase := range []struct {
+ args []string
+ status int
+ text string
+ }{
+ {[]string{"--help"}, 0, "keypad 7/8/9"},
+ {[]string{"--help"}, 0, "(default 80)"},
+ {[]string{"--version"}, 0, "fenrir " + version.Label()},
+ {[]string{"--unknown"}, 2, "flag provided but not defined"},
+ {[]string{"--speech-socket=/tmp/unused.sock"}, 2, "flag provided but not defined"},
+ {[]string{"unexpected"}, 2, "unexpected positional arguments"},
+ {[]string{"--speech-rate=101"}, 2, "presentation.speech.rate must be"},
+ {[]string{"-x", "--keyboard-layout", "../../config/keyboard/desktop.toml", "--punctuation-profile", "../../config/punctuation/default.toml", "--check-config"}, 0, "Settings valid"},
+ {[]string{"-x", "--input-device=/dev/input/event1"}, 2, "-x does not use"},
+ } {
+ var stdout, stderr bytes.Buffer
+ if got := run(testCase.args, &stdout, &stderr); got != testCase.status {
+ t.Fatalf("%v: exit %d, output %s%s", testCase.args, got, &stdout, &stderr)
+ }
+ if !strings.Contains(stdout.String()+stderr.String(), testCase.text) {
+ t.Fatalf("%v: missing %q", testCase.args, testCase.text)
+ }
+ }
+}
+
+func TestSettingsCommandsAndExplicitCLIOverrides(t *testing.T) {
+ root := t.TempDir()
+ path := filepath.Join(root, "settings.toml")
+ if err := os.WriteFile(path, []byte("version=1\n[sound]\nenabled=false\n[speech]\nrate=25\nmodule='example'\n[keyboard]\ndevices=['/dev/input/event1']\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ var stdout, stderr bytes.Buffer
+ if status := run([]string{"--config", path, "--keyboard-layout", "../../config/keyboard/desktop.toml", "--punctuation-profile", "../../config/punctuation/default.toml", "--check-config"}, &stdout, &stderr); status != 0 {
+ t.Fatal(status, stderr.String())
+ }
+ output := filepath.Join(root, "saved.toml")
+ if status := run([]string{"--config", path, "--speech-rate=80", "--speech-module=", "--input-device=/dev/input/event2", "--write-config", output}, &stdout, &stderr); status != 0 {
+ t.Fatal(status, stderr.String())
+ }
+ settings, _, err := config.Load(output, nil)
+ if err != nil || settings.Presentation.Speech.Rate != 80 || settings.Presentation.Speech.Module != "" || settings.Presentation.Sound.Enabled || len(settings.Keyboard.Devices) != 1 || settings.Keyboard.Devices[0] != "/dev/input/event2" {
+ t.Fatalf("explicit overrides: %+v %v", settings, err)
+ }
+ if status := run([]string{"--config", filepath.Join(root, "missing.toml"), "--punctuation-profile", "../../config/punctuation/default.toml", "--check-config"}, &stdout, &stderr); status != 2 {
+ t.Fatal("ignored missing explicit config", status)
+ }
+}
+
+func TestDebugFileErrorPrecedesReaderStartup(t *testing.T) {
+ var stdout, stderr bytes.Buffer
+ path := filepath.Join(t.TempDir(), "missing", "timing.log")
+ if status := run([]string{"--keyboard-layout", "../../config/keyboard/desktop.toml", "--debug-file", path}, &stdout, &stderr); status != 1 {
+ t.Fatal("wrong exit status", status)
+ }
+ if !strings.Contains(stderr.String(), "open debug file:") {
+ t.Fatal("debug-file error was not reported before reader startup", stderr.String())
+ }
+}
+
+func TestKeyboardLayoutSelectionAndValidationBeforeStartup(t *testing.T) {
+ root := t.TempDir()
+ settingsPath := filepath.Join(root, "settings.toml")
+ layoutPath := filepath.Join(root, "custom.toml")
+ data, err := os.ReadFile("../../config/keyboard/desktop.toml")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(layoutPath, data, 0600); err != nil {
+ t.Fatal(err)
+ }
+ settings := config.Defaults()
+ settings.Keyboard.Layout = layoutPath
+ if err := config.Save(settingsPath, settings); err != nil {
+ t.Fatal(err)
+ }
+ var stdout, stderr bytes.Buffer
+ if status := run([]string{"--config", settingsPath, "--punctuation-profile", "../../config/punctuation/default.toml", "--check-config"}, &stdout, &stderr); status != 0 || !strings.Contains(stdout.String(), layoutPath) {
+ t.Fatal(status, stdout.String(), stderr.String())
+ }
+ for _, layout := range []string{"", filepath.Join(root, "missing.toml"), filepath.Join(root, "old.conf")} {
+ stderr.Reset()
+ if status := run([]string{"--config", settingsPath, "--keyboard-layout", layout}, &stdout, &stderr); status != 2 {
+ t.Fatal("layout error reached startup", layout, status, stderr.String())
+ }
+ }
+ if err := os.WriteFile(layoutPath, []byte("version=1\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if status := run([]string{"--config", settingsPath, "--punctuation-profile", "../../config/punctuation/default.toml", "--check-config"}, &stdout, &stderr); status != 2 {
+ t.Fatal("malformed layout accepted", status)
+ }
+ if status := run([]string{"--config", settingsPath, "--keyboard-layout", "../../config/keyboard/laptop.toml", "--punctuation-profile", "../../config/punctuation/default.toml", "--check-config"}, &stdout, &stderr); status != 0 {
+ t.Fatal("CLI override ignored", status, stderr.String())
+ }
+}
+
+func TestDriverChoiceUsesSameValidationForFileAndCLI(t *testing.T) {
+ root := t.TempDir()
+ path := filepath.Join(root, "settings.toml")
+ if err := config.Save(path, config.Defaults()); err != nil {
+ t.Fatal(err)
+ }
+ var stdout, stderr bytes.Buffer
+ output := filepath.Join(root, "saved.toml")
+ if got := run([]string{"--config", path, "--speech-driver=speech-dispatcher", "--write-config", output}, &stdout, &stderr); got != 0 {
+ t.Fatal(got, stderr.String())
+ }
+ loaded, _, err := config.Load(output, nil)
+ if err != nil || loaded.Presentation.Speech.Driver != "speech-dispatcher" {
+ t.Fatal(loaded, err)
+ }
+ for _, driver := range []string{"invented", "", "speech-dispatcher,other"} {
+ stderr.Reset()
+ if got := run([]string{"--config", path, "--speech-driver=" + driver, "--punctuation-profile", "../../config/punctuation/default.toml", "--check-config"}, &stdout, &stderr); got != 2 || !strings.Contains(stderr.String(), "unknown speech driver") {
+ t.Fatal(got, stderr.String())
+ }
+ }
+}
diff --git a/config/keyboard/desktop.conf b/config/keyboard/desktop.conf
deleted file mode 100644
index b69a5df3..00000000
--- a/config/keyboard/desktop.conf
+++ /dev/null
@@ -1,132 +0,0 @@
-KEY_FENRIR,KEY_H=toggle_tutorial_mode
-KEY_FENRIR,KEY_CTRL,KEY_H=speech_history
-KEY_CTRL=shut_up
-KEY_FENRIR,KEY_KP9=review_bottom
-KEY_FENRIR,KEY_KP7=review_top
-KEY_KP8=review_curr_line
-KEY_KP7=review_prev_line
-KEY_KP9=review_next_line
-KEY_FENRIR,KEY_KP4=review_line_begin
-KEY_FENRIR,KEY_KP6=review_line_end
-KEY_FENRIR,KEY_KP1=review_line_first_char
-KEY_FENRIR,KEY_KP3=review_line_last_char
-KEY_FENRIR,KEY_ALT,KEY_1=present_first_line
-KEY_FENRIR,KEY_ALT,KEY_2=present_last_line
-KEY_KP5=review_curr_word
-KEY_KP4=review_prev_word
-KEY_KP6=review_next_word
-2,KEY_KP5=review_curr_word_spell
-3,KEY_KP5=review_curr_word_phonetic
-KEY_KP2=review_curr_char
-KEY_KP1=review_prev_char
-KEY_KP3=review_next_char
-2,KEY_KP2=review_curr_char_phonetic
-KEY_FENRIR,KEY_CTRL,KEY_KP8=review_up
-KEY_FENRIR,KEY_CTRL,KEY_KP2=review_down
-KEY_FENRIR,KEY_KPDOT=exit_review
-KEY_KPDOT=cursor_position
-KEY_FENRIR,KEY_I=indent_curr_line
-KEY_FENRIR,KEY_KP5=curr_screen
-KEY_FENRIR,KEY_KP8=curr_screen_before_cursor
-KEY_FENRIR,KEY_KP2=curr_screen_after_cursor
-#=cursor_read_to_end_of_line
-#=review_screen_last_char
-#=review_screen_first_char
-#=cursor_column
-#=cursor_lineno
-#=braille_flush
-#=braille_return_to_cursor
-#=braille_pan_left
-#=braille_pan_right
-#=cursor_read_line_to_cursor
-KEY_FENRIR,KEY_CTRL,KEY_1=clear_bookmark_1
-KEY_FENRIR,KEY_SHIFT,KEY_1=set_bookmark_1
-KEY_FENRIR,KEY_1=bookmark_1
-KEY_FENRIR,KEY_CTRL,KEY_2=clear_bookmark_2
-KEY_FENRIR,KEY_SHIFT,KEY_2=set_bookmark_2
-KEY_FENRIR,KEY_2=bookmark_2
-KEY_FENRIR,KEY_CTRL,KEY_3=clear_bookmark_3
-KEY_FENRIR,KEY_SHIFT,KEY_3=set_bookmark_3
-KEY_FENRIR,KEY_3=bookmark_3
-KEY_FENRIR,KEY_CTRL,KEY_4=clear_bookmark_4
-KEY_FENRIR,KEY_SHIFT,KEY_4=set_bookmark_4
-KEY_FENRIR,KEY_4=bookmark_4
-KEY_FENRIR,KEY_CTRL,KEY_5=clear_bookmark_5
-KEY_FENRIR,KEY_SHIFT,KEY_5=set_bookmark_5
-KEY_FENRIR,KEY_5=bookmark_5
-KEY_FENRIR,KEY_CTRL,KEY_6=clear_bookmark_6
-KEY_FENRIR,KEY_SHIFT,KEY_6=set_bookmark_6
-KEY_FENRIR,KEY_6=bookmark_6
-KEY_FENRIR,KEY_CTRL,KEY_7=clear_bookmark_7
-KEY_FENRIR,KEY_SHIFT,KEY_7=set_bookmark_7
-KEY_FENRIR,KEY_7=bookmark_7
-KEY_FENRIR,KEY_CTRL,KEY_8=clear_bookmark_8
-KEY_FENRIR,KEY_SHIFT,KEY_8=set_bookmark_8
-KEY_FENRIR,KEY_8=bookmark_8
-KEY_FENRIR,KEY_CTRL,KEY_9=clear_bookmark_9
-KEY_FENRIR,KEY_SHIFT,KEY_9=set_bookmark_9
-KEY_FENRIR,KEY_9=bookmark_9
-KEY_FENRIR,KEY_CTRL,KEY_0=clear_bookmark_10
-KEY_FENRIR,KEY_SHIFT,KEY_0=set_bookmark_10
-KEY_FENRIR,KEY_0=bookmark_10
-KEY_FENRIR,KEY_KPSLASH=set_window_application
-2,KEY_FENRIR,KEY_KPSLASH=clear_window_application
-KEY_KPPLUS=progress_bar_monitor
-#KEY_FENRIR,KEY_KPPLUS=silence_until_prompt
-KEY_FENRIR,KEY_F2=toggle_braille
-KEY_FENRIR,KEY_F3=toggle_sound
-KEY_FENRIR,KEY_F4=toggle_speech
-KEY_KPENTER=temp_disable_speech
-KEY_FENRIR,KEY_CTRL,KEY_P=toggle_punctuation_level
-KEY_FENRIR,KEY_RIGHTBRACE=toggle_auto_spell_check
-KEY_FENRIR,KEY_BACKSLASH=toggle_output
-KEY_FENRIR,KEY_CTRL,KEY_E=toggle_emoticons
-KEY_FENRIR,KEY_CTRL,KEY_D=toggle_diff_mode
-KEY_FENRIR,KEY_CTRL,KEY_SHIFT,KEY_E=cycle_key_echo
-key_FENRIR,KEY_KPENTER=toggle_auto_read
-KEY_FENRIR,KEY_CTRL,KEY_T=toggle_auto_time
-KEY_FENRIR,KEY_KPASTERISK=toggle_highlight_tracking
-KEY_FENRIR,KEY_KPMINUS=toggle_barrier
-KEY_FENRIR,KEY_Q=quit_fenrir
-KEY_FENRIR,KEY_T=time
-2,KEY_FENRIR,KEY_T=date
-KEY_KPSLASH=toggle_auto_indent
-KEY_KPMINUS=attribute_cursor
-#=toggle_has_attribute
-KEY_FENRIR,KEY_S=spell_check
-2,KEY_FENRIR,KEY_S=add_word_to_spell_check
-KEY_FENRIR,KEY_SHIFT,KEY_S=remove_word_from_spell_check
-KEY_FENRIR,KEY_BACKSPACE=forward_keypress
-KEY_FENRIR,KEY_ALT,KEY_UP=inc_sound_volume
-KEY_FENRIR,KEY_ALT,KEY_DOWN=dec_sound_volume
-KEY_FENRIR,KEY_CTRL,KEY_SHIFT,KEY_C=clear_clipboard
-KEY_FENRIR,KEY_HOME=first_clipboard
-KEY_FENRIR,KEY_END=last_clipboard
-KEY_FENRIR,KEY_PAGEUP=prev_clipboard
-KEY_FENRIR,KEY_PAGEDOWN=next_clipboard
-KEY_FENRIR,KEY_SHIFT,KEY_C=curr_clipboard
-KEY_FENRIR,KEY_C=copy_marked_to_clipboard
-KEY_FENRIR,KEY_CTRL,KEY_C=copy_last_echo_to_clipboard
-KEY_FENRIR,KEY_V=paste_clipboard
-KEY_FENRIR,KEY_F5=import_clipboard_from_file
-KEY_FENRIR,KEY_F6=export_clipboard_to_file
-KEY_FENRIR,KEY_CTRL,KEY_SHIFT,KEY_X=remove_marks
-KEY_FENRIR,KEY_X=set_mark
-KEY_FENRIR,KEY_SHIFT,KEY_X=marked_text
-KEY_FENRIR,KEY_F10=toggle_vmenu_mode
-KEY_FENRIR,KEY_SHIFT,KEY_F10=voice_browser_safe
-KEY_FENRIR,KEY_CTRL,KEY_SHIFT,KEY_F10=apply_tested_voice
-KEY_FENRIR,KEY_SPACE=current_quick_menu_entry
-KEY_FENRIR,KEY_CTRL,KEY_SPACE=current_quick_menu_value
-KEY_FENRIR,KEY_RIGHT=next_quick_menu_entry
-KEY_FENRIR,KEY_UP=next_quick_menu_value
-KEY_FENRIR,KEY_LEFT=prev_quick_menu_entry
-KEY_FENRIR,KEY_DOWN=prev_quick_menu_value
-KEY_FENRIR,KEY_CTRL,KEY_S=save_settings
-# linux specific
-KEY_FENRIR,KEY_F7=import_clipboard_from_x
-KEY_FENRIR,KEY_F8=export_clipboard_to_x
-# Read-all functionality
-KEY_FENRIR,KEY_CTRL,KEY_DOWN=read_all_by_line
-KEY_FENRIR,KEY_CTRL,KEY_PAGEDOWN=read_all_by_page
-KEY_FENRIR,KEY_SHIFT,KEY_V=announce_fenrir_version
diff --git a/config/keyboard/desktop.toml b/config/keyboard/desktop.toml
new file mode 100644
index 00000000..ad682137
--- /dev/null
+++ b/config/keyboard/desktop.toml
@@ -0,0 +1,480 @@
+# Shared desktop layout for evdev and X11 terminal input.
+# FENRIR is Insert, Super, or NumLock-off keypad 0.
+# Bare keypad review bindings apply with NumLock off.
+version = 1
+
+[[bindings]]
+name = "Tracking mode"
+keys = ["FENRIR", "KPASTERISK"]
+command = "toggle_highlight_tracking"
+
+[[bindings]]
+name = "Fenrir Menu"
+keys = ["FENRIR", "F"]
+command = "fenrir_menu"
+
+[[bindings]]
+name = "Emoji Menu"
+keys = ["FENRIR", "CTRL", "E"]
+command = "emoji_menu"
+
+[[bindings]]
+name = "Top"
+keys = ["FENRIR", "KP7"]
+command = "top"
+
+[[bindings]]
+name = "Bottom"
+keys = ["FENRIR", "KP9"]
+command = "bottom"
+
+[[bindings]]
+name = "Line start"
+keys = ["FENRIR", "KP4"]
+command = "line_start"
+
+[[bindings]]
+name = "Line end"
+keys = ["FENRIR", "KP6"]
+command = "line_end"
+
+[[bindings]]
+name = "First text"
+keys = ["FENRIR", "KP1"]
+command = "first_text"
+
+[[bindings]]
+name = "Last text"
+keys = ["FENRIR", "KP3"]
+command = "last_text"
+
+[[bindings]]
+name = "Read screen"
+keys = ["FENRIR", "KP5"]
+command = "read_screen"
+
+[[bindings]]
+name = "Screen before cursor"
+keys = ["FENRIR", "KP8"]
+command = "curr_screen_before_cursor"
+
+[[bindings]]
+name = "Screen after cursor"
+keys = ["FENRIR", "KP2"]
+command = "curr_screen_after_cursor"
+
+[[bindings]]
+name = "Character above"
+keys = ["FENRIR", "CTRL", "KP8"]
+command = "review_up"
+
+[[bindings]]
+name = "Character below"
+keys = ["FENRIR", "CTRL", "KP2"]
+command = "review_down"
+
+[[bindings]]
+name = "Follow cursor"
+keys = ["FENRIR", "KPDOT"]
+command = "follow_cursor"
+
+[[bindings]]
+name = "Help"
+keys = ["FENRIR", "H"]
+command = "help"
+
+[[bindings]]
+name = "Tutorial"
+keys = ["FENRIR", "F1"]
+command = "help"
+
+[[bindings]]
+name = "Mute"
+keys = ["FENRIR", "F4"]
+command = "mute"
+
+[[bindings]]
+name = "Temporary speech silence"
+keys = ["KPENTER"]
+command = "temp_disable_speech"
+
+[[bindings]]
+name = "Automatic reading"
+keys = ["FENRIR", "KPENTER"]
+command = "auto_read"
+
+[[bindings]]
+name = "Forward next key"
+keys = ["FENRIR", "BACKSPACE"]
+command = "pass_next"
+
+[[bindings]]
+name = "Quit"
+keys = ["FENRIR", "Q"]
+command = "quit"
+
+[[bindings]]
+name = "Previous line"
+keys = ["KP7"]
+command = "previous_line"
+
+[[bindings]]
+name = "Current line"
+keys = ["KP8"]
+command = "current_line"
+double_command = "spell_line"
+triple_command = "phonetic_line"
+
+[[bindings]]
+name = "Next line"
+keys = ["KP9"]
+command = "next_line"
+
+[[bindings]]
+name = "Previous word"
+keys = ["KP4"]
+command = "previous_word"
+
+[[bindings]]
+name = "Current word"
+keys = ["KP5"]
+command = "current_word"
+double_command = "spell_word"
+triple_command = "phonetic_word"
+
+[[bindings]]
+name = "Next word"
+keys = ["KP6"]
+command = "next_word"
+
+[[bindings]]
+name = "Previous character"
+keys = ["KP1"]
+command = "previous_character"
+
+[[bindings]]
+name = "Current character"
+keys = ["KP2"]
+command = "current_character"
+double_command = "phonetic_character"
+
+[[bindings]]
+name = "Next character"
+keys = ["KP3"]
+command = "next_character"
+
+[[bindings]]
+name = "Position"
+keys = ["KPDOT"]
+command = "position"
+
+# Clipboard commands match Python Fenrir.
+
+[[bindings]]
+name = "Set mark"
+keys = ["FENRIR", "X"]
+command = "set_mark"
+
+[[bindings]]
+name = "Remove marks"
+keys = ["FENRIR", "CTRL", "SHIFT", "X"]
+command = "remove_marks"
+
+[[bindings]]
+name = "Marked text"
+keys = ["FENRIR", "SHIFT", "X"]
+command = "marked_text"
+
+[[bindings]]
+name = "Copy marked text"
+keys = ["FENRIR", "C"]
+command = "copy_marked_to_clipboard"
+
+[[bindings]]
+name = "Current clipboard"
+keys = ["FENRIR", "SHIFT", "C"]
+command = "curr_clipboard"
+
+[[bindings]]
+name = "Clear clipboard"
+keys = ["FENRIR", "CTRL", "SHIFT", "C"]
+command = "clear_clipboard"
+
+[[bindings]]
+name = "First clipboard"
+keys = ["FENRIR", "HOME"]
+command = "first_clipboard"
+
+[[bindings]]
+name = "Last clipboard"
+keys = ["FENRIR", "END"]
+command = "last_clipboard"
+
+[[bindings]]
+name = "Previous clipboard"
+keys = ["FENRIR", "PAGEUP"]
+command = "prev_clipboard"
+
+[[bindings]]
+name = "Next clipboard"
+keys = ["FENRIR", "PAGEDOWN"]
+command = "next_clipboard"
+
+[[bindings]]
+name = "Paste clipboard"
+keys = ["FENRIR", "V"]
+command = "paste_clipboard"
+
+[[bindings]]
+name = "Import clipboard from file"
+keys = ["FENRIR", "F5"]
+command = "import_clipboard_from_file"
+
+[[bindings]]
+name = "Export clipboard to file"
+keys = ["FENRIR", "F6"]
+command = "export_clipboard_to_file"
+
+[[bindings]]
+name = "Import clipboard from X"
+keys = ["FENRIR", "F7"]
+command = "import_clipboard_from_x"
+
+[[bindings]]
+name = "Export clipboard to X"
+keys = ["FENRIR", "F8"]
+command = "export_clipboard_to_x"
+
+[[bindings]]
+name = "Copy last echo"
+keys = ["FENRIR", "CTRL", "C"]
+command = "copy_last_echo_to_clipboard"
+
+[[bindings]]
+name = "Current quick menu entry"
+keys = ["FENRIR", "SPACE"]
+command = "current_quick_menu_entry"
+
+[[bindings]]
+name = "Next quick menu category"
+keys = ["FENRIR", "CTRL", "RIGHT"]
+command = "next_quick_menu_category"
+
+[[bindings]]
+name = "Previous quick menu category"
+keys = ["FENRIR", "CTRL", "LEFT"]
+command = "prev_quick_menu_category"
+
+[[bindings]]
+name = "Next quick menu entry"
+keys = ["FENRIR", "RIGHT"]
+command = "next_quick_menu_entry"
+
+[[bindings]]
+name = "Previous quick menu entry"
+keys = ["FENRIR", "LEFT"]
+command = "prev_quick_menu_entry"
+
+[[bindings]]
+name = "Increase quick menu value"
+keys = ["FENRIR", "UP"]
+command = "next_quick_menu_value"
+
+[[bindings]]
+name = "Decrease quick menu value"
+keys = ["FENRIR", "DOWN"]
+command = "prev_quick_menu_value"
+
+[[bindings]]
+name = "Save settings"
+keys = ["FENRIR", "CTRL", "S"]
+command = "save_settings"
+
+[[bindings]]
+name = "Progress monitoring"
+keys = ["KPPLUS"]
+command = "progress_bar_monitor"
+
+[[bindings]]
+name = "Time and date"
+keys = ["FENRIR", "T"]
+command = "time"
+double_command = "date"
+
+[[bindings]]
+name = "Fenrir version"
+keys = ["FENRIR", "SHIFT", "V"]
+command = "announce_fenrir_version"
+
+[[bindings]]
+name = "Row number"
+keys = ["FENRIR", "CTRL", "KP7"]
+command = "cursor_lineno"
+
+[[bindings]]
+name = "Column number"
+keys = ["FENRIR", "CTRL", "KP9"]
+command = "cursor_column"
+
+[[bindings]]
+name = "Line to cursor"
+keys = ["FENRIR", "CTRL", "KP4"]
+command = "cursor_read_line_to_cursor"
+
+[[bindings]]
+name = "Line from cursor"
+keys = ["FENRIR", "CTRL", "KP6"]
+command = "cursor_read_to_end_of_line"
+
+[[bindings]]
+name = "First screen character"
+keys = ["FENRIR", "CTRL", "KP1"]
+command = "review_screen_first_char"
+
+[[bindings]]
+name = "Last screen character"
+keys = ["FENRIR", "CTRL", "KP3"]
+command = "review_screen_last_char"
+
+[[bindings]]
+name = "Attributes"
+keys = ["KPMINUS"]
+command = "attribute_cursor"
+
+[[bindings]]
+name = "Toggle attribute feedback"
+keys = ["FENRIR", "KPMINUS"]
+command = "toggle_has_attribute"
+
+[[bindings]]
+name = "Spellcheck Menu"
+keys = ["FENRIR", "S"]
+command = "spellcheck_menu"
+
+# Reading regions last for this reader session; marks use Fenrir+X.
+
+[[bindings]]
+name = "Reading window"
+keys = ["FENRIR", "KPSLASH"]
+command = "set_window_application"
+double_command = "clear_window_application"
+
+[[bindings]]
+name = "Set live region"
+keys = ["FENRIR", "B"]
+command = "set_live_region"
+
+[[bindings]]
+name = "List reading regions"
+keys = ["FENRIR", "SHIFT", "B"]
+command = "list_reading_regions"
+
+[[bindings]]
+name = "Clear live regions"
+keys = ["FENRIR", "CTRL", "B"]
+command = "clear_live_regions"
+
+[[bindings]]
+name = "Clear all reading regions"
+keys = ["FENRIR", "CTRL", "SHIFT", "B"]
+command = "clear_reading_regions"
+
+[[bindings]]
+name = "Read live region 1"
+keys = ["FENRIR", "1"]
+command = "read_live_region_1"
+
+[[bindings]]
+name = "Clear live region 1"
+keys = ["FENRIR", "SHIFT", "1"]
+command = "clear_live_region_1"
+
+[[bindings]]
+name = "Read live region 2"
+keys = ["FENRIR", "2"]
+command = "read_live_region_2"
+
+[[bindings]]
+name = "Clear live region 2"
+keys = ["FENRIR", "SHIFT", "2"]
+command = "clear_live_region_2"
+
+[[bindings]]
+name = "Read live region 3"
+keys = ["FENRIR", "3"]
+command = "read_live_region_3"
+
+[[bindings]]
+name = "Clear live region 3"
+keys = ["FENRIR", "SHIFT", "3"]
+command = "clear_live_region_3"
+
+[[bindings]]
+name = "Read live region 4"
+keys = ["FENRIR", "4"]
+command = "read_live_region_4"
+
+[[bindings]]
+name = "Clear live region 4"
+keys = ["FENRIR", "SHIFT", "4"]
+command = "clear_live_region_4"
+
+[[bindings]]
+name = "Read live region 5"
+keys = ["FENRIR", "5"]
+command = "read_live_region_5"
+
+[[bindings]]
+name = "Clear live region 5"
+keys = ["FENRIR", "SHIFT", "5"]
+command = "clear_live_region_5"
+
+[[bindings]]
+name = "Read live region 6"
+keys = ["FENRIR", "6"]
+command = "read_live_region_6"
+
+[[bindings]]
+name = "Clear live region 6"
+keys = ["FENRIR", "SHIFT", "6"]
+command = "clear_live_region_6"
+
+[[bindings]]
+name = "Read live region 7"
+keys = ["FENRIR", "7"]
+command = "read_live_region_7"
+
+[[bindings]]
+name = "Clear live region 7"
+keys = ["FENRIR", "SHIFT", "7"]
+command = "clear_live_region_7"
+
+[[bindings]]
+name = "Read live region 8"
+keys = ["FENRIR", "8"]
+command = "read_live_region_8"
+
+[[bindings]]
+name = "Clear live region 8"
+keys = ["FENRIR", "SHIFT", "8"]
+command = "clear_live_region_8"
+
+[[bindings]]
+name = "Read live region 9"
+keys = ["FENRIR", "9"]
+command = "read_live_region_9"
+
+[[bindings]]
+name = "Clear live region 9"
+keys = ["FENRIR", "SHIFT", "9"]
+command = "clear_live_region_9"
+
+[[bindings]]
+name = "Read live region 10"
+keys = ["FENRIR", "0"]
+command = "read_live_region_10"
+
+[[bindings]]
+name = "Clear live region 10"
+keys = ["FENRIR", "SHIFT", "0"]
+command = "clear_live_region_10"
diff --git a/config/keyboard/laptop.conf b/config/keyboard/laptop.conf
deleted file mode 100644
index b14be74f..00000000
--- a/config/keyboard/laptop.conf
+++ /dev/null
@@ -1,132 +0,0 @@
-KEY_FENRIR,KEY_H=toggle_tutorial_mode
-KEY_FENRIR,KEY_CTRL,KEY_H=speech_history
-KEY_CTRL=shut_up
-KEY_FENRIR,KEY_SHIFT,KEY_O=review_bottom
-KEY_FENRIR,KEY_SHIFT,KEY_U=review_top
-KEY_FENRIR,KEY_I=review_curr_line
-KEY_FENRIR,KEY_U=review_prev_line
-KEY_FENRIR,KEY_O=review_next_line
-KEY_FENRIR,KEY_SHIFT,KEY_J=review_line_begin
-KEY_FENRIR,KEY_SHIFT,KEY_L=review_line_end
-KEY_FENRIR,KEY_CTRL,KEY_J=review_line_first_char
-KEY_FENRIR,KEY_CTRL,KEY_L=review_line_last_char
-KEY_FENRIR,KEY_ALT,KEY_1=present_first_line
-KEY_FENRIR,KEY_ALT,KEY_2=present_last_line
-KEY_FENRIR,KEY_K=review_curr_word
-KEY_FENRIR,KEY_J=review_prev_word
-KEY_FENRIR,KEY_L=review_next_word
-2,KEY_FENRIR,KEY_K=review_curr_word_spell
-3,KEY_FENRIR,KEY_K=review_curr_word_phonetic
-KEY_FENRIR,KEY_COMMA=review_curr_char
-KEY_FENRIR,KEY_M=review_prev_char
-KEY_FENRIR,KEY_DOT=review_next_char
-2,KEY_FENRIR,KEY_COMMA=review_curr_char_phonetic
-KEY_FENRIR,KEY_CTRL,KEY_I=review_up
-KEY_FENRIR,KEY_CTRL,KEY_COMMA=review_down
-KEY_FENRIR,KEY_SLASH=exit_review
-KEY_FENRIR,KEY_SHIFT,KEY_DOT=cursor_position
-2,KEY_FENRIR,KEY_I=indent_curr_line
-KEY_FENRIR,KEY_SHIFT,KEY_K=curr_screen
-KEY_FENRIR,KEY_SHIFT,KEY_I=curr_screen_before_cursor
-KEY_FENRIR,KEY_SHIFT,KEY_COMMA=curr_screen_after_cursor
-#=cursor_read_to_end_of_line
-#=cursor_column
-#=cursor_lineno
-#=braille_flush
-#=braille_return_to_cursor
-#=braille_pan_left
-#=braille_pan_right
-KEY_FENRIR,KEY_CTRL,KEY_1=clear_bookmark_1
-KEY_FENRIR,KEY_SHIFT,KEY_1=set_bookmark_1
-KEY_FENRIR,KEY_1=bookmark_1
-KEY_FENRIR,KEY_CTRL,KEY_2=clear_bookmark_2
-KEY_FENRIR,KEY_SHIFT,KEY_2=set_bookmark_2
-KEY_FENRIR,KEY_2=bookmark_2
-KEY_FENRIR,KEY_CTRL,KEY_3=clear_bookmark_3
-KEY_FENRIR,KEY_SHIFT,KEY_3=set_bookmark_3
-KEY_FENRIR,KEY_3=bookmark_3
-KEY_FENRIR,KEY_CTRL,KEY_4=clear_bookmark_4
-KEY_FENRIR,KEY_SHIFT,KEY_4=set_bookmark_4
-KEY_FENRIR,KEY_4=bookmark_4
-KEY_FENRIR,KEY_CTRL,KEY_5=clear_bookmark_5
-KEY_FENRIR,KEY_SHIFT,KEY_5=set_bookmark_5
-KEY_FENRIR,KEY_5=bookmark_5
-KEY_FENRIR,KEY_CTRL,KEY_6=clear_bookmark_6
-KEY_FENRIR,KEY_SHIFT,KEY_6=set_bookmark_6
-KEY_FENRIR,KEY_6=bookmark_6
-KEY_FENRIR,KEY_CTRL,KEY_7=clear_bookmark_7
-KEY_FENRIR,KEY_SHIFT,KEY_7=set_bookmark_7
-KEY_FENRIR,KEY_7=bookmark_7
-KEY_FENRIR,KEY_CTRL,KEY_8=clear_bookmark_8
-KEY_FENRIR,KEY_SHIFT,KEY_8=set_bookmark_8
-KEY_FENRIR,KEY_8=bookmark_8
-KEY_FENRIR,KEY_CTRL,KEY_9=clear_bookmark_9
-KEY_FENRIR,KEY_SHIFT,KEY_9=set_bookmark_9
-KEY_FENRIR,KEY_9=bookmark_9
-KEY_FENRIR,KEY_CTRL,KEY_0=clear_bookmark_10
-KEY_FENRIR,KEY_SHIFT,KEY_0=set_bookmark_10
-KEY_FENRIR,KEY_0=bookmark_10
-KEY_FENRIR,KEY_CTRL,KEY_8=set_window_application
-2,KEY_FENRIR,KEY_CTRL,KEY_8=clear_window_application
-KEY_FENRIR,KEY_SEMICOLON=last_incoming
-KEY_FENRIR,KEY_F2=toggle_braille
-KEY_FENRIR,KEY_F3=toggle_sound
-KEY_FENRIR,KEY_F4=toggle_speech
-KEY_FENRIR,KEY_ENTER=temp_disable_speech
-KEY_FENRIR,KEY_SHIFT,KEY_P=progress_bar_monitor
-KEY_FENRIR,KEY_SHIFT,KEY_ENTER=toggle_auto_read
-KEY_FENRIR,KEY_SHIFT,KEY_CTRL,KEY_P=toggle_punctuation_level
-KEY_FENRIR,KEY_RIGHTBRACE=toggle_auto_spell_check
-KEY_FENRIR,KEY_CTRL,KEY_SHIFT,KEY_ENTER=toggle_output
-KEY_FENRIR,KEY_SHIFT,KEY_E=toggle_emoticons
-KEY_FENRIR,KEY_CTRL,KEY_D=toggle_diff_mode
-KEY_FENRIR,KEY_CTRL,KEY_SHIFT,KEY_E=cycle_key_echo
-KEY_FENRIR,KEY_CTRL,KEY_T=toggle_auto_time
-KEY_FENRIR,KEY_Y=toggle_highlight_tracking
-#=toggle_barrier
-#=cursor_read_line_to_cursor
-KEY_FENRIR,KEY_Q=quit_fenrir
-KEY_FENRIR,KEY_T=time
-2,KEY_FENRIR,KEY_T=date
-KEY_FENRIR,KEY_BACKSLASH=toggle_auto_indent
-KEY_FENRIR,KEY_MINUS=attribute_cursor
-#=toggle_has_attribute
-KEY_FENRIR,KEY_S=spell_check
-2,KEY_FENRIR,KEY_S=add_word_to_spell_check
-KEY_FENRIR,KEY_SHIFT,KEY_S=remove_word_from_spell_check
-KEY_FENRIR,KEY_BACKSPACE=forward_keypress
-KEY_FENRIR,KEY_ALT,KEY_UP=inc_sound_volume
-KEY_FENRIR,KEY_ALT,KEY_DOWN=dec_sound_volume
-KEY_FENRIR,KEY_CTRL,KEY_SHIFT,KEY_C=clear_clipboard
-KEY_FENRIR,KEY_HOME=first_clipboard
-KEY_FENRIR,KEY_END=last_clipboard
-KEY_FENRIR,KEY_PAGEUP=prev_clipboard
-KEY_FENRIR,KEY_PAGEDOWN=next_clipboard
-KEY_FENRIR,KEY_SHIFT,KEY_C=curr_clipboard
-KEY_FENRIR,KEY_C=copy_marked_to_clipboard
-KEY_FENRIR,KEY_CTRL,KEY_U=copy_last_echo_to_clipboard
-KEY_FENRIR,KEY_V=paste_clipboard
-KEY_FENRIR,KEY_F5=import_clipboard_from_file
-KEY_FENRIR,KEY_F6=export_clipboard_to_file
-KEY_FENRIR,KEY_CTRL,KEY_SHIFT,KEY_X=remove_marks
-KEY_FENRIR,KEY_X=set_mark
-KEY_FENRIR,KEY_SHIFT,KEY_X=marked_text
-KEY_FENRIR,KEY_F10=toggle_vmenu_mode
-KEY_FENRIR,KEY_SHIFT,KEY_F10=voice_browser_safe
-KEY_FENRIR,KEY_CTRL,KEY_SHIFT,KEY_F10=apply_tested_voice
-KEY_FENRIR,KEY_SPACE=current_quick_menu_entry
-KEY_FENRIR,KEY_CTRL,KEY_SPACE=current_quick_menu_value
-KEY_FENRIR,KEY_RIGHT=next_quick_menu_entry
-KEY_FENRIR,KEY_UP=next_quick_menu_value
-KEY_FENRIR,KEY_LEFT=prev_quick_menu_entry
-KEY_FENRIR,KEY_DOWN=prev_quick_menu_value
-KEY_FENRIR,KEY_ALT,KEY_RIGHT=inc_speech_pitch
-KEY_FENRIR,KEY_ALT,KEY_LEFT=dec_speech_pitch
-KEY_FENRIR,KEY_CTRL,KEY_S=save_settings
-# linux specific
-KEY_FENRIR,KEY_F7=import_clipboard_from_x
-KEY_FENRIR,KEY_F8=export_clipboard_to_x
-# Read-all functionality
-KEY_FENRIR,KEY_CTRL,KEY_DOWN=read_all_by_line
-KEY_FENRIR,KEY_CTRL,KEY_PAGEDOWN=read_all_by_page
-KEY_FENRIR,KEY_SHIFT,KEY_V=announce_fenrir_version
diff --git a/config/keyboard/laptop.toml b/config/keyboard/laptop.toml
new file mode 100644
index 00000000..aa55038a
--- /dev/null
+++ b/config/keyboard/laptop.toml
@@ -0,0 +1,474 @@
+# Laptop layout for currently implemented reader commands.
+# FENRIR is Insert, Super, or NumLock-off keypad 0.
+version = 1
+
+[[bindings]]
+name = "Tracking mode"
+keys = ["FENRIR", "Y"]
+command = "toggle_highlight_tracking"
+
+[[bindings]]
+name = "Fenrir Menu"
+keys = ["FENRIR", "F"]
+command = "fenrir_menu"
+
+[[bindings]]
+name = "Emoji Menu"
+keys = ["FENRIR", "CTRL", "E"]
+command = "emoji_menu"
+
+[[bindings]]
+name = "Top"
+keys = ["FENRIR", "SHIFT", "U"]
+command = "top"
+
+[[bindings]]
+name = "Bottom"
+keys = ["FENRIR", "SHIFT", "O"]
+command = "bottom"
+
+[[bindings]]
+name = "Line start"
+keys = ["FENRIR", "SHIFT", "J"]
+command = "line_start"
+
+[[bindings]]
+name = "Line end"
+keys = ["FENRIR", "SHIFT", "L"]
+command = "line_end"
+
+[[bindings]]
+name = "First text"
+keys = ["FENRIR", "CTRL", "J"]
+command = "first_text"
+
+[[bindings]]
+name = "Last text"
+keys = ["FENRIR", "CTRL", "L"]
+command = "last_text"
+
+[[bindings]]
+name = "Read screen"
+keys = ["FENRIR", "SHIFT", "K"]
+command = "read_screen"
+
+[[bindings]]
+name = "Screen before cursor"
+keys = ["FENRIR", "SHIFT", "I"]
+command = "curr_screen_before_cursor"
+
+[[bindings]]
+name = "Screen after cursor"
+keys = ["FENRIR", "SHIFT", "COMMA"]
+command = "curr_screen_after_cursor"
+
+[[bindings]]
+name = "Character above"
+keys = ["FENRIR", "CTRL", "I"]
+command = "review_up"
+
+[[bindings]]
+name = "Character below"
+keys = ["FENRIR", "CTRL", "COMMA"]
+command = "review_down"
+
+[[bindings]]
+name = "Follow cursor"
+keys = ["FENRIR", "SLASH"]
+command = "follow_cursor"
+
+[[bindings]]
+name = "Help"
+keys = ["FENRIR", "H"]
+command = "help"
+
+[[bindings]]
+name = "Tutorial"
+keys = ["FENRIR", "F1"]
+command = "help"
+
+[[bindings]]
+name = "Mute"
+keys = ["FENRIR", "F4"]
+command = "mute"
+
+[[bindings]]
+name = "Automatic reading"
+keys = ["FENRIR", "ENTER"]
+command = "auto_read"
+
+[[bindings]]
+name = "Forward next key"
+keys = ["FENRIR", "BACKSPACE"]
+command = "pass_next"
+
+[[bindings]]
+name = "Quit"
+keys = ["FENRIR", "Q"]
+command = "quit"
+
+[[bindings]]
+name = "Previous line"
+keys = ["FENRIR", "U"]
+command = "previous_line"
+
+[[bindings]]
+name = "Current line"
+keys = ["FENRIR", "I"]
+command = "current_line"
+double_command = "spell_line"
+triple_command = "phonetic_line"
+
+[[bindings]]
+name = "Next line"
+keys = ["FENRIR", "O"]
+command = "next_line"
+
+[[bindings]]
+name = "Previous word"
+keys = ["FENRIR", "J"]
+command = "previous_word"
+
+[[bindings]]
+name = "Current word"
+keys = ["FENRIR", "K"]
+command = "current_word"
+double_command = "spell_word"
+triple_command = "phonetic_word"
+
+[[bindings]]
+name = "Next word"
+keys = ["FENRIR", "L"]
+command = "next_word"
+
+[[bindings]]
+name = "Previous character"
+keys = ["FENRIR", "M"]
+command = "previous_character"
+
+[[bindings]]
+name = "Current character"
+keys = ["FENRIR", "COMMA"]
+command = "current_character"
+double_command = "phonetic_character"
+
+[[bindings]]
+name = "Next character"
+keys = ["FENRIR", "DOT"]
+command = "next_character"
+
+[[bindings]]
+name = "Position"
+keys = ["FENRIR", "SHIFT", "DOT"]
+command = "position"
+
+# Clipboard commands match Python Fenrir.
+
+[[bindings]]
+name = "Set mark"
+keys = ["FENRIR", "X"]
+command = "set_mark"
+
+[[bindings]]
+name = "Remove marks"
+keys = ["FENRIR", "CTRL", "SHIFT", "X"]
+command = "remove_marks"
+
+[[bindings]]
+name = "Marked text"
+keys = ["FENRIR", "SHIFT", "X"]
+command = "marked_text"
+
+[[bindings]]
+name = "Copy marked text"
+keys = ["FENRIR", "C"]
+command = "copy_marked_to_clipboard"
+
+[[bindings]]
+name = "Current clipboard"
+keys = ["FENRIR", "SHIFT", "C"]
+command = "curr_clipboard"
+
+[[bindings]]
+name = "Clear clipboard"
+keys = ["FENRIR", "CTRL", "SHIFT", "C"]
+command = "clear_clipboard"
+
+[[bindings]]
+name = "First clipboard"
+keys = ["FENRIR", "HOME"]
+command = "first_clipboard"
+
+[[bindings]]
+name = "Last clipboard"
+keys = ["FENRIR", "END"]
+command = "last_clipboard"
+
+[[bindings]]
+name = "Previous clipboard"
+keys = ["FENRIR", "PAGEUP"]
+command = "prev_clipboard"
+
+[[bindings]]
+name = "Next clipboard"
+keys = ["FENRIR", "PAGEDOWN"]
+command = "next_clipboard"
+
+[[bindings]]
+name = "Paste clipboard"
+keys = ["FENRIR", "V"]
+command = "paste_clipboard"
+
+[[bindings]]
+name = "Import clipboard from file"
+keys = ["FENRIR", "F5"]
+command = "import_clipboard_from_file"
+
+[[bindings]]
+name = "Export clipboard to file"
+keys = ["FENRIR", "F6"]
+command = "export_clipboard_to_file"
+
+[[bindings]]
+name = "Import clipboard from X"
+keys = ["FENRIR", "F7"]
+command = "import_clipboard_from_x"
+
+[[bindings]]
+name = "Export clipboard to X"
+keys = ["FENRIR", "F8"]
+command = "export_clipboard_to_x"
+
+[[bindings]]
+name = "Copy last echo"
+keys = ["FENRIR", "CTRL", "U"]
+command = "copy_last_echo_to_clipboard"
+
+[[bindings]]
+name = "Current quick menu entry"
+keys = ["FENRIR", "SPACE"]
+command = "current_quick_menu_entry"
+
+[[bindings]]
+name = "Next quick menu category"
+keys = ["FENRIR", "CTRL", "RIGHT"]
+command = "next_quick_menu_category"
+
+[[bindings]]
+name = "Previous quick menu category"
+keys = ["FENRIR", "CTRL", "LEFT"]
+command = "prev_quick_menu_category"
+
+[[bindings]]
+name = "Next quick menu entry"
+keys = ["FENRIR", "RIGHT"]
+command = "next_quick_menu_entry"
+
+[[bindings]]
+name = "Previous quick menu entry"
+keys = ["FENRIR", "LEFT"]
+command = "prev_quick_menu_entry"
+
+[[bindings]]
+name = "Increase quick menu value"
+keys = ["FENRIR", "UP"]
+command = "next_quick_menu_value"
+
+[[bindings]]
+name = "Decrease quick menu value"
+keys = ["FENRIR", "DOWN"]
+command = "prev_quick_menu_value"
+
+[[bindings]]
+name = "Save settings"
+keys = ["FENRIR", "CTRL", "S"]
+command = "save_settings"
+
+[[bindings]]
+name = "Progress monitoring"
+keys = ["FENRIR", "SHIFT", "P"]
+command = "progress_bar_monitor"
+
+[[bindings]]
+name = "Time and date"
+keys = ["FENRIR", "T"]
+command = "time"
+double_command = "date"
+
+[[bindings]]
+name = "Fenrir version"
+keys = ["FENRIR", "SHIFT", "V"]
+command = "announce_fenrir_version"
+
+[[bindings]]
+name = "Row number"
+keys = ["FENRIR", "CTRL", "SHIFT", "U"]
+command = "cursor_lineno"
+
+[[bindings]]
+name = "Column number"
+keys = ["FENRIR", "CTRL", "SHIFT", "O"]
+command = "cursor_column"
+
+[[bindings]]
+name = "Line to cursor"
+keys = ["FENRIR", "CTRL", "SHIFT", "J"]
+command = "cursor_read_line_to_cursor"
+
+[[bindings]]
+name = "Line from cursor"
+keys = ["FENRIR", "CTRL", "SHIFT", "L"]
+command = "cursor_read_to_end_of_line"
+
+[[bindings]]
+name = "First screen character"
+keys = ["FENRIR", "CTRL", "M"]
+command = "review_screen_first_char"
+
+[[bindings]]
+name = "Last screen character"
+keys = ["FENRIR", "CTRL", "DOT"]
+command = "review_screen_last_char"
+
+[[bindings]]
+name = "Attributes"
+keys = ["FENRIR", "A"]
+command = "attribute_cursor"
+
+[[bindings]]
+name = "Toggle attribute feedback"
+keys = ["FENRIR", "KPMINUS"]
+command = "toggle_has_attribute"
+
+[[bindings]]
+name = "Spellcheck Menu"
+keys = ["FENRIR", "S"]
+command = "spellcheck_menu"
+
+# Reading regions last for this reader session; marks use Fenrir+X.
+
+[[bindings]]
+name = "Reading window"
+keys = ["FENRIR", "CTRL", "8"]
+command = "set_window_application"
+double_command = "clear_window_application"
+
+[[bindings]]
+name = "Set live region"
+keys = ["FENRIR", "B"]
+command = "set_live_region"
+
+[[bindings]]
+name = "List reading regions"
+keys = ["FENRIR", "SHIFT", "B"]
+command = "list_reading_regions"
+
+[[bindings]]
+name = "Clear live regions"
+keys = ["FENRIR", "CTRL", "B"]
+command = "clear_live_regions"
+
+[[bindings]]
+name = "Clear all reading regions"
+keys = ["FENRIR", "CTRL", "SHIFT", "B"]
+command = "clear_reading_regions"
+
+[[bindings]]
+name = "Read live region 1"
+keys = ["FENRIR", "1"]
+command = "read_live_region_1"
+
+[[bindings]]
+name = "Clear live region 1"
+keys = ["FENRIR", "SHIFT", "1"]
+command = "clear_live_region_1"
+
+[[bindings]]
+name = "Read live region 2"
+keys = ["FENRIR", "2"]
+command = "read_live_region_2"
+
+[[bindings]]
+name = "Clear live region 2"
+keys = ["FENRIR", "SHIFT", "2"]
+command = "clear_live_region_2"
+
+[[bindings]]
+name = "Read live region 3"
+keys = ["FENRIR", "3"]
+command = "read_live_region_3"
+
+[[bindings]]
+name = "Clear live region 3"
+keys = ["FENRIR", "SHIFT", "3"]
+command = "clear_live_region_3"
+
+[[bindings]]
+name = "Read live region 4"
+keys = ["FENRIR", "4"]
+command = "read_live_region_4"
+
+[[bindings]]
+name = "Clear live region 4"
+keys = ["FENRIR", "SHIFT", "4"]
+command = "clear_live_region_4"
+
+[[bindings]]
+name = "Read live region 5"
+keys = ["FENRIR", "5"]
+command = "read_live_region_5"
+
+[[bindings]]
+name = "Clear live region 5"
+keys = ["FENRIR", "SHIFT", "5"]
+command = "clear_live_region_5"
+
+[[bindings]]
+name = "Read live region 6"
+keys = ["FENRIR", "6"]
+command = "read_live_region_6"
+
+[[bindings]]
+name = "Clear live region 6"
+keys = ["FENRIR", "SHIFT", "6"]
+command = "clear_live_region_6"
+
+[[bindings]]
+name = "Read live region 7"
+keys = ["FENRIR", "7"]
+command = "read_live_region_7"
+
+[[bindings]]
+name = "Clear live region 7"
+keys = ["FENRIR", "SHIFT", "7"]
+command = "clear_live_region_7"
+
+[[bindings]]
+name = "Read live region 8"
+keys = ["FENRIR", "8"]
+command = "read_live_region_8"
+
+[[bindings]]
+name = "Clear live region 8"
+keys = ["FENRIR", "SHIFT", "8"]
+command = "clear_live_region_8"
+
+[[bindings]]
+name = "Read live region 9"
+keys = ["FENRIR", "9"]
+command = "read_live_region_9"
+
+[[bindings]]
+name = "Clear live region 9"
+keys = ["FENRIR", "SHIFT", "9"]
+command = "clear_live_region_9"
+
+[[bindings]]
+name = "Read live region 10"
+keys = ["FENRIR", "0"]
+command = "read_live_region_10"
+
+[[bindings]]
+name = "Clear live region 10"
+keys = ["FENRIR", "SHIFT", "0"]
+command = "clear_live_region_10"
diff --git a/config/punctuation/de.conf b/config/punctuation/de.conf
deleted file mode 100644
index e14e8292..00000000
--- a/config/punctuation/de.conf
+++ /dev/null
@@ -1,78 +0,0 @@
-# how to use this file?
-# the # on the beginning of the line is a comment
-# the different sections are seperated by [Dict] is the section name. Dict is a keyword
-# the entrys are seperated with :===: in words colon tripple equal colon ( to not collide with substitutions)
-[levelDict]
-none:===:
-some:===:.-$~+*-/\@
-most:===:.,:-$~+*-/\@!#%^&*()[]}{<>;
-all:===:!"#$%& \'()*+,-./:;<=>?@[\\]^_`{|}~
-
-[punctDict]
- :===:Leer
-&:===:Und
-':===:Apostroph
-@:===:At
-\:===:Backslash
-|:===:Pipe
-!:===:Ausrufezeichen
-^:===:Hoch
-::===:Doppelpunkt
-,:===:Komma
--:===:Minus
-$:===:Dollar
-.:===:Punkt
->:===:GrƶĆer als
-`:===:Grave
-#:===:Hash
-{:===:Geschweifte Klammer auf
-[:===:eckige Klammer auf
-(:===:Klammer auf
-<:===:Kleiner als
-%:===:Prozent
-+:===:Plus
-?:===:Fragezeichen?
-":===:GƤnsefüĆchen
-):===:Klammer zu
-}:===:Geschweifte Klammer zu
-]:===:Eckige Klammer zu
-;:===:Semikolon
-/:===:Geteilt durch
-*:===:Mal
-~:===:Tilde
-_:===:Lienie unten
-=:===:Istgleich
-
-[customDict]
-
-[emoticonDict]
-regex;(?:^|\s)(?::-\)|:\))(?:\s|$):===:smile
-regex;(?:^|\s)(?::-\(|:\()(?:\s|$):===:frown
-regex;(?:^|\s)(?::-D|:D)(?:\s|$):===:big grin
-regex;(?:^|\s)(?:;-\)|;\))(?:\s|$):===:wink
-regex;(?:^|\s)(?::-P|:P)(?:\s|$):===:tongue out
-regex;(?:^|\s)(?::-O|:O)(?:\s|$):===:surprised
-regex;(?:^|\s)(?::-/|:/)(?:\s|$):===:skeptical
-regex;(?:^|\s)(?:>:-\(|>:\()(?:\s|$):===:angry
-regex;(?:^|\s)(?::'\(|:'-\()(?:\s|$):===:crying
-regex;(?:^|\s)(?::-\||:\|)(?:\s|$):===:neutral
-regex;(?:^|\s)(?:8-\)|B-\))(?:\s|$):===:cool
-regex;(?:^|\s)8-X(?:\s|$):===:skull and crossbones
-regex;(?:^|\s)(?:X-X|x-x)(?:\s|$):===:knocked out
-regex;(?:^|\s)(?::-X|:-x|:X|:x)(?:\s|$):===:lips sealed
-regex;(?:^|\s)XD(?:\s|$):===:laughing hard
-regex;(?:^|\s)\^_\^(?:\s|$):===:happy
-regex;(?:^|\s)-_-(?:\s|$):===:annoyed
-regex;(?:^|\s)o_O(?:\s|$):===:confused
-regex;(?:^|\s)>_<(?:\s|$):===:frustrated
-regex;(?:^|\s)\\o/(?:\s|$):===:hurray
-regex;(?:^|\s)<3(?:\s|$):===:heart
-regex;(?:^|\s)3(?:\s|$):===:broken heart
-regex;(?:^|\s)(?:Oo|oO)(?:\s|$):===:WTF?
-regex;(?:^|\s)<{-.-}>(?:\s|$):===:Raves
-regex;(?:^|\s)-\.-(?:\s|$):===:bugged
-regex;(?:^|\s)>\.<(?:\s|$):===:laughing
-regex;(?:^|\s)(?:>:\)|>:-\))(?:\s|$):===:evil smile
-regex;(?:^|\s)\^\^(?:\s|$):===:enjoy smile
-regex;(?:^|\s)->(?:\s|$):===:arrow right
-regex;(?:^|\s)<-(?:\s|$):===:arrow left
diff --git a/config/punctuation/de.toml b/config/punctuation/de.toml
new file mode 100644
index 00000000..35cd542a
--- /dev/null
+++ b/config/punctuation/de.toml
@@ -0,0 +1,45 @@
+# Punctuation names and levels from Python Fenrir (LGPL-3.0-or-later).
+# Pronunciation dictionaries and emoticons are separate from punctuation.
+version = 1
+translate = false
+
+[levels]
+"none" = ""
+"some" = ".-$~+*-/\\@"
+"most" = ".,:-$~+*-/\\@!#%^&*()[]}{<>;"
+"all" = "!\"#$%& \\'()*+,-./:;<=>?@[\\\\]^_`{|}~"
+
+[symbols]
+" " = "Leer"
+"&" = "Und"
+"'" = "Apostroph"
+"@" = "At"
+"\\" = "Backslash"
+"|" = "Pipe"
+"!" = "Ausrufezeichen"
+"^" = "Hoch"
+":" = "Doppelpunkt"
+"," = "Komma"
+"-" = "Minus"
+"$" = "Dollar"
+"." = "Punkt"
+">" = "GrƶĆer als"
+"`" = "Grave"
+"#" = "Hash"
+"{" = "Geschweifte Klammer auf"
+"[" = "eckige Klammer auf"
+"(" = "Klammer auf"
+"<" = "Kleiner als"
+"%" = "Prozent"
+"+" = "Plus"
+"?" = "Fragezeichen?"
+"\"" = "GƤnsefüĆchen"
+")" = "Klammer zu"
+"}" = "Geschweifte Klammer zu"
+"]" = "Eckige Klammer zu"
+";" = "Semikolon"
+"/" = "Geteilt durch"
+"*" = "Mal"
+"~" = "Tilde"
+"_" = "Lienie unten"
+"=" = "Istgleich"
diff --git a/config/punctuation/default.conf b/config/punctuation/default.conf
deleted file mode 100644
index ea63344e..00000000
--- a/config/punctuation/default.conf
+++ /dev/null
@@ -1,78 +0,0 @@
-# how to use this file?
-# the # on the beginning of the line is a comment
-# the different sections are seperated by [Dict] is the section name. Dict is a keyword
-# the entrys are seperated with :===: in words colon tripple equal colon ( to not collide with substitutions)
-[levelDict]
-none:===:
-some:===:-$~+*-/\@#
-most:===:.,:-_$~+*-/\@!#%^&*()[]}{<>;
-all:===:!"#$%& \'()*+,-./:;<=>?@[\\]^_`{|}~
-
-[punctDict]
- :===:space
-&:===:and
-':===:apostrophe
-@:===:at
-\:===:backslash
-|:===:bar
-!:===:bang
-^:===:carrot
-::===:colon
-,:===:comma
--:===:dash
-$:===:dollar
-.:===:dot
->:===:greater
-`:===:grave
-#:===:hash
-{:===:left brace
-[:===:left bracket
-(:===:left paren
-<:===:less
-%:===:percent
-+:===:plus
-?:===:question?
-":===:quote
-):===:right paren
-}:===:right brace
-]:===:right bracket
-;:===:semicolon
-/:===:slash
-*:===:star
-~:===:tilde
-_:===:line
-=:===:equals
-
-[customDict]
-
-[emoticonDict]
-regex;(?:^|\s)(?::-\)|:\))(?:\s|$):===:smile
-regex;(?:^|\s)(?::-\(|:\()(?:\s|$):===:frown
-regex;(?:^|\s)(?::-D|:D)(?:\s|$):===:big grin
-regex;(?:^|\s)(?:;-\)|;\))(?:\s|$):===:wink
-regex;(?:^|\s)(?::-P|:P)(?:\s|$):===:tongue out
-regex;(?:^|\s)(?::-O|:O)(?:\s|$):===:surprised
-regex;(?:^|\s)(?::-/|:/)(?:\s|$):===:skeptical
-regex;(?:^|\s)(?:>:-\(|>:\()(?:\s|$):===:angry
-regex;(?:^|\s)(?::'\(|:'-\()(?:\s|$):===:crying
-regex;(?:^|\s)(?::-\||:\|)(?:\s|$):===:neutral
-regex;(?:^|\s)(?:8-\)|B-\))(?:\s|$):===:cool
-regex;(?:^|\s)8-X(?:\s|$):===:skull and crossbones
-regex;(?:^|\s)(?:X-X|x-x)(?:\s|$):===:knocked out
-regex;(?:^|\s)(?::-X|:-x|:X|:x)(?:\s|$):===:lips sealed
-regex;(?:^|\s)XD(?:\s|$):===:laughing hard
-regex;(?:^|\s)\^_\^(?:\s|$):===:happy
-regex;(?:^|\s)-_-(?:\s|$):===:annoyed
-regex;(?:^|\s)o_O(?:\s|$):===:confused
-regex;(?:^|\s)>_<(?:\s|$):===:frustrated
-regex;(?:^|\s)\\o/(?:\s|$):===:hurray
-regex;(?:^|\s)<3(?:\s|$):===:heart
-regex;(?:^|\s)3(?:\s|$):===:broken heart
-regex;(?:^|\s)(?:Oo|oO)(?:\s|$):===:WTF?
-regex;(?:^|\s)<{-.-}>(?:\s|$):===:Raves
-regex;(?:^|\s)-\.-(?:\s|$):===:bugged
-regex;(?:^|\s)>\.<(?:\s|$):===:laughing
-regex;(?:^|\s)(?:>:\)|>:-\))(?:\s|$):===:evil smile
-regex;(?:^|\s)\^\^(?:\s|$):===:enjoy smile
-regex;(?:^|\s)->(?:\s|$):===:arrow right
-regex;(?:^|\s)<-(?:\s|$):===:arrow left
diff --git a/config/punctuation/default.toml b/config/punctuation/default.toml
new file mode 100644
index 00000000..21952caa
--- /dev/null
+++ b/config/punctuation/default.toml
@@ -0,0 +1,45 @@
+# Punctuation names and levels from Python Fenrir (LGPL-3.0-or-later).
+# Pronunciation dictionaries and emoticons are separate from punctuation.
+version = 1
+translate = true
+
+[levels]
+"none" = ""
+"some" = "-$~+*-/\\@#"
+"most" = ".,:-_$~+*-/\\@!#%^&*()[]}{<>;"
+"all" = "!\"#$%& \\'()*+,-./:;<=>?@[\\\\]^_`{|}~"
+
+[symbols]
+" " = "space"
+"&" = "and"
+"'" = "apostrophe"
+"@" = "at"
+"\\" = "backslash"
+"|" = "bar"
+"!" = "bang"
+"^" = "carrot"
+":" = "colon"
+"," = "comma"
+"-" = "dash"
+"$" = "dollar"
+"." = "dot"
+">" = "greater"
+"`" = "grave"
+"#" = "hash"
+"{" = "left brace"
+"[" = "left bracket"
+"(" = "left paren"
+"<" = "less"
+"%" = "percent"
+"+" = "plus"
+"?" = "question?"
+"\"" = "quote"
+")" = "right paren"
+"}" = "right brace"
+"]" = "right bracket"
+";" = "semicolon"
+"/" = "slash"
+"*" = "star"
+"~" = "tilde"
+"_" = "line"
+"=" = "equals"
diff --git a/config/punctuation/en.conf b/config/punctuation/en.conf
deleted file mode 100644
index 86a44472..00000000
--- a/config/punctuation/en.conf
+++ /dev/null
@@ -1,78 +0,0 @@
-# how to use this file?
-# the # on the beginning of the line is a comment
-# the different sections are seperated by [Dict] is the section name. Dict is a keyword
-# the entrys are seperated with :===: in words colon tripple equal colon ( to not collide with substitutions)
-[levelDict]
-none:===:
-some:===:-$~+*-/\@
-most:===:.,:-$~+*-_/\@!#%^&*()[]}{<>;
-all:===:!"#$%& \'()*+,-./:;<=>?@[\\]^_`{|}~
-
-[punctDict]
- :===:space
-&:===:and
-':===:apostrophe
-@:===:at
-\:===:backslash
-|:===:bar
-!:===:bang
-^:===:carrot
-::===:colon
-,:===:comma
--:===:dash
-$:===:dollar
-.:===:dot
->:===:greater
-`:===:grave
-#:===:hash
-{:===:left brace
-[:===:left bracket
-(:===:left paren
-<:===:less
-%:===:percent
-+:===:plus
-?:===:question?
-":===:quote
-):===:right paren
-}:===:right brace
-]:===:right bracket
-;:===:semicolon
-/:===:slash
-*:===:star
-~:===:tilde
-_:===:line
-=:===:equals
-
-[customDict]
-
-[emoticonDict]
-regex;(?:^|\s)(?::-\)|:\))(?:\s|$):===:smile
-regex;(?:^|\s)(?::-\(|:\()(?:\s|$):===:frown
-regex;(?:^|\s)(?::-D|:D)(?:\s|$):===:big grin
-regex;(?:^|\s)(?:;-\)|;\))(?:\s|$):===:wink
-regex;(?:^|\s)(?::-P|:P)(?:\s|$):===:tongue out
-regex;(?:^|\s)(?::-O|:O)(?:\s|$):===:surprised
-regex;(?:^|\s)(?::-/|:/)(?:\s|$):===:skeptical
-regex;(?:^|\s)(?:>:-\(|>:\()(?:\s|$):===:angry
-regex;(?:^|\s)(?::'\(|:'-\()(?:\s|$):===:crying
-regex;(?:^|\s)(?::-\||:\|)(?:\s|$):===:neutral
-regex;(?:^|\s)(?:8-\)|B-\))(?:\s|$):===:cool
-regex;(?:^|\s)8-X(?:\s|$):===:skull and crossbones
-regex;(?:^|\s)(?:X-X|x-x)(?:\s|$):===:knocked out
-regex;(?:^|\s)(?::-X|:-x|:X|:x)(?:\s|$):===:lips sealed
-regex;(?:^|\s)XD(?:\s|$):===:laughing hard
-regex;(?:^|\s)\^_\^(?:\s|$):===:happy
-regex;(?:^|\s)-_-(?:\s|$):===:annoyed
-regex;(?:^|\s)o_O(?:\s|$):===:confused
-regex;(?:^|\s)>_<(?:\s|$):===:frustrated
-regex;(?:^|\s)\\o/(?:\s|$):===:hurray
-regex;(?:^|\s)<3(?:\s|$):===:heart
-regex;(?:^|\s)3(?:\s|$):===:broken heart
-regex;(?:^|\s)(?:Oo|oO)(?:\s|$):===:WTF?
-regex;(?:^|\s)<{-.-}>(?:\s|$):===:Raves
-regex;(?:^|\s)-\.-(?:\s|$):===:bugged
-regex;(?:^|\s)>\.<(?:\s|$):===:laughing
-regex;(?:^|\s)(?:>:\)|>:-\))(?:\s|$):===:evil smile
-regex;(?:^|\s)\^\^(?:\s|$):===:enjoy smile
-regex;(?:^|\s)->(?:\s|$):===:arrow right
-regex;(?:^|\s)<-(?:\s|$):===:arrow left
diff --git a/config/punctuation/en.toml b/config/punctuation/en.toml
new file mode 100644
index 00000000..4388475e
--- /dev/null
+++ b/config/punctuation/en.toml
@@ -0,0 +1,45 @@
+# Punctuation names and levels from Python Fenrir (LGPL-3.0-or-later).
+# Pronunciation dictionaries and emoticons are separate from punctuation.
+version = 1
+translate = true
+
+[levels]
+"none" = ""
+"some" = "-$~+*-/\\@"
+"most" = ".,:-$~+*-_/\\@!#%^&*()[]}{<>;"
+"all" = "!\"#$%& \\'()*+,-./:;<=>?@[\\\\]^_`{|}~"
+
+[symbols]
+" " = "space"
+"&" = "and"
+"'" = "apostrophe"
+"@" = "at"
+"\\" = "backslash"
+"|" = "bar"
+"!" = "bang"
+"^" = "carrot"
+":" = "colon"
+"," = "comma"
+"-" = "dash"
+"$" = "dollar"
+"." = "dot"
+">" = "greater"
+"`" = "grave"
+"#" = "hash"
+"{" = "left brace"
+"[" = "left bracket"
+"(" = "left paren"
+"<" = "less"
+"%" = "percent"
+"+" = "plus"
+"?" = "question?"
+"\"" = "quote"
+")" = "right paren"
+"}" = "right brace"
+"]" = "right bracket"
+";" = "semicolon"
+"/" = "slash"
+"*" = "star"
+"~" = "tilde"
+"_" = "line"
+"=" = "equals"
diff --git a/config/punctuation/es.conf b/config/punctuation/es.conf
deleted file mode 100644
index 37c46c13..00000000
--- a/config/punctuation/es.conf
+++ /dev/null
@@ -1,78 +0,0 @@
-# how to use this file?
-# the # on the beginning of the line is a comment
-# the different sections are seperated by [Dict] is the section name. Dict is a keyword
-# the entrys are seperated with :===: in words colon tripple equal colon ( to not collide with substitutions)
-[levelDict]
-none:===:
-some:===:.-$~+*-/\@
-most:===:.,:-$~+*-/\@!#%^&*()[]}{<>;
-all:===:!"#$%& \'()*+,-./:;<=>?@[\\]^_`{|}~
-
-[punctDict]
- :===:espacio
-&:===:et
-':===:apóstrofo
-@:===:arroba
-\:===:barra inversa
-|:===:barra vertical
-!:===:Cerrar exclamación
-^:===:circumplejo
-::===:dos puntos
-,:===:coma
--:===:guion
-$:===:dólar
-.:===:punto
->:===:mayor que
-`:===:grave
-#:===:signo de nĆŗmero
-{:===:abrir yave
-[:===:abrir corchete
-(:===:abrir parƩntesis
-<:===:menor que
-%:===:porciento
-+:===:mƔs
-?:===:cerrar interrogación?
-":===:comillas
-):===:cerrar parƩntesis
-}:===:cerrar yave
-]:===:cerrar corchete
-;:===:punto y coma
-/:===:barra
-*:===:asterisco
-~:===:tilde
-_:===:subrayado
-=:===:igual
-
-[customDict]
-
-[emoticonDict]
-regex;(?:^|\s)(?::-\)|:\))(?:\s|$):===:smile
-regex;(?:^|\s)(?::-\(|:\()(?:\s|$):===:frown
-regex;(?:^|\s)(?::-D|:D)(?:\s|$):===:big grin
-regex;(?:^|\s)(?:;-\)|;\))(?:\s|$):===:wink
-regex;(?:^|\s)(?::-P|:P)(?:\s|$):===:tongue out
-regex;(?:^|\s)(?::-O|:O)(?:\s|$):===:surprised
-regex;(?:^|\s)(?::-/|:/)(?:\s|$):===:skeptical
-regex;(?:^|\s)(?:>:-\(|>:\()(?:\s|$):===:angry
-regex;(?:^|\s)(?::'\(|:'-\()(?:\s|$):===:crying
-regex;(?:^|\s)(?::-\||:\|)(?:\s|$):===:neutral
-regex;(?:^|\s)(?:8-\)|B-\))(?:\s|$):===:cool
-regex;(?:^|\s)8-X(?:\s|$):===:skull and crossbones
-regex;(?:^|\s)(?:X-X|x-x)(?:\s|$):===:knocked out
-regex;(?:^|\s)(?::-X|:-x|:X|:x)(?:\s|$):===:lips sealed
-regex;(?:^|\s)XD(?:\s|$):===:laughing hard
-regex;(?:^|\s)\^_\^(?:\s|$):===:happy
-regex;(?:^|\s)-_-(?:\s|$):===:annoyed
-regex;(?:^|\s)o_O(?:\s|$):===:confused
-regex;(?:^|\s)>_<(?:\s|$):===:frustrated
-regex;(?:^|\s)\\o/(?:\s|$):===:hurray
-regex;(?:^|\s)<3(?:\s|$):===:heart
-regex;(?:^|\s)3(?:\s|$):===:broken heart
-regex;(?:^|\s)(?:Oo|oO)(?:\s|$):===:WTF?
-regex;(?:^|\s)<{-.-}>(?:\s|$):===:Raves
-regex;(?:^|\s)-\.-(?:\s|$):===:bugged
-regex;(?:^|\s)>\.<(?:\s|$):===:laughing
-regex;(?:^|\s)(?:>:\)|>:-\))(?:\s|$):===:evil smile
-regex;(?:^|\s)\^\^(?:\s|$):===:enjoy smile
-regex;(?:^|\s)->(?:\s|$):===:arrow right
-regex;(?:^|\s)<-(?:\s|$):===:arrow left
diff --git a/config/punctuation/es.toml b/config/punctuation/es.toml
new file mode 100644
index 00000000..20baa702
--- /dev/null
+++ b/config/punctuation/es.toml
@@ -0,0 +1,45 @@
+# Punctuation names and levels from Python Fenrir (LGPL-3.0-or-later).
+# Pronunciation dictionaries and emoticons are separate from punctuation.
+version = 1
+translate = false
+
+[levels]
+"none" = ""
+"some" = ".-$~+*-/\\@"
+"most" = ".,:-$~+*-/\\@!#%^&*()[]}{<>;"
+"all" = "!\"#$%& \\'()*+,-./:;<=>?@[\\\\]^_`{|}~"
+
+[symbols]
+" " = "espacio"
+"&" = "et"
+"'" = "apóstrofo"
+"@" = "arroba"
+"\\" = "barra inversa"
+"|" = "barra vertical"
+"!" = "Cerrar exclamación"
+"^" = "circumplejo"
+":" = "dos puntos"
+"," = "coma"
+"-" = "guion"
+"$" = "dólar"
+"." = "punto"
+">" = "mayor que"
+"`" = "grave"
+"#" = "signo de nĆŗmero"
+"{" = "abrir yave"
+"[" = "abrir corchete"
+"(" = "abrir parƩntesis"
+"<" = "menor que"
+"%" = "porciento"
+"+" = "mƔs"
+"?" = "cerrar interrogación?"
+"\"" = "comillas"
+")" = "cerrar parƩntesis"
+"}" = "cerrar yave"
+"]" = "cerrar corchete"
+";" = "punto y coma"
+"/" = "barra"
+"*" = "asterisco"
+"~" = "tilde"
+"_" = "subrayado"
+"=" = "igual"
diff --git a/config/punctuation/fr.conf b/config/punctuation/fr.conf
deleted file mode 100644
index 58a264d7..00000000
--- a/config/punctuation/fr.conf
+++ /dev/null
@@ -1,78 +0,0 @@
-# how to use this file?
-# the # on the beginning of the line is a comment
-# the different sections are seperated by [Dict] is the section name. Dict is a keyword
-# the entrys are seperated with :===: in words colon tripple equal colon ( to not collide with substitutions)
-[levelDict]
-none:===:
-some:===:.-$~+*-/\@
-most:===:.,:-$~+*-/\@!#%^&*()[]}{<>;
-all:===:!"#$%& \'()*+,-./:;<=>?@[\\]^_`{|}~
-
-[punctDict]
- :===:espace
-&:===:et
-':===:apostrophe
-@:===:arobase
-\:===:barre oblique inversƩe
-|:===:barre verticale
-!:===:point d'exclamation
-^:===:accent circonflexe
-::===:deux points
-,:===:virgule
--:===:tiret
-$:===:dollar
-.:===:point
->:===:suppĆ©rieur Ć
-`:===:accent grave
-#:===:diĆØse
-{:===:accolade ouvrante
-[:===:crochet ouvrant
-(:===:parenthĆØse ouvrante
-<:===:infĆ©rieur Ć
-%:===:pourcent
-+:===:plus
-?:===:point d'interrogation
-":===:guillemet
-):===:parenthĆØse fermante
-}:===:accolade fermante
-]:===:crochet fermant
-;:===:point virgule
-/:===:barre oblique
-*:===:astƩrisque
-~:===:tildƩ
-_:===:soulignƩ
-=:===:Ć©gale Ć
-
-[customDict]
-
-[emoticonDict]
-regex;(?:^|\s)(?::-\)|:\))(?:\s|$):===:smile
-regex;(?:^|\s)(?::-\(|:\()(?:\s|$):===:frown
-regex;(?:^|\s)(?::-D|:D)(?:\s|$):===:big grin
-regex;(?:^|\s)(?:;-\)|;\))(?:\s|$):===:wink
-regex;(?:^|\s)(?::-P|:P)(?:\s|$):===:tongue out
-regex;(?:^|\s)(?::-O|:O)(?:\s|$):===:surprised
-regex;(?:^|\s)(?::-/|:/)(?:\s|$):===:skeptical
-regex;(?:^|\s)(?:>:-\(|>:\()(?:\s|$):===:angry
-regex;(?:^|\s)(?::'\(|:'-\()(?:\s|$):===:crying
-regex;(?:^|\s)(?::-\||:\|)(?:\s|$):===:neutral
-regex;(?:^|\s)(?:8-\)|B-\))(?:\s|$):===:cool
-regex;(?:^|\s)8-X(?:\s|$):===:skull and crossbones
-regex;(?:^|\s)(?:X-X|x-x)(?:\s|$):===:knocked out
-regex;(?:^|\s)(?::-X|:-x|:X|:x)(?:\s|$):===:lips sealed
-regex;(?:^|\s)XD(?:\s|$):===:laughing hard
-regex;(?:^|\s)\^_\^(?:\s|$):===:happy
-regex;(?:^|\s)-_-(?:\s|$):===:annoyed
-regex;(?:^|\s)o_O(?:\s|$):===:confused
-regex;(?:^|\s)>_<(?:\s|$):===:frustrated
-regex;(?:^|\s)\\o/(?:\s|$):===:hurray
-regex;(?:^|\s)<3(?:\s|$):===:heart
-regex;(?:^|\s)3(?:\s|$):===:broken heart
-regex;(?:^|\s)(?:Oo|oO)(?:\s|$):===:WTF?
-regex;(?:^|\s)<{-.-}>(?:\s|$):===:Raves
-regex;(?:^|\s)-\.-(?:\s|$):===:bugged
-regex;(?:^|\s)>\.<(?:\s|$):===:laughing
-regex;(?:^|\s)(?:>:\)|>:-\))(?:\s|$):===:evil smile
-regex;(?:^|\s)\^\^(?:\s|$):===:enjoy smile
-regex;(?:^|\s)->(?:\s|$):===:arrow right
-regex;(?:^|\s)<-(?:\s|$):===:arrow left
diff --git a/config/punctuation/fr.toml b/config/punctuation/fr.toml
new file mode 100644
index 00000000..9f0fdd6e
--- /dev/null
+++ b/config/punctuation/fr.toml
@@ -0,0 +1,45 @@
+# Punctuation names and levels from Python Fenrir (LGPL-3.0-or-later).
+# Pronunciation dictionaries and emoticons are separate from punctuation.
+version = 1
+translate = false
+
+[levels]
+"none" = ""
+"some" = ".-$~+*-/\\@"
+"most" = ".,:-$~+*-/\\@!#%^&*()[]}{<>;"
+"all" = "!\"#$%& \\'()*+,-./:;<=>?@[\\\\]^_`{|}~"
+
+[symbols]
+" " = "espace"
+"&" = "et"
+"'" = "apostrophe"
+"@" = "arobase"
+"\\" = "barre oblique inversƩe"
+"|" = "barre verticale"
+"!" = "point d'exclamation"
+"^" = "accent circonflexe"
+":" = "deux points"
+"," = "virgule"
+"-" = "tiret"
+"$" = "dollar"
+"." = "point"
+">" = "suppérieur à "
+"`" = "accent grave"
+"#" = "diĆØse"
+"{" = "accolade ouvrante"
+"[" = "crochet ouvrant"
+"(" = "parenthĆØse ouvrante"
+"<" = "inférieur à "
+"%" = "pourcent"
+"+" = "plus"
+"?" = "point d'interrogation"
+"\"" = "guillemet"
+")" = "parenthĆØse fermante"
+"}" = "accolade fermante"
+"]" = "crochet fermant"
+";" = "point virgule"
+"/" = "barre oblique"
+"*" = "astƩrisque"
+"~" = "tildƩ"
+"_" = "soulignƩ"
+"=" = "égale à "
diff --git a/config/punctuation/pl.conf b/config/punctuation/pl.conf
deleted file mode 100644
index e39cd4c0..00000000
--- a/config/punctuation/pl.conf
+++ /dev/null
@@ -1,78 +0,0 @@
-# how to use this file?
-# the # on the beginning of the line is a comment
-# the different sections are seperated by [Dict] is the section name. Dict is a keyword
-# the entrys are seperated with :===: in words colon tripple equal colon ( to not collide with substitutions)
-[levelDict]
-none:===:
-some:===:.-$~+*-/\@
-most:===:.,:-$~+*-/\@!#%^&*()[]}{<>;
-all:===:!"#$%& \'()*+,-./:;<=>?@[\\]^_`{|}~
-
-[punctDict]
- :===:spacja
-&:===:ampersant
-':===:apostrof
-@:===:maÅpa
-\:===:bekslesz
-|:===:pionowa kreska
-!:===:wykrzyknik
-^:===:daszek
-::===:dwukropek
-,:===:przecinek
--:===:myÅlnik
-$:===:dolar
-.:===:kropka
->:===:wiÄksze
-`:===:akcent
-#:===:hasz
-{:===:lewa klamra
-[:===:lewy nawias kwadratowy
-(:===:lewy nawias
-<:===:mniejsze
-%:===:procent
-+:===:plus
-?:===:pytajnik
-":===:cudzysÅów
-):===:prawy nawias
-}:===:prawa klamra
-]:===:prawy nawias kwadratowy
-;:===:Årednik
-/:===:slesz
-*:===:gwiazdka
-~:===:tylda
-_:===:podkreÅlnik
-=:===:równa siÄ
-
-[customDict]
-
-[emoticonDict]
-regex;(?:^|\s)(?::-\)|:\))(?:\s|$):===:smile
-regex;(?:^|\s)(?::-\(|:\()(?:\s|$):===:frown
-regex;(?:^|\s)(?::-D|:D)(?:\s|$):===:big grin
-regex;(?:^|\s)(?:;-\)|;\))(?:\s|$):===:wink
-regex;(?:^|\s)(?::-P|:P)(?:\s|$):===:tongue out
-regex;(?:^|\s)(?::-O|:O)(?:\s|$):===:surprised
-regex;(?:^|\s)(?::-/|:/)(?:\s|$):===:skeptical
-regex;(?:^|\s)(?:>:-\(|>:\()(?:\s|$):===:angry
-regex;(?:^|\s)(?::'\(|:'-\()(?:\s|$):===:crying
-regex;(?:^|\s)(?::-\||:\|)(?:\s|$):===:neutral
-regex;(?:^|\s)(?:8-\)|B-\))(?:\s|$):===:cool
-regex;(?:^|\s)8-X(?:\s|$):===:skull and crossbones
-regex;(?:^|\s)(?:X-X|x-x)(?:\s|$):===:knocked out
-regex;(?:^|\s)(?::-X|:-x|:X|:x)(?:\s|$):===:lips sealed
-regex;(?:^|\s)XD(?:\s|$):===:laughing hard
-regex;(?:^|\s)\^_\^(?:\s|$):===:happy
-regex;(?:^|\s)-_-(?:\s|$):===:annoyed
-regex;(?:^|\s)o_O(?:\s|$):===:confused
-regex;(?:^|\s)>_<(?:\s|$):===:frustrated
-regex;(?:^|\s)\\o/(?:\s|$):===:hurray
-regex;(?:^|\s)<3(?:\s|$):===:heart
-regex;(?:^|\s)3(?:\s|$):===:broken heart
-regex;(?:^|\s)(?:Oo|oO)(?:\s|$):===:WTF?
-regex;(?:^|\s)<{-.-}>(?:\s|$):===:Raves
-regex;(?:^|\s)-\.-(?:\s|$):===:bugged
-regex;(?:^|\s)>\.<(?:\s|$):===:laughing
-regex;(?:^|\s)(?:>:\)|>:-\))(?:\s|$):===:evil smile
-regex;(?:^|\s)\^\^(?:\s|$):===:enjoy smile
-regex;(?:^|\s)->(?:\s|$):===:arrow right
-regex;(?:^|\s)<-(?:\s|$):===:arrow left
diff --git a/config/punctuation/pl.toml b/config/punctuation/pl.toml
new file mode 100644
index 00000000..cdf1cf70
--- /dev/null
+++ b/config/punctuation/pl.toml
@@ -0,0 +1,45 @@
+# Punctuation names and levels from Python Fenrir (LGPL-3.0-or-later).
+# Pronunciation dictionaries and emoticons are separate from punctuation.
+version = 1
+translate = false
+
+[levels]
+"none" = ""
+"some" = ".-$~+*-/\\@"
+"most" = ".,:-$~+*-/\\@!#%^&*()[]}{<>;"
+"all" = "!\"#$%& \\'()*+,-./:;<=>?@[\\\\]^_`{|}~"
+
+[symbols]
+" " = "spacja"
+"&" = "ampersant"
+"'" = "apostrof"
+"@" = "maÅpa"
+"\\" = "bekslesz"
+"|" = "pionowa kreska"
+"!" = "wykrzyknik"
+"^" = "daszek"
+":" = "dwukropek"
+"," = "przecinek"
+"-" = "myÅlnik"
+"$" = "dolar"
+"." = "kropka"
+">" = "wiÄksze"
+"`" = "akcent"
+"#" = "hasz"
+"{" = "lewa klamra"
+"[" = "lewy nawias kwadratowy"
+"(" = "lewy nawias"
+"<" = "mniejsze"
+"%" = "procent"
+"+" = "plus"
+"?" = "pytajnik"
+"\"" = "cudzysÅów"
+")" = "prawy nawias"
+"}" = "prawa klamra"
+"]" = "prawy nawias kwadratowy"
+";" = "Årednik"
+"/" = "slesz"
+"*" = "gwiazdka"
+"~" = "tylda"
+"_" = "podkreÅlnik"
+"=" = "równa siÄ"
diff --git a/config/punctuation/rhvoice-en.conf b/config/punctuation/rhvoice-en.conf
deleted file mode 100644
index 4101ea68..00000000
--- a/config/punctuation/rhvoice-en.conf
+++ /dev/null
@@ -1,84 +0,0 @@
-# how to use this file?
-# the # on the beginning of the line is a comment
-# the different sections are seperated by [Dict] is the section name. Dict is a keyword
-# the entrys are seperated with :===: in words colon tripple equal colon ( to not collide with substitutions)
-[levelDict]
-none:===:
-some:===:-$~+*-/\@_
-most:===:.,:-$~+*-/\@!#%^&*()[]}{<>;
-all:===:!"#$%& \'()*+,-./:;<=>?@[\\]^_`{|}~
-
-[punctDict]
- :===:space
-&:===:and
-':===:apostrophe
-@:===:at
-\:===:backslash
-|:===:bar
-!:===:bang
-^:===:carrot
-::===:colon
-,:===:comma
--:===:dash
-$:===:dollar
-.:===:dot
->:===:greater
-`:===:grave
-#:===:hash
-{:===:left brace
-[:===:left bracket
-(:===:left paren
-<:===:less
-%:===:percent
-+:===:plus
-?:===:question?
-":===:quote
-):===:right paren
-}:===:right brace
-]:===:right bracket
-;:===:semicolon
-/:===:slash
-*:===:star
-~:===:tilde
-_:===:line
-=:===:equals
-
-[customDict]
-regex;(?i)certificates:===:certiffikets
-regex;(?i)certificate:===:certifficate
-regex;(?i)lowblow:===:LowBlow
-regex;(?i)nginx:===:EngineX
-regex;(?i)shit:===:shitt
-regex;(?i)slither:===:slitther
-
-[emoticonDict]
-regex;(?:^|\s)(?::-\)|:\))(?:\s|$):===:smile
-regex;(?:^|\s)(?::-\(|:\()(?:\s|$):===:frown
-regex;(?:^|\s)(?::-D|:D)(?:\s|$):===:big grin
-regex;(?:^|\s)(?:;-\)|;\))(?:\s|$):===:wink
-regex;(?:^|\s)(?::-P|:P)(?:\s|$):===:tongue out
-regex;(?:^|\s)(?::-O|:O)(?:\s|$):===:surprised
-regex;(?:^|\s)(?::-/|:/)(?:\s|$):===:skeptical
-regex;(?:^|\s)(?:>:-\(|>:\()(?:\s|$):===:angry
-regex;(?:^|\s)(?::'\(|:'-\()(?:\s|$):===:crying
-regex;(?:^|\s)(?::-\||:\|)(?:\s|$):===:neutral
-regex;(?:^|\s)(?:8-\)|B-\))(?:\s|$):===:cool
-regex;(?:^|\s)8-X(?:\s|$):===:skull and crossbones
-regex;(?:^|\s)(?:X-X|x-x)(?:\s|$):===:knocked out
-regex;(?:^|\s)(?::-X|:-x|:X|:x)(?:\s|$):===:lips sealed
-regex;(?:^|\s)XD(?:\s|$):===:laughing hard
-regex;(?:^|\s)\^_\^(?:\s|$):===:happy
-regex;(?:^|\s)-_-(?:\s|$):===:annoyed
-regex;(?:^|\s)o_O(?:\s|$):===:confused
-regex;(?:^|\s)>_<(?:\s|$):===:frustrated
-regex;(?:^|\s)\\o/(?:\s|$):===:hurray
-regex;(?:^|\s)<3(?:\s|$):===:heart
-regex;(?:^|\s)3(?:\s|$):===:broken heart
-regex;(?:^|\s)(?:Oo|oO)(?:\s|$):===:WTF?
-regex;(?:^|\s)<{-.-}>(?:\s|$):===:Raves
-regex;(?:^|\s)-\.-(?:\s|$):===:bugged
-regex;(?:^|\s)>\.<(?:\s|$):===:laughing
-regex;(?:^|\s)(?:>:\)|>:-\))(?:\s|$):===:evil smile
-regex;(?:^|\s)\^\^(?:\s|$):===:enjoy smile
-regex;(?:^|\s)->(?:\s|$):===:arrow right
-regex;(?:^|\s)<-(?:\s|$):===:arrow left
diff --git a/config/punctuation/rhvoice-en.toml b/config/punctuation/rhvoice-en.toml
new file mode 100644
index 00000000..7b65a7fa
--- /dev/null
+++ b/config/punctuation/rhvoice-en.toml
@@ -0,0 +1,45 @@
+# Punctuation names and levels from Python Fenrir (LGPL-3.0-or-later).
+# Pronunciation dictionaries and emoticons are separate from punctuation.
+version = 1
+translate = true
+
+[levels]
+"none" = ""
+"some" = "-$~+*-/\\@_"
+"most" = ".,:-$~+*-/\\@!#%^&*()[]}{<>;"
+"all" = "!\"#$%& \\'()*+,-./:;<=>?@[\\\\]^_`{|}~"
+
+[symbols]
+" " = "space"
+"&" = "and"
+"'" = "apostrophe"
+"@" = "at"
+"\\" = "backslash"
+"|" = "bar"
+"!" = "bang"
+"^" = "carrot"
+":" = "colon"
+"," = "comma"
+"-" = "dash"
+"$" = "dollar"
+"." = "dot"
+">" = "greater"
+"`" = "grave"
+"#" = "hash"
+"{" = "left brace"
+"[" = "left bracket"
+"(" = "left paren"
+"<" = "less"
+"%" = "percent"
+"+" = "plus"
+"?" = "question?"
+"\"" = "quote"
+")" = "right paren"
+"}" = "right brace"
+"]" = "right bracket"
+";" = "semicolon"
+"/" = "slash"
+"*" = "star"
+"~" = "tilde"
+"_" = "line"
+"=" = "equals"
diff --git a/config/settings/settings.conf b/config/settings/settings.conf
deleted file mode 100644
index f2e4523e..00000000
--- a/config/settings/settings.conf
+++ /dev/null
@@ -1,378 +0,0 @@
-[sound]
-# Turn sound on or off:
-enabled=True
-
-# Select the driver used to play sounds, choices are genericDriver and gstreamerDriver.
-# Generic driver uses fewer dependencies but spawns a process for each sound played including progress bar beeps
-# Gstreamer is the default.
-driver=gstreamerDriver
-#driver=genericDriver
-
-# Sound themes. These are the pack of sounds used for sound alerts.
-# Sound packs may be located at ~/.local/stormux/fenrir/sounds,
-# /usr/share/sounds/fenrir, or /usr/share/sounds/fenrirscreenreader.
-theme=default
-
-# Sound volume controls how loud the sounds for your selected soundpack are.
-# 0 is quietest, 1.0 is loudest.
-volume=0.7
-
-# shell commands for generic sound driver
-# the folowing variable are substituted
-# fenrir_volume = the current volume setting
-# fenrir_sound_file = the soundfile for an soundicon
-# fenrir_frequency = the frequency to play
-# fenrir_duration = the duration of the frequency
-# the following command is used to play a soundfile
-generic_play_file_command=play -q -v fenrir_volume fenrir_sound_file
-#the following command is used to generate a frequency beep
-generic_frequency_command=play -q -v fenrir_volume -n -c1 synth fenrir_duration sine fenrir_frequency
-
-# Enable progress bar monitoring with ascending tones by default
-progress_monitoring=True
-
-[speech]
-# Turn speech on or off:
-enabled=True
-
-# Select speech driver, options are speechdDriver, genericDriver,
-# dectalkDriver, dectalkUsbDriver, litetalkDriver, doubletalkDriver,
-# or tripletalkDriver:
-driver=speechdDriver
-#driver=genericDriver
-#driver=dectalkDriver
-#driver=dectalkUsbDriver
-#driver=litetalkDriver
-#driver=doubletalkDriver
-#driver=tripletalkDriver
-
-# The rate selects how fast Fenrir will speak. Options range from 0, slowest, to 1.0, fastest.
-rate=0.5
-
-# Pitch controls the pitch of the voice, select from 0, lowest, to 1.0, highest.
-pitch=0.5
-# Pitch for capital letters
-capital_pitch=0.9
-# How to indicate capital letters:
-# pitch = change speech pitch (uses capital_pitch value)
-# beep = play Caps.wav sound icon
-# both = play beep AND change pitch
-# none = no special indication
-capital_indicator=pitch
-
-# Volume controls the loudness of the voice, select from 0, quietest, to 1.0, loudest.
-volume=1.0
-
-# Module is used for Speech-dispatcher, to select the speech module you want to use.
-# Consult Speech-dispatcher's configuration and help Fenrir find out which modules are available.
-# The default is specified in speechd.conf.
-#module=espeak-ng
-
-# Voice selects the voice you want to use, for example, en-GB-scotland will use the Scotish English voice in Espeak,
-# To find out which voices are available, consult the documentation provided with your selected synthesizer.
-# This also sets the voice used in the generic driver.
-# You can add a variant by adding +name onto the end.
-# voice=en-us
-
-# Select the language you want Fenrir to use.
-#language=en
-
-# Hardware speech synthesizer serial device.
-# Used by dectalkDriver, litetalkDriver, doubletalkDriver, and tripletalkDriver.
-# dectalkUsbDriver autodetects the Access Solutions device and ignores this.
-# USB serial devices are supported if Linux exposes them as /dev/ttyACM*
-# or /dev/ttyUSB*. USB-only synths with no tty device need a separate driver.
-# Set an explicit device for hardware speech.
-# Examples:
-# hardware_device=/dev/ttyACM0 # RPITalk USB gadget mode
-# hardware_device=/dev/ttyUSB0 # USB serial adapter
-# hardware_device=/dev/ttyS0 # built-in serial port
-hardware_device=/dev/ttyS0
-
-# Serial baud rate for hardware speech synthesizers.
-hardware_baud_rate=9600
-
-# Read new text as it happens?
-auto_read_incoming=True
-
-# Speak individual numbers instead of whole string.
-read_numbers_as_digits = False
-
-# Number of spoken items kept in runtime speech history.
-history_size=50
-
-# Flood control: batch rapid updates instead of speaking each one
-# Number of updates within rapid_update_window to trigger batching
-rapid_update_threshold=5
-
-# Time window (seconds) for detecting rapid updates
-rapid_update_window=0.3
-
-# How often to speak batched content (seconds)
-batch_flush_interval=0.5
-
-# Maximum lines to keep when batching (keeps newest, drops oldest)
-max_batch_lines=100
-
-# Only enable flood control if this many new lines appear in the window
-flood_line_threshold=500
-
-# generic_speech_command is the command that is executed for talking
-# the following variables are replaced with values
-# fenrir_text = is the text that should be spoken
-# fenrir_module = may be the speech module like used in speech-dispatcher, not every TTY need this
-# fenrir_language = the language
-# fenrir_voice = is the current voice that should be used. Set the voice variable above.
-# the current volume, pitch and rate is calculated like this
-# value = min + settingValue * (min - max )
-# fenrir_volume = is replaced with the current volume
-# fenrir_pitch = is replaced with the current pitch
-# fenrir_rate = is replaced with the current speed (speech rate)
-generic_speech_command=espeak-ng -a fenrir_volume -s fenrir_rate -p fenrir_pitch -v fenrir_voice -- "fenrir_text"
-
-# min and max values of the TTS system that is used in generic_speech_command
-fenrir_min_volume=0
-fenrir_max_volume=200
-fenrir_min_pitch=0
-fenrir_max_pitch=99
-fenrir_min_rate=80
-fenrir_max_rate=450
-
-[screen]
-# Screen driver: vcsaDriver (Linux TTY/virtual console), ptyDriver (terminal emulation)
-driver=vcsaDriver
-# Text encoding: auto (auto-detect), utf-8, latin1, etc.
-encoding=auto
-# Delay in seconds between screen updates (lower = more responsive, higher = less CPU)
-# Recommended: 0.05 for most systems
-screen_update_delay=0.05
-# TTY number to completely ignore (e.g., 7 for X11/graphical login screen)
-ignore_screen=7
-# Automatically detect and ignore graphical TTYs (X11, Wayland sessions)
-autodetect_ignore_screen=True
-
-[keyboard]
-driver=evdevDriver
-# filter input devices NOMICE, ALL or a DEVICE NAME
-device=ALL
-# gives Fenrir exclusive access to the keyboard and lets it control keystrokes.
-grab_devices=True
-# Optional X11 target window id for x11Driver. Empty uses WINDOWID or active window.
-x11_window_id=
-# Ignore shortcut bindings and pass all keys through without processing Fenrir commands.
-# When True, Fenrir will only monitor screen content without intercepting keyboard input.
-# the current shortcut layout located in /etc/fenrirscreenreader/keyboard
-ignore_shortcuts=False
-keyboard_layout=desktop
-# echo chars while typing.
-# 0 = None
-# 1 = always
-# 2 = only while capslock (not compatible with capslock as fenrir key)
-char_echo_mode=1
-# echo deleted chars
-char_delete_echo=True
-# echo word after pressing space
-word_echo=False
-# interrupt speech on any keypress
-interrupt_on_key_press=True
-# you can filter the keys on that the speech should interrupt (empty = all keys, otherwhise the given keys)
-interrupt_on_key_press_filter=
-# timeout for double tap in sec
-double_tap_timeout=0.2
-
-[general]
-# Debug levels: 0=NONE, 1=ERROR, 2=WARNING, 3=INFO (most verbose)
-# For production use, WARNING (2) provides good balance of useful info without spam
-# The default is 0, no logging.
-debug_level=0
-# debugMode sets where the debug output should send to:
-# debugMode=File writes to debug_file (Default:/tmp/fenrir.log)
-# If the default log is already in use, Fenrir uses /tmp/fenrir2.log, etc.
-# debugMode=Print just prints on the screen
-debug_mode=File
-debug_file=
-# Punctuation settings control how punctuation is spoken during text review.
-# Profile selects a punctuation definition file from config/punctuation/ (e.g., default.conf)
-punctuation_profile=default
-punctuation_level=some
-respect_punctuation_pause=True
-# Replace undefined punctuation with spaces instead of removing them
-# This improves readability of text with punctuation like [X]mute, IP addresses, etc.
-replace_undefined_punctuation_with_space=True
-# Pause speech briefly at newline characters for better readability
-new_line_pause=True
-# Convert text emoticons like :) to descriptive text (e.g., "smiling face")
-emoticons=True
-# Define the Fenrir modifier key(s) - used to trigger Fenrir commands
-# Examples: KEY_KP0 (numpad 0), KEY_META (Super/Windows), KEY_INSERT
-# Multiple keys: KEY_KP0,KEY_META,KEY_INSERT
-fenrir_keys=KEY_KP0,KEY_META,KEY_INSERT
-# Script key - used to execute custom scripts in script_path
-script_keys=KEY_COMPOSE
-# Time format using Python strftime codes (https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior)
-# Default: %%I:%%M%%P (12-hour format, e.g., "06:38pm")
-# 24-hour format: %%H:%%M (e.g., "18:38")
-# Common codes: %%I=12hr hour, %%H=24hr hour, %%M=minute, %%S=second, %%P=am/pm lowercase, %%p=AM/PM uppercase
-time_format=%%I:%%M%%P
-# Date format using Python strftime codes
-# Default: %%A, %%B %%d, %%Y (e.g., "Tuesday, December 10, 2024")
-# ISO format: %%Y-%%m-%%d (e.g., "2024-12-10")
-# Common codes: %%A=weekday name, %%B=month name, %%d=day, %%Y=year with century, %%m=month number
-date_format=%%A, %%B %%d, %%Y
-# Automatically spell check words when reviewing character-by-character
-auto_spell_check=True
-# Language for spell checking (format: language_COUNTRY, e.g., en_US, en_GB, es_ES)
-spell_check_language=en_US
-# path for your scripts "script_keys" functionality.
-# User-local scripts in ~/.local/stormux/fenrir are loaded first.
-script_path=/usr/share/fenrirscreenreader/scripts
-# Override default commands or add custom commands without modifying Fenrir installation
-# Leave empty to use default commands only
-command_path=
-# Format string for announcing text attributes (colors, formatting)
-# Available variables:
-# fenrirBGColor = the background color
-# fenrirFGColor = the foreground color
-# fenrirUnderline = speak the underline attribute
-# fenrirBold = speak the bold attribute
-# fenrirBlink = speak the blink attribute
-# fenrirFont = the font name
-# fenrirFontSize = the font size
-attribute_format_string=Background fenrirBGColor,Foreground fenrirFGColor,fenrirUnderline,fenrirBold,fenrirBlink, Font fenrirFont,Fontsize fenrirFontSize
-# Automatically announce indentation level changes (useful for Python, YAML, etc.)
-auto_present_indent=False
-# How to present indentation changes:
-# 0 = sound and speak (both audio feedback and voice announcement)
-# 1 = sound only (just play a tone)
-# 2 = speak only (just voice announcement)
-auto_present_indent_mode=1
-# Play a sound when text attributes (color, bold, etc.) change
-has_attributes=True
-# Shell to use for PTY emulation mode (empty = use system default shell)
-# Examples: /bin/bash, /bin/zsh, /usr/bin/fish
-shell=
-# Diff review presentation mode:
-# speech = spoken feedback only
-# sound = sound cues only (falls back to speech when cue is unavailable)
-# both = speech and sound cues
-diff_presentation=both
-# Diff review verbosity:
-# compact = concise role/location feedback
-# verbose = include diff line content during navigation
-diff_verbosity=compact
-
-[clipboard]
-# Number of clipboard history entries Fenrir keeps.
-number_of_clipboards=50
-# used path for "export_clipboard_to_file"
-# $user is replaced by username
-#clipboard_export_path=/home/$user/fenrirClipboard
-clipboard_export_path=/tmp/fenrirClipboard
-# Keep Fenrir's clipboard history and the X clipboard synchronized.
-# In fenrir -x, an empty sync_display uses the current DISPLAY.
-# In console/TTY mode, set sync_display explicitly, for example :0.
-# Manual graphical clipboard import/export also use sync_display when set.
-sync_enabled=False
-sync_display=
-sync_interval=0.5
-
-[focus]
-# Follow and announce text cursor position changes
-cursor=True
-# Follow and announce highlighted/selected text changes (useful in menus)
-highlight=False
-# Suppress generic incoming announcements for full-screen TUIs that redraw often
-tui=False
-
-[remote]
-enable=True
-# driver
-# unixDriver = unix sockets
-# tcpDriver = tcp (localhost only)
-driver=unixDriver
-# tcp port
-port=22447
-# socket filepath
-socket_file=
-# allow settings to overwrite
-enable_settings_remote=True
-# allow commands to be executed
-enable_command_remote=True
-
-[barrier]
-# Enable barrier detection - automatically detects table/box borders for improved navigation
-enabled=False
-# Characters that represent left-side barriers/borders (for table/box detection)
-left_barriers=āāāā
-# Characters that represent right-side barriers/borders (for table/box detection)
-right_barriers=āāāā
-
-[review]
-# Announce line breaks during review mode
-line_break=True
-# Announce when reaching the end of screen during review
-end_of_screen=True
-# Exit review mode when cursor position changes (e.g., when typing)
-leave_review_on_cursor_change=True
-# Exit review mode when switching to a different TTY/screen
-leave_review_on_screen_change=True
-
-[menu]
-# Custom path for VMenu (virtual menu) profiles
-# Leave empty to use default location (/etc/fenrirscreenreader/vmenu-profiles/)
-vmenu_path=
-# Quick menu: Semicolon-separated list of settings for rapid adjustment via VMenu
-# Access with Fenrir+F10, navigate to "Quick Settings"
-# Format: section#setting;section#setting;...
-# Supported settings:
-# - speech#rate, speech#pitch, speech#volume (0.0-1.0)
-# - speech#module, speech#voice (speechdDriver only, auto-added)
-# Note: speech#module and speech#voice are automatically added when
-# speechdDriver is active. Do not add them manually.
-quick_menu=speech#rate;speech#pitch;speech#volume
-
-[prompt]
-# Custom prompt patterns for silence until prompt feature
-# You can add your own shell prompt patterns as regular expressions
-# Each pattern should be on a separate line, format: customPatterns=pattern1,pattern2,pattern3
-#
-# Built-in patterns include:
-# - Shell prompts: $, #, >, user@host$, [user@host]$, bash-5.1$
-# - Package manager prompts: [Y/n], [y/N], [Yes/No], (Y/n), (y/N)
-# - sudo prompts: [sudo] password for user:, Password:, user's password:
-# - Confirmation prompts: Press any key, Are you sure?, Please confirm
-#
-# Custom pattern examples:
-# For PS1='[\u@\h \W] \$ ' use: \[.*@.*\s.*\]\s*[$#>]\s*
-# For "[user@hostname ~] $" use: \[.*@.*\s.*\]\s*[$#>]\s*
-# For custom prompts ending with specific strings, use patterns like: .*your_prompt_ending$
-# For custom package manager prompts: .*your_package_manager.*\[[YyNn]/[YyNn]\].*
-custom_patterns=
-
-# Specific prompt strings to match exactly (useful for very specific custom prompts)
-# Format: exact_matches=prompt1,prompt2,prompt3
-# Examples:
-# exact_matches=[storm@fenrir ~] $,[root@fenrir ~] #,Continue installation? [Y/n]
-exact_matches=
-
-[time]
-# Enable automatic time announcements
-enabled=False
-# Announce the current time
-present_time=True
-# Announce the date (only when it changes, e.g., at midnight)
-present_date=True
-# Announce time every N seconds (0 = disabled)
-# If delay_sec > 0, on_minutes is ignored
-delay_sec=0
-# Announce time at specific minutes of each hour (comma-separated)
-# Examples:
-# 00 = Only on the hour (1:00, 2:00, 3:00, etc.)
-# 00,30 = On the hour and half-hour (1:00, 1:30, 2:00, 2:30, etc.)
-# 00,15,30,45 = Every 15 minutes
-# Note: This is ignored if delay_sec > 0
-on_minutes=00,30
-# Play a sound icon before time announcement (non-interrupting)
-announce=True
-# Interrupt current speech to announce time immediately
-interrupt=False
diff --git a/config/settings/settings.toml b/config/settings/settings.toml
new file mode 100644
index 00000000..a4ea9902
--- /dev/null
+++ b/config/settings/settings.toml
@@ -0,0 +1,138 @@
+# Same settings directories as Python Fenrir, with a separate TOML filename.
+version = 3
+
+[presentation.sound]
+enabled = true
+# Driver and theme apply at launch; the Sound form saves these selections.
+# pulse uses PulseAudio or PipeWire's PulseAudio service; alsa opens a PCM.
+driver = "pulse"
+theme = "default"
+# File-only ALSA PCM name, ignored by pulse. Use plughw:CARD=...,DEV=... for
+# direct hardware with format conversion; "default" follows ALSA configuration.
+device = "default"
+# 0.0 is silent, 1.0 is full volume. Matches Python Fenrir's default.
+volume = 0.7
+
+[presentation.speech]
+driver = "speech-dispatcher"
+# Hardware alternatives: dectalk, dectalk-usb, litetalk, doubletalk, tripletalk.
+# Serial drivers require an explicit path. Also available in the Speech form.
+# Driver, port and baud changes apply at launch; Test synthesizer previews them.
+hardware_device = ""
+# 0 selects 9600 baud. Native dectalk-usb ignores serial settings.
+hardware_baud_rate = 0
+enabled = true
+# Shared -100 to 100 scale; hardware drivers map it to native ranges.
+rate = 80
+# Empty choices use the server default. Voice names are module-specific.
+module = ""
+language = ""
+voice = ""
+pitch = 0
+volume = 100
+
+# Tone feedback is independent of speech and automatic reading.
+# Toggle with NumLock-off KP+ (desktop) or Fenrir+Shift+P (laptop).
+[presentation.progress]
+enabled = true
+
+# Select presets with current examples in Presentation > Time and date.
+[presentation.time_and_date]
+time_format = "locale"
+date_format = "locale"
+
+[keyboard]
+# Window between fresh presses; 1ā5000 ms. File-only, applied at launch.
+multi_press_timeout_ms = 200
+# Named layouts live in keyboard/ alongside settings/. Explicit TOML paths work too.
+layout = "desktop"
+# Empty discovers all supported keyboards and numeric keypads.
+devices = []
+character_echo = true
+word_echo = false
+# Enables character echo while CapsLock is on, even if character_echo is false.
+echo_on_capslock = false
+
+[reading]
+# Automatic completed-word checking uses the session language and PATH.
+spellcheck = true
+spellcheck_read_word = false
+spellcheck_say_misspelled = false
+spellcheck_sound = true
+automatic = true
+# cursor (default), table, or highlight. Cycle with Fenrir+KP* / Fenrir+Y.
+mode = "cursor"
+# Cursor navigation feedback, independent of typing echo.
+cursor_echo = true
+punctuation_profile = "default"
+# none, some, most, all. Character review always names punctuation.
+punctuation_level = "some"
+# Whole whitespace-delimited emoticons, matching Python's names/boundaries.
+emoticons = true
+# Optional summaries for whole repeated-symbol tokens, e.g. ##### -> 5 number.
+# Only symbols named at the selected punctuation level are summarized.
+repeated_symbols = false
+# Minimum run length, 2ā10000; default 4 means more than three repeats.
+repeated_symbols_min = 4
+# Independent capital indicators for typing, cursor echo and character review.
+raise_pitch_for_capitals = true
+beep_for_capitals = false
+say_cap_for_capitals = false
+# Automatic indentation feedback: none, speech, beeps, or both.
+# Also available through quick settings; explicit Save persists the choice.
+indentation = "none"
+# Attribute feedback: none, speech, beeps (presence cue), or both.
+# Fenrir+KP- temporarily toggles feedback without changing this startup default.
+attributes = "none"
+attribute_format = "Background fenrirBGColor,Foreground fenrirFGColor,fenrirUnderline,fenrirBold,fenrirBlink, Font fenrirFont,Fontsize fenrirFontSize"
+
+# File-only prompt customization, applied at launch. See docs/prompt-silence.md.
+# Built-in shell/password/confirmation and Worked-for completion detection stays active.
+[prompt]
+exact_matches = []
+# Regular expressions; literal TOML strings preserve backslashes.
+custom_patterns = []
+
+[clipboard]
+# One sync switch for both -x and console mode. Missing X never stops Fenrir.
+sync = false
+# Empty uses DISPLAY; without DISPLAY discover a single local X server.
+sync_display = ""
+# Saves unencrypted history in separate TTY/terminal files. Off by default.
+persistent = false
+number_of_clipboards = 200
+# File import/export use this path. Exports are atomic and private (0600).
+export_path = "/tmp/fenrirClipboard"
+
+# Python Fenrir's existing script paths and filename shortcuts are retained.
+# The user resource root ~/.local/stormux/fenrir is searched first.
+[general]
+script_path = "/usr/share/fenrirscreenreader/scripts"
+script_keys = ["KEY_COMPOSE"]
+
+# File-only runner limits. Zero timeout preserves long-running Python scripts.
+[scripts]
+workers = 2
+queue_limit = 16
+output_bytes = 1048576
+timeout_seconds = 0
+
+# Existing socat callers use /tmp/fenrirscreenreader-daemon.sock by default.
+# An occupied socket is never replaced; choose a separate path for a trial.
+[remote]
+enabled = true
+socket_file = ""
+enable_command_remote = true
+enable_settings_remote = true
+
+# Review feedback and exit policies; file-only, applied at launch.
+[review]
+line_break = true
+end_of_screen = true
+leave_review_on_cursor_change = true
+leave_review_on_screen_change = true
+
+# File-only console policy. Ignored VTs release input and skip screen capture.
+# Applies only to Linux TTY mode; graphical VTs are always released automatically.
+[screen]
+ignored_vts = [] # For example [7, 8, 10]; distinct numbers from 1 through 63.
diff --git a/config/sound/default/Accept.wav b/config/sound/default/Accept.wav
index d0fd482c..00f2e500 100644
Binary files a/config/sound/default/Accept.wav and b/config/sound/default/Accept.wav differ
diff --git a/config/sound/default/Announce.wav b/config/sound/default/Announce.wav
index 27e0c94e..8e31e385 100644
Binary files a/config/sound/default/Announce.wav and b/config/sound/default/Announce.wav differ
diff --git a/config/sound/default/Cancel.wav b/config/sound/default/Cancel.wav
index d09d1f36..362752ba 100644
Binary files a/config/sound/default/Cancel.wav and b/config/sound/default/Cancel.wav differ
diff --git a/config/sound/default/Caps.wav b/config/sound/default/Caps.wav
index f9120bca..3f8a7100 100644
Binary files a/config/sound/default/Caps.wav and b/config/sound/default/Caps.wav differ
diff --git a/config/sound/default/ChangeTTY.wav b/config/sound/default/ChangeTTY.wav
index 124e213c..71e5c25d 100644
Binary files a/config/sound/default/ChangeTTY.wav and b/config/sound/default/ChangeTTY.wav differ
diff --git a/config/sound/default/ContentChanged.wav b/config/sound/default/ContentChanged.wav
index 692725ad..87f4f324 100644
Binary files a/config/sound/default/ContentChanged.wav and b/config/sound/default/ContentChanged.wav differ
diff --git a/config/sound/default/CopyToClipboard.wav b/config/sound/default/CopyToClipboard.wav
index bcaf44be..41870926 100644
Binary files a/config/sound/default/CopyToClipboard.wav and b/config/sound/default/CopyToClipboard.wav differ
diff --git a/config/sound/default/EmptyLine.wav b/config/sound/default/EmptyLine.wav
index 68683eef..8c1898dd 100644
Binary files a/config/sound/default/EmptyLine.wav and b/config/sound/default/EmptyLine.wav differ
diff --git a/config/sound/default/EndOfLine.wav b/config/sound/default/EndOfLine.wav
index c782c6cd..1114e1db 100644
Binary files a/config/sound/default/EndOfLine.wav and b/config/sound/default/EndOfLine.wav differ
diff --git a/config/sound/default/EndOfScreen.wav b/config/sound/default/EndOfScreen.wav
index 48ae1532..55e1e02a 100644
Binary files a/config/sound/default/EndOfScreen.wav and b/config/sound/default/EndOfScreen.wav differ
diff --git a/config/sound/default/ErrorScreen.wav b/config/sound/default/ErrorScreen.wav
index 189eda41..59974c5e 100644
Binary files a/config/sound/default/ErrorScreen.wav and b/config/sound/default/ErrorScreen.wav differ
diff --git a/config/sound/default/ErrorSpeech.wav b/config/sound/default/ErrorSpeech.wav
index 189eda41..59974c5e 100644
Binary files a/config/sound/default/ErrorSpeech.wav and b/config/sound/default/ErrorSpeech.wav differ
diff --git a/config/sound/default/PasteClipboardOnScreen.wav b/config/sound/default/PasteClipboardOnScreen.wav
index 429d8cbc..b659045c 100644
Binary files a/config/sound/default/PasteClipboardOnScreen.wav and b/config/sound/default/PasteClipboardOnScreen.wav differ
diff --git a/config/sound/default/PlaceEndMark.wav b/config/sound/default/PlaceEndMark.wav
index 44b592e9..8813e0b8 100644
Binary files a/config/sound/default/PlaceEndMark.wav and b/config/sound/default/PlaceEndMark.wav differ
diff --git a/config/sound/default/PlaceStartMark.wav b/config/sound/default/PlaceStartMark.wav
index ef2b75d0..067d8070 100644
Binary files a/config/sound/default/PlaceStartMark.wav and b/config/sound/default/PlaceStartMark.wav differ
diff --git a/config/sound/default/README.md b/config/sound/default/README.md
new file mode 100644
index 00000000..b5a8d9cb
--- /dev/null
+++ b/config/sound/default/README.md
@@ -0,0 +1,21 @@
+# Normalized Fenrir sounds
+
+These 28 WAVs are derived from Python Fenrir's `config/sound/default` at commit
+`c018f5fd294068febe0efb7961b06f87b834c4e8`:
+https://git.stormux.org/storm/fenrir
+
+Python Fenrir identifies its license as LGPL v3. Original sound names and
+content are retained; this copy changes gain, limits peaks, and encodes 24-bit
+PCM. Normalized for Fenrir in 2026.
+
+The target is -21.67 LUFS, about 8.89 dB below the original pack's median.
+This includes a further 10% reduction in perceived loudness (-1.52 dB) after
+the initial normalization target, following a listening trial at maximum volume.
+`normalization.json` records the source checksums, per-file gain and measured
+before/after loudness. After listening, `EmptyLine.wav` was lowered another
+approximately 10% in perceived loudness (-1.52 dB), to -23.15 LUFS. This
+adjustment is also retained by the normalization tool. Generated progress tones
+are not part of this pack.
+
+Do not use these already-normalized files as the source for another
+normalization pass.
diff --git a/config/sound/default/ScreenReaderOff.wav b/config/sound/default/ScreenReaderOff.wav
index fcca3d08..1f1c2d94 100644
Binary files a/config/sound/default/ScreenReaderOff.wav and b/config/sound/default/ScreenReaderOff.wav differ
diff --git a/config/sound/default/ScreenReaderOn.wav b/config/sound/default/ScreenReaderOn.wav
index 95aefffc..78e88bbc 100644
Binary files a/config/sound/default/ScreenReaderOn.wav and b/config/sound/default/ScreenReaderOn.wav differ
diff --git a/config/sound/default/SoundOff.wav b/config/sound/default/SoundOff.wav
index 9f93ee5f..1f6d3459 100644
Binary files a/config/sound/default/SoundOff.wav and b/config/sound/default/SoundOff.wav differ
diff --git a/config/sound/default/SoundOn.wav b/config/sound/default/SoundOn.wav
index 16c193cb..6f1f5e5e 100644
Binary files a/config/sound/default/SoundOn.wav and b/config/sound/default/SoundOn.wav differ
diff --git a/config/sound/default/SpeechOff.wav b/config/sound/default/SpeechOff.wav
index df71ed8c..40ca7b6f 100644
Binary files a/config/sound/default/SpeechOff.wav and b/config/sound/default/SpeechOff.wav differ
diff --git a/config/sound/default/SpeechOn.wav b/config/sound/default/SpeechOn.wav
index cf19b3cf..9ddda7d8 100644
Binary files a/config/sound/default/SpeechOn.wav and b/config/sound/default/SpeechOn.wav differ
diff --git a/config/sound/default/StartOfLine.wav b/config/sound/default/StartOfLine.wav
index 1e78066b..35e23c5c 100644
Binary files a/config/sound/default/StartOfLine.wav and b/config/sound/default/StartOfLine.wav differ
diff --git a/config/sound/default/StartOfScreen.wav b/config/sound/default/StartOfScreen.wav
index 3992153b..37a13d85 100644
Binary files a/config/sound/default/StartOfScreen.wav and b/config/sound/default/StartOfScreen.wav differ
diff --git a/config/sound/default/barrier.wav b/config/sound/default/barrier.wav
index 2254f60e..cc71d269 100644
Binary files a/config/sound/default/barrier.wav and b/config/sound/default/barrier.wav differ
diff --git a/config/sound/default/barrier_end.wav b/config/sound/default/barrier_end.wav
index 5425ab8c..4d3cc3bf 100644
Binary files a/config/sound/default/barrier_end.wav and b/config/sound/default/barrier_end.wav differ
diff --git a/config/sound/default/barrier_start.wav b/config/sound/default/barrier_start.wav
index 2254f60e..cc71d269 100644
Binary files a/config/sound/default/barrier_start.wav and b/config/sound/default/barrier_start.wav differ
diff --git a/config/sound/default/has_attribute.wav b/config/sound/default/has_attribute.wav
index 764f2549..aafd9069 100644
Binary files a/config/sound/default/has_attribute.wav and b/config/sound/default/has_attribute.wav differ
diff --git a/config/sound/default/mispell.wav b/config/sound/default/mispell.wav
index 2497c3d3..a8559d0d 100644
Binary files a/config/sound/default/mispell.wav and b/config/sound/default/mispell.wav differ
diff --git a/config/sound/default/normalization.json b/config/sound/default/normalization.json
new file mode 100644
index 00000000..c2d785f1
--- /dev/null
+++ b/config/sound/default/normalization.json
@@ -0,0 +1,234 @@
+{
+ "source_median_lufs": -12.785,
+ "target_lufs": -21.67,
+ "perceived_loudness_scale": 0.54,
+ "perceived_loudness_adjustments": {
+ "EmptyLine.wav": 0.9
+ },
+ "files": [
+ {
+ "file": "Accept.wav",
+ "source_sha256": "41fc788d070a74445e57d3b7723b3fdc38f9fcea989eef6ff166c77a66fa5d20",
+ "before_lufs": -8.25,
+ "after_lufs": -21.75,
+ "after_true_peak_db": -17.69,
+ "gain_db": -13.42
+ },
+ {
+ "file": "Announce.wav",
+ "source_sha256": "021a66d00793de36e21f8905b8bbc3653b765c81e432c02cf588690e10533072",
+ "before_lufs": -15.11,
+ "after_lufs": -21.65,
+ "after_true_peak_db": -21.44,
+ "gain_db": -6.56
+ },
+ {
+ "file": "Cancel.wav",
+ "source_sha256": "990e6b60e24602874a4e89db2ce2f4edd1ac9d7185ee8a3249d471076dfa4533",
+ "before_lufs": -8.25,
+ "after_lufs": -21.75,
+ "after_true_peak_db": -18.34,
+ "gain_db": -13.42
+ },
+ {
+ "file": "Caps.wav",
+ "source_sha256": "23b1f5de6e667c71b463daad6f81332eb61c2bf4489a2b41850fdabe4212145b",
+ "before_lufs": -9.95,
+ "after_lufs": -21.65,
+ "after_true_peak_db": -16.48,
+ "gain_db": -11.72
+ },
+ {
+ "file": "ChangeTTY.wav",
+ "source_sha256": "e3b5c03d292c68da1f71eee91f8685c304d8ac4043233fb789b7e993849b8e87",
+ "before_lufs": -17.14,
+ "after_lufs": -21.68,
+ "after_true_peak_db": -11.01,
+ "gain_db": -4.53
+ },
+ {
+ "file": "ContentChanged.wav",
+ "source_sha256": "d9da910d3cf85903a3896b27b20ab58be20b823547f6ed3c81f18755c76d8ee8",
+ "before_lufs": -2.75,
+ "after_lufs": -21.65,
+ "after_true_peak_db": -17.71,
+ "gain_db": -18.92
+ },
+ {
+ "file": "CopyToClipboard.wav",
+ "source_sha256": "6aff71f2f175f7d90460df7a9444e268849b584d25437af0c5d8bdfbbaeea777",
+ "before_lufs": -24.4,
+ "after_lufs": -21.82,
+ "after_true_peak_db": -1.74,
+ "gain_db": 12.58
+ },
+ {
+ "file": "EmptyLine.wav",
+ "source_sha256": "0f6220afbe37185e2708cf295f9e05f0a4293f70a38d07ff0a156b103d2356f0",
+ "before_lufs": -13.75,
+ "after_lufs": -23.15,
+ "after_true_peak_db": -14.73,
+ "gain_db": -9.44
+ },
+ {
+ "file": "EndOfLine.wav",
+ "source_sha256": "906ff8680c0c9f1c6991d30c70650a880aad60c9bf06fe781d41bfb9d715743f",
+ "before_lufs": -5.25,
+ "after_lufs": -21.65,
+ "after_true_peak_db": -15.32,
+ "gain_db": -16.42
+ },
+ {
+ "file": "EndOfScreen.wav",
+ "source_sha256": "a0e83918278759d29e38750ba7f7aff1e9db18510580b396b5e8c365e12c799d",
+ "before_lufs": -5.38,
+ "after_lufs": -21.68,
+ "after_true_peak_db": -14.25,
+ "gain_db": -16.29
+ },
+ {
+ "file": "ErrorScreen.wav",
+ "source_sha256": "88dd050d142f415febb3357811d23690f6b60a162d519a742999149d55c2d36e",
+ "before_lufs": -12.22,
+ "after_lufs": -21.67,
+ "after_true_peak_db": -12.4,
+ "gain_db": -9.45
+ },
+ {
+ "file": "ErrorSpeech.wav",
+ "source_sha256": "88dd050d142f415febb3357811d23690f6b60a162d519a742999149d55c2d36e",
+ "before_lufs": -12.22,
+ "after_lufs": -21.67,
+ "after_true_peak_db": -12.4,
+ "gain_db": -9.45
+ },
+ {
+ "file": "PasteClipboardOnScreen.wav",
+ "source_sha256": "aceeabb9fab12cdbec17b594121125828d6fefa038f1198455de024d3d5c2473",
+ "before_lufs": -9.75,
+ "after_lufs": -21.65,
+ "after_true_peak_db": -16.86,
+ "gain_db": -11.92
+ },
+ {
+ "file": "PlaceEndMark.wav",
+ "source_sha256": "7f875d4f613f38cc33198e6f4de8f7861e49642b73ed4866b6322c4109c7e7d5",
+ "before_lufs": -2.35,
+ "after_lufs": -21.75,
+ "after_true_peak_db": -16.33,
+ "gain_db": -19.32
+ },
+ {
+ "file": "PlaceStartMark.wav",
+ "source_sha256": "3a74af25063282f5a88e32ed69454416ca2868357165805f2ba56601fb855b4a",
+ "before_lufs": -2.35,
+ "after_lufs": -21.65,
+ "after_true_peak_db": -16.5,
+ "gain_db": -19.32
+ },
+ {
+ "file": "ScreenReaderOff.wav",
+ "source_sha256": "4926b5e0dcc4e6349c04a3566c77fc0c3d6188c29ee88ea0dabd92e4b7c4f689",
+ "before_lufs": -16.69,
+ "after_lufs": -21.69,
+ "after_true_peak_db": -8.91,
+ "gain_db": -4.98
+ },
+ {
+ "file": "ScreenReaderOn.wav",
+ "source_sha256": "53b2d006b69e6058d841879352e993b452db5ef31ba1fe4fcba3e6566a9525b9",
+ "before_lufs": -16.19,
+ "after_lufs": -21.66,
+ "after_true_peak_db": -8.39,
+ "gain_db": -5.48
+ },
+ {
+ "file": "SoundOff.wav",
+ "source_sha256": "b31584366e09c6f9dbb4a81fb0906f424f15d56e5b66816fd6e6f750ad69c18c",
+ "before_lufs": -15.25,
+ "after_lufs": -21.65,
+ "after_true_peak_db": -9.35,
+ "gain_db": -6.42
+ },
+ {
+ "file": "SoundOn.wav",
+ "source_sha256": "d2780a3ff906423a874b3fc718a676f52667f285dfe479f1d60221b0dd23e4eb",
+ "before_lufs": -13.35,
+ "after_lufs": -21.65,
+ "after_true_peak_db": -11.27,
+ "gain_db": -8.32
+ },
+ {
+ "file": "SpeechOff.wav",
+ "source_sha256": "fa8539b1d83985a2f0bf3e97a9c00dc1cc7881b93995095a933aa8bfe436d7f4",
+ "before_lufs": -14.32,
+ "after_lufs": -21.7,
+ "after_true_peak_db": -10.35,
+ "gain_db": -7.35
+ },
+ {
+ "file": "SpeechOn.wav",
+ "source_sha256": "09fbcf257e94db8f15e1fae1ea98ca33a95953cb9b818bba56c4dc8ead540631",
+ "before_lufs": -15.46,
+ "after_lufs": -21.66,
+ "after_true_peak_db": -9.14,
+ "gain_db": -6.21
+ },
+ {
+ "file": "StartOfLine.wav",
+ "source_sha256": "09ea8ba67c27ff98665581b754f6aa165ac83ee09d90accb4e79045127114303",
+ "before_lufs": -5.25,
+ "after_lufs": -21.65,
+ "after_true_peak_db": -15.32,
+ "gain_db": -16.42
+ },
+ {
+ "file": "StartOfScreen.wav",
+ "source_sha256": "57437534e287a38f070a58d20e0049b928ff0b1da0fcf401ba49a50e8d03e7f1",
+ "before_lufs": -4.05,
+ "after_lufs": -21.65,
+ "after_true_peak_db": -15.66,
+ "gain_db": -17.62
+ },
+ {
+ "file": "barrier.wav",
+ "source_sha256": "50def1d5d12b3c25f216d6514cc15ed2d5f8dbd1e71b94e703ef05a7a7c2c935",
+ "before_lufs": -14.85,
+ "after_lufs": -21.69,
+ "after_true_peak_db": -10.88,
+ "gain_db": -6.82
+ },
+ {
+ "file": "barrier_end.wav",
+ "source_sha256": "7a5c54bfbd6f77a274ce1e3be93c636837fe1aea6e66d4c8032bf51b53f4a5cd",
+ "before_lufs": -14.85,
+ "after_lufs": -21.68,
+ "after_true_peak_db": -10.88,
+ "gain_db": -6.82
+ },
+ {
+ "file": "barrier_start.wav",
+ "source_sha256": "50def1d5d12b3c25f216d6514cc15ed2d5f8dbd1e71b94e703ef05a7a7c2c935",
+ "before_lufs": -14.85,
+ "after_lufs": -21.69,
+ "after_true_peak_db": -10.88,
+ "gain_db": -6.82
+ },
+ {
+ "file": "has_attribute.wav",
+ "source_sha256": "4c3ba10bec728a257b83d58205ee60392992fc25505dce9193400239182daee2",
+ "before_lufs": -26.45,
+ "after_lufs": -21.65,
+ "after_true_peak_db": -10.98,
+ "gain_db": 4.78
+ },
+ {
+ "file": "mispell.wav",
+ "source_sha256": "4e951c989f609a899492e2acebacfda5863c6a7714856ef747402f0aaa5d2181",
+ "before_lufs": -4.45,
+ "after_lufs": -21.65,
+ "after_true_peak_db": -18.2,
+ "gain_db": -17.22
+ }
+ ]
+}
diff --git a/config/sound/default/soundicons.conf b/config/sound/default/soundicons.conf
deleted file mode 100644
index ce09f4e4..00000000
--- a/config/sound/default/soundicons.conf
+++ /dev/null
@@ -1,52 +0,0 @@
-# Screen Reader Turned On or Off
-ScreenReaderOn='ScreenReaderOn.wav'
-ScreenReaderOff='ScreenReaderOff.wav'
-# Cancel the current command
-Cancel='Cancel.wav'
-# Accept command
-Accept='Accept.wav'
-# Bell a sound if the TTY is changed (ctrl + alt +FX)
-ChangeTTY='ChangeTTY.wav'
-# Is the first Position on the line
-StartOfLine='StartOfLine.wav'
-# Is the last position of the Line
-EndOfLine='EndOfLine.wav'
-# barrier was detected
-BarrierFound='barrier.wav'
-# barrier mode starts
-BarrierStart='barrier_start.wav'
-# barrier mode ends
-BarrierEnd='barrier_end.wav'
-# the Line is empty
-EmptyLine='EmptyLine.wav'
-# Is the first line on the screen.
-StartOfScreen='StartOfScreen.wav'
-# Is the last line on the screen
-EndOfScreen='EndOfScreen.wav'
-# The content has changed
-ContentChanged='ContentChanged.wav'
-# Speech has turned On or Off
-SpeechOn='SpeechOn.wav'
-SpeechOff='SpeechOff.wav'
-# SoundIcons has turned On or Off
-SoundOn='SoundOn.wav'
-SoundOff='SoundOff.wav'
-# Set beginnig mark
-PlaceStartMark='PlaceStartMark.wav'
-# Set end mark
-PlaceEndMark='PlaceEndMark.wav'
-# Copied to clipboard
-CopyToClipboard='CopyToClipboard.wav'
-# Pasted on the screen
-PasteClipboardOnScreen='PasteClipboardOnScreen.wav'
-# An error accoured while speech output or reading the screen
-ErrorSpeech='ErrorSpeech.wav'
-ErrorScreen='ErrorScreen.wav'
-# If you cursor over an text that has attributs (like color)
-HasAttributes='has_attribute.wav'
-# missspelled indicator
-mispell='mispell.wav'
-# the for capital letter
-capital='Caps.wav'
-# general announcement
-announce="Announce.wav"
diff --git a/config/sound/default/soundicons.toml b/config/sound/default/soundicons.toml
new file mode 100644
index 00000000..c0598c74
--- /dev/null
+++ b/config/sound/default/soundicons.toml
@@ -0,0 +1,21 @@
+# Normalized Fenrir default sound pack.
+[icons]
+ready = "ScreenReaderOn.wav"
+stopped = "ScreenReaderOff.wav"
+tty_changed = "ChangeTTY.wav"
+speech_on = "SpeechOn.wav"
+speech_off = "SpeechOff.wav"
+blank = "EmptyLine.wav"
+capital = "Caps.wav"
+top = "StartOfScreen.wav"
+bottom = "EndOfScreen.wav"
+line_start = "StartOfLine.wav"
+line_end = "EndOfLine.wav"
+error = "ErrorScreen.wav"
+accept = "Accept.wav"
+place_start_mark = "PlaceStartMark.wav"
+place_end_mark = "PlaceEndMark.wav"
+copy_to_clipboard = "CopyToClipboard.wav"
+paste_clipboard = "PasteClipboardOnScreen.wav"
+has_attributes = "has_attribute.wav"
+misspelled = "mispell.wav"
diff --git a/config/sound/template/soundicons.conf b/config/sound/template/soundicons.conf
deleted file mode 100644
index afc7d1a7..00000000
--- a/config/sound/template/soundicons.conf
+++ /dev/null
@@ -1,56 +0,0 @@
-# Screen Reader Turned On or Off
-ScreenReaderOn=''
-ScreenReaderOff=''
-# Cancel the current command
-Cancel=''
-# Accept command
-Accept=''
-# Bell a sound if the TTY is changed (ctrl + alt +FX)
-ChangeTTY=''
-# Is the first Position on the line
-StartOfLine=''
-# Is the last position of the Line
-EndOfLine=''
-# barrier was detected
-BarrierFound=''
-# barrier mode starts
-BarrierStart=''
-# barrier mode ends
-BarrierEnd=''
-# the Line is empty
-EmptyLine=''
-# Is the first line on the screen.
-StartOfScreen=''
-# Is the last line on the screen
-EndOfScreen=''
-# The content has changed
-ContentChanged=''
-# Speech has turned On or Off
-SpeechOn=''
-SpeechOff=''
-# Braille has turned On or Off
-BrailleOn=''
-BrailleOff=''
-# SoundIcons has turned On or Off
-SoundIconsOn=''
-SoundIconsOff=''
-# Set beginnig mark for copy to clipboard
-PlaceStartCopyMark=''
-# Set end mark for copy to clipboard
-PlaceEndCopyMark=''
-# Copied to clipboard
-CopyToClipboard=''
-# Pasted on the screen
-PasedClipboardOnScreen=''
-# An error accoured while speech or braille output or reading the screen
-ErrorSpeech=''
-ErrorBraille=''
-ErrorScreen=''
-# If you cursor over an text that has attributs (like color)
-HasAttributes=''
-# misspelled indicator
-mispell=''
-# the for capital letter:
-capital=''
-# general announcement
-announce=""
diff --git a/docs/development.txt b/docs/development.txt
deleted file mode 100644
index 31f0aa8d..00000000
--- a/docs/development.txt
+++ /dev/null
@@ -1,387 +0,0 @@
-# Fenrir Development Guide
-
-This document provides information for developers who want to contribute to Fenrir or understand its architecture.
-
-## Project Structure
-
-Fenrir follows a modular, driver-based architecture:
-
-```
-src/fenrirscreenreader/
-āāā core/ # Core system modules
-ā āāā fenrirManager.py # Main application manager
-ā āāā screenManager.py # Screen handling
-ā āāā inputManager.py # Input handling
-ā āāā outputManager.py # Speech/sound output
-ā āāā commandManager.py # Command system
-ā āāā settingsManager.py # Configuration management
-āāā commands/ # Command implementations
-ā āāā commands/ # User-invoked commands
-ā āāā onCursorChange/ # Cursor movement hooks
-ā āāā onScreenUpdate/ # Screen update hooks
-ā āāā onKeyInput/ # Key input hooks
-ā āāā help/ # Tutorial system
-āāā drivers/ # Driver implementations
-ā āāā inputDriver/ # Input drivers (evdev, x11)
-ā āāā screenDriver/ # Screen drivers (vcsa, pty)
-ā āāā speechDriver/ # Speech drivers (speechd, generic)
-ā āāā soundDriver/ # Sound drivers (generic, gstreamer)
-āāā utils/ # Utility modules
-```
-
-## Core Architecture
-
-### Driver System
-Fenrir uses a pluggable driver architecture:
-
-1. **Input Drivers**: Capture keyboard input
- - evdevDriver: Linux evdev (recommended)
- - x11Driver: X11 terminal-scoped input
-
-2. **Screen Drivers**: Read screen content
- - vcsaDriver: Linux VCSA devices
- - ptyDriver: Terminal emulation
-
-3. **Speech Drivers**: Text-to-speech output
- - speechdDriver: Speech-dispatcher
- - genericDriver: Command-line TTS
-
-4. **Sound Drivers**: Audio output
- - genericDriver: Sox-based
- - gstreamerDriver: GStreamer
-
-5. **Remote Drivers**: Remote control interfaces
- - unixDriver: Unix socket control
- - tcpDriver: TCP socket control
-
-### Command System
-Commands are Python modules that implement specific functionality:
-
-```python
-class command():
- def __init__(self):
- pass
-
- def initialize(self, environment):
- self.env = environment
-
- def shutdown(self):
- pass
-
- def getDescription(self):
- return _('Command description')
-
- def run(self):
- # Command implementation
- pass
-```
-
-### Event Hooks
-Fenrir supports various event hooks:
-
-- **onCursorChange**: Triggered when cursor moves
-- **onScreenUpdate**: Triggered on screen content changes
-- **onKeyInput**: Triggered on key presses
-- **onScreenChanged**: Triggered when switching screens
-
-## Development Setup
-
-### Requirements
-- Python 3.6+
-- python3-evdev
-- python3-pyudev
-- speech-dispatcher
-- sox
-
-### Getting Started
-```bash
-# Clone repository
-git clone https://git.stormux.org/storm/fenrir.git
-cd fenrir
-
-# Install dependencies
-sudo pip3 install -r requirements.txt
-
-# Run from source
-cd src/
-sudo ./fenrir -f -d
-```
-
-### Testing
-```bash
-# Run in debug mode
-sudo ./fenrir -f -d -p
-
-# Debug output goes to:
-# - Console (with -p flag)
-# - /tmp/fenrir.log
-```
-
-## Creating Commands
-
-### Basic Command
-Create a file in `src/fenrirscreenreader/commands/commands/`:
-
-```python
-from fenrirscreenreader.core import debug
-
-class command():
- def __init__(self):
- pass
-
- def initialize(self, environment):
- self.env = environment
-
- def shutdown(self):
- pass
-
- def getDescription(self):
- return _('My custom command')
-
- def run(self):
- # Get current text
- text = self.env['screen']['newContentText']
-
- # Speak something
- self.env['runtime']['outputManager'].presentText('Hello World')
-
- # Play sound
- self.env['runtime']['outputManager'].playSoundIcon('Accept')
-```
-
-### Key Bindings
-Add key bindings in keyboard layout files:
-`config/keyboard/desktop.conf` or `config/keyboard/laptop.conf`
-
-```ini
-[KEY_CTRL]#[KEY_ALT]#[KEY_H]=my_command
-```
-
-### Event Hooks
-Create event handlers in appropriate directories:
-
-```python
-# onCursorChange/my_hook.py
-class command():
- def __init__(self):
- pass
-
- def initialize(self, environment):
- self.env = environment
-
- def shutdown(self):
- pass
-
- def getDescription(self):
- return _('My cursor change handler')
-
- def run(self):
- if self.env['runtime']['cursorManager'].isCursorHorizontalMove():
- # Handle horizontal cursor movement
- pass
-```
-
-## Creating Drivers
-
-### Driver Template
-```python
-class driver():
- def __init__(self):
- pass
-
- def initialize(self, environment):
- self.env = environment
-
- def shutdown(self):
- pass
-
- # Driver-specific methods...
-```
-
-### Input Driver
-Implement these methods:
-- `getInputEvent()`: Return input events
-- `writeEventBuffer()`: Handle output events
-- `grabDevices()`: Take exclusive control
-- `releaseDevices()`: Release control
-
-### Screen Driver
-Implement these methods:
-- `getCurrScreen()`: Get current screen content
-- `getSessionInformation()`: Get session info
-
-### Speech Driver
-Implement these methods:
-- `speak()`: Speak text
-- `cancel()`: Stop speech
-- `setCallback()`: Set callback functions
-
-### Remote Driver
-Implement these methods:
-- `initialize()`: Setup socket/connection
-- `watchDog()`: Listen for incoming commands
-- `shutdown()`: Clean up connections
-
-#### Remote Driver Example
-```python
-class driver(remoteDriver):
- def initialize(self, environment):
- self.env = environment
- # Start watchdog thread
- self.env['runtime']['processManager'].addCustomEventThread(
- self.watchDog, multiprocess=True
- )
-
- def watchDog(self, active, eventQueue):
- # Listen for connections and process commands
- while active.value:
- # Accept connections
- # Parse incoming data
- # Send to event queue
- eventQueue.put({
- "Type": fenrirEventType.RemoteIncomming,
- "Data": command_text
- })
-```
-
-## Configuration
-
-### Settings System
-Settings are hierarchical:
-1. Command-line options (`-o`)
-2. Configuration file
-3. Hard-coded defaults
-
-### Adding Settings
-1. Add default value to `core/settingsData.py`
-2. Access via `self.env['runtime']['settingsManager'].getSetting(section, key)`
-
-## Debugging
-
-### Debug Levels
-- 0: DEACTIVE
-- 1: ERROR
-- 2: WARNING
-- 3: INFO
-
-### Debug Output
-```python
-self.env['runtime']['debug'].writeDebugOut(
- 'Debug message',
- debug.debugLevel.INFO
-)
-```
-
-### Testing Commands
-```bash
-# Test specific functionality
-sudo fenrir -f -d -o "general#debugLevel=3"
-
-# Test with custom config
-sudo fenrir -f -s /path/to/test.conf
-```
-
-## Contributing
-
-### Code Style
-- Follow PEP 8
-- Use descriptive variable names
-- Add docstrings for complex functions
-- Handle exceptions gracefully
-
-### Testing
-- Test with different drivers
-- Test keyboard layouts
-- Test on different terminals
-- Verify accessibility features
-
-### Submitting Changes
-1. Fork the repository
-2. Create feature branch
-3. Make changes with clear commit messages
-4. Test thoroughly
-5. Submit pull request
-
-## API Reference
-
-### Environment Structure
-The `environment` dict contains all runtime data:
-
-```python
-environment = {
- 'runtime': {
- 'settingsManager': settingsManager,
- 'commandManager': commandManager,
- 'screenManager': screenManager,
- 'inputManager': inputManager,
- 'outputManager': outputManager,
- 'debug': debugManager,
- # ... other managers
- },
- 'screen': {
- 'newContentText': '',
- 'oldContentText': '',
- 'newCursor': {'x': 0, 'y': 0},
- 'oldCursor': {'x': 0, 'y': 0},
- # ... screen data
- },
- 'general': {
- 'prevCommand': '',
- 'currCommand': '',
- # ... general data
- }
-}
-```
-
-### Common Operations
-
-#### Speaking Text
-```python
-self.env['runtime']['outputManager'].presentText('Hello')
-```
-
-#### Playing Sounds
-```python
-self.env['runtime']['outputManager'].playSoundIcon('Accept')
-```
-
-#### Getting Settings
-```python
-rate = self.env['runtime']['settingsManager'].getSetting('speech', 'rate')
-```
-
-#### Cursor Information
-```python
-x = self.env['screen']['newCursor']['x']
-y = self.env['screen']['newCursor']['y']
-```
-
-#### Screen Content
-```python
-text = self.env['screen']['newContentText']
-lines = text.split('\n')
-current_line = lines[self.env['screen']['newCursor']['y']]
-```
-
-## Maintenance
-
-### Release Process
-1. Update version in `fenrirVersion.py`
-2. Update changelog
-3. Test on multiple systems
-4. Tag release
-5. Update documentation
-
-### Compatibility
-- Maintain Python 3.6+ compatibility
-- Test on multiple Linux distributions
-- Ensure driver compatibility
-- Check dependencies
-
-## Resources
-
-- **Repository**: https://git.stormux.org/storm/fenrir
-- **Wiki**: https://git.stormux.org/storm/fenrir/wiki
-- **Issues**: Use repository issue tracker
-- **Community**: IRC irc.stormux.org #stormux
-- **Email**: stormux+subscribe@groups.io
diff --git a/go.mod b/go.mod
new file mode 100644
index 00000000..aff8645e
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,9 @@
+module git.stormux.org/storm/fenrir
+
+go 1.27.2
+
+require (
+ github.com/jfreymuth/pulse v0.1.3
+ github.com/leonelquinteros/gotext v1.8.0
+ github.com/pelletier/go-toml/v2 v2.4.3
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 00000000..9c83c9a1
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,6 @@
+github.com/jfreymuth/pulse v0.1.3 h1:bc5TdxiB8E+2INnFjFWWgyfgXtz2IyNNNCX+Wt/ZD14=
+github.com/jfreymuth/pulse v0.1.3/go.mod h1:cpYspI6YljhkUf1WLXLLDmeaaPFc3CnGLjDZf9dZ4no=
+github.com/leonelquinteros/gotext v1.8.0 h1:wwpxINWmWq3JN3WFI9BoSupBFrvAqxZTtf3qppijYkU=
+github.com/leonelquinteros/gotext v1.8.0/go.mod h1:Wqke380+8unZq2F4oTmmqmbUY9zWQMzwImdC34XOAo8=
+github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
+github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
diff --git a/install.sh b/install.sh
deleted file mode 100755
index 42b6e308..00000000
--- a/install.sh
+++ /dev/null
@@ -1,71 +0,0 @@
-#!/usr/bin/env bash
-#Basic install script for Fenrir.
-read -rp "This will install Fenrir. Press ctrl+C to cancel, or enter to continue."
-
-# Fenrir main application
-install -m755 -d /opt/fenrirscreenreader
-cp -af src/* /opt/fenrirscreenreader
-
-ln -fs /opt/fenrirscreenreader/fenrir /usr/bin/fenrir
-# compiled translations
-for localeFile in locale/*/LC_MESSAGES/*.mo; do
- [[ -f "$localeFile" ]] || continue
- install -m644 -D "$localeFile" "/usr/share/$localeFile"
-done
-# tools
-install -m755 -d /usr/share/fenrirscreenreader/tools
-cp -af tools/* /usr/share/fenrirscreenreader/tools
-
-# scripts
-install -m755 -d /usr/share/fenrirscreenreader/scripts
-cp -af "config/scripts/wlan__-__key_y.sh" /usr/share/fenrirscreenreader/scripts/
-
-# keyboard
-for i in "config/keyboard/"*.conf ; do
- install -m644 -D "$i" "/etc/fenrirscreenreader/keyboard/${i##*/}"
-done
-
-# punctuation
-install -m755 -d /etc/fenrirscreenreader/punctuation
-cp -af config/punctuation/* /etc/fenrirscreenreader/punctuation
-
-# sound
-install -d /usr/share/sounds/fenrirscreenreader
-cp -af config/sound/default /usr/share/sounds/fenrirscreenreader/default
-cp -af config/sound/template /usr/share/sounds/fenrirscreenreader/template
-
-# config
-if [ -f "/etc/fenrirscreenreader/settings/settings.conf" ]; then
- echo "Do you want to overwrite your current global settings? (y/n)"
- read -r yn
- yn="${yn:0:1}"
- if [[ "${yn^}" == "Y" ]]; then
- mv /etc/fenrirscreenreader/settings/settings.conf /etc/fenrirscreenreader/settings/settings.conf.bak
- echo "Your old settings.conf has been backed up to settings.conf.bak."
- install -m644 -D "config/settings/settings.conf" /etc/fenrirscreenreader/settings/settings.conf
- else
- install -m644 -D "config/settings/settings.conf" /etc/fenrirscreenreader/settings/settings.conf.current
- fi
-else
- install -m644 -D "config/settings/settings.conf" /etc/fenrirscreenreader/settings/settings.conf
-fi
-
-
-# end message
-cat << EOF
-Installation complete.
-install path:/opt/fenrirscreenreader
-settings path:/etc/fenrirscreenreader
-
-To test Fenrir:
-sudo fenrir
-
-To have Fenrir start on system boot using systemd:
-download service file: https://git.stormux.org/storm/fenrir/raw/branch/master/autostart/systemd/Arch/fenrir.service
-move the service file to: /etc/systemd/system/fenrir.service
-sudo systemctl enable fenrir
-
-Pulseaudio users may want to run
-/usr/share/fenrirscreenreader/tools/configure_pulse.sh
-once from their user account, then once from the root.
-EOF
diff --git a/internal/app/additional_review_test.go b/internal/app/additional_review_test.go
new file mode 100644
index 00000000..af448c17
--- /dev/null
+++ b/internal/app/additional_review_test.go
@@ -0,0 +1,62 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package app
+
+import (
+ "context"
+ "reflect"
+ "testing"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/input"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+func TestAdditionalReviewDispatchAndBlankCursorAnnouncement(t *testing.T) {
+ for _, soundsEnabled := range []bool{false, true} {
+ for _, trial := range []struct {
+ id command.ID
+ x int
+ text string
+ want []string
+ cue presentation.Cue
+ }{
+ {command.CursorLine, 2, "alphaomega", []string{"1", " text cursor line number"}, ""},
+ {command.CursorColumn, 2, "alphaomega", []string{"3", " text cursor column number"}, ""},
+ {command.LineToCursor, 2, "alphaomega", []string{"al", " text cursor "}, ""},
+ {command.LineFromCursor, 2, "alphaomega", []string{"pha", " text cursor "}, ""},
+ {command.LineToCursor, 0, "alphaomega", []string{"blank", " text cursor "}, presentation.Blank},
+ {command.LineFromCursor, 0, "blankomega", []string{"blank", " text cursor "}, ""},
+ {command.ScreenFirstChar, 2, "alphaomega", []string{"a first character in screen"}, ""},
+ {command.ScreenLastChar, 2, "alphaomega", []string{"a last character in screen"}, ""},
+ } {
+ t.Run(string(trial.id), func(t *testing.T) {
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 2, Cols: 5, X: trial.x, Cells: []rune(trial.text)}}
+ voice, sounds := &fakeVoice{}, &fakeSounds{}
+ keys := &fakeKeys{poll: func(_ bool, handle func(input.Decision)) error {
+ voice.messages, sounds.cues = nil, nil
+ handle(input.Decision{Command: trial.id, Interrupt: true})
+ handle(input.Decision{Command: command.Quit})
+ return nil
+ }}
+ settings := config.Defaults()
+ settings.Presentation.Sound.Enabled = soundsEnabled
+ if err := run(context.Background(), source, keys, voice, nil, RunOptions{Settings: settings, Sounds: sounds}); err != nil {
+ t.Fatal(err)
+ }
+ want := trial.want
+ var wantCues []presentation.Cue
+ if soundsEnabled && trial.cue != "" {
+ want = want[1:]
+ wantCues = []presentation.Cue{trial.cue}
+ }
+ if !reflect.DeepEqual(voice.messages, want) || !reflect.DeepEqual(sounds.cues, wantCues) || voice.interrupts != 1 {
+ t.Fatalf("speech=%q cues=%v interrupts=%d; want speech=%q cues=%v", voice.messages, sounds.cues, voice.interrupts, want, wantCues)
+ }
+ })
+ }
+ }
+}
diff --git a/internal/app/app.go b/internal/app/app.go
new file mode 100644
index 00000000..9791d6d1
--- /dev/null
+++ b/internal/app/app.go
@@ -0,0 +1,87 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+// Package app joins the console, input, reading, and speech services.
+package app
+
+import (
+ "git.stormux.org/storm/fenrir/internal/clipboard"
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/control"
+ "git.stormux.org/storm/fenrir/internal/input"
+ "git.stormux.org/storm/fenrir/internal/preferences"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "git.stormux.org/storm/fenrir/internal/punctuation"
+ "git.stormux.org/storm/fenrir/internal/screen"
+ "git.stormux.org/storm/fenrir/internal/scripts"
+ "git.stormux.org/storm/fenrir/internal/speech"
+ "git.stormux.org/storm/fenrir/internal/spellcheck"
+)
+
+type Source interface {
+ Events() <-chan error
+ State() (int, bool, bool, error)
+ Read() (screen.Snapshot, error)
+}
+
+type Keyboards interface {
+ Events() <-chan error
+ Poll(bool, bool, func(input.Decision)) error
+ Scan() error
+ Pass_next()
+ Capture(bool) error
+ Close() error
+}
+
+type Speaker interface {
+ Say(string)
+ Say_with_pitch(text string, pitch int)
+ Configure(speech.Parameters)
+ Interrupt()
+ Toggle_mute() bool
+ Errors() <-chan error
+ Close()
+}
+
+type SoundPlayer interface {
+ // Play reports whether the cue was accepted, so unavailable cues use speech.
+ Play(presentation.Cue) bool
+ Play_progress(float64) bool
+ Play_indentation(int) bool
+ Discard_progress()
+ Clear_progress()
+ Set_volume(float64)
+ Errors() <-chan error
+}
+
+type RunOptions struct {
+ Bindings []input.Binding
+ Punctuation *punctuation.Processor
+ Settings config.Settings
+ EffectiveSpeech *config.Speech
+ Sounds SoundPlayer
+ ConfigPath string
+ Clipboard ClipboardTransfers
+ ClipboardHistory *clipboard.Persistence
+ SettingsChoices func(config.Settings) preferences.Discovery
+ Scripts map[command.ID]scripts.Script
+ ScriptRunner *scripts.Runner
+ Remote *control.Server
+ TerminalMode bool
+ Spellcheck *spellcheck.Service
+}
diff --git a/internal/app/app_test.go b/internal/app/app_test.go
new file mode 100644
index 00000000..bec749af
--- /dev/null
+++ b/internal/app/app_test.go
@@ -0,0 +1,774 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package app
+
+import (
+ "context"
+ "errors"
+ "io"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/input"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "git.stormux.org/storm/fenrir/internal/screen"
+ "git.stormux.org/storm/fenrir/internal/speech"
+ "git.stormux.org/storm/fenrir/internal/terminal"
+)
+
+func TestTerminalExitDoesNotHideOtherServiceEOF(t *testing.T) {
+ for _, problem := range []error{terminal.ErrClosed, io.EOF} {
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 1}, readErr: problem}
+ keys := &fakeKeys{}
+ err := run(context.Background(), source, keys, &fakeVoice{}, nil)
+ if problem == terminal.ErrClosed && err != nil || problem == io.EOF && !errors.Is(err, io.EOF) || !keys.closed {
+ t.Fatalf("source %v: got %v, closed=%v", problem, err, keys.closed)
+ }
+ }
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 1}}
+ keys := &fakeKeys{poll: func(bool, func(input.Decision)) error { return nil }}
+ voice := &fakeVoice{errors: make(chan error, 1)}
+ voice.errors <- io.EOF
+ if err := run(context.Background(), source, keys, voice, nil); !errors.Is(err, io.EOF) || !keys.closed {
+ t.Fatal("speech EOF was hidden by normal terminal exit handling", err, keys.closed)
+ }
+}
+
+func TestApplicationNavigationAfterReviewResumesReading(t *testing.T) {
+ for _, forwarded := range []bool{true, false} {
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 2, Cols: 5, Cells: []rune("alphaomega")}, events: make(chan error, 1)}
+ keys := &fakeKeys{events: make(chan error, 1)}
+ voice := &fakeVoice{}
+ reads, polls := 0, 0
+ source.afterRead = func() {
+ reads++
+ if reads == 2 {
+ keys.events <- nil
+ }
+ }
+ keys.poll = func(_ bool, handle func(input.Decision)) error {
+ polls++
+ if polls == 1 {
+ handle(input.Decision{Command: command.CurrentLine, Interrupt: true})
+ handle(input.Decision{Command: command.NextLine, Interrupt: true})
+ if forwarded {
+ handle(input.Decision{Forward: true, Interrupt: true, Keypress: true})
+ } else {
+ handle(input.Decision{Command: command.PasteClipboard, Interrupt: true, Keypress: true})
+ }
+ voice.messages = nil
+ source.snapshot.Y = 1
+ source.events <- nil
+ } else {
+ handle(input.Decision{Command: command.Quit})
+ }
+ return nil
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ err := run(ctx, source, keys, voice, nil)
+ cancel()
+ if err != nil || reads != 2 || polls != 2 {
+ t.Fatal("fixture did not process navigation", err, reads, polls)
+ }
+ var want []string
+ if forwarded {
+ want = []string{"omega"}
+ }
+ if !reflect.DeepEqual(voice.messages, want) {
+ t.Fatal("review exit did not distinguish application input from reader commands", forwarded, voice.messages)
+ }
+ }
+}
+
+type fakeSounds struct {
+ volume float64
+ progress []float64
+ indentation []int
+ cues []presentation.Cue
+ errors chan error
+ missing map[presentation.Cue]bool
+}
+
+func (f *fakeSounds) Set_volume(volume float64) { f.volume = volume }
+
+func (f *fakeSounds) Play(cue presentation.Cue) bool {
+ if f.missing[cue] {
+ return false
+ }
+ f.cues = append(f.cues, cue)
+ return true
+}
+func (f *fakeSounds) Play_progress(value float64) bool {
+ f.progress = append(f.progress, value)
+ return true
+}
+func (f *fakeSounds) Play_indentation(level int) bool {
+ f.indentation = append(f.indentation, level)
+ return true
+}
+func (f *fakeSounds) Clear_progress() {}
+func (f *fakeSounds) Discard_progress() {}
+func (f *fakeSounds) Errors() <-chan error { return f.errors }
+
+func TestSoundFailureDoesNotStopInputOrSpeech(t *testing.T) {
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 1}}
+ keys := &fakeKeys{events: make(chan error, 1)}
+ sounds := &fakeSounds{errors: make(chan error)}
+ voice := &fakeVoice{}
+ calls := 0
+ keys.poll = func(_ bool, handle func(input.Decision)) error {
+ calls++
+ if calls == 2 {
+ handle(input.Decision{Command: command.Unbound})
+ handle(input.Decision{Command: command.Quit})
+ }
+ return nil
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ go func() {
+ select {
+ case sounds.errors <- errors.New("fixture sound connection closed"):
+ keys.events <- nil
+ case <-ctx.Done():
+ }
+ }()
+ if err := run(ctx, source, keys, voice, nil, RunOptions{Settings: config.Defaults(), Sounds: sounds}); err != nil {
+ t.Fatal("sound failure stopped reader", err)
+ }
+ if calls != 2 || !keys.closed {
+ t.Fatal("sound failure lost input/speech", calls, keys.closed, voice.messages)
+ }
+ if !reflect.DeepEqual(voice.messages, []string{"unbound"}) {
+ t.Fatal("failed sound did not fall back to speech", voice.messages)
+ }
+}
+
+func TestAlertsUseSoundOrSpeech(t *testing.T) {
+ for _, mode := range []string{"enabled", "disabled", "unavailable", "missing cue"} {
+ for _, testCase := range []struct {
+ name string
+ action command.ID
+ x, y int
+ cells string
+ text string
+ cue presentation.Cue
+ }{
+ {"blank row", command.CurrentLine, 0, 1, "blank ", "blank", presentation.Blank},
+ {"literal blank", command.CurrentLine, 0, 0, "blank ", "blank", ""},
+ {"previous row", command.PreviousLine, 0, 0, "blank ", "start of screen", presentation.Top},
+ {"next row", command.NextLine, 0, 1, "blank ", "end of screen", presentation.Bottom},
+ {"previous word", command.PreviousWord, 0, 0, "blank ", "start of screen", presentation.Top},
+ {"next word", command.NextWord, 0, 1, "blank ", "end of screen", presentation.Bottom},
+ {"previous character", command.PreviousChar, 0, 0, "blank ", "start of screen", presentation.Top},
+ {"next character", command.NextChar, 4, 1, "blank ", "end of screen", presentation.Bottom},
+ {"character above", command.ReviewUp, 2, 1, "alphaomega", "p", ""},
+ {"character below", command.ReviewDown, 2, 0, "alphaomega", "e", ""},
+ {"character above screen", command.ReviewUp, 2, 0, "alphaomega", "start of screen", presentation.Top},
+ {"character below screen", command.ReviewDown, 2, 1, "alphaomega", "end of screen", presentation.Bottom},
+ {"empty screen", command.ReadScreen, 0, 0, " ", "screen is empty", presentation.Blank},
+ {"blank screen before", command.ScreenBeforeCursor, 2, 1, " ", "blank", presentation.Blank},
+ {"blank screen after", command.ScreenAfterCursor, 2, 0, " ", "blank", presentation.Blank},
+ {"literal blank screen range", command.ScreenAfterCursor, 0, 0, "blank ", "blank\n", ""},
+ {"screen before", command.ScreenBeforeCursor, 2, 0, "alphaomega", "alp", ""},
+ {"screen after", command.ScreenAfterCursor, 2, 0, "alphaomega", "pha\nomega", ""},
+ {"explicit top", command.Top, 0, 1, "blank ", "Top", ""},
+ {"explicit bottom", command.Bottom, 0, 0, "blank ", "Bottom", ""},
+ {"automatic reading", command.AutoRead, 0, 0, "blank ", "autoread disabled", ""},
+ {"unbound", command.Unbound, 0, 0, "blank ", "unbound", presentation.Error},
+ } {
+ t.Run(mode+"/"+testCase.name, func(t *testing.T) {
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 2, Cols: 5, X: testCase.x, Y: testCase.y, Cells: []rune(testCase.cells)}}
+ voice, sounds := &fakeVoice{}, &fakeSounds{}
+ keys := &fakeKeys{poll: func(_ bool, handle func(input.Decision)) error {
+ voice.messages, sounds.cues = nil, nil // Inspect only this command.
+ handle(input.Decision{Command: testCase.action, Interrupt: true})
+ handle(input.Decision{Command: command.Quit})
+ return nil
+ }}
+ settings := config.Defaults()
+ settings.Presentation.Sound.Enabled = mode != "disabled"
+ var player SoundPlayer = sounds
+ if mode == "unavailable" {
+ player = nil
+ } else if mode == "missing cue" {
+ sounds.missing = map[presentation.Cue]bool{testCase.cue: true}
+ }
+ if err := run(context.Background(), source, keys, voice, nil, RunOptions{Settings: settings, Sounds: player}); err != nil {
+ t.Fatal(err)
+ }
+ var wantSounds []presentation.Cue
+ var wantSpeech []string
+ if mode == "enabled" && testCase.cue != "" {
+ wantSounds = []presentation.Cue{testCase.cue}
+ } else {
+ wantSpeech = []string{testCase.text}
+ }
+ if !reflect.DeepEqual(voice.messages, wantSpeech) || !reflect.DeepEqual(sounds.cues, wantSounds) || voice.interrupts != 1 {
+ t.Fatalf("speech=%v, sounds=%v, interrupts=%d; want speech=%v, sounds=%v, interrupts=1", voice.messages, sounds.cues, voice.interrupts, wantSpeech, wantSounds)
+ }
+ })
+ }
+ }
+}
+
+func TestStartupAndUnmuteUseSoundOrSpeech(t *testing.T) {
+ for _, enabled := range []bool{false, true} {
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 5, Cells: []rune("hello")}}
+ voice, sounds := &fakeVoice{}, &fakeSounds{}
+ keys := &fakeKeys{poll: func(_ bool, handle func(input.Decision)) error {
+ handle(input.Decision{Command: command.Mute})
+ handle(input.Decision{Command: command.Mute})
+ handle(input.Decision{Command: command.Quit})
+ return nil
+ }}
+ settings := config.Defaults()
+ settings.Presentation.Sound.Enabled = enabled
+ if err := run(context.Background(), source, keys, voice, nil, RunOptions{Settings: settings, Sounds: sounds}); err != nil {
+ t.Fatal(err)
+ }
+ wantSpeech := []string{"hello"}
+ var wantSounds []presentation.Cue
+ if enabled {
+ wantSounds = []presentation.Cue{presentation.Ready, presentation.TTYChanged, presentation.SpeechOff, presentation.SpeechOn}
+ } else {
+ wantSpeech = []string{"Start Fenrir", "screen 2", "hello", "speech enabled"}
+ }
+ if !reflect.DeepEqual(voice.messages, wantSpeech) || !reflect.DeepEqual(sounds.cues, wantSounds) {
+ t.Fatalf("sound=%v: speech=%v, cues=%v; want speech=%v, cues=%v", enabled, voice.messages, sounds.cues, wantSpeech, wantSounds)
+ }
+ }
+}
+
+func TestTemporarySilenceDropsOutputUntilFreshKeypress(t *testing.T) {
+ for _, enabled := range []bool{false, true} {
+ for _, restoreKey := range []uint16{input.KeyShift, input.KeyKP8, 30} {
+ bindings, _, err := input.Load_bindings([]string{"../../config/keyboard/desktop.toml"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ router, err := input.New_router(bindings)
+ if err != nil {
+ t.Fatal(err)
+ }
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 5, Cells: []rune("hello")}, events: make(chan error, 1)}
+ voice, sounds := &fakeVoice{}, &fakeSounds{}
+ keys := &fakeKeys{events: make(chan error, 1)}
+ reads, polls := 0, 0
+ source.afterRead = func() {
+ reads++
+ if reads > 1 {
+ keys.events <- nil
+ }
+ }
+ keys.poll = func(_ bool, handle func(input.Decision)) error {
+ polls++
+ switch polls {
+ case 1:
+ voice.messages, sounds.cues = nil, nil
+ handle(router.Key(0, input.KeyKPEnter, 1, false, false))
+ handle(router.Key(0, input.KeyKPEnter, 2, false, false))
+ handle(router.Key(0, input.KeyKPEnter, 0, false, false))
+ handle(router.Key(1, input.KeyShift, 1, false, true))
+ handle(router.Key(1, input.KeyShift, 0, false, true))
+ voice.messages, sounds.cues = nil, nil
+ source.snapshot.Cells = []rune("quiet")
+ source.events <- nil
+ case 2:
+ if len(voice.messages) != 0 || len(sounds.cues) != 0 {
+ t.Fatal("speech resumed without fresh input", voice.messages, sounds.cues)
+ }
+ handle(router.Key(0, restoreKey, 1, false, false))
+ handle(router.Key(0, restoreKey, 0, false, false))
+ source.snapshot.Cells = []rune("after")
+ source.events <- nil
+ case 3:
+ handle(input.Decision{Command: command.Quit})
+ }
+ return nil
+ }
+ settings := config.Defaults()
+ settings.Presentation.Sound.Enabled = enabled
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ err = run(ctx, source, keys, voice, nil, RunOptions{Settings: settings, Sounds: sounds})
+ cancel()
+ if err != nil || polls != 3 {
+ t.Fatal("temporary silence event loop", err, polls)
+ }
+ wantSpeech := []string{"after"}
+ if restoreKey == input.KeyKP8 {
+ wantSpeech = []string{"quiet", "after"}
+ }
+ var wantSounds []presentation.Cue
+ if enabled {
+ wantSounds = []presentation.Cue{presentation.SpeechOn}
+ } else {
+ wantSpeech = append([]string{"speech enabled"}, wantSpeech...)
+ }
+ if !reflect.DeepEqual(voice.messages, wantSpeech) || !reflect.DeepEqual(sounds.cues, wantSounds) {
+ t.Fatal("resume lost fresh output or replayed suppressed output", enabled, restoreKey, voice.messages, sounds.cues)
+ }
+ }
+ }
+}
+
+func TestTemporarySilenceCueAndPersistentMute(t *testing.T) {
+ for _, soundMode := range []string{"enabled", "disabled", "missing"} {
+ for _, persistentMute := range []bool{false, true} {
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 5, Cells: []rune("hello")}}
+ voice, sounds := &fakeVoice{}, &fakeSounds{}
+ keys := &fakeKeys{poll: func(_ bool, handle func(input.Decision)) error {
+ if persistentMute {
+ handle(input.Decision{Command: command.Mute, Interrupt: true})
+ }
+ voice.messages, sounds.cues = nil, nil
+ handle(input.Decision{Command: command.TempDisableSpeech, Interrupt: true, Keypress: true})
+ if persistentMute {
+ handle(input.Decision{Keypress: true})
+ }
+ handle(input.Decision{Command: command.Quit})
+ return nil
+ }}
+ settings := config.Defaults()
+ settings.Presentation.Sound.Enabled = soundMode != "disabled"
+ if soundMode == "missing" {
+ sounds.missing = map[presentation.Cue]bool{presentation.SpeechOff: true}
+ }
+ if err := run(context.Background(), source, keys, voice, nil, RunOptions{Settings: settings, Sounds: sounds}); err != nil {
+ t.Fatal(err)
+ }
+ var wantSpeech []string
+ var wantSounds []presentation.Cue
+ if !persistentMute {
+ if soundMode == "enabled" {
+ wantSounds = []presentation.Cue{presentation.SpeechOff}
+ } else {
+ wantSpeech = []string{"speech temporary disabled"}
+ }
+ }
+ if !reflect.DeepEqual(voice.messages, wantSpeech) || !reflect.DeepEqual(sounds.cues, wantSounds) || voice.muted != persistentMute {
+ t.Fatal("temporary silence changed persistent mute or lost its cue", soundMode, persistentMute, voice.messages, sounds.cues, voice.muted)
+ }
+ }
+ }
+}
+
+func TestAutomaticAlertsUseSoundOrSpeech(t *testing.T) {
+ for _, enabled := range []bool{false, true} {
+ for _, automatic := range []bool{false, true} {
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 2, Cols: 5, Cells: []rune("blank ")}, events: make(chan error, 1)}
+ voice, sounds := &fakeVoice{}, &fakeSounds{}
+ keys := &fakeKeys{events: make(chan error, 1)}
+ calls := 0
+ keys.poll = func(_ bool, handle func(input.Decision)) error {
+ calls++
+ switch calls {
+ case 1:
+ voice.messages, sounds.cues = nil, nil
+ source.snapshot.Y = 1
+ source.events <- nil
+ case 2:
+ source.snapshot.Terminal = 3
+ source.events <- nil
+ default:
+ handle(input.Decision{Command: command.Quit})
+ }
+ return nil
+ }
+ reads := 0
+ source.afterRead = func() {
+ reads++
+ if reads > 1 {
+ keys.events <- nil
+ }
+ }
+ settings := config.Defaults()
+ settings.Presentation.Sound.Enabled = enabled
+ settings.Reading.Automatic = automatic
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ err := run(ctx, source, keys, voice, nil, RunOptions{Settings: settings, Sounds: sounds})
+ cancel()
+ if err != nil {
+ t.Fatal(err)
+ }
+ var wantSpeech []string
+ var wantSounds []presentation.Cue
+ if enabled {
+ wantSounds = []presentation.Cue{presentation.TTYChanged}
+ if automatic {
+ wantSounds = []presentation.Cue{presentation.Blank, presentation.TTYChanged, presentation.Blank}
+ }
+ } else if automatic {
+ wantSpeech = []string{"blank", "screen 3", "blank"}
+ }
+ if calls != 3 || !reflect.DeepEqual(voice.messages, wantSpeech) || !reflect.DeepEqual(sounds.cues, wantSounds) {
+ t.Fatalf("sound=%v, automatic=%v: calls=%d, speech=%v, cues=%v; want speech=%v, cues=%v", enabled, automatic, calls, voice.messages, sounds.cues, wantSpeech, wantSounds)
+ }
+ }
+ }
+}
+
+func TestCuesFollowTypedReviewAndMuteEvents(t *testing.T) {
+ for _, enabled := range []bool{false, true} {
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 5, Cells: []rune("blank")}}
+ voice, sounds := &fakeVoice{}, &fakeSounds{}
+ keys := &fakeKeys{poll: func(_ bool, handle func(input.Decision)) error {
+ handle(input.Decision{Command: command.CurrentLine}) // Literal "blank" is text, not a blank-row cue.
+ handle(input.Decision{Command: command.PreviousLine})
+ handle(input.Decision{Command: command.Mute})
+ handle(input.Decision{Command: command.Mute})
+ handle(input.Decision{Command: command.Quit})
+ return nil
+ }}
+ settings := config.Defaults()
+ settings.Presentation.Sound.Enabled = enabled
+ if err := run(context.Background(), source, keys, voice, nil, RunOptions{Settings: settings, Sounds: sounds}); err != nil {
+ t.Fatal(err)
+ }
+ want := []presentation.Cue{presentation.Ready, presentation.TTYChanged, presentation.Top, presentation.SpeechOff, presentation.SpeechOn}
+ if !enabled {
+ want = nil
+ }
+ if !reflect.DeepEqual(sounds.cues, want) {
+ t.Fatalf("enabled=%v: got %v want %v", enabled, sounds.cues, want)
+ }
+ }
+}
+
+type fakeSource struct {
+ snapshot screen.Snapshot
+ readErr, stateErr error
+ events chan error
+ afterRead func()
+}
+
+func (f *fakeSource) Events() <-chan error { return f.events }
+
+func (f *fakeSource) State() (int, bool, bool, error) {
+ return f.snapshot.Terminal, f.snapshot.Active, f.snapshot.NumLock, f.stateErr
+}
+func (f *fakeSource) Read() (screen.Snapshot, error) {
+ snapshot := f.snapshot
+ if f.afterRead != nil {
+ f.afterRead()
+ }
+ return snapshot, f.readErr
+}
+
+type fakeKeys struct {
+ poll func(bool, func(input.Decision)) error
+ closed, passed bool
+ events chan error
+ captures []bool
+ captureErr error
+ onCapture func(bool)
+}
+
+func (f *fakeKeys) Events() <-chan error { return f.events }
+
+func (f *fakeKeys) Poll(textMode, numLock bool, handle func(input.Decision)) error {
+ return f.poll(textMode, handle)
+}
+func (f *fakeKeys) Scan() error { return nil }
+func (f *fakeKeys) Capture(enabled bool) error {
+ f.captures = append(f.captures, enabled)
+ if f.onCapture != nil {
+ f.onCapture(enabled)
+ }
+ return f.captureErr
+}
+func (f *fakeKeys) Pass_next() { f.passed = true }
+func (f *fakeKeys) Close() error { f.closed = true; return nil }
+
+type fakeVoice struct {
+ parameters []speech.Parameters
+ messages []string
+ interrupts int
+ muted bool
+ errors chan error
+ onSay func(string)
+}
+
+func (f *fakeVoice) Configure(parameters speech.Parameters) {
+ f.parameters = append(f.parameters, parameters)
+}
+
+func (f *fakeVoice) Say_with_pitch(text string, pitch int) {
+ f.Say(text)
+}
+
+func (f *fakeVoice) Say(text string) {
+ if !f.muted {
+ f.messages = append(f.messages, text)
+ if f.onSay != nil {
+ f.onSay(text)
+ }
+ }
+}
+func (f *fakeVoice) Interrupt() { f.interrupts++ }
+func (f *fakeVoice) Toggle_mute() bool { f.muted = !f.muted; return f.muted }
+func (f *fakeVoice) Errors() <-chan error { return f.errors }
+func (f *fakeVoice) Close() {}
+
+func TestForegroundReviewGraphicsHandoffMuteAndQuit(t *testing.T) {
+ text := screen.Snapshot{Terminal: 2, Active: true, Rows: 2, Cols: 5, Y: 1, Cells: []rune("alphaomega")}
+ source := &fakeSource{snapshot: text}
+ voice := &fakeVoice{}
+ keys := &fakeKeys{}
+ var modes []bool
+ step := 0
+ keys.poll = func(active bool, handle func(input.Decision)) error {
+ modes = append(modes, active)
+ step++
+ switch step {
+ case 1:
+ handle(input.Decision{Command: command.PreviousLine, Interrupt: true})
+ source.snapshot = screen.Snapshot{Terminal: 1}
+ case 2:
+ source.snapshot = text
+ source.snapshot.Terminal = 3
+ case 3:
+ handle(input.Decision{Command: command.PassNext, Interrupt: true})
+ handle(input.Decision{Command: command.Mute, Interrupt: true})
+ handle(input.Decision{Command: command.ReadScreen, Interrupt: true})
+ handle(input.Decision{Command: command.Mute, Interrupt: true})
+ handle(input.Decision{Command: command.Quit, Interrupt: true})
+ }
+ return nil
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ if err := Run(ctx, source, keys, voice); err != nil {
+ t.Fatal(err)
+ }
+ if !keys.closed || !keys.passed {
+ t.Fatal("input was not released or pass-next was lost")
+ }
+ if !reflect.DeepEqual(modes, []bool{true, false, true}) {
+ t.Fatal("wrong graphical handoff", modes)
+ }
+ joined := strings.Join(voice.messages, "|")
+ for _, expected := range []string{"Start Fenrir", "screen 2", "alpha", "screen 3", "speech enabled"} {
+ if !strings.Contains(joined, expected) {
+ t.Fatalf("missing %q in %v", expected, voice.messages)
+ }
+ }
+ if strings.Contains(joined, "alpha\nomega") {
+ t.Fatal("mute leaked screen read")
+ }
+}
+
+func TestGraphicalStartupWaitsAndFollowsTextVTs(t *testing.T) {
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 1}}
+ voice := &fakeVoice{}
+ keys := &fakeKeys{}
+ var modes []bool
+ step := 0
+ keys.poll = func(active bool, handle func(input.Decision)) error {
+ modes = append(modes, active)
+ step++
+ switch step {
+ case 1:
+ if len(voice.messages) != 0 {
+ t.Fatalf("graphical startup announced speech: %v", voice.messages)
+ }
+ source.snapshot = screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 5, Cells: []rune("alpha")}
+ case 2:
+ handle(input.Decision{Command: command.CurrentLine, Interrupt: true})
+ source.snapshot = screen.Snapshot{Terminal: 1}
+ case 3:
+ source.snapshot = screen.Snapshot{Terminal: 3, Active: true, Rows: 1, Cols: 5, Cells: []rune("omega")}
+ case 4:
+ handle(input.Decision{Command: command.CurrentLine, Interrupt: true})
+ handle(input.Decision{Command: command.Quit, Interrupt: true})
+ }
+ return nil
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ if err := Run(ctx, source, keys, voice); err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(modes, []bool{false, true, false, true}) {
+ t.Fatalf("wrong capture modes: %v", modes)
+ }
+ if !keys.closed {
+ t.Fatal("input was not closed")
+ }
+ for _, expected := range []string{"screen 2", "alpha", "screen 3", "omega"} {
+ if !strings.Contains(strings.Join(voice.messages, "|"), expected) {
+ t.Fatalf("missing %q: %v", expected, voice.messages)
+ }
+ }
+}
+
+func TestInputReleasedOnSourceInputAndSpeechFailures(t *testing.T) {
+ for _, failure := range []string{"initial screen", "VT state", "keyboard", "speech"} {
+ t.Run(failure, func(t *testing.T) {
+ problem := errors.New("fixture failure")
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 1, Cells: []rune{'a'}}}
+ keys := &fakeKeys{poll: func(bool, func(input.Decision)) error { return nil }}
+ voice := &fakeVoice{errors: make(chan error, 1)}
+ switch failure {
+ case "initial screen":
+ source.readErr = problem
+ case "VT state":
+ source.stateErr = problem
+ case "keyboard":
+ keys.poll = func(bool, func(input.Decision)) error { return problem }
+ case "speech":
+ voice.errors <- problem
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ if err := Run(ctx, source, keys, voice); !errors.Is(err, problem) {
+ t.Fatalf("wrong error: %v", err)
+ }
+ if !keys.closed {
+ t.Fatal("keyboard left open after fatal error")
+ }
+ })
+ }
+}
+
+func TestInputReadinessRunsWithoutMaintenanceTicks(t *testing.T) {
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 5, Cells: []rune("alpha")}}
+ voice := &fakeVoice{}
+ keys := &fakeKeys{events: make(chan error, 1)}
+ calls := 0
+ keys.poll = func(active bool, handle func(input.Decision)) error {
+ calls++
+ if calls == 1 {
+ keys.events <- nil
+ return nil
+ }
+ handle(input.Decision{Command: command.CurrentLine, Interrupt: true})
+ handle(input.Decision{Command: command.Quit, Interrupt: true})
+ return nil
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ // A nil tick channel proves input is handled without any periodic tick.
+ if err := run(ctx, source, keys, voice, nil); err != nil {
+ t.Fatal(err)
+ }
+ if calls != 2 || !keys.closed || voice.interrupts != 2 {
+ t.Fatalf("input readiness did not dispatch and quit: calls=%d, closed=%v, interrupts=%d", calls, keys.closed, voice.interrupts)
+ }
+}
+
+func TestWatcherFailureReleasesKeyboards(t *testing.T) {
+ problem := errors.New("watcher fixture failure")
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 1}}
+ keys := &fakeKeys{events: make(chan error, 1), poll: func(bool, func(input.Decision)) error { return nil }}
+ keys.events <- problem
+ if err := run(context.Background(), source, keys, &fakeVoice{}, nil); !errors.Is(err, problem) {
+ t.Fatal("lost watcher failure", err)
+ }
+ if !keys.closed {
+ t.Fatal("watcher failure left keyboards open")
+ }
+}
+
+func TestRapidTypingEchoDoesNotWaitForScreenSampling(t *testing.T) {
+ const letters = "abcdefghijklmnop"
+ makeScreen := func(text string) screen.Snapshot {
+ return screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 20, X: len(text), Cells: []rune(text + strings.Repeat(" ", 20-len(text)))}
+ }
+ source := &fakeSource{snapshot: makeScreen("$ "), events: make(chan error, 1)}
+ voice := &fakeVoice{}
+ keys := &fakeKeys{events: make(chan error, 1)}
+ typed := 0
+ keys.poll = func(active bool, handle func(input.Decision)) error {
+ if typed == len(letters) {
+ handle(input.Decision{Command: command.Quit, Interrupt: true})
+ return nil
+ }
+ handle(input.Decision{Interrupt: true})
+ typed++
+ source.snapshot = makeScreen("$ " + letters[:typed])
+ source.events <- nil
+ source.afterRead = func() { keys.events <- nil }
+ return nil
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ // Disable all maintenance ticks: every character must arrive through the
+ // kernel-readiness path, without sleeping or waiting for a screen timer.
+ if err := run(ctx, source, keys, voice, nil); err != nil {
+ t.Fatal(err)
+ }
+ want := []string{"Start Fenrir", "screen 2", "$"}
+ for _, char := range letters {
+ want = append(want, string(char))
+ }
+ if !reflect.DeepEqual(voice.messages, want) {
+ t.Fatalf("typing echo fell behind: got %v, want %v", voice.messages, want)
+ }
+}
+
+func TestPendingInputInterruptsBeforeItsScreenEcho(t *testing.T) {
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 3, X: 2, Cells: []rune("$ ")}, events: make(chan error, 1)}
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ voice := &fakeVoice{}
+ voice.onSay = func(text string) {
+ if text == "a" {
+ if voice.interrupts != 1 {
+ t.Fatal("screen echo ran before its pending key interrupted old speech")
+ }
+ cancel()
+ }
+ }
+ keys := &fakeKeys{events: make(chan error, 1)}
+ first := true
+ keys.poll = func(active bool, handle func(input.Decision)) error {
+ if first {
+ first = false
+ source.snapshot.Cells = []rune("$ a")
+ source.events <- nil
+ keys.events <- nil
+ } else {
+ handle(input.Decision{Interrupt: true})
+ }
+ return nil
+ }
+ if err := run(ctx, source, keys, voice, nil); err != nil {
+ t.Fatal(err)
+ }
+ if len(voice.messages) == 0 || voice.messages[len(voice.messages)-1] != "a" {
+ t.Fatal("screen echo was not delivered", voice.messages)
+ }
+}
+
+func TestScreenWatcherFailureReleasesKeyboards(t *testing.T) {
+ problem := errors.New("screen watcher fixture failure")
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 1}, events: make(chan error, 1)}
+ source.events <- problem
+ keys := &fakeKeys{poll: func(bool, func(input.Decision)) error { return nil }}
+ if err := run(context.Background(), source, keys, &fakeVoice{}, nil); !errors.Is(err, problem) {
+ t.Fatal("lost screen watcher failure", err)
+ }
+ if !keys.closed {
+ t.Fatal("screen watcher failure left keyboards open")
+ }
+}
diff --git a/internal/app/application.go b/internal/app/application.go
new file mode 100644
index 00000000..361b34a4
--- /dev/null
+++ b/internal/app/application.go
@@ -0,0 +1,149 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package app
+
+import (
+ "context"
+ "log/slog"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/i18n"
+ "git.stormux.org/storm/fenrir/internal/preferences"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "git.stormux.org/storm/fenrir/internal/prompt"
+ "git.stormux.org/storm/fenrir/internal/reader"
+ "git.stormux.org/storm/fenrir/internal/speech"
+ "git.stormux.org/storm/fenrir/internal/terminal"
+)
+
+// application owns mutable reader state. Its methods run only on the event
+// loop; asynchronous services return results rather than changing this state.
+type application struct {
+ ctx context.Context
+ source Source
+ keyboards Keyboards
+ voice *echoSpeaker
+ cues SoundPlayer
+ options RunOptions
+
+ engine *reader.Reader
+ ui *internalUI
+ clipboards *clipboardHost
+ spellMenu *spellcheckMenu
+ prompts *prompt.Detector
+ settings applicationSettings
+ quick *quickSettings
+ saver *settingsSaver
+ speechPending bool
+ speechResults chan speechChangeResult
+ speechChoices chan preferences.Discovery
+ prepareSpeech func(config.Speech) (speech.Transport, error)
+
+ muted, temporarySilence bool
+ quitting, quitAfterSave bool
+
+ scriptGeneration, spellSession, spellFeedback uint64
+
+ lastTerminal int
+ lastActive bool
+ lastGeneration uint64
+ lastRows, lastCols int
+ lastNumLock bool
+}
+
+func new_application(ctx context.Context, source Source, keyboards Keyboards, voice Speaker, options RunOptions) (*application, error) {
+ prompts, err := prompt.New(options.Settings.Prompt.ExactMatches, options.Settings.Prompt.CustomPatterns)
+ if err != nil {
+ return nil, err
+ }
+ a := &application{
+ ctx: ctx, source: source, keyboards: keyboards, options: options,
+ voice: &echoSpeaker{Speaker: voice}, cues: options.Sounds,
+ engine: reader.New(), prompts: prompts,
+ settings: applicationSettings{
+ live: options.Settings, saveDraft: options.Settings, resetBase: options.Settings,
+ remoteFields: make(map[string]uint64),
+ speechAvailable: options.Settings.Presentation.Speech.Enabled,
+ },
+ muted: !options.Settings.Presentation.Speech.Enabled,
+ quick: new_quick_settings(options.Settings.Presentation.Speech.Driver), saver: &settingsSaver{results: make(chan error, 1)},
+ }
+ if options.EffectiveSpeech != nil {
+ copy_speech_selection(&a.settings.live.Presentation.Speech, *options.EffectiveSpeech)
+ }
+ a.voice.punctuation, a.voice.punctuationLevel = options.Punctuation, options.Settings.Reading.PunctuationLevel
+ a.voice.settings, a.voice.play = &a.settings.live, a.play
+ a.ui = new_internal_ui(ctx, keyboards, a.voice)
+ a.ui.previewSpelling = a.preview_spelling
+ a.ui.discoverChoices, a.ui.saveSettings = options.SettingsChoices, a.save_menu_settings
+ a.speechResults = make(chan speechChangeResult, 1)
+ a.speechChoices = make(chan preferences.Discovery, 1)
+ a.prepareSpeech = prepare_speech
+ a.quick.selectSpeech = func(target config.Speech, done func(error, bool)) {
+ session, feedback, uiGeneration := a.spellSession, a.spellFeedback, a.ui.generation
+ a.change_speech(target, func(err error) {
+ done(err, a.spellSession == session && a.spellFeedback == feedback && a.ui.generation == uiGeneration && !a.quitting)
+ })
+ }
+ a.ui.catalog = func(discovery preferences.Discovery) {
+ if discovery.Driver == "" || discovery.Driver == a.settings.live.Presentation.Speech.Driver {
+ a.quick.discovery = discovery
+ }
+ }
+ if options.SettingsChoices != nil {
+ go func() {
+ discovery := options.SettingsChoices(options.Settings)
+ select {
+ case a.speechChoices <- discovery:
+ case <-ctx.Done():
+ }
+ }()
+ }
+ a.configure_reader()
+ a.clipboards = new_clipboard(options.Settings.Clipboard, a.engine, options.Clipboard, a.present, func() string { return a.voice.last })
+ a.clipboards.historyStore = options.ClipboardHistory
+ a.restore_clipboard()
+ a.spellMenu = &spellcheckMenu{ui: a.ui, service: options.Spellcheck, clipboards: a.clipboards}
+ if session, ok := source.(*terminal.Session); ok {
+ a.clipboards.queuePaste = session.Queue_paste
+ }
+ return a, nil
+}
+
+func (a *application) restore_clipboard() {
+ if a.options.ClipboardHistory == nil {
+ return
+ }
+ if a.settings.live.Clipboard.Persistent {
+ if err := a.options.ClipboardHistory.LoadErr; err != nil {
+ slog.Warn("clipboard history load failed", "error", err, "at", time.Now())
+ a.present(i18n.T("Clipboard import failed"), presentation.Error)
+ } else if err := a.clipboards.history.Restore(a.options.ClipboardHistory.Initial); err != nil {
+ slog.Warn("clipboard history load failed", "error", err, "at", time.Now())
+ a.present(i18n.T("Clipboard import failed"), presentation.Error)
+ } else if len(a.options.ClipboardHistory.Initial.Entries) > a.settings.live.Clipboard.NumberOfClipboards {
+ a.options.ClipboardHistory.Resize(a.settings.live.Clipboard.NumberOfClipboards)
+ }
+ if a.options.ClipboardHistory.LoadErr == nil {
+ a.clipboards.settings.Persistent = a.options.ClipboardHistory.InitialEnabled
+ a.settings.live.Clipboard.Persistent = a.options.ClipboardHistory.InitialEnabled
+ a.settings.saveDraft.Clipboard.Persistent = a.options.ClipboardHistory.InitialEnabled
+ }
+ }
+}
diff --git a/internal/app/attributes_test.go b/internal/app/attributes_test.go
new file mode 100644
index 00000000..cc6296ec
--- /dev/null
+++ b/internal/app/attributes_test.go
@@ -0,0 +1,281 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package app
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/input"
+ "git.stormux.org/storm/fenrir/internal/interaction"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+func TestQuickAttributesChoicesMenuCancelSaveAndRestart(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "settings.toml")
+ base := config.Defaults()
+ base.General.ScriptKeys = []string{"KEY_COMPOSE", "KEY_META"}
+ if err := config.Save(path, base); err != nil {
+ t.Fatal(err)
+ }
+ voice := &fakeVoice{}
+ quick := new_quick_settings("speech-dispatcher")
+ quick.command(command.NextQuickCategory, &base, voice)
+ for range 2 {
+ quick.command(command.NextQuickEntry, &base, voice)
+ }
+ for _, trial := range []struct {
+ id command.ID
+ want string
+ }{
+ {command.CurrentQuickEntry, "Attributes none"},
+ {command.NextQuickValue, "speech"}, {command.NextQuickValue, "beeps"},
+ {command.NextQuickValue, "speech and beeps"}, {command.NextQuickValue, "none"},
+ {command.PreviousQuickValue, "speech and beeps"},
+ } {
+ quick.command(trial.id, &base, voice)
+ if voice.messages[len(voice.messages)-1] != trial.want {
+ t.Fatal(voice.messages)
+ }
+ }
+ if len(voice.parameters) != 0 || base.Reading.Attributes != "both" {
+ t.Fatal(base, voice.parameters)
+ }
+ ui := new_internal_ui(context.Background(), &fakeKeys{}, voice)
+ ui.open_settings(base, path, base)
+ receive_ui(t, ui)
+ control := ui.session.Values()["reading.attributes"]
+ if control.Choices[control.Selected].ID != "both" {
+ t.Fatal("menu lost quick choice", control)
+ }
+ ui.handle(interaction.Key{Name: "escape"})
+ saved, _, err := config.Load(path, nil)
+ if err != nil || saved.Reading.Attributes != "none" {
+ t.Fatal("cancel saved choice", saved.Reading, err)
+ }
+
+ for session := range 2 {
+ voice = &fakeVoice{}
+ keys := &fakeKeys{poll: func(_ bool, handle func(input.Decision)) error {
+ handle(input.Decision{Command: command.NextQuickCategory})
+ for range 2 {
+ handle(input.Decision{Command: command.NextQuickEntry})
+ }
+ if session == 0 {
+ for range 3 {
+ handle(input.Decision{Command: command.NextQuickValue})
+ }
+ handle(input.Decision{Command: command.SaveSettings})
+ } else if voice.messages[len(voice.messages)-1] != "Attributes speech and beeps" {
+ t.Fatal(voice.messages)
+ }
+ handle(input.Decision{Command: command.Quit})
+ return nil
+ }}
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 4, Cells: []rune("root")}}
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ err = run(ctx, source, keys, voice, nil, RunOptions{Settings: saved, ConfigPath: path})
+ cancel()
+ if err != nil {
+ t.Fatal(err)
+ }
+ saved, _, err = config.Load(path, nil)
+ if err != nil || saved.Reading.Attributes != "both" || !reflect.DeepEqual(saved.General, base.General) {
+ t.Fatal(saved, err)
+ }
+ }
+}
+
+func TestAttributeSoundSpeechFallbackMuteAndTemporarySilence(t *testing.T) {
+ for _, mode := range []string{"none", "speech", "beeps", "both"} {
+ for _, state := range []string{"enabled", "disabled", "zero volume", "missing", "muted", "temporary silence"} {
+ t.Run(mode+"/"+state, func(t *testing.T) {
+ settings := config.Defaults()
+ settings.Reading.Attributes = mode
+ settings.Reading.AttributeFormat = "fenrirBold"
+ settings.Presentation.Sound.Enabled = state != "disabled"
+ if state == "zero volume" {
+ settings.Presentation.Sound.Volume = 0
+ }
+ sounds, voice := &fakeSounds{}, &fakeVoice{}
+ if state == "missing" {
+ sounds.missing = map[presentation.Cue]bool{presentation.HasAttributes: true}
+ }
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 2, Cols: 1, Cells: []rune("ab"), Attributes: []screen.Attribute{{Foreground: screen.ColorDefault, Background: screen.ColorDefault}, {Style: screen.Bold}}}}
+ keys := &fakeKeys{poll: func(_ bool, handle func(input.Decision)) error {
+ if state == "muted" {
+ handle(input.Decision{Command: command.Mute})
+ }
+ if state == "temporary silence" {
+ handle(input.Decision{Command: command.TempDisableSpeech})
+ }
+ voice.messages, sounds.cues = nil, nil
+ handle(input.Decision{Command: command.NextLine})
+ handle(input.Decision{Command: command.CurrentLine})
+ handle(input.Decision{Command: command.Quit})
+ return nil
+ }}
+ if err := run(context.Background(), source, keys, voice, nil, RunOptions{Settings: settings, Sounds: sounds}); err != nil {
+ t.Fatal(err)
+ }
+ count := 0
+ for _, cue := range sounds.cues {
+ if cue == presentation.HasAttributes {
+ count++
+ }
+ }
+ wantCue := state == "enabled" && (mode == "beeps" || mode == "both")
+ if (count == 1) != wantCue {
+ t.Fatal("cue", sounds.cues)
+ }
+ count = 0
+ for _, text := range voice.messages {
+ if text == "bold" || text == "has attribute" {
+ count++
+ }
+ }
+ wantSpeech := mode != "none" && (mode != "beeps" || state != "enabled") && state != "muted" && state != "temporary silence"
+ if (count == 1) != wantSpeech {
+ t.Fatal("speech", voice.messages)
+ }
+ })
+ }
+ }
+}
+
+func TestMenuAttributesAppliesOnlyAfterSuccessfulSave(t *testing.T) {
+ for _, action := range []string{"save", "cancel", "failed save"} {
+ t.Run(action, func(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "settings.toml")
+ if err := os.WriteFile(path, []byte("version=3\n[reading]\nattributes='none'\nattribute_format='fenrirBold'\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 2, Cols: 8, Cells: []rune("root leaf"), Attributes: []screen.Attribute{{}, {}, {}, {}, {}, {}, {}, {}, {Style: screen.Bold}, {}, {}, {}, {}, {}, {}, {}}}}
+ voice, keys := &fakeVoice{}, &fakeKeys{events: make(chan error, 4)}
+ keys.onCapture = func(enabled bool) {
+ if !enabled {
+ keys.events <- nil
+ }
+ }
+ voice.onSay = func(text string) {
+ if strings.HasPrefix(text, "Fenrir Menu.") || strings.HasPrefix(text, "Could not save settings:") {
+ keys.events <- nil
+ }
+ }
+ step := 0
+ keys.poll = func(_ bool, handle func(input.Decision)) error {
+ step++
+ key := func(name string) { handle(input.Decision{UI: &interaction.Key{Name: name}}) }
+ switch step {
+ case 1:
+ handle(input.Decision{Command: command.FenrirMenu})
+ case 2:
+ for range 10 {
+ key("tab")
+ }
+ if voice.messages[len(voice.messages)-1] != "Attributes combo box none" {
+ t.Fatal(voice.messages)
+ }
+ key("down")
+ if action == "cancel" {
+ key("escape")
+ break
+ }
+ if action == "failed save" {
+ if err := os.WriteFile(path, []byte("version=3\n[reading]\nattributes='beeps'\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ for range 32 {
+ key("tab")
+ if voice.messages[len(voice.messages)-1] == "OK button" {
+ break
+ }
+ }
+ key("enter")
+ case 3:
+ if action == "failed save" {
+ key("escape")
+ }
+ voice.messages = nil
+ handle(input.Decision{Command: command.NextLine})
+ if strings.Contains(strings.Join(voice.messages, "|"), "bold") != (action == "save") {
+ t.Fatal(action, voice.messages)
+ }
+ handle(input.Decision{Command: command.Quit})
+ }
+ return nil
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ if err := run(ctx, source, keys, voice, nil, RunOptions{Settings: config.Defaults(), ConfigPath: path}); err != nil || step != 3 {
+ t.Fatal(err, step)
+ }
+ })
+ }
+}
+
+func TestAttributeSessionToggleQuickSaveAndFreshStartup(t *testing.T) {
+ for _, mode := range []string{"none", "beeps", "speech", "both"} {
+ t.Run(mode, func(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "settings.toml")
+ settings := config.Defaults()
+ settings.Reading.Attributes = mode
+ settings.Reading.AttributeFormat = "fenrirBold"
+ settings.Presentation.Sound.Enabled = false
+ if err := config.Save(path, settings); err != nil {
+ t.Fatal(err)
+ }
+ for session := range 2 {
+ voice := &fakeVoice{}
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 2, Cols: 1, Cells: []rune("ab"), Attributes: []screen.Attribute{{Foreground: screen.ColorDefault, Background: screen.ColorDefault}, {Style: screen.Bold}}}}
+ keys := &fakeKeys{poll: func(_ bool, handle func(input.Decision)) error {
+ if session == 0 {
+ handle(input.Decision{Command: command.ToggleAttributes})
+ // Every quick command reapplies configuration; it must
+ // preserve the session override and still save the default.
+ handle(input.Decision{Command: command.NextQuickValue})
+ handle(input.Decision{Command: command.SaveSettings})
+ }
+ voice.messages = nil
+ handle(input.Decision{Command: command.NextLine})
+ feedback := false
+ for _, text := range voice.messages {
+ if text == "has attribute" || text == "bold" {
+ feedback = true
+ }
+ }
+ wantFeedback := mode != "none"
+ if session == 0 {
+ wantFeedback = !wantFeedback
+ }
+ if feedback != wantFeedback {
+ t.Fatal(session, mode, voice.messages)
+ }
+ handle(input.Decision{Command: command.Quit})
+ return nil
+ }}
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ err := run(ctx, source, keys, voice, nil, RunOptions{Settings: settings, ConfigPath: path})
+ cancel()
+ if err != nil {
+ t.Fatal(err)
+ }
+ saved, _, err := config.Load(path, nil)
+ if err != nil || saved.Reading.Attributes != mode || saved.Reading.AttributeFormat != "fenrirBold" || saved.Presentation.Speech.Rate != 90 {
+ t.Fatal("temporary toggle persisted", saved, err)
+ }
+ settings = saved
+ }
+ })
+ }
+}
diff --git a/internal/app/capitalization_test.go b/internal/app/capitalization_test.go
new file mode 100644
index 00000000..0bb97f52
--- /dev/null
+++ b/internal/app/capitalization_test.go
@@ -0,0 +1,93 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package app
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "git.stormux.org/storm/fenrir/internal/punctuation"
+)
+
+type capitalVoice struct {
+ fakeVoice
+ pitches []int
+}
+
+func (v *capitalVoice) Say_with_pitch(text string, pitch int) {
+ v.pitches = append(v.pitches, pitch)
+ v.Say(text)
+}
+
+func TestCapitalChoicesAreIndependentAndKeepOriginalEcho(t *testing.T) {
+ processor, _, err := punctuation.Load([]string{"../../config/punctuation/default.toml"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for choices := range 8 {
+ t.Run(fmt.Sprint(choices), func(t *testing.T) {
+ settings := config.Defaults()
+ settings.Reading.RaisePitchForCapitals = choices&1 != 0
+ settings.Reading.BeepForCapitals = choices&2 != 0
+ settings.Reading.SayCapForCapitals = choices&4 != 0
+ voice := &capitalVoice{}
+ var cues []presentation.Cue
+ echo := &echoSpeaker{Speaker: voice, settings: &settings, punctuation: processor, punctuationLevel: "all", play: func(cue presentation.Cue) bool {
+ cues = append(cues, cue)
+ return true
+ }}
+ for _, char := range "EĆeĆ©1?" {
+ echo.say_character(char)
+ }
+ prefix := ""
+ if settings.Reading.SayCapForCapitals {
+ prefix = "cap "
+ }
+ want := prefix + "E|" + prefix + "Ć|e|Ć©|1|question?"
+ if got := strings.Join(voice.messages, "|"); got != want {
+ t.Fatal(got, want)
+ }
+ wantPitch, wantBeep := 0, 0
+ if settings.Reading.RaisePitchForCapitals {
+ wantPitch = 2
+ }
+ if settings.Reading.BeepForCapitals {
+ wantBeep = 2
+ }
+ if len(voice.pitches) != wantPitch || len(cues) != wantBeep {
+ t.Fatal(voice.pitches, cues)
+ }
+ for _, cue := range cues {
+ if cue != presentation.Capital {
+ t.Fatal(cue)
+ }
+ }
+ echo.say_character('E', " first character in cell Subject")
+ if echo.last != "E first character in cell Subject" {
+ t.Fatal("capital indication leaked into clipboard echo", echo.last)
+ }
+ beforePitch, beforeCues := len(voice.pitches), len(cues)
+ echo.Say("ALL CAPS line")
+ if len(voice.pitches) != beforePitch || len(cues) != beforeCues {
+ t.Fatal("ordinary text triggered character cues")
+ }
+ })
+ }
+}
+
+func TestCapitalPitchFollowsLiveNormalPitch(t *testing.T) {
+ settings := config.Defaults()
+ voice := &capitalVoice{}
+ echo := &echoSpeaker{Speaker: voice, settings: &settings}
+ for _, trial := range []struct{ normal, capital int }{{0, 60}, {30, 70}, {90, 100}, {-80, 60}} {
+ settings.Presentation.Speech.Pitch = trial.normal
+ echo.say_character('A')
+ if got := voice.pitches[len(voice.pitches)-1]; got != trial.capital {
+ t.Fatal(trial, got)
+ }
+ }
+}
diff --git a/internal/app/clipboard.go b/internal/app/clipboard.go
new file mode 100644
index 00000000..190eec9e
--- /dev/null
+++ b/internal/app/clipboard.go
@@ -0,0 +1,423 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package app
+
+import (
+ "context"
+ "errors"
+ "log/slog"
+ "os"
+ "os/user"
+ "reflect"
+ "strings"
+ "time"
+ "unicode"
+
+ "git.stormux.org/storm/fenrir/internal/clipboard"
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/i18n"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "git.stormux.org/storm/fenrir/internal/punctuation"
+ "git.stormux.org/storm/fenrir/internal/reader"
+)
+
+type ClipboardTransfers interface {
+ Submit(clipboard.Request) bool
+ Results() <-chan clipboard.Result
+ Cancel()
+ Close()
+}
+
+type echoSpeaker struct {
+ Speaker
+ last string
+ punctuation *punctuation.Processor
+ punctuationLevel string
+ settings *config.Settings
+ play func(presentation.Cue) bool
+}
+
+func (s *echoSpeaker) Say(text string) {
+ if text != "" {
+ s.last = text
+ }
+ if s.punctuation != nil {
+ options := punctuation.TextOptions{}
+ if s.settings != nil {
+ reading := s.settings.Reading
+ options = punctuation.TextOptions{Emoticons: reading.Emoticons, RepeatedSymbols: reading.RepeatedSymbols, RepeatedSymbolsMin: reading.RepeatedSymbolsMin}
+ }
+ text = s.punctuation.Process_text(text, s.punctuationLevel, options)
+ }
+ s.Speaker.Say(text)
+}
+
+// Character feedback bypasses text expansion, including punctuation in names.
+func (s *echoSpeaker) say_character(char rune, suffix ...string) {
+ text := presentation.Character(char)
+ if s.punctuation != nil {
+ text = s.punctuation.Character(char)
+ }
+ s.last = text + strings.Join(suffix, "")
+ for _, part := range suffix {
+ if s.punctuation != nil {
+ part = s.punctuation.Text(part, s.punctuationLevel)
+ }
+ text += part
+ }
+ if s.settings == nil || !unicode.IsUpper(char) {
+ s.Speaker.Say(text)
+ return
+ }
+ choices := s.settings.Reading
+ if choices.BeepForCapitals && s.play != nil {
+ s.play(presentation.Capital)
+ }
+ if choices.SayCapForCapitals {
+ text = i18n.T("cap") + " " + text
+ }
+ if choices.RaisePitchForCapitals {
+ // Python Fenrir's capital_pitch=0.8 maps to 60 on the SSIP scale.
+ // Keep a rise above a higher normal pitch, up to the backend limit.
+ pitch := min(100, max(60, s.settings.Presentation.Speech.Pitch+40))
+ s.Speaker.Say_with_pitch(text, pitch)
+ return
+ }
+ s.Speaker.Say(text)
+}
+
+// clipboardHost owns history, sync observations and revisions on the app loop.
+// Workers return data only; stale imports and feedback cannot mutate new state.
+type clipboardHost struct {
+ history *clipboard.History
+ historyStore *clipboard.Persistence
+ settings config.Clipboard
+ worker ClipboardTransfers
+ engine *reader.Reader
+ present func(string, presentation.Cue)
+ lastEcho func() string
+ queuePaste func(string) error
+ revision, feedback uint64
+ dirty, busy bool
+ observed string
+ next []clipboard.Request
+ terminal int
+}
+
+func new_clipboard(settings config.Clipboard, engine *reader.Reader, worker ClipboardTransfers, present func(string, presentation.Cue), lastEcho func() string) *clipboardHost {
+ return &clipboardHost{history: clipboard.New_history(settings.NumberOfClipboards), settings: settings, engine: engine, worker: worker, present: present, lastEcho: lastEcho}
+}
+
+func (c *clipboardHost) configure(settings config.Clipboard) {
+ wasPersistent := c.settings.Persistent
+ c.settings.Sync = settings.Sync
+ c.settings.Persistent = settings.Persistent
+ c.settings.NumberOfClipboards = settings.NumberOfClipboards
+ c.history.Resize(settings.NumberOfClipboards)
+ c.changed(c.dirty)
+ if c.historyStore != nil {
+ switch {
+ case wasPersistent && !settings.Persistent:
+ c.historyStore.Disable()
+ case !wasPersistent && settings.Persistent:
+ c.historyStore.Enable(c.history.Snapshot(), settings.NumberOfClipboards)
+ case settings.Persistent:
+ c.historyStore.Resize(settings.NumberOfClipboards)
+ }
+ }
+ c.next = nil
+ c.poll()
+}
+
+func (c *clipboardHost) say(text string) { c.present(i18n.T(text), "") }
+
+func (c *clipboardHost) changed(dirty bool) {
+ c.revision++
+ c.dirty = dirty
+ if c.worker != nil {
+ c.worker.Cancel()
+ }
+}
+
+func (c *clipboardHost) add(text string, dirty bool) bool {
+ if err := c.history.Add(text); err != nil {
+ c.present(err.Error(), presentation.Error)
+ return false
+ }
+ c.changed(dirty)
+ if c.settings.Persistent && c.historyStore != nil {
+ if dirty {
+ c.historyStore.Add(text, c.settings.NumberOfClipboards)
+ } else {
+ c.historyStore.Import(text, c.settings.NumberOfClipboards)
+ }
+ }
+ return true
+}
+
+func (c *clipboardHost) history_update(update clipboard.HistoryUpdate) {
+ if update.Sequence != c.historyStore.Sequence() {
+ return // A newer local action has not been acknowledged yet.
+ }
+ c.settings.Persistent = update.Enabled
+ if !update.Enabled {
+ return // Disabling persistence retains the in-memory clipboard.
+ }
+ before := c.history.Snapshot()
+ if err := c.history.Refresh(update.State); err != nil {
+ c.present(err.Error(), presentation.Error)
+ return
+ }
+ if !reflect.DeepEqual(before.Entries, c.history.Snapshot().Entries) {
+ // Remote changes are silent and must not export an old X selection.
+ c.changed(false)
+ }
+}
+
+func (c *clipboardHost) session_changed(terminal int) {
+ c.terminal = terminal
+ c.revision++
+ c.feedback++
+ c.next = nil
+ if c.worker != nil {
+ c.worker.Cancel()
+ }
+}
+
+func clipboard_path(path string) string {
+ name := os.Getenv("SUDO_USER")
+ if name == "" {
+ if current, err := user.Current(); err == nil {
+ name = current.Username
+ }
+ }
+ for _, token := range []string{"$user", "$USER", "$User"} {
+ path = strings.ReplaceAll(path, token, name)
+ }
+ return path
+}
+
+func (c *clipboardHost) transfer(id command.ID, text string) {
+ if c.worker == nil {
+ c.say("Clipboard import failed")
+ return
+ }
+ request := clipboard.Request{Command: id, Revision: c.revision, Feedback: c.feedback, Text: text, Path: clipboard_path(c.settings.ExportPath), Display: c.settings.SyncDisplay, Terminal: c.terminal}
+ c.next = append(c.next, request)
+ if c.busy {
+ c.worker.Cancel()
+ }
+ c.poll()
+}
+
+func (c *clipboardHost) poll() {
+ if c.worker == nil || c.busy {
+ return
+ }
+ var request clipboard.Request
+ if len(c.next) > 0 {
+ request = c.next[0]
+ c.next = c.next[1:]
+ if request.Revision != c.revision && (request.Command == command.PasteClipboard || request.Command == command.ImportClipboard || request.Command == command.ImportXClipboard) {
+ c.poll()
+ return
+ }
+ } else {
+ if !c.settings.Sync {
+ return
+ }
+ request = clipboard.Request{Revision: c.revision, Feedback: c.feedback, Display: c.settings.SyncDisplay, Background: true}
+ if text, ok := c.history.Current(); c.dirty && ok && text != "" {
+ request.Command, request.Text = command.ExportXClipboard, text
+ }
+ }
+ if c.worker.Submit(request) {
+ c.busy = true
+ } else {
+ c.next = append([]clipboard.Request{request}, c.next...)
+ }
+}
+
+func (c *clipboardHost) command(id command.ID) bool {
+ switch id {
+ case command.SetMark:
+ message := c.engine.Set_mark()
+ c.present(message.Text, message.Cue)
+ case command.RemoveMarks:
+ c.engine.Clear_marks()
+ c.say("Remove marks")
+ case command.MarkedText, command.CopyMarked:
+ text, ok := c.engine.Marked_text(id == command.CopyMarked)
+ if !ok {
+ if id == command.CopyMarked {
+ c.say("One or two marks are needed")
+ } else {
+ c.say("please set begin and endmark")
+ }
+ return true
+ }
+ if id == command.CopyMarked {
+ if c.add(text, true) {
+ c.engine.Clear_marks()
+ c.present(text, presentation.CopyToClipboard)
+ }
+ } else if strings.TrimSpace(text) == "" {
+ c.present(i18n.T("blank"), presentation.Blank)
+ } else {
+ c.present(text, "")
+ }
+ case command.CopyLastEcho:
+ text := c.lastEcho()
+ if strings.TrimSpace(text) != "" {
+ text = strings.TrimRight(text, " \t\r\n")
+ }
+ if c.add(text, true) {
+ c.present(text, presentation.CopyToClipboard)
+ }
+ case command.ClearClipboard:
+ c.history.Clear()
+ c.changed(false)
+ if c.historyStore != nil {
+ c.historyStore.Clear()
+ }
+ c.say("clipboard cleared")
+ case command.CurrentClipboard, command.PreviousClipboard, command.NextClipboard, command.FirstClipboard, command.LastClipboard:
+ if _, ok := c.history.Current(); !ok {
+ c.say("clipboard empty")
+ return true
+ }
+ if id != command.CurrentClipboard {
+ switch id {
+ case command.PreviousClipboard:
+ c.history.Move(-1)
+ case command.NextClipboard:
+ c.history.Move(1)
+ case command.FirstClipboard:
+ c.history.First()
+ case command.LastClipboard:
+ c.history.Last()
+ }
+ c.changed(true)
+ if c.settings.Persistent && c.historyStore != nil {
+ c.historyStore.Select(c.history.Snapshot())
+ }
+ }
+ if id == command.PreviousClipboard || id == command.NextClipboard {
+ if c.history.At_first() {
+ c.say("First clipboard ")
+ }
+ if c.history.At_last() {
+ c.say("Last clipboard ")
+ }
+ }
+ text, _ := c.history.Current()
+ c.present(text, "")
+ case command.ImportClipboard, command.ImportXClipboard:
+ c.changed(c.dirty)
+ c.transfer(id, "")
+ case command.PasteClipboard, command.ExportClipboard, command.ExportXClipboard:
+ text, ok := c.history.Current()
+ if !ok {
+ c.say("clipboard empty")
+ return true
+ }
+ if id == command.PasteClipboard && c.queuePaste != nil {
+ if err := c.queuePaste(text); err != nil {
+ c.present(err.Error(), presentation.Error)
+ } else {
+ c.present(i18n.T("paste clipboard"), presentation.PasteClipboard)
+ }
+ } else {
+ c.transfer(id, text)
+ }
+ default:
+ return false
+ }
+ return true
+}
+
+func (c *clipboardHost) result(result clipboard.Result) {
+ c.busy = false
+ request := result.Request
+ if request.Revision != c.revision {
+ if len(c.next) > 0 {
+ c.poll()
+ }
+ return
+ }
+ feedback := request.Feedback == c.feedback && !request.Background
+ if result.Err != nil {
+ if !errors.Is(result.Err, context.Canceled) {
+ // Transfer diagnostics deliberately omit text, paths and raw errors.
+ slog.Debug("clipboard transfer failed", "command", request.Command, "at", time.Now())
+ if feedback {
+ switch request.Command {
+ case command.ExportXClipboard:
+ c.say("failed to export to X clipboard. No available display found.")
+ case command.ImportXClipboard:
+ c.say("Clipboard import failed")
+ case command.ImportClipboard:
+ if errors.Is(result.Err, os.ErrNotExist) {
+ c.say("File does not exist")
+ } else {
+ c.present(result.Err.Error(), "")
+ }
+ default:
+ c.present(result.Err.Error(), presentation.Error)
+ }
+ }
+ }
+ } else {
+ switch request.Command {
+ case command.ExportXClipboard:
+ c.observed, c.dirty = request.Text, false
+ if feedback {
+ c.say("exported to the X session.")
+ }
+ case command.ExportClipboard:
+ if feedback {
+ c.say("clipboard exported to file")
+ }
+ case command.PasteClipboard:
+ if feedback {
+ c.present(i18n.T("paste clipboard"), presentation.PasteClipboard)
+ }
+ default:
+ text := result.Text
+ if request.Command == command.ImportClipboard || text != "" && (request.Command == command.ImportXClipboard || text != c.observed) {
+ current, ok := c.history.Current()
+ if request.Command != "" || !ok || text != current {
+ c.add(text, request.Command == command.ImportClipboard)
+ }
+ if request.Command != command.ImportClipboard {
+ c.observed, c.dirty = text, false
+ }
+ if feedback {
+ c.present(i18n.T("Import to Clipboard"), presentation.CopyToClipboard)
+ c.present(text, "")
+ }
+ } else if feedback && request.Command == command.ImportXClipboard {
+ c.say("No text found in clipboard or no accessible display")
+ }
+ }
+ }
+ if len(c.next) > 0 {
+ c.poll()
+ }
+}
diff --git a/internal/app/clipboard_test.go b/internal/app/clipboard_test.go
new file mode 100644
index 00000000..11bc1f4e
--- /dev/null
+++ b/internal/app/clipboard_test.go
@@ -0,0 +1,325 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package app
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "reflect"
+ "testing"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/clipboard"
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/input"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "git.stormux.org/storm/fenrir/internal/reader"
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+type fakeTransfers struct{ requests []clipboard.Request }
+
+func (f *fakeTransfers) Submit(request clipboard.Request) bool {
+ f.requests = append(f.requests, request)
+ return true
+}
+func (f *fakeTransfers) Results() <-chan clipboard.Result { return nil }
+func (f *fakeTransfers) Cancel() {}
+func (f *fakeTransfers) Close() {}
+
+func clipboard_fixture() (*clipboardHost, *fakeTransfers, *[]presentation.Message, *string) {
+ settings := config.Defaults().Clipboard
+ settings.Sync = true
+ worker := &fakeTransfers{}
+ messages := []presentation.Message{}
+ echo := "last text"
+ host := new_clipboard(settings, reader.New(), worker, func(text string, cue presentation.Cue) {
+ messages = append(messages, presentation.Message{Text: text, Cue: cue})
+ }, func() string { return echo })
+ return host, worker, &messages, &echo
+}
+
+func TestClipboardSyncStaleImportRetryAcknowledgementAndNoLoop(t *testing.T) {
+ host, worker, messages, echo := clipboard_fixture()
+ host.poll()
+ stale := worker.requests[0]
+ host.command(command.CopyLastEcho)
+ host.result(clipboard.Result{Request: stale, Text: "old X text"})
+ if text, _ := host.history.Current(); text != *echo {
+ t.Fatal("stale poll overwrote copy", text)
+ }
+ host.poll()
+ write := worker.requests[len(worker.requests)-1]
+ if write.Command != command.ExportXClipboard || write.Text != *echo {
+ t.Fatal(write)
+ }
+ host.result(clipboard.Result{Request: write, Err: errors.New("fixture failed")})
+ if !host.dirty || host.observed != "" {
+ t.Fatal("failed write was acknowledged")
+ }
+ host.poll()
+ host.result(clipboard.Result{Request: worker.requests[len(worker.requests)-1]})
+ if host.dirty || host.observed != *echo {
+ t.Fatal("successful write not acknowledged")
+ }
+ host.poll()
+ poll := worker.requests[len(worker.requests)-1]
+ host.result(clipboard.Result{Request: poll, Text: *echo})
+ if !host.history.At_first() || !host.history.At_last() || len(*messages) != 1 {
+ t.Fatal("own write reimported or sync spoke", *messages)
+ }
+ host.poll()
+ host.result(clipboard.Result{Request: worker.requests[len(worker.requests)-1], Text: "new X text"})
+ if text, _ := host.history.Current(); text != "new X text" {
+ t.Fatal(text)
+ }
+ host.command(command.NextClipboard)
+ if text, _ := host.history.Current(); text != *echo {
+ t.Fatal("history entry lost", text)
+ }
+}
+
+func TestClipboardManualImportsAndLateFeedback(t *testing.T) {
+ host, worker, messages, _ := clipboard_fixture()
+ host.settings.Sync = false
+ host.poll()
+ if len(worker.requests) != 0 {
+ t.Fatal("disabled sync opened X")
+ }
+ host.command(command.ImportXClipboard)
+ request := worker.requests[0]
+ host.feedback++ // Later typing suppresses old completion speech.
+ host.result(clipboard.Result{Request: request, Text: "imported"})
+ if text, _ := host.history.Current(); text != "imported" || len(*messages) != 0 {
+ t.Fatal(text, *messages)
+ }
+ host.command(command.ImportClipboard)
+ host.result(clipboard.Result{Request: worker.requests[1], Text: "file text"})
+ if len(*messages) != 2 || (*messages)[0].Text != "Import to Clipboard" || (*messages)[0].Cue != presentation.CopyToClipboard || (*messages)[1].Text != "file text" {
+ t.Fatal(*messages)
+ }
+ host.command(command.ClearClipboard)
+ if _, ok := host.history.Current(); ok {
+ t.Fatal("history not cleared")
+ }
+ host.command(command.PasteClipboard)
+ if (*messages)[len(*messages)-1].Text != "clipboard empty" {
+ t.Fatal(*messages)
+ }
+}
+
+func TestClipboardPendingManualTransferRetiresOnTargetChange(t *testing.T) {
+ host, worker, messages, _ := clipboard_fixture()
+ host.poll()
+ stale := worker.requests[0]
+ host.command(command.ImportXClipboard)
+ host.session_changed(3)
+ host.result(clipboard.Result{Request: stale, Text: "old session"})
+ if _, ok := host.history.Current(); ok || len(*messages) != 0 || len(worker.requests) != 1 {
+ t.Fatal("stale session changed clipboard", *messages, worker.requests)
+ }
+}
+
+func TestManualTransferQueuePreservesEveryExport(t *testing.T) {
+ host, worker, _, _ := clipboard_fixture()
+ host.settings.Sync = false
+ host.add("text", true)
+ for range 3 {
+ host.command(command.ExportClipboard)
+ }
+ for i := range 3 {
+ if len(worker.requests) != i+1 || worker.requests[i].Command != command.ExportClipboard {
+ t.Fatal("manual transfer lost", worker.requests)
+ }
+ host.result(clipboard.Result{Request: worker.requests[i]})
+ }
+ if host.busy || len(host.next) != 0 {
+ t.Fatal("transfer queue did not drain")
+ }
+}
+
+func TestPersistentClipboardLiveEnableResizeSelectionDisableAndClear(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "private", "clipboard.json")
+ host, _, _, _ := clipboard_fixture()
+ host.historyStore = clipboard.New_persistence(path, false)
+ host.settings.Sync = false
+ host.add("old", true)
+ host.add("new", true)
+ settings := host.settings
+ settings.Persistent = true
+ host.configure(settings)
+ host.command(command.NextClipboard)
+ if err := host.historyStore.Close(); err != nil {
+ t.Fatal(err)
+ }
+ store := clipboard.New_persistence(path, true)
+ if store.LoadErr != nil || store.Initial.Selected != 1 || !reflect.DeepEqual(store.Initial.Entries, []string{"new", "old"}) {
+ t.Fatal(store.Initial, store.LoadErr)
+ }
+ host.historyStore = store
+ settings.NumberOfClipboards = 1
+ host.configure(settings)
+ if got, _ := host.history.Current(); got != "new" {
+ t.Fatal(got)
+ }
+ settings.Persistent = false
+ host.configure(settings)
+ host.add("memory only", true)
+ if err := store.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("disabling kept the saved file", err)
+ }
+ host.historyStore = clipboard.New_persistence(path, false)
+ settings.Persistent = true
+ host.configure(settings)
+ host.command(command.ClearClipboard)
+ if err := host.historyStore.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("clear kept the saved file", err)
+ }
+}
+
+func TestPersistentClipboardStartupRestoresClipsAndRetainsSelection(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "private", "tty.json")
+ store := clipboard.New_persistence(path, false)
+ store.Enable(clipboard.Snapshot{Version: 1, Entries: []string{"new", "middle", "old"}, Selected: 1}, 200)
+ if err := store.Close(); err != nil {
+ t.Fatal(err)
+ }
+ store = clipboard.New_persistence(path, true)
+ settings := config.Defaults()
+ settings.Clipboard.Persistent = true
+ settings.Clipboard.NumberOfClipboards = 2
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 1, Cells: []rune("x")}}
+ voice := &fakeVoice{}
+ keys := &fakeKeys{poll: func(_ bool, handle func(input.Decision)) error {
+ handle(input.Decision{Command: command.CurrentClipboard})
+ if voice.messages[len(voice.messages)-1] != "middle" {
+ t.Fatal("startup selection was lost", voice.messages)
+ }
+ handle(input.Decision{Command: command.Quit})
+ return nil
+ }}
+ if err := run(context.Background(), source, keys, voice, nil, RunOptions{Settings: settings, ClipboardHistory: store}); err != nil {
+ t.Fatal(err)
+ }
+ if err := store.Close(); err != nil {
+ t.Fatal(err)
+ }
+ store = clipboard.New_persistence(path, true)
+ if store.LoadErr != nil || !reflect.DeepEqual(store.Initial.Entries, []string{"new", "middle"}) || store.Initial.Selected != 1 {
+ t.Fatal("startup limit not applied on disk", store.Initial, store.LoadErr)
+ }
+ store.Close()
+}
+
+func apply_shared_history(t *testing.T, host *clipboardHost) {
+ t.Helper()
+ deadline := time.NewTimer(3 * time.Second)
+ defer deadline.Stop()
+ for {
+ select {
+ case update := <-host.historyStore.Updates():
+ host.history_update(update)
+ if update.Sequence == host.historyStore.Sequence() {
+ return
+ }
+ case err := <-host.historyStore.Errors():
+ t.Fatal(err)
+ case <-deadline.C:
+ t.Fatal("shared clipboard update did not arrive")
+ }
+ }
+}
+
+func TestSharedClipboardHostsKeepSelectionLocalAndRefreshSilently(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "private", "clipboard.json")
+ first, _, firstMessages, _ := clipboard_fixture()
+ second, _, secondMessages, _ := clipboard_fixture()
+ for _, host := range []*clipboardHost{first, second} {
+ host.settings.Persistent = true
+ host.settings.Sync = false
+ host.historyStore = clipboard.New_persistence(path, true)
+ t.Cleanup(func() { host.historyStore.Close() })
+ }
+ first.add("older", true)
+ apply_shared_history(t, first)
+ apply_shared_history(t, second)
+ first.add("newer", true)
+ apply_shared_history(t, first)
+ apply_shared_history(t, second)
+ first.command(command.NextClipboard)
+ apply_shared_history(t, first)
+ second.add("peer", true)
+ apply_shared_history(t, second)
+ apply_shared_history(t, first)
+ if got, _ := first.history.Current(); got != "older" {
+ t.Fatal("peer copy changed local selection", got)
+ }
+ if got, _ := second.history.Current(); got != "peer" {
+ t.Fatal("local copy did not remain selected", got)
+ }
+ if !reflect.DeepEqual(first.history.Snapshot().Entries, []string{"peer", "newer", "older"}) {
+ t.Fatal(first.history.Snapshot())
+ }
+ beforeFirst, beforeSecond := len(*firstMessages), len(*secondMessages)
+ second.command(command.ClearClipboard)
+ apply_shared_history(t, second)
+ apply_shared_history(t, first)
+ if _, ok := first.history.Current(); ok {
+ t.Fatal("peer Clear kept local history")
+ }
+ if len(*firstMessages) != beforeFirst || len(*secondMessages) != beforeSecond+1 {
+ t.Fatal("refresh announced unsolicited feedback", *firstMessages, *secondMessages)
+ }
+ // Pending local copies cannot be overwritten by an earlier refresh.
+ first.add("pending", true)
+ first.history_update(clipboard.HistoryUpdate{State: clipboard.Snapshot{Version: 1}, Enabled: true})
+ if got, _ := first.history.Current(); got != "pending" {
+ t.Fatal("stale refresh replaced pending copy", got)
+ }
+ apply_shared_history(t, first)
+}
+
+func TestDisabledClipboardClearDoesNotEnablePersistence(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "private", "clipboard.json")
+ host, _, _, _ := clipboard_fixture()
+ host.historyStore = clipboard.New_persistence(path, false)
+ host.command(command.ClearClipboard)
+ if err := host.historyStore.Close(); err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case update := <-host.historyStore.Updates():
+ host.history_update(update)
+ default:
+ }
+ if host.settings.Persistent {
+ t.Fatal("memory-only Clear enabled persistence")
+ }
+ if _, err := os.Stat(filepath.Dir(path)); !os.IsNotExist(err) {
+ t.Fatal("memory-only Clear created storage", err)
+ }
+}
diff --git a/internal/app/commands.go b/internal/app/commands.go
new file mode 100644
index 00000000..a1756e2b
--- /dev/null
+++ b/internal/app/commands.go
@@ -0,0 +1,172 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package app
+
+import (
+ "log/slog"
+ "strings"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/datetime"
+ "git.stormux.org/storm/fenrir/internal/diagnostic"
+ "git.stormux.org/storm/fenrir/internal/i18n"
+ "git.stormux.org/storm/fenrir/internal/input"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "git.stormux.org/storm/fenrir/internal/scripts"
+ "git.stormux.org/storm/fenrir/internal/version"
+)
+
+func (a *application) handle_input(decision input.Decision) {
+ started := time.Now()
+ defer func() { diagnostic.Timing("input action", started, "command", decision.Command) }()
+ if a.quitting {
+ return
+ }
+ diagnostic.Record("input decision", "command", decision.Command, "forward", decision.Forward,
+ "interrupt", decision.Interrupt, "keypress", decision.Keypress, "typing", decision.Typing, "deletion", decision.Deletion,
+ "ui_active", a.ui.active)
+ if decision.Interrupt {
+ a.spellFeedback++
+ a.engine.Reset_progress()
+ if a.cues != nil {
+ a.cues.Clear_progress()
+ }
+ a.clipboards.feedback++
+ a.voice.Interrupt()
+ a.engine.Note_input(decision.Forward && !a.ui.active, decision.Typing, decision.WordBoundary)
+ if decision.Forward && decision.Deletion && !a.ui.active {
+ a.engine.Note_deletion()
+ }
+ }
+ if decision.Keypress && a.temporarySilence {
+ a.temporarySilence = false
+ a.present(i18n.T("speech enabled"), presentation.SpeechOn)
+ }
+ if a.ui.active {
+ if a.ui.tutorial != nil {
+ if decision.Learn != nil {
+ if !decision.Interrupt {
+ a.voice.Interrupt()
+ }
+ a.ui.handle_tutorial(*decision.Learn)
+ }
+ } else if decision.UI != nil {
+ a.ui.handle(*decision.UI)
+ }
+ return
+ }
+ a.handle_command(decision.Command)
+}
+
+func (a *application) handle_command(id command.ID) {
+ switch id {
+ case "":
+ case command.Help:
+ a.ui.open_tutorial(a.options.Bindings, a.options.Scripts, a.settings.live.General.ScriptKeys)
+ case command.Version:
+ a.present(i18n.Format("Fenrir screen reader version {0}-{1}", version.Build, version.Channel), "")
+ case command.Time:
+ text, _ := datetime.Format_time(a.settings.live.Presentation.DateTime.TimeFormat, time.Now())
+ a.present(text, "")
+ case command.Date:
+ text, _ := datetime.Format_date(a.settings.live.Presentation.DateTime.DateFormat, time.Now())
+ a.present(text, "")
+ case command.Quit:
+ if a.saver.busy {
+ a.quitAfterSave = true
+ } else {
+ a.quitting = true
+ }
+ case command.EmojiMenu:
+ a.ui.open_emoji(a.clipboards)
+ case command.SpellcheckMenu:
+ a.spellMenu.open(a.engine.Spellcheck_word())
+ case command.FenrirMenu:
+ a.open_settings()
+ case command.SaveSettings:
+ a.save_settings()
+ case command.ProgressMonitor:
+ a.quick.mark_changed("presentation.progress.enabled")
+ a.settings.live.Presentation.Progress.Enabled = !a.settings.live.Presentation.Progress.Enabled
+ a.settings.saveDraft.Presentation.Progress = a.settings.live.Presentation.Progress
+ a.engine.Configure_progress(a.settings.live.Presentation.Progress.Enabled)
+ if a.settings.live.Presentation.Progress.Enabled {
+ a.voice.Say(i18n.T("Progress monitoring enabled"))
+ } else {
+ a.voice.Say(i18n.T("Progress monitoring disabled"))
+ }
+ case command.Mute:
+ // This toggle lasts only for this session, including when settings
+ // are saved. Speech enablement belongs to the settings menu/file.
+ a.muted = a.voice.Toggle_mute()
+ a.present_speech_state()
+ case command.TempDisableSpeech:
+ if !a.muted && !a.temporarySilence {
+ a.present(i18n.T("speech temporary disabled"), presentation.SpeechOff)
+ a.temporarySilence = true
+ }
+ case command.PassNext:
+ a.keyboards.Pass_next()
+ a.voice.Say(i18n.T("Forward next keypress"))
+ default:
+ if a.quick.command(id, &a.settings.live, a.voice) {
+ a.apply_quick_settings()
+ return
+ }
+ if script, exists := a.options.Scripts[id]; exists {
+ if a.options.ScriptRunner == nil || !a.options.ScriptRunner.Submit(scripts.Invocation{Script: script, Terminal: a.lastTerminal, Generation: a.scriptGeneration}) {
+ slog.Warn("script invocation queue full", "at", time.Now())
+ a.present(i18n.T("Script failed"), presentation.Error)
+ }
+ return
+ }
+ if a.clipboards.command(id) {
+ return
+ }
+ a.review_command(id)
+ }
+}
+
+func (a *application) review_command(id command.ID) {
+ regionRevision := a.engine.Region_revision()
+ result := a.engine.Command(id)
+ if regionRevision != a.engine.Region_revision() {
+ a.reading_regions_changed()
+ }
+ if message := result.Attributes; message != nil {
+ a.present_attributes(*message)
+ }
+ if message := result.Indentation; message != nil {
+ a.present_indentation(*message)
+ }
+ switch id {
+ case command.ToggleTracking:
+ a.settings.saveDraft.Reading.Mode = a.engine.Tracking_mode()
+ }
+ if spelling := result.Spelling; spelling != nil && !a.temporarySilence {
+ a.voice.say_spelling(*spelling)
+ } else if char := result.Character; char != nil && !a.temporarySilence {
+ a.voice.say_character(*char, strings.TrimPrefix(result.Text, presentation.Character(*char)))
+ } else {
+ a.present(result.Text, result.Cue)
+ }
+ if after := result.After.Text; after != "" {
+ a.present(after, result.After.Cue)
+ }
+}
diff --git a/internal/app/date_time_test.go b/internal/app/date_time_test.go
new file mode 100644
index 00000000..13db1cb6
--- /dev/null
+++ b/internal/app/date_time_test.go
@@ -0,0 +1,112 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package app
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/datetime"
+ "git.stormux.org/storm/fenrir/internal/input"
+ "git.stormux.org/storm/fenrir/internal/interaction"
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+func TestTimeDateMenuAppliesOnlyAfterSave(t *testing.T) {
+ for _, action := range []string{"save", "cancel", "failed save"} {
+ t.Run(action, func(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "settings.toml")
+ // Put this group first to exercise its controls directly.
+ original := "version=3\n[presentation.time_and_date]\ntime_format='24-hour-seconds'\ndate_format='numbers-ymd'\n"
+ if err := os.WriteFile(path, []byte(original), 0600); err != nil {
+ t.Fatal(err)
+ }
+ settings, err := config.Decode([]byte(original))
+ if err != nil {
+ t.Fatal(err)
+ }
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 1, Active: true}}
+ keys, voice := &fakeKeys{events: make(chan error, 4)}, &fakeVoice{}
+ keys.onCapture = func(enabled bool) {
+ if !enabled {
+ keys.events <- nil
+ }
+ }
+ voice.onSay = func(text string) {
+ if strings.HasPrefix(text, "Fenrir Menu.") || strings.HasPrefix(text, "Could not save settings:") {
+ keys.events <- nil
+ }
+ }
+ step := 0
+ keys.poll = func(_ bool, handle func(input.Decision)) error {
+ step++
+ key := func(name string, shift bool) { handle(input.Decision{UI: &interaction.Key{Name: name, Shift: shift}}) }
+ switch step {
+ case 1:
+ handle(input.Decision{Command: command.FenrirMenu})
+ case 2:
+ key("tab", false)
+ key("down", false) // 24-hour-seconds -> 24-hour-seconds-words.
+ key("tab", false)
+ key("down", false) // numbers-ymd -> full-dm.
+ if action == "cancel" {
+ key("escape", false)
+ break
+ }
+ if action == "failed save" {
+ if err := os.WriteFile(path, []byte(original+"# external edit\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ for range 4 {
+ key("tab", true)
+ }
+ key("enter", false)
+ case 3:
+ if action == "failed save" {
+ key("escape", false)
+ }
+ formats := settings.Presentation.DateTime
+ if action == "save" {
+ formats.TimeFormat, formats.DateFormat = "24-hour-seconds-words", "full-dm"
+ }
+ for _, id := range []command.ID{command.Time, command.Date} {
+ format, value := datetime.Format_time, formats.TimeFormat
+ if id == command.Date {
+ format, value = datetime.Format_date, formats.DateFormat
+ }
+ before, _ := format(value, time.Now())
+ handle(input.Decision{Command: id, Interrupt: true})
+ after, _ := format(value, time.Now())
+ got := voice.messages[len(voice.messages)-1]
+ if got != before && got != after {
+ t.Fatal("running format", id, got, before, after)
+ }
+ }
+ handle(input.Decision{Command: command.Quit})
+ }
+ return nil
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ if err := run(ctx, source, keys, voice, nil, RunOptions{Settings: settings, ConfigPath: path}); err != nil || step != 3 {
+ t.Fatal(err, step, voice.messages)
+ }
+ loaded, _, err := config.Load(path, nil)
+ want := settings.Presentation.DateTime
+ if action == "save" {
+ want.TimeFormat, want.DateFormat = "24-hour-seconds-words", "full-dm"
+ }
+ if err != nil || loaded.Presentation.DateTime != want {
+ t.Fatal("saved formats", loaded.Presentation.DateTime, err)
+ }
+ })
+ }
+}
diff --git a/internal/app/diagnostics_test.go b/internal/app/diagnostics_test.go
new file mode 100644
index 00000000..95cab1cb
--- /dev/null
+++ b/internal/app/diagnostics_test.go
@@ -0,0 +1,109 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package app
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "log/slog"
+ "strings"
+ "testing"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/input"
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+func TestPresentationDiagnosticsExplainSuppressionWithoutText(t *testing.T) {
+ for _, testCase := range []struct {
+ name string
+ action command.ID
+ muted bool
+ silent bool
+ }{
+ {name: "ordinary"},
+ {name: "muted", action: command.Mute, muted: true},
+ {name: "temporary silence", action: command.TempDisableSpeech, silent: true},
+ } {
+ t.Run(testCase.name, func(t *testing.T) {
+ var log bytes.Buffer
+ previousLogger := slog.Default()
+ slog.SetDefault(slog.New(slog.NewJSONHandler(&log, &slog.HandlerOptions{Level: slog.LevelDebug})))
+ defer slog.SetDefault(previousLogger)
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 7, Cells: []rune("PRIVATE")}, events: make(chan error, 1)}
+ keys := &fakeKeys{events: make(chan error, 1)}
+ reads, polls := 0, 0
+ source.afterRead = func() {
+ reads++
+ if reads == 2 {
+ keys.events <- nil
+ }
+ }
+ keys.poll = func(_ bool, handle func(input.Decision)) error {
+ polls++
+ if polls == 1 {
+ handle(input.Decision{Command: testCase.action, Interrupt: true, Keypress: true})
+ source.snapshot.Cells = []rune("SECRET!")
+ source.events <- nil
+ } else {
+ handle(input.Decision{Command: command.Quit})
+ }
+ return nil
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ if err := run(ctx, source, keys, &fakeVoice{}, nil); err != nil || reads != 2 || polls != 2 {
+ t.Fatal("fixture did not process the screen update", err, reads, polls)
+ }
+ if strings.Contains(log.String(), "PRIVATE") || strings.Contains(log.String(), "SECRET") {
+ t.Fatal("diagnostics exposed screen or speech text")
+ }
+ var presentation struct {
+ Msg string
+ Messages int
+ Muted bool
+ TemporarySilence bool `json:"temporary_silence"`
+ UIActive bool `json:"ui_active"`
+ }
+ inputLogged := false
+ for _, line := range bytes.Split(bytes.TrimSpace(log.Bytes()), []byte("\n")) {
+ var record struct {
+ Msg string
+ Interrupt bool
+ Keypress bool
+ }
+ if err := json.Unmarshal(line, &record); err != nil {
+ t.Fatal(err)
+ }
+ if record.Msg == "reader presentation" {
+ if err := json.Unmarshal(line, &presentation); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if record.Msg == "input decision" && record.Interrupt && record.Keypress {
+ inputLogged = true
+ }
+ }
+ if !inputLogged || presentation.Messages != 1 || presentation.Muted != testCase.muted || presentation.TemporarySilence != testCase.silent || presentation.UIActive {
+ t.Fatal("missing or incorrect input/presentation state", inputLogged, presentation)
+ }
+ })
+ }
+}
diff --git a/internal/app/emoji_menu.go b/internal/app/emoji_menu.go
new file mode 100644
index 00000000..f2393686
--- /dev/null
+++ b/internal/app/emoji_menu.go
@@ -0,0 +1,37 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package app
+
+import (
+ "git.stormux.org/storm/fenrir/internal/emoji"
+ "git.stormux.org/storm/fenrir/internal/i18n"
+ "git.stormux.org/storm/fenrir/internal/interaction"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+)
+
+func (ui *internalUI) open_emoji(clipboards *clipboardHost) {
+ session, err := emoji.New_session()
+ if err != nil {
+ ui.failed("Could not open Emoji Menu: {0}", err)
+ return
+ }
+ if _, err := ui.begin(emoji.MenuTitle); err != nil {
+ ui.failed("Could not open Emoji Menu: {0}", err)
+ return
+ }
+ ui.session = session
+ ui.action = func(action string, session *interaction.Session) {
+ if action != "copy_emoji" {
+ return
+ }
+ value, label, ok := emoji.Selection(session)
+ if !ok || !clipboards.add(value, true) {
+ return
+ }
+ ui.close()
+ clipboards.present(i18n.Format("Added {0} to clipboard", i18n.T(label)), presentation.CopyToClipboard)
+ }
+ ui.voice.Say(session.Open())
+}
diff --git a/internal/app/emoji_menu_test.go b/internal/app/emoji_menu_test.go
new file mode 100644
index 00000000..07e4c773
--- /dev/null
+++ b/internal/app/emoji_menu_test.go
@@ -0,0 +1,79 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package app
+
+import (
+ "context"
+ "errors"
+ "reflect"
+ "testing"
+
+ "git.stormux.org/storm/fenrir/internal/clipboard"
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/interaction"
+)
+
+func TestEmojiMenuCancellationCopyAndStaleClipboardSync(t *testing.T) {
+ for _, query := range []string{"Jack", "Black cat", "United States", "Coffin"} {
+ t.Run(query, func(t *testing.T) {
+ host, worker, _, _ := clipboard_fixture()
+ host.add("original", false)
+ host.poll()
+ stale := worker.requests[0]
+ keys, voice := &fakeKeys{}, &fakeVoice{}
+ ui := new_internal_ui(context.Background(), keys, voice)
+ ui.open_emoji(host)
+ if voice.messages[0] != "Emoji Menu. All tab" {
+ t.Fatal(voice.messages)
+ }
+ ui.handle(interaction.Key{Name: "tab"})
+ ui.handle(interaction.Key{Text: query})
+ ui.handle(interaction.Key{Name: "escape"})
+ if got, _ := host.history.Current(); got != "original" || host.revision != stale.Revision {
+ t.Fatal("cancel changed clipboard", got)
+ }
+ ui.open_emoji(host)
+ ui.handle(interaction.Key{Name: "tab"})
+ ui.handle(interaction.Key{Text: query + "zzzz"})
+ ui.handle(interaction.Key{Name: "enter"})
+ if !ui.active {
+ t.Fatal("empty results closed menu")
+ }
+ ui.handle(interaction.Key{Name: "backspace", Ctrl: true})
+ ui.handle(interaction.Key{Text: query})
+ ui.handle(interaction.Key{Name: "enter"})
+ want := map[string]string{"Jack": "š", "Black cat": "šāā¬", "United States": "šŗšø", "Coffin": "ā°ļø"}[query]
+ if got, _ := host.history.Current(); got != want || ui.active || !host.dirty {
+ t.Fatal("selection not copied", got, ui.active, host.dirty)
+ }
+ host.result(clipboard.Result{Request: stale, Text: "stale X clipboard"})
+ if got, _ := host.history.Current(); got != want {
+ t.Fatal("stale import replaced emoji", got)
+ }
+ host.poll()
+ request := worker.requests[len(worker.requests)-1]
+ if request.Command != command.ExportXClipboard || request.Text != want {
+ t.Fatal("emoji not offered to sync", request)
+ }
+ host.history.Move(1)
+ if got, _ := host.history.Current(); got != "original" {
+ t.Fatal("history lost", got)
+ }
+ if !reflect.DeepEqual(keys.captures, []bool{true, false, true, false}) {
+ t.Fatal(keys.captures)
+ }
+ })
+ }
+}
+
+func TestEmojiMenuCaptureFailureDoesNotChangeClipboard(t *testing.T) {
+ host, _, _, _ := clipboard_fixture()
+ keys, voice := &fakeKeys{captureErr: errors.New("capture refused")}, &fakeVoice{}
+ ui := new_internal_ui(context.Background(), keys, voice)
+ ui.open_emoji(host)
+ if ui.active || host.revision != 0 || len(voice.messages) != 1 {
+ t.Fatal(ui.active, host.revision, voice.messages)
+ }
+}
diff --git a/internal/app/event_loop.go b/internal/app/event_loop.go
new file mode 100644
index 00000000..562c7142
--- /dev/null
+++ b/internal/app/event_loop.go
@@ -0,0 +1,180 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package app
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/clipboard"
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/control"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "git.stormux.org/storm/fenrir/internal/scripts"
+ "git.stormux.org/storm/fenrir/internal/spellcheck"
+ "git.stormux.org/storm/fenrir/internal/terminal"
+)
+
+// Run owns all screen/review/input state on one event loop. Speech network
+// work happens in Speaker's workers. A fatal I/O error always releases input.
+func Run(ctx context.Context, source Source, keyboards Keyboards, voices Speaker, options ...RunOptions) (result error) {
+ tick := time.NewTicker(10 * time.Millisecond)
+ defer tick.Stop()
+ return run(ctx, source, keyboards, voices, tick.C, options...)
+}
+
+func run(ctx context.Context, source Source, keyboards Keyboards, voices Speaker, ticks <-chan time.Time, options ...RunOptions) (result error) {
+ defer func() {
+ if errors.Is(result, terminal.ErrClosed) {
+ result = nil
+ }
+ result = errors.Join(result, keyboards.Close())
+ }()
+ opts := RunOptions{Settings: config.Defaults()}
+ if len(options) > 0 {
+ opts = options[0]
+ }
+ a, err := new_application(ctx, source, keyboards, voices, opts)
+ if err != nil {
+ return err
+ }
+ defer func() { a.ui.close(); a.ui.testWorkers.Wait() }()
+ return a.loop(ticks)
+}
+
+func (a *application) loop(ticks <-chan time.Time) error {
+ var scriptResults <-chan scripts.Result
+ if a.options.ScriptRunner != nil {
+ scriptResults = a.options.ScriptRunner.Results()
+ }
+ var remoteRequests <-chan control.Request
+ if a.options.Remote != nil {
+ remoteRequests = a.options.Remote.Requests()
+ }
+ var spellResults <-chan spellcheck.Result
+ if a.options.Spellcheck != nil {
+ spellResults = a.options.Spellcheck.Results()
+ }
+ var clipboardResults <-chan clipboard.Result
+ if a.options.Clipboard != nil {
+ clipboardResults = a.options.Clipboard.Results()
+ }
+ var historyErrors <-chan error
+ var historyUpdates <-chan clipboard.HistoryUpdate
+ if a.options.ClipboardHistory != nil {
+ historyErrors = a.options.ClipboardHistory.Errors()
+ historyUpdates = a.options.ClipboardHistory.Updates()
+ }
+ var soundErrors <-chan error
+ if a.cues != nil {
+ soundErrors = a.cues.Errors()
+ }
+ clipboardTick := time.NewTicker(500 * time.Millisecond)
+ defer clipboardTick.Stop()
+ if err := a.read_screen(); err != nil {
+ return err
+ }
+ scan := time.NewTicker(time.Second)
+ defer scan.Stop()
+ if err := a.poll_input(true); err != nil {
+ return err
+ }
+ for {
+ if a.quitting {
+ return nil
+ }
+ select {
+ case discovery := <-a.speechChoices:
+ a.quick.discovery = discovery
+ case result := <-a.speechResults:
+ a.speech_result(result)
+ case result := <-spellResults:
+ if err := a.poll_input(false); err != nil {
+ return err
+ }
+ a.spellcheck_result(result)
+ case err := <-a.saver.results:
+ a.settings_save_result(err)
+ a.quitting = a.quitAfterSave
+ case result := <-scriptResults:
+ if err := a.poll_input(false); err != nil {
+ return err
+ }
+ a.script_result(result)
+ case request := <-remoteRequests:
+ if err := a.poll_input(false); err != nil {
+ return err
+ }
+ a.remote_request(request)
+ case err := <-historyErrors:
+ slog.Warn("clipboard history update failed", "error", err, "at", time.Now())
+ a.present(err.Error(), presentation.Error)
+ case update := <-historyUpdates:
+ a.clipboards.history_update(update)
+ a.settings.live.Clipboard.Persistent = a.clipboards.settings.Persistent
+ a.settings.saveDraft.Clipboard.Persistent = a.clipboards.settings.Persistent
+ case result := <-clipboardResults:
+ if err := a.poll_input(false); err != nil {
+ return err
+ }
+ a.clipboards.result(result)
+ case <-clipboardTick.C:
+ a.clipboards.poll()
+ case update := <-a.ui.updates:
+ if err := a.poll_input(false); err != nil {
+ return err
+ }
+ a.ui.update(update)
+ case <-a.ctx.Done():
+ return nil
+ case err := <-a.voice.Errors():
+ return fmt.Errorf("speech connection failed: %w", err)
+ case err := <-soundErrors:
+ slog.Warn("sound disabled", "error", err, "at", time.Now())
+ soundErrors, a.cues = nil, nil
+ case err := <-a.source.Events():
+ if err := a.screen_event(err); err != nil {
+ return err
+ }
+ case err := <-a.keyboards.Events():
+ if err != nil {
+ return fmt.Errorf("keyboard watcher failed: %w", err)
+ }
+ if err := a.poll_input(true); err != nil {
+ return err
+ }
+ case <-scan.C:
+ if err := a.keyboards.Scan(); err != nil {
+ return err
+ }
+ // Acquire newly discovered idle keyboards before their first key.
+ if err := a.poll_input(true); err != nil {
+ return err
+ }
+ case <-ticks:
+ if err := a.poll_input(false); err != nil {
+ return err
+ }
+ a.play_progress()
+ a.spellMenu.poll()
+ }
+ }
+}
diff --git a/internal/app/feedback.go b/internal/app/feedback.go
new file mode 100644
index 00000000..bd41d129
--- /dev/null
+++ b/internal/app/feedback.go
@@ -0,0 +1,92 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package app
+
+import (
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/diagnostic"
+ "git.stormux.org/storm/fenrir/internal/i18n"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+)
+
+func (a *application) play(cue presentation.Cue) bool {
+ if a.settings.live.Presentation.Sound.Enabled && a.cues != nil && cue != "" {
+ return a.cues.Play(cue)
+ }
+ return false
+}
+
+func (a *application) present(text string, cue presentation.Cue) {
+ if !a.play(cue) && !a.temporarySilence {
+ a.voice.Say(text)
+ }
+}
+
+func (a *application) present_speech_state() {
+ if a.muted {
+ a.play(presentation.SpeechOff)
+ } else {
+ a.present(i18n.T("speech enabled"), presentation.SpeechOn)
+ }
+}
+
+func (a *application) present_indentation(message presentation.Message) {
+ if a.temporarySilence || a.muted {
+ return
+ }
+ accepted := false
+ if message.Indentation != nil && a.settings.live.Presentation.Sound.Enabled && a.settings.live.Presentation.Sound.Volume > 0 && a.cues != nil {
+ accepted = a.cues.Play_indentation(message.Indentation.Level)
+ }
+ if !accepted || message.Indentation.Speech {
+ a.voice.Say(message.Text)
+ }
+}
+
+func (a *application) present_attributes(message presentation.Message) {
+ if a.temporarySilence || a.muted {
+ return
+ }
+ accepted := message.Cue != "" && a.settings.live.Presentation.Sound.Volume > 0 && a.play(message.Cue)
+ if !accepted || message.Attributes.Speech {
+ a.voice.Say(message.Text)
+ }
+}
+
+func (a *application) play_progress() {
+ if a.ui.active {
+ a.engine.Reset_progress()
+ }
+ if a.cues != nil {
+ if a.ui.active {
+ a.cues.Clear_progress()
+ } else if !a.engine.Progress_active() {
+ // A passive redraw can reset detection halfway through a beep.
+ // Discard stale waiting tones, but let the current one finish.
+ a.cues.Discard_progress()
+ }
+ }
+ if !a.ui.active && a.settings.live.Presentation.Sound.Enabled && a.cues != nil {
+ if value, ok := a.engine.Next_progress(time.Now()); ok {
+ if a.cues.Play_progress(value) {
+ diagnostic.Record("progress tone", "percentage", value)
+ }
+ }
+ }
+}
diff --git a/internal/app/fenrir_menu.go b/internal/app/fenrir_menu.go
new file mode 100644
index 00000000..5ceff3e9
--- /dev/null
+++ b/internal/app/fenrir_menu.go
@@ -0,0 +1,193 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package app
+
+import (
+ "context"
+ "errors"
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/i18n"
+ "git.stormux.org/storm/fenrir/internal/interaction"
+ "git.stormux.org/storm/fenrir/internal/preferences"
+)
+
+func (ui *internalUI) open_settings(settings config.Settings, path string, liveQuick ...config.Settings) {
+ generation, err := ui.begin(preferences.MenuTitle)
+ if err != nil {
+ ui.failed("Could not open Fenrir Menu: {0}", err)
+ return
+ }
+ if ui.previewSpelling != nil {
+ ui.onClosed = func() { ui.previewSpelling(settings.Reading) }
+ ui.changed = func(session *interaction.Session) {
+ values := session.Values()
+ draft := settings.Reading
+ draft.Spellcheck = values["reading.spellcheck"].Checked
+ draft.SpellcheckReadWord = values["reading.spellcheck_read_word"].Checked
+ draft.SpellcheckSayMisspelled = values["reading.spellcheck_say_misspelled"].Checked
+ draft.SpellcheckSound = values["reading.spellcheck_sound"].Checked
+ ui.previewSpelling(draft)
+ }
+ }
+ previewChanged := ui.changed
+ speechOverride := ui.speechOverride
+ go func() {
+ doc, err := config.Open_document(path, settings)
+ if err != nil {
+ ui.post(uiUpdate{generation: generation, close: true, speech: i18n.Format("Could not open Fenrir Menu: {0}", err.Error())})
+ return
+ }
+ if len(liveQuick) != 0 {
+ copy_quick_settings(&doc.Settings, liveQuick[0])
+ }
+ if speechOverride != nil && speechOverride.Driver == doc.Settings.Presentation.Speech.Driver {
+ copy_speech_selection(&doc.Settings.Presentation.Speech, *speechOverride)
+ }
+ var discovery preferences.Discovery
+ if ui.discoverChoices != nil {
+ discovery = ui.discoverChoices(doc.Settings)
+ }
+ session, err := preferences.New_session(doc.Settings, doc.Order, discovery.Choices(doc.Settings))
+ if err != nil {
+ ui.post(uiUpdate{generation: generation, close: true, speech: i18n.Format("Could not open Fenrir Menu: {0}", err.Error())})
+ return
+ }
+ previous := doc.Settings
+ catalogs := map[string]preferences.Discovery{previous.Presentation.Speech.Driver: discovery}
+ pending := map[string]bool{}
+ var tested config.Speech
+ var testSequence uint64
+ changed := func(session *interaction.Session) {
+ if previewChanged != nil {
+ previewChanged(session)
+ }
+ if ui.cancelTest != nil {
+ draft, err := preferences.Settings(session, doc.Settings)
+ if err != nil || draft.Presentation.Speech != tested {
+ ui.stop_test()
+ }
+ }
+ if !discovery.Refresh_speech(session, &previous) {
+ return
+ }
+ driverID := previous.Presentation.Speech.Driver
+ if cached, ok := catalogs[driverID]; ok {
+ discovery = cached
+ discovery.Refresh_catalog(session, previous)
+ return
+ }
+ discovery = preferences.Discovery{Driver: driverID}
+ // Leave hidden selections alone. An uncached backend starts with
+ // the existing default/saved choices until its worker returns.
+ if pending[driverID] || ui.discoverChoices == nil {
+ return
+ }
+ pending[driverID] = true
+ discover := ui.discoverChoices
+ go func(selected config.Settings) {
+ result := discover(selected)
+ ui.post(uiUpdate{generation: generation, apply: func() {
+ if ui.session != session {
+ return
+ }
+ delete(pending, driverID)
+ catalogs[driverID] = result
+ if previous.Presentation.Speech.Driver != driverID {
+ return
+ }
+ discovery = result
+ discovery.Refresh_catalog(session, previous)
+ if ui.catalog != nil {
+ ui.catalog(result)
+ }
+ }})
+ }(previous)
+ }
+ var action func(string, *interaction.Session)
+ action = func(name string, session *interaction.Session) {
+ if name == "test-synthesizer" {
+ draft, err := preferences.Settings(session, doc.Settings)
+ if err != nil {
+ ui.test_failed(err)
+ return
+ }
+ ui.stop_test()
+ testSequence++
+ sequence := testSequence
+ testCtx, cancel := context.WithCancel(ui.ctx)
+ ui.cancelTest = cancel
+ tested = draft.Presentation.Speech
+ previousDone := ui.testDone
+ done := make(chan struct{})
+ ui.testDone = done
+ runTest := ui.testSpeech
+ ui.voice.Say(i18n.T("Testing voice"))
+ ui.testWorkers.Add(1)
+ go func(selected config.Speech) {
+ defer ui.testWorkers.Done()
+ defer close(done)
+ defer cancel()
+ if previousDone != nil {
+ select {
+ case <-previousDone:
+ case <-testCtx.Done():
+ return
+ }
+ }
+ err := runTest(testCtx, selected, settings.Presentation.Speech)
+ update := uiUpdate{generation: generation, apply: func() {
+ if ui.session != session || sequence != testSequence {
+ return
+ }
+ ui.cancelTest = nil
+ if err != nil && !errors.Is(err, context.Canceled) {
+ ui.test_failed(err)
+ }
+ }}
+ select {
+ case ui.updates <- update:
+ case <-testCtx.Done():
+ }
+ }(tested)
+ return
+ }
+ if name != "save" {
+ return
+ }
+ draft, err := preferences.Settings(session, doc.Settings)
+ if err != nil {
+ ui.failed("Could not save settings: {0}", err)
+ return
+ }
+ ui.stop_test()
+ if ui.saveSettings(doc, draft, func(err error) {
+ if err != nil {
+ ui.update(uiUpdate{generation: generation, session: session, action: action, speech: i18n.Format("Could not save settings: {0}", err.Error())})
+ return
+ }
+ if ui.active && ui.generation == generation {
+ ui.onClosed = nil // Successful save commits the form preview.
+ }
+ ui.update(uiUpdate{generation: generation, close: true})
+ }) {
+ ui.session = nil
+ }
+ }
+ ui.post(uiUpdate{generation: generation, session: session, action: action, speech: session.Open(), changed: changed, discovery: &discovery})
+ }()
+}
diff --git a/internal/app/hardware_form_test.go b/internal/app/hardware_form_test.go
new file mode 100644
index 00000000..22f764cc
--- /dev/null
+++ b/internal/app/hardware_form_test.go
@@ -0,0 +1,212 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package app
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/interaction"
+ "git.stormux.org/storm/fenrir/internal/preferences"
+ "git.stormux.org/storm/fenrir/internal/speech"
+)
+
+func focus_ui_control(t *testing.T, ui *internalUI, id string) {
+ t.Helper()
+ for range 100 {
+ if control, ok := ui.session.Focused_control(); ok && control.ID == id {
+ return
+ }
+ ui.handle(interaction.Key{Name: "tab"})
+ }
+ t.Fatal("control not reachable", id)
+}
+
+func hardware_form_application(t *testing.T) (*application, string, *fakeVoice) {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), "settings.toml")
+ if err := config.Save(path, config.Defaults()); err != nil {
+ t.Fatal(err)
+ }
+ voice := &fakeVoice{}
+ a := settings_ui_application(t, &fakeKeys{}, voice, path)
+ a.ui.open_settings(a.settings.live, path)
+ receive_ui(t, a.ui)
+ t.Cleanup(func() { a.ui.close(); a.ui.testWorkers.Wait() })
+ focus_ui_control(t, a.ui, "speech.driver")
+ a.ui.handle(interaction.Key{Name: "down"})
+ focus_ui_control(t, a.ui, "speech.hardware_device")
+ a.ui.handle(interaction.Key{Text: "/dev/fixture-synth"})
+ focus_ui_control(t, a.ui, "speech.hardware_baud_rate")
+ a.ui.handle(interaction.Key{Name: "end"})
+ return a, path, voice
+}
+
+func TestHardwarePreviewUsesUnsavedDraftAndSaveKeepsActiveDriver(t *testing.T) {
+ a, path, _ := hardware_form_application(t)
+ before, _ := os.ReadFile(path)
+ requested, stopped := make(chan config.Speech, 2), make(chan struct{}, 2)
+ a.ui.testSpeech = func(ctx context.Context, selected, current config.Speech) error {
+ if current.Driver != "speech-dispatcher" {
+ return errors.New("wrong active driver")
+ }
+ requested <- selected
+ <-ctx.Done()
+ stopped <- struct{}{}
+ return ctx.Err()
+ }
+ focus_ui_control(t, a.ui, "speech.test")
+ a.ui.handle(interaction.Key{Name: "enter"})
+ select {
+ case selected := <-requested:
+ if selected.Driver != "dectalk" || selected.HardwareDevice != "/dev/fixture-synth" || selected.HardwareBaudRate != 115200 {
+ t.Fatal(selected)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("preview did not start")
+ }
+ if after, _ := os.ReadFile(path); !reflect.DeepEqual(after, before) {
+ t.Fatal("test saved settings")
+ }
+ if a.settings.live.Presentation.Speech.Driver != "speech-dispatcher" || !a.ui.active {
+ t.Fatal("test replaced active driver or closed menu")
+ }
+ focus_ui_control(t, a.ui, "speech.hardware_device")
+ a.ui.handle(interaction.Key{Ctrl: true, Text: "a"})
+ a.ui.handle(interaction.Key{Text: "/dev/fixture-updated"})
+ select {
+ case <-stopped:
+ case <-time.After(time.Second):
+ t.Fatal("editing did not cancel preview")
+ }
+ focus_ui_control(t, a.ui, "ok")
+ a.ui.handle(interaction.Key{Name: "enter"})
+ finish_persistence(t, a, false)
+ saved, _, err := config.Load(path, nil)
+ if err != nil || saved.Presentation.Speech.HardwareDevice != "/dev/fixture-updated" || saved.Presentation.Speech.HardwareBaudRate != 115200 {
+ t.Fatal(saved, err)
+ }
+ if a.settings.live.Presentation.Speech.Driver != "speech-dispatcher" {
+ t.Fatal("save replaced active driver")
+ }
+}
+
+func TestHardwarePreviewFailureAndCloseDoNotWriteOrDeliverStaleFeedback(t *testing.T) {
+ a, path, voice := hardware_form_application(t)
+ before, _ := os.ReadFile(path)
+ a.ui.testSpeech = func(context.Context, config.Speech, config.Speech) error {
+ return errors.New("fixture port unavailable")
+ }
+ focus_ui_control(t, a.ui, "speech.test")
+ a.ui.handle(interaction.Key{Name: "enter"})
+ receive_ui(t, a.ui)
+ if got := voice.messages[len(voice.messages)-1]; !strings.Contains(got, "Voice test failed") || !strings.Contains(got, "fixture port unavailable") {
+ t.Fatal(got)
+ }
+ started, release := make(chan struct{}), make(chan struct{})
+ a.ui.testSpeech = func(ctx context.Context, _, _ config.Speech) error {
+ close(started)
+ <-ctx.Done()
+ <-release
+ return errors.New("stale fixture failure")
+ }
+ a.ui.handle(interaction.Key{Name: "enter"})
+ select {
+ case <-started:
+ case <-time.After(time.Second):
+ t.Fatal("preview not started")
+ }
+ a.ui.close()
+ close(release)
+ a.ui.testWorkers.Wait()
+ for len(a.ui.updates) > 0 {
+ receive_ui(t, a.ui)
+ }
+ if strings.Contains(strings.Join(voice.messages, "|"), "stale fixture failure") {
+ t.Fatal("late failure escaped closed menu", voice.messages)
+ }
+ if after, _ := os.ReadFile(path); !reflect.DeepEqual(before, after) {
+ t.Fatal("failed/canceled test wrote settings")
+ }
+}
+
+type previewVoice struct {
+ fakeVoice
+ previews chan speech.Parameters
+}
+
+func (v *previewVoice) Say_with_parameters(_ string, parameters speech.Parameters) {
+ v.previews <- parameters
+}
+
+func TestHardwarePreviewReusesOwnedConnectionAndNormalizesDefaultBaud(t *testing.T) {
+ voice := &previewVoice{previews: make(chan speech.Parameters, 1)}
+ ui := new_internal_ui(context.Background(), &fakeKeys{}, &echoSpeaker{Speaker: voice})
+ current := config.Defaults().Presentation.Speech
+ current.Driver, current.HardwareDevice = "dectalk", "/dev/fixture-owned"
+ selected := current
+ selected.HardwareBaudRate, selected.Rate = 9600, 20
+ if err := ui.test_synthesizer(context.Background(), selected, current); err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case parameters := <-voice.previews:
+ if parameters.Rate != 20 {
+ t.Fatal(parameters)
+ }
+ default:
+ t.Fatal("owned connection was not used")
+ }
+ if len(voice.parameters) != 0 {
+ t.Fatal("preview changed persistent live parameters")
+ }
+}
+
+func TestDriverCatalogArrivesWithoutOverwritingAnotherDriverOrMovingFocus(t *testing.T) {
+ a, _, _ := hardware_form_application(t)
+ queries, releases := make(chan string, 4), make(chan struct{}, 4)
+ a.ui.discoverChoices = func(settings config.Settings) preferences.Discovery {
+ id := settings.Presentation.Speech.Driver
+ queries <- id
+ <-releases
+ return preferences.Discovery{Driver: id, Catalog: speech.Catalog{Modules: []string{"fixture-module"}}}
+ }
+ // Reopen so the first catalog belongs to hardware, then discover Speech
+ // Dispatcher only when selected. No connection is opened on the input loop.
+ a.ui.close()
+ base := a.settings.live
+ base.Presentation.Speech.Driver, base.Presentation.Speech.HardwareDevice = "dectalk", "/dev/fixture-synth"
+ go func() { releases <- struct{}{} }()
+ a.ui.open_settings(base, "")
+ receive_ui(t, a.ui)
+ <-queries
+ focus_ui_control(t, a.ui, "speech.driver")
+ a.ui.handle(interaction.Key{Name: "home"})
+ select {
+ case id := <-queries:
+ if id != "speech-dispatcher" {
+ t.Fatal(id)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("new driver catalog not requested")
+ }
+ a.ui.handle(interaction.Key{Name: "down"})
+ releases <- struct{}{}
+ receive_ui(t, a.ui)
+ if !a.ui.session.Values()["presentation.speech.module"].Hidden {
+ t.Fatal("stale catalog changed driver visibility")
+ }
+ a.ui.handle(interaction.Key{Name: "home"})
+ control, _ := a.ui.session.Focused_control()
+ if control.ID != "speech.driver" || len(a.ui.session.Values()["presentation.speech.module"].Choices) != 2 {
+ t.Fatal("catalog/focus not restored", control)
+ }
+}
diff --git a/internal/app/indentation_test.go b/internal/app/indentation_test.go
new file mode 100644
index 00000000..eff0b007
--- /dev/null
+++ b/internal/app/indentation_test.go
@@ -0,0 +1,212 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package app
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/input"
+ "git.stormux.org/storm/fenrir/internal/interaction"
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+type unavailableIndentationSounds struct{ fakeSounds }
+
+func (f *unavailableIndentationSounds) Play_indentation(int) bool { return false }
+
+func TestReviewIndentationSpeechBeepCombinedFallbackAndSilence(t *testing.T) {
+ for _, mode := range []string{"none", "speech", "beeps", "both"} {
+ for _, state := range []string{"enabled", "disabled", "zero volume", "missing", "muted", "temporary silence"} {
+ t.Run(mode+"/"+state, func(t *testing.T) {
+ settings := config.Defaults()
+ settings.Reading.Indentation = mode
+ settings.Presentation.Sound.Enabled = state != "disabled"
+ if state == "zero volume" {
+ settings.Presentation.Sound.Volume = 0
+ }
+ sounds, voice := &fakeSounds{}, &fakeVoice{}
+ var player SoundPlayer = sounds
+ if state == "missing" {
+ player = &unavailableIndentationSounds{}
+ }
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 2, Cols: 8, Cells: []rune("root leaf")}}
+ keys := &fakeKeys{poll: func(_ bool, handle func(input.Decision)) error {
+ if state == "muted" {
+ handle(input.Decision{Command: command.Mute})
+ }
+ if state == "temporary silence" {
+ handle(input.Decision{Command: command.TempDisableSpeech})
+ }
+ voice.messages, sounds.indentation = nil, nil
+ handle(input.Decision{Command: command.NextLine, Interrupt: true})
+ handle(input.Decision{Command: command.CurrentLine, Interrupt: true})
+ handle(input.Decision{Command: command.Quit})
+ return nil
+ }}
+ if err := run(context.Background(), source, keys, voice, nil, RunOptions{Settings: settings, Sounds: player}); err != nil {
+ t.Fatal(err)
+ }
+ wantTone := state == "enabled" && (mode == "beeps" || mode == "both")
+ if wantTone != reflect.DeepEqual(sounds.indentation, []int{4}) {
+ t.Fatal("tone", sounds.indentation)
+ }
+ wantSpeech := mode != "none" && (mode != "beeps" || state != "enabled") && state != "muted" && state != "temporary silence"
+ if strings.Contains(strings.Join(voice.messages, "|"), "indented 4 ") != wantSpeech {
+ t.Fatal("speech", voice.messages)
+ }
+ })
+ }
+ }
+}
+
+func TestQuickIndentationChoicesMenuCancelSaveAndRestart(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "settings.toml")
+ base := config.Defaults()
+ base.General.ScriptKeys = []string{"KEY_COMPOSE", "KEY_META"}
+ if err := config.Save(path, base); err != nil {
+ t.Fatal(err)
+ }
+ voice := &fakeVoice{}
+ quick := new_quick_settings("speech-dispatcher")
+ quick.command(command.NextQuickCategory, &base, voice)
+ quick.command(command.NextQuickEntry, &base, voice)
+ for _, trial := range []struct {
+ id command.ID
+ want string
+ }{
+ {command.CurrentQuickEntry, "Indentation none"},
+ {command.NextQuickValue, "speech"}, {command.NextQuickValue, "beeps"},
+ {command.NextQuickValue, "speech and beeps"}, {command.NextQuickValue, "none"},
+ {command.PreviousQuickValue, "speech and beeps"},
+ } {
+ quick.command(trial.id, &base, voice)
+ if voice.messages[len(voice.messages)-1] != trial.want {
+ t.Fatal(voice.messages)
+ }
+ }
+ if len(voice.parameters) != 0 || base.Reading.Indentation != "both" {
+ t.Fatal(base, voice.parameters)
+ }
+ ui := new_internal_ui(context.Background(), &fakeKeys{}, voice)
+ ui.open_settings(base, path, base)
+ receive_ui(t, ui)
+ control := ui.session.Values()["reading.indentation"]
+ if control.Choices[control.Selected].ID != "both" {
+ t.Fatal("menu lost quick choice", control)
+ }
+ ui.handle(interaction.Key{Name: "escape"})
+ saved, _, err := config.Load(path, nil)
+ if err != nil || saved.Reading.Indentation != "none" {
+ t.Fatal("cancel saved choice", saved.Reading, err)
+ }
+
+ for session := range 2 {
+ voice = &fakeVoice{}
+ keys := &fakeKeys{poll: func(_ bool, handle func(input.Decision)) error {
+ handle(input.Decision{Command: command.NextQuickCategory})
+ handle(input.Decision{Command: command.NextQuickEntry})
+ if session == 0 {
+ for range 3 {
+ handle(input.Decision{Command: command.NextQuickValue})
+ }
+ handle(input.Decision{Command: command.SaveSettings})
+ } else if voice.messages[len(voice.messages)-1] != "Indentation speech and beeps" {
+ t.Fatal(voice.messages)
+ }
+ handle(input.Decision{Command: command.Quit})
+ return nil
+ }}
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 4, Cells: []rune("root")}}
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ err = run(ctx, source, keys, voice, nil, RunOptions{Settings: saved, ConfigPath: path})
+ cancel()
+ if err != nil {
+ t.Fatal(err)
+ }
+ saved, _, err = config.Load(path, nil)
+ if err != nil || saved.Reading.Indentation != "both" || !reflect.DeepEqual(saved.General, base.General) {
+ t.Fatal(saved, err)
+ }
+ }
+}
+
+func TestMenuIndentationAppliesOnlyAfterSuccessfulSave(t *testing.T) {
+ for _, action := range []string{"save", "cancel", "failed save"} {
+ t.Run(action, func(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "settings.toml")
+ if err := os.WriteFile(path, []byte("version=3\n[reading]\nindentation='none'\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 2, Cols: 8, Cells: []rune("root leaf")}}
+ voice, keys := &fakeVoice{}, &fakeKeys{events: make(chan error, 4)}
+ keys.onCapture = func(enabled bool) {
+ if !enabled {
+ keys.events <- nil
+ }
+ }
+ voice.onSay = func(text string) {
+ if strings.HasPrefix(text, "Fenrir Menu.") || strings.HasPrefix(text, "Could not save settings:") {
+ keys.events <- nil
+ }
+ }
+ step := 0
+ keys.poll = func(_ bool, handle func(input.Decision)) error {
+ step++
+ key := func(name string) { handle(input.Decision{UI: &interaction.Key{Name: name}}) }
+ switch step {
+ case 1:
+ handle(input.Decision{Command: command.FenrirMenu})
+ case 2:
+ for range 9 {
+ key("tab")
+ }
+ if voice.messages[len(voice.messages)-1] != "Indentation combo box none" {
+ t.Fatal(voice.messages)
+ }
+ key("down")
+ if action == "cancel" {
+ key("escape")
+ break
+ }
+ if action == "failed save" {
+ if err := os.WriteFile(path, []byte("version=3\n[reading]\nindentation='beeps'\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ for range 32 {
+ key("tab")
+ if voice.messages[len(voice.messages)-1] == "OK button" {
+ break
+ }
+ }
+ key("enter")
+ case 3:
+ if action == "failed save" {
+ key("escape")
+ }
+ voice.messages = nil
+ handle(input.Decision{Command: command.NextLine})
+ if strings.Contains(strings.Join(voice.messages, "|"), "indented 4 ") != (action == "save") {
+ t.Fatal(action, voice.messages)
+ }
+ handle(input.Decision{Command: command.Quit})
+ }
+ return nil
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ if err := run(ctx, source, keys, voice, nil, RunOptions{Settings: config.Defaults(), ConfigPath: path}); err != nil || step != 3 {
+ t.Fatal(err, step)
+ }
+ })
+ }
+}
diff --git a/internal/app/internal_ui.go b/internal/app/internal_ui.go
new file mode 100644
index 00000000..784dff8f
--- /dev/null
+++ b/internal/app/internal_ui.go
@@ -0,0 +1,182 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package app
+
+import (
+ "context"
+ "log/slog"
+ "sync"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/i18n"
+ "git.stormux.org/storm/fenrir/internal/interaction"
+ "git.stormux.org/storm/fenrir/internal/preferences"
+)
+
+// internalUI is the host boundary for every form type and feature. All state
+// changes run on the app loop; workers return generation-tagged presentation.
+// Admitted settings writes complete through the application save coordinator.
+type internalUI struct {
+ catalog func(preferences.Discovery)
+ ctx context.Context
+ keys Keyboards
+ voice Speaker
+ active bool
+ tutorial *tutorialMode
+ name string
+ generation uint64
+ session *interaction.Session
+ action func(string, *interaction.Session)
+ updates chan uiUpdate
+ saveSettings func(*config.Document, config.Settings, func(error)) bool
+ discoverChoices func(config.Settings) preferences.Discovery
+ changed func(*interaction.Session)
+ onClosed func()
+ previewSpelling func(config.Reading)
+ speechOverride *config.Speech
+ testSpeech func(context.Context, config.Speech, config.Speech) error
+ cancelTest context.CancelFunc
+ testDone <-chan struct{}
+ testWorkers sync.WaitGroup
+}
+
+type uiUpdate struct {
+ apply func() // Event-loop-only completion; guarded by menu generation.
+ discovery *preferences.Discovery
+ generation uint64
+ session *interaction.Session
+ action func(string, *interaction.Session)
+ speech string
+ changed func(*interaction.Session)
+ close bool
+}
+
+func new_internal_ui(ctx context.Context, keys Keyboards, voice Speaker) *internalUI {
+ ui := &internalUI{ctx: ctx, keys: keys, voice: voice, updates: make(chan uiUpdate, 8)}
+ ui.testSpeech = ui.test_synthesizer
+ return ui
+}
+
+func (ui *internalUI) stop_test() {
+ if ui.cancelTest != nil {
+ ui.cancelTest()
+ ui.cancelTest = nil
+ }
+}
+
+func (ui *internalUI) begin(name string) (uint64, error) {
+ if err := ui.keys.Capture(true); err != nil {
+ return 0, err
+ }
+ ui.stop_test()
+ ui.generation++
+ ui.active, ui.session, ui.action = true, nil, nil
+ ui.changed, ui.onClosed = nil, nil
+ ui.name = name
+ return ui.generation, nil
+}
+
+func (ui *internalUI) close() {
+ if !ui.active {
+ return
+ }
+ ui.stop_test()
+ if ui.tutorial != nil {
+ if err := ui.keys.Capture(false); err != nil {
+ slog.Error("release tutorial capture", "error", err, "at", time.Now())
+ ui.voice.Say(i18n.T(tutorialReleaseFailed))
+ return
+ }
+ ui.generation++
+ ui.active, ui.tutorial = false, nil
+ ui.voice.Say(i18n.T(tutorialExit))
+ return
+ }
+ ui.generation++
+ name := ui.name
+ ui.name = ""
+ ui.active, ui.session, ui.action = false, nil, nil
+ if ui.onClosed != nil {
+ ui.onClosed()
+ }
+ ui.changed, ui.onClosed = nil, nil
+ if err := ui.keys.Capture(false); err != nil {
+ slog.Error("release internal UI capture", "error", err, "at", time.Now())
+ }
+ ui.voice.Say(i18n.Format("{0} closed", i18n.T(name)))
+}
+
+func (ui *internalUI) post(update uiUpdate) {
+ select {
+ case ui.updates <- update:
+ case <-ui.ctx.Done():
+ }
+}
+
+func (ui *internalUI) update(update uiUpdate) {
+ if !ui.active || ui.generation != update.generation {
+ return
+ }
+ if update.apply != nil {
+ update.apply()
+ return
+ }
+ if update.discovery != nil && ui.catalog != nil {
+ ui.catalog(*update.discovery)
+ }
+ if update.close {
+ ui.close()
+ } else {
+ ui.session, ui.action = update.session, update.action
+ if update.changed != nil {
+ ui.changed = update.changed
+ }
+ }
+ if update.speech != "" {
+ ui.voice.Say(update.speech)
+ }
+}
+
+func (ui *internalUI) handle(key interaction.Key) {
+ if key.Name == "escape" {
+ ui.close()
+ return
+ }
+ if ui.session == nil {
+ return
+ } // Loading or saving still owns every key.
+ result := ui.session.Handle(key)
+ if ui.changed != nil {
+ ui.changed(ui.session)
+ }
+ if result.Cancelled || result.Action == "cancel" {
+ ui.close()
+ return
+ }
+ if result.Speech != "" {
+ ui.voice.Say(result.Speech)
+ }
+ if result.Action != "" && ui.action != nil {
+ ui.action(result.Action, ui.session)
+ }
+}
+
+func (ui *internalUI) failed(message string, err error) {
+ ui.voice.Say(i18n.Format(message, err.Error()))
+}
diff --git a/internal/app/internal_ui_test.go b/internal/app/internal_ui_test.go
new file mode 100644
index 00000000..d5f408a7
--- /dev/null
+++ b/internal/app/internal_ui_test.go
@@ -0,0 +1,356 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package app
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/input"
+ "git.stormux.org/storm/fenrir/internal/interaction"
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+func settings_ui_application(t *testing.T, keys *fakeKeys, voice *fakeVoice, path string) *application {
+ t.Helper()
+ a, err := new_application(context.Background(), &fakeSource{}, keys, voice, RunOptions{Settings: config.Defaults(), ConfigPath: path})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(a.ui.close)
+ return a
+}
+
+func receive_ui(t *testing.T, ui *internalUI) {
+ t.Helper()
+ select {
+ case update := <-ui.updates:
+ ui.update(update)
+ case <-time.After(time.Second):
+ t.Fatal("UI worker did not finish")
+ }
+}
+
+func TestSettingsMenuCancelSaveAndReopen(t *testing.T) {
+ for _, action := range []string{"escape", "cancel", "save"} {
+ t.Run(action, func(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "settings.toml")
+ if err := os.WriteFile(path, []byte("version=1\n[speech]\nrate=40\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ keys, voice := &fakeKeys{}, &fakeVoice{}
+ a := settings_ui_application(t, keys, voice, path)
+ ui := a.ui
+ ui.open_settings(config.Defaults(), path)
+ receive_ui(t, ui)
+ if !ui.active || voice.messages[len(voice.messages)-1] != "Fenrir Menu. Presentation tab" {
+ t.Fatal(ui.active, voice.messages)
+ }
+ ui.handle(interaction.Key{Name: "tab"})
+ ui.handle(interaction.Key{Name: "tab"})
+ ui.handle(interaction.Key{Name: "up"})
+ if action == "escape" {
+ ui.handle(interaction.Key{Name: "escape"})
+ } else {
+ for range 13 {
+ ui.handle(interaction.Key{Name: "tab"})
+ }
+ if action == "cancel" {
+ ui.handle(interaction.Key{Name: "tab"})
+ }
+ ui.handle(interaction.Key{Name: "enter"})
+ if action == "save" {
+ finish_persistence(t, a, false)
+ }
+ }
+ if ui.active || !reflect.DeepEqual(keys.captures, []bool{true, false}) {
+ t.Fatal("capture survived close", ui.active, keys.captures)
+ }
+ if got := voice.messages[len(voice.messages)-1]; got != "Fenrir Menu closed" {
+ t.Fatal("missing close announcement", got)
+ }
+ settings, _, err := config.Load(path, nil)
+ want := 40
+ if action == "save" {
+ want = 41
+ }
+ if err != nil || settings.Presentation.Speech.Rate != want {
+ t.Fatal("wrong persistence", settings, err)
+ }
+ ui.open_settings(config.Defaults(), path)
+ receive_ui(t, ui)
+ ui.handle(interaction.Key{Name: "tab"})
+ ui.handle(interaction.Key{Name: "tab"})
+ if ui.session.Values()["presentation.speech.rate"].Number != float64(want) {
+ t.Fatal("reopen did not reload settings")
+ }
+ ui.close()
+ })
+ }
+}
+
+func TestMenuSoundVolumeAppliesOnlyAfterSuccessfulSave(t *testing.T) {
+ for _, action := range []string{"lower", "zero", "raise", "cancel", "failed save"} {
+ t.Run(action, func(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "settings.toml")
+ // Put Sound first so traversal starts with its controls.
+ if err := os.WriteFile(path, []byte("version=3\n[presentation.sound]\nvolume=0.7\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 1, Active: true}}
+ keys := &fakeKeys{events: make(chan error, 4)}
+ voice := &fakeVoice{}
+ sounds := &fakeSounds{volume: 0.7}
+ keys.onCapture = func(enabled bool) {
+ if !enabled {
+ keys.events <- nil
+ }
+ }
+ voice.onSay = func(text string) {
+ if strings.HasPrefix(text, "Fenrir Menu.") || strings.HasPrefix(text, "Could not save settings:") {
+ keys.events <- nil
+ }
+ }
+ step := 0
+ keys.poll = func(_ bool, handle func(input.Decision)) error {
+ step++
+ key := func(name string, shift bool) { handle(input.Decision{UI: &interaction.Key{Name: name, Shift: shift}}) }
+ switch step {
+ case 1:
+ handle(input.Decision{Command: command.FenrirMenu})
+ case 2:
+ key("tab", false)
+ key("tab", false)
+ if action == "raise" {
+ key("up", false)
+ } else {
+ for range 6 {
+ key("left", false)
+ }
+ if action == "zero" {
+ key("left", false)
+ }
+ }
+ if sounds.volume != 0.7 {
+ t.Fatal("draft changed running volume", sounds.volume)
+ }
+ if action == "cancel" {
+ key("escape", false)
+ break
+ }
+ if action == "failed save" {
+ // An external edit must reject the save and retain the draft.
+ if err := os.WriteFile(path, []byte("version=3\n[presentation.sound]\nvolume=0.8\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ // Move back through Sound enabled, the tab label and Cancel to OK.
+ for range 4 {
+ key("tab", true)
+ }
+ key("enter", false)
+ case 3:
+ if action == "failed save" {
+ key("escape", false)
+ }
+ handle(input.Decision{Command: command.Quit})
+ }
+ return nil
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ if err := run(ctx, source, keys, voice, nil, RunOptions{Settings: config.Defaults(), Sounds: sounds, ConfigPath: path}); err != nil || step != 3 {
+ t.Fatal("menu workflow incomplete", err, step, voice.messages)
+ }
+ want := 0.1
+ switch action {
+ case "zero":
+ want = 0
+ case "raise":
+ want = 1
+ case "cancel", "failed save":
+ want = 0.7
+ }
+ if sounds.volume != want {
+ t.Fatal("running sound volume", sounds.volume, "want", want)
+ }
+ })
+ }
+}
+
+func TestMenuSaveFailureRetainsEditsAndCapture(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "settings.toml")
+ if err := config.Save(path, config.Defaults()); err != nil {
+ t.Fatal(err)
+ }
+ a := settings_ui_application(t, &fakeKeys{}, &fakeVoice{}, path)
+ ui := a.ui
+ ui.open_settings(config.Defaults(), path)
+ receive_ui(t, ui)
+ ui.handle(interaction.Key{Name: "tab"})
+ ui.handle(interaction.Key{Name: "space"}) // Speech enabled false.
+ other := config.Defaults()
+ other.Presentation.Speech.Rate = 12
+ if err := config.Save(path, other); err != nil {
+ t.Fatal(err)
+ }
+ ui.action("save", ui.session)
+ finish_persistence(t, a, true)
+ if !ui.active || ui.session == nil || ui.session.Values()["presentation.speech.enabled"].Checked {
+ t.Fatal("failed save lost draft or capture")
+ }
+ voice := a.voice.Speaker.(*fakeVoice)
+ if !strings.Contains(voice.messages[len(voice.messages)-1], "settings changed") {
+ t.Fatal(voice.messages)
+ }
+ ui.handle(interaction.Key{Name: "escape"})
+ settings, _, err := config.Load(path, nil)
+ if err != nil || settings.Presentation.Speech.Rate != 12 {
+ t.Fatal("external edit overwritten", settings, err)
+ }
+}
+
+func TestMenuRejectsStaleWorkerAndCaptureFailure(t *testing.T) {
+ keys := &fakeKeys{captureErr: errors.New("fixture capture denied")}
+ voice := &fakeVoice{}
+ ui := new_internal_ui(context.Background(), keys, voice)
+ ui.open_settings(config.Defaults(), filepath.Join(t.TempDir(), "settings.toml"))
+ if ui.active || !strings.Contains(strings.Join(voice.messages, "|"), "capture denied") {
+ t.Fatal(ui.active, voice.messages)
+ }
+ keys.captureErr = nil
+ ui.open_settings(config.Defaults(), filepath.Join(t.TempDir(), "settings.toml"))
+ ui.close()
+ closedCount := len(voice.messages)
+ receive_ui(t, ui)
+ if ui.active || ui.session != nil || len(voice.messages) != closedCount {
+ t.Fatal("closed UI resurrected")
+ }
+}
+
+func TestInternalUICloseUsesTitleWithoutLoadedSessionAndOnlyOnce(t *testing.T) {
+ for _, title := range []string{"Fenrir settings", "Actions"} {
+ keys, voice := &fakeKeys{}, &fakeVoice{}
+ ui := new_internal_ui(context.Background(), keys, voice)
+ if _, err := ui.begin(title); err != nil {
+ t.Fatal(err)
+ }
+ ui.handle(interaction.Key{Name: "escape"})
+ ui.close()
+ if ui.active || !reflect.DeepEqual(voice.messages, []string{title + " closed"}) || !reflect.DeepEqual(keys.captures, []bool{true, false}) {
+ t.Fatal(title, ui.active, voice.messages, keys.captures)
+ }
+ }
+}
+
+func TestMenuOpenFailureClosesWithNameAndReportsError(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "settings.toml")
+ if err := os.WriteFile(path, []byte("not valid TOML"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ voice := &fakeVoice{}
+ ui := new_internal_ui(context.Background(), &fakeKeys{}, voice)
+ ui.open_settings(config.Defaults(), path)
+ receive_ui(t, ui)
+ if ui.active || len(voice.messages) != 2 || voice.messages[0] != "Fenrir Menu closed" || !strings.Contains(voice.messages[1], "Could not open Fenrir Menu:") {
+ t.Fatal(ui.active, voice.messages)
+ }
+}
+
+func TestMenuSuppressesBackgroundScreenAndRestoresCommands(t *testing.T) {
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 5, Cells: []rune("first")}, events: make(chan error, 1)}
+ keys := &fakeKeys{events: make(chan error, 1)}
+ voice := &fakeVoice{}
+ step := 0
+ keys.poll = func(_ bool, handle func(input.Decision)) error {
+ step++
+ if step == 1 {
+ handle(input.Decision{Command: command.FenrirMenu, Interrupt: true})
+ }
+ if step == 2 {
+ handle(input.Decision{Command: command.PassNext}) // Blocked while UI owns input.
+ handle(input.Decision{UI: &interaction.Key{Name: "escape"}, Interrupt: true})
+ handle(input.Decision{Command: command.CurrentLine, Interrupt: true})
+ handle(input.Decision{Command: command.Quit})
+ }
+ return nil
+ }
+ voice.onSay = func(text string) {
+ if !strings.HasPrefix(text, "Fenrir Menu.") {
+ return
+ }
+ source.snapshot.Cells = []rune("later")
+ source.afterRead = func() { source.afterRead = nil; keys.events <- nil }
+ source.events <- nil
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ err := run(ctx, source, keys, voice, nil, RunOptions{Settings: config.Defaults(), ConfigPath: filepath.Join(t.TempDir(), "settings.toml")})
+ if err != nil || step != 2 || keys.passed || !keys.closed {
+ t.Fatal(err, step, keys)
+ }
+ count := 0
+ for _, text := range voice.messages {
+ if text == "later" {
+ count++
+ }
+ }
+ if count != 1 {
+ t.Fatal("background echo escaped or review failed after close", voice.messages)
+ }
+ if !reflect.DeepEqual(keys.captures, []bool{true, false}) {
+ t.Fatal(keys.captures)
+ }
+}
+
+func TestMenuAbortsOnTerminalChange(t *testing.T) {
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 1, Cells: []rune("x")}}
+ keys := &fakeKeys{}
+ voice := &fakeVoice{}
+ step := 0
+ keys.poll = func(_ bool, handle func(input.Decision)) error {
+ step++
+ if step == 1 {
+ handle(input.Decision{Command: command.FenrirMenu})
+ source.snapshot.Terminal = 3
+ }
+ if step == 2 {
+ handle(input.Decision{Command: command.Quit})
+ }
+ return nil
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ if err := Run(ctx, source, keys, voice, RunOptions{Settings: config.Defaults(), ConfigPath: filepath.Join(t.TempDir(), "settings.toml")}); err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(keys.captures, []bool{true, false}) {
+ t.Fatal("capture moved between VTs", keys.captures)
+ }
+ if !strings.Contains(strings.Join(voice.messages, "|"), "Fenrir Menu closed") {
+ t.Fatal("terminal change did not announce closure", voice.messages)
+ }
+}
diff --git a/internal/app/menu_save_test.go b/internal/app/menu_save_test.go
new file mode 100644
index 00000000..5ee133ac
--- /dev/null
+++ b/internal/app/menu_save_test.go
@@ -0,0 +1,168 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package app
+
+import (
+ "reflect"
+ "testing"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/input"
+ "git.stormux.org/storm/fenrir/internal/interaction"
+)
+
+func admit_menu_save(t *testing.T, a *application) {
+ t.Helper()
+ a.open_settings()
+ receive_ui(t, a.ui)
+ a.ui.handle(interaction.Key{Name: "tab"})
+ a.ui.handle(interaction.Key{Name: "tab"})
+ a.ui.handle(interaction.Key{Name: "up"}) // Rate 24 -> 25.
+ a.ui.action("save", a.ui.session)
+ if !a.saver.busy || a.ui.session != nil {
+ t.Fatal("menu save was not admitted")
+ }
+}
+
+func committed_menu_result(t *testing.T, a *application) error {
+ t.Helper()
+ select {
+ case err := <-a.saver.results:
+ return err
+ case <-time.After(time.Second):
+ t.Fatal("menu save worker stalled")
+ return nil
+ }
+}
+
+func TestMenuCommitSurvivesCloseAndHandoff(t *testing.T) {
+ for _, terminalMode := range []bool{false, true} {
+ for _, closeAction := range []string{"escape", "terminal", "focus", "other menu"} {
+ t.Run(closeAction+map[bool]string{false: "/tty", true: "/x11"}[terminalMode], func(t *testing.T) {
+ a, voice := persistence_application(t, terminalMode)
+ admit_menu_save(t, a)
+ err := committed_menu_result(t, a) // Gate completion after disk commit.
+ if err != nil {
+ t.Fatal(err)
+ }
+ switch closeAction {
+ case "terminal", "focus":
+ source := a.source.(*fakeSource)
+ if closeAction == "terminal" {
+ source.snapshot.Terminal++
+ } else {
+ source.snapshot.Active = false
+ }
+ if err := a.read_screen(); err != nil {
+ t.Fatal(err)
+ }
+ default:
+ a.ui.handle(interaction.Key{Name: "escape"})
+ }
+ if a.ui.active || a.keyboards.(*fakeKeys).captures[1] {
+ t.Fatal("close did not release capture immediately")
+ }
+ if closeAction == "other menu" {
+ a.ui.begin("Other menu")
+ }
+ generation, speechCount := a.ui.generation, len(voice.messages)
+ a.settings_save_result(err)
+ saved, _, err := config.Load(a.options.ConfigPath, nil)
+ if err != nil || saved.Presentation.Speech.Rate != 25 || a.settings.live.Presentation.Speech.Rate != 25 || !reflect.DeepEqual(a.settings.saveDraft, saved) || !reflect.DeepEqual(a.settings.resetBase, saved) {
+ t.Fatal("committed menu settings diverged", saved, a.settings, err)
+ }
+ if a.ui.generation != generation || len(voice.messages) != speechCount || a.ui.active != (closeAction == "other menu") {
+ t.Fatal("stale save changed modal presentation")
+ }
+ configured := len(voice.parameters)
+ a.settings_save_result(nil)
+ if len(voice.parameters) != configured {
+ t.Fatal("duplicate result applied twice")
+ }
+ if closeAction == "other menu" {
+ a.ui.close()
+ }
+ a.open_settings()
+ receive_ui(t, a.ui)
+ if a.ui.session.Values()["presentation.speech.rate"].Number != 25 {
+ t.Fatal("reopened form lost the commit")
+ }
+ })
+ }
+ }
+}
+
+func TestMenuCommitPreservesNewerOverridesAndCoordinatesSaves(t *testing.T) {
+ for _, terminalMode := range []bool{false, true} {
+ a, _ := persistence_application(t, terminalMode)
+ admit_menu_save(t, a)
+ err := committed_menu_result(t, a)
+ if err != nil {
+ t.Fatal(err)
+ }
+ a.ui.close()
+ a.open_settings()
+ if a.ui.active {
+ t.Fatal("settings menu reopened during an admitted save")
+ }
+ persistence_reply(t, persistence_request(a, "SAVE"), true)
+ persistence_reply(t, persistence_request(a, "SAVEAS ignored.toml"), true)
+ persistence_reply(t, persistence_request(a, "RESET"), true)
+ a.save_settings() // Cannot replace the pending menu snapshot.
+ persistence_reply(t, persistence_request(a, "SET speech#pitch=0.8;keyboard#word_echo=True"), false)
+ for _, id := range []command.ID{command.NextQuickValue, command.PreviousQuickValue, command.ProgressMonitor, command.Mute, command.AutoRead} {
+ a.handle_input(input.Decision{Command: id})
+ }
+ // Returning rate to its old value still counts as a later adjustment.
+ a.settings_save_result(err)
+ if a.settings.live.Presentation.Speech.Rate != 24 || a.settings.live.Presentation.Speech.Pitch != 60 || !a.settings.live.Keyboard.WordEcho || a.settings.live.Presentation.Progress.Enabled == a.settings.resetBase.Presentation.Progress.Enabled || !a.muted || a.engine.Automatic_reading() || !a.quick.changed {
+ t.Fatal("menu completion overwrote a later/session change", a.settings.live)
+ }
+ if a.settings.saveDraft.Presentation.Speech.Rate != 24 || a.settings.saveDraft.Presentation.Speech.Pitch != 60 || !a.settings.saveDraft.Keyboard.WordEcho || a.settings.resetBase.Presentation.Speech.Rate != 25 {
+ t.Fatal("save draft or reset baseline diverged", a.settings)
+ }
+ persistence_reply(t, persistence_request(a, "RESET"), false)
+ if a.settings.live.Presentation.Speech.Rate != 25 || a.settings.live.Presentation.Speech.Pitch != a.settings.resetBase.Presentation.Speech.Pitch || a.settings.live.Keyboard.WordEcho || !a.muted || a.engine.Automatic_reading() {
+ t.Fatal("RESET did not restore committed values and preserve session toggles")
+ }
+ }
+}
+
+func TestMenuFailureAfterCloseIsSilentAndDoesNotCommit(t *testing.T) {
+ a, voice := persistence_application(t, true)
+ base := a.settings.resetBase
+ a.open_settings()
+ receive_ui(t, a.ui)
+ external := base
+ external.Presentation.Speech.Rate = 70
+ if err := config.Save(a.options.ConfigPath, external); err != nil {
+ t.Fatal(err)
+ }
+ a.ui.action("save", a.ui.session)
+ err := committed_menu_result(t, a)
+ if err == nil {
+ t.Fatal("external edit was overwritten")
+ }
+ a.ui.close()
+ count := len(voice.messages)
+ a.settings_save_result(err)
+ if a.ui.active || a.saver.busy || len(voice.messages) != count || !reflect.DeepEqual(a.settings.resetBase, base) || !reflect.DeepEqual(a.settings.saveDraft, base) {
+ t.Fatal("closed failure changed state or presented into another session")
+ }
+}
+
+func TestQuitWaitsForMenuCommitAfterClose(t *testing.T) {
+ a, _ := persistence_application(t, true)
+ admit_menu_save(t, a)
+ a.ui.close()
+ a.handle_input(input.Decision{Command: command.Quit})
+ if a.quitting || !a.quitAfterSave {
+ t.Fatal("quit did not wait for menu save")
+ }
+ if err := a.loop(nil); err != nil || a.ctx.Err() != nil || a.settings.resetBase.Presentation.Speech.Rate != 25 || a.saver.busy {
+ t.Fatal("quit lost admitted commit", err, a.ctx.Err())
+ }
+}
diff --git a/internal/app/progress_test.go b/internal/app/progress_test.go
new file mode 100644
index 00000000..12163fcb
--- /dev/null
+++ b/internal/app/progress_test.go
@@ -0,0 +1,252 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package app
+
+import (
+ "context"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/input"
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+func TestSharedLoopProgressSoundGatesAndToggle(t *testing.T) {
+ for _, mode := range []string{"enabled", "sound off", "progress off", "toggle off", "toggle on", "muted", "automatic off", "menu"} {
+ t.Run(mode, func(t *testing.T) {
+ settings := config.Defaults()
+ settings.Presentation.Sound.Enabled = mode != "sound off"
+ settings.Presentation.Progress.Enabled = mode != "progress off" && mode != "toggle on"
+ settings.Reading.Automatic = mode != "automatic off"
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 1, Active: true, Rows: 1, Cols: 20, Cells: []rune("Download 20% ")}, events: make(chan error, 1)}
+ keys := &fakeKeys{events: make(chan error, 1)}
+ voice, sounds := &fakeVoice{}, &fakeSounds{}
+ reads, polls := 0, 0
+ source.afterRead = func() {
+ reads++
+ if reads == 2 {
+ keys.events <- nil
+ }
+ }
+ keys.poll = func(_ bool, handle func(input.Decision)) error {
+ polls++
+ if polls == 1 {
+ switch mode {
+ case "toggle off", "toggle on":
+ handle(input.Decision{Command: command.ProgressMonitor, Interrupt: true})
+ case "muted":
+ handle(input.Decision{Command: command.Mute, Interrupt: true})
+ case "menu":
+ handle(input.Decision{Command: command.FenrirMenu, Interrupt: true})
+ }
+ source.snapshot.Cells = []rune("Download 30% ")
+ source.events <- nil
+ } else {
+ handle(input.Decision{Command: command.Quit})
+ }
+ return nil
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ if err := run(ctx, source, keys, voice, nil, RunOptions{Settings: settings, Sounds: sounds}); err != nil || reads != 2 {
+ t.Fatal(err, reads)
+ }
+ want := []float64{30}
+ if mode == "sound off" || mode == "progress off" || mode == "toggle off" || mode == "menu" {
+ want = nil
+ }
+ if !reflect.DeepEqual(sounds.progress, want) {
+ t.Fatal(mode, sounds.progress, want)
+ }
+ if mode == "toggle off" && !strings.Contains(strings.Join(voice.messages, "|"), "Progress monitoring disabled") {
+ t.Fatal(voice.messages)
+ }
+ if mode == "toggle on" && !strings.Contains(strings.Join(voice.messages, "|"), "Progress monitoring enabled") {
+ t.Fatal(voice.messages)
+ }
+ })
+ }
+}
+
+type progressSounds struct {
+ fakeSounds
+ onProgress func(float64)
+}
+
+func (f *progressSounds) Play_progress(value float64) bool {
+ f.onProgress(value)
+ return f.fakeSounds.Play_progress(value)
+}
+
+type progressResetSounds struct {
+ fakeSounds
+ clears, discards int
+}
+
+func (f *progressResetSounds) Clear_progress() { f.clears++ }
+func (f *progressResetSounds) Discard_progress() { f.discards++ }
+
+func TestProgressResetDistinguishesPassiveRedrawFromInterruption(t *testing.T) {
+ for _, redraw := range []string{"cursor", "ordinary output", "prompt", "repaint", "input", "menu", "terminal", "inactive", "buffer", "resize"} {
+ t.Run(redraw, func(t *testing.T) {
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 1, Active: true, Rows: 2, Cols: 20, Cells: []rune("Download 20% ")}, events: make(chan error, 1)}
+ keys := &fakeKeys{events: make(chan error, 1)}
+ sounds := &progressResetSounds{}
+ reads, clearsBeforeRedraw, discardsBeforeRedraw := 0, 0, 0
+ source.afterRead = func() {
+ reads++
+ if reads >= 2 {
+ keys.events <- nil
+ }
+ }
+ keys.poll = func(_ bool, handle func(input.Decision)) error {
+ switch reads {
+ case 1:
+ source.snapshot.Cells = []rune("Download 30% ")
+ source.events <- nil
+ case 2:
+ clearsBeforeRedraw = sounds.clears
+ discardsBeforeRedraw = sounds.discards
+ switch redraw {
+ case "cursor":
+ source.snapshot.X = 1
+ case "ordinary output":
+ source.snapshot.Cells = []rune("Done. ")
+ case "prompt":
+ source.snapshot.Cells = []rune("Username@host:~$ ")
+ case "repaint":
+ source.snapshot.Cells = []rune("Working Status ")
+ case "input":
+ handle(input.Decision{Interrupt: true, Keypress: true, Forward: true})
+ case "menu":
+ handle(input.Decision{Command: command.FenrirMenu, Interrupt: true})
+ case "terminal":
+ source.snapshot.Terminal++
+ case "inactive":
+ source.snapshot.Active = false
+ case "buffer":
+ source.snapshot.Generation++
+ case "resize":
+ source.snapshot.Rows = 1
+ source.snapshot.Cells = source.snapshot.Cells[:20]
+ }
+ source.events <- nil
+ case 3:
+ handle(input.Decision{Command: command.Quit})
+ }
+ return nil
+ }
+ settings := config.Defaults()
+ settings.Reading.Automatic = false
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ if err := run(ctx, source, keys, &fakeVoice{}, nil, RunOptions{Settings: settings, Sounds: sounds}); err != nil {
+ t.Fatal(err)
+ }
+ if reads != 3 || !reflect.DeepEqual(sounds.progress, []float64{30}) {
+ t.Fatal("fixture missed progress/redraw", reads, sounds.progress)
+ }
+ mustStop := redraw == "input" || redraw == "menu" || redraw == "terminal" || redraw == "inactive" || redraw == "buffer" || redraw == "resize"
+ if (sounds.clears > clearsBeforeRedraw) != mustStop {
+ t.Fatal("wrong playing-tone cancellation", sounds.clears-clearsBeforeRedraw, "must stop", mustStop)
+ }
+ if !mustStop && sounds.discards == discardsBeforeRedraw {
+ t.Fatal("passive redraw retained waiting progress")
+ }
+ })
+ }
+}
+
+func TestProgressLoopFlushesFinalValueWithoutAnotherScreenEvent(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ settings := config.Defaults()
+ settings.Reading.Automatic = false
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 1, Active: true, Rows: 1, Cols: 20, Cells: []rune("Download 20% ")}, events: make(chan error, 2)}
+ keys := &fakeKeys{events: make(chan error, 1)}
+ sounds := &progressSounds{onProgress: func(value float64) {
+ if value == 100 {
+ cancel()
+ }
+ }}
+ reads := 0
+ source.afterRead = func() {
+ reads++
+ if reads == 2 {
+ source.snapshot.Cells = []rune("Download 100% ")
+ source.events <- nil
+ }
+ }
+ keys.poll = func(_ bool, _ func(input.Decision)) error {
+ if reads == 1 {
+ source.snapshot.Cells = []rune("Download 30% ")
+ source.events <- nil
+ }
+ return nil
+ }
+ if err := Run(ctx, source, keys, &fakeVoice{}, RunOptions{Settings: settings, Sounds: sounds}); err != nil {
+ t.Fatal(err)
+ }
+ if reads != 3 || !reflect.DeepEqual(sounds.progress, []float64{30, 100}) {
+ t.Fatal("final pending percentage lost", reads, sounds.progress)
+ }
+}
+
+func TestBackspaceThroughSharedRouterDoesNotBeep(t *testing.T) {
+ router, err := input.New_router([]input.Binding{{Name: "Quit", Keys: []string{"FENRIR", "Q"}, Command: command.Quit}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 1, Active: true, Rows: 1, Cols: 20, X: 11, Cells: []rune("type 2% abc ")}, events: make(chan error, 1)}
+ keys := &fakeKeys{events: make(chan error, 1)}
+ voice, sounds := &fakeVoice{}, &fakeSounds{}
+ reads, polls := 0, 0
+ source.afterRead = func() {
+ reads++
+ if reads == 2 {
+ keys.events <- nil
+ }
+ }
+ keys.poll = func(_ bool, handle func(input.Decision)) error {
+ polls++
+ if polls == 1 {
+ handle(router.Key(0, input.KeyBackspace, 1, false, false))
+ source.snapshot.Cells = []rune("type 2% ab ")
+ source.snapshot.X = 10
+ source.events <- nil
+ } else {
+ handle(input.Decision{Command: command.Quit})
+ }
+ return nil
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ if err := run(ctx, source, keys, voice, nil, RunOptions{Settings: config.Defaults(), Sounds: sounds}); err != nil || reads != 2 {
+ t.Fatal(err, reads)
+ }
+ if len(sounds.progress) != 0 {
+ t.Fatal("Backspace beeped", sounds.progress)
+ }
+ if !strings.Contains(strings.Join(voice.messages, "|"), "c") {
+ t.Fatal("deletion feedback lost", voice.messages)
+ }
+}
diff --git a/internal/app/prompt_test.go b/internal/app/prompt_test.go
new file mode 100644
index 00000000..7e2072c1
--- /dev/null
+++ b/internal/app/prompt_test.go
@@ -0,0 +1,124 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package app
+
+import (
+ "context"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/input"
+ "git.stormux.org/storm/fenrir/internal/interaction"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+func prompt_snapshot(lines ...string) screen.Snapshot {
+ result := screen.Snapshot{Terminal: 1, Active: true, Rows: len(lines), Cols: 80, Y: len(lines) - 1}
+ for _, line := range lines {
+ cells := make([]rune, result.Cols)
+ for index := range cells {
+ cells[index] = ' '
+ }
+ copy(cells, []rune(line))
+ result.Cells = append(result.Cells, cells...)
+ }
+ return result
+}
+
+func TestPromptRestorationDiscardsBatchAndPreservesModes(t *testing.T) {
+ for _, mode := range []string{"sound", "speech", "missing sound", "automatic off", "muted", "menu"} {
+ for _, line := range []string{"$", "Password:", "Worked for 19s ⢠7:46 PM", "Worked for 3m 35s ⢠8:05 PM", "CUSTOM READY"} {
+ t.Run(mode+"/"+line, func(t *testing.T) {
+ settings := config.Defaults()
+ settings.Presentation.Sound.Enabled = mode != "speech"
+ settings.Reading.Automatic = mode != "automatic off"
+ settings.Prompt.ExactMatches = []string{"CUSTOM READY"}
+ source := &fakeSource{snapshot: prompt_snapshot("old", "working"), events: make(chan error, 1)}
+ keys := &fakeKeys{events: make(chan error, 1)}
+ voice, sounds := &fakeVoice{}, &fakeSounds{}
+ if mode == "missing sound" {
+ sounds.missing = map[presentation.Cue]bool{presentation.SpeechOn: true}
+ }
+ reads, polls := 0, 0
+ source.afterRead = func() {
+ reads++
+ if reads > 1 {
+ keys.events <- nil
+ }
+ }
+ keys.poll = func(_ bool, handle func(input.Decision)) error {
+ polls++
+ switch polls {
+ case 1:
+ handle(input.Decision{Command: command.TempDisableSpeech, Interrupt: true, Keypress: true})
+ if mode == "muted" {
+ handle(input.Decision{Command: command.Mute})
+ }
+ if mode == "menu" {
+ // Menu loading is asynchronous. Start the prompt trial
+ // only after its opening announcement has been delivered.
+ voice.onSay = func(text string) {
+ if !strings.HasPrefix(text, "Fenrir Menu.") {
+ return
+ }
+ voice.onSay = nil
+ voice.messages, sounds.cues = nil, nil
+ source.snapshot = prompt_snapshot("discard this backlog", "Worked for 19s ⢠7:4")
+ source.events <- nil
+ }
+ handle(input.Decision{Command: command.FenrirMenu})
+ return nil
+ }
+ voice.messages, sounds.cues = nil, nil
+ source.snapshot = prompt_snapshot("discard this backlog", "Worked for 19s ⢠7:4")
+ source.events <- nil
+ case 2:
+ if len(voice.messages) != 0 || len(sounds.cues) != 0 {
+ t.Fatal("incomplete line restored speech", voice.messages, sounds.cues)
+ }
+ source.snapshot = prompt_snapshot("also discard this batch", line)
+ source.events <- nil
+ case 3:
+ var wantSpeech []string
+ var wantCues []presentation.Cue
+ if mode != "muted" && mode != "menu" {
+ if mode == "speech" || mode == "missing sound" {
+ wantSpeech = append(wantSpeech, "Speech restored")
+ } else {
+ wantCues = []presentation.Cue{presentation.SpeechOn}
+ }
+ if mode != "automatic off" {
+ wantSpeech = append(wantSpeech, line)
+ }
+ }
+ if !reflect.DeepEqual(voice.messages, wantSpeech) || !reflect.DeepEqual(sounds.cues, wantCues) {
+ t.Fatal(voice.messages, sounds.cues, "want", wantSpeech, wantCues)
+ }
+ voice.messages, sounds.cues = nil, nil
+ source.events <- nil // unchanged trigger must not repeat
+ case 4:
+ if len(voice.messages) != 0 || len(sounds.cues) != 0 {
+ t.Fatal("repeated restoration", voice.messages, sounds.cues)
+ }
+ if mode == "menu" {
+ handle(input.Decision{UI: &interaction.Key{Name: "escape"}})
+ }
+ handle(input.Decision{Command: command.Quit})
+ }
+ return nil
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ if err := run(ctx, source, keys, voice, nil, RunOptions{Settings: settings, Sounds: sounds}); err != nil || polls != 4 {
+ t.Fatal(err, polls)
+ }
+ })
+ }
+ }
+}
diff --git a/internal/app/punctuation_test.go b/internal/app/punctuation_test.go
new file mode 100644
index 00000000..5dc63fa4
--- /dev/null
+++ b/internal/app/punctuation_test.go
@@ -0,0 +1,112 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package app
+
+import (
+ "context"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/input"
+ "git.stormux.org/storm/fenrir/internal/punctuation"
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+func TestSharedSpeechPunctuationAndPreparedCharacterFeedback(t *testing.T) {
+ processor, _, err := punctuation.Load([]string{"../../config/punctuation/default.toml"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ voice := &fakeVoice{}
+ echo := &echoSpeaker{Speaker: voice, punctuation: processor, punctuationLevel: "some"}
+ echo.Say("a/b")
+ if echo.last != "a/b" {
+ t.Fatal("clipboard echo lost original text", echo.last)
+ }
+ echo.punctuationLevel = "none"
+ echo.say_character('?')
+ echo.Say("a/b")
+ echo.punctuationLevel = "all"
+ echo.say_character('?')
+ echo.punctuationLevel = "some"
+ echo.say_character('?', " first character in cell path/to")
+ if echo.last != "question? first character in cell path/to" {
+ t.Fatal("clipboard echo lost raw table header", echo.last)
+ }
+ got := strings.Join(voice.messages, "|")
+ if got != "a slash b|question?|a b|question?|question? first character in cell path slash to" {
+ t.Fatal(got)
+ }
+}
+
+func TestReviewCharactersUseProfileRegardlessOfLevelAndKeepBoundaries(t *testing.T) {
+ processor, _, err := punctuation.Load([]string{"../../config/punctuation/fr.toml"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, level := range punctuation.Levels() {
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 2, Cells: []rune("?/")}}
+ voice := &fakeVoice{}
+ keys := &fakeKeys{poll: func(_ bool, handle func(input.Decision)) error {
+ voice.messages = nil // Inspect command feedback after startup.
+ for _, id := range []command.ID{command.CurrentChar, command.NextChar, command.NextChar, command.Quit} {
+ handle(input.Decision{Command: id, Interrupt: true})
+ }
+ return nil
+ }}
+ settings := config.Defaults()
+ settings.Reading.Automatic = false
+ settings.Reading.PunctuationLevel = level
+ settings.Presentation.Sound.Enabled = false
+ if err := run(context.Background(), source, keys, voice, nil, RunOptions{Settings: settings, Punctuation: processor}); err != nil {
+ t.Fatal(err)
+ }
+ want := "point d'interrogation|barre oblique|end of screen"
+ if level == "all" {
+ want = "point d'interrogation|barre oblique|end espace of espace screen"
+ }
+ if got := strings.Join(voice.messages, "|"); got != want {
+ t.Fatal(level, got)
+ }
+ }
+}
+
+func TestQuickPunctuationAppliesLiveAndSavesWhileMuted(t *testing.T) {
+ processor, _, err := punctuation.Load([]string{"../../config/punctuation/default.toml"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(t.TempDir(), "settings.toml")
+ settings := config.Defaults()
+ settings.Reading.Automatic = false
+ voice := &fakeVoice{}
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 3, Cells: []rune("a/b")}}
+ keys := &fakeKeys{poll: func(_ bool, handle func(input.Decision)) error {
+ handle(input.Decision{Command: command.NextQuickCategory})
+ handle(input.Decision{Command: command.PreviousQuickValue})
+ voice.messages = nil
+ handle(input.Decision{Command: command.CurrentLine})
+ if !reflect.DeepEqual(voice.messages, []string{"a b"}) {
+ t.Fatal("punctuation did not apply live", voice.messages)
+ }
+ handle(input.Decision{Command: command.Mute})
+ handle(input.Decision{Command: command.NextQuickValue})
+ handle(input.Decision{Command: command.NextQuickValue})
+ handle(input.Decision{Command: command.SaveSettings})
+ handle(input.Decision{Command: command.Quit})
+ return nil
+ }}
+ if err := run(context.Background(), source, keys, voice, nil, RunOptions{Settings: settings, Punctuation: processor, ConfigPath: path}); err != nil {
+ t.Fatal(err)
+ }
+ saved, _, err := config.Load(path, nil)
+ if err != nil || saved.Reading.PunctuationLevel != "most" || saved.Reading.Automatic || !saved.Presentation.Speech.Enabled {
+ t.Fatal(saved, err)
+ }
+}
diff --git a/internal/app/quick_settings.go b/internal/app/quick_settings.go
new file mode 100644
index 00000000..bb85bc11
--- /dev/null
+++ b/internal/app/quick_settings.go
@@ -0,0 +1,299 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package app
+
+import (
+ "context"
+ "log/slog"
+ "reflect"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/i18n"
+ "git.stormux.org/storm/fenrir/internal/preferences"
+ "git.stormux.org/storm/fenrir/internal/speech"
+)
+
+type quickCategory struct {
+ label string
+ entries []config.Definition
+ position int
+}
+
+type quickSettings struct {
+ selectionChanged bool
+ categories []quickCategory
+ category int
+ changed bool
+ discovery preferences.Discovery
+ selectSpeech func(config.Speech, func(error, bool))
+ revision uint64
+ fields map[string]uint64
+}
+
+func new_quick_settings(driverID string) *quickSettings {
+ driver, _ := speech.Find_driver(driverID) // Startup settings are validated.
+ var sections []config.Section
+ var visit func([]config.Section)
+ visit = func(groups []config.Section) {
+ for _, group := range groups {
+ sections = append(sections, group)
+ visit(group.Sections)
+ }
+ }
+ visit(config.Sections())
+ menu := &quickSettings{}
+ for _, section := range config.Ordered_sections(sections, []string{"presentation.speech", "reading", "keyboard"}) {
+ category := quickCategory{label: section.Label}
+ for _, definition := range section.Settings {
+ if definition.ChoiceSource == "speech-module" && driver.Modules == nil {
+ continue
+ }
+ if (definition.ChoiceSource == "speech-language" || definition.ChoiceSource == "speech-voice") && driver.Voices == nil {
+ continue
+ }
+ if definition.QuickLabel != "" {
+ category.entries = append(category.entries, definition)
+ }
+ }
+ if section.ID == "presentation.speech" {
+ ranks := map[string]int{"rate": 0, "pitch": 1, "volume": 2, "module": 3, "language": 4, "voice": 5}
+ sort.SliceStable(category.entries, func(i, j int) bool {
+ return ranks[strings.TrimPrefix(category.entries[i].Path, "presentation.speech.")] < ranks[strings.TrimPrefix(category.entries[j].Path, "presentation.speech.")]
+ })
+ }
+ if len(category.entries) > 0 {
+ menu.categories = append(menu.categories, category)
+ }
+ }
+ return menu
+}
+
+// Overlay only the curated quick settings, preserving file-only and session
+// choices such as automatic reading, mute and the attribute feedback override.
+func copy_quick_settings(target *config.Settings, source config.Settings) {
+ for _, definition := range config.Definitions() {
+ if definition.QuickLabel != "" && !speech_choice(definition.ChoiceSource) {
+ if err := definition.Set(target, definition.Value(source)); err != nil {
+ panic(err)
+ }
+ }
+ }
+}
+
+func speech_parameters(settings config.Settings) speech.Parameters {
+ voice := settings.Presentation.Speech
+ return speech.Parameters{Rate: voice.Rate, Pitch: voice.Pitch, Volume: voice.Volume}
+}
+
+func (q *quickSettings) command(id command.ID, settings *config.Settings, voice Speaker) bool {
+ switch id {
+ case command.CurrentQuickEntry, command.CurrentQuickValue, command.NextQuickEntry, command.PreviousQuickEntry, command.NextQuickValue, command.PreviousQuickValue, command.NextQuickCategory, command.PreviousQuickCategory:
+ default:
+ return false
+ }
+ if len(q.categories) == 0 {
+ voice.Say(i18n.T("Quick menu not available"))
+ return true
+ }
+ categoryChanged := id == command.NextQuickCategory || id == command.PreviousQuickCategory
+ if categoryChanged {
+ step := 1
+ if id == command.PreviousQuickCategory {
+ step = -1
+ }
+ q.category = (q.category + step + len(q.categories)) % len(q.categories)
+ }
+ category := &q.categories[q.category]
+ switch id {
+ case command.NextQuickEntry:
+ category.position = (category.position + 1) % len(category.entries)
+ case command.PreviousQuickEntry:
+ category.position = (category.position + len(category.entries) - 1) % len(category.entries)
+ }
+ entry := category.entries[category.position]
+ previous := entry.Value(*settings)
+ adjusting := id == command.NextQuickValue || id == command.PreviousQuickValue
+ step := 1
+ if id == command.PreviousQuickValue {
+ step = -1
+ }
+ var text string
+ switch {
+ case entry.Kind == reflect.Bool:
+ value := entry.Value(*settings).(bool)
+ if adjusting {
+ value = !value
+ if err := entry.Set(settings, value); err != nil {
+ panic(err)
+ }
+ q.changed = true
+ }
+ text = "unchecked"
+ if value {
+ text = "checked"
+ }
+ text = i18n.T(text)
+ case entry.ChoiceSource != "":
+ choices := entry.Choices(*settings)
+ dynamic := entry.ChoiceSource == "speech-module" || entry.ChoiceSource == "speech-language" || entry.ChoiceSource == "speech-voice"
+ if dynamic {
+ choices = q.discovery.Choices(*settings)[entry.Path]
+ }
+ position := 0
+ for index, choice := range choices {
+ if choice.ID == entry.Value(*settings).(string) {
+ position = index
+ break
+ }
+ }
+ if adjusting {
+ position = (position + step + len(choices)) % len(choices)
+ if dynamic && q.selectSpeech != nil {
+ candidate := *settings
+ if err := entry.Set(&candidate, choices[position].ID); err != nil {
+ panic(err)
+ }
+ if entry.ChoiceSource == "speech-module" {
+ candidate.Presentation.Speech.Language = ""
+ candidate.Presentation.Speech.Voice = ""
+ }
+ if entry.ChoiceSource == "speech-language" {
+ candidate.Presentation.Speech.Voice = ""
+ }
+ message := i18n.T(choices[position].Label)
+ if choices[position].ID == "" {
+ message = i18n.T(entry.QuickLabel) + " " + i18n.T("default")
+ }
+ q.selectSpeech(candidate.Presentation.Speech, func(err error, present bool) {
+ if err != nil {
+ slog.Warn("speech selection failed", "error", err, "at", time.Now())
+ if present {
+ voice.Say(i18n.T("Voice test failed"))
+ }
+ return
+ }
+ copy_speech_selection(&settings.Presentation.Speech, candidate.Presentation.Speech)
+ q.changed = true
+ q.selectionChanged = true
+ for _, path := range []string{"presentation.speech.module", "presentation.speech.language", "presentation.speech.voice"} {
+ q.mark_changed(path)
+ }
+ if present {
+ voice.Say(message)
+ }
+ })
+ return true
+ }
+ if err := entry.Set(settings, choices[position].ID); err != nil {
+ panic(err)
+ }
+ q.changed = true
+ }
+ text = i18n.T(choices[position].Label)
+ default:
+ value := entry.Value(*settings).(int)
+ if adjusting {
+ // Python's 0.05 normalized step on the 200-point SSIP scale.
+ next := max(int(entry.Min), min(int(entry.Max), value+step*10))
+ if next != value {
+ if err := entry.Set(settings, next); err != nil {
+ panic(err)
+ }
+ voice.Configure(speech_parameters(*settings))
+ q.changed = true
+ value = next
+ }
+ }
+ text = strconv.Itoa(value)
+ }
+ if !reflect.DeepEqual(previous, entry.Value(*settings)) {
+ q.mark_changed(entry.Path)
+ }
+ defaultSpeech := speech_choice(entry.ChoiceSource) && entry.Value(*settings).(string) == ""
+ if defaultSpeech {
+ text = i18n.T(entry.QuickLabel) + " " + i18n.T("default")
+ } else if categoryChanged || id == command.CurrentQuickEntry || id == command.NextQuickEntry || id == command.PreviousQuickEntry {
+ text = i18n.T(entry.QuickLabel) + " " + text
+ }
+ if categoryChanged {
+ text = i18n.T(category.label) + ". " + text
+ }
+ voice.Say(text)
+ return true
+}
+
+func (q *quickSettings) mark_changed(path string) {
+ if q.fields == nil {
+ q.fields = make(map[string]uint64)
+ }
+ q.revision++
+ q.fields[path] = q.revision
+}
+
+// Direct saves keep disk I/O off the input loop. Only one save is admitted at
+// a time; the captured settings remain an immutable snapshot for that write.
+type settingsSaver struct {
+ busy bool
+ results chan error
+ snapshot config.Settings
+ revision uint64
+ reply chan string
+ menuDone func(error)
+}
+
+func (s *settingsSaver) save(ctx context.Context, path string, settings config.Settings) bool {
+ return s.start(ctx, settings, func() error {
+ doc, err := config.Open_document(path, settings)
+ if err != nil {
+ return err
+ }
+ return doc.Save(settings)
+ })
+}
+
+func (s *settingsSaver) start(ctx context.Context, settings config.Settings, write func() error) bool {
+ if s.busy {
+ return false
+ }
+ s.busy = true
+ s.snapshot = settings
+ s.reply = nil
+ go func() {
+ err := write()
+ select {
+ case s.results <- err:
+ case <-ctx.Done():
+ }
+ }()
+ return true
+}
+
+func (s *settingsSaver) result(err error, voice Speaker) {
+ s.busy = false
+ if err != nil {
+ slog.Warn("save settings failed", "error", err, "at", time.Now())
+ voice.Say(i18n.T("Failed to save settings."))
+ return
+ }
+ voice.Say(i18n.T("Settings saved."))
+}
diff --git a/internal/app/quick_settings_test.go b/internal/app/quick_settings_test.go
new file mode 100644
index 00000000..a2ce34d1
--- /dev/null
+++ b/internal/app/quick_settings_test.go
@@ -0,0 +1,477 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package app
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/input"
+ "git.stormux.org/storm/fenrir/internal/interaction"
+ "git.stormux.org/storm/fenrir/internal/screen"
+ "git.stormux.org/storm/fenrir/internal/speech"
+)
+
+func TestQuickSpeechSelectionAdjustmentAndBounds(t *testing.T) {
+ settings := config.Defaults()
+ voice := &fakeVoice{}
+ menu := new_quick_settings("speech-dispatcher")
+ for _, testCase := range []struct {
+ id command.ID
+ want string
+ }{
+ {command.CurrentQuickEntry, "speech rate 80"},
+ {command.NextQuickValue, "90"},
+ {command.NextQuickValue, "100"},
+ {command.NextQuickValue, "100"},
+ {command.NextQuickEntry, "speech pitch 0"},
+ {command.PreviousQuickValue, "-10"},
+ {command.CurrentQuickValue, "-10"},
+ {command.NextQuickEntry, "speech volume 100"},
+ {command.PreviousQuickValue, "90"},
+ {command.NextQuickEntry, "Speech Dispatcher module default"},
+ {command.NextQuickEntry, "speech language default"},
+ {command.NextQuickEntry, "speech voice default"},
+ {command.CurrentQuickValue, "speech voice default"},
+ {command.NextQuickValue, "speech voice default"},
+ {command.NextQuickEntry, "speech rate 100"},
+ {command.PreviousQuickEntry, "speech voice default"},
+ {command.PreviousQuickEntry, "speech language default"},
+ {command.PreviousQuickEntry, "Speech Dispatcher module default"},
+ {command.PreviousQuickEntry, "speech volume 90"},
+ } {
+ if !menu.command(testCase.id, &settings, voice) || voice.messages[len(voice.messages)-1] != testCase.want {
+ t.Fatal(testCase, voice.messages)
+ }
+ }
+ for range 30 {
+ menu.command(command.PreviousQuickValue, &settings, voice)
+ }
+ if settings.Presentation.Speech.Volume != -100 || !menu.changed || settings.Validate() != nil {
+ t.Fatal(settings, menu.changed)
+ }
+ if voice.parameters[len(voice.parameters)-1] != (speech.Parameters{Rate: 100, Pitch: -10, Volume: -100}) {
+ t.Fatal(voice.parameters)
+ }
+ if menu.command(command.CurrentLine, &settings, voice) {
+ t.Fatal("quick menu consumed an unrelated command")
+ }
+}
+
+func TestHardwareQuickSettingsOnlyOfferSupportedControls(t *testing.T) {
+ for _, driver := range []string{"dectalk", "dectalk-usb", "litetalk", "doubletalk", "tripletalk"} {
+ settings := config.Defaults()
+ settings.Presentation.Speech.Driver = driver
+ settings.Presentation.Speech.HardwareDevice = "/dev/fixture-synth"
+ menu, voice := new_quick_settings(driver), &fakeVoice{}
+ menu.selectSpeech = func(config.Speech, func(error, bool)) {
+ t.Fatal("hardware numeric adjustment tried reopening the device")
+ }
+ for _, want := range []string{"speech pitch 0", "speech volume 100", "speech rate 80"} {
+ menu.command(command.NextQuickEntry, &settings, voice)
+ if got := voice.messages[len(voice.messages)-1]; got != want {
+ t.Fatal(driver, got, want)
+ }
+ }
+ menu.command(command.NextQuickValue, &settings, voice)
+ if settings.Presentation.Speech.Rate != 90 || voice.parameters[len(voice.parameters)-1].Rate != 90 {
+ t.Fatal(settings, voice.parameters)
+ }
+ }
+}
+
+func TestQuickChangesSurviveMenuCancelAndExplicitSaveThenQuit(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "settings.toml")
+ base := config.Defaults()
+ base.General.ScriptKeys = []string{"KEY_COMPOSE", "KEY_F9"}
+ if err := config.Save(path, base); err != nil {
+ t.Fatal(err)
+ }
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 5, Cells: []rune("hello")}}
+ voice, keys := &fakeVoice{}, &fakeKeys{}
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ keys.poll = func(_ bool, handle func(input.Decision)) error {
+ keys.poll = nil
+ for _, id := range []command.ID{command.NextQuickValue, command.NextQuickEntry, command.PreviousQuickValue, command.AutoRead, command.ToggleTracking, command.SaveSettings, command.Quit} {
+ handle(input.Decision{Command: id, Interrupt: true})
+ }
+ return nil
+ }
+ if err := run(ctx, source, keys, voice, nil, RunOptions{Settings: base, ConfigPath: path}); err != nil {
+ t.Fatal(err)
+ }
+ saved, _, err := config.Load(path, nil)
+ if err != nil || saved.Presentation.Speech.Rate != 90 || saved.Presentation.Speech.Pitch != -10 || !saved.Reading.Automatic || saved.Reading.Mode != "table" || !reflect.DeepEqual(saved.General, base.General) {
+ t.Fatal(saved, err)
+ }
+ if !strings.Contains(strings.Join(voice.messages, "|"), "Settings saved.") {
+ t.Fatal(voice.messages)
+ }
+ info, err := os.Stat(path)
+ if err != nil || info.Mode().Perm() != 0600 {
+ t.Fatal(info, err)
+ }
+
+ // Opening the full menu reflects unsaved quick adjustments; cancelling
+ // it leaves the running values and the existing disk file intact.
+ menu := new_quick_settings("speech-dispatcher")
+ menu.command(command.NextQuickValue, &base, voice)
+ ui := new_internal_ui(context.Background(), keys, voice)
+ ui.open_settings(base, path, base)
+ receive_ui(t, ui)
+ if got := ui.session.Values()["presentation.speech.pitch"].Number; got != 0 {
+ t.Fatal(got)
+ }
+ ui.handle(interaction.Key{Name: "escape"})
+ stillSaved, _, err := config.Load(path, nil)
+ if err != nil || !reflect.DeepEqual(saved, stillSaved) {
+ t.Fatal(stillSaved, err)
+ }
+}
+
+func TestSessionMuteDoesNotPersistAcrossRestart(t *testing.T) {
+ for _, trial := range []struct {
+ name string
+ enabled bool
+ toggles int
+ save bool
+ }{
+ {name: "quit while muted", enabled: true, toggles: 1},
+ {name: "save while muted", enabled: true, toggles: 1, save: true},
+ {name: "save after unmuting", enabled: true, toggles: 2, save: true},
+ {name: "configured speech disabled", toggles: 1, save: true},
+ } {
+ t.Run(trial.name, func(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "settings.toml")
+ settings := config.Defaults()
+ settings.Presentation.Speech.Enabled = trial.enabled
+ if err := config.Save(path, settings); err != nil {
+ t.Fatal(err)
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ for session := range 2 {
+ voice := &fakeVoice{}
+ var speaker Speaker = voice
+ if !settings.Presentation.Speech.Enabled {
+ speaker = disabledVoice{}
+ }
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 5, Cells: []rune("hello")}}
+ keys := &fakeKeys{}
+ keys.poll = func(_ bool, handle func(input.Decision)) error {
+ keys.poll = nil
+ if session == 0 {
+ for range trial.toggles {
+ handle(input.Decision{Command: command.Mute, Interrupt: true})
+ }
+ if trial.enabled && voice.muted != (trial.toggles%2 == 1) {
+ t.Fatal("session mute did not toggle", voice.muted)
+ }
+ if trial.save {
+ handle(input.Decision{Command: command.NextQuickValue})
+ handle(input.Decision{Command: command.SaveSettings})
+ }
+ } else if trial.enabled && !strings.Contains(strings.Join(voice.messages, "|"), "Start Fenrir") {
+ t.Fatal("speech did not resume after restart", voice.messages)
+ }
+ handle(input.Decision{Command: command.Quit})
+ return nil
+ }
+ if err := run(ctx, source, keys, speaker, nil, RunOptions{Settings: settings, ConfigPath: path}); err != nil {
+ t.Fatal(err)
+ }
+ var err error
+ settings, _, err = config.Load(path, nil)
+ if err != nil || settings.Presentation.Speech.Enabled != trial.enabled {
+ t.Fatal("session mute changed saved speech enablement", settings.Presentation.Speech, err)
+ }
+ if trial.save && settings.Presentation.Speech.Rate != 90 {
+ t.Fatal("quick speech adjustment was not saved", settings.Presentation.Speech)
+ }
+ }
+ })
+ }
+}
+
+func TestSessionAutomaticReadingDoesNotPersistAcrossRestart(t *testing.T) {
+ for _, trial := range []struct {
+ name string
+ automatic bool
+ save bool
+ }{
+ {"quit with reading off", true, false},
+ {"save with reading off", true, true},
+ {"configured reading off, quit with reading on", false, false},
+ {"configured reading off, save with reading on", false, true},
+ } {
+ t.Run(trial.name, func(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "settings.toml")
+ settings := config.Defaults()
+ settings.Reading.Automatic = trial.automatic
+ if err := config.Save(path, settings); err != nil {
+ t.Fatal(err)
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ for session := range 2 {
+ voice := &fakeVoice{}
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 5, Cells: []rune("hello")}}
+ keys := &fakeKeys{poll: func(_ bool, handle func(input.Decision)) error {
+ hasIncomingText := strings.Contains(strings.Join(voice.messages, "|"), "hello")
+ if hasIncomingText != trial.automatic {
+ t.Fatalf("session %d: startup reading=%v, want %v; speech=%v", session, hasIncomingText, trial.automatic, voice.messages)
+ }
+ if session == 0 {
+ handle(input.Decision{Command: command.AutoRead, Interrupt: true})
+ want := "autoread enabled"
+ if trial.automatic {
+ want = "autoread disabled"
+ }
+ if voice.messages[len(voice.messages)-1] != want {
+ t.Fatal(voice.messages)
+ }
+ if trial.save {
+ handle(input.Decision{Command: command.NextQuickValue})
+ handle(input.Decision{Command: command.SaveSettings})
+ }
+ } else {
+ // Even an explicitly disabled automatic-reading default
+ // must leave manual speech available on a new session.
+ voice.messages = nil
+ handle(input.Decision{Command: command.CurrentLine, Interrupt: true})
+ if !reflect.DeepEqual(voice.messages, []string{"hello"}) {
+ t.Fatal(voice.messages)
+ }
+ }
+ handle(input.Decision{Command: command.Quit})
+ return nil
+ }}
+ if err := run(ctx, source, keys, voice, nil, RunOptions{Settings: settings, ConfigPath: path}); err != nil {
+ t.Fatal(err)
+ }
+ var err error
+ settings, _, err = config.Load(path, nil)
+ if err != nil || settings.Reading.Automatic != trial.automatic || !settings.Presentation.Speech.Enabled {
+ t.Fatal("session reading toggle changed saved startup behavior", settings, err)
+ }
+ if trial.save && settings.Presentation.Speech.Rate != 90 {
+ t.Fatal("quick adjustment was not saved", settings.Presentation.Speech)
+ }
+ }
+ })
+ }
+}
+
+func TestSaveFailureKeepsRunningQuickSettings(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "not-a-directory")
+ if err := os.WriteFile(path, []byte("fixture"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ settings := config.Defaults()
+ voice := &fakeVoice{}
+ menu := new_quick_settings("speech-dispatcher")
+ menu.command(command.NextQuickValue, &settings, voice)
+ saver := &settingsSaver{results: make(chan error, 1)}
+ saver.save(context.Background(), filepath.Join(path, "settings.toml"), settings)
+ select {
+ case err := <-saver.results:
+ if err == nil {
+ t.Fatal("invalid destination accepted")
+ }
+ saver.result(err, voice)
+ case <-time.After(time.Second):
+ t.Fatal("save hung")
+ }
+ if saver.busy || settings.Presentation.Speech.Rate != 90 || voice.messages[len(voice.messages)-1] != "Failed to save settings." {
+ t.Fatal(settings, voice.messages)
+ }
+}
+
+func TestQuickCategoriesRememberSelectionAndWrap(t *testing.T) {
+ settings := config.Defaults()
+ voice := &fakeVoice{}
+ menu := new_quick_settings("speech-dispatcher")
+ for _, trial := range []struct {
+ id command.ID
+ want string
+ }{
+ {command.NextQuickEntry, "speech pitch 0"},
+ {command.NextQuickCategory, "Reading. Punctuation level some"},
+ {command.NextQuickValue, "most"},
+ {command.NextQuickValue, "all"},
+ {command.NextQuickValue, "none"},
+ {command.PreviousQuickValue, "all"},
+ {command.PreviousQuickEntry, "Attributes none"},
+ {command.NextQuickEntry, "Punctuation level all"},
+ {command.NextQuickEntry, "Indentation none"},
+ {command.NextQuickCategory, "Keyboard. Character echo checked"},
+ {command.NextQuickValue, "unchecked"},
+ {command.PreviousQuickEntry, "Echo on CapsLock unchecked"},
+ {command.NextQuickCategory, "Speech. speech pitch 0"},
+ {command.PreviousQuickCategory, "Keyboard. Echo on CapsLock unchecked"},
+ {command.PreviousQuickCategory, "Reading. Indentation none"},
+ {command.PreviousQuickCategory, "Speech. speech pitch 0"},
+ {command.PreviousQuickCategory, "Keyboard. Echo on CapsLock unchecked"},
+ } {
+ menu.command(trial.id, &settings, voice)
+ if got := voice.messages[len(voice.messages)-1]; got != trial.want {
+ t.Fatalf("%s: got %q, want %q", trial.id, got, trial.want)
+ }
+ }
+ if menu := new_quick_settings("speech-dispatcher"); !menu.command(command.CurrentQuickEntry, &settings, voice) || voice.messages[len(voice.messages)-1] != "speech rate 80" {
+ t.Fatal("new session did not reset selection", voice.messages)
+ }
+}
+
+func TestQuickEchoCombinationsAndMenuCancelOrSave(t *testing.T) {
+ for mask := range 8 {
+ for _, save := range []bool{false, true} {
+ path := filepath.Join(t.TempDir(), "settings.toml")
+ original := config.Defaults()
+ if err := config.Save(path, original); err != nil {
+ t.Fatal(err)
+ }
+ original, _, err := config.Load(path, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ settings := original
+ voice := &fakeVoice{}
+ menu := new_quick_settings("speech-dispatcher")
+ menu.command(command.NextQuickCategory, &settings, voice)
+ menu.command(command.PreviousQuickValue, &settings, voice) // punctuation none
+ menu.command(command.NextQuickCategory, &settings, voice)
+ for index := range 3 {
+ entry := menu.categories[menu.category].entries[index]
+ if entry.Value(settings).(bool) != (mask&(1< 1 {
+ keys.events <- nil
+ }
+ }
+ keys.poll = func(_ bool, handle func(input.Decision)) error {
+ polls++
+ switch polls {
+ case 1:
+ handle(input.Decision{Command: command.PreviousQuickCategory})
+ for index := range 3 {
+ initial := index == 0
+ if initial != (mask&(1<.
+
+package app
+
+import (
+ "fmt"
+ "log/slog"
+ "math"
+ "strconv"
+ "strings"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/control"
+ "git.stormux.org/storm/fenrir/internal/input"
+)
+
+// Python's section#setting=value wire syntax remains distinct from TOML disk
+// settings. Apply an entire request to a copy and validate before changing the
+// reader. Unsupported Python-only features are reported, never silently saved.
+func remote_settings(base config.Settings, text string) (config.Settings, error) {
+ verb, parameters, _ := strings.Cut(text, " ")
+ if strings.ToUpper(verb) != "SET" {
+ return base, fmt.Errorf("Unsupported remote settings operation: %s", verb)
+ }
+ updated := base
+ cursorTracking, highlightTracking := base.Reading.Mode == "cursor", base.Reading.Mode == "highlight"
+ focusChanged, modeChanged := false, false
+ for _, assignment := range remote_assignments(parameters) {
+ path, _, _ := strings.Cut(assignment, "=")
+ switch strings.ToLower(strings.TrimSpace(path)) {
+ case "speech#module":
+ updated.Presentation.Speech.Language = ""
+ updated.Presentation.Speech.Voice = ""
+ case "speech#language":
+ updated.Presentation.Speech.Voice = ""
+ }
+ }
+ for _, assignment := range remote_assignments(parameters) {
+ path, value, ok := strings.Cut(assignment, "=")
+ if !ok {
+ return base, fmt.Errorf("Invalid remote setting assignment")
+ }
+ path, value = strings.ToLower(strings.TrimSpace(path)), strings.TrimSpace(value)
+ var target *bool
+ switch path {
+ case "reading#mode":
+ updated.Reading.Mode = value
+ modeChanged = true
+ continue
+ case "focus#cursor":
+ target, focusChanged = &cursorTracking, true
+ case "focus#highlight":
+ target, focusChanged = &highlightTracking, true
+ case "speech#module":
+ updated.Presentation.Speech.Module = value
+ continue
+ case "speech#language":
+ updated.Presentation.Speech.Language = value
+ continue
+ case "speech#voice":
+ updated.Presentation.Speech.Voice = value
+ continue
+ case "speech#rate", "speech#pitch", "speech#volume", "sound#volume":
+ // Python callers use normalized values, independently of Go's
+ // on-disk SSIP scale. Reject unsupported ranges before conversion.
+ level, err := strconv.ParseFloat(value, 64)
+ if err != nil || math.IsNaN(level) || math.IsInf(level, 0) || level < 0 || level > 1 {
+ return base, fmt.Errorf("Remote setting %s must be a finite number between 0 and 1", path)
+ }
+ switch path {
+ case "speech#rate":
+ updated.Presentation.Speech.Rate = int(-100 + level*200)
+ case "speech#pitch":
+ updated.Presentation.Speech.Pitch = int(-100 + level*200)
+ case "speech#volume":
+ updated.Presentation.Speech.Volume = int(-100 + level*200)
+ case "sound#volume":
+ updated.Presentation.Sound.Volume = level
+ }
+ continue
+ case "general#punctuation_level":
+ updated.Reading.PunctuationLevel = value
+ continue
+ case "screen#ignore_screen":
+ updated.Screen.IgnoredVTs = []int{}
+ if value != "" {
+ for _, field := range strings.Split(value, ",") {
+ number, err := strconv.Atoi(strings.TrimSpace(field))
+ if err != nil {
+ return base, fmt.Errorf("Invalid ignored VT number")
+ }
+ updated.Screen.IgnoredVTs = append(updated.Screen.IgnoredVTs, number)
+ }
+ }
+ continue
+ case "keyboard#char_echo_mode":
+ mode, err := strconv.Atoi(value)
+ if err != nil || mode < 0 || mode > 2 {
+ return base, fmt.Errorf("Invalid character echo mode")
+ }
+ updated.Keyboard.CharacterEcho, updated.Keyboard.EchoOnCapsLock = mode == 1, mode == 2
+ continue
+ case "speech#enabled":
+ target = &updated.Presentation.Speech.Enabled
+ case "sound#progress_monitoring":
+ target = &updated.Presentation.Progress.Enabled
+ case "sound#enabled":
+ target = &updated.Presentation.Sound.Enabled
+ case "speech#auto_read_incoming", "reading#automatic":
+ target = &updated.Reading.Automatic
+ case "reading#cursor_echo":
+ target = &updated.Reading.CursorEcho
+ case "keyboard#word_echo":
+ target = &updated.Keyboard.WordEcho
+ case "keyboard#echo_on_capslock":
+ target = &updated.Keyboard.EchoOnCapsLock
+ default:
+ return base, fmt.Errorf("Unsupported remote setting: %s", path)
+ }
+ parsed, err := strconv.ParseBool(value)
+ if err != nil {
+ return base, fmt.Errorf("Invalid boolean remote setting: %s", path)
+ }
+ *target = parsed
+ }
+ if focusChanged {
+ if modeChanged {
+ return base, fmt.Errorf("Use reading#mode or focus#cursor/focus#highlight in one request, not both")
+ }
+ if cursorTracking && highlightTracking {
+ return base, fmt.Errorf("Cursor and highlight tracking cannot both be enabled")
+ }
+ updated.Reading.Mode = "table"
+ if cursorTracking {
+ updated.Reading.Mode = "cursor"
+ } else if highlightTracking {
+ updated.Reading.Mode = "highlight"
+ }
+ }
+ if err := updated.Validate(); err != nil {
+ return base, err
+ }
+ return updated, nil
+}
+
+// Python lists use commas inside a value. Preserve those while retaining the
+// existing comma-separated assignments when a new path=value begins.
+func remote_assignments(parameters string) []string {
+ var assignments []string
+ for _, group := range strings.Split(parameters, ";") {
+ parts := strings.Split(group, ",")
+ assignment := parts[0]
+ for _, part := range parts[1:] {
+ if strings.Contains(part, "=") {
+ assignments = append(assignments, assignment)
+ assignment = part
+ } else {
+ assignment += "," + part
+ }
+ }
+ assignments = append(assignments, assignment)
+ }
+ return assignments
+}
+
+func (a *application) remote_request(request control.Request) {
+ response := ""
+ deferred := false
+ if !remote_authorized(request.UID, a.lastTerminal, a.options.TerminalMode) {
+ response = "Remote control is not permitted for this session"
+ } else {
+ section, text, _ := strings.Cut(request.Text, " ")
+ switch strings.ToUpper(section) {
+ case "COMMAND":
+ if !a.settings.live.Remote.EnableCommandRemote {
+ response = "Command remote control is disabled"
+ break
+ }
+ if reply, handled := a.remote_window(text); handled {
+ response = reply
+ break
+ }
+ verb, parameter, _ := strings.Cut(text, " ")
+ switch strings.ToUpper(verb) {
+ case "INTERRUPT", "TEMPDISABLESPEECH", "QUITAPPLICATION", "EXPORTCLIPBOARD":
+ if strings.TrimSpace(parameter) != "" {
+ response = strings.ToUpper(verb) + " does not accept parameters"
+ }
+ }
+ if response != "" {
+ break
+ }
+ switch strings.ToUpper(verb) {
+ case "SAY":
+ a.present(parameter, "")
+ case "INTERRUPT":
+ a.voice.Interrupt()
+ case "TEMPDISABLESPEECH":
+ a.handle_input(input.Decision{Command: command.TempDisableSpeech, Interrupt: true})
+ case "QUITAPPLICATION":
+ a.handle_command(command.Quit)
+ case "CLIPBOARD":
+ a.clipboards.add(parameter, true)
+ case "EXPORTCLIPBOARD":
+ a.clipboards.command(command.ExportClipboard)
+ default:
+ response = "Unsupported remote command: " + verb
+ }
+ case "SETTING":
+ if !a.settings.live.Remote.EnableSettingsRemote {
+ response = "Settings remote control is disabled"
+ break
+ }
+ if reply, handled := a.remote_window(text); handled {
+ response = reply
+ break
+ }
+ response, deferred = a.remote_settings_request(text, request)
+ default:
+ response = "Unknown command format. Use 'COMMAND ...' or 'SETTING ...'"
+ }
+ }
+ if deferred {
+ return // The save result owns the original reply channel.
+ }
+ remote_reply(request.Reply, response)
+}
+
+func remote_reply(reply chan string, response string) {
+ if response != "" {
+ slog.Warn("remote request rejected", "error", response, "at", time.Now())
+ }
+ select {
+ case reply <- response:
+ default:
+ }
+}
+
+// Resolve paired focus flags at admission. A physical tracking command during
+// asynchronous voice preparation must not make a validated request invalid
+// after its speaking transport has already been committed.
+func resolved_remote_selection(text string, admitted config.Settings) string {
+ _, parameters, _ := strings.Cut(text, " ")
+ var kept []string
+ focus := false
+ for _, assignment := range remote_assignments(parameters) {
+ path, _, _ := strings.Cut(assignment, "=")
+ switch strings.ToLower(strings.TrimSpace(path)) {
+ case "focus#cursor", "focus#highlight":
+ focus = true
+ default:
+ kept = append(kept, assignment)
+ }
+ }
+ if !focus {
+ return text
+ }
+ kept = append(kept, "reading#mode="+admitted.Reading.Mode)
+ return "SET " + strings.Join(kept, ";")
+}
diff --git a/internal/app/review_navigation_test.go b/internal/app/review_navigation_test.go
new file mode 100644
index 00000000..73e7508f
--- /dev/null
+++ b/internal/app/review_navigation_test.go
@@ -0,0 +1,52 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package app
+
+import (
+ "context"
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/input"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "git.stormux.org/storm/fenrir/internal/screen"
+ "reflect"
+ "testing"
+)
+
+func TestReviewNavigationContextSoundFallbackAndInterruption(t *testing.T) {
+ for _, sound := range []bool{false, true} {
+ for _, feedback := range []bool{false, true} {
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 2, Cols: 3, X: 2, Cells: []rune("abcĆ! ")}}
+ voice, sounds := &fakeVoice{}, &fakeSounds{}
+ keys := &fakeKeys{poll: func(_ bool, handle func(input.Decision)) error {
+ voice.messages, sounds.cues = nil, nil
+ handle(input.Decision{Command: command.NextChar, Interrupt: true})
+ handle(input.Decision{Command: command.NextChar, Interrupt: true})
+ handle(input.Decision{Command: command.LineStart, Interrupt: true})
+ handle(input.Decision{Command: command.Quit})
+ return nil
+ }}
+ settings := config.Defaults()
+ settings.Reading.RaisePitchForCapitals = false
+ settings.Presentation.Sound.Enabled = sound
+ settings.Review.LineBreak = feedback
+ if err := run(context.Background(), source, keys, voice, nil, RunOptions{Settings: settings, Sounds: sounds}); err != nil {
+ t.Fatal(err)
+ }
+ want := []string{"Ć"}
+ var wantCues []presentation.Cue
+ if feedback {
+ if sound {
+ wantCues = []presentation.Cue{presentation.LineEnd}
+ } else {
+ want = append(want, "line break")
+ }
+ }
+ want = append(want, "bang", "Ć", "beginning of line")
+ if !reflect.DeepEqual(voice.messages, want) || !reflect.DeepEqual(sounds.cues, wantCues) || voice.interrupts != 3 {
+ t.Fatalf("sound=%v feedback=%v: speech=%q cues=%v interrupts=%d", sound, feedback, voice.messages, sounds.cues, voice.interrupts)
+ }
+ }
+ }
+}
diff --git a/internal/app/review_spelling.go b/internal/app/review_spelling.go
new file mode 100644
index 00000000..151f0dbe
--- /dev/null
+++ b/internal/app/review_spelling.go
@@ -0,0 +1,35 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package app
+
+import (
+ "unicode"
+
+ "git.stormux.org/storm/fenrir/internal/i18n"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+)
+
+func (s *echoSpeaker) say_spelling(spelling presentation.Spelling) {
+ for _, char := range spelling.Text {
+ if !spelling.Phonetic {
+ s.say_character(char)
+ continue
+ }
+ text := presentation.Phonetic_letter(char)
+ if text == "" {
+ text = presentation.Character(char)
+ if s.punctuation != nil {
+ text = s.punctuation.Character(char)
+ }
+ }
+ if unicode.IsUpper(char) && s.settings != nil && s.settings.Reading.SayCapForCapitals {
+ text = i18n.T("cap") + " " + text
+ }
+ // Phonetics bypass character capital beeps/pitch and text processing:
+ // names (including punctuation within names) must be spoken only once.
+ s.Speaker.Say(text)
+ }
+ // Copy-last-echo retains the complete source, not the last spelled rune.
+ s.last = spelling.Text
+}
diff --git a/internal/app/review_spelling_test.go b/internal/app/review_spelling_test.go
new file mode 100644
index 00000000..6daea5a2
--- /dev/null
+++ b/internal/app/review_spelling_test.go
@@ -0,0 +1,112 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package app
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/input"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "git.stormux.org/storm/fenrir/internal/punctuation"
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+func TestSpellingPreservesRunesAndNamesWithoutDoubleProcessing(t *testing.T) {
+ for _, profile := range []string{"default", "fr"} {
+ processor, _, err := punctuation.Load([]string{"../../config/punctuation/" + profile + ".toml"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, level := range punctuation.Levels() {
+ for choices := range 8 {
+ settings := config.Defaults()
+ settings.Reading.RaisePitchForCapitals = choices&1 != 0
+ settings.Reading.BeepForCapitals = choices&2 != 0
+ settings.Reading.SayCapForCapitals = choices&4 != 0
+ voice := &capitalVoice{}
+ beeps := 0
+ echo := &echoSpeaker{Speaker: voice, settings: &settings, punctuation: processor, punctuationLevel: level,
+ play: func(presentation.Cue) bool { beeps++; return true }}
+ const source = "A book!?Ćēš\t"
+ echo.say_spelling(presentation.Spelling{Text: source, Phonetic: true})
+ prefix := ""
+ if settings.Reading.SayCapForCapitals {
+ prefix = "cap "
+ }
+ want := []string{prefix + "Alpha", processor.Character(' '), "bravo", "oscar", "oscar", "kilo", processor.Character('!'), processor.Character('?'), prefix + "Ć", "ē", "š", processor.Character('\t')}
+ if got := strings.Join(voice.messages, "|"); got != strings.Join(want, "|") || beeps != 0 || len(voice.pitches) != 0 {
+ t.Fatal(profile, level, choices, got, voice.pitches, beeps)
+ }
+ voice.messages = nil
+ echo.say_spelling(presentation.Spelling{Text: source})
+ want[0], want[2], want[3], want[4], want[5] = prefix+"A", "b", "o", "o", "k"
+ if got := strings.Join(voice.messages, "|"); got != strings.Join(want, "|") {
+ t.Fatal(profile, level, got)
+ }
+ wantPitch, wantBeeps := 0, 0
+ if settings.Reading.RaisePitchForCapitals {
+ wantPitch = 2
+ }
+ if settings.Reading.BeepForCapitals {
+ wantBeeps = 2
+ }
+ if len(voice.pitches) != wantPitch || beeps != wantBeeps || echo.last != source {
+ t.Fatal("ordinary capital behavior or last echo changed", voice.pitches, beeps, echo.last)
+ }
+ }
+ }
+ }
+}
+
+func TestReviewSpellingThroughSharedLoop(t *testing.T) {
+ processor, _, err := punctuation.Load([]string{"../../config/punctuation/default.toml"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, muted := range []bool{false, true} {
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 5, Cells: []rune("Book!"), X: 1}}
+ voice := &fakeVoice{muted: muted}
+ settings := config.Defaults()
+ settings.Reading.Automatic = false
+ settings.Reading.RaisePitchForCapitals = false
+ settings.Presentation.Speech.Enabled = !muted
+ settings.Presentation.Sound.Enabled = false
+ keys := &fakeKeys{poll: func(_ bool, handle func(input.Decision)) error {
+ voice.messages = nil
+ for _, trial := range []struct {
+ id command.ID
+ want string
+ }{
+ {command.SpellWord, "B|o|o|k|bang"},
+ {command.PhoneticWord, "Bravo|oscar|oscar|kilo|bang"},
+ {command.CopyLastEcho, "Book!"},
+ {command.CurrentClipboard, "Book!"},
+ {command.SpellLine, "B|o|o|k|bang"},
+ {command.PhoneticLine, "Bravo|oscar|oscar|kilo|bang"},
+ {command.PhoneticChar, "oscar"},
+ {command.CurrentChar, "o"},
+ {command.CurrentLine, "Book!"},
+ } {
+ voice.messages = nil
+ handle(input.Decision{Command: trial.id, Keypress: true, Interrupt: true})
+ want := trial.want
+ if muted {
+ want = ""
+ }
+ if got := strings.Join(voice.messages, "|"); got != want {
+ t.Fatal(muted, trial.id, got, want)
+ }
+ }
+ handle(input.Decision{Command: command.Quit})
+ return nil
+ }}
+ if err := run(context.Background(), source, keys, voice, nil, RunOptions{Settings: settings, Punctuation: processor}); err != nil {
+ t.Fatal(err)
+ }
+ }
+}
diff --git a/internal/app/runtime_settings.go b/internal/app/runtime_settings.go
new file mode 100644
index 00000000..9a6b06be
--- /dev/null
+++ b/internal/app/runtime_settings.go
@@ -0,0 +1,322 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package app
+
+import (
+ "fmt"
+ "strings"
+
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/i18n"
+ "git.stormux.org/storm/fenrir/internal/speech"
+)
+
+// applicationSettings separates the effective configuration from the save draft.
+// The draft retains next-launch menu choices; session mute, automatic reading,
+// and attribute overrides are not implicitly copied into it.
+type applicationSettings struct {
+ live config.Settings
+ saveDraft config.Settings
+ resetBase config.Settings // Last loaded or successfully saved settings.
+ remoteFields map[string]uint64
+ remoteRevision uint64
+ speechAvailable bool // The startup connection cannot be enabled by a remote setting.
+}
+
+// configure_reader applies startup settings, including next-launch-only fields.
+func (a *application) configure_reader() {
+ a.configure_screen()
+ a.engine.Configure(a.settings.live.Reading.Automatic, a.settings.live.Reading.CursorEcho)
+ a.engine.Configure_indentation(a.settings.live.Reading.Indentation)
+ a.engine.Configure_attributes(a.settings.live.Reading.Attributes, a.settings.live.Reading.AttributeFormat)
+ a.engine.Configure_progress(a.settings.live.Presentation.Progress.Enabled)
+ a.engine.Configure_tracking(a.settings.live.Reading.Mode)
+ a.engine.Configure_review(a.settings.live.Review.LineBreak, a.settings.live.Review.EndOfScreen, a.settings.live.Review.LeaveReviewOnCursorChange, a.settings.live.Review.LeaveReviewOnScreenChange)
+ a.engine.Configure_echo(a.settings.live.Keyboard.CharacterEcho, a.settings.live.Keyboard.WordEcho, a.settings.live.Keyboard.EchoOnCapsLock)
+}
+
+func (a *application) configure_screen() {
+ if !a.options.TerminalMode {
+ if source, ok := a.source.(interface{ Configure_ignored_vts([]int) }); ok {
+ source.Configure_ignored_vts(a.settings.live.Screen.IgnoredVTs)
+ }
+ }
+}
+
+// preview_spelling invalidates queued feedback when menu previews change.
+// The modal host restores the previous values on cancellation.
+func (a *application) preview_spelling(reading config.Reading) {
+ if a.settings.live.Reading.Spellcheck != reading.Spellcheck || a.settings.live.Reading.SpellcheckSound != reading.SpellcheckSound ||
+ a.settings.live.Reading.SpellcheckReadWord != reading.SpellcheckReadWord || a.settings.live.Reading.SpellcheckSayMisspelled != reading.SpellcheckSayMisspelled {
+ a.spellFeedback++
+ }
+ a.settings.live.Reading.Spellcheck = reading.Spellcheck
+ a.settings.live.Reading.SpellcheckReadWord = reading.SpellcheckReadWord
+ a.settings.live.Reading.SpellcheckSayMisspelled = reading.SpellcheckSayMisspelled
+ a.settings.live.Reading.SpellcheckSound = reading.SpellcheckSound
+}
+
+// apply_saved_settings keeps the complete saved draft but applies only fields
+// supported live. Device, layout and backend changes wait until the next launch.
+func (a *application) apply_saved_settings(saved config.Settings, speechApplied bool) {
+ if speechApplied {
+ copy_speech_selection(&a.settings.live.Presentation.Speech, saved.Presentation.Speech)
+ }
+ a.settings.saveDraft = saved
+ a.settings.resetBase = saved
+ a.settings.live.Screen = saved.Screen
+ a.configure_screen()
+ a.preview_spelling(saved.Reading)
+ a.settings.live.Presentation.DateTime = saved.Presentation.DateTime
+ a.settings.live.Reading.PunctuationLevel = saved.Reading.PunctuationLevel
+ a.settings.live.Reading.Emoticons = saved.Reading.Emoticons
+ a.settings.live.Reading.RepeatedSymbols = saved.Reading.RepeatedSymbols
+ a.settings.live.Reading.RepeatedSymbolsMin = saved.Reading.RepeatedSymbolsMin
+ a.settings.live.Reading.Attributes = saved.Reading.Attributes
+ a.settings.live.Reading.AttributeFormat = saved.Reading.AttributeFormat
+ a.engine.Configure_attributes(saved.Reading.Attributes, saved.Reading.AttributeFormat)
+ a.settings.live.Reading.Indentation = saved.Reading.Indentation
+ a.engine.Configure_indentation(saved.Reading.Indentation)
+ a.settings.live.Reading.RaisePitchForCapitals = saved.Reading.RaisePitchForCapitals
+ a.settings.live.Reading.BeepForCapitals = saved.Reading.BeepForCapitals
+ a.settings.live.Reading.SayCapForCapitals = saved.Reading.SayCapForCapitals
+ a.voice.punctuationLevel = saved.Reading.PunctuationLevel
+ a.settings.live.Presentation.Speech.Rate = saved.Presentation.Speech.Rate
+ a.settings.live.Presentation.Speech.Pitch = saved.Presentation.Speech.Pitch
+ a.settings.live.Presentation.Speech.Volume = saved.Presentation.Speech.Volume
+ a.voice.Configure(speech_parameters(a.settings.live))
+ a.settings.live.Keyboard.CharacterEcho = saved.Keyboard.CharacterEcho
+ a.settings.live.Keyboard.WordEcho = saved.Keyboard.WordEcho
+ a.settings.live.Keyboard.EchoOnCapsLock = saved.Keyboard.EchoOnCapsLock
+ a.engine.Configure_echo(a.settings.live.Keyboard.CharacterEcho, a.settings.live.Keyboard.WordEcho, a.settings.live.Keyboard.EchoOnCapsLock)
+ a.quick.changed = false
+ a.quick.selectionChanged = false
+ a.settings.live.Presentation.Sound.Volume = saved.Presentation.Sound.Volume
+ if a.cues != nil {
+ a.cues.Set_volume(saved.Presentation.Sound.Volume)
+ }
+ a.settings.live.Presentation.Progress = saved.Presentation.Progress
+ a.engine.Configure_progress(saved.Presentation.Progress.Enabled)
+ if a.cues != nil {
+ a.cues.Clear_progress()
+ }
+ a.settings.live.Clipboard = saved.Clipboard
+ a.clipboards.configure(saved.Clipboard)
+}
+
+// Quick commands have already changed live values and configured speech when
+// needed. Refresh the reader without copying session overrides into the draft.
+func (a *application) apply_quick_settings() {
+ a.voice.punctuationLevel = a.settings.live.Reading.PunctuationLevel
+ a.engine.Configure_echo(a.settings.live.Keyboard.CharacterEcho, a.settings.live.Keyboard.WordEcho, a.settings.live.Keyboard.EchoOnCapsLock)
+ a.engine.Configure_indentation(a.settings.live.Reading.Indentation)
+ a.engine.Configure_attributes(a.settings.live.Reading.Attributes, a.settings.live.Reading.AttributeFormat)
+}
+
+func (a *application) open_settings() {
+ if a.saver.busy || a.speechPending {
+ a.ui.failed("Could not open Fenrir Menu: {0}", fmt.Errorf("settings save in progress"))
+ return
+ }
+ a.settings.live.Reading.Mode = a.engine.Tracking_mode()
+ a.ui.speechOverride = nil
+ if a.quick.selectionChanged {
+ selected := a.settings.live.Presentation.Speech
+ a.ui.speechOverride = &selected
+ }
+ if a.quick.changed {
+ a.ui.open_settings(a.settings.live, a.options.ConfigPath, a.settings.live)
+ } else {
+ a.ui.open_settings(a.settings.live, a.options.ConfigPath)
+ }
+}
+
+func (a *application) save_settings() {
+ if a.speechPending {
+ a.voice.Say(i18n.T("Failed to save settings."))
+ return
+ }
+ if a.saver.save(a.ctx, a.options.ConfigPath, a.settings_to_save()) {
+ a.saver.revision = a.settings.remoteRevision
+ }
+}
+
+// Menu writes share admission and completion with quick/remote saves. Only
+// presentation belongs to the modal generation; a committed write belongs to
+// the application even after Escape or a session handoff closes its form.
+func (a *application) save_menu_settings(doc *config.Document, draft config.Settings, done func(error)) bool {
+ if a.speechPending {
+ a.ui.failed("Could not save settings: {0}", fmt.Errorf("speech selection in progress"))
+ return false
+ }
+ speechApplied := a.settings.speechAvailable && draft.Presentation.Speech.Driver == a.settings.live.Presentation.Speech.Driver && !same_speech_selection(draft.Presentation.Speech, a.settings.saveDraft.Presentation.Speech)
+ if !a.saver.start(a.ctx, draft, func() error {
+ var prepared speech.Transport
+ if speechApplied {
+ var err error
+ prepared, err = a.prepareSpeech(draft.Presentation.Speech)
+ if err != nil {
+ return err
+ }
+ }
+ if err := doc.Save(draft); err != nil {
+ if prepared != nil {
+ prepared.Close()
+ }
+ return err
+ }
+ if prepared != nil {
+ if err := a.replace_speech(prepared); err != nil {
+ return &savedSpeechError{err: err}
+ }
+ return nil
+ }
+ return nil
+ }) {
+ a.ui.failed("Could not save settings: {0}", fmt.Errorf("settings save in progress"))
+ return false
+ }
+ a.saver.revision = a.settings.remoteRevision
+ quickRevision := a.quick.revision
+ a.saver.menuDone = func(err error) {
+ if settings_committed(err) {
+ merged := draft
+ // settings_save_result already removed overrides included in the
+ // commit. Remaining remote fields were assigned after admission.
+ copy_remote_settings(&merged, a.settings.saveDraft, a.settings.remoteFields)
+ newerQuick := false
+ for _, definition := range config.Definitions() {
+ if a.quick.fields[definition.Path] > quickRevision {
+ if err := definition.Set(&merged, definition.Value(a.settings.live)); err != nil {
+ panic(err)
+ }
+ newerQuick = true
+ }
+ }
+ // Progress is shortcut/file-only and has no editable definition.
+ if a.quick.fields["presentation.progress.enabled"] > quickRevision {
+ merged.Presentation.Progress = a.settings.live.Presentation.Progress
+ }
+ a.apply_saved_settings(merged, speechApplied && err == nil)
+ a.settings.resetBase = draft // RESET reflects disk, not later overrides.
+ a.quick.changed = newerQuick
+ }
+ done(err)
+ }
+ return true
+}
+
+func (a *application) settings_to_save() config.Settings {
+ saved := a.settings.saveDraft
+ copy_quick_settings(&saved, a.settings.live)
+ if a.quick.selectionChanged {
+ copy_speech_selection(&saved.Presentation.Speech, a.settings.live.Presentation.Speech)
+ }
+ return saved
+}
+
+func (a *application) effective_settings() config.Settings {
+ base := a.settings.live
+ base.Presentation.Speech.Enabled = !a.muted
+ base.Reading.Automatic = a.engine.Automatic_reading()
+ base.Reading.Mode = a.engine.Tracking_mode()
+ return base
+}
+
+// Remote assignments are validated against effective session state, then
+// separately overlaid on the save draft so unrelated next-launch choices survive.
+func (a *application) apply_remote_settings(text string) string {
+ updated, err := remote_settings(a.effective_settings(), text)
+ if err != nil {
+ return err.Error()
+ }
+ if updated.Presentation.Speech.Enabled && !a.settings.speechAvailable {
+ return "Speech was disabled at startup; enable it in settings and relaunch"
+ }
+ // Apply only the requested assignments to the persistence
+ // draft, retaining unrelated next-launch menu settings.
+ draftBase := a.settings.saveDraft
+ _, parameters, _ := strings.Cut(text, " ")
+ for _, assignment := range remote_assignments(parameters) {
+ path, _, _ := strings.Cut(assignment, "=")
+ switch strings.ToLower(strings.TrimSpace(path)) {
+ case "reading#mode", "focus#cursor", "focus#highlight":
+ // Resolve paired flags against the running mode, even if the
+ // next-launch draft started with a different mode.
+ draftBase.Reading.Mode = a.engine.Tracking_mode()
+ }
+ }
+ persisted, err := remote_settings(draftBase, text)
+ if err != nil {
+ return err.Error()
+ }
+ a.settings.saveDraft = persisted
+ if !same_speech_selection(updated.Presentation.Speech, a.settings.live.Presentation.Speech) {
+ a.quick.selectionChanged = false
+ }
+ a.settings.remoteRevision++
+ restoreSpeech := false
+ for _, assignment := range remote_assignments(parameters) {
+ path, _, _ := strings.Cut(assignment, "=")
+ path = strings.ToLower(strings.TrimSpace(path))
+ a.settings.remoteFields[path] = a.settings.remoteRevision
+ if path == "speech#enabled" {
+ restoreSpeech = updated.Presentation.Speech.Enabled
+ }
+ }
+ a.apply_remote_state(updated, restoreSpeech)
+ return ""
+}
+
+func (a *application) apply_remote_state(updated config.Settings, restoreSpeech bool) {
+ base := a.effective_settings()
+ speechChanged := updated.Presentation.Speech.Enabled != !a.muted
+ restored := restoreSpeech && updated.Presentation.Speech.Enabled && a.temporarySilence
+ if restored {
+ a.temporarySilence = false
+ }
+ if speechChanged {
+ a.muted = a.voice.Toggle_mute()
+ }
+ a.settings.live = updated
+ a.configure_screen()
+ if speech_parameters(base) != speech_parameters(updated) {
+ a.voice.Configure(speech_parameters(updated))
+ }
+ a.voice.punctuationLevel = updated.Reading.PunctuationLevel
+ if base.Presentation.Sound.Volume != updated.Presentation.Sound.Volume && a.cues != nil {
+ a.cues.Set_volume(updated.Presentation.Sound.Volume)
+ }
+ a.engine.Configure(a.settings.live.Reading.Automatic, a.settings.live.Reading.CursorEcho)
+ a.engine.Configure_progress(a.settings.live.Presentation.Progress.Enabled)
+ if !a.settings.live.Presentation.Sound.Enabled && a.cues != nil {
+ a.cues.Clear_progress()
+ a.engine.Reset_progress()
+ }
+ a.engine.Configure_echo(a.settings.live.Keyboard.CharacterEcho, a.settings.live.Keyboard.WordEcho, a.settings.live.Keyboard.EchoOnCapsLock)
+ a.engine.Configure_tracking(a.settings.live.Reading.Mode)
+ if base.Reading.Indentation != updated.Reading.Indentation {
+ a.engine.Configure_indentation(updated.Reading.Indentation)
+ }
+ if base.Reading.Attributes != updated.Reading.Attributes || base.Reading.AttributeFormat != updated.Reading.AttributeFormat {
+ a.engine.Configure_attributes(updated.Reading.Attributes, updated.Reading.AttributeFormat)
+ }
+ if speechChanged || restored {
+ a.present_speech_state()
+ }
+}
diff --git a/internal/app/runtime_settings_test.go b/internal/app/runtime_settings_test.go
new file mode 100644
index 00000000..5e8c3985
--- /dev/null
+++ b/internal/app/runtime_settings_test.go
@@ -0,0 +1,119 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package app
+
+import (
+ "context"
+ "path/filepath"
+ "slices"
+ "strings"
+ "testing"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/control"
+ "git.stormux.org/storm/fenrir/internal/input"
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+func TestMenuRemoteAndQuickSavePreserveNextLaunchChoices(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ path := filepath.Join(t.TempDir(), "settings.toml")
+ base := config.Defaults()
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 1, Active: true, Rows: 1, Cols: 5, Cells: []rune("alpha")}}
+ voice := &fakeVoice{}
+ keys := &fakeKeys{poll: func(bool, func(input.Decision)) error { return nil }}
+ a, err := new_application(ctx, source, keys, voice, RunOptions{Settings: base, ConfigPath: path})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer a.ui.close()
+
+ // Exercise the successful menu-save callback with both live and next-launch
+ // choices, then interleave session commands, remote changes and a quick save.
+ menuSaved := base
+ menuSaved.Keyboard.Layout = "laptop"
+ menuSaved.Presentation.Speech.Module = "next-module"
+ menuSaved.Presentation.Speech.Rate = 60
+ menuSaved.Review.LineBreak = false
+ menuSaved.Presentation.Sound.Driver = "alsa"
+ menuSaved.Presentation.Sound.Theme = "fixture-pack"
+ menuSaved.Presentation.Sound.Device = "null"
+ if err := config.Save(path, menuSaved); err != nil {
+ t.Fatal(err)
+ }
+ a.apply_saved_settings(menuSaved, false)
+ if a.settings.live.Keyboard.Layout != base.Keyboard.Layout || a.settings.live.Presentation.Speech.Module != base.Presentation.Speech.Module || a.settings.live.Review != base.Review || a.settings.live.Presentation.Sound != base.Presentation.Sound {
+ t.Fatal("next-launch choices changed the running configuration")
+ }
+ if len(voice.parameters) == 0 || voice.parameters[len(voice.parameters)-1].Rate != 60 {
+ t.Fatal("menu speech adjustment did not apply live", voice.parameters)
+ }
+ for _, id := range []command.ID{command.Mute, command.AutoRead, command.ToggleAttributes} {
+ a.handle_input(input.Decision{Command: id})
+ }
+ for _, request := range []struct {
+ text string
+ want string
+ }{
+ {"SETTING SET speech#enabled=True;unknown#setting=True", "Unsupported remote setting"},
+ {"SETTING SET keyboard#word_echo=True", ""},
+ } {
+ replies := make(chan string, 1)
+ a.remote_request(control.Request{UID: 0, Text: request.text, Reply: replies})
+ if reply := <-replies; !strings.HasPrefix(reply, request.want) || request.want == "" && reply != "" {
+ t.Fatal("unexpected remote response", reply)
+ }
+ if !voice.muted || a.engine.Automatic_reading() {
+ t.Fatal("remote assignment changed unrelated session state")
+ }
+ }
+ for _, id := range []command.ID{command.NextQuickValue, command.SaveSettings, command.Quit} {
+ a.handle_input(input.Decision{Command: id})
+ }
+ if err := a.loop(nil); err != nil || ctx.Err() != nil {
+ t.Fatal("save and quit failed", err, ctx.Err())
+ }
+ saved, _, err := config.Load(path, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := menuSaved
+ want.Keyboard.WordEcho = true
+ want.Presentation.Speech.Rate = 70
+ if saved.Keyboard.Layout != want.Keyboard.Layout || saved.Presentation.Speech.Module != want.Presentation.Speech.Module || saved.Review != want.Review || saved.Presentation.Speech.Rate != want.Presentation.Speech.Rate || !saved.Keyboard.WordEcho || saved.Presentation.Sound != want.Presentation.Sound {
+ t.Fatal("mixed settings paths lost saved choices", saved)
+ }
+ if !saved.Presentation.Speech.Enabled || !saved.Reading.Automatic || saved.Reading.Attributes != base.Reading.Attributes {
+ t.Fatal("session overrides leaked into the save", saved)
+ }
+ freshVoice := &fakeVoice{}
+ fresh, err := new_application(ctx, source, &fakeKeys{}, freshVoice, RunOptions{Settings: saved})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer fresh.ui.close()
+ if err := fresh.read_screen(); err != nil || !slices.Contains(freshVoice.messages, "alpha") {
+ t.Fatal("saved defaults did not restore startup reading", err, freshVoice.messages)
+ }
+}
+
+func TestRemoteCannotEnableSpeechWithoutStartupConnection(t *testing.T) {
+ base := config.Defaults()
+ base.Presentation.Speech.Enabled = false
+ a, err := new_application(context.Background(), &fakeSource{}, &fakeKeys{}, disabledVoice{}, RunOptions{Settings: base})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer a.ui.close()
+ response := a.apply_remote_settings("SET speech#enabled=True;keyboard#word_echo=True")
+ if response != "Speech was disabled at startup; enable it in settings and relaunch" {
+ t.Fatal(response)
+ }
+ if !a.muted || a.settings.live.Presentation.Speech.Enabled || a.settings.live.Keyboard.WordEcho || a.settings.saveDraft.Presentation.Speech.Enabled || a.settings.saveDraft.Keyboard.WordEcho {
+ t.Fatal("rejected request partially changed live or saved settings")
+ }
+}
diff --git a/internal/app/screen_policy_test.go b/internal/app/screen_policy_test.go
new file mode 100644
index 00000000..31f64c9c
--- /dev/null
+++ b/internal/app/screen_policy_test.go
@@ -0,0 +1,138 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package app
+
+import (
+ "context"
+ "os"
+ "reflect"
+ "testing"
+
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/control"
+ "git.stormux.org/storm/fenrir/internal/input"
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+// Model the console's policy boundary independently of the shared application.
+type policySource struct {
+ fakeSource
+ ignoredVTs []int
+}
+
+func (s *policySource) Configure_ignored_vts(numbers []int) {
+ s.ignoredVTs = append([]int(nil), numbers...)
+}
+
+func (s *policySource) Read() (screen.Snapshot, error) {
+ for _, number := range s.ignoredVTs {
+ if number == s.snapshot.Terminal {
+ return screen.Snapshot{Terminal: number}, nil
+ }
+ }
+ return s.fakeSource.Read()
+}
+
+func (s *policySource) State() (int, bool, bool, error) {
+ snapshot, err := s.Read()
+ return snapshot.Terminal, snapshot.Active, snapshot.NumLock, err
+}
+
+func TestRemoteIgnoredVTListsAreAtomic(t *testing.T) {
+ base := config.Defaults()
+ base.Screen.IgnoredVTs = []int{2, 3}
+ for _, text := range []string{
+ "SET screen#ignore_screen=7, 8,10;speech#enabled=False",
+ "SET screen#ignore_screen=7, 8,10,speech#enabled=False",
+ } {
+ updated, err := remote_settings(base, text)
+ if err != nil || !reflect.DeepEqual(updated.Screen.IgnoredVTs, []int{7, 8, 10}) || updated.Presentation.Speech.Enabled {
+ t.Fatal("lost list or following assignment", updated, err)
+ }
+ }
+ for _, value := range []string{"0", "64", "-1", "7,7", "7,", "7,,8", "tty7", "7;unknown#setting=True"} {
+ failed, err := remote_settings(base, "SET speech#enabled=False;screen#ignore_screen="+value)
+ if err == nil || !reflect.DeepEqual(failed, base) || !reflect.DeepEqual(base.Screen.IgnoredVTs, []int{2, 3}) {
+ t.Fatal("invalid list partially applied", value, failed, base, err)
+ }
+ }
+ updated, err := remote_settings(base, "SET screen#ignore_screen=")
+ if err != nil || len(updated.Screen.IgnoredVTs) != 0 {
+ t.Fatal("empty list did not clear policy", updated.Screen, err)
+ }
+}
+
+func TestIgnoredVTStartupRemoteReleaseRecoveryAndTerminalIsolation(t *testing.T) {
+ for _, terminalMode := range []bool{false, true} {
+ settings := config.Defaults()
+ settings.Screen.IgnoredVTs = []int{2, 7, 8, 10}
+ source := &policySource{fakeSource: fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 5, Cells: []rune("alpha")}}}
+ voice := &fakeVoice{}
+ var modes []bool
+ keys := &fakeKeys{poll: func(active bool, _ func(input.Decision)) error {
+ modes = append(modes, active)
+ return nil
+ }}
+ a, err := new_application(context.Background(), source, keys, voice, RunOptions{Settings: settings, TerminalMode: terminalMode})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(a.ui.close)
+ if err := a.read_screen(); err != nil {
+ t.Fatal(err)
+ }
+ if err := a.poll_input(true); err != nil {
+ t.Fatal(err)
+ }
+ if a.lastActive != terminalMode || (!terminalMode && len(voice.messages) != 0) {
+ t.Fatal("wrong startup policy", terminalMode, a.lastActive, voice.messages)
+ }
+ request := func(uid int, text string) string {
+ t.Helper()
+ replies := make(chan string, 1)
+ a.remote_request(control.Request{UID: uid, Text: "SETTING " + text, Reply: replies})
+ return <-replies
+ }
+ if reply := request(os.Geteuid()+1, "SET screen#ignore_screen="); reply == "" || !reflect.DeepEqual(a.settings.live.Screen, settings.Screen) {
+ t.Fatal("unauthorized policy change", reply, a.settings.live.Screen)
+ }
+ if reply := request(os.Geteuid(), "SET screen#ignore_screen="); reply != "" {
+ t.Fatal(reply)
+ }
+ if err := a.poll_input(true); err != nil || !a.lastActive {
+ t.Fatal("did not recover after remote clear", err)
+ }
+ a.ui.open_tutorial(nil, nil, nil)
+ interrupts := voice.interrupts
+ if reply := request(os.Geteuid(), "SET screen#ignore_screen=2,7,8,10"); reply != "" {
+ t.Fatal(reply)
+ }
+ if err := a.poll_input(false); err != nil {
+ t.Fatal(err)
+ }
+ if !terminalMode && (a.lastActive || a.ui.active || voice.interrupts <= interrupts || !reflect.DeepEqual(keys.captures, []bool{true, false})) {
+ t.Fatal("handoff retained modal capture or speech", a.lastActive, a.ui.active, keys.captures, voice.interrupts)
+ }
+ if terminalMode && (!a.lastActive || !a.ui.active) {
+ t.Fatal("console policy affected X11 session")
+ }
+ source.snapshot.Terminal = 3
+ if err := a.poll_input(false); err != nil || !a.lastActive {
+ t.Fatal("allowed VT did not resume", err)
+ }
+ if !terminalMode && !reflect.DeepEqual(modes, []bool{false, true, false, true}) {
+ t.Fatal("wrong handoff order", modes)
+ }
+ // Applying a saved draft uses the same policy; quick-save must retain it.
+ saved := settings
+ saved.Screen.IgnoredVTs = []int{3}
+ a.apply_saved_settings(saved, false)
+ if err := a.poll_input(false); err != nil || a.lastActive != terminalMode {
+ t.Fatal("saved policy was not applied", err, a.lastActive)
+ }
+ if !reflect.DeepEqual(a.settings.saveDraft.Screen.IgnoredVTs, []int{3}) {
+ t.Fatal("lost saved policy")
+ }
+ }
+}
diff --git a/internal/app/screen_updates.go b/internal/app/screen_updates.go
new file mode 100644
index 00000000..d06c01d0
--- /dev/null
+++ b/internal/app/screen_updates.go
@@ -0,0 +1,156 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package app
+
+import (
+ "fmt"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/diagnostic"
+ "git.stormux.org/storm/fenrir/internal/i18n"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "git.stormux.org/storm/fenrir/internal/spellcheck"
+)
+
+func (a *application) read_screen() error {
+ started := time.Now()
+ snapshot, err := a.source.Read()
+ diagnostic.Timing("screen capture", started)
+ if err != nil {
+ return err
+ }
+ if a.lastTerminal == 0 {
+ if snapshot.Active {
+ a.present(i18n.T("Start Fenrir"), presentation.Ready)
+ } else {
+ a.play(presentation.Ready)
+ }
+ }
+ sessionChanged := a.lastTerminal != 0 && (snapshot.Terminal != a.lastTerminal || snapshot.Active != a.lastActive)
+ if a.lastTerminal != 0 && (sessionChanged || snapshot.Generation != a.lastGeneration || snapshot.Rows != a.lastRows || snapshot.Cols != a.lastCols) {
+ a.spellSession++
+ }
+ if a.cues != nil && (sessionChanged || a.lastTerminal != 0 &&
+ (snapshot.Generation != a.lastGeneration || snapshot.Rows != a.lastRows || snapshot.Cols != a.lastCols)) {
+ a.cues.Clear_progress()
+ }
+ if sessionChanged {
+ a.voice.Interrupt()
+ a.ui.close()
+ a.clipboards.session_changed(snapshot.Terminal)
+ if a.options.ScriptRunner != nil {
+ a.scriptGeneration = a.options.ScriptRunner.Cancel()
+ }
+ }
+ a.clipboards.terminal = snapshot.Terminal
+ updated := time.Now()
+ output := a.engine.Update(snapshot)
+ if a.options.Spellcheck != nil && a.settings.live.Reading.Spellcheck && !a.ui.active && !a.muted && !a.temporarySilence {
+ for _, word := range a.engine.Completed_words() {
+ a.options.Spellcheck.Submit(spellcheck.Request{Operation: spellcheck.Automatic, Word: word, Generation: a.spellSession, Revision: a.spellFeedback})
+ }
+ }
+ restoreLine := a.prompts.Observe(a.engine.Automatic_snapshot())
+ restored := a.temporarySilence && !a.muted && !a.ui.active && restoreLine != ""
+ if restored {
+ a.temporarySilence = false
+ a.present(i18n.T("Speech restored"), presentation.SpeechOn)
+ if a.engine.Automatic_reading() {
+ a.present(restoreLine, "")
+ }
+ }
+ diagnostic.Timing("screen processing", updated)
+ diagnostic.Record("reader presentation", "terminal", snapshot.Terminal, "generation", snapshot.Generation,
+ "messages", len(output), "ui_active", a.ui.active, "temporary_silence", a.temporarySilence,
+ "muted", a.muted, "speech_available", a.settings.live.Presentation.Speech.Enabled,
+ "progress_enabled", a.settings.live.Presentation.Progress.Enabled, "sound_enabled", a.settings.live.Presentation.Sound.Enabled, "sound_available", a.cues != nil)
+ // VT sounds remain available while automatic reading is disabled.
+ if sessionChanged && snapshot.Active && len(output) == 0 {
+ a.play(presentation.TTYChanged)
+ }
+ if !a.ui.active && !restored {
+ for _, message := range output {
+ if message.Attributes != nil {
+ a.present_attributes(message)
+ } else if message.Indentation != nil {
+ a.present_indentation(message)
+ } else if message.Character != nil && !a.temporarySilence {
+ a.voice.say_character(*message.Character)
+ } else {
+ a.present(message.Text, message.Cue)
+ }
+ }
+ }
+ a.lastTerminal, a.lastActive = snapshot.Terminal, snapshot.Active
+ a.lastGeneration, a.lastRows, a.lastCols = snapshot.Generation, snapshot.Rows, snapshot.Cols
+ a.lastNumLock = snapshot.NumLock
+ a.play_progress()
+ return nil
+}
+
+func (a *application) poll_input(inputReady bool) error {
+ tty, active, numLock, err := a.source.State()
+ if err != nil {
+ return err
+ }
+ changed := tty != a.lastTerminal || active != a.lastActive
+ if changed {
+ if err := a.read_screen(); err != nil {
+ return err
+ }
+ active, numLock = a.lastActive, a.lastNumLock
+ }
+ if inputReady || changed {
+ if err := a.keyboards.Poll(active, numLock, a.handle_input); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (a *application) screen_event(err error) error {
+ if err != nil {
+ return fmt.Errorf("screen watcher failed: %w", err)
+ }
+ // Interrupt for any pending key before presenting the resulting
+ // screen echo. This prevents that same key from canceling its echo
+ // just because the kernel reported screen readiness first.
+ select {
+ case err := <-a.keyboards.Events():
+ if err != nil {
+ return fmt.Errorf("keyboard watcher failed: %w", err)
+ }
+ if err := a.poll_input(true); err != nil {
+ return err
+ }
+ default:
+ }
+ if a.quitting {
+ return nil
+ }
+ previousTTY, previousText := a.lastTerminal, a.lastActive
+ if err := a.read_screen(); err != nil {
+ return err
+ }
+ if previousTTY != a.lastTerminal || previousText != a.lastActive {
+ if err := a.keyboards.Poll(a.lastActive, a.lastNumLock, a.handle_input); err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/internal/app/scripts.go b/internal/app/scripts.go
new file mode 100644
index 00000000..597c3b90
--- /dev/null
+++ b/internal/app/scripts.go
@@ -0,0 +1,117 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package app
+
+import (
+ "fmt"
+ "log/slog"
+ "os"
+ "os/user"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "syscall"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/i18n"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "git.stormux.org/storm/fenrir/internal/scripts"
+)
+
+// script_username supplies Python's current-user argument without changing
+// process credentials, HOME, cwd or any inherited environment variable.
+// Session lookup runs in a script worker, never in the input dispatcher.
+func script_username(terminalMode bool, terminal int) string {
+ if terminalMode {
+ for _, name := range []string{os.Getenv("LOGNAME"), os.Getenv("USER")} {
+ if name != "" {
+ return name
+ }
+ }
+ if current, err := user.Current(); err == nil {
+ return current.Username
+ }
+ return ""
+ }
+ // Read logind/elogind's local session metadata, matching Python's active-VT
+ // lookup without adding a synchronous D-Bus round trip to keyboard handling.
+ for _, directory := range []string{"/run/systemd/sessions", "/run/elogind/sessions"} {
+ entries, _ := os.ReadDir(directory)
+ for _, entry := range entries {
+ data, err := os.ReadFile(filepath.Join(directory, entry.Name()))
+ if err != nil {
+ continue
+ }
+ values := map[string]string{}
+ for _, line := range strings.Split(string(data), "\n") {
+ if key, value, ok := strings.Cut(line, "="); ok {
+ values[key] = value
+ }
+ }
+ if values["VTNR"] != strconv.Itoa(terminal) && values["TTY"] != fmt.Sprintf("tty%d", terminal) {
+ continue
+ }
+ if current, err := user.LookupId(values["UID"]); err == nil {
+ return current.Username
+ }
+ }
+ }
+ return "" // Python also leaves this empty when no session was found.
+}
+
+func remote_authorized(uid, terminal int, terminalMode bool) bool {
+ if uid == 0 || uid == os.Geteuid() {
+ return true
+ }
+ if terminalMode || os.Geteuid() != 0 || terminal < 1 {
+ return false
+ }
+ info, err := os.Stat(fmt.Sprintf("/dev/tty%d", terminal))
+ if err != nil {
+ return false
+ }
+ stat, ok := info.Sys().(*syscall.Stat_t)
+ return ok && int(stat.Uid) == uid
+}
+
+func (a *application) script_result(result scripts.Result) {
+ if result.Generation != a.scriptGeneration || result.Terminal != a.lastTerminal || !a.lastActive {
+ return
+ }
+ if result.Err != nil {
+ slog.Warn("script invocation failed", "error", result.Err, "at", time.Now())
+ text := "Script failed"
+ switch result.Err.Error() {
+ case "Script file not found", "Script source is not a valid file", "Script file is not executable":
+ text = result.Err.Error()
+ }
+ a.present(i18n.T(text), presentation.Error)
+ return
+ }
+ if result.Truncated {
+ slog.Warn("script output truncated", "at", time.Now())
+ }
+ // Match Python's stderr-before-stdout result delivery. Speech still
+ // follows this reader's shared queue and mute policy.
+ if result.Stderr != "" {
+ a.present(result.Stderr, "")
+ }
+ if result.Stdout != "" {
+ a.present(result.Stdout, "")
+ }
+}
diff --git a/internal/app/scripts_test.go b/internal/app/scripts_test.go
new file mode 100644
index 00000000..6884465b
--- /dev/null
+++ b/internal/app/scripts_test.go
@@ -0,0 +1,129 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package app
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "reflect"
+ "slices"
+ "testing"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/input"
+ "git.stormux.org/storm/fenrir/internal/screen"
+ "git.stormux.org/storm/fenrir/internal/scripts"
+)
+
+func TestHungScriptDoesNotBlockReviewOrReaderShutdown(t *testing.T) {
+ root := t.TempDir()
+ path, marker := filepath.Join(root, "script.sh"), filepath.Join(root, "started")
+ if err := os.WriteFile(path, []byte("#!/bin/sh\necho started > '"+marker+"'\nsleep 30\n"), 0700); err != nil {
+ t.Fatal(err)
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ runner := scripts.New_runner(ctx, scripts.Limits{Workers: 1, Queue: 1, OutputBytes: 1024}, nil)
+ defer runner.Close()
+ script := scripts.Script{ID: "script:hang", Path: path}
+ source := &fakeSource{snapshot: screen.Snapshot{Active: true, Terminal: 1, Rows: 1, Cols: 5, Cells: []rune("alpha")}}
+ keys := &fakeKeys{events: make(chan error, 1)}
+ voice := &fakeVoice{}
+ polls := 0
+ keys.poll = func(_ bool, handle func(input.Decision)) error {
+ polls++
+ if polls == 1 {
+ handle(input.Decision{Command: script.ID, Interrupt: true})
+ } else {
+ handle(input.Decision{Command: command.CurrentLine, Interrupt: true})
+ handle(input.Decision{Command: command.Quit})
+ }
+ return nil
+ }
+ go func() {
+ ticker := time.NewTicker(5 * time.Millisecond)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ if _, err := os.Stat(marker); err == nil {
+ keys.events <- nil
+ return
+ }
+ }
+ }
+ }()
+ started := time.Now()
+ err := run(ctx, source, keys, voice, nil, RunOptions{Settings: config.Defaults(), Scripts: map[command.ID]scripts.Script{script.ID: script}, ScriptRunner: runner})
+ if err != nil || ctx.Err() != nil || time.Since(started) > time.Second || !keys.closed || !slices.Contains(voice.messages, "alpha") {
+ t.Fatal("script stalled reader", err, ctx.Err(), voice.messages)
+ }
+}
+
+func TestScriptResultsUseMainLoopStderrThenStdout(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "script.sh")
+ if err := os.WriteFile(path, []byte("#!/bin/sh\necho failure >&2\necho result\nexit 7\n"), 0700); err != nil {
+ t.Fatal(err)
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ runner := scripts.New_runner(ctx, scripts.Limits{Workers: 1, Queue: 1, OutputBytes: 1024}, nil)
+ defer runner.Close()
+ script := scripts.Script{ID: "script:result", Path: path}
+ source := &fakeSource{snapshot: screen.Snapshot{Active: true, Terminal: 1, Rows: 1, Cols: 5, Cells: []rune("alpha")}}
+ keys := &fakeKeys{events: make(chan error, 1)}
+ voice := &fakeVoice{onSay: func(text string) {
+ if text == "result\n" {
+ keys.events <- nil
+ }
+ }}
+ polls := 0
+ keys.poll = func(_ bool, handle func(input.Decision)) error {
+ polls++
+ if polls == 1 {
+ handle(input.Decision{Command: script.ID})
+ } else {
+ handle(input.Decision{Command: command.Quit})
+ }
+ return nil
+ }
+ err := run(ctx, source, keys, voice, nil, RunOptions{Settings: config.Defaults(), Scripts: map[command.ID]scripts.Script{script.ID: script}, ScriptRunner: runner})
+ if err != nil || ctx.Err() != nil {
+ t.Fatal(err, ctx.Err())
+ }
+ if len(voice.messages) < 2 || !reflect.DeepEqual(voice.messages[len(voice.messages)-2:], []string{"failure\n", "result\n"}) {
+ t.Fatal(voice.messages)
+ }
+}
+
+func TestRemoteSettingsAreAtomicAndUsePythonNames(t *testing.T) {
+ base := config.Defaults()
+ updated, err := remote_settings(base, "set speech#enabled=False;speech#auto_read_incoming=False,keyboard#word_echo=True")
+ if err != nil || updated.Presentation.Speech.Enabled || updated.Reading.Automatic || !updated.Keyboard.WordEcho {
+ t.Fatal(updated, err)
+ }
+ failed, err := remote_settings(base, "SET speech#enabled=False;unknown#setting=True")
+ if err == nil || !reflect.DeepEqual(failed, base) {
+ t.Fatal("partial settings applied", failed, err)
+ }
+}
diff --git a/internal/app/speech_selection.go b/internal/app/speech_selection.go
new file mode 100644
index 00000000..17f5f3f0
--- /dev/null
+++ b/internal/app/speech_selection.go
@@ -0,0 +1,106 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package app
+
+import (
+ "errors"
+ "fmt"
+ "log/slog"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/speech"
+)
+
+type speechChangeResult struct {
+ err error
+ done func(error)
+}
+
+func same_speech_selection(a, b config.Speech) bool {
+ return a.Driver == b.Driver && a.Module == b.Module && a.Language == b.Language && a.Voice == b.Voice
+}
+
+func copy_speech_selection(target *config.Speech, source config.Speech) {
+ target.Module, target.Language, target.Voice = source.Module, source.Language, source.Voice
+}
+
+func prepare_speech(selected config.Speech) (speech.Transport, error) {
+ return speech.Prepare(selected.Driver, speech_options(selected))
+}
+
+func speech_options(selected config.Speech) speech.Options {
+ return speech.Options{Rate: selected.Rate, Pitch: &selected.Pitch, Volume: &selected.Volume, Module: selected.Module, Language: selected.Language, Voice: selected.Voice, Hardware: speech.HardwareOptions{Device: selected.HardwareDevice, BaudRate: selected.HardwareBaudRate}}
+}
+
+func (a *application) replace_speech(prepared speech.Transport) error {
+ if service, ok := a.voice.Speaker.(interface{ Replace(speech.Transport) error }); ok {
+ return service.Replace(prepared)
+ }
+ prepared.Close()
+ return fmt.Errorf("speech service does not support live selection")
+}
+
+// Only the result callback mutates reader state. Preparing a silent candidate
+// and committing it through the speech worker never blocks the input loop.
+func (a *application) change_speech(selected config.Speech, done func(error)) {
+ if a.speechPending || a.saver.busy {
+ done(fmt.Errorf("settings are busy"))
+ return
+ }
+ if !a.settings.speechAvailable {
+ done(fmt.Errorf("speech was disabled at startup"))
+ return
+ }
+ a.speechPending = true
+ go func() {
+ prepared, err := a.prepareSpeech(selected)
+ if err == nil {
+ err = a.replace_speech(prepared)
+ }
+ select {
+ case a.speechResults <- speechChangeResult{err: err, done: done}:
+ case <-a.ctx.Done():
+ }
+ }()
+}
+
+func speech_choice(source string) bool {
+ return source == "speech-module" || source == "speech-language" || source == "speech-voice"
+}
+
+func (a *application) speech_result(result speechChangeResult) {
+ a.speechPending = false
+ result.done(result.err)
+ // Reapply numeric overrides made while the silent candidate was prepared.
+ a.voice.Configure(speech_parameters(a.settings.live))
+}
+
+type savedSpeechError struct{ err error }
+
+func (e *savedSpeechError) Error() string {
+ return "settings saved; speech selection was not applied: " + e.err.Error()
+}
+func (e *savedSpeechError) Unwrap() error { return e.err }
+func settings_committed(err error) bool {
+ var saved *savedSpeechError
+ return err == nil || errors.As(err, &saved)
+}
+
+func open_speech(selected config.Speech) (speech.Transport, config.Speech, error) {
+ options := speech_options(selected)
+ if selected.Module == "" && selected.Language == "" && selected.Voice == "" {
+ client, err := speech.Open_driver(selected.Driver, options)
+ return client, selected, err
+ }
+ client, err := prepare_speech(selected)
+ if err == nil {
+ return client, selected, nil
+ }
+ slog.Warn("saved speech selection unavailable; using server default", "error", err, "at", time.Now())
+ selected.Module, selected.Language, selected.Voice = "", "", ""
+ options.Module, options.Language, options.Voice = "", "", ""
+ client, err = speech.Open_driver(selected.Driver, options)
+ return client, selected, err
+}
diff --git a/internal/app/speech_selection_test.go b/internal/app/speech_selection_test.go
new file mode 100644
index 00000000..78ebd6e8
--- /dev/null
+++ b/internal/app/speech_selection_test.go
@@ -0,0 +1,317 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package app
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/control"
+ "git.stormux.org/storm/fenrir/internal/preferences"
+ "git.stormux.org/storm/fenrir/internal/speech"
+)
+
+type preparedVoice struct{ closed atomic.Bool }
+
+func (p *preparedVoice) Say(string) error { return nil }
+func (p *preparedVoice) Configure(speech.Parameters) error { return nil }
+func (p *preparedVoice) Set_pitch(int) error { return nil }
+func (p *preparedVoice) Cancel() error { return nil }
+func (p *preparedVoice) Close() { p.closed.Store(true) }
+
+type selectingVoice struct {
+ fakeVoice
+ replaced chan speech.Transport
+ replacementError error
+}
+
+func (s *selectingVoice) Replace(candidate speech.Transport) error {
+ if s.replacementError != nil {
+ candidate.Close()
+ return s.replacementError
+ }
+ s.replaced <- candidate
+ return nil
+}
+
+func selection_application(t *testing.T, terminal bool) (*application, *selectingVoice, string) {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), "settings.toml")
+ base := config.Defaults()
+ if err := config.Save(path, base); err != nil {
+ t.Fatal(err)
+ }
+ voice := &selectingVoice{replaced: make(chan speech.Transport, 8)}
+ ctx, cancel := context.WithCancel(context.Background())
+ t.Cleanup(cancel)
+ a, err := new_application(ctx, &fakeSource{}, &fakeKeys{}, voice, RunOptions{Settings: base, ConfigPath: path, TerminalMode: terminal})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ for {
+ select {
+ case candidate := <-voice.replaced:
+ candidate.Close()
+ default:
+ return
+ }
+ }
+ })
+ return a, voice, path
+}
+func finish_selection(t *testing.T, a *application) {
+ t.Helper()
+ select {
+ case result := <-a.speechResults:
+ a.speech_result(result)
+ case <-time.After(time.Second):
+ t.Fatal("selection stalled")
+ }
+}
+func request_selection(t *testing.T, a *application, text string) string {
+ t.Helper()
+ reply := make(chan string, 1)
+ a.remote_request(control.Request{UID: os.Geteuid(), Text: "SETTING " + text, Reply: reply})
+ if a.speechPending {
+ finish_selection(t, a)
+ }
+ select {
+ case result := <-reply:
+ return result
+ case <-time.After(time.Second):
+ t.Fatal("remote response stalled")
+ return ""
+ }
+}
+
+func TestRemoteVoiceChangesAreAtomicAndResetRestoresTransport(t *testing.T) {
+ for _, terminal := range []bool{false, true} {
+ t.Run(map[bool]string{false: "tty", true: "terminal"}[terminal], func(t *testing.T) {
+ a, voice, path := selection_application(t, terminal)
+ base := a.effective_settings()
+ a.prepareSpeech = func(config.Speech) (speech.Transport, error) { return nil, errors.New("fixture unavailable") }
+ result := request_selection(t, a, "SET speech#module=fixture;speech#voice=Missing;speech#rate=0.25;speech#enabled=False")
+ if !strings.Contains(result, "fixture unavailable") || !reflect.DeepEqual(a.effective_settings(), base) || !reflect.DeepEqual(a.settings.saveDraft, base) {
+ t.Fatal("failed selection partially applied", result, a.effective_settings())
+ }
+ var prepared []config.Speech
+ a.prepareSpeech = func(selected config.Speech) (speech.Transport, error) {
+ prepared = append(prepared, selected)
+ return &preparedVoice{}, nil
+ }
+ result = request_selection(t, a, "SET speech#voice=Base Voice;speech#language=en-US;speech#module=fixture;speech#rate=0.25")
+ if result != "" || a.settings.live.Presentation.Speech.Voice != "Base Voice" || prepared[0].Voice != "Base Voice" || prepared[0].Language != "en-US" || a.settings.live.Presentation.Speech.Rate != -50 {
+ t.Fatal(result, prepared, a.settings.live)
+ }
+ saved, _, err := config.Load(path, nil)
+ if err != nil || saved.Presentation.Speech.Module != "" {
+ t.Fatal("SET wrote the file", saved, err)
+ }
+ if result = request_selection(t, a, "RESET"); result != "" || len(prepared) != 2 || prepared[1].Module != "" || !same_speech_selection(a.settings.live.Presentation.Speech, base.Presentation.Speech) {
+ t.Fatal("RESET did not replace transport", result, prepared, a.settings.live)
+ }
+ if a.speechPending {
+ t.Fatal("selection admission not released")
+ }
+ if len(voice.parameters) == 0 || voice.parameters[len(voice.parameters)-1] != speech_parameters(base) {
+ t.Fatal("RESET numeric parameters not restored", voice.parameters)
+ }
+ })
+ }
+}
+
+func TestHardwareOptionsReachStartupAndRemoteChangesStayAtomic(t *testing.T) {
+ for _, terminal := range []bool{false, true} {
+ a, _, _ := selection_application(t, terminal)
+ selected := a.settings.live.Presentation.Speech
+ selected.Driver, selected.HardwareDevice, selected.HardwareBaudRate = "dectalk", "/dev/fixture-synth", 19200
+ options := speech_options(selected)
+ if options.Hardware.Device != selected.HardwareDevice || options.Hardware.BaudRate != 19200 {
+ t.Fatal(options)
+ }
+ a.settings.live.Presentation.Speech = selected
+ a.settings.saveDraft.Presentation.Speech = selected
+ a.settings.resetBase.Presentation.Speech = selected
+ before := a.effective_settings()
+ if result := request_selection(t, a, "SET speech#voice=Unsupported;speech#rate=0.25;speech#enabled=False"); result == "" || !reflect.DeepEqual(a.effective_settings(), before) || !reflect.DeepEqual(a.settings.saveDraft, before) {
+ t.Fatal("partial hardware assignment", result, a.effective_settings())
+ }
+ if result := request_selection(t, a, "SET speech#rate=0.5;speech#pitch=0.25;speech#volume=1"); result != "" || speech_parameters(a.effective_settings()) != (speech.Parameters{Rate: 0, Pitch: -50, Volume: 100}) {
+ t.Fatal(result, a.effective_settings())
+ }
+ if result := request_selection(t, a, "RESET"); result != "" || a.effective_settings().Presentation.Speech != selected {
+ t.Fatal("hardware RESET", result, a.effective_settings())
+ }
+ }
+}
+
+func TestMenuSelectionFailurePreservesDiskAndCommittedSaveSurvivesClose(t *testing.T) {
+ for _, failed := range []bool{true, false} {
+ t.Run(map[bool]string{true: "failed", false: "success"}[failed], func(t *testing.T) {
+ a, _, path := selection_application(t, true)
+ before, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ doc, err := config.Open_document(path, a.settings.live)
+ if err != nil {
+ t.Fatal(err)
+ }
+ draft := doc.Settings
+ draft.Presentation.Speech.Module = "fixture"
+ draft.Presentation.Speech.Language = "en-US"
+ draft.Presentation.Speech.Voice = "Base Voice"
+ started, release := make(chan struct{}), make(chan struct{})
+ a.prepareSpeech = func(config.Speech) (speech.Transport, error) {
+ close(started)
+ <-release
+ if failed {
+ return nil, errors.New("fixture unavailable")
+ }
+ return &preparedVoice{}, nil
+ }
+ var outcome error
+ if !a.save_menu_settings(doc, draft, func(err error) { outcome = err }) {
+ t.Fatal("save not admitted")
+ }
+ <-started
+ a.ui.close()
+ a.quick.command(command.NextQuickValue, &a.settings.live, a.voice)
+ close(release)
+ finish_persistence(t, a, failed)
+ after, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if failed {
+ if outcome == nil || string(after) != string(before) || a.settings.live.Presentation.Speech.Module != "" {
+ t.Fatal("failure changed disk/live", outcome)
+ }
+ } else {
+ if outcome != nil || a.settings.live.Presentation.Speech.Voice != "Base Voice" || a.settings.live.Presentation.Speech.Rate != 90 || a.settings.resetBase.Presentation.Speech.Rate != 80 || a.settings.resetBase.Presentation.Speech.Voice != "Base Voice" {
+ t.Fatal("committed selection or newer numeric override lost", outcome, a.settings.live, a.settings.resetBase)
+ }
+ }
+ })
+ }
+}
+
+func TestFailedWriteClosesPreparedClientAndKeepsActiveSelection(t *testing.T) {
+ a, voice, path := selection_application(t, true)
+ doc, err := config.Open_document(path, a.settings.live)
+ if err != nil {
+ t.Fatal(err)
+ }
+ draft := doc.Settings
+ draft.Presentation.Speech.Module = "fixture"
+ prepared := &preparedVoice{}
+ a.prepareSpeech = func(config.Speech) (speech.Transport, error) { return prepared, nil }
+ if err := os.WriteFile(path, []byte("external edit"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ var outcome error
+ if !a.save_menu_settings(doc, draft, func(err error) { outcome = err }) {
+ t.Fatal("not admitted")
+ }
+ finish_persistence(t, a, true)
+ if outcome == nil || !prepared.closed.Load() || len(voice.replaced) != 0 || a.settings.live.Presentation.Speech.Module != "" {
+ t.Fatal("failed write replaced voice or leaked candidate", outcome)
+ }
+}
+
+func TestSavedFileTruthSurvivesConnectionFailureAtCommit(t *testing.T) {
+ a, voice, path := selection_application(t, true)
+ doc, err := config.Open_document(path, a.settings.live)
+ if err != nil {
+ t.Fatal(err)
+ }
+ draft := doc.Settings
+ draft.Presentation.Speech.Module = "fixture"
+ a.prepareSpeech = func(config.Speech) (speech.Transport, error) { return &preparedVoice{}, nil }
+ voice.replacementError = errors.New("fixture connection lost")
+ var outcome error
+ if !a.save_menu_settings(doc, draft, func(err error) { outcome = err }) {
+ t.Fatal("not admitted")
+ }
+ finish_persistence(t, a, true)
+ saved, _, err := config.Load(path, nil)
+ if err != nil || saved.Presentation.Speech.Module != "fixture" || a.settings.resetBase.Presentation.Speech.Module != "fixture" || a.settings.saveDraft.Presentation.Speech.Module != "fixture" || a.settings.live.Presentation.Speech.Module != "" || !settings_committed(outcome) {
+ t.Fatal("disk truth lost after committed write", outcome, saved, a.settings)
+ }
+}
+
+func TestQuickVoiceCancelSaveAndStaleFeedback(t *testing.T) {
+ a, voice, path := selection_application(t, true)
+ a.quick.discovery = preferences.Discovery{Catalog: speech.Catalog{Modules: []string{"fixture"}, Voices: map[string][]speech.Voice{
+ "fixture": {{Name: "Base Voice", Language: "en-US"}},
+ }}}
+ started, release := make(chan struct{}), make(chan struct{})
+ a.prepareSpeech = func(config.Speech) (speech.Transport, error) { close(started); <-release; return &preparedVoice{}, nil }
+ for range 3 {
+ a.quick.command(command.NextQuickEntry, &a.settings.live, a.voice)
+ }
+ a.quick.command(command.NextQuickValue, &a.settings.live, a.voice)
+ <-started
+ // Focus handoff invalidates presentation, not the admitted setting change.
+ a.spellSession++
+ before := len(voice.messages)
+ close(release)
+ finish_selection(t, a)
+ if a.settings.live.Presentation.Speech.Module != "fixture" || !a.quick.selectionChanged || len(voice.messages) != before {
+ t.Fatal("stale feedback or lost selection", voice.messages)
+ }
+ // Unsaved quick selection appears in the form; Cancel does not undo it.
+ a.open_settings()
+ receive_ui(t, a.ui)
+ if got := a.ui.session.Values()["presentation.speech.module"]; got.Choices[got.Selected].ID != "fixture" {
+ t.Fatal(got)
+ }
+ a.ui.close()
+ saved, _, err := config.Load(path, nil)
+ if err != nil || saved.Presentation.Speech.Module != "" {
+ t.Fatal("Cancel saved quick voice", saved, err)
+ }
+ a.save_settings()
+ finish_persistence(t, a, false)
+ saved, _, err = config.Load(path, nil)
+ if err != nil || saved.Presentation.Speech.Module != "fixture" {
+ t.Fatal("quick save lost module", saved, err)
+ }
+ // Startup recovery keeps the original choice in the draft/reset baseline.
+ original := saved
+ original.Presentation.Speech.Module = "missing"
+ effective := original.Presentation.Speech
+ effective.Module = ""
+ effective.Language = ""
+ effective.Voice = ""
+ fresh, err := new_application(a.ctx, &fakeSource{}, &fakeKeys{}, &fakeVoice{}, RunOptions{Settings: original, EffectiveSpeech: &effective})
+ if err != nil || fresh.settings.live.Presentation.Speech.Module != "" || fresh.settings_to_save().Presentation.Speech.Module != "missing" || fresh.settings.resetBase.Presentation.Speech.Module != "missing" {
+ t.Fatal("startup recovery erased saved choice", err)
+ }
+}
+
+func TestRemoteSelectionRetainsAdmittedTrackingIntent(t *testing.T) {
+ a, _, _ := selection_application(t, true)
+ started, release := make(chan struct{}), make(chan struct{})
+ a.prepareSpeech = func(config.Speech) (speech.Transport, error) { close(started); <-release; return &preparedVoice{}, nil }
+ reply := make(chan string, 1)
+ a.remote_request(control.Request{UID: os.Geteuid(), Text: "SETTING SET speech#module=fixture;focus#cursor=True", Reply: reply})
+ <-started
+ a.engine.Configure_tracking("highlight")
+ close(release)
+ finish_selection(t, a)
+ if response := <-reply; response != "" || a.engine.Tracking_mode() != "cursor" || a.settings.live.Presentation.Speech.Module != "fixture" {
+ t.Fatal("admitted selection became invalid during preparation", response, a.effective_settings())
+ }
+}
diff --git a/internal/app/spellcheck.go b/internal/app/spellcheck.go
new file mode 100644
index 00000000..ca9c70cc
--- /dev/null
+++ b/internal/app/spellcheck.go
@@ -0,0 +1,37 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package app
+
+import (
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/i18n"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "git.stormux.org/storm/fenrir/internal/spellcheck"
+)
+
+func present_misspelling(word string, settings config.Reading, voice Speaker, play func(presentation.Cue) bool) {
+ accepted := false
+ if settings.SpellcheckSound {
+ accepted = play(presentation.Misspelled)
+ }
+ if settings.SpellcheckReadWord {
+ voice.Say(word)
+ }
+ if settings.SpellcheckSayMisspelled || settings.SpellcheckSound && !accepted {
+ voice.Say(i18n.T("misspelled"))
+ }
+}
+
+func (a *application) spellcheck_result(result spellcheck.Result) {
+ if result.Operation != spellcheck.Automatic {
+ a.spellMenu.result(result)
+ return
+ }
+ if result.Generation != a.spellSession || result.Revision != a.spellFeedback || !a.lastActive || a.ui.active || a.muted || a.temporarySilence || !a.settings.live.Reading.Spellcheck || !result.Available || result.Err != nil || result.Correct || result.Word == "" {
+ return
+ }
+ present_misspelling(result.Word, a.settings.live.Reading, a.voice, func(cue presentation.Cue) bool {
+ return a.settings.live.Presentation.Sound.Volume > 0 && a.play(cue)
+ })
+}
diff --git a/internal/app/spellcheck_loop_test.go b/internal/app/spellcheck_loop_test.go
new file mode 100644
index 00000000..8c118956
--- /dev/null
+++ b/internal/app/spellcheck_loop_test.go
@@ -0,0 +1,98 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package app
+
+import (
+ "context"
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/input"
+ "git.stormux.org/storm/fenrir/internal/screen"
+ "git.stormux.org/storm/fenrir/internal/spellcheck"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+type LoopDictionary struct {
+ entered chan error
+ release <-chan struct{}
+}
+
+func (d *LoopDictionary) Check(string) (bool, error) {
+ d.entered <- nil
+ <-d.release
+ return false, nil
+}
+func (*LoopDictionary) Suggest(string) ([]string, error) { return nil, nil }
+func (*LoopDictionary) Close() {}
+
+func TestAutomaticSpellcheckDropsResultsAfterInputMuteSilenceAndScreenChanges(t *testing.T) {
+ for _, state := range []string{"enabled", "disabled", "muted", "temporary", "new input", "screen generation", "screen size"} {
+ t.Run(state, func(t *testing.T) {
+ settings := config.Defaults()
+ settings.Reading.Spellcheck = state != "disabled"
+ settings.Keyboard.CharacterEcho = false
+ settings.Reading.Automatic = false // Spellcheck is an independent choice.
+ settings.Presentation.Sound.Enabled = false
+ keys := &fakeKeys{events: make(chan error, 2)}
+ gate := make(chan struct{})
+ service := spellcheck.New_service(context.Background(), spellcheck.Options{ExceptionsPath: filepath.Join(t.TempDir(), "exceptions.json"), OpenDictionary: func(string) (spellcheck.Dictionary, error) { return &LoopDictionary{keys.events, gate}, nil }})
+ defer service.Close()
+ voice := &fakeVoice{}
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 32, X: 6, Cells: []rune("$ helo" + strings.Repeat(" ", 26))}, events: make(chan error, 2)}
+ reads, steps := 0, 0
+ source.afterRead = func() {
+ reads++
+ if reads == 3 && (state == "screen generation" || state == "screen size") {
+ close(gate)
+ }
+ }
+ keys.poll = func(_ bool, handle func(input.Decision)) error {
+ steps++
+ if steps == 1 {
+ voice.messages = nil
+ handle(input.Decision{Forward: true, Typing: true, WordBoundary: true, Interrupt: true, Keypress: true})
+ source.snapshot.Cells = []rune("$ helo " + strings.Repeat(" ", 25))
+ source.snapshot.X = 7
+ source.events <- nil
+ } else if steps == 2 {
+ switch state {
+ case "muted":
+ handle(input.Decision{Command: command.Mute, Interrupt: true})
+ case "temporary":
+ handle(input.Decision{Command: command.TempDisableSpeech, Interrupt: true})
+ case "new input":
+ handle(input.Decision{Forward: true, Typing: true, Interrupt: true, Keypress: true})
+ case "screen generation":
+ source.snapshot.Generation++
+ source.events <- nil
+ return nil
+ case "screen size":
+ source.snapshot.Cols++
+ source.snapshot.Cells = append(source.snapshot.Cells, ' ')
+ source.events <- nil
+ return nil
+ }
+ close(gate)
+ }
+ return nil
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
+ err := run(ctx, source, keys, voice, nil, RunOptions{Settings: settings, Spellcheck: service})
+ cancel()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if state != "disabled" && steps < 2 {
+ t.Fatal("fixture did not reach the delayed dictionary result", reads, steps)
+ }
+ got := strings.Contains(strings.Join(voice.messages, "|"), "misspelled")
+ if got != (state == "enabled") {
+ t.Fatal(state, voice.messages, reads, steps)
+ }
+ })
+ }
+}
diff --git a/internal/app/spellcheck_menu.go b/internal/app/spellcheck_menu.go
new file mode 100644
index 00000000..901a7dbd
--- /dev/null
+++ b/internal/app/spellcheck_menu.go
@@ -0,0 +1,192 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package app
+
+import (
+ "git.stormux.org/storm/fenrir/internal/i18n"
+ "git.stormux.org/storm/fenrir/internal/interaction"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "git.stormux.org/storm/fenrir/internal/spellcheck"
+ "strings"
+ "time"
+)
+
+const spellcheckTitle = "Spellcheck Menu"
+
+type spellcheckMenu struct {
+ ui *internalUI
+ service *spellcheck.Service
+ clipboards *clipboardHost
+ generation, revision uint64
+ source, deleted string
+ due time.Time
+ busy bool
+}
+
+func word_choices(words []string) []interaction.Choice {
+ choices := make([]interaction.Choice, 0, len(words))
+ for _, word := range words {
+ choices = append(choices, interaction.Choice{ID: word, Label: word, Literal: true})
+ }
+ return choices
+}
+
+func (h *spellcheckMenu) open(word string) {
+ ui := h.ui
+ generation, err := ui.begin(spellcheckTitle)
+ if err != nil {
+ ui.failed("Could not open Spellcheck Menu: {0}", err)
+ return
+ }
+ h.generation, h.source, h.deleted, h.busy = generation, spellcheck.Word(word), "", false
+ h.revision++
+ session, err := interaction.New_session(interaction.Form{Type: interaction.TabForm, Title: spellcheckTitle, Tabs: []interaction.Tab{
+ {ID: "suggestions", Label: "Suggestions", Controls: []interaction.Control{
+ {ID: "word", Label: "Word", Kind: interaction.Entry, Text: h.source, Action: "check_word"},
+ {ID: "suggestions", Label: "Suggestions", Kind: interaction.Grid, Columns: 6, Action: "copy_word"},
+ }},
+ {ID: "exceptions", Label: "Exceptions", Controls: []interaction.Control{
+ {ID: "word", Label: "Word", Kind: interaction.Entry, Text: h.source, Action: "add_word"},
+ {ID: "add", Label: "Add", Kind: interaction.Button, Action: "add_word"},
+ {ID: "exceptions", Label: "Exceptions", Kind: interaction.Grid, Columns: 6, Action: "copy_word", DeleteAction: "remove_word"},
+ {ID: "undo", Label: "Undo", Kind: interaction.Button, Action: "undo_remove"},
+ }},
+ }})
+ if err != nil {
+ ui.close()
+ ui.failed("Could not open Spellcheck Menu: {0}", err)
+ return
+ }
+ ui.session, ui.action = session, h.action
+ ui.changed = func(session *interaction.Session) {
+ source := session.Values()["suggestions.word"].Text
+ if source != h.source {
+ h.source = source
+ h.revision++
+ h.due = time.Now().Add(150 * time.Millisecond)
+ session.Set_choices("suggestions", "suggestions", nil)
+ }
+ }
+ ui.voice.Say(session.Open())
+ h.due = time.Now()
+ h.poll()
+}
+
+func (h *spellcheckMenu) poll() {
+ if h.due.IsZero() || time.Now().Before(h.due) || !h.ui.active || h.ui.generation != h.generation || h.busy {
+ return
+ }
+ if h.service == nil {
+ h.ui.voice.Say(i18n.T("Spell checking is unavailable"))
+ h.due = time.Time{}
+ return
+ }
+ if h.service.Submit(spellcheck.Request{Operation: spellcheck.Inspect, Word: h.source, Generation: h.generation, Revision: h.revision}) {
+ h.due = time.Time{}
+ }
+}
+
+func (h *spellcheckMenu) action(action string, session *interaction.Session) {
+ if action == "check_word" {
+ h.revision++
+ h.due = time.Now()
+ h.poll()
+ return
+ }
+ if action == "copy_word" {
+ control, ok := session.Focused_control()
+ if !ok || control.Selected < 0 || control.Selected >= len(control.Choices) {
+ return
+ }
+ word := control.Choices[control.Selected].ID
+ if !h.clipboards.add(word, true) {
+ return
+ }
+ h.ui.close()
+ h.clipboards.present(i18n.Format("Added {0} to clipboard", word), presentation.CopyToClipboard)
+ return
+ }
+ if h.busy || h.service == nil {
+ return
+ }
+ operation, word := spellcheck.Add, ""
+ switch action {
+ case "add_word":
+ word = session.Values()["exceptions.word"].Text
+ case "remove_word":
+ control, ok := session.Focused_control()
+ if !ok || control.Selected < 0 || control.Selected >= len(control.Choices) {
+ return
+ }
+ word, operation = control.Choices[control.Selected].ID, spellcheck.Remove
+ case "undo_remove":
+ if h.deleted == "" {
+ return
+ }
+ word = h.deleted
+ default:
+ return
+ }
+ if action == "add_word" {
+ for _, choice := range session.Values()["exceptions.exceptions"].Choices {
+ if strings.EqualFold(choice.ID, strings.TrimSpace(word)) {
+ h.ui.voice.Say(i18n.Format("{0} is already in dictionary", choice.ID))
+ return
+ }
+ }
+ }
+ if h.service.Submit(spellcheck.Request{Operation: operation, Word: word, Generation: h.generation, Revision: h.revision}) {
+ h.busy = true
+ }
+}
+
+func (h *spellcheckMenu) result(result spellcheck.Result) {
+ ui := h.ui
+ if !ui.active || ui.session == nil || ui.generation != h.generation || result.Generation != h.generation {
+ return
+ }
+ if result.Operation == spellcheck.Inspect && result.Revision != h.revision {
+ return
+ }
+ if result.Operation == spellcheck.Add || result.Operation == spellcheck.Remove {
+ h.busy = false
+ if result.Err != nil {
+ ui.failed("Could not save exceptions: {0}", result.Err)
+ return
+ }
+ message := "{0} added to dictionary"
+ if result.Operation == spellcheck.Remove {
+ message = "{0} removed"
+ h.deleted = result.Word
+ } else if strings.EqualFold(h.deleted, result.Word) {
+ h.deleted = ""
+ }
+ ui.session.Set_choices("exceptions", "exceptions", word_choices(result.Exceptions))
+ // An exception change may alter the source word's status immediately.
+ h.revision++
+ h.due = time.Now().Add(150 * time.Millisecond)
+ ui.voice.Say(i18n.Format(message, result.Word))
+ return
+ }
+ ui.session.Set_choices("exceptions", "exceptions", word_choices(result.Exceptions))
+ ui.session.Set_choices("suggestions", "suggestions", word_choices(result.Suggestions))
+ if result.Err != nil {
+ ui.failed("Could not check spelling: {0}", result.Err)
+ return
+ }
+ if !result.Available {
+ if result.DictionaryError != nil {
+ ui.failed("Spell checking is unavailable: {0}", result.DictionaryError)
+ }
+ return
+ }
+ status := "correct"
+ if !result.Correct {
+ status = "misspelled"
+ }
+ if result.Word == "" {
+ status = "No word"
+ }
+ ui.voice.Say(i18n.Format("Dictionary {0}. {1}. {2}", result.Language, result.Word, i18n.T(status)))
+}
diff --git a/internal/app/spellcheck_menu_test.go b/internal/app/spellcheck_menu_test.go
new file mode 100644
index 00000000..d5836bf2
--- /dev/null
+++ b/internal/app/spellcheck_menu_test.go
@@ -0,0 +1,195 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package app
+
+import (
+ "context"
+ "errors"
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/interaction"
+ "git.stormux.org/storm/fenrir/internal/spellcheck"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+type SpellDictionary struct{}
+
+func (*SpellDictionary) Check(word string) (bool, error) { return word == "hello", nil }
+func (*SpellDictionary) Suggest(string) ([]string, error) {
+ return []string{"hello", "help", "held"}, nil
+}
+func (*SpellDictionary) Close() {}
+
+func receive_spelling(t *testing.T, menu *spellcheckMenu) spellcheck.Result {
+ t.Helper()
+ select {
+ case result := <-menu.service.Results():
+ menu.result(result)
+ return result
+ case <-time.After(time.Second):
+ t.Fatal("spellcheck worker did not finish")
+ return spellcheck.Result{}
+ }
+}
+
+func TestSpellcheckMenuSuggestionsFilterCopyCancelAndEditableSource(t *testing.T) {
+ service := spellcheck.New_service(context.Background(), spellcheck.Options{Language: "en_US", ExceptionsPath: filepath.Join(t.TempDir(), "exceptions.json"), OpenDictionary: func(string) (spellcheck.Dictionary, error) { return &SpellDictionary{}, nil }})
+ defer service.Close()
+ clipboard, _, _, _ := clipboard_fixture()
+ clipboard.add("original", false)
+ ui := new_internal_ui(context.Background(), &fakeKeys{}, &fakeVoice{})
+ menu := &spellcheckMenu{ui: ui, service: service, clipboards: clipboard}
+ menu.open("helo")
+ receive_spelling(t, menu)
+ ui.handle(interaction.Key{Name: "tab"})
+ ui.handle(interaction.Key{Name: "tab"})
+ ui.handle(interaction.Key{Text: "zz"})
+ ui.handle(interaction.Key{Name: "enter"})
+ if !ui.active {
+ t.Fatal("empty suggestions closed menu")
+ }
+ ui.handle(interaction.Key{Name: "escape"})
+ if text, _ := clipboard.history.Current(); text != "original" {
+ t.Fatal(text)
+ }
+ menu.open("helo")
+ receive_spelling(t, menu)
+ ui.handle(interaction.Key{Name: "tab"})
+ ui.handle(interaction.Key{Name: "tab"})
+ ui.handle(interaction.Key{Text: "hel"})
+ ui.handle(interaction.Key{Name: "right"})
+ ui.handle(interaction.Key{Name: "enter"})
+ if text, _ := clipboard.history.Current(); text != "help" || ui.active {
+ t.Fatal(text, ui.active)
+ }
+ menu.open("helo")
+ receive_spelling(t, menu)
+ ui.handle(interaction.Key{Name: "tab"})
+ ui.handle(interaction.Key{Text: "a", Ctrl: true})
+ ui.handle(interaction.Key{Text: "hello"})
+ ui.handle(interaction.Key{Name: "enter"})
+ if result := receive_spelling(t, menu); result.Word != "hello" || !result.Correct {
+ t.Fatal(result)
+ }
+ if control, _ := ui.session.Focused_control(); control.ID != "word" || control.Text != "hello" {
+ t.Fatal(control)
+ }
+ ui.close()
+ menu.open("other")
+ menu.result(spellcheck.Result{Request: spellcheck.Request{Generation: menu.generation - 1}, Suggestions: []string{"wrong"}})
+ if len(ui.session.Values()["suggestions.suggestions"].Choices) != 0 {
+ t.Fatal("stale response applied")
+ }
+ ui.close()
+}
+
+func TestSpellcheckExceptionAddDeleteUndoAndCopy(t *testing.T) {
+ service := spellcheck.New_service(context.Background(), spellcheck.Options{Language: "en_US", ExceptionsPath: filepath.Join(t.TempDir(), "exceptions.json"), OpenDictionary: func(string) (spellcheck.Dictionary, error) { return &SpellDictionary{}, nil }})
+ defer service.Close()
+ clipboard, _, _, _ := clipboard_fixture()
+ ui := new_internal_ui(context.Background(), &fakeKeys{}, &fakeVoice{})
+ menu := &spellcheckMenu{ui: ui, service: service, clipboards: clipboard}
+ menu.open("Fenrir")
+ receive_spelling(t, menu)
+ ui.handle(interaction.Key{Name: "right"})
+ ui.handle(interaction.Key{Name: "tab"})
+ ui.handle(interaction.Key{Name: "enter"})
+ if result := receive_spelling(t, menu); result.Err != nil {
+ t.Fatal(result)
+ }
+ ui.handle(interaction.Key{Name: "tab"})
+ ui.handle(interaction.Key{Name: "tab"})
+ ui.handle(interaction.Key{Text: "fen"})
+ ui.handle(interaction.Key{Name: "delete", Repeat: true})
+ if menu.busy {
+ t.Fatal("held Delete removed exception")
+ }
+ ui.handle(interaction.Key{Name: "delete"})
+ receive_spelling(t, menu)
+ if !ui.active || len(ui.session.Values()["exceptions.exceptions"].Choices) != 0 {
+ t.Fatal("remove did not retain empty grid")
+ }
+ ui.handle(interaction.Key{Name: "tab"})
+ ui.handle(interaction.Key{Name: "enter"})
+ receive_spelling(t, menu)
+ ui.handle(interaction.Key{Name: "tab", Shift: true})
+ control, _ := ui.session.Focused_control()
+ if control.Text != "fen" || len(control.Choices) != 1 {
+ t.Fatal(control)
+ }
+ ui.handle(interaction.Key{Name: "enter"})
+ if text, _ := clipboard.history.Current(); text != "Fenrir" || ui.active {
+ t.Fatal(text, ui.active)
+ }
+}
+
+func TestSpellcheckMenuMissingDictionaryAndCaptureFailure(t *testing.T) {
+ service := spellcheck.New_service(context.Background(), spellcheck.Options{ExceptionsPath: filepath.Join(t.TempDir(), "exceptions.json"), OpenDictionary: func(string) (spellcheck.Dictionary, error) { return nil, errors.New("missing dictionary") }})
+ defer service.Close()
+ voice := &fakeVoice{}
+ clipboard, _, _, _ := clipboard_fixture()
+ ui := new_internal_ui(context.Background(), &fakeKeys{}, voice)
+ menu := &spellcheckMenu{ui: ui, service: service, clipboards: clipboard}
+ menu.open("helo")
+ receive_spelling(t, menu)
+ if !ui.active || !strings.Contains(strings.Join(voice.messages, "|"), "missing dictionary") {
+ t.Fatal(voice.messages)
+ }
+ ui.close()
+ ui.keys = &fakeKeys{captureErr: errors.New("capture refused")}
+ menu.open("helo")
+ if ui.active {
+ t.Fatal("capture failure opened menu")
+ }
+}
+
+func TestSpellcheckReadingPreviewCancelAndSave(t *testing.T) {
+ for _, save := range []bool{false, true} {
+ settings := config.Defaults()
+ path := filepath.Join(t.TempDir(), "settings.toml")
+ if err := config.Save(path, settings); err != nil {
+ t.Fatal(err)
+ }
+ voice := &fakeVoice{}
+ a := settings_ui_application(t, &fakeKeys{}, voice, path)
+ ui := a.ui
+ live := &a.settings.live.Reading
+ ui.open_settings(settings, path)
+ receive_ui(t, ui)
+ for range 8 {
+ if strings.Contains(ui.session.Describe(), "Reading tab") {
+ break
+ }
+ ui.handle(interaction.Key{Name: "right"})
+ }
+ for range 32 {
+ ui.handle(interaction.Key{Name: "tab"})
+ if control, ok := ui.session.Focused_control(); ok && control.ID == "spellcheck" {
+ break
+ }
+ }
+ ui.handle(interaction.Key{Name: "space"})
+ if live.Spellcheck {
+ t.Fatal("checkbox preview did not take effect")
+ }
+ if save {
+ for range 32 {
+ ui.handle(interaction.Key{Name: "tab"})
+ if ui.session.Describe() == "OK button" {
+ break
+ }
+ }
+ ui.handle(interaction.Key{Name: "enter"})
+ finish_persistence(t, a, false)
+ } else {
+ ui.handle(interaction.Key{Name: "escape"})
+ }
+ saved, _, err := config.Load(path, nil)
+ if err != nil || live.Spellcheck != !save || saved.Reading.Spellcheck != !save {
+ t.Fatal(save, live, saved.Reading, err)
+ }
+ }
+}
diff --git a/internal/app/spellcheck_test.go b/internal/app/spellcheck_test.go
new file mode 100644
index 00000000..dc116b33
--- /dev/null
+++ b/internal/app/spellcheck_test.go
@@ -0,0 +1,35 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package app
+
+import (
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "reflect"
+ "testing"
+)
+
+func TestMisspellingFeedbackIndependentChoicesAndSoundFallback(t *testing.T) {
+ for choices := range 8 {
+ for _, soundAvailable := range []bool{false, true} {
+ settings := config.Defaults().Reading
+ settings.SpellcheckReadWord = choices&1 != 0
+ settings.SpellcheckSayMisspelled = choices&2 != 0
+ settings.SpellcheckSound = choices&4 != 0
+ voice := &fakeVoice{}
+ var cues []presentation.Cue
+ present_misspelling("helo", settings, voice, func(cue presentation.Cue) bool { cues = append(cues, cue); return soundAvailable })
+ var want []string
+ if settings.SpellcheckReadWord {
+ want = append(want, "helo")
+ }
+ if settings.SpellcheckSayMisspelled || settings.SpellcheckSound && !soundAvailable {
+ want = append(want, "misspelled")
+ }
+ if !reflect.DeepEqual(voice.messages, want) || (len(cues) == 1) != settings.SpellcheckSound {
+ t.Fatal(choices, soundAvailable, voice.messages, cues)
+ }
+ }
+ }
+}
diff --git a/internal/app/startup.go b/internal/app/startup.go
new file mode 100644
index 00000000..e2c35103
--- /dev/null
+++ b/internal/app/startup.go
@@ -0,0 +1,178 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package app
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/clipboard"
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/console"
+ "git.stormux.org/storm/fenrir/internal/control"
+ "git.stormux.org/storm/fenrir/internal/input"
+ "git.stormux.org/storm/fenrir/internal/preferences"
+ "git.stormux.org/storm/fenrir/internal/punctuation"
+ "git.stormux.org/storm/fenrir/internal/scripts"
+ "git.stormux.org/storm/fenrir/internal/sound"
+ "git.stormux.org/storm/fenrir/internal/speech"
+ "git.stormux.org/storm/fenrir/internal/spellcheck"
+ "git.stormux.org/storm/fenrir/internal/terminal"
+)
+
+// disabledVoice allows sound-only operation without opening a speech driver.
+type disabledVoice struct{}
+
+func (disabledVoice) Say(string) {}
+func (disabledVoice) Say_with_pitch(string, int) {}
+func (disabledVoice) Configure(speech.Parameters) {}
+func (disabledVoice) Interrupt() {}
+func (disabledVoice) Toggle_mute() bool { return true }
+func (disabledVoice) Errors() <-chan error { return nil }
+func (disabledVoice) Close() {}
+
+func Start(ctx context.Context, settings config.Settings, configPath string, bindings []input.Binding) error {
+ return start(ctx, settings, false, configPath, bindings)
+}
+
+// Start_terminal runs the same reader and command dispatcher in an X11 PTY.
+func Start_terminal(ctx context.Context, settings config.Settings, configPath string, bindings []input.Binding) error {
+ return start(ctx, settings, true, configPath, bindings)
+}
+
+func start(ctx context.Context, settings config.Settings, terminalMode bool, configPath string, bindings []input.Binding) (result error) {
+ if err := settings.Validate(); err != nil {
+ return err
+ }
+ punctuationProcessor, punctuationPath, err := punctuation.Open(settings.Reading.PunctuationProfile)
+ if err != nil {
+ return fmt.Errorf("punctuation profile %s: %w", punctuationPath, err)
+ }
+ // Connect before creating virtual keyboards or acquiring any input grabs.
+ router, err := input.New_router(bindings)
+ if err != nil {
+ return err
+ }
+ if err := router.Set_multi_press_timeout(time.Duration(settings.Keyboard.MultiPressTimeoutMS) * time.Millisecond); err != nil {
+ return err
+ }
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return err
+ }
+ executable, err := os.Executable()
+ if err != nil {
+ return err
+ }
+ spellChecker := spellcheck.New_service(ctx, spellcheck.Options{Language: spellcheck.Language(), ExceptionsPath: spellcheck.Exceptions_path(home)})
+ defer spellChecker.Close()
+ installedScripts, problems := scripts.Discover(scripts.Paths(home, settings.General.ScriptPath, filepath.Join(filepath.Dir(executable), "config", "scripts")), nil)
+ for _, problem := range problems {
+ slog.Warn("script discovery", "error", problem, "at", time.Now())
+ }
+ scriptCommands := make(map[command.ID]scripts.Script)
+ var scriptBindings []input.ScriptBinding
+ for _, script := range installedScripts {
+ scriptCommands[script.ID] = script
+ scriptBindings = append(scriptBindings, input.ScriptBinding{ID: script.ID, Keys: script.Keys})
+ }
+ if err := router.Configure_scripts(settings.General.ScriptKeys, scriptBindings); err != nil {
+ return err
+ }
+ var remote *control.Server
+ if settings.Remote.Enabled {
+ remote, err = control.Open(ctx, control.Options{SocketFile: settings.Remote.SocketFile, TerminalMode: terminalMode, WindowID: os.Getenv("WINDOWID")})
+ if err != nil {
+ return fmt.Errorf("remote interface: %w (use remote.socket_file for a separate trial instance)", err)
+ }
+ defer remote.Close()
+ }
+ scriptRunner := scripts.New_runner(ctx, scripts.Limits{Workers: settings.Scripts.Workers, Queue: settings.Scripts.QueueLimit, OutputBytes: settings.Scripts.OutputBytes, Timeout: time.Duration(settings.Scripts.TimeoutSeconds) * time.Second}, func(terminal int) string { return script_username(terminalMode, terminal) })
+ defer scriptRunner.Close()
+ var voices Speaker = disabledVoice{}
+ var effectiveSpeech *config.Speech
+ if settings.Presentation.Speech.Enabled {
+ client, selected, err := open_speech(settings.Presentation.Speech)
+ effectiveSpeech = &selected
+ if err != nil {
+ return err
+ }
+ voices = speech.New(client)
+ voices.Configure(speech_parameters(settings))
+ }
+ defer voices.Close()
+ var cues SoundPlayer
+ var player *sound.Service
+ if settings.Presentation.Sound.Enabled {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return err
+ }
+ player, err = sound.Open(sound.Theme_paths(home, settings.Presentation.Sound.Theme), sound.Options{Driver: settings.Presentation.Sound.Driver, Device: settings.Presentation.Sound.Device, Volume: settings.Presentation.Sound.Volume})
+ if err != nil {
+ slog.Warn("sound unavailable or incomplete", "error", err, "at", time.Now())
+ }
+ if player != nil {
+ defer player.Close()
+ cues = player
+ }
+ }
+ historyStore := clipboard.New_persistence(clipboard.History_path(home, terminalMode), settings.Clipboard.Persistent)
+ defer func() { result = errors.Join(result, historyStore.Close()) }()
+ var source Source
+ var keyboards Keyboards
+ if terminalMode {
+ session, err := terminal.Open(os.Stdin, os.Stdout, router)
+ if err != nil {
+ return err
+ }
+ defer func() { result = errors.Join(result, session.Close()) }()
+ source, keyboards = session, session.Keyboard()
+ } else {
+ consoleSource, err := console.OpenSource()
+ if err != nil {
+ return err
+ }
+ defer consoleSource.Close()
+ source = consoleSource
+ consoleKeys, err := console.Open_keyboards(settings.Keyboard.Devices, router)
+ if err != nil {
+ return err
+ }
+ keyboards = consoleKeys
+ }
+ var paste func(context.Context, string, int) error
+ if consoleSource, ok := source.(*console.Source); ok {
+ paste = consoleSource.Paste
+ }
+ transfers := clipboard.New_worker(ctx, paste)
+ defer transfers.Close()
+ result = Run(ctx, source, keyboards, voices, RunOptions{Spellcheck: spellChecker, Bindings: bindings, Settings: settings, EffectiveSpeech: effectiveSpeech, Punctuation: punctuationProcessor, Sounds: cues, ConfigPath: configPath, Clipboard: transfers, ClipboardHistory: historyStore, SettingsChoices: preferences.Discover_choices, Scripts: scriptCommands, ScriptRunner: scriptRunner, Remote: remote, TerminalMode: terminalMode})
+ if result == nil && player != nil {
+ if err := player.Finish(context.Background()); err != nil {
+ slog.Warn("shutdown sound incomplete", "error", err, "at", time.Now())
+ }
+ }
+ return result
+}
diff --git a/internal/app/synthesizer_preview.go b/internal/app/synthesizer_preview.go
new file mode 100644
index 00000000..c9ddd2bf
--- /dev/null
+++ b/internal/app/synthesizer_preview.go
@@ -0,0 +1,61 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package app
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/i18n"
+ "git.stormux.org/storm/fenrir/internal/speech"
+)
+
+// Reuse the current connection only when its driver, device and baud match.
+// Numeric preview and restoration stay on the normal serialized speech worker.
+func (ui *internalUI) test_synthesizer(ctx context.Context, selected, current config.Speech) error {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ text := i18n.T("Voice test: The quick brown fox jumps over the lazy dog.")
+ sameConnection := selected.Driver == current.Driver
+ driver, err := speech.Find_driver(selected.Driver)
+ if err != nil {
+ return err
+ }
+ if !driver.Hardware {
+ return fmt.Errorf("selected driver is not a hardware synthesizer")
+ }
+ if driver.Serial {
+ baud := func(value int) int {
+ if value == 0 {
+ return 9600
+ }
+ return value
+ }
+ sameConnection = sameConnection && selected.HardwareDevice == current.HardwareDevice && baud(selected.HardwareBaudRate) == baud(current.HardwareBaudRate)
+ }
+ if sameConnection && current.Enabled {
+ voice := ui.voice
+ if echo, ok := voice.(*echoSpeaker); ok {
+ voice = echo.Speaker
+ }
+ preview, ok := voice.(interface {
+ Say_with_parameters(string, speech.Parameters)
+ })
+ if !ok {
+ return fmt.Errorf("speech service does not support parameter preview")
+ }
+ preview.Say_with_parameters(text, speech.Parameters{Rate: selected.Rate, Pitch: selected.Pitch, Volume: selected.Volume})
+ return nil
+ }
+ return speech.Test_hardware(ctx, selected.Driver, speech_options(selected), text)
+}
+
+func (ui *internalUI) test_failed(err error) {
+ slog.Warn("hardware speech test failed", "error", err, "at", time.Now())
+ ui.voice.Say(i18n.T("Voice test failed") + ": " + err.Error())
+}
diff --git a/internal/app/text_processing_test.go b/internal/app/text_processing_test.go
new file mode 100644
index 00000000..99b93aae
--- /dev/null
+++ b/internal/app/text_processing_test.go
@@ -0,0 +1,134 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package app
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/interaction"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "git.stormux.org/storm/fenrir/internal/punctuation"
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+func TestTextProcessingMenuCommitCancelAndFailedSave(t *testing.T) {
+ processor, _, err := punctuation.Load([]string{"../../config/punctuation/default.toml"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, action := range []string{"save", "cancel", "failed save"} {
+ t.Run(action, func(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "settings.toml")
+ if err := os.WriteFile(path, []byte("version=3\n[reading]\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ settings := config.Defaults()
+ text := ":) :) #####"
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 1, Active: true, Rows: 1, Cols: len(text), Cells: []rune(text)}}
+ voice := &fakeVoice{}
+ a, err := new_application(context.Background(), source, &fakeKeys{}, voice, RunOptions{Settings: settings, ConfigPath: path, Punctuation: processor})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer a.ui.close()
+ if err := a.read_screen(); err != nil {
+ t.Fatal(err)
+ }
+ if got := strings.Join(strings.Fields(voice.messages[len(voice.messages)-1]), " "); got != "smile smile hash hash hash hash hash" {
+ t.Fatal("automatic reading did not use default substitutions", voice.messages)
+ }
+ if a.voice.last != text {
+ t.Fatal("automatic reading changed clipboard echo", a.voice.last)
+ }
+ a.open_settings()
+ receive_ui(t, a.ui)
+ focus := func(want string) {
+ t.Helper()
+ for range 40 {
+ a.ui.handle(interaction.Key{Name: "tab"})
+ if voice.messages[len(voice.messages)-1] == want {
+ return
+ }
+ }
+ t.Fatal("control not found", want, voice.messages)
+ }
+ focus("Emoticons checked")
+ a.ui.handle(interaction.Key{Name: "space"})
+ focus("Repeated symbols unchecked")
+ a.ui.handle(interaction.Key{Name: "space"})
+ focus("Repeated symbols minimum length 4")
+ a.ui.handle(interaction.Key{Name: "up"})
+ if a.settings.live.Reading != settings.Reading {
+ t.Fatal("draft changed runtime processing")
+ }
+ if action == "cancel" {
+ a.ui.handle(interaction.Key{Name: "escape"})
+ } else {
+ if action == "failed save" {
+ if err := os.WriteFile(path, []byte("version=3\n[reading]\nemoticons=false\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ focus("OK button")
+ a.ui.handle(interaction.Key{Name: "enter"})
+ finish_persistence(t, a, action == "failed save")
+ if action == "failed save" {
+ if !a.ui.active || !strings.HasPrefix(voice.messages[len(voice.messages)-1], "Could not save settings:") {
+ t.Fatal("failed save lost the draft", voice.messages)
+ }
+ a.ui.handle(interaction.Key{Name: "escape"})
+ }
+ }
+ voice.messages = nil
+ a.voice.Say(text)
+ if a.voice.last != text {
+ t.Fatal("processing changed raw clipboard echo", a.voice.last)
+ }
+ if action != "save" {
+ if a.settings.live.Reading != settings.Reading || strings.Join(strings.Fields(voice.messages[0]), " ") != "smile smile hash hash hash hash hash" {
+ t.Fatal("cancel or failed save changed processing", voice.messages)
+ }
+ return
+ }
+ if strings.Join(strings.Fields(voice.messages[0]), " ") != ": : 5 number" {
+ t.Fatal("saved processing did not apply live", voice.messages)
+ }
+ saved, _, err := config.Load(path, nil)
+ if err != nil || saved.Reading.Emoticons || !saved.Reading.RepeatedSymbols || saved.Reading.RepeatedSymbolsMin != 5 {
+ t.Fatal(saved.Reading, err)
+ }
+ freshVoice := &fakeVoice{}
+ fresh, err := new_application(context.Background(), source, &fakeKeys{}, freshVoice, RunOptions{Settings: saved, Punctuation: processor})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer fresh.ui.close()
+ if err := fresh.read_screen(); err != nil || strings.Join(strings.Fields(freshVoice.messages[len(freshVoice.messages)-1]), " ") != ": : 5 number" {
+ t.Fatal("fresh reader lost saved processing", freshVoice.messages, err)
+ }
+ voice.messages = nil
+ a.review_command(command.CurrentLine)
+ if strings.Join(strings.Fields(voice.messages[0]), " ") != ": : 5 number" {
+ t.Fatal("review bypassed processing", voice.messages)
+ }
+ voice.messages = nil
+ a.voice.say_spelling(presentation.Spelling{Text: "#####"})
+ if !reflect.DeepEqual(voice.messages, []string{"hash", "hash", "hash", "hash", "hash"}) || a.voice.last != "#####" {
+ t.Fatal("summary changed spelling or raw copy", voice.messages, a.voice.last)
+ }
+ voice.messages = nil
+ a.voice.say_spelling(presentation.Spelling{Text: ":)", Phonetic: true})
+ if !reflect.DeepEqual(voice.messages, []string{"colon", "right paren"}) {
+ t.Fatal("emoticon changed phonetic feedback", voice.messages)
+ }
+ })
+ }
+}
diff --git a/internal/app/tutorial.go b/internal/app/tutorial.go
new file mode 100644
index 00000000..cd8c0398
--- /dev/null
+++ b/internal/app/tutorial.go
@@ -0,0 +1,186 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package app
+
+import (
+ "log/slog"
+ "slices"
+ "strconv"
+ "strings"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/i18n"
+ "git.stormux.org/storm/fenrir/internal/input"
+ "git.stormux.org/storm/fenrir/internal/scripts"
+)
+
+// These are the complete message IDs from Python's tutorial command and
+// HelpManager. Keep named fields intact until after gettext lookup.
+const tutorialEntry = "Entering tutorial mode. In this mode commands are described but not executed. Use up and down to browse actions and left and right to switch between Active actions, Unbound actions, and Plugins. Press Space to repeat the current item. To exit tutorial mode press Fenrir+F1 or Escape. Active actions."
+const tutorialExit = "Exiting tutorial mode. To enter tutorial mode again press Fenrir+F1"
+const tutorialReleaseFailed = "Unable to exit tutorial mode because exclusive keyboard capture could not be released. Press Fenrir+F1 or Escape to try again."
+const tutorialCaptureFailed = "Warning: full keyboard capture is unavailable. Unbound keys may reach the active application."
+const tutorialItem = "{command_name}, Shortcuts {command_shortcuts}, Description {command_description}"
+
+type tutorialMode struct {
+ sections [3][]command.ID
+ section int
+ index int
+ shortcuts map[command.ID][]string
+ names map[command.ID]string
+}
+
+func tutorial_key_name(name string) string {
+ name = strings.ToLower(name)
+ name = strings.ReplaceAll(name, "key_kp", "keypad ")
+ name = strings.TrimPrefix(name, "key_")
+ return i18n.T(name)
+}
+
+func tutorial_shortcut(keys []string, presses int, scriptKeys []string) string {
+ keys = slices.Clone(keys)
+ var ordered []string
+ for _, modifier := range []string{"KEY_FENRIR", "KEY_SCRIPT", "KEY_CTRL", "KEY_SHIFT", "KEY_ALT", "KEY_META"} {
+ if index := slices.Index(keys, modifier); index >= 0 {
+ ordered = append(ordered, modifier)
+ keys = slices.Delete(keys, index, index+1)
+ }
+ }
+ keys = append(ordered, keys...)
+ var names []string
+ for _, key := range keys {
+ if key == "KEY_SCRIPT" {
+ var physical []string
+ for _, scriptKey := range scriptKeys {
+ physical = append(physical, tutorial_key_name(scriptKey))
+ }
+ names = append(names, strings.Join(physical, " / "))
+ } else {
+ names = append(names, tutorial_key_name(key))
+ }
+ }
+ prefix := ""
+ if presses > 1 {
+ prefix = strings.ReplaceAll(i18n.T("{} times "), "{}", strconv.Itoa(presses))
+ }
+ return prefix + strings.Join(names, ", ")
+}
+
+func new_tutorial(bindings []input.Binding, installed map[command.ID]scripts.Script, scriptKeys []string) *tutorialMode {
+ mode := &tutorialMode{index: -1, shortcuts: map[command.ID][]string{}, names: map[command.ID]string{}}
+ for _, binding := range bindings {
+ keys := make([]string, len(binding.Keys))
+ for index, key := range binding.Keys {
+ keys[index] = "KEY_" + key
+ }
+ for index, id := range []command.ID{binding.Command, binding.DoubleCommand, binding.TripleCommand} {
+ if id != "" {
+ mode.shortcuts[id] = append(mode.shortcuts[id], tutorial_shortcut(keys, index+1, scriptKeys))
+ }
+ }
+ }
+ for _, id := range command.IDs() {
+ section := 0
+ if len(mode.shortcuts[id]) == 0 {
+ section = 1
+ }
+ mode.sections[section] = append(mode.sections[section], id)
+ }
+ for id, script := range installed {
+ mode.names[id] = strings.ReplaceAll(strings.ToLower(script.Name), "_", " ")
+ mode.shortcuts[id] = []string{tutorial_shortcut(script.Keys, 1, scriptKeys)}
+ mode.sections[2] = append(mode.sections[2], id)
+ }
+ slices.Sort(mode.sections[2])
+ return mode
+}
+
+func (mode *tutorialMode) describe(id command.ID) string {
+ name := mode.names[id]
+ if name == "" {
+ name = strings.ReplaceAll(string(id), "_", " ")
+ }
+ shortcuts := strings.Join(mode.shortcuts[id], "; ")
+ if shortcuts == "" {
+ shortcuts = i18n.T("unbound")
+ }
+ return strings.NewReplacer("{command_name}", name, "{command_shortcuts}", shortcuts,
+ "{command_description}", command.Description(id)).Replace(i18n.T(tutorialItem))
+}
+
+func (mode *tutorialMode) current() string {
+ if mode.index < 0 || len(mode.sections[mode.section]) == 0 {
+ return i18n.T([]string{"Active actions", "Unbound actions", "Plugins"}[mode.section])
+ }
+ return mode.describe(mode.sections[mode.section][mode.index])
+}
+
+func (mode *tutorialMode) handle(key input.LearnKey) (string, bool) {
+ if key.Command == command.Help || key.Code == input.KeyEsc {
+ return "", true
+ }
+ if key.Bare {
+ switch key.Code {
+ case input.KeyLeft, input.KeyRight:
+ delta := 1
+ if key.Code == input.KeyLeft {
+ delta = -1
+ }
+ mode.section = (mode.section + delta + 3) % 3
+ mode.index = -1
+ return mode.current(), false
+ case input.KeyUp, input.KeyDown:
+ count := len(mode.sections[mode.section])
+ if count > 0 {
+ if mode.index < 0 {
+ mode.index = 0
+ if key.Code == input.KeyUp {
+ mode.index = count - 1
+ }
+ } else if key.Code == input.KeyUp {
+ mode.index = (mode.index + count - 1) % count
+ } else {
+ mode.index = (mode.index + 1) % count
+ }
+ }
+ return mode.current(), false
+ case input.KeySpace:
+ return mode.current(), false
+ }
+ }
+ var names []string
+ for _, code := range append(slices.Clone(key.Held), key.Code) {
+ names = append(names, tutorial_key_name(input.Key_name(code)))
+ }
+ text := strings.Join(names, ", ")
+ if key.Command != "" && key.Command != command.Unbound {
+ text += ". " + mode.describe(key.Command)
+ } else {
+ text += ". " + i18n.T("unbound")
+ }
+ return text, false
+}
+
+func (ui *internalUI) open_tutorial(bindings []input.Binding, installed map[command.ID]scripts.Script, scriptKeys []string) {
+ if ui.active {
+ return
+ }
+ if _, err := ui.begin(""); err != nil {
+ slog.Warn("tutorial capture failed", "error", err, "at", time.Now())
+ ui.voice.Say(i18n.T(tutorialCaptureFailed))
+ return
+ }
+ ui.tutorial = new_tutorial(bindings, installed, scriptKeys)
+ ui.voice.Say(i18n.T(tutorialEntry))
+}
+
+func (ui *internalUI) handle_tutorial(key input.LearnKey) {
+ text, close := ui.tutorial.handle(key)
+ if close {
+ ui.close()
+ return
+ }
+ ui.voice.Say(text)
+}
diff --git a/internal/app/tutorial_test.go b/internal/app/tutorial_test.go
new file mode 100644
index 00000000..b3fd2b90
--- /dev/null
+++ b/internal/app/tutorial_test.go
@@ -0,0 +1,264 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package app
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/i18n"
+ "git.stormux.org/storm/fenrir/internal/input"
+ "git.stormux.org/storm/fenrir/internal/screen"
+ "git.stormux.org/storm/fenrir/internal/scripts"
+)
+
+func TestTutorialBrowsesCustomUnboundAndPluginActions(t *testing.T) {
+ bindings := []input.Binding{{Keys: []string{"FENRIR", "Z"}, Command: command.Time, DoubleCommand: command.Date, TripleCommand: command.CurrentQuickEntry}}
+ installed := map[command.ID]scripts.Script{"script:example": {Name: "EXAMPLE", Keys: []string{"KEY_SCRIPT", "KEY_SHIFT", "KEY_U"}}}
+ mode := new_tutorial(bindings, installed, []string{"KEY_COMPOSE", "KEY_META"})
+ if got := mode.describe(command.Date); !strings.Contains(got, "2 times fenrir, z") || !strings.Contains(got, "presents the date") {
+ t.Fatal(got)
+ }
+ if got := mode.describe(command.CurrentQuickEntry); !strings.Contains(got, "3 times fenrir, z") {
+ t.Fatal(got)
+ }
+ if got := mode.describe("script:example"); !strings.Contains(got, "compose / meta, shift, u") {
+ t.Fatal(got)
+ }
+ press := func(code uint16) string {
+ t.Helper()
+ text, closed := mode.handle(input.LearnKey{Code: code, Bare: true})
+ if closed {
+ t.Fatal("unexpected exit")
+ }
+ return text
+ }
+ if press(input.KeySpace) != "Active actions" {
+ t.Fatal(mode.current())
+ }
+ first := press(input.KeyDown)
+ if first != mode.describe(command.CurrentQuickEntry) || press(input.KeySpace) != first {
+ t.Fatal(first)
+ }
+ if press(input.KeyUp) != mode.describe(command.Time) {
+ t.Fatal("previous did not wrap", mode.current())
+ }
+ if press(input.KeyRight) != "Unbound actions" {
+ t.Fatal(mode.current())
+ }
+ if got := press(input.KeyDown); !strings.Contains(got, "Shortcuts unbound") {
+ t.Fatal(got)
+ }
+ if press(input.KeyRight) != "Plugins" || press(input.KeyDown) != mode.describe("script:example") {
+ t.Fatal(mode.current())
+ }
+ if press(input.KeyRight) != "Active actions" || press(input.KeyLeft) != "Plugins" {
+ t.Fatal(mode.current())
+ }
+ empty := new_tutorial(nil, nil, nil)
+ empty.section = 2
+ if got, _ := empty.handle(input.LearnKey{Code: input.KeyDown, Bare: true}); got != "Plugins" {
+ t.Fatal(got)
+ }
+ if got, _ := mode.handle(input.LearnKey{Code: 88}); got != "f12. unbound" {
+ t.Fatal(got)
+ }
+ if got, _ := mode.handle(input.LearnKey{Code: input.KeyRightShift}); got != "rightshift. unbound" {
+ t.Fatal(got)
+ }
+ if _, closed := mode.handle(input.LearnKey{Code: input.KeyEsc, Held: []uint16{44}}); !closed {
+ t.Fatal("Escape did not exit")
+ }
+}
+
+func TestTutorialDispatcherBlocksActionsAndRestoresTyping(t *testing.T) {
+ bindings, _, err := input.Load_bindings([]string{"../../config/keyboard/desktop.toml"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ router, err := input.New_router(bindings)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := router.Configure_scripts([]string{"KEY_COMPOSE"}, []input.ScriptBinding{{ID: "script:example", Keys: []string{"KEY_SCRIPT", "KEY_U"}}}); err != nil {
+ t.Fatal(err)
+ }
+ keys, voice := &fakeKeys{}, &fakeVoice{}
+ keys.onCapture = router.Capture
+ var forwarded []uint16
+ keys.poll = func(_ bool, handle func(input.Decision)) error {
+ chord := func(codes ...uint16) {
+ for _, code := range codes {
+ decision := router.Key(0, code, 1, false, false)
+ if decision.Forward {
+ forwarded = append(forwarded, code)
+ }
+ handle(decision)
+ }
+ for _, code := range codes {
+ handle(router.Key(0, code, 0, false, false))
+ }
+ }
+ chord(input.KeyInsert, input.KeyH)
+ for _, code := range []uint16{input.KeyQ, input.KeyF4, input.KeyBackspace, input.KeyF, input.KeyKPEnter, 47} {
+ chord(input.KeyInsert, code)
+ }
+ chord(input.KeyInsert, input.KeyCtrl, 31) // save
+ compose, _ := input.Key_code("KEY_COMPOSE")
+ chord(compose, 22)
+ chord(88)
+ if !router.Captured() {
+ t.Fatal("described action closed tutorial")
+ }
+ chord(input.KeyEsc)
+ chord(30)
+ chord(input.KeyInsert, input.KeyQ)
+ return nil
+ }
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 1, Active: true}}
+ if err := run(context.Background(), source, keys, voice, nil, RunOptions{Bindings: bindings, Settings: config.Defaults()}); err != nil {
+ t.Fatal(err)
+ }
+ if voice.muted || keys.passed || !keys.closed || !reflect.DeepEqual(keys.captures, []bool{true, false}) || !reflect.DeepEqual(forwarded, []uint16{30}) {
+ t.Fatal(voice.muted, keys, forwarded)
+ }
+ text := strings.Join(voice.messages, "\n")
+ for _, expected := range []string{tutorialEntry, tutorialExit, "exits Fenrir", "enables or disables speech", "Fenrir Menu", "Saves your current Fenrir settings", "f12. unbound"} {
+ if !strings.Contains(text, expected) {
+ t.Fatal("missing", expected, text)
+ }
+ }
+}
+
+func TestTutorialCaptureFailureAndMenuExclusion(t *testing.T) {
+ keys, voice := &fakeKeys{}, &fakeVoice{}
+ ui := new_internal_ui(context.Background(), keys, voice)
+ keys.captureErr = errors.New("capture refused")
+ ui.open_tutorial(nil, nil, nil)
+ if ui.active || voice.messages[len(voice.messages)-1] != tutorialCaptureFailed {
+ t.Fatal(ui, voice.messages)
+ }
+ keys.captureErr = nil
+ ui.open_tutorial(nil, nil, nil)
+ keys.captureErr = errors.New("release refused")
+ ui.close()
+ if !ui.active || ui.tutorial == nil || voice.messages[len(voice.messages)-1] != tutorialReleaseFailed {
+ t.Fatal(ui, voice.messages)
+ }
+ keys.captureErr = nil
+ ui.close()
+ if ui.active || voice.messages[len(voice.messages)-1] != tutorialExit {
+ t.Fatal(ui, voice.messages)
+ }
+ ui.begin("Fenrir Menu")
+ count := len(keys.captures)
+ ui.open_tutorial(nil, nil, nil)
+ if ui.tutorial != nil || len(keys.captures) != count {
+ t.Fatal("tutorial replaced menu")
+ }
+ ui.close()
+}
+
+func TestTutorialClosesOnFocusOrVTHandoff(t *testing.T) {
+ for _, focusLoss := range []bool{false, true} {
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 1, Active: true}}
+ keys, voice := &fakeKeys{}, &fakeVoice{}
+ step := 0
+ keys.poll = func(_ bool, handle func(input.Decision)) error {
+ step++
+ if step == 1 {
+ handle(input.Decision{Command: command.Help})
+ if focusLoss {
+ source.snapshot.Active = false
+ } else {
+ source.snapshot.Terminal = 2
+ }
+ } else {
+ handle(input.Decision{Command: command.Quit})
+ }
+ return nil
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ err := Run(ctx, source, keys, voice, RunOptions{Settings: config.Defaults()})
+ cancel()
+ if err != nil || step != 2 || !reflect.DeepEqual(keys.captures, []bool{true, false}) || !strings.Contains(strings.Join(voice.messages, "\n"), tutorialExit) {
+ t.Fatal(focusLoss, err, step, keys.captures, voice.messages)
+ }
+ }
+}
+
+func TestTutorialSuppressesBackgroundReading(t *testing.T) {
+ source := &fakeSource{snapshot: screen.Snapshot{Terminal: 1, Active: true, Rows: 1, Cols: 5, Cells: []rune("first")}, events: make(chan error, 1)}
+ keys, voice := &fakeKeys{events: make(chan error, 1)}, &fakeVoice{}
+ step := 0
+ keys.poll = func(_ bool, handle func(input.Decision)) error {
+ step++
+ if step == 1 {
+ handle(input.Decision{Command: command.Help})
+ source.snapshot.Cells = []rune("later")
+ source.afterRead = func() { source.afterRead = nil; keys.events <- nil }
+ source.events <- nil
+ } else {
+ handle(input.Decision{Learn: &input.LearnKey{Code: input.KeyEsc}})
+ handle(input.Decision{Command: command.CurrentLine})
+ handle(input.Decision{Command: command.Quit})
+ }
+ return nil
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ if err := run(ctx, source, keys, voice, nil, RunOptions{Settings: config.Defaults()}); err != nil {
+ t.Fatal(err)
+ }
+ count := 0
+ for _, text := range voice.messages {
+ if text == "later" {
+ count++
+ }
+ }
+ if step != 2 || count != 1 {
+ t.Fatal("background reading escaped or review failed after exit", step, voice.messages)
+ }
+}
+
+func TestTutorialReusesLocalizedDescriptionsAndNamedFields(t *testing.T) {
+ root := t.TempDir()
+ dir := filepath.Join(root, "xx", "LC_MESSAGES")
+ if err := os.MkdirAll(dir, 0700); err != nil {
+ t.Fatal(err)
+ }
+ data := `msgid ""
+msgstr ""
+"Content-Type: text/plain; charset=UTF-8\n"
+
+msgid "{command_name}, Shortcuts {command_shortcuts}, Description {command_description}"
+msgstr "{command_description}: {command_shortcuts}: {command_name}"
+
+msgid "presents the time"
+msgstr "translated-time"
+
+msgid "{} times "
+msgstr "{} repetitions "
+
+msgid "fenrir"
+msgstr "translated-fenrir"
+`
+ if err := os.WriteFile(filepath.Join(dir, "fenrir.po"), []byte(data), 0600); err != nil {
+ t.Fatal(err)
+ }
+ previous := i18n.Use(i18n.New(root, "xx"))
+ t.Cleanup(func() { i18n.Use(previous) })
+ mode := new_tutorial([]input.Binding{{Keys: []string{"FENRIR", "Z"}, Command: command.Date, DoubleCommand: command.Time}}, nil, nil)
+ if got := mode.describe(command.Time); got != "translated-time: 2 repetitions translated-fenrir, z: time" {
+ t.Fatal(got)
+ }
+}
diff --git a/internal/app/version_test.go b/internal/app/version_test.go
new file mode 100644
index 00000000..8daf4086
--- /dev/null
+++ b/internal/app/version_test.go
@@ -0,0 +1,29 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package app
+
+import (
+ "context"
+ "testing"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/input"
+ "git.stormux.org/storm/fenrir/internal/version"
+)
+
+func TestVersionUsesBuildMetadataInBothModes(t *testing.T) {
+ for _, terminalMode := range []bool{false, true} {
+ voice := &fakeVoice{}
+ a, err := new_application(context.Background(), &fakeSource{}, &fakeKeys{}, voice, RunOptions{Settings: config.Defaults(), TerminalMode: terminalMode})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer a.ui.close()
+ a.handle_input(input.Decision{Command: command.Version, Interrupt: true})
+ if len(voice.messages) != 1 || voice.messages[0] != "Fenrir screen reader version "+version.Label() {
+ t.Fatal("spoken version differs from CLI metadata", voice.messages)
+ }
+ }
+}
diff --git a/internal/clipboard/display.go b/internal/clipboard/display.go
new file mode 100644
index 00000000..eb1031d0
--- /dev/null
+++ b/internal/clipboard/display.go
@@ -0,0 +1,121 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package clipboard
+
+import (
+ "fmt"
+ "os"
+ "os/user"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+)
+
+type Target struct{ Display, Authority string }
+
+// Display_candidates never falls through an explicit setting or DISPLAY to a
+// different session. Discovery uses existing local sockets, not a fixed :0..:9.
+func Display_candidates(configured, current, authority, home, socketDirectory string) ([]Target, error) {
+ if authority == "" {
+ authority = filepath.Join(home, ".Xauthority")
+ }
+ if configured != "" {
+ return []Target{{configured, authority}}, nil
+ }
+ if current != "" {
+ return []Target{{current, authority}}, nil
+ }
+ entries, err := os.ReadDir(socketDirectory)
+ if err != nil {
+ return nil, err
+ }
+ var targets []Target
+ for _, entry := range entries {
+ name := entry.Name()
+ if !strings.HasPrefix(name, "X") {
+ continue
+ }
+ number, err := strconv.Atoi(name[1:])
+ if err != nil || number < 0 {
+ continue
+ }
+ info, err := entry.Info()
+ if err == nil && info.Mode()&os.ModeSocket != 0 {
+ targets = append(targets, Target{fmt.Sprintf(":%d", number), authority})
+ }
+ }
+ sort.Slice(targets, func(i, j int) bool { return targets[i].Display < targets[j].Display })
+ if len(targets) == 0 {
+ return nil, fmt.Errorf("no local X display")
+ }
+ // Multiple desktops require an explicit choice; do not silently mix users.
+ if len(targets) > 1 {
+ return nil, fmt.Errorf("multiple X displays; set clipboard.sync_display")
+ }
+ return targets, nil
+}
+
+func system_targets(configured string) ([]Target, error) {
+ home, _ := os.UserHomeDir()
+ if os.Geteuid() == 0 && os.Getenv("SUDO_UID") != "" {
+ if caller, err := user.LookupId(os.Getenv("SUDO_UID")); err == nil {
+ home = caller.HomeDir
+ }
+ }
+ targets, err := Display_candidates(configured, os.Getenv("DISPLAY"), os.Getenv("XAUTHORITY"), home, "/tmp/.X11-unix")
+ if err != nil {
+ return nil, err
+ }
+ if os.Getenv("XAUTHORITY") == "" {
+ // Console services may lack a desktop environment. Xorg's -auth names
+ // the actual cookie file, including display-manager runtime locations.
+ // Read command-line metadata only; never log or copy cookie contents.
+ paths, _ := filepath.Glob("/proc/[0-9]*/cmdline")
+ for _, path := range paths {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ continue
+ }
+ args := strings.Split(strings.TrimRight(string(data), "\x00"), "\x00")
+ if len(args) == 0 {
+ continue
+ }
+ switch filepath.Base(args[0]) {
+ case "Xorg", "X", "Xwayland":
+ default:
+ continue
+ }
+ display, auth := "", ""
+ for i, arg := range args {
+ if strings.HasPrefix(arg, ":") {
+ display = arg
+ }
+ if arg == "-auth" && i+1 < len(args) {
+ auth = args[i+1]
+ }
+ }
+ for i := range targets {
+ if display != "" && strings.SplitN(targets[i].Display, ".", 2)[0] == display && auth != "" {
+ targets[i].Authority = auth
+ }
+ }
+ }
+ }
+ return targets, nil
+}
diff --git a/internal/clipboard/history.go b/internal/clipboard/history.go
new file mode 100644
index 00000000..b97a5fc6
--- /dev/null
+++ b/internal/clipboard/history.go
@@ -0,0 +1,100 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+// Package clipboard owns text history and asynchronous clipboard transfers.
+package clipboard
+
+import (
+ "fmt"
+ "unicode/utf8"
+)
+
+// MaxBytes bounds individual entries and file/helper messages.
+const MaxBytes = 4 << 20
+
+// History belongs to the application loop. New entries select the newest item;
+// navigation wraps, matching Python Fenrir. Clear removes the entire history.
+type History struct {
+ entries []string
+ index int
+ limit int
+}
+
+func New_history(limit int) *History { return &History{limit: max(1, limit)} }
+
+func Validate_text(text string) error {
+ if len(text) > MaxBytes || !utf8.ValidString(text) {
+ return fmt.Errorf("clipboard text must be valid UTF-8 and at most 4 MiB")
+ }
+ return nil
+}
+
+func (h *History) Add(text string) error {
+ if err := Validate_text(text); err != nil {
+ return err
+ }
+ h.entries = append([]string{text}, h.entries[:min(len(h.entries), h.limit-1)]...)
+ h.index = 0
+ return nil
+}
+
+func (h *History) Current() (string, bool) {
+ if len(h.entries) == 0 {
+ return "", false
+ }
+ return h.entries[h.index], true
+}
+
+func (h *History) Move(delta int) {
+ if len(h.entries) > 0 {
+ h.index = (h.index + delta + len(h.entries)) % len(h.entries)
+ }
+}
+func (h *History) First() { h.index = 0 }
+func (h *History) Last() { h.index = max(0, len(h.entries)-1) }
+func (h *History) At_first() bool { return len(h.entries) > 0 && h.index == 0 }
+func (h *History) At_last() bool { return len(h.entries) > 0 && h.index == len(h.entries)-1 }
+func (h *History) Clear() { h.entries, h.index = nil, 0 }
+
+// Refresh adopts shared entries while retaining this reader's selected copy.
+// Count duplicates from the oldest end so a new identical copy stays distinct.
+func (h *History) Refresh(state Snapshot) error {
+ text, selected := h.Current()
+ occurrence := 0
+ if selected {
+ for _, entry := range h.entries[h.index:] {
+ if entry == text {
+ occurrence++
+ }
+ }
+ }
+ if err := h.Restore(state); err != nil {
+ return err
+ }
+ if selected {
+ for i := len(h.entries) - 1; i >= 0; i-- {
+ if h.entries[i] == text {
+ occurrence--
+ if occurrence == 0 {
+ h.index = i
+ break
+ }
+ }
+ }
+ }
+ return nil
+}
diff --git a/internal/clipboard/history_test.go b/internal/clipboard/history_test.go
new file mode 100644
index 00000000..f6fc85d0
--- /dev/null
+++ b/internal/clipboard/history_test.go
@@ -0,0 +1,122 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package clipboard
+
+import (
+ "net"
+ "os"
+ "path/filepath"
+ "strings"
+ "syscall"
+ "testing"
+)
+
+func TestHistoryLimitUnicodeWrappingAndClear(t *testing.T) {
+ history := New_history(2)
+ for _, text := range []string{"old", "Ć©ē\nnext", "new"} {
+ if err := history.Add(text); err != nil {
+ t.Fatal(err)
+ }
+ }
+ history.Move(-1)
+ if text, ok := history.Current(); !ok || text != "Ć©ē\nnext" || !history.At_last() {
+ t.Fatal(text, ok)
+ }
+ history.Move(1)
+ if text, _ := history.Current(); text != "new" || !history.At_first() {
+ t.Fatal(text)
+ }
+ history.Last()
+ history.First()
+ if text, _ := history.Current(); text != "new" {
+ t.Fatal(text)
+ }
+ for _, text := range []string{string([]byte{0xff}), strings.Repeat("x", MaxBytes+1)} {
+ if err := history.Add(text); err == nil {
+ t.Fatal("unbounded or invalid text accepted")
+ }
+ }
+ history.Clear()
+ if _, ok := history.Current(); ok {
+ t.Fatal("clear retained history")
+ }
+}
+
+func TestDisplaySelectionDoesNotFallThroughAndDiscoversHigherNumbers(t *testing.T) {
+ root := t.TempDir()
+ for _, fixture := range []struct{ configured, current, want string }{{":8", ":7", ":8"}, {"", ":7", ":7"}} {
+ targets, err := Display_candidates(fixture.configured, fixture.current, "/test/auth", root, "/does-not-exist")
+ if err != nil || len(targets) != 1 || targets[0] != (Target{fixture.want, "/test/auth"}) {
+ t.Fatal(targets, err)
+ }
+ }
+ listener, err := net.Listen("unix", filepath.Join(root, "X42"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer listener.Close()
+ if err := os.WriteFile(filepath.Join(root, "X3"), nil, 0600); err != nil {
+ t.Fatal(err)
+ }
+ targets, err := Display_candidates("", "", "", root, root)
+ if err != nil || len(targets) != 1 || targets[0].Display != ":42" {
+ t.Fatal(targets, err)
+ }
+ other, err := net.Listen("unix", filepath.Join(root, "X43"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer other.Close()
+ if _, err := Display_candidates("", "", "", root, root); err == nil {
+ t.Fatal("silently chose among multiple desktops")
+ }
+}
+
+func TestFileRoundTripBoundsPrivacyAndNonregularInput(t *testing.T) {
+ root := t.TempDir()
+ path := filepath.Join(root, "clipboard")
+ text := "Ć©ē\nsecond line\n"
+ if err := write_file(path, text); err != nil {
+ t.Fatal(err)
+ }
+ if got, err := read_file(path); err != nil || got != text {
+ t.Fatal(got, err)
+ }
+ info, err := os.Stat(path)
+ if err != nil || info.Mode().Perm() != 0600 {
+ t.Fatal(info, err)
+ }
+ if err := write_file(path, strings.Repeat("x", MaxBytes+1)); err == nil {
+ t.Fatal("oversize export accepted")
+ }
+ if got, _ := read_file(path); got != text {
+ t.Fatal("failed export changed original")
+ }
+ if err := syscall.Mkfifo(filepath.Join(root, "fifo"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := read_file(filepath.Join(root, "fifo")); err == nil {
+ t.Fatal("FIFO accepted")
+ }
+ if err := os.WriteFile(path, []byte{0xff}, 0600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := read_file(path); err == nil {
+ t.Fatal("invalid UTF-8 imported")
+ }
+}
diff --git a/internal/clipboard/persistence.go b/internal/clipboard/persistence.go
new file mode 100644
index 00000000..4991e3cf
--- /dev/null
+++ b/internal/clipboard/persistence.go
@@ -0,0 +1,242 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package clipboard
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "syscall"
+ "time"
+ "unicode/utf8"
+)
+
+const maxHistoryBytes = 64 << 20
+
+// Snapshot preserves newest-first order and the selected history entry.
+type Snapshot struct {
+ Version int `json:"version"`
+ Entries []string `json:"entries"`
+ Selected int `json:"selected"`
+}
+
+func History_path(home string, terminalMode bool) string {
+ if terminalMode {
+ return filepath.Join(home, ".local/state/fenrir/clipboard/terminal.json")
+ }
+ return "/var/lib/fenrir/clipboard/tty.json"
+}
+
+func (h *History) Snapshot() Snapshot {
+ return Snapshot{Version: 1, Entries: append([]string{}, h.entries...), Selected: h.index}
+}
+
+func validate_snapshot(state Snapshot) error {
+ if state.Version != 1 || len(state.Entries) > 10000 || state.Selected < 0 || (len(state.Entries) == 0 && state.Selected != 0) || (len(state.Entries) > 0 && state.Selected >= len(state.Entries)) {
+ return fmt.Errorf("invalid clipboard history")
+ }
+ total := 0
+ for _, text := range state.Entries {
+ if err := Validate_text(text); err != nil {
+ return err
+ }
+ total += len(text)
+ if total > maxHistoryBytes {
+ return fmt.Errorf("clipboard history exceeds 64 MiB")
+ }
+ }
+ return nil
+}
+
+func (h *History) Restore(state Snapshot) error {
+ if err := validate_snapshot(state); err != nil {
+ return err
+ }
+ h.entries = append([]string(nil), state.Entries[:min(len(state.Entries), h.limit)]...)
+ h.index = min(state.Selected, max(0, len(h.entries)-1))
+ return nil
+}
+
+func (h *History) Resize(limit int) {
+ h.limit = max(1, limit)
+ h.entries = h.entries[:min(len(h.entries), h.limit)]
+ h.index = min(h.index, max(0, len(h.entries)-1))
+}
+
+// A stable lock inode serializes only individual transactions. Closing it
+// releases ownership; another reader can immediately read, update or clear.
+func acquire_history(path string, create bool) (*os.File, error) {
+ if err := private_directory(filepath.Dir(path), create); err != nil {
+ if !create && errors.Is(err, os.ErrNotExist) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ file, err := os.OpenFile(path+".lock", os.O_CREATE|os.O_RDWR|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0600)
+ if err != nil {
+ return nil, err
+ }
+ info, err := file.Stat()
+ if err == nil && (!info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 || info.Sys().(*syscall.Stat_t).Uid != uint32(os.Geteuid())) {
+ err = fmt.Errorf("clipboard history lock must be private and owned by the current account")
+ }
+ deadline := time.Now().Add(2 * time.Second)
+ for err == nil {
+ err = syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
+ if !errors.Is(err, syscall.EWOULDBLOCK) || time.Now().After(deadline) {
+ break
+ }
+ time.Sleep(10 * time.Millisecond)
+ err = nil
+ }
+ if err == nil {
+ err = remove_temporary_history(path)
+ }
+ if err != nil {
+ file.Close()
+ return nil, err
+ }
+ return file, nil
+}
+
+func private_directory(path string, create bool) error {
+ if create {
+ if err := os.MkdirAll(path, 0700); err != nil {
+ return err
+ }
+ }
+ info, err := os.Lstat(path)
+ if err != nil {
+ return err
+ }
+ if !info.IsDir() || info.Mode().Perm()&0077 != 0 || info.Sys().(*syscall.Stat_t).Uid != uint32(os.Geteuid()) {
+ return fmt.Errorf("clipboard history directory must be private and owned by the current account")
+ }
+ return nil
+}
+
+func load_history(path string) (Snapshot, error) {
+ empty := Snapshot{Version: 1}
+ if err := private_directory(filepath.Dir(path), false); err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return empty, nil
+ }
+ return empty, err
+ }
+ file, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
+ if errors.Is(err, os.ErrNotExist) {
+ return empty, nil
+ }
+ if err != nil {
+ return empty, err
+ }
+ defer file.Close()
+ info, err := file.Stat()
+ if err != nil {
+ return empty, err
+ }
+ if !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 || info.Sys().(*syscall.Stat_t).Uid != uint32(os.Geteuid()) {
+ return empty, fmt.Errorf("clipboard history must be a private regular file owned by the current account")
+ }
+ data, err := io.ReadAll(io.LimitReader(file, maxHistoryBytes+1))
+ if err != nil {
+ return empty, err
+ }
+ if len(data) > maxHistoryBytes || !utf8.Valid(data) {
+ return empty, fmt.Errorf("invalid or oversized clipboard history")
+ }
+ var state Snapshot
+ if err := json.Unmarshal(data, &state); err != nil {
+ return empty, fmt.Errorf("invalid clipboard history JSON")
+ }
+ if err := validate_snapshot(state); err != nil {
+ return empty, err
+ }
+ return state, nil
+}
+
+func save_history(path string, state Snapshot) error {
+ if err := validate_snapshot(state); err != nil {
+ return err
+ }
+ data, err := json.Marshal(state)
+ if err != nil {
+ return err
+ }
+ if len(data) > maxHistoryBytes {
+ return fmt.Errorf("clipboard history exceeds 64 MiB")
+ }
+ if err := private_directory(filepath.Dir(path), true); err != nil {
+ return err
+ }
+ // Do not replace symlinks, devices or directories supplied at the destination.
+ if info, err := os.Lstat(path); err == nil {
+ if !info.Mode().IsRegular() {
+ return fmt.Errorf("clipboard history must be a regular file")
+ }
+ } else if !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ file, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+"-*.tmp")
+ if err != nil {
+ return err
+ }
+ defer os.Remove(file.Name())
+ _, writeErr := file.Write(data)
+ err = errors.Join(writeErr, file.Sync(), file.Close())
+ if err != nil {
+ return err
+ }
+ if err := os.Rename(file.Name(), path); err != nil {
+ return err
+ }
+ return sync_directory(filepath.Dir(path))
+}
+
+func remove_history(path string) error {
+ if err := private_directory(filepath.Dir(path), false); err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ return err
+ }
+ err := os.Remove(path)
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ return sync_directory(filepath.Dir(path))
+}
+
+func sync_directory(path string) error {
+ dir, err := os.Open(path)
+ if err != nil {
+ return err
+ }
+ return errors.Join(dir.Sync(), dir.Close())
+}
+
+// A killed process can leave an atomic-write temporary file. Only clean this
+// history's own files after acquiring its lock, including when clearing it.
+func remove_temporary_history(path string) error {
+ entries, err := os.ReadDir(filepath.Dir(path))
+ if err != nil {
+ return err
+ }
+ for _, entry := range entries {
+ if !strings.HasPrefix(entry.Name(), "."+filepath.Base(path)+"-") || !strings.HasSuffix(entry.Name(), ".tmp") || !entry.Type().IsRegular() {
+ continue
+ }
+ if err := os.Remove(filepath.Join(filepath.Dir(path), entry.Name())); err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/internal/clipboard/persistence_test.go b/internal/clipboard/persistence_test.go
new file mode 100644
index 00000000..fce509e8
--- /dev/null
+++ b/internal/clipboard/persistence_test.go
@@ -0,0 +1,227 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package clipboard
+
+import (
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "syscall"
+ "testing"
+ "time"
+)
+
+func TestPersistentHistoryRoundTripResizeAndPrivateModes(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "private", "terminal.json")
+ history := New_history(3)
+ for _, text := range []string{"old", "Ć©ē\nsecond line", "š"} {
+ if err := history.Add(text); err != nil {
+ t.Fatal(err)
+ }
+ }
+ history.Move(1)
+ store := New_persistence(path, false)
+ store.Enable(history.Snapshot(), 3)
+ if err := store.Close(); err != nil {
+ t.Fatal(err)
+ }
+ for file, mode := range map[string]os.FileMode{path: 0600, filepath.Dir(path): 0700} {
+ info, err := os.Stat(file)
+ if err != nil || info.Mode().Perm() != mode {
+ t.Fatal(info, err)
+ }
+ }
+ store = New_persistence(path, true)
+ if store.LoadErr != nil {
+ t.Fatal(store.LoadErr)
+ }
+ restored := New_history(2)
+ if err := restored.Restore(store.Initial); err != nil {
+ t.Fatal(err)
+ }
+ if got, _ := restored.Current(); got != "Ć©ē\nsecond line" || !restored.At_last() {
+ t.Fatal(got)
+ }
+ if err := store.Close(); err != nil {
+ t.Fatal(err)
+ }
+ restored.Resize(1)
+ if got, _ := restored.Current(); got != "š" {
+ t.Fatal(got)
+ }
+ restored.Resize(4)
+ restored.Add("new")
+ if !reflect.DeepEqual(restored.Snapshot().Entries, []string{"new", "š"}) {
+ t.Fatal(restored.Snapshot())
+ }
+}
+
+func TestPersistenceDisabledDoesNotReadOrCreateAndModesAreSeparate(t *testing.T) {
+ home := t.TempDir()
+ terminalPath := History_path(home, true)
+ if !strings.HasPrefix(terminalPath, home+string(os.PathSeparator)) || History_path(home, false) != "/var/lib/fenrir/clipboard/tty.json" || terminalPath == History_path(home, false) {
+ t.Fatal(terminalPath, History_path(home, false))
+ }
+ store := New_persistence(terminalPath, false)
+ if store.LoadErr != nil || len(store.Initial.Entries) != 0 {
+ t.Fatal(store)
+ }
+ if err := store.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(filepath.Dir(terminalPath)); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("disabled persistence created state", err)
+ }
+ // A pre-existing malformed file is also ignored while disabled.
+ path := filepath.Join(home, "malformed.json")
+ os.WriteFile(path, []byte("not json"), 0600)
+ store = New_persistence(path, false)
+ if store.LoadErr != nil {
+ t.Fatal("disabled persistence read a file", store.LoadErr)
+ }
+ store.Close()
+}
+
+func TestPersistenceClearWinsOverQueuedWritesAndQuitFlushes(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "private", "clipboard.json")
+ state := Snapshot{Version: 1, Entries: []string{"first"}}
+ store := New_persistence(path, false)
+ store.Enable(state, 200)
+ deadline := time.Now().Add(time.Second)
+ for {
+ if _, err := os.Stat(path); err == nil {
+ break
+ }
+ if time.Now().After(deadline) {
+ t.Fatal("initial write did not finish")
+ }
+ time.Sleep(time.Millisecond)
+ }
+ for range 10 {
+ store.Add(strings.Repeat("x", 1<<20), 200)
+ }
+ store.Clear()
+ if err := store.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("old write resurrected cleared history", err)
+ }
+ store = New_persistence(path, false)
+ store.Enable(state, 200)
+ if err := store.Close(); err != nil {
+ t.Fatal(err)
+ }
+ got, err := load_history(path)
+ if err != nil || !reflect.DeepEqual(got, state) {
+ t.Fatal("quit did not flush newest state", got, err)
+ }
+}
+
+func TestPersistenceRejectsCorruptUnsafeAndUnboundedFiles(t *testing.T) {
+ root := filepath.Join(t.TempDir(), "private")
+ if err := os.Mkdir(root, 0700); err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(root, "history.json")
+ for _, data := range [][]byte{
+ []byte("not JSON"), []byte{0xff},
+ []byte(`{"version":2,"entries":[],"selected":0}`),
+ []byte(`{"version":1,"entries":["word"],"selected":2}`),
+ []byte(`{"version":1,"entries":[],"selected":-1}`),
+ } {
+ if err := os.WriteFile(path, data, 0600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := load_history(path); err == nil {
+ t.Fatal("invalid snapshot accepted")
+ }
+ }
+ state := Snapshot{Version: 1, Entries: []string{"word"}}
+ if err := save_history(path, state); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chmod(path, 0644); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := load_history(path); err == nil {
+ t.Fatal("public file accepted")
+ }
+ os.Chmod(path, 0600)
+ file, err := os.OpenFile(path, os.O_WRONLY, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ file.Truncate(maxHistoryBytes + 1)
+ file.Close()
+ if _, err := load_history(path); err == nil {
+ t.Fatal("oversized file accepted")
+ }
+ if err := save_history(path, state); err != nil {
+ t.Fatal(err)
+ }
+ before, _ := os.ReadFile(path)
+ if err := save_history(path, Snapshot{Version: 1, Entries: []string{strings.Repeat("x", MaxBytes+1)}}); err == nil {
+ t.Fatal("oversized entry saved")
+ }
+ after, _ := os.ReadFile(path)
+ if string(before) != string(after) {
+ t.Fatal("rejected save replaced history")
+ }
+ link := filepath.Join(root, "link.json")
+ if err := os.Symlink(path, link); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := load_history(link); err == nil {
+ t.Fatal("symlink read")
+ }
+ if err := save_history(link, state); err == nil {
+ t.Fatal("symlink replaced")
+ }
+ fifo := filepath.Join(root, "fifo")
+ if err := syscall.Mkfifo(fifo, 0600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := load_history(fifo); err == nil {
+ t.Fatal("FIFO accepted")
+ }
+ public := filepath.Join(root, "public")
+ os.Mkdir(public, 0755)
+ if err := save_history(filepath.Join(public, "history.json"), state); err == nil {
+ t.Fatal("public directory accepted")
+ }
+ var decoded Snapshot
+ if err := json.Unmarshal(before, &decoded); err != nil || !reflect.DeepEqual(state, decoded) {
+ t.Fatal(decoded, err)
+ }
+}
+
+func TestClearRemovesTemporaryHistoryLeftByInterruptedWrite(t *testing.T) {
+ root := filepath.Join(t.TempDir(), "private")
+ if err := os.Mkdir(root, 0700); err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(root, "terminal.json")
+ stale := filepath.Join(root, ".terminal.json-interrupted.tmp")
+ other := filepath.Join(root, ".other.json-interrupted.tmp")
+ for _, name := range []string{stale, other} {
+ if err := os.WriteFile(name, []byte("old clipboard text"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ store := New_persistence(path, false)
+ store.Clear()
+ if err := store.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(stale); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("clear retained interrupted write", err)
+ }
+ if _, err := os.Stat(other); err != nil {
+ t.Fatal("clear removed another history's file", err)
+ }
+}
diff --git a/internal/clipboard/shared_history.go b/internal/clipboard/shared_history.go
new file mode 100644
index 00000000..67d663f0
--- /dev/null
+++ b/internal/clipboard/shared_history.go
@@ -0,0 +1,358 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package clipboard
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "reflect"
+ "sync"
+ "syscall"
+ "time"
+)
+
+// HistoryUpdate contains shared entries and the local submission it acknowledges.
+// Selection stays local; Selected is only the next launch's saved selection.
+type HistoryUpdate struct {
+ State Snapshot
+ Enabled bool
+ Sequence uint64
+}
+
+type historyJob struct {
+ action string
+ state Snapshot
+ text string
+ limit int
+ sequence uint64
+ imported bool
+}
+
+// Persistence applies actions to the latest shared snapshot under a short lock.
+// Disk I/O and lock contention stay off the input loop. Close drains actions.
+type Persistence struct {
+ Initial Snapshot
+ LoadErr error
+ InitialEnabled bool
+ wake chan struct{}
+ errors chan error
+ updates chan HistoryUpdate
+ done chan struct{}
+ mutex sync.Mutex
+ jobs []historyJob
+ sequence uint64
+ closed bool
+ lastErr error
+}
+
+func New_persistence(path string, load bool) *Persistence {
+ p := &Persistence{Initial: Snapshot{Version: 1}, InitialEnabled: load, wake: make(chan struct{}, 1), errors: make(chan error, 1), updates: make(chan HistoryUpdate, 1), done: make(chan struct{})}
+ if load {
+ file, err := acquire_history(path, false)
+ if err == nil {
+ p.InitialEnabled, err = persistence_enabled(file)
+ if err == nil && p.InitialEnabled {
+ p.Initial, err = load_history(path)
+ }
+ if file != nil {
+ file.Close()
+ }
+ }
+ p.LoadErr = err
+ }
+ go p.run(path, load)
+ return p
+}
+
+func (p *Persistence) Errors() <-chan error { return p.errors }
+func (p *Persistence) Updates() <-chan HistoryUpdate { return p.updates }
+func (p *Persistence) Sequence() uint64 {
+ p.mutex.Lock()
+ defer p.mutex.Unlock()
+ return p.sequence
+}
+
+func (p *Persistence) submit(job historyJob) {
+ p.mutex.Lock()
+ p.sequence++
+ job.sequence = p.sequence
+ // Intermediate navigation is disposable; data and enable/disable actions
+ // retain their order, including an enable immediately followed by Clear.
+ if job.action == "select" && len(p.jobs) > 0 && p.jobs[len(p.jobs)-1].action == "select" {
+ p.jobs = p.jobs[:len(p.jobs)-1]
+ }
+ p.jobs = append(p.jobs, job)
+ p.mutex.Unlock()
+ select {
+ case p.wake <- struct{}{}:
+ default:
+ }
+}
+
+func (p *Persistence) Enable(state Snapshot, limit int) {
+ p.submit(historyJob{action: "enable", state: state, limit: limit})
+}
+func (p *Persistence) Add(text string, limit int) {
+ p.submit(historyJob{action: "add", text: text, limit: limit})
+}
+func (p *Persistence) Import(text string, limit int) {
+ p.submit(historyJob{action: "add", text: text, limit: limit, imported: true})
+}
+func (p *Persistence) Select(state Snapshot) { p.submit(historyJob{action: "select", state: state}) }
+func (p *Persistence) Resize(limit int) { p.submit(historyJob{action: "resize", limit: limit}) }
+func (p *Persistence) Clear() { p.submit(historyJob{action: "clear"}) }
+func (p *Persistence) Disable() { p.submit(historyJob{action: "disable"}) }
+func (p *Persistence) Close() error {
+ p.mutex.Lock()
+ p.closed = true
+ p.mutex.Unlock()
+ select {
+ case p.wake <- struct{}{}:
+ default:
+ }
+ <-p.done
+ return p.lastErr
+}
+
+func (p *Persistence) report(err error) {
+ p.lastErr = err
+ if err != nil {
+ select {
+ case <-p.errors:
+ default:
+ }
+ p.errors <- err
+ }
+}
+
+func (p *Persistence) run(path string, watching bool) {
+ defer close(p.done)
+ tick := time.NewTicker(200 * time.Millisecond)
+ defer tick.Stop()
+ last := HistoryUpdate{State: p.Initial, Enabled: p.InitialEnabled}
+ var signature string
+ var acknowledged uint64
+ for {
+ select {
+ case <-p.wake:
+ case <-tick.C:
+ }
+ p.mutex.Lock()
+ jobs, closed := p.jobs, p.closed
+ p.jobs = nil
+ p.mutex.Unlock()
+ for _, job := range jobs {
+ update, err := apply_history(path, job)
+ acknowledged = job.sequence
+ p.report(err)
+ if job.action == "enable" {
+ watching = true
+ }
+ if err == nil && watching {
+ update.Sequence = acknowledged
+ p.publish(update)
+ last = update
+ }
+ signature = ""
+ }
+ if closed {
+ return
+ }
+ // Starting disabled never imports a previous snapshot. Once participating,
+ // readers also observe another participant's disable/re-enable operations.
+ if !watching {
+ continue
+ }
+ nextSignature := history_signature(path)
+ if nextSignature == signature {
+ continue
+ }
+ file, err := acquire_history(path, false)
+ update := HistoryUpdate{State: Snapshot{Version: 1}, Sequence: acknowledged}
+ if err == nil {
+ update.Enabled, err = persistence_enabled(file)
+ if err == nil && update.Enabled {
+ update.State, err = load_history(path)
+ }
+ if file != nil {
+ file.Close()
+ }
+ }
+ if err != nil {
+ // Report a stable failure once, and retry after a file changes.
+ if signature != nextSignature {
+ p.report(err)
+ }
+ } else {
+ if !reflect.DeepEqual(update, last) {
+ p.publish(update)
+ last = update
+ }
+ }
+ signature = nextSignature
+ }
+}
+
+func (p *Persistence) publish(update HistoryUpdate) {
+ select {
+ case <-p.updates:
+ default:
+ }
+ p.updates <- update
+}
+
+func history_signature(path string) string {
+ result := ""
+ for _, name := range []string{path, path + ".lock"} {
+ info, err := os.Lstat(name)
+ if err != nil {
+ result += fmt.Sprint(err)
+ continue
+ }
+ stat := info.Sys().(*syscall.Stat_t)
+ result += fmt.Sprintf("%d:%d:%d:%d;", stat.Dev, stat.Ino, info.Size(), info.ModTime().UnixNano())
+ }
+ return result
+}
+
+// The empty original lock file means enabled. One metadata byte coordinates
+// disabling without retaining clipboard text or resurrecting a deleted file.
+func persistence_enabled(file *os.File) (bool, error) {
+ if file == nil {
+ return true, nil
+ }
+ var value [1]byte
+ n, err := file.ReadAt(value[:], 0)
+ if n == 0 && errors.Is(err, io.EOF) {
+ return true, nil
+ }
+ if err != nil {
+ return false, err
+ }
+ if value[0] != '0' && value[0] != '1' {
+ return false, fmt.Errorf("invalid clipboard persistence state")
+ }
+ return value[0] == '1', nil
+}
+
+func set_persistence_enabled(file *os.File, enabled bool) error {
+ value := []byte("0")
+ if enabled {
+ value[0] = '1'
+ }
+ _, err := file.WriteAt(value, 0)
+ return errors.Join(err, file.Sync())
+}
+
+func apply_history(path string, job historyJob) (HistoryUpdate, error) {
+ result := HistoryUpdate{State: Snapshot{Version: 1}}
+ create := job.action == "enable" || job.action == "add" || job.action == "disable"
+ file, err := acquire_history(path, create)
+ if err != nil {
+ return result, err
+ }
+ if file != nil {
+ defer file.Close()
+ }
+ result.Enabled, err = persistence_enabled(file)
+ if err != nil {
+ return result, err
+ }
+ switch job.action {
+ case "clear", "disable":
+ if job.action == "disable" {
+ if err := set_persistence_enabled(file, false); err != nil {
+ return result, err
+ }
+ result.Enabled = false
+ }
+ return result, remove_history(path)
+ case "enable":
+ if err := validate_snapshot(job.state); err != nil {
+ return result, err
+ }
+ // Enabling deliberately saves retained memory, but preserves newer shared
+ // entries when another reader already has an active saved history.
+ state, err := load_history(path)
+ if err != nil {
+ return result, err
+ }
+ if len(state.Entries) == 0 {
+ state = job.state
+ } else {
+ for i := len(job.state.Entries) - 1; i >= 0; i-- {
+ text := job.state.Entries[i]
+ found := false
+ for _, existing := range state.Entries {
+ if existing == text {
+ found = true
+ break
+ }
+ }
+ if !found {
+ state.Entries = append([]string{text}, state.Entries...)
+ }
+ }
+ }
+ result.State = trim_history(state, job.limit)
+ if len(result.State.Entries) > 0 {
+ err = save_history(path, result.State)
+ } else {
+ err = remove_history(path)
+ }
+ if err != nil {
+ return result, err
+ }
+ if err := set_persistence_enabled(file, true); err != nil {
+ return result, err
+ }
+ result.Enabled = true
+ return result, nil
+ }
+ if !result.Enabled {
+ return result, nil
+ }
+ result.State, err = load_history(path)
+ if err != nil {
+ return result, err
+ }
+ switch job.action {
+ case "add":
+ if err := Validate_text(job.text); err != nil {
+ return result, err
+ }
+ if job.imported && len(result.State.Entries) > 0 && result.State.Entries[0] == job.text {
+ return result, nil // Two X observers imported the same clipboard change.
+ }
+ result.State.Entries = append([]string{job.text}, result.State.Entries...)
+ result.State.Selected = 0
+ result.State = trim_history(result.State, job.limit)
+ case "select":
+ // Navigation cannot recreate entries removed by another reader.
+ if job.state.Selected < len(job.state.Entries) && job.state.Selected >= 0 {
+ history := New_history(10000)
+ if err := history.Restore(job.state); err != nil {
+ return result, err
+ }
+ if err := history.Refresh(result.State); err != nil {
+ return result, err
+ }
+ result.State.Selected = history.Snapshot().Selected
+ }
+ case "resize":
+ result.State = trim_history(result.State, job.limit)
+ }
+ if len(result.State.Entries) == 0 {
+ return result, nil
+ }
+ return result, save_history(path, result.State)
+}
+
+func trim_history(state Snapshot, limit int) Snapshot {
+ state.Entries = state.Entries[:min(len(state.Entries), max(1, limit))]
+ state.Selected = min(state.Selected, max(0, len(state.Entries)-1))
+ return state
+}
diff --git a/internal/clipboard/shared_persistence_test.go b/internal/clipboard/shared_persistence_test.go
new file mode 100644
index 00000000..15e0d8a3
--- /dev/null
+++ b/internal/clipboard/shared_persistence_test.go
@@ -0,0 +1,330 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package clipboard
+
+import (
+ "encoding/json"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "reflect"
+ "sync"
+ "testing"
+ "time"
+)
+
+type sharedCommand struct {
+ Action string
+ Text string
+ State Snapshot
+ Limit int
+}
+
+type sharedReply struct {
+ State Snapshot
+ Enabled bool
+ Error string
+}
+
+// Separate processes exercise flock and polling, rather than sharing Go state.
+func TestSharedClipboardProcess(t *testing.T) {
+ path := os.Getenv("FENRIR_CLIPBOARD_PROCESS_PATH")
+ if path == "" {
+ return
+ }
+ store := New_persistence(path, true)
+ current := sharedReply{State: store.Initial, Enabled: store.InitialEnabled}
+ if store.LoadErr != nil {
+ current.Error = store.LoadErr.Error()
+ }
+ encoder, decoder := json.NewEncoder(os.Stdout), json.NewDecoder(os.Stdin)
+ if err := encoder.Encode(current); err != nil {
+ os.Exit(2)
+ }
+ for {
+ var command sharedCommand
+ if err := decoder.Decode(&command); err != nil {
+ store.Close()
+ os.Exit(0)
+ }
+ switch command.Action {
+ case "add":
+ store.Add(command.Text, command.Limit)
+ case "select":
+ store.Select(command.State)
+ case "resize":
+ store.Resize(command.Limit)
+ case "clear":
+ store.Clear()
+ case "disable":
+ store.Disable()
+ case "enable":
+ store.Enable(command.State, command.Limit)
+ case "quit":
+ if err := store.Close(); err != nil {
+ current.Error = err.Error()
+ }
+ encoder.Encode(current)
+ os.Exit(0)
+ }
+ if command.Action != "state" {
+ deadline := time.NewTimer(4 * time.Second)
+ wait:
+ for {
+ select {
+ case update := <-store.Updates():
+ current.State, current.Enabled = update.State, update.Enabled
+ if update.Sequence == store.Sequence() {
+ break wait
+ }
+ case err := <-store.Errors():
+ current.Error = err.Error()
+ break wait
+ case <-deadline.C:
+ current.Error = "timed out waiting for action"
+ break wait
+ }
+ }
+ deadline.Stop()
+ } else {
+ drain:
+ for {
+ select {
+ case update := <-store.Updates():
+ current.State, current.Enabled = update.State, update.Enabled
+ case err := <-store.Errors():
+ current.Error = err.Error()
+ default:
+ break drain
+ }
+ }
+ }
+ if err := encoder.Encode(current); err != nil {
+ os.Exit(2)
+ }
+ }
+}
+
+type sharedProcess struct {
+ command *exec.Cmd
+ input io.WriteCloser
+ encoder *json.Encoder
+ decoder *json.Decoder
+}
+
+func start_shared_process(t *testing.T, path string) *sharedProcess {
+ t.Helper()
+ command := exec.Command(os.Args[0], "-test.run=^TestSharedClipboardProcess$")
+ command.Env = append(os.Environ(), "FENRIR_CLIPBOARD_PROCESS_PATH="+path)
+ command.Stderr = os.Stderr
+ input, err := command.StdinPipe()
+ if err != nil {
+ t.Fatal(err)
+ }
+ output, err := command.StdoutPipe()
+ if err != nil {
+ t.Fatal(err)
+ }
+ process := &sharedProcess{command: command, input: input, encoder: json.NewEncoder(input), decoder: json.NewDecoder(output)}
+ if err := command.Start(); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { input.Close(); command.Wait() })
+ reply := process.read(t)
+ if reply.Error != "" {
+ t.Fatal(reply.Error)
+ }
+ return process
+}
+
+func (p *sharedProcess) read(t *testing.T) sharedReply {
+ t.Helper()
+ // A broken worker must fail the test instead of blocking the whole suite.
+ result := make(chan sharedReply, 1)
+ failures := make(chan error, 1)
+ go func() {
+ var reply sharedReply
+ if err := p.decoder.Decode(&reply); err != nil {
+ failures <- err
+ } else {
+ result <- reply
+ }
+ }()
+ select {
+ case reply := <-result:
+ return reply
+ case err := <-failures:
+ t.Fatal(err)
+ case <-time.After(6 * time.Second):
+ p.command.Process.Kill()
+ t.Fatal("clipboard process did not reply")
+ }
+ return sharedReply{}
+}
+
+func (p *sharedProcess) action(t *testing.T, command sharedCommand) sharedReply {
+ t.Helper()
+ if command.Limit == 0 {
+ command.Limit = 200
+ }
+ if err := p.encoder.Encode(command); err != nil {
+ t.Fatal(err)
+ }
+ reply := p.read(t)
+ if reply.Error != "" {
+ t.Fatal(reply.Error)
+ }
+ return reply
+}
+
+func (p *sharedProcess) await(t *testing.T, entries []string, enabled bool) sharedReply {
+ t.Helper()
+ deadline := time.Now().Add(4 * time.Second)
+ for {
+ reply := p.action(t, sharedCommand{Action: "state"})
+ if reflect.DeepEqual(reply.State.Entries, entries) && reply.Enabled == enabled {
+ return reply
+ }
+ if time.Now().After(deadline) {
+ t.Fatalf("shared state = %+v, want %v enabled=%v", reply, entries, enabled)
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+}
+
+func TestPersistentClipboardSharesAcrossProcessesAndClearCannotResurrect(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "private", "terminal.json")
+ first, second := start_shared_process(t, path), start_shared_process(t, path)
+ first.action(t, sharedCommand{Action: "add", Text: "first"})
+ second.await(t, []string{"first"}, true)
+ second.action(t, sharedCommand{Action: "add", Text: "second"})
+ first.await(t, []string{"second", "first"}, true)
+ stale := Snapshot{Version: 1, Entries: []string{"second", "first"}, Selected: 1}
+ first.action(t, sharedCommand{Action: "select", State: stale})
+ second.action(t, sharedCommand{Action: "clear"})
+ first.action(t, sharedCommand{Action: "select", State: stale})
+ first.action(t, sharedCommand{Action: "resize", Limit: 10})
+ first.await(t, nil, true)
+ if _, err := os.Stat(path); !os.IsNotExist(err) {
+ t.Fatal("navigation recreated cleared history", err)
+ }
+ first.action(t, sharedCommand{Action: "add", Text: "after clear"})
+ second.await(t, []string{"after clear"}, true)
+ second.action(t, sharedCommand{Action: "disable"})
+ first.await(t, nil, false)
+ first.action(t, sharedCommand{Action: "select", State: stale})
+ first.action(t, sharedCommand{Action: "add", Text: "late copy"})
+ if _, err := os.Stat(path); !os.IsNotExist(err) {
+ t.Fatal("peer recreated disabled snapshot", err)
+ }
+ // An explicit enable is required to resume writes.
+ second.action(t, sharedCommand{Action: "enable", State: Snapshot{Version: 1, Entries: []string{"retained"}}})
+ first.await(t, []string{"retained"}, true)
+ first.action(t, sharedCommand{Action: "quit"})
+ second.action(t, sharedCommand{Action: "add", Text: "survivor"})
+ third := start_shared_process(t, path)
+ third.await(t, []string{"survivor", "retained"}, true)
+ if err := second.command.Process.Kill(); err != nil {
+ t.Fatal(err)
+ }
+ third.action(t, sharedCommand{Action: "add", Text: "after crash"})
+ third.await(t, []string{"after crash", "survivor", "retained"}, true)
+}
+
+func TestPersistentClipboardConcurrentProcessCopiesKeepBothUpdates(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "private", "terminal.json")
+ first, second := start_shared_process(t, path), start_shared_process(t, path)
+ var group sync.WaitGroup
+ for _, process := range []*sharedProcess{first, second} {
+ group.Add(1)
+ go func() {
+ defer group.Done()
+ for _, text := range []string{"one", "two", "three", "four"} {
+ process.action(t, sharedCommand{Action: "add", Text: text})
+ }
+ }()
+ }
+ group.Wait()
+ state, err := load_history(path)
+ if err != nil || len(state.Entries) != 8 {
+ t.Fatal("lost concurrent copies", state, err)
+ }
+ counts := make(map[string]int)
+ for _, text := range state.Entries {
+ counts[text]++
+ }
+ for _, text := range []string{"one", "two", "three", "four"} {
+ if counts[text] != 2 {
+ t.Fatal("copy lost or duplicated", counts)
+ }
+ }
+}
+
+func TestPersistentClipboardRefreshRetainsLocalSelectionAndDuplicateIdentity(t *testing.T) {
+ history := New_history(5)
+ history.Add("same")
+ history.Add("middle")
+ history.Add("same")
+ history.Move(2)
+ if err := history.Refresh(Snapshot{Version: 1, Entries: []string{"same", "same", "middle", "same"}}); err != nil {
+ t.Fatal(err)
+ }
+ if history.Snapshot().Selected != 3 {
+ t.Fatal("selected older duplicate moved", history.Snapshot())
+ }
+ if err := history.Refresh(Snapshot{Version: 1}); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := history.Current(); ok {
+ t.Fatal("shared clear retained a local entry")
+ }
+}
+
+func TestPersistenceQueuedEnableThenClearStillParticipates(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "private", "terminal.json")
+ store := New_persistence(path, false)
+ store.Enable(Snapshot{Version: 1, Entries: []string{"old"}}, 200)
+ store.Clear()
+ store.Add("fresh", 200)
+ deadline := time.NewTimer(3 * time.Second)
+ defer deadline.Stop()
+ for {
+ select {
+ case update := <-store.Updates():
+ if update.Sequence == store.Sequence() {
+ if !update.Enabled || !reflect.DeepEqual(update.State.Entries, []string{"fresh"}) {
+ t.Fatal(update)
+ }
+ if err := store.Close(); err != nil {
+ t.Fatal(err)
+ }
+ return
+ }
+ case err := <-store.Errors():
+ t.Fatal(err)
+ case <-deadline.C:
+ store.Close()
+ t.Fatal("queued enable/Clear lost participation")
+ }
+ }
+}
+
+func TestPersistenceDuplicateXImportsAreOneEntryButCopiesRemainDistinct(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "private", "terminal.json")
+ first, second := New_persistence(path, true), New_persistence(path, true)
+ first.Import("shared X text", 200)
+ if err := first.Close(); err != nil {
+ t.Fatal(err)
+ }
+ second.Import("shared X text", 200)
+ second.Add("shared X text", 200)
+ if err := second.Close(); err != nil {
+ t.Fatal(err)
+ }
+ state, err := load_history(path)
+ if err != nil || !reflect.DeepEqual(state.Entries, []string{"shared X text", "shared X text"}) {
+ t.Fatal(state, err)
+ }
+}
diff --git a/internal/clipboard/worker.go b/internal/clipboard/worker.go
new file mode 100644
index 00000000..66574c1d
--- /dev/null
+++ b/internal/clipboard/worker.go
@@ -0,0 +1,205 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package clipboard
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "sync"
+ "syscall"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+)
+
+type Request struct {
+ Command command.ID // empty means a background X poll
+ Revision uint64
+ Text, Path, Display string
+ Terminal int
+ Background bool
+ Feedback uint64
+}
+type Result struct {
+ Request Request
+ Text string
+ Err error
+}
+
+type queuedRequest struct {
+ request Request
+ epoch uint64
+}
+
+type Worker struct {
+ jobs chan queuedRequest
+ results chan Result
+ ctx context.Context
+ cancel context.CancelFunc
+ done chan struct{}
+ targets func(string) ([]Target, error)
+ paste func(context.Context, string, int) error
+ mu sync.Mutex
+ jobCancel context.CancelFunc
+ jobCommand command.ID
+ epoch uint64
+}
+
+func New_worker(ctx context.Context, paste func(context.Context, string, int) error) *Worker {
+ ctx, cancel := context.WithCancel(ctx)
+ w := &Worker{jobs: make(chan queuedRequest, 1), results: make(chan Result, 8), ctx: ctx, cancel: cancel, done: make(chan struct{}), targets: system_targets, paste: paste}
+ go w.run()
+ return w
+}
+func (w *Worker) Results() <-chan Result { return w.results }
+func (w *Worker) Submit(request Request) bool {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ select {
+ case w.jobs <- queuedRequest{request, w.epoch}:
+ return true
+ default:
+ return false
+ }
+}
+func (w *Worker) Cancel() {
+ w.mu.Lock()
+ w.epoch++
+ // Revisions reject stale X reads/writes without killing a selection owner.
+ // Only a console paste must stop immediately when its target changes.
+ if w.jobCancel != nil && w.jobCommand == command.PasteClipboard {
+ w.jobCancel()
+ }
+ w.mu.Unlock()
+}
+func (w *Worker) Close() { w.cancel(); <-w.done }
+
+func (w *Worker) run() {
+ defer close(w.done)
+ var xTransport *xClipboard
+ selectedDisplay := ""
+ defer func() {
+ if xTransport != nil {
+ xTransport.close()
+ }
+ }()
+ for {
+ select {
+ case <-w.ctx.Done():
+ return
+ case job := <-w.jobs:
+ request := job.request
+ ctx, cancel := context.WithTimeout(w.ctx, 2*time.Second)
+ w.mu.Lock()
+ w.jobCancel = cancel
+ w.jobCommand = request.Command
+ if request.Command == command.PasteClipboard && job.epoch != w.epoch {
+ cancel()
+ }
+ w.mu.Unlock()
+ result := Result{Request: request}
+ switch request.Command {
+ case command.ImportClipboard:
+ result.Text, result.Err = read_file(request.Path)
+ case command.ExportClipboard:
+ result.Err = write_file(request.Path, request.Text)
+ case command.PasteClipboard:
+ if ctx.Err() != nil {
+ result.Err = ctx.Err()
+ } else if w.paste == nil {
+ result.Err = fmt.Errorf("clipboard paste unavailable")
+ } else {
+ result.Err = w.paste(ctx, request.Text, request.Terminal)
+ }
+ default:
+ if xTransport != nil && selectedDisplay != request.Display {
+ xTransport.close()
+ xTransport = nil
+ }
+ if xTransport == nil {
+ targets, err := w.targets(request.Display)
+ if err != nil {
+ result.Err = err
+ break
+ }
+ xTransport = &xClipboard{target: targets[0]}
+ selectedDisplay = request.Display
+ }
+ if request.Command == command.ExportXClipboard {
+ result.Err = xTransport.write(ctx, request.Text)
+ } else {
+ result.Text, result.Err = xTransport.read(ctx)
+ }
+ if result.Err != nil && xTransport != nil {
+ xTransport.close()
+ xTransport = nil
+ }
+ }
+ w.mu.Lock()
+ w.jobCancel = nil
+ w.mu.Unlock()
+ cancel()
+ select {
+ case w.results <- result:
+ case <-w.ctx.Done():
+ return
+ }
+ }
+ }
+}
+
+func read_file(path string) (string, error) {
+ file, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0)
+ if err != nil {
+ return "", err
+ }
+ defer file.Close()
+ info, err := file.Stat()
+ if err != nil {
+ return "", err
+ }
+ if !info.Mode().IsRegular() {
+ return "", fmt.Errorf("clipboard file must be regular")
+ }
+ data, err := io.ReadAll(io.LimitReader(file, MaxBytes+1))
+ if err != nil {
+ return "", err
+ }
+ return string(data), Validate_text(string(data))
+}
+
+func write_file(path, text string) error {
+ if err := Validate_text(text); err != nil {
+ return err
+ }
+ file, err := os.CreateTemp(filepath.Dir(path), ".fenrir-clipboard-*")
+ if err != nil {
+ return err
+ }
+ defer os.Remove(file.Name())
+ _, writeErr := file.WriteString(text)
+ err = errors.Join(writeErr, file.Sync(), file.Close())
+ if err != nil {
+ return err
+ }
+ return os.Rename(file.Name(), path)
+}
diff --git a/internal/clipboard/worker_test.go b/internal/clipboard/worker_test.go
new file mode 100644
index 00000000..33e8b4ff
--- /dev/null
+++ b/internal/clipboard/worker_test.go
@@ -0,0 +1,70 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package clipboard
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+)
+
+func TestQueuedPasteCannotStartAfterTargetRetirement(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ called := false
+ worker := &Worker{jobs: make(chan queuedRequest, 1), results: make(chan Result, 1), ctx: ctx, cancel: cancel, done: make(chan struct{}), paste: func(context.Context, string, int) error { called = true; return nil }}
+ worker.Submit(Request{Command: command.PasteClipboard, Text: "old target", Terminal: 2})
+ worker.Cancel()
+ go worker.run()
+ defer worker.Close()
+ select {
+ case result := <-worker.Results():
+ if called || !errors.Is(result.Err, context.Canceled) {
+ t.Fatal(called, result.Err)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("canceled paste did not finish")
+ }
+}
+
+func TestActivePasteCancellationDoesNotBlockShutdown(t *testing.T) {
+ started := make(chan struct{})
+ worker := New_worker(context.Background(), func(ctx context.Context, text string, terminal int) error {
+ close(started)
+ <-ctx.Done()
+ return ctx.Err()
+ })
+ worker.Submit(Request{Command: command.PasteClipboard, Text: "target", Terminal: 2})
+ select {
+ case <-started:
+ case <-time.After(time.Second):
+ t.Fatal("paste did not start")
+ }
+ worker.Cancel()
+ select {
+ case result := <-worker.Results():
+ if !errors.Is(result.Err, context.Canceled) {
+ t.Fatal(result.Err)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("paste did not cancel")
+ }
+ worker.Close()
+}
diff --git a/internal/clipboard/xclip.go b/internal/clipboard/xclip.go
new file mode 100644
index 00000000..ac2bb547
--- /dev/null
+++ b/internal/clipboard/xclip.go
@@ -0,0 +1,167 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package clipboard
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "strings"
+ "syscall"
+ "time"
+)
+
+// xClipboard delegates X11 ownership, TARGETS and incremental transfers to
+// xclip. Only the transfer worker touches it. There is no global initialization
+// or environment mutation, and no shell interprets clipboard contents.
+type xClipboard struct {
+ target Target
+ owner *exec.Cmd
+ ownerDone chan error
+}
+
+type boundedOutput struct {
+ bytes.Buffer
+ limit int
+}
+
+func (b *boundedOutput) Write(data []byte) (int, error) {
+ if len(data) > b.limit-b.Len() {
+ return 0, fmt.Errorf("clipboard output exceeds size limit")
+ }
+ return b.Buffer.Write(data)
+}
+
+func (x *xClipboard) command(ctx context.Context, args ...string) *exec.Cmd {
+ cmd := exec.CommandContext(ctx, "xclip", args...)
+ cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGKILL}
+ for _, variable := range os.Environ() {
+ name := strings.SplitN(variable, "=", 2)[0]
+ if name != "DISPLAY" && name != "XAUTHORITY" {
+ cmd.Env = append(cmd.Env, variable)
+ }
+ }
+ cmd.Env = append(cmd.Env, "DISPLAY="+x.target.Display, "XAUTHORITY="+x.target.Authority)
+ return cmd
+}
+
+func (x *xClipboard) output(ctx context.Context, target string, limit int) ([]byte, error) {
+ cmd := x.command(ctx, "-selection", "clipboard", "-out", "-target", target)
+ output := &boundedOutput{limit: limit}
+ stderr := &boundedOutput{limit: 4096}
+ cmd.Stdout, cmd.Stderr = output, stderr
+ if err := cmd.Run(); err != nil {
+ // xclip uses these errors for an empty selection or unsupported format.
+ message := strings.TrimSpace(stderr.String())
+ if strings.HasPrefix(message, "Error: target ") && strings.HasSuffix(message, " not available") {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("X clipboard transfer failed: check xclip, display and X authorization")
+ }
+ return output.Bytes(), nil
+}
+
+func (x *xClipboard) read(ctx context.Context) (string, error) {
+ targets, err := x.output(ctx, "TARGETS", 64<<10)
+ if err != nil {
+ return "", err
+ }
+ available := make(map[string]bool)
+ for _, target := range strings.Fields(string(targets)) {
+ available[target] = true
+ }
+ for _, target := range []string{"UTF8_STRING", "text/plain;charset=utf-8", "text/plain", "STRING", "TEXT"} {
+ if !available[target] {
+ continue
+ }
+ data, err := x.output(ctx, target, MaxBytes)
+ if err != nil {
+ return "", err
+ }
+ text := string(data)
+ if target == "STRING" {
+ // ICCCM STRING is Latin-1, not UTF-8.
+ runes := make([]rune, len(data))
+ for i, value := range data {
+ runes[i] = rune(value)
+ }
+ text = string(runes)
+ }
+ return text, Validate_text(text)
+ }
+ return "", nil // Images, files and unowned selections are ordinary no-data.
+}
+
+func (x *xClipboard) write(ctx context.Context, text string) error {
+ if text == "" {
+ return fmt.Errorf("clipboard text is empty")
+ }
+ if err := Validate_text(text); err != nil {
+ return err
+ }
+ // The owner outlives this request. Its parent-death signal and Close own
+ // cleanup; normal xclip forking would leave an unmanaged process behind.
+ cmd := x.command(context.Background(), "-selection", "clipboard", "-in", "-quiet")
+ cmd.Stdin = strings.NewReader(text)
+ if err := cmd.Start(); err != nil {
+ return fmt.Errorf("X clipboard writer unavailable: %w", err)
+ }
+ done := make(chan error, 1)
+ go func() { done <- cmd.Wait(); close(done) }()
+ accepted := false
+ defer func() {
+ if !accepted {
+ _ = cmd.Process.Kill()
+ <-done
+ }
+ }()
+ for {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-done:
+ return fmt.Errorf("X clipboard writer exited before acknowledgement")
+ default:
+ }
+ observed, err := x.read(ctx)
+ if err != nil {
+ return err
+ }
+ if observed == text {
+ x.close()
+ x.owner, x.ownerDone, accepted = cmd, done, true
+ return nil
+ }
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-time.After(10 * time.Millisecond):
+ }
+ }
+}
+
+func (x *xClipboard) close() {
+ if x.owner != nil {
+ _ = x.owner.Process.Kill()
+ <-x.ownerDone
+ x.owner = nil
+ x.ownerDone = nil
+ }
+}
diff --git a/internal/command/command.go b/internal/command/command.go
new file mode 100644
index 00000000..cae46a13
--- /dev/null
+++ b/internal/command/command.go
@@ -0,0 +1,126 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+// Package command defines reader actions independently of their input bindings.
+package command
+
+// ID is a stable action name for input bindings and command dispatch.
+type ID string
+
+const (
+ Time ID = "time"
+ Date ID = "date"
+ PreviousLine ID = "previous_line"
+ CurrentLine ID = "current_line"
+ NextLine ID = "next_line"
+ PreviousWord ID = "previous_word"
+ CurrentWord ID = "current_word"
+ NextWord ID = "next_word"
+ PreviousChar ID = "previous_character"
+ CurrentChar ID = "current_character"
+ SpellWord ID = "spell_word"
+ PhoneticWord ID = "phonetic_word"
+ SpellLine ID = "spell_line"
+ PhoneticLine ID = "phonetic_line"
+ PhoneticChar ID = "phonetic_character"
+ NextChar ID = "next_character"
+ ReviewUp ID = "review_up"
+ ReviewDown ID = "review_down"
+ Top ID = "top"
+ Bottom ID = "bottom"
+ LineStart ID = "line_start"
+ LineEnd ID = "line_end"
+ FirstText ID = "first_text"
+ LastText ID = "last_text"
+ ReadScreen ID = "read_screen"
+ ScreenBeforeCursor ID = "curr_screen_before_cursor"
+ ScreenAfterCursor ID = "curr_screen_after_cursor"
+ ToggleAttributes ID = "toggle_has_attribute"
+ AttributeCursor ID = "attribute_cursor"
+ Position ID = "position"
+ CursorLine ID = "cursor_lineno"
+ CursorColumn ID = "cursor_column"
+ LineToCursor ID = "cursor_read_line_to_cursor"
+ LineFromCursor ID = "cursor_read_to_end_of_line"
+ ScreenFirstChar ID = "review_screen_first_char"
+ ScreenLastChar ID = "review_screen_last_char"
+ FollowCursor ID = "follow_cursor"
+ Help ID = "help"
+ Version ID = "announce_fenrir_version"
+ EmojiMenu ID = "emoji_menu"
+ SpellcheckMenu ID = "spellcheck_menu"
+ FenrirMenu ID = "fenrir_menu"
+ NextQuickCategory ID = "next_quick_menu_category"
+ PreviousQuickCategory ID = "prev_quick_menu_category"
+ CurrentQuickEntry ID = "current_quick_menu_entry"
+ CurrentQuickValue ID = "current_quick_menu_value"
+ NextQuickEntry ID = "next_quick_menu_entry"
+ PreviousQuickEntry ID = "prev_quick_menu_entry"
+ NextQuickValue ID = "next_quick_menu_value"
+ PreviousQuickValue ID = "prev_quick_menu_value"
+ SaveSettings ID = "save_settings"
+ Mute ID = "mute"
+ TempDisableSpeech ID = "temp_disable_speech"
+ ProgressMonitor ID = "progress_bar_monitor"
+ AutoRead ID = "auto_read"
+ ToggleTracking ID = "toggle_highlight_tracking"
+ PassNext ID = "pass_next"
+ Quit ID = "quit"
+ Unbound ID = "unbound"
+ SetWindow ID = "set_window_application"
+ ClearWindow ID = "clear_window_application"
+ SetLiveRegion ID = "set_live_region"
+ ListReadingRegions ID = "list_reading_regions"
+ ClearLiveRegions ID = "clear_live_regions"
+ ClearReadingRegions ID = "clear_reading_regions"
+ ReadLiveRegion1 ID = "read_live_region_1"
+ ClearLiveRegion1 ID = "clear_live_region_1"
+ ReadLiveRegion2 ID = "read_live_region_2"
+ ClearLiveRegion2 ID = "clear_live_region_2"
+ ReadLiveRegion3 ID = "read_live_region_3"
+ ClearLiveRegion3 ID = "clear_live_region_3"
+ ReadLiveRegion4 ID = "read_live_region_4"
+ ClearLiveRegion4 ID = "clear_live_region_4"
+ ReadLiveRegion5 ID = "read_live_region_5"
+ ClearLiveRegion5 ID = "clear_live_region_5"
+ ReadLiveRegion6 ID = "read_live_region_6"
+ ClearLiveRegion6 ID = "clear_live_region_6"
+ ReadLiveRegion7 ID = "read_live_region_7"
+ ClearLiveRegion7 ID = "clear_live_region_7"
+ ReadLiveRegion8 ID = "read_live_region_8"
+ ClearLiveRegion8 ID = "clear_live_region_8"
+ ReadLiveRegion9 ID = "read_live_region_9"
+ ClearLiveRegion9 ID = "clear_live_region_9"
+ ReadLiveRegion10 ID = "read_live_region_10"
+ ClearLiveRegion10 ID = "clear_live_region_10"
+ SetMark ID = "set_mark"
+ RemoveMarks ID = "remove_marks"
+ MarkedText ID = "marked_text"
+ CopyMarked ID = "copy_marked_to_clipboard"
+ CopyLastEcho ID = "copy_last_echo_to_clipboard"
+ CurrentClipboard ID = "curr_clipboard"
+ PreviousClipboard ID = "prev_clipboard"
+ NextClipboard ID = "next_clipboard"
+ FirstClipboard ID = "first_clipboard"
+ LastClipboard ID = "last_clipboard"
+ ClearClipboard ID = "clear_clipboard"
+ PasteClipboard ID = "paste_clipboard"
+ ImportClipboard ID = "import_clipboard_from_file"
+ ExportClipboard ID = "export_clipboard_to_file"
+ ImportXClipboard ID = "import_clipboard_from_x"
+ ExportXClipboard ID = "export_clipboard_to_x"
+)
diff --git a/internal/command/descriptions.go b/internal/command/descriptions.go
new file mode 100644
index 00000000..4e282a44
--- /dev/null
+++ b/internal/command/descriptions.go
@@ -0,0 +1,135 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package command
+
+import (
+ "git.stormux.org/storm/fenrir/internal/i18n"
+ "slices"
+)
+
+// descriptions uses Python Fenrir command get_description message IDs.
+// Go-only menu actions reuse their existing localized UI names.
+var descriptions = map[ID]string{
+ Time: "presents the time",
+ Date: "presents the date",
+ PreviousLine: "moves review to the previous line ",
+ CurrentLine: "current line",
+ NextLine: "moves review to the next line ",
+ PreviousWord: "moves review focus to the previous word ",
+ CurrentWord: "current word.",
+ NextWord: "moves review to the next word ",
+ PreviousChar: "moves review to the previous character",
+ CurrentChar: "presents the current character.",
+ SpellWord: "Spells the current word",
+ PhoneticWord: "Phonetically spells the current word",
+ SpellLine: "no description available",
+ PhoneticLine: "no description available",
+ PhoneticChar: "set review and phonetically presents the current character",
+ NextChar: "Moves review to the next character",
+ ReviewUp: "Move review to the character in the line above the current position",
+ ReviewDown: "Move review to the character below the current position",
+ Top: "move review to top of screen",
+ Bottom: "Move review to the bottom of the screen",
+ LineStart: "set review cursor to begin of current line and display the content",
+ LineEnd: "Move Review to the end of current line and display the content",
+ FirstText: "Move Review to the first character on the line",
+ LastText: "Move Review to the last character on the line",
+ ReadScreen: "reads the contents of the current screen",
+ ScreenBeforeCursor: "Reads from the top of the screen to the cursor position",
+ ScreenAfterCursor: "reads from the cursor to the bottom of the screen",
+ ToggleAttributes: "enables or disables the announcement of attributes",
+ AttributeCursor: "Reads attributes of current cursor position",
+ Position: "displays the position of the review cursor",
+ CursorLine: "Line number for cursor",
+ CursorColumn: "Column number for cursor",
+ LineToCursor: "read line to cursor pos, use review cursor if you are in review mode, otherwhise use text cursor",
+ LineFromCursor: "read to end of line, use review cursor if you are in review mode, otherwhise use text cursor",
+ ScreenFirstChar: "Move Review to the first character on the screen (left top)",
+ ScreenLastChar: "Move Review to the last character on the screen (right bottom)",
+ FollowCursor: "exits review mode",
+ Help: "toggles the tutorial mode",
+ Version: "Present the version of Fenrir currently in use.",
+ EmojiMenu: "Emoji Menu",
+ SpellcheckMenu: "Spellcheck Menu",
+ FenrirMenu: "Fenrir Menu",
+ NextQuickCategory: "get next quick menu category",
+ PreviousQuickCategory: "get previous quick menu category",
+ CurrentQuickEntry: "get current quick menu entry",
+ CurrentQuickValue: "get current quick menu value",
+ NextQuickEntry: "get next quick menu entry",
+ PreviousQuickEntry: "get previous quick menu entry",
+ NextQuickValue: "get next quick menu value",
+ PreviousQuickValue: "get previous quick menu value",
+ SaveSettings: "Saves your current Fenrir settings so they are the default.",
+ Mute: "enables or disables speech",
+ TempDisableSpeech: "disables speech until next keypress",
+ ProgressMonitor: "Toggle progress bar monitoring with ascending tones",
+ AutoRead: "enables or disables automatic reading of new text as it appears",
+ ToggleTracking: "enables or disables tracking of highlighted text",
+ PassNext: "sends the following keypress to the terminal or application",
+ Quit: "exits Fenrir",
+ SetWindow: "set Window Mode, needs 2 marks ",
+ ClearWindow: "Turn off window mode",
+ SetLiveRegion: "Set a live region from one or two marks",
+ ListReadingRegions: "List reading regions",
+ ClearLiveRegions: "Clear all live regions",
+ ClearReadingRegions: "Clear all reading regions",
+ ReadLiveRegion1: "Read live region 1",
+ ClearLiveRegion1: "Clear live region 1",
+ ReadLiveRegion2: "Read live region 2",
+ ClearLiveRegion2: "Clear live region 2",
+ ReadLiveRegion3: "Read live region 3",
+ ClearLiveRegion3: "Clear live region 3",
+ ReadLiveRegion4: "Read live region 4",
+ ClearLiveRegion4: "Clear live region 4",
+ ReadLiveRegion5: "Read live region 5",
+ ClearLiveRegion5: "Clear live region 5",
+ ReadLiveRegion6: "Read live region 6",
+ ClearLiveRegion6: "Clear live region 6",
+ ReadLiveRegion7: "Read live region 7",
+ ClearLiveRegion7: "Clear live region 7",
+ ReadLiveRegion8: "Read live region 8",
+ ClearLiveRegion8: "Clear live region 8",
+ ReadLiveRegion9: "Read live region 9",
+ ClearLiveRegion9: "Clear live region 9",
+ ReadLiveRegion10: "Read live region 10",
+ ClearLiveRegion10: "Clear live region 10",
+ SetMark: "places marks to select text to copy to the clipboard",
+ RemoveMarks: "Removes marks from selected text",
+ MarkedText: "Presents the currently selected text that will be copied to the clipboard",
+ CopyMarked: "copies marked text to the currently selected clipboard",
+ CopyLastEcho: "copies last presented text to the clipboard",
+ CurrentClipboard: "speaks the contents of the currently selected clipboard",
+ PreviousClipboard: "selects the previous clipboard",
+ NextClipboard: "selects the next clipboard",
+ FirstClipboard: "selects the first clipboard",
+ LastClipboard: "selects the last clipboard",
+ ClearClipboard: "clears the currently selected clipboard",
+ PasteClipboard: "pastes the text from the currently selected clipboard",
+ ImportClipboard: "imports text from clipboard file to the clipboard",
+ ExportClipboard: "export the current fenrir clipboard to a file",
+ ImportXClipboard: "imports the graphical clipboard to Fenrir's clipboard",
+ ExportXClipboard: "Export current fenrir clipboard to X or GUI clipboard",
+}
+
+// Description translates at presentation time, like Python command help.
+func Description(id ID) string {
+ message := descriptions[id]
+ if message == "" {
+ message = "no description available"
+ }
+ return i18n.T(message)
+}
+
+func Known(id ID) bool { _, ok := descriptions[id]; return ok }
+
+// IDs lists all available actions, including those absent from a custom layout.
+func IDs() []ID {
+ ids := make([]ID, 0, len(descriptions))
+ for id := range descriptions {
+ ids = append(ids, id)
+ }
+ slices.Sort(ids)
+ return ids
+}
diff --git a/internal/config/attributes_test.go b/internal/config/attributes_test.go
new file mode 100644
index 00000000..74347804
--- /dev/null
+++ b/internal/config/attributes_test.go
@@ -0,0 +1,37 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package config
+
+import (
+ "fmt"
+ "path/filepath"
+ "testing"
+)
+
+func TestAttributesDefaultsValidationAndPersistence(t *testing.T) {
+ for _, version := range []int{1, 2, 3} {
+ settings, err := Decode([]byte(fmt.Sprintf("version=%d\n", version)))
+ if err != nil || settings.Reading.Attributes != "none" {
+ t.Fatal(version, settings.Reading, err)
+ }
+ }
+ for _, mode := range []string{"none", "speech", "beeps", "both"} {
+ settings, err := Decode([]byte("version=3\n[reading]\nattributes='" + mode + "'\n"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ settings.Reading.AttributeFormat = "fenrirBold|fenrirUnderline|fenrirFGColor"
+ path := filepath.Join(t.TempDir(), "settings.toml")
+ if err := Save(path, settings); err != nil {
+ t.Fatal(err)
+ }
+ saved, _, err := Load(path, nil)
+ if err != nil || saved.Reading != settings.Reading {
+ t.Fatal(saved.Reading, err)
+ }
+ }
+ if _, err := Decode([]byte("version=3\n[reading]\nattributes='invalid'\n")); err == nil {
+ t.Fatal("invalid attributes mode accepted")
+ }
+}
diff --git a/internal/config/capitalization_test.go b/internal/config/capitalization_test.go
new file mode 100644
index 00000000..f53d6e49
--- /dev/null
+++ b/internal/config/capitalization_test.go
@@ -0,0 +1,34 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package config
+
+import (
+ "fmt"
+ "path/filepath"
+ "testing"
+)
+
+func TestCapitalDefaultsAndExplicitChoicesSurviveSave(t *testing.T) {
+ for _, version := range []int{1, 2, 3} {
+ settings, err := Decode([]byte(fmt.Sprintf("version=%d\n", version)))
+ if err != nil || !settings.Reading.RaisePitchForCapitals || settings.Reading.BeepForCapitals || settings.Reading.SayCapForCapitals {
+ t.Fatal(version, settings.Reading, err)
+ }
+ }
+ for choices := range 8 {
+ input := fmt.Sprintf("version=3\n[reading]\nraise_pitch_for_capitals=%t\nbeep_for_capitals=%t\nsay_cap_for_capitals=%t\n", choices&1 != 0, choices&2 != 0, choices&4 != 0)
+ settings, err := Decode([]byte(input))
+ if err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(t.TempDir(), "settings.toml")
+ if err := Save(path, settings); err != nil {
+ t.Fatal(err)
+ }
+ saved, _, err := Load(path, nil)
+ if err != nil || saved.Reading != settings.Reading {
+ t.Fatal(saved.Reading, settings.Reading, err)
+ }
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 00000000..448c1062
--- /dev/null
+++ b/internal/config/config.go
@@ -0,0 +1,331 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+// Package config loads Fenrir's typed TOML settings without reading Python INI files.
+package config
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "git.stormux.org/storm/fenrir/internal/input"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "git.stormux.org/storm/fenrir/internal/prompt"
+ "git.stormux.org/storm/fenrir/internal/punctuation"
+ "git.stormux.org/storm/fenrir/internal/sound"
+ "git.stormux.org/storm/fenrir/internal/speech"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/pelletier/go-toml/v2"
+)
+
+const SystemPath = "/etc/fenrirscreenreader/settings/settings.toml"
+
+// Review options are file-only and take effect at launch.
+type Review struct {
+ LineBreak bool `toml:"line_break"`
+ EndOfScreen bool `toml:"end_of_screen"`
+ LeaveReviewOnCursorChange bool `toml:"leave_review_on_cursor_change"`
+ LeaveReviewOnScreenChange bool `toml:"leave_review_on_screen_change"`
+}
+
+type Settings struct {
+ Version int `toml:"version"`
+ Presentation Presentation `toml:"presentation" label:"Presentation"`
+ Keyboard Keyboard `toml:"keyboard" label:"Keyboard"`
+ Reading Reading `toml:"reading" label:"Reading"`
+ Review Review `toml:"review"`
+ Screen Screen `toml:"screen"`
+ Clipboard Clipboard `toml:"clipboard" label:"Clipboard"`
+ General General `toml:"general"`
+ Scripts Scripts `toml:"scripts"`
+ Remote Remote `toml:"remote"`
+ Prompt Prompt `toml:"prompt"`
+}
+
+// Screen policy is file-only; VT numbers apply only to the console backend.
+type Screen struct {
+ IgnoredVTs []int `toml:"ignored_vts"`
+}
+
+// Prompt customization is file-only, using Go regular expressions.
+type Prompt struct {
+ ExactMatches []string `toml:"exact_matches"`
+ CustomPatterns []string `toml:"custom_patterns"`
+}
+
+// These sections are file-only until a dedicated scripts UI is introduced.
+type General struct {
+ ScriptPath string `toml:"script_path"`
+ ScriptKeys []string `toml:"script_keys"`
+}
+type Scripts struct {
+ Workers int `toml:"workers"`
+ QueueLimit int `toml:"queue_limit"`
+ OutputBytes int `toml:"output_bytes"`
+ TimeoutSeconds int `toml:"timeout_seconds"`
+}
+type Remote struct {
+ Enabled bool `toml:"enabled"`
+ SocketFile string `toml:"socket_file"`
+ EnableCommandRemote bool `toml:"enable_command_remote"`
+ EnableSettingsRemote bool `toml:"enable_settings_remote"`
+}
+
+type Presentation struct {
+ Speech Speech `toml:"speech" label:"Speech"`
+ Sound Sound `toml:"sound" label:"Sound"`
+ Progress Progress `toml:"progress"`
+ DateTime DateTime `toml:"time_and_date" label:"Time and date"`
+}
+
+type DateTime struct {
+ TimeFormat string `toml:"time_format" label:"Time format" choices:"time-format"`
+ DateFormat string `toml:"date_format" label:"Date format" choices:"date-format"`
+}
+
+type Clipboard struct {
+ Sync bool `toml:"sync" label:"Clipboard sync"`
+ Persistent bool `toml:"persistent" label:"Enable Persistent Clipboard"`
+ SyncDisplay string `toml:"sync_display"`
+ NumberOfClipboards int `toml:"number_of_clipboards" label:"History items" min:"1" max:"10000" step:"1"`
+ ExportPath string `toml:"export_path"`
+}
+
+type Progress struct {
+ Enabled bool `toml:"enabled"`
+}
+
+type Sound struct {
+ Enabled bool `toml:"enabled" label:"Sound enabled"`
+ Volume float64 `toml:"volume" label:"Volume" min:"0" max:"1" step:"0.1"`
+ Driver string `toml:"driver" label:"Driver" choices:"sound-driver"`
+ Theme string `toml:"theme" label:"Select sound theme" choices:"sound-theme"`
+ Device string `toml:"device"`
+}
+type Speech struct {
+ Enabled bool `toml:"enabled" label:"Speech enabled"`
+ Rate int `toml:"rate" label:"Rate" min:"-100" max:"100" step:"1" quick:"speech rate"`
+ Driver string `toml:"driver" label:"Driver" choices:"speech-driver"`
+ HardwareDevice string `toml:"hardware_device" label:"Serial port" speech:"serial"`
+ HardwareBaudRate int `toml:"hardware_baud_rate" label:"Baud rate" choices:"speech-baud" speech:"serial"`
+ Module string `toml:"module" label:"Speech Dispatcher module" choices:"speech-module" quick:"Speech Dispatcher module" speech:"modules"`
+ Language string `toml:"language" label:"Speech language" choices:"speech-language" quick:"speech language" speech:"voices"`
+ Voice string `toml:"voice" label:"Speech voice" choices:"speech-voice" quick:"speech voice" speech:"voices"`
+ Pitch int `toml:"pitch" label:"Pitch" min:"-100" max:"100" step:"1" quick:"speech pitch"`
+ Volume int `toml:"volume" label:"Volume" min:"-100" max:"100" step:"1" quick:"speech volume"`
+}
+type Keyboard struct {
+ MultiPressTimeoutMS int `toml:"multi_press_timeout_ms"`
+ Devices []string `toml:"devices" label:"Devices"`
+ Layout string `toml:"layout" label:"Layout"`
+ CharacterEcho bool `toml:"character_echo" label:"Character echo" quick:"Character echo"`
+ WordEcho bool `toml:"word_echo" label:"Word echo" quick:"Word echo"`
+ EchoOnCapsLock bool `toml:"echo_on_capslock" label:"Echo on CapsLock" quick:"Echo on CapsLock"`
+}
+type Reading struct {
+ Automatic bool `toml:"automatic" label:"Automatic reading"`
+ CursorEcho bool `toml:"cursor_echo" label:"Cursor echo"`
+ Mode string `toml:"mode" label:"Tracking mode" choices:"tracking-mode"`
+ PunctuationProfile string `toml:"punctuation_profile" label:"Punctuation profile"`
+ PunctuationLevel string `toml:"punctuation_level" label:"Punctuation level" choices:"punctuation-level" quick:"Punctuation level"`
+ RaisePitchForCapitals bool `toml:"raise_pitch_for_capitals" label:"Raise pitch for capital letters"`
+ BeepForCapitals bool `toml:"beep_for_capitals" label:"Beep for capital letters"`
+ SayCapForCapitals bool `toml:"say_cap_for_capitals" label:"Say ācapā before capital letters"`
+ Indentation string `toml:"indentation" label:"Indentation" choices:"indentation" quick:"Indentation"`
+ Attributes string `toml:"attributes" label:"Attributes" choices:"attributes" quick:"Attributes"`
+ AttributeFormat string `toml:"attribute_format" label:"Attribute format"`
+ Spellcheck bool `toml:"spellcheck" label:"Automatic spell checking"`
+ SpellcheckReadWord bool `toml:"spellcheck_read_word" label:"Read misspelled word"`
+ SpellcheckSayMisspelled bool `toml:"spellcheck_say_misspelled" label:"Say āmisspelledā"`
+ SpellcheckSound bool `toml:"spellcheck_sound" label:"Misspelling sound"`
+ Emoticons bool `toml:"emoticons" label:"Emoticons"`
+ RepeatedSymbols bool `toml:"repeated_symbols" label:"Repeated symbols"`
+ RepeatedSymbolsMin int `toml:"repeated_symbols_min" label:"Repeated symbols minimum length" min:"2" max:"10000" step:"1"`
+}
+
+func Defaults() Settings {
+ return Settings{Screen: Screen{IgnoredVTs: []int{}}, Review: Review{LineBreak: true, EndOfScreen: true, LeaveReviewOnCursorChange: true, LeaveReviewOnScreenChange: true}, Prompt: Prompt{ExactMatches: []string{}, CustomPatterns: []string{}}, Version: 3, Presentation: Presentation{DateTime: DateTime{TimeFormat: "locale", DateFormat: "locale"}, Progress: Progress{Enabled: true}, Sound: Sound{Enabled: true, Volume: 0.7, Driver: "pulse", Theme: "default", Device: "default"}, Speech: Speech{Enabled: true, Rate: 80, Volume: 100, Driver: "speech-dispatcher"}}, Keyboard: Keyboard{MultiPressTimeoutMS: int(input.DefaultMultiPressTimeout / time.Millisecond), Layout: "desktop", CharacterEcho: true}, Reading: Reading{Emoticons: true, RepeatedSymbolsMin: 4, Spellcheck: true, SpellcheckSound: true, Attributes: "none", AttributeFormat: presentation.Default_attribute_format, Indentation: "none", RaisePitchForCapitals: true, Automatic: true, CursorEcho: true, Mode: "cursor", PunctuationProfile: "default", PunctuationLevel: "some"}, Clipboard: Clipboard{NumberOfClipboards: 200, ExportPath: "/tmp/fenrirClipboard"}, General: General{ScriptPath: "/usr/share/fenrirscreenreader/scripts", ScriptKeys: []string{"KEY_COMPOSE"}}, Scripts: Scripts{Workers: 2, QueueLimit: 16, OutputBytes: 1 << 20}, Remote: Remote{Enabled: true, EnableCommandRemote: true, EnableSettingsRemote: true}}
+}
+
+func (s Settings) Validate() error {
+ if err := sound.Validate_selection(s.Presentation.Sound.Driver, s.Presentation.Sound.Device); err != nil {
+ return err
+ }
+ if err := sound.Validate_theme(s.Presentation.Sound.Theme); err != nil {
+ return err
+ }
+ seenVTs := make(map[int]bool)
+ for _, number := range s.Screen.IgnoredVTs {
+ if number < 1 || number > 63 || seenVTs[number] {
+ return fmt.Errorf("screen.ignored_vts must contain distinct VT numbers between 1 and 63")
+ }
+ seenVTs[number] = true
+ }
+ if s.Keyboard.MultiPressTimeoutMS < 1 || s.Keyboard.MultiPressTimeoutMS > 5000 {
+ return fmt.Errorf("keyboard.multi_press_timeout_ms must be between 1 and 5000")
+ }
+ if _, err := prompt.New(s.Prompt.ExactMatches, s.Prompt.CustomPatterns); err != nil {
+ return err
+ }
+ if err := punctuation.Validate_name(s.Reading.PunctuationProfile); err != nil {
+ return err
+ }
+ if s.Scripts.Workers < 1 || s.Scripts.Workers > 32 || s.Scripts.QueueLimit < 1 || s.Scripts.QueueLimit > 1024 || s.Scripts.OutputBytes < 1 || s.Scripts.OutputBytes > 4<<20 || s.Scripts.TimeoutSeconds < 0 || s.Scripts.TimeoutSeconds > 86400 {
+ return fmt.Errorf("invalid script execution limits")
+ }
+ for _, key := range s.General.ScriptKeys {
+ if _, ok := input.Key_code(key); !ok {
+ return fmt.Errorf("invalid script key %q", key)
+ }
+ }
+ if strings.ContainsAny(s.General.ScriptPath, "\x00\r\n") || strings.ContainsAny(s.Remote.SocketFile, "\x00\r\n") {
+ return fmt.Errorf("invalid script or remote path")
+ }
+ if err := validate_definitions(s); err != nil {
+ return err
+ }
+ if s.Clipboard.NumberOfClipboards < 1 || s.Clipboard.NumberOfClipboards > 10000 {
+ return fmt.Errorf("number_of_clipboards must be between 1 and 10000")
+ }
+ if strings.ContainsAny(s.Clipboard.SyncDisplay, "\x00\r\n") || strings.TrimSpace(s.Clipboard.ExportPath) == "" || strings.ContainsAny(s.Clipboard.ExportPath, "\x00\r\n") {
+ return fmt.Errorf("invalid clipboard display or export path")
+ }
+ if s.Version != 3 {
+ return fmt.Errorf("unsupported settings version %d (expected 3)", s.Version)
+ }
+ selected := s.Presentation.Speech
+ if err := speech.Validate_driver_options(selected.Driver, speech.Options{Module: selected.Module, Language: selected.Language, Voice: selected.Voice, Hardware: speech.HardwareOptions{Device: selected.HardwareDevice, BaudRate: selected.HardwareBaudRate}}); err != nil {
+ return err
+ }
+ for _, path := range s.Keyboard.Devices {
+ if strings.TrimSpace(path) == "" || strings.ContainsRune(path, 0) {
+ return fmt.Errorf("keyboard device path cannot be empty or contain NUL")
+ }
+ }
+ if strings.TrimSpace(s.Keyboard.Layout) == "" || strings.ContainsAny(s.Keyboard.Layout, "\x00\r\n") {
+ return fmt.Errorf("keyboard layout cannot be empty or contain control characters")
+ }
+ extension := filepath.Ext(s.Keyboard.Layout)
+ if extension != "" && extension != ".toml" || strings.ContainsRune(s.Keyboard.Layout, filepath.Separator) && extension != ".toml" {
+ return fmt.Errorf("keyboard layout must be a name or a .toml path")
+ }
+ if strings.TrimSuffix(s.Keyboard.Layout, ".toml") == "" {
+ return fmt.Errorf("keyboard layout name cannot be empty")
+ }
+ return nil
+}
+
+// Paths follows Python Fenrir's selection: root uses the system file; a normal
+// user first checks their personal file, then the system file. Sudo also uses
+// the system settings, just as Python does.
+func Paths(home string, uid int) []string {
+ if uid == 0 {
+ return []string{SystemPath}
+ }
+ return []string{filepath.Join(home, ".local/share/stormux/fenrirscreenreader/settings/settings.toml"), SystemPath}
+}
+
+func Decode(data []byte) (Settings, error) {
+ // Version 1 is migrated in memory only. Disk is changed solely by Save.
+ var header struct {
+ Version *int `toml:"version"`
+ }
+ if err := toml.Unmarshal(data, &header); err != nil {
+ return Settings{}, fmt.Errorf("parse settings: %w", err)
+ }
+ if header.Version == nil || *header.Version == 1 {
+ return decode_v1(data)
+ }
+ if *header.Version == 2 {
+ return decode_v2(data)
+ }
+ settings := Defaults()
+ if err := toml.NewDecoder(bytes.NewReader(data)).DisallowUnknownFields().Decode(&settings); err != nil {
+ return Settings{}, fmt.Errorf("parse settings: %w", err)
+ }
+ return settings, settings.Validate()
+}
+
+// Load selects one file rather than merging files. An explicitly requested
+// file must exist; only absent default files select built-in defaults.
+func Load(requested string, paths []string) (Settings, string, error) {
+ if requested != "" {
+ paths = []string{requested}
+ }
+ for _, path := range paths {
+ file, err := os.Open(path)
+ if errors.Is(err, os.ErrNotExist) && requested == "" {
+ continue
+ }
+ if err != nil {
+ return Settings{}, path, err
+ }
+ data, readErr := io.ReadAll(io.LimitReader(file, (1<<20)+1))
+ closeErr := file.Close()
+ if err := errors.Join(readErr, closeErr); err != nil {
+ return Settings{}, path, err
+ }
+ if len(data) > 1<<20 {
+ return Settings{}, path, fmt.Errorf("settings file exceeds 1 MiB")
+ }
+ settings, err := Decode(data)
+ return settings, path, err
+ }
+ return Defaults(), "", nil
+}
+
+// Save validates before touching disk and replaces the file atomically. The
+// caller retains its last good in-memory settings on a load/save error.
+func Save(path string, settings Settings) error {
+ if err := settings.Validate(); err != nil {
+ return err
+ }
+ data, err := toml.Marshal(settings)
+ if err != nil {
+ return err
+ }
+ return save_data(path, data)
+}
+
+func save_data(path string, data []byte) error {
+ dir := filepath.Dir(path)
+ if err := os.MkdirAll(dir, 0700); err != nil {
+ return err
+ }
+ file, err := os.CreateTemp(dir, ".settings-*.toml")
+ if err != nil {
+ return err
+ }
+ defer os.Remove(file.Name())
+ if _, err := file.Write(data); err != nil {
+ file.Close()
+ return err
+ }
+ if err := file.Sync(); err != nil {
+ file.Close()
+ return err
+ }
+ if err := file.Close(); err != nil {
+ return err
+ }
+ return os.Rename(file.Name(), path)
+}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
new file mode 100644
index 00000000..c5c0565c
--- /dev/null
+++ b/internal/config/config_test.go
@@ -0,0 +1,192 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package config
+
+import (
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+)
+
+func TestDefaultsStrictValuesAndUnknownSettings(t *testing.T) {
+ settings, err := Decode([]byte("[sound]\nenabled = false\n[speech]\nrate = 55\n"))
+ if err != nil || settings.Presentation.Sound.Enabled || settings.Presentation.Speech.Rate != 55 || !settings.Reading.CursorEcho {
+ t.Fatalf("partial settings: %+v, %v", settings, err)
+ }
+ for _, data := range []string{"version = 4", "[sound]\ndriver = 'external'", "[sound]\nvolume = -0.1", "[sound]\nvolume = 1.1", "[sound]\nvolume = nan", "[sound]\nvolume = inf", "[speech]\nrate = 101", "[speech]\nmodule = 'bad\\nname'", "[keyboard]\ndevices = ['']", "[sound]\nenabled = 'false'", "[reading]\ncursorEcho = false", "[sound"} {
+ if _, err := Decode([]byte(data)); err == nil {
+ t.Errorf("accepted invalid settings: %q", data)
+ }
+ }
+}
+
+func TestTrackingModeDefaultsValidationAndSave(t *testing.T) {
+ for _, version := range []string{"1", "2", "3"} {
+ settings, err := Decode([]byte("version=" + version + "\n[reading]\ncursor_echo=false\n"))
+ if err != nil || settings.Reading.Mode != "cursor" || settings.Reading.CursorEcho {
+ t.Fatal("older settings lost cursor behavior", version, settings, err)
+ }
+ }
+ for _, mode := range []string{"cursor", "table", "highlight"} {
+ settings, err := Decode([]byte("version=3\n[reading]\nmode='" + mode + "'\n"))
+ if err != nil || settings.Reading.Mode != mode {
+ t.Fatal(settings, err)
+ }
+ path := filepath.Join(t.TempDir(), "settings.toml")
+ if err := Save(path, settings); err != nil {
+ t.Fatal(err)
+ }
+ loaded, _, err := Load(path, nil)
+ if err != nil || loaded.Reading.Mode != mode {
+ t.Fatal(loaded, err)
+ }
+ }
+ for _, mode := range []string{"", "invalid", "Cursor"} {
+ if _, err := Decode([]byte("version=3\n[reading]\nmode='" + mode + "'\n")); err == nil {
+ t.Fatal("unsupported tracking mode accepted", mode)
+ }
+ }
+}
+
+func TestSettingsSelectionDoesNotHideInvalidOrExplicitMissingFiles(t *testing.T) {
+ root := t.TempDir()
+ userPath, systemPath := filepath.Join(root, "user.toml"), filepath.Join(root, "system.toml")
+ if err := os.WriteFile(systemPath, []byte("[speech]\nrate = 30\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ settings, selected, err := Load("", []string{userPath, systemPath})
+ if err != nil || selected != systemPath || settings.Presentation.Speech.Rate != 30 {
+ t.Fatalf("system selection: %+v %s %v", settings, selected, err)
+ }
+ if err := os.WriteFile(userPath, []byte("[sound]\nenabled = false\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ settings, selected, err = Load("", []string{userPath, systemPath})
+ if err != nil || selected != userPath || settings.Presentation.Sound.Enabled || settings.Presentation.Speech.Rate != 80 {
+ t.Fatalf("user selection merged system values: %+v %s %v", settings, selected, err)
+ }
+ if err := os.WriteFile(userPath, []byte("invalid = true"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if _, selected, err := Load("", []string{userPath, systemPath}); err == nil || selected != userPath {
+ t.Fatal("hid invalid selected settings", selected, err)
+ }
+ if _, _, err := Load(filepath.Join(root, "missing.toml"), []string{systemPath}); err == nil {
+ t.Fatal("ignored explicit missing file")
+ }
+ if got := Paths(root, 0); !reflect.DeepEqual(got, []string{SystemPath}) {
+ t.Fatal(got)
+ }
+ if got := Paths(root, 1000); len(got) != 2 || !strings.HasPrefix(got[0], root) || got[1] != SystemPath {
+ t.Fatal(got)
+ }
+}
+
+func TestAtomicSaveRoundTripAndRejectedSavePreservesOriginal(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "settings", "settings.toml")
+ settings := Defaults()
+ settings.Presentation.Sound.Enabled, settings.Presentation.Speech.Enabled, settings.Reading.CursorEcho = false, false, false
+ settings.Presentation.Speech.Rate, settings.Presentation.Speech.Module = 45, "example"
+ settings.Presentation.Speech.Pitch, settings.Presentation.Speech.Volume = -30, 75
+ settings.Presentation.Sound.Volume = 0.25
+ settings.Keyboard.Devices = []string{"/dev/input/event1"}
+ settings.Keyboard.CharacterEcho, settings.Keyboard.WordEcho, settings.Keyboard.EchoOnCapsLock = false, true, true
+ if err := Save(path, settings); err != nil {
+ t.Fatal(err)
+ }
+ got, _, err := Load(path, nil)
+ if err != nil || !reflect.DeepEqual(got, settings) {
+ t.Fatalf("round trip: %+v, %v", got, err)
+ }
+ info, err := os.Stat(path)
+ if err != nil || info.Mode().Perm() != 0600 {
+ t.Fatal("settings are not private", info, err)
+ }
+ original, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ settings.Presentation.Speech.Rate = 101
+ if err := Save(path, settings); err == nil {
+ t.Fatal("saved invalid settings")
+ }
+ current, err := os.ReadFile(path)
+ if err != nil || string(current) != string(original) {
+ t.Fatal("rejected save changed original", err)
+ }
+ entries, err := os.ReadDir(filepath.Dir(path))
+ if err != nil || len(entries) != 1 {
+ t.Fatal("temporary save leaked", entries, err)
+ }
+}
+
+func TestShippedExampleMatchesDefaults(t *testing.T) {
+ settings, _, err := Load("../../config/settings/settings.toml", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ settings.Keyboard.Devices = nil // TOML's explicit empty array is equivalent.
+ if !reflect.DeepEqual(settings, Defaults()) {
+ t.Fatalf("example differs: %+v", settings)
+ }
+}
+
+func TestSpeechParameterDefaultsAndBounds(t *testing.T) {
+ for _, data := range []string{"version=1\n", "version=2\n", "version=3\n"} {
+ settings, err := Decode([]byte(data))
+ if err != nil || settings.Presentation.Speech.Pitch != 0 || settings.Presentation.Speech.Volume != 100 {
+ t.Fatal(settings, err)
+ }
+ }
+ for _, setting := range []string{"pitch", "volume"} {
+ for _, invalid := range []string{"-101", "101"} {
+ if _, err := Decode([]byte("version=3\n[presentation.speech]\n" + setting + "=" + invalid)); err == nil {
+ t.Fatal(setting, invalid)
+ }
+ }
+ }
+}
+
+func TestClipboardPersistenceDefaultsAndSavedHistoryLimit(t *testing.T) {
+ settings := Defaults()
+ if settings.Clipboard.Persistent || settings.Clipboard.NumberOfClipboards != 200 {
+ t.Fatal(settings.Clipboard)
+ }
+ for _, version := range []string{"1", "2", "3"} {
+ loaded, err := Decode([]byte("version=" + version + "\n[clipboard]\nnumber_of_clipboards=50\n"))
+ if err != nil || loaded.Clipboard.Persistent || loaded.Clipboard.NumberOfClipboards != 50 {
+ t.Fatal(loaded.Clipboard, err)
+ }
+ }
+ for _, limit := range []string{"0", "10001"} {
+ if _, err := Decode([]byte("version=3\n[clipboard]\nnumber_of_clipboards=" + limit)); err == nil {
+ t.Fatal("invalid history size accepted", limit)
+ }
+ }
+ settings.Clipboard.Persistent = true
+ path := filepath.Join(t.TempDir(), "settings.toml")
+ if err := Save(path, settings); err != nil {
+ t.Fatal(err)
+ }
+ loaded, _, err := Load(path, nil)
+ if err != nil || loaded.Clipboard != settings.Clipboard {
+ t.Fatal(loaded.Clipboard, err)
+ }
+}
diff --git a/internal/config/date_time_test.go b/internal/config/date_time_test.go
new file mode 100644
index 00000000..c00efcdc
--- /dev/null
+++ b/internal/config/date_time_test.go
@@ -0,0 +1,35 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package config
+
+import (
+ "path/filepath"
+ "testing"
+)
+
+func TestDateTimePresetsValidateAndRoundTrip(t *testing.T) {
+ settings := Defaults()
+ settings.Presentation.DateTime = DateTime{TimeFormat: "12-hour-seconds", DateFormat: "full-dmy"}
+ path := filepath.Join(t.TempDir(), "settings.toml")
+ if err := Save(path, settings); err != nil {
+ t.Fatal(err)
+ }
+ loaded, _, err := Load(path, nil)
+ if err != nil || loaded.Presentation.DateTime != settings.Presentation.DateTime {
+ t.Fatal(loaded, err)
+ }
+ for _, value := range []string{"", "unknown", "%H:%M"} {
+ for _, date := range []bool{false, true} {
+ invalid := Defaults()
+ if date {
+ invalid.Presentation.DateTime.DateFormat = value
+ } else {
+ invalid.Presentation.DateTime.TimeFormat = value
+ }
+ if invalid.Validate() == nil {
+ t.Fatal("invalid format accepted", date, value)
+ }
+ }
+ }
+}
diff --git a/internal/config/definitions.go b/internal/config/definitions.go
new file mode 100644
index 00000000..10c0740d
--- /dev/null
+++ b/internal/config/definitions.go
@@ -0,0 +1,302 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package config
+
+import (
+ "fmt"
+ "math"
+ "os"
+ "reflect"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/datetime"
+ "git.stormux.org/storm/fenrir/internal/punctuation"
+ "git.stormux.org/storm/fenrir/internal/sound"
+ "git.stormux.org/storm/fenrir/internal/speech"
+)
+
+// Section and Definition are derived from the typed TOML schema. A labelled
+// struct is a section/group; labelled leaves supply controls and validation.
+// Unlabelled leaves remain file-only settings and survive form submission.
+type Section struct {
+ ID, Label string
+ Sections []Section
+ Settings []Definition
+}
+
+type Definition struct {
+ Path, Label string
+ Kind reflect.Kind
+ Min, Max, Step float64
+ ChoiceSource string
+ QuickLabel string
+ SpeechCapability string
+}
+
+type Choice struct {
+ ID, Label string
+ Unavailable string
+}
+
+func Sections() []Section { return sections(reflect.TypeFor[Settings](), "") }
+
+func sections(structType reflect.Type, prefix string) []Section {
+ var result []Section
+ for index := range structType.NumField() {
+ field := structType.Field(index)
+ if field.Type.Kind() != reflect.Struct {
+ continue
+ }
+ path := prefix + field.Tag.Get("toml")
+ section := Section{ID: path, Label: field.Tag.Get("label")}
+ section.Sections = sections(field.Type, path+".")
+ for childIndex := range field.Type.NumField() {
+ child := field.Type.Field(childIndex)
+ if child.Type.Kind() == reflect.Struct || child.Tag.Get("label") == "" {
+ continue
+ }
+ definition := Definition{Path: path + "." + child.Tag.Get("toml"), Label: child.Tag.Get("label"), Kind: child.Type.Kind(), ChoiceSource: child.Tag.Get("choices"), QuickLabel: child.Tag.Get("quick"), SpeechCapability: child.Tag.Get("speech")}
+ if definition.ChoiceSource == "" && (definition.Kind == reflect.Int || definition.Kind == reflect.Float64) {
+ // Numeric bounds are mandatory metadata for UI-editable numbers.
+ for tag, target := range map[string]*float64{"min": &definition.Min, "max": &definition.Max, "step": &definition.Step} {
+ value, err := strconv.ParseFloat(child.Tag.Get(tag), 64)
+ if err != nil {
+ panic("invalid numeric setting definition: " + definition.Path + " " + tag)
+ }
+ *target = value
+ }
+ }
+ section.Settings = append(section.Settings, definition)
+ }
+ result = append(result, section)
+ }
+ return result
+}
+
+func Definitions() []Definition {
+ var result []Definition
+ var visit func([]Section)
+ visit = func(sections []Section) {
+ for _, section := range sections {
+ result = append(result, section.Settings...)
+ visit(section.Sections)
+ }
+ }
+ visit(Sections())
+ return result
+}
+
+func setting_value(settings reflect.Value, path string) reflect.Value {
+ for _, name := range strings.Split(path, ".") {
+ structType := settings.Type()
+ found := false
+ for index := range structType.NumField() {
+ if structType.Field(index).Tag.Get("toml") == name {
+ settings = settings.Field(index)
+ found = true
+ break
+ }
+ }
+ if !found {
+ panic("unknown setting definition: " + path)
+ }
+ }
+ return settings
+}
+
+func (d Definition) Value(settings Settings) any {
+ value := setting_value(reflect.ValueOf(settings), d.Path)
+ if value.Kind() == reflect.Slice {
+ return append([]string(nil), value.Interface().([]string)...)
+ }
+ return value.Interface()
+}
+
+// Set assigns a typed draft value. Validation still runs on the entire draft.
+func (d Definition) Set(settings *Settings, value any) error {
+ target := setting_value(reflect.ValueOf(settings).Elem(), d.Path)
+ source := reflect.ValueOf(value)
+ if !source.IsValid() || source.Type() != target.Type() {
+ return fmt.Errorf("invalid value type for %s", d.Path)
+ }
+ target.Set(source)
+ return nil
+}
+
+// Available controls visibility and validation using the selected driver's
+// implemented capabilities. Hidden form values can survive a driver round trip.
+func (d Definition) Available(settings Settings) bool {
+ if d.SpeechCapability == "" {
+ return true
+ }
+ driver, err := speech.Find_driver(settings.Presentation.Speech.Driver)
+ if err != nil {
+ return false
+ }
+ switch d.SpeechCapability {
+ case "serial":
+ return driver.Serial
+ case "modules":
+ return driver.Modules != nil
+ case "voices":
+ return driver.Voices != nil
+ }
+ return false
+}
+
+func (d Definition) Choices(settings Settings) []Choice {
+ switch d.ChoiceSource {
+ case "sound-driver":
+ var result []Choice
+ for _, driver := range sound.Drivers() {
+ result = append(result, Choice{ID: driver.ID, Label: driver.Label})
+ }
+ return result
+ case "sound-theme":
+ home, _ := os.UserHomeDir()
+ var result []Choice
+ found := false
+ for _, name := range sound.Theme_names(home) {
+ result = append(result, Choice{ID: name, Label: name})
+ found = found || name == settings.Presentation.Sound.Theme
+ }
+ if !found {
+ name := settings.Presentation.Sound.Theme
+ result = append(result, Choice{ID: name, Label: name})
+ }
+ return result
+ case "speech-baud":
+ var result []Choice
+ found := false
+ for _, baud := range speech.Baud_rates() {
+ id := strconv.Itoa(baud)
+ label := id
+ if baud == 0 {
+ label = "default"
+ }
+ result = append(result, Choice{ID: id, Label: label})
+ found = found || baud == settings.Presentation.Speech.HardwareBaudRate
+ }
+ // Preserve unused file values while another driver is selected. An
+ // active serial driver still validates them before save/startup.
+ if !found {
+ id := strconv.Itoa(settings.Presentation.Speech.HardwareBaudRate)
+ result = append(result, Choice{ID: id, Label: id})
+ }
+ return result
+ case "indentation", "attributes":
+ return []Choice{{ID: "none", Label: "none"}, {ID: "speech", Label: "speech"}, {ID: "beeps", Label: "beeps"}, {ID: "both", Label: "speech and beeps"}}
+ case "time-format", "date-format":
+ now := time.Now()
+ options := datetime.Time_options(now)
+ if d.ChoiceSource == "date-format" {
+ options = datetime.Date_options(now)
+ }
+ var result []Choice
+ for _, option := range options {
+ result = append(result, Choice{ID: option.Value, Label: option.Label})
+ }
+ return result
+ case "punctuation-level":
+ var result []Choice
+ for _, level := range punctuation.Levels() {
+ result = append(result, Choice{ID: level, Label: level})
+ }
+ return result
+ case "tracking-mode":
+ return []Choice{{ID: "cursor", Label: "cursor tracking"}, {ID: "table", Label: "table mode"}, {ID: "highlight", Label: "highlight tracking"}}
+ case "speech-driver":
+ var result []Choice
+ for _, driver := range speech.Drivers() {
+ choice := Choice{ID: driver.ID, Label: driver.Label}
+ if err := driver.Availability(); err != nil {
+ choice.Unavailable = err.Error()
+ }
+ result = append(result, choice)
+ }
+ return result
+ case "speech-module", "speech-language", "speech-voice":
+ result := []Choice{{ID: "", Label: "default"}}
+ if value := d.Value(settings).(string); value != "" {
+ result = append(result, Choice{ID: value, Label: value})
+ }
+ return result
+ }
+ return nil
+}
+
+func validate_definitions(settings Settings) error {
+ for _, definition := range Definitions() {
+ if !definition.Available(settings) {
+ continue
+ }
+ value := definition.Value(settings)
+ if definition.ChoiceSource == "" && (definition.Kind == reflect.Int || definition.Kind == reflect.Float64) {
+ var number float64
+ if definition.Kind == reflect.Int {
+ number = float64(value.(int))
+ } else {
+ number = value.(float64)
+ }
+ if math.IsNaN(number) || math.IsInf(number, 0) || number < definition.Min || number > definition.Max {
+ return fmt.Errorf("%s must be between %g and %g", definition.Path, definition.Min, definition.Max)
+ }
+ }
+ // Driver support is independent of transient local availability. Module
+ // choices are discovered by the selected backend, never a static allowlist.
+ if definition.ChoiceSource == "speech-driver" {
+ if _, err := speech.Find_driver(value.(string)); err != nil {
+ return err
+ }
+ }
+ if definition.ChoiceSource == "tracking-mode" || definition.ChoiceSource == "punctuation-level" || definition.ChoiceSource == "time-format" || definition.ChoiceSource == "date-format" || definition.ChoiceSource == "indentation" || definition.ChoiceSource == "attributes" {
+ valid := false
+ for _, choice := range definition.Choices(settings) {
+ valid = valid || choice.ID == value.(string)
+ }
+ if !valid {
+ return fmt.Errorf("unsupported %s %q", definition.Path, value)
+ }
+ }
+ }
+ return nil
+}
+
+// Ordered_sections preserves explicit sibling order and appends omitted sections
+// in schema order. Nested paths never create duplicate top-level tabs.
+func Ordered_sections(sections []Section, order []string) []Section {
+ result := append([]Section(nil), sections...)
+ ranks := make(map[string]int)
+ for index, id := range order {
+ if _, ok := ranks[id]; !ok {
+ ranks[id] = index
+ }
+ }
+ sort.SliceStable(result, func(left, right int) bool {
+ leftRank, leftOK := ranks[result[left].ID]
+ rightRank, rightOK := ranks[result[right].ID]
+ if leftOK != rightOK {
+ return leftOK
+ }
+ return leftOK && leftRank < rightRank
+ })
+ return result
+}
diff --git a/internal/config/document.go b/internal/config/document.go
new file mode 100644
index 00000000..2a014340
--- /dev/null
+++ b/internal/config/document.go
@@ -0,0 +1,190 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package config
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "reflect"
+ "strings"
+ "syscall"
+
+ "github.com/pelletier/go-toml/v2"
+ "github.com/pelletier/go-toml/v2/unstable"
+)
+
+// Document retains file identity and table order for an isolated edit. Version
+// is metadata, not a UI tab. Omitted tables follow in schema order.
+type Document struct {
+ Settings Settings
+ Order []string
+ path string
+ original []byte
+ existed bool
+}
+
+func read_document(path string) ([]byte, error) {
+ // Remote SAVEAS can name an arbitrary destination. A FIFO must not
+ // block opening before we can establish that it is a regular file.
+ file, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0)
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
+ info, err := file.Stat()
+ if err != nil {
+ return nil, err
+ }
+ if !info.Mode().IsRegular() {
+ return nil, fmt.Errorf("settings destination is not a regular file")
+ }
+ data, err := io.ReadAll(io.LimitReader(file, (1<<20)+1))
+ if err == nil && len(data) > 1<<20 {
+ err = fmt.Errorf("settings file exceeds 1 MiB")
+ }
+ return data, err
+}
+
+func Open_document(path string, fallback Settings) (*Document, error) {
+ doc := &Document{Settings: fallback, path: path}
+ data, err := read_document(path)
+ if !errors.Is(err, os.ErrNotExist) {
+ if err != nil {
+ return nil, err
+ }
+ doc.Settings, err = Decode(data)
+ if err != nil {
+ return nil, err
+ }
+ doc.original, doc.existed = data, true
+ var parser unstable.Parser
+ parser.Reset(data)
+ seen := make(map[string]bool)
+ sectionIDs := make(map[string]bool)
+ var collect func([]Section)
+ collect = func(sections []Section) {
+ for _, section := range sections {
+ sectionIDs[section.ID] = true
+ collect(section.Sections)
+ }
+ }
+ collect(Sections())
+ record := func(parts []string) {
+ // Map old table order to the migrated hierarchy, preserving sibling order.
+ if len(parts) > 0 && (parts[0] == "speech" || parts[0] == "sound") {
+ parts = append([]string{"presentation"}, parts...)
+ }
+ for count := 1; count <= len(parts); count++ {
+ name := strings.Join(parts[:count], ".")
+ if sectionIDs[name] && !seen[name] {
+ doc.Order = append(doc.Order, name)
+ seen[name] = true
+ }
+ }
+ }
+ var visit func(*unstable.Node, []string)
+ visit = func(node *unstable.Node, prefix []string) {
+ parts := append([]string(nil), prefix...)
+ keys := node.Key()
+ for keys.Next() {
+ parts = append(parts, string(keys.Node().Data))
+ }
+ record(parts)
+ if node.Kind == unstable.KeyValue && node.Value().Kind == unstable.InlineTable {
+ children := node.Value().Children()
+ for children.Next() {
+ visit(children.Node(), parts)
+ }
+ }
+ }
+ var tablePath []string
+ for parser.NextExpression() {
+ node := parser.Expression()
+ switch node.Kind {
+ case unstable.Table:
+ tablePath = nil
+ keys := node.Key()
+ for keys.Next() {
+ tablePath = append(tablePath, string(keys.Node().Data))
+ }
+ record(tablePath)
+ case unstable.KeyValue:
+ visit(node, tablePath)
+ }
+ }
+ if err := parser.Error(); err != nil {
+ return nil, err
+ }
+ }
+ return doc, nil
+}
+
+// Save checks for external edits made since the menu opened before writing.
+// Atomic replacement still uses the same private file permissions as Save.
+func (d *Document) Save(settings Settings) error {
+ data, err := read_document(d.path)
+ if d.existed {
+ if err != nil {
+ return err
+ }
+ if !bytes.Equal(data, d.original) {
+ return fmt.Errorf("settings changed since the menu opened")
+ }
+ } else if err == nil {
+ return fmt.Errorf("settings created since the menu opened")
+ } else if !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ if err := settings.Validate(); err != nil {
+ return err
+ }
+ // The same schema drives persistence and the form, including nested groups.
+ var output bytes.Buffer
+ fmt.Fprintf(&output, "version = %d\n", settings.Version)
+ var encode func([]Section) error
+ encode = func(sections []Section) error {
+ for _, section := range Ordered_sections(sections, d.Order) {
+ value := setting_value(reflect.ValueOf(settings), section.ID)
+ fields := make(map[string]any)
+ for index := range value.NumField() {
+ if value.Field(index).Kind() != reflect.Struct {
+ fields[value.Type().Field(index).Tag.Get("toml")] = value.Field(index).Interface()
+ }
+ }
+ if len(fields) > 0 {
+ encoded, err := toml.Marshal(fields)
+ if err != nil {
+ return err
+ }
+ fmt.Fprintf(&output, "\n[%s]\n", section.ID)
+ output.Write(encoded)
+ }
+ if err := encode(section.Sections); err != nil {
+ return err
+ }
+ }
+ return nil
+ }
+ if err := encode(Sections()); err != nil {
+ return err
+ }
+ return save_data(d.path, output.Bytes())
+}
diff --git a/internal/config/document_test.go b/internal/config/document_test.go
new file mode 100644
index 00000000..74eadd5b
--- /dev/null
+++ b/internal/config/document_test.go
@@ -0,0 +1,122 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package config
+
+import (
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+)
+
+func TestDocumentPreservesSectionOrderAndPrivateAtomicSave(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "settings.toml")
+ if err := os.WriteFile(path, []byte("version=1\n[ 'speech' ]\nrate=40\n[reading]\nautomatic=false\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ doc, err := Open_document(path, Defaults())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(doc.Order, []string{"presentation", "presentation.speech", "reading"}) {
+ t.Fatal(doc.Order)
+ }
+ settings := doc.Settings
+ settings.Presentation.Speech.Rate = 41
+ if err := doc.Save(settings); err != nil {
+ t.Fatal(err)
+ }
+ loaded, err := Open_document(path, Defaults())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if loaded.Settings.Presentation.Speech.Rate != 41 || !reflect.DeepEqual(loaded.Order, []string{"presentation", "presentation.speech", "presentation.sound", "presentation.progress", "presentation.time_and_date", "reading", "keyboard", "review", "screen", "clipboard", "general", "scripts", "remote", "prompt"}) {
+ t.Fatal(loaded)
+ }
+ info, err := os.Stat(path)
+ if err != nil || info.Mode().Perm() != 0600 {
+ t.Fatal(info, err)
+ }
+ files, err := filepath.Glob(filepath.Join(filepath.Dir(path), ".settings-*"))
+ if err != nil || len(files) != 0 {
+ t.Fatal(files, err)
+ }
+}
+
+func TestDocumentRejectsExternalEditsAndInvalidSettings(t *testing.T) {
+ for _, mode := range []string{"changed", "created", "invalid"} {
+ t.Run(mode, func(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "settings.toml")
+ if mode != "created" {
+ if err := Save(path, Defaults()); err != nil {
+ t.Fatal(err)
+ }
+ }
+ doc, err := Open_document(path, Defaults())
+ if err != nil {
+ t.Fatal(err)
+ }
+ draft := doc.Settings
+ if mode == "invalid" {
+ draft.Presentation.Sound.Volume = 2
+ } else {
+ other := Defaults()
+ other.Presentation.Speech.Rate = 3
+ if err := Save(path, other); err != nil {
+ t.Fatal(err)
+ }
+ }
+ before, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := doc.Save(draft); err == nil {
+ t.Fatal("unsafe save accepted")
+ }
+ after, err := os.ReadFile(path)
+ if err != nil || string(before) != string(after) {
+ t.Fatal("failed save changed file", err)
+ }
+ })
+ }
+}
+
+func TestDocumentDefaultsAndDottedTables(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "settings.toml")
+ doc, err := Open_document(path, Defaults())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := doc.Save(doc.Settings); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte("reading.automatic=false\nspeech = {rate=30}\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ doc, err = Open_document(path, Defaults())
+ if err != nil || !reflect.DeepEqual(doc.Order, []string{"reading", "presentation", "presentation.speech"}) {
+ t.Fatal(doc, err)
+ }
+ if err := os.WriteFile(path, []byte(strings.Repeat("#", (1<<20)+1)), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Open_document(path, Defaults()); err == nil {
+ t.Fatal("oversize document accepted")
+ }
+}
diff --git a/internal/config/hardware_test.go b/internal/config/hardware_test.go
new file mode 100644
index 00000000..6399539f
--- /dev/null
+++ b/internal/config/hardware_test.go
@@ -0,0 +1,30 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package config
+
+import (
+ "path/filepath"
+ "testing"
+)
+
+func TestHardwareSettingsRoundTripWithoutAccessingDevice(t *testing.T) {
+ for _, driver := range []string{"dectalk", "dectalk-usb", "litetalk", "doubletalk", "tripletalk"} {
+ settings := Defaults()
+ settings.Presentation.Speech.Driver = driver
+ settings.Presentation.Speech.HardwareDevice = "/dev/fixture-synth"
+ settings.Presentation.Speech.HardwareBaudRate = 19200
+ path := filepath.Join(t.TempDir(), "settings.toml")
+ if err := Save(path, settings); err != nil {
+ t.Fatal(driver, err)
+ }
+ loaded, _, err := Load(path, nil)
+ if err != nil || loaded.Presentation.Speech != settings.Presentation.Speech {
+ t.Fatal(driver, loaded, err)
+ }
+ loaded.Presentation.Speech.Voice = "unsupported"
+ if err := loaded.Validate(); err == nil {
+ t.Fatal("unsupported voice accepted", driver)
+ }
+ }
+}
diff --git a/internal/config/indentation_test.go b/internal/config/indentation_test.go
new file mode 100644
index 00000000..7a3682e0
--- /dev/null
+++ b/internal/config/indentation_test.go
@@ -0,0 +1,36 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package config
+
+import (
+ "fmt"
+ "path/filepath"
+ "testing"
+)
+
+func TestIndentationDefaultsValidationAndPersistence(t *testing.T) {
+ for _, version := range []int{1, 2, 3} {
+ settings, err := Decode([]byte(fmt.Sprintf("version=%d\n", version)))
+ if err != nil || settings.Reading.Indentation != "none" {
+ t.Fatal(version, settings.Reading, err)
+ }
+ }
+ for _, mode := range []string{"none", "speech", "beeps", "both"} {
+ settings, err := Decode([]byte("version=3\n[reading]\nindentation='" + mode + "'\n"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(t.TempDir(), "settings.toml")
+ if err := Save(path, settings); err != nil {
+ t.Fatal(err)
+ }
+ saved, _, err := Load(path, nil)
+ if err != nil || saved.Reading != settings.Reading {
+ t.Fatal(saved.Reading, err)
+ }
+ }
+ if _, err := Decode([]byte("version=3\n[reading]\nindentation='invalid'\n")); err == nil {
+ t.Fatal("invalid indentation mode accepted")
+ }
+}
diff --git a/internal/config/migrate.go b/internal/config/migrate.go
new file mode 100644
index 00000000..0926c345
--- /dev/null
+++ b/internal/config/migrate.go
@@ -0,0 +1,98 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package config
+
+import (
+ "bytes"
+ "fmt"
+
+ "github.com/pelletier/go-toml/v2"
+)
+
+// decode_v1 is the explicit migration boundary for the prototype's flat
+// speech/sound tables. Unknown keys and mixed old/new schemas are rejected.
+func decode_v1(data []byte) (Settings, error) {
+ defaults := Defaults()
+ type oldSpeech struct {
+ Enabled bool `toml:"enabled"`
+ Rate int `toml:"rate"`
+ Module string `toml:"module"`
+ Socket string `toml:"socket"`
+ }
+ old := struct {
+ Version int `toml:"version"`
+ Review Review `toml:"review"`
+ Speech oldSpeech `toml:"speech"`
+ Sound Sound `toml:"sound"`
+ Keyboard Keyboard `toml:"keyboard"`
+ Reading Reading `toml:"reading"`
+ Clipboard Clipboard `toml:"clipboard"`
+ General General `toml:"general"`
+ Scripts Scripts `toml:"scripts"`
+ Remote Remote `toml:"remote"`
+ }{Review: defaults.Review, Version: 1, Speech: oldSpeech{Enabled: true, Rate: 80}, Sound: defaults.Presentation.Sound, Keyboard: defaults.Keyboard, Reading: defaults.Reading, Clipboard: defaults.Clipboard, General: defaults.General, Scripts: defaults.Scripts, Remote: defaults.Remote}
+ if err := toml.NewDecoder(bytes.NewReader(data)).DisallowUnknownFields().Decode(&old); err != nil {
+ return Settings{}, fmt.Errorf("parse version 1 settings: %w", err)
+ }
+ defaults.Presentation.Speech.Enabled = old.Speech.Enabled
+ defaults.Presentation.Speech.Rate = old.Speech.Rate
+ defaults.Presentation.Speech.Module = old.Speech.Module
+ defaults.Presentation.Sound = old.Sound
+ defaults.Keyboard, defaults.Reading, defaults.Clipboard = old.Keyboard, old.Reading, old.Clipboard
+ defaults.General, defaults.Scripts, defaults.Remote = old.General, old.Scripts, old.Remote
+ defaults.Review = old.Review
+ return defaults, defaults.Validate()
+}
+
+// Version 2 briefly exposed Socket under Presentation. Migrate it away without
+// carrying an endpoint override into either runtime settings or future saves.
+func decode_v2(data []byte) (Settings, error) {
+ defaults := Defaults()
+ var old struct {
+ Version int `toml:"version"`
+ Review Review `toml:"review"`
+ Presentation struct {
+ Speech struct {
+ Speech
+ Socket string `toml:"socket"`
+ } `toml:"speech"`
+ Sound Sound `toml:"sound"`
+ } `toml:"presentation"`
+ Keyboard Keyboard `toml:"keyboard"`
+ Reading Reading `toml:"reading"`
+ Clipboard Clipboard `toml:"clipboard"`
+ General General `toml:"general"`
+ Scripts Scripts `toml:"scripts"`
+ Remote Remote `toml:"remote"`
+ }
+ old.Version = 2
+ old.Review = defaults.Review
+ old.Presentation.Speech.Speech = defaults.Presentation.Speech
+ old.Presentation.Sound = defaults.Presentation.Sound
+ old.Keyboard, old.Reading, old.Clipboard = defaults.Keyboard, defaults.Reading, defaults.Clipboard
+ old.General, old.Scripts, old.Remote = defaults.General, defaults.Scripts, defaults.Remote
+ if err := toml.NewDecoder(bytes.NewReader(data)).DisallowUnknownFields().Decode(&old); err != nil {
+ return Settings{}, fmt.Errorf("parse version 2 settings: %w", err)
+ }
+ defaults.Presentation.Speech = old.Presentation.Speech.Speech
+ defaults.Presentation.Sound = old.Presentation.Sound
+ defaults.Keyboard, defaults.Reading, defaults.Clipboard = old.Keyboard, old.Reading, old.Clipboard
+ defaults.General, defaults.Scripts, defaults.Remote = old.General, old.Scripts, old.Remote
+ defaults.Review = old.Review
+ return defaults, defaults.Validate()
+}
diff --git a/internal/config/migrate_test.go b/internal/config/migrate_test.go
new file mode 100644
index 00000000..77069d10
--- /dev/null
+++ b/internal/config/migrate_test.go
@@ -0,0 +1,155 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package config
+
+import (
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+)
+
+func TestVersionOneMigrationPreservesValuesAndOnlyWritesOnSave(t *testing.T) {
+ original := []byte(`version = 1
+[clipboard]
+sync = true
+sync_display = ":42"
+number_of_clipboards = 7
+export_path = "/tmp/fixture-clipboard"
+[sound]
+enabled = false
+volume = 0.3
+[speech]
+enabled = false
+rate = 45
+module = "fixture"
+socket = "/tmp/fixture.sock"
+[keyboard]
+layout = "laptop"
+devices = ["/dev/input/event-fixture"]
+character_echo = false
+word_echo = true
+echo_on_capslock = true
+[reading]
+automatic = false
+cursor_echo = false
+`)
+ path := filepath.Join(t.TempDir(), "settings.toml")
+ if err := os.WriteFile(path, original, 0600); err != nil {
+ t.Fatal(err)
+ }
+ doc, err := Open_document(path, Defaults())
+ if err != nil {
+ t.Fatal(err)
+ }
+ wanted := Defaults()
+ wanted.Presentation.Sound = Sound{Enabled: false, Volume: 0.3, Driver: "pulse", Theme: "default", Device: "default"}
+ wanted.Presentation.Speech = Speech{Enabled: false, Rate: 45, Volume: 100, Driver: "speech-dispatcher", Module: "fixture"}
+ wanted.Keyboard = Keyboard{MultiPressTimeoutMS: 200, Layout: "laptop", Devices: []string{"/dev/input/event-fixture"}, WordEcho: true, EchoOnCapsLock: true}
+ wanted.Reading = Reading{Emoticons: true, RepeatedSymbolsMin: 4, Spellcheck: true, SpellcheckSound: true, Attributes: Defaults().Reading.Attributes, AttributeFormat: Defaults().Reading.AttributeFormat, Indentation: "none", RaisePitchForCapitals: true, Mode: "cursor", PunctuationProfile: "default", PunctuationLevel: "some"}
+ wanted.Clipboard = Clipboard{Sync: true, SyncDisplay: ":42", NumberOfClipboards: 7, ExportPath: "/tmp/fixture-clipboard"}
+ if !reflect.DeepEqual(doc.Settings, wanted) {
+ t.Fatal(doc.Settings, wanted)
+ }
+ data, err := os.ReadFile(path)
+ if err != nil || string(data) != string(original) {
+ t.Fatal("opening rewrote settings", err)
+ }
+ if err := doc.Save(doc.Settings); err != nil {
+ t.Fatal(err)
+ }
+ data, err = os.ReadFile(path)
+ if err != nil || !strings.Contains(string(data), "version = 3") || strings.Contains(string(data), "\n[sound]") || strings.Contains(string(data), "socket =") {
+ t.Fatal(string(data), err)
+ }
+ got, err := Open_document(path, Defaults())
+ wantedOrder := []string{"clipboard", "presentation", "presentation.sound", "presentation.speech", "presentation.progress", "presentation.time_and_date", "keyboard", "reading", "review", "screen", "general", "scripts", "remote", "prompt"}
+ if err != nil || !reflect.DeepEqual(got.Settings, wanted) || !reflect.DeepEqual(got.Order, wantedOrder) {
+ t.Fatal(got, err)
+ }
+}
+
+func TestRejectMixedSchemasAndUnknownDriverEvenWhenSpeechDisabled(t *testing.T) {
+ for _, input := range []string{
+ "version=1\n[presentation.speech]\nrate=40\n",
+ "version=2\n[speech]\nrate=40\n",
+ "version=2\n[presentation.speech]\ndriver='invented'\nenabled=false\n",
+ "version=1\n[speech]\ndriver='invented'\n",
+ "version=2\n[presentation.speech]\ndrivers=['speech-dispatcher']\n",
+ "version=0\n", "version=4\n",
+ "version=2\n[presentation.sound]\nvolume=nan\n",
+ "version=2\n[presentation.progress]\nenabled=true\n",
+ } {
+ if _, err := Decode([]byte(input)); err == nil {
+ t.Fatal("invalid schema accepted", input)
+ }
+ }
+}
+
+func TestNestedDottedAndInlineSectionsShareOrder(t *testing.T) {
+ for _, input := range []string{
+ "version=2\n[reading]\nautomatic=false\n[presentation.sound]\nvolume=0.2\n[presentation.speech]\nrate=40\n",
+ "version=2\nreading.automatic=false\npresentation.sound.volume=0.2\npresentation.speech.rate=40\n",
+ "version=2\nreading={automatic=false}\npresentation.sound={volume=0.2}\npresentation.speech={rate=40}\n",
+ "version=2\nreading={automatic=false}\npresentation={sound={volume=0.2}, speech={rate=40}}\n",
+ "version=2\n[reading]\nautomatic=false\n[presentation]\nsound={volume=0.2}\nspeech={rate=40}\n",
+ } {
+ path := filepath.Join(t.TempDir(), "settings.toml")
+ if err := os.WriteFile(path, []byte(input), 0600); err != nil {
+ t.Fatal(err)
+ }
+ doc, err := Open_document(path, Defaults())
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := []string{"reading", "presentation", "presentation.sound", "presentation.speech"}
+ if !reflect.DeepEqual(doc.Order, want) {
+ t.Fatal(doc.Order)
+ }
+ if err := doc.Save(doc.Settings); err != nil {
+ t.Fatal(err)
+ }
+ saved, err := Open_document(path, Defaults())
+ if err == nil {
+ saved.Settings.Keyboard.Devices = doc.Settings.Keyboard.Devices
+ }
+ if err != nil || !reflect.DeepEqual(saved.Settings, doc.Settings) {
+ t.Fatal(saved, err)
+ }
+ }
+}
+
+func TestVersionTwoMigrationDiscardsSocketAndCurrentSchemaRejectsIt(t *testing.T) {
+ old := []byte("version=2\n[presentation.speech]\nsocket='/tmp/obsolete.sock'\nrate=45\nmodule='fixture'\n[presentation.sound]\nvolume=0.3\n")
+ settings, err := Decode(old)
+ if err != nil || settings.Version != 3 || settings.Presentation.Speech.Rate != 45 || settings.Presentation.Speech.Module != "fixture" || settings.Presentation.Sound.Volume != 0.3 {
+ t.Fatal(settings, err)
+ }
+ path := filepath.Join(t.TempDir(), "settings.toml")
+ if err := Save(path, settings); err != nil {
+ t.Fatal(err)
+ }
+ data, err := os.ReadFile(path)
+ if err != nil || strings.Contains(string(data), "socket =") {
+ t.Fatal(string(data), err)
+ }
+ if _, err := Decode([]byte("version=3\n[presentation.speech]\nsocket='/tmp/unused.sock'\n")); err == nil {
+ t.Fatal("removed setting accepted in current schema")
+ }
+}
diff --git a/internal/config/multi_press_test.go b/internal/config/multi_press_test.go
new file mode 100644
index 00000000..bd3f6c7a
--- /dev/null
+++ b/internal/config/multi_press_test.go
@@ -0,0 +1,45 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package config
+
+import (
+ "path/filepath"
+ "testing"
+)
+
+func TestMultiPressTimeoutDefaultsValidationAndSave(t *testing.T) {
+ for _, version := range []string{"1", "2", "3"} {
+ settings, err := Decode([]byte("version=" + version + "\n"))
+ if err != nil || settings.Keyboard.MultiPressTimeoutMS != 200 {
+ t.Fatal(settings, err)
+ }
+ }
+ for _, value := range []string{"0", "-1", "5001", "0.5", "'200'"} {
+ if _, err := Decode([]byte("version=3\n[keyboard]\nmulti_press_timeout_ms=" + value)); err == nil {
+ t.Fatal(value)
+ }
+ }
+ for _, value := range []string{"1", "700", "5000"} {
+ settings, err := Decode([]byte("version=3\n[keyboard]\nmulti_press_timeout_ms=" + value))
+ if err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(t.TempDir(), "settings.toml")
+ if err := Save(path, settings); err != nil {
+ t.Fatal(err)
+ }
+ doc, err := Open_document(path, Defaults())
+ if err != nil {
+ t.Fatal(err)
+ }
+ doc.Settings.Presentation.Speech.Rate = 30
+ if err := doc.Save(doc.Settings); err != nil {
+ t.Fatal(err)
+ }
+ loaded, _, err := Load(path, nil)
+ if err != nil || loaded.Keyboard.MultiPressTimeoutMS != settings.Keyboard.MultiPressTimeoutMS {
+ t.Fatal(loaded, err)
+ }
+ }
+}
diff --git a/internal/config/progress_test.go b/internal/config/progress_test.go
new file mode 100644
index 00000000..781efc3d
--- /dev/null
+++ b/internal/config/progress_test.go
@@ -0,0 +1,44 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package config
+
+import (
+ "path/filepath"
+ "testing"
+)
+
+func TestProgressDefaultsAndPersistence(t *testing.T) {
+ for _, version := range []string{"1", "2", "3"} {
+ settings, err := Decode([]byte("version=" + version + "\n"))
+ if err != nil || !settings.Presentation.Progress.Enabled {
+ t.Fatal(version, settings, err)
+ }
+ }
+ settings, err := Decode([]byte("version=3\n[presentation.progress]\nenabled=false\n"))
+ if err != nil || settings.Presentation.Progress.Enabled {
+ t.Fatal(settings, err)
+ }
+ path := filepath.Join(t.TempDir(), "settings.toml")
+ if err := Save(path, settings); err != nil {
+ t.Fatal(err)
+ }
+ got, _, err := Load(path, nil)
+ if err != nil || got.Presentation.Progress.Enabled {
+ t.Fatal(got, err)
+ }
+}
diff --git a/internal/config/prompt_test.go b/internal/config/prompt_test.go
new file mode 100644
index 00000000..14c61cd2
--- /dev/null
+++ b/internal/config/prompt_test.go
@@ -0,0 +1,32 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package config
+
+import (
+ "path/filepath"
+ "reflect"
+ "testing"
+)
+
+func TestPromptSettingsValidationAndSave(t *testing.T) {
+ settings, err := Decode([]byte("version = 3\n[prompt]\nexact_matches = ['READY']\ncustom_patterns = ['^Device [0-9]+ ready:$']\n"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(t.TempDir(), "settings.toml")
+ doc, err := Open_document(path, settings)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := doc.Save(settings); err != nil {
+ t.Fatal(err)
+ }
+ loaded, _, err := Load(path, nil)
+ if err != nil || !reflect.DeepEqual(loaded.Prompt, settings.Prompt) {
+ t.Fatal(loaded.Prompt, err)
+ }
+ if _, err := Decode([]byte("version = 3\n[prompt]\ncustom_patterns = ['[']\n")); err == nil {
+ t.Fatal("invalid regex accepted")
+ }
+}
diff --git a/internal/config/review_test.go b/internal/config/review_test.go
new file mode 100644
index 00000000..2a774b67
--- /dev/null
+++ b/internal/config/review_test.go
@@ -0,0 +1,42 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package config
+
+import (
+ "path/filepath"
+ "testing"
+)
+
+func TestReviewOptionsMigrationAndDocumentSave(t *testing.T) {
+ for _, version := range []string{"1", "2", "3"} {
+ base := "version=" + version + "\n"
+ settings, err := Decode([]byte(base))
+ if err != nil || settings.Review != (Review{true, true, true, true}) {
+ t.Fatal(version, settings.Review, err)
+ }
+ settings, err = Decode([]byte(base + "[review]\nline_break=false\nend_of_screen=false\nleave_review_on_cursor_change=false\nleave_review_on_screen_change=false\n"))
+ if err != nil || settings.Review != (Review{}) {
+ t.Fatal(version, settings.Review, err)
+ }
+ path := filepath.Join(t.TempDir(), "settings.toml")
+ if err := Save(path, settings); err != nil {
+ t.Fatal(err)
+ }
+ doc, err := Open_document(path, Defaults())
+ if err != nil {
+ t.Fatal(err)
+ }
+ doc.Settings.Presentation.Speech.Rate = 20
+ if err := doc.Save(doc.Settings); err != nil {
+ t.Fatal(err)
+ }
+ loaded, _, err := Load(path, nil)
+ if err != nil || loaded.Review != (Review{}) {
+ t.Fatal(loaded.Review, err)
+ }
+ }
+ if _, err := Decode([]byte("version=3\n[review]\nline_break='false'")); err == nil {
+ t.Fatal("accepted invalid review setting")
+ }
+}
diff --git a/internal/config/screen_test.go b/internal/config/screen_test.go
new file mode 100644
index 00000000..eb520420
--- /dev/null
+++ b/internal/config/screen_test.go
@@ -0,0 +1,43 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package config
+
+import (
+ "path/filepath"
+ "reflect"
+ "testing"
+)
+
+func TestIgnoredVTValidationAndPersistence(t *testing.T) {
+ for _, value := range []string{"[0]", "[-1]", "[64]", "[7,7]", "['7']", "'7,8'"} {
+ if _, err := Decode([]byte("version=3\n[screen]\nignored_vts=" + value)); err == nil {
+ t.Fatal("accepted invalid ignored VTs", value)
+ }
+ }
+ for _, value := range []string{"[]", "[1,7,8,10,63]"} {
+ settings, err := Decode([]byte("version=3\n[screen]\nignored_vts=" + value))
+ if err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(t.TempDir(), "settings.toml")
+ doc, err := Open_document(path, settings)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := doc.Save(settings); err != nil {
+ t.Fatal(err)
+ }
+ loaded, _, err := Load(path, nil)
+ if err != nil || !reflect.DeepEqual(loaded.Screen, settings.Screen) {
+ t.Fatal("document lost console policy", loaded.Screen, settings.Screen, err)
+ }
+ if err := Save(path, settings); err != nil {
+ t.Fatal(err)
+ }
+ loaded, _, err = Load(path, nil)
+ if err != nil || !reflect.DeepEqual(loaded.Screen, settings.Screen) {
+ t.Fatal("quick save lost console policy", loaded.Screen, settings.Screen, err)
+ }
+ }
+}
diff --git a/internal/config/scripts_test.go b/internal/config/scripts_test.go
new file mode 100644
index 00000000..3c06b62b
--- /dev/null
+++ b/internal/config/scripts_test.go
@@ -0,0 +1,61 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package config
+
+import (
+ "path/filepath"
+ "reflect"
+ "testing"
+)
+
+func TestScriptDefaultsAndSaveRoundTrip(t *testing.T) {
+ for _, version := range []string{"1", "2", "3"} {
+ settings, err := Decode([]byte("version=" + version + "\n[general]\nscript_path='/custom/scripts'\nscript_keys=['KEY_F12','KEY_RIGHTCTRL']\n[scripts]\ntimeout_seconds=20\n[remote]\nsocket_file='/tmp/private.sock'\n"))
+ if err != nil || settings.General.ScriptPath != "/custom/scripts" || settings.Scripts.TimeoutSeconds != 20 || settings.Remote.SocketFile != "/tmp/private.sock" {
+ t.Fatal(version, settings, err)
+ }
+ path := filepath.Join(t.TempDir(), "settings.toml")
+ doc, err := Open_document(path, settings)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := doc.Save(settings); err != nil {
+ t.Fatal(err)
+ }
+ loaded, _, err := Load(path, nil)
+ // TOML serializes a nil device slice as an empty array.
+ if len(loaded.Keyboard.Devices) == 0 && len(settings.Keyboard.Devices) == 0 {
+ loaded.Keyboard.Devices = settings.Keyboard.Devices
+ }
+ if err != nil || !reflect.DeepEqual(loaded, settings) {
+ t.Fatal(loaded, err)
+ }
+ }
+ defaults := Defaults()
+ if !reflect.DeepEqual(defaults.General.ScriptKeys, []string{"KEY_COMPOSE"}) || defaults.General.ScriptPath != "/usr/share/fenrirscreenreader/scripts" || defaults.Scripts.TimeoutSeconds != 0 {
+ t.Fatal(defaults)
+ }
+}
+
+func TestRejectInvalidScriptLimitsAndModifierNames(t *testing.T) {
+ for _, data := range []string{"[scripts]\nworkers=0", "[scripts]\nqueue_limit=0", "[scripts]\noutput_bytes=0", "[scripts]\ntimeout_seconds=-1", "[general]\nscript_keys=['KEY_UNKNOWN_123']"} {
+ if _, err := Decode([]byte("version=3\n" + data)); err == nil {
+ t.Fatal("accepted invalid settings", data)
+ }
+ }
+}
diff --git a/internal/config/sound_test.go b/internal/config/sound_test.go
new file mode 100644
index 00000000..c0a503db
--- /dev/null
+++ b/internal/config/sound_test.go
@@ -0,0 +1,36 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package config
+
+import (
+ "path/filepath"
+ "testing"
+)
+
+func TestSoundDefaultsSelectionAndRoundTrip(t *testing.T) {
+ for _, version := range []string{"1", "2", "3"} {
+ settings, err := Decode([]byte("version=" + version + "\n"))
+ if err != nil || settings.Presentation.Sound != Defaults().Presentation.Sound {
+ t.Fatal(settings.Presentation.Sound, err)
+ }
+ }
+ settings := Defaults()
+ settings.Presentation.Sound.Driver = "alsa"
+ settings.Presentation.Sound.Device = "plughw:CARD=Generic,DEV=0"
+ settings.Presentation.Sound.Theme = "/tmp/fixture-pack"
+ path := filepath.Join(t.TempDir(), "settings.toml")
+ if err := Save(path, settings); err != nil {
+ t.Fatal(err)
+ }
+ got, _, err := Load(path, nil)
+ if err != nil || got.Presentation.Sound != settings.Presentation.Sound {
+ t.Fatal(got.Presentation.Sound, err)
+ }
+ for _, data := range []string{"driver='unknown'", "device=''", "device='bad\nname'", "theme='../other'"} {
+ if _, err := Decode([]byte("version=3\n[presentation.sound]\nenabled=false\n" + data)); err == nil {
+ t.Fatal("invalid sound choice accepted", data)
+ }
+ }
+}
diff --git a/internal/config/spellcheck_test.go b/internal/config/spellcheck_test.go
new file mode 100644
index 00000000..0efc30dd
--- /dev/null
+++ b/internal/config/spellcheck_test.go
@@ -0,0 +1,31 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package config
+
+import (
+ "fmt"
+ "path/filepath"
+ "testing"
+)
+
+func TestSpellcheckDefaultsMigrationAndDisabledPersistence(t *testing.T) {
+ for _, version := range []int{1, 2, 3} {
+ settings, err := Decode([]byte(fmt.Sprintf("version=%d\n", version)))
+ if err != nil || !settings.Reading.Spellcheck || !settings.Reading.SpellcheckSound || settings.Reading.SpellcheckReadWord || settings.Reading.SpellcheckSayMisspelled {
+ t.Fatal(version, settings.Reading, err)
+ }
+ settings.Reading.Spellcheck = false
+ settings.Reading.SpellcheckReadWord = true
+ settings.Reading.SpellcheckSayMisspelled = true
+ settings.Reading.SpellcheckSound = false
+ path := filepath.Join(t.TempDir(), "settings.toml")
+ if err := Save(path, settings); err != nil {
+ t.Fatal(err)
+ }
+ saved, _, err := Load(path, nil)
+ if err != nil || saved.Reading != settings.Reading {
+ t.Fatal(saved.Reading, err)
+ }
+ }
+}
diff --git a/internal/config/text_processing_test.go b/internal/config/text_processing_test.go
new file mode 100644
index 00000000..b15d2346
--- /dev/null
+++ b/internal/config/text_processing_test.go
@@ -0,0 +1,37 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package config
+
+import (
+ "fmt"
+ "path/filepath"
+ "testing"
+)
+
+func TestTextProcessingDefaultsMigrationAndPersistence(t *testing.T) {
+ for _, version := range []int{1, 2, 3} {
+ settings, err := Decode([]byte(fmt.Sprintf("version=%d\n", version)))
+ if err != nil || !settings.Reading.Emoticons || settings.Reading.RepeatedSymbols || settings.Reading.RepeatedSymbolsMin != 4 {
+ t.Fatal(version, settings.Reading, err)
+ }
+ settings.Reading.Emoticons = false
+ settings.Reading.RepeatedSymbols = true
+ settings.Reading.RepeatedSymbolsMin = 6
+ path := filepath.Join(t.TempDir(), "settings.toml")
+ if err := Save(path, settings); err != nil {
+ t.Fatal(err)
+ }
+ saved, _, err := Load(path, nil)
+ if err != nil || saved.Reading != settings.Reading {
+ t.Fatal(saved.Reading, err)
+ }
+ }
+ for _, minimum := range []int{-1, 0, 1, 10001} {
+ settings := Defaults()
+ settings.Reading.RepeatedSymbolsMin = minimum
+ if err := settings.Validate(); err == nil {
+ t.Fatal("invalid minimum accepted", minimum)
+ }
+ }
+}
diff --git a/internal/console/decode_linux.go b/internal/console/decode_linux.go
new file mode 100644
index 00000000..76f7fe72
--- /dev/null
+++ b/internal/console/decode_linux.go
@@ -0,0 +1,78 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+//go:build linux && cgo
+
+package console
+
+import (
+ "encoding/binary"
+ "fmt"
+ "unicode/utf8"
+
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+// decode_snapshot uses the VCSA header and native-endian UTF-32 VCSU cells. Glyph
+// numbers from VCSA are deliberately not treated as Unicode characters.
+func decode_snapshot(tty int, header, text []byte) (screen.Snapshot, error) {
+ if len(header) < 4 {
+ return screen.Snapshot{}, fmt.Errorf("short VCSA header")
+ }
+ snapshot := screen.Snapshot{Terminal: tty, Active: true, Rows: int(header[0]), Cols: int(header[1]), X: int(header[2]), Y: int(header[3])}
+ if snapshot.Rows == 0 || snapshot.Cols == 0 || snapshot.X >= snapshot.Cols || snapshot.Y >= snapshot.Rows {
+ return screen.Snapshot{}, fmt.Errorf("invalid console geometry or cursor")
+ }
+ count := snapshot.Rows * snapshot.Cols
+ if len(text) != count*4 {
+ return screen.Snapshot{}, fmt.Errorf("VCSU size %d does not match %dx%d console", len(text), snapshot.Cols, snapshot.Rows)
+ }
+ snapshot.Cells = make([]rune, count)
+ for index := range snapshot.Cells {
+ value := binary.NativeEndian.Uint32(text[index*4:])
+ switch {
+ case value == 0:
+ snapshot.Cells[index] = ' '
+ case value > utf8.MaxRune || value >= 0xd800 && value <= 0xdfff:
+ snapshot.Cells[index] = utf8.RuneError
+ default:
+ snapshot.Cells[index] = rune(value)
+ }
+ }
+ return snapshot, nil
+}
+
+func decode_attributes(snapshot *screen.Snapshot, data []byte) error {
+ if len(data) != 4+len(snapshot.Cells)*2 {
+ return fmt.Errorf("VCSA size %d does not match %dx%d console", len(data), snapshot.Cols, snapshot.Rows)
+ }
+ snapshot.Attributes = make([]screen.Attribute, len(snapshot.Cells))
+ for index := range snapshot.Attributes {
+ // VCSA stores native-endian glyph/attribute words. Glyph bytes remain
+ // unused: Unicode always comes from VCSU, including non-ASCII fonts.
+ attribute := binary.NativeEndian.Uint16(data[4+index*2:]) >> 8
+ // VGA palette order swaps ANSI red/blue. Intensity is a color, not
+ // proof of bold; VCSA cannot recover the original SGR font styles.
+ ansi := func(value uint16) uint32 { return uint32(value&10 | (value&1)<<2 | (value&4)>>2) }
+ style := uint32(0)
+ if attribute&128 != 0 {
+ style = screen.ConsoleHigh
+ }
+ snapshot.Attributes[index] = screen.Attribute{Foreground: screen.ColorIndexed | ansi(attribute&15), Background: screen.ColorIndexed | ansi((attribute>>4)&7), Style: style}
+ }
+ return nil
+}
diff --git a/internal/console/decode_linux_test.go b/internal/console/decode_linux_test.go
new file mode 100644
index 00000000..0c9fe943
--- /dev/null
+++ b/internal/console/decode_linux_test.go
@@ -0,0 +1,79 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+//go:build linux && cgo
+
+package console
+
+import (
+ "encoding/binary"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "git.stormux.org/storm/fenrir/internal/screen"
+ "testing"
+)
+
+func TestVCSAAttributesUseNativeWordsAndKeepUnicode(t *testing.T) {
+ text := make([]byte, 8)
+ binary.NativeEndian.PutUint32(text, 'Ć©')
+ binary.NativeEndian.PutUint32(text[4:], 'ē')
+ data := []byte{1, 2, 0, 0, 0, 0, 0, 0}
+ binary.NativeEndian.PutUint16(data[4:], 0x1741)
+ binary.NativeEndian.PutUint16(data[6:], 0x8742)
+ snapshot, err := decode_snapshot(2, data, text)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := decode_attributes(&snapshot, data); err != nil {
+ t.Fatal(err)
+ }
+ if string(snapshot.Cells) != "Ć©ē" || snapshot.Attributes[0] != (screen.Attribute{Foreground: screen.ColorIndexed | 7, Background: screen.ColorIndexed | 4}) || snapshot.Attributes[1] != (screen.Attribute{Foreground: screen.ColorIndexed | 7, Background: screen.ColorIndexed, Style: screen.ConsoleHigh}) {
+ t.Fatal("attributes corrupted Unicode or cell order", snapshot)
+ }
+ if got := presentation.Format_attributes(snapshot.Attributes[0], "fenrirFGColor|fenrirBGColor|fenrirBold|fenrirBlink"); got != "light gray|blue||" {
+ t.Fatal(got)
+ }
+ if got := presentation.Format_attributes(snapshot.Attributes[1], "fenrirFGColor|fenrirBGColor|fenrirBold|fenrirBlink"); got != "light gray|black||" {
+ t.Fatal(got)
+ }
+ for _, invalid := range [][]byte{data[:4], data[:7], append(append([]byte{}, data...), 0)} {
+ if err := decode_attributes(&snapshot, invalid); err == nil {
+ t.Fatal("accepted partial or oversized VCSA", invalid)
+ }
+ }
+}
+
+func TestDecodeUnicodeAndGeometry(t *testing.T) {
+ data := make([]byte, 16)
+ for index, value := range []uint32{'Ć©', 'ē', 0, 0x110000} {
+ binary.NativeEndian.PutUint32(data[index*4:], value)
+ }
+ snapshot, err := decode_snapshot(2, []byte{2, 2, 1, 0}, data)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(snapshot.Cells) != "Ć©ē ļæ½" || snapshot.Text(0) != "Ć©ē" {
+ t.Fatalf("wrong Unicode: %+v", snapshot)
+ }
+ for _, header := range [][]byte{{}, {0, 2, 0, 0}, {2, 2, 2, 0}, {2, 2, 0, 2}} {
+ if _, err := decode_snapshot(1, header, data); err == nil {
+ t.Fatalf("accepted invalid header %v", header)
+ }
+ }
+ if _, err := decode_snapshot(1, []byte{2, 2, 0, 0}, data[:12]); err == nil {
+ t.Fatal("accepted resize mismatch")
+ }
+}
diff --git a/internal/console/discovery_linux.go b/internal/console/discovery_linux.go
new file mode 100644
index 00000000..d1d83f45
--- /dev/null
+++ b/internal/console/discovery_linux.go
@@ -0,0 +1,39 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+//go:build linux && cgo
+
+package console
+
+import "syscall"
+
+// Include ctime so a recreated node, permission change or reused inode causes
+// another probe. Do not include atime: probing itself can change it.
+type deviceIdentity struct {
+ device, inode, rdev uint64
+ changed syscall.Timespec
+}
+
+func identity_from_stat(stat *syscall.Stat_t) deviceIdentity {
+ return deviceIdentity{device: uint64(stat.Dev), inode: stat.Ino, rdev: uint64(stat.Rdev), changed: stat.Ctim}
+}
+
+func device_identity(path string) (deviceIdentity, error) {
+ var stat syscall.Stat_t
+ err := syscall.Stat(path, &stat)
+ return identity_from_stat(&stat), err
+}
diff --git a/internal/console/discovery_linux_test.go b/internal/console/discovery_linux_test.go
new file mode 100644
index 00000000..d0e0e8f3
--- /dev/null
+++ b/internal/console/discovery_linux_test.go
@@ -0,0 +1,117 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+//go:build linux && cgo
+
+package console
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func BenchmarkCachedDeviceScan(b *testing.B) {
+ root := b.TempDir()
+ k := &Keyboards{rejected: make(map[string]deviceIdentity)}
+ var paths []string
+ for i := range 20 {
+ path := filepath.Join(root, fmt.Sprintf("event%d", i))
+ if err := os.WriteFile(path, nil, 0600); err != nil {
+ b.Fatal(err)
+ }
+ identity, err := device_identity(path)
+ if err != nil {
+ b.Fatal(err)
+ }
+ k.rejected[path] = identity
+ paths = append(paths, path)
+ }
+ b.ReportAllocs()
+ b.ResetTimer()
+ for b.Loop() {
+ if err := k.scan_paths(paths); err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+func TestScanCachesRejectionsAndRetriesReplacement(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "event0")
+ if err := os.WriteFile(path, nil, 0600); err != nil {
+ t.Fatal(err)
+ }
+ identity, err := device_identity(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ k := &Keyboards{rejected: map[string]deviceIdentity{path: identity}}
+ // A cached non-keyboard must not be reopened. This regular file would
+ // fail libevdev initialization and lose its cache entry if probed.
+ for range 3 {
+ if err := k.scan_paths([]string{path}); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := k.rejected[path]; !ok {
+ t.Fatal("reopened an unchanged rejected device")
+ }
+ }
+ // Keep the old inode alive so this also exercises a reused event path.
+ if err := os.Rename(path, path+".old"); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, nil, 0600); err != nil {
+ t.Fatal(err)
+ }
+ // Explicit paths expose the open error, proving a new node was probed.
+ k.paths = []string{path}
+ if err := k.Scan(); err == nil {
+ t.Fatal("replacement was skipped")
+ }
+ if len(k.rejected) != 0 {
+ t.Fatal("cached a failed open")
+ }
+ if err := k.Scan(); err == nil {
+ t.Fatal("failed open was not retried")
+ }
+}
+
+func TestScanInvalidatesMissingAndChangedNodes(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "event0")
+ if err := os.WriteFile(path, nil, 0600); err != nil {
+ t.Fatal(err)
+ }
+ identity, err := device_identity(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // ctime changes must invalidate even when device and inode stay equal.
+ stale := identity
+ stale.changed.Sec--
+ k := &Keyboards{paths: []string{path}, rejected: map[string]deviceIdentity{path: stale}}
+ if err := k.Scan(); err == nil {
+ t.Fatal("changed node was not probed")
+ }
+ k.rejected[path] = identity
+ if err := k.scan_paths(nil); err != nil {
+ t.Fatal(err)
+ }
+ if len(k.rejected) != 0 {
+ t.Fatal("retained disappeared node")
+ }
+}
diff --git a/internal/console/input_watcher_linux_test.go b/internal/console/input_watcher_linux_test.go
new file mode 100644
index 00000000..25d47afe
--- /dev/null
+++ b/internal/console/input_watcher_linux_test.go
@@ -0,0 +1,116 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+//go:build linux && cgo
+
+package console
+
+import (
+ "os"
+ "syscall"
+ "testing"
+ "time"
+)
+
+func TestInputWatcherReadinessRearmAndHotplug(t *testing.T) {
+ watcher, err := newReadinessWatcher(syscall.EPOLLIN)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer watcher.close()
+ for device := 0; device < 2; device++ {
+ readEnd, writeEnd, err := os.Pipe()
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { readEnd.Close(); writeEnd.Close() })
+ if err := watcher.add(int(readEnd.Fd())); err != nil {
+ t.Fatal(err)
+ }
+ for press := 0; press < 2; press++ {
+ if _, err := writeEnd.Write([]byte{1}); err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case err := <-watcher.events:
+ if err != nil {
+ t.Fatal(err)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("input did not wake epoll")
+ }
+ var data [1]byte
+ if _, err := readEnd.Read(data[:]); err != nil {
+ t.Fatal(err)
+ }
+ if err := watcher.arm(int(readEnd.Fd())); err != nil {
+ t.Fatal(err)
+ }
+ }
+ readEnd.Close()
+ writeEnd.Close()
+ }
+}
+
+func TestInputWatcherIdleShutdown(t *testing.T) {
+ watcher, err := newReadinessWatcher(syscall.EPOLLIN)
+ if err != nil {
+ t.Fatal(err)
+ }
+ closed := make(chan error, 1)
+ go func() { closed <- watcher.close() }()
+ select {
+ case err := <-closed:
+ if err != nil {
+ t.Fatal(err)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("idle watcher failed to wake on shutdown")
+ }
+}
+
+func BenchmarkInputReadiness(b *testing.B) {
+ watcher, err := newReadinessWatcher(syscall.EPOLLIN)
+ if err != nil {
+ b.Fatal(err)
+ }
+ defer watcher.close()
+ readEnd, writeEnd, err := os.Pipe()
+ if err != nil {
+ b.Fatal(err)
+ }
+ defer readEnd.Close()
+ defer writeEnd.Close()
+ if err := watcher.add(int(readEnd.Fd())); err != nil {
+ b.Fatal(err)
+ }
+ var data [1]byte
+ for b.Loop() {
+ if _, err := writeEnd.Write(data[:]); err != nil {
+ b.Fatal(err)
+ }
+ if err := <-watcher.events; err != nil {
+ b.Fatal(err)
+ }
+ if _, err := readEnd.Read(data[:]); err != nil {
+ b.Fatal(err)
+ }
+ if err := watcher.arm(int(readEnd.Fd())); err != nil {
+ b.Fatal(err)
+ }
+ }
+}
diff --git a/internal/console/native_linux.go b/internal/console/native_linux.go
new file mode 100644
index 00000000..909a851a
--- /dev/null
+++ b/internal/console/native_linux.go
@@ -0,0 +1,413 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+//go:build linux && cgo
+
+// Package console implements Linux console and libevdev/uinput boundaries.
+package console
+
+/*
+#cgo pkg-config: libevdev
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+static int console_state(int fd, int *mode, unsigned char *leds) {
+ if (ioctl(fd, KDGETMODE, mode) < 0) return -errno;
+ if (*mode == KD_TEXT && ioctl(fd, KDGKBLED, leds) < 0) return -errno;
+ return 0;
+}
+
+static int read_feedback(const struct libevdev_uinput *device, struct input_event *event) {
+ ssize_t count = read(libevdev_uinput_get_fd(device), event, sizeof(*event));
+ if (count < 0) return -errno;
+ return count == sizeof(*event) ? 0 : -EIO;
+}
+
+static long long input_event_time_us(const struct input_event *event) {
+ return (long long)event->input_event_sec * 1000000 + event->input_event_usec;
+}
+*/
+import "C"
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "syscall"
+ "time"
+ "unsafe"
+
+ "git.stormux.org/storm/fenrir/internal/diagnostic"
+ "git.stormux.org/storm/fenrir/internal/input"
+)
+
+func kernel_error(code C.int) error {
+ if code >= 0 {
+ return nil
+ }
+ return syscall.Errno(-code)
+}
+
+func query_state(path string) (bool, bool, bool, error) {
+ file, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOCTTY, 0)
+ if err != nil {
+ return false, false, false, err
+ }
+ defer file.Close()
+ var mode C.int
+ var leds C.uchar
+ if err := kernel_error(C.console_state(C.int(file.Fd()), &mode, &leds)); err != nil {
+ return false, false, false, err
+ }
+ return mode == C.KD_TEXT, leds&C.LED_NUM != 0, leds&C.LED_CAP != 0, nil
+}
+
+type Device struct {
+ path string
+ fd int
+ dev *C.struct_libevdev
+ virtual *C.struct_libevdev_uinput
+ grabbed bool
+ syncing bool
+}
+
+func open_device(path string) (*Device, error) {
+ fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_NONBLOCK|syscall.O_CLOEXEC, 0)
+ if err != nil {
+ return nil, err
+ }
+ device := &Device{path: path, fd: fd}
+ if err := kernel_error(C.libevdev_new_from_fd(C.int(fd), &device.dev)); err != nil {
+ syscall.Close(fd)
+ return nil, err
+ }
+ return device, nil
+}
+
+func (d *Device) keyboard() bool {
+ name := C.GoString(C.libevdev_get_name(d.dev))
+ if strings.HasPrefix(name, "fenrir ") || name == "fenrir-uinput" {
+ return false
+ }
+ has := func(key C.uint) bool { return C.libevdev_has_event_code(d.dev, C.EV_KEY, key) != 0 }
+ return has(C.KEY_A) && has(C.KEY_Z) && has(C.KEY_ENTER) || has(C.KEY_KP7) && has(C.KEY_KP5) && has(C.KEY_KP1)
+}
+
+func (d *Device) create_forwarder() error {
+ name := C.CString("fenrir forwarding " + filepath.Base(d.path))
+ defer C.free(unsafe.Pointer(name))
+ C.libevdev_set_name(d.dev, name)
+ // The physical keyboard already generates repeats. A second repeating
+ // virtual device would duplicate them.
+ C.libevdev_disable_event_type(d.dev, C.EV_REP)
+ if err := kernel_error(C.libevdev_uinput_create_from_device(d.dev, C.LIBEVDEV_UINPUT_OPEN_MANAGED, &d.virtual)); err != nil {
+ return err
+ }
+ return syscall.SetNonblock(int(C.libevdev_uinput_get_fd(d.virtual)), true)
+}
+
+func (d *Device) forward(eventType, code uint16, value int32) error {
+ return kernel_error(C.libevdev_uinput_write_event(d.virtual, C.uint(eventType), C.uint(code), C.int(value)))
+}
+
+func (d *Device) held() bool {
+ for key := C.uint(0); key <= C.KEY_MAX; key++ {
+ if C.libevdev_get_event_value(d.dev, C.EV_KEY, key) != 0 {
+ return true
+ }
+ }
+ return false
+}
+
+func (d *Device) release(router *input.Router) error {
+ var errs []error
+ if d.virtual != nil {
+ for _, key := range router.Forwarded(d.fd) {
+ errs = append(errs, d.forward(C.EV_KEY, key, 0))
+ }
+ errs = append(errs, d.forward(C.EV_SYN, C.SYN_REPORT, 0))
+ }
+ router.Drop(d.fd)
+ if d.grabbed {
+ errs = append(errs, kernel_error(C.libevdev_grab(d.dev, C.LIBEVDEV_UNGRAB)))
+ d.grabbed = false
+ }
+ return errors.Join(errs...)
+}
+
+func (d *Device) close(router *input.Router) error {
+ err := d.release(router)
+ if d.virtual != nil {
+ C.libevdev_uinput_destroy(d.virtual)
+ d.virtual = nil
+ }
+ C.libevdev_free(d.dev)
+ return errors.Join(err, syscall.Close(d.fd))
+}
+
+// Keyboards owns every native handle on the application's event loop.
+// It never grabs until screen access and the speech connection are ready.
+type Keyboards struct {
+ devices map[string]*Device
+ rejected map[string]deviceIdentity
+ paths []string
+ router input.Router
+ watcher *readinessWatcher
+ uiMap *os.File
+}
+
+func Open_keyboards(paths []string, router input.Router) (*Keyboards, error) {
+ watcher, err := newReadinessWatcher(syscall.EPOLLIN)
+ if err != nil {
+ return nil, fmt.Errorf("watch keyboards: %w", err)
+ }
+ keyboards := &Keyboards{devices: make(map[string]*Device), paths: paths, watcher: watcher, router: router}
+ if err := keyboards.Scan(); err != nil {
+ keyboards.Close()
+ return nil, err
+ }
+ if len(keyboards.devices) == 0 {
+ keyboards.Close()
+ return nil, fmt.Errorf("no accessible keyboards in /dev/input/event* (TTY mode needs privileged evdev access)")
+ }
+ return keyboards, nil
+}
+
+func (k *Keyboards) Scan() error {
+ paths := k.paths
+ if len(paths) == 0 {
+ paths, _ = filepath.Glob("/dev/input/event*")
+ }
+ return k.scan_paths(paths)
+}
+
+func (k *Keyboards) scan_paths(paths []string) error {
+ started := time.Now()
+ probed, skipped := 0, 0
+ defer func() { diagnostic.Timing("keyboard scan", started, "probed", probed, "cached", skipped) }()
+ if k.rejected == nil {
+ k.rejected = make(map[string]deviceIdentity)
+ }
+ // Rebuild the negative cache so disappeared devices do not accumulate.
+ rejected := make(map[string]deviceIdentity)
+ defer func() { k.rejected = rejected }()
+ for _, path := range paths {
+ if k.devices[path] != nil {
+ continue
+ }
+ identity, statErr := device_identity(path)
+ if cached, ok := k.rejected[path]; ok && statErr == nil && cached == identity {
+ rejected[path] = cached
+ skipped++
+ continue
+ }
+ probed++
+ device, err := open_device(path)
+ if err != nil {
+ if len(k.paths) != 0 {
+ return fmt.Errorf("open keyboard %s: %w", path, err)
+ }
+ continue
+ }
+ if !device.keyboard() {
+ // Cache the opened device, not a potentially replaced path. Failed
+ // opens remain retryable; hotplug creates a different node identity.
+ var stat syscall.Stat_t
+ if err := syscall.Fstat(device.fd, &stat); err == nil {
+ rejected[path] = identity_from_stat(&stat)
+ }
+ device.close(&k.router)
+ if len(k.paths) != 0 {
+ return fmt.Errorf("%s is not a supported keyboard", path)
+ }
+ continue
+ }
+ if err := device.create_forwarder(); err != nil {
+ device.close(&k.router)
+ return fmt.Errorf("create uinput forwarder for %s (is /dev/uinput available?): %w", path, err)
+ }
+ if err := k.watcher.add(device.fd); err != nil {
+ device.close(&k.router)
+ return fmt.Errorf("watch keyboard %s: %w", path, err)
+ }
+ k.devices[path] = device
+ }
+ return nil
+}
+
+func (k *Keyboards) Pass_next() { k.router.Pass_next() }
+
+// Events wakes the application when a keyboard is readable, without reading it.
+// A nil event means readiness; a non-nil event is a fatal watcher error.
+func (k *Keyboards) Events() <-chan error { return k.watcher.events }
+
+func (k *Keyboards) Close() error {
+ var errs []error
+ if k.uiMap != nil {
+ errs = append(errs, k.uiMap.Close())
+ k.uiMap = nil
+ }
+ if k.watcher != nil {
+ errs = append(errs, k.watcher.close())
+ k.watcher = nil
+ }
+ for path, device := range k.devices {
+ errs = append(errs, device.close(&k.router))
+ delete(k.devices, path)
+ }
+ k.router.Reset()
+ return errors.Join(errs...)
+}
+
+func (k *Keyboards) Poll(textMode, numLock bool, handle func(input.Decision)) error {
+ if !textMode {
+ for _, device := range k.devices {
+ if device.grabbed {
+ if err := device.release(&k.router); err != nil {
+ return err
+ }
+ }
+ }
+ k.router.Reset()
+ }
+ for path, device := range k.devices {
+ drained, err := k.drain(device, numLock, handle)
+ if errors.Is(err, syscall.ENODEV) || errors.Is(err, syscall.EIO) {
+ device.close(&k.router)
+ delete(k.devices, path)
+ continue
+ }
+ if err != nil {
+ return fmt.Errorf("keyboard %s: %w", path, err)
+ }
+ if !drained {
+ // libevdev may retain buffered events after the bounded drain.
+ // Schedule another pass even if the kernel fd is no longer readable.
+ k.watcher.notify()
+ }
+ // Wait for all physical keys to be released before taking ownership.
+ // This avoids stealing half a Ctrl+Alt+Fn chord at a VT switch.
+ if textMode && !device.grabbed && drained && !device.held() {
+ if err := kernel_error(C.libevdev_grab(device.dev, C.LIBEVDEV_GRAB)); err != nil {
+ return fmt.Errorf("grab %s (another reader may own it): %w", path, err)
+ }
+ // A press can race the last read and EVIOCGRAB. Refresh the kernel
+ // state without forwarding commands before accepting ownership.
+ var event C.struct_input_event
+ code := C.libevdev_next_event(device.dev, C.LIBEVDEV_READ_FLAG_FORCE_SYNC, &event)
+ if code < 0 {
+ C.libevdev_grab(device.dev, C.LIBEVDEV_UNGRAB)
+ return kernel_error(code)
+ }
+ device.syncing = true
+ ready, err := k.drain(device, numLock, handle)
+ if err != nil || !ready || device.held() {
+ C.libevdev_grab(device.dev, C.LIBEVDEV_UNGRAB)
+ if err != nil {
+ return err
+ }
+ if !ready {
+ k.watcher.notify()
+ }
+ } else {
+ device.grabbed = true
+ }
+ }
+ if err := k.watcher.arm(device.fd); err != nil {
+ return fmt.Errorf("rearm keyboard %s: %w", path, err)
+ }
+ }
+ return nil
+}
+
+func (k *Keyboards) drain(device *Device, numLock bool, handle func(input.Decision)) (bool, error) {
+ // Bound each pass so busy input can't starve VT checks, speech, or quit.
+ for count := 0; count < 256; count++ {
+ var event C.struct_input_event
+ flags := C.uint(C.LIBEVDEV_READ_FLAG_NORMAL)
+ if device.syncing {
+ flags = C.LIBEVDEV_READ_FLAG_SYNC
+ }
+ code := C.libevdev_next_event(device.dev, flags, &event)
+ if code == -C.EAGAIN {
+ if device.syncing {
+ device.syncing = false
+ continue
+ }
+ return true, device.feedback()
+ }
+ if code < 0 {
+ return false, kernel_error(code)
+ }
+ if code == C.LIBEVDEV_READ_STATUS_SYNC && !device.syncing {
+ device.syncing = true
+ continue
+ }
+ if !device.grabbed {
+ continue
+ }
+ decision := input.Decision{Forward: true}
+ if event._type == C.EV_KEY {
+ if event.value != 0 && !device.syncing && diagnostic.Enabled() {
+ diagnostic.Timing("keyboard event age", time.UnixMicro(int64(C.input_event_time_us(&event))))
+ }
+ decision = k.router.Key(device.fd, uint16(event.code), int32(event.value), numLock, device.syncing)
+ if err := k.ui_text(uint16(event.code), decision); err != nil {
+ return false, err
+ }
+ }
+ if decision.Forward {
+ if err := device.forward(uint16(event._type), uint16(event.code), int32(event.value)); err != nil {
+ return false, err
+ }
+ }
+ if decision.Keypress || decision.Interrupt || decision.Command != "" || decision.UI != nil {
+ handle(decision)
+ }
+ }
+ return false, nil
+}
+
+func (d *Device) feedback() error {
+ for count := 0; count < 64; count++ {
+ var event C.struct_input_event
+ code := C.read_feedback(d.virtual, &event)
+ if code == -C.EAGAIN {
+ return nil
+ }
+ if code < 0 {
+ return kernel_error(code)
+ }
+ if event._type == C.EV_LED {
+ value := C.enum_libevdev_led_value(C.LIBEVDEV_LED_OFF)
+ if event.value != 0 {
+ value = C.LIBEVDEV_LED_ON
+ }
+ if err := kernel_error(C.libevdev_kernel_set_led_value(d.dev, C.uint(event.code), value)); err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}
diff --git a/internal/console/paste_linux.go b/internal/console/paste_linux.go
new file mode 100644
index 00000000..af82788d
--- /dev/null
+++ b/internal/console/paste_linux.go
@@ -0,0 +1,66 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+//go:build linux && cgo
+
+package console
+
+/*
+#include
+#include
+#include
+static int paste_byte(int fd, unsigned char value) {
+ return ioctl(fd, TIOCSTI, &value) < 0 ? -errno : 0;
+}
+*/
+import "C"
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "syscall"
+)
+
+// Paste runs on the clipboard worker and targets a captured VT. Recheck text
+// mode and the active VT before each byte; never follow a VT switch mid-paste.
+// Kernels that deny TIOCSTI return their error without reporting success.
+func (s *Source) Paste(ctx context.Context, text string, terminal int) error {
+ path := filepath.Join(s.root, "dev", fmt.Sprintf("tty%d", terminal))
+ file, err := os.OpenFile(path, os.O_WRONLY|syscall.O_NOCTTY|syscall.O_NONBLOCK, 0)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+ for _, value := range []byte(text) {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ number, textMode, _, _, err := s.active()
+ if err != nil {
+ return err
+ }
+ if number != terminal || !textMode {
+ return fmt.Errorf("clipboard paste stopped: active VT changed")
+ }
+ if code := C.paste_byte(C.int(file.Fd()), C.uchar(value)); code < 0 {
+ return fmt.Errorf("console clipboard paste: %w", syscall.Errno(-code))
+ }
+ }
+ return nil
+}
diff --git a/internal/console/paste_linux_test.go b/internal/console/paste_linux_test.go
new file mode 100644
index 00000000..b553f820
--- /dev/null
+++ b/internal/console/paste_linux_test.go
@@ -0,0 +1,63 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+//go:build linux && cgo
+
+package console
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestConsolePasteRejectsChangedTargetAndFailedInjection(t *testing.T) {
+ root := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(root, "sys/devices/virtual/tty/tty0"), 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Mkdir(filepath.Join(root, "dev"), 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(root, "dev/tty2"), nil, 0600); err != nil {
+ t.Fatal(err)
+ }
+ activePath := filepath.Join(root, "sys/devices/virtual/tty/tty0/active")
+ source := &Source{root: root, state: func(string) (bool, bool, bool, error) { return true, false, false, nil }}
+ if err := os.WriteFile(activePath, []byte("tty3\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if err := source.Paste(context.Background(), "text", 2); err == nil || !strings.Contains(err.Error(), "active VT changed") {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(activePath, []byte("tty2\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if err := source.Paste(context.Background(), "text", 2); err == nil {
+ t.Fatal("failed TIOCSTI reported success")
+ }
+ source.Configure_ignored_vts([]int{2})
+ if err := source.Paste(context.Background(), "text", 2); err == nil || !strings.Contains(err.Error(), "active VT changed") {
+ t.Fatal("paste attempted injection into ignored VT", err)
+ }
+ data, err := os.ReadFile(filepath.Join(root, "dev/tty2"))
+ if err != nil || len(data) != 0 {
+ t.Fatal("paste wrote to terminal output", data, err)
+ }
+}
diff --git a/internal/console/readiness_linux.go b/internal/console/readiness_linux.go
new file mode 100644
index 00000000..19d401d0
--- /dev/null
+++ b/internal/console/readiness_linux.go
@@ -0,0 +1,118 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+//go:build linux && cgo
+
+package console
+
+import (
+ "errors"
+ "sync"
+ "syscall"
+)
+
+// readinessWatcher reports readiness without reading the observed descriptors.
+// The application loop owns device reads, acknowledgement and descriptor lifetime.
+type readinessWatcher struct {
+ fd int
+ mask uint32
+ wake [2]int
+ events chan error
+ stop chan struct{}
+ workers sync.WaitGroup
+}
+
+func newReadinessWatcher(mask uint32) (*readinessWatcher, error) {
+ fd, err := syscall.EpollCreate1(syscall.EPOLL_CLOEXEC)
+ if err != nil {
+ return nil, err
+ }
+ w := &readinessWatcher{fd: fd, mask: mask, events: make(chan error, 1), stop: make(chan struct{})}
+ if err := syscall.Pipe2(w.wake[:], syscall.O_NONBLOCK|syscall.O_CLOEXEC); err != nil {
+ syscall.Close(fd)
+ return nil, err
+ }
+ event := syscall.EpollEvent{Fd: int32(w.wake[0]), Events: syscall.EPOLLIN}
+ if err := syscall.EpollCtl(fd, syscall.EPOLL_CTL_ADD, w.wake[0], &event); err != nil {
+ syscall.Close(w.wake[0])
+ syscall.Close(w.wake[1])
+ syscall.Close(fd)
+ return nil, err
+ }
+ w.workers.Add(1)
+ go w.watch()
+ return w, nil
+}
+
+func (w *readinessWatcher) add(fd int) error {
+ return w.control(syscall.EPOLL_CTL_ADD, fd)
+}
+
+func (w *readinessWatcher) arm(fd int) error {
+ return w.control(syscall.EPOLL_CTL_MOD, fd)
+}
+
+func (w *readinessWatcher) control(operation, fd int) error {
+ event := syscall.EpollEvent{Fd: int32(fd), Events: w.mask | syscall.EPOLLERR | syscall.EPOLLHUP | syscall.EPOLLONESHOT}
+ return syscall.EpollCtl(w.fd, operation, fd, &event)
+}
+
+func (w *readinessWatcher) notify() {
+ select {
+ case w.events <- nil:
+ default:
+ }
+}
+
+func (w *readinessWatcher) watch() {
+ defer w.workers.Done()
+ var events [16]syscall.EpollEvent
+ for {
+ select {
+ case <-w.stop:
+ return
+ default:
+ }
+ // Device readiness and shutdown wake epoll directly; there is no polling timer.
+ count, err := syscall.EpollWait(w.fd, events[:], -1)
+ if errors.Is(err, syscall.EINTR) {
+ continue
+ }
+ if err != nil {
+ select {
+ case w.events <- err:
+ case <-w.stop:
+ }
+ return
+ }
+ if count > 0 {
+ for _, event := range events[:count] {
+ if int(event.Fd) == w.wake[0] {
+ return
+ }
+ }
+ w.notify()
+ }
+ }
+}
+
+func (w *readinessWatcher) close() error {
+ close(w.stop)
+ _, err := syscall.Write(w.wake[1], []byte{1})
+ w.workers.Wait()
+ return errors.Join(err, syscall.Close(w.wake[0]), syscall.Close(w.wake[1]), syscall.Close(w.fd))
+}
diff --git a/internal/console/readiness_linux_test.go b/internal/console/readiness_linux_test.go
new file mode 100644
index 00000000..b8f75915
--- /dev/null
+++ b/internal/console/readiness_linux_test.go
@@ -0,0 +1,97 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+//go:build linux && cgo
+
+package console
+
+import (
+ "net"
+ "syscall"
+ "testing"
+ "time"
+)
+
+// TCP urgent data exercises the same kernel POLLPRI/acknowledge/rearm contract
+// as VCSA without requiring a real console, privileges or physical input.
+func TestPriorityReadinessAcknowledgementAndRearm(t *testing.T) {
+ listener, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer listener.Close()
+ writer, err := net.DialTCP("tcp", nil, listener.Addr().(*net.TCPAddr))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer writer.Close()
+ reader, err := listener.AcceptTCP()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer reader.Close()
+ readControl, err := reader.SyscallConn()
+ if err != nil {
+ t.Fatal(err)
+ }
+ writeControl, err := writer.SyscallConn()
+ if err != nil {
+ t.Fatal(err)
+ }
+ watcher, err := newReadinessWatcher(syscall.EPOLLPRI)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer watcher.close()
+ var operationErr error
+ if err := readControl.Control(func(fd uintptr) { operationErr = watcher.add(int(fd)) }); err != nil {
+ t.Fatal(err)
+ }
+ if operationErr != nil {
+ t.Fatal(operationErr)
+ }
+ for range 2 {
+ if err := writeControl.Control(func(fd uintptr) {
+ operationErr = syscall.Sendto(int(fd), []byte{1}, syscall.MSG_OOB, nil)
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if operationErr != nil {
+ t.Fatal(operationErr)
+ }
+ select {
+ case err := <-watcher.events:
+ if err != nil {
+ t.Fatal(err)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("priority data did not wake the watcher")
+ }
+ if err := readControl.Control(func(fd uintptr) {
+ var data [1]byte
+ _, _, operationErr = syscall.Recvfrom(int(fd), data[:], syscall.MSG_OOB)
+ if operationErr == nil {
+ operationErr = watcher.arm(int(fd))
+ }
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if operationErr != nil {
+ t.Fatal(operationErr)
+ }
+ }
+}
diff --git a/internal/console/source_linux.go b/internal/console/source_linux.go
new file mode 100644
index 00000000..efd9459b
--- /dev/null
+++ b/internal/console/source_linux.go
@@ -0,0 +1,220 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+//go:build linux && cgo
+
+package console
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync/atomic"
+ "syscall"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/diagnostic"
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+type Source struct {
+ root string
+ state func(string) (bool, bool, bool, error)
+ changes *os.File
+ watcher *readinessWatcher
+ ignoredVTs atomic.Uint64 // Paste checks policy from its background worker.
+}
+
+// Configure_ignored_vts receives the validated console policy from the event loop.
+func (s *Source) Configure_ignored_vts(numbers []int) {
+ var mask uint64
+ for _, number := range numbers {
+ mask |= uint64(1) << uint(number)
+ }
+ s.ignoredVTs.Store(mask)
+}
+
+// OpenSource watches the foreground-console alias. Kernel POLLPRI notifications
+// follow the active VT, so text feedback needs no periodic screen sampler.
+func OpenSource() (*Source, error) {
+ changes, err := os.Open("/dev/vcsa")
+ if err != nil {
+ return nil, fmt.Errorf("open foreground console notifications: %w", err)
+ }
+ watcher, err := newReadinessWatcher(syscall.EPOLLPRI)
+ if err != nil {
+ changes.Close()
+ return nil, err
+ }
+ source := &Source{root: "/", state: query_state, changes: changes, watcher: watcher}
+ if err := watcher.add(int(changes.Fd())); err != nil {
+ source.Close()
+ return nil, fmt.Errorf("watch foreground console: %w", err)
+ }
+ return source, nil
+}
+
+func (s *Source) Events() <-chan error { return s.watcher.events }
+
+func (s *Source) Close() error {
+ return errors.Join(s.watcher.close(), s.changes.Close())
+}
+
+func (s *Source) active() (int, bool, bool, bool, error) {
+ data, err := os.ReadFile(filepath.Join(s.root, "sys/devices/virtual/tty/tty0/active"))
+ if err != nil {
+ return 0, false, false, false, fmt.Errorf("read active VT: %w", err)
+ }
+ name := strings.TrimSpace(string(data))
+ if !strings.HasPrefix(name, "tty") {
+ return 0, false, false, false, fmt.Errorf("invalid active VT %q", name)
+ }
+ number, err := strconv.Atoi(strings.TrimPrefix(name, "tty"))
+ if err != nil || number < 1 || number > 63 {
+ return 0, false, false, false, fmt.Errorf("invalid active VT %q", name)
+ }
+ if s.ignoredVTs.Load()&(uint64(1)<.
+
+//go:build linux && cgo
+
+package console
+
+import (
+ "encoding/binary"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestSourceRetriesUnicodeSizeChange(t *testing.T) {
+ for _, cols := range []int{1, 3} {
+ t.Run(fmt.Sprint(cols), func(t *testing.T) {
+ root := t.TempDir()
+ write := func(path string, data []byte) {
+ t.Helper()
+ if strings.HasPrefix(path, "dev/vcsa") && len(data) == 4 {
+ data = append(data, make([]byte, int(data[0])*int(data[1])*2)...)
+ }
+ path = filepath.Join(root, path)
+ if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, data, 0600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ write("sys/devices/virtual/tty/tty0/active", []byte("tty2\n"))
+ write("dev/vcsa2", []byte{1, 2, 0, 0})
+ write("dev/vcsu2", make([]byte, cols*4))
+ calls := 0
+ source := &Source{root: root, state: func(string) (bool, bool, bool, error) {
+ calls++
+ if calls == 2 {
+ write("dev/vcsa2", []byte{1, byte(cols), 0, 0})
+ }
+ return true, false, false, nil
+ }}
+ snapshot, err := source.Read()
+ if err != nil || snapshot.Cols != cols || calls != 3 {
+ t.Fatalf("resize retry: cols=%d calls=%d error=%v", snapshot.Cols, calls, err)
+ }
+ })
+ }
+}
+
+func TestSourceUsesActiveVTAndSkipsGraphics(t *testing.T) {
+ root := t.TempDir()
+ write := func(path string, data []byte) {
+ t.Helper()
+ if strings.HasPrefix(path, "dev/vcsa") && len(data) == 4 {
+ data = append(data, make([]byte, int(data[0])*int(data[1])*2)...)
+ }
+ path = filepath.Join(root, path)
+ if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, data, 0600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ write("sys/devices/virtual/tty/tty0/active", []byte("tty2\n"))
+ write("dev/vcsa2", []byte{1, 2, 0, 0})
+ data := make([]byte, 8)
+ binary.NativeEndian.PutUint32(data, 'ē')
+ binary.NativeEndian.PutUint32(data[4:], 'a')
+ write("dev/vcsu2", data)
+ source := &Source{root: root, state: func(path string) (bool, bool, bool, error) {
+ if filepath.Base(path) != "tty2" {
+ t.Fatalf("wrong tty %s", path)
+ }
+ return true, true, true, nil
+ }}
+ snapshot, err := source.Read()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if snapshot.Terminal != 2 || !snapshot.NumLock || !snapshot.CapsLock || string(snapshot.Cells) != "ēa" {
+ t.Fatalf("wrong snapshot: %+v", snapshot)
+ }
+ if len(snapshot.Attributes) != 2 {
+ t.Fatal("source dropped attributes", snapshot.Attributes)
+ }
+ allowedState := source.state
+ source.Configure_ignored_vts([]int{2, 7, 8, 10})
+ source.state = func(string) (bool, bool, bool, error) {
+ t.Fatal("queried ignored console")
+ return true, false, false, nil
+ }
+ if number, active, _, err := source.State(); err != nil || number != 2 || active {
+ t.Fatal("ignored VT still accepts input", number, active, err)
+ }
+ snapshot, err = source.Read()
+ if err != nil || snapshot.Terminal != 2 || snapshot.Active || len(snapshot.Cells) != 0 || len(snapshot.Attributes) != 0 {
+ t.Fatal("ignored VT retained screen data", snapshot, err)
+ }
+ source.Configure_ignored_vts(nil)
+ source.state = allowedState
+ snapshot, err = source.Read()
+ if err != nil || !snapshot.Active || string(snapshot.Cells) != "ēa" || !snapshot.NumLock || !snapshot.CapsLock {
+ t.Fatal("cleared policy did not restore console", snapshot, err)
+ }
+ source.state = func(string) (bool, bool, bool, error) { return false, false, false, nil }
+ if err := os.Remove(filepath.Join(root, "dev/vcsu2")); err != nil {
+ t.Fatal(err)
+ }
+ snapshot, err = source.Read()
+ if err != nil || snapshot.Active || len(snapshot.Cells) != 0 {
+ t.Fatalf("read graphical console text: %+v, %v", snapshot, err)
+ }
+ write("sys/devices/virtual/tty/tty0/active", []byte("../../unsafe"))
+ if _, err := source.Read(); err == nil {
+ t.Fatal("accepted invalid VT name")
+ }
+}
+
+func TestSourceRetriesResizeAndVTChange(t *testing.T) {
+ root := t.TempDir()
+ active := filepath.Join(root, "sys/devices/virtual/tty/tty0/active")
+ if err := os.MkdirAll(filepath.Dir(active), 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(filepath.Join(root, "dev"), 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(active, []byte("tty2\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ for _, name := range []string{"vcsa2", "vcsa3"} {
+ if err := os.WriteFile(filepath.Join(root, "dev", name), []byte{1, 1, 0, 0, 0, 7}, 0600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ for _, name := range []string{"vcsu2", "vcsu3"} {
+ data := make([]byte, 4)
+ binary.NativeEndian.PutUint32(data, 'a')
+ if err := os.WriteFile(filepath.Join(root, "dev", name), data, 0600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ calls := 0
+ source := &Source{root: root, state: func(string) (bool, bool, bool, error) {
+ calls++
+ if calls == 1 {
+ if err := os.WriteFile(active, []byte("tty3\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return true, false, false, nil
+ }}
+ snapshot, err := source.Read()
+ if err != nil || snapshot.Terminal != 3 || calls < 4 {
+ t.Fatalf("did not retry switch: %+v, %v, %d calls", snapshot, err, calls)
+ }
+ if err := os.WriteFile(filepath.Join(root, "dev/vcsu3"), []byte{}, 0600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := source.Read(); err == nil {
+ t.Fatal("accepted inconsistent geometry")
+ }
+}
diff --git a/internal/console/ui_linux.go b/internal/console/ui_linux.go
new file mode 100644
index 00000000..ab63f030
--- /dev/null
+++ b/internal/console/ui_linux.go
@@ -0,0 +1,110 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+//go:build linux && cgo
+
+package console
+
+/*
+#include
+#include
+#include
+#include
+#include
+
+static int ui_key_text(int fd, unsigned int code, int shift, int altgr) {
+ if (code >= NR_KEYS) return 0;
+ unsigned char leds = 0;
+ if (ioctl(fd, KDGKBLED, &leds) < 0) return -errno;
+ struct kbentry entry = {.kb_table = (shift ? 1 << KG_SHIFT : 0) |
+ (altgr ? 1 << KG_ALTGR : 0), .kb_index = code};
+ if (ioctl(fd, KDGKBENT, &entry) < 0) return -errno;
+ if (KTYP(entry.kb_value) == KT_LETTER && (leds & LED_CAP)) {
+ entry.kb_table ^= 1 << KG_SHIFT;
+ if (ioctl(fd, KDGKBENT, &entry) < 0) return -errno;
+ }
+ unsigned int value = entry.kb_value;
+ if (KTYP(value) == KT_LATIN || KTYP(value) == KT_LETTER) return KVAL(value);
+ // The console encodes Unicode keysyms by inverting their high nibble.
+ if (KTYP(value) >= 0x10) return value ^ 0xf000;
+ return 0; // Compose/dead-key sequences are not text by themselves.
+}
+*/
+import "C"
+
+import (
+ "os"
+ "syscall"
+ "unicode"
+
+ "git.stormux.org/storm/fenrir/internal/input"
+)
+
+func (k *Keyboards) Capture(enabled bool) (result error) {
+ if enabled {
+ file, err := os.OpenFile("/dev/tty0", os.O_RDONLY|syscall.O_NOCTTY, 0)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if result != nil {
+ file.Close()
+ }
+ }()
+ // Neutralize terminal-visible modifiers before taking modal ownership.
+ for _, device := range k.devices {
+ for _, code := range k.router.Forwarded(device.fd) {
+ if err := device.forward(1, code, 0); err != nil {
+ return err
+ }
+ }
+ if err := device.forward(0, 0, 0); err != nil {
+ return err
+ }
+ }
+ if k.uiMap != nil {
+ k.uiMap.Close()
+ }
+ k.uiMap = file
+ } else if k.uiMap != nil {
+ k.uiMap.Close()
+ k.uiMap = nil
+ }
+ k.router.Capture(enabled)
+ return nil
+}
+
+func (k *Keyboards) ui_text(code uint16, decision input.Decision) error {
+ if decision.UI == nil || decision.UI.Name != "" || k.uiMap == nil {
+ return nil
+ }
+ shift, altGr := C.int(0), C.int(0)
+ if decision.UI.Shift {
+ shift = 1
+ }
+ if decision.UI.AltGr {
+ altGr = 1
+ }
+ value := int(C.ui_key_text(C.int(k.uiMap.Fd()), C.uint(code), shift, altGr))
+ if value < 0 {
+ return syscall.Errno(-value)
+ }
+ if value > 0 && unicode.IsPrint(rune(value)) {
+ decision.UI.Text = string(rune(value))
+ }
+ return nil
+}
diff --git a/internal/console/unicode_linux_test.go b/internal/console/unicode_linux_test.go
new file mode 100644
index 00000000..f43e03c6
--- /dev/null
+++ b/internal/console/unicode_linux_test.go
@@ -0,0 +1,72 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+//go:build linux && cgo
+
+package console
+
+import (
+ "bytes"
+ "errors"
+ "io"
+ "testing"
+)
+
+type unicodeReader struct {
+ *bytes.Reader
+ requests []int
+ limit int
+}
+
+func (r *unicodeReader) Read(data []byte) (int, error) {
+ r.requests = append(r.requests, len(data))
+ if r.limit > 0 && len(data) > r.limit {
+ data = data[:r.limit]
+ }
+ return r.Reader.Read(data)
+}
+
+func TestUnicodeReadBoundedAlignedAndComplete(t *testing.T) {
+ const cells = 80 * 25
+ for _, size := range []int{0, 4, cells*4 - 4, cells * 4, cells*4 + 4, cells * 8} {
+ for _, limit := range []int{0, 128} {
+ reader := &unicodeReader{Reader: bytes.NewReader(make([]byte, size)), limit: limit}
+ data, err := read_unicode(reader, cells)
+ if err != nil || len(data) != min(size, (cells+1)*4) {
+ t.Fatalf("size %d limit %d: got %d bytes, %v", size, limit, len(data), err)
+ }
+ for _, request := range reader.requests {
+ if request%4 != 0 {
+ t.Fatalf("unaligned request: %d", request)
+ }
+ }
+ if limit == 0 && len(reader.requests) > 2 {
+ t.Fatalf("unnecessary reads: %v", reader.requests)
+ }
+ }
+ }
+}
+
+type failedReader struct{}
+
+func (failedReader) Read([]byte) (int, error) { return 0, io.ErrClosedPipe }
+
+func TestUnicodeReadPreservesIOError(t *testing.T) {
+ if _, err := read_unicode(failedReader{}, 80*25); !errors.Is(err, io.ErrClosedPipe) {
+ t.Fatalf("lost I/O error: %v", err)
+ }
+}
diff --git a/internal/control/coexistence_linux.go b/internal/control/coexistence_linux.go
new file mode 100644
index 00000000..0a3f568e
--- /dev/null
+++ b/internal/control/coexistence_linux.go
@@ -0,0 +1,112 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package control
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "slices"
+)
+
+func (s *Server) endpoint_snapshot() []endpoint {
+ s.endpointsMu.RLock()
+ defer s.endpointsMu.RUnlock()
+ return slices.Clone(s.endpoints)
+}
+
+// Every default instance has a private endpoint. The first available instance
+// also serves the shared path, and a survivor claims it after that owner exits.
+func (s *Server) claim_main() bool {
+ if s.options.SocketFile != "" || s.ctx.Err() != nil {
+ return false
+ }
+ s.endpointsMu.Lock()
+ defer s.endpointsMu.Unlock()
+ for _, endpoint := range s.endpoints {
+ if endpoint.path == s.main_path() {
+ return false
+ }
+ }
+ endpoint, err := listen(s.main_path())
+ if err != nil {
+ return false // Active readers and unrelated paths are preserved.
+ }
+ if s.ctx.Err() != nil {
+ endpoint.listener.Close()
+ if info, err := os.Lstat(endpoint.path); err == nil && os.SameFile(info, endpoint.info) {
+ _ = os.Remove(endpoint.path)
+ }
+ return false
+ }
+ s.endpoints = append(s.endpoints, endpoint)
+ s.workers.Go(func() { s.accept(endpoint) })
+ return true
+}
+
+func (s *Server) private_path(data instance) string {
+ path := filepath.Join(filepath.Dir(s.main_path()), fmt.Sprintf("fenrirscreenreader-%d.sock", data.PID))
+ if slices.Contains(data.SocketFiles, path) {
+ return path
+ }
+ return ""
+}
+
+// Select a terminal only when it is an ancestor of the caller. Outside a
+// terminal instance, prefer TTY; a single same-user terminal is unambiguous.
+// Never silently send a command to an arbitrary terminal in a multi-reader session.
+func (s *Server) route_target(pid, uid int) (string, error) {
+ instances := s.instances()
+ var terminals, consoles []instance
+ for _, data := range instances {
+ if s.private_path(data) == "" {
+ continue
+ }
+ if data.ScreenDriver == "vcsaDriver" && (data.uid == 0 || data.uid == uid) {
+ consoles = append(consoles, data)
+ } else if data.ScreenDriver == "ptyDriver" && (data.uid == uid || uid == 0) {
+ terminals = append(terminals, data)
+ }
+ }
+ seen := map[int]bool{}
+ for ancestor := pid; ancestor > 1 && !seen[ancestor]; ancestor = parent_pid(ancestor) {
+ seen[ancestor] = true
+ for _, data := range terminals {
+ if data.PID == ancestor {
+ return s.destination(data)
+ }
+ }
+ for _, data := range consoles {
+ if data.PID == ancestor {
+ return s.destination(data)
+ }
+ }
+ }
+ if len(consoles) == 1 {
+ return s.destination(consoles[0])
+ }
+ if len(consoles) == 0 && len(terminals) == 1 {
+ return s.destination(terminals[0])
+ }
+ if len(consoles) == 0 && len(terminals) == 0 {
+ // The application applies its normal authorization to the shared owner.
+ return "", nil
+ }
+ return "", fmt.Errorf("caller does not identify a unique reader; use its private socket")
+}
+
+func (s *Server) destination(data instance) (string, error) {
+ if data.PID == os.Getpid() {
+ return "", nil
+ }
+ if !data.ConnectionHandoff {
+ return "", fmt.Errorf("reader %d cannot accept a connection handoff", data.PID)
+ }
+ path := s.private_path(data)
+ info, err := os.Lstat(path)
+ if err != nil || info.Mode()&os.ModeSocket == 0 {
+ return "", fmt.Errorf("reader %d private socket is unavailable", data.PID)
+ }
+ return path, nil
+}
diff --git a/internal/control/coexistence_test.go b/internal/control/coexistence_test.go
new file mode 100644
index 00000000..24b6abc4
--- /dev/null
+++ b/internal/control/coexistence_test.go
@@ -0,0 +1,330 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package control
+
+import (
+ "bufio"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+type processCommand struct {
+ Path, Text string
+ Stop bool
+}
+
+type processReply struct {
+ PID int
+ Response string
+ Error string
+}
+
+type requestReceipt struct {
+ InstancePID, CallerPID, CallerUID int
+ Text string
+}
+
+// Each fixture reader runs in a separate process so PID endpoints, ancestry,
+// registry ownership and descriptor credentials are exercised through the OS.
+func TestControlProcess(t *testing.T) {
+ mode := os.Getenv("FENRIR_CONTROL_PROCESS")
+ if mode == "" {
+ return
+ }
+ if mode == "client" {
+ reply := processReply{PID: os.Getpid()}
+ connection, err := net.DialTimeout("unix", os.Getenv("FENRIR_CONTROL_PATH"), time.Second)
+ if err == nil {
+ defer connection.Close()
+ _ = connection.SetDeadline(time.Now().Add(3 * time.Second))
+ _, err = io.WriteString(connection, os.Getenv("FENRIR_CONTROL_TEXT")+"\n")
+ if err == nil {
+ data, readErr := io.ReadAll(connection)
+ reply.Response, err = string(data), readErr
+ }
+ }
+ if err != nil {
+ reply.Error = err.Error()
+ }
+ _ = json.NewEncoder(os.Stdout).Encode(reply)
+ os.Exit(0)
+ }
+ run_reader_process(mode)
+ os.Exit(0)
+}
+
+func child_command(mode string) *exec.Cmd {
+ command := exec.Command(os.Args[0], "-test.run=^TestControlProcess$")
+ command.Env = append(os.Environ(), "FENRIR_CONTROL_PROCESS="+mode)
+ return command
+}
+
+func run_reader_process(mode string) {
+ encoder := json.NewEncoder(os.Stdout)
+ directory := os.Getenv("FENRIR_CONTROL_DIRECTORY")
+ server, err := Open(context.Background(), Options{Directory: directory, RegistryDirectory: filepath.Join(directory, "registry"), TerminalMode: mode == "terminal"})
+ if err != nil {
+ _ = encoder.Encode(processReply{Error: err.Error()})
+ return
+ }
+ defer server.Close()
+ go func() {
+ for {
+ select {
+ case request := <-server.Requests():
+ data, _ := json.Marshal(requestReceipt{os.Getpid(), request.PID, request.UID, request.Text})
+ request.Reply <- string(data)
+ case <-server.ctx.Done():
+ return
+ }
+ }
+ }()
+ _ = encoder.Encode(processReply{PID: os.Getpid()})
+ scanner := bufio.NewScanner(os.Stdin)
+ for scanner.Scan() {
+ var request processCommand
+ if json.Unmarshal(scanner.Bytes(), &request) != nil || request.Stop {
+ return
+ }
+ command := child_command("client")
+ command.Env = append(command.Env, "FENRIR_CONTROL_PATH="+request.Path, "FENRIR_CONTROL_TEXT="+request.Text)
+ data, err := command.Output()
+ var reply processReply
+ if err != nil {
+ reply.Error = err.Error()
+ } else if err := json.Unmarshal(data, &reply); err != nil {
+ reply.Error = err.Error()
+ }
+ _ = encoder.Encode(reply)
+ }
+}
+
+type readerProcess struct {
+ command *exec.Cmd
+ input *json.Encoder
+ output *json.Decoder
+ pid int
+ stopped bool
+}
+
+func start_reader_process(t *testing.T, directory, mode string) *readerProcess {
+ t.Helper()
+ command := child_command(mode)
+ command.Env = append(command.Env, "FENRIR_CONTROL_DIRECTORY="+directory)
+ command.Stderr = os.Stderr
+ input, err := command.StdinPipe()
+ if err != nil {
+ t.Fatal(err)
+ }
+ output, err := command.StdoutPipe()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := command.Start(); err != nil {
+ t.Fatal(err)
+ }
+ process := &readerProcess{command: command, input: json.NewEncoder(input), output: json.NewDecoder(output)}
+ t.Cleanup(func() { process.stop(t) })
+ var ready processReply
+ if err := process.output.Decode(&ready); err != nil || ready.Error != "" {
+ t.Fatal("reader startup", ready, err)
+ }
+ process.pid = ready.PID
+ return process
+}
+
+func (p *readerProcess) stop(t *testing.T) {
+ t.Helper()
+ if p.stopped {
+ return
+ }
+ p.stopped = true
+ _ = p.input.Encode(processCommand{Stop: true})
+ done := make(chan error, 1)
+ go func() { done <- p.command.Wait() }()
+ select {
+ case err := <-done:
+ if err != nil {
+ t.Error("reader exit", err)
+ }
+ case <-time.After(5 * time.Second):
+ _ = p.command.Process.Kill()
+ <-done
+ t.Error("reader shutdown blocked")
+ }
+}
+
+func (p *readerProcess) send(t *testing.T, path, text string) processReply {
+ t.Helper()
+ if err := p.input.Encode(processCommand{Path: path, Text: text}); err != nil {
+ t.Fatal(err)
+ }
+ var reply processReply
+ if err := p.output.Decode(&reply); err != nil || reply.Error != "" {
+ t.Fatal("remote request", reply, err)
+ }
+ return reply
+}
+
+func check_receipt(t *testing.T, reply processReply, instancePID int, text string) {
+ t.Helper()
+ var receipt requestReceipt
+ if err := json.Unmarshal([]byte(reply.Response), &receipt); err != nil {
+ t.Fatal("invalid remote reply", reply, err)
+ }
+ if receipt.InstancePID != instancePID || receipt.CallerPID != reply.PID || receipt.CallerUID != os.Geteuid() || receipt.Text != text {
+ t.Fatalf("receipt=%+v; want instance=%d caller=%d uid=%d text=%q", receipt, instancePID, reply.PID, os.Geteuid(), text)
+ }
+}
+
+func wait_main_owner(t *testing.T, observer *Server, expectedCount int, excludedPID int) int {
+ t.Helper()
+ deadline := time.Now().Add(5 * time.Second)
+ for time.Now().Before(deadline) {
+ instances := observer.instances()
+ owners, owner := 0, 0
+ for _, data := range instances {
+ if data.MainSocket {
+ owners++
+ owner = data.PID
+ }
+ }
+ if len(instances) == expectedCount && owners == 1 && owner != excludedPID {
+ return owner
+ }
+ time.Sleep(20 * time.Millisecond)
+ }
+ t.Fatal("shared endpoint ownership did not settle", observer.instances())
+ return 0
+}
+
+func TestReadersCoexistInBothStartupOrdersAndSurviveOwnerExit(t *testing.T) {
+ for _, order := range []string{"tty-first", "terminal-first"} {
+ t.Run(order, func(t *testing.T) {
+ options := private_options(t)
+ observer := &Server{ctx: context.Background(), options: options}
+ mainPath := observer.main_path()
+ var tty, first, second, owner *readerProcess
+ if order == "tty-first" {
+ tty = start_reader_process(t, options.Directory, "tty")
+ owner = tty
+ first = start_reader_process(t, options.Directory, "terminal")
+ } else {
+ first = start_reader_process(t, options.Directory, "terminal")
+ owner = first
+ tty = start_reader_process(t, options.Directory, "tty")
+ }
+ second = start_reader_process(t, options.Directory, "terminal")
+ if got := wait_main_owner(t, observer, 3, 0); got != owner.pid {
+ t.Fatal("startup replaced the shared owner", got, owner.pid)
+ }
+ listing := first.send(t, mainPath, "COMMAND LS").Response
+ for _, reader := range []*readerProcess{tty, first, second} {
+ if !strings.Contains(listing, fmt.Sprintf("pid=%d ", reader.pid)) {
+ t.Fatal("missing reader", reader.pid, listing)
+ }
+ }
+ for _, reader := range []*readerProcess{tty, first, second} {
+ check_receipt(t, reader.send(t, mainPath, "COMMAND SAY shared"), reader.pid, "COMMAND SAY shared")
+ privatePath := filepath.Join(options.Directory, fmt.Sprintf("fenrirscreenreader-%d.sock", reader.pid))
+ check_receipt(t, second.send(t, privatePath, "COMMAND SAY private"), reader.pid, "COMMAND SAY private")
+ }
+ // No descendant terminal: a legacy caller belongs to the TTY reader.
+ client := child_command("client")
+ client.Env = append(client.Env, "FENRIR_CONTROL_PATH="+mainPath, "FENRIR_CONTROL_TEXT=COMMAND SAY legacy")
+ data, err := client.Output()
+ if err != nil {
+ t.Fatal(err)
+ }
+ var reply processReply
+ if err := json.Unmarshal(data, &reply); err != nil {
+ t.Fatal(err)
+ }
+ check_receipt(t, reply, tty.pid, "COMMAND SAY legacy")
+ owner.stop(t)
+ wait_main_owner(t, observer, 2, owner.pid)
+ for _, reader := range []*readerProcess{tty, first, second} {
+ if reader != owner {
+ check_receipt(t, reader.send(t, mainPath, "COMMAND SAY survivor"), reader.pid, "COMMAND SAY survivor")
+ }
+ }
+ })
+ }
+}
+
+func external_request(t *testing.T, path, text string) processReply {
+ t.Helper()
+ client := child_command("client")
+ client.Env = append(client.Env, "FENRIR_CONTROL_PATH="+path, "FENRIR_CONTROL_TEXT="+text)
+ data, err := client.Output()
+ if err != nil {
+ t.Fatal(err)
+ }
+ var reply processReply
+ if err := json.Unmarshal(data, &reply); err != nil || reply.Error != "" {
+ t.Fatal(reply, err)
+ }
+ return reply
+}
+
+func TestTerminalReadersRejectAmbiguityAndRecoverAfterOwnerFailure(t *testing.T) {
+ options := private_options(t)
+ observer := &Server{ctx: context.Background(), options: options}
+ mainPath := observer.main_path()
+ first := start_reader_process(t, options.Directory, "terminal")
+ second := start_reader_process(t, options.Directory, "terminal")
+ third := start_reader_process(t, options.Directory, "terminal")
+ wait_main_owner(t, observer, 3, 0)
+ for _, reader := range []*readerProcess{first, second, third} {
+ check_receipt(t, reader.send(t, mainPath, "COMMAND SAY terminal"), reader.pid, "COMMAND SAY terminal")
+ }
+ if reply := external_request(t, mainPath, "COMMAND SAY ambiguous"); !strings.Contains(reply.Response, "unique reader") {
+ t.Fatal("arbitrary terminal selected", reply)
+ }
+ if reply := external_request(t, mainPath, "LIST"); strings.Count(reply.Response, "pid=") != 6 {
+ // Each record contains both pid= and ppid=.
+ t.Fatal("ambiguous caller could not discover instances", reply)
+ }
+ // Registration failure after binding a new private socket must preserve
+ // the active shared endpoint and every existing registry record.
+ before, err := os.Stat(mainPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ broken := options
+ broken.RegistryDirectory = filepath.Join(options.Directory, "registry-file")
+ if err := os.WriteFile(broken.RegistryDirectory, []byte("preserve"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if server, err := Open(context.Background(), broken); err == nil {
+ server.Close()
+ t.Fatal("accepted a registry file")
+ }
+ privatePath := filepath.Join(options.Directory, fmt.Sprintf("fenrirscreenreader-%d.sock", os.Getpid()))
+ if _, err := os.Lstat(privatePath); !os.IsNotExist(err) {
+ t.Fatal("failed startup leaked a private socket", err)
+ }
+ after, err := os.Stat(mainPath)
+ if err != nil || !os.SameFile(before, after) || len(observer.instances()) != 3 {
+ t.Fatal("failed startup disturbed active endpoints", err)
+ }
+ first.stopped = true
+ if err := first.command.Process.Kill(); err != nil {
+ t.Fatal(err)
+ }
+ _ = first.command.Wait()
+ wait_main_owner(t, observer, 2, first.pid)
+ for _, reader := range []*readerProcess{second, third} {
+ check_receipt(t, reader.send(t, mainPath, "COMMAND SAY recovered"), reader.pid, "COMMAND SAY recovered")
+ }
+}
diff --git a/internal/control/handoff_linux.go b/internal/control/handoff_linux.go
new file mode 100644
index 00000000..4b4e92d0
--- /dev/null
+++ b/internal/control/handoff_linux.go
@@ -0,0 +1,113 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package control
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "net"
+ "os"
+ "path/filepath"
+ "syscall"
+ "time"
+)
+
+const handoffMarker = "FENRIR-CONNECTION\n"
+
+// Transfer the original connected socket, rather than forwarding its text.
+// SO_PEERCRED and replies then remain attached to the actual caller, including
+// when an unprivileged shared owner routes a request to a root TTY reader.
+func handoff(caller *net.UnixConn, path string) error {
+ connection, err := net.DialTimeout("unix", path, 200*time.Millisecond)
+ if err != nil {
+ return err
+ }
+ defer connection.Close()
+ target := connection.(*net.UnixConn)
+ var expectedPID int
+ _, parseErr := fmt.Sscanf(filepath.Base(path), "fenrirscreenreader-%d.sock", &expectedPID)
+ pid, uid, credentialErr := peer(target)
+ info, statErr := os.Lstat(path)
+ if parseErr != nil || credentialErr != nil || statErr != nil || pid != expectedPID {
+ return fmt.Errorf("private reader identity changed")
+ }
+ if stat, ok := info.Sys().(*syscall.Stat_t); !ok || int(stat.Uid) != uid {
+ return fmt.Errorf("private reader ownership changed")
+ }
+ _ = target.SetDeadline(time.Now().Add(500 * time.Millisecond))
+ raw, err := caller.SyscallConn()
+ if err != nil {
+ return err
+ }
+ var sendErr error
+ err = raw.Control(func(fd uintptr) {
+ count, _, err := target.WriteMsgUnix([]byte(handoffMarker), syscall.UnixRights(int(fd)), nil)
+ sendErr = err
+ if sendErr == nil && count < len(handoffMarker) {
+ _, sendErr = io.WriteString(target, handoffMarker[count:])
+ }
+ })
+ if err != nil {
+ return err
+ }
+ return sendErr
+}
+
+func receive_request(connection *net.UnixConn) (string, *net.UnixConn, error) {
+ _ = connection.SetReadDeadline(time.Now().Add(250 * time.Millisecond))
+ var buffer [8192]byte
+ control := make([]byte, syscall.CmsgSpace(4))
+ count, controlCount, flags, _, err := connection.ReadMsgUnix(buffer[:], control)
+ if err != nil {
+ return "", nil, err
+ }
+ var files []int
+ messages, parseErr := syscall.ParseSocketControlMessage(control[:controlCount])
+ for _, message := range messages {
+ fds, err := syscall.ParseUnixRights(&message)
+ if err != nil {
+ parseErr = err
+ }
+ for _, fd := range fds {
+ syscall.CloseOnExec(fd)
+ }
+ files = append(files, fds...)
+ }
+ defer func() {
+ for _, fd := range files {
+ _ = syscall.Close(fd)
+ }
+ }()
+ if parseErr != nil || flags&(syscall.MSG_CTRUNC|syscall.MSG_TRUNC) != 0 {
+ return "", nil, fmt.Errorf("invalid connection handoff")
+ }
+ if len(files) > 0 {
+ if len(files) != 1 {
+ return "", nil, fmt.Errorf("invalid connection handoff")
+ }
+ frame := io.MultiReader(bytes.NewReader(buffer[:count]), connection)
+ header := make([]byte, len(handoffMarker))
+ if _, err := io.ReadFull(frame, header); err != nil || string(header) != handoffMarker {
+ return "", nil, fmt.Errorf("invalid connection handoff header")
+ }
+ // FileConn duplicates the descriptor. The received descriptor is closed
+ // below; the duplicate belongs to the request handler.
+ file := os.NewFile(uintptr(files[0]), "remote-caller")
+ files = nil
+ forwarded, err := net.FileConn(file)
+ file.Close()
+ if err != nil {
+ return "", nil, err
+ }
+ unix, ok := forwarded.(*net.UnixConn)
+ if !ok || forwarded.LocalAddr().Network() != "unix" {
+ forwarded.Close()
+ return "", nil, fmt.Errorf("handoff is not a Unix connection")
+ }
+ return "", unix, nil
+ }
+ text, err := read_request(connection, buffer[:count])
+ return text, nil, err
+}
diff --git a/internal/control/handoff_test.go b/internal/control/handoff_test.go
new file mode 100644
index 00000000..d96cfe3e
--- /dev/null
+++ b/internal/control/handoff_test.go
@@ -0,0 +1,87 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package control
+
+import (
+ "io"
+ "net"
+ "os"
+ "path/filepath"
+ "syscall"
+ "testing"
+)
+
+func socket_pair(t *testing.T, path string) (*net.UnixConn, *net.UnixConn) {
+ t.Helper()
+ listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: path, Net: "unix"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { listener.Close() })
+ client := connect_remote(t, path)
+ server, err := listener.AcceptUnix()
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { server.Close() })
+ return client, server
+}
+
+func TestHandoffReadsOriginalCallerBytesAndRejectsInvalidDescriptors(t *testing.T) {
+ options := private_options(t)
+ caller, original := socket_pair(t, filepath.Join(options.Directory, "caller.sock"))
+ if _, err := io.WriteString(caller, "COMMAND SAY original\n"); err != nil {
+ t.Fatal(err)
+ }
+ file, err := original.File()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer file.Close()
+ ordinary, err := os.CreateTemp(options.Directory, "ordinary-")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer ordinary.Close()
+ for _, trial := range []struct {
+ name, header string
+ fds []int
+ valid bool
+ }{
+ {"original", handoffMarker + "COMMAND SAY substituted\n", []int{int(file.Fd())}, true},
+ {"file", handoffMarker, []int{int(ordinary.Fd())}, false},
+ {"multiple", handoffMarker, []int{int(file.Fd()), int(file.Fd())}, false},
+ {"header", "INVALID\n", []int{int(file.Fd())}, false},
+ } {
+ t.Run(trial.name, func(t *testing.T) {
+ sender, receiver := socket_pair(t, filepath.Join(options.Directory, trial.name+".sock"))
+ if _, _, err := sender.WriteMsgUnix([]byte(trial.header), syscall.UnixRights(trial.fds...), nil); err != nil {
+ t.Fatal(err)
+ }
+ _ = sender.CloseWrite()
+ _, forwarded, err := receive_request(receiver)
+ if !trial.valid {
+ if forwarded != nil {
+ forwarded.Close()
+ }
+ if err == nil {
+ t.Fatal("accepted malformed handoff")
+ }
+ return
+ }
+ if err != nil || forwarded == nil {
+ t.Fatal("valid handoff rejected", err)
+ }
+ defer forwarded.Close()
+ pid, uid, err := peer(forwarded)
+ if err != nil || pid != os.Getpid() || uid != os.Geteuid() {
+ t.Fatal("caller credentials changed", pid, uid, err)
+ }
+ text, err := read_request(forwarded, nil)
+ if err != nil || text != "COMMAND SAY original" {
+ t.Fatal("handoff substituted caller bytes", text, err)
+ }
+ })
+ }
+}
diff --git a/internal/control/registry_linux.go b/internal/control/registry_linux.go
new file mode 100644
index 00000000..ba4c3975
--- /dev/null
+++ b/internal/control/registry_linux.go
@@ -0,0 +1,203 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package control
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+ "syscall"
+ "time"
+)
+
+type instance struct {
+ PID int `json:"pid"`
+ ConnectionHandoff bool `json:"connection_handoff,omitempty"`
+ PPID int `json:"ppid"`
+ SocketFiles []string `json:"socket_files"`
+ MainSocket bool `json:"main_socket"`
+ ScreenDriver string `json:"screen_driver"`
+ KeyboardDriver string `json:"keyboard_driver"`
+ WindowID string `json:"x11_window_id"`
+ CreatedAt float64 `json:"created_at"`
+ UpdatedAt float64 `json:"updated_at"`
+ uid int
+}
+
+func (s *Server) registry_dir() string {
+ if s.options.RegistryDirectory != "" {
+ return s.options.RegistryDirectory
+ }
+ return filepath.Join(os.TempDir(), "fenrirscreenreader-instances")
+}
+
+func (s *Server) register() error {
+ dir := s.registry_dir()
+ if err := os.Mkdir(dir, os.ModeSticky|0777); err != nil && !os.IsExist(err) {
+ return err
+ }
+ info, err := os.Lstat(dir)
+ if err != nil {
+ return err
+ }
+ if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
+ return fmt.Errorf("invalid instance registry %s", dir)
+ }
+ // Only change permissions on a directory created/owned by this reader's UID.
+ if stat, ok := info.Sys().(*syscall.Stat_t); ok && int(stat.Uid) == os.Geteuid() {
+ if err := os.Chmod(dir, os.ModeSticky|0777); err != nil {
+ return err
+ }
+ info, err = os.Lstat(dir)
+ if err != nil {
+ return err
+ }
+ }
+ if info.Mode()&os.ModeSticky == 0 && info.Mode().Perm()&0022 != 0 {
+ return fmt.Errorf("instance registry lacks sticky permission: %s", dir)
+ }
+ now := float64(time.Now().UnixNano()) / 1e9
+ data := instance{ConnectionHandoff: true, PID: os.Getpid(), PPID: os.Getppid(), CreatedAt: now, UpdatedAt: now, ScreenDriver: "vcsaDriver", KeyboardDriver: "evdevDriver"}
+ if s.options.TerminalMode {
+ data.ScreenDriver, data.KeyboardDriver, data.WindowID = "ptyDriver", "x11Driver", s.options.WindowID
+ }
+ for _, endpoint := range s.endpoint_snapshot() {
+ data.SocketFiles = append(data.SocketFiles, endpoint.path)
+ data.MainSocket = data.MainSocket || endpoint.path == s.main_path()
+ }
+ encoded, err := json.Marshal(data)
+ if err != nil {
+ return err
+ }
+ file, err := os.CreateTemp(dir, ".instance-*")
+ if err != nil {
+ return err
+ }
+ defer os.Remove(file.Name())
+ if _, err := file.Write(append(encoded, '\n')); err != nil {
+ file.Close()
+ return err
+ }
+ if err := file.Chmod(0644); err != nil {
+ file.Close()
+ return err
+ }
+ if err := file.Close(); err != nil {
+ return err
+ }
+ path := filepath.Join(dir, strconv.Itoa(os.Getpid())+".json")
+ if err := os.Rename(file.Name(), path); err != nil {
+ return err
+ }
+ s.registryPath = path
+ return nil
+}
+
+func (s *Server) refresh_registry() {
+ ticker := time.NewTicker(200 * time.Millisecond)
+ lastRefresh := time.Now()
+ defer ticker.Stop()
+ for {
+ select {
+ case <-s.ctx.Done():
+ return
+ case <-ticker.C:
+ if s.claim_main() || time.Since(lastRefresh) >= 10*time.Second {
+ _ = s.register()
+ lastRefresh = time.Now()
+ }
+ }
+ }
+}
+
+func (s *Server) instances() []instance {
+ entries, _ := os.ReadDir(s.registry_dir())
+ var instances []instance
+ for _, entry := range entries {
+ if s.ctx.Err() != nil {
+ break
+ }
+ if !strings.HasSuffix(entry.Name(), ".json") {
+ continue
+ }
+ path := filepath.Join(s.registry_dir(), entry.Name())
+ file, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
+ if err != nil {
+ continue
+ }
+ info, err := file.Stat()
+ if err != nil || !info.Mode().IsRegular() {
+ file.Close()
+ continue
+ }
+ var data instance
+ err = json.NewDecoder(io.LimitReader(file, 16384)).Decode(&data)
+ file.Close()
+ stat, ok := info.Sys().(*syscall.Stat_t)
+ if err != nil || !ok || data.PID <= 1 || entry.Name() != strconv.Itoa(data.PID)+".json" || time.Since(time.Unix(0, int64(data.UpdatedAt*1e9))) > 30*time.Second {
+ continue
+ }
+ if err := syscall.Kill(data.PID, 0); err != nil && err != syscall.EPERM {
+ continue
+ }
+ data.uid = int(stat.Uid)
+ instances = append(instances, data)
+ }
+ sort.Slice(instances, func(a, b int) bool { return instances[a].PID < instances[b].PID })
+ return instances
+}
+
+func (s *Server) list() string {
+ var lines []string
+ for _, data := range s.instances() {
+ window, main := data.WindowID, "no"
+ if window == "" {
+ window = "none"
+ }
+ if data.MainSocket {
+ main = "yes"
+ }
+ lines = append(lines, fmt.Sprintf("pid=%d ppid=%d screen=%s keyboard=%s main_socket=%s x11_window_id=%s sockets=%s", data.PID, data.PPID, data.ScreenDriver, data.KeyboardDriver, main, window, strings.Join(data.SocketFiles, ", ")))
+ }
+ if len(lines) == 0 {
+ return "No Fenrir instances registered"
+ }
+ return strings.Join(lines, "\n")
+}
+
+func parent_pid(pid int) int {
+ data, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid))
+ if err != nil {
+ return 0
+ }
+ end := strings.LastIndexByte(string(data), ')')
+ if end < 0 {
+ return 0
+ }
+ fields := strings.Fields(string(data[end+1:]))
+ if len(fields) < 2 {
+ return 0
+ }
+ parent, _ := strconv.Atoi(fields[1])
+ return parent
+}
diff --git a/internal/control/server_linux.go b/internal/control/server_linux.go
new file mode 100644
index 00000000..0ea0cb14
--- /dev/null
+++ b/internal/control/server_linux.go
@@ -0,0 +1,330 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+// Package control serves Python Fenrir's Unix-stream self-voicing protocol.
+// Socket I/O and instance routing run outside the reader's state owner.
+package control
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "syscall"
+ "time"
+ "unicode/utf8"
+)
+
+const MainSocket = "/tmp/fenrirscreenreader-daemon.sock"
+
+type Options struct {
+ SocketFile string
+ TerminalMode bool
+ WindowID string
+ // Tests use a private registry and socket directory. Production leaves both
+ // empty to retain Python's existing paths and registry format.
+ Directory, RegistryDirectory string
+}
+
+type Request struct {
+ Text string
+ PID, UID int
+ Reply chan string
+}
+
+type endpoint struct {
+ listener *net.UnixListener
+ path string
+ info os.FileInfo
+}
+
+type Server struct {
+ ctx context.Context
+ cancel context.CancelFunc
+ options Options
+ endpointsMu sync.RWMutex
+ endpoints []endpoint
+ requests chan Request
+ workers sync.WaitGroup
+ clients chan struct{}
+ registryPath string
+ stopOnce sync.Once
+ done chan struct{}
+}
+
+func (s *Server) main_path() string {
+ if s.options.Directory != "" {
+ return filepath.Join(s.options.Directory, filepath.Base(MainSocket))
+ }
+ return MainSocket
+}
+
+func Open(ctx context.Context, options Options) (*Server, error) {
+ ctx, cancel := context.WithCancel(ctx)
+ s := &Server{ctx: ctx, cancel: cancel, options: options, requests: make(chan Request, 32), clients: make(chan struct{}, 8), done: make(chan struct{})}
+ paths := []string{filepath.Join(filepath.Dir(s.main_path()), fmt.Sprintf("fenrirscreenreader-%d.sock", os.Getpid())), s.main_path()}
+ if options.SocketFile != "" {
+ paths = []string{options.SocketFile}
+ }
+ for index, path := range paths {
+ endpoint, err := listen(path)
+ if err != nil {
+ if options.SocketFile == "" && index == 1 && errors.Is(err, errSocketBusy) {
+ continue
+ }
+ s.Close()
+ return nil, err
+ }
+ s.endpoints = append(s.endpoints, endpoint)
+ }
+ if err := s.register(); err != nil {
+ s.Close()
+ return nil, err
+ }
+ for _, endpoint := range s.endpoints {
+ s.workers.Go(func() { s.accept(endpoint) })
+ }
+ s.workers.Go(s.refresh_registry)
+ return s, nil
+}
+
+var errSocketBusy = errors.New("another reader owns remote socket")
+
+// Never unlink an active Python/Go reader, a symlink, or an unrelated file.
+func listen(path string) (endpoint, error) {
+ if info, err := os.Lstat(path); err == nil {
+ if info.Mode()&os.ModeSocket == 0 {
+ return endpoint{}, fmt.Errorf("remote path is not a socket: %s", path)
+ }
+ connection, dialErr := net.DialTimeout("unix", path, 100*time.Millisecond)
+ if dialErr == nil {
+ connection.Close()
+ return endpoint{}, fmt.Errorf("%w %s", errSocketBusy, path)
+ }
+ if errors.Is(dialErr, syscall.EACCES) {
+ return endpoint{}, fmt.Errorf("%w %s: %v", errSocketBusy, path, dialErr)
+ }
+ if !errors.Is(dialErr, syscall.ECONNREFUSED) {
+ return endpoint{}, fmt.Errorf("cannot establish remote socket is stale: %s: %w", path, dialErr)
+ }
+ if stat, ok := info.Sys().(*syscall.Stat_t); !ok || int(stat.Uid) != os.Geteuid() {
+ return endpoint{}, fmt.Errorf("stale socket belongs to another user: %s", path)
+ }
+ current, err := os.Lstat(path)
+ if err != nil || !os.SameFile(info, current) {
+ return endpoint{}, fmt.Errorf("remote socket changed while probing: %s", path)
+ }
+ if err := os.Remove(path); err != nil {
+ return endpoint{}, err
+ }
+ } else if !os.IsNotExist(err) {
+ return endpoint{}, err
+ }
+ listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: path, Net: "unix"})
+ if err != nil {
+ // Another starter may bind between our Lstat and ListenUnix.
+ if errors.Is(err, syscall.EADDRINUSE) {
+ if info, statErr := os.Lstat(path); statErr == nil && info.Mode()&os.ModeSocket != 0 {
+ return endpoint{}, fmt.Errorf("%w %s", errSocketBusy, path)
+ }
+ }
+ return endpoint{}, err
+ }
+ listener.SetUnlinkOnClose(false)
+ // Keep existing unprivileged socat callers working. Peer credentials are
+ // checked by the application before any request may mutate reader state.
+ if err := os.Chmod(path, 0666); err != nil {
+ listener.Close()
+ os.Remove(path)
+ return endpoint{}, err
+ }
+ info, err := os.Lstat(path)
+ if err != nil {
+ listener.Close()
+ return endpoint{}, err
+ }
+ return endpoint{listener: listener, path: path, info: info}, nil
+}
+
+func (s *Server) Requests() <-chan Request { return s.requests }
+
+func (s *Server) Close() {
+ s.stopOnce.Do(func() {
+ s.cancel()
+ s.endpointsMu.Lock()
+ for _, endpoint := range s.endpoints {
+ endpoint.listener.Close()
+ }
+ s.endpointsMu.Unlock()
+ go func() {
+ s.workers.Wait()
+ for _, endpoint := range s.endpoints {
+ if info, err := os.Lstat(endpoint.path); err == nil && os.SameFile(info, endpoint.info) {
+ _ = os.Remove(endpoint.path)
+ }
+ }
+ if s.registryPath != "" {
+ _ = os.Remove(s.registryPath)
+ }
+ close(s.done)
+ }()
+ })
+ select {
+ case <-s.done:
+ case <-time.After(time.Second):
+ }
+}
+
+func (s *Server) accept(endpoint endpoint) {
+ for {
+ connection, err := endpoint.listener.AcceptUnix()
+ if err != nil {
+ return
+ }
+ select {
+ case s.clients <- struct{}{}:
+ s.workers.Go(func() { defer func() { <-s.clients; connection.Close() }(); s.handle(connection, endpoint.path) })
+ default:
+ connection.Close()
+ }
+ }
+}
+
+func peer(connection *net.UnixConn) (int, int, error) {
+ raw, err := connection.SyscallConn()
+ if err != nil {
+ return 0, -1, err
+ }
+ var credentials *syscall.Ucred
+ var failure error
+ err = raw.Control(func(fd uintptr) {
+ credentials, failure = syscall.GetsockoptUcred(int(fd), syscall.SOL_SOCKET, syscall.SO_PEERCRED)
+ })
+ if err != nil {
+ return 0, -1, err
+ }
+ if failure != nil {
+ return 0, -1, failure
+ }
+ return int(credentials.Pid), int(credentials.Uid), nil
+}
+
+func read_request(connection *net.UnixConn, data []byte) (string, error) {
+ _ = connection.SetReadDeadline(time.Now().Add(250 * time.Millisecond))
+ var buffer [8130]byte
+ if len(data) > 8129 {
+ return "", fmt.Errorf("remote request exceeds 8129 bytes")
+ }
+ for len(data) <= 8129 && !bytes.HasSuffix(data, []byte("\n")) {
+ count, err := connection.Read(buffer[:min(len(buffer), 8130-len(data))])
+ data = append(data, buffer[:count]...)
+ if len(data) > 8129 {
+ return "", fmt.Errorf("remote request exceeds 8129 bytes")
+ }
+ if err != nil {
+ var timeout net.Error
+ if err != io.EOF && !(errors.As(err, &timeout) && timeout.Timeout() && len(data) > 0) {
+ return "", err
+ }
+ break
+ }
+ if bytes.HasSuffix(data, []byte("\n")) {
+ break
+ }
+ }
+ if !utf8.Valid(data) {
+ return "", fmt.Errorf("remote request is not UTF-8")
+ }
+ return strings.TrimSpace(string(data)), nil
+}
+
+func (s *Server) handle(connection *net.UnixConn, path string) {
+ pid, uid, err := peer(connection)
+ if err != nil {
+ return
+ }
+ // Route before consuming request bytes. A handed-off descriptor carries
+ // both the caller's kernel credentials and the caller's original request.
+ var routingErr error
+ if path == s.main_path() {
+ var target string
+ target, routingErr = s.route_target(pid, uid)
+ if routingErr == nil && target != "" {
+ routingErr = handoff(connection, target)
+ if routingErr == nil {
+ return
+ }
+ }
+ }
+ text, forwarded, err := receive_request(connection)
+ if forwarded != nil {
+ defer forwarded.Close()
+ connection = forwarded
+ pid, uid, err = peer(connection)
+ if err == nil {
+ // Handoffs never nest and never supply replacement request text.
+ text, err = read_request(connection, nil)
+ }
+ }
+ if err != nil || text == "" {
+ return
+ }
+ if is_list(text) {
+ _ = connection.SetWriteDeadline(time.Now().Add(250 * time.Millisecond))
+ _, _ = io.WriteString(connection, s.list()+"\n")
+ return
+ }
+ if routingErr != nil {
+ _ = connection.SetWriteDeadline(time.Now().Add(250 * time.Millisecond))
+ _, _ = io.WriteString(connection, "Remote routing failed: "+routingErr.Error()+"\n")
+ return
+ }
+ request := Request{Text: text, PID: pid, UID: uid, Reply: make(chan string, 1)}
+ select {
+ case s.requests <- request:
+ default:
+ _ = connection.SetWriteDeadline(time.Now().Add(250 * time.Millisecond))
+ _, _ = io.WriteString(connection, "Remote request queue is full\n")
+ return
+ }
+ response := ""
+ select {
+ case response = <-request.Reply:
+ case <-s.ctx.Done():
+ response = "Remote request cancelled; completion is unknown"
+ case <-time.After(time.Second):
+ response = "Remote request timed out; completion is unknown"
+ }
+ if response != "" {
+ _ = connection.SetWriteDeadline(time.Now().Add(250 * time.Millisecond))
+ _, _ = io.WriteString(connection, response+"\n")
+ }
+}
+
+func is_list(text string) bool {
+ switch strings.ToUpper(text) {
+ case "LS", "LIST", "COMMAND LS", "COMMAND LIST":
+ return true
+ }
+ return false
+}
diff --git a/internal/control/server_test.go b/internal/control/server_test.go
new file mode 100644
index 00000000..d08c4709
--- /dev/null
+++ b/internal/control/server_test.go
@@ -0,0 +1,268 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package control
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func private_options(t *testing.T) Options {
+ t.Helper()
+ directory, err := os.MkdirTemp("/tmp", "fenrir-remote-")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { os.RemoveAll(directory) })
+ return Options{Directory: directory, RegistryDirectory: filepath.Join(directory, "registry")}
+}
+
+func connect_remote(t *testing.T, path string) *net.UnixConn {
+ t.Helper()
+ connection, err := net.DialUnix("unix", nil, &net.UnixAddr{Name: path, Net: "unix"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ connection.SetDeadline(time.Now().Add(2 * time.Second))
+ t.Cleanup(func() { connection.Close() })
+ return connection
+}
+
+func TestPythonSocketProtocolAndRegistry(t *testing.T) {
+ options := private_options(t)
+ server, err := Open(context.Background(), options)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer server.Close()
+ connection := connect_remote(t, server.main_path())
+ if _, err := io.WriteString(connection, "command say Existing Bash script\n"); err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case request := <-server.Requests():
+ if request.Text != "command say Existing Bash script" || request.UID != os.Geteuid() || request.PID != os.Getpid() {
+ t.Fatal(request)
+ }
+ request.Reply <- ""
+ case <-time.After(time.Second):
+ t.Fatal("command was not delivered")
+ }
+ for _, command := range []string{"LIST", "command ls"} {
+ connection := connect_remote(t, server.main_path())
+ io.WriteString(connection, command+"\n")
+ response, err := io.ReadAll(connection)
+ if err != nil || !strings.Contains(string(response), "screen=vcsaDriver keyboard=evdevDriver main_socket=yes") {
+ t.Fatal(string(response), err)
+ }
+ }
+ info, err := os.Stat(server.main_path())
+ if err != nil || info.Mode().Perm() != 0666 {
+ t.Fatal(info, err)
+ }
+ instances := server.instances()
+ if len(instances) != 1 || instances[0].PID != os.Getpid() {
+ t.Fatal(instances)
+ }
+}
+
+func TestRequestTimeoutReturnsErrorInsteadOfSilentSuccess(t *testing.T) {
+ server, err := Open(context.Background(), private_options(t))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer server.Close()
+ connection := connect_remote(t, server.main_path())
+ if _, err := io.WriteString(connection, "SETTING SAVE\n"); err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case <-server.Requests(): // Deliberately leave the admitted operation pending.
+ case <-time.After(time.Second):
+ t.Fatal("request not admitted")
+ }
+ response, err := io.ReadAll(connection)
+ if err != nil || !strings.Contains(string(response), "timed out; completion is unknown") {
+ t.Fatal("timeout looked like success", string(response), err)
+ }
+}
+
+func TestFullRequestQueueReturnsErrorInsteadOfSilentSuccess(t *testing.T) {
+ server, err := Open(context.Background(), private_options(t))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer server.Close()
+ for range cap(server.requests) {
+ server.requests <- Request{}
+ }
+ connection := connect_remote(t, server.main_path())
+ if _, err := io.WriteString(connection, "SETTING SAVE\n"); err != nil {
+ t.Fatal(err)
+ }
+ response, err := io.ReadAll(connection)
+ if err != nil || !strings.Contains(string(response), "request queue is full") {
+ t.Fatal("full queue looked like success", string(response), err)
+ }
+}
+
+func TestCancelledRequestReturnsUnknownCompletion(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ server, err := Open(ctx, private_options(t))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer server.Close()
+ connection := connect_remote(t, server.main_path())
+ if _, err := io.WriteString(connection, "SETTING SAVE\n"); err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case <-server.Requests():
+ case <-time.After(time.Second):
+ t.Fatal("request not admitted")
+ }
+ cancel()
+ response, err := io.ReadAll(connection)
+ if err != nil || !strings.Contains(string(response), "cancelled; completion is unknown") {
+ t.Fatal("cancellation looked like success", string(response), err)
+ }
+}
+
+func TestSocketCoexistenceAndCleanupNeverReplaceActiveReader(t *testing.T) {
+ options := private_options(t)
+ server, err := Open(context.Background(), options)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer server.Close()
+ if second, err := Open(context.Background(), options); err == nil {
+ second.Close()
+ t.Fatal("replaced active socket")
+ }
+ // A failed startup must not remove the first reader's registry entry.
+ if len(server.instances()) != 1 {
+ t.Fatal("active registry entry removed")
+ }
+ if connection, err := net.Dial("unix", server.main_path()); err != nil {
+ t.Fatal("main socket lost", err)
+ } else {
+ connection.Close()
+ }
+}
+
+func TestSocketRejectsSymlinksAndUnrelatedFiles(t *testing.T) {
+ for _, symlink := range []bool{false, true} {
+ options := private_options(t)
+ path := filepath.Join(options.Directory, "fenrirscreenreader-daemon.sock")
+ if symlink {
+ if err := os.Symlink("/dev/null", path); err != nil {
+ t.Fatal(err)
+ }
+ } else if err := os.WriteFile(path, []byte("preserve"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if server, err := Open(context.Background(), options); err == nil {
+ server.Close()
+ t.Fatal("accepted unrelated file")
+ }
+ if _, err := os.Lstat(path); err != nil {
+ t.Fatal("unrelated file removed", err)
+ }
+ }
+}
+
+func TestSilentAndOversizedClientsDoNotBlockOtherRequests(t *testing.T) {
+ server, err := Open(context.Background(), private_options(t))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer server.Close()
+ silent := connect_remote(t, server.main_path())
+ defer silent.Close()
+ oversized := connect_remote(t, server.main_path())
+ io.WriteString(oversized, strings.Repeat("x", 9000))
+ good := connect_remote(t, server.main_path())
+ io.WriteString(good, "COMMAND INTERRUPT\n")
+ select {
+ case request := <-server.Requests():
+ if request.Text != "COMMAND INTERRUPT" {
+ t.Fatal(request.Text)
+ }
+ request.Reply <- ""
+ case <-time.After(time.Second):
+ t.Fatal("silent client blocked reader")
+ }
+ select {
+ case request := <-server.Requests():
+ t.Fatal("invalid request admitted", request.Text)
+ case <-time.After(300 * time.Millisecond):
+ }
+}
+
+func TestStaleSocketReplacementAndPrivateInstanceSelection(t *testing.T) {
+ options := private_options(t)
+ path := filepath.Join(options.Directory, "stale.sock")
+ old, err := net.ListenUnix("unix", &net.UnixAddr{Name: path, Net: "unix"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ old.SetUnlinkOnClose(false)
+ old.Close()
+ endpoint, err := listen(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ endpoint.listener.Close()
+ if err := os.Mkdir(options.RegistryDirectory, 0700); err != nil {
+ t.Fatal(err)
+ }
+ pid := os.Getppid()
+ privatePath := filepath.Join(options.Directory, fmt.Sprintf("fenrirscreenreader-%d.sock", pid))
+ listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: privatePath, Net: "unix"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer listener.Close()
+ data, err := json.Marshal(instance{PID: pid, ScreenDriver: "ptyDriver", ConnectionHandoff: true, SocketFiles: []string{privatePath}, UpdatedAt: float64(time.Now().UnixNano()) / 1e9})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(options.RegistryDirectory, fmt.Sprintf("%d.json", pid)), data, 0600); err != nil {
+ t.Fatal(err)
+ }
+ options.TerminalMode = true
+ server := &Server{ctx: context.Background(), options: options}
+ target, err := server.route_target(pid, os.Geteuid())
+ if err != nil || target != privatePath {
+ t.Fatal("registered ancestor instance not selected", target, err)
+ }
+ target, err = server.route_target(pid, os.Geteuid()+1)
+ if err != nil || target != "" {
+ t.Fatal("forwarded to another user's instance", target, err)
+ }
+}
diff --git a/internal/datetime/datetime.go b/internal/datetime/datetime.go
new file mode 100644
index 00000000..941ef90e
--- /dev/null
+++ b/internal/datetime/datetime.go
@@ -0,0 +1,141 @@
+// Copyright (c) 2026 Storm Dragon
+// Author: Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package datetime
+
+/*
+#include
+#include
+#include
+*/
+import "C"
+
+import (
+ "time"
+ "unsafe"
+)
+
+type Option struct {
+ Value string
+ Label string
+}
+
+var timeFormats = []struct {
+ Value string
+ Spec string
+}{
+ {"locale", "%X"},
+ {"12-hour", "%I:%M %p"},
+ {"12-hour-seconds", "%I:%M:%S %p"},
+ {"24-hour-seconds", "%H:%M:%S"},
+ {"24-hour-seconds-words", "%H hours, %M minutes and %S seconds"},
+ {"24-hour", "%H:%M"},
+ {"24-hour-words", "%H hours and %M minutes"},
+}
+
+var dateFormats = []struct {
+ Value string
+ Spec string
+}{
+ {"locale", "%x"},
+ {"numbers-dm", "%d/%m"},
+ {"numbers-md", "%m/%d"},
+ {"numbers-dmy", "%d/%m/%Y"},
+ {"numbers-mdy", "%m/%d/%Y"},
+ {"numbers-ymd", "%Y/%m/%d"},
+ {"full-dm", "%A, %-d %B"},
+ {"full-md", "%A, %B %-d"},
+ {"full-dmy", "%A, %-d %B, %Y"},
+ {"full-mdy", "%A, %B %-d, %Y"},
+ {"full-ymd", "%Y. %B %-d, %A"},
+ {"abbreviated-dm", "%a, %-d %b"},
+ {"abbreviated-md", "%a, %b %-d"},
+ {"abbreviated-dmy", "%a, %-d %b, %Y"},
+ {"abbreviated-mdy", "%a, %b %-d, %Y"},
+ {"abbreviated-ymd", "%Y. %b %-d, %a"},
+}
+
+func init() {
+ locale := C.CString("")
+ defer C.free(unsafe.Pointer(locale))
+ C.setlocale(C.LC_TIME, locale)
+}
+
+func Time_options(now time.Time) []Option {
+ return options(timeFormats, now)
+}
+
+func Date_options(now time.Time) []Option {
+ return options(dateFormats, now)
+}
+
+func options(formats []struct {
+ Value string
+ Spec string
+}, now time.Time) []Option {
+ result := make([]Option, 0, len(formats))
+ for _, item := range formats {
+ result = append(result, Option{Value: item.Value, Label: format(now, item.Spec)})
+ }
+ return result
+}
+
+func Format_time(value string, now time.Time) (string, bool) {
+ return format_value(timeFormats, value, now)
+}
+
+func Format_date(value string, now time.Time) (string, bool) {
+ return format_value(dateFormats, value, now)
+}
+
+func Valid_time(value string) bool {
+ return valid(timeFormats, value)
+}
+
+func Valid_date(value string) bool {
+ return valid(dateFormats, value)
+}
+
+func format_value(formats []struct {
+ Value string
+ Spec string
+}, value string, now time.Time) (string, bool) {
+ for _, item := range formats {
+ if item.Value == value {
+ return format(now, item.Spec), true
+ }
+ }
+ return "", false
+}
+
+func valid(formats []struct {
+ Value string
+ Spec string
+}, value string) bool {
+ for _, item := range formats {
+ if item.Value == value {
+ return true
+ }
+ }
+ return false
+}
+
+func format(now time.Time, spec string) string {
+ cSpec := C.CString(spec)
+ defer C.free(unsafe.Pointer(cSpec))
+ tm := C.struct_tm{
+ tm_year: C.int(now.Year() - 1900),
+ tm_mon: C.int(int(now.Month()) - 1),
+ tm_mday: C.int(now.Day()),
+ tm_hour: C.int(now.Hour()),
+ tm_min: C.int(now.Minute()),
+ tm_sec: C.int(now.Second()),
+ tm_wday: C.int(int(now.Weekday())),
+ tm_yday: C.int(now.YearDay() - 1),
+ tm_isdst: -1,
+ }
+ buffer := make([]C.char, 512)
+ length := C.strftime(&buffer[0], C.size_t(len(buffer)), cSpec, &tm)
+ return C.GoStringN(&buffer[0], C.int(length))
+}
diff --git a/internal/datetime/datetime_test.go b/internal/datetime/datetime_test.go
new file mode 100644
index 00000000..ca0ded53
--- /dev/null
+++ b/internal/datetime/datetime_test.go
@@ -0,0 +1,53 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package datetime
+
+import (
+ "testing"
+ "time"
+)
+
+func TestFormatsAndExamples(t *testing.T) {
+ now := time.Date(2026, 10, 25, 14, 5, 9, 0, time.FixedZone("fixture", -4*3600))
+ for _, testCase := range []struct {
+ id, want string
+ date bool
+ }{
+ {"24-hour", "14:05", false},
+ {"24-hour-seconds", "14:05:09", false},
+ {"24-hour-words", "14 hours and 05 minutes", false},
+ {"24-hour-seconds-words", "14 hours, 05 minutes and 09 seconds", false},
+ {"numbers-dm", "25/10", true},
+ {"numbers-md", "10/25", true},
+ {"numbers-dmy", "25/10/2026", true},
+ {"numbers-mdy", "10/25/2026", true},
+ {"numbers-ymd", "2026/10/25", true},
+ } {
+ format := Format_time
+ if testCase.date {
+ format = Format_date
+ }
+ if got, ok := format(testCase.id, now); !ok || got != testCase.want {
+ t.Fatal(testCase.id, got, ok)
+ }
+ }
+ for _, date := range []bool{false, true} {
+ options, format, valid := Time_options(now), Format_time, Valid_time
+ if date {
+ options, format, valid = Date_options(now), Format_date, Valid_date
+ }
+ for _, option := range options {
+ got, ok := format(option.Value, now)
+ if !ok || !valid(option.Value) || got == "" || got != option.Label {
+ t.Fatal(option, got, ok)
+ }
+ }
+ if got, ok := format("%arbitrary", now); ok || got != "" || valid("%arbitrary") {
+ t.Fatal("invalid preset accepted")
+ }
+ }
+ if got, _ := Format_time("24-hour-seconds", now.Add(10*time.Hour)); got != "00:05:09" {
+ t.Fatal(got)
+ }
+}
diff --git a/internal/diagnostic/timing.go b/internal/diagnostic/timing.go
new file mode 100644
index 00000000..62afd66f
--- /dev/null
+++ b/internal/diagnostic/timing.go
@@ -0,0 +1,45 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+// Package diagnostic records opt-in timings and reader decisions without text or key values.
+package diagnostic
+
+import (
+ "context"
+ "log/slog"
+ "time"
+)
+
+func Enabled() bool {
+ return slog.Default().Enabled(context.Background(), slog.LevelDebug)
+}
+
+func Record(stage string, attributes ...any) {
+ if !Enabled() {
+ return
+ }
+ attributes = append(attributes, "at", time.Now())
+ slog.Debug(stage, attributes...)
+}
+
+func Timing(stage string, started time.Time, attributes ...any) {
+ if !Enabled() {
+ return
+ }
+ attributes = append(attributes, "elapsed", time.Since(started))
+ Record(stage, attributes...)
+}
diff --git a/internal/emoji/catalog.go b/internal/emoji/catalog.go
new file mode 100644
index 00000000..83c91826
--- /dev/null
+++ b/internal/emoji/catalog.go
@@ -0,0 +1,181 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+// Package emoji provides the bundled picker catalog and its internal UI form.
+package emoji
+
+import (
+ "sort"
+
+ "git.stormux.org/storm/fenrir/internal/interaction"
+)
+
+const MenuTitle = "Emoji Menu"
+
+// Catalog values originate in Python Fenrir commands/vmenu-profiles/KEY/emoji.
+// Duplicate aliases are omitted; names describe the stored Unicode sequence.
+var categories = []struct {
+ id, label string
+ choices []interaction.Choice
+}{
+ {"people", "People", []interaction.Choice{
+ {ID: "š ", Label: "Angry"},
+ {ID: "š", Label: "Beaming"},
+ {ID: "š", Label: "Blowing kiss"},
+ {ID: "š", Label: "Cool"},
+ {ID: "š", Label: "Crying"},
+ {ID: "š", Label: "Devil"},
+ {ID: "šµ", Label: "Dizzy"},
+ {ID: "š¤Æ", Label: "Exploding head"},
+ {ID: "š¤¬", Label: "Face with symbols"},
+ {ID: "š", Label: "Grinning"},
+ {ID: "š
", Label: "Grinning sweat"},
+ {ID: "š", Label: "Heart eyes"},
+ {ID: "š„°", Label: "Hearts around"},
+ {ID: "šæ", Label: "Imp"},
+ {ID: "š", Label: "Laughing"},
+ {ID: "š¤¢", Label: "Nauseated"},
+ {ID: "š„ŗ", Label: "Pleading"},
+ {ID: "š©", Label: "Poop"},
+ {ID: "š¤£", Label: "Rolling laughing"},
+ {ID: "š¢", Label: "Sad"},
+ {ID: "š", Label: "Savoring food"},
+ {ID: "š±", Label: "Screaming"},
+ {ID: "š", Label: "Smiling"},
+ {ID: "š", Label: "Smirking"},
+ {ID: "š", Label: "Thumbs up"},
+ {ID: "š", Label: "Winking"},
+ }},
+ {"nature", "Nature", []interaction.Choice{
+ {ID: "š¦", Label: "Butterfly"},
+ {ID: "š±", Label: "Cat"},
+ {ID: "šø", Label: "Cherry blossom"},
+ {ID: "š¶", Label: "Dog"},
+ {ID: "š", Label: "Moon"},
+ {ID: "š", Label: "Rainbow"},
+ {ID: "š¹", Label: "Rose"},
+ {ID: "āļø", Label: "Sun"},
+ {ID: "š»", Label: "Sunflower"},
+ {ID: "š³", Label: "Tree"},
+ {ID: "šŗ", Label: "Wolf"},
+ }},
+ {"food", "Food", []interaction.Choice{
+ {ID: "š", Label: "Apple"},
+ {ID: "š„", Label: "Avocado"},
+ {ID: "šŗ", Label: "Beer"},
+ {ID: "š", Label: "Birthday cake"},
+ {ID: "ā", Label: "Coffee"},
+ {ID: "š©", Label: "Donut"},
+ {ID: "š", Label: "Hamburger"},
+ {ID: "š", Label: "Pizza"},
+ {ID: "š", Label: "Strawberry"},
+ {ID: "š®", Label: "Taco"},
+ }},
+ {"holidays", "Holidays", []interaction.Choice{
+ {ID: "š¾", Label: "Alien monster"},
+ {ID: "š¦", Label: "Bat"},
+ {ID: "šāā¬", Label: "Black cat"},
+ {ID: "š°", Label: "Bunny"},
+ {ID: "š", Label: "Christmas tree"},
+ {ID: "ā°ļø", Label: "Coffin"},
+ {ID: "š„", Label: "Egg"},
+ {ID: "š", Label: "Fireworks"},
+ {ID: "š»", Label: "Ghost"},
+ {ID: "š", Label: "Gift"},
+ {ID: "š", Label: "Jack o'lantern"},
+ {ID: "š§", Label: "Mage"},
+ {ID: "š
", Label: "Santa"},
+ {ID: "āļø", Label: "Shamrock"},
+ {ID: "š", Label: "Skull"},
+ {ID: "ā", Label: "Snowman"},
+ {ID: "š·", Label: "Spider"},
+ {ID: "š¦", Label: "Turkey"},
+ {ID: "š§", Label: "Vampire"},
+ {ID: "šøļø", Label: "Web"},
+ {ID: "š§āāļø", Label: "Witch"},
+ {ID: "š§", Label: "Zombie"},
+ }},
+ {"symbols", "Symbols", []interaction.Choice{
+ {ID: "š¦“", Label: "Bone"},
+ {ID: "āļø", Label: "Chains"},
+ {ID: "ā
", Label: "Checkmark"},
+ {ID: "š”ļø", Label: "Dagger"},
+ {ID: "š„", Label: "Fire"},
+ {ID: "š", Label: "Folded hands"},
+ {ID: "ā¤ļø", Label: "Heart"},
+ {ID: "ā”", Label: "High voltage"},
+ {ID: "šÆ", Label: "Hundred points"},
+ {ID: "āļø", Label: "Peace"},
+ {ID: "š¤", Label: "Rock on"},
+ {ID: "ā ļø", Label: "Skull crossbones"},
+ {ID: "āØ", Label: "Sparkles"},
+ {ID: "ā", Label: "Star"},
+ {ID: "āļø", Label: "Sword"},
+ }},
+ {"flags", "Flags", []interaction.Choice{
+ {ID: "š¦š·", Label: "Argentina"},
+ {ID: "š¦šŗ", Label: "Australia"},
+ {ID: "š§šŖ", Label: "Belgium"},
+ {ID: "š§š·", Label: "Brazil"},
+ {ID: "šØš¦", Label: "Canada"},
+ {ID: "šØš³", Label: "China"},
+ {ID: "š©š°", Label: "Denmark"},
+ {ID: "š«š®", Label: "Finland"},
+ {ID: "š«š·", Label: "France"},
+ {ID: "š©šŖ", Label: "Germany"},
+ {ID: "š¬š·", Label: "Greece"},
+ {ID: "š®š³", Label: "India"},
+ {ID: "š®šŖ", Label: "Ireland"},
+ {ID: "š®š±", Label: "Israel"},
+ {ID: "š®š¹", Label: "Italy"},
+ {ID: "šÆšµ", Label: "Japan"},
+ {ID: "š²š½", Label: "Mexico"},
+ {ID: "š³š±", Label: "Netherlands"},
+ {ID: "š³š“", Label: "Norway"},
+ {ID: "šµš±", Label: "Poland"},
+ {ID: "šµš¹", Label: "Portugal"},
+ {ID: "š·šŗ", Label: "Russia"},
+ {ID: "šæš¦", Label: "South Africa"},
+ {ID: "š°š·", Label: "South Korea"},
+ {ID: "šŖšø", Label: "Spain"},
+ {ID: "šøšŖ", Label: "Sweden"},
+ {ID: "šØš", Label: "Switzerland"},
+ {ID: "š¹š·", Label: "Turkey"},
+ {ID: "šŗš¦", Label: "Ukraine"},
+ {ID: "š¬š§", Label: "United Kingdom"},
+ {ID: "šŗšø", Label: "United States"},
+ }},
+}
+
+// New_session builds an independent draft. Search is local to each category;
+// the first All tab searches the complete catalog.
+func New_session() (*interaction.Session, error) {
+ form := interaction.Form{Type: interaction.TabForm, Title: MenuTitle}
+ var all []interaction.Choice
+ for _, category := range categories {
+ all = append(all, category.choices...)
+ }
+ sort.SliceStable(all, func(i, j int) bool { return all[i].Label < all[j].Label })
+ addTab := func(id, label string, choices []interaction.Choice) {
+ form.Tabs = append(form.Tabs, interaction.Tab{ID: id, Label: label, Controls: []interaction.Control{{
+ ID: "emoji", Label: "Emoji", Kind: interaction.Grid, Choices: choices, Columns: 6, Action: "copy_emoji",
+ }}})
+ }
+ addTab("all", "All", all)
+ for _, category := range categories {
+ addTab(category.id, category.label, category.choices)
+ }
+ return interaction.New_session(form)
+}
+
+// Selection returns the selected Unicode sequence and readable name. It never
+// returns a selection from another tab or from an empty search.
+func Selection(session *interaction.Session) (string, string, bool) {
+ control, ok := session.Focused_control()
+ if !ok || control.Kind != interaction.Grid || control.Selected < 0 || control.Selected >= len(control.Choices) {
+ return "", "", false
+ }
+ choice := control.Choices[control.Selected]
+ return choice.ID, choice.Label, true
+}
diff --git a/internal/emoji/catalog_test.go b/internal/emoji/catalog_test.go
new file mode 100644
index 00000000..1860a871
--- /dev/null
+++ b/internal/emoji/catalog_test.go
@@ -0,0 +1,69 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package emoji
+
+import (
+ "git.stormux.org/storm/fenrir/internal/i18n"
+ "strings"
+ "testing"
+
+ "git.stormux.org/storm/fenrir/internal/interaction"
+)
+
+func TestCategorySelectionAndIndependentSearches(t *testing.T) {
+ session, err := New_session()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, _, ok := Selection(session); ok {
+ t.Fatal("tab strip has a selection")
+ }
+ session.Handle(interaction.Key{Name: "tab"})
+ session.Handle(interaction.Key{Text: "j"})
+ session.Handle(interaction.Key{Name: "right"})
+ if value, _, _ := Selection(session); value != "šÆšµ" {
+ t.Fatal("filtered All navigation", value)
+ }
+ session.Handle(interaction.Key{Name: "tab"})
+ session.Handle(interaction.Key{Name: "right"})
+ session.Handle(interaction.Key{Name: "right"})
+ session.Handle(interaction.Key{Name: "tab"})
+ session.Handle(interaction.Key{Text: "c"})
+ if value, _, _ := Selection(session); value != "š±" {
+ t.Fatal("Nature selection came from another tab", value)
+ }
+ session.Handle(interaction.Key{Name: "tab", Shift: true})
+ session.Handle(interaction.Key{Name: "left"})
+ session.Handle(interaction.Key{Name: "left"})
+ session.Handle(interaction.Key{Name: "tab"})
+ if value, _, _ := Selection(session); value != "šÆšµ" {
+ t.Fatal("All search/selection not retained", value)
+ }
+ other, err := New_session()
+ if err != nil {
+ t.Fatal(err)
+ }
+ other.Handle(interaction.Key{Name: "tab"})
+ if value, _, _ := Selection(other); value != "š¾" {
+ t.Fatal("new session inherited previous search", value)
+ }
+}
+
+func TestBundledTranslationSearchPreservesEmoji(t *testing.T) {
+ previous := i18n.Use(i18n.New(t.TempDir(), "de"))
+ t.Cleanup(func() { i18n.Use(previous) })
+ session, err := New_session()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := session.Open(); !strings.HasPrefix(got, "Emoji-Menü. Alle Registerkarte") {
+ t.Fatal("untranslated picker", got)
+ }
+ session.Handle(interaction.Key{Name: "tab"})
+ session.Handle(interaction.Key{Text: "Schmetter"})
+ if value, label, ok := Selection(session); !ok || value != "š¦" || label != "Butterfly" {
+ t.Fatalf("localized search changed identity: %q %q %v", value, label, ok)
+ }
+}
diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go
new file mode 100644
index 00000000..ba1eafa8
--- /dev/null
+++ b/internal/i18n/i18n.go
@@ -0,0 +1,119 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+// Package i18n combines installed and bundled Fenrir gettext catalogs. Screen
+// contents and internal command/cue IDs must never pass through this package.
+package i18n
+
+import (
+ "embed"
+ "os"
+ "strconv"
+ "strings"
+ "sync/atomic"
+
+ "github.com/leonelquinteros/gotext"
+)
+
+const Domain = "fenrir"
+const LocaleDir = "/usr/share/locale"
+
+//go:embed locale/*/LC_MESSAGES/fenrir.po
+var bundled embed.FS
+
+type Catalog struct{ locales []*gotext.Locale }
+
+var current atomic.Pointer[Catalog]
+
+// Language follows Python gettext's environment precedence. LANGUAGE may list
+// several languages separated by colons; C/POSIX stop catalog fallback.
+func Language() string {
+ for _, name := range []string{"LANGUAGE", "LC_ALL", "LC_MESSAGES", "LANG"} {
+ if value := os.Getenv(name); value != "" {
+ return value
+ }
+ }
+ return "C"
+}
+
+func New(root, languages string) *Catalog {
+ catalog := &Catalog{}
+ for _, language := range strings.Split(languages, ":") {
+ language = strings.TrimSpace(language)
+ if index := strings.IndexByte(language, '.'); index >= 0 {
+ modifier := ""
+ if at := strings.IndexByte(language[index:], '@'); at >= 0 {
+ modifier = language[index+at:]
+ }
+ language = language[:index] + modifier
+ }
+ if language == "C" || language == "POSIX" {
+ break
+ }
+ if language == "" || strings.ContainsAny(language, "/\\") {
+ continue
+ }
+ locale := gotext.NewLocale(root, language)
+ locale.AddDomain(Domain)
+ catalog.locales = append(catalog.locales, locale)
+ // Keep each language's additions ahead of the next LANGUAGE fallback.
+ // Installed contributor translations retain priority within a language.
+ local := gotext.NewLocaleFSWithPath(language, bundled, "locale")
+ local.AddDomain(Domain)
+ catalog.locales = append(catalog.locales, local)
+ }
+ return catalog
+}
+
+// Use installs an immutable catalog and returns the previous one. Configure
+// once at startup; no catalog loading belongs on the input/event path.
+func Use(catalog *Catalog) *Catalog { return current.Swap(catalog) }
+
+func T(message string) string {
+ if catalog := current.Load(); catalog != nil {
+ for _, locale := range catalog.locales {
+ if locale.IsTranslated(message) {
+ return locale.Get(message)
+ }
+ }
+ }
+ return message
+}
+
+func N(singular, plural string, count int) string {
+ if catalog := current.Load(); catalog != nil {
+ for _, locale := range catalog.locales {
+ if locale.IsTranslatedN(singular, count) {
+ return locale.GetN(singular, plural, count)
+ }
+ }
+ }
+ if count == 1 {
+ return singular
+ }
+ return plural
+}
+
+// Format substitutes Python-style positional fields after lookup. Keeping
+// {0}/{1} intact lets the Go reader reuse existing catalog message IDs.
+func Format(message string, fields ...string) string {
+ replacements := []string{"{{", "{", "}}", "}"}
+ for index, value := range fields {
+ replacements = append(replacements, "{"+strconv.Itoa(index)+"}", value)
+ }
+ return strings.NewReplacer(replacements...).Replace(T(message))
+}
diff --git a/internal/i18n/i18n_test.go b/internal/i18n/i18n_test.go
new file mode 100644
index 00000000..a1229b71
--- /dev/null
+++ b/internal/i18n/i18n_test.go
@@ -0,0 +1,167 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package i18n
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestCatalogReusePluralRulesAndPythonFields(t *testing.T) {
+ root := t.TempDir()
+ dir := filepath.Join(root, "xx", "LC_MESSAGES")
+ if err := os.MkdirAll(dir, 0700); err != nil {
+ t.Fatal(err)
+ }
+ data := `msgid ""
+msgstr ""
+"Content-Type: text/plain; charset=UTF-8\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+msgid "Start Fenrir"
+msgstr "translated-start"
+
+msgid "screen {0}"
+msgstr "translated-screen {0}"
+
+msgid "line {0}, column {1}, Terminal {2}"
+msgstr "{2}: {1}, {0}"
+
+msgid "one item"
+msgid_plural "many items"
+msgstr[0] "translated-one"
+msgstr[1] "translated-many"
+`
+ if err := os.WriteFile(filepath.Join(dir, "fenrir.po"), []byte(data), 0600); err != nil {
+ t.Fatal(err)
+ }
+ previous := Use(New(root, "missing:xx_YY.UTF-8"))
+ t.Cleanup(func() { Use(previous) })
+ if got := T("Start Fenrir"); got != "translated-start" {
+ t.Fatal("catalog not reused", got)
+ }
+ if got := Format("screen {0}", "3"); got != "translated-screen 3" {
+ t.Fatal(got)
+ }
+ if got := Format("line {0}, column {1}, Terminal {2}", "7", "8", "9"); got != "9: 8, 7" {
+ t.Fatal("translated field order lost", got)
+ }
+ if got := Format("screen {0}", "{0}"); got != "translated-screen {0}" {
+ t.Fatal("replacement data was formatted again", got)
+ }
+ if got := N("one item", "many items", 1); got != "translated-one" {
+ t.Fatal(got)
+ }
+ if got := N("one item", "many items", 3); got != "translated-many" {
+ t.Fatal(got)
+ }
+ if got := T("start of screen"); got != "start of screen" {
+ t.Fatal("missing translation lost English msgid", got)
+ }
+}
+
+func TestLanguagePrecedenceAndCStopsFallback(t *testing.T) {
+ for _, name := range []string{"LANGUAGE", "LC_ALL", "LC_MESSAGES", "LANG"} {
+ t.Setenv(name, "")
+ }
+ if Language() != "C" {
+ t.Fatal(Language())
+ }
+ for _, pair := range [][2]string{{"LANG", "en"}, {"LC_MESSAGES", "es"}, {"LC_ALL", "de"}, {"LANGUAGE", "pl:ru"}} {
+ t.Setenv(pair[0], pair[1])
+ if Language() != pair[1] {
+ t.Fatal(Language())
+ }
+ }
+ catalog := New(t.TempDir(), "C.UTF-8:de")
+ if len(catalog.locales) != 0 {
+ t.Fatal("C locale continued translation fallback")
+ }
+}
+
+func TestInstalledFenrirCatalogWhenRequested(t *testing.T) {
+ root := os.Getenv("FENRIR_TEST_LOCALE_DIR")
+ if root == "" {
+ t.Skip("set FENRIR_TEST_LOCALE_DIR to check an installed Fenrir catalog")
+ }
+ previous := Use(New(root, "de_DE.UTF-8"))
+ t.Cleanup(func() { Use(previous) })
+ if got := T("Start Fenrir"); got != "Fenrir starten" {
+ t.Fatal("installed MO not reused", got)
+ }
+ if got := Format("screen {0}", "2"); got != "Bildschirm 2" {
+ t.Fatal("Python fields not reused", got)
+ }
+}
+
+func TestBundledCatalogsWithoutInstalledTranslations(t *testing.T) {
+ root := t.TempDir()
+ for _, trial := range []struct{ language, menu, tab string }{
+ {"de_DE.UTF-8", "Fenrir-Menü", "Lesen Registerkarte"},
+ {"es_ES.UTF-8", "Menú de Fenrir", "Lectura pestaña"},
+ {"pl_PL.UTF-8", "Menu Fenrira", "Czytanie zakÅadka"},
+ {"pt_BR.UTF-8", "Menu do Fenrir", "Leitura aba"},
+ {"ru_RU.UTF-8", "ŠŠµŠ½Ń Fenrir", "Š§ŃŠµŠ½ŠøŠµ, вклаГка"},
+ } {
+ t.Run(trial.language, func(t *testing.T) {
+ previous := Use(New(root, trial.language))
+ t.Cleanup(func() { Use(previous) })
+ if got := T("Fenrir Menu"); got != trial.menu {
+ t.Fatalf("bundled menu: %q", got)
+ }
+ if got := Format("{0} tab", T("Reading")); got != trial.tab {
+ t.Fatalf("nested translation: %q", got)
+ }
+ if got := Format("Added {0} to clipboard", "{0}: application text"); !strings.Contains(got, "{0}: application text") {
+ t.Fatalf("application value reformatted: %q", got)
+ }
+ for _, message := range []string{"Start Fenrir", "character {0}", "number", "tab", "Could not check spelling: {0}"} {
+ if T(message) == message {
+ t.Errorf("untranslated %q", message)
+ }
+ }
+ })
+ }
+}
+
+func TestInstalledPriorityAndBundledLanguageFallback(t *testing.T) {
+ root := t.TempDir()
+ dir := filepath.Join(root, "de", "LC_MESSAGES")
+ if err := os.MkdirAll(dir, 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "fenrir.po"), []byte("msgid \"Start Fenrir\"\nmsgstr \"custom-start\"\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ previous := Use(New(root, "missing:de:es"))
+ t.Cleanup(func() { Use(previous) })
+ if got := T("Start Fenrir"); got != "custom-start" {
+ t.Fatal("installed translation lost priority", got)
+ }
+ if got := T("Fenrir Menu"); got != "Fenrir-Menü" {
+ t.Fatal("later language replaced bundled German", got)
+ }
+ for _, language := range []string{"C.UTF-8:de", "POSIX:es", "en_US.UTF-8", "missing"} {
+ Use(New(root, language))
+ if got := T("Fenrir Menu"); got != "Fenrir Menu" {
+ t.Fatalf("%s did not retain English: %q", language, got)
+ }
+ }
+}
diff --git a/internal/i18n/locale/de/LC_MESSAGES/fenrir.po b/internal/i18n/locale/de/LC_MESSAGES/fenrir.po
new file mode 100644
index 00000000..15066d5e
--- /dev/null
+++ b/internal/i18n/locale/de/LC_MESSAGES/fenrir.po
@@ -0,0 +1,2165 @@
+# Fenrir reader translations (GPL-3.0-or-later).
+# Existing translations retained from Python Fenrir.
+# AI-assisted additions require language-contributor review.
+msgid ""
+msgstr ""
+"Project-Id-Version: fenrir\n"
+"PO-Revision-Date: 2017-12-27 22:42+0100\n"
+"Last-Translator: Steffen Schultz \n"
+"Language-Team: \n"
+"Language: de\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Generated-By: pygettext.py 1.5\n"
+"X-Generator: Poedit 2.0.3\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+#: internal/review/cursor_column.go:13
+msgid " column number"
+msgstr " Spaltennummer"
+
+#: internal/review/cursor_line.go:13
+msgid " line number"
+msgstr " Zeilennummer"
+
+#: internal/review/cursor_scope.go:10
+msgid " review cursor "
+msgstr " Lesecursor "
+
+#: internal/review/cursor_scope.go:12
+msgid " text cursor "
+msgstr " Textcursor "
+
+#: internal/sound/drivers.go:39
+msgid "ALSA"
+msgstr "ALSA"
+
+#: internal/app/tutorial.go
+msgid "Active actions"
+msgstr "Aktive Aktionen"
+
+#: internal/app/spellcheck_menu.go:51
+msgid "Add"
+msgstr "Hinzufügen"
+
+#: internal/app/emoji_menu.go:34 internal/app/spellcheck_menu.go:107
+msgid "Added {0} to clipboard"
+msgstr "{0} zur Zwischenablage hinzugefügt"
+
+#: internal/emoji/catalog.go:76
+msgid "Alien monster"
+msgstr "AuĆerirdisches Monster"
+
+#: internal/emoji/catalog.go
+msgid "All"
+msgstr "Alle"
+
+#: internal/reader/regions.go:203
+msgid "All 10 live regions are set"
+msgstr "Alle 10 Live-Bereiche sind festgelegt"
+
+#: internal/emoji/catalog.go:23
+msgid "Angry"
+msgstr "Wütend"
+
+#: internal/emoji/catalog.go:64
+msgid "Apple"
+msgstr "Apfel"
+
+#: internal/emoji/catalog.go:117
+msgid "Argentina"
+msgstr "Argentinien"
+
+#: internal/config/config.go:155
+msgid "Attribute format"
+msgstr "Attributformat"
+
+#: internal/config/config.go:154
+msgid "Attributes"
+msgstr "Attribute"
+
+#: internal/emoji/catalog.go:118
+msgid "Australia"
+msgstr "Australien"
+
+#: internal/config/config.go:145
+msgid "Automatic reading"
+msgstr "Automatisches Lesen"
+
+#: internal/config/config.go:156
+msgid "Automatic spell checking"
+msgstr "Automatische Rechtschreibprüfung"
+
+#: internal/emoji/catalog.go:65
+msgid "Avocado"
+msgstr "Avocado"
+
+#: internal/emoji/catalog.go:77
+msgid "Bat"
+msgstr "Fledermaus"
+
+#: internal/config/config.go:129
+msgid "Baud rate"
+msgstr "Baudrate"
+
+#: internal/emoji/catalog.go:24
+msgid "Beaming"
+msgstr "Strahlend"
+
+#: internal/config/config.go:151
+msgid "Beep for capital letters"
+msgstr "Signalton bei GroĆbuchstaben"
+
+#: internal/emoji/catalog.go:66
+msgid "Beer"
+msgstr "Bier"
+
+#: internal/emoji/catalog.go:119
+msgid "Belgium"
+msgstr "Belgien"
+
+#: internal/emoji/catalog.go:67
+msgid "Birthday cake"
+msgstr "Geburtstagstorte"
+
+#: internal/emoji/catalog.go:78
+msgid "Black cat"
+msgstr "Schwarze Katze"
+
+#: internal/emoji/catalog.go:25
+msgid "Blowing kiss"
+msgstr "Kuss zuwerfend"
+
+#: internal/emoji/catalog.go:100
+msgid "Bone"
+msgstr "Knochen"
+
+#: internal/review/bottom.go:29
+msgid "Bottom"
+msgstr "Unten"
+
+#: internal/emoji/catalog.go:120
+msgid "Brazil"
+msgstr "Brasilien"
+
+#: internal/emoji/catalog.go:79
+msgid "Bunny"
+msgstr "HƤschen"
+
+#: internal/emoji/catalog.go:51
+msgid "Butterfly"
+msgstr "Schmetterling"
+
+#: internal/emoji/catalog.go:121
+msgid "Canada"
+msgstr "Kanada"
+
+#: internal/preferences/settings.go:79
+msgid "Cancel"
+msgstr "Abbrechen"
+
+#: internal/emoji/catalog.go:52
+msgid "Cat"
+msgstr "Katze"
+
+#: internal/emoji/catalog.go:101
+msgid "Chains"
+msgstr "Ketten"
+
+#: internal/config/config.go:140
+msgid "Character echo"
+msgstr "Zeichenansage"
+
+#: internal/emoji/catalog.go:102
+msgid "Checkmark"
+msgstr "HƤkchen"
+
+#: internal/emoji/catalog.go:53
+msgid "Cherry blossom"
+msgstr "Kirschblüte"
+
+#: internal/emoji/catalog.go:122
+msgid "China"
+msgstr "China"
+
+#: internal/emoji/catalog.go:80
+msgid "Christmas tree"
+msgstr "Weihnachtsbaum"
+
+#: internal/command/descriptions.go:76
+msgid "Clear all live regions"
+msgstr "Alle Live-Bereiche lƶschen"
+
+#: internal/command/descriptions.go:77
+msgid "Clear all reading regions"
+msgstr "Alle Lesebereiche lƶschen"
+
+#: internal/command/descriptions.go:79
+msgid "Clear live region 1"
+msgstr "Live-Bereich 1 lƶschen"
+
+#: internal/command/descriptions.go:97
+msgid "Clear live region 10"
+msgstr "Live-Bereich 10 lƶschen"
+
+#: internal/command/descriptions.go:81
+msgid "Clear live region 2"
+msgstr "Live-Bereich 2 lƶschen"
+
+#: internal/command/descriptions.go:83
+msgid "Clear live region 3"
+msgstr "Live-Bereich 3 lƶschen"
+
+#: internal/command/descriptions.go:85
+msgid "Clear live region 4"
+msgstr "Live-Bereich 4 lƶschen"
+
+#: internal/command/descriptions.go:87
+msgid "Clear live region 5"
+msgstr "Live-Bereich 5 lƶschen"
+
+#: internal/command/descriptions.go:89
+msgid "Clear live region 6"
+msgstr "Live-Bereich 6 lƶschen"
+
+#: internal/command/descriptions.go:91
+msgid "Clear live region 7"
+msgstr "Live-Bereich 7 lƶschen"
+
+#: internal/command/descriptions.go:93
+msgid "Clear live region 8"
+msgstr "Live-Bereich 8 lƶschen"
+
+#: internal/command/descriptions.go:95
+msgid "Clear live region 9"
+msgstr "Live-Bereich 9 lƶschen"
+
+#: internal/config/config.go:57
+msgid "Clipboard"
+msgstr "Zwischenablage"
+
+#: internal/app/application.go:136 internal/app/application.go:139
+#: internal/app/clipboard.go:218 internal/app/clipboard.go:374
+msgid "Clipboard import failed"
+msgstr "Import aus der Zwischenablage fehlgeschlagen"
+
+#: internal/config/config.go:106
+msgid "Clipboard sync"
+msgstr "Zwischenablage synchronisieren"
+
+#: internal/emoji/catalog.go:68
+msgid "Coffee"
+msgstr "Kaffee"
+
+#: internal/emoji/catalog.go:81
+msgid "Coffin"
+msgstr "Sarg"
+
+#: internal/command/descriptions.go:45
+msgid "Column number for cursor"
+msgstr "Spaltennummer des Cursors"
+
+#: internal/emoji/catalog.go:26
+msgid "Cool"
+msgstr "Cool"
+
+#: internal/app/spellcheck_menu.go:175
+msgid "Could not check spelling: {0}"
+msgstr "Rechtschreibung konnte nicht geprüft werden: {0}"
+
+#: internal/app/emoji_menu.go:17 internal/app/emoji_menu.go:21
+msgid "Could not open Emoji Menu: {0}"
+msgstr "Emoji-Menü konnte nicht geöffnet werden: {0}"
+
+#: internal/app/fenrir_menu.go:32 internal/app/fenrir_menu.go:52
+#: internal/app/fenrir_menu.go:67 internal/app/runtime_settings.go:133
+msgid "Could not open Fenrir Menu: {0}"
+msgstr "Fenrir-Menü konnte nicht geöffnet werden: {0}"
+
+#: internal/app/spellcheck_menu.go:39 internal/app/spellcheck_menu.go:58
+msgid "Could not open Spellcheck Menu: {0}"
+msgstr "Rechtschreibmenü konnte nicht geöffnet werden: {0}"
+
+#: internal/app/spellcheck_menu.go:155
+msgid "Could not save exceptions: {0}"
+msgstr "Ausnahmen konnten nicht gespeichert werden: {0}"
+
+#: internal/app/fenrir_menu.go:174 internal/app/fenrir_menu.go:180
+#: internal/app/runtime_settings.go:164 internal/app/runtime_settings.go:191
+msgid "Could not save settings: {0}"
+msgstr "Einstellungen konnten nicht gespeichert werden: {0}"
+
+#: internal/emoji/catalog.go:27
+msgid "Crying"
+msgstr "Weinend"
+
+#: internal/config/config.go:146
+msgid "Cursor echo"
+msgstr "Cursoransage"
+
+#: internal/emoji/catalog.go:103
+msgid "Dagger"
+msgstr "Dolch"
+
+#: internal/config/config.go:102
+msgid "Date format"
+msgstr "Datumsformat"
+
+#: internal/emoji/catalog.go:123
+msgid "Denmark"
+msgstr "DƤnemark"
+
+#: internal/config/config.go:138
+msgid "Devices"
+msgstr "GerƤte"
+
+#: internal/emoji/catalog.go:28
+msgid "Devil"
+msgstr "Teufel"
+
+#: internal/app/spellcheck_menu.go:191
+msgid "Dictionary {0}. {1}. {2}"
+msgstr "Wƶrterbuch {0}. {1}. {2}"
+
+#: internal/emoji/catalog.go:29
+msgid "Dizzy"
+msgstr "Schwindelig"
+
+#: internal/emoji/catalog.go:54
+msgid "Dog"
+msgstr "Hund"
+
+#: internal/emoji/catalog.go:69
+msgid "Donut"
+msgstr "Donut"
+
+#: internal/config/config.go:120 internal/config/config.go:127
+msgid "Driver"
+msgstr "Treiber"
+
+#: internal/config/config.go:142
+msgid "Echo on CapsLock"
+msgstr "Zeichenansage bei Feststelltaste"
+
+#: internal/emoji/catalog.go:82
+msgid "Egg"
+msgstr "Ei"
+
+#: internal/emoji/catalog.go:162
+msgid "Emoji"
+msgstr "Emoji"
+
+#: internal/command/descriptions.go:53 internal/emoji/catalog.go:14
+msgid "Emoji Menu"
+msgstr "Emoji-Menü"
+
+#: internal/config/config.go:160
+msgid "Emoticons"
+msgstr "Emoticons"
+
+#: internal/config/config.go:107
+msgid "Enable Persistent Clipboard"
+msgstr "Dauerhafte Zwischenablage aktivieren"
+
+#: internal/app/tutorial.go:21
+msgid ""
+"Entering tutorial mode. In this mode commands are described but not "
+"executed. Use up and down to browse actions and left and right to switch "
+"between Active actions, Unbound actions, and Plugins. Press Space to repeat "
+"the current item. To exit tutorial mode press Fenrir+F1 or Escape. Active "
+"actions."
+msgstr ""
+"Anleitungsmodus gestartet. In diesem Modus werden Befehle beschrieben, aber "
+"nicht ausgeführt. Mit Auf und Ab Aktionen durchgehen und mit Links und "
+"Rechts zwischen aktiven Aktionen, unbelegten Aktionen und Plugins wechseln. "
+"Mit der Leertaste den aktuellen Eintrag wiederholen. Zum Verlassen Fenrir+F1 "
+"oder Escape drücken. Aktive Aktionen."
+
+#: internal/app/spellcheck_menu.go:49 internal/app/spellcheck_menu.go:52
+msgid "Exceptions"
+msgstr "Ausnahmen"
+
+#: internal/review/follow_cursor.go:29
+msgid "Exiting Review Mode"
+msgstr "Lesemodus verlassen"
+
+#: internal/app/tutorial.go:22
+msgid "Exiting tutorial mode. To enter tutorial mode again press Fenrir+F1"
+msgstr "Anleitungsmodus verlassen. Zum erneuten Aufrufen Fenrir+F1 drücken"
+
+#: internal/emoji/catalog.go:30
+msgid "Exploding head"
+msgstr "Explodierender Kopf"
+
+#: internal/command/descriptions.go:113
+msgid "Export current fenrir clipboard to X or GUI clipboard"
+msgstr ""
+"Aktuelle Fenrir-Zwischenablage in die grafische Zwischenablage exportieren"
+
+#: internal/emoji/catalog.go:31
+msgid "Face with symbols"
+msgstr "Gesicht mit Symbolen"
+
+#: internal/app/quick_settings.go:295 internal/app/runtime_settings.go:151
+msgid "Failed to save settings."
+msgstr "Einstellungen konnten nicht gespeichert werden."
+
+#: internal/command/descriptions.go:55 internal/preferences/settings.go:38
+msgid "Fenrir Menu"
+msgstr "Fenrir-Menü"
+
+#: internal/app/commands.go:83
+msgid "Fenrir screen reader version {0}-{1}"
+msgstr "Fenrir-Bildschirmleser Version {0}-{1}"
+
+#: internal/app/clipboard.go:377
+msgid "File does not exist"
+msgstr "Datei existiert nicht"
+
+#: internal/emoji/catalog.go:124
+msgid "Finland"
+msgstr "Finnland"
+
+#: internal/emoji/catalog.go:104
+msgid "Fire"
+msgstr "Feuer"
+
+#: internal/emoji/catalog.go:83
+msgid "Fireworks"
+msgstr "Feuerwerk"
+
+#: internal/app/clipboard.go:323
+msgid "First clipboard "
+msgstr "Erste Zwischenablage "
+
+#: internal/emoji/catalog.go
+msgid "Flags"
+msgstr "Flaggen"
+
+#: internal/emoji/catalog.go:105
+msgid "Folded hands"
+msgstr "Gefaltete HƤnde"
+
+#: internal/emoji/catalog.go
+msgid "Food"
+msgstr "Essen"
+
+#: internal/app/commands.go:126
+msgid "Forward next keypress"
+msgstr "NƤchste Taste durchreichen"
+
+#: internal/emoji/catalog.go:125
+msgid "France"
+msgstr "Frankreich"
+
+#: internal/emoji/catalog.go:126
+msgid "Germany"
+msgstr "Deutschland"
+
+#: internal/emoji/catalog.go:84
+msgid "Ghost"
+msgstr "Geist"
+
+#: internal/emoji/catalog.go:85
+msgid "Gift"
+msgstr "Geschenk"
+
+#: internal/emoji/catalog.go:127
+msgid "Greece"
+msgstr "Griechenland"
+
+#: internal/emoji/catalog.go:32
+msgid "Grinning"
+msgstr "Grinsend"
+
+#: internal/emoji/catalog.go:33
+msgid "Grinning sweat"
+msgstr "Grinsend mit SchweiĆtropfen"
+
+#: internal/emoji/catalog.go:70
+msgid "Hamburger"
+msgstr "Hamburger"
+
+#: internal/emoji/catalog.go:106
+msgid "Heart"
+msgstr "Herz"
+
+#: internal/emoji/catalog.go:34
+msgid "Heart eyes"
+msgstr "Herzaugen"
+
+#: internal/emoji/catalog.go:35
+msgid "Hearts around"
+msgstr "Von Herzen umgeben"
+
+#: internal/emoji/catalog.go:107
+msgid "High voltage"
+msgstr "Hochspannung"
+
+#: internal/config/config.go:109
+msgid "History items"
+msgstr "EintrƤge im Verlauf"
+
+#: internal/emoji/catalog.go
+msgid "Holidays"
+msgstr "Feiertage"
+
+#: internal/emoji/catalog.go:108
+msgid "Hundred points"
+msgstr "Hundert Punkte"
+
+#: internal/emoji/catalog.go:36
+msgid "Imp"
+msgstr "Kobold"
+
+#: internal/app/clipboard.go:412
+msgid "Import to Clipboard"
+msgstr "In Zwischenablage importieren"
+
+#: internal/config/config.go:153
+msgid "Indentation"
+msgstr "Einrückung"
+
+#: internal/emoji/catalog.go:128
+msgid "India"
+msgstr "Indien"
+
+#: internal/emoji/catalog.go:129
+msgid "Ireland"
+msgstr "Irland"
+
+#: internal/emoji/catalog.go:130
+msgid "Israel"
+msgstr "Israel"
+
+#: internal/emoji/catalog.go:131
+msgid "Italy"
+msgstr "Italien"
+
+#: internal/emoji/catalog.go:86
+msgid "Jack o'lantern"
+msgstr "Halloween-Kürbis"
+
+#: internal/emoji/catalog.go:132
+msgid "Japan"
+msgstr "Japan"
+
+#: internal/config/config.go:53
+msgid "Keyboard"
+msgstr "Tastatur"
+
+#: internal/app/clipboard.go:326
+msgid "Last clipboard "
+msgstr "Letzte Zwischenablage "
+
+#: internal/emoji/catalog.go:37
+msgid "Laughing"
+msgstr "Lachend"
+
+#: internal/config/config.go:139
+msgid "Layout"
+msgstr "Tastaturbelegung"
+
+#: internal/command/descriptions.go:44
+msgid "Line number for cursor"
+msgstr "Zeilennummer des Cursors"
+
+#: internal/command/descriptions.go:75
+msgid "List reading regions"
+msgstr "Lesebereiche auflisten"
+
+#: internal/reader/regions.go:185
+msgid "Live region {0} cleared"
+msgstr "Live-Bereich {0} gelƶscht"
+
+#: internal/reader/regions.go:181
+msgid "Live region {0} not set"
+msgstr "Live-Bereich {0} nicht festgelegt"
+
+#: internal/reader/regions.go:189
+msgid "Live region {0} outside screen"
+msgstr "Live-Bereich {0} auĆerhalb des Bildschirms"
+
+#: internal/reader/regions.go:209
+msgid "Live region {0} set"
+msgstr "Live-Bereich {0} festgelegt"
+
+#: internal/reader/regions.go:245
+msgid "Live region {0}: {1}"
+msgstr "Live-Bereich {0}: {1}"
+
+#: internal/reader/regions.go:232
+msgid "Live regions cleared"
+msgstr "Live-Bereiche gelƶscht"
+
+#: internal/emoji/catalog.go:87
+msgid "Mage"
+msgstr "Magier"
+
+#: internal/emoji/catalog.go:133
+msgid "Mexico"
+msgstr "Mexiko"
+
+#: internal/config/config.go:159
+msgid "Misspelling sound"
+msgstr "Signalton bei Rechtschreibfehlern"
+
+#: internal/emoji/catalog.go:55
+msgid "Moon"
+msgstr "Mond"
+
+#: internal/command/descriptions.go:35
+msgid "Move Review to the end of current line and display the content"
+msgstr "Lesecursor zum Ende der aktuellen Zeile bewegen und Inhalt ausgeben"
+
+#: internal/command/descriptions.go:36
+msgid "Move Review to the first character on the line"
+msgstr "Lesecursor zum ersten Zeichen der Zeile bewegen"
+
+#: internal/command/descriptions.go:48
+msgid "Move Review to the first character on the screen (left top)"
+msgstr "Lesecursor zum ersten Bildschirmzeichen bewegen, oben links"
+
+#: internal/command/descriptions.go:37
+msgid "Move Review to the last character on the line"
+msgstr "Lesecursor zum letzten Zeichen der Zeile bewegen"
+
+#: internal/command/descriptions.go:49
+msgid "Move Review to the last character on the screen (right bottom)"
+msgstr "Lesecursor zum letzten Bildschirmzeichen bewegen, unten rechts"
+
+#: internal/command/descriptions.go:33
+msgid "Move review to the bottom of the screen"
+msgstr "Lesecursor zum Bildschirmende bewegen"
+
+#: internal/command/descriptions.go:31
+msgid "Move review to the character below the current position"
+msgstr "Lesecursor zum Zeichen unter der aktuellen Position bewegen"
+
+#: internal/command/descriptions.go:30
+msgid "Move review to the character in the line above the current position"
+msgstr ""
+"Lesecursor zum Zeichen in der Zeile über der aktuellen Position bewegen"
+
+#: internal/command/descriptions.go:29
+msgid "Moves review to the next character"
+msgstr "Bewegt den Lesecursor zum nƤchsten Zeichen"
+
+#: internal/emoji/catalog.go
+msgid "Nature"
+msgstr "Natur"
+
+#: internal/emoji/catalog.go:38
+msgid "Nauseated"
+msgstr "Ćbelkeit"
+
+#: internal/emoji/catalog.go:134
+msgid "Netherlands"
+msgstr "Niederlande"
+
+#: internal/interaction/grid.go:43 internal/interaction/grid.go:79
+msgid "No matches"
+msgstr "Keine Treffer"
+
+#: internal/reader/regions.go:251
+msgid "No reading regions set"
+msgstr "Keine Lesebereiche festgelegt"
+
+#: internal/app/clipboard.go:416
+msgid "No text found in clipboard or no accessible display"
+msgstr "Kein Text in der Zwischenablage oder kein zugƤngliches Display"
+
+#: internal/app/spellcheck_menu.go
+msgid "No word"
+msgstr "Kein Wort"
+
+#: internal/emoji/catalog.go:135
+msgid "Norway"
+msgstr "Norwegen"
+
+#: internal/review/follow_cursor.go:26
+msgid "Not in Review Mode"
+msgstr "Nicht im Lesemodus"
+
+#: internal/reader/regions.go:222
+msgid "Not in window Mode"
+msgstr "Nicht im Fenstermodus"
+
+#: internal/preferences/settings.go:79
+msgid "OK"
+msgstr "OK"
+
+#: internal/app/clipboard.go:269 internal/reader/regions.go:197
+msgid "One or two marks are needed"
+msgstr "Eine oder zwei Markierungen werden benƶtigt"
+
+#: internal/emoji/catalog.go:109
+msgid "Peace"
+msgstr "Frieden"
+
+#: internal/emoji/catalog.go
+msgid "People"
+msgstr "Menschen"
+
+#: internal/command/descriptions.go:25
+msgid "Phonetically spells the current word"
+msgstr "Buchstabiert das aktuelle Wort mit dem Buchstabieralphabet"
+
+#: internal/config/config.go:133
+msgid "Pitch"
+msgstr "Tonhƶhe"
+
+#: internal/emoji/catalog.go:71
+msgid "Pizza"
+msgstr "Pizza"
+
+#: internal/emoji/catalog.go:39
+msgid "Pleading"
+msgstr "Flehend"
+
+#: internal/app/tutorial.go
+msgid "Plugins"
+msgstr "Plugins"
+
+#: internal/emoji/catalog.go:136
+msgid "Poland"
+msgstr "Polen"
+
+#: internal/emoji/catalog.go:40
+msgid "Poop"
+msgstr "Kothaufen"
+
+#: internal/emoji/catalog.go:137
+msgid "Portugal"
+msgstr "Portugal"
+
+#: internal/command/descriptions.go:52
+msgid "Present the version of Fenrir currently in use."
+msgstr "Verwendete Fenrir-Version ansagen."
+
+#: internal/config/config.go:52
+msgid "Presentation"
+msgstr "Ausgabe"
+
+#: internal/command/descriptions.go:100
+msgid ""
+"Presents the currently selected text that will be copied to the clipboard"
+msgstr ""
+"Gibt den aktuell ausgewƤhlten Text aus, der in die Zwischenablage kopiert "
+"wird"
+
+#: internal/app/commands.go:112
+msgid "Progress monitoring disabled"
+msgstr "Fortschrittsüberwachung deaktiviert"
+
+#: internal/app/commands.go:110
+msgid "Progress monitoring enabled"
+msgstr "Fortschrittsüberwachung aktiviert"
+
+#: internal/sound/drivers.go:39
+msgid "PulseAudio"
+msgstr "PulseAudio"
+
+#: internal/config/config.go:149
+msgid "Punctuation level"
+msgstr "Satzzeichenstufe"
+
+#: internal/config/config.go:148
+msgid "Punctuation profile"
+msgstr "Satzzeichenprofil"
+
+#: internal/app/quick_settings.go:115
+msgid "Quick menu not available"
+msgstr "Schnellmenü nicht verfügbar"
+
+#: internal/emoji/catalog.go:56
+msgid "Rainbow"
+msgstr "Regenbogen"
+
+#: internal/config/config.go:150
+msgid "Raise pitch for capital letters"
+msgstr "Tonhƶhe bei GroĆbuchstaben anheben"
+
+#: internal/config/config.go:126
+msgid "Rate"
+msgstr "Geschwindigkeit"
+
+#: internal/punctuation/emoticons.go:33
+msgid "Raves"
+msgstr "Raves"
+
+#: internal/command/descriptions.go:78
+msgid "Read live region 1"
+msgstr "Live-Bereich 1 lesen"
+
+#: internal/command/descriptions.go:96
+msgid "Read live region 10"
+msgstr "Live-Bereich 10 lesen"
+
+#: internal/command/descriptions.go:80
+msgid "Read live region 2"
+msgstr "Live-Bereich 2 lesen"
+
+#: internal/command/descriptions.go:82
+msgid "Read live region 3"
+msgstr "Live-Bereich 3 lesen"
+
+#: internal/command/descriptions.go:84
+msgid "Read live region 4"
+msgstr "Live-Bereich 4 lesen"
+
+#: internal/command/descriptions.go:86
+msgid "Read live region 5"
+msgstr "Live-Bereich 5 lesen"
+
+#: internal/command/descriptions.go:88
+msgid "Read live region 6"
+msgstr "Live-Bereich 6 lesen"
+
+#: internal/command/descriptions.go:90
+msgid "Read live region 7"
+msgstr "Live-Bereich 7 lesen"
+
+#: internal/command/descriptions.go:92
+msgid "Read live region 8"
+msgstr "Live-Bereich 8 lesen"
+
+#: internal/command/descriptions.go:94
+msgid "Read live region 9"
+msgstr "Live-Bereich 9 lesen"
+
+#: internal/config/config.go:157
+msgid "Read misspelled word"
+msgstr "Falsch geschriebenes Wort vorlesen"
+
+#: internal/config/config.go:54
+msgid "Reading"
+msgstr "Lesen"
+
+#: internal/reader/regions.go:236
+msgid "Reading regions cleared"
+msgstr "Lesebereiche gelƶscht"
+
+#: internal/reader/regions.go:241
+msgid "Reading window: {0}"
+msgstr "Lesefenster: {0}"
+
+#: internal/command/descriptions.go:42
+msgid "Reads attributes of current cursor position"
+msgstr "Liest die Attribute an der aktuellen Cursorposition"
+
+#: internal/command/descriptions.go:39
+msgid "Reads from the top of the screen to the cursor position"
+msgstr "Liest vom oberen Bildschirmrand bis zur Cursorposition"
+
+#: internal/app/clipboard.go:264
+msgid "Remove marks"
+msgstr "Marken entfernen"
+
+#: internal/command/descriptions.go:99
+msgid "Removes marks from selected text"
+msgstr "Entfernt die Markierungen des ausgewƤhlten Textes"
+
+#: internal/config/config.go:161
+msgid "Repeated symbols"
+msgstr "Wiederholte Symbole"
+
+#: internal/config/config.go:162
+msgid "Repeated symbols minimum length"
+msgstr "Mindestanzahl wiederholter Symbole"
+
+#: internal/emoji/catalog.go:110
+msgid "Rock on"
+msgstr "Rock-Geste"
+
+#: internal/emoji/catalog.go:41
+msgid "Rolling laughing"
+msgstr "Sich vor Lachen wƤlzend"
+
+#: internal/emoji/catalog.go:57
+msgid "Rose"
+msgstr "Rose"
+
+#: internal/emoji/catalog.go:138
+msgid "Russia"
+msgstr "Russland"
+
+#: internal/emoji/catalog.go:42
+msgid "Sad"
+msgstr "Traurig"
+
+#: internal/emoji/catalog.go:88
+msgid "Santa"
+msgstr "Weihnachtsmann"
+
+#: internal/command/descriptions.go:64
+msgid "Saves your current Fenrir settings so they are the default."
+msgstr "Speichert die aktuellen Fenrir-Einstellungen als Standard."
+
+#: internal/emoji/catalog.go:43
+msgid "Savoring food"
+msgstr "Essen genieĆend"
+
+#: internal/config/config.go:152
+msgid "Say ācapā before capital letters"
+msgstr "āGroĆā vor GroĆbuchstaben ansagen"
+
+#: internal/config/config.go:158
+msgid "Say āmisspelledā"
+msgstr "āFalsch geschriebenā ansagen"
+
+#: internal/emoji/catalog.go:44
+msgid "Screaming"
+msgstr "Schreiend"
+
+#: internal/app/commands.go:135 internal/app/scripts.go
+msgid "Script failed"
+msgstr "Skript fehlgeschlagen"
+
+#: internal/app/scripts.go
+msgid "Script file is not executable"
+msgstr "Skriptdatei ist nicht ausführbar"
+
+#: internal/app/scripts.go
+msgid "Script file not found"
+msgstr "Skriptdatei nicht gefunden"
+
+#: internal/app/scripts.go
+msgid "Script source is not a valid file"
+msgstr "Skriptquelle ist keine gültige Datei"
+
+#: internal/interaction/grid.go:71
+msgid "Search cleared"
+msgstr "Suche gelƶscht"
+
+#: internal/interaction/grid.go:73
+msgid "Search {0}"
+msgstr "Suche {0}"
+
+#: internal/config/config.go:121
+msgid "Select sound theme"
+msgstr "Klangthema auswƤhlen"
+
+#: internal/config/config.go:128
+msgid "Serial port"
+msgstr "Serielle Schnittstelle"
+
+#: internal/command/descriptions.go:74
+msgid "Set a live region from one or two marks"
+msgstr "Live-Bereich aus einer oder zwei Markierungen festlegen"
+
+#: internal/reader/regions.go:213
+msgid "Set window begin and end marks"
+msgstr "Setze Fensteranfangs- und -endmarken"
+
+#: internal/app/quick_settings.go:298
+msgid "Settings saved."
+msgstr "Einstellungen gespeichert."
+
+#: internal/emoji/catalog.go:89
+msgid "Shamrock"
+msgstr "Kleeblatt"
+
+#: internal/emoji/catalog.go:90
+msgid "Skull"
+msgstr "SchƤdel"
+
+#: internal/emoji/catalog.go:111
+msgid "Skull crossbones"
+msgstr "Totenkopf mit gekreuzten Knochen"
+
+#: internal/emoji/catalog.go:45
+msgid "Smiling"
+msgstr "LƤchelnd"
+
+#: internal/emoji/catalog.go:46
+msgid "Smirking"
+msgstr "SelbstgefƤllig grinsend"
+
+#: internal/emoji/catalog.go:91
+msgid "Snowman"
+msgstr "Schneemann"
+
+#: internal/config/config.go:95
+msgid "Sound"
+msgstr "Klang"
+
+#: internal/config/config.go:118
+msgid "Sound enabled"
+msgstr "Klang aktiviert"
+
+#: internal/emoji/catalog.go:139
+msgid "South Africa"
+msgstr "Südafrika"
+
+#: internal/emoji/catalog.go:140
+msgid "South Korea"
+msgstr "Südkorea"
+
+#: internal/emoji/catalog.go:141
+msgid "Spain"
+msgstr "Spanien"
+
+#: internal/emoji/catalog.go:112
+msgid "Sparkles"
+msgstr "Funkeln"
+
+#: internal/config/config.go:94
+msgid "Speech"
+msgstr "Sprache"
+
+#: internal/speech/drivers.go:44
+msgid "Speech Dispatcher"
+msgstr "Speech Dispatcher"
+
+#: internal/config/config.go:130
+msgid "Speech Dispatcher module"
+msgstr "Speech-Dispatcher-Modul"
+
+#: internal/config/config.go:125
+msgid "Speech enabled"
+msgstr "Sprachausgabe aktiviert"
+
+#: internal/config/config.go:131
+msgid "Speech language"
+msgstr "Ausgabesprache"
+
+#: internal/app/screen_updates.go:72
+msgid "Speech restored"
+msgstr "Sprachausgabe wiederhergestellt"
+
+#: internal/config/config.go:132
+msgid "Speech voice"
+msgstr "Stimme"
+
+#: internal/app/spellcheck_menu.go:81
+msgid "Spell checking is unavailable"
+msgstr "Rechtschreibprüfung nicht verfügbar"
+
+#: internal/app/spellcheck_menu.go:180
+msgid "Spell checking is unavailable: {0}"
+msgstr "Rechtschreibprüfung nicht verfügbar: {0}"
+
+#: internal/command/descriptions.go:54
+msgid "Spellcheck Menu"
+msgstr "Rechtschreibmenü"
+
+#: internal/command/descriptions.go:24
+msgid "Spells the current word"
+msgstr "Buchstabiert das aktuelle Wort"
+
+#: internal/emoji/catalog.go:92
+msgid "Spider"
+msgstr "Spinne"
+
+#: internal/emoji/catalog.go:113
+msgid "Star"
+msgstr "Stern"
+
+#: internal/app/screen_updates.go:39
+msgid "Start Fenrir"
+msgstr "Fenrir starten"
+
+#: internal/emoji/catalog.go:72
+msgid "Strawberry"
+msgstr "Erdbeere"
+
+#: internal/app/spellcheck_menu.go:45 internal/app/spellcheck_menu.go:47
+msgid "Suggestions"
+msgstr "VorschlƤge"
+
+#: internal/emoji/catalog.go:58
+msgid "Sun"
+msgstr "Sonne"
+
+#: internal/emoji/catalog.go:59
+msgid "Sunflower"
+msgstr "Sonnenblume"
+
+#: internal/emoji/catalog.go:142
+msgid "Sweden"
+msgstr "Schweden"
+
+#: internal/emoji/catalog.go:143
+msgid "Switzerland"
+msgstr "Schweiz"
+
+#: internal/emoji/catalog.go:114
+msgid "Sword"
+msgstr "Schwert"
+
+#: internal/emoji/catalog.go
+msgid "Symbols"
+msgstr "Symbole"
+
+#: internal/emoji/catalog.go:73
+msgid "Taco"
+msgstr "Taco"
+
+#: internal/preferences/settings.go:72
+msgid "Test synthesizer"
+msgstr "Sprachsynthesizer testen"
+
+#: internal/app/fenrir_menu.go:139
+msgid "Testing voice"
+msgstr "Stimme wird getestet"
+
+#: internal/emoji/catalog.go:47
+msgid "Thumbs up"
+msgstr "Daumen hoch"
+
+#: internal/config/config.go:97
+msgid "Time and date"
+msgstr "Uhrzeit und Datum"
+
+#: internal/config/config.go:101
+msgid "Time format"
+msgstr "Zeitformat"
+
+#: internal/command/descriptions.go:67
+msgid "Toggle progress bar monitoring with ascending tones"
+msgstr "Fortschrittsüberwachung mit ansteigenden Tönen umschalten"
+
+#: internal/review/top.go:29
+msgid "Top"
+msgstr "Oben"
+
+#: internal/config/config.go:147
+msgid "Tracking mode"
+msgstr "Verfolgungsmodus"
+
+#: internal/emoji/catalog.go:60
+msgid "Tree"
+msgstr "Baum"
+
+#: internal/emoji/catalog.go:144 internal/emoji/catalog.go:93
+msgid "Turkey"
+msgstr "Truthahn"
+
+#: internal/command/descriptions.go:73
+msgid "Turn off window mode"
+msgstr "Fenstermodus ausschalten"
+
+#: internal/emoji/catalog.go:145
+msgid "Ukraine"
+msgstr "Ukraine"
+
+#: internal/app/tutorial.go:23
+msgid ""
+"Unable to exit tutorial mode because exclusive keyboard capture could not be "
+"released. Press Fenrir+F1 or Escape to try again."
+msgstr ""
+"Der Anleitungsmodus kann nicht verlassen werden, da die exklusive "
+"Tastaturerfassung nicht freigegeben werden konnte. Mit Fenrir+F1 oder Escape "
+"erneut versuchen."
+
+#: internal/app/tutorial.go
+msgid "Unbound actions"
+msgstr "Unbelegte Aktionen"
+
+#: internal/app/spellcheck_menu.go:53
+msgid "Undo"
+msgstr "Rückgängig"
+
+#: internal/emoji/catalog.go:146
+msgid "United Kingdom"
+msgstr "Vereinigtes Kƶnigreich"
+
+#: internal/emoji/catalog.go:147
+msgid "United States"
+msgstr "Vereinigte Staaten"
+
+#: internal/emoji/catalog.go:94
+msgid "Vampire"
+msgstr "Vampir"
+
+#: internal/app/quick_settings.go:191 internal/app/synthesizer_preview.go:60
+msgid "Voice test failed"
+msgstr "Stimmtest fehlgeschlagen"
+
+#: internal/app/synthesizer_preview.go:23
+msgid "Voice test: The quick brown fox jumps over the lazy dog."
+msgstr ""
+"Stimmtest: Franz jagt im komplett verwahrlosten Taxi quer durch Bayern."
+
+#: internal/config/config.go:119 internal/config/config.go:134
+msgid "Volume"
+msgstr "LautstƤrke"
+
+#: internal/punctuation/emoticons.go:32
+msgid "WTF?"
+msgstr "Was zum Teufel?"
+
+#: internal/app/tutorial.go:24
+msgid ""
+"Warning: full keyboard capture is unavailable. Unbound keys may reach the "
+"active application."
+msgstr ""
+"Warnung: Vollständige Tastaturerfassung nicht verfügbar. Unbelegte Tasten "
+"kƶnnen die aktive Anwendung erreichen."
+
+#: internal/emoji/catalog.go:95
+msgid "Web"
+msgstr "Spinnennetz"
+
+#: internal/reader/regions.go:225
+msgid "Window Mode off for terminal {0}"
+msgstr "Fenstermodus für Terminal {0} aus"
+
+#: internal/reader/regions.go:219
+msgid "Window Mode on for terminal {0}"
+msgstr "Fenstermodus für Terminal {0} an"
+
+#: internal/emoji/catalog.go:48
+msgid "Winking"
+msgstr "Zwinkernd"
+
+#: internal/emoji/catalog.go:96
+msgid "Witch"
+msgstr "Hexe"
+
+#: internal/emoji/catalog.go:61
+msgid "Wolf"
+msgstr "Wolf"
+
+#: internal/app/spellcheck_menu.go:46 internal/app/spellcheck_menu.go:50
+msgid "Word"
+msgstr "Wort"
+
+#: internal/config/config.go:141
+msgid "Word echo"
+msgstr "Wortansage"
+
+#: internal/emoji/catalog.go:97
+msgid "Zombie"
+msgstr "Zombie"
+
+#: internal/punctuation/profile.go
+msgid "all"
+msgstr "alle"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "and"
+msgstr "und"
+
+#: internal/punctuation/emoticons.go:17
+msgid "angry"
+msgstr "wütend"
+
+#: internal/reader/attributes.go:36
+msgid "announcement of attributes disabled"
+msgstr "Attributansage deaktiviert"
+
+#: internal/reader/attributes.go:33
+msgid "announcement of attributes enabled"
+msgstr "Attributansage aktiviert"
+
+#: internal/punctuation/emoticons.go:26
+msgid "annoyed"
+msgstr "genervt"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "apostrophe"
+msgstr "Apostroph"
+
+#: internal/punctuation/emoticons.go:39
+msgid "arrow left"
+msgstr "Pfeil links"
+
+#: internal/punctuation/emoticons.go:38
+msgid "arrow right"
+msgstr "Pfeil rechts"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "at"
+msgstr "At-Zeichen"
+
+#: internal/reader/reader.go:197
+msgid "autoread disabled"
+msgstr "Automatisches Vorlesen ausgeschaltet"
+
+#: internal/reader/reader.go:195
+msgid "autoread enabled"
+msgstr "Automatisches Vorlesen eingeschaltet"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "backslash"
+msgstr "umgekehrter SchrƤgstrich"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "bang"
+msgstr "Ausrufezeichen"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "bar"
+msgstr "senkrechter Strich"
+
+#: internal/config/definitions.go:206
+msgid "beeps"
+msgstr "Signaltƶne"
+
+#: internal/review/line_start.go:28
+msgid "beginning of line"
+msgstr "Anfang der Zeile"
+
+#: internal/punctuation/emoticons.go:12
+msgid "big grin"
+msgstr "breites Grinsen"
+
+#: internal/presentation/attributes.go
+msgid "black"
+msgstr "schwarz"
+
+#: internal/app/clipboard.go:281 internal/presentation/text.go:35
+#: internal/reader/regions.go:191 internal/review/line_start.go:31
+#: internal/review/screen_range.go:39 internal/review/spelling.go:44
+#: internal/review/table.go:124 internal/review/table.go:205
+#: internal/review/vertical_character.go:31 internal/review/word.go:64
+msgid "blank"
+msgstr "Leer"
+
+#: internal/presentation/attributes.go
+msgid "blink"
+msgstr "blinkend"
+
+#: internal/presentation/attributes.go
+msgid "blue"
+msgstr "blau"
+
+#: internal/presentation/attributes.go
+msgid "bold"
+msgstr "fett"
+
+#: internal/punctuation/emoticons.go:31
+msgid "broken heart"
+msgstr "gebrochenes Herz"
+
+#: internal/presentation/attributes.go
+msgid "brown/yellow"
+msgstr "braun/gelb"
+
+#: internal/punctuation/emoticons.go:34
+msgid "bugged"
+msgstr "verƤrgert"
+
+#: internal/app/clipboard.go:93 internal/app/review_spelling.go:27
+msgid "cap"
+msgstr "groĆ"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "carrot"
+msgstr "Zirkumflex"
+
+#: internal/presentation/text.go:68
+msgid "character {0}"
+msgstr "Zeichen {0}"
+
+#: internal/interaction/presentation.go
+msgid "checked"
+msgstr "aktiviert"
+
+#: internal/command/descriptions.go:108
+msgid "clears the currently selected clipboard"
+msgstr "Lƶscht die momentan gewƤhlte Zwischenablage"
+
+#: internal/app/clipboard.go:299
+msgid "clipboard cleared"
+msgstr "Zwischenablage gelƶscht"
+
+#: internal/app/clipboard.go:302 internal/app/clipboard.go:337
+msgid "clipboard empty"
+msgstr "Zwischenablage ist leer"
+
+#: internal/app/clipboard.go:395
+msgid "clipboard exported to file"
+msgstr "Zwischenablage in Datei exportiert"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "colon"
+msgstr "Doppelpunkt"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "comma"
+msgstr "Komma"
+
+#: internal/punctuation/emoticons.go:27
+msgid "confused"
+msgstr "verwirrt"
+
+#: internal/punctuation/emoticons.go:20
+msgid "cool"
+msgstr "cool"
+
+#: internal/command/descriptions.go:102
+msgid "copies last presented text to the clipboard"
+msgstr "kopiert den zuletzt ausgegebenen Text in die Zwischenablage"
+
+#: internal/command/descriptions.go:101
+msgid "copies marked text to the currently selected clipboard"
+msgstr "Kopiert markierten Text in die gewƤhlte Zwischenablage"
+
+#: internal/app/spellcheck_menu.go
+msgid "correct"
+msgstr "Korrekt"
+
+#: internal/review/table.go:132
+msgid "could not set header row"
+msgstr "Kopfzeile konnte nicht festgelegt werden"
+
+#: internal/punctuation/emoticons.go:18
+msgid "crying"
+msgstr "weinend"
+
+#: internal/command/descriptions.go:17
+msgid "current line"
+msgstr "Aktuelle Zeile"
+
+#: internal/command/descriptions.go:20
+msgid "current word."
+msgstr "Aktuelles Wort."
+
+#: internal/config/definitions.go:225 internal/reader/reader.go:190
+msgid "cursor tracking"
+msgstr "Cursor-Verfolgung"
+
+#: internal/presentation/attributes.go
+msgid "cyan"
+msgstr "türkis"
+
+#: internal/presentation/attributes.go
+msgid "dark gray"
+msgstr "dunkelgrau"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "dash"
+msgstr "Bindestrich"
+
+#: internal/app/quick_settings.go:185 internal/app/quick_settings.go:234
+#: internal/config/definitions.go:237 internal/preferences/discovery.go:45
+#: internal/preferences/discovery.go:55 internal/preferences/discovery.go:81
+#: internal/presentation/attributes.go:18
+#: internal/presentation/attributes.go:44
+msgid "default"
+msgstr "Standard"
+
+#: internal/command/descriptions.go:66
+msgid "disables speech until next keypress"
+msgstr "Sprachausgabe bis zum nƤchsten Tastendruck ausschalten"
+
+#: internal/command/descriptions.go:43
+msgid "displays the position of the review cursor"
+msgstr "Zeigt die Position des Bildschirmcursors an"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "dollar"
+msgstr "Dollar"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "dot"
+msgstr "Punkt"
+
+#: internal/command/descriptions.go:68
+msgid "enables or disables automatic reading of new text as it appears"
+msgstr "Automatisches Vorlesen von neuem Text ein- oder ausschalten"
+
+#: internal/command/descriptions.go:65
+msgid "enables or disables speech"
+msgstr "Schaltet die Sprachausgabe ein oder aus"
+
+#: internal/command/descriptions.go:41
+msgid "enables or disables the announcement of attributes"
+msgstr "aktiviert oder deaktiviert die Attributansage"
+
+#: internal/command/descriptions.go:69
+msgid "enables or disables tracking of highlighted text"
+msgstr "Schaltet die Verfolgung von hervorgehobenem Text ein oder aus"
+
+#: internal/review/table.go:218
+msgid "end of cell"
+msgstr "Zellenende"
+
+#: internal/review/line_end.go:24 internal/review/table.go:175
+msgid "end of line"
+msgstr "Ende der Zeile"
+
+#: internal/review/navigation.go:17
+msgid "end of screen"
+msgstr "Ende des Bildschirms"
+
+#: internal/punctuation/emoticons.go:37
+msgid "enjoy smile"
+msgstr "zufriedenes LƤcheln"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "equals"
+msgstr "Gleichheitszeichen"
+
+#: internal/punctuation/emoticons.go:36
+msgid "evil smile"
+msgstr "bƶses LƤcheln"
+
+#: internal/command/descriptions.go:71
+msgid "exits Fenrir"
+msgstr "Beendet Fenrir"
+
+#: internal/command/descriptions.go:50
+msgid "exits review mode"
+msgstr "VerlƤsst die Bildschirmdarstellung"
+
+#: internal/command/descriptions.go:111
+msgid "export the current fenrir clipboard to a file"
+msgstr "Aktuelle Fenrir-Zwischenablage in Datei exportieren"
+
+#: internal/app/clipboard.go:391
+msgid "exported to the X session."
+msgstr "In die X-Sitzung exportiert."
+
+#: internal/app/clipboard.go:372
+msgid "failed to export to X clipboard. No available display found."
+msgstr ""
+"Export in die X-Zwischenablage fehlgeschlagen. Kein verfügbares Display "
+"gefunden."
+
+#: internal/review/table.go:190
+msgid "first cell"
+msgstr "erste Zelle"
+
+#: internal/review/table.go:224
+msgid "first character in cell {0}"
+msgstr "erstes Zeichen in Zelle {0}"
+
+#: internal/review/first_text.go:35
+msgid "first character in line indent {0}"
+msgstr "erstes Zeichen in der Zeile, Einrückung {0}"
+
+#: internal/review/screen_first_character.go:13
+msgid "first character in screen"
+msgstr "erstes Zeichen auf dem Bildschirm"
+
+#: internal/punctuation/emoticons.go:11
+msgid "frown"
+msgstr "Stirnrunzeln"
+
+#: internal/punctuation/emoticons.go:28
+msgid "frustrated"
+msgstr "frustriert"
+
+#: internal/command/descriptions.go:58
+msgid "get current quick menu entry"
+msgstr "aktuellen Schnellmenüeintrag ansagen"
+
+#: internal/command/descriptions.go:59
+msgid "get current quick menu value"
+msgstr "aktuellen Schnellmenüwert ansagen"
+
+#: internal/command/descriptions.go:56
+msgid "get next quick menu category"
+msgstr "nächste Schnellmenükategorie auswählen"
+
+#: internal/command/descriptions.go:60
+msgid "get next quick menu entry"
+msgstr "nächsten Schnellmenüeintrag auswählen"
+
+#: internal/command/descriptions.go:62
+msgid "get next quick menu value"
+msgstr "nächsten Schnellmenüwert auswählen"
+
+#: internal/command/descriptions.go:57
+msgid "get previous quick menu category"
+msgstr "vorherige Schnellmenükategorie auswählen"
+
+#: internal/command/descriptions.go:61
+msgid "get previous quick menu entry"
+msgstr "vorherigen Schnellmenüeintrag auswählen"
+
+#: internal/command/descriptions.go:63
+msgid "get previous quick menu value"
+msgstr "vorherigen Schnellmenüwert auswählen"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "grave"
+msgstr "Gravis"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "greater"
+msgstr "grƶĆer als"
+
+#: internal/presentation/attributes.go
+msgid "green"
+msgstr "grün"
+
+#: internal/punctuation/emoticons.go:25
+msgid "happy"
+msgstr "glücklich"
+
+#: internal/presentation/attributes.go:65
+msgid "has attribute"
+msgstr "hat Attribut"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "hash"
+msgstr "Raute"
+
+#: internal/review/table.go:138
+msgid "header row set"
+msgstr "Kopfzeile festgelegt"
+
+#: internal/punctuation/emoticons.go:30
+msgid "heart"
+msgstr "Herz"
+
+#: internal/config/definitions.go:225 internal/reader/reader.go:187
+msgid "highlight tracking"
+msgstr "Hervorhebungen verfolgen"
+
+#: internal/punctuation/emoticons.go:29
+msgid "hurray"
+msgstr "hurra"
+
+#: internal/command/descriptions.go:110
+msgid "imports text from clipboard file to the clipboard"
+msgstr "Importiert Text aus Datei in die Zwischenablage"
+
+#: internal/command/descriptions.go:112
+msgid "imports the graphical clipboard to Fenrir's clipboard"
+msgstr "importiert die grafische Zwischenablage in die Fenrir-Zwischenablage"
+
+#: internal/presentation/indentation.go:32
+msgid "indented "
+msgstr "eingerückt "
+
+#: internal/presentation/attributes.go
+msgid "italic"
+msgstr "kursiv"
+
+#: internal/punctuation/emoticons.go:22
+msgid "knocked out"
+msgstr "bewusstlos"
+
+#: internal/review/table.go:192
+msgid "last cell"
+msgstr "letzte Zelle"
+
+#: internal/review/table.go:227
+msgid "last character in cell {0}"
+msgstr "letztes Zeichen in Zelle {0}"
+
+#: internal/review/last_text.go:34
+msgid "last character in line"
+msgstr "letztes Zeichen in der Zeile"
+
+#: internal/review/screen_last_character.go:13
+msgid "last character in screen"
+msgstr "letztes Zeichen auf dem Bildschirm"
+
+#: internal/punctuation/emoticons.go:35
+msgid "laughing"
+msgstr "lachend"
+
+#: internal/punctuation/emoticons.go:24
+msgid "laughing hard"
+msgstr "laut lachend"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "left brace"
+msgstr "geschweifte Klammer auf"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "left bracket"
+msgstr "eckige Klammer auf"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "left paren"
+msgstr "Klammer auf"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "less"
+msgstr "kleiner als"
+
+#: internal/presentation/attributes.go
+msgid "light blue"
+msgstr "hellblau"
+
+#: internal/presentation/attributes.go
+msgid "light cyan"
+msgstr "hellcyan"
+
+#: internal/presentation/attributes.go
+msgid "light gray"
+msgstr "hellgrau"
+
+#: internal/presentation/attributes.go
+msgid "light green"
+msgstr "hellgrün"
+
+#: internal/presentation/attributes.go
+msgid "light magenta"
+msgstr "hellmagenta"
+
+#: internal/presentation/attributes.go
+msgid "light red"
+msgstr "hellrot"
+
+#: internal/presentation/attributes.go
+msgid "light yellow"
+msgstr "hellgelb"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "line"
+msgstr "Unterstrich"
+
+#: internal/review/navigation.go:25
+msgid "line break"
+msgstr "Zeilenumbruch"
+
+#: internal/review/first_text.go:29 internal/review/last_text.go:28
+msgid "line is empty"
+msgstr "Zeile ist leer"
+
+#: internal/review/position.go:27
+msgid "line {0}, column {1}, Terminal {2}"
+msgstr "Zeile {0}, Spalte {1}, Terminal {2}"
+
+#: internal/punctuation/emoticons.go:23
+msgid "lips sealed"
+msgstr "Lippen versiegelt"
+
+#: internal/presentation/attributes.go
+msgid "magenta"
+msgstr "magenta"
+
+#: internal/app/spellcheck.go:22 internal/app/spellcheck_menu.go
+msgid "misspelled"
+msgstr "Falsch geschrieben"
+
+#: internal/punctuation/profile.go
+msgid "most"
+msgstr "die meisten"
+
+#: internal/command/descriptions.go:32
+msgid "move review to top of screen"
+msgstr "Bewegt die Ansicht an den oberen Bildschirmrand"
+
+#: internal/command/descriptions.go:19
+msgid "moves review focus to the previous word "
+msgstr "bewegt den Lesecursor zum vorherigen Wort "
+
+#: internal/command/descriptions.go:18
+msgid "moves review to the next line "
+msgstr "bewegt den Lesecursor zur nƤchsten Zeile "
+
+#: internal/command/descriptions.go:21
+msgid "moves review to the next word "
+msgstr "bewegt den Lesecursor zum nƤchsten Wort "
+
+#: internal/command/descriptions.go:22
+msgid "moves review to the previous character"
+msgstr "bewegt den Lesecursor zum vorherigen Zeichen"
+
+#: internal/command/descriptions.go:16
+msgid "moves review to the previous line "
+msgstr "bewegt den Lesecursor zur vorherigen Zeile "
+
+#: internal/punctuation/emoticons.go:19
+msgid "neutral"
+msgstr "neutral"
+
+#: internal/command/descriptions.go internal/command/descriptions.go:26
+#: internal/command/descriptions.go:27
+msgid "no description available"
+msgstr "keine Beschreibung verfügbar"
+
+#: internal/review/marks.go:30
+msgid "no review cursor"
+msgstr "Kein Bildschirm-Cursor"
+
+#: internal/review/spelling.go:19 internal/review/table.go:150
+#: internal/review/table.go:158 internal/review/vertical_character.go:26
+#: internal/review/vertical_character.go:34
+msgid "no table data"
+msgstr "keine Tabellendaten"
+
+#: internal/reader/regions.go:173 internal/review/review.go:135
+msgid "no text console"
+msgstr "keine Textkonsole"
+
+#: internal/config/definitions.go:206 internal/punctuation/profile.go
+msgid "none"
+msgstr "keine"
+
+#: internal/punctuation/text_processing.go
+msgid "number"
+msgstr "Nummer"
+
+#: internal/app/clipboard.go:344 internal/app/clipboard.go:399
+msgid "paste clipboard"
+msgstr "Zwischenablage einfügen"
+
+#: internal/command/descriptions.go:109
+msgid "pastes the text from the currently selected clipboard"
+msgstr "Fügt den Text aus der momentan gewählten Zwischenablage ein"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "percent"
+msgstr "Prozent"
+
+#: internal/command/descriptions.go:98
+msgid "places marks to select text to copy to the clipboard"
+msgstr "Setzt Marken zum AuswƤhlen und Kopieren von Text in die Zwischenablage"
+
+#: internal/app/clipboard.go:271
+msgid "please set begin and endmark"
+msgstr "Bitte zuerst Start- und Endmarke setzen"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "plus"
+msgstr "Plus"
+
+#: internal/command/descriptions.go:23
+msgid "presents the current character."
+msgstr "Gibt das aktuelle Zeichen aus."
+
+#: internal/command/descriptions.go:15
+msgid "presents the date"
+msgstr "Gibt das Datum aus"
+
+#: internal/command/descriptions.go:14
+msgid "presents the time"
+msgstr "Gibt die Zeit aus"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "question?"
+msgstr "Fragezeichen"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "quote"
+msgstr "Anführungszeichen"
+
+#: internal/command/descriptions.go:46
+msgid ""
+"read line to cursor pos, use review cursor if you are in review mode, "
+"otherwhise use text cursor"
+msgstr ""
+"Zeile bis zum Cursor lesen, im Lesemodus den Lesecursor verwenden, sonst den "
+"Textcursor"
+
+#: internal/command/descriptions.go:47
+msgid ""
+"read to end of line, use review cursor if you are in review mode, otherwhise "
+"use text cursor"
+msgstr ""
+"Liest bis zum Ende der Zeile. Verwendet den Bildschirm-Cursor im Bildschirm-"
+"Modus, anderenfalls wird der Textcursor benutzt."
+
+#: internal/command/descriptions.go:40
+msgid "reads from the cursor to the bottom of the screen"
+msgstr "Liest vom Cursor bis zum unteren Bildschirmrand"
+
+#: internal/command/descriptions.go:38
+msgid "reads the contents of the current screen"
+msgstr "Liest den Inhalt des aktuellen Bildschirms vor"
+
+#: internal/presentation/attributes.go
+msgid "red"
+msgstr "rot"
+
+#: internal/presentation/attributes.go
+msgid "reverse"
+msgstr "invers"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "right brace"
+msgstr "geschweifte Klammer zu"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "right bracket"
+msgstr "eckige Klammer zu"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "right paren"
+msgstr "Klammer zu"
+
+#: internal/reader/regions.go:259
+msgid "row {0}, column {1} to end of line"
+msgstr "Zeile {0}, Spalte {1} bis Zeilenende"
+
+#: internal/reader/regions.go:261
+msgid "row {0}, column {1} to row {2}, column {3}"
+msgstr "Zeile {0}, Spalte {1} bis Zeile {2}, Spalte {3}"
+
+#: internal/review/read_screen.go:34
+msgid "screen is empty"
+msgstr "Bildschirm ist leer"
+
+#: internal/reader/reader.go:132
+msgid "screen {0}"
+msgstr "Bildschirm {0}"
+
+#: internal/interaction/entry.go:31
+msgid "selected"
+msgstr "ausgewƤhlt"
+
+#: internal/command/descriptions.go:106
+msgid "selects the first clipboard"
+msgstr "WƤhlt die erste Zwischenablage"
+
+#: internal/command/descriptions.go:107
+msgid "selects the last clipboard"
+msgstr "WƤhlt die letzte Zwischenablage"
+
+#: internal/command/descriptions.go:105
+msgid "selects the next clipboard"
+msgstr "WƤhlt die nƤchste Zwischenablage"
+
+#: internal/command/descriptions.go:104
+msgid "selects the previous clipboard"
+msgstr "WƤhlt die vorherige Zwischenablage"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "semicolon"
+msgstr "Semikolon"
+
+#: internal/command/descriptions.go:70
+msgid "sends the following keypress to the terminal or application"
+msgstr "sendet den nƤchsten Tastendruck an das Terminal oder die Anwendung"
+
+#: internal/command/descriptions.go:72
+msgid "set Window Mode, needs 2 marks "
+msgstr "Fenstermodus setzen, benƶtigt 2 Marken "
+
+#: internal/review/marks.go:43
+msgid "set mark"
+msgstr "Marke setzen"
+
+#: internal/command/descriptions.go:28
+msgid "set review and phonetically presents the current character"
+msgstr "Setzt die Ansicht auf das aktuelle Zeichen und gibt es phonetisch aus"
+
+#: internal/command/descriptions.go:34
+msgid "set review cursor to begin of current line and display the content"
+msgstr ""
+"Setzt den Bildschirm-Cursor an den Anfang der aktuellen Zeile und zeigt "
+"deren Inhalt an"
+
+#: internal/punctuation/emoticons.go:16
+msgid "skeptical"
+msgstr "skeptisch"
+
+#: internal/punctuation/emoticons.go:21
+msgid "skull and crossbones"
+msgstr "Totenkopf mit gekreuzten Knochen"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "slash"
+msgstr "SchrƤgstrich"
+
+#: internal/punctuation/emoticons.go:10
+msgid "smile"
+msgstr "LƤcheln"
+
+#: internal/punctuation/profile.go
+msgid "some"
+msgstr "einige"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "space"
+msgstr "Leerzeichen"
+
+#: internal/command/descriptions.go:103
+msgid "speaks the contents of the currently selected clipboard"
+msgstr "Liest den Inhalt der momentan gewƤhlten Zwischenablage vor"
+
+#: internal/config/definitions.go:206
+msgid "speech"
+msgstr "Sprache"
+
+#: internal/config/definitions.go:206
+msgid "speech and beeps"
+msgstr "Sprache und Signaltƶne"
+
+#: internal/app/commands.go:59 internal/app/feedback.go:45
+msgid "speech enabled"
+msgstr "Sprachausgabe eingeschaltet"
+
+#: internal/config/config.go:131
+msgid "speech language"
+msgstr "Ausgabesprache"
+
+#: internal/config/config.go:133
+msgid "speech pitch"
+msgstr "Tonhƶhe"
+
+#: internal/config/config.go:126
+msgid "speech rate"
+msgstr "Sprechgeschwindigkeit"
+
+#: internal/app/commands.go:121
+msgid "speech temporary disabled"
+msgstr "Sprachausgabe vorübergehend ausgeschaltet"
+
+#: internal/config/config.go:132
+msgid "speech voice"
+msgstr "Stimme"
+
+#: internal/config/config.go:134
+msgid "speech volume"
+msgstr "SprachlautstƤrke"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "star"
+msgstr "Stern"
+
+#: internal/review/table.go:212
+msgid "start of cell"
+msgstr "Zellenanfang"
+
+#: internal/review/table.go:168
+msgid "start of line"
+msgstr "Zeilenanfang"
+
+#: internal/review/navigation.go:20
+msgid "start of screen"
+msgstr "Bildschirmanfang"
+
+#: internal/presentation/attributes.go
+msgid "strikethrough"
+msgstr "durchgestrichen"
+
+#: internal/punctuation/emoticons.go:15
+msgid "surprised"
+msgstr "überrascht"
+
+#: internal/presentation/text.go
+msgid "tab"
+msgstr "Tabulator"
+
+#: internal/config/definitions.go:225 internal/reader/reader.go:184
+msgid "table mode"
+msgstr "Tabellenmodus"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "tilde"
+msgstr "Tilde"
+
+#: internal/command/descriptions.go:51
+msgid "toggles the tutorial mode"
+msgstr "Schaltet den Lernmodus um"
+
+#: internal/punctuation/emoticons.go:14
+msgid "tongue out"
+msgstr "Zunge heraus"
+
+#: internal/interaction/presentation.go:62
+msgid "unavailable"
+msgstr "nicht verfügbar"
+
+#: internal/app/tutorial.go:107 internal/app/tutorial.go:161
+#: internal/reader/reader.go:199 internal/review/review.go:140
+msgid "unbound"
+msgstr "unbelegt"
+
+#: internal/interaction/presentation.go
+msgid "unchecked"
+msgstr "deaktiviert"
+
+#: internal/presentation/attributes.go
+msgid "underline"
+msgstr "unterstrichen"
+
+#: internal/presentation/attributes.go internal/presentation/attributes.go:27
+msgid "white"
+msgstr "weiĆ"
+
+#: internal/punctuation/emoticons.go:13
+msgid "wink"
+msgstr "Zwinkern"
+
+#: internal/app/spellcheck_menu.go
+msgid "{0} added to dictionary"
+msgstr "{0} zum Wörterbuch hinzugefügt"
+
+#: internal/interaction/presentation.go:53
+msgid "{0} button"
+msgstr "{0} SchaltflƤche"
+
+#: internal/app/internal_ui.go:122
+msgid "{0} closed"
+msgstr "{0} geschlossen"
+
+#: internal/interaction/presentation.go:51
+msgid "{0} combo box {1}"
+msgstr "{0} Kombinationsfeld {1}"
+
+#: internal/interaction/presentation.go:45
+msgid "{0} entry {1}"
+msgstr "{0} Eingabefeld {1}"
+
+#: internal/interaction/presentation.go:49
+msgid "{0} grid. {1}"
+msgstr "{0} Raster. {1}"
+
+#: internal/app/spellcheck_menu.go:134
+msgid "{0} is already in dictionary"
+msgstr "{0} ist bereits im Wƶrterbuch"
+
+#: internal/app/spellcheck_menu.go
+msgid "{0} removed"
+msgstr "{0} entfernt"
+
+#: internal/interaction/presentation.go:31
+msgid "{0} tab"
+msgstr "{0} Registerkarte"
+
+#: internal/punctuation/text_processing.go:69
+msgid "{0} {1}"
+msgstr "{0} {1}"
+
+#: internal/interaction/grid.go:39
+msgid "{0}. row {1}, column {2}. {3} of {4}"
+msgstr "{0}. Zeile {1}, Spalte {2}. {3} von {4}"
+
+#: internal/app/tutorial.go:25
+msgid ""
+"{command_name}, Shortcuts {command_shortcuts}, Description "
+"{command_description}"
+msgstr ""
+"{command_name}, Tastenkürzel {command_shortcuts}, Beschreibung "
+"{command_description}"
+
+#: internal/app/tutorial.go internal/app/tutorial.go:66
+msgid "{} times "
+msgstr "{} mal "
diff --git a/internal/i18n/locale/es/LC_MESSAGES/fenrir.po b/internal/i18n/locale/es/LC_MESSAGES/fenrir.po
new file mode 100644
index 00000000..f9e4f4e2
--- /dev/null
+++ b/internal/i18n/locale/es/LC_MESSAGES/fenrir.po
@@ -0,0 +1,2170 @@
+# Fenrir reader translations (GPL-3.0-or-later).
+# Existing translations retained from Python Fenrir.
+# AI-assisted additions require language-contributor review.
+msgid ""
+msgstr ""
+"Project-Id-Version: fenrir\n"
+"PO-Revision-Date: 2017-02-26 17:01-0600\n"
+"Last-Translator: \n"
+"Language-Team: \n"
+"Language: es\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Generated-By: pygettext.py 1.5\n"
+"X-Generator: Poedit 1.6.11\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+#: internal/review/cursor_column.go:13
+msgid " column number"
+msgstr " nĆŗmero de columna"
+
+#: internal/review/cursor_line.go:13
+msgid " line number"
+msgstr " nĆŗmero de lĆnea"
+
+#: internal/review/cursor_scope.go:10
+msgid " review cursor "
+msgstr " cursor de revisión "
+
+#: internal/review/cursor_scope.go:12
+msgid " text cursor "
+msgstr " cursor de texto "
+
+#: internal/sound/drivers.go:39
+msgid "ALSA"
+msgstr "ALSA"
+
+#: internal/app/tutorial.go
+msgid "Active actions"
+msgstr "Acciones activas"
+
+#: internal/app/spellcheck_menu.go:51
+msgid "Add"
+msgstr "AƱadir"
+
+#: internal/app/emoji_menu.go:34 internal/app/spellcheck_menu.go:107
+msgid "Added {0} to clipboard"
+msgstr "Se añadió {0} al portapapeles"
+
+#: internal/emoji/catalog.go:76
+msgid "Alien monster"
+msgstr "Monstruo alienĆgena"
+
+#: internal/emoji/catalog.go
+msgid "All"
+msgstr "Todos"
+
+#: internal/reader/regions.go:203
+msgid "All 10 live regions are set"
+msgstr "Las 10 regiones dinƔmicas estƔn definidas"
+
+#: internal/emoji/catalog.go:23
+msgid "Angry"
+msgstr "Enfadado"
+
+#: internal/emoji/catalog.go:64
+msgid "Apple"
+msgstr "Manzana"
+
+#: internal/emoji/catalog.go:117
+msgid "Argentina"
+msgstr "Argentina"
+
+#: internal/config/config.go:155
+msgid "Attribute format"
+msgstr "Formato de atributos"
+
+#: internal/config/config.go:154
+msgid "Attributes"
+msgstr "Atributos"
+
+#: internal/emoji/catalog.go:118
+msgid "Australia"
+msgstr "Australia"
+
+#: internal/config/config.go:145
+msgid "Automatic reading"
+msgstr "Lectura automƔtica"
+
+#: internal/config/config.go:156
+msgid "Automatic spell checking"
+msgstr "Revisión ortogrÔfica automÔtica"
+
+#: internal/emoji/catalog.go:65
+msgid "Avocado"
+msgstr "Aguacate"
+
+#: internal/emoji/catalog.go:77
+msgid "Bat"
+msgstr "MurciƩlago"
+
+#: internal/config/config.go:129
+msgid "Baud rate"
+msgstr "Velocidad en baudios"
+
+#: internal/emoji/catalog.go:24
+msgid "Beaming"
+msgstr "Sonrisa radiante"
+
+#: internal/config/config.go:151
+msgid "Beep for capital letters"
+msgstr "Pitido para letras mayĆŗsculas"
+
+#: internal/emoji/catalog.go:66
+msgid "Beer"
+msgstr "Cerveza"
+
+#: internal/emoji/catalog.go:119
+msgid "Belgium"
+msgstr "BƩlgica"
+
+#: internal/emoji/catalog.go:67
+msgid "Birthday cake"
+msgstr "Tarta de cumpleaƱos"
+
+#: internal/emoji/catalog.go:78
+msgid "Black cat"
+msgstr "Gato negro"
+
+#: internal/emoji/catalog.go:25
+msgid "Blowing kiss"
+msgstr "Lanzando un beso"
+
+#: internal/emoji/catalog.go:100
+msgid "Bone"
+msgstr "Hueso"
+
+#: internal/review/bottom.go:29
+msgid "Bottom"
+msgstr "Fin"
+
+#: internal/emoji/catalog.go:120
+msgid "Brazil"
+msgstr "Brasil"
+
+#: internal/emoji/catalog.go:79
+msgid "Bunny"
+msgstr "Conejo"
+
+#: internal/emoji/catalog.go:51
+msgid "Butterfly"
+msgstr "Mariposa"
+
+#: internal/emoji/catalog.go:121
+msgid "Canada"
+msgstr "CanadĆ”"
+
+#: internal/preferences/settings.go:79
+msgid "Cancel"
+msgstr "Cancelar"
+
+#: internal/emoji/catalog.go:52
+msgid "Cat"
+msgstr "Gato"
+
+#: internal/emoji/catalog.go:101
+msgid "Chains"
+msgstr "Cadenas"
+
+#: internal/config/config.go:140
+msgid "Character echo"
+msgstr "Eco de caracteres"
+
+#: internal/emoji/catalog.go:102
+msgid "Checkmark"
+msgstr "Marca de verificación"
+
+#: internal/emoji/catalog.go:53
+msgid "Cherry blossom"
+msgstr "Flor de cerezo"
+
+#: internal/emoji/catalog.go:122
+msgid "China"
+msgstr "China"
+
+#: internal/emoji/catalog.go:80
+msgid "Christmas tree"
+msgstr "Ćrbol de Navidad"
+
+#: internal/command/descriptions.go:76
+msgid "Clear all live regions"
+msgstr "Borrar todas las regiones dinƔmicas"
+
+#: internal/command/descriptions.go:77
+msgid "Clear all reading regions"
+msgstr "Borrar todas las regiones de lectura"
+
+#: internal/command/descriptions.go:79
+msgid "Clear live region 1"
+msgstr "Borrar la región dinÔmica 1"
+
+#: internal/command/descriptions.go:97
+msgid "Clear live region 10"
+msgstr "Borrar la región dinÔmica 10"
+
+#: internal/command/descriptions.go:81
+msgid "Clear live region 2"
+msgstr "Borrar la región dinÔmica 2"
+
+#: internal/command/descriptions.go:83
+msgid "Clear live region 3"
+msgstr "Borrar la región dinÔmica 3"
+
+#: internal/command/descriptions.go:85
+msgid "Clear live region 4"
+msgstr "Borrar la región dinÔmica 4"
+
+#: internal/command/descriptions.go:87
+msgid "Clear live region 5"
+msgstr "Borrar la región dinÔmica 5"
+
+#: internal/command/descriptions.go:89
+msgid "Clear live region 6"
+msgstr "Borrar la región dinÔmica 6"
+
+#: internal/command/descriptions.go:91
+msgid "Clear live region 7"
+msgstr "Borrar la región dinÔmica 7"
+
+#: internal/command/descriptions.go:93
+msgid "Clear live region 8"
+msgstr "Borrar la región dinÔmica 8"
+
+#: internal/command/descriptions.go:95
+msgid "Clear live region 9"
+msgstr "Borrar la región dinÔmica 9"
+
+#: internal/config/config.go:57
+msgid "Clipboard"
+msgstr "Portapapeles"
+
+#: internal/app/application.go:136 internal/app/application.go:139
+#: internal/app/clipboard.go:218 internal/app/clipboard.go:374
+msgid "Clipboard import failed"
+msgstr "Error al importar el portapapeles"
+
+#: internal/config/config.go:106
+msgid "Clipboard sync"
+msgstr "Sincronización del portapapeles"
+
+#: internal/emoji/catalog.go:68
+msgid "Coffee"
+msgstr "CafƩ"
+
+#: internal/emoji/catalog.go:81
+msgid "Coffin"
+msgstr "AtaĆŗd"
+
+#: internal/command/descriptions.go:45
+msgid "Column number for cursor"
+msgstr "NĆŗmero de columna del cursor"
+
+#: internal/emoji/catalog.go:26
+msgid "Cool"
+msgstr "Genial"
+
+#: internal/app/spellcheck_menu.go:175
+msgid "Could not check spelling: {0}"
+msgstr "No se pudo revisar la ortografĆa: {0}"
+
+#: internal/app/emoji_menu.go:17 internal/app/emoji_menu.go:21
+msgid "Could not open Emoji Menu: {0}"
+msgstr "No se pudo abrir el menĆŗ de emojis: {0}"
+
+#: internal/app/fenrir_menu.go:32 internal/app/fenrir_menu.go:52
+#: internal/app/fenrir_menu.go:67 internal/app/runtime_settings.go:133
+msgid "Could not open Fenrir Menu: {0}"
+msgstr "No se pudo abrir el menĆŗ de Fenrir: {0}"
+
+#: internal/app/spellcheck_menu.go:39 internal/app/spellcheck_menu.go:58
+msgid "Could not open Spellcheck Menu: {0}"
+msgstr "No se pudo abrir el menĆŗ de ortografĆa: {0}"
+
+#: internal/app/spellcheck_menu.go:155
+msgid "Could not save exceptions: {0}"
+msgstr "No se pudieron guardar las excepciones: {0}"
+
+#: internal/app/fenrir_menu.go:174 internal/app/fenrir_menu.go:180
+#: internal/app/runtime_settings.go:164 internal/app/runtime_settings.go:191
+msgid "Could not save settings: {0}"
+msgstr "No se pudo guardar la configuración: {0}"
+
+#: internal/emoji/catalog.go:27
+msgid "Crying"
+msgstr "Llorando"
+
+#: internal/config/config.go:146
+msgid "Cursor echo"
+msgstr "Eco del cursor"
+
+#: internal/emoji/catalog.go:103
+msgid "Dagger"
+msgstr "Daga"
+
+#: internal/config/config.go:102
+msgid "Date format"
+msgstr "Formato de fecha"
+
+#: internal/emoji/catalog.go:123
+msgid "Denmark"
+msgstr "Dinamarca"
+
+#: internal/config/config.go:138
+msgid "Devices"
+msgstr "Dispositivos"
+
+#: internal/emoji/catalog.go:28
+msgid "Devil"
+msgstr "Diablo"
+
+#: internal/app/spellcheck_menu.go:191
+msgid "Dictionary {0}. {1}. {2}"
+msgstr "Diccionario {0}. {1}. {2}"
+
+#: internal/emoji/catalog.go:29
+msgid "Dizzy"
+msgstr "Mareado"
+
+#: internal/emoji/catalog.go:54
+msgid "Dog"
+msgstr "Perro"
+
+#: internal/emoji/catalog.go:69
+msgid "Donut"
+msgstr "Rosquilla"
+
+#: internal/config/config.go:120 internal/config/config.go:127
+msgid "Driver"
+msgstr "Controlador"
+
+#: internal/config/config.go:142
+msgid "Echo on CapsLock"
+msgstr "Eco con Bloq MayĆŗs"
+
+#: internal/emoji/catalog.go:82
+msgid "Egg"
+msgstr "Huevo"
+
+#: internal/emoji/catalog.go:162
+msgid "Emoji"
+msgstr "Emoji"
+
+#: internal/command/descriptions.go:53 internal/emoji/catalog.go:14
+msgid "Emoji Menu"
+msgstr "MenĆŗ de emojis"
+
+#: internal/config/config.go:160
+msgid "Emoticons"
+msgstr "Emoticonos"
+
+#: internal/config/config.go:107
+msgid "Enable Persistent Clipboard"
+msgstr "Activar portapapeles persistente"
+
+#: internal/app/tutorial.go:21
+msgid ""
+"Entering tutorial mode. In this mode commands are described but not "
+"executed. Use up and down to browse actions and left and right to switch "
+"between Active actions, Unbound actions, and Plugins. Press Space to repeat "
+"the current item. To exit tutorial mode press Fenrir+F1 or Escape. Active "
+"actions."
+msgstr ""
+"Entrando en el modo de aprendizaje. En este modo, los comandos se describen "
+"pero no se ejecutan. Use las flechas arriba y abajo para recorrer las "
+"acciones e izquierda y derecha para cambiar entre acciones activas, acciones "
+"sin asignar y complementos. Pulse Espacio para repetir el elemento actual. "
+"Para salir, pulse Fenrir+F1 o Escape. Acciones activas."
+
+#: internal/app/spellcheck_menu.go:49 internal/app/spellcheck_menu.go:52
+msgid "Exceptions"
+msgstr "Excepciones"
+
+#: internal/review/follow_cursor.go:29
+msgid "Exiting Review Mode"
+msgstr "Saliendo del modo de revisión"
+
+#: internal/app/tutorial.go:22
+msgid "Exiting tutorial mode. To enter tutorial mode again press Fenrir+F1"
+msgstr ""
+"Saliendo del modo de aprendizaje. Para volver a entrar, pulse Fenrir+F1"
+
+#: internal/emoji/catalog.go:30
+msgid "Exploding head"
+msgstr "Cabeza explotando"
+
+#: internal/command/descriptions.go:113
+msgid "Export current fenrir clipboard to X or GUI clipboard"
+msgstr ""
+"Exportar el portapapeles actual de Fenrir al portapapeles grƔfico o de X"
+
+#: internal/emoji/catalog.go:31
+msgid "Face with symbols"
+msgstr "Cara con sĆmbolos"
+
+#: internal/app/quick_settings.go:295 internal/app/runtime_settings.go:151
+msgid "Failed to save settings."
+msgstr "No se pudo guardar la configuración."
+
+#: internal/command/descriptions.go:55 internal/preferences/settings.go:38
+msgid "Fenrir Menu"
+msgstr "MenĆŗ de Fenrir"
+
+#: internal/app/commands.go:83
+msgid "Fenrir screen reader version {0}-{1}"
+msgstr "Lector de pantalla Fenrir versión {0}-{1}"
+
+#: internal/app/clipboard.go:377
+msgid "File does not exist"
+msgstr "El archivo no existe"
+
+#: internal/emoji/catalog.go:124
+msgid "Finland"
+msgstr "Finlandia"
+
+#: internal/emoji/catalog.go:104
+msgid "Fire"
+msgstr "Fuego"
+
+#: internal/emoji/catalog.go:83
+msgid "Fireworks"
+msgstr "Fuegos artificiales"
+
+#: internal/app/clipboard.go:323
+msgid "First clipboard "
+msgstr "Primer portapapeles "
+
+#: internal/emoji/catalog.go
+msgid "Flags"
+msgstr "Banderas"
+
+#: internal/emoji/catalog.go:105
+msgid "Folded hands"
+msgstr "Manos juntas"
+
+#: internal/emoji/catalog.go
+msgid "Food"
+msgstr "Comida"
+
+#: internal/app/commands.go:126
+msgid "Forward next keypress"
+msgstr "Enviar la siguiente tecla"
+
+#: internal/emoji/catalog.go:125
+msgid "France"
+msgstr "Francia"
+
+#: internal/emoji/catalog.go:126
+msgid "Germany"
+msgstr "Alemania"
+
+#: internal/emoji/catalog.go:84
+msgid "Ghost"
+msgstr "Fantasma"
+
+#: internal/emoji/catalog.go:85
+msgid "Gift"
+msgstr "Regalo"
+
+#: internal/emoji/catalog.go:127
+msgid "Greece"
+msgstr "Grecia"
+
+#: internal/emoji/catalog.go:32
+msgid "Grinning"
+msgstr "Sonrisa amplia"
+
+#: internal/emoji/catalog.go:33
+msgid "Grinning sweat"
+msgstr "Sonrisa con sudor"
+
+#: internal/emoji/catalog.go:70
+msgid "Hamburger"
+msgstr "Hamburguesa"
+
+#: internal/emoji/catalog.go:106
+msgid "Heart"
+msgstr "Corazón"
+
+#: internal/emoji/catalog.go:34
+msgid "Heart eyes"
+msgstr "Ojos de corazón"
+
+#: internal/emoji/catalog.go:35
+msgid "Hearts around"
+msgstr "Rodeado de corazones"
+
+#: internal/emoji/catalog.go:107
+msgid "High voltage"
+msgstr "Alta tensión"
+
+#: internal/config/config.go:109
+msgid "History items"
+msgstr "Elementos del historial"
+
+#: internal/emoji/catalog.go
+msgid "Holidays"
+msgstr "Fiestas"
+
+#: internal/emoji/catalog.go:108
+msgid "Hundred points"
+msgstr "Cien puntos"
+
+#: internal/emoji/catalog.go:36
+msgid "Imp"
+msgstr "Diablillo"
+
+#: internal/app/clipboard.go:412
+msgid "Import to Clipboard"
+msgstr "Importar al portapapeles"
+
+#: internal/config/config.go:153
+msgid "Indentation"
+msgstr "SangrĆa"
+
+#: internal/emoji/catalog.go:128
+msgid "India"
+msgstr "India"
+
+#: internal/emoji/catalog.go:129
+msgid "Ireland"
+msgstr "Irlanda"
+
+#: internal/emoji/catalog.go:130
+msgid "Israel"
+msgstr "Israel"
+
+#: internal/emoji/catalog.go:131
+msgid "Italy"
+msgstr "Italia"
+
+#: internal/emoji/catalog.go:86
+msgid "Jack o'lantern"
+msgstr "Calabaza de Halloween"
+
+#: internal/emoji/catalog.go:132
+msgid "Japan"
+msgstr "Japón"
+
+#: internal/config/config.go:53
+msgid "Keyboard"
+msgstr "Teclado"
+
+#: internal/app/clipboard.go:326
+msgid "Last clipboard "
+msgstr "Ćltimo portapapeles "
+
+#: internal/emoji/catalog.go:37
+msgid "Laughing"
+msgstr "Riendo"
+
+#: internal/config/config.go:139
+msgid "Layout"
+msgstr "Distribución del teclado"
+
+#: internal/command/descriptions.go:44
+msgid "Line number for cursor"
+msgstr "NĆŗmero de lĆnea del cursor"
+
+#: internal/command/descriptions.go:75
+msgid "List reading regions"
+msgstr "Listar las regiones de lectura"
+
+#: internal/reader/regions.go:185
+msgid "Live region {0} cleared"
+msgstr "Región dinÔmica {0} borrada"
+
+#: internal/reader/regions.go:181
+msgid "Live region {0} not set"
+msgstr "Región dinÔmica {0} sin definir"
+
+#: internal/reader/regions.go:189
+msgid "Live region {0} outside screen"
+msgstr "Región dinÔmica {0} fuera de la pantalla"
+
+#: internal/reader/regions.go:209
+msgid "Live region {0} set"
+msgstr "Región dinÔmica {0} definida"
+
+#: internal/reader/regions.go:245
+msgid "Live region {0}: {1}"
+msgstr "Región dinÔmica {0}: {1}"
+
+#: internal/reader/regions.go:232
+msgid "Live regions cleared"
+msgstr "Regiones dinƔmicas borradas"
+
+#: internal/emoji/catalog.go:87
+msgid "Mage"
+msgstr "Mago"
+
+#: internal/emoji/catalog.go:133
+msgid "Mexico"
+msgstr "MƩxico"
+
+#: internal/config/config.go:159
+msgid "Misspelling sound"
+msgstr "Sonido de error ortogrƔfico"
+
+#: internal/emoji/catalog.go:55
+msgid "Moon"
+msgstr "Luna"
+
+#: internal/command/descriptions.go:35
+msgid "Move Review to the end of current line and display the content"
+msgstr ""
+"Mover el cursor de revisión al final de la lĆnea actual y presentar el "
+"contenido"
+
+#: internal/command/descriptions.go:36
+msgid "Move Review to the first character on the line"
+msgstr "Mover el cursor de revisión al primer carĆ”cter de la lĆnea"
+
+#: internal/command/descriptions.go:48
+msgid "Move Review to the first character on the screen (left top)"
+msgstr ""
+"Mover el cursor de revisión al primer carÔcter de la pantalla, arriba a la "
+"izquierda"
+
+#: internal/command/descriptions.go:37
+msgid "Move Review to the last character on the line"
+msgstr "Mover el cursor de revisión al Ćŗltimo carĆ”cter de la lĆnea"
+
+#: internal/command/descriptions.go:49
+msgid "Move Review to the last character on the screen (right bottom)"
+msgstr ""
+"Mover el cursor de revisión al último carÔcter de la pantalla, abajo a la "
+"derecha"
+
+#: internal/command/descriptions.go:33
+msgid "Move review to the bottom of the screen"
+msgstr "Mover el cursor de revisión al final de la pantalla"
+
+#: internal/command/descriptions.go:31
+msgid "Move review to the character below the current position"
+msgstr "Mover el cursor de revisión al carÔcter debajo de la posición actual"
+
+#: internal/command/descriptions.go:30
+msgid "Move review to the character in the line above the current position"
+msgstr ""
+"Mover el cursor de revisión al carĆ”cter de la lĆnea superior a la posición "
+"actual"
+
+#: internal/command/descriptions.go:29
+msgid "Moves review to the next character"
+msgstr "Mueve el cursor de revisión al carÔcter siguiente"
+
+#: internal/emoji/catalog.go
+msgid "Nature"
+msgstr "Naturaleza"
+
+#: internal/emoji/catalog.go:38
+msgid "Nauseated"
+msgstr "NƔuseas"
+
+#: internal/emoji/catalog.go:134
+msgid "Netherlands"
+msgstr "PaĆses Bajos"
+
+#: internal/interaction/grid.go:43 internal/interaction/grid.go:79
+msgid "No matches"
+msgstr "Sin coincidencias"
+
+#: internal/reader/regions.go:251
+msgid "No reading regions set"
+msgstr "No hay regiones de lectura definidas"
+
+#: internal/app/clipboard.go:416
+msgid "No text found in clipboard or no accessible display"
+msgstr ""
+"No se encontró texto en el portapapeles o no hay una pantalla accesible"
+
+#: internal/app/spellcheck_menu.go
+msgid "No word"
+msgstr "Ninguna palabra"
+
+#: internal/emoji/catalog.go:135
+msgid "Norway"
+msgstr "Noruega"
+
+#: internal/review/follow_cursor.go:26
+msgid "Not in Review Mode"
+msgstr "Fuera del modo de revisión"
+
+#: internal/reader/regions.go:222
+msgid "Not in window Mode"
+msgstr "No estĆ” en modo ventana"
+
+#: internal/preferences/settings.go:79
+msgid "OK"
+msgstr "Aceptar"
+
+#: internal/app/clipboard.go:269 internal/reader/regions.go:197
+msgid "One or two marks are needed"
+msgstr "Se necesitan una o dos marcas"
+
+#: internal/emoji/catalog.go:109
+msgid "Peace"
+msgstr "Paz"
+
+#: internal/emoji/catalog.go
+msgid "People"
+msgstr "Personas"
+
+#: internal/command/descriptions.go:25
+msgid "Phonetically spells the current word"
+msgstr "Deletrea fonƩticamente la palabra actual"
+
+#: internal/config/config.go:133
+msgid "Pitch"
+msgstr "Tono"
+
+#: internal/emoji/catalog.go:71
+msgid "Pizza"
+msgstr "Pizza"
+
+#: internal/emoji/catalog.go:39
+msgid "Pleading"
+msgstr "Suplicante"
+
+#: internal/app/tutorial.go
+msgid "Plugins"
+msgstr "Complementos"
+
+#: internal/emoji/catalog.go:136
+msgid "Poland"
+msgstr "Polonia"
+
+#: internal/emoji/catalog.go:40
+msgid "Poop"
+msgstr "Caca"
+
+#: internal/emoji/catalog.go:137
+msgid "Portugal"
+msgstr "Portugal"
+
+#: internal/command/descriptions.go:52
+msgid "Present the version of Fenrir currently in use."
+msgstr "Anunciar la versión de Fenrir en uso."
+
+#: internal/config/config.go:52
+msgid "Presentation"
+msgstr "Presentación"
+
+#: internal/command/descriptions.go:100
+msgid ""
+"Presents the currently selected text that will be copied to the clipboard"
+msgstr "Presenta el texto seleccionado que se copiarĆ” al portapapeles"
+
+#: internal/app/commands.go:112
+msgid "Progress monitoring disabled"
+msgstr "Seguimiento del progreso desactivado"
+
+#: internal/app/commands.go:110
+msgid "Progress monitoring enabled"
+msgstr "Seguimiento del progreso activado"
+
+#: internal/sound/drivers.go:39
+msgid "PulseAudio"
+msgstr "PulseAudio"
+
+#: internal/config/config.go:149
+msgid "Punctuation level"
+msgstr "Nivel de puntuación"
+
+#: internal/config/config.go:148
+msgid "Punctuation profile"
+msgstr "Perfil de puntuación"
+
+#: internal/app/quick_settings.go:115
+msgid "Quick menu not available"
+msgstr "Menú rÔpido no disponible"
+
+#: internal/emoji/catalog.go:56
+msgid "Rainbow"
+msgstr "ArcoĆris"
+
+#: internal/config/config.go:150
+msgid "Raise pitch for capital letters"
+msgstr "Elevar el tono para letras mayĆŗsculas"
+
+#: internal/config/config.go:126
+msgid "Rate"
+msgstr "Velocidad"
+
+#: internal/punctuation/emoticons.go:33
+msgid "Raves"
+msgstr "Fiestas rave"
+
+#: internal/command/descriptions.go:78
+msgid "Read live region 1"
+msgstr "Leer la región dinÔmica 1"
+
+#: internal/command/descriptions.go:96
+msgid "Read live region 10"
+msgstr "Leer la región dinÔmica 10"
+
+#: internal/command/descriptions.go:80
+msgid "Read live region 2"
+msgstr "Leer la región dinÔmica 2"
+
+#: internal/command/descriptions.go:82
+msgid "Read live region 3"
+msgstr "Leer la región dinÔmica 3"
+
+#: internal/command/descriptions.go:84
+msgid "Read live region 4"
+msgstr "Leer la región dinÔmica 4"
+
+#: internal/command/descriptions.go:86
+msgid "Read live region 5"
+msgstr "Leer la región dinÔmica 5"
+
+#: internal/command/descriptions.go:88
+msgid "Read live region 6"
+msgstr "Leer la región dinÔmica 6"
+
+#: internal/command/descriptions.go:90
+msgid "Read live region 7"
+msgstr "Leer la región dinÔmica 7"
+
+#: internal/command/descriptions.go:92
+msgid "Read live region 8"
+msgstr "Leer la región dinÔmica 8"
+
+#: internal/command/descriptions.go:94
+msgid "Read live region 9"
+msgstr "Leer la región dinÔmica 9"
+
+#: internal/config/config.go:157
+msgid "Read misspelled word"
+msgstr "Leer la palabra mal escrita"
+
+#: internal/config/config.go:54
+msgid "Reading"
+msgstr "Lectura"
+
+#: internal/reader/regions.go:236
+msgid "Reading regions cleared"
+msgstr "Regiones de lectura borradas"
+
+#: internal/reader/regions.go:241
+msgid "Reading window: {0}"
+msgstr "Ventana de lectura: {0}"
+
+#: internal/command/descriptions.go:42
+msgid "Reads attributes of current cursor position"
+msgstr "Lee los atributos de la posición actual del cursor"
+
+#: internal/command/descriptions.go:39
+msgid "Reads from the top of the screen to the cursor position"
+msgstr "Lee desde el inicio de la pantalla hasta la posición del cursor."
+
+#: internal/app/clipboard.go:264
+msgid "Remove marks"
+msgstr "Marcas eliminadas"
+
+#: internal/command/descriptions.go:99
+msgid "Removes marks from selected text"
+msgstr "Elimina las marcas del texto seleccionado"
+
+#: internal/config/config.go:161
+msgid "Repeated symbols"
+msgstr "SĆmbolos repetidos"
+
+#: internal/config/config.go:162
+msgid "Repeated symbols minimum length"
+msgstr "Cantidad mĆnima de sĆmbolos repetidos"
+
+#: internal/emoji/catalog.go:110
+msgid "Rock on"
+msgstr "Gesto de rock"
+
+#: internal/emoji/catalog.go:41
+msgid "Rolling laughing"
+msgstr "RevolcƔndose de risa"
+
+#: internal/emoji/catalog.go:57
+msgid "Rose"
+msgstr "Rosa"
+
+#: internal/emoji/catalog.go:138
+msgid "Russia"
+msgstr "Rusia"
+
+#: internal/emoji/catalog.go:42
+msgid "Sad"
+msgstr "Triste"
+
+#: internal/emoji/catalog.go:88
+msgid "Santa"
+msgstr "PapĆ” Noel"
+
+#: internal/command/descriptions.go:64
+msgid "Saves your current Fenrir settings so they are the default."
+msgstr "Guarda la configuración actual de Fenrir como predeterminada."
+
+#: internal/emoji/catalog.go:43
+msgid "Savoring food"
+msgstr "Saboreando comida"
+
+#: internal/config/config.go:152
+msgid "Say ācapā before capital letters"
+msgstr "Decir «mayúscula» antes de las letras mayúsculas"
+
+#: internal/config/config.go:158
+msgid "Say āmisspelledā"
+msgstr "Decir «mal escrita»"
+
+#: internal/emoji/catalog.go:44
+msgid "Screaming"
+msgstr "Gritando"
+
+#: internal/app/commands.go:135 internal/app/scripts.go
+msgid "Script failed"
+msgstr "El script falló"
+
+#: internal/app/scripts.go
+msgid "Script file is not executable"
+msgstr "El archivo de script no es ejecutable"
+
+#: internal/app/scripts.go
+msgid "Script file not found"
+msgstr "Archivo de script no encontrado"
+
+#: internal/app/scripts.go
+msgid "Script source is not a valid file"
+msgstr "El origen del script no es un archivo vƔlido"
+
+#: internal/interaction/grid.go:71
+msgid "Search cleared"
+msgstr "BĆŗsqueda borrada"
+
+#: internal/interaction/grid.go:73
+msgid "Search {0}"
+msgstr "Buscar {0}"
+
+#: internal/config/config.go:121
+msgid "Select sound theme"
+msgstr "Seleccionar tema de sonido"
+
+#: internal/config/config.go:128
+msgid "Serial port"
+msgstr "Puerto serie"
+
+#: internal/command/descriptions.go:74
+msgid "Set a live region from one or two marks"
+msgstr "Definir una región dinÔmica a partir de una o dos marcas"
+
+#: internal/reader/regions.go:213
+msgid "Set window begin and end marks"
+msgstr "Establece una marca de principio y otra para el final de la ventana"
+
+#: internal/app/quick_settings.go:298
+msgid "Settings saved."
+msgstr "Configuración guardada."
+
+#: internal/emoji/catalog.go:89
+msgid "Shamrock"
+msgstr "TrƩbol"
+
+#: internal/emoji/catalog.go:90
+msgid "Skull"
+msgstr "Calavera"
+
+#: internal/emoji/catalog.go:111
+msgid "Skull crossbones"
+msgstr "Calavera con huesos cruzados"
+
+#: internal/emoji/catalog.go:45
+msgid "Smiling"
+msgstr "Sonriendo"
+
+#: internal/emoji/catalog.go:46
+msgid "Smirking"
+msgstr "Sonrisa pĆcara"
+
+#: internal/emoji/catalog.go:91
+msgid "Snowman"
+msgstr "MuƱeco de nieve"
+
+#: internal/config/config.go:95
+msgid "Sound"
+msgstr "Sonido"
+
+#: internal/config/config.go:118
+msgid "Sound enabled"
+msgstr "Sonido activado"
+
+#: internal/emoji/catalog.go:139
+msgid "South Africa"
+msgstr "SudƔfrica"
+
+#: internal/emoji/catalog.go:140
+msgid "South Korea"
+msgstr "Corea del Sur"
+
+#: internal/emoji/catalog.go:141
+msgid "Spain"
+msgstr "EspaƱa"
+
+#: internal/emoji/catalog.go:112
+msgid "Sparkles"
+msgstr "Destellos"
+
+#: internal/config/config.go:94
+msgid "Speech"
+msgstr "Voz"
+
+#: internal/speech/drivers.go:44
+msgid "Speech Dispatcher"
+msgstr "Speech Dispatcher"
+
+#: internal/config/config.go:130
+msgid "Speech Dispatcher module"
+msgstr "Módulo de Speech Dispatcher"
+
+#: internal/config/config.go:125
+msgid "Speech enabled"
+msgstr "Voz activada"
+
+#: internal/config/config.go:131
+msgid "Speech language"
+msgstr "Idioma de la voz"
+
+#: internal/app/screen_updates.go:72
+msgid "Speech restored"
+msgstr "Voz restablecida"
+
+#: internal/config/config.go:132
+msgid "Speech voice"
+msgstr "Voz de sĆntesis"
+
+#: internal/app/spellcheck_menu.go:81
+msgid "Spell checking is unavailable"
+msgstr "La revisión ortogrÔfica no estÔ disponible"
+
+#: internal/app/spellcheck_menu.go:180
+msgid "Spell checking is unavailable: {0}"
+msgstr "La revisión ortogrÔfica no estÔ disponible: {0}"
+
+#: internal/command/descriptions.go:54
+msgid "Spellcheck Menu"
+msgstr "MenĆŗ de ortografĆa"
+
+#: internal/command/descriptions.go:24
+msgid "Spells the current word"
+msgstr "Deletrea la palabra actual"
+
+#: internal/emoji/catalog.go:92
+msgid "Spider"
+msgstr "AraƱa"
+
+#: internal/emoji/catalog.go:113
+msgid "Star"
+msgstr "Estrella"
+
+#: internal/app/screen_updates.go:39
+msgid "Start Fenrir"
+msgstr "Iniciando fenrir"
+
+#: internal/emoji/catalog.go:72
+msgid "Strawberry"
+msgstr "Fresa"
+
+#: internal/app/spellcheck_menu.go:45 internal/app/spellcheck_menu.go:47
+msgid "Suggestions"
+msgstr "Sugerencias"
+
+#: internal/emoji/catalog.go:58
+msgid "Sun"
+msgstr "Sol"
+
+#: internal/emoji/catalog.go:59
+msgid "Sunflower"
+msgstr "Girasol"
+
+#: internal/emoji/catalog.go:142
+msgid "Sweden"
+msgstr "Suecia"
+
+#: internal/emoji/catalog.go:143
+msgid "Switzerland"
+msgstr "Suiza"
+
+#: internal/emoji/catalog.go:114
+msgid "Sword"
+msgstr "Espada"
+
+#: internal/emoji/catalog.go
+msgid "Symbols"
+msgstr "SĆmbolos"
+
+#: internal/emoji/catalog.go:73
+msgid "Taco"
+msgstr "Taco"
+
+#: internal/preferences/settings.go:72
+msgid "Test synthesizer"
+msgstr "Probar sintetizador"
+
+#: internal/app/fenrir_menu.go:139
+msgid "Testing voice"
+msgstr "Probando la voz"
+
+#: internal/emoji/catalog.go:47
+msgid "Thumbs up"
+msgstr "Pulgar arriba"
+
+#: internal/config/config.go:97
+msgid "Time and date"
+msgstr "Hora y fecha"
+
+#: internal/config/config.go:101
+msgid "Time format"
+msgstr "Formato de hora"
+
+#: internal/command/descriptions.go:67
+msgid "Toggle progress bar monitoring with ascending tones"
+msgstr "Alternar el seguimiento del progreso con tonos ascendentes"
+
+#: internal/review/top.go:29
+msgid "Top"
+msgstr "Fin"
+
+#: internal/config/config.go:147
+msgid "Tracking mode"
+msgstr "Modo de seguimiento"
+
+#: internal/emoji/catalog.go:60
+msgid "Tree"
+msgstr "Ćrbol"
+
+#: internal/emoji/catalog.go:144 internal/emoji/catalog.go:93
+msgid "Turkey"
+msgstr "Pavo"
+
+#: internal/command/descriptions.go:73
+msgid "Turn off window mode"
+msgstr "Desactivar el modo de ventana"
+
+#: internal/emoji/catalog.go:145
+msgid "Ukraine"
+msgstr "Ucrania"
+
+#: internal/app/tutorial.go:23
+msgid ""
+"Unable to exit tutorial mode because exclusive keyboard capture could not be "
+"released. Press Fenrir+F1 or Escape to try again."
+msgstr ""
+"No se puede salir del modo de aprendizaje porque no se pudo liberar la "
+"captura exclusiva del teclado. Pulse Fenrir+F1 o Escape para reintentarlo."
+
+#: internal/app/tutorial.go
+msgid "Unbound actions"
+msgstr "Acciones sin asignar"
+
+#: internal/app/spellcheck_menu.go:53
+msgid "Undo"
+msgstr "Deshacer"
+
+#: internal/emoji/catalog.go:146
+msgid "United Kingdom"
+msgstr "Reino Unido"
+
+#: internal/emoji/catalog.go:147
+msgid "United States"
+msgstr "Estados Unidos"
+
+#: internal/emoji/catalog.go:94
+msgid "Vampire"
+msgstr "Vampiro"
+
+#: internal/app/quick_settings.go:191 internal/app/synthesizer_preview.go:60
+msgid "Voice test failed"
+msgstr "La prueba de voz falló"
+
+#: internal/app/synthesizer_preview.go:23
+msgid "Voice test: The quick brown fox jumps over the lazy dog."
+msgstr "Prueba de voz: El veloz zorro marrón salta sobre el perro perezoso."
+
+#: internal/config/config.go:119 internal/config/config.go:134
+msgid "Volume"
+msgstr "Volumen"
+
+#: internal/punctuation/emoticons.go:32
+msgid "WTF?"
+msgstr "¿Pero qué demonios?"
+
+#: internal/app/tutorial.go:24
+msgid ""
+"Warning: full keyboard capture is unavailable. Unbound keys may reach the "
+"active application."
+msgstr ""
+"Advertencia: la captura completa del teclado no estĆ” disponible. Las teclas "
+"sin asignar pueden llegar a la aplicación activa."
+
+#: internal/emoji/catalog.go:95
+msgid "Web"
+msgstr "TelaraƱa"
+
+#: internal/reader/regions.go:225
+msgid "Window Mode off for terminal {0}"
+msgstr "Modo de ventana desactivado para el terminal {0}"
+
+#: internal/reader/regions.go:219
+msgid "Window Mode on for terminal {0}"
+msgstr "Modo de ventana activado para el terminal {0}"
+
+#: internal/emoji/catalog.go:48
+msgid "Winking"
+msgstr "GuiƱando un ojo"
+
+#: internal/emoji/catalog.go:96
+msgid "Witch"
+msgstr "Bruja"
+
+#: internal/emoji/catalog.go:61
+msgid "Wolf"
+msgstr "Lobo"
+
+#: internal/app/spellcheck_menu.go:46 internal/app/spellcheck_menu.go:50
+msgid "Word"
+msgstr "Palabra"
+
+#: internal/config/config.go:141
+msgid "Word echo"
+msgstr "Eco de palabras"
+
+#: internal/emoji/catalog.go:97
+msgid "Zombie"
+msgstr "Zombi"
+
+#: internal/punctuation/profile.go
+msgid "all"
+msgstr "todos"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "and"
+msgstr "y"
+
+#: internal/punctuation/emoticons.go:17
+msgid "angry"
+msgstr "enfadado"
+
+#: internal/reader/attributes.go:36
+msgid "announcement of attributes disabled"
+msgstr "Anuncio de atributos desactivado"
+
+#: internal/reader/attributes.go:33
+msgid "announcement of attributes enabled"
+msgstr "Anuncio de atributos activado"
+
+#: internal/punctuation/emoticons.go:26
+msgid "annoyed"
+msgstr "molesto"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "apostrophe"
+msgstr "apóstrofo"
+
+#: internal/punctuation/emoticons.go:39
+msgid "arrow left"
+msgstr "flecha izquierda"
+
+#: internal/punctuation/emoticons.go:38
+msgid "arrow right"
+msgstr "flecha derecha"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "at"
+msgstr "arroba"
+
+#: internal/reader/reader.go:197
+msgid "autoread disabled"
+msgstr "Lectura automƔtica desactivada"
+
+#: internal/reader/reader.go:195
+msgid "autoread enabled"
+msgstr "Lectura automƔtica activada"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "backslash"
+msgstr "barra inversa"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "bang"
+msgstr "exclamación"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "bar"
+msgstr "barra vertical"
+
+#: internal/config/definitions.go:206
+msgid "beeps"
+msgstr "pitidos"
+
+#: internal/review/line_start.go:28
+msgid "beginning of line"
+msgstr "principio de lĆnea"
+
+#: internal/punctuation/emoticons.go:12
+msgid "big grin"
+msgstr "sonrisa amplia"
+
+#: internal/presentation/attributes.go
+msgid "black"
+msgstr "negro"
+
+#: internal/app/clipboard.go:281 internal/presentation/text.go:35
+#: internal/reader/regions.go:191 internal/review/line_start.go:31
+#: internal/review/screen_range.go:39 internal/review/spelling.go:44
+#: internal/review/table.go:124 internal/review/table.go:205
+#: internal/review/vertical_character.go:31 internal/review/word.go:64
+msgid "blank"
+msgstr "en blanco"
+
+#: internal/presentation/attributes.go
+msgid "blink"
+msgstr "parpadeo"
+
+#: internal/presentation/attributes.go
+msgid "blue"
+msgstr "azul"
+
+#: internal/presentation/attributes.go
+msgid "bold"
+msgstr "negrita"
+
+#: internal/punctuation/emoticons.go:31
+msgid "broken heart"
+msgstr "corazón roto"
+
+#: internal/presentation/attributes.go
+msgid "brown/yellow"
+msgstr "marrón/amarillo"
+
+#: internal/punctuation/emoticons.go:34
+msgid "bugged"
+msgstr "fastidiado"
+
+#: internal/app/clipboard.go:93 internal/app/review_spelling.go:27
+msgid "cap"
+msgstr "mayĆŗscula"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "carrot"
+msgstr "acento circunflejo"
+
+#: internal/presentation/text.go:68
+msgid "character {0}"
+msgstr "carƔcter {0}"
+
+#: internal/interaction/presentation.go
+msgid "checked"
+msgstr "marcado"
+
+#: internal/command/descriptions.go:108
+msgid "clears the currently selected clipboard"
+msgstr "VacĆa el portapapeles seleccionado"
+
+#: internal/app/clipboard.go:299
+msgid "clipboard cleared"
+msgstr "Portapapeles vacĆo"
+
+#: internal/app/clipboard.go:302 internal/app/clipboard.go:337
+msgid "clipboard empty"
+msgstr "Portapapeles vacĆo"
+
+#: internal/app/clipboard.go:395
+msgid "clipboard exported to file"
+msgstr "portapapeles exportado a un archivo"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "colon"
+msgstr "dos puntos"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "comma"
+msgstr "coma"
+
+#: internal/punctuation/emoticons.go:27
+msgid "confused"
+msgstr "confundido"
+
+#: internal/punctuation/emoticons.go:20
+msgid "cool"
+msgstr "genial"
+
+#: internal/command/descriptions.go:102
+msgid "copies last presented text to the clipboard"
+msgstr "copia el Ćŗltimo texto presentado al portapapeles"
+
+#: internal/command/descriptions.go:101
+msgid "copies marked text to the currently selected clipboard"
+msgstr "Copia el texto marcado al portapapeles en uso"
+
+#: internal/app/spellcheck_menu.go
+msgid "correct"
+msgstr "Correcto"
+
+#: internal/review/table.go:132
+msgid "could not set header row"
+msgstr "no se pudo definir la fila de encabezado"
+
+#: internal/punctuation/emoticons.go:18
+msgid "crying"
+msgstr "llorando"
+
+#: internal/command/descriptions.go:17
+msgid "current line"
+msgstr "lĆnea actual"
+
+#: internal/command/descriptions.go:20
+msgid "current word."
+msgstr "Palabra actual."
+
+#: internal/config/definitions.go:225 internal/reader/reader.go:190
+msgid "cursor tracking"
+msgstr "Seguimiento del cursor"
+
+#: internal/presentation/attributes.go
+msgid "cyan"
+msgstr "cian"
+
+#: internal/presentation/attributes.go
+msgid "dark gray"
+msgstr "gris oscuro"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "dash"
+msgstr "guion"
+
+#: internal/app/quick_settings.go:185 internal/app/quick_settings.go:234
+#: internal/config/definitions.go:237 internal/preferences/discovery.go:45
+#: internal/preferences/discovery.go:55 internal/preferences/discovery.go:81
+#: internal/presentation/attributes.go:18
+#: internal/presentation/attributes.go:44
+msgid "default"
+msgstr "predeterminado"
+
+#: internal/command/descriptions.go:66
+msgid "disables speech until next keypress"
+msgstr "Desactiva el habla hasta la siguiente pulsación de teclas"
+
+#: internal/command/descriptions.go:43
+msgid "displays the position of the review cursor"
+msgstr "Indica la posición del cursor de revisión"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "dollar"
+msgstr "dólar"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "dot"
+msgstr "punto"
+
+#: internal/command/descriptions.go:68
+msgid "enables or disables automatic reading of new text as it appears"
+msgstr ""
+"Activa o desactiva la lectura de texto cuando hay nuevo contenido en la "
+"pantalla"
+
+#: internal/command/descriptions.go:65
+msgid "enables or disables speech"
+msgstr "Activa o desactiva el habla"
+
+#: internal/command/descriptions.go:41
+msgid "enables or disables the announcement of attributes"
+msgstr "activa o desactiva el anuncio de atributos"
+
+#: internal/command/descriptions.go:69
+msgid "enables or disables tracking of highlighted text"
+msgstr "activa o desactiva el seguimiento del texto resaltado"
+
+#: internal/review/table.go:218
+msgid "end of cell"
+msgstr "fin de celda"
+
+#: internal/review/line_end.go:24 internal/review/table.go:175
+msgid "end of line"
+msgstr "Fin de lĆnea"
+
+#: internal/review/navigation.go:17
+msgid "end of screen"
+msgstr "Fin de pantalla"
+
+#: internal/punctuation/emoticons.go:37
+msgid "enjoy smile"
+msgstr "sonrisa de satisfacción"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "equals"
+msgstr "igual"
+
+#: internal/punctuation/emoticons.go:36
+msgid "evil smile"
+msgstr "sonrisa malvada"
+
+#: internal/command/descriptions.go:71
+msgid "exits Fenrir"
+msgstr "Sale de Fenrir"
+
+#: internal/command/descriptions.go:50
+msgid "exits review mode"
+msgstr "Sale del modo de revisión"
+
+#: internal/command/descriptions.go:111
+msgid "export the current fenrir clipboard to a file"
+msgstr "exporta el portapapeles actual de Fenrir a un archivo"
+
+#: internal/app/clipboard.go:391
+msgid "exported to the X session."
+msgstr "exportado a la sesión X."
+
+#: internal/app/clipboard.go:372
+msgid "failed to export to X clipboard. No available display found."
+msgstr ""
+"no se pudo exportar al portapapeles de X. No se encontró ninguna pantalla "
+"disponible."
+
+#: internal/review/table.go:190
+msgid "first cell"
+msgstr "primera celda"
+
+#: internal/review/table.go:224
+msgid "first character in cell {0}"
+msgstr "primer carƔcter de la celda {0}"
+
+#: internal/review/first_text.go:35
+msgid "first character in line indent {0}"
+msgstr "primer carĆ”cter de la lĆnea, sangrĆa {0}"
+
+#: internal/review/screen_first_character.go:13
+msgid "first character in screen"
+msgstr "primer carƔcter de la pantalla"
+
+#: internal/punctuation/emoticons.go:11
+msgid "frown"
+msgstr "ceƱo fruncido"
+
+#: internal/punctuation/emoticons.go:28
+msgid "frustrated"
+msgstr "frustrado"
+
+#: internal/command/descriptions.go:58
+msgid "get current quick menu entry"
+msgstr "anunciar la entrada actual del menú rÔpido"
+
+#: internal/command/descriptions.go:59
+msgid "get current quick menu value"
+msgstr "anunciar el valor actual del menú rÔpido"
+
+#: internal/command/descriptions.go:56
+msgid "get next quick menu category"
+msgstr "seleccionar la siguiente categorĆa del menĆŗ rĆ”pido"
+
+#: internal/command/descriptions.go:60
+msgid "get next quick menu entry"
+msgstr "seleccionar la siguiente entrada del menú rÔpido"
+
+#: internal/command/descriptions.go:62
+msgid "get next quick menu value"
+msgstr "seleccionar el siguiente valor del menú rÔpido"
+
+#: internal/command/descriptions.go:57
+msgid "get previous quick menu category"
+msgstr "seleccionar la categorĆa anterior del menĆŗ rĆ”pido"
+
+#: internal/command/descriptions.go:61
+msgid "get previous quick menu entry"
+msgstr "seleccionar la entrada anterior del menú rÔpido"
+
+#: internal/command/descriptions.go:63
+msgid "get previous quick menu value"
+msgstr "seleccionar el valor anterior del menú rÔpido"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "grave"
+msgstr "acento grave"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "greater"
+msgstr "mayor que"
+
+#: internal/presentation/attributes.go
+msgid "green"
+msgstr "verde"
+
+#: internal/punctuation/emoticons.go:25
+msgid "happy"
+msgstr "feliz"
+
+#: internal/presentation/attributes.go:65
+msgid "has attribute"
+msgstr "tiene atributos"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "hash"
+msgstr "almohadilla"
+
+#: internal/review/table.go:138
+msgid "header row set"
+msgstr "fila de encabezado definida"
+
+#: internal/punctuation/emoticons.go:30
+msgid "heart"
+msgstr "corazón"
+
+#: internal/config/definitions.go:225 internal/reader/reader.go:187
+msgid "highlight tracking"
+msgstr "Seguimiento del texto resaltado"
+
+#: internal/punctuation/emoticons.go:29
+msgid "hurray"
+msgstr "hurra"
+
+#: internal/command/descriptions.go:110
+msgid "imports text from clipboard file to the clipboard"
+msgstr "importa texto del archivo del portapapeles al portapapeles"
+
+#: internal/command/descriptions.go:112
+msgid "imports the graphical clipboard to Fenrir's clipboard"
+msgstr "importa el portapapeles grƔfico al portapapeles de Fenrir"
+
+#: internal/presentation/indentation.go:32
+msgid "indented "
+msgstr "sangrĆa "
+
+#: internal/presentation/attributes.go
+msgid "italic"
+msgstr "cursiva"
+
+#: internal/punctuation/emoticons.go:22
+msgid "knocked out"
+msgstr "inconsciente"
+
+#: internal/review/table.go:192
+msgid "last cell"
+msgstr "Ćŗltima celda"
+
+#: internal/review/table.go:227
+msgid "last character in cell {0}"
+msgstr "último carÔcter de la celda {0}"
+
+#: internal/review/last_text.go:34
+msgid "last character in line"
+msgstr "Ćŗltimo carĆ”cter de la lĆnea"
+
+#: internal/review/screen_last_character.go:13
+msgid "last character in screen"
+msgstr "último carÔcter de la pantalla"
+
+#: internal/punctuation/emoticons.go:35
+msgid "laughing"
+msgstr "riendo"
+
+#: internal/punctuation/emoticons.go:24
+msgid "laughing hard"
+msgstr "carcajada"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "left brace"
+msgstr "llave izquierda"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "left bracket"
+msgstr "corchete izquierdo"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "left paren"
+msgstr "parƩntesis izquierdo"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "less"
+msgstr "menor que"
+
+#: internal/presentation/attributes.go
+msgid "light blue"
+msgstr "azul claro"
+
+#: internal/presentation/attributes.go
+msgid "light cyan"
+msgstr "cian claro"
+
+#: internal/presentation/attributes.go
+msgid "light gray"
+msgstr "gris claro"
+
+#: internal/presentation/attributes.go
+msgid "light green"
+msgstr "verde claro"
+
+#: internal/presentation/attributes.go
+msgid "light magenta"
+msgstr "magenta claro"
+
+#: internal/presentation/attributes.go
+msgid "light red"
+msgstr "rojo claro"
+
+#: internal/presentation/attributes.go
+msgid "light yellow"
+msgstr "amarillo claro"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "line"
+msgstr "guion bajo"
+
+#: internal/review/navigation.go:25
+msgid "line break"
+msgstr "Nueva lĆnea"
+
+#: internal/review/first_text.go:29 internal/review/last_text.go:28
+msgid "line is empty"
+msgstr "LĆnea en blanco"
+
+#: internal/review/position.go:27
+msgid "line {0}, column {1}, Terminal {2}"
+msgstr "lĆnea {0}, columna {1}, terminal {2}"
+
+#: internal/punctuation/emoticons.go:23
+msgid "lips sealed"
+msgstr "labios sellados"
+
+#: internal/presentation/attributes.go
+msgid "magenta"
+msgstr "magenta"
+
+#: internal/app/spellcheck.go:22 internal/app/spellcheck_menu.go
+msgid "misspelled"
+msgstr "mal escrito"
+
+#: internal/punctuation/profile.go
+msgid "most"
+msgstr "la mayorĆa"
+
+#: internal/command/descriptions.go:32
+msgid "move review to top of screen"
+msgstr "ir al final de la pantalla"
+
+#: internal/command/descriptions.go:19
+msgid "moves review focus to the previous word "
+msgstr "mueve el cursor de revisión a la palabra anterior "
+
+#: internal/command/descriptions.go:18
+msgid "moves review to the next line "
+msgstr "mueve el cursor de revisión a la lĆnea siguiente "
+
+#: internal/command/descriptions.go:21
+msgid "moves review to the next word "
+msgstr "mueve el cursor de revisión a la palabra siguiente "
+
+#: internal/command/descriptions.go:22
+msgid "moves review to the previous character"
+msgstr "mueve el cursor de revisión al carÔcter anterior"
+
+#: internal/command/descriptions.go:16
+msgid "moves review to the previous line "
+msgstr "mueve el cursor de revisión a la lĆnea anterior "
+
+#: internal/punctuation/emoticons.go:19
+msgid "neutral"
+msgstr "neutral"
+
+#: internal/command/descriptions.go internal/command/descriptions.go:26
+#: internal/command/descriptions.go:27
+msgid "no description available"
+msgstr "sin descripción disponible"
+
+#: internal/review/marks.go:30
+msgid "no review cursor"
+msgstr "No hay cursor de revisión"
+
+#: internal/review/spelling.go:19 internal/review/table.go:150
+#: internal/review/table.go:158 internal/review/vertical_character.go:26
+#: internal/review/vertical_character.go:34
+msgid "no table data"
+msgstr "sin datos de tabla"
+
+#: internal/reader/regions.go:173 internal/review/review.go:135
+msgid "no text console"
+msgstr "sin consola de texto"
+
+#: internal/config/definitions.go:206 internal/punctuation/profile.go
+msgid "none"
+msgstr "ninguno"
+
+#: internal/punctuation/text_processing.go
+msgid "number"
+msgstr "nĆŗmero"
+
+#: internal/app/clipboard.go:344 internal/app/clipboard.go:399
+msgid "paste clipboard"
+msgstr "pegar portapapeles"
+
+#: internal/command/descriptions.go:109
+msgid "pastes the text from the currently selected clipboard"
+msgstr "Pega el texto del portapapeles en uso"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "percent"
+msgstr "por ciento"
+
+#: internal/command/descriptions.go:98
+msgid "places marks to select text to copy to the clipboard"
+msgstr ""
+"Establece marcas para seleccionar texto que serĆ” copiado al portapapeles"
+
+#: internal/app/clipboard.go:271
+msgid "please set begin and endmark"
+msgstr "Establece una marca de principio y otra para el final de la selección."
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "plus"
+msgstr "mƔs"
+
+#: internal/command/descriptions.go:23
+msgid "presents the current character."
+msgstr "Lee el carƔcter actual"
+
+#: internal/command/descriptions.go:15
+msgid "presents the date"
+msgstr "Lee la fecha"
+
+#: internal/command/descriptions.go:14
+msgid "presents the time"
+msgstr "Lee la hora"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "question?"
+msgstr "interrogación"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "quote"
+msgstr "comillas"
+
+#: internal/command/descriptions.go:46
+msgid ""
+"read line to cursor pos, use review cursor if you are in review mode, "
+"otherwhise use text cursor"
+msgstr ""
+"leer la lĆnea hasta el cursor; usar el cursor de revisión en modo de "
+"revisión y el cursor de texto en los demÔs casos"
+
+#: internal/command/descriptions.go:47
+msgid ""
+"read to end of line, use review cursor if you are in review mode, otherwhise "
+"use text cursor"
+msgstr ""
+"Leer hasta el fin de lĆnea, utiliza el cursor de revisión cuando el modo de "
+"revisión estÔ activo, de lo contrario utiliza el cursor de texto."
+
+#: internal/command/descriptions.go:40
+msgid "reads from the cursor to the bottom of the screen"
+msgstr "Lee desde el cursor hasta el final de la pantalla"
+
+#: internal/command/descriptions.go:38
+msgid "reads the contents of the current screen"
+msgstr "Lee el contenido de la pantalla actual"
+
+#: internal/presentation/attributes.go
+msgid "red"
+msgstr "rojo"
+
+#: internal/presentation/attributes.go
+msgid "reverse"
+msgstr "inverso"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "right brace"
+msgstr "llave derecha"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "right bracket"
+msgstr "corchete derecho"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "right paren"
+msgstr "parƩntesis derecho"
+
+#: internal/reader/regions.go:259
+msgid "row {0}, column {1} to end of line"
+msgstr "fila {0}, columna {1} hasta el final de la lĆnea"
+
+#: internal/reader/regions.go:261
+msgid "row {0}, column {1} to row {2}, column {3}"
+msgstr "fila {0}, columna {1} hasta fila {2}, columna {3}"
+
+#: internal/review/read_screen.go:34
+msgid "screen is empty"
+msgstr "La pantalla estĆ” vacĆa"
+
+#: internal/reader/reader.go:132
+msgid "screen {0}"
+msgstr "pantalla {0}"
+
+#: internal/interaction/entry.go:31
+msgid "selected"
+msgstr "seleccionado"
+
+#: internal/command/descriptions.go:106
+msgid "selects the first clipboard"
+msgstr "Selecciona el primer portapapeles"
+
+#: internal/command/descriptions.go:107
+msgid "selects the last clipboard"
+msgstr "Selecciona el Ćŗltimo portapapeles"
+
+#: internal/command/descriptions.go:105
+msgid "selects the next clipboard"
+msgstr "Selecciona el siguiente portapapeles"
+
+#: internal/command/descriptions.go:104
+msgid "selects the previous clipboard"
+msgstr "Selecciona el portapapeles anterior"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "semicolon"
+msgstr "punto y coma"
+
+#: internal/command/descriptions.go:70
+msgid "sends the following keypress to the terminal or application"
+msgstr "envĆa la siguiente pulsación al terminal o a la aplicación"
+
+#: internal/command/descriptions.go:72
+msgid "set Window Mode, needs 2 marks "
+msgstr "se necesitan dos marcas para establecer el modo ventana "
+
+#: internal/review/marks.go:43
+msgid "set mark"
+msgstr "Marcar"
+
+#: internal/command/descriptions.go:28
+msgid "set review and phonetically presents the current character"
+msgstr "Lee fonéticamente el último carÔcter"
+
+#: internal/command/descriptions.go:34
+msgid "set review cursor to begin of current line and display the content"
+msgstr "lee la lĆnea actual desde el principio"
+
+#: internal/punctuation/emoticons.go:16
+msgid "skeptical"
+msgstr "escƩptico"
+
+#: internal/punctuation/emoticons.go:21
+msgid "skull and crossbones"
+msgstr "calavera con huesos cruzados"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "slash"
+msgstr "barra"
+
+#: internal/punctuation/emoticons.go:10
+msgid "smile"
+msgstr "sonrisa"
+
+#: internal/punctuation/profile.go
+msgid "some"
+msgstr "algunos"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "space"
+msgstr "espacio"
+
+#: internal/command/descriptions.go:103
+msgid "speaks the contents of the currently selected clipboard"
+msgstr "Habla el contenido del portapapeles seleccionado"
+
+#: internal/config/definitions.go:206
+msgid "speech"
+msgstr "voz"
+
+#: internal/config/definitions.go:206
+msgid "speech and beeps"
+msgstr "voz y pitidos"
+
+#: internal/app/commands.go:59 internal/app/feedback.go:45
+msgid "speech enabled"
+msgstr "Habla activada"
+
+#: internal/config/config.go:131
+msgid "speech language"
+msgstr "idioma de la voz"
+
+#: internal/config/config.go:133
+msgid "speech pitch"
+msgstr "tono de voz"
+
+#: internal/config/config.go:126
+msgid "speech rate"
+msgstr "velocidad de voz"
+
+#: internal/app/commands.go:121
+msgid "speech temporary disabled"
+msgstr "Habla desactivada temporalmente"
+
+#: internal/config/config.go:132
+msgid "speech voice"
+msgstr "voz de sĆntesis"
+
+#: internal/config/config.go:134
+msgid "speech volume"
+msgstr "volumen de voz"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "star"
+msgstr "asterisco"
+
+#: internal/review/table.go:212
+msgid "start of cell"
+msgstr "inicio de celda"
+
+#: internal/review/table.go:168
+msgid "start of line"
+msgstr "inicio de lĆnea"
+
+#: internal/review/navigation.go:20
+msgid "start of screen"
+msgstr "inicio de pantalla"
+
+#: internal/presentation/attributes.go
+msgid "strikethrough"
+msgstr "tachado"
+
+#: internal/punctuation/emoticons.go:15
+msgid "surprised"
+msgstr "sorprendido"
+
+#: internal/presentation/text.go
+msgid "tab"
+msgstr "tabulación"
+
+#: internal/config/definitions.go:225 internal/reader/reader.go:184
+msgid "table mode"
+msgstr "modo de tabla"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "tilde"
+msgstr "tilde"
+
+#: internal/command/descriptions.go:51
+msgid "toggles the tutorial mode"
+msgstr "alterna el modo de aprendizaje"
+
+#: internal/punctuation/emoticons.go:14
+msgid "tongue out"
+msgstr "lengua fuera"
+
+#: internal/interaction/presentation.go:62
+msgid "unavailable"
+msgstr "no disponible"
+
+#: internal/app/tutorial.go:107 internal/app/tutorial.go:161
+#: internal/reader/reader.go:199 internal/review/review.go:140
+msgid "unbound"
+msgstr "sin asignar"
+
+#: internal/interaction/presentation.go
+msgid "unchecked"
+msgstr "no marcado"
+
+#: internal/presentation/attributes.go
+msgid "underline"
+msgstr "subrayado"
+
+#: internal/presentation/attributes.go internal/presentation/attributes.go:27
+msgid "white"
+msgstr "blanco"
+
+#: internal/punctuation/emoticons.go:13
+msgid "wink"
+msgstr "guiƱo"
+
+#: internal/app/spellcheck_menu.go
+msgid "{0} added to dictionary"
+msgstr "{0} aƱadido al diccionario"
+
+#: internal/interaction/presentation.go:53
+msgid "{0} button"
+msgstr "{0} botón"
+
+#: internal/app/internal_ui.go:122
+msgid "{0} closed"
+msgstr "{0} cerrado"
+
+#: internal/interaction/presentation.go:51
+msgid "{0} combo box {1}"
+msgstr "{0} cuadro combinado {1}"
+
+#: internal/interaction/presentation.go:45
+msgid "{0} entry {1}"
+msgstr "{0} campo de texto {1}"
+
+#: internal/interaction/presentation.go:49
+msgid "{0} grid. {1}"
+msgstr "{0} cuadrĆcula. {1}"
+
+#: internal/app/spellcheck_menu.go:134
+msgid "{0} is already in dictionary"
+msgstr "{0} ya estĆ” en el diccionario"
+
+#: internal/app/spellcheck_menu.go
+msgid "{0} removed"
+msgstr "se ha eliminado {0}"
+
+#: internal/interaction/presentation.go:31
+msgid "{0} tab"
+msgstr "{0} pestaƱa"
+
+#: internal/punctuation/text_processing.go:69
+msgid "{0} {1}"
+msgstr "{0} {1}"
+
+#: internal/interaction/grid.go:39
+msgid "{0}. row {1}, column {2}. {3} of {4}"
+msgstr "{0}. fila {1}, columna {2}. {3} de {4}"
+
+#: internal/app/tutorial.go:25
+msgid ""
+"{command_name}, Shortcuts {command_shortcuts}, Description "
+"{command_description}"
+msgstr ""
+"{command_name}, Atajos {command_shortcuts}, Descripción {command_description}"
+
+#: internal/app/tutorial.go internal/app/tutorial.go:66
+msgid "{} times "
+msgstr "{} veces "
diff --git a/internal/i18n/locale/fenrir.pot b/internal/i18n/locale/fenrir.pot
new file mode 100644
index 00000000..708a0fe5
--- /dev/null
+++ b/internal/i18n/locale/fenrir.pot
@@ -0,0 +1,2066 @@
+# Fenrir reader messages. Generated by go run ./tools/translations.
+msgid ""
+msgstr ""
+"Project-Id-Version: fenrir\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+
+#: internal/review/cursor_column.go:13
+msgid " column number"
+msgstr ""
+
+#: internal/review/cursor_line.go:13
+msgid " line number"
+msgstr ""
+
+#: internal/review/cursor_scope.go:10
+msgid " review cursor "
+msgstr ""
+
+#: internal/review/cursor_scope.go:12
+msgid " text cursor "
+msgstr ""
+
+#: internal/sound/drivers.go:39
+msgid "ALSA"
+msgstr ""
+
+#: internal/app/tutorial.go
+msgid "Active actions"
+msgstr ""
+
+#: internal/app/spellcheck_menu.go:51
+msgid "Add"
+msgstr ""
+
+#: internal/app/emoji_menu.go:34 internal/app/spellcheck_menu.go:107
+msgid "Added {0} to clipboard"
+msgstr ""
+
+#: internal/emoji/catalog.go:76
+msgid "Alien monster"
+msgstr ""
+
+#: internal/emoji/catalog.go
+msgid "All"
+msgstr ""
+
+#: internal/reader/regions.go:203
+msgid "All 10 live regions are set"
+msgstr ""
+
+#: internal/emoji/catalog.go:23
+msgid "Angry"
+msgstr ""
+
+#: internal/emoji/catalog.go:64
+msgid "Apple"
+msgstr ""
+
+#: internal/emoji/catalog.go:117
+msgid "Argentina"
+msgstr ""
+
+#: internal/config/config.go:155
+msgid "Attribute format"
+msgstr ""
+
+#: internal/config/config.go:154
+msgid "Attributes"
+msgstr ""
+
+#: internal/emoji/catalog.go:118
+msgid "Australia"
+msgstr ""
+
+#: internal/config/config.go:145
+msgid "Automatic reading"
+msgstr ""
+
+#: internal/config/config.go:156
+msgid "Automatic spell checking"
+msgstr ""
+
+#: internal/emoji/catalog.go:65
+msgid "Avocado"
+msgstr ""
+
+#: internal/emoji/catalog.go:77
+msgid "Bat"
+msgstr ""
+
+#: internal/config/config.go:129
+msgid "Baud rate"
+msgstr ""
+
+#: internal/emoji/catalog.go:24
+msgid "Beaming"
+msgstr ""
+
+#: internal/config/config.go:151
+msgid "Beep for capital letters"
+msgstr ""
+
+#: internal/emoji/catalog.go:66
+msgid "Beer"
+msgstr ""
+
+#: internal/emoji/catalog.go:119
+msgid "Belgium"
+msgstr ""
+
+#: internal/emoji/catalog.go:67
+msgid "Birthday cake"
+msgstr ""
+
+#: internal/emoji/catalog.go:78
+msgid "Black cat"
+msgstr ""
+
+#: internal/emoji/catalog.go:25
+msgid "Blowing kiss"
+msgstr ""
+
+#: internal/emoji/catalog.go:100
+msgid "Bone"
+msgstr ""
+
+#: internal/review/bottom.go:29
+msgid "Bottom"
+msgstr ""
+
+#: internal/emoji/catalog.go:120
+msgid "Brazil"
+msgstr ""
+
+#: internal/emoji/catalog.go:79
+msgid "Bunny"
+msgstr ""
+
+#: internal/emoji/catalog.go:51
+msgid "Butterfly"
+msgstr ""
+
+#: internal/emoji/catalog.go:121
+msgid "Canada"
+msgstr ""
+
+#: internal/preferences/settings.go:79
+msgid "Cancel"
+msgstr ""
+
+#: internal/emoji/catalog.go:52
+msgid "Cat"
+msgstr ""
+
+#: internal/emoji/catalog.go:101
+msgid "Chains"
+msgstr ""
+
+#: internal/config/config.go:140
+msgid "Character echo"
+msgstr ""
+
+#: internal/emoji/catalog.go:102
+msgid "Checkmark"
+msgstr ""
+
+#: internal/emoji/catalog.go:53
+msgid "Cherry blossom"
+msgstr ""
+
+#: internal/emoji/catalog.go:122
+msgid "China"
+msgstr ""
+
+#: internal/emoji/catalog.go:80
+msgid "Christmas tree"
+msgstr ""
+
+#: internal/command/descriptions.go:76
+msgid "Clear all live regions"
+msgstr ""
+
+#: internal/command/descriptions.go:77
+msgid "Clear all reading regions"
+msgstr ""
+
+#: internal/command/descriptions.go:79
+msgid "Clear live region 1"
+msgstr ""
+
+#: internal/command/descriptions.go:97
+msgid "Clear live region 10"
+msgstr ""
+
+#: internal/command/descriptions.go:81
+msgid "Clear live region 2"
+msgstr ""
+
+#: internal/command/descriptions.go:83
+msgid "Clear live region 3"
+msgstr ""
+
+#: internal/command/descriptions.go:85
+msgid "Clear live region 4"
+msgstr ""
+
+#: internal/command/descriptions.go:87
+msgid "Clear live region 5"
+msgstr ""
+
+#: internal/command/descriptions.go:89
+msgid "Clear live region 6"
+msgstr ""
+
+#: internal/command/descriptions.go:91
+msgid "Clear live region 7"
+msgstr ""
+
+#: internal/command/descriptions.go:93
+msgid "Clear live region 8"
+msgstr ""
+
+#: internal/command/descriptions.go:95
+msgid "Clear live region 9"
+msgstr ""
+
+#: internal/config/config.go:57
+msgid "Clipboard"
+msgstr ""
+
+#: internal/app/application.go:136 internal/app/application.go:139 internal/app/clipboard.go:218 internal/app/clipboard.go:374
+msgid "Clipboard import failed"
+msgstr ""
+
+#: internal/config/config.go:106
+msgid "Clipboard sync"
+msgstr ""
+
+#: internal/emoji/catalog.go:68
+msgid "Coffee"
+msgstr ""
+
+#: internal/emoji/catalog.go:81
+msgid "Coffin"
+msgstr ""
+
+#: internal/command/descriptions.go:45
+msgid "Column number for cursor"
+msgstr ""
+
+#: internal/emoji/catalog.go:26
+msgid "Cool"
+msgstr ""
+
+#: internal/app/spellcheck_menu.go:175
+msgid "Could not check spelling: {0}"
+msgstr ""
+
+#: internal/app/emoji_menu.go:17 internal/app/emoji_menu.go:21
+msgid "Could not open Emoji Menu: {0}"
+msgstr ""
+
+#: internal/app/fenrir_menu.go:32 internal/app/fenrir_menu.go:52 internal/app/fenrir_menu.go:67 internal/app/runtime_settings.go:133
+msgid "Could not open Fenrir Menu: {0}"
+msgstr ""
+
+#: internal/app/spellcheck_menu.go:39 internal/app/spellcheck_menu.go:58
+msgid "Could not open Spellcheck Menu: {0}"
+msgstr ""
+
+#: internal/app/spellcheck_menu.go:155
+msgid "Could not save exceptions: {0}"
+msgstr ""
+
+#: internal/app/fenrir_menu.go:174 internal/app/fenrir_menu.go:180 internal/app/runtime_settings.go:164 internal/app/runtime_settings.go:191
+msgid "Could not save settings: {0}"
+msgstr ""
+
+#: internal/emoji/catalog.go:27
+msgid "Crying"
+msgstr ""
+
+#: internal/config/config.go:146
+msgid "Cursor echo"
+msgstr ""
+
+#: internal/emoji/catalog.go:103
+msgid "Dagger"
+msgstr ""
+
+#: internal/config/config.go:102
+msgid "Date format"
+msgstr ""
+
+#: internal/emoji/catalog.go:123
+msgid "Denmark"
+msgstr ""
+
+#: internal/config/config.go:138
+msgid "Devices"
+msgstr ""
+
+#: internal/emoji/catalog.go:28
+msgid "Devil"
+msgstr ""
+
+#: internal/app/spellcheck_menu.go:191
+msgid "Dictionary {0}. {1}. {2}"
+msgstr ""
+
+#: internal/emoji/catalog.go:29
+msgid "Dizzy"
+msgstr ""
+
+#: internal/emoji/catalog.go:54
+msgid "Dog"
+msgstr ""
+
+#: internal/emoji/catalog.go:69
+msgid "Donut"
+msgstr ""
+
+#: internal/config/config.go:120 internal/config/config.go:127
+msgid "Driver"
+msgstr ""
+
+#: internal/config/config.go:142
+msgid "Echo on CapsLock"
+msgstr ""
+
+#: internal/emoji/catalog.go:82
+msgid "Egg"
+msgstr ""
+
+#: internal/emoji/catalog.go:162
+msgid "Emoji"
+msgstr ""
+
+#: internal/command/descriptions.go:53 internal/emoji/catalog.go:14
+msgid "Emoji Menu"
+msgstr ""
+
+#: internal/config/config.go:160
+msgid "Emoticons"
+msgstr ""
+
+#: internal/config/config.go:107
+msgid "Enable Persistent Clipboard"
+msgstr ""
+
+#: internal/app/tutorial.go:21
+msgid "Entering tutorial mode. In this mode commands are described but not executed. Use up and down to browse actions and left and right to switch between Active actions, Unbound actions, and Plugins. Press Space to repeat the current item. To exit tutorial mode press Fenrir+F1 or Escape. Active actions."
+msgstr ""
+
+#: internal/app/spellcheck_menu.go:49 internal/app/spellcheck_menu.go:52
+msgid "Exceptions"
+msgstr ""
+
+#: internal/review/follow_cursor.go:29
+msgid "Exiting Review Mode"
+msgstr ""
+
+#: internal/app/tutorial.go:22
+msgid "Exiting tutorial mode. To enter tutorial mode again press Fenrir+F1"
+msgstr ""
+
+#: internal/emoji/catalog.go:30
+msgid "Exploding head"
+msgstr ""
+
+#: internal/command/descriptions.go:113
+msgid "Export current fenrir clipboard to X or GUI clipboard"
+msgstr ""
+
+#: internal/emoji/catalog.go:31
+msgid "Face with symbols"
+msgstr ""
+
+#: internal/app/quick_settings.go:295 internal/app/runtime_settings.go:151
+msgid "Failed to save settings."
+msgstr ""
+
+#: internal/command/descriptions.go:55 internal/preferences/settings.go:38
+msgid "Fenrir Menu"
+msgstr ""
+
+#: internal/app/commands.go:83
+msgid "Fenrir screen reader version {0}-{1}"
+msgstr ""
+
+#: internal/app/clipboard.go:377
+msgid "File does not exist"
+msgstr ""
+
+#: internal/emoji/catalog.go:124
+msgid "Finland"
+msgstr ""
+
+#: internal/emoji/catalog.go:104
+msgid "Fire"
+msgstr ""
+
+#: internal/emoji/catalog.go:83
+msgid "Fireworks"
+msgstr ""
+
+#: internal/app/clipboard.go:323
+msgid "First clipboard "
+msgstr ""
+
+#: internal/emoji/catalog.go
+msgid "Flags"
+msgstr ""
+
+#: internal/emoji/catalog.go:105
+msgid "Folded hands"
+msgstr ""
+
+#: internal/emoji/catalog.go
+msgid "Food"
+msgstr ""
+
+#: internal/app/commands.go:126
+msgid "Forward next keypress"
+msgstr ""
+
+#: internal/emoji/catalog.go:125
+msgid "France"
+msgstr ""
+
+#: internal/emoji/catalog.go:126
+msgid "Germany"
+msgstr ""
+
+#: internal/emoji/catalog.go:84
+msgid "Ghost"
+msgstr ""
+
+#: internal/emoji/catalog.go:85
+msgid "Gift"
+msgstr ""
+
+#: internal/emoji/catalog.go:127
+msgid "Greece"
+msgstr ""
+
+#: internal/emoji/catalog.go:32
+msgid "Grinning"
+msgstr ""
+
+#: internal/emoji/catalog.go:33
+msgid "Grinning sweat"
+msgstr ""
+
+#: internal/emoji/catalog.go:70
+msgid "Hamburger"
+msgstr ""
+
+#: internal/emoji/catalog.go:106
+msgid "Heart"
+msgstr ""
+
+#: internal/emoji/catalog.go:34
+msgid "Heart eyes"
+msgstr ""
+
+#: internal/emoji/catalog.go:35
+msgid "Hearts around"
+msgstr ""
+
+#: internal/emoji/catalog.go:107
+msgid "High voltage"
+msgstr ""
+
+#: internal/config/config.go:109
+msgid "History items"
+msgstr ""
+
+#: internal/emoji/catalog.go
+msgid "Holidays"
+msgstr ""
+
+#: internal/emoji/catalog.go:108
+msgid "Hundred points"
+msgstr ""
+
+#: internal/emoji/catalog.go:36
+msgid "Imp"
+msgstr ""
+
+#: internal/app/clipboard.go:412
+msgid "Import to Clipboard"
+msgstr ""
+
+#: internal/config/config.go:153
+msgid "Indentation"
+msgstr ""
+
+#: internal/emoji/catalog.go:128
+msgid "India"
+msgstr ""
+
+#: internal/emoji/catalog.go:129
+msgid "Ireland"
+msgstr ""
+
+#: internal/emoji/catalog.go:130
+msgid "Israel"
+msgstr ""
+
+#: internal/emoji/catalog.go:131
+msgid "Italy"
+msgstr ""
+
+#: internal/emoji/catalog.go:86
+msgid "Jack o'lantern"
+msgstr ""
+
+#: internal/emoji/catalog.go:132
+msgid "Japan"
+msgstr ""
+
+#: internal/config/config.go:53
+msgid "Keyboard"
+msgstr ""
+
+#: internal/app/clipboard.go:326
+msgid "Last clipboard "
+msgstr ""
+
+#: internal/emoji/catalog.go:37
+msgid "Laughing"
+msgstr ""
+
+#: internal/config/config.go:139
+msgid "Layout"
+msgstr ""
+
+#: internal/command/descriptions.go:44
+msgid "Line number for cursor"
+msgstr ""
+
+#: internal/command/descriptions.go:75
+msgid "List reading regions"
+msgstr ""
+
+#: internal/reader/regions.go:185
+msgid "Live region {0} cleared"
+msgstr ""
+
+#: internal/reader/regions.go:181
+msgid "Live region {0} not set"
+msgstr ""
+
+#: internal/reader/regions.go:189
+msgid "Live region {0} outside screen"
+msgstr ""
+
+#: internal/reader/regions.go:209
+msgid "Live region {0} set"
+msgstr ""
+
+#: internal/reader/regions.go:245
+msgid "Live region {0}: {1}"
+msgstr ""
+
+#: internal/reader/regions.go:232
+msgid "Live regions cleared"
+msgstr ""
+
+#: internal/emoji/catalog.go:87
+msgid "Mage"
+msgstr ""
+
+#: internal/emoji/catalog.go:133
+msgid "Mexico"
+msgstr ""
+
+#: internal/config/config.go:159
+msgid "Misspelling sound"
+msgstr ""
+
+#: internal/emoji/catalog.go:55
+msgid "Moon"
+msgstr ""
+
+#: internal/command/descriptions.go:35
+msgid "Move Review to the end of current line and display the content"
+msgstr ""
+
+#: internal/command/descriptions.go:36
+msgid "Move Review to the first character on the line"
+msgstr ""
+
+#: internal/command/descriptions.go:48
+msgid "Move Review to the first character on the screen (left top)"
+msgstr ""
+
+#: internal/command/descriptions.go:37
+msgid "Move Review to the last character on the line"
+msgstr ""
+
+#: internal/command/descriptions.go:49
+msgid "Move Review to the last character on the screen (right bottom)"
+msgstr ""
+
+#: internal/command/descriptions.go:33
+msgid "Move review to the bottom of the screen"
+msgstr ""
+
+#: internal/command/descriptions.go:31
+msgid "Move review to the character below the current position"
+msgstr ""
+
+#: internal/command/descriptions.go:30
+msgid "Move review to the character in the line above the current position"
+msgstr ""
+
+#: internal/command/descriptions.go:29
+msgid "Moves review to the next character"
+msgstr ""
+
+#: internal/emoji/catalog.go
+msgid "Nature"
+msgstr ""
+
+#: internal/emoji/catalog.go:38
+msgid "Nauseated"
+msgstr ""
+
+#: internal/emoji/catalog.go:134
+msgid "Netherlands"
+msgstr ""
+
+#: internal/interaction/grid.go:43 internal/interaction/grid.go:79
+msgid "No matches"
+msgstr ""
+
+#: internal/reader/regions.go:251
+msgid "No reading regions set"
+msgstr ""
+
+#: internal/app/clipboard.go:416
+msgid "No text found in clipboard or no accessible display"
+msgstr ""
+
+#: internal/app/spellcheck_menu.go
+msgid "No word"
+msgstr ""
+
+#: internal/emoji/catalog.go:135
+msgid "Norway"
+msgstr ""
+
+#: internal/review/follow_cursor.go:26
+msgid "Not in Review Mode"
+msgstr ""
+
+#: internal/reader/regions.go:222
+msgid "Not in window Mode"
+msgstr ""
+
+#: internal/preferences/settings.go:79
+msgid "OK"
+msgstr ""
+
+#: internal/app/clipboard.go:269 internal/reader/regions.go:197
+msgid "One or two marks are needed"
+msgstr ""
+
+#: internal/emoji/catalog.go:109
+msgid "Peace"
+msgstr ""
+
+#: internal/emoji/catalog.go
+msgid "People"
+msgstr ""
+
+#: internal/command/descriptions.go:25
+msgid "Phonetically spells the current word"
+msgstr ""
+
+#: internal/config/config.go:133
+msgid "Pitch"
+msgstr ""
+
+#: internal/emoji/catalog.go:71
+msgid "Pizza"
+msgstr ""
+
+#: internal/emoji/catalog.go:39
+msgid "Pleading"
+msgstr ""
+
+#: internal/app/tutorial.go
+msgid "Plugins"
+msgstr ""
+
+#: internal/emoji/catalog.go:136
+msgid "Poland"
+msgstr ""
+
+#: internal/emoji/catalog.go:40
+msgid "Poop"
+msgstr ""
+
+#: internal/emoji/catalog.go:137
+msgid "Portugal"
+msgstr ""
+
+#: internal/command/descriptions.go:52
+msgid "Present the version of Fenrir currently in use."
+msgstr ""
+
+#: internal/config/config.go:52
+msgid "Presentation"
+msgstr ""
+
+#: internal/command/descriptions.go:100
+msgid "Presents the currently selected text that will be copied to the clipboard"
+msgstr ""
+
+#: internal/app/commands.go:112
+msgid "Progress monitoring disabled"
+msgstr ""
+
+#: internal/app/commands.go:110
+msgid "Progress monitoring enabled"
+msgstr ""
+
+#: internal/sound/drivers.go:39
+msgid "PulseAudio"
+msgstr ""
+
+#: internal/config/config.go:149
+msgid "Punctuation level"
+msgstr ""
+
+#: internal/config/config.go:148
+msgid "Punctuation profile"
+msgstr ""
+
+#: internal/app/quick_settings.go:115
+msgid "Quick menu not available"
+msgstr ""
+
+#: internal/emoji/catalog.go:56
+msgid "Rainbow"
+msgstr ""
+
+#: internal/config/config.go:150
+msgid "Raise pitch for capital letters"
+msgstr ""
+
+#: internal/config/config.go:126
+msgid "Rate"
+msgstr ""
+
+#: internal/punctuation/emoticons.go:33
+msgid "Raves"
+msgstr ""
+
+#: internal/command/descriptions.go:78
+msgid "Read live region 1"
+msgstr ""
+
+#: internal/command/descriptions.go:96
+msgid "Read live region 10"
+msgstr ""
+
+#: internal/command/descriptions.go:80
+msgid "Read live region 2"
+msgstr ""
+
+#: internal/command/descriptions.go:82
+msgid "Read live region 3"
+msgstr ""
+
+#: internal/command/descriptions.go:84
+msgid "Read live region 4"
+msgstr ""
+
+#: internal/command/descriptions.go:86
+msgid "Read live region 5"
+msgstr ""
+
+#: internal/command/descriptions.go:88
+msgid "Read live region 6"
+msgstr ""
+
+#: internal/command/descriptions.go:90
+msgid "Read live region 7"
+msgstr ""
+
+#: internal/command/descriptions.go:92
+msgid "Read live region 8"
+msgstr ""
+
+#: internal/command/descriptions.go:94
+msgid "Read live region 9"
+msgstr ""
+
+#: internal/config/config.go:157
+msgid "Read misspelled word"
+msgstr ""
+
+#: internal/config/config.go:54
+msgid "Reading"
+msgstr ""
+
+#: internal/reader/regions.go:236
+msgid "Reading regions cleared"
+msgstr ""
+
+#: internal/reader/regions.go:241
+msgid "Reading window: {0}"
+msgstr ""
+
+#: internal/command/descriptions.go:42
+msgid "Reads attributes of current cursor position"
+msgstr ""
+
+#: internal/command/descriptions.go:39
+msgid "Reads from the top of the screen to the cursor position"
+msgstr ""
+
+#: internal/app/clipboard.go:264
+msgid "Remove marks"
+msgstr ""
+
+#: internal/command/descriptions.go:99
+msgid "Removes marks from selected text"
+msgstr ""
+
+#: internal/config/config.go:161
+msgid "Repeated symbols"
+msgstr ""
+
+#: internal/config/config.go:162
+msgid "Repeated symbols minimum length"
+msgstr ""
+
+#: internal/emoji/catalog.go:110
+msgid "Rock on"
+msgstr ""
+
+#: internal/emoji/catalog.go:41
+msgid "Rolling laughing"
+msgstr ""
+
+#: internal/emoji/catalog.go:57
+msgid "Rose"
+msgstr ""
+
+#: internal/emoji/catalog.go:138
+msgid "Russia"
+msgstr ""
+
+#: internal/emoji/catalog.go:42
+msgid "Sad"
+msgstr ""
+
+#: internal/emoji/catalog.go:88
+msgid "Santa"
+msgstr ""
+
+#: internal/command/descriptions.go:64
+msgid "Saves your current Fenrir settings so they are the default."
+msgstr ""
+
+#: internal/emoji/catalog.go:43
+msgid "Savoring food"
+msgstr ""
+
+#: internal/config/config.go:152
+msgid "Say ācapā before capital letters"
+msgstr ""
+
+#: internal/config/config.go:158
+msgid "Say āmisspelledā"
+msgstr ""
+
+#: internal/emoji/catalog.go:44
+msgid "Screaming"
+msgstr ""
+
+#: internal/app/commands.go:135 internal/app/scripts.go
+msgid "Script failed"
+msgstr ""
+
+#: internal/app/scripts.go
+msgid "Script file is not executable"
+msgstr ""
+
+#: internal/app/scripts.go
+msgid "Script file not found"
+msgstr ""
+
+#: internal/app/scripts.go
+msgid "Script source is not a valid file"
+msgstr ""
+
+#: internal/interaction/grid.go:71
+msgid "Search cleared"
+msgstr ""
+
+#: internal/interaction/grid.go:73
+msgid "Search {0}"
+msgstr ""
+
+#: internal/config/config.go:121
+msgid "Select sound theme"
+msgstr ""
+
+#: internal/config/config.go:128
+msgid "Serial port"
+msgstr ""
+
+#: internal/command/descriptions.go:74
+msgid "Set a live region from one or two marks"
+msgstr ""
+
+#: internal/reader/regions.go:213
+msgid "Set window begin and end marks"
+msgstr ""
+
+#: internal/app/quick_settings.go:298
+msgid "Settings saved."
+msgstr ""
+
+#: internal/emoji/catalog.go:89
+msgid "Shamrock"
+msgstr ""
+
+#: internal/emoji/catalog.go:90
+msgid "Skull"
+msgstr ""
+
+#: internal/emoji/catalog.go:111
+msgid "Skull crossbones"
+msgstr ""
+
+#: internal/emoji/catalog.go:45
+msgid "Smiling"
+msgstr ""
+
+#: internal/emoji/catalog.go:46
+msgid "Smirking"
+msgstr ""
+
+#: internal/emoji/catalog.go:91
+msgid "Snowman"
+msgstr ""
+
+#: internal/config/config.go:95
+msgid "Sound"
+msgstr ""
+
+#: internal/config/config.go:118
+msgid "Sound enabled"
+msgstr ""
+
+#: internal/emoji/catalog.go:139
+msgid "South Africa"
+msgstr ""
+
+#: internal/emoji/catalog.go:140
+msgid "South Korea"
+msgstr ""
+
+#: internal/emoji/catalog.go:141
+msgid "Spain"
+msgstr ""
+
+#: internal/emoji/catalog.go:112
+msgid "Sparkles"
+msgstr ""
+
+#: internal/config/config.go:94
+msgid "Speech"
+msgstr ""
+
+#: internal/speech/drivers.go:44
+msgid "Speech Dispatcher"
+msgstr ""
+
+#: internal/config/config.go:130
+msgid "Speech Dispatcher module"
+msgstr ""
+
+#: internal/config/config.go:125
+msgid "Speech enabled"
+msgstr ""
+
+#: internal/config/config.go:131
+msgid "Speech language"
+msgstr ""
+
+#: internal/app/screen_updates.go:72
+msgid "Speech restored"
+msgstr ""
+
+#: internal/config/config.go:132
+msgid "Speech voice"
+msgstr ""
+
+#: internal/app/spellcheck_menu.go:81
+msgid "Spell checking is unavailable"
+msgstr ""
+
+#: internal/app/spellcheck_menu.go:180
+msgid "Spell checking is unavailable: {0}"
+msgstr ""
+
+#: internal/command/descriptions.go:54
+msgid "Spellcheck Menu"
+msgstr ""
+
+#: internal/command/descriptions.go:24
+msgid "Spells the current word"
+msgstr ""
+
+#: internal/emoji/catalog.go:92
+msgid "Spider"
+msgstr ""
+
+#: internal/emoji/catalog.go:113
+msgid "Star"
+msgstr ""
+
+#: internal/app/screen_updates.go:39
+msgid "Start Fenrir"
+msgstr ""
+
+#: internal/emoji/catalog.go:72
+msgid "Strawberry"
+msgstr ""
+
+#: internal/app/spellcheck_menu.go:45 internal/app/spellcheck_menu.go:47
+msgid "Suggestions"
+msgstr ""
+
+#: internal/emoji/catalog.go:58
+msgid "Sun"
+msgstr ""
+
+#: internal/emoji/catalog.go:59
+msgid "Sunflower"
+msgstr ""
+
+#: internal/emoji/catalog.go:142
+msgid "Sweden"
+msgstr ""
+
+#: internal/emoji/catalog.go:143
+msgid "Switzerland"
+msgstr ""
+
+#: internal/emoji/catalog.go:114
+msgid "Sword"
+msgstr ""
+
+#: internal/emoji/catalog.go
+msgid "Symbols"
+msgstr ""
+
+#: internal/emoji/catalog.go:73
+msgid "Taco"
+msgstr ""
+
+#: internal/preferences/settings.go:72
+msgid "Test synthesizer"
+msgstr ""
+
+#: internal/app/fenrir_menu.go:139
+msgid "Testing voice"
+msgstr ""
+
+#: internal/emoji/catalog.go:47
+msgid "Thumbs up"
+msgstr ""
+
+#: internal/config/config.go:97
+msgid "Time and date"
+msgstr ""
+
+#: internal/config/config.go:101
+msgid "Time format"
+msgstr ""
+
+#: internal/command/descriptions.go:67
+msgid "Toggle progress bar monitoring with ascending tones"
+msgstr ""
+
+#: internal/review/top.go:29
+msgid "Top"
+msgstr ""
+
+#: internal/config/config.go:147
+msgid "Tracking mode"
+msgstr ""
+
+#: internal/emoji/catalog.go:60
+msgid "Tree"
+msgstr ""
+
+#: internal/emoji/catalog.go:144 internal/emoji/catalog.go:93
+msgid "Turkey"
+msgstr ""
+
+#: internal/command/descriptions.go:73
+msgid "Turn off window mode"
+msgstr ""
+
+#: internal/emoji/catalog.go:145
+msgid "Ukraine"
+msgstr ""
+
+#: internal/app/tutorial.go:23
+msgid "Unable to exit tutorial mode because exclusive keyboard capture could not be released. Press Fenrir+F1 or Escape to try again."
+msgstr ""
+
+#: internal/app/tutorial.go
+msgid "Unbound actions"
+msgstr ""
+
+#: internal/app/spellcheck_menu.go:53
+msgid "Undo"
+msgstr ""
+
+#: internal/emoji/catalog.go:146
+msgid "United Kingdom"
+msgstr ""
+
+#: internal/emoji/catalog.go:147
+msgid "United States"
+msgstr ""
+
+#: internal/emoji/catalog.go:94
+msgid "Vampire"
+msgstr ""
+
+#: internal/app/quick_settings.go:191 internal/app/synthesizer_preview.go:60
+msgid "Voice test failed"
+msgstr ""
+
+#: internal/app/synthesizer_preview.go:23
+msgid "Voice test: The quick brown fox jumps over the lazy dog."
+msgstr ""
+
+#: internal/config/config.go:119 internal/config/config.go:134
+msgid "Volume"
+msgstr ""
+
+#: internal/punctuation/emoticons.go:32
+msgid "WTF?"
+msgstr ""
+
+#: internal/app/tutorial.go:24
+msgid "Warning: full keyboard capture is unavailable. Unbound keys may reach the active application."
+msgstr ""
+
+#: internal/emoji/catalog.go:95
+msgid "Web"
+msgstr ""
+
+#: internal/reader/regions.go:225
+msgid "Window Mode off for terminal {0}"
+msgstr ""
+
+#: internal/reader/regions.go:219
+msgid "Window Mode on for terminal {0}"
+msgstr ""
+
+#: internal/emoji/catalog.go:48
+msgid "Winking"
+msgstr ""
+
+#: internal/emoji/catalog.go:96
+msgid "Witch"
+msgstr ""
+
+#: internal/emoji/catalog.go:61
+msgid "Wolf"
+msgstr ""
+
+#: internal/app/spellcheck_menu.go:46 internal/app/spellcheck_menu.go:50
+msgid "Word"
+msgstr ""
+
+#: internal/config/config.go:141
+msgid "Word echo"
+msgstr ""
+
+#: internal/emoji/catalog.go:97
+msgid "Zombie"
+msgstr ""
+
+#: internal/punctuation/profile.go
+msgid "all"
+msgstr ""
+
+#: config/punctuation/default.toml config/punctuation/en.toml config/punctuation/rhvoice-en.toml
+msgid "and"
+msgstr ""
+
+#: internal/punctuation/emoticons.go:17
+msgid "angry"
+msgstr ""
+
+#: internal/reader/attributes.go:36
+msgid "announcement of attributes disabled"
+msgstr ""
+
+#: internal/reader/attributes.go:33
+msgid "announcement of attributes enabled"
+msgstr ""
+
+#: internal/punctuation/emoticons.go:26
+msgid "annoyed"
+msgstr ""
+
+#: config/punctuation/default.toml config/punctuation/en.toml config/punctuation/rhvoice-en.toml
+msgid "apostrophe"
+msgstr ""
+
+#: internal/punctuation/emoticons.go:39
+msgid "arrow left"
+msgstr ""
+
+#: internal/punctuation/emoticons.go:38
+msgid "arrow right"
+msgstr ""
+
+#: config/punctuation/default.toml config/punctuation/en.toml config/punctuation/rhvoice-en.toml
+msgid "at"
+msgstr ""
+
+#: internal/reader/reader.go:197
+msgid "autoread disabled"
+msgstr ""
+
+#: internal/reader/reader.go:195
+msgid "autoread enabled"
+msgstr ""
+
+#: config/punctuation/default.toml config/punctuation/en.toml config/punctuation/rhvoice-en.toml
+msgid "backslash"
+msgstr ""
+
+#: config/punctuation/default.toml config/punctuation/en.toml config/punctuation/rhvoice-en.toml
+msgid "bang"
+msgstr ""
+
+#: config/punctuation/default.toml config/punctuation/en.toml config/punctuation/rhvoice-en.toml
+msgid "bar"
+msgstr ""
+
+#: internal/config/definitions.go:206
+msgid "beeps"
+msgstr ""
+
+#: internal/review/line_start.go:28
+msgid "beginning of line"
+msgstr ""
+
+#: internal/punctuation/emoticons.go:12
+msgid "big grin"
+msgstr ""
+
+#: internal/presentation/attributes.go
+msgid "black"
+msgstr ""
+
+#: internal/app/clipboard.go:281 internal/presentation/text.go:35 internal/reader/regions.go:191 internal/review/line_start.go:31 internal/review/screen_range.go:39 internal/review/spelling.go:44 internal/review/table.go:124 internal/review/table.go:205 internal/review/vertical_character.go:31 internal/review/word.go:64
+msgid "blank"
+msgstr ""
+
+#: internal/presentation/attributes.go
+msgid "blink"
+msgstr ""
+
+#: internal/presentation/attributes.go
+msgid "blue"
+msgstr ""
+
+#: internal/presentation/attributes.go
+msgid "bold"
+msgstr ""
+
+#: internal/punctuation/emoticons.go:31
+msgid "broken heart"
+msgstr ""
+
+#: internal/presentation/attributes.go
+msgid "brown/yellow"
+msgstr ""
+
+#: internal/punctuation/emoticons.go:34
+msgid "bugged"
+msgstr ""
+
+#: internal/app/clipboard.go:93 internal/app/review_spelling.go:27
+msgid "cap"
+msgstr ""
+
+#: config/punctuation/default.toml config/punctuation/en.toml config/punctuation/rhvoice-en.toml
+msgid "carrot"
+msgstr ""
+
+#: internal/presentation/text.go:68
+msgid "character {0}"
+msgstr ""
+
+#: internal/interaction/presentation.go
+msgid "checked"
+msgstr ""
+
+#: internal/command/descriptions.go:108
+msgid "clears the currently selected clipboard"
+msgstr ""
+
+#: internal/app/clipboard.go:299
+msgid "clipboard cleared"
+msgstr ""
+
+#: internal/app/clipboard.go:302 internal/app/clipboard.go:337
+msgid "clipboard empty"
+msgstr ""
+
+#: internal/app/clipboard.go:395
+msgid "clipboard exported to file"
+msgstr ""
+
+#: config/punctuation/default.toml config/punctuation/en.toml config/punctuation/rhvoice-en.toml
+msgid "colon"
+msgstr ""
+
+#: config/punctuation/default.toml config/punctuation/en.toml config/punctuation/rhvoice-en.toml
+msgid "comma"
+msgstr ""
+
+#: internal/punctuation/emoticons.go:27
+msgid "confused"
+msgstr ""
+
+#: internal/punctuation/emoticons.go:20
+msgid "cool"
+msgstr ""
+
+#: internal/command/descriptions.go:102
+msgid "copies last presented text to the clipboard"
+msgstr ""
+
+#: internal/command/descriptions.go:101
+msgid "copies marked text to the currently selected clipboard"
+msgstr ""
+
+#: internal/app/spellcheck_menu.go
+msgid "correct"
+msgstr ""
+
+#: internal/review/table.go:132
+msgid "could not set header row"
+msgstr ""
+
+#: internal/punctuation/emoticons.go:18
+msgid "crying"
+msgstr ""
+
+#: internal/command/descriptions.go:17
+msgid "current line"
+msgstr ""
+
+#: internal/command/descriptions.go:20
+msgid "current word."
+msgstr ""
+
+#: internal/config/definitions.go:225 internal/reader/reader.go:190
+msgid "cursor tracking"
+msgstr ""
+
+#: internal/presentation/attributes.go
+msgid "cyan"
+msgstr ""
+
+#: internal/presentation/attributes.go
+msgid "dark gray"
+msgstr ""
+
+#: config/punctuation/default.toml config/punctuation/en.toml config/punctuation/rhvoice-en.toml
+msgid "dash"
+msgstr ""
+
+#: internal/app/quick_settings.go:185 internal/app/quick_settings.go:234 internal/config/definitions.go:237 internal/preferences/discovery.go:45 internal/preferences/discovery.go:55 internal/preferences/discovery.go:81 internal/presentation/attributes.go:18 internal/presentation/attributes.go:44
+msgid "default"
+msgstr ""
+
+#: internal/command/descriptions.go:66
+msgid "disables speech until next keypress"
+msgstr ""
+
+#: internal/command/descriptions.go:43
+msgid "displays the position of the review cursor"
+msgstr ""
+
+#: config/punctuation/default.toml config/punctuation/en.toml config/punctuation/rhvoice-en.toml
+msgid "dollar"
+msgstr ""
+
+#: config/punctuation/default.toml config/punctuation/en.toml config/punctuation/rhvoice-en.toml
+msgid "dot"
+msgstr ""
+
+#: internal/command/descriptions.go:68
+msgid "enables or disables automatic reading of new text as it appears"
+msgstr ""
+
+#: internal/command/descriptions.go:65
+msgid "enables or disables speech"
+msgstr ""
+
+#: internal/command/descriptions.go:41
+msgid "enables or disables the announcement of attributes"
+msgstr ""
+
+#: internal/command/descriptions.go:69
+msgid "enables or disables tracking of highlighted text"
+msgstr ""
+
+#: internal/review/table.go:218
+msgid "end of cell"
+msgstr ""
+
+#: internal/review/line_end.go:24 internal/review/table.go:175
+msgid "end of line"
+msgstr ""
+
+#: internal/review/navigation.go:17
+msgid "end of screen"
+msgstr ""
+
+#: internal/punctuation/emoticons.go:37
+msgid "enjoy smile"
+msgstr ""
+
+#: config/punctuation/default.toml config/punctuation/en.toml config/punctuation/rhvoice-en.toml
+msgid "equals"
+msgstr ""
+
+#: internal/punctuation/emoticons.go:36
+msgid "evil smile"
+msgstr ""
+
+#: internal/command/descriptions.go:71
+msgid "exits Fenrir"
+msgstr ""
+
+#: internal/command/descriptions.go:50
+msgid "exits review mode"
+msgstr ""
+
+#: internal/command/descriptions.go:111
+msgid "export the current fenrir clipboard to a file"
+msgstr ""
+
+#: internal/app/clipboard.go:391
+msgid "exported to the X session."
+msgstr ""
+
+#: internal/app/clipboard.go:372
+msgid "failed to export to X clipboard. No available display found."
+msgstr ""
+
+#: internal/review/table.go:190
+msgid "first cell"
+msgstr ""
+
+#: internal/review/table.go:224
+msgid "first character in cell {0}"
+msgstr ""
+
+#: internal/review/first_text.go:35
+msgid "first character in line indent {0}"
+msgstr ""
+
+#: internal/review/screen_first_character.go:13
+msgid "first character in screen"
+msgstr ""
+
+#: internal/punctuation/emoticons.go:11
+msgid "frown"
+msgstr ""
+
+#: internal/punctuation/emoticons.go:28
+msgid "frustrated"
+msgstr ""
+
+#: internal/command/descriptions.go:58
+msgid "get current quick menu entry"
+msgstr ""
+
+#: internal/command/descriptions.go:59
+msgid "get current quick menu value"
+msgstr ""
+
+#: internal/command/descriptions.go:56
+msgid "get next quick menu category"
+msgstr ""
+
+#: internal/command/descriptions.go:60
+msgid "get next quick menu entry"
+msgstr ""
+
+#: internal/command/descriptions.go:62
+msgid "get next quick menu value"
+msgstr ""
+
+#: internal/command/descriptions.go:57
+msgid "get previous quick menu category"
+msgstr ""
+
+#: internal/command/descriptions.go:61
+msgid "get previous quick menu entry"
+msgstr ""
+
+#: internal/command/descriptions.go:63
+msgid "get previous quick menu value"
+msgstr ""
+
+#: config/punctuation/default.toml config/punctuation/en.toml config/punctuation/rhvoice-en.toml
+msgid "grave"
+msgstr ""
+
+#: config/punctuation/default.toml config/punctuation/en.toml config/punctuation/rhvoice-en.toml
+msgid "greater"
+msgstr ""
+
+#: internal/presentation/attributes.go
+msgid "green"
+msgstr ""
+
+#: internal/punctuation/emoticons.go:25
+msgid "happy"
+msgstr ""
+
+#: internal/presentation/attributes.go:65
+msgid "has attribute"
+msgstr ""
+
+#: config/punctuation/default.toml config/punctuation/en.toml config/punctuation/rhvoice-en.toml
+msgid "hash"
+msgstr ""
+
+#: internal/review/table.go:138
+msgid "header row set"
+msgstr ""
+
+#: internal/punctuation/emoticons.go:30
+msgid "heart"
+msgstr ""
+
+#: internal/config/definitions.go:225 internal/reader/reader.go:187
+msgid "highlight tracking"
+msgstr ""
+
+#: internal/punctuation/emoticons.go:29
+msgid "hurray"
+msgstr ""
+
+#: internal/command/descriptions.go:110
+msgid "imports text from clipboard file to the clipboard"
+msgstr ""
+
+#: internal/command/descriptions.go:112
+msgid "imports the graphical clipboard to Fenrir's clipboard"
+msgstr ""
+
+#: internal/presentation/indentation.go:32
+msgid "indented "
+msgstr ""
+
+#: internal/presentation/attributes.go
+msgid "italic"
+msgstr ""
+
+#: internal/punctuation/emoticons.go:22
+msgid "knocked out"
+msgstr ""
+
+#: internal/review/table.go:192
+msgid "last cell"
+msgstr ""
+
+#: internal/review/table.go:227
+msgid "last character in cell {0}"
+msgstr ""
+
+#: internal/review/last_text.go:34
+msgid "last character in line"
+msgstr ""
+
+#: internal/review/screen_last_character.go:13
+msgid "last character in screen"
+msgstr ""
+
+#: internal/punctuation/emoticons.go:35
+msgid "laughing"
+msgstr ""
+
+#: internal/punctuation/emoticons.go:24
+msgid "laughing hard"
+msgstr ""
+
+#: config/punctuation/default.toml config/punctuation/en.toml config/punctuation/rhvoice-en.toml
+msgid "left brace"
+msgstr ""
+
+#: config/punctuation/default.toml config/punctuation/en.toml config/punctuation/rhvoice-en.toml
+msgid "left bracket"
+msgstr ""
+
+#: config/punctuation/default.toml config/punctuation/en.toml config/punctuation/rhvoice-en.toml
+msgid "left paren"
+msgstr ""
+
+#: config/punctuation/default.toml config/punctuation/en.toml config/punctuation/rhvoice-en.toml
+msgid "less"
+msgstr ""
+
+#: internal/presentation/attributes.go
+msgid "light blue"
+msgstr ""
+
+#: internal/presentation/attributes.go
+msgid "light cyan"
+msgstr ""
+
+#: internal/presentation/attributes.go
+msgid "light gray"
+msgstr ""
+
+#: internal/presentation/attributes.go
+msgid "light green"
+msgstr ""
+
+#: internal/presentation/attributes.go
+msgid "light magenta"
+msgstr ""
+
+#: internal/presentation/attributes.go
+msgid "light red"
+msgstr ""
+
+#: internal/presentation/attributes.go
+msgid "light yellow"
+msgstr ""
+
+#: config/punctuation/default.toml config/punctuation/en.toml config/punctuation/rhvoice-en.toml
+msgid "line"
+msgstr ""
+
+#: internal/review/navigation.go:25
+msgid "line break"
+msgstr ""
+
+#: internal/review/first_text.go:29 internal/review/last_text.go:28
+msgid "line is empty"
+msgstr ""
+
+#: internal/review/position.go:27
+msgid "line {0}, column {1}, Terminal {2}"
+msgstr ""
+
+#: internal/punctuation/emoticons.go:23
+msgid "lips sealed"
+msgstr ""
+
+#: internal/presentation/attributes.go
+msgid "magenta"
+msgstr ""
+
+#: internal/app/spellcheck.go:22 internal/app/spellcheck_menu.go
+msgid "misspelled"
+msgstr ""
+
+#: internal/punctuation/profile.go
+msgid "most"
+msgstr ""
+
+#: internal/command/descriptions.go:32
+msgid "move review to top of screen"
+msgstr ""
+
+#: internal/command/descriptions.go:19
+msgid "moves review focus to the previous word "
+msgstr ""
+
+#: internal/command/descriptions.go:18
+msgid "moves review to the next line "
+msgstr ""
+
+#: internal/command/descriptions.go:21
+msgid "moves review to the next word "
+msgstr ""
+
+#: internal/command/descriptions.go:22
+msgid "moves review to the previous character"
+msgstr ""
+
+#: internal/command/descriptions.go:16
+msgid "moves review to the previous line "
+msgstr ""
+
+#: internal/punctuation/emoticons.go:19
+msgid "neutral"
+msgstr ""
+
+#: internal/command/descriptions.go internal/command/descriptions.go:26 internal/command/descriptions.go:27
+msgid "no description available"
+msgstr ""
+
+#: internal/review/marks.go:30
+msgid "no review cursor"
+msgstr ""
+
+#: internal/review/spelling.go:19 internal/review/table.go:150 internal/review/table.go:158 internal/review/vertical_character.go:26 internal/review/vertical_character.go:34
+msgid "no table data"
+msgstr ""
+
+#: internal/reader/regions.go:173 internal/review/review.go:135
+msgid "no text console"
+msgstr ""
+
+#: internal/config/definitions.go:206 internal/punctuation/profile.go
+msgid "none"
+msgstr ""
+
+#: internal/punctuation/text_processing.go
+msgid "number"
+msgstr ""
+
+#: internal/app/clipboard.go:344 internal/app/clipboard.go:399
+msgid "paste clipboard"
+msgstr ""
+
+#: internal/command/descriptions.go:109
+msgid "pastes the text from the currently selected clipboard"
+msgstr ""
+
+#: config/punctuation/default.toml config/punctuation/en.toml config/punctuation/rhvoice-en.toml
+msgid "percent"
+msgstr ""
+
+#: internal/command/descriptions.go:98
+msgid "places marks to select text to copy to the clipboard"
+msgstr ""
+
+#: internal/app/clipboard.go:271
+msgid "please set begin and endmark"
+msgstr ""
+
+#: config/punctuation/default.toml config/punctuation/en.toml config/punctuation/rhvoice-en.toml
+msgid "plus"
+msgstr ""
+
+#: internal/command/descriptions.go:23
+msgid "presents the current character."
+msgstr ""
+
+#: internal/command/descriptions.go:15
+msgid "presents the date"
+msgstr ""
+
+#: internal/command/descriptions.go:14
+msgid "presents the time"
+msgstr ""
+
+#: config/punctuation/default.toml config/punctuation/en.toml config/punctuation/rhvoice-en.toml
+msgid "question?"
+msgstr ""
+
+#: config/punctuation/default.toml config/punctuation/en.toml config/punctuation/rhvoice-en.toml
+msgid "quote"
+msgstr ""
+
+#: internal/command/descriptions.go:46
+msgid "read line to cursor pos, use review cursor if you are in review mode, otherwhise use text cursor"
+msgstr ""
+
+#: internal/command/descriptions.go:47
+msgid "read to end of line, use review cursor if you are in review mode, otherwhise use text cursor"
+msgstr ""
+
+#: internal/command/descriptions.go:40
+msgid "reads from the cursor to the bottom of the screen"
+msgstr ""
+
+#: internal/command/descriptions.go:38
+msgid "reads the contents of the current screen"
+msgstr ""
+
+#: internal/presentation/attributes.go
+msgid "red"
+msgstr ""
+
+#: internal/presentation/attributes.go
+msgid "reverse"
+msgstr ""
+
+#: config/punctuation/default.toml config/punctuation/en.toml config/punctuation/rhvoice-en.toml
+msgid "right brace"
+msgstr ""
+
+#: config/punctuation/default.toml config/punctuation/en.toml config/punctuation/rhvoice-en.toml
+msgid "right bracket"
+msgstr ""
+
+#: config/punctuation/default.toml config/punctuation/en.toml config/punctuation/rhvoice-en.toml
+msgid "right paren"
+msgstr ""
+
+#: internal/reader/regions.go:259
+msgid "row {0}, column {1} to end of line"
+msgstr ""
+
+#: internal/reader/regions.go:261
+msgid "row {0}, column {1} to row {2}, column {3}"
+msgstr ""
+
+#: internal/review/read_screen.go:34
+msgid "screen is empty"
+msgstr ""
+
+#: internal/reader/reader.go:132
+msgid "screen {0}"
+msgstr ""
+
+#: internal/interaction/entry.go:31
+msgid "selected"
+msgstr ""
+
+#: internal/command/descriptions.go:106
+msgid "selects the first clipboard"
+msgstr ""
+
+#: internal/command/descriptions.go:107
+msgid "selects the last clipboard"
+msgstr ""
+
+#: internal/command/descriptions.go:105
+msgid "selects the next clipboard"
+msgstr ""
+
+#: internal/command/descriptions.go:104
+msgid "selects the previous clipboard"
+msgstr ""
+
+#: config/punctuation/default.toml config/punctuation/en.toml config/punctuation/rhvoice-en.toml
+msgid "semicolon"
+msgstr ""
+
+#: internal/command/descriptions.go:70
+msgid "sends the following keypress to the terminal or application"
+msgstr ""
+
+#: internal/command/descriptions.go:72
+msgid "set Window Mode, needs 2 marks "
+msgstr ""
+
+#: internal/review/marks.go:43
+msgid "set mark"
+msgstr ""
+
+#: internal/command/descriptions.go:28
+msgid "set review and phonetically presents the current character"
+msgstr ""
+
+#: internal/command/descriptions.go:34
+msgid "set review cursor to begin of current line and display the content"
+msgstr ""
+
+#: internal/punctuation/emoticons.go:16
+msgid "skeptical"
+msgstr ""
+
+#: internal/punctuation/emoticons.go:21
+msgid "skull and crossbones"
+msgstr ""
+
+#: config/punctuation/default.toml config/punctuation/en.toml config/punctuation/rhvoice-en.toml
+msgid "slash"
+msgstr ""
+
+#: internal/punctuation/emoticons.go:10
+msgid "smile"
+msgstr ""
+
+#: internal/punctuation/profile.go
+msgid "some"
+msgstr ""
+
+#: config/punctuation/default.toml config/punctuation/en.toml config/punctuation/rhvoice-en.toml
+msgid "space"
+msgstr ""
+
+#: internal/command/descriptions.go:103
+msgid "speaks the contents of the currently selected clipboard"
+msgstr ""
+
+#: internal/config/definitions.go:206
+msgid "speech"
+msgstr ""
+
+#: internal/config/definitions.go:206
+msgid "speech and beeps"
+msgstr ""
+
+#: internal/app/commands.go:59 internal/app/feedback.go:45
+msgid "speech enabled"
+msgstr ""
+
+#: internal/config/config.go:131
+msgid "speech language"
+msgstr ""
+
+#: internal/config/config.go:133
+msgid "speech pitch"
+msgstr ""
+
+#: internal/config/config.go:126
+msgid "speech rate"
+msgstr ""
+
+#: internal/app/commands.go:121
+msgid "speech temporary disabled"
+msgstr ""
+
+#: internal/config/config.go:132
+msgid "speech voice"
+msgstr ""
+
+#: internal/config/config.go:134
+msgid "speech volume"
+msgstr ""
+
+#: config/punctuation/default.toml config/punctuation/en.toml config/punctuation/rhvoice-en.toml
+msgid "star"
+msgstr ""
+
+#: internal/review/table.go:212
+msgid "start of cell"
+msgstr ""
+
+#: internal/review/table.go:168
+msgid "start of line"
+msgstr ""
+
+#: internal/review/navigation.go:20
+msgid "start of screen"
+msgstr ""
+
+#: internal/presentation/attributes.go
+msgid "strikethrough"
+msgstr ""
+
+#: internal/punctuation/emoticons.go:15
+msgid "surprised"
+msgstr ""
+
+#: internal/presentation/text.go
+msgid "tab"
+msgstr ""
+
+#: internal/config/definitions.go:225 internal/reader/reader.go:184
+msgid "table mode"
+msgstr ""
+
+#: config/punctuation/default.toml config/punctuation/en.toml config/punctuation/rhvoice-en.toml
+msgid "tilde"
+msgstr ""
+
+#: internal/command/descriptions.go:51
+msgid "toggles the tutorial mode"
+msgstr ""
+
+#: internal/punctuation/emoticons.go:14
+msgid "tongue out"
+msgstr ""
+
+#: internal/interaction/presentation.go:62
+msgid "unavailable"
+msgstr ""
+
+#: internal/app/tutorial.go:107 internal/app/tutorial.go:161 internal/reader/reader.go:199 internal/review/review.go:140
+msgid "unbound"
+msgstr ""
+
+#: internal/interaction/presentation.go
+msgid "unchecked"
+msgstr ""
+
+#: internal/presentation/attributes.go
+msgid "underline"
+msgstr ""
+
+#: internal/presentation/attributes.go internal/presentation/attributes.go:27
+msgid "white"
+msgstr ""
+
+#: internal/punctuation/emoticons.go:13
+msgid "wink"
+msgstr ""
+
+#: internal/app/spellcheck_menu.go
+msgid "{0} added to dictionary"
+msgstr ""
+
+#: internal/interaction/presentation.go:53
+msgid "{0} button"
+msgstr ""
+
+#: internal/app/internal_ui.go:122
+msgid "{0} closed"
+msgstr ""
+
+#: internal/interaction/presentation.go:51
+msgid "{0} combo box {1}"
+msgstr ""
+
+#: internal/interaction/presentation.go:45
+msgid "{0} entry {1}"
+msgstr ""
+
+#: internal/interaction/presentation.go:49
+msgid "{0} grid. {1}"
+msgstr ""
+
+#: internal/app/spellcheck_menu.go:134
+msgid "{0} is already in dictionary"
+msgstr ""
+
+#: internal/app/spellcheck_menu.go
+msgid "{0} removed"
+msgstr ""
+
+#: internal/interaction/presentation.go:31
+msgid "{0} tab"
+msgstr ""
+
+#: internal/punctuation/text_processing.go:69
+msgid "{0} {1}"
+msgstr ""
+
+#: internal/interaction/grid.go:39
+msgid "{0}. row {1}, column {2}. {3} of {4}"
+msgstr ""
+
+#: internal/app/tutorial.go:25
+msgid "{command_name}, Shortcuts {command_shortcuts}, Description {command_description}"
+msgstr ""
+
+#: internal/app/tutorial.go internal/app/tutorial.go:66
+msgid "{} times "
+msgstr ""
diff --git a/internal/i18n/locale/pl/LC_MESSAGES/fenrir.po b/internal/i18n/locale/pl/LC_MESSAGES/fenrir.po
new file mode 100644
index 00000000..0a911671
--- /dev/null
+++ b/internal/i18n/locale/pl/LC_MESSAGES/fenrir.po
@@ -0,0 +1,2161 @@
+# Fenrir reader translations (GPL-3.0-or-later).
+# Existing translations retained from Python Fenrir.
+# AI-assisted additions require language-contributor review.
+msgid ""
+msgstr ""
+"Project-Id-Version: fenrir\n"
+"PO-Revision-Date: 2018-10-23 16:35+0200\n"
+"Last-Translator: \n"
+"Language-Team: \n"
+"Language: pl\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Generated-By: pygettext.py 1.5\n"
+"X-Generator: Poedit 2.2\n"
+"Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 "
+"|| n%100>14) ? 1 : 2);\n"
+
+#: internal/review/cursor_column.go:13
+msgid " column number"
+msgstr " numer kolumny"
+
+#: internal/review/cursor_line.go:13
+msgid " line number"
+msgstr " numer wiersza"
+
+#: internal/review/cursor_scope.go:10
+msgid " review cursor "
+msgstr " kursor przeglÄ
du "
+
+#: internal/review/cursor_scope.go:12
+msgid " text cursor "
+msgstr " kursor tekstowy "
+
+#: internal/sound/drivers.go:39
+msgid "ALSA"
+msgstr "ALSA"
+
+#: internal/app/tutorial.go
+msgid "Active actions"
+msgstr "Aktywne dziaÅania"
+
+#: internal/app/spellcheck_menu.go:51
+msgid "Add"
+msgstr "Dodaj"
+
+#: internal/app/emoji_menu.go:34 internal/app/spellcheck_menu.go:107
+msgid "Added {0} to clipboard"
+msgstr "Dodano {0} do schowka"
+
+#: internal/emoji/catalog.go:76
+msgid "Alien monster"
+msgstr "Potwór z kosmosu"
+
+#: internal/emoji/catalog.go
+msgid "All"
+msgstr "Wszystkie"
+
+#: internal/reader/regions.go:203
+msgid "All 10 live regions are set"
+msgstr "Ustawiono wszystkie 10 obszarów dynamicznych"
+
+#: internal/emoji/catalog.go:23
+msgid "Angry"
+msgstr "ZÅoÅÄ"
+
+#: internal/emoji/catalog.go:64
+msgid "Apple"
+msgstr "JabÅko"
+
+#: internal/emoji/catalog.go:117
+msgid "Argentina"
+msgstr "Argentyna"
+
+#: internal/config/config.go:155
+msgid "Attribute format"
+msgstr "Format atrybutów"
+
+#: internal/config/config.go:154
+msgid "Attributes"
+msgstr "Atrybuty"
+
+#: internal/emoji/catalog.go:118
+msgid "Australia"
+msgstr "Australia"
+
+#: internal/config/config.go:145
+msgid "Automatic reading"
+msgstr "Automatyczne czytanie"
+
+#: internal/config/config.go:156
+msgid "Automatic spell checking"
+msgstr "Automatyczne sprawdzanie pisowni"
+
+#: internal/emoji/catalog.go:65
+msgid "Avocado"
+msgstr "Awokado"
+
+#: internal/emoji/catalog.go:77
+msgid "Bat"
+msgstr "Nietoperz"
+
+#: internal/config/config.go:129
+msgid "Baud rate"
+msgstr "SzybkoÅÄ transmisji"
+
+#: internal/emoji/catalog.go:24
+msgid "Beaming"
+msgstr "Promienny uÅmiech"
+
+#: internal/config/config.go:151
+msgid "Beep for capital letters"
+msgstr "SygnaÅ dla wielkich liter"
+
+#: internal/emoji/catalog.go:66
+msgid "Beer"
+msgstr "Piwo"
+
+#: internal/emoji/catalog.go:119
+msgid "Belgium"
+msgstr "Belgia"
+
+#: internal/emoji/catalog.go:67
+msgid "Birthday cake"
+msgstr "Tort urodzinowy"
+
+#: internal/emoji/catalog.go:78
+msgid "Black cat"
+msgstr "Czarny kot"
+
+#: internal/emoji/catalog.go:25
+msgid "Blowing kiss"
+msgstr "PrzesyÅanie pocaÅunku"
+
+#: internal/emoji/catalog.go:100
+msgid "Bone"
+msgstr "KoÅÄ"
+
+#: internal/review/bottom.go:29
+msgid "Bottom"
+msgstr "DóÅ."
+
+#: internal/emoji/catalog.go:120
+msgid "Brazil"
+msgstr "Brazylia"
+
+#: internal/emoji/catalog.go:79
+msgid "Bunny"
+msgstr "Królik"
+
+#: internal/emoji/catalog.go:51
+msgid "Butterfly"
+msgstr "Motyl"
+
+#: internal/emoji/catalog.go:121
+msgid "Canada"
+msgstr "Kanada"
+
+#: internal/preferences/settings.go:79
+msgid "Cancel"
+msgstr "Anuluj"
+
+#: internal/emoji/catalog.go:52
+msgid "Cat"
+msgstr "Kot"
+
+#: internal/emoji/catalog.go:101
+msgid "Chains"
+msgstr "ÅaÅcuchy"
+
+#: internal/config/config.go:140
+msgid "Character echo"
+msgstr "Echo znaków"
+
+#: internal/emoji/catalog.go:102
+msgid "Checkmark"
+msgstr "Znacznik wyboru"
+
+#: internal/emoji/catalog.go:53
+msgid "Cherry blossom"
+msgstr "Kwiat wiÅni"
+
+#: internal/emoji/catalog.go:122
+msgid "China"
+msgstr "Chiny"
+
+#: internal/emoji/catalog.go:80
+msgid "Christmas tree"
+msgstr "Choinka"
+
+#: internal/command/descriptions.go:76
+msgid "Clear all live regions"
+msgstr "WyczyÅÄ wszystkie obszary dynamiczne"
+
+#: internal/command/descriptions.go:77
+msgid "Clear all reading regions"
+msgstr "WyczyÅÄ wszystkie obszary czytania"
+
+#: internal/command/descriptions.go:79
+msgid "Clear live region 1"
+msgstr "WyczyÅÄ obszar dynamiczny 1"
+
+#: internal/command/descriptions.go:97
+msgid "Clear live region 10"
+msgstr "WyczyÅÄ obszar dynamiczny 10"
+
+#: internal/command/descriptions.go:81
+msgid "Clear live region 2"
+msgstr "WyczyÅÄ obszar dynamiczny 2"
+
+#: internal/command/descriptions.go:83
+msgid "Clear live region 3"
+msgstr "WyczyÅÄ obszar dynamiczny 3"
+
+#: internal/command/descriptions.go:85
+msgid "Clear live region 4"
+msgstr "WyczyÅÄ obszar dynamiczny 4"
+
+#: internal/command/descriptions.go:87
+msgid "Clear live region 5"
+msgstr "WyczyÅÄ obszar dynamiczny 5"
+
+#: internal/command/descriptions.go:89
+msgid "Clear live region 6"
+msgstr "WyczyÅÄ obszar dynamiczny 6"
+
+#: internal/command/descriptions.go:91
+msgid "Clear live region 7"
+msgstr "WyczyÅÄ obszar dynamiczny 7"
+
+#: internal/command/descriptions.go:93
+msgid "Clear live region 8"
+msgstr "WyczyÅÄ obszar dynamiczny 8"
+
+#: internal/command/descriptions.go:95
+msgid "Clear live region 9"
+msgstr "WyczyÅÄ obszar dynamiczny 9"
+
+#: internal/config/config.go:57
+msgid "Clipboard"
+msgstr "Schowek"
+
+#: internal/app/application.go:136 internal/app/application.go:139
+#: internal/app/clipboard.go:218 internal/app/clipboard.go:374
+msgid "Clipboard import failed"
+msgstr "Importowanie schowka nie powiodÅo siÄ"
+
+#: internal/config/config.go:106
+msgid "Clipboard sync"
+msgstr "Synchronizacja schowka"
+
+#: internal/emoji/catalog.go:68
+msgid "Coffee"
+msgstr "Kawa"
+
+#: internal/emoji/catalog.go:81
+msgid "Coffin"
+msgstr "Trumna"
+
+#: internal/command/descriptions.go:45
+msgid "Column number for cursor"
+msgstr "Numer kolumny kursora"
+
+#: internal/emoji/catalog.go:26
+msgid "Cool"
+msgstr "Wyluzowanie"
+
+#: internal/app/spellcheck_menu.go:175
+msgid "Could not check spelling: {0}"
+msgstr "Nie można sprawdziÄ pisowni: {0}"
+
+#: internal/app/emoji_menu.go:17 internal/app/emoji_menu.go:21
+msgid "Could not open Emoji Menu: {0}"
+msgstr "Nie można otworzyÄ menu emoji: {0}"
+
+#: internal/app/fenrir_menu.go:32 internal/app/fenrir_menu.go:52
+#: internal/app/fenrir_menu.go:67 internal/app/runtime_settings.go:133
+msgid "Could not open Fenrir Menu: {0}"
+msgstr "Nie można otworzyÄ menu Fenrira: {0}"
+
+#: internal/app/spellcheck_menu.go:39 internal/app/spellcheck_menu.go:58
+msgid "Could not open Spellcheck Menu: {0}"
+msgstr "Nie można otworzyÄ menu sprawdzania pisowni: {0}"
+
+#: internal/app/spellcheck_menu.go:155
+msgid "Could not save exceptions: {0}"
+msgstr "Nie można zapisaÄ wyjÄ
tków: {0}"
+
+#: internal/app/fenrir_menu.go:174 internal/app/fenrir_menu.go:180
+#: internal/app/runtime_settings.go:164 internal/app/runtime_settings.go:191
+msgid "Could not save settings: {0}"
+msgstr "Nie można zapisaÄ ustawieÅ: {0}"
+
+#: internal/emoji/catalog.go:27
+msgid "Crying"
+msgstr "PÅacz"
+
+#: internal/config/config.go:146
+msgid "Cursor echo"
+msgstr "Echo kursora"
+
+#: internal/emoji/catalog.go:103
+msgid "Dagger"
+msgstr "Sztylet"
+
+#: internal/config/config.go:102
+msgid "Date format"
+msgstr "Format daty"
+
+#: internal/emoji/catalog.go:123
+msgid "Denmark"
+msgstr "Dania"
+
+#: internal/config/config.go:138
+msgid "Devices"
+msgstr "UrzÄ
dzenia"
+
+#: internal/emoji/catalog.go:28
+msgid "Devil"
+msgstr "DiabeÅ"
+
+#: internal/app/spellcheck_menu.go:191
+msgid "Dictionary {0}. {1}. {2}"
+msgstr "SÅownik {0}. {1}. {2}"
+
+#: internal/emoji/catalog.go:29
+msgid "Dizzy"
+msgstr "Zawroty gÅowy"
+
+#: internal/emoji/catalog.go:54
+msgid "Dog"
+msgstr "Pies"
+
+#: internal/emoji/catalog.go:69
+msgid "Donut"
+msgstr "PÄ
czek"
+
+#: internal/config/config.go:120 internal/config/config.go:127
+msgid "Driver"
+msgstr "Sterownik"
+
+#: internal/config/config.go:142
+msgid "Echo on CapsLock"
+msgstr "Echo przy CapsLock"
+
+#: internal/emoji/catalog.go:82
+msgid "Egg"
+msgstr "Jajko"
+
+#: internal/emoji/catalog.go:162
+msgid "Emoji"
+msgstr "Emoji"
+
+#: internal/command/descriptions.go:53 internal/emoji/catalog.go:14
+msgid "Emoji Menu"
+msgstr "Menu emoji"
+
+#: internal/config/config.go:160
+msgid "Emoticons"
+msgstr "Emotikony"
+
+#: internal/config/config.go:107
+msgid "Enable Persistent Clipboard"
+msgstr "WÅÄ
cz trwaÅy schowek"
+
+#: internal/app/tutorial.go:21
+msgid ""
+"Entering tutorial mode. In this mode commands are described but not "
+"executed. Use up and down to browse actions and left and right to switch "
+"between Active actions, Unbound actions, and Plugins. Press Space to repeat "
+"the current item. To exit tutorial mode press Fenrir+F1 or Escape. Active "
+"actions."
+msgstr ""
+"WÅÄ
czono tryb nauki. W tym trybie polecenia sÄ
opisywane, ale nie "
+"wykonywane. StrzaÅkami w górÄ i w dóŠprzeglÄ
daj dziaÅania, a strzaÅkami w "
+"lewo i w prawo przeÅÄ
czaj miÄdzy aktywnymi dziaÅaniami, nieprzypisanymi "
+"dziaÅaniami i wtyczkami. NaciÅnij spacjÄ, aby powtórzyÄ bieżÄ
cÄ
pozycjÄ. Aby "
+"wyjÅÄ, naciÅnij Fenrir+F1 lub Escape. Aktywne dziaÅania."
+
+#: internal/app/spellcheck_menu.go:49 internal/app/spellcheck_menu.go:52
+msgid "Exceptions"
+msgstr "WyjÄ
tki"
+
+#: internal/review/follow_cursor.go:29
+msgid "Exiting Review Mode"
+msgstr "WyjÅcie z trybu przeglÄ
du"
+
+#: internal/app/tutorial.go:22
+msgid "Exiting tutorial mode. To enter tutorial mode again press Fenrir+F1"
+msgstr "WyjÅcie z trybu nauki. Aby ponownie go wÅÄ
czyÄ, naciÅnij Fenrir+F1"
+
+#: internal/emoji/catalog.go:30
+msgid "Exploding head"
+msgstr "EksplodujÄ
ca gÅowa"
+
+#: internal/command/descriptions.go:113
+msgid "Export current fenrir clipboard to X or GUI clipboard"
+msgstr ""
+"Eksportuj bieżÄ
cy schowek Fenrira do schowka X lub interfejsu graficznego"
+
+#: internal/emoji/catalog.go:31
+msgid "Face with symbols"
+msgstr "Twarz z symbolami"
+
+#: internal/app/quick_settings.go:295 internal/app/runtime_settings.go:151
+msgid "Failed to save settings."
+msgstr "Nie udaÅo siÄ zapisaÄ ustawieÅ."
+
+#: internal/command/descriptions.go:55 internal/preferences/settings.go:38
+msgid "Fenrir Menu"
+msgstr "Menu Fenrira"
+
+#: internal/app/commands.go:83
+msgid "Fenrir screen reader version {0}-{1}"
+msgstr "Czytnik ekranu Fenrir, wersja {0}-{1}"
+
+#: internal/app/clipboard.go:377
+msgid "File does not exist"
+msgstr "Plik nie istnieje."
+
+#: internal/emoji/catalog.go:124
+msgid "Finland"
+msgstr "Finlandia"
+
+#: internal/emoji/catalog.go:104
+msgid "Fire"
+msgstr "OgieÅ"
+
+#: internal/emoji/catalog.go:83
+msgid "Fireworks"
+msgstr "Fajerwerki"
+
+#: internal/app/clipboard.go:323
+msgid "First clipboard "
+msgstr "Pierwszy showek "
+
+#: internal/emoji/catalog.go
+msgid "Flags"
+msgstr "Flagi"
+
+#: internal/emoji/catalog.go:105
+msgid "Folded hands"
+msgstr "ZÅożone dÅonie"
+
+#: internal/emoji/catalog.go
+msgid "Food"
+msgstr "Jedzenie"
+
+#: internal/app/commands.go:126
+msgid "Forward next keypress"
+msgstr "PrzepuÅÄ nastÄpny klawisz"
+
+#: internal/emoji/catalog.go:125
+msgid "France"
+msgstr "Francja"
+
+#: internal/emoji/catalog.go:126
+msgid "Germany"
+msgstr "Niemcy"
+
+#: internal/emoji/catalog.go:84
+msgid "Ghost"
+msgstr "Duch"
+
+#: internal/emoji/catalog.go:85
+msgid "Gift"
+msgstr "Prezent"
+
+#: internal/emoji/catalog.go:127
+msgid "Greece"
+msgstr "Grecja"
+
+#: internal/emoji/catalog.go:32
+msgid "Grinning"
+msgstr "Szeroki uÅmiech"
+
+#: internal/emoji/catalog.go:33
+msgid "Grinning sweat"
+msgstr "UÅmiech z kroplÄ
potu"
+
+#: internal/emoji/catalog.go:70
+msgid "Hamburger"
+msgstr "Hamburger"
+
+#: internal/emoji/catalog.go:106
+msgid "Heart"
+msgstr "Serce"
+
+#: internal/emoji/catalog.go:34
+msgid "Heart eyes"
+msgstr "Oczy w ksztaÅcie serc"
+
+#: internal/emoji/catalog.go:35
+msgid "Hearts around"
+msgstr "Twarz otoczona sercami"
+
+#: internal/emoji/catalog.go:107
+msgid "High voltage"
+msgstr "Wysokie napiÄcie"
+
+#: internal/config/config.go:109
+msgid "History items"
+msgstr "Elementy historii"
+
+#: internal/emoji/catalog.go
+msgid "Holidays"
+msgstr "ÅwiÄta"
+
+#: internal/emoji/catalog.go:108
+msgid "Hundred points"
+msgstr "Sto punktów"
+
+#: internal/emoji/catalog.go:36
+msgid "Imp"
+msgstr "DiabeÅek"
+
+#: internal/app/clipboard.go:412
+msgid "Import to Clipboard"
+msgstr "Importuj do schowka"
+
+#: internal/config/config.go:153
+msgid "Indentation"
+msgstr "WciÄcia"
+
+#: internal/emoji/catalog.go:128
+msgid "India"
+msgstr "Indie"
+
+#: internal/emoji/catalog.go:129
+msgid "Ireland"
+msgstr "Irlandia"
+
+#: internal/emoji/catalog.go:130
+msgid "Israel"
+msgstr "Izrael"
+
+#: internal/emoji/catalog.go:131
+msgid "Italy"
+msgstr "WÅochy"
+
+#: internal/emoji/catalog.go:86
+msgid "Jack o'lantern"
+msgstr "Dynia halloweenowa"
+
+#: internal/emoji/catalog.go:132
+msgid "Japan"
+msgstr "Japonia"
+
+#: internal/config/config.go:53
+msgid "Keyboard"
+msgstr "Klawiatura"
+
+#: internal/app/clipboard.go:326
+msgid "Last clipboard "
+msgstr "Ostatni showek "
+
+#: internal/emoji/catalog.go:37
+msgid "Laughing"
+msgstr "Åmiech"
+
+#: internal/config/config.go:139
+msgid "Layout"
+msgstr "UkÅad klawiatury"
+
+#: internal/command/descriptions.go:44
+msgid "Line number for cursor"
+msgstr "Numer wiersza kursora"
+
+#: internal/command/descriptions.go:75
+msgid "List reading regions"
+msgstr "WyÅwietl obszary czytania"
+
+#: internal/reader/regions.go:185
+msgid "Live region {0} cleared"
+msgstr "Wyczyszczono obszar dynamiczny {0}"
+
+#: internal/reader/regions.go:181
+msgid "Live region {0} not set"
+msgstr "Obszar dynamiczny {0} nie jest ustawiony"
+
+#: internal/reader/regions.go:189
+msgid "Live region {0} outside screen"
+msgstr "Obszar dynamiczny {0} poza ekranem"
+
+#: internal/reader/regions.go:209
+msgid "Live region {0} set"
+msgstr "Ustawiono obszar dynamiczny {0}"
+
+#: internal/reader/regions.go:245
+msgid "Live region {0}: {1}"
+msgstr "Obszar dynamiczny {0}: {1}"
+
+#: internal/reader/regions.go:232
+msgid "Live regions cleared"
+msgstr "Wyczyszczono obszary dynamiczne"
+
+#: internal/emoji/catalog.go:87
+msgid "Mage"
+msgstr "Mag"
+
+#: internal/emoji/catalog.go:133
+msgid "Mexico"
+msgstr "Meksyk"
+
+#: internal/config/config.go:159
+msgid "Misspelling sound"
+msgstr "DÅŗwiÄk bÅÄdu pisowni"
+
+#: internal/emoji/catalog.go:55
+msgid "Moon"
+msgstr "KsiÄżyc"
+
+#: internal/command/descriptions.go:35
+msgid "Move Review to the end of current line and display the content"
+msgstr ""
+"PrzenieÅ kursor przeglÄ
du na koniec bieżÄ
cego wiersza i odczytaj zawartoÅÄ"
+
+#: internal/command/descriptions.go:36
+msgid "Move Review to the first character on the line"
+msgstr "PrzenieÅ kursor przeglÄ
du do pierwszego znaku w wierszu"
+
+#: internal/command/descriptions.go:48
+msgid "Move Review to the first character on the screen (left top)"
+msgstr ""
+"PrzenieÅ kursor przeglÄ
du do pierwszego znaku na ekranie, w lewym górnym rogu"
+
+#: internal/command/descriptions.go:37
+msgid "Move Review to the last character on the line"
+msgstr "PrzenieÅ kursor przeglÄ
du do ostatniego znaku w wierszu"
+
+#: internal/command/descriptions.go:49
+msgid "Move Review to the last character on the screen (right bottom)"
+msgstr ""
+"PrzenieÅ kursor przeglÄ
du do ostatniego znaku na ekranie, w prawym dolnym "
+"rogu"
+
+#: internal/command/descriptions.go:33
+msgid "Move review to the bottom of the screen"
+msgstr "PrzenieÅ kursor przeglÄ
du na dóŠekranu"
+
+#: internal/command/descriptions.go:31
+msgid "Move review to the character below the current position"
+msgstr "PrzenieÅ kursor przeglÄ
du do znaku poniżej bieżÄ
cej pozycji"
+
+#: internal/command/descriptions.go:30
+msgid "Move review to the character in the line above the current position"
+msgstr "PrzenieÅ kursor przeglÄ
du do znaku w wierszu powyżej bieżÄ
cej pozycji"
+
+#: internal/command/descriptions.go:29
+msgid "Moves review to the next character"
+msgstr "Przenosi kursor przeglÄ
du do nastÄpnego znaku"
+
+#: internal/emoji/catalog.go
+msgid "Nature"
+msgstr "Przyroda"
+
+#: internal/emoji/catalog.go:38
+msgid "Nauseated"
+msgstr "NudnoÅci"
+
+#: internal/emoji/catalog.go:134
+msgid "Netherlands"
+msgstr "Holandia"
+
+#: internal/interaction/grid.go:43 internal/interaction/grid.go:79
+msgid "No matches"
+msgstr "Brak dopasowaÅ"
+
+#: internal/reader/regions.go:251
+msgid "No reading regions set"
+msgstr "Nie ustawiono obszarów czytania"
+
+#: internal/app/clipboard.go:416
+msgid "No text found in clipboard or no accessible display"
+msgstr "Brak tekstu w schowku lub dostÄpnego ekranu"
+
+#: internal/app/spellcheck_menu.go
+msgid "No word"
+msgstr "Brak sÅowa"
+
+#: internal/emoji/catalog.go:135
+msgid "Norway"
+msgstr "Norwegia"
+
+#: internal/review/follow_cursor.go:26
+msgid "Not in Review Mode"
+msgstr "Poza trybem przeglÄ
du"
+
+#: internal/reader/regions.go:222
+msgid "Not in window Mode"
+msgstr "Nie znajdujesz siÄ w trybie okna."
+
+#: internal/preferences/settings.go:79
+msgid "OK"
+msgstr "OK"
+
+#: internal/app/clipboard.go:269 internal/reader/regions.go:197
+msgid "One or two marks are needed"
+msgstr "Potrzebny jest jeden lub dwa znaczniki"
+
+#: internal/emoji/catalog.go:109
+msgid "Peace"
+msgstr "Pokój"
+
+#: internal/emoji/catalog.go
+msgid "People"
+msgstr "Ludzie"
+
+#: internal/command/descriptions.go:25
+msgid "Phonetically spells the current word"
+msgstr "Literuje bieżÄ
ce sÅowo alfabetem fonetycznym"
+
+#: internal/config/config.go:133
+msgid "Pitch"
+msgstr "WysokoÅÄ gÅosu"
+
+#: internal/emoji/catalog.go:71
+msgid "Pizza"
+msgstr "Pizza"
+
+#: internal/emoji/catalog.go:39
+msgid "Pleading"
+msgstr "BÅaganie"
+
+#: internal/app/tutorial.go
+msgid "Plugins"
+msgstr "Wtyczki"
+
+#: internal/emoji/catalog.go:136
+msgid "Poland"
+msgstr "Polska"
+
+#: internal/emoji/catalog.go:40
+msgid "Poop"
+msgstr "Kupa"
+
+#: internal/emoji/catalog.go:137
+msgid "Portugal"
+msgstr "Portugalia"
+
+#: internal/command/descriptions.go:52
+msgid "Present the version of Fenrir currently in use."
+msgstr "Odczytaj używanÄ
wersjÄ Fenrira."
+
+#: internal/config/config.go:52
+msgid "Presentation"
+msgstr "Prezentacja"
+
+#: internal/command/descriptions.go:100
+msgid ""
+"Presents the currently selected text that will be copied to the clipboard"
+msgstr "Odczytuje zaznaczony tekst, który zostanie skopiowany do schowka"
+
+#: internal/app/commands.go:112
+msgid "Progress monitoring disabled"
+msgstr "Monitorowanie postÄpu wyÅÄ
czone"
+
+#: internal/app/commands.go:110
+msgid "Progress monitoring enabled"
+msgstr "Monitorowanie postÄpu wÅÄ
czone"
+
+#: internal/sound/drivers.go:39
+msgid "PulseAudio"
+msgstr "PulseAudio"
+
+#: internal/config/config.go:149
+msgid "Punctuation level"
+msgstr "Poziom interpunkcji"
+
+#: internal/config/config.go:148
+msgid "Punctuation profile"
+msgstr "Profil interpunkcji"
+
+#: internal/app/quick_settings.go:115
+msgid "Quick menu not available"
+msgstr "Menu podrÄczne niedostÄpne"
+
+#: internal/emoji/catalog.go:56
+msgid "Rainbow"
+msgstr "TÄcza"
+
+#: internal/config/config.go:150
+msgid "Raise pitch for capital letters"
+msgstr "PodnieÅ wysokoÅÄ gÅosu dla wielkich liter"
+
+#: internal/config/config.go:126
+msgid "Rate"
+msgstr "SzybkoÅÄ"
+
+#: internal/punctuation/emoticons.go:33
+msgid "Raves"
+msgstr "Imprezy rave"
+
+#: internal/command/descriptions.go:78
+msgid "Read live region 1"
+msgstr "Czytaj obszar dynamiczny 1"
+
+#: internal/command/descriptions.go:96
+msgid "Read live region 10"
+msgstr "Czytaj obszar dynamiczny 10"
+
+#: internal/command/descriptions.go:80
+msgid "Read live region 2"
+msgstr "Czytaj obszar dynamiczny 2"
+
+#: internal/command/descriptions.go:82
+msgid "Read live region 3"
+msgstr "Czytaj obszar dynamiczny 3"
+
+#: internal/command/descriptions.go:84
+msgid "Read live region 4"
+msgstr "Czytaj obszar dynamiczny 4"
+
+#: internal/command/descriptions.go:86
+msgid "Read live region 5"
+msgstr "Czytaj obszar dynamiczny 5"
+
+#: internal/command/descriptions.go:88
+msgid "Read live region 6"
+msgstr "Czytaj obszar dynamiczny 6"
+
+#: internal/command/descriptions.go:90
+msgid "Read live region 7"
+msgstr "Czytaj obszar dynamiczny 7"
+
+#: internal/command/descriptions.go:92
+msgid "Read live region 8"
+msgstr "Czytaj obszar dynamiczny 8"
+
+#: internal/command/descriptions.go:94
+msgid "Read live region 9"
+msgstr "Czytaj obszar dynamiczny 9"
+
+#: internal/config/config.go:157
+msgid "Read misspelled word"
+msgstr "Czytaj bÅÄdnie napisane sÅowo"
+
+#: internal/config/config.go:54
+msgid "Reading"
+msgstr "Czytanie"
+
+#: internal/reader/regions.go:236
+msgid "Reading regions cleared"
+msgstr "Wyczyszczono obszary czytania"
+
+#: internal/reader/regions.go:241
+msgid "Reading window: {0}"
+msgstr "Okno czytania: {0}"
+
+#: internal/command/descriptions.go:42
+msgid "Reads attributes of current cursor position"
+msgstr "Odczytuje atrybuty bieżÄ
cej pozycji kursora"
+
+#: internal/command/descriptions.go:39
+msgid "Reads from the top of the screen to the cursor position"
+msgstr "Czyta od góry ekranu do pozycji kursora."
+
+#: internal/app/clipboard.go:264
+msgid "Remove marks"
+msgstr "UsuÅ znaczniki"
+
+#: internal/command/descriptions.go:99
+msgid "Removes marks from selected text"
+msgstr "Usuwa znaczniki z zaznaczonego tekstu"
+
+#: internal/config/config.go:161
+msgid "Repeated symbols"
+msgstr "Powtarzane symbole"
+
+#: internal/config/config.go:162
+msgid "Repeated symbols minimum length"
+msgstr "Minimalna liczba powtarzanych symboli"
+
+#: internal/emoji/catalog.go:110
+msgid "Rock on"
+msgstr "Gest rockowy"
+
+#: internal/emoji/catalog.go:41
+msgid "Rolling laughing"
+msgstr "Tarzanie siÄ ze Åmiechu"
+
+#: internal/emoji/catalog.go:57
+msgid "Rose"
+msgstr "Róża"
+
+#: internal/emoji/catalog.go:138
+msgid "Russia"
+msgstr "Rosja"
+
+#: internal/emoji/catalog.go:42
+msgid "Sad"
+msgstr "Smutek"
+
+#: internal/emoji/catalog.go:88
+msgid "Santa"
+msgstr "ÅwiÄty MikoÅaj"
+
+#: internal/command/descriptions.go:64
+msgid "Saves your current Fenrir settings so they are the default."
+msgstr "Zapisuje bieżÄ
ce ustawienia Fenrira jako domyÅlne."
+
+#: internal/emoji/catalog.go:43
+msgid "Savoring food"
+msgstr "Delektowanie siÄ jedzeniem"
+
+#: internal/config/config.go:152
+msgid "Say ācapā before capital letters"
+msgstr "Mów ādużaā przed wielkimi literami"
+
+#: internal/config/config.go:158
+msgid "Say āmisspelledā"
+msgstr "Mów ābÅÄdna pisowniaā"
+
+#: internal/emoji/catalog.go:44
+msgid "Screaming"
+msgstr "Krzyk"
+
+#: internal/app/commands.go:135 internal/app/scripts.go
+msgid "Script failed"
+msgstr "Skrypt nie powiódÅ siÄ"
+
+#: internal/app/scripts.go
+msgid "Script file is not executable"
+msgstr "Plik skryptu nie jest wykonywalny"
+
+#: internal/app/scripts.go
+msgid "Script file not found"
+msgstr "Nie znaleziono pliku skryptu"
+
+#: internal/app/scripts.go
+msgid "Script source is not a valid file"
+msgstr "ŹródÅo skryptu nie jest prawidÅowym plikiem"
+
+#: internal/interaction/grid.go:71
+msgid "Search cleared"
+msgstr "Wyszukiwanie wyczyszczone"
+
+#: internal/interaction/grid.go:73
+msgid "Search {0}"
+msgstr "Szukaj {0}"
+
+#: internal/config/config.go:121
+msgid "Select sound theme"
+msgstr "Wybierz motyw dÅŗwiÄkowy"
+
+#: internal/config/config.go:128
+msgid "Serial port"
+msgstr "Port szeregowy"
+
+#: internal/command/descriptions.go:74
+msgid "Set a live region from one or two marks"
+msgstr "Ustaw obszar dynamiczny za pomocÄ
jednego lub dwóch znaczników"
+
+#: internal/reader/regions.go:213
+msgid "Set window begin and end marks"
+msgstr "Ustaw poczÄ
tek i koniec okna."
+
+#: internal/app/quick_settings.go:298
+msgid "Settings saved."
+msgstr "Ustawienia zapisane."
+
+#: internal/emoji/catalog.go:89
+msgid "Shamrock"
+msgstr "Koniczyna"
+
+#: internal/emoji/catalog.go:90
+msgid "Skull"
+msgstr "Czaszka"
+
+#: internal/emoji/catalog.go:111
+msgid "Skull crossbones"
+msgstr "Czaszka ze skrzyżowanymi piszczelami"
+
+#: internal/emoji/catalog.go:45
+msgid "Smiling"
+msgstr "UÅmiech"
+
+#: internal/emoji/catalog.go:46
+msgid "Smirking"
+msgstr "UÅmieszek"
+
+#: internal/emoji/catalog.go:91
+msgid "Snowman"
+msgstr "BaÅwan"
+
+#: internal/config/config.go:95
+msgid "Sound"
+msgstr "DÅŗwiÄk"
+
+#: internal/config/config.go:118
+msgid "Sound enabled"
+msgstr "DÅŗwiÄk wÅÄ
czony"
+
+#: internal/emoji/catalog.go:139
+msgid "South Africa"
+msgstr "Republika PoÅudniowej Afryki"
+
+#: internal/emoji/catalog.go:140
+msgid "South Korea"
+msgstr "Korea PoÅudniowa"
+
+#: internal/emoji/catalog.go:141
+msgid "Spain"
+msgstr "Hiszpania"
+
+#: internal/emoji/catalog.go:112
+msgid "Sparkles"
+msgstr "Iskierki"
+
+#: internal/config/config.go:94
+msgid "Speech"
+msgstr "Mowa"
+
+#: internal/speech/drivers.go:44
+msgid "Speech Dispatcher"
+msgstr "Speech Dispatcher"
+
+#: internal/config/config.go:130
+msgid "Speech Dispatcher module"
+msgstr "ModuÅ Speech Dispatcher"
+
+#: internal/config/config.go:125
+msgid "Speech enabled"
+msgstr "Mowa wÅÄ
czona"
+
+#: internal/config/config.go:131
+msgid "Speech language"
+msgstr "JÄzyk mowy"
+
+#: internal/app/screen_updates.go:72
+msgid "Speech restored"
+msgstr "Mowa przywrócona"
+
+#: internal/config/config.go:132
+msgid "Speech voice"
+msgstr "GÅos"
+
+#: internal/app/spellcheck_menu.go:81
+msgid "Spell checking is unavailable"
+msgstr "Sprawdzanie pisowni jest niedostÄpne"
+
+#: internal/app/spellcheck_menu.go:180
+msgid "Spell checking is unavailable: {0}"
+msgstr "Sprawdzanie pisowni jest niedostÄpne: {0}"
+
+#: internal/command/descriptions.go:54
+msgid "Spellcheck Menu"
+msgstr "Menu sprawdzania pisowni"
+
+#: internal/command/descriptions.go:24
+msgid "Spells the current word"
+msgstr "Literuje bieżÄ
ce sÅowo"
+
+#: internal/emoji/catalog.go:92
+msgid "Spider"
+msgstr "PajÄ
k"
+
+#: internal/emoji/catalog.go:113
+msgid "Star"
+msgstr "Gwiazda"
+
+#: internal/app/screen_updates.go:39
+msgid "Start Fenrir"
+msgstr "Uruchom Fenrir"
+
+#: internal/emoji/catalog.go:72
+msgid "Strawberry"
+msgstr "Truskawka"
+
+#: internal/app/spellcheck_menu.go:45 internal/app/spellcheck_menu.go:47
+msgid "Suggestions"
+msgstr "Sugestie"
+
+#: internal/emoji/catalog.go:58
+msgid "Sun"
+msgstr "SÅoÅce"
+
+#: internal/emoji/catalog.go:59
+msgid "Sunflower"
+msgstr "SÅonecznik"
+
+#: internal/emoji/catalog.go:142
+msgid "Sweden"
+msgstr "Szwecja"
+
+#: internal/emoji/catalog.go:143
+msgid "Switzerland"
+msgstr "Szwajcaria"
+
+#: internal/emoji/catalog.go:114
+msgid "Sword"
+msgstr "Miecz"
+
+#: internal/emoji/catalog.go
+msgid "Symbols"
+msgstr "Symbole"
+
+#: internal/emoji/catalog.go:73
+msgid "Taco"
+msgstr "Taco"
+
+#: internal/preferences/settings.go:72
+msgid "Test synthesizer"
+msgstr "Testuj syntezator"
+
+#: internal/app/fenrir_menu.go:139
+msgid "Testing voice"
+msgstr "Testowanie gÅosu"
+
+#: internal/emoji/catalog.go:47
+msgid "Thumbs up"
+msgstr "Kciuk w górÄ"
+
+#: internal/config/config.go:97
+msgid "Time and date"
+msgstr "Czas i data"
+
+#: internal/config/config.go:101
+msgid "Time format"
+msgstr "Format czasu"
+
+#: internal/command/descriptions.go:67
+msgid "Toggle progress bar monitoring with ascending tones"
+msgstr "PrzeÅÄ
cz monitorowanie paska postÄpu z rosnÄ
cymi tonami"
+
+#: internal/review/top.go:29
+msgid "Top"
+msgstr "Góra."
+
+#: internal/config/config.go:147
+msgid "Tracking mode"
+msgstr "Tryb Åledzenia"
+
+#: internal/emoji/catalog.go:60
+msgid "Tree"
+msgstr "Drzewo"
+
+#: internal/emoji/catalog.go:144 internal/emoji/catalog.go:93
+msgid "Turkey"
+msgstr "Indyk"
+
+#: internal/command/descriptions.go:73
+msgid "Turn off window mode"
+msgstr "WyÅÄ
cz tryb okna"
+
+#: internal/emoji/catalog.go:145
+msgid "Ukraine"
+msgstr "Ukraina"
+
+#: internal/app/tutorial.go:23
+msgid ""
+"Unable to exit tutorial mode because exclusive keyboard capture could not be "
+"released. Press Fenrir+F1 or Escape to try again."
+msgstr ""
+"Nie można wyjÅÄ z trybu nauki, ponieważ nie udaÅo siÄ zwolniÄ wyÅÄ
cznego "
+"przechwytywania klawiatury. NaciÅnij Fenrir+F1 lub Escape, aby spróbowaÄ "
+"ponownie."
+
+#: internal/app/tutorial.go
+msgid "Unbound actions"
+msgstr "Nieprzypisane dziaÅania"
+
+#: internal/app/spellcheck_menu.go:53
+msgid "Undo"
+msgstr "Cofnij"
+
+#: internal/emoji/catalog.go:146
+msgid "United Kingdom"
+msgstr "Wielka Brytania"
+
+#: internal/emoji/catalog.go:147
+msgid "United States"
+msgstr "Stany Zjednoczone"
+
+#: internal/emoji/catalog.go:94
+msgid "Vampire"
+msgstr "Wampir"
+
+#: internal/app/quick_settings.go:191 internal/app/synthesizer_preview.go:60
+msgid "Voice test failed"
+msgstr "Test gÅosu nie powiódÅ siÄ"
+
+#: internal/app/synthesizer_preview.go:23
+msgid "Voice test: The quick brown fox jumps over the lazy dog."
+msgstr "Test gÅosu: Szybki brÄ
zowy lis przeskakuje nad leniwym psem."
+
+#: internal/config/config.go:119 internal/config/config.go:134
+msgid "Volume"
+msgstr "GÅoÅnoÅÄ"
+
+#: internal/punctuation/emoticons.go:32
+msgid "WTF?"
+msgstr "Co do diabÅa?"
+
+#: internal/app/tutorial.go:24
+msgid ""
+"Warning: full keyboard capture is unavailable. Unbound keys may reach the "
+"active application."
+msgstr ""
+"Uwaga: peÅne przechwytywanie klawiatury jest niedostÄpne. Nieprzypisane "
+"klawisze mogÄ
trafiÄ do aktywnej aplikacji."
+
+#: internal/emoji/catalog.go:95
+msgid "Web"
+msgstr "PajÄczyna"
+
+#: internal/reader/regions.go:225
+msgid "Window Mode off for terminal {0}"
+msgstr "Tryb okna wyÅÄ
czony dla terminala {0}"
+
+#: internal/reader/regions.go:219
+msgid "Window Mode on for terminal {0}"
+msgstr "Tryb okna wÅÄ
czony dla terminala {0}"
+
+#: internal/emoji/catalog.go:48
+msgid "Winking"
+msgstr "MrugniÄcie"
+
+#: internal/emoji/catalog.go:96
+msgid "Witch"
+msgstr "Czarownica"
+
+#: internal/emoji/catalog.go:61
+msgid "Wolf"
+msgstr "Wilk"
+
+#: internal/app/spellcheck_menu.go:46 internal/app/spellcheck_menu.go:50
+msgid "Word"
+msgstr "SÅowo"
+
+#: internal/config/config.go:141
+msgid "Word echo"
+msgstr "Echo sÅów"
+
+#: internal/emoji/catalog.go:97
+msgid "Zombie"
+msgstr "Zombie"
+
+#: internal/punctuation/profile.go
+msgid "all"
+msgstr "wszystkie"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "and"
+msgstr "i"
+
+#: internal/punctuation/emoticons.go:17
+msgid "angry"
+msgstr "zÅoÅÄ"
+
+#: internal/reader/attributes.go:36
+msgid "announcement of attributes disabled"
+msgstr "Odczytywanie atrybutów wyÅÄ
czone"
+
+#: internal/reader/attributes.go:33
+msgid "announcement of attributes enabled"
+msgstr "Odczytywanie atrybutów wÅÄ
czone"
+
+#: internal/punctuation/emoticons.go:26
+msgid "annoyed"
+msgstr "irytacja"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "apostrophe"
+msgstr "apostrof"
+
+#: internal/punctuation/emoticons.go:39
+msgid "arrow left"
+msgstr "strzaÅka w lewo"
+
+#: internal/punctuation/emoticons.go:38
+msgid "arrow right"
+msgstr "strzaÅka w prawo"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "at"
+msgstr "maÅpa"
+
+#: internal/reader/reader.go:197
+msgid "autoread disabled"
+msgstr "WyÅÄ
czono automatyczne ogÅaszanie dynamicznych zmian treÅci."
+
+#: internal/reader/reader.go:195
+msgid "autoread enabled"
+msgstr "WÅÄ
czono ogÅaszanie dynamicznych zmian treÅci."
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "backslash"
+msgstr "ukoÅnik odwrotny"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "bang"
+msgstr "wykrzyknik"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "bar"
+msgstr "kreska pionowa"
+
+#: internal/config/definitions.go:206
+msgid "beeps"
+msgstr "sygnaÅy"
+
+#: internal/review/line_start.go:28
+msgid "beginning of line"
+msgstr "PoczÄ
tek linii."
+
+#: internal/punctuation/emoticons.go:12
+msgid "big grin"
+msgstr "szeroki uÅmiech"
+
+#: internal/presentation/attributes.go
+msgid "black"
+msgstr "Czarny"
+
+#: internal/app/clipboard.go:281 internal/presentation/text.go:35
+#: internal/reader/regions.go:191 internal/review/line_start.go:31
+#: internal/review/screen_range.go:39 internal/review/spelling.go:44
+#: internal/review/table.go:124 internal/review/table.go:205
+#: internal/review/vertical_character.go:31 internal/review/word.go:64
+msgid "blank"
+msgstr "Pusta"
+
+#: internal/presentation/attributes.go
+msgid "blink"
+msgstr "MigajÄ
cy"
+
+#: internal/presentation/attributes.go
+msgid "blue"
+msgstr "niebieski"
+
+#: internal/presentation/attributes.go
+msgid "bold"
+msgstr "Pogrubiony"
+
+#: internal/punctuation/emoticons.go:31
+msgid "broken heart"
+msgstr "zÅamane serce"
+
+#: internal/presentation/attributes.go
+msgid "brown/yellow"
+msgstr "BrÄ
zowo-żóÅty"
+
+#: internal/punctuation/emoticons.go:34
+msgid "bugged"
+msgstr "rozdrażnienie"
+
+#: internal/app/clipboard.go:93 internal/app/review_spelling.go:27
+msgid "cap"
+msgstr "duża"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "carrot"
+msgstr "daszek"
+
+#: internal/presentation/text.go:68
+msgid "character {0}"
+msgstr "znak {0}"
+
+#: internal/interaction/presentation.go
+msgid "checked"
+msgstr "zaznaczone"
+
+#: internal/command/descriptions.go:108
+msgid "clears the currently selected clipboard"
+msgstr "CzyÅci aktualnie zaznaczony showek."
+
+#: internal/app/clipboard.go:299
+msgid "clipboard cleared"
+msgstr "Showek wyczyszczony"
+
+#: internal/app/clipboard.go:302 internal/app/clipboard.go:337
+msgid "clipboard empty"
+msgstr "Showek pusty"
+
+#: internal/app/clipboard.go:395
+msgid "clipboard exported to file"
+msgstr "Showek wyeksportowany do pliku."
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "colon"
+msgstr "dwukropek"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "comma"
+msgstr "przecinek"
+
+#: internal/punctuation/emoticons.go:27
+msgid "confused"
+msgstr "dezorientacja"
+
+#: internal/punctuation/emoticons.go:20
+msgid "cool"
+msgstr "wyluzowanie"
+
+#: internal/command/descriptions.go:102
+msgid "copies last presented text to the clipboard"
+msgstr "kopiuje ostatnio odczytany tekst do schowka"
+
+#: internal/command/descriptions.go:101
+msgid "copies marked text to the currently selected clipboard"
+msgstr "Kopiuje zaznaczony tekst do aktualnie wybranego schowka."
+
+#: internal/app/spellcheck_menu.go
+msgid "correct"
+msgstr "Poprawny"
+
+#: internal/review/table.go:132
+msgid "could not set header row"
+msgstr "nie można ustawiÄ wiersza nagÅówka"
+
+#: internal/punctuation/emoticons.go:18
+msgid "crying"
+msgstr "pÅacz"
+
+#: internal/command/descriptions.go:17
+msgid "current line"
+msgstr "Aktualna linia"
+
+#: internal/command/descriptions.go:20
+msgid "current word."
+msgstr "Aktualne sÅowo"
+
+#: internal/config/definitions.go:225 internal/reader/reader.go:190
+msgid "cursor tracking"
+msgstr "Åledzenie kursora."
+
+#: internal/presentation/attributes.go
+msgid "cyan"
+msgstr "Turkusowy"
+
+#: internal/presentation/attributes.go
+msgid "dark gray"
+msgstr "ciemnoszary"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "dash"
+msgstr "ÅÄ
cznik"
+
+#: internal/app/quick_settings.go:185 internal/app/quick_settings.go:234
+#: internal/config/definitions.go:237 internal/preferences/discovery.go:45
+#: internal/preferences/discovery.go:55 internal/preferences/discovery.go:81
+#: internal/presentation/attributes.go:18
+#: internal/presentation/attributes.go:44
+msgid "default"
+msgstr "domyÅlne"
+
+#: internal/command/descriptions.go:66
+msgid "disables speech until next keypress"
+msgstr "WyÅÄ
cza mowÄ do nastÄpnego naciÅniÄcia klawisza."
+
+#: internal/command/descriptions.go:43
+msgid "displays the position of the review cursor"
+msgstr "WyÅwietla pozycjÄ kursora przeglÄ
du"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "dollar"
+msgstr "dolar"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "dot"
+msgstr "kropka"
+
+#: internal/command/descriptions.go:68
+msgid "enables or disables automatic reading of new text as it appears"
+msgstr "WÅÄ
cza lub wyÅÄ
cza informowanie o dynamicznych zmianach treÅci."
+
+#: internal/command/descriptions.go:65
+msgid "enables or disables speech"
+msgstr "WÅÄ
cza lub wyÅÄ
cza mowÄ."
+
+#: internal/command/descriptions.go:41
+msgid "enables or disables the announcement of attributes"
+msgstr "wÅÄ
cza lub wyÅÄ
cza odczytywanie atrybutów"
+
+#: internal/command/descriptions.go:69
+msgid "enables or disables tracking of highlighted text"
+msgstr "WÅÄ
cza lub wyÅÄ
cza Åledzenie podÅwietlonego tekstu."
+
+#: internal/review/table.go:218
+msgid "end of cell"
+msgstr "koniec komórki"
+
+#: internal/review/line_end.go:24 internal/review/table.go:175
+msgid "end of line"
+msgstr "Koniec linii."
+
+#: internal/review/navigation.go:17
+msgid "end of screen"
+msgstr "Koniec ekranu"
+
+#: internal/punctuation/emoticons.go:37
+msgid "enjoy smile"
+msgstr "zadowolony uÅmiech"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "equals"
+msgstr "równa siÄ"
+
+#: internal/punctuation/emoticons.go:36
+msgid "evil smile"
+msgstr "zÅowrogi uÅmiech"
+
+#: internal/command/descriptions.go:71
+msgid "exits Fenrir"
+msgstr "Zamyka Fenrir."
+
+#: internal/command/descriptions.go:50
+msgid "exits review mode"
+msgstr "Wychodzi z trybu przeglÄ
du."
+
+#: internal/command/descriptions.go:111
+msgid "export the current fenrir clipboard to a file"
+msgstr "Wyeksportuj aktualny showek Fenrir do pliku."
+
+#: internal/app/clipboard.go:391
+msgid "exported to the X session."
+msgstr "wyeksportowano do sesji X."
+
+#: internal/app/clipboard.go:372
+msgid "failed to export to X clipboard. No available display found."
+msgstr ""
+"nie udaÅo siÄ wyeksportowaÄ do schowka X. Nie znaleziono dostÄpnego ekranu."
+
+#: internal/review/table.go:190
+msgid "first cell"
+msgstr "pierwsza komórka"
+
+#: internal/review/table.go:224
+msgid "first character in cell {0}"
+msgstr "pierwszy znak w komórce {0}"
+
+#: internal/review/first_text.go:35
+msgid "first character in line indent {0}"
+msgstr "pierwszy znak w wierszu, wciÄcie {0}"
+
+#: internal/review/screen_first_character.go:13
+msgid "first character in screen"
+msgstr "pierwszy znak na ekranie"
+
+#: internal/punctuation/emoticons.go:11
+msgid "frown"
+msgstr "niezadowolenie"
+
+#: internal/punctuation/emoticons.go:28
+msgid "frustrated"
+msgstr "frustracja"
+
+#: internal/command/descriptions.go:58
+msgid "get current quick menu entry"
+msgstr "odczytaj bieżÄ
cÄ
pozycjÄ menu podrÄcznego"
+
+#: internal/command/descriptions.go:59
+msgid "get current quick menu value"
+msgstr "odczytaj bieżÄ
cÄ
wartoÅÄ menu podrÄcznego"
+
+#: internal/command/descriptions.go:56
+msgid "get next quick menu category"
+msgstr "wybierz nastÄpnÄ
kategoriÄ menu podrÄcznego"
+
+#: internal/command/descriptions.go:60
+msgid "get next quick menu entry"
+msgstr "wybierz nastÄpnÄ
pozycjÄ menu podrÄcznego"
+
+#: internal/command/descriptions.go:62
+msgid "get next quick menu value"
+msgstr "wybierz nastÄpnÄ
wartoÅÄ menu podrÄcznego"
+
+#: internal/command/descriptions.go:57
+msgid "get previous quick menu category"
+msgstr "wybierz poprzedniÄ
kategoriÄ menu podrÄcznego"
+
+#: internal/command/descriptions.go:61
+msgid "get previous quick menu entry"
+msgstr "wybierz poprzedniÄ
pozycjÄ menu podrÄcznego"
+
+#: internal/command/descriptions.go:63
+msgid "get previous quick menu value"
+msgstr "wybierz poprzedniÄ
wartoÅÄ menu podrÄcznego"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "grave"
+msgstr "akcent sÅaby"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "greater"
+msgstr "wiÄksze niż"
+
+#: internal/presentation/attributes.go
+msgid "green"
+msgstr "Zielony"
+
+#: internal/punctuation/emoticons.go:25
+msgid "happy"
+msgstr "radoÅÄ"
+
+#: internal/presentation/attributes.go:65
+msgid "has attribute"
+msgstr "ma atrybut"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "hash"
+msgstr "kratka"
+
+#: internal/review/table.go:138
+msgid "header row set"
+msgstr "ustawiono wiersz nagÅówka"
+
+#: internal/punctuation/emoticons.go:30
+msgid "heart"
+msgstr "serce"
+
+#: internal/config/definitions.go:225 internal/reader/reader.go:187
+msgid "highlight tracking"
+msgstr "Åledzenie podÅwietlenia."
+
+#: internal/punctuation/emoticons.go:29
+msgid "hurray"
+msgstr "hurra"
+
+#: internal/command/descriptions.go:110
+msgid "imports text from clipboard file to the clipboard"
+msgstr "Importuje tekst z pliku do schowka."
+
+#: internal/command/descriptions.go:112
+msgid "imports the graphical clipboard to Fenrir's clipboard"
+msgstr "importuje schowek graficzny do schowka Fenrira"
+
+#: internal/presentation/indentation.go:32
+msgid "indented "
+msgstr "wciÄcie "
+
+#: internal/presentation/attributes.go
+msgid "italic"
+msgstr "kursywa"
+
+#: internal/punctuation/emoticons.go:22
+msgid "knocked out"
+msgstr "znokautowanie"
+
+#: internal/review/table.go:192
+msgid "last cell"
+msgstr "ostatnia komórka"
+
+#: internal/review/table.go:227
+msgid "last character in cell {0}"
+msgstr "ostatni znak w komórce {0}"
+
+#: internal/review/last_text.go:34
+msgid "last character in line"
+msgstr "ostatni znak w wierszu"
+
+#: internal/review/screen_last_character.go:13
+msgid "last character in screen"
+msgstr "ostatni znak na ekranie"
+
+#: internal/punctuation/emoticons.go:35
+msgid "laughing"
+msgstr "Åmiech"
+
+#: internal/punctuation/emoticons.go:24
+msgid "laughing hard"
+msgstr "gÅoÅny Åmiech"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "left brace"
+msgstr "lewy nawias klamrowy"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "left bracket"
+msgstr "lewy nawias kwadratowy"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "left paren"
+msgstr "lewy nawias okrÄ
gÅy"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "less"
+msgstr "mniejsze niż"
+
+#: internal/presentation/attributes.go
+msgid "light blue"
+msgstr "jasnoniebieski"
+
+#: internal/presentation/attributes.go
+msgid "light cyan"
+msgstr "jasny cyjan"
+
+#: internal/presentation/attributes.go
+msgid "light gray"
+msgstr "jasnoszary"
+
+#: internal/presentation/attributes.go
+msgid "light green"
+msgstr "jasnozielony"
+
+#: internal/presentation/attributes.go
+msgid "light magenta"
+msgstr "jasna magenta"
+
+#: internal/presentation/attributes.go
+msgid "light red"
+msgstr "jasnoczerwony"
+
+#: internal/presentation/attributes.go
+msgid "light yellow"
+msgstr "jasnożóÅty"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "line"
+msgstr "podkreÅlenie"
+
+#: internal/review/navigation.go:25
+msgid "line break"
+msgstr "Znak koÅca wiersza."
+
+#: internal/review/first_text.go:29 internal/review/last_text.go:28
+msgid "line is empty"
+msgstr "Linia jest pusta."
+
+#: internal/review/position.go:27
+msgid "line {0}, column {1}, Terminal {2}"
+msgstr "wiersz {0}, kolumna {1}, terminal {2}"
+
+#: internal/punctuation/emoticons.go:23
+msgid "lips sealed"
+msgstr "usta zamkniÄte"
+
+#: internal/presentation/attributes.go
+msgid "magenta"
+msgstr "magenta"
+
+#: internal/app/spellcheck.go:22 internal/app/spellcheck_menu.go
+msgid "misspelled"
+msgstr "BÅÄ
d pisowni"
+
+#: internal/punctuation/profile.go
+msgid "most"
+msgstr "wiÄkszoÅÄ"
+
+#: internal/command/descriptions.go:32
+msgid "move review to top of screen"
+msgstr "PrzenieÅ kursor przeglÄ
du na górÄ ekranu."
+
+#: internal/command/descriptions.go:19
+msgid "moves review focus to the previous word "
+msgstr "przenosi kursor przeglÄ
du do poprzedniego sÅowa "
+
+#: internal/command/descriptions.go:18
+msgid "moves review to the next line "
+msgstr "przenosi kursor przeglÄ
du do nastÄpnego wiersza "
+
+#: internal/command/descriptions.go:21
+msgid "moves review to the next word "
+msgstr "przenosi kursor przeglÄ
du do nastÄpnego sÅowa "
+
+#: internal/command/descriptions.go:22
+msgid "moves review to the previous character"
+msgstr "przenosi kursor przeglÄ
du do poprzedniego znaku"
+
+#: internal/command/descriptions.go:16
+msgid "moves review to the previous line "
+msgstr "przenosi kursor przeglÄ
du do poprzedniego wiersza "
+
+#: internal/punctuation/emoticons.go:19
+msgid "neutral"
+msgstr "neutralnoÅÄ"
+
+#: internal/command/descriptions.go internal/command/descriptions.go:26
+#: internal/command/descriptions.go:27
+msgid "no description available"
+msgstr "brak opisu"
+
+#: internal/review/marks.go:30
+msgid "no review cursor"
+msgstr "Brak kursora przeglÄ
du."
+
+#: internal/review/spelling.go:19 internal/review/table.go:150
+#: internal/review/table.go:158 internal/review/vertical_character.go:26
+#: internal/review/vertical_character.go:34
+msgid "no table data"
+msgstr "brak danych tabeli"
+
+#: internal/reader/regions.go:173 internal/review/review.go:135
+msgid "no text console"
+msgstr "brak konsoli tekstowej"
+
+#: internal/config/definitions.go:206 internal/punctuation/profile.go
+msgid "none"
+msgstr "brak"
+
+#: internal/punctuation/text_processing.go
+msgid "number"
+msgstr "numer"
+
+#: internal/app/clipboard.go:344 internal/app/clipboard.go:399
+msgid "paste clipboard"
+msgstr "wklej schowek"
+
+#: internal/command/descriptions.go:109
+msgid "pastes the text from the currently selected clipboard"
+msgstr "Wkleja tekst z aktualnie wybranego schowka."
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "percent"
+msgstr "procent"
+
+#: internal/command/descriptions.go:98
+msgid "places marks to select text to copy to the clipboard"
+msgstr "Ustawia znaczniki w tekÅcie do skopiowania do schowka."
+
+#: internal/app/clipboard.go:271
+msgid "please set begin and endmark"
+msgstr "ustaw znacznik poczÄ
tku i koÅca."
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "plus"
+msgstr "plus"
+
+#: internal/command/descriptions.go:23
+msgid "presents the current character."
+msgstr "Odczytuje aktualny znak."
+
+#: internal/command/descriptions.go:15
+msgid "presents the date"
+msgstr "Odczytuje aktualnÄ
datÄ."
+
+#: internal/command/descriptions.go:14
+msgid "presents the time"
+msgstr "WyÅwietla i odczytuje aktualny czas."
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "question?"
+msgstr "znak zapytania"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "quote"
+msgstr "cudzysÅów"
+
+#: internal/command/descriptions.go:46
+msgid ""
+"read line to cursor pos, use review cursor if you are in review mode, "
+"otherwhise use text cursor"
+msgstr ""
+"czytaj wiersz do kursora; w trybie przeglÄ
du użyj kursora przeglÄ
du, w "
+"pozostaÅych przypadkach kursora tekstowego"
+
+#: internal/command/descriptions.go:47
+msgid ""
+"read to end of line, use review cursor if you are in review mode, otherwhise "
+"use text cursor"
+msgstr ""
+"Czyta tekst do koÅca linii. Używa kursora przeglÄ
du (jeÅli w trybie "
+"przeglÄ
du), w przeciwnym razie używa kursora tekstu."
+
+#: internal/command/descriptions.go:40
+msgid "reads from the cursor to the bottom of the screen"
+msgstr "Czyta od pozycji kursora do koÅca ekranu."
+
+#: internal/command/descriptions.go:38
+msgid "reads the contents of the current screen"
+msgstr "Wypowiada treÅÄ aktualnego ekranu."
+
+#: internal/presentation/attributes.go
+msgid "red"
+msgstr "Czerwony"
+
+#: internal/presentation/attributes.go
+msgid "reverse"
+msgstr "odwrócenie"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "right brace"
+msgstr "prawy nawias klamrowy"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "right bracket"
+msgstr "prawy nawias kwadratowy"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "right paren"
+msgstr "prawy nawias okrÄ
gÅy"
+
+#: internal/reader/regions.go:259
+msgid "row {0}, column {1} to end of line"
+msgstr "wiersz {0}, kolumna {1} do koÅca wiersza"
+
+#: internal/reader/regions.go:261
+msgid "row {0}, column {1} to row {2}, column {3}"
+msgstr "wiersz {0}, kolumna {1} do wiersza {2}, kolumny {3}"
+
+#: internal/review/read_screen.go:34
+msgid "screen is empty"
+msgstr "Ekran jest pusty."
+
+#: internal/reader/reader.go:132
+msgid "screen {0}"
+msgstr "Ekran {0}"
+
+#: internal/interaction/entry.go:31
+msgid "selected"
+msgstr "zaznaczone"
+
+#: internal/command/descriptions.go:106
+msgid "selects the first clipboard"
+msgstr "Wybiera pierwszy schowek."
+
+#: internal/command/descriptions.go:107
+msgid "selects the last clipboard"
+msgstr "Wybiera ostatni showek."
+
+#: internal/command/descriptions.go:105
+msgid "selects the next clipboard"
+msgstr "Wybiera nastÄpny showek."
+
+#: internal/command/descriptions.go:104
+msgid "selects the previous clipboard"
+msgstr "Wybiera poprzedni showek."
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "semicolon"
+msgstr "Årednik"
+
+#: internal/command/descriptions.go:70
+msgid "sends the following keypress to the terminal or application"
+msgstr "przekazuje nastÄpne naciÅniÄcie klawisza do terminala lub aplikacji"
+
+#: internal/command/descriptions.go:72
+msgid "set Window Mode, needs 2 marks "
+msgstr "ustaw tryb okna, wymagane dwa znaczniki. "
+
+#: internal/review/marks.go:43
+msgid "set mark"
+msgstr "ustaw znacznik."
+
+#: internal/command/descriptions.go:28
+msgid "set review and phonetically presents the current character"
+msgstr "Ustawia kursor przeglÄ
du i fonetycznie odczytuje zaznaczony znak."
+
+#: internal/command/descriptions.go:34
+msgid "set review cursor to begin of current line and display the content"
+msgstr "Ustawia kursor przeglÄ
du na poczÄ
tku linii i odczytuje jej treÅÄ."
+
+#: internal/punctuation/emoticons.go:16
+msgid "skeptical"
+msgstr "sceptycyzm"
+
+#: internal/punctuation/emoticons.go:21
+msgid "skull and crossbones"
+msgstr "czaszka ze skrzyżowanymi piszczelami"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "slash"
+msgstr "ukoÅnik"
+
+#: internal/punctuation/emoticons.go:10
+msgid "smile"
+msgstr "uÅmiech"
+
+#: internal/punctuation/profile.go
+msgid "some"
+msgstr "niektóre"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "space"
+msgstr "spacja"
+
+#: internal/command/descriptions.go:103
+msgid "speaks the contents of the currently selected clipboard"
+msgstr "Wypowiada treÅÄ aktualnie wybranego showka"
+
+#: internal/config/definitions.go:206
+msgid "speech"
+msgstr "mowa"
+
+#: internal/config/definitions.go:206
+msgid "speech and beeps"
+msgstr "mowa i sygnaÅy"
+
+#: internal/app/commands.go:59 internal/app/feedback.go:45
+msgid "speech enabled"
+msgstr "Mowa wÅÄ
czona."
+
+#: internal/config/config.go:131
+msgid "speech language"
+msgstr "jÄzyk mowy"
+
+#: internal/config/config.go:133
+msgid "speech pitch"
+msgstr "wysokoÅÄ gÅosu"
+
+#: internal/config/config.go:126
+msgid "speech rate"
+msgstr "szybkoÅÄ mowy"
+
+#: internal/app/commands.go:121
+msgid "speech temporary disabled"
+msgstr "Mowa tymczasowo wyÅÄ
czona."
+
+#: internal/config/config.go:132
+msgid "speech voice"
+msgstr "gÅos"
+
+#: internal/config/config.go:134
+msgid "speech volume"
+msgstr "gÅoÅnoÅÄ mowy"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "star"
+msgstr "gwiazdka"
+
+#: internal/review/table.go:212
+msgid "start of cell"
+msgstr "poczÄ
tek komórki"
+
+#: internal/review/table.go:168
+msgid "start of line"
+msgstr "poczÄ
tek wiersza"
+
+#: internal/review/navigation.go:20
+msgid "start of screen"
+msgstr "poczÄ
tek ekranu"
+
+#: internal/presentation/attributes.go
+msgid "strikethrough"
+msgstr "przekreÅlenie"
+
+#: internal/punctuation/emoticons.go:15
+msgid "surprised"
+msgstr "zaskoczenie"
+
+#: internal/presentation/text.go
+msgid "tab"
+msgstr "tabulator"
+
+#: internal/config/definitions.go:225 internal/reader/reader.go:184
+msgid "table mode"
+msgstr "tryb tabeli"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "tilde"
+msgstr "tylda"
+
+#: internal/command/descriptions.go:51
+msgid "toggles the tutorial mode"
+msgstr "PrzeÅÄ
cza tryb pomocy."
+
+#: internal/punctuation/emoticons.go:14
+msgid "tongue out"
+msgstr "wystawiony jÄzyk"
+
+#: internal/interaction/presentation.go:62
+msgid "unavailable"
+msgstr "niedostÄpne"
+
+#: internal/app/tutorial.go:107 internal/app/tutorial.go:161
+#: internal/reader/reader.go:199 internal/review/review.go:140
+msgid "unbound"
+msgstr "nieprzypisane"
+
+#: internal/interaction/presentation.go
+msgid "unchecked"
+msgstr "niezaznaczone"
+
+#: internal/presentation/attributes.go
+msgid "underline"
+msgstr "podkreÅlenie"
+
+#: internal/presentation/attributes.go internal/presentation/attributes.go:27
+msgid "white"
+msgstr "BiaÅy"
+
+#: internal/punctuation/emoticons.go:13
+msgid "wink"
+msgstr "mrugniÄcie"
+
+#: internal/app/spellcheck_menu.go
+msgid "{0} added to dictionary"
+msgstr "Dodano {0} do sÅownika"
+
+#: internal/interaction/presentation.go:53
+msgid "{0} button"
+msgstr "{0} przycisk"
+
+#: internal/app/internal_ui.go:122
+msgid "{0} closed"
+msgstr "{0} zamkniÄte"
+
+#: internal/interaction/presentation.go:51
+msgid "{0} combo box {1}"
+msgstr "{0} pole kombi {1}"
+
+#: internal/interaction/presentation.go:45
+msgid "{0} entry {1}"
+msgstr "{0} pole edycji {1}"
+
+#: internal/interaction/presentation.go:49
+msgid "{0} grid. {1}"
+msgstr "{0} siatka. {1}"
+
+#: internal/app/spellcheck_menu.go:134
+msgid "{0} is already in dictionary"
+msgstr "{0} jest już w sÅowniku"
+
+#: internal/app/spellcheck_menu.go
+msgid "{0} removed"
+msgstr "{0} usuniÄte."
+
+#: internal/interaction/presentation.go:31
+msgid "{0} tab"
+msgstr "{0} zakÅadka"
+
+#: internal/punctuation/text_processing.go:69
+msgid "{0} {1}"
+msgstr "{0} {1}"
+
+#: internal/interaction/grid.go:39
+msgid "{0}. row {1}, column {2}. {3} of {4}"
+msgstr "{0}. wiersz {1}, kolumna {2}. {3} z {4}"
+
+#: internal/app/tutorial.go:25
+msgid ""
+"{command_name}, Shortcuts {command_shortcuts}, Description "
+"{command_description}"
+msgstr "{command_name}, Skróty {command_shortcuts}, Opis {command_description}"
+
+#: internal/app/tutorial.go internal/app/tutorial.go:66
+msgid "{} times "
+msgstr "{} razy "
diff --git a/internal/i18n/locale/pt/LC_MESSAGES/fenrir.po b/internal/i18n/locale/pt/LC_MESSAGES/fenrir.po
new file mode 100644
index 00000000..1a5a7824
--- /dev/null
+++ b/internal/i18n/locale/pt/LC_MESSAGES/fenrir.po
@@ -0,0 +1,2175 @@
+# Fenrir reader translations (GPL-3.0-or-later).
+# Existing translations retained from Python Fenrir.
+# AI-assisted additions require language-contributor review.
+msgid ""
+msgstr ""
+"Project-Id-Version: fenrir\n"
+"PO-Revision-Date: 2018-07-10 22:55-0400\n"
+"Last-Translator: Roberta Santos \n"
+"Language-Team: Brazilian Portuguese\n"
+"Language: pt\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Generated-By: pygettext.py 1.5\n"
+"Plural-Forms: nplurals=2; plural=(n > 1);\n"
+
+#: internal/review/cursor_column.go:13
+msgid " column number"
+msgstr " nĆŗmero da coluna"
+
+#: internal/review/cursor_line.go:13
+msgid " line number"
+msgstr " nĆŗmero da linha"
+
+#: internal/review/cursor_scope.go:10
+msgid " review cursor "
+msgstr " cursor de revisão "
+
+#: internal/review/cursor_scope.go:12
+msgid " text cursor "
+msgstr " cursor de texto "
+
+#: internal/sound/drivers.go:39
+msgid "ALSA"
+msgstr "ALSA"
+
+#: internal/app/tutorial.go
+msgid "Active actions"
+msgstr "AƧƵes ativas"
+
+#: internal/app/spellcheck_menu.go:51
+msgid "Add"
+msgstr "Adicionar"
+
+#: internal/app/emoji_menu.go:34 internal/app/spellcheck_menu.go:107
+msgid "Added {0} to clipboard"
+msgstr "{0} adicionado à Ôrea de transferência"
+
+#: internal/emoji/catalog.go:76
+msgid "Alien monster"
+msgstr "Monstro alienĆgena"
+
+#: internal/emoji/catalog.go
+msgid "All"
+msgstr "Todos"
+
+#: internal/reader/regions.go:203
+msgid "All 10 live regions are set"
+msgstr "Todas as 10 regiões dinâmicas estão definidas"
+
+#: internal/emoji/catalog.go:23
+msgid "Angry"
+msgstr "Zangado"
+
+#: internal/emoji/catalog.go:64
+msgid "Apple"
+msgstr "Maçã"
+
+#: internal/emoji/catalog.go:117
+msgid "Argentina"
+msgstr "Argentina"
+
+#: internal/config/config.go:155
+msgid "Attribute format"
+msgstr "Formato dos atributos"
+
+#: internal/config/config.go:154
+msgid "Attributes"
+msgstr "Atributos"
+
+#: internal/emoji/catalog.go:118
+msgid "Australia"
+msgstr "AustrƔlia"
+
+#: internal/config/config.go:145
+msgid "Automatic reading"
+msgstr "Leitura automƔtica"
+
+#: internal/config/config.go:156
+msgid "Automatic spell checking"
+msgstr "Verificação ortogrÔfica automÔtica"
+
+#: internal/emoji/catalog.go:65
+msgid "Avocado"
+msgstr "Abacate"
+
+#: internal/emoji/catalog.go:77
+msgid "Bat"
+msgstr "Morcego"
+
+#: internal/config/config.go:129
+msgid "Baud rate"
+msgstr "Taxa de transmissão"
+
+#: internal/emoji/catalog.go:24
+msgid "Beaming"
+msgstr "Sorriso radiante"
+
+#: internal/config/config.go:151
+msgid "Beep for capital letters"
+msgstr "Sinal sonoro para letras maiĆŗsculas"
+
+#: internal/emoji/catalog.go:66
+msgid "Beer"
+msgstr "Cerveja"
+
+#: internal/emoji/catalog.go:119
+msgid "Belgium"
+msgstr "BƩlgica"
+
+#: internal/emoji/catalog.go:67
+msgid "Birthday cake"
+msgstr "Bolo de aniversƔrio"
+
+#: internal/emoji/catalog.go:78
+msgid "Black cat"
+msgstr "Gato preto"
+
+#: internal/emoji/catalog.go:25
+msgid "Blowing kiss"
+msgstr "Mandando um beijo"
+
+#: internal/emoji/catalog.go:100
+msgid "Bone"
+msgstr "Osso"
+
+#: internal/review/bottom.go:29
+msgid "Bottom"
+msgstr "Inferior"
+
+#: internal/emoji/catalog.go:120
+msgid "Brazil"
+msgstr "Brasil"
+
+#: internal/emoji/catalog.go:79
+msgid "Bunny"
+msgstr "Coelho"
+
+#: internal/emoji/catalog.go:51
+msgid "Butterfly"
+msgstr "Borboleta"
+
+#: internal/emoji/catalog.go:121
+msgid "Canada"
+msgstr "CanadĆ”"
+
+#: internal/preferences/settings.go:79
+msgid "Cancel"
+msgstr "Cancelar"
+
+#: internal/emoji/catalog.go:52
+msgid "Cat"
+msgstr "Gato"
+
+#: internal/emoji/catalog.go:101
+msgid "Chains"
+msgstr "Correntes"
+
+#: internal/config/config.go:140
+msgid "Character echo"
+msgstr "Eco de caracteres"
+
+#: internal/emoji/catalog.go:102
+msgid "Checkmark"
+msgstr "Marca de verificação"
+
+#: internal/emoji/catalog.go:53
+msgid "Cherry blossom"
+msgstr "Flor de cerejeira"
+
+#: internal/emoji/catalog.go:122
+msgid "China"
+msgstr "China"
+
+#: internal/emoji/catalog.go:80
+msgid "Christmas tree"
+msgstr "Ćrvore de Natal"
+
+#: internal/command/descriptions.go:76
+msgid "Clear all live regions"
+msgstr "Limpar todas as regiões dinâmicas"
+
+#: internal/command/descriptions.go:77
+msgid "Clear all reading regions"
+msgstr "Limpar todas as regiƵes de leitura"
+
+#: internal/command/descriptions.go:79
+msgid "Clear live region 1"
+msgstr "Limpar região dinâmica 1"
+
+#: internal/command/descriptions.go:97
+msgid "Clear live region 10"
+msgstr "Limpar região dinâmica 10"
+
+#: internal/command/descriptions.go:81
+msgid "Clear live region 2"
+msgstr "Limpar região dinâmica 2"
+
+#: internal/command/descriptions.go:83
+msgid "Clear live region 3"
+msgstr "Limpar região dinâmica 3"
+
+#: internal/command/descriptions.go:85
+msgid "Clear live region 4"
+msgstr "Limpar região dinâmica 4"
+
+#: internal/command/descriptions.go:87
+msgid "Clear live region 5"
+msgstr "Limpar região dinâmica 5"
+
+#: internal/command/descriptions.go:89
+msgid "Clear live region 6"
+msgstr "Limpar região dinâmica 6"
+
+#: internal/command/descriptions.go:91
+msgid "Clear live region 7"
+msgstr "Limpar região dinâmica 7"
+
+#: internal/command/descriptions.go:93
+msgid "Clear live region 8"
+msgstr "Limpar região dinâmica 8"
+
+#: internal/command/descriptions.go:95
+msgid "Clear live region 9"
+msgstr "Limpar região dinâmica 9"
+
+#: internal/config/config.go:57
+msgid "Clipboard"
+msgstr "Ćrea de transferĆŖncia"
+
+#: internal/app/application.go:136 internal/app/application.go:139
+#: internal/app/clipboard.go:218 internal/app/clipboard.go:374
+msgid "Clipboard import failed"
+msgstr "Falha ao importar a Ôrea de transferência"
+
+#: internal/config/config.go:106
+msgid "Clipboard sync"
+msgstr "Sincronização da Ôrea de transferência"
+
+#: internal/emoji/catalog.go:68
+msgid "Coffee"
+msgstr "CafƩ"
+
+#: internal/emoji/catalog.go:81
+msgid "Coffin"
+msgstr "Caixão"
+
+#: internal/command/descriptions.go:45
+msgid "Column number for cursor"
+msgstr "NĆŗmero da coluna do cursor"
+
+#: internal/emoji/catalog.go:26
+msgid "Cool"
+msgstr "Legal"
+
+#: internal/app/spellcheck_menu.go:175
+msgid "Could not check spelling: {0}"
+msgstr "NĆ£o foi possĆvel verificar a ortografia: {0}"
+
+#: internal/app/emoji_menu.go:17 internal/app/emoji_menu.go:21
+msgid "Could not open Emoji Menu: {0}"
+msgstr "NĆ£o foi possĆvel abrir o menu de emojis: {0}"
+
+#: internal/app/fenrir_menu.go:32 internal/app/fenrir_menu.go:52
+#: internal/app/fenrir_menu.go:67 internal/app/runtime_settings.go:133
+msgid "Could not open Fenrir Menu: {0}"
+msgstr "NĆ£o foi possĆvel abrir o menu do Fenrir: {0}"
+
+#: internal/app/spellcheck_menu.go:39 internal/app/spellcheck_menu.go:58
+msgid "Could not open Spellcheck Menu: {0}"
+msgstr "NĆ£o foi possĆvel abrir o menu de ortografia: {0}"
+
+#: internal/app/spellcheck_menu.go:155
+msgid "Could not save exceptions: {0}"
+msgstr "NĆ£o foi possĆvel salvar as exceƧƵes: {0}"
+
+#: internal/app/fenrir_menu.go:174 internal/app/fenrir_menu.go:180
+#: internal/app/runtime_settings.go:164 internal/app/runtime_settings.go:191
+msgid "Could not save settings: {0}"
+msgstr "NĆ£o foi possĆvel salvar as configuraƧƵes: {0}"
+
+#: internal/emoji/catalog.go:27
+msgid "Crying"
+msgstr "Chorando"
+
+#: internal/config/config.go:146
+msgid "Cursor echo"
+msgstr "Eco do cursor"
+
+#: internal/emoji/catalog.go:103
+msgid "Dagger"
+msgstr "Adaga"
+
+#: internal/config/config.go:102
+msgid "Date format"
+msgstr "Formato da data"
+
+#: internal/emoji/catalog.go:123
+msgid "Denmark"
+msgstr "Dinamarca"
+
+#: internal/config/config.go:138
+msgid "Devices"
+msgstr "Dispositivos"
+
+#: internal/emoji/catalog.go:28
+msgid "Devil"
+msgstr "Diabo"
+
+#: internal/app/spellcheck_menu.go:191
+msgid "Dictionary {0}. {1}. {2}"
+msgstr "DicionƔrio {0}. {1}. {2}"
+
+#: internal/emoji/catalog.go:29
+msgid "Dizzy"
+msgstr "Tonto"
+
+#: internal/emoji/catalog.go:54
+msgid "Dog"
+msgstr "Cachorro"
+
+#: internal/emoji/catalog.go:69
+msgid "Donut"
+msgstr "Rosquinha"
+
+#: internal/config/config.go:120 internal/config/config.go:127
+msgid "Driver"
+msgstr "Controlador"
+
+#: internal/config/config.go:142
+msgid "Echo on CapsLock"
+msgstr "Eco com CapsLock"
+
+#: internal/emoji/catalog.go:82
+msgid "Egg"
+msgstr "Ovo"
+
+#: internal/emoji/catalog.go:162
+msgid "Emoji"
+msgstr "Emoji"
+
+#: internal/command/descriptions.go:53 internal/emoji/catalog.go:14
+msgid "Emoji Menu"
+msgstr "Menu de emojis"
+
+#: internal/config/config.go:160
+msgid "Emoticons"
+msgstr "Emoticons"
+
+#: internal/config/config.go:107
+msgid "Enable Persistent Clipboard"
+msgstr "Ativar Ôrea de transferência persistente"
+
+#: internal/app/tutorial.go:21
+msgid ""
+"Entering tutorial mode. In this mode commands are described but not "
+"executed. Use up and down to browse actions and left and right to switch "
+"between Active actions, Unbound actions, and Plugins. Press Space to repeat "
+"the current item. To exit tutorial mode press Fenrir+F1 or Escape. Active "
+"actions."
+msgstr ""
+"Entrando no modo de aprendizado. Neste modo, os comandos são descritos, mas "
+"não executados. Use as setas para cima e para baixo para percorrer as ações "
+"e para a esquerda e para a direita para alternar entre aƧƵes ativas, aƧƵes "
+"sem atalho e complementos. Pressione EspaƧo para repetir o item atual. Para "
+"sair, pressione Fenrir+F1 ou Escape. AƧƵes ativas."
+
+#: internal/app/spellcheck_menu.go:49 internal/app/spellcheck_menu.go:52
+msgid "Exceptions"
+msgstr "ExceƧƵes"
+
+#: internal/review/follow_cursor.go:29
+msgid "Exiting Review Mode"
+msgstr "Saindo do modo de revisão"
+
+#: internal/app/tutorial.go:22
+msgid "Exiting tutorial mode. To enter tutorial mode again press Fenrir+F1"
+msgstr ""
+"Saindo do modo de aprendizado. Para entrar novamente, pressione Fenrir+F1"
+
+#: internal/emoji/catalog.go:30
+msgid "Exploding head"
+msgstr "CabeƧa explodindo"
+
+#: internal/command/descriptions.go:113
+msgid "Export current fenrir clipboard to X or GUI clipboard"
+msgstr ""
+"Exportar a Ôrea de transferência atual do Fenrir para a Ôrea de "
+"transferência grÔfica ou do X"
+
+#: internal/emoji/catalog.go:31
+msgid "Face with symbols"
+msgstr "Rosto com sĆmbolos"
+
+#: internal/app/quick_settings.go:295 internal/app/runtime_settings.go:151
+msgid "Failed to save settings."
+msgstr "Falha ao salvar as configuraƧƵes."
+
+#: internal/command/descriptions.go:55 internal/preferences/settings.go:38
+msgid "Fenrir Menu"
+msgstr "Menu do Fenrir"
+
+#: internal/app/commands.go:83
+msgid "Fenrir screen reader version {0}-{1}"
+msgstr "Leitor de tela Fenrir versão {0}-{1}"
+
+#: internal/app/clipboard.go:377
+msgid "File does not exist"
+msgstr "O arquivo não existe"
+
+#: internal/emoji/catalog.go:124
+msgid "Finland"
+msgstr "Finlândia"
+
+#: internal/emoji/catalog.go:104
+msgid "Fire"
+msgstr "Fogo"
+
+#: internal/emoji/catalog.go:83
+msgid "Fireworks"
+msgstr "Fogos de artifĆcio"
+
+#: internal/app/clipboard.go:323
+msgid "First clipboard "
+msgstr "Primeira Ôrea de transferência "
+
+#: internal/emoji/catalog.go
+msgid "Flags"
+msgstr "Bandeiras"
+
+#: internal/emoji/catalog.go:105
+msgid "Folded hands"
+msgstr "MĆ£os juntas"
+
+#: internal/emoji/catalog.go
+msgid "Food"
+msgstr "Comida"
+
+#: internal/app/commands.go:126
+msgid "Forward next keypress"
+msgstr "Avançar próxima tecla pressionada"
+
+#: internal/emoji/catalog.go:125
+msgid "France"
+msgstr "FranƧa"
+
+#: internal/emoji/catalog.go:126
+msgid "Germany"
+msgstr "Alemanha"
+
+#: internal/emoji/catalog.go:84
+msgid "Ghost"
+msgstr "Fantasma"
+
+#: internal/emoji/catalog.go:85
+msgid "Gift"
+msgstr "Presente"
+
+#: internal/emoji/catalog.go:127
+msgid "Greece"
+msgstr "GrƩcia"
+
+#: internal/emoji/catalog.go:32
+msgid "Grinning"
+msgstr "Sorriso largo"
+
+#: internal/emoji/catalog.go:33
+msgid "Grinning sweat"
+msgstr "Sorriso com suor"
+
+#: internal/emoji/catalog.go:70
+msgid "Hamburger"
+msgstr "HambĆŗrguer"
+
+#: internal/emoji/catalog.go:106
+msgid "Heart"
+msgstr "Coração"
+
+#: internal/emoji/catalog.go:34
+msgid "Heart eyes"
+msgstr "Olhos de coração"
+
+#: internal/emoji/catalog.go:35
+msgid "Hearts around"
+msgstr "Rodeado de coraƧƵes"
+
+#: internal/emoji/catalog.go:107
+msgid "High voltage"
+msgstr "Alta tensão"
+
+#: internal/config/config.go:109
+msgid "History items"
+msgstr "Itens do histórico"
+
+#: internal/emoji/catalog.go
+msgid "Holidays"
+msgstr "Festas"
+
+#: internal/emoji/catalog.go:108
+msgid "Hundred points"
+msgstr "Cem pontos"
+
+#: internal/emoji/catalog.go:36
+msgid "Imp"
+msgstr "Diabinho"
+
+#: internal/app/clipboard.go:412
+msgid "Import to Clipboard"
+msgstr "Importar para a Ôrea de transferência"
+
+#: internal/config/config.go:153
+msgid "Indentation"
+msgstr "Recuo"
+
+#: internal/emoji/catalog.go:128
+msgid "India"
+msgstr "Ćndia"
+
+#: internal/emoji/catalog.go:129
+msgid "Ireland"
+msgstr "Irlanda"
+
+#: internal/emoji/catalog.go:130
+msgid "Israel"
+msgstr "Israel"
+
+#: internal/emoji/catalog.go:131
+msgid "Italy"
+msgstr "ItƔlia"
+
+#: internal/emoji/catalog.go:86
+msgid "Jack o'lantern"
+msgstr "Abóbora de Halloween"
+
+#: internal/emoji/catalog.go:132
+msgid "Japan"
+msgstr "Japão"
+
+#: internal/config/config.go:53
+msgid "Keyboard"
+msgstr "Teclado"
+
+#: internal/app/clipboard.go:326
+msgid "Last clipboard "
+msgstr "Ćltima Ć”rea de transferĆŖncia "
+
+#: internal/emoji/catalog.go:37
+msgid "Laughing"
+msgstr "Rindo"
+
+#: internal/config/config.go:139
+msgid "Layout"
+msgstr "Layout do teclado"
+
+#: internal/command/descriptions.go:44
+msgid "Line number for cursor"
+msgstr "NĆŗmero da linha do cursor"
+
+#: internal/command/descriptions.go:75
+msgid "List reading regions"
+msgstr "Listar regiƵes de leitura"
+
+#: internal/reader/regions.go:185
+msgid "Live region {0} cleared"
+msgstr "Região dinâmica {0} limpa"
+
+#: internal/reader/regions.go:181
+msgid "Live region {0} not set"
+msgstr "Região dinâmica {0} não definida"
+
+#: internal/reader/regions.go:189
+msgid "Live region {0} outside screen"
+msgstr "Região dinâmica {0} fora da tela"
+
+#: internal/reader/regions.go:209
+msgid "Live region {0} set"
+msgstr "Região dinâmica {0} definida"
+
+#: internal/reader/regions.go:245
+msgid "Live region {0}: {1}"
+msgstr "Região dinâmica {0}: {1}"
+
+#: internal/reader/regions.go:232
+msgid "Live regions cleared"
+msgstr "Regiões dinâmicas limpas"
+
+#: internal/emoji/catalog.go:87
+msgid "Mage"
+msgstr "Mago"
+
+#: internal/emoji/catalog.go:133
+msgid "Mexico"
+msgstr "MƩxico"
+
+#: internal/config/config.go:159
+msgid "Misspelling sound"
+msgstr "Som de erro ortogrƔfico"
+
+#: internal/emoji/catalog.go:55
+msgid "Moon"
+msgstr "Lua"
+
+#: internal/command/descriptions.go:35
+msgid "Move Review to the end of current line and display the content"
+msgstr ""
+"Mover o cursor de revisão para o fim da linha atual e apresentar o conteúdo"
+
+#: internal/command/descriptions.go:36
+msgid "Move Review to the first character on the line"
+msgstr "Mover o cursor de revisão para o primeiro caractere da linha"
+
+#: internal/command/descriptions.go:48
+msgid "Move Review to the first character on the screen (left top)"
+msgstr ""
+"Mover o cursor de revisão para o primeiro caractere da tela, no canto "
+"superior esquerdo"
+
+#: internal/command/descriptions.go:37
+msgid "Move Review to the last character on the line"
+msgstr "Mover o cursor de revisão para o último caractere da linha"
+
+#: internal/command/descriptions.go:49
+msgid "Move Review to the last character on the screen (right bottom)"
+msgstr ""
+"Mover o cursor de revisão para o último caractere da tela, no canto inferior "
+"direito"
+
+#: internal/command/descriptions.go:33
+msgid "Move review to the bottom of the screen"
+msgstr "Mover o cursor de revisão para o fim da tela"
+
+#: internal/command/descriptions.go:31
+msgid "Move review to the character below the current position"
+msgstr "Mover o cursor de revisão para o caractere abaixo da posição atual"
+
+#: internal/command/descriptions.go:30
+msgid "Move review to the character in the line above the current position"
+msgstr ""
+"Mover o cursor de revisão para o caractere na linha acima da posição atual"
+
+#: internal/command/descriptions.go:29
+msgid "Moves review to the next character"
+msgstr "Move o cursor de revisão para o próximo caractere"
+
+#: internal/emoji/catalog.go
+msgid "Nature"
+msgstr "Natureza"
+
+#: internal/emoji/catalog.go:38
+msgid "Nauseated"
+msgstr "Enjoado"
+
+#: internal/emoji/catalog.go:134
+msgid "Netherlands"
+msgstr "PaĆses Baixos"
+
+#: internal/interaction/grid.go:43 internal/interaction/grid.go:79
+msgid "No matches"
+msgstr "Nenhum resultado"
+
+#: internal/reader/regions.go:251
+msgid "No reading regions set"
+msgstr "Nenhuma região de leitura definida"
+
+#: internal/app/clipboard.go:416
+msgid "No text found in clipboard or no accessible display"
+msgstr ""
+"Nenhum texto encontrado na Ć”rea de transferĆŖncia ou nenhuma tela acessĆvel"
+
+#: internal/app/spellcheck_menu.go
+msgid "No word"
+msgstr "Nenhuma palavra"
+
+#: internal/emoji/catalog.go:135
+msgid "Norway"
+msgstr "Noruega"
+
+#: internal/review/follow_cursor.go:26
+msgid "Not in Review Mode"
+msgstr "Fora do modo de revisão"
+
+#: internal/reader/regions.go:222
+msgid "Not in window Mode"
+msgstr "NĆ£o no Modo de Janela"
+
+#: internal/preferences/settings.go:79
+msgid "OK"
+msgstr "OK"
+
+#: internal/app/clipboard.go:269 internal/reader/regions.go:197
+msgid "One or two marks are needed"
+msgstr "São necessÔrias uma ou duas marcas"
+
+#: internal/emoji/catalog.go:109
+msgid "Peace"
+msgstr "Paz"
+
+#: internal/emoji/catalog.go
+msgid "People"
+msgstr "Pessoas"
+
+#: internal/command/descriptions.go:25
+msgid "Phonetically spells the current word"
+msgstr "Soletra foneticamente a palavra atual"
+
+#: internal/config/config.go:133
+msgid "Pitch"
+msgstr "Tom"
+
+#: internal/emoji/catalog.go:71
+msgid "Pizza"
+msgstr "Pizza"
+
+#: internal/emoji/catalog.go:39
+msgid "Pleading"
+msgstr "Suplicante"
+
+#: internal/app/tutorial.go
+msgid "Plugins"
+msgstr "Complementos"
+
+#: internal/emoji/catalog.go:136
+msgid "Poland"
+msgstr "PolƓnia"
+
+#: internal/emoji/catalog.go:40
+msgid "Poop"
+msgstr "CocƓ"
+
+#: internal/emoji/catalog.go:137
+msgid "Portugal"
+msgstr "Portugal"
+
+#: internal/command/descriptions.go:52
+msgid "Present the version of Fenrir currently in use."
+msgstr "Anunciar a versão do Fenrir em uso."
+
+#: internal/config/config.go:52
+msgid "Presentation"
+msgstr "Apresentação"
+
+#: internal/command/descriptions.go:100
+msgid ""
+"Presents the currently selected text that will be copied to the clipboard"
+msgstr ""
+"Apresenta o texto selecionado que serÔ copiado para a Ôrea de transferência"
+
+#: internal/app/commands.go:112
+msgid "Progress monitoring disabled"
+msgstr "Monitoramento de progresso desativado"
+
+#: internal/app/commands.go:110
+msgid "Progress monitoring enabled"
+msgstr "Monitoramento de progresso ativado"
+
+#: internal/sound/drivers.go:39
+msgid "PulseAudio"
+msgstr "PulseAudio"
+
+#: internal/config/config.go:149
+msgid "Punctuation level"
+msgstr "NĆvel de pontuação"
+
+#: internal/config/config.go:148
+msgid "Punctuation profile"
+msgstr "Perfil de pontuação"
+
+#: internal/app/quick_settings.go:115
+msgid "Quick menu not available"
+msgstr "Menu rĆ”pido indisponĆvel"
+
+#: internal/emoji/catalog.go:56
+msgid "Rainbow"
+msgstr "Arco-Ćris"
+
+#: internal/config/config.go:150
+msgid "Raise pitch for capital letters"
+msgstr "Elevar o tom para letras maiĆŗsculas"
+
+#: internal/config/config.go:126
+msgid "Rate"
+msgstr "Velocidade"
+
+#: internal/punctuation/emoticons.go:33
+msgid "Raves"
+msgstr "Festas rave"
+
+#: internal/command/descriptions.go:78
+msgid "Read live region 1"
+msgstr "Ler região dinâmica 1"
+
+#: internal/command/descriptions.go:96
+msgid "Read live region 10"
+msgstr "Ler região dinâmica 10"
+
+#: internal/command/descriptions.go:80
+msgid "Read live region 2"
+msgstr "Ler região dinâmica 2"
+
+#: internal/command/descriptions.go:82
+msgid "Read live region 3"
+msgstr "Ler região dinâmica 3"
+
+#: internal/command/descriptions.go:84
+msgid "Read live region 4"
+msgstr "Ler região dinâmica 4"
+
+#: internal/command/descriptions.go:86
+msgid "Read live region 5"
+msgstr "Ler região dinâmica 5"
+
+#: internal/command/descriptions.go:88
+msgid "Read live region 6"
+msgstr "Ler região dinâmica 6"
+
+#: internal/command/descriptions.go:90
+msgid "Read live region 7"
+msgstr "Ler região dinâmica 7"
+
+#: internal/command/descriptions.go:92
+msgid "Read live region 8"
+msgstr "Ler região dinâmica 8"
+
+#: internal/command/descriptions.go:94
+msgid "Read live region 9"
+msgstr "Ler região dinâmica 9"
+
+#: internal/config/config.go:157
+msgid "Read misspelled word"
+msgstr "Ler palavra com erro ortogrƔfico"
+
+#: internal/config/config.go:54
+msgid "Reading"
+msgstr "Leitura"
+
+#: internal/reader/regions.go:236
+msgid "Reading regions cleared"
+msgstr "RegiƵes de leitura limpas"
+
+#: internal/reader/regions.go:241
+msgid "Reading window: {0}"
+msgstr "Janela de leitura: {0}"
+
+#: internal/command/descriptions.go:42
+msgid "Reads attributes of current cursor position"
+msgstr "Lê os atributos da posição atual do cursor"
+
+#: internal/command/descriptions.go:39
+msgid "Reads from the top of the screen to the cursor position"
+msgstr "Lê da parte superior da tela até a posição do cursor"
+
+#: internal/app/clipboard.go:264
+msgid "Remove marks"
+msgstr "Remove marcas"
+
+#: internal/command/descriptions.go:99
+msgid "Removes marks from selected text"
+msgstr "Remove as marcas do texto selecionado"
+
+#: internal/config/config.go:161
+msgid "Repeated symbols"
+msgstr "SĆmbolos repetidos"
+
+#: internal/config/config.go:162
+msgid "Repeated symbols minimum length"
+msgstr "Quantidade mĆnima de sĆmbolos repetidos"
+
+#: internal/emoji/catalog.go:110
+msgid "Rock on"
+msgstr "Gesto de rock"
+
+#: internal/emoji/catalog.go:41
+msgid "Rolling laughing"
+msgstr "Rolando de rir"
+
+#: internal/emoji/catalog.go:57
+msgid "Rose"
+msgstr "Rosa"
+
+#: internal/emoji/catalog.go:138
+msgid "Russia"
+msgstr "RĆŗssia"
+
+#: internal/emoji/catalog.go:42
+msgid "Sad"
+msgstr "Triste"
+
+#: internal/emoji/catalog.go:88
+msgid "Santa"
+msgstr "Papai Noel"
+
+#: internal/command/descriptions.go:64
+msgid "Saves your current Fenrir settings so they are the default."
+msgstr "Salva as configurações atuais do Fenrir como padrão."
+
+#: internal/emoji/catalog.go:43
+msgid "Savoring food"
+msgstr "Saboreando comida"
+
+#: internal/config/config.go:152
+msgid "Say ācapā before capital letters"
+msgstr "Dizer āmaiĆŗsculaā antes das letras maiĆŗsculas"
+
+#: internal/config/config.go:158
+msgid "Say āmisspelledā"
+msgstr "Dizer āerro ortogrĆ”ficoā"
+
+#: internal/emoji/catalog.go:44
+msgid "Screaming"
+msgstr "Gritando"
+
+#: internal/app/commands.go:135 internal/app/scripts.go
+msgid "Script failed"
+msgstr "O script falhou"
+
+#: internal/app/scripts.go
+msgid "Script file is not executable"
+msgstr "O arquivo de script não é executÔvel"
+
+#: internal/app/scripts.go
+msgid "Script file not found"
+msgstr "Arquivo de script não encontrado"
+
+#: internal/app/scripts.go
+msgid "Script source is not a valid file"
+msgstr "A origem do script não é um arquivo vÔlido"
+
+#: internal/interaction/grid.go:71
+msgid "Search cleared"
+msgstr "Pesquisa limpa"
+
+#: internal/interaction/grid.go:73
+msgid "Search {0}"
+msgstr "Pesquisar {0}"
+
+#: internal/config/config.go:121
+msgid "Select sound theme"
+msgstr "Selecionar tema de som"
+
+#: internal/config/config.go:128
+msgid "Serial port"
+msgstr "Porta serial"
+
+#: internal/command/descriptions.go:74
+msgid "Set a live region from one or two marks"
+msgstr "Definir uma região dinâmica a partir de uma ou duas marcas"
+
+#: internal/reader/regions.go:213
+msgid "Set window begin and end marks"
+msgstr "Definir marcas de inĆcio e fim da janela"
+
+#: internal/app/quick_settings.go:298
+msgid "Settings saved."
+msgstr "ConfiguraƧƵes salvas."
+
+#: internal/emoji/catalog.go:89
+msgid "Shamrock"
+msgstr "Trevo"
+
+#: internal/emoji/catalog.go:90
+msgid "Skull"
+msgstr "Caveira"
+
+#: internal/emoji/catalog.go:111
+msgid "Skull crossbones"
+msgstr "Caveira com ossos cruzados"
+
+#: internal/emoji/catalog.go:45
+msgid "Smiling"
+msgstr "Sorrindo"
+
+#: internal/emoji/catalog.go:46
+msgid "Smirking"
+msgstr "Sorriso malicioso"
+
+#: internal/emoji/catalog.go:91
+msgid "Snowman"
+msgstr "Boneco de neve"
+
+#: internal/config/config.go:95
+msgid "Sound"
+msgstr "Som"
+
+#: internal/config/config.go:118
+msgid "Sound enabled"
+msgstr "Som ativado"
+
+#: internal/emoji/catalog.go:139
+msgid "South Africa"
+msgstr "Ćfrica do Sul"
+
+#: internal/emoji/catalog.go:140
+msgid "South Korea"
+msgstr "Coreia do Sul"
+
+#: internal/emoji/catalog.go:141
+msgid "Spain"
+msgstr "Espanha"
+
+#: internal/emoji/catalog.go:112
+msgid "Sparkles"
+msgstr "Brilhos"
+
+#: internal/config/config.go:94
+msgid "Speech"
+msgstr "Fala"
+
+#: internal/speech/drivers.go:44
+msgid "Speech Dispatcher"
+msgstr "Speech Dispatcher"
+
+#: internal/config/config.go:130
+msgid "Speech Dispatcher module"
+msgstr "Módulo do Speech Dispatcher"
+
+#: internal/config/config.go:125
+msgid "Speech enabled"
+msgstr "Fala ativada"
+
+#: internal/config/config.go:131
+msgid "Speech language"
+msgstr "Idioma da fala"
+
+#: internal/app/screen_updates.go:72
+msgid "Speech restored"
+msgstr "Fala restaurada"
+
+#: internal/config/config.go:132
+msgid "Speech voice"
+msgstr "Voz de sĆntese"
+
+#: internal/app/spellcheck_menu.go:81
+msgid "Spell checking is unavailable"
+msgstr "A verificação ortogrĆ”fica nĆ£o estĆ” disponĆvel"
+
+#: internal/app/spellcheck_menu.go:180
+msgid "Spell checking is unavailable: {0}"
+msgstr "A verificação ortogrĆ”fica nĆ£o estĆ” disponĆvel: {0}"
+
+#: internal/command/descriptions.go:54
+msgid "Spellcheck Menu"
+msgstr "Menu de ortografia"
+
+#: internal/command/descriptions.go:24
+msgid "Spells the current word"
+msgstr "Soletra a palavra atual"
+
+#: internal/emoji/catalog.go:92
+msgid "Spider"
+msgstr "Aranha"
+
+#: internal/emoji/catalog.go:113
+msgid "Star"
+msgstr "Estrela"
+
+#: internal/app/screen_updates.go:39
+msgid "Start Fenrir"
+msgstr "Iniciar o Fenrir"
+
+#: internal/emoji/catalog.go:72
+msgid "Strawberry"
+msgstr "Morango"
+
+#: internal/app/spellcheck_menu.go:45 internal/app/spellcheck_menu.go:47
+msgid "Suggestions"
+msgstr "SugestƵes"
+
+#: internal/emoji/catalog.go:58
+msgid "Sun"
+msgstr "Sol"
+
+#: internal/emoji/catalog.go:59
+msgid "Sunflower"
+msgstr "Girassol"
+
+#: internal/emoji/catalog.go:142
+msgid "Sweden"
+msgstr "SuƩcia"
+
+#: internal/emoji/catalog.go:143
+msgid "Switzerland"
+msgstr "SuĆƧa"
+
+#: internal/emoji/catalog.go:114
+msgid "Sword"
+msgstr "Espada"
+
+#: internal/emoji/catalog.go
+msgid "Symbols"
+msgstr "SĆmbolos"
+
+#: internal/emoji/catalog.go:73
+msgid "Taco"
+msgstr "Taco"
+
+#: internal/preferences/settings.go:72
+msgid "Test synthesizer"
+msgstr "Testar sintetizador"
+
+#: internal/app/fenrir_menu.go:139
+msgid "Testing voice"
+msgstr "Testando a voz"
+
+#: internal/emoji/catalog.go:47
+msgid "Thumbs up"
+msgstr "Polegar para cima"
+
+#: internal/config/config.go:97
+msgid "Time and date"
+msgstr "Hora e data"
+
+#: internal/config/config.go:101
+msgid "Time format"
+msgstr "Formato da hora"
+
+#: internal/command/descriptions.go:67
+msgid "Toggle progress bar monitoring with ascending tones"
+msgstr "Alternar o monitoramento do progresso com tons ascendentes"
+
+#: internal/review/top.go:29
+msgid "Top"
+msgstr "Topo"
+
+#: internal/config/config.go:147
+msgid "Tracking mode"
+msgstr "Modo de acompanhamento"
+
+#: internal/emoji/catalog.go:60
+msgid "Tree"
+msgstr "Ćrvore"
+
+#: internal/emoji/catalog.go:144 internal/emoji/catalog.go:93
+msgid "Turkey"
+msgstr "Peru"
+
+#: internal/command/descriptions.go:73
+msgid "Turn off window mode"
+msgstr "Desativar o modo de janela"
+
+#: internal/emoji/catalog.go:145
+msgid "Ukraine"
+msgstr "Ucrânia"
+
+#: internal/app/tutorial.go:23
+msgid ""
+"Unable to exit tutorial mode because exclusive keyboard capture could not be "
+"released. Press Fenrir+F1 or Escape to try again."
+msgstr ""
+"NĆ£o foi possĆvel sair do modo de aprendizado porque a captura exclusiva do "
+"teclado não pÓde ser liberada. Pressione Fenrir+F1 ou Escape para tentar "
+"novamente."
+
+#: internal/app/tutorial.go
+msgid "Unbound actions"
+msgstr "AƧƵes sem atalho"
+
+#: internal/app/spellcheck_menu.go:53
+msgid "Undo"
+msgstr "Desfazer"
+
+#: internal/emoji/catalog.go:146
+msgid "United Kingdom"
+msgstr "Reino Unido"
+
+#: internal/emoji/catalog.go:147
+msgid "United States"
+msgstr "Estados Unidos"
+
+#: internal/emoji/catalog.go:94
+msgid "Vampire"
+msgstr "Vampiro"
+
+#: internal/app/quick_settings.go:191 internal/app/synthesizer_preview.go:60
+msgid "Voice test failed"
+msgstr "O teste de voz falhou"
+
+#: internal/app/synthesizer_preview.go:23
+msgid "Voice test: The quick brown fox jumps over the lazy dog."
+msgstr ""
+"Teste de voz: A rƔpida raposa marrom salta sobre o cachorro preguiƧoso."
+
+#: internal/config/config.go:119 internal/config/config.go:134
+msgid "Volume"
+msgstr "Volume"
+
+#: internal/punctuation/emoticons.go:32
+msgid "WTF?"
+msgstr "Mas que diabos?"
+
+#: internal/app/tutorial.go:24
+msgid ""
+"Warning: full keyboard capture is unavailable. Unbound keys may reach the "
+"active application."
+msgstr ""
+"Aviso: a captura completa do teclado nĆ£o estĆ” disponĆvel. Teclas sem atalho "
+"podem chegar ao aplicativo ativo."
+
+#: internal/emoji/catalog.go:95
+msgid "Web"
+msgstr "Teia"
+
+#: internal/reader/regions.go:225
+msgid "Window Mode off for terminal {0}"
+msgstr "Modo de janela desativado para o terminal {0}"
+
+#: internal/reader/regions.go:219
+msgid "Window Mode on for terminal {0}"
+msgstr "Modo de janela ativado para o terminal {0}"
+
+#: internal/emoji/catalog.go:48
+msgid "Winking"
+msgstr "Piscando"
+
+#: internal/emoji/catalog.go:96
+msgid "Witch"
+msgstr "Bruxa"
+
+#: internal/emoji/catalog.go:61
+msgid "Wolf"
+msgstr "Lobo"
+
+#: internal/app/spellcheck_menu.go:46 internal/app/spellcheck_menu.go:50
+msgid "Word"
+msgstr "Palavra"
+
+#: internal/config/config.go:141
+msgid "Word echo"
+msgstr "Eco de palavras"
+
+#: internal/emoji/catalog.go:97
+msgid "Zombie"
+msgstr "Zumbi"
+
+#: internal/punctuation/profile.go
+msgid "all"
+msgstr "todos"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "and"
+msgstr "e"
+
+#: internal/punctuation/emoticons.go:17
+msgid "angry"
+msgstr "zangado"
+
+#: internal/reader/attributes.go:36
+msgid "announcement of attributes disabled"
+msgstr "AnĆŗncio de atributos desativado"
+
+#: internal/reader/attributes.go:33
+msgid "announcement of attributes enabled"
+msgstr "AnĆŗncio de atributos ativado"
+
+#: internal/punctuation/emoticons.go:26
+msgid "annoyed"
+msgstr "irritado"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "apostrophe"
+msgstr "apóstrofo"
+
+#: internal/punctuation/emoticons.go:39
+msgid "arrow left"
+msgstr "seta para a esquerda"
+
+#: internal/punctuation/emoticons.go:38
+msgid "arrow right"
+msgstr "seta para a direita"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "at"
+msgstr "arroba"
+
+#: internal/reader/reader.go:197
+msgid "autoread disabled"
+msgstr "leitura automƔtica desativada"
+
+#: internal/reader/reader.go:195
+msgid "autoread enabled"
+msgstr "leitura automƔtica ativada"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "backslash"
+msgstr "barra invertida"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "bang"
+msgstr "exclamação"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "bar"
+msgstr "barra vertical"
+
+#: internal/config/definitions.go:206
+msgid "beeps"
+msgstr "sinais sonoros"
+
+#: internal/review/line_start.go:28
+msgid "beginning of line"
+msgstr "inĆcio da linha"
+
+#: internal/punctuation/emoticons.go:12
+msgid "big grin"
+msgstr "sorriso largo"
+
+#: internal/presentation/attributes.go
+msgid "black"
+msgstr "preto"
+
+#: internal/app/clipboard.go:281 internal/presentation/text.go:35
+#: internal/reader/regions.go:191 internal/review/line_start.go:31
+#: internal/review/screen_range.go:39 internal/review/spelling.go:44
+#: internal/review/table.go:124 internal/review/table.go:205
+#: internal/review/vertical_character.go:31 internal/review/word.go:64
+msgid "blank"
+msgstr "em branco"
+
+#: internal/presentation/attributes.go
+msgid "blink"
+msgstr "piscando"
+
+#: internal/presentation/attributes.go
+msgid "blue"
+msgstr "azul"
+
+#: internal/presentation/attributes.go
+msgid "bold"
+msgstr "negrito"
+
+#: internal/punctuation/emoticons.go:31
+msgid "broken heart"
+msgstr "coração partido"
+
+#: internal/presentation/attributes.go
+msgid "brown/yellow"
+msgstr "marrom/amarelo"
+
+#: internal/punctuation/emoticons.go:34
+msgid "bugged"
+msgstr "aborrecido"
+
+#: internal/app/clipboard.go:93 internal/app/review_spelling.go:27
+msgid "cap"
+msgstr "maiĆŗscula"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "carrot"
+msgstr "acento circunflexo"
+
+#: internal/presentation/text.go:68
+msgid "character {0}"
+msgstr "caractere {0}"
+
+#: internal/interaction/presentation.go
+msgid "checked"
+msgstr "marcado"
+
+#: internal/command/descriptions.go:108
+msgid "clears the currently selected clipboard"
+msgstr "limpa a Ôrea de transferência atualmente selecionada"
+
+#: internal/app/clipboard.go:299
+msgid "clipboard cleared"
+msgstr "Ôrea de transferência limpa"
+
+#: internal/app/clipboard.go:302 internal/app/clipboard.go:337
+msgid "clipboard empty"
+msgstr "Ôrea de transferência vazia"
+
+#: internal/app/clipboard.go:395
+msgid "clipboard exported to file"
+msgstr "Ôrea de transferência exportada para arquivo"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "colon"
+msgstr "dois-pontos"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "comma"
+msgstr "vĆrgula"
+
+#: internal/punctuation/emoticons.go:27
+msgid "confused"
+msgstr "confuso"
+
+#: internal/punctuation/emoticons.go:20
+msgid "cool"
+msgstr "legal"
+
+#: internal/command/descriptions.go:102
+msgid "copies last presented text to the clipboard"
+msgstr "copia o último texto apresentado para a Ôrea de transferência"
+
+#: internal/command/descriptions.go:101
+msgid "copies marked text to the currently selected clipboard"
+msgstr ""
+"copia o texto marcado para a Ôrea de transferência atualmente selecionada"
+
+#: internal/app/spellcheck_menu.go
+msgid "correct"
+msgstr "correto"
+
+#: internal/review/table.go:132
+msgid "could not set header row"
+msgstr "nĆ£o foi possĆvel definir a linha de cabeƧalho"
+
+#: internal/punctuation/emoticons.go:18
+msgid "crying"
+msgstr "chorando"
+
+#: internal/command/descriptions.go:17
+msgid "current line"
+msgstr "linha atual"
+
+#: internal/command/descriptions.go:20
+msgid "current word."
+msgstr "palavra atual."
+
+#: internal/config/definitions.go:225 internal/reader/reader.go:190
+msgid "cursor tracking"
+msgstr "rastreamento do cursor"
+
+#: internal/presentation/attributes.go
+msgid "cyan"
+msgstr "ciano"
+
+#: internal/presentation/attributes.go
+msgid "dark gray"
+msgstr "cinza-escuro"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "dash"
+msgstr "hĆfen"
+
+#: internal/app/quick_settings.go:185 internal/app/quick_settings.go:234
+#: internal/config/definitions.go:237 internal/preferences/discovery.go:45
+#: internal/preferences/discovery.go:55 internal/preferences/discovery.go:81
+#: internal/presentation/attributes.go:18
+#: internal/presentation/attributes.go:44
+msgid "default"
+msgstr "padrão"
+
+#: internal/command/descriptions.go:66
+msgid "disables speech until next keypress"
+msgstr "desativa a fala até a próxima tecla pressionada"
+
+#: internal/command/descriptions.go:43
+msgid "displays the position of the review cursor"
+msgstr "mostra a posição do cursor de revisão"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "dollar"
+msgstr "cifrão"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "dot"
+msgstr "ponto"
+
+#: internal/command/descriptions.go:68
+msgid "enables or disables automatic reading of new text as it appears"
+msgstr "ativa ou desativa a leitura automƔtica de novo texto como aparece"
+
+#: internal/command/descriptions.go:65
+msgid "enables or disables speech"
+msgstr "ativa ou desativa a fala"
+
+#: internal/command/descriptions.go:41
+msgid "enables or disables the announcement of attributes"
+msgstr "ativa ou desativa o anĆŗncio de atributos"
+
+#: internal/command/descriptions.go:69
+msgid "enables or disables tracking of highlighted text"
+msgstr "ativa ou desativa o acompanhamento do texto destacado"
+
+#: internal/review/table.go:218
+msgid "end of cell"
+msgstr "fim da cƩlula"
+
+#: internal/review/line_end.go:24 internal/review/table.go:175
+msgid "end of line"
+msgstr "fim da linha"
+
+#: internal/review/navigation.go:17
+msgid "end of screen"
+msgstr "fim da tela"
+
+#: internal/punctuation/emoticons.go:37
+msgid "enjoy smile"
+msgstr "sorriso de satisfação"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "equals"
+msgstr "igual"
+
+#: internal/punctuation/emoticons.go:36
+msgid "evil smile"
+msgstr "sorriso malvado"
+
+#: internal/command/descriptions.go:71
+msgid "exits Fenrir"
+msgstr "sai do Fenrir"
+
+#: internal/command/descriptions.go:50
+msgid "exits review mode"
+msgstr "sai do modo de revisão"
+
+#: internal/command/descriptions.go:111
+msgid "export the current fenrir clipboard to a file"
+msgstr "exporta a Ôrea de transferência atual do Fenrir para um arquivo"
+
+#: internal/app/clipboard.go:391
+msgid "exported to the X session."
+msgstr "exportado para a sessão X."
+
+#: internal/app/clipboard.go:372
+msgid "failed to export to X clipboard. No available display found."
+msgstr ""
+"falha ao exportar para a Ć”rea de transferĆŖncia do X. Nenhuma tela disponĆvel "
+"encontrada."
+
+#: internal/review/table.go:190
+msgid "first cell"
+msgstr "primeira cƩlula"
+
+#: internal/review/table.go:224
+msgid "first character in cell {0}"
+msgstr "primeiro caractere na cƩlula {0}"
+
+#: internal/review/first_text.go:35
+msgid "first character in line indent {0}"
+msgstr "primeiro caractere na linha, recuo {0}"
+
+#: internal/review/screen_first_character.go:13
+msgid "first character in screen"
+msgstr "primeiro caractere na tela"
+
+#: internal/punctuation/emoticons.go:11
+msgid "frown"
+msgstr "carranca"
+
+#: internal/punctuation/emoticons.go:28
+msgid "frustrated"
+msgstr "frustrado"
+
+#: internal/command/descriptions.go:58
+msgid "get current quick menu entry"
+msgstr "anunciar a entrada atual do menu rƔpido"
+
+#: internal/command/descriptions.go:59
+msgid "get current quick menu value"
+msgstr "anunciar o valor atual do menu rƔpido"
+
+#: internal/command/descriptions.go:56
+msgid "get next quick menu category"
+msgstr "selecionar a próxima categoria do menu rÔpido"
+
+#: internal/command/descriptions.go:60
+msgid "get next quick menu entry"
+msgstr "selecionar a próxima entrada do menu rÔpido"
+
+#: internal/command/descriptions.go:62
+msgid "get next quick menu value"
+msgstr "selecionar o próximo valor do menu rÔpido"
+
+#: internal/command/descriptions.go:57
+msgid "get previous quick menu category"
+msgstr "selecionar a categoria anterior do menu rƔpido"
+
+#: internal/command/descriptions.go:61
+msgid "get previous quick menu entry"
+msgstr "selecionar a entrada anterior do menu rƔpido"
+
+#: internal/command/descriptions.go:63
+msgid "get previous quick menu value"
+msgstr "selecionar o valor anterior do menu rƔpido"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "grave"
+msgstr "acento grave"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "greater"
+msgstr "maior que"
+
+#: internal/presentation/attributes.go
+msgid "green"
+msgstr "verde"
+
+#: internal/punctuation/emoticons.go:25
+msgid "happy"
+msgstr "feliz"
+
+#: internal/presentation/attributes.go:65
+msgid "has attribute"
+msgstr "tem atributo"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "hash"
+msgstr "cerquilha"
+
+#: internal/review/table.go:138
+msgid "header row set"
+msgstr "linha de cabeƧalho definida"
+
+#: internal/punctuation/emoticons.go:30
+msgid "heart"
+msgstr "coração"
+
+#: internal/config/definitions.go:225 internal/reader/reader.go:187
+msgid "highlight tracking"
+msgstr "rastreamento em destaque"
+
+#: internal/punctuation/emoticons.go:29
+msgid "hurray"
+msgstr "viva"
+
+#: internal/command/descriptions.go:110
+msgid "imports text from clipboard file to the clipboard"
+msgstr ""
+"importa texto do arquivo da Ôrea de transferência para a Ôrea de "
+"transferĆŖncia"
+
+#: internal/command/descriptions.go:112
+msgid "imports the graphical clipboard to Fenrir's clipboard"
+msgstr ""
+"importa a Ôrea de transferência grÔfica para a Ôrea de transferência do "
+"Fenrir"
+
+#: internal/presentation/indentation.go:32
+msgid "indented "
+msgstr "recuo "
+
+#: internal/presentation/attributes.go
+msgid "italic"
+msgstr "itƔlico"
+
+#: internal/punctuation/emoticons.go:22
+msgid "knocked out"
+msgstr "desmaiado"
+
+#: internal/review/table.go:192
+msgid "last cell"
+msgstr "última célula"
+
+#: internal/review/table.go:227
+msgid "last character in cell {0}"
+msgstr "último caractere na célula {0}"
+
+#: internal/review/last_text.go:34
+msgid "last character in line"
+msgstr "Ćŗltimo caractere na linha"
+
+#: internal/review/screen_last_character.go:13
+msgid "last character in screen"
+msgstr "Ćŗltimo caractere na tela"
+
+#: internal/punctuation/emoticons.go:35
+msgid "laughing"
+msgstr "rindo"
+
+#: internal/punctuation/emoticons.go:24
+msgid "laughing hard"
+msgstr "gargalhando"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "left brace"
+msgstr "abre chaves"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "left bracket"
+msgstr "abre colchetes"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "left paren"
+msgstr "abre parĆŖnteses"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "less"
+msgstr "menor que"
+
+#: internal/presentation/attributes.go
+msgid "light blue"
+msgstr "azul-claro"
+
+#: internal/presentation/attributes.go
+msgid "light cyan"
+msgstr "ciano-claro"
+
+#: internal/presentation/attributes.go
+msgid "light gray"
+msgstr "cinza-claro"
+
+#: internal/presentation/attributes.go
+msgid "light green"
+msgstr "verde-claro"
+
+#: internal/presentation/attributes.go
+msgid "light magenta"
+msgstr "magenta-claro"
+
+#: internal/presentation/attributes.go
+msgid "light red"
+msgstr "vermelho-claro"
+
+#: internal/presentation/attributes.go
+msgid "light yellow"
+msgstr "amarelo-claro"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "line"
+msgstr "sublinhado"
+
+#: internal/review/navigation.go:25
+msgid "line break"
+msgstr "quebra de linha"
+
+#: internal/review/first_text.go:29 internal/review/last_text.go:28
+msgid "line is empty"
+msgstr "a linha estĆ” vazia"
+
+#: internal/review/position.go:27
+msgid "line {0}, column {1}, Terminal {2}"
+msgstr "linha {0}, coluna {1}, terminal {2}"
+
+#: internal/punctuation/emoticons.go:23
+msgid "lips sealed"
+msgstr "lƔbios selados"
+
+#: internal/presentation/attributes.go
+msgid "magenta"
+msgstr "magenta"
+
+#: internal/app/spellcheck.go:22 internal/app/spellcheck_menu.go
+msgid "misspelled"
+msgstr "com erros ortogrƔficos"
+
+#: internal/punctuation/profile.go
+msgid "most"
+msgstr "a maioria"
+
+#: internal/command/descriptions.go:32
+msgid "move review to top of screen"
+msgstr "mover a revisão para o topo da tela"
+
+#: internal/command/descriptions.go:19
+msgid "moves review focus to the previous word "
+msgstr "move o cursor de revisão para a palavra anterior "
+
+#: internal/command/descriptions.go:18
+msgid "moves review to the next line "
+msgstr "move o cursor de revisão para a próxima linha "
+
+#: internal/command/descriptions.go:21
+msgid "moves review to the next word "
+msgstr "move o cursor de revisão para a próxima palavra "
+
+#: internal/command/descriptions.go:22
+msgid "moves review to the previous character"
+msgstr "move o cursor de revisão para o caractere anterior"
+
+#: internal/command/descriptions.go:16
+msgid "moves review to the previous line "
+msgstr "move o cursor de revisão para a linha anterior "
+
+#: internal/punctuation/emoticons.go:19
+msgid "neutral"
+msgstr "neutro"
+
+#: internal/command/descriptions.go internal/command/descriptions.go:26
+#: internal/command/descriptions.go:27
+msgid "no description available"
+msgstr "nenhuma descrição disponĆvel"
+
+#: internal/review/marks.go:30
+msgid "no review cursor"
+msgstr "sem cursor de revisão"
+
+#: internal/review/spelling.go:19 internal/review/table.go:150
+#: internal/review/table.go:158 internal/review/vertical_character.go:26
+#: internal/review/vertical_character.go:34
+msgid "no table data"
+msgstr "sem dados de tabela"
+
+#: internal/reader/regions.go:173 internal/review/review.go:135
+msgid "no text console"
+msgstr "nenhum console de texto"
+
+#: internal/config/definitions.go:206 internal/punctuation/profile.go
+msgid "none"
+msgstr "nenhum"
+
+#: internal/punctuation/text_processing.go
+msgid "number"
+msgstr "nĆŗmero"
+
+#: internal/app/clipboard.go:344 internal/app/clipboard.go:399
+msgid "paste clipboard"
+msgstr "colar Ôrea de transferência"
+
+#: internal/command/descriptions.go:109
+msgid "pastes the text from the currently selected clipboard"
+msgstr "cola o texto da Ôrea de transferência atualmente selecionada"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "percent"
+msgstr "por cento"
+
+#: internal/command/descriptions.go:98
+msgid "places marks to select text to copy to the clipboard"
+msgstr ""
+"coloca marcas para selecionar texto para copiar para a Ôrea de transferência"
+
+#: internal/app/clipboard.go:271
+msgid "please set begin and endmark"
+msgstr "por favor, defina o inĆcio e o fim da seleção"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "plus"
+msgstr "mais"
+
+#: internal/command/descriptions.go:23
+msgid "presents the current character."
+msgstr "apresenta o caracter atual."
+
+#: internal/command/descriptions.go:15
+msgid "presents the date"
+msgstr "apresenta a data"
+
+#: internal/command/descriptions.go:14
+msgid "presents the time"
+msgstr "apresenta a hora"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "question?"
+msgstr "interrogação"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "quote"
+msgstr "aspas"
+
+#: internal/command/descriptions.go:46
+msgid ""
+"read line to cursor pos, use review cursor if you are in review mode, "
+"otherwhise use text cursor"
+msgstr ""
+"ler a linha até o cursor; usar o cursor de revisão no modo de revisão e o "
+"cursor de texto nos demais casos"
+
+#: internal/command/descriptions.go:47
+msgid ""
+"read to end of line, use review cursor if you are in review mode, otherwhise "
+"use text cursor"
+msgstr ""
+"ler até o final da linha, use o cursor de revisão se você estiver no modo de "
+"revisão, ou use o cursor de texto"
+
+#: internal/command/descriptions.go:40
+msgid "reads from the cursor to the bottom of the screen"
+msgstr "lê a partir do cursor até a parte inferior da tela"
+
+#: internal/command/descriptions.go:38
+msgid "reads the contents of the current screen"
+msgstr "lĆŖ o conteĆŗdo da tela atual"
+
+#: internal/presentation/attributes.go
+msgid "red"
+msgstr "vermelho"
+
+#: internal/presentation/attributes.go
+msgid "reverse"
+msgstr "invertido"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "right brace"
+msgstr "fecha chaves"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "right bracket"
+msgstr "fecha colchetes"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "right paren"
+msgstr "fecha parĆŖnteses"
+
+#: internal/reader/regions.go:259
+msgid "row {0}, column {1} to end of line"
+msgstr "linha {0}, coluna {1} atƩ o fim da linha"
+
+#: internal/reader/regions.go:261
+msgid "row {0}, column {1} to row {2}, column {3}"
+msgstr "linha {0}, coluna {1} atƩ a linha {2}, coluna {3}"
+
+#: internal/review/read_screen.go:34
+msgid "screen is empty"
+msgstr "a tela estĆ” vazia"
+
+#: internal/reader/reader.go:132
+msgid "screen {0}"
+msgstr "tela {0}"
+
+#: internal/interaction/entry.go:31
+msgid "selected"
+msgstr "selecionado"
+
+#: internal/command/descriptions.go:106
+msgid "selects the first clipboard"
+msgstr "seleciona a primeira Ôrea de transferência"
+
+#: internal/command/descriptions.go:107
+msgid "selects the last clipboard"
+msgstr "seleciona a última Ôrea de transferência"
+
+#: internal/command/descriptions.go:105
+msgid "selects the next clipboard"
+msgstr "seleciona a próxima Ôrea de transferência"
+
+#: internal/command/descriptions.go:104
+msgid "selects the previous clipboard"
+msgstr "seleciona a Ôrea de transferência anterior"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "semicolon"
+msgstr "ponto e vĆrgula"
+
+#: internal/command/descriptions.go:70
+msgid "sends the following keypress to the terminal or application"
+msgstr "envia a próxima tecla ao terminal ou aplicativo"
+
+#: internal/command/descriptions.go:72
+msgid "set Window Mode, needs 2 marks "
+msgstr "definir o Modo de Janela, precisa de 2 marcas "
+
+#: internal/review/marks.go:43
+msgid "set mark"
+msgstr "define marca"
+
+#: internal/command/descriptions.go:28
+msgid "set review and phonetically presents the current character"
+msgstr "define revisão e apresenta o caracter atual foneticamente"
+
+#: internal/command/descriptions.go:34
+msgid "set review cursor to begin of current line and display the content"
+msgstr ""
+"define o cursor de revisĆ£o para o inĆcio da linha atual e exibe o conteĆŗdo"
+
+#: internal/punctuation/emoticons.go:16
+msgid "skeptical"
+msgstr "cƩtico"
+
+#: internal/punctuation/emoticons.go:21
+msgid "skull and crossbones"
+msgstr "caveira com ossos cruzados"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "slash"
+msgstr "barra"
+
+#: internal/punctuation/emoticons.go:10
+msgid "smile"
+msgstr "sorriso"
+
+#: internal/punctuation/profile.go
+msgid "some"
+msgstr "alguns"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "space"
+msgstr "espaƧo"
+
+#: internal/command/descriptions.go:103
+msgid "speaks the contents of the currently selected clipboard"
+msgstr "fala o conteúdo da Ôrea de transferência atualmente selecionada"
+
+#: internal/config/definitions.go:206
+msgid "speech"
+msgstr "fala"
+
+#: internal/config/definitions.go:206
+msgid "speech and beeps"
+msgstr "fala e sinais sonoros"
+
+#: internal/app/commands.go:59 internal/app/feedback.go:45
+msgid "speech enabled"
+msgstr "fala ativada"
+
+#: internal/config/config.go:131
+msgid "speech language"
+msgstr "idioma da fala"
+
+#: internal/config/config.go:133
+msgid "speech pitch"
+msgstr "tom da fala"
+
+#: internal/config/config.go:126
+msgid "speech rate"
+msgstr "velocidade da fala"
+
+#: internal/app/commands.go:121
+msgid "speech temporary disabled"
+msgstr "fala temporariamente desativada"
+
+#: internal/config/config.go:132
+msgid "speech voice"
+msgstr "voz de sĆntese"
+
+#: internal/config/config.go:134
+msgid "speech volume"
+msgstr "volume da fala"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "star"
+msgstr "asterisco"
+
+#: internal/review/table.go:212
+msgid "start of cell"
+msgstr "inĆcio da cĆ©lula"
+
+#: internal/review/table.go:168
+msgid "start of line"
+msgstr "inĆcio da linha"
+
+#: internal/review/navigation.go:20
+msgid "start of screen"
+msgstr "inĆcio da tela"
+
+#: internal/presentation/attributes.go
+msgid "strikethrough"
+msgstr "tachado"
+
+#: internal/punctuation/emoticons.go:15
+msgid "surprised"
+msgstr "surpreso"
+
+#: internal/presentation/text.go
+msgid "tab"
+msgstr "tabulação"
+
+#: internal/config/definitions.go:225 internal/reader/reader.go:184
+msgid "table mode"
+msgstr "modo de tabela"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "tilde"
+msgstr "til"
+
+#: internal/command/descriptions.go:51
+msgid "toggles the tutorial mode"
+msgstr "alterna o modo de aprendizado"
+
+#: internal/punctuation/emoticons.go:14
+msgid "tongue out"
+msgstr "lĆngua de fora"
+
+#: internal/interaction/presentation.go:62
+msgid "unavailable"
+msgstr "indisponĆvel"
+
+#: internal/app/tutorial.go:107 internal/app/tutorial.go:161
+#: internal/reader/reader.go:199 internal/review/review.go:140
+msgid "unbound"
+msgstr "sem atalho"
+
+#: internal/interaction/presentation.go
+msgid "unchecked"
+msgstr "desmarcado"
+
+#: internal/presentation/attributes.go
+msgid "underline"
+msgstr "sublinhado"
+
+#: internal/presentation/attributes.go internal/presentation/attributes.go:27
+msgid "white"
+msgstr "branco"
+
+#: internal/punctuation/emoticons.go:13
+msgid "wink"
+msgstr "piscadela"
+
+#: internal/app/spellcheck_menu.go
+msgid "{0} added to dictionary"
+msgstr "{0} adicionado ao dicionƔrio"
+
+#: internal/interaction/presentation.go:53
+msgid "{0} button"
+msgstr "{0} botão"
+
+#: internal/app/internal_ui.go:122
+msgid "{0} closed"
+msgstr "{0} fechado"
+
+#: internal/interaction/presentation.go:51
+msgid "{0} combo box {1}"
+msgstr "{0} caixa de combinação {1}"
+
+#: internal/interaction/presentation.go:45
+msgid "{0} entry {1}"
+msgstr "{0} campo de texto {1}"
+
+#: internal/interaction/presentation.go:49
+msgid "{0} grid. {1}"
+msgstr "{0} grade. {1}"
+
+#: internal/app/spellcheck_menu.go:134
+msgid "{0} is already in dictionary"
+msgstr "{0} jƔ estƔ no dicionƔrio"
+
+#: internal/app/spellcheck_menu.go
+msgid "{0} removed"
+msgstr "{0} removido"
+
+#: internal/interaction/presentation.go:31
+msgid "{0} tab"
+msgstr "{0} aba"
+
+#: internal/punctuation/text_processing.go:69
+msgid "{0} {1}"
+msgstr "{0} {1}"
+
+#: internal/interaction/grid.go:39
+msgid "{0}. row {1}, column {2}. {3} of {4}"
+msgstr "{0}. linha {1}, coluna {2}. {3} de {4}"
+
+#: internal/app/tutorial.go:25
+msgid ""
+"{command_name}, Shortcuts {command_shortcuts}, Description "
+"{command_description}"
+msgstr ""
+"{command_name}, Atalhos {command_shortcuts}, Descrição {command_description}"
+
+#: internal/app/tutorial.go internal/app/tutorial.go:66
+msgid "{} times "
+msgstr "{} vezes "
diff --git a/internal/i18n/locale/ru/LC_MESSAGES/fenrir.po b/internal/i18n/locale/ru/LC_MESSAGES/fenrir.po
new file mode 100644
index 00000000..70c07e1e
--- /dev/null
+++ b/internal/i18n/locale/ru/LC_MESSAGES/fenrir.po
@@ -0,0 +1,2160 @@
+# Fenrir reader translations (GPL-3.0-or-later).
+# Existing translations retained from Python Fenrir.
+# AI-assisted additions require language-contributor review.
+msgid ""
+msgstr ""
+"Project-Id-Version: fenrir\n"
+"PO-Revision-Date: 2020-04-19 19:26+07\n"
+"Last-Translator: denis rybin \n"
+"Language-Team: russian\n"
+"Language: ru\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Generated-By: pygettext.py 1.5\n"
+
+#: internal/review/cursor_column.go:13
+msgid " column number"
+msgstr " Š½Š¾Š¼ŠµŃ ŃŃŠ¾Š»Š±Ńа"
+
+#: internal/review/cursor_line.go:13
+msgid " line number"
+msgstr " Š½Š¾Š¼ŠµŃ ŃŃŃŠ¾ŠŗŠø"
+
+#: internal/review/cursor_scope.go:10
+msgid " review cursor "
+msgstr " ŠŗŃŃŃŠ¾Ń Š¾Š±Š·Š¾ŃŠ° "
+
+#: internal/review/cursor_scope.go:12
+msgid " text cursor "
+msgstr " ŃŠµŠŗŃŃŠ¾Š²Ńй ŠŗŃŃŃŠ¾Ń "
+
+#: internal/sound/drivers.go:39
+msgid "ALSA"
+msgstr "ALSA"
+
+#: internal/app/tutorial.go
+msgid "Active actions"
+msgstr "ŠŠŗŃŠøŠ²Š½ŃŠµ ГейŃŃŠ²ŠøŃ"
+
+#: internal/app/spellcheck_menu.go:51
+msgid "Add"
+msgstr "ŠŠ¾Š±Š°Š²ŠøŃŃ"
+
+#: internal/app/emoji_menu.go:34 internal/app/spellcheck_menu.go:107
+msgid "Added {0} to clipboard"
+msgstr "{0} Гобавлено в бŃŃŠµŃ обмена"
+
+#: internal/emoji/catalog.go:76
+msgid "Alien monster"
+msgstr "ŠŠ½Š¾ŠæŠ»Š°Š½ŠµŃное ŃŃŠ“Š¾Š²ŠøŃŠµ"
+
+#: internal/emoji/catalog.go
+msgid "All"
+msgstr "ŠŃе"
+
+#: internal/reader/regions.go:203
+msgid "All 10 live regions are set"
+msgstr "ŠŃе 10 Š“ŠøŠ½Š°Š¼ŠøŃŠµŃŠŗŠøŃ
облаŃŃŠµŠ¹ заГанŃ"
+
+#: internal/emoji/catalog.go:23
+msgid "Angry"
+msgstr "ŠŠ»Š¾ŃŃŃ"
+
+#: internal/emoji/catalog.go:64
+msgid "Apple"
+msgstr "Яблоко"
+
+#: internal/emoji/catalog.go:117
+msgid "Argentina"
+msgstr "ŠŃŠ³ŠµŠ½ŃŠøŠ½Š°"
+
+#: internal/config/config.go:155
+msgid "Attribute format"
+msgstr "Š¤Š¾ŃŠ¼Š°Ń аŃŃŠøŠ±ŃŃŠ¾Š²"
+
+#: internal/config/config.go:154
+msgid "Attributes"
+msgstr "ŠŃŃŠøŠ±ŃŃŃ"
+
+#: internal/emoji/catalog.go:118
+msgid "Australia"
+msgstr "ŠŠ²ŃŃŃŠ°Š»ŠøŃ"
+
+#: internal/config/config.go:145
+msgid "Automatic reading"
+msgstr "ŠŠ²ŃŠ¾Š¼Š°ŃŠøŃŠµŃŠŗŠ¾Šµ ŃŃŠµŠ½ŠøŠµ"
+
+#: internal/config/config.go:156
+msgid "Automatic spell checking"
+msgstr "ŠŠ²ŃŠ¾Š¼Š°ŃŠøŃŠµŃŠŗŠ°Ń ŠæŃŠ¾Š²ŠµŃка ŠæŃавопиŃаниŃ"
+
+#: internal/emoji/catalog.go:65
+msgid "Avocado"
+msgstr "ŠŠ²Š¾ŠŗŠ°Š“о"
+
+#: internal/emoji/catalog.go:77
+msgid "Bat"
+msgstr "ŠŠµŃŃŃŠ°Ń мŃŃŃ"
+
+#: internal/config/config.go:129
+msgid "Baud rate"
+msgstr "Š”ŠŗŠ¾ŃŠ¾ŃŃŃ ŠæŠµŃŠµŠ“Š°ŃŠø"
+
+#: internal/emoji/catalog.go:24
+msgid "Beaming"
+msgstr "ДиŃŃŃŠ°Ń ŃŠ»Ńбка"
+
+#: internal/config/config.go:151
+msgid "Beep for capital letters"
+msgstr "ŠŠ²Ńковой ŃŠøŠ³Š½Š°Š» Š“Š»Ń Š·Š°Š³Š»Š°Š²Š½ŃŃ
Š±ŃŠŗŠ²"
+
+#: internal/emoji/catalog.go:66
+msgid "Beer"
+msgstr "ŠŠøŠ²Š¾"
+
+#: internal/emoji/catalog.go:119
+msgid "Belgium"
+msgstr "ŠŠµŠ»ŃгиŃ"
+
+#: internal/emoji/catalog.go:67
+msgid "Birthday cake"
+msgstr "ŠŃŠ°Š·Š“Š½ŠøŃŠ½Ńй ŃŠ¾ŃŃ"
+
+#: internal/emoji/catalog.go:78
+msgid "Black cat"
+msgstr "ЧŃŃŠ½Š°Ń ŠŗŠ¾ŃŠŗŠ°"
+
+#: internal/emoji/catalog.go:25
+msgid "Blowing kiss"
+msgstr "ŠŠ¾Š·Š“ŃŃŠ½Ńй ŠæŠ¾ŃŠµŠ»Ńй"
+
+#: internal/emoji/catalog.go:100
+msgid "Bone"
+msgstr "ŠŠ¾ŃŃŃ"
+
+#: internal/review/bottom.go:29
+msgid "Bottom"
+msgstr "ŠŠ¾Š½ŠµŃ"
+
+#: internal/emoji/catalog.go:120
+msgid "Brazil"
+msgstr "ŠŃазилиŃ"
+
+#: internal/emoji/catalog.go:79
+msgid "Bunny"
+msgstr "ŠŃолик"
+
+#: internal/emoji/catalog.go:51
+msgid "Butterfly"
+msgstr "ŠŠ°Š±Š¾Ńка"
+
+#: internal/emoji/catalog.go:121
+msgid "Canada"
+msgstr "ŠŠ°Š½Š°Š“а"
+
+#: internal/preferences/settings.go:79
+msgid "Cancel"
+msgstr "ŠŃмена"
+
+#: internal/emoji/catalog.go:52
+msgid "Cat"
+msgstr "ŠŠ¾Ńка"
+
+#: internal/emoji/catalog.go:101
+msgid "Chains"
+msgstr "Цепи"
+
+#: internal/config/config.go:140
+msgid "Character echo"
+msgstr "ŠŃ
о ŃŠøŠ¼Š²Š¾Š»Š¾Š²"
+
+#: internal/emoji/catalog.go:102
+msgid "Checkmark"
+msgstr "ŠŠ°Š»Š¾Ńка"
+
+#: internal/emoji/catalog.go:53
+msgid "Cherry blossom"
+msgstr "Š¦Š²ŠµŃŠ¾Šŗ ŃŠ°ŠŗŃŃŃ"
+
+#: internal/emoji/catalog.go:122
+msgid "China"
+msgstr "ŠŠøŃай"
+
+#: internal/emoji/catalog.go:80
+msgid "Christmas tree"
+msgstr "ŠŠ¾Š²Š¾Š³Š¾Š“нŃŃ ŃŠ»ŠŗŠ°"
+
+#: internal/command/descriptions.go:76
+msgid "Clear all live regions"
+msgstr "ŠŃŠøŃŃŠøŃŃ Š²ŃŠµ Š“ŠøŠ½Š°Š¼ŠøŃŠµŃкие облаŃŃŠø"
+
+#: internal/command/descriptions.go:77
+msgid "Clear all reading regions"
+msgstr "ŠŃŠøŃŃŠøŃŃ Š²ŃŠµ облаŃŃŠø ŃŃŠµŠ½ŠøŃ"
+
+#: internal/command/descriptions.go:79
+msgid "Clear live region 1"
+msgstr "ŠŃŠøŃŃŠøŃŃ Š“ŠøŠ½Š°Š¼ŠøŃŠµŃŠŗŃŃ Š¾Š±Š»Š°ŃŃŃ 1"
+
+#: internal/command/descriptions.go:97
+msgid "Clear live region 10"
+msgstr "ŠŃŠøŃŃŠøŃŃ Š“ŠøŠ½Š°Š¼ŠøŃŠµŃŠŗŃŃ Š¾Š±Š»Š°ŃŃŃ 10"
+
+#: internal/command/descriptions.go:81
+msgid "Clear live region 2"
+msgstr "ŠŃŠøŃŃŠøŃŃ Š“ŠøŠ½Š°Š¼ŠøŃŠµŃŠŗŃŃ Š¾Š±Š»Š°ŃŃŃ 2"
+
+#: internal/command/descriptions.go:83
+msgid "Clear live region 3"
+msgstr "ŠŃŠøŃŃŠøŃŃ Š“ŠøŠ½Š°Š¼ŠøŃŠµŃŠŗŃŃ Š¾Š±Š»Š°ŃŃŃ 3"
+
+#: internal/command/descriptions.go:85
+msgid "Clear live region 4"
+msgstr "ŠŃŠøŃŃŠøŃŃ Š“ŠøŠ½Š°Š¼ŠøŃŠµŃŠŗŃŃ Š¾Š±Š»Š°ŃŃŃ 4"
+
+#: internal/command/descriptions.go:87
+msgid "Clear live region 5"
+msgstr "ŠŃŠøŃŃŠøŃŃ Š“ŠøŠ½Š°Š¼ŠøŃŠµŃŠŗŃŃ Š¾Š±Š»Š°ŃŃŃ 5"
+
+#: internal/command/descriptions.go:89
+msgid "Clear live region 6"
+msgstr "ŠŃŠøŃŃŠøŃŃ Š“ŠøŠ½Š°Š¼ŠøŃŠµŃŠŗŃŃ Š¾Š±Š»Š°ŃŃŃ 6"
+
+#: internal/command/descriptions.go:91
+msgid "Clear live region 7"
+msgstr "ŠŃŠøŃŃŠøŃŃ Š“ŠøŠ½Š°Š¼ŠøŃŠµŃŠŗŃŃ Š¾Š±Š»Š°ŃŃŃ 7"
+
+#: internal/command/descriptions.go:93
+msgid "Clear live region 8"
+msgstr "ŠŃŠøŃŃŠøŃŃ Š“ŠøŠ½Š°Š¼ŠøŃŠµŃŠŗŃŃ Š¾Š±Š»Š°ŃŃŃ 8"
+
+#: internal/command/descriptions.go:95
+msgid "Clear live region 9"
+msgstr "ŠŃŠøŃŃŠøŃŃ Š“ŠøŠ½Š°Š¼ŠøŃŠµŃŠŗŃŃ Š¾Š±Š»Š°ŃŃŃ 9"
+
+#: internal/config/config.go:57
+msgid "Clipboard"
+msgstr "ŠŃŃŠµŃ обмена"
+
+#: internal/app/application.go:136 internal/app/application.go:139
+#: internal/app/clipboard.go:218 internal/app/clipboard.go:374
+msgid "Clipboard import failed"
+msgstr "ŠŠµ ŃŠ“алоŃŃ ŠøŠ¼ŠæŠ¾ŃŃŠøŃоваŃŃ Š±ŃŃŠµŃ обмена"
+
+#: internal/config/config.go:106
+msgid "Clipboard sync"
+msgstr "ДинŃ
ŃŠ¾Š½ŠøŠ·Š°ŃŠøŃ Š±ŃŃŠµŃа обмена"
+
+#: internal/emoji/catalog.go:68
+msgid "Coffee"
+msgstr "ŠŠ¾Ńе"
+
+#: internal/emoji/catalog.go:81
+msgid "Coffin"
+msgstr "ŠŃоб"
+
+#: internal/command/descriptions.go:45
+msgid "Column number for cursor"
+msgstr "ŠŠ¾Š¼ŠµŃ ŃŃŠ¾Š»Š±Ńа Š“Š»Ń ŠŗŃŃŃŠ¾Ńа."
+
+#: internal/emoji/catalog.go:26
+msgid "Cool"
+msgstr "ŠŃŃŃŠ¾"
+
+#: internal/app/spellcheck_menu.go:175
+msgid "Could not check spelling: {0}"
+msgstr "ŠŠµ ŃŠ“алоŃŃ ŠæŃŠ¾Š²ŠµŃŠøŃŃ ŠæŃŠ°Š²Š¾ŠæŠøŃание: {0}"
+
+#: internal/app/emoji_menu.go:17 internal/app/emoji_menu.go:21
+msgid "Could not open Emoji Menu: {0}"
+msgstr "ŠŠµ ŃŠ“алоŃŃ Š¾ŃŠŗŃŃŃŃ Š¼ŠµŠ½Ń ŃŠ¼Š¾Š“Š·Šø: {0}"
+
+#: internal/app/fenrir_menu.go:32 internal/app/fenrir_menu.go:52
+#: internal/app/fenrir_menu.go:67 internal/app/runtime_settings.go:133
+msgid "Could not open Fenrir Menu: {0}"
+msgstr "ŠŠµ ŃŠ“алоŃŃ Š¾ŃŠŗŃŃŃŃ Š¼ŠµŠ½Ń Fenrir: {0}"
+
+#: internal/app/spellcheck_menu.go:39 internal/app/spellcheck_menu.go:58
+msgid "Could not open Spellcheck Menu: {0}"
+msgstr "ŠŠµ ŃŠ“алоŃŃ Š¾ŃŠŗŃŃŃŃ Š¼ŠµŠ½Ń ŠæŃŠ¾Š²ŠµŃŠŗŠø ŠæŃŠ°Š²Š¾ŠæŠøŃаниŃ: {0}"
+
+#: internal/app/spellcheck_menu.go:155
+msgid "Could not save exceptions: {0}"
+msgstr "ŠŠµ ŃŠ“алоŃŃ ŃŠ¾Ń
ŃŠ°Š½ŠøŃŃ ŠøŃŠŗŠ»ŃŃŠµŠ½ŠøŃ: {0}"
+
+#: internal/app/fenrir_menu.go:174 internal/app/fenrir_menu.go:180
+#: internal/app/runtime_settings.go:164 internal/app/runtime_settings.go:191
+msgid "Could not save settings: {0}"
+msgstr "ŠŠµ ŃŠ“алоŃŃ ŃŠ¾Ń
ŃŠ°Š½ŠøŃŃ Š½Š°ŃŃŃŠ¾Š¹ŠŗŠø: {0}"
+
+#: internal/emoji/catalog.go:27
+msgid "Crying"
+msgstr "ŠŠ»Š°Ń"
+
+#: internal/config/config.go:146
+msgid "Cursor echo"
+msgstr "ŠŠ·Š²ŃŃŠøŠ²Š°Š½ŠøŠµ ŠŗŃŃŃŠ¾Ńа"
+
+#: internal/emoji/catalog.go:103
+msgid "Dagger"
+msgstr "ŠŠøŠ½Š¶Š°Š»"
+
+#: internal/config/config.go:102
+msgid "Date format"
+msgstr "Š¤Š¾ŃŠ¼Š°Ń ГаŃŃ"
+
+#: internal/emoji/catalog.go:123
+msgid "Denmark"
+msgstr "ŠŠ°Š½ŠøŃ"
+
+#: internal/config/config.go:138
+msgid "Devices"
+msgstr "Š£ŃŃŃŠ¾Š¹ŃŃŠ²Š°"
+
+#: internal/emoji/catalog.go:28
+msgid "Devil"
+msgstr "ŠŃŃŠ²Š¾Š»"
+
+#: internal/app/spellcheck_menu.go:191
+msgid "Dictionary {0}. {1}. {2}"
+msgstr "ДловаŃŃ {0}. {1}. {2}"
+
+#: internal/emoji/catalog.go:29
+msgid "Dizzy"
+msgstr "ŠŠ¾Š»Š¾Š²Š¾ŠŗŃŃŠ¶ŠµŠ½ŠøŠµ"
+
+#: internal/emoji/catalog.go:54
+msgid "Dog"
+msgstr "Добака"
+
+#: internal/emoji/catalog.go:69
+msgid "Donut"
+msgstr "ŠŠ¾Š½ŃŠøŠŗ"
+
+#: internal/config/config.go:120 internal/config/config.go:127
+msgid "Driver"
+msgstr "ŠŃайвеŃ"
+
+#: internal/config/config.go:142
+msgid "Echo on CapsLock"
+msgstr "ŠŃ
о ŠæŃŠø CapsLock"
+
+#: internal/emoji/catalog.go:82
+msgid "Egg"
+msgstr "ŠÆŠ¹ŃŠ¾"
+
+#: internal/emoji/catalog.go:162
+msgid "Emoji"
+msgstr "ŠŠ¼Š¾Š“Š·Šø"
+
+#: internal/command/descriptions.go:53 internal/emoji/catalog.go:14
+msgid "Emoji Menu"
+msgstr "ŠŠµŠ½Ń ŃŠ¼Š¾Š“Š·Šø"
+
+#: internal/config/config.go:160
+msgid "Emoticons"
+msgstr "ТекŃŃŠ¾Š²Ńе ŃŠ¼Š°Š¹Š»ŠøŠŗŠø"
+
+#: internal/config/config.go:107
+msgid "Enable Persistent Clipboard"
+msgstr "ŠŠŗŠ»ŃŃŠøŃŃ ŠæŠ¾ŃŃŠ¾ŃŠ½Š½ŃŠ¹ бŃŃŠµŃ обмена"
+
+#: internal/app/tutorial.go:21
+msgid ""
+"Entering tutorial mode. In this mode commands are described but not "
+"executed. Use up and down to browse actions and left and right to switch "
+"between Active actions, Unbound actions, and Plugins. Press Space to repeat "
+"the current item. To exit tutorial mode press Fenrir+F1 or Escape. Active "
+"actions."
+msgstr ""
+"ŠŃ
оГ в ŃŠµŠ¶ŠøŠ¼ обŃŃŠµŠ½ŠøŃ. Š ŃŃŠ¾Š¼ ŃŠµŠ¶ŠøŠ¼Šµ ŠŗŠ¾Š¼Š°Š½Š“Ń Š¾ŠæŠøŃŃŠ²Š°ŃŃŃŃ, но не Š²ŃполнŃŃŃŃŃ. "
+"Š”ŃŃŠµŠ»ŠŗŠ°Š¼Šø ввеŃŃ
Šø вниз ŠæŃоŃмаŃŃŠøŠ²Š°Š¹Ńе ГейŃŃŠ²ŠøŃ, а ŃŃŃŠµŠ»ŠŗŠ°Š¼Šø влево Šø Š²ŠæŃŠ°Š²Š¾ "
+"ŠæŠµŃŠµŠŗŠ»ŃŃŠ°Š¹ŃеŃŃ Š¼ŠµŠ¶Š“Ń Š°ŠŗŃŠøŠ²Š½Ńми ГейŃŃŠ²ŠøŃми, ГейŃŃŠ²ŠøŃми без ŃŠ¾ŃŠµŃŠ°Š½ŠøŠ¹ ŠŗŠ»Š°Š²ŠøŃ Šø "
+"плагинами. ŠŠ°Š¶Š¼ŠøŃе ŠŃобел, ŃŃŠ¾Š±Ń ŠæŠ¾Š²ŃŠ¾ŃŠøŃŃ ŃŠµŠŗŃŃŠøŠ¹ ŃŠ»ŠµŠ¼ŠµŠ½Ń. ŠŠ»Ń вŃŃ
оГа "
+"Š½Š°Š¶Š¼ŠøŃŠµ Fenrir+F1 или Escape. ŠŠŗŃŠøŠ²Š½ŃŠµ ГейŃŃŠ²ŠøŃ."
+
+#: internal/app/spellcheck_menu.go:49 internal/app/spellcheck_menu.go:52
+msgid "Exceptions"
+msgstr "ŠŃŠŗŠ»ŃŃŠµŠ½ŠøŃ"
+
+#: internal/review/follow_cursor.go:29
+msgid "Exiting Review Mode"
+msgstr "ŠŠ°ŠŗŃŃŃŠøŠµ ŃŠµŠ¶ŠøŠ¼Š° ŠæŃŠ¾ŃмоŃŃŠ°."
+
+#: internal/app/tutorial.go:22
+msgid "Exiting tutorial mode. To enter tutorial mode again press Fenrir+F1"
+msgstr "ŠŃŃ
оГ ŠøŠ· ŃŠµŠ¶ŠøŠ¼Š° обŃŃŠµŠ½ŠøŃ. ŠŠ»Ń ŠæŠ¾Š²ŃŠ¾Ńного вŃ
оГа Š½Š°Š¶Š¼ŠøŃе Fenrir+F1"
+
+#: internal/emoji/catalog.go:30
+msgid "Exploding head"
+msgstr "ŠŠ·ŃŃŠ²Š°ŃŃŠ°ŃŃŃ Š³Š¾Š»Š¾Š²Š°"
+
+#: internal/command/descriptions.go:113
+msgid "Export current fenrir clipboard to X or GUI clipboard"
+msgstr "ŠŠŗŃпоŃŃ ŃŠµŠŗŃŃŠµŠ³Š¾ бŃŃŠµŃа обмена fenrir в бŃŃŠµŃ обмена X или GUI"
+
+#: internal/emoji/catalog.go:31
+msgid "Face with symbols"
+msgstr "ŠŠøŃо Ń ŃŠøŠ¼Š²Š¾Š»Š°Š¼Šø"
+
+#: internal/app/quick_settings.go:295 internal/app/runtime_settings.go:151
+msgid "Failed to save settings."
+msgstr "ŠŠµ ŃŠ“алоŃŃ ŃŠ¾Ń
ŃŠ°Š½ŠøŃŃ Š½Š°ŃŃŃŠ¾Š¹ŠŗŠø."
+
+#: internal/command/descriptions.go:55 internal/preferences/settings.go:38
+msgid "Fenrir Menu"
+msgstr "ŠŠµŠ½Ń Fenrir"
+
+#: internal/app/commands.go:83
+msgid "Fenrir screen reader version {0}-{1}"
+msgstr "ŠŃŠ¾Š³ŃŠ°Š¼Š¼Š° ŃŠŗŃанного ГоŃŃŃŠæŠ° Fenrir, веŃŃŠøŃ {0}-{1}"
+
+#: internal/app/clipboard.go:377
+msgid "File does not exist"
+msgstr "Файл не ŃŃŃŠµŃŃŠ²ŃеŃ."
+
+#: internal/emoji/catalog.go:124
+msgid "Finland"
+msgstr "Š¤ŠøŠ½Š»ŃŠ½Š“ŠøŃ"
+
+#: internal/emoji/catalog.go:104
+msgid "Fire"
+msgstr "ŠŠ³Š¾Š½Ń"
+
+#: internal/emoji/catalog.go:83
+msgid "Fireworks"
+msgstr "Š¤ŠµŠ¹ŠµŃŠ²ŠµŃŠŗ"
+
+#: internal/app/clipboard.go:323
+msgid "First clipboard "
+msgstr "ŠŠµŃŠ²ŃŠ¹ бŃŃŠµŃ обмена. "
+
+#: internal/emoji/catalog.go
+msgid "Flags"
+msgstr "Флаги"
+
+#: internal/emoji/catalog.go:105
+msgid "Folded hands"
+msgstr "Š”Š»Š¾Š¶ŠµŠ½Š½ŃŠµ лаГони"
+
+#: internal/emoji/catalog.go
+msgid "Food"
+msgstr "ŠŠ“а"
+
+#: internal/app/commands.go:126
+msgid "Forward next keypress"
+msgstr "ŠŠµŃŠµŃŠ»Š°ŃŃ ŃŠ»ŠµŠ“ŃŃŃŃŃ ŠŗŠ»Š°Š²ŠøŃŃ."
+
+#: internal/emoji/catalog.go:125
+msgid "France"
+msgstr "Š¤ŃŠ°Š½ŃŠøŃ"
+
+#: internal/emoji/catalog.go:126
+msgid "Germany"
+msgstr "ŠŠµŃманиŃ"
+
+#: internal/emoji/catalog.go:84
+msgid "Ghost"
+msgstr "ŠŃивиГение"
+
+#: internal/emoji/catalog.go:85
+msgid "Gift"
+msgstr "ŠŠ¾Š“Š°ŃŠ¾Šŗ"
+
+#: internal/emoji/catalog.go:127
+msgid "Greece"
+msgstr "ŠŃŠµŃŠøŃ"
+
+#: internal/emoji/catalog.go:32
+msgid "Grinning"
+msgstr "ŠØŠøŃŠ¾ŠŗŠ°Ń ŃŠ»Ńбка"
+
+#: internal/emoji/catalog.go:33
+msgid "Grinning sweat"
+msgstr "Š£Š»ŃŠ±ŠŗŠ° Ń ŠŗŠ°ŠæŠ»ŠµŠ¹ ŠæŠ¾ŃŠ°"
+
+#: internal/emoji/catalog.go:70
+msgid "Hamburger"
+msgstr "ŠŠ°Š¼Š±ŃŃŠ³ŠµŃ"
+
+#: internal/emoji/catalog.go:106
+msgid "Heart"
+msgstr "Š”ŠµŃŠ“ŃŠµ"
+
+#: internal/emoji/catalog.go:34
+msgid "Heart eyes"
+msgstr "ŠŠ»Š°Š·Š°-ŃŠµŃŠ“ŠµŃŠŗŠø"
+
+#: internal/emoji/catalog.go:35
+msgid "Hearts around"
+msgstr "ŠŠøŃо в ŃŠµŃŠ“ŠµŃŠŗŠ°Ń
"
+
+#: internal/emoji/catalog.go:107
+msgid "High voltage"
+msgstr "ŠŃŃŠ¾ŠŗŠ¾Šµ напŃŃŠ¶ŠµŠ½ŠøŠµ"
+
+#: internal/config/config.go:109
+msgid "History items"
+msgstr "ŠŠ»ŠµŠ¼ŠµŠ½ŃŃ ŠøŃŃŠ¾ŃŠøŠø"
+
+#: internal/emoji/catalog.go
+msgid "Holidays"
+msgstr "ŠŃазГники"
+
+#: internal/emoji/catalog.go:108
+msgid "Hundred points"
+msgstr "Š”ŃŠ¾ Š¾ŃŠŗŠ¾Š²"
+
+#: internal/emoji/catalog.go:36
+msgid "Imp"
+msgstr "ŠŠµŃŃŠ½Š¾Šŗ"
+
+#: internal/app/clipboard.go:412
+msgid "Import to Clipboard"
+msgstr "ŠŠ¼ŠæŠ¾ŃŃŠøŃоваŃŃ Š² бŃŃŠµŃ обмена"
+
+#: internal/config/config.go:153
+msgid "Indentation"
+msgstr "ŠŃŃŃŃŠæŃ"
+
+#: internal/emoji/catalog.go:128
+msgid "India"
+msgstr "ŠŠ½Š“ŠøŃ"
+
+#: internal/emoji/catalog.go:129
+msgid "Ireland"
+msgstr "ŠŃланГиŃ"
+
+#: internal/emoji/catalog.go:130
+msgid "Israel"
+msgstr "ŠŠ·ŃаилŃ"
+
+#: internal/emoji/catalog.go:131
+msgid "Italy"
+msgstr "ŠŃалиŃ"
+
+#: internal/emoji/catalog.go:86
+msgid "Jack o'lantern"
+msgstr "Š¢ŃŠŗŠ²Š° на Š„ŃŠ»Š»Š¾Ńин"
+
+#: internal/emoji/catalog.go:132
+msgid "Japan"
+msgstr "ЯпониŃ"
+
+#: internal/config/config.go:53
+msgid "Keyboard"
+msgstr "ŠŠ»Š°Š²ŠøŠ°ŃŃŃŠ°"
+
+#: internal/app/clipboard.go:326
+msgid "Last clipboard "
+msgstr "ŠŠ¾ŃлеГний бŃŃŠµŃ обмена. "
+
+#: internal/emoji/catalog.go:37
+msgid "Laughing"
+msgstr "ДмеŃ
"
+
+#: internal/config/config.go:139
+msgid "Layout"
+msgstr "Š Š°ŃŠŗŠ»Š°Š“ка клавиаŃŃŃŃ"
+
+#: internal/command/descriptions.go:44
+msgid "Line number for cursor"
+msgstr "ŠŠ¾Š¼ŠµŃ ŃŃŃŠ¾ŠŗŠø Š“Š»Ń ŠŗŃŃŃŠ¾Ńа."
+
+#: internal/command/descriptions.go:75
+msgid "List reading regions"
+msgstr "ŠŠµŃŠµŃŠøŃлиŃŃ Š¾Š±Š»Š°ŃŃŠø ŃŃŠµŠ½ŠøŃ"
+
+#: internal/reader/regions.go:185
+msgid "Live region {0} cleared"
+msgstr "ŠŠøŠ½Š°Š¼ŠøŃŠµŃŠŗŠ°Ń облаŃŃŃ {0} Š¾ŃŠøŃена"
+
+#: internal/reader/regions.go:181
+msgid "Live region {0} not set"
+msgstr "ŠŠøŠ½Š°Š¼ŠøŃŠµŃŠŗŠ°Ń облаŃŃŃ {0} не заГана"
+
+#: internal/reader/regions.go:189
+msgid "Live region {0} outside screen"
+msgstr "ŠŠøŠ½Š°Š¼ŠøŃŠµŃŠŗŠ°Ń облаŃŃŃ {0} за ŠæŃеГелами ŃŠŗŃана"
+
+#: internal/reader/regions.go:209
+msgid "Live region {0} set"
+msgstr "ŠŠøŠ½Š°Š¼ŠøŃŠµŃŠŗŠ°Ń облаŃŃŃ {0} заГана"
+
+#: internal/reader/regions.go:245
+msgid "Live region {0}: {1}"
+msgstr "ŠŠøŠ½Š°Š¼ŠøŃŠµŃŠŗŠ°Ń облаŃŃŃ {0}: {1}"
+
+#: internal/reader/regions.go:232
+msgid "Live regions cleared"
+msgstr "ŠŠøŠ½Š°Š¼ŠøŃŠµŃŠŗŠøŠµ облаŃŃŠø Š¾ŃŠøŃенŃ"
+
+#: internal/emoji/catalog.go:87
+msgid "Mage"
+msgstr "ŠŠ°Š³"
+
+#: internal/emoji/catalog.go:133
+msgid "Mexico"
+msgstr "ŠŠµŠŗŃика"
+
+#: internal/config/config.go:159
+msgid "Misspelling sound"
+msgstr "ŠŠ²ŃŠŗ Š¾ŃŠøŠ±ŠŗŠø ŠæŃŠ°Š²Š¾ŠæŠøŃаниŃ"
+
+#: internal/emoji/catalog.go:55
+msgid "Moon"
+msgstr "ŠŃна"
+
+#: internal/command/descriptions.go:35
+msgid "Move Review to the end of current line and display the content"
+msgstr ""
+"Š£ŃŃŠ°Š½Š¾Š²ŠøŃŃ ŠŗŃŃŃŠ¾Ń ŠæŃŠ¾ŃмоŃŃŠ° в ŠŗŠ¾Š½ŠµŃ ŃŠµŠŗŃŃŠµŠ¹ ŃŃŃŠ¾ŠŗŠø Šø Š¾ŃŠ¾Š±ŃазиŃŃ ŃŠ¾Š“ŠµŃŠ¶ŠøŠ¼Š¾Šµ."
+
+#: internal/command/descriptions.go:36
+msgid "Move Review to the first character on the line"
+msgstr "ŠŠµŃемеŃŃŠøŃŃ ŠŗŃŃŃŠ¾Ń ŠæŃŠ¾ŃмоŃŃŠ° Šŗ ŠæŠµŃŠ²Š¾Š¼Ń ŃŠøŠ¼Š²Š¾Š»Ń ŃŃŃŠ¾ŠŗŠø"
+
+#: internal/command/descriptions.go:48
+msgid "Move Review to the first character on the screen (left top)"
+msgstr ""
+"ŠŠµŃемеŃŃŠøŃŃ ŠŗŃŃŃŠ¾Ń Š¾Š±Š·Š¾ŃŠ° Šŗ ŠæŠµŃŠ²Š¾Š¼Ń ŃŠøŠ¼Š²Š¾Š»Ń ŃŠŗŃана, в Š»ŠµŠ²Ńй веŃŃ
ний ŃŠ³Š¾Š»"
+
+#: internal/command/descriptions.go:37
+msgid "Move Review to the last character on the line"
+msgstr "ŠŠµŃемеŃŃŠøŃŃ ŠŗŃŃŃŠ¾Ń Šŗ ŠæŠ¾ŃŠ»ŠµŠ“Š½ŠµŠ¼Ń ŃŠøŠ¼Š²Š¾Š»Ń на ŃŃŃŠ¾ŠŗŠµ"
+
+#: internal/command/descriptions.go:49
+msgid "Move Review to the last character on the screen (right bottom)"
+msgstr ""
+"ŠŠµŃемеŃŃŠøŃŃ ŠŗŃŃŃŠ¾Ń Š¾Š±Š·Š¾ŃŠ° Šŗ ŠæŠ¾ŃŠ»ŠµŠ“Š½ŠµŠ¼Ń ŃŠøŠ¼Š²Š¾Š»Ń ŃŠŗŃана, в ŠæŃавŃй нижний ŃŠ³Š¾Š»"
+
+#: internal/command/descriptions.go:33
+msgid "Move review to the bottom of the screen"
+msgstr "ŠŠµŃемеŃŃŠøŃŃ ŠæŃŠ¾ŃмоŃŃ Š² ŠŗŠ¾Š½ŠµŃ ŃŠŗŃана."
+
+#: internal/command/descriptions.go:31
+msgid "Move review to the character below the current position"
+msgstr "ŠŠµŃемеŃŃŠøŃŃ Š¾Š±Š·Š¾Ń Š½Š° ŃŠøŠ¼Š²Š¾Š» ниже ŃŠµŠŗŃŃŠµŠ¹ ŠæŠ¾Š·ŠøŃŠøŠø."
+
+#: internal/command/descriptions.go:30
+msgid "Move review to the character in the line above the current position"
+msgstr "ŠŠµŃемеŃŃŠøŃŃ ŠŗŃŃŃŠ¾Ń Š¾Š±Š·Š¾ŃŠ° на ŃŠøŠ¼Š²Š¾Š» в ŃŃŃŠ¾ŠŗŠµ вŃŃŠµ ŃŠµŠŗŃŃŠµŠ¹ ŠæŠ¾Š·ŠøŃŠøŠø."
+
+#: internal/command/descriptions.go:29
+msgid "Moves review to the next character"
+msgstr "ŠŠµŃŠµŠ¼ŠµŃŠ°ŠµŃ ŠŗŃŃŃŠ¾Ń Š¾Š±Š·Š¾ŃŠ° Šŗ ŃŠ»ŠµŠ“ŃŃŃŠµŠ¼Ń ŃŠøŠ¼Š²Š¾Š»Ń"
+
+#: internal/emoji/catalog.go
+msgid "Nature"
+msgstr "ŠŃŠøŃŠ¾Š“а"
+
+#: internal/emoji/catalog.go:38
+msgid "Nauseated"
+msgstr "Š¢Š¾ŃŠ½Š¾Ńа"
+
+#: internal/emoji/catalog.go:134
+msgid "Netherlands"
+msgstr "ŠŠøŠ“ŠµŃŠ»Š°Š½Š“Ń"
+
+#: internal/interaction/grid.go:43 internal/interaction/grid.go:79
+msgid "No matches"
+msgstr "ДовпаГений неŃ"
+
+#: internal/reader/regions.go:251
+msgid "No reading regions set"
+msgstr "ŠŠ±Š»Š°ŃŃŠø ŃŃŠµŠ½ŠøŃ не заГанŃ"
+
+#: internal/app/clipboard.go:416
+msgid "No text found in clipboard or no accessible display"
+msgstr "РбŃŃŠµŃе обмена Š½ŠµŃ ŃŠµŠŗŃŃŠ° или Š½ŠµŃ ГоŃŃŃŠæŠ½Š¾Š³Š¾ Š“ŠøŃŠæŠ»ŠµŃ"
+
+#: internal/app/spellcheck_menu.go
+msgid "No word"
+msgstr "ŠŠµŃ ŃŠ»Š¾Š²Š°"
+
+#: internal/emoji/catalog.go:135
+msgid "Norway"
+msgstr "ŠŠ¾ŃвегиŃ"
+
+#: internal/review/follow_cursor.go:26
+msgid "Not in Review Mode"
+msgstr "ŠŠµŃ в ŃŠµŠ¶ŠøŠ¼Šµ ŠæŃŠ¾ŃмоŃŃŠ°."
+
+#: internal/reader/regions.go:222
+msgid "Not in window Mode"
+msgstr "ŠŠµ в ŃŠµŠ¶ŠøŠ¼Šµ окна."
+
+#: internal/preferences/settings.go:79
+msgid "OK"
+msgstr "ŠŠ"
+
+#: internal/app/clipboard.go:269 internal/reader/regions.go:197
+msgid "One or two marks are needed"
+msgstr "ŠŠµŠ¾Š±Ń
Š¾Š“ŠøŠ¼Ń Š¾Š“Š½Š° или Гве Š¼ŠµŠ½ŃŠŗŠø."
+
+#: internal/emoji/catalog.go:109
+msgid "Peace"
+msgstr "ŠŠøŃ"
+
+#: internal/emoji/catalog.go
+msgid "People"
+msgstr "ŠŃГи"
+
+#: internal/command/descriptions.go:25
+msgid "Phonetically spells the current word"
+msgstr "ŠŃеГŃŃŠ°Š²Š»ŃŠµŃ ŃŠ¾Š½ŠµŃŠøŃŠµŃŠŗŠø ŃŠµŠŗŃŃŠµŠµ ŃŠ»Š¾Š²Š¾"
+
+#: internal/config/config.go:133
+msgid "Pitch"
+msgstr "ŠŃŃŠ¾Ńа Š³Š¾Š»Š¾Ńа"
+
+#: internal/emoji/catalog.go:71
+msgid "Pizza"
+msgstr "ŠŠøŃŃŠ°"
+
+#: internal/emoji/catalog.go:39
+msgid "Pleading"
+msgstr "УмолŃŃŃŠµŠµ Š»ŠøŃŠ¾"
+
+#: internal/app/tutorial.go
+msgid "Plugins"
+msgstr "ŠŠ»Š°Š³ŠøŠ½Ń"
+
+#: internal/emoji/catalog.go:136
+msgid "Poland"
+msgstr "ŠŠ¾Š»ŃŃŠ°"
+
+#: internal/emoji/catalog.go:40
+msgid "Poop"
+msgstr "ŠŠ°ŠŗŠ°Ńка"
+
+#: internal/emoji/catalog.go:137
+msgid "Portugal"
+msgstr "ŠŠ¾ŃŃŃŠ³Š°Š»ŠøŃ"
+
+#: internal/command/descriptions.go:52
+msgid "Present the version of Fenrir currently in use."
+msgstr "ŠŠ°Š·Š²Š°ŃŃ ŠøŃŠæŠ¾Š»ŃŠ·ŃемŃŃ Š²ŠµŃŃŠøŃ Fenrir."
+
+#: internal/config/config.go:52
+msgid "Presentation"
+msgstr "ŠŃеГŃŃŠ°Š²Š»ŠµŠ½ŠøŠµ"
+
+#: internal/command/descriptions.go:100
+msgid ""
+"Presents the currently selected text that will be copied to the clipboard"
+msgstr ""
+"Š”Š¾Š¾Š±ŃŠ°ŠµŃ ŃŠµŠŗŃŃŠøŠ¹ Š²ŃŠ±ŃŠ°Š½Š½ŃŠ¹ ŃŠµŠŗŃŃ, ŠŗŠ¾ŃŠ¾ŃŃŠ¹ Š±ŃŠ“ŠµŃ ŃŠŗŠ¾ŠæŠøŃован в бŃŃŠµŃ обмена."
+
+#: internal/app/commands.go:112
+msgid "Progress monitoring disabled"
+msgstr "ŠŃŃŠ»ŠµŠ¶ŠøŠ²Š°Š½ŠøŠµ Ń
оГа Š²ŃŠæŠ¾Š»Š½ŠµŠ½ŠøŃ Š¾ŃŠŗŠ»ŃŃŠµŠ½Š¾"
+
+#: internal/app/commands.go:110
+msgid "Progress monitoring enabled"
+msgstr "ŠŃŃŠ»ŠµŠ¶ŠøŠ²Š°Š½ŠøŠµ Ń
оГа Š²ŃŠæŠ¾Š»Š½ŠµŠ½ŠøŃ Š²ŠŗŠ»ŃŃŠµŠ½Š¾"
+
+#: internal/sound/drivers.go:39
+msgid "PulseAudio"
+msgstr "PulseAudio"
+
+#: internal/config/config.go:149
+msgid "Punctuation level"
+msgstr "Š£ŃŠ¾Š²ŠµŠ½Ń ŠæŃŠ½ŠŗŃŃŠ°ŃŠøŠø"
+
+#: internal/config/config.go:148
+msgid "Punctuation profile"
+msgstr "ŠŃŠ¾ŃŠøŠ»Ń ŠæŃŠ½ŠŗŃŃŠ°ŃŠøŠø"
+
+#: internal/app/quick_settings.go:115
+msgid "Quick menu not available"
+msgstr "ŠŃŃŃŃŠ¾Šµ Š¼ŠµŠ½Ń Š½ŠµŠ“Š¾ŃŃŃŠæŠ½Š¾."
+
+#: internal/emoji/catalog.go:56
+msgid "Rainbow"
+msgstr "Š Š°Š“ŃŠ³Š°"
+
+#: internal/config/config.go:150
+msgid "Raise pitch for capital letters"
+msgstr "ŠŠ¾Š²ŃŃŠ°ŃŃ ŃŠ¾Š½ Š“Š»Ń Š·Š°Š³Š»Š°Š²Š½ŃŃ
Š±ŃŠŗŠ²"
+
+#: internal/config/config.go:126
+msgid "Rate"
+msgstr "Š”ŠŗŠ¾ŃŠ¾ŃŃŃ"
+
+#: internal/punctuation/emoticons.go:33
+msgid "Raves"
+msgstr "РейвŃ"
+
+#: internal/command/descriptions.go:78
+msgid "Read live region 1"
+msgstr "ŠŃŠ¾ŃŠøŃаŃŃ Š“ŠøŠ½Š°Š¼ŠøŃŠµŃŠŗŃŃ Š¾Š±Š»Š°ŃŃŃ 1"
+
+#: internal/command/descriptions.go:96
+msgid "Read live region 10"
+msgstr "ŠŃŠ¾ŃŠøŃаŃŃ Š“ŠøŠ½Š°Š¼ŠøŃŠµŃŠŗŃŃ Š¾Š±Š»Š°ŃŃŃ 10"
+
+#: internal/command/descriptions.go:80
+msgid "Read live region 2"
+msgstr "ŠŃŠ¾ŃŠøŃаŃŃ Š“ŠøŠ½Š°Š¼ŠøŃŠµŃŠŗŃŃ Š¾Š±Š»Š°ŃŃŃ 2"
+
+#: internal/command/descriptions.go:82
+msgid "Read live region 3"
+msgstr "ŠŃŠ¾ŃŠøŃаŃŃ Š“ŠøŠ½Š°Š¼ŠøŃŠµŃŠŗŃŃ Š¾Š±Š»Š°ŃŃŃ 3"
+
+#: internal/command/descriptions.go:84
+msgid "Read live region 4"
+msgstr "ŠŃŠ¾ŃŠøŃаŃŃ Š“ŠøŠ½Š°Š¼ŠøŃŠµŃŠŗŃŃ Š¾Š±Š»Š°ŃŃŃ 4"
+
+#: internal/command/descriptions.go:86
+msgid "Read live region 5"
+msgstr "ŠŃŠ¾ŃŠøŃаŃŃ Š“ŠøŠ½Š°Š¼ŠøŃŠµŃŠŗŃŃ Š¾Š±Š»Š°ŃŃŃ 5"
+
+#: internal/command/descriptions.go:88
+msgid "Read live region 6"
+msgstr "ŠŃŠ¾ŃŠøŃаŃŃ Š“ŠøŠ½Š°Š¼ŠøŃŠµŃŠŗŃŃ Š¾Š±Š»Š°ŃŃŃ 6"
+
+#: internal/command/descriptions.go:90
+msgid "Read live region 7"
+msgstr "ŠŃŠ¾ŃŠøŃаŃŃ Š“ŠøŠ½Š°Š¼ŠøŃŠµŃŠŗŃŃ Š¾Š±Š»Š°ŃŃŃ 7"
+
+#: internal/command/descriptions.go:92
+msgid "Read live region 8"
+msgstr "ŠŃŠ¾ŃŠøŃаŃŃ Š“ŠøŠ½Š°Š¼ŠøŃŠµŃŠŗŃŃ Š¾Š±Š»Š°ŃŃŃ 8"
+
+#: internal/command/descriptions.go:94
+msgid "Read live region 9"
+msgstr "ŠŃŠ¾ŃŠøŃаŃŃ Š“ŠøŠ½Š°Š¼ŠøŃŠµŃŠŗŃŃ Š¾Š±Š»Š°ŃŃŃ 9"
+
+#: internal/config/config.go:157
+msgid "Read misspelled word"
+msgstr "Š§ŠøŃŠ°ŃŃ ŃŠ»Š¾Š²Š¾ Ń Š¾ŃŠøŠ±ŠŗŠ¾Š¹"
+
+#: internal/config/config.go:54
+msgid "Reading"
+msgstr "Š§ŃŠµŠ½ŠøŠµ"
+
+#: internal/reader/regions.go:236
+msgid "Reading regions cleared"
+msgstr "ŠŠ±Š»Š°ŃŃŠø ŃŃŠµŠ½ŠøŃ Š¾ŃŠøŃенŃ"
+
+#: internal/reader/regions.go:241
+msgid "Reading window: {0}"
+msgstr "ŠŠŗŠ½Š¾ ŃŃŠµŠ½ŠøŃ: {0}"
+
+#: internal/command/descriptions.go:42
+msgid "Reads attributes of current cursor position"
+msgstr "Š§ŠøŃŠ°ŠµŃ аŃŃŠøŠ±ŃŃŃ ŃŠµŠŗŃŃŠµŠ¹ ŠæŠ¾Š·ŠøŃŠøŠø ŠŗŃŃŃŠ¾Ńа"
+
+#: internal/command/descriptions.go:39
+msgid "Reads from the top of the screen to the cursor position"
+msgstr "Š§ŃŠµŠ½ŠøŠµ ŃŠ½Š°Ńала ŃŠŗŃана Го ŠŗŃŃŃŠ¾Ńа."
+
+#: internal/app/clipboard.go:264
+msgid "Remove marks"
+msgstr "УГалиŃŃ Š¼ŠµŃŠŗŠø."
+
+#: internal/command/descriptions.go:99
+msgid "Removes marks from selected text"
+msgstr "УГалиŃŃ Š¼ŠµŃŠŗŠø ŠøŠ· Š²ŃŠ±Ńанного ŃŠµŠŗŃŃŠ°."
+
+#: internal/config/config.go:161
+msgid "Repeated symbols"
+msgstr "ŠŠ¾Š²ŃоŃŃŃŃŠøŠµŃŃ ŃŠøŠ¼Š²Š¾Š»Ń"
+
+#: internal/config/config.go:162
+msgid "Repeated symbols minimum length"
+msgstr "ŠŠøŠ½ŠøŠ¼Š°Š»Ńное ŃŠøŃло ŠæŠ¾Š²ŃŠ¾ŃŃŃŃŠøŃ
ŃŃ ŃŠøŠ¼Š²Š¾Š»Š¾Š²"
+
+#: internal/emoji/catalog.go:110
+msgid "Rock on"
+msgstr "РокеŃŃŠŗŠøŠ¹ жеŃŃ"
+
+#: internal/emoji/catalog.go:41
+msgid "Rolling laughing"
+msgstr "ŠŠ°ŃаеŃŃŃ Š¾Ń ŃŠ¼ŠµŃ
а"
+
+#: internal/emoji/catalog.go:57
+msgid "Rose"
+msgstr "Роза"
+
+#: internal/emoji/catalog.go:138
+msgid "Russia"
+msgstr "РоŃŃŠøŃ"
+
+#: internal/emoji/catalog.go:42
+msgid "Sad"
+msgstr "ŠŃŃŃŃŃ"
+
+#: internal/emoji/catalog.go:88
+msgid "Santa"
+msgstr "ŠŠµŠ“ ŠŠ¾Ńоз"
+
+#: internal/command/descriptions.go:64
+msgid "Saves your current Fenrir settings so they are the default."
+msgstr "ДоŃ
ŃŠ°Š½ŠøŃŃ ŃŠµŠŗŃŃŠøŠµ наŃŃŃŠ¾Š¹ŠŗŠø fenrir как наŃŃŃŠ¾Š¹ŠŗŠø по ŃŠ¼Š¾Š»ŃаниŃ."
+
+#: internal/emoji/catalog.go:43
+msgid "Savoring food"
+msgstr "ŠŠ°ŃлажГение еГой"
+
+#: internal/config/config.go:152
+msgid "Say ācapā before capital letters"
+msgstr "ŠŠ¾Š²Š¾ŃŠøŃŃ Ā«Š±Š¾Š»ŃŃŠ°ŃĀ» ŠæŠµŃŠµŠ“ Š·Š°Š³Š»Š°Š²Š½ŃŠ¼Šø Š±ŃŠŗŠ²Š°Š¼Šø"
+
+#: internal/config/config.go:158
+msgid "Say āmisspelledā"
+msgstr "ŠŠ¾Š²Š¾ŃŠøŃŃ Ā«Š¾ŃŠøŠ±ŠŗŠ°Ā»"
+
+#: internal/emoji/catalog.go:44
+msgid "Screaming"
+msgstr "ŠŃŠøŠŗ"
+
+#: internal/app/commands.go:135 internal/app/scripts.go
+msgid "Script failed"
+msgstr "ŠŃибка ŃŃŠµŠ½Š°ŃŠøŃ"
+
+#: internal/app/scripts.go
+msgid "Script file is not executable"
+msgstr "Файл ŃŠŗŃŠøŠæŃŠ° не ŠøŃŠæŠ¾Š»Š½ŃŠµŠ¼Ńй"
+
+#: internal/app/scripts.go
+msgid "Script file not found"
+msgstr "Š”ŠŗŃŠøŠæŃ не найГен"
+
+#: internal/app/scripts.go
+msgid "Script source is not a valid file"
+msgstr "ŠŃŃ
оГник ŃŠŗŃŠøŠæŃŠ° Š½ŠµŠæŃŠ°Š²ŠøŠ»ŃŠ½ŃŠ¹ ŃŠ°Š¹Š»"
+
+#: internal/interaction/grid.go:71
+msgid "Search cleared"
+msgstr "ŠŠ¾ŠøŃŠŗ Š¾ŃŠøŃен"
+
+#: internal/interaction/grid.go:73
+msgid "Search {0}"
+msgstr "ŠŠ¾ŠøŃŠŗ {0}"
+
+#: internal/config/config.go:121
+msgid "Select sound theme"
+msgstr "ŠŃŠ±ŃŠ°ŃŃ Š·Š²ŃŠŗŠ¾Š²ŃŃ ŃŠµŠ¼Ń"
+
+#: internal/config/config.go:128
+msgid "Serial port"
+msgstr "ŠŠ¾ŃŠ»ŠµŠ“Š¾Š²Š°ŃŠµŠ»ŃŠ½ŃŠ¹ поŃŃ"
+
+#: internal/command/descriptions.go:74
+msgid "Set a live region from one or two marks"
+msgstr "ŠŠ°Š“аŃŃ Š“ŠøŠ½Š°Š¼ŠøŃŠµŃŠŗŃŃ Š¾Š±Š»Š°ŃŃŃ ŠæŠ¾ оГной или Š“Š²ŃŠ¼ Š¼ŠµŃŠŗŠ°Š¼"
+
+#: internal/reader/regions.go:213
+msgid "Set window begin and end marks"
+msgstr "Š£ŃŃŠ°Š½Š¾Š²ŠŗŠ° Š½Š°ŃŠ°Š»Š° Šø ŠŗŠ¾Š½ŃŠ° окна"
+
+#: internal/app/quick_settings.go:298
+msgid "Settings saved."
+msgstr "ŠŠ°ŃŃŃŠ¾Š¹ŠŗŠø ŃŠ¾Ń
ŃŠ°Š½ŠµŠ½Ń"
+
+#: internal/emoji/catalog.go:89
+msgid "Shamrock"
+msgstr "Š¢ŃŠøŠ»ŠøŃŃŠ½ŠøŠŗ"
+
+#: internal/emoji/catalog.go:90
+msgid "Skull"
+msgstr "Š§ŠµŃŠµŠæ"
+
+#: internal/emoji/catalog.go:111
+msgid "Skull crossbones"
+msgstr "Š§ŠµŃŠµŠæ Ń ŠŗŠ¾ŃŃŃŠ¼Šø"
+
+#: internal/emoji/catalog.go:45
+msgid "Smiling"
+msgstr "Š£Š»ŃŠ±ŠŗŠ°"
+
+#: internal/emoji/catalog.go:46
+msgid "Smirking"
+msgstr "Š£Ń
Š¼ŃŠ»ŠŗŠ°"
+
+#: internal/emoji/catalog.go:91
+msgid "Snowman"
+msgstr "Днеговик"
+
+#: internal/config/config.go:95
+msgid "Sound"
+msgstr "ŠŠ²ŃŠŗ"
+
+#: internal/config/config.go:118
+msgid "Sound enabled"
+msgstr "ŠŠ²ŃŠŗ вклŃŃŃŠ½"
+
+#: internal/emoji/catalog.go:139
+msgid "South Africa"
+msgstr "Š®Š¶Š½Š°Ń ŠŃŃŠøŠŗŠ°"
+
+#: internal/emoji/catalog.go:140
+msgid "South Korea"
+msgstr "Š®Š¶Š½Š°Ń ŠŠ¾ŃеŃ"
+
+#: internal/emoji/catalog.go:141
+msgid "Spain"
+msgstr "ŠŃпаниŃ"
+
+#: internal/emoji/catalog.go:112
+msgid "Sparkles"
+msgstr "ŠŃŠŗŃŃ"
+
+#: internal/config/config.go:94
+msgid "Speech"
+msgstr "РеŃŃ"
+
+#: internal/speech/drivers.go:44
+msgid "Speech Dispatcher"
+msgstr "Speech Dispatcher"
+
+#: internal/config/config.go:130
+msgid "Speech Dispatcher module"
+msgstr "ŠŠ¾Š“ŃŠ»Ń Speech Dispatcher"
+
+#: internal/config/config.go:125
+msgid "Speech enabled"
+msgstr "РеŃŃ Š²ŠŗŠ»ŃŃŠµŠ½Š°"
+
+#: internal/config/config.go:131
+msgid "Speech language"
+msgstr "ŠÆŠ·ŃŠŗ ŃŠµŃŠø"
+
+#: internal/app/screen_updates.go:72
+msgid "Speech restored"
+msgstr "РеŃŃ Š²Š¾ŃŃŃŠ°Š½Š¾Š²Š»ŠµŠ½Š°"
+
+#: internal/config/config.go:132
+msgid "Speech voice"
+msgstr "ŠŠ¾Š»Š¾Ń ŃŠøŠ½ŃŠµŠ·Š°ŃŠ¾Ńа"
+
+#: internal/app/spellcheck_menu.go:81
+msgid "Spell checking is unavailable"
+msgstr "ŠŃŠ¾Š²ŠµŃŠŗŠ° ŠæŃŠ°Š²Š¾ŠæŠøŃŠ°Š½ŠøŃ Š½ŠµŠ“Š¾ŃŃŃŠæŠ½Š°"
+
+#: internal/app/spellcheck_menu.go:180
+msgid "Spell checking is unavailable: {0}"
+msgstr "ŠŃŠ¾Š²ŠµŃŠŗŠ° ŠæŃŠ°Š²Š¾ŠæŠøŃŠ°Š½ŠøŃ Š½ŠµŠ“Š¾ŃŃŃŠæŠ½Š°: {0}"
+
+#: internal/command/descriptions.go:54
+msgid "Spellcheck Menu"
+msgstr "ŠŠµŠ½Ń ŠæŃŠ¾Š²ŠµŃŠŗŠø ŠæŃŠ°Š²Š¾ŠæŠøŃаниŃ"
+
+#: internal/command/descriptions.go:24
+msgid "Spells the current word"
+msgstr "ŠŃŠ¾ŠøŠ·Š½Š¾ŃŠøŃ ŃŠµŠŗŃŃŠµŠµ ŃŠ»Š¾Š²Š¾ по Š±Ńквам"
+
+#: internal/emoji/catalog.go:92
+msgid "Spider"
+msgstr "ŠŠ°ŃŠŗ"
+
+#: internal/emoji/catalog.go:113
+msgid "Star"
+msgstr "ŠŠ²ŠµŠ·Š“а"
+
+#: internal/app/screen_updates.go:39
+msgid "Start Fenrir"
+msgstr "fenrir запŃŃŠµŠ½"
+
+#: internal/emoji/catalog.go:72
+msgid "Strawberry"
+msgstr "ŠŠ»Ńбника"
+
+#: internal/app/spellcheck_menu.go:45 internal/app/spellcheck_menu.go:47
+msgid "Suggestions"
+msgstr "ŠŃеГложениŃ"
+
+#: internal/emoji/catalog.go:58
+msgid "Sun"
+msgstr "Š”Š¾Š»Š½ŃŠµ"
+
+#: internal/emoji/catalog.go:59
+msgid "Sunflower"
+msgstr "ŠŠ¾Š“ŃŠ¾Š»Š½ŃŃ
"
+
+#: internal/emoji/catalog.go:142
+msgid "Sweden"
+msgstr "ŠØŠ²ŠµŃŠøŃ"
+
+#: internal/emoji/catalog.go:143
+msgid "Switzerland"
+msgstr "ŠØŠ²ŠµŠ¹ŃŠ°ŃŠøŃ"
+
+#: internal/emoji/catalog.go:114
+msgid "Sword"
+msgstr "ŠŠµŃ"
+
+#: internal/emoji/catalog.go
+msgid "Symbols"
+msgstr "ДимволŃ"
+
+#: internal/emoji/catalog.go:73
+msgid "Taco"
+msgstr "Тако"
+
+#: internal/preferences/settings.go:72
+msgid "Test synthesizer"
+msgstr "ŠŃŠ¾Š²ŠµŃŠøŃŃ ŃŠøŠ½ŃŠµŠ·Š°ŃŠ¾Ń"
+
+#: internal/app/fenrir_menu.go:139
+msgid "Testing voice"
+msgstr "ŠŃŠ¾Š²ŠµŃŠŗŠ° Š³Š¾Š»Š¾ŃŠ°"
+
+#: internal/emoji/catalog.go:47
+msgid "Thumbs up"
+msgstr "ŠŠ¾Š»ŃŃŠ¾Š¹ ŠæŠ°Š»ŠµŃ Š²Š²ŠµŃŃ
"
+
+#: internal/config/config.go:97
+msgid "Time and date"
+msgstr "ŠŃŠµŠ¼Ń Šø Š“Š°ŃŠ°"
+
+#: internal/config/config.go:101
+msgid "Time format"
+msgstr "Š¤Š¾ŃŠ¼Š°Ń Š²ŃŠµŠ¼ŠµŠ½Šø"
+
+#: internal/command/descriptions.go:67
+msgid "Toggle progress bar monitoring with ascending tones"
+msgstr "ŠŠµŃеклŃŃŠøŃŃ Š¾ŃŃŠ»ŠµŠ¶ŠøŠ²Š°Š½ŠøŠµ Ń
оГа Š²ŃŠæŠ¾Š»Š½ŠµŠ½ŠøŃ Ń ŠæŠ¾Š²ŃŃŠ°ŃŃŠøŠ¼ŠøŃŃ ŃŠ¾Š½Š°Š¼Šø"
+
+#: internal/review/top.go:29
+msgid "Top"
+msgstr "ŠŠ°Ńало"
+
+#: internal/config/config.go:147
+msgid "Tracking mode"
+msgstr "Режим оŃŃŠ»ŠµŠ¶ŠøŠ²Š°Š½ŠøŃ"
+
+#: internal/emoji/catalog.go:60
+msgid "Tree"
+msgstr "ŠŠµŃево"
+
+#: internal/emoji/catalog.go:144 internal/emoji/catalog.go:93
+msgid "Turkey"
+msgstr "ŠŠ½Š“ейка"
+
+#: internal/command/descriptions.go:73
+msgid "Turn off window mode"
+msgstr "ŠŃŠŗŠ»ŃŃŠøŃŃ Š¾ŠŗŠ¾Š½Š½ŃŠ¹ ŃŠµŠ¶ŠøŠ¼"
+
+#: internal/emoji/catalog.go:145
+msgid "Ukraine"
+msgstr "Š£ŠŗŃŠ°ŠøŠ½Š°"
+
+#: internal/app/tutorial.go:23
+msgid ""
+"Unable to exit tutorial mode because exclusive keyboard capture could not be "
+"released. Press Fenrir+F1 or Escape to try again."
+msgstr ""
+"ŠŠµ ŃŠ“алоŃŃ Š²ŃŠ¹ŃŠø ŠøŠ· ŃŠµŠ¶ŠøŠ¼Š° обŃŃŠµŠ½ŠøŃ, ŃŠ°Šŗ как не ŃŠ“алоŃŃ Š¾ŃŠ²Š¾Š±Š¾Š“ŠøŃŃ "
+"ŠøŃŠŗŠ»ŃŃŠøŃŠµŠ»ŃŠ½Ńй заŃ
Š²Š°Ń ŠŗŠ»Š°Š²ŠøŠ°ŃŃŃŃ. ŠŠ°Š¶Š¼ŠøŃе Fenrir+F1 или Escape, ŃŃŠ¾Š±Ń "
+"ŠæŠ¾Š²ŃŠ¾ŃŠøŃŃ ŠæŠ¾ŠæŃŃŠŗŃ."
+
+#: internal/app/tutorial.go
+msgid "Unbound actions"
+msgstr "ŠŠµŠ¹ŃŃŠ²ŠøŃ без ŃŠ¾ŃŠµŃŠ°Š½ŠøŠ¹ клавиŃ"
+
+#: internal/app/spellcheck_menu.go:53
+msgid "Undo"
+msgstr "ŠŃмениŃŃ"
+
+#: internal/emoji/catalog.go:146
+msgid "United Kingdom"
+msgstr "ŠŠµŠ»ŠøŠŗŠ¾Š±ŃŠøŃŠ°Š½ŠøŃ"
+
+#: internal/emoji/catalog.go:147
+msgid "United States"
+msgstr "Š”Š¾ŠµŠ“ŠøŠ½ŃŠ½Š½Ńе ŠØŃаŃŃ"
+
+#: internal/emoji/catalog.go:94
+msgid "Vampire"
+msgstr "ŠŠ°Š¼ŠæŠøŃ"
+
+#: internal/app/quick_settings.go:191 internal/app/synthesizer_preview.go:60
+msgid "Voice test failed"
+msgstr "ŠŠµ ŃŠ“алоŃŃ ŠæŃŠ¾Š²ŠµŃŠøŃŃ Š³Š¾Š»Š¾Ń"
+
+#: internal/app/synthesizer_preview.go:23
+msgid "Voice test: The quick brown fox jumps over the lazy dog."
+msgstr ""
+"ŠŃŠ¾Š²ŠµŃŠŗŠ° Š³Š¾Š»Š¾ŃŠ°: ŠŃŃŃŃŠ°Ń бŃŃŠ°Ń Š»ŠøŃŠ° ŠæŠµŃŠµŠæŃŃŠ³ŠøŠ²Š°ŠµŃ ŃŠµŃез ленивŃŃ ŃŠ¾Š±Š°ŠŗŃ."
+
+#: internal/config/config.go:119 internal/config/config.go:134
+msgid "Volume"
+msgstr "ŠŃомкоŃŃŃ"
+
+#: internal/punctuation/emoticons.go:32
+msgid "WTF?"
+msgstr "ŠŠ°ŠŗŠ¾Š³Š¾ ŃŃŃŃŠ°?"
+
+#: internal/app/tutorial.go:24
+msgid ""
+"Warning: full keyboard capture is unavailable. Unbound keys may reach the "
+"active application."
+msgstr ""
+"ŠŃŠµŠ“ŃŠæŃежГение: ŠæŠ¾Š»Š½ŃŠ¹ заŃ
Š²Š°Ń ŠŗŠ»Š°Š²ŠøŠ°ŃŃŃŃ Š½ŠµŠ“Š¾ŃŃŃŠæŠµŠ½. ŠŠµŠ½Š°Š·Š½Š°ŃŠµŠ½Š½ŃŠµ ŠŗŠ»Š°Š²ŠøŃŠø "
+"могŃŃ ŠæŠµŃŠµŠ“аваŃŃŃŃ Š°ŠŗŃŠøŠ²Š½Š¾Š¼Ń ŠæŃŠøŠ»Š¾Š¶ŠµŠ½ŠøŃ."
+
+#: internal/emoji/catalog.go:95
+msgid "Web"
+msgstr "ŠŠ°ŃŃŠøŠ½Š°"
+
+#: internal/reader/regions.go:225
+msgid "Window Mode off for terminal {0}"
+msgstr "ŠŠŗŠ¾Š½Š½Ńй ŃŠµŠ¶ŠøŠ¼ Š²ŃŠŗŠ»ŃŃŠµŠ½ Š“Š»Ń ŃŠµŃминала {0}"
+
+#: internal/reader/regions.go:219
+msgid "Window Mode on for terminal {0}"
+msgstr "ŠŠŗŠ¾Š½Š½Ńй ŃŠµŠ¶ŠøŠ¼ вклŃŃŃŠ½ Š“Š»Ń ŃŠµŃминала {0}"
+
+#: internal/emoji/catalog.go:48
+msgid "Winking"
+msgstr "ŠŠ¾Š“мигивание"
+
+#: internal/emoji/catalog.go:96
+msgid "Witch"
+msgstr "ŠŠµŠ“ŃŠ¼Š°"
+
+#: internal/emoji/catalog.go:61
+msgid "Wolf"
+msgstr "ŠŠ¾Š»Šŗ"
+
+#: internal/app/spellcheck_menu.go:46 internal/app/spellcheck_menu.go:50
+msgid "Word"
+msgstr "Длово"
+
+#: internal/config/config.go:141
+msgid "Word echo"
+msgstr "ŠŃ
о ŃŠ»Š¾Š²"
+
+#: internal/emoji/catalog.go:97
+msgid "Zombie"
+msgstr "ŠŠ¾Š¼Š±Šø"
+
+#: internal/punctuation/profile.go
+msgid "all"
+msgstr "Š²ŃŠµ"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "and"
+msgstr "Šø"
+
+#: internal/punctuation/emoticons.go:17
+msgid "angry"
+msgstr "злоŃŃŃ"
+
+#: internal/reader/attributes.go:36
+msgid "announcement of attributes disabled"
+msgstr "Š§ŃŠµŠ½ŠøŠµ аŃŃŠøŠ±ŃŃŠ¾Š² Š²ŃŠŗŠ»ŃŃŠµŠ½Š¾"
+
+#: internal/reader/attributes.go:33
+msgid "announcement of attributes enabled"
+msgstr "Š§ŃŠµŠ½ŠøŠµ аŃŃŠøŠ±ŃŃŠ¾Š² вклŃŃŠµŠ½Š¾"
+
+#: internal/punctuation/emoticons.go:26
+msgid "annoyed"
+msgstr "ŃŠ°Š·Š“ŃŠ°Š¶ŠµŠ½ŠøŠµ"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "apostrophe"
+msgstr "апоŃŃŃŠ¾Ń"
+
+#: internal/punctuation/emoticons.go:39
+msgid "arrow left"
+msgstr "ŃŃŃŠµŠ»ŠŗŠ° влево"
+
+#: internal/punctuation/emoticons.go:38
+msgid "arrow right"
+msgstr "ŃŃŃŠµŠ»ŠŗŠ° Š²ŠæŃŠ°Š²Š¾"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "at"
+msgstr "ŃŠ¾Š±Š°ŠŗŠ°"
+
+#: internal/reader/reader.go:197
+msgid "autoread disabled"
+msgstr "ŠŠ²ŃŠ¾Š¼Š°ŃŠøŃŠµŃŠŗŠ¾Šµ ŃŃŠµŠ½ŠøŠµ нового ŃŠµŠŗŃŃŠ° Š²ŃŠŗŠ»ŃŃŠµŠ½Š¾"
+
+#: internal/reader/reader.go:195
+msgid "autoread enabled"
+msgstr "ŠŠ²ŃŠ¾Š¼Š°ŃŠøŃŠµŃŠŗŠ¾Šµ ŃŃŠµŠ½ŠøŠµ нового ŃŠµŠŗŃŃŠ° вклŃŃŠµŠ½Š¾"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "backslash"
+msgstr "Š¾Š±ŃŠ°ŃŠ½Š°Ń ŠŗŠ¾ŃŠ°Ń ŃŠµŃŃŠ°"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "bang"
+msgstr "Š²Š¾ŃŠŗŠ»ŠøŃŠ°ŃŠµŠ»ŃŠ½ŃŠ¹ знак"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "bar"
+msgstr "веŃŃŠøŠŗŠ°Š»ŃŠ½Š°Ń ŃŠµŃŃŠ°"
+
+#: internal/config/definitions.go:206
+msgid "beeps"
+msgstr "Š·Š²ŃŠŗŠ¾Š²Ńе ŃŠøŠ³Š½Š°Š»Ń"
+
+#: internal/review/line_start.go:28
+msgid "beginning of line"
+msgstr "ŠŠ°Ńало ŃŃŃŠ¾ŠŗŠø"
+
+#: internal/punctuation/emoticons.go:12
+msgid "big grin"
+msgstr "ŃŠøŃŠ¾ŠŗŠ°Ń ŃŠ»Ńбка"
+
+#: internal/presentation/attributes.go
+msgid "black"
+msgstr "ŃŠµŃŠ½ŃŠ¹"
+
+#: internal/app/clipboard.go:281 internal/presentation/text.go:35
+#: internal/reader/regions.go:191 internal/review/line_start.go:31
+#: internal/review/screen_range.go:39 internal/review/spelling.go:44
+#: internal/review/table.go:124 internal/review/table.go:205
+#: internal/review/vertical_character.go:31 internal/review/word.go:64
+msgid "blank"
+msgstr "ŠæŃŃŃŠ¾"
+
+#: internal/presentation/attributes.go
+msgid "blink"
+msgstr "ŠŠµŃŃŠ°Š½ŠøŠµ"
+
+#: internal/presentation/attributes.go
+msgid "blue"
+msgstr "ŃŠøŠ½ŠøŠ¹"
+
+#: internal/presentation/attributes.go
+msgid "bold"
+msgstr "ŠŠøŃŠ½ŃŠ¹"
+
+#: internal/punctuation/emoticons.go:31
+msgid "broken heart"
+msgstr "ŃŠ°Š·Š±ŠøŃое ŃŠµŃŠ“ŃŠµ"
+
+#: internal/presentation/attributes.go
+msgid "brown/yellow"
+msgstr "ŠŗŠ¾ŃŠøŃŠ½ŠµŠ²ŃŠ¹/желŃŃŠ¹"
+
+#: internal/punctuation/emoticons.go:34
+msgid "bugged"
+msgstr "Š“Š¾ŃŠ°Š“а"
+
+#: internal/app/clipboard.go:93 internal/app/review_spelling.go:27
+msgid "cap"
+msgstr "болŃŃŠ°Ń"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "carrot"
+msgstr "ŠŗŃŃŃŠŗŠ°"
+
+#: internal/presentation/text.go:68
+msgid "character {0}"
+msgstr "ŃŠøŠ¼Š²Š¾Š» {0}"
+
+#: internal/interaction/presentation.go
+msgid "checked"
+msgstr "Š¾ŃŠ¼ŠµŃено"
+
+#: internal/command/descriptions.go:108
+msgid "clears the currently selected clipboard"
+msgstr "ŠŃŠøŃŠ°ŠµŃ ŃŠµŠŗŃŃŠøŠ¹ Š²ŃŠ±ŃŠ°Š½Š½ŃŠ¹ бŃŃŠµŃ обмена."
+
+#: internal/app/clipboard.go:299
+msgid "clipboard cleared"
+msgstr "ŠŃŃŠµŃ обмена Š¾ŃŠøŃен."
+
+#: internal/app/clipboard.go:302 internal/app/clipboard.go:337
+msgid "clipboard empty"
+msgstr "ŠŃŃŠµŃ обмена ŠæŃŃŃŠ¾Š¹."
+
+#: internal/app/clipboard.go:395
+msgid "clipboard exported to file"
+msgstr "ŠŃŃŠµŃ обмена ŃŠŗŃпоŃŃŠøŃован в ŃŠ°Š¹Š»."
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "colon"
+msgstr "Š“Š²Š¾ŠµŃŠ¾Ńие"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "comma"
+msgstr "запŃŃŠ°Ń"
+
+#: internal/punctuation/emoticons.go:27
+msgid "confused"
+msgstr "Š½ŠµŠ“Š¾ŃŠ¼ŠµŠ½ŠøŠµ"
+
+#: internal/punctuation/emoticons.go:20
+msgid "cool"
+msgstr "ŠŗŃŃŃŠ¾"
+
+#: internal/command/descriptions.go:102
+msgid "copies last presented text to the clipboard"
+msgstr "ŠŠ¾ŠæŠøŃŃŠµŃ ŠæŠ¾ŃŠ»ŠµŠ“ний ŠæŃеГŃŃŠ°Š²Š»ŠµŠ½Š½Ńй ŃŠµŠŗŃŃ Š² бŃŃŠµŃ обмена."
+
+#: internal/command/descriptions.go:101
+msgid "copies marked text to the currently selected clipboard"
+msgstr "ŠŠ¾ŠæŠøŃŃŠµŃ ŠæŠ¾Š¼ŠµŃŠµŠ½Š½Ńй ŃŠµŠŗŃŃ Š² Š²ŃŠ±ŃŠ°Š½Š½ŃŠ¹ бŃŃŠµŃ обмена."
+
+#: internal/app/spellcheck_menu.go
+msgid "correct"
+msgstr "ŠŃŠ°Š²ŠøŠ»ŃŠ½Š¾"
+
+#: internal/review/table.go:132
+msgid "could not set header row"
+msgstr "не ŃŠ“алоŃŃ Š·Š°Š“Š°ŃŃ ŃŃŃŠ¾ŠŗŃ заголовков"
+
+#: internal/punctuation/emoticons.go:18
+msgid "crying"
+msgstr "плаŃ"
+
+#: internal/command/descriptions.go:17
+msgid "current line"
+msgstr "ТекŃŃŠ°Ń ŃŃŃŠ¾ŠŗŠ°."
+
+#: internal/command/descriptions.go:20
+msgid "current word."
+msgstr "ТекŃŃŠµŠµ ŃŠ»Š¾Š²Š¾."
+
+#: internal/config/definitions.go:225 internal/reader/reader.go:190
+msgid "cursor tracking"
+msgstr "ŠŃŃŠ»ŠµŠ¶ŠøŠ²Š°Š½ŠøŠµ ŠŗŃŃŃŠ¾Ńа"
+
+#: internal/presentation/attributes.go
+msgid "cyan"
+msgstr "Š³Š¾Š»ŃŠ±Š¾Š¹"
+
+#: internal/presentation/attributes.go
+msgid "dark gray"
+msgstr "ŃŃŠ¼Š½Š¾-ŃŠµŃŃŠ¹"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "dash"
+msgstr "Š“ŠµŃŠøŃ"
+
+#: internal/app/quick_settings.go:185 internal/app/quick_settings.go:234
+#: internal/config/definitions.go:237 internal/preferences/discovery.go:45
+#: internal/preferences/discovery.go:55 internal/preferences/discovery.go:81
+#: internal/presentation/attributes.go:18
+#: internal/presentation/attributes.go:44
+msgid "default"
+msgstr "ŠŠ¾ ŃŠ¼Š¾Š»ŃаниŃ"
+
+#: internal/command/descriptions.go:66
+msgid "disables speech until next keypress"
+msgstr "ŠŃŠŗŠ»ŃŃŠøŃŃ ŃŠµŃŃ ŠæŠ¾ŠŗŠ° не Š½Š°Š¶Š°Ńа ŃŠ»ŠµŠ“ŃŃŃŠ°Ń ŠŗŠ»Š°Š²ŠøŃŠ°"
+
+#: internal/command/descriptions.go:43
+msgid "displays the position of the review cursor"
+msgstr "ŠŃŠ¾Š±ŃŠ°Š¶Š°ŠµŃ положение ŠŗŃŃŃŠ¾Ńа Š¾Š±Š·Š¾Ńа."
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "dollar"
+msgstr "ГоллаŃ"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "dot"
+msgstr "ŃŠ¾Ńка"
+
+#: internal/command/descriptions.go:68
+msgid "enables or disables automatic reading of new text as it appears"
+msgstr "вклŃŃŠøŃŃ ŠøŠ»Šø Š¾ŃŠŗŠ»ŃŃŠøŃŃ ŃŃŠµŠ½ŠøŃ нового ŃŠµŠŗŃŃŠ°"
+
+#: internal/command/descriptions.go:65
+msgid "enables or disables speech"
+msgstr "ŠŠŗŠ»ŃŃŠµŠ½ŠøŠµ или Š¾ŃŠŗŠ»ŃŃŠµŠ½ŠøŠµ ŃŠµŃŠø"
+
+#: internal/command/descriptions.go:41
+msgid "enables or disables the announcement of attributes"
+msgstr "ŠŠŗŠ»ŃŃŠµŠ½ŠøŠµ или Š¾ŃŠŗŠ»ŃŃŠµŠ½ŠøŠµ ŃŃŠµŠ½ŠøŃ аŃŃŠøŠ±ŃŃŠ¾Š²"
+
+#: internal/command/descriptions.go:69
+msgid "enables or disables tracking of highlighted text"
+msgstr "ŠŠŗŠ»ŃŃŠ°ŠµŃ или Š¾ŃŠŗŠ»ŃŃŠ°ŠµŃ оŃŃŠ»ŠµŠ¶ŠøŠ²Š°Š½ŠøŠµ Š²ŃŠ“еленного ŃŠµŠŗŃŃŠ°"
+
+#: internal/review/table.go:218
+msgid "end of cell"
+msgstr "ŠŗŠ¾Š½ŠµŃ ŃŃŠµŠ¹ŠŗŠø"
+
+#: internal/review/line_end.go:24 internal/review/table.go:175
+msgid "end of line"
+msgstr "ŠŠ¾Š½ŠµŃ ŃŃŃŠ¾ŠŗŠø"
+
+#: internal/review/navigation.go:17
+msgid "end of screen"
+msgstr "ŠŠ¾Š½ŠµŃ ŃŠŗŃана."
+
+#: internal/punctuation/emoticons.go:37
+msgid "enjoy smile"
+msgstr "Š“Š¾Š²Š¾Š»ŃŠ½Š°Ń ŃŠ»Ńбка"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "equals"
+msgstr "ŃŠ°Š²Š½Š¾"
+
+#: internal/punctuation/emoticons.go:36
+msgid "evil smile"
+msgstr "Š·Š»Š¾Š²ŠµŃŠ°Ń ŃŠ»Ńбка"
+
+#: internal/command/descriptions.go:71
+msgid "exits Fenrir"
+msgstr "ŠŃŃ
оГ из fenrir."
+
+#: internal/command/descriptions.go:50
+msgid "exits review mode"
+msgstr "ŠŃŃ
оГ ŠøŠ· ŃŠµŠ¶ŠøŠ¼Š° ŠæŃŠ¾ŃмоŃŃŠ°."
+
+#: internal/command/descriptions.go:111
+msgid "export the current fenrir clipboard to a file"
+msgstr "ŠŠŗŃпоŃŃŠøŃоваŃŃ ŃŠµŠŗŃŃŠøŠ¹ бŃŃŠµŃ обмена Fenrir в ŃŠ°Š¹Š»."
+
+#: internal/app/clipboard.go:391
+msgid "exported to the X session."
+msgstr "ŃŠŗŃпоŃŃŠøŃовано в ŃŠµŠ°Š½Ń X."
+
+#: internal/app/clipboard.go:372
+msgid "failed to export to X clipboard. No available display found."
+msgstr "не ŃŠ“алоŃŃ ŃŠŗŃпоŃŃŠøŃоваŃŃ Š² бŃŃŠµŃ X. ŠŠ¾ŃŃŃŠæŠ½ŃŠ¹ Š“ŠøŃŠæŠ»ŠµŠ¹ не найГен."
+
+#: internal/review/table.go:190
+msgid "first cell"
+msgstr "ŠæŠµŃŠ²Š°Ń ŃŃŠµŠ¹ŠŗŠ°"
+
+#: internal/review/table.go:224
+msgid "first character in cell {0}"
+msgstr "ŠæŠµŃŠ²Ńй ŃŠøŠ¼Š²Š¾Š» в ŃŃŠµŠ¹ŠŗŠµ {0}"
+
+#: internal/review/first_text.go:35
+msgid "first character in line indent {0}"
+msgstr "ŠŠµŃŠ²ŃŠ¹ ŃŠøŠ¼Š²Š¾Š» в ŃŃŃŠ¾ŠŗŠµ {0}"
+
+#: internal/review/screen_first_character.go:13
+msgid "first character in screen"
+msgstr "ŠæŠµŃŠ²Ńй ŃŠøŠ¼Š²Š¾Š» на ŃŠŗŃане"
+
+#: internal/punctuation/emoticons.go:11
+msgid "frown"
+msgstr "Ń
мŃŃŠ¾Šµ Š»ŠøŃŠ¾"
+
+#: internal/punctuation/emoticons.go:28
+msgid "frustrated"
+msgstr "ŃŠ°Š·Š¾ŃŠ°ŃŠ¾Š²Š°Š½ŠøŠµ"
+
+#: internal/command/descriptions.go:58
+msgid "get current quick menu entry"
+msgstr "ŠŠ¾Š»ŃŃŠøŃŃ ŃŠµŠŗŃŃŠøŠ¹ бŃŃŃŃŃŠ¹ ŠæŃŠ½ŠŗŃ менŃ"
+
+#: internal/command/descriptions.go:59
+msgid "get current quick menu value"
+msgstr "ŠŠ¾Š»ŃŃŠøŃŃ ŃŠµŠŗŃŃŠµŠµ Š·Š½Š°ŃŠµŠ½ŠøŠµ бŃŃŃŃŠ¾Š³Š¾ менŃ."
+
+#: internal/command/descriptions.go:56
+msgid "get next quick menu category"
+msgstr "Š²ŃŠ±ŃаŃŃ ŃŠ»ŠµŠ“ŃŃŃŃŃ ŠŗŠ°ŃŠµŠ³Š¾ŃŠøŃ Š±ŃŃŃŃŠ¾Š³Š¾ менŃ"
+
+#: internal/command/descriptions.go:60
+msgid "get next quick menu entry"
+msgstr "ŠŠ¾Š»ŃŃŠøŃŃ ŃŠ»ŠµŠ“ŃŃŃŠøŠ¹ бŃŃŃŃŃŠ¹ ŠæŃŠ½ŠŗŃ менŃ."
+
+#: internal/command/descriptions.go:62
+msgid "get next quick menu value"
+msgstr "ŠŠ¾Š»ŃŃŠøŃŃ ŃŠ»ŠµŠ“ŃŃŃŠµŠµ Š·Š½Š°ŃŠµŠ½ŠøŠµ бŃŃŃŃŠ¾Š³Š¾ менŃ."
+
+#: internal/command/descriptions.go:57
+msgid "get previous quick menu category"
+msgstr "Š²ŃŠ±ŃаŃŃ ŠæŃŠµŠ“ŃŠ“ŃŃŃŃ ŠŗŠ°ŃŠµŠ³Š¾ŃŠøŃ Š±ŃŃŃŃŠ¾Š³Š¾ менŃ"
+
+#: internal/command/descriptions.go:61
+msgid "get previous quick menu entry"
+msgstr "ŠŠ¾Š»ŃŃŠøŃŃ ŠæŃŠµŠ“ŃŠ“ŃŃŠøŠ¹ ŠæŃŠ½ŠŗŃ бŃŃŃŃŠ¾Š³Š¾ менŃ."
+
+#: internal/command/descriptions.go:63
+msgid "get previous quick menu value"
+msgstr "ŠŠ¾Š»ŃŃŠøŃŃ ŠæŃŠµŠ“ŃŠ“ŃŃŠµŠµ Š·Š½Š°ŃŠµŠ½ŠøŠµ бŃŃŃŃŠ¾Š³Š¾ менŃ."
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "grave"
+msgstr "Š¾Š±ŃŠ°ŃŠ½ŃŠ¹ апоŃŃŃŠ¾Ń"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "greater"
+msgstr "болŃŃŠµ"
+
+#: internal/presentation/attributes.go
+msgid "green"
+msgstr "Š·ŠµŠ»ŠµŠ½ŃŠ¹"
+
+#: internal/punctuation/emoticons.go:25
+msgid "happy"
+msgstr "ŃŠ°Š“оŃŃŃ"
+
+#: internal/presentation/attributes.go:65
+msgid "has attribute"
+msgstr "еŃŃŃ Š°ŃŃŠøŠ±ŃŃ"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "hash"
+msgstr "ŃŠµŃŃŃŠŗŠ°"
+
+#: internal/review/table.go:138
+msgid "header row set"
+msgstr "ŃŃŃŠ¾ŠŗŠ° заголовков заГана"
+
+#: internal/punctuation/emoticons.go:30
+msgid "heart"
+msgstr "ŃŠµŃŠ“ŃŠµ"
+
+#: internal/config/definitions.go:225 internal/reader/reader.go:187
+msgid "highlight tracking"
+msgstr "ŠŃŃŠ»ŠµŠ¶ŠøŠ²Š°Š½ŠøŠµ Š²ŃŠ“елениŃ"
+
+#: internal/punctuation/emoticons.go:29
+msgid "hurray"
+msgstr "ŃŃŠ°"
+
+#: internal/command/descriptions.go:110
+msgid "imports text from clipboard file to the clipboard"
+msgstr "импоŃŃŠøŃŃŠµŃ ŃŠµŠŗŃŃ ŠøŠ· бŃŃŠµŃа ŃŠ°Š¹Š»Š° в бŃŃŠµŃ обмена"
+
+#: internal/command/descriptions.go:112
+msgid "imports the graphical clipboard to Fenrir's clipboard"
+msgstr "ŠŠ¼ŠæŠ¾ŃŃŠøŃŃŠµŃ Š³ŃŠ°ŃŠøŃŠµŃкий бŃŃŠµŃ обмена в бŃŃŠµŃ обмена Š¤ŠµŠ½ŃŠøŃа."
+
+#: internal/presentation/indentation.go:32
+msgid "indented "
+msgstr "оŃŃŃŃŠæ "
+
+#: internal/presentation/attributes.go
+msgid "italic"
+msgstr "ŠŃŃŃŠøŠ²Š½Ńй"
+
+#: internal/punctuation/emoticons.go:22
+msgid "knocked out"
+msgstr "без ŃŠ¾Š·Š½Š°Š½ŠøŃ"
+
+#: internal/review/table.go:192
+msgid "last cell"
+msgstr "ŠæŠ¾ŃŠ»ŠµŠ“нŃŃ ŃŃŠµŠ¹ŠŗŠ°"
+
+#: internal/review/table.go:227
+msgid "last character in cell {0}"
+msgstr "ŠæŠ¾ŃŠ»ŠµŠ“ний ŃŠøŠ¼Š²Š¾Š» в ŃŃŠµŠ¹ŠŗŠµ {0}"
+
+#: internal/review/last_text.go:34
+msgid "last character in line"
+msgstr "ŠŠ¾ŃлеГний ŃŠøŠ¼Š²Š¾Š» в ŃŃŃŠ¾ŠŗŠµ"
+
+#: internal/review/screen_last_character.go:13
+msgid "last character in screen"
+msgstr "ŠæŠ¾ŃŠ»ŠµŠ“ний ŃŠøŠ¼Š²Š¾Š» на ŃŠŗŃане"
+
+#: internal/punctuation/emoticons.go:35
+msgid "laughing"
+msgstr "ŃŠ¼ŠµŃ
"
+
+#: internal/punctuation/emoticons.go:24
+msgid "laughing hard"
+msgstr "Š³ŃŠ¾Š¼ŠŗŠøŠ¹ ŃŠ¼ŠµŃ
"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "left brace"
+msgstr "Š»ŠµŠ²Š°Ń ŃŠøŠ³ŃŃŠ½Š°Ń ŃŠŗŠ¾Š±ŠŗŠ°"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "left bracket"
+msgstr "Š»ŠµŠ²Š°Ń ŠŗŠ²Š°Š“ŃŠ°ŃŠ½Š°Ń ŃŠŗŠ¾Š±ŠŗŠ°"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "left paren"
+msgstr "Š»ŠµŠ²Š°Ń ŠŗŃŃŠ³Š»Š°Ń ŃŠŗŠ¾Š±ŠŗŠ°"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "less"
+msgstr "менŃŃŠµ"
+
+#: internal/presentation/attributes.go
+msgid "light blue"
+msgstr "ŃŠ²ŠµŃло-ŃŠøŠ½ŠøŠ¹"
+
+#: internal/presentation/attributes.go
+msgid "light cyan"
+msgstr "ŃŠ²ŠµŃло-Š³Š¾Š»ŃŠ±Š¾Š¹"
+
+#: internal/presentation/attributes.go
+msgid "light gray"
+msgstr "ŃŠ²ŠµŃло-ŃŠµŃŃŠ¹"
+
+#: internal/presentation/attributes.go
+msgid "light green"
+msgstr "ŃŠ²ŠµŃло-Š·ŠµŠ»ŃŠ½Ńй"
+
+#: internal/presentation/attributes.go
+msgid "light magenta"
+msgstr "ŃŠ²ŠµŃло-ŠæŃŃŠæŃŃŠ½Ńй"
+
+#: internal/presentation/attributes.go
+msgid "light red"
+msgstr "ŃŠ²ŠµŃло-ŠŗŃŠ°ŃŠ½ŃŠ¹"
+
+#: internal/presentation/attributes.go
+msgid "light yellow"
+msgstr "ŃŠ²ŠµŃло-Š¶ŃŠ»ŃŃŠ¹"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "line"
+msgstr "поГŃŃŃŠŗŠøŠ²Š°Š½ŠøŠµ"
+
+#: internal/review/navigation.go:25
+msgid "line break"
+msgstr "РазŃŃŠ² ŃŃŃŠ¾ŠŗŠø"
+
+#: internal/review/first_text.go:29 internal/review/last_text.go:28
+msgid "line is empty"
+msgstr "Š”ŃŃŠ¾ŠŗŠ° ŠæŃŃŃŠ°Ń"
+
+#: internal/review/position.go:27
+msgid "line {0}, column {1}, Terminal {2}"
+msgstr "Š”ŃŃŠ¾ŠŗŠ° {0}, ŃŃŠ¾Š»Š±ŠµŃ {1}, ŃŠµŃминал {2}"
+
+#: internal/punctuation/emoticons.go:23
+msgid "lips sealed"
+msgstr "ŃŠ¾Ń на замке"
+
+#: internal/presentation/attributes.go
+msgid "magenta"
+msgstr "ŠæŃŃŠæŃŃŠ½Ńй"
+
+#: internal/app/spellcheck.go:22 internal/app/spellcheck_menu.go
+msgid "misspelled"
+msgstr "ŠŃŃŠ¾Š³ŃŠ°ŃŠøŃŠµŃŠŗŠ°Ń Š¾ŃŠøŠ±ŠŗŠ°"
+
+#: internal/punctuation/profile.go
+msgid "most"
+msgstr "болŃŃŠøŠ½ŃŃŠ²Š¾"
+
+#: internal/command/descriptions.go:32
+msgid "move review to top of screen"
+msgstr "ŠŠµŃемеŃŃŠøŃŃ ŠŗŃŃŃŠ¾Ń ŠæŃŠ¾ŃмоŃŃŠ° в Š½Š°Ńало ŃŠŗŃана"
+
+#: internal/command/descriptions.go:19
+msgid "moves review focus to the previous word "
+msgstr "ŠŠµŃемеŃŃŠøŃŃ ŠŗŃŃŃŠ¾Ń ŠæŃŠ¾ŃмоŃŃŠ° Šŗ ŠæŃŠµŠ“ŃŠ“ŃŃŠµŠ¼Ń ŃŠ»Š¾Š²Ń "
+
+#: internal/command/descriptions.go:18
+msgid "moves review to the next line "
+msgstr "ŠŠµŃемеŃŃŠøŃŃ ŠŗŃŃŃŠ¾Ń ŠæŃŠ¾ŃмоŃŃŠ° Šŗ ŃŠ»ŠµŠ“ŃŃŃŠµŠ¹ ŃŃŃŠ¾ŠŗŠµ "
+
+#: internal/command/descriptions.go:21
+msgid "moves review to the next word "
+msgstr "ŠŠµŃемеŃŃŠøŃŃ ŠŗŃŃŃŠ¾Ń ŠæŃŠ¾ŃмоŃŃŠ° Šŗ ŃŠ»ŠµŠ“ŃŃŃŠµŠ¼Ń ŃŠ»Š¾Š²Ń "
+
+#: internal/command/descriptions.go:22
+msgid "moves review to the previous character"
+msgstr "ŠæŠµŃŠµŠ¼ŠµŃŠ°ŠµŃ ŠŗŃŃŃŠ¾Ń Š¾Š±Š·Š¾ŃŠ° Šŗ ŠæŃŠµŠ“ŃŠ“ŃŃŠµŠ¼Ń ŃŠøŠ¼Š²Š¾Š»Ń"
+
+#: internal/command/descriptions.go:16
+msgid "moves review to the previous line "
+msgstr "ŠŠµŃемеŃŃŠøŃŃ ŠŗŃŃŃŠ¾Ń ŠæŃŠ¾ŃмоŃŃŠ° Šŗ ŠæŃŠµŠ“ŃŠ“ŃŃŠµŠ¹ ŃŃŃŠ¾ŠŗŠµ "
+
+#: internal/punctuation/emoticons.go:19
+msgid "neutral"
+msgstr "нейŃŃŠ°Š»Ńное Š»ŠøŃо"
+
+#: internal/command/descriptions.go internal/command/descriptions.go:26
+#: internal/command/descriptions.go:27
+msgid "no description available"
+msgstr "Š¾ŠæŠøŃŠ°Š½ŠøŠµ неГоŃŃŃŠæŠ½Š¾"
+
+#: internal/review/marks.go:30
+msgid "no review cursor"
+msgstr "ŠŠµŃ ŠŗŃŃŃŠ¾Ńа ŠæŃоŃмоŃŃŠ°"
+
+#: internal/review/spelling.go:19 internal/review/table.go:150
+#: internal/review/table.go:158 internal/review/vertical_character.go:26
+#: internal/review/vertical_character.go:34
+msgid "no table data"
+msgstr "Š½ŠµŃ Š“Š°Š½Š½ŃŃ
ŃŠ°Š±Š»ŠøŃŃ"
+
+#: internal/reader/regions.go:173 internal/review/review.go:135
+msgid "no text console"
+msgstr "Š½ŠµŃ ŃŠµŠŗŃŃŠ¾Š²Š¾Š¹ ŠŗŠ¾Š½ŃŠ¾Š»Šø"
+
+#: internal/config/definitions.go:206 internal/punctuation/profile.go
+msgid "none"
+msgstr "неŃ"
+
+#: internal/punctuation/text_processing.go
+msgid "number"
+msgstr "номеŃ"
+
+#: internal/app/clipboard.go:344 internal/app/clipboard.go:399
+msgid "paste clipboard"
+msgstr "вŃŃŠ°Š²ŠøŃŃ Š±ŃŃŠµŃ обмена"
+
+#: internal/command/descriptions.go:109
+msgid "pastes the text from the currently selected clipboard"
+msgstr "ŠŃŃŠ°Š²Š»ŃŠµŃ ŃŠµŠŗŃŃ ŠøŠ· ŃŠµŠŗŃŃŠµŠ³Š¾ Š²ŃŠ±Ńанного бŃŃŠµŃа обмена."
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "percent"
+msgstr "ŠæŃŠ¾ŃенŃ"
+
+#: internal/command/descriptions.go:98
+msgid "places marks to select text to copy to the clipboard"
+msgstr "Š”ŃŠ°Š²ŠøŃ Š¼ŠµŃŠŗŠø Š“Š»Ń Š²ŃŠ±Š¾Ńа ŃŠµŠŗŃŃŠ° Š“Š»Ń ŠŗŠ¾ŠæŠøŃŠ¾Š²Š°Š½ŠøŃ в бŃŃŠµŃ обмена."
+
+#: internal/app/clipboard.go:271
+msgid "please set begin and endmark"
+msgstr "ŠŠ¾Š¶Š°Š»ŃйŃŃŠ°, ŃŃŃŠ°Š½Š¾Š²ŠøŃе Š½Š°Ńало Šø конеŃ."
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "plus"
+msgstr "плŃŃ"
+
+#: internal/command/descriptions.go:23
+msgid "presents the current character."
+msgstr "Š”Š¾Š¾Š±ŃŠ°ŃŃ ŃŠµŠŗŃŃŠøŠ¹ ŃŠøŠ¼Š²Š¾Š»."
+
+#: internal/command/descriptions.go:15
+msgid "presents the date"
+msgstr "Š”Š¾Š¾Š±ŃŠøŃŃ Š“Š°ŃŃ."
+
+#: internal/command/descriptions.go:14
+msgid "presents the time"
+msgstr "Š”Š¾Š¾Š±ŃŠøŃŃ Š²ŃŠµŠ¼Ń"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "question?"
+msgstr "Š²Š¾ŠæŃŠ¾ŃŠøŃŠµŠ»ŃŠ½ŃŠ¹ знак"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "quote"
+msgstr "кавŃŃŠŗŠø"
+
+#: internal/command/descriptions.go:46
+msgid ""
+"read line to cursor pos, use review cursor if you are in review mode, "
+"otherwhise use text cursor"
+msgstr ""
+"ŠæŃŠ¾ŃŠøŃŠ°ŃŃ ŃŃŃŠ¾ŠŗŃ Го ŠŗŃŃŃŠ¾Ńа; в ŃŠµŠ¶ŠøŠ¼Šµ Š¾Š±Š·Š¾ŃŠ° ŠøŃŠæŠ¾Š»ŃŠ·Š¾Š²Š°ŃŃ ŠŗŃŃŃŠ¾Ń Š¾Š±Š·Š¾ŃŠ°, "
+"ŠøŠ½Š°ŃŠµ ŃŠµŠŗŃŃŠ¾Š²Ńй ŠŗŃŃŃŠ¾Ń"
+
+#: internal/command/descriptions.go:47
+msgid ""
+"read to end of line, use review cursor if you are in review mode, otherwhise "
+"use text cursor"
+msgstr ""
+"Š§ŃŠµŠ½ŠøŠµ Го ŠŗŠ¾Š½Ńа ŃŃŃŠ¾ŠŗŠø, ŠøŃŠæŠ¾Š»ŃŠ·ŃŠ¹ŃŠµ ŠŗŃŃŃŠ¾Ń Š¾Š±Š·Š¾ŃŠ°, ŠµŃŠ»Šø Š²Ń Š½Š°Ń
Š¾Š“ŠøŃŠµŃŃ Š² "
+"ŃŠµŠ¶ŠøŠ¼Šµ ŠæŃŠ¾ŃмоŃŃŠ°, ŠøŠ½Š°ŃŠµ ŠøŃŠæŠ¾Š»ŃŠ·ŃŠ¹ŃŠµ ŃŠµŠŗŃŃŠ¾Š²Ńй ŠŗŃŃŃŠ¾Ń."
+
+#: internal/command/descriptions.go:40
+msgid "reads from the cursor to the bottom of the screen"
+msgstr "Š§ŃŠµŠ½ŠøŠµ Š¾Ń ŠŗŃŃŃŠ¾Ńа Го ŠŗŠ¾Š½Ńа ŃŠŗŃана."
+
+#: internal/command/descriptions.go:38
+msgid "reads the contents of the current screen"
+msgstr "Š§ŃŠµŠ½ŠøŠµ ŃŠ¾Š“ŠµŃŠ¶ŠøŠ¼Š¾Š³Š¾ ŃŠµŠŗŃŃŠµŠ³Š¾ ŃŠŗŃана."
+
+#: internal/presentation/attributes.go
+msgid "red"
+msgstr "ŠŗŃŠ°ŃŠ½ŃŠ¹"
+
+#: internal/presentation/attributes.go
+msgid "reverse"
+msgstr "ŠŠ±ŃŠ°ŃŠ½Ńй"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "right brace"
+msgstr "ŠæŃŠ°Š²Š°Ń ŃŠøŠ³ŃŃŠ½Š°Ń ŃŠŗŠ¾Š±ŠŗŠ°"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "right bracket"
+msgstr "ŠæŃŠ°Š²Š°Ń ŠŗŠ²Š°Š“ŃŠ°ŃŠ½Š°Ń ŃŠŗŠ¾Š±ŠŗŠ°"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "right paren"
+msgstr "ŠæŃŠ°Š²Š°Ń ŠŗŃŃŠ³Š»Š°Ń ŃŠŗŠ¾Š±ŠŗŠ°"
+
+#: internal/reader/regions.go:259
+msgid "row {0}, column {1} to end of line"
+msgstr "ŃŃŃŠ¾ŠŗŠ° {0}, ŃŃŠ¾Š»Š±ŠµŃ {1} Го ŠŗŠ¾Š½Ńа ŃŃŃŠ¾ŠŗŠø"
+
+#: internal/reader/regions.go:261
+msgid "row {0}, column {1} to row {2}, column {3}"
+msgstr "Š¾Ń ŃŃŃŠ¾ŠŗŠø {0}, ŃŃŠ¾Š»Š±Ńа {1} Го ŃŃŃŠ¾ŠŗŠø {2}, ŃŃŠ¾Š»Š±Ńа {3}"
+
+#: internal/review/read_screen.go:34
+msgid "screen is empty"
+msgstr "ŠŠŗŃан ŠæŃŃŃŠ¾Š¹."
+
+#: internal/reader/reader.go:132
+msgid "screen {0}"
+msgstr "ŠŠŗŃан {0}"
+
+#: internal/interaction/entry.go:31
+msgid "selected"
+msgstr "Š²ŃŠ“елено"
+
+#: internal/command/descriptions.go:106
+msgid "selects the first clipboard"
+msgstr "ŠŃŠ±ŠøŃŠ°ŠµŃ ŠæŠµŃŠ²Ńй бŃŃŠµŃ обмена."
+
+#: internal/command/descriptions.go:107
+msgid "selects the last clipboard"
+msgstr "ŠŃŠ±ŠøŃŠ°ŠµŃ ŠæŠ¾ŃŠ»ŠµŠ“ний бŃŃŠµŃ обмена."
+
+#: internal/command/descriptions.go:105
+msgid "selects the next clipboard"
+msgstr "ŠŃŠ±ŠøŃŠ°ŠµŃ ŃŠ»ŠµŠ“ŃŃŃŠøŠ¹ бŃŃŠµŃ обмена."
+
+#: internal/command/descriptions.go:104
+msgid "selects the previous clipboard"
+msgstr "ŠŃŠ±ŠøŃŠ°ŠµŃ ŠæŃŠµŠ“ŃŠ“ŃŃŠøŠ¹ бŃŃŠµŃ обмена."
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "semicolon"
+msgstr "ŃŠ¾Ńка Ń Š·Š°ŠæŃŃŠ¾Š¹"
+
+#: internal/command/descriptions.go:70
+msgid "sends the following keypress to the terminal or application"
+msgstr "ŠŃŠæŃŠ°Š²Š»ŃŠµŃ ŃŠ»ŠµŠ“ŃŃŃŠµŠµ Š½Š°Š¶Š°ŃŠøŠµ на ŃŠµŃминал или ŠæŃиложение."
+
+#: internal/command/descriptions.go:72
+msgid "set Window Mode, needs 2 marks "
+msgstr "Š£ŃŃŠ°Š½Š¾Š²ŠŗŠ° ŃŠµŠ¶ŠøŠ¼Š° окна Š½ŃжГаеŃŃŃ Š² ГвŃŃ
Š¼ŠµŃŠŗŠ°Ń
"
+
+#: internal/review/marks.go:43
+msgid "set mark"
+msgstr "Š£ŃŃŠ°Š½Š¾Š²ŠŗŠ° Š¼ŠµŃŠŗŠø."
+
+#: internal/command/descriptions.go:28
+msgid "set review and phonetically presents the current character"
+msgstr "Š£ŃŃŠ°Š½Š¾Š²ŠøŃŃ Š¾Š±Š·Š¾Ń Šø ŃŠ¾Š½ŠµŃŠøŃŠµŃŠŗŠø ŠæŃŠµŠ“ŃŃŠ°Š²Š»ŃŃŃ ŃŠµŠŗŃŃŠøŠ¹ ŃŠøŠ¼Š²Š¾Š»."
+
+#: internal/command/descriptions.go:34
+msgid "set review cursor to begin of current line and display the content"
+msgstr ""
+"Š£ŃŃŠ°Š½Š¾Š²ŠøŃŃ ŠŗŃŃŃŠ¾Ń ŠæŃŠ¾ŃмоŃŃŠ° в Š½Š°Ńало ŃŠµŠŗŃŃŠµŠ¹ ŃŃŃŠ¾ŠŗŠø Šø Š¾ŃŠ¾Š±ŃазиŃŃ ŃŠ¾Š“ŠµŃŠ¶ŠøŠ¼Š¾Šµ."
+
+#: internal/punctuation/emoticons.go:16
+msgid "skeptical"
+msgstr "ŃŠŗŠµŠæŃŠøŃ"
+
+#: internal/punctuation/emoticons.go:21
+msgid "skull and crossbones"
+msgstr "ŃŠµŃеп Ń ŠŗŠ¾ŃŃŃŠ¼Šø"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "slash"
+msgstr "ŠŗŠ¾ŃŠ°Ń ŃŠµŃŃŠ°"
+
+#: internal/punctuation/emoticons.go:10
+msgid "smile"
+msgstr "ŃŠ»Ńбка"
+
+#: internal/punctuation/profile.go
+msgid "some"
+msgstr "Š½ŠµŠŗŠ¾ŃŠ¾ŃŃŠµ"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "space"
+msgstr "ŠæŃŠ¾Š±ŠµŠ»"
+
+#: internal/command/descriptions.go:103
+msgid "speaks the contents of the currently selected clipboard"
+msgstr "ŠŠ¾Š²Š¾ŃŠøŃ ŃŠ¾Š“ŠµŃŠ¶ŠøŠ¼Š¾Šµ Š²ŃŠ±Ńанного бŃŃŠµŃа обмена."
+
+#: internal/config/definitions.go:206
+msgid "speech"
+msgstr "ŃŠµŃŃ"
+
+#: internal/config/definitions.go:206
+msgid "speech and beeps"
+msgstr "ŃŠµŃŃ Šø Š·Š²ŃŠŗŠ¾Š²Ńе ŃŠøŠ³Š½Š°Š»Ń"
+
+#: internal/app/commands.go:59 internal/app/feedback.go:45
+msgid "speech enabled"
+msgstr "РеŃŃ Š²ŠŗŠ»ŃŃŠµŠ½Š°"
+
+#: internal/config/config.go:131
+msgid "speech language"
+msgstr "ŃŠ·ŃŠŗ ŃŠµŃŠø"
+
+#: internal/config/config.go:133
+msgid "speech pitch"
+msgstr "вŃŃŠ¾Ńа Š³Š¾Š»Š¾Ńа"
+
+#: internal/config/config.go:126
+msgid "speech rate"
+msgstr "ŃŠŗŠ¾ŃоŃŃŃ ŃŠµŃŠø"
+
+#: internal/app/commands.go:121
+msgid "speech temporary disabled"
+msgstr "РеŃŃ Š²ŃŠµŠ¼ŠµŠ½Š½Š¾ Š¾ŃŠŗŠ»ŃŃŠµŠ½Š°"
+
+#: internal/config/config.go:132
+msgid "speech voice"
+msgstr "Š³Š¾Š»Š¾Ń ŃŠøŠ½ŃŠµŠ·Š°ŃŠ¾Ńа"
+
+#: internal/config/config.go:134
+msgid "speech volume"
+msgstr "Š³ŃŠ¾Š¼ŠŗŠ¾ŃŃŃ ŃŠµŃŠø"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "star"
+msgstr "Š·Š²ŃŠ·Š“Š¾ŃŠŗŠ°"
+
+#: internal/review/table.go:212
+msgid "start of cell"
+msgstr "Š½Š°ŃŠ°Š»Š¾ ŃŃŠµŠ¹ŠŗŠø"
+
+#: internal/review/table.go:168
+msgid "start of line"
+msgstr "Š½Š°ŃŠ°Š»Š¾ ŃŃŃŠ¾ŠŗŠø"
+
+#: internal/review/navigation.go:20
+msgid "start of screen"
+msgstr "Š½Š°ŃŠ°Š»Š¾ ŃŠŗŃана"
+
+#: internal/presentation/attributes.go
+msgid "strikethrough"
+msgstr "ŠŠ°ŃŠµŃŠŗŠ½ŃŃŃŠ¹"
+
+#: internal/punctuation/emoticons.go:15
+msgid "surprised"
+msgstr "ŃŠ“ивление"
+
+#: internal/presentation/text.go
+msgid "tab"
+msgstr "ŃŠ°Š±ŃŠ»ŃŃŠøŃ"
+
+#: internal/config/definitions.go:225 internal/reader/reader.go:184
+msgid "table mode"
+msgstr "ŃŠµŠ¶ŠøŠ¼ ŃŠ°Š±Š»ŠøŃŃ"
+
+#: config/punctuation/default.toml config/punctuation/en.toml
+#: config/punctuation/rhvoice-en.toml
+msgid "tilde"
+msgstr "ŃŠøŠ»ŃГа"
+
+#: internal/command/descriptions.go:51
+msgid "toggles the tutorial mode"
+msgstr "ŠŠµŃеклŃŃŠµŠ½ŠøŠµ в ŃŠµŠ¶ŠøŠ¼ обŃŃŠµŠ½ŠøŃ"
+
+#: internal/punctuation/emoticons.go:14
+msgid "tongue out"
+msgstr "вŃŃŃŠ½ŃŃŃŠ¹ ŃŠ·ŃŠŗ"
+
+#: internal/interaction/presentation.go:62
+msgid "unavailable"
+msgstr "неГоŃŃŃŠæŠ½Š¾"
+
+#: internal/app/tutorial.go:107 internal/app/tutorial.go:161
+#: internal/reader/reader.go:199 internal/review/review.go:140
+msgid "unbound"
+msgstr "не Š½Š°Š·Š½Š°Ńено"
+
+#: internal/interaction/presentation.go
+msgid "unchecked"
+msgstr "не Š¾ŃмеŃено"
+
+#: internal/presentation/attributes.go
+msgid "underline"
+msgstr "ŠŠ¾Š“ŃŠµŃкнŃŃŃŠ¹"
+
+#: internal/presentation/attributes.go internal/presentation/attributes.go:27
+msgid "white"
+msgstr "Š±ŠµŠ»ŃŠ¹"
+
+#: internal/punctuation/emoticons.go:13
+msgid "wink"
+msgstr "поГмигивание"
+
+#: internal/app/spellcheck_menu.go
+msgid "{0} added to dictionary"
+msgstr "{0} Гобавлено в ŃŠ»Š¾Š²Š°ŃŃ"
+
+#: internal/interaction/presentation.go:53
+msgid "{0} button"
+msgstr "{0}, кнопка"
+
+#: internal/app/internal_ui.go:122
+msgid "{0} closed"
+msgstr "{0} закŃŃŃŠ¾"
+
+#: internal/interaction/presentation.go:51
+msgid "{0} combo box {1}"
+msgstr "{0}, поле ŃŠ¾ ŃŠæŠøŃŠŗŠ¾Š¼ {1}"
+
+#: internal/interaction/presentation.go:45
+msgid "{0} entry {1}"
+msgstr "{0}, поле ввоГа {1}"
+
+#: internal/interaction/presentation.go:49
+msgid "{0} grid. {1}"
+msgstr "{0}, ŃŠµŃка. {1}"
+
+#: internal/app/spellcheck_menu.go:134
+msgid "{0} is already in dictionary"
+msgstr "{0} ŃŠ¶Šµ наŃ
оГиŃŃŃ Š² ŃŠ»Š¾Š²Š°Ńе"
+
+#: internal/app/spellcheck_menu.go
+msgid "{0} removed"
+msgstr "{0} ŃŠ“алено"
+
+#: internal/interaction/presentation.go:31
+msgid "{0} tab"
+msgstr "{0}, вклаГка"
+
+#: internal/punctuation/text_processing.go:69
+msgid "{0} {1}"
+msgstr "{0} {1}"
+
+#: internal/interaction/grid.go:39
+msgid "{0}. row {1}, column {2}. {3} of {4}"
+msgstr "{0}. ŃŃŃŠ¾ŠŗŠ° {1}, ŃŃŠ¾Š»Š±ŠµŃ {2}. {3} ŠøŠ· {4}"
+
+#: internal/app/tutorial.go:25
+msgid ""
+"{command_name}, Shortcuts {command_shortcuts}, Description "
+"{command_description}"
+msgstr ""
+"{command_name}, Š”Š¾ŃŠµŃŠ°Š½ŠøŃ ŠŗŠ»Š°Š²ŠøŃ {command_shortcuts}, ŠŠæŠøŃŠ°Š½ŠøŠµ "
+"{command_description}"
+
+#: internal/app/tutorial.go internal/app/tutorial.go:66
+msgid "{} times "
+msgstr "{} ŃŠ°Š· "
diff --git a/internal/input/additional_review_test.go b/internal/input/additional_review_test.go
new file mode 100644
index 00000000..527428ec
--- /dev/null
+++ b/internal/input/additional_review_test.go
@@ -0,0 +1,79 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package input
+
+import (
+ "testing"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+)
+
+func TestAdditionalReviewChordsConsumeHoldsAndBalanceReleases(t *testing.T) {
+ for _, layout := range []string{"desktop", "laptop"} {
+ bindings, _, err := Load_bindings([]string{"../../config/keyboard/" + layout + ".toml"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, trial := range []struct {
+ desktop, laptop uint16
+ shift bool
+ want command.ID
+ }{
+ {KeyKP7, 22, true, command.CursorLine},
+ {KeyKP9, 24, true, command.CursorColumn},
+ {KeyKP4, 36, true, command.LineToCursor},
+ {KeyKP6, 38, true, command.LineFromCursor},
+ {KeyKP1, 50, false, command.ScreenFirstChar},
+ {KeyKP3, 52, false, command.ScreenLastChar},
+ } {
+ for _, controlKey := range []uint16{KeyCtrl, KeyRightCtrl} {
+ for _, modifierFirst := range []bool{false, true} {
+ for _, numLock := range []bool{false, true} {
+ router, err := New_router(bindings)
+ if err != nil {
+ t.Fatal(err)
+ }
+ keyCode := trial.desktop
+ modifierCodes := []uint16{KeyInsert, controlKey}
+ if layout == "laptop" {
+ keyCode = trial.laptop
+ if trial.shift {
+ modifierCodes = append(modifierCodes, KeyRightShift)
+ }
+ }
+ forwarded := map[uint16]bool{}
+ for _, code := range modifierCodes {
+ forwarded[code] = router.Key(1, code, 1, numLock, false).Forward
+ }
+ if got := router.Key(0, keyCode, 1, numLock, false); got.Command != trial.want || got.Forward || !got.Interrupt {
+ t.Fatal(layout, trial.want, got)
+ }
+ if got := router.Key(0, keyCode, 2, numLock, false); got.Command != "" || got.Forward || got.Interrupt {
+ t.Fatal("hold repeated command", trial.want, got)
+ }
+ releases := append([]uint16{keyCode}, modifierCodes...)
+ if modifierFirst {
+ releases = append(modifierCodes, keyCode)
+ }
+ for _, code := range releases {
+ device := 1
+ if code == keyCode {
+ device = 0
+ }
+ if got := router.Key(device, code, 0, numLock, false); got.Command != "" || got.Forward != forwarded[code] {
+ t.Fatal("unbalanced release", layout, trial.want, code, got)
+ }
+ }
+ if router.Held(0) || router.Held(1) {
+ t.Fatal("stuck additional-review chord")
+ }
+ if got := router.Key(0, 30, 1, numLock, false); !got.Forward || got.Command != "" {
+ t.Fatal("typing after report", got)
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/internal/input/attributes_test.go b/internal/input/attributes_test.go
new file mode 100644
index 00000000..b9b08d96
--- /dev/null
+++ b/internal/input/attributes_test.go
@@ -0,0 +1,97 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package input
+
+import (
+ "git.stormux.org/storm/fenrir/internal/command"
+ "testing"
+)
+
+func TestAttributeBindingsNumLockRepeatsAndRelease(t *testing.T) {
+ for _, layout := range []string{"desktop", "laptop"} {
+ bindings, _, err := Load_bindings([]string{"../../config/keyboard/" + layout + ".toml"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, numLock := range []bool{false, true} {
+ for _, modifierFirst := range []bool{false, true} {
+ router, err := New_router(bindings)
+ if err != nil {
+ t.Fatal(err)
+ }
+ code := uint16(74)
+ if layout == "laptop" {
+ code = 30
+ router.Key(0, KeyInsert, 1, numLock, false)
+ }
+ for range 2 {
+ got := router.Key(0, code, 1, numLock, false)
+ forwarded := layout == "desktop" && numLock
+ if got.Forward != forwarded || (!forwarded && got.Command != command.AttributeCursor) || (forwarded && got.Command != "") {
+ t.Fatal(layout, numLock, got)
+ }
+ got = router.Key(0, code, 2, numLock, false)
+ if got.Command != "" || got.Forward != forwarded {
+ t.Fatal("held report", got)
+ }
+ router.Key(0, code, 0, numLock, false)
+ }
+ router.Key(0, code, 1, numLock, false)
+ if modifierFirst && layout == "laptop" {
+ router.Key(0, KeyInsert, 0, numLock, false)
+ }
+ router.Key(0, code, 0, numLock, false)
+ if !modifierFirst && layout == "laptop" {
+ router.Key(0, KeyInsert, 0, numLock, false)
+ }
+ if router.Held(0) {
+ t.Fatal("stuck attributes chord", layout)
+ }
+ if got := router.Key(0, 48, 1, numLock, false); !got.Forward || got.Command != "" {
+ t.Fatal("typing after attributes", got)
+ }
+ }
+ }
+ }
+}
+
+func TestAttributeToggleChordBothLayoutsNumLockAndReleases(t *testing.T) {
+ for _, layout := range []string{"desktop", "laptop"} {
+ bindings, _, err := Load_bindings([]string{"../../config/keyboard/" + layout + ".toml"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, numLock := range []bool{false, true} {
+ for _, modifierFirst := range []bool{false, true} {
+ router, err := New_router(bindings)
+ if err != nil {
+ t.Fatal(err)
+ }
+ router.Key(0, KeyInsert, 1, numLock, false)
+ got := router.Key(0, 74, 1, numLock, false)
+ if got.Command != command.ToggleAttributes || got.Forward || !got.Interrupt {
+ t.Fatal(layout, numLock, got)
+ }
+ if got := router.Key(0, 74, 2, numLock, false); got.Command != "" || got.Forward {
+ t.Fatal("repeat toggle", got)
+ }
+ releases := []uint16{74, KeyInsert}
+ if modifierFirst {
+ releases = []uint16{KeyInsert, 74}
+ }
+ for _, code := range releases {
+ if got := router.Key(0, code, 0, numLock, false); got.Command != "" || got.Forward {
+ t.Fatal("release", got)
+ }
+ }
+ if router.Held(0) {
+ t.Fatal("stuck toggle")
+ }
+ if got := router.Key(0, 48, 1, numLock, false); !got.Forward || got.Command != "" {
+ t.Fatal("typing", got)
+ }
+ }
+ }
+ }
+}
diff --git a/internal/input/bindings.go b/internal/input/bindings.go
new file mode 100644
index 00000000..7b0ab331
--- /dev/null
+++ b/internal/input/bindings.go
@@ -0,0 +1,136 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package input
+
+import (
+ "bytes"
+ "fmt"
+ "strings"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "github.com/pelletier/go-toml/v2"
+)
+
+type Binding struct {
+ Name string `toml:"name"`
+ Keys []string `toml:"keys"`
+ Command command.ID `toml:"command"`
+ DoubleCommand command.ID `toml:"double_command,omitempty"`
+ TripleCommand command.ID `toml:"triple_command,omitempty"`
+}
+
+var keyCodes = map[string]uint16{
+ "F": KeyF, "Q": KeyQ, "H": KeyH, "F4": KeyF4, "BACKSPACE": KeyBackspace,
+ "KP7": KeyKP7, "KP8": KeyKP8, "KP9": KeyKP9, "KP4": KeyKP4,
+ "KP5": KeyKP5, "KP6": KeyKP6, "KP1": KeyKP1, "KP2": KeyKP2,
+ "KP3": KeyKP3, "KPDOT": KeyKPDot, "KPENTER": KeyKPEnter,
+ "KPASTERISK": 55, "KPPLUS": KeyKPPlus,
+ "A": 30, "B": 48, "C": 46, "D": 32, "E": 18, "G": 34,
+ "I": 23, "J": 36, "K": 37, "L": 38, "M": 50, "N": 49,
+ "O": 24, "P": 25, "R": 19, "S": 31, "T": 20, "U": 22,
+ "V": 47, "W": 17, "X": 45, "Y": 21, "Z": 44,
+ "0": 11, "1": 2, "2": 3, "3": 4, "4": 5, "5": 6,
+ "6": 7, "7": 8, "8": 9, "9": 10,
+ "COMMA": 51, "DOT": 52, "SLASH": 53,
+ "UP": KeyUp, "DOWN": KeyDown, "LEFT": KeyLeft, "RIGHT": KeyRight,
+ "HOME": KeyHome, "END": KeyEnd, "DELETE": KeyDelete,
+ "TAB": KeyTab, "ENTER": KeyEnter, "SPACE": KeySpace, "ESC": KeyEsc,
+ "PAGEUP": 104, "PAGEDOWN": 109, "F5": 63, "F6": 64, "F7": 65, "F8": 66,
+ "F1": 59, "KPMINUS": 74, "KPSLASH": 98, "RIGHTBRACE": 27,
+}
+
+type modifiers uint8
+
+const (
+ fenrirModifier modifiers = 1 << iota
+ ctrlModifier
+ shiftModifier
+ altModifier
+)
+
+type chord struct {
+ key uint16
+ modifiers modifiers
+}
+
+var modifierNames = map[string]modifiers{"FENRIR": fenrirModifier, "CTRL": ctrlModifier, "SHIFT": shiftModifier, "ALT": altModifier}
+
+// Decode_bindings rejects unsupported keys, commands and duplicate chords.
+func Decode_bindings(data []byte) ([]Binding, error) {
+ var file struct {
+ Version int `toml:"version"`
+ Bindings []Binding `toml:"bindings"`
+ }
+ if err := toml.NewDecoder(bytes.NewReader(data)).DisallowUnknownFields().Decode(&file); err != nil {
+ return nil, err
+ }
+ if file.Version != 1 {
+ return nil, fmt.Errorf("unsupported keyboard version %d", file.Version)
+ }
+ _, err := New_router(file.Bindings)
+ return file.Bindings, err
+}
+
+// New_router compiles a private layout. Reset and capture preserve these bindings.
+func New_router(bindings []Binding) (Router, error) {
+ router := Router{bindings: make(map[chord]command.ID), doubleBindings: make(map[chord]command.ID), tripleBindings: make(map[chord]command.ID), multiPressTimeout: DefaultMultiPressTimeout}
+ if len(bindings) == 0 {
+ return Router{}, fmt.Errorf("keyboard layout has no bindings")
+ }
+ for _, binding := range bindings {
+ if binding.TripleCommand != "" && (!command.Known(binding.TripleCommand) || binding.DoubleCommand == "") {
+ return Router{}, fmt.Errorf("invalid triple command in binding %q (requires a double command)", binding.Name)
+ }
+ if binding.DoubleCommand != "" && !command.Known(binding.DoubleCommand) {
+ return Router{}, fmt.Errorf("invalid double command in binding %q", binding.Name)
+ }
+ if strings.TrimSpace(binding.Name) == "" || len(binding.Keys) == 0 || !command.Known(binding.Command) {
+ return Router{}, fmt.Errorf("invalid keyboard binding %q", binding.Name)
+ }
+ key, valid := keyCodes[binding.Keys[len(binding.Keys)-1]]
+ if !valid {
+ return Router{}, fmt.Errorf("unsupported key in binding %q", binding.Name)
+ }
+ var mods modifiers
+ for _, name := range binding.Keys[:len(binding.Keys)-1] {
+ modifier, valid := modifierNames[name]
+ if !valid || mods&modifier != 0 {
+ return Router{}, fmt.Errorf("invalid modifiers in binding %q", binding.Name)
+ }
+ mods |= modifier
+ }
+ // Both adapters can intercept Fenrir chords and bare review keypad keys.
+ // Other bare keys would require additional X11 passive grabs.
+ bareKeypad := false
+ switch key {
+ case KeyKP1, KeyKP2, KeyKP3, KeyKP4, KeyKP5, KeyKP6, KeyKP7, KeyKP8, KeyKP9, KeyKPDot, KeyKPEnter, KeyKPPlus, 74, 98:
+ bareKeypad = true
+ }
+ if mods&fenrirModifier == 0 && (mods != 0 || !bareKeypad) {
+ return Router{}, fmt.Errorf("binding %q requires FENRIR outside bare review keypad keys", binding.Name)
+ }
+ combo := chord{key: key, modifiers: mods}
+ if _, exists := router.bindings[combo]; exists {
+ return Router{}, fmt.Errorf("duplicate keyboard chord in binding %q", binding.Name)
+ }
+ router.bindings[combo] = binding.Command
+ router.doubleBindings[combo] = binding.DoubleCommand
+ router.tripleBindings[combo] = binding.TripleCommand
+ }
+ return router, nil
+}
diff --git a/internal/input/bindings_test.go b/internal/input/bindings_test.go
new file mode 100644
index 00000000..d82faa61
--- /dev/null
+++ b/internal/input/bindings_test.go
@@ -0,0 +1,289 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package input
+
+import (
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+)
+
+func desktop_data(t *testing.T) []byte {
+ t.Helper()
+ data, err := os.ReadFile("../../config/keyboard/desktop.toml")
+ if err != nil {
+ t.Fatal(err)
+ }
+ return data
+}
+
+func desktop_router(t *testing.T) Router {
+ t.Helper()
+ bindings, err := Decode_bindings(desktop_data(t))
+ if err != nil {
+ t.Fatal(err)
+ }
+ router, err := New_router(bindings)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return router
+}
+
+func TestRuntimeLayoutsAreIndependentAndRetainedAcrossReset(t *testing.T) {
+ root := t.TempDir()
+ path := filepath.Join(root, "custom.toml")
+ if err := os.WriteFile(path, []byte(strings.Replace(string(desktop_data(t)), `keys = ["FENRIR", "F"]`, `keys = ["FENRIR", "G"]`, 1)), 0600); err != nil {
+ t.Fatal(err)
+ }
+ bindings, selected, err := Load_bindings([]string{path})
+ if err != nil || selected != path {
+ t.Fatal(selected, err)
+ }
+ custom, err := New_router(bindings)
+ if err != nil {
+ t.Fatal(err)
+ }
+ desktop := desktop_router(t)
+ for range 2 {
+ custom.Key(0, KeyInsert, 1, false, false)
+ if got := custom.Key(0, 34, 1, false, false); got.Command != command.FenrirMenu || got.Forward {
+ t.Fatal("custom binding unused", got)
+ }
+ if got := custom.Key(0, KeyF, 1, false, false); got.Command != command.Unbound || got.Forward {
+ t.Fatal("desktop merged into custom", got)
+ }
+ custom.Reset()
+ }
+ desktop.Key(0, KeyInsert, 1, false, false)
+ if got := desktop.Key(0, KeyF, 1, false, false); got.Command != command.FenrirMenu {
+ t.Fatal("custom mutated another router", got)
+ }
+ // Editing the file affects the next load, without rebuilding or mutating
+ // an already running router.
+ if err := os.WriteFile(path, desktop_data(t), 0600); err != nil {
+ t.Fatal(err)
+ }
+ updated, _, err := Load_bindings([]string{path})
+ if err != nil {
+ t.Fatal(err)
+ }
+ reloaded, err := New_router(updated)
+ if err != nil {
+ t.Fatal(err)
+ }
+ reloaded.Key(0, KeyInsert, 1, false, false)
+ if got := reloaded.Key(0, KeyF, 1, false, false); got.Command != command.FenrirMenu {
+ t.Fatal("disk edit not loaded", got)
+ }
+}
+
+func TestBothLayoutsProvideAllClipboardCommands(t *testing.T) {
+ want := []command.ID{command.SetMark, command.RemoveMarks, command.MarkedText, command.CopyMarked, command.CopyLastEcho, command.CurrentClipboard, command.PreviousClipboard, command.NextClipboard, command.FirstClipboard, command.LastClipboard, command.ClearClipboard, command.PasteClipboard, command.ImportClipboard, command.ExportClipboard, command.ImportXClipboard, command.ExportXClipboard}
+ for _, layout := range []string{"desktop", "laptop"} {
+ bindings, _, err := Load_bindings([]string{"../../config/keyboard/" + layout + ".toml"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ counts := make(map[command.ID]int)
+ for _, binding := range bindings {
+ counts[binding.Command]++
+ }
+ for _, id := range want {
+ if counts[id] != 1 {
+ t.Fatal(layout, id, counts[id])
+ }
+ }
+ }
+}
+
+func TestPythonTrackingChordsConsumePressRepeatAndBothReleases(t *testing.T) {
+ for _, fixture := range []struct {
+ layout string
+ key uint16
+ }{{"desktop", 55}, {"laptop", 21}} {
+ bindings, _, err := Load_bindings([]string{"../../config/keyboard/" + fixture.layout + ".toml"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, modifierFirst := range []bool{false, true} {
+ for _, numLock := range []bool{false, true} {
+ router, err := New_router(bindings)
+ if err != nil {
+ t.Fatal(err)
+ }
+ router.Key(0, KeyInsert, 1, numLock, false)
+ if got := router.Key(0, fixture.key, 1, numLock, false); got.Command != command.ToggleTracking || got.Forward {
+ t.Fatal(fixture.layout, got)
+ }
+ if got := router.Key(0, fixture.key, 2, numLock, false); got.Command != "" || got.Forward {
+ t.Fatal("mode repeated", got)
+ }
+ keys := []uint16{fixture.key, KeyInsert}
+ if modifierFirst {
+ keys[0], keys[1] = keys[1], keys[0]
+ }
+ for _, key := range keys {
+ if got := router.Key(0, key, 0, numLock, false); got.Forward {
+ t.Fatal("mode release leaked", got)
+ }
+ }
+ }
+ }
+ }
+}
+
+func TestLaptopRuntimeLayoutAndModifierChords(t *testing.T) {
+ bindings, _, err := Load_bindings([]string{"../../config/keyboard/laptop.toml"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, fixture := range []struct {
+ key, modifier uint16
+ want command.ID
+ }{
+ {23, 0, command.CurrentLine}, {22, KeyShift, command.Top}, {36, KeyCtrl, command.FirstText},
+ {52, KeyShift, command.Position}, {KeyF, 0, command.FenrirMenu},
+ } {
+ router, err := New_router(bindings)
+ if err != nil {
+ t.Fatal(err)
+ }
+ router.Key(0, KeyInsert, 1, false, false)
+ if fixture.modifier != 0 {
+ router.Key(1, fixture.modifier, 1, false, false)
+ }
+ if got := router.Key(0, fixture.key, 1, false, false); got.Command != fixture.want || got.Forward {
+ t.Fatal(fixture, got)
+ }
+ router.Key(0, KeyInsert, 0, false, false)
+ if got := router.Key(0, fixture.key, 0, false, false); got.Forward {
+ t.Fatal("laptop release leaked", got)
+ }
+ }
+}
+
+func TestLayoutPathSelection(t *testing.T) {
+ home, bundle := t.TempDir(), t.TempDir()
+ paths, err := Layout_paths("laptop", home, 1000, bundle)
+ if err != nil || !reflect.DeepEqual(paths, []string{filepath.Join(home, ".local/share/stormux/fenrirscreenreader/keyboard/laptop.toml"), "/etc/fenrirscreenreader/keyboard/laptop.toml", filepath.Join(bundle, "laptop.toml")}) {
+ t.Fatal(paths, err)
+ }
+ paths, err = Layout_paths("desktop.toml", home, 0, bundle)
+ if err != nil || len(paths) != 2 || paths[0] != "/etc/fenrirscreenreader/keyboard/desktop.toml" {
+ t.Fatal(paths, err)
+ }
+ path := filepath.Join(t.TempDir(), "custom.toml")
+ paths, err = Layout_paths(path, home, 1000, bundle)
+ if err != nil || !reflect.DeepEqual(paths, []string{path}) {
+ t.Fatal(paths, err)
+ }
+ for _, value := range []string{"", "desktop.conf", "../desktop", "../desktop.conf", "\x00"} {
+ if _, err := Layout_paths(value, home, 1000, bundle); err == nil {
+ t.Fatal("invalid selection", value)
+ }
+ }
+}
+
+func TestInvalidLayoutNeverFallsThroughOrMerges(t *testing.T) {
+ root := t.TempDir()
+ bad, good := filepath.Join(root, "bad.toml"), filepath.Join(root, "desktop.toml")
+ if err := os.WriteFile(good, desktop_data(t), 0600); err != nil {
+ t.Fatal(err)
+ }
+ for _, data := range []string{"version=1\n", "version=1\nunknown=true\n", strings.Repeat("#", (1<<20)+1)} {
+ if err := os.WriteFile(bad, []byte(data), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if _, selected, err := Load_bindings([]string{bad, good}); err == nil || selected != bad {
+ t.Fatal("bad layout ignored", selected, err)
+ }
+ }
+ if _, _, err := Load_bindings([]string{filepath.Join(root, "missing.toml")}); err == nil {
+ t.Fatal("missing layout accepted")
+ }
+ if _, selected, err := Load_bindings([]string{filepath.Join(root, "missing.toml"), good}); err != nil || selected != good {
+ t.Fatal(selected, err)
+ }
+ base := "version=1\n[[bindings]]\nname='Example'\nkeys=%s\ncommand='help'\n"
+ for _, keys := range []string{`['FENRIR','FENRIR','F']`, `['CTRL','F']`, `['A']`, `['FENRIR','UNKNOWN','F']`} {
+ if _, err := Decode_bindings([]byte(strings.Replace(base, "%s", keys, 1))); err == nil {
+ t.Fatal("unsupported chord accepted", keys)
+ }
+ }
+ duplicate := strings.Replace(base, "%s", "['FENRIR','SHIFT','F']", 1) + "[[bindings]]\nname='Duplicate'\nkeys=['SHIFT','FENRIR','F']\ncommand='quit'\n"
+ if _, err := Decode_bindings([]byte(duplicate)); err == nil {
+ t.Fatal("reordered duplicate modifiers accepted")
+ }
+}
+
+func TestQuickSettingsChordsInBothLayouts(t *testing.T) {
+ for _, layout := range []string{"desktop", "laptop"} {
+ bindings, _, err := Load_bindings([]string{"../../config/keyboard/" + layout + ".toml"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, binding := range bindings {
+ if binding.Command == command.CurrentQuickValue {
+ t.Fatal("bundled layout retains the redundant value-only shortcut", layout, binding)
+ }
+ }
+ for _, testCase := range []struct {
+ key uint16
+ ctrl, repeats bool
+ want command.ID
+ }{
+ {KeySpace, false, false, command.CurrentQuickEntry},
+ {KeyRight, true, true, command.NextQuickCategory},
+ {KeyLeft, true, true, command.PreviousQuickCategory},
+ {KeyRight, false, true, command.NextQuickEntry},
+ {KeyLeft, false, true, command.PreviousQuickEntry},
+ {KeyUp, false, true, command.NextQuickValue},
+ {KeyDown, false, true, command.PreviousQuickValue},
+ {31, true, false, command.SaveSettings},
+ } {
+ router, err := New_router(bindings)
+ if err != nil {
+ t.Fatal(err)
+ }
+ router.Key(0, KeyInsert, 1, false, false)
+ if testCase.ctrl {
+ router.Key(0, KeyCtrl, 1, false, false)
+ }
+ if got := router.Key(0, testCase.key, 1, false, false); got.Command != testCase.want || got.Forward {
+ t.Fatal(layout, testCase, got)
+ }
+ repeat := router.Key(0, testCase.key, 2, false, false)
+ if repeat.Forward || (testCase.repeats && repeat.Command != testCase.want) || (!testCase.repeats && repeat.Command != "") {
+ t.Fatal(testCase, repeat)
+ }
+ router.Key(0, KeyInsert, 0, false, false)
+ if got := router.Key(0, testCase.key, 0, false, false); got.Forward {
+ t.Fatal("release escaped", got)
+ }
+ router.Key(0, KeyCtrl, 0, false, false)
+ if got := router.Key(0, 31, 1, false, false); !got.Forward {
+ t.Fatal("modifiers survived", got)
+ }
+ }
+ }
+}
diff --git a/internal/input/double_press_test.go b/internal/input/double_press_test.go
new file mode 100644
index 00000000..c4655485
--- /dev/null
+++ b/internal/input/double_press_test.go
@@ -0,0 +1,121 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package input
+
+import (
+ "os"
+ "testing"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+)
+
+func TestTimeDateDoublePress(t *testing.T) {
+ for _, layout := range []string{"desktop", "laptop"} {
+ for _, scenario := range []string{"double", "expired", "repeat", "modifier release", "other key", "reset", "capture", "sync", "unchanged sync", "drop", "other device", "pass next"} {
+ t.Run(layout+"/"+scenario, func(t *testing.T) {
+ data, err := os.ReadFile("../../config/keyboard/" + layout + ".toml")
+ if err != nil {
+ t.Fatal(err)
+ }
+ bindings, err := Decode_bindings(data)
+ if err != nil {
+ t.Fatal(err)
+ }
+ router, err := New_router(bindings)
+ if err != nil {
+ t.Fatal(err)
+ }
+ now := time.Unix(1000, 0)
+ router.now = func() time.Time { return now }
+ const keyT = 20
+ router.Key(1, KeyInsert, 1, false, false)
+ if got := router.Key(1, keyT, 1, false, false); got.Command != command.Time || got.Forward || !got.Interrupt {
+ t.Fatal(got)
+ }
+ if got := router.Key(1, keyT, 2, false, false); got.Command != "" || got.Forward {
+ t.Fatal("repeat became date", got)
+ }
+ if got := router.Key(1, keyT, 0, false, false); got.Forward {
+ t.Fatal("release leaked", got)
+ }
+ now = now.Add(100 * time.Millisecond)
+ want, device := command.Time, 1
+ switch scenario {
+ case "double", "repeat":
+ want = command.Date
+ case "expired":
+ now = now.Add(101 * time.Millisecond)
+ case "modifier release":
+ router.Key(1, KeyInsert, 0, false, false)
+ router.Key(1, KeyInsert, 1, false, false)
+ want = command.Date
+ case "other key":
+ router.Key(1, KeyF, 1, false, false)
+ router.Key(1, KeyF, 0, false, false)
+ case "reset":
+ router.Reset()
+ router.Key(1, KeyInsert, 1, false, false)
+ case "capture":
+ router.Capture(true)
+ router.Capture(false)
+ case "sync":
+ router.Key(1, KeyShift, 1, false, true)
+ router.Key(1, KeyShift, 0, false, true)
+ case "unchanged sync":
+ router.Key(1, KeyShift, 0, false, true)
+ router.Key(1, KeyInsert, 1, false, true)
+ want = command.Date
+ case "drop":
+ router.Drop(1)
+ router.Key(1, KeyInsert, 1, false, false)
+ case "other device":
+ device = 2
+ case "pass next":
+ router.Pass_next()
+ if got := router.Key(1, keyT, 1, false, false); !got.Forward || got.Command != "" {
+ t.Fatal(got)
+ }
+ return
+ }
+ if got := router.Key(device, keyT, 1, false, false); got.Command != want || got.Forward {
+ t.Fatal(got, "want", want)
+ }
+ if want == command.Date {
+ router.Key(device, keyT, 0, false, false)
+ if got := router.Key(device, keyT, 1, false, false); got.Command != command.Time {
+ t.Fatal("third press", got)
+ }
+ }
+ })
+ }
+ }
+}
+
+func TestDoubleCommandValidationAndRemapping(t *testing.T) {
+ for _, doubleCommand := range []command.ID{command.Date, "unknown"} {
+ router, err := New_router([]Binding{{Name: "Clock", Keys: []string{"FENRIR", "G"}, Command: command.Time, DoubleCommand: doubleCommand}})
+ if doubleCommand == "unknown" {
+ if err == nil {
+ t.Fatal("unknown double command accepted")
+ }
+ continue
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ router.Key(1, KeyInsert, 1, false, false)
+ if got := router.Key(1, 34, 1, false, false); got.Command != command.Time {
+ t.Fatal(got)
+ }
+ router.Key(1, 34, 0, false, false)
+ if got := router.Key(1, 34, 1, false, false); got.Command != command.Date {
+ t.Fatal(got)
+ }
+ router.Reset()
+ if got := router.Key(1, 20, 1, false, false); !got.Forward || got.Command != "" {
+ t.Fatal("ordinary t intercepted", got)
+ }
+ }
+}
diff --git a/internal/input/emoji_test.go b/internal/input/emoji_test.go
new file mode 100644
index 00000000..ae9b1e81
--- /dev/null
+++ b/internal/input/emoji_test.go
@@ -0,0 +1,37 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package input
+
+import (
+ "testing"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+)
+
+func TestEmojiChordBothLayoutsAndReleaseOrders(t *testing.T) {
+ for _, layout := range []string{"desktop", "laptop"} {
+ bindings, _, err := Load_bindings([]string{"../../config/keyboard/" + layout + ".toml"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, release := range [][]uint16{{KeyInsert, KeyCtrl, 18}, {18, KeyCtrl, KeyInsert}} {
+ router, err := New_router(bindings)
+ if err != nil {
+ t.Fatal(err)
+ }
+ router.Key(0, KeyInsert, 1, false, false)
+ router.Key(0, KeyCtrl, 1, false, false)
+ if got := router.Key(0, 18, 1, false, false); got.Command != command.EmojiMenu || got.Forward {
+ t.Fatal(layout, got)
+ }
+ router.Capture(true)
+ for _, code := range release {
+ if got := router.Key(0, code, 0, false, false); got.Forward || got.UI != nil {
+ t.Fatal("opening chord leaked", layout, got)
+ }
+ }
+ }
+ }
+}
diff --git a/internal/input/layout.go b/internal/input/layout.go
new file mode 100644
index 00000000..f5968016
--- /dev/null
+++ b/internal/input/layout.go
@@ -0,0 +1,83 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package input
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// Layout_paths keeps named TOML layouts in Fenrir's keyboard directories.
+// Explicit paths select exactly one file. The bundle is an on-disk runtime asset.
+func Layout_paths(layout, home string, uid int, bundledDirectory string) ([]string, error) {
+ if strings.TrimSpace(layout) == "" || strings.ContainsAny(layout, "\x00\r\n") {
+ return nil, fmt.Errorf("keyboard layout cannot be empty or contain control characters")
+ }
+ if filepath.Ext(layout) != "" && filepath.Ext(layout) != ".toml" {
+ return nil, fmt.Errorf("keyboard layout must use TOML")
+ }
+ if strings.ContainsRune(layout, filepath.Separator) || filepath.IsAbs(layout) {
+ if filepath.Ext(layout) != ".toml" {
+ return nil, fmt.Errorf("keyboard layout path must end in .toml")
+ }
+ return []string{layout}, nil
+ }
+ name := strings.TrimSuffix(layout, ".toml")
+ if name == "" || name == "." || name == ".." {
+ return nil, fmt.Errorf("invalid keyboard layout name")
+ }
+ name += ".toml"
+ paths := []string{}
+ if uid != 0 {
+ paths = append(paths, filepath.Join(home, ".local/share/stormux/fenrirscreenreader/keyboard", name))
+ }
+ paths = append(paths, filepath.Join("/etc/fenrirscreenreader/keyboard", name))
+ if bundledDirectory != "" {
+ paths = append(paths, filepath.Join(bundledDirectory, name))
+ }
+ return paths, nil
+}
+
+// Load_bindings selects one whole file. A malformed or unreadable candidate
+// stops loading; only absent named candidates advance to the next directory.
+func Load_bindings(paths []string) ([]Binding, string, error) {
+ for _, path := range paths {
+ file, err := os.Open(path)
+ if errors.Is(err, os.ErrNotExist) {
+ continue
+ }
+ if err != nil {
+ return nil, path, err
+ }
+ data, readErr := io.ReadAll(io.LimitReader(file, (1<<20)+1))
+ closeErr := file.Close()
+ if err := errors.Join(readErr, closeErr); err != nil {
+ return nil, path, err
+ }
+ if len(data) > 1<<20 {
+ return nil, path, fmt.Errorf("keyboard layout exceeds 1 MiB")
+ }
+ bindings, err := Decode_bindings(data)
+ return bindings, path, err
+ }
+ return nil, "", fmt.Errorf("keyboard layout not found (searched %s)", strings.Join(paths, ", "))
+}
diff --git a/internal/input/learn.go b/internal/input/learn.go
new file mode 100644
index 00000000..7a461dce
--- /dev/null
+++ b/internal/input/learn.go
@@ -0,0 +1,42 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package input
+
+import (
+ "slices"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+)
+
+// LearnKey describes a captured fresh press. Command is metadata only: it is
+// never placed in Decision.Command and cannot launch an action or script.
+type LearnKey struct {
+ Code uint16
+ Held []uint16
+ Command command.ID
+ Bare bool
+}
+
+func (r *Router) learn_key(device int, key uint16, numLock bool) *LearnKey {
+ pressed := &LearnKey{Code: key, Bare: r.empty()}
+ seen := map[uint16]bool{}
+ for _, held := range r.keys {
+ for code := range held {
+ seen[code] = true
+ }
+ }
+ for code := range seen {
+ pressed.Held = append(pressed.Held, code)
+ }
+ slices.Sort(pressed.Held)
+ id, scriptChord := r.script_binding(key, numLock)
+ if !scriptChord {
+ id = r.binding(key, numLock)
+ }
+ if !pure_modifier(key) && !reader_modifier(key, numLock) {
+ id = r.multi_press(device, r.binding_chord(key, numLock), id, scriptChord)
+ }
+ pressed.Command = id
+ return pressed
+}
diff --git a/internal/input/learn_test.go b/internal/input/learn_test.go
new file mode 100644
index 00000000..1671bf68
--- /dev/null
+++ b/internal/input/learn_test.go
@@ -0,0 +1,100 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package input
+
+import (
+ "testing"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+)
+
+func TestCapturedKeysDescribeActualBindingsWithoutDispatch(t *testing.T) {
+ bindings := []Binding{{Name: "Custom", Keys: []string{"FENRIR", "Z"}, Command: command.Time, DoubleCommand: command.Date, TripleCommand: command.Quit}}
+ router, err := New_router(bindings)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := router.Configure_scripts([]string{"KEY_COMPOSE", "KEY_META"}, []ScriptBinding{{ID: "script:example", Keys: []string{"KEY_SCRIPT", "KEY_U"}}}); err != nil {
+ t.Fatal(err)
+ }
+ now := time.Unix(1000, 0)
+ router.now = func() time.Time { return now }
+ router.Capture(true)
+ press := func(code uint16, want command.ID) {
+ t.Helper()
+ decision := router.Key(0, code, 1, false, false)
+ if decision.Forward || decision.Command != "" || decision.Learn == nil || decision.Learn.Code != code || decision.Learn.Command != want {
+ t.Fatalf("code %d: %+v learn=%+v want %s", code, decision, decision.Learn, want)
+ }
+ if got := router.Key(0, code, 2, false, false); got.Learn != nil || got.Command != "" || got.Forward {
+ t.Fatal("repeat dispatched", got)
+ }
+ }
+ for _, want := range []command.ID{command.Time, command.Date, command.Quit} {
+ press(KeyInsert, "")
+ press(44, want)
+ // Releasing the modifier first must not turn the action into typing.
+ for _, code := range []uint16{KeyInsert, 44} {
+ if got := router.Key(0, code, 0, false, false); got.Forward || got.Learn != nil {
+ t.Fatal(got)
+ }
+ }
+ now = now.Add(50 * time.Millisecond)
+ }
+ compose, _ := Key_code("KEY_COMPOSE")
+ for _, modifier := range []uint16{compose, KeyMeta} {
+ press(modifier, "")
+ press(22, "script:example")
+ router.Key(0, 22, 0, false, false)
+ router.Key(0, modifier, 0, false, false)
+ }
+ press(88, "") // F12 has no binding but retains its physical identity.
+ if Key_name(88) != "KEY_F12" {
+ t.Fatal(Key_name(88))
+ }
+ if got := router.Key(2, KeyRightShift, 1, false, true); got.Learn != nil || got.Forward {
+ t.Fatal("sync spoke", got)
+ }
+ router.Capture(false)
+ if got := router.Key(0, 88, 2, false, false); got.Forward || got.Learn != nil {
+ t.Fatal("held key escaped exit", got)
+ }
+ router.Key(0, 88, 0, false, false)
+ if got := router.Key(0, 30, 1, false, false); !got.Forward {
+ t.Fatal("typing not restored", got)
+ }
+}
+
+func TestTutorialEntryAliasesAndNumLockIdentification(t *testing.T) {
+ for _, layout := range []string{"desktop", "laptop"} {
+ bindings, _, err := Load_bindings([]string{"../../config/keyboard/" + layout + ".toml"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, entry := range []uint16{KeyH, 59} {
+ router, err := New_router(bindings)
+ if err != nil {
+ t.Fatal(err)
+ }
+ router.Key(0, KeyInsert, 1, false, false)
+ if got := router.Key(0, entry, 1, false, false); got.Command != command.Help {
+ t.Fatal(layout, entry, got)
+ }
+ }
+ router, _ := New_router(bindings)
+ router.Capture(true)
+ for _, numLock := range []bool{false, true} {
+ got := router.Key(0, KeyKP8, 1, numLock, false)
+ want := command.ID("")
+ if !numLock && layout == "desktop" {
+ want = command.CurrentLine
+ }
+ if got.Forward || got.Learn == nil || got.Learn.Command != want {
+ t.Fatal(layout, numLock, got)
+ }
+ router.Key(0, KeyKP8, 0, numLock, false)
+ }
+ }
+}
diff --git a/internal/input/multi_press.go b/internal/input/multi_press.go
new file mode 100644
index 00000000..1001c05c
--- /dev/null
+++ b/internal/input/multi_press.go
@@ -0,0 +1,53 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package input
+
+import (
+ "fmt"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+)
+
+const DefaultMultiPressTimeout = 200 * time.Millisecond
+
+// Set_multi_press_timeout changes the window between consecutive fresh presses.
+// It preserves bindings and physical key ownership, but starts a new sequence.
+func (r *Router) Set_multi_press_timeout(timeout time.Duration) error {
+ if timeout < time.Millisecond || timeout > 5*time.Second {
+ return fmt.Errorf("multi-press timeout must be between 1 and 5000 milliseconds")
+ }
+ r.multiPressTimeout = timeout
+ r.lastPress = time.Time{}
+ return nil
+}
+
+func (r *Router) multi_press(device int, combo chord, id command.ID, scriptChord bool) command.ID {
+ if scriptChord || id == "" || r.doubleBindings[combo] == "" {
+ r.lastPress = time.Time{}
+ return id
+ }
+ now := time.Now()
+ if r.now != nil {
+ now = r.now()
+ }
+ elapsed := now.Sub(r.lastPress)
+ if r.lastPress.IsZero() || r.lastDevice != device || r.lastChord != combo || elapsed < 0 || elapsed > r.multiPressTimeout {
+ r.pressCount = 1
+ } else {
+ r.pressCount++
+ }
+ r.lastChord, r.lastDevice, r.lastPress = combo, device, now
+ switch r.pressCount {
+ case 2:
+ id = r.doubleBindings[combo]
+ if r.tripleBindings[combo] == "" {
+ r.lastPress = time.Time{}
+ }
+ case 3:
+ id = r.tripleBindings[combo]
+ r.lastPress = time.Time{}
+ }
+ return id
+}
diff --git a/internal/input/multi_press_test.go b/internal/input/multi_press_test.go
new file mode 100644
index 00000000..7e136ca6
--- /dev/null
+++ b/internal/input/multi_press_test.go
@@ -0,0 +1,187 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package input
+
+import (
+ "testing"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+)
+
+func TestTriplePressSequences(t *testing.T) {
+ for _, layout := range []string{"desktop", "laptop"} {
+ for _, modifierFirst := range []bool{false, true} {
+ t.Run(layout+"/"+map[bool]string{false: "key first", true: "modifier first"}[modifierFirst], func(t *testing.T) {
+ bindings, _, err := Load_bindings([]string{"../../config/keyboard/" + layout + ".toml"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ primary := uint16(KeyKP5)
+ target := command.ReadScreen
+ if layout == "laptop" {
+ primary = 37
+ target = command.CurrentWord
+ }
+ for index := range bindings {
+ if bindings[index].Command == target {
+ bindings[index].Command = command.CurrentWord
+ bindings[index].DoubleCommand = command.CurrentChar
+ bindings[index].TripleCommand = command.CurrentLine
+ }
+ }
+ router, err := New_router(bindings)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := router.Set_multi_press_timeout(700 * time.Millisecond); err != nil {
+ t.Fatal(err)
+ }
+ now := time.Unix(1000, 0)
+ router.now = func() time.Time { return now }
+ for _, want := range []command.ID{command.CurrentWord, command.CurrentChar, command.CurrentLine, command.CurrentWord} {
+ router.Key(1, KeyInsert, 1, false, false)
+ got := router.Key(1, primary, 1, false, false)
+ if got.Command != want || got.Forward || !got.Keypress || !got.Interrupt {
+ t.Fatal(got, want)
+ }
+ // Both evdev repeats and duplicate X11 presses stay in the held action.
+ for _, value := range []int32{2, 1, 2} {
+ got := router.Key(1, primary, value, false, false)
+ expected := command.ID("")
+ if value == 2 {
+ expected = want
+ }
+ if got.Command != expected || got.Keypress || got.Forward {
+ t.Fatal("held", got, want)
+ }
+ }
+ releases := []uint16{primary, KeyInsert}
+ if modifierFirst {
+ releases[0], releases[1] = releases[1], releases[0]
+ }
+ for _, key := range releases {
+ if got := router.Key(1, key, 0, false, false); got.Forward || got.Command != "" {
+ t.Fatal("release", got)
+ }
+ }
+ // Total triple duration exceeds the window; each adjacent gap fits.
+ now = now.Add(600 * time.Millisecond)
+ }
+ })
+ }
+ }
+}
+
+func TestTriplePressBoundariesAndInvalidation(t *testing.T) {
+ for _, scenario := range []string{"inside", "at boundary", "outside", "backward clock", "other chord", "typing", "numlock", "reset", "capture", "drop", "repair", "unchanged repair", "other device", "pass next", "reconfigure"} {
+ t.Run(scenario, func(t *testing.T) {
+ router, err := New_router([]Binding{{Name: "Review", Keys: []string{"KP5"}, Command: command.CurrentWord, DoubleCommand: command.CurrentChar, TripleCommand: command.CurrentLine}, {Name: "Other", Keys: []string{"FENRIR", "KP5"}, Command: command.Time, DoubleCommand: command.Date}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ now := time.Unix(1000, 0)
+ router.now = func() time.Time { return now }
+ for _, want := range []command.ID{command.CurrentWord, command.CurrentChar} {
+ if got := router.Key(1, KeyKP5, 1, false, false); got.Command != want {
+ t.Fatal(got)
+ }
+ router.Key(1, KeyKP5, 0, false, false)
+ now = now.Add(100 * time.Millisecond)
+ }
+ want, device := command.CurrentWord, 1
+ switch scenario {
+ case "inside":
+ want = command.CurrentLine
+ case "at boundary":
+ now = now.Add(100 * time.Millisecond)
+ want = command.CurrentLine
+ case "outside":
+ now = now.Add(100*time.Millisecond + time.Nanosecond)
+ case "backward clock":
+ now = now.Add(-101 * time.Millisecond)
+ case "other chord":
+ router.Key(1, KeyInsert, 1, false, false)
+ router.Key(1, KeyKP5, 1, false, false)
+ router.Key(1, KeyKP5, 0, false, false)
+ router.Key(1, KeyInsert, 0, false, false)
+ case "typing":
+ if got := router.Key(1, 30, 1, false, false); !got.Typing || !got.Forward {
+ t.Fatal(got)
+ }
+ router.Key(1, 30, 0, false, false)
+ case "numlock":
+ if got := router.Key(1, KeyKP5, 1, true, false); !got.Forward {
+ t.Fatal(got)
+ }
+ router.Key(1, KeyKP5, 0, true, false)
+ case "reset":
+ router.Reset()
+ case "capture":
+ router.Capture(true)
+ if got := router.Key(1, KeyKP5, 1, false, false); got.UI == nil || got.Command != "" || got.Forward {
+ t.Fatal(got)
+ }
+ router.Key(1, KeyKP5, 0, false, false)
+ router.Capture(false)
+ case "drop":
+ router.Drop(1)
+ case "repair":
+ router.Key(1, KeyShift, 1, false, true)
+ router.Key(1, KeyShift, 0, false, true)
+ case "unchanged repair":
+ router.Key(1, KeyShift, 0, false, true)
+ want = command.CurrentLine
+ case "other device":
+ device = 2
+ case "pass next":
+ router.Pass_next()
+ if got := router.Key(1, KeyKP5, 1, false, false); !got.Forward || got.Command != "" {
+ t.Fatal(got)
+ }
+ router.Key(1, KeyKP5, 0, false, false)
+ case "reconfigure":
+ if err := router.Set_multi_press_timeout(500 * time.Millisecond); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if got := router.Key(device, KeyKP5, 1, false, false); got.Command != want || got.Forward {
+ t.Fatal(got, want)
+ }
+ })
+ }
+}
+
+func TestTripleBindingValidation(t *testing.T) {
+ for _, data := range []string{
+ "version=1\n[[bindings]]\nname='Review'\nkeys=['KP5']\ncommand='current_word'\ntriple_command='current_line'\n",
+ "version=1\n[[bindings]]\nname='Review'\nkeys=['KP5']\ncommand='current_word'\ndouble_command='current_character'\ntriple_command='unknown'\n",
+ } {
+ if _, err := Decode_bindings([]byte(data)); err == nil {
+ t.Fatal("invalid triple accepted", data)
+ }
+ }
+ router := desktop_router(t)
+ for _, timeout := range []time.Duration{0, -time.Second, time.Millisecond - time.Nanosecond, 5*time.Second + time.Nanosecond} {
+ if err := router.Set_multi_press_timeout(timeout); err == nil {
+ t.Fatal(timeout)
+ }
+ }
+}
+
+func TestRetainedFeatureKeyNames(t *testing.T) {
+ for _, key := range []string{"F1", "KPMINUS", "KPSLASH", "RIGHTBRACE"} {
+ for _, bare := range []bool{false, true} {
+ keys := []string{"FENRIR", key}
+ if bare {
+ keys = keys[1:]
+ }
+ _, err := New_router([]Binding{{Name: "Retained key", Keys: keys, Command: command.Help}})
+ wantValid := !bare || key == "KPMINUS" || key == "KPSLASH"
+ if (err == nil) != wantValid {
+ t.Fatal(key, bare, err)
+ }
+ }
+ }
+}
diff --git a/internal/input/progress_test.go b/internal/input/progress_test.go
new file mode 100644
index 00000000..7cde77a2
--- /dev/null
+++ b/internal/input/progress_test.go
@@ -0,0 +1,92 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+// Package input routes Linux key codes without depending on a device backend.
+package input
+
+import (
+ "os"
+ "testing"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+)
+
+func TestPythonProgressBindingsAndNumLockPassThrough(t *testing.T) {
+ for _, layout := range []string{"desktop", "laptop"} {
+ data, err := os.ReadFile("../../config/keyboard/" + layout + ".toml")
+ if err != nil {
+ t.Fatal(err)
+ }
+ bindings, err := Decode_bindings(data)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, numLock := range []bool{false, true} {
+ router, err := New_router(bindings)
+ if err != nil {
+ t.Fatal(err)
+ }
+ key := uint16(KeyKPPlus)
+ if layout == "laptop" {
+ router.Key(0, KeyInsert, 1, numLock, false)
+ router.Key(0, KeyShift, 1, numLock, false)
+ key = 25
+ }
+ got := router.Key(0, key, 1, numLock, false)
+ if layout == "desktop" && numLock {
+ if !got.Forward || got.Command != "" {
+ t.Fatal("keypad Plus no longer types", got)
+ }
+ } else if got.Forward || got.Command != command.ProgressMonitor {
+ t.Fatal(layout, numLock, got)
+ }
+ release := router.Key(0, key, 0, numLock, false)
+ if release.Forward != got.Forward {
+ t.Fatal("release leaked", layout, release)
+ }
+ }
+ }
+}
+
+func TestDeletionMetadataDoesNotChangeKeyRouting(t *testing.T) {
+ for _, key := range []uint16{KeyBackspace, KeyDelete} {
+ router := desktop_router(t)
+ for _, value := range []int32{1, 2} {
+ got := router.Key(0, key, value, false, false)
+ if !got.Forward || !got.Deletion || !got.Interrupt || got.Typing {
+ t.Fatal("deletion metadata", key, value, got)
+ }
+ }
+ if got := router.Key(0, key, 2, false, true); got.Deletion || got.Interrupt {
+ t.Fatal("state sync became deletion", got)
+ }
+ if got := router.Key(0, key, 0, false, false); !got.Forward || got.Deletion {
+ t.Fatal("deletion release changed", got)
+ }
+ }
+ router := desktop_router(t)
+ router.Key(0, KeyInsert, 1, false, false)
+ if got := router.Key(0, KeyBackspace, 1, false, false); got.Deletion || got.Forward || got.Command != command.PassNext {
+ t.Fatal("reader chord became deletion", got)
+ }
+ for _, key := range []uint16{KeyEnter, KeyLeft, KeyKPDot} {
+ router := desktop_router(t)
+ if got := router.Key(0, key, 1, false, false); got.Deletion {
+ t.Fatal("unrelated key became deletion", key, got)
+ }
+ }
+}
diff --git a/internal/input/regions_test.go b/internal/input/regions_test.go
new file mode 100644
index 00000000..064f35d2
--- /dev/null
+++ b/internal/input/regions_test.go
@@ -0,0 +1,98 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package input
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+)
+
+func TestRegionChordsInBothLayoutsConsumeRepeatsAndEitherReleaseOrder(t *testing.T) {
+ for _, layout := range []string{"desktop", "laptop"} {
+ bindings, _, err := Load_bindings([]string{"../../config/keyboard/" + layout + ".toml"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, binding := range bindings {
+ if binding.Command != command.SetWindow && binding.Command != command.SetLiveRegion && binding.Command != command.ListReadingRegions && binding.Command != command.ClearLiveRegions && binding.Command != command.ClearReadingRegions &&
+ !strings.HasPrefix(string(binding.Command), "read_live_region_") && !strings.HasPrefix(string(binding.Command), "clear_live_region_") {
+ continue
+ }
+ for _, modifierFirst := range []bool{false, true} {
+ for _, numLock := range []bool{false, true} {
+ router, err := New_router(bindings)
+ if err != nil {
+ t.Fatal(err)
+ }
+ modifiers := []uint16{KeyInsert}
+ for _, name := range binding.Keys[1 : len(binding.Keys)-1] {
+ if name == "CTRL" {
+ modifiers = append(modifiers, KeyCtrl)
+ } else if name == "SHIFT" {
+ modifiers = append(modifiers, KeyShift)
+ }
+ }
+ for _, key := range modifiers {
+ router.Key(0, key, 1, numLock, false)
+ }
+ key := keyCodes[binding.Keys[len(binding.Keys)-1]]
+ if got := router.Key(0, key, 1, numLock, false); got.Command != binding.Command || got.Forward {
+ t.Fatal(layout, binding.Name, got)
+ }
+ if got := router.Key(0, key, 2, numLock, false); got.Command != "" || got.Forward {
+ t.Fatal("repeat escaped", layout, binding.Name, got)
+ }
+ if modifierFirst {
+ for _, modifier := range modifiers {
+ router.Key(0, modifier, 0, numLock, false)
+ }
+ }
+ if got := router.Key(0, key, 0, numLock, false); got.Forward || got.Command != "" {
+ t.Fatal("release escaped", layout, binding.Name, got)
+ }
+ if !modifierFirst {
+ for _, modifier := range modifiers {
+ router.Key(0, modifier, 0, numLock, false)
+ }
+ }
+ if got := router.Key(0, 48, 1, numLock, false); !got.Forward {
+ t.Fatal("plain typing trapped after chord", layout, got)
+ }
+ }
+ }
+ }
+ }
+}
+
+func TestWindowDoublePressSurvivesX11AggregateControlSamples(t *testing.T) {
+ for _, modifier := range []uint16{KeyCtrl, KeyRightCtrl} {
+ bindings, _, err := Load_bindings([]string{"../../config/keyboard/laptop.toml"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ router, err := New_router(bindings)
+ if err != nil {
+ t.Fatal(err)
+ }
+ now := time.Unix(1000, 0)
+ router.now = func() time.Time { return now }
+ for _, want := range []command.ID{command.SetWindow, command.ClearWindow} {
+ router.Observe_modifier(1, KeyCtrl, 0, false)
+ router.Key(0, KeyInsert, 1, false, false)
+ router.Key(0, modifier, 1, false, false)
+ router.Observe_modifier(1, KeyCtrl, 1, false)
+ if got := router.Key(0, 9, 1, false, false); got.Command != want || got.Forward {
+ t.Fatal("control modifier shadow broke double press", modifier, got, want)
+ }
+ router.Key(0, 9, 0, false, false)
+ router.Key(0, modifier, 0, false, false)
+ router.Observe_modifier(1, KeyCtrl, 0, false)
+ router.Key(0, KeyInsert, 0, false, false)
+ now = now.Add(100 * time.Millisecond)
+ }
+ }
+}
diff --git a/internal/input/router.go b/internal/input/router.go
new file mode 100644
index 00000000..6a483a25
--- /dev/null
+++ b/internal/input/router.go
@@ -0,0 +1,342 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+// Package input routes Linux key codes without depending on a device backend.
+package input
+
+import (
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/interaction"
+ "time"
+)
+
+const (
+ KeyEsc = 1
+ KeyBackspace = 14
+ KeyTab = 15
+ KeyQ = 16
+ KeyEnter = 28
+ KeyCtrl = 29
+ KeyF = 33
+ KeyH = 35
+ KeyShift = 42
+ KeyRightShift = 54
+ KeyAlt = 56
+ KeySpace = 57
+ KeyF4 = 62
+ KeyKP7 = 71
+ KeyKP8 = 72
+ KeyKP9 = 73
+ KeyKP4 = 75
+ KeyKP5 = 76
+ KeyKP6 = 77
+ KeyKPPlus = 78
+ KeyKP1 = 79
+ KeyKP2 = 80
+ KeyKP3 = 81
+ KeyKP0 = 82
+ KeyKPDot = 83
+ KeyKPEnter = 96
+ KeyRightCtrl = 97
+ KeyRightAlt = 100
+ KeyHome = 102
+ KeyUp = 103
+ KeyLeft = 105
+ KeyRight = 106
+ KeyEnd = 107
+ KeyDown = 108
+ KeyInsert = 110
+ KeyDelete = 111
+ KeyMeta = 125
+ KeyRightMeta = 126
+)
+
+type Decision struct {
+ Forward bool
+ Interrupt bool
+ Keypress bool // a fresh physical press, excluding repeats and state repair
+ Deletion bool // forwarded Backspace/Delete, including repeats
+ Typing bool // forwarded text key or repeat; the screen supplies its actual text
+ WordBoundary bool // a forwarded Space, rather than cursor navigation
+ Command command.ID
+ UI *interaction.Key
+ Learn *LearnKey // captured physical press and its inactive command
+}
+
+type Router struct {
+ keys map[int]map[uint16]heldKey
+ passArmed bool
+ passing bool
+ captured bool
+ bindings map[chord]command.ID
+ doubleBindings map[chord]command.ID
+ tripleBindings map[chord]command.ID
+ multiPressTimeout time.Duration
+ pressCount int
+ lastChord chord
+ lastDevice int
+ lastPress time.Time
+ now func() time.Time
+ scriptKeys map[uint16]bool
+ scriptBindings map[string]command.ID
+}
+
+type heldKey struct {
+ forwarded bool
+ commandID command.ID
+ ui bool
+}
+
+func (r *Router) Reset() {
+ r.lastPress = time.Time{}
+ r.keys = nil
+ r.passArmed = false
+ r.passing = false
+ r.captured = false
+}
+
+func (r *Router) Drop(device int) {
+ r.lastPress = time.Time{}
+ delete(r.keys, device)
+ if r.empty() {
+ r.passing = false
+ }
+}
+
+func (r *Router) Forwarded(device int) []uint16 {
+ var keys []uint16
+ for key, state := range r.keys[device] {
+ if state.forwarded {
+ keys = append(keys, key)
+ }
+ }
+ return keys
+}
+
+// Held reports physical state for one device, including consumed keys whose
+// release has not arrived yet.
+func (r *Router) Held(device int) bool { return len(r.keys[device]) != 0 }
+
+func (r *Router) Pass_next() {
+ r.lastPress = time.Time{}
+ r.passArmed = true
+}
+
+// Key keeps the routing of a press through repeat/release, even if a modifier
+// or NumLock changes in between. Sync deltas repair state without commands.
+func (r *Router) Key(device int, key uint16, value int32, numLock, syncEvent bool) Decision {
+ return r.key(device, key, value, numLock, syncEvent, syncEvent)
+}
+
+// Observe_modifier records an aggregate X11 modifier sample on its separate
+// shadow device. These samples accompany ordinary presses/releases; they are
+// not evidence of lost input and must not cancel a pending double press. The
+// next physical action still checks the complete chord and timeout. Actual
+// state repair, handoff and capture continue to invalidate through Key/Reset.
+func (r *Router) Observe_modifier(device int, key uint16, value int32, numLock bool) {
+ if key == KeyCtrl || key == KeyShift || key == KeyAlt {
+ r.key(device, key, value, numLock, true, false)
+ }
+}
+
+func (r *Router) key(device int, key uint16, value int32, numLock, syncEvent, repair bool) Decision {
+ if repair {
+ // X11 checks modifier state before every event, including unchanged
+ // keys. Only an actual repair invalidates a pending multi-press.
+ _, held := r.keys[device][key]
+ if value == 0 && held || value == 1 && !held {
+ r.lastPress = time.Time{}
+ }
+ }
+ if r.keys == nil {
+ r.keys = make(map[int]map[uint16]heldKey)
+ }
+ if r.keys[device] == nil {
+ r.keys[device] = make(map[uint16]heldKey)
+ }
+ held := r.keys[device]
+ state, exists := held[key]
+ if value == 0 {
+ delete(held, key)
+ if r.empty() {
+ r.passing = false
+ }
+ return Decision{Forward: exists && state.forwarded}
+ }
+ if value != 1 && value != 2 {
+ return Decision{}
+ }
+ if exists {
+ if state.ui {
+ if r.captured && value == 2 && !syncEvent && !pure_modifier(key) && key != KeyEsc && key != KeyEnter && key != KeyKPEnter && key != KeySpace {
+ decision := r.ui_key(key)
+ decision.UI.Repeat = true
+ return decision
+ }
+ return Decision{}
+ }
+ if state.forwarded {
+ typing := !syncEvent && r.typing_key(key, numLock)
+ return Decision{Forward: true, Deletion: !syncEvent && deletion_key(key, numLock), Typing: typing, WordBoundary: typing && key == KeySpace, Interrupt: !syncEvent && !pure_modifier(key)}
+ }
+ commandID := state.commandID
+ if value == 2 && repeatable(commandID) && !syncEvent {
+ return Decision{Command: commandID, Interrupt: true}
+ }
+ return Decision{}
+ }
+ // Orphan repeats do not become new presses after a handoff.
+ if value == 2 {
+ return Decision{}
+ }
+ if r.captured {
+ var learn *LearnKey
+ if !syncEvent {
+ learn = r.learn_key(device, key, numLock)
+ }
+ held[key] = heldKey{ui: true}
+ if syncEvent || pure_modifier(key) {
+ return Decision{Learn: learn, Keypress: !syncEvent, Interrupt: !syncEvent && (key == KeyCtrl || key == KeyRightCtrl)}
+ }
+ decision := r.ui_key(key)
+ decision.Learn = learn
+ decision.Keypress = true
+ return decision
+ }
+ if r.passArmed && !syncEvent {
+ r.passArmed, r.passing = false, true
+ }
+ commandID := command.ID("")
+ scriptChord := false
+ if !r.passing {
+ commandID, scriptChord = r.script_binding(key, numLock)
+ if !scriptChord {
+ commandID = r.binding(key, numLock)
+ }
+ }
+ if !syncEvent && !pure_modifier(key) && !reader_modifier(key, numLock) {
+ commandID = r.multi_press(device, r.binding_chord(key, numLock), commandID, scriptChord)
+ }
+ forwarded := commandID == "" && !scriptChord && !(reader_modifier(key, numLock) && !r.passing)
+ held[key] = heldKey{forwarded: forwarded, commandID: commandID}
+ if syncEvent {
+ return Decision{Forward: forwarded}
+ }
+ typing := forwarded && r.typing_key(key, numLock)
+ return Decision{Keypress: true, Deletion: forwarded && deletion_key(key, numLock), Typing: typing, WordBoundary: typing && key == KeySpace, Forward: forwarded, Command: commandID, Interrupt: commandID != "" || forwarded && (!pure_modifier(key) || key == KeyCtrl || key == KeyRightCtrl)}
+}
+
+func (r *Router) typing_key(key uint16, numLock bool) bool {
+ for scriptKey := range r.scriptKeys {
+ if r.down(scriptKey) {
+ return false
+ }
+ }
+ if r.down(KeyCtrl) || r.down(KeyRightCtrl) || r.down(KeyAlt) || r.down(KeyInsert) || r.down(KeyMeta) || r.down(KeyRightMeta) {
+ return false
+ }
+ // Physical text-key positions, independent of keyboard layout. AltGr is
+ // allowed; text and capitalization come from the echoed screen cells.
+ if key >= 2 && key <= 13 || key >= 16 && key <= 27 || key >= 30 && key <= 41 || key >= 43 && key <= 53 {
+ return true
+ }
+ switch key {
+ case KeySpace, 86, 89, 124: // space, ISO extra key, RO and Yen
+ return true
+ case KeyKP0, KeyKP1, KeyKP2, KeyKP3, KeyKP4, KeyKP5, KeyKP6, KeyKP7, KeyKP8, KeyKP9, KeyKPDot:
+ return numLock
+ case 55, 74, 78, 98: // keypad multiply, minus, plus and divide
+ return true
+ }
+ return false
+}
+
+func (r *Router) down(key uint16) bool {
+ for _, held := range r.keys {
+ if _, ok := held[key]; ok {
+ return true
+ }
+ }
+ return false
+}
+
+func (r *Router) empty() bool {
+ for _, held := range r.keys {
+ if len(held) != 0 {
+ return false
+ }
+ }
+ return true
+}
+
+func reader_modifier(key uint16, numLock bool) bool {
+ return key == KeyInsert || key == KeyMeta || key == KeyRightMeta || key == KeyKP0 && !numLock
+}
+
+func pure_modifier(key uint16) bool {
+ return key == KeyCtrl || key == KeyRightCtrl || key == KeyShift || key == KeyRightShift || key == KeyAlt || key == KeyRightAlt || key == KeyInsert || key == KeyMeta || key == KeyRightMeta
+}
+
+func (r *Router) binding(key uint16, numLock bool) command.ID {
+ if reader_modifier(key, numLock) || pure_modifier(key) {
+ return ""
+ }
+ combo := r.binding_chord(key, numLock)
+ if combo.modifiers&fenrirModifier != 0 {
+ if id := r.bindings[combo]; id != "" {
+ return id
+ }
+ return command.Unbound
+ }
+ if numLock || combo.modifiers != 0 {
+ return ""
+ }
+ return r.bindings[combo]
+}
+
+func (r *Router) binding_chord(key uint16, numLock bool) chord {
+ var mods modifiers
+ if r.down(KeyInsert) || r.down(KeyMeta) || r.down(KeyRightMeta) || !numLock && r.down(KeyKP0) {
+ mods |= fenrirModifier
+ }
+ if r.down(KeyCtrl) || r.down(KeyRightCtrl) {
+ mods |= ctrlModifier
+ }
+ if r.down(KeyShift) || r.down(KeyRightShift) {
+ mods |= shiftModifier
+ }
+ if r.down(KeyAlt) || r.down(KeyRightAlt) {
+ mods |= altModifier
+ }
+ return chord{key: key, modifiers: mods}
+}
+
+func repeatable(commandID command.ID) bool {
+ switch commandID {
+ case command.NextQuickCategory, command.PreviousQuickCategory, command.NextQuickEntry, command.PreviousQuickEntry, command.NextQuickValue, command.PreviousQuickValue:
+ return true
+ case command.PreviousLine, command.CurrentLine, command.NextLine, command.PreviousWord, command.CurrentWord, command.NextWord, command.PreviousChar, command.CurrentChar, command.NextChar,
+ command.SpellLine, command.PhoneticLine, command.SpellWord, command.PhoneticWord, command.PhoneticChar,
+ command.ReviewUp, command.ReviewDown:
+ return true
+ }
+ return false
+}
+
+func deletion_key(key uint16, numLock bool) bool {
+ return key == KeyBackspace || key == KeyDelete || key == KeyKPDot && !numLock
+}
diff --git a/internal/input/router_test.go b/internal/input/router_test.go
new file mode 100644
index 00000000..a7a728bc
--- /dev/null
+++ b/internal/input/router_test.go
@@ -0,0 +1,186 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package input
+
+import (
+ "git.stormux.org/storm/fenrir/internal/command"
+ "testing"
+)
+
+func TestReviewAndReleaseOrder(t *testing.T) {
+ router := desktop_router(t)
+ if got := router.Key(1, KeyInsert, 1, false, false); got.Forward || got.Interrupt {
+ t.Fatalf("modifier: %+v", got)
+ }
+ if got := router.Key(2, KeyKP5, 1, false, false); got.Forward || got.Command != command.ReadScreen {
+ t.Fatalf("cross-device chord: %+v", got)
+ }
+ router.Key(1, KeyInsert, 0, false, false)
+ if got := router.Key(2, KeyKP5, 2, true, false); got.Forward || got.Command != "" {
+ t.Fatalf("held chord changed after modifier release: %+v", got)
+ }
+ if got := router.Key(2, KeyKP5, 0, true, false); got.Forward {
+ t.Fatalf("consumed release leaked: %+v", got)
+ }
+ if got := router.Key(2, KeyKP5, 1, false, false); got.Command != command.CurrentWord {
+ t.Fatalf("bare review: %+v", got)
+ }
+ if got := router.Key(2, KeyKP5, 2, false, false); got.Command != command.CurrentWord {
+ t.Fatalf("repeat: %+v", got)
+ }
+}
+
+func TestTypingNumLockAndControl(t *testing.T) {
+ router := desktop_router(t)
+ if got := router.Key(1, KeyKP0, 1, true, false); !got.Forward || !got.Interrupt {
+ t.Fatalf("numeric zero must interrupt like other typing: %+v", got)
+ }
+ router.Key(1, KeyKP0, 0, true, false)
+ if got := router.Key(1, KeyKP1, 1, true, false); !got.Forward || !got.Interrupt || got.Command != "" {
+ t.Fatalf("numeric press: %+v", got)
+ }
+ if got := router.Key(1, KeyKP1, 0, false, false); !got.Forward || got.Interrupt {
+ t.Fatalf("numeric release after NumLock changed: %+v", got)
+ }
+ if got := router.Key(1, KeyCtrl, 1, false, false); !got.Forward || !got.Interrupt {
+ t.Fatalf("control must stop speech and reach application: %+v", got)
+ }
+ if got := router.Key(1, KeyKP4, 1, false, false); !got.Forward || got.Command != "" {
+ t.Fatalf("application control chord: %+v", got)
+ }
+ if got := router.Key(1, KeyShift, 1, false, false); !got.Forward || got.Interrupt {
+ t.Fatalf("shift must not interrupt: %+v", got)
+ }
+ if got := router.Key(1, 30, 1, false, false); !got.Forward || !got.Interrupt {
+ t.Fatalf("ordinary letter: %+v", got)
+ }
+ if got := router.Key(1, 30, 0, false, false); !got.Forward || got.Interrupt {
+ t.Fatalf("letter release: %+v", got)
+ }
+}
+
+func TestPassNextChordAndReset(t *testing.T) {
+ router := desktop_router(t)
+ router.Pass_next()
+ if got := router.Key(1, KeyInsert, 1, false, false); !got.Forward {
+ t.Fatal("pass-next swallowed Insert")
+ }
+ if got := router.Key(1, KeyQ, 1, false, false); !got.Forward || got.Command != "" {
+ t.Fatalf("pass-next quit chord: %+v", got)
+ }
+ router.Key(1, KeyInsert, 0, false, false)
+ router.Key(1, KeyQ, 0, false, false)
+ router.Key(1, KeyInsert, 1, false, false)
+ if got := router.Key(1, KeyQ, 1, false, false); got.Forward || got.Command != command.Quit {
+ t.Fatalf("reader chord after pass-next: %+v", got)
+ }
+ router.Reset()
+ if got := router.Key(1, 30, 0, false, false); got.Forward {
+ t.Fatal("orphan release after handoff")
+ }
+ if got := router.Key(1, KeyKP8, 2, false, false); got.Command != "" {
+ t.Fatal("orphan repeat after handoff")
+ }
+}
+
+func TestSyncAndDisconnectDoNotRunCommands(t *testing.T) {
+ router := desktop_router(t)
+ router.Key(1, KeyInsert, 1, false, true)
+ if got := router.Key(1, KeyQ, 1, false, true); got.Forward || got.Command != "" || got.Interrupt {
+ t.Fatalf("sync ran command: %+v", got)
+ }
+ router.Drop(1)
+ if got := router.Key(2, KeyQ, 1, false, false); !got.Forward || got.Command != "" {
+ t.Fatalf("disconnected modifier stuck: %+v", got)
+ }
+}
+
+func TestBareKeypadEnterIsConsumed(t *testing.T) {
+ router := desktop_router(t)
+ if got := router.Key(0, KeyKPEnter, 1, false, false); got.Command != "temp_disable_speech" || got.Forward || !got.Interrupt {
+ t.Fatal("keypad Enter did not temporarily silence speech", got)
+ }
+ if got := router.Key(0, KeyKPEnter, 2, false, false); got.Command != "" || got.Forward {
+ t.Fatal("held keypad Enter repeated or leaked", got)
+ }
+ if got := router.Key(0, KeyKPEnter, 0, true, false); got.Forward {
+ t.Fatal("keypad Enter release leaked after NumLock change", got)
+ }
+ if got := router.Key(0, KeyKPEnter, 1, true, false); !got.Forward || got.Command != "" {
+ t.Fatal("NumLock-on keypad Enter was consumed", got)
+ }
+ router.Reset()
+ router.Key(0, KeyInsert, 1, false, false)
+ if got := router.Key(0, KeyKPEnter, 1, false, false); got.Command != command.AutoRead || got.Forward {
+ t.Fatal("Fenrir+keypad Enter lost automatic-reading toggle", got)
+ }
+}
+
+func TestKeypressExcludesRepeatsReleasesAndStateRepair(t *testing.T) {
+ for _, key := range []uint16{KeyKPEnter, KeyKP8, KeyShift, KeyInsert, KeyF} {
+ for _, capture := range []bool{false, true} {
+ router := desktop_router(t)
+ router.Capture(capture)
+ if got := router.Key(0, key, 1, false, false); !got.Keypress {
+ t.Fatal("fresh press not reported", key, capture, got)
+ }
+ for _, event := range []struct {
+ value int32
+ sync bool
+ }{{2, false}, {1, false}, {0, false}, {1, true}, {0, true}, {2, false}} {
+ if got := router.Key(0, key, event.value, false, event.sync); got.Keypress {
+ t.Fatal("non-press reported as fresh key", key, capture, event, got)
+ }
+ }
+ }
+ }
+}
+
+func TestTypingMetadataExcludesCommandsNavigationAndShortcuts(t *testing.T) {
+ for _, testCase := range []struct {
+ key uint16
+ modifier uint16
+ numLock, typing, boundary bool
+ }{
+ {30, 0, false, true, false}, {30, KeyShift, false, true, false},
+ {30, KeyRightAlt, false, true, false}, {30, KeyCtrl, false, false, false},
+ {30, KeyAlt, false, false, false}, {KeySpace, 0, false, true, true},
+ {KeyKP8, 0, false, false, false}, {KeyKP8, 0, true, true, false},
+ {KeyRight, 0, false, false, false}, {KeyBackspace, 0, false, false, false},
+ {KeyTab, 0, false, false, false}, {KeyF, KeyInsert, false, false, false},
+ } {
+ router := desktop_router(t)
+ if testCase.modifier != 0 {
+ router.Key(0, testCase.modifier, 1, testCase.numLock, false)
+ }
+ for _, value := range []int32{1, 2} {
+ got := router.Key(0, testCase.key, value, testCase.numLock, false)
+ if got.Typing != testCase.typing || got.WordBoundary != testCase.boundary {
+ t.Fatal("wrong typing classification", testCase, value, got)
+ }
+ }
+ for _, event := range []struct {
+ value int32
+ sync bool
+ }{{0, false}, {1, true}, {0, true}} {
+ if got := router.Key(0, testCase.key, event.value, testCase.numLock, event.sync); got.Typing || got.WordBoundary {
+ t.Fatal("release or state repair became typing", testCase, event, got)
+ }
+ }
+ }
+}
diff --git a/internal/input/screen_range_test.go b/internal/input/screen_range_test.go
new file mode 100644
index 00000000..02ee7524
--- /dev/null
+++ b/internal/input/screen_range_test.go
@@ -0,0 +1,67 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package input
+
+import (
+ "testing"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+)
+
+func TestScreenRangeChordsConsumePressHoldAndBothReleaseOrders(t *testing.T) {
+ for _, layout := range []string{"desktop", "laptop"} {
+ bindings, _, err := Load_bindings([]string{"../../config/keyboard/" + layout + ".toml"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, trial := range []struct {
+ desktop, laptop uint16
+ want command.ID
+ }{
+ {KeyKP8, 23, command.ScreenBeforeCursor},
+ {KeyKP2, 51, command.ScreenAfterCursor},
+ } {
+ for _, modifierFirst := range []bool{false, true} {
+ for _, numLock := range []bool{false, true} {
+ router, err := New_router(bindings)
+ if err != nil {
+ t.Fatal(err)
+ }
+ keyCode := trial.desktop
+ modifiers := []uint16{KeyInsert}
+ if layout == "laptop" {
+ keyCode = trial.laptop
+ modifiers = append(modifiers, KeyShift)
+ }
+ for _, modifier := range modifiers {
+ router.Key(0, modifier, 1, numLock, false)
+ }
+ if got := router.Key(0, keyCode, 1, numLock, false); got.Command != trial.want || got.Forward || !got.Interrupt {
+ t.Fatal(layout, trial.want, numLock, got)
+ }
+ // Like ReadScreen, a held range key must not restart long speech.
+ if got := router.Key(0, keyCode, 2, numLock, false); got.Command != "" || got.Forward || got.Interrupt {
+ t.Fatal("range repeated", got)
+ }
+ releases := append([]uint16{keyCode}, modifiers...)
+ if modifierFirst {
+ releases = append(modifiers, keyCode)
+ }
+ for _, code := range releases {
+ // Ordinary Shift is forwarded as a balanced press/release.
+ if got := router.Key(0, code, 0, numLock, false); got.Forward != (code == KeyShift) || got.Command != "" {
+ t.Fatal("release leaked", got)
+ }
+ }
+ if router.Held(0) {
+ t.Fatal("stuck key after range command")
+ }
+ if got := router.Key(0, 30, 1, numLock, false); !got.Forward || got.Command != "" {
+ t.Fatal("ordinary typing after range chord", got)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/internal/input/script_keys_linux.go b/internal/input/script_keys_linux.go
new file mode 100644
index 00000000..2173ab03
--- /dev/null
+++ b/internal/input/script_keys_linux.go
@@ -0,0 +1,201 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+//go:build linux && cgo
+
+package input
+
+/*
+#cgo pkg-config: libevdev
+#include
+#include
+*/
+import "C"
+
+import (
+ "fmt"
+ "slices"
+ "strconv"
+ "strings"
+ "unsafe"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+)
+
+// ScriptBinding uses Python Fenrir's normalized KEY_* names, including the
+// synthetic KEY_SCRIPT, KEY_FENRIR and generic modifier names.
+type ScriptBinding struct {
+ ID command.ID
+ Keys []string
+}
+
+func Key_code(name string) (uint16, bool) {
+ name = strings.ToUpper(name)
+ aliases := map[string]string{"KEY_CTRL": "KEY_LEFTCTRL", "KEY_SHIFT": "KEY_LEFTSHIFT", "KEY_ALT": "KEY_LEFTALT", "KEY_META": "KEY_LEFTMETA"}
+ if alias := aliases[name]; alias != "" {
+ name = alias
+ }
+ text := C.CString(name)
+ defer C.free(unsafe.Pointer(text))
+ code := int(C.libevdev_event_code_from_name(C.EV_KEY, text))
+ return uint16(code), code >= 0 && code <= int(C.KEY_MAX)
+}
+
+func Valid_script_key(name string) bool {
+ if name == "KEY_SCRIPT" || name == "KEY_FENRIR" {
+ return true
+ }
+ _, ok := Key_code(name)
+ return ok
+}
+
+// Key_name retains the physical left/right/keypad identity for tutorial input.
+func Key_name(code uint16) string {
+ name := C.libevdev_event_code_get_name(C.EV_KEY, C.uint(code))
+ if name == nil {
+ return strconv.Itoa(int(code))
+ }
+ return C.GoString(name)
+}
+
+func Script_chord(keys []string) string {
+ keys = slices.Clone(keys)
+ slices.Sort(keys)
+ return strings.Join(keys, ",")
+}
+
+func (r *Router) Configure_scripts(keys []string, bindings []ScriptBinding) error {
+ r.scriptKeys = make(map[uint16]bool)
+ r.scriptBindings = make(map[string]command.ID)
+ for _, name := range keys {
+ code, ok := Key_code(name)
+ if !ok {
+ return fmt.Errorf("invalid script key %q", name)
+ }
+ r.scriptKeys[code] = true
+ switch strings.ToUpper(name) {
+ case "KEY_CTRL":
+ r.scriptKeys[KeyRightCtrl] = true
+ case "KEY_SHIFT":
+ r.scriptKeys[KeyRightShift] = true
+ case "KEY_ALT":
+ r.scriptKeys[KeyRightAlt] = true
+ case "KEY_META":
+ r.scriptKeys[KeyRightMeta] = true
+ }
+ }
+ for _, binding := range bindings {
+ if binding.ID == "" {
+ return fmt.Errorf("script binding has no command")
+ }
+ for _, name := range binding.Keys {
+ if !Valid_script_key(name) {
+ return fmt.Errorf("invalid script shortcut %q", name)
+ }
+ }
+ chord := Script_chord(binding.Keys)
+ if _, exists := r.scriptBindings[chord]; exists {
+ return fmt.Errorf("duplicate script shortcut %s", chord)
+ }
+ r.scriptBindings[chord] = binding.ID
+ }
+ return nil
+}
+
+func (r *Router) Script_keys() []uint16 {
+ var keys []uint16
+ for key := range r.scriptKeys {
+ keys = append(keys, key)
+ }
+ slices.Sort(keys)
+ return keys
+}
+
+func (r *Router) script_name(key uint16, numLock bool) string {
+ // Python normalizes left/right modifiers before resolving reader modifiers.
+ physical := key
+ switch key {
+ case KeyRightCtrl:
+ key = KeyCtrl
+ case KeyRightShift:
+ key = KeyShift
+ case KeyRightAlt:
+ key = KeyAlt
+ case KeyRightMeta:
+ key = KeyMeta
+ }
+ if r.scriptKeys[physical] {
+ return "KEY_SCRIPT"
+ }
+ if reader_modifier(key, numLock) {
+ return "KEY_FENRIR"
+ }
+ switch key {
+ case KeyCtrl:
+ return "KEY_CTRL"
+ case KeyShift:
+ return "KEY_SHIFT"
+ case KeyAlt:
+ return "KEY_ALT"
+ }
+ name := C.libevdev_event_code_get_name(C.EV_KEY, C.uint(key))
+ if name == nil {
+ return ""
+ }
+ return C.GoString(name)
+}
+
+func (r *Router) script_binding(key uint16, numLock bool) (command.ID, bool) {
+ active := r.scriptKeys[key]
+ if !active {
+ for scriptKey := range r.scriptKeys {
+ if r.down(scriptKey) {
+ active = true
+ break
+ }
+ }
+ }
+ if !active {
+ return "", false
+ }
+ names := map[string]bool{}
+ for _, held := range r.keys {
+ for code := range held {
+ names[r.script_name(code, numLock)] = true
+ }
+ }
+ names[r.script_name(key, numLock)] = true
+ if !names["KEY_SCRIPT"] {
+ return "", false
+ }
+ var keys []string
+ for name := range names {
+ keys = append(keys, name)
+ }
+ if id := r.scriptBindings[Script_chord(keys)]; id != "" {
+ return id, true
+ }
+ // Modifier prefixes are silent. Nonmatching script chords are consumed.
+ for _, name := range keys {
+ switch name {
+ case "KEY_SCRIPT", "KEY_FENRIR", "KEY_CTRL", "KEY_SHIFT", "KEY_ALT", "KEY_META":
+ default:
+ return command.Unbound, true
+ }
+ }
+ return "", true
+}
diff --git a/internal/input/script_keys_test.go b/internal/input/script_keys_test.go
new file mode 100644
index 00000000..9347da15
--- /dev/null
+++ b/internal/input/script_keys_test.go
@@ -0,0 +1,93 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package input
+
+import (
+ "git.stormux.org/storm/fenrir/internal/command"
+ "testing"
+)
+
+func TestScriptChordsModifiersRepeatsAndReleaseOrders(t *testing.T) {
+ for _, releaseModifierFirst := range []bool{true, false} {
+ router := new_script_router(t, []string{"KEY_COMPOSE"}, []ScriptBinding{{ID: "script:music", Keys: []string{"KEY_SCRIPT", "KEY_SHIFT", "KEY_U"}}})
+ compose, _ := Key_code("KEY_COMPOSE")
+ for _, key := range []uint16{compose, KeyRightShift} {
+ if result := router.Key(0, key, 1, false, false); result.Forward || result.Command != "" {
+ t.Fatal(result)
+ }
+ }
+ if result := router.Key(0, 22, 1, false, false); result.Command != "script:music" || result.Forward {
+ t.Fatal(result)
+ }
+ if result := router.Key(0, 22, 2, false, false); result.Command != "" || result.Forward {
+ t.Fatal("repeat launched script", result)
+ }
+ keys := []uint16{22, KeyRightShift, compose}
+ if releaseModifierFirst {
+ keys = []uint16{compose, KeyRightShift, 22}
+ }
+ for _, key := range keys {
+ if result := router.Key(0, key, 0, false, false); result.Forward || result.Command != "" {
+ t.Fatal(result)
+ }
+ }
+ if result := router.Key(0, 22, 1, false, false); !result.Forward || result.Command != "" {
+ t.Fatal("normal typing lost", result)
+ }
+ }
+}
+
+func new_script_router(t *testing.T, keys []string, bindings []ScriptBinding) Router {
+ t.Helper()
+ router, err := New_router([]Binding{{Name: "Quit", Keys: []string{"FENRIR", "Q"}, Command: command.Quit}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := router.Configure_scripts(keys, bindings); err != nil {
+ t.Fatal(err)
+ }
+ return router
+}
+
+func TestScriptRoutingHandoffPassNextAndUnbound(t *testing.T) {
+ router := new_script_router(t, []string{"KEY_F12"}, []ScriptBinding{{ID: "script:a", Keys: []string{"KEY_SCRIPT", "KEY_A"}}})
+ f12, _ := Key_code("KEY_F12")
+ router.Pass_next()
+ if result := router.Key(0, f12, 1, false, false); !result.Forward {
+ t.Fatal(result)
+ }
+ if result := router.Key(0, 30, 1, false, false); !result.Forward {
+ t.Fatal(result)
+ }
+ router.Reset()
+ router.Key(0, f12, 1, false, false)
+ if result := router.Key(0, 30, 1, false, true); result.Command != "" {
+ t.Fatal("sync fired script", result)
+ }
+ router.Reset()
+ router.Key(0, f12, 1, false, false)
+ if result := router.Key(0, 48, 1, false, false); result.Command != command.Unbound || result.Forward {
+ t.Fatal(result)
+ }
+ router.Reset()
+ router.Capture(true)
+ router.Key(0, f12, 1, false, false)
+ if result := router.Key(0, 30, 1, false, false); result.Command != "" || result.UI == nil {
+ t.Fatal("script escaped menu capture", result)
+ }
+}
diff --git a/internal/input/spellcheck_test.go b/internal/input/spellcheck_test.go
new file mode 100644
index 00000000..1d03287a
--- /dev/null
+++ b/internal/input/spellcheck_test.go
@@ -0,0 +1,45 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package input
+
+import (
+ "git.stormux.org/storm/fenrir/internal/command"
+ "testing"
+)
+
+func TestSpellcheckChordCaptureReleaseAndHeldDelete(t *testing.T) {
+ for _, layout := range []string{"desktop", "laptop"} {
+ bindings, _, err := Load_bindings([]string{"../../config/keyboard/" + layout + ".toml"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, releases := range [][]uint16{{KeyInsert, 31}, {31, KeyInsert}} {
+ router, err := New_router(bindings)
+ if err != nil {
+ t.Fatal(err)
+ }
+ router.Key(0, KeyInsert, 1, false, false)
+ if got := router.Key(0, 31, 1, false, false); got.Command != command.SpellcheckMenu || got.Forward {
+ t.Fatal(layout, got)
+ }
+ if got := router.Key(0, 31, 2, false, false); got.Command != "" {
+ t.Fatal("held opener repeated", got)
+ }
+ router.Capture(true)
+ for _, code := range releases {
+ if got := router.Key(0, code, 0, false, false); got.Forward || got.UI != nil {
+ t.Fatal(got)
+ }
+ }
+ router.Key(0, KeyDelete, 1, false, false)
+ if got := router.Key(0, KeyDelete, 2, false, false); got.UI == nil || !got.UI.Repeat {
+ t.Fatal("repeat metadata lost", got)
+ }
+ router.Capture(false)
+ if got := router.Key(0, KeyDelete, 2, false, false); got.Forward || got.UI != nil {
+ t.Fatal("held Delete escaped capture", got)
+ }
+ }
+ }
+}
diff --git a/internal/input/spelling_test.go b/internal/input/spelling_test.go
new file mode 100644
index 00000000..0c8cea6f
--- /dev/null
+++ b/internal/input/spelling_test.go
@@ -0,0 +1,68 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package input
+
+import (
+ "testing"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+)
+
+func TestBundledSpellingPressesAndHeldActions(t *testing.T) {
+ for _, layout := range []string{"desktop", "laptop"} {
+ bindings, _, err := Load_bindings([]string{"../../config/keyboard/" + layout + ".toml"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, trial := range []struct {
+ desktop, laptop uint16
+ actions []command.ID
+ }{
+ {KeyKP5, 37, []command.ID{command.CurrentWord, command.SpellWord, command.PhoneticWord, command.CurrentWord}},
+ {KeyKP8, 23, []command.ID{command.CurrentLine, command.SpellLine, command.PhoneticLine, command.CurrentLine}},
+ {KeyKP2, 51, []command.ID{command.CurrentChar, command.PhoneticChar, command.CurrentChar}},
+ } {
+ router, err := New_router(bindings)
+ if err != nil {
+ t.Fatal(err)
+ }
+ now := time.Unix(1000, 0)
+ router.now = func() time.Time { return now }
+ keyCode := trial.desktop
+ if layout == "laptop" {
+ keyCode = trial.laptop
+ }
+ for index, want := range trial.actions {
+ if layout == "laptop" {
+ router.Key(1, KeyInsert, 1, false, false)
+ }
+ if got := router.Key(1, keyCode, 1, false, false); got.Command != want || got.Forward || !got.Interrupt {
+ t.Fatal(layout, index, got, want)
+ }
+ for range 3 {
+ if got := router.Key(1, keyCode, 2, false, false); got.Command != want || got.Keypress || got.Forward {
+ t.Fatal("repeat advanced or lost its action", layout, got, want)
+ }
+ }
+ // Exercise both modifier release orders across fresh presses.
+ if layout == "laptop" && index%2 == 0 {
+ router.Key(1, KeyInsert, 0, false, false)
+ }
+ router.Key(1, keyCode, 0, false, false)
+ if layout == "laptop" && index%2 != 0 {
+ router.Key(1, KeyInsert, 0, false, false)
+ }
+ now = now.Add(100 * time.Millisecond)
+ }
+ now = now.Add(time.Second)
+ if layout == "laptop" {
+ router.Key(1, KeyInsert, 1, false, false)
+ }
+ if got := router.Key(1, keyCode, 1, false, false); got.Command != trial.actions[0] {
+ t.Fatal("expired sequence did not restart", got)
+ }
+ }
+ }
+}
diff --git a/internal/input/ui.go b/internal/input/ui.go
new file mode 100644
index 00000000..e5b08903
--- /dev/null
+++ b/internal/input/ui.go
@@ -0,0 +1,78 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package input
+
+import (
+ "git.stormux.org/storm/fenrir/internal/interaction"
+ "time"
+)
+
+// Capture changes ownership without forgetting physical keys. Keys held on
+// entry cannot navigate the UI; keys held on exit cannot repeat into a terminal.
+// Backends release previously forwarded keys before enabling capture.
+func (r *Router) Capture(enabled bool) {
+ r.lastPress = time.Time{}
+ r.captured = enabled
+ r.passArmed, r.passing = false, false
+ for _, held := range r.keys {
+ for code, state := range held {
+ state.forwarded, state.ui = false, false
+ state.commandID = ""
+ held[code] = state
+ }
+ }
+}
+
+func (r *Router) Captured() bool { return r.captured }
+
+func (r *Router) ui_key(code uint16) Decision {
+ key := &interaction.Key{
+ Shift: r.down(KeyShift) || r.down(KeyRightShift),
+ Ctrl: r.down(KeyCtrl) || r.down(KeyRightCtrl),
+ Alt: r.down(KeyAlt), // Right Alt is text input (AltGr).
+ AltGr: r.down(KeyRightAlt),
+ }
+ switch code {
+ case KeyEsc:
+ key.Name = "escape"
+ case KeyTab:
+ key.Name = "tab"
+ case KeyEnter, KeyKPEnter:
+ key.Name = "enter"
+ case KeySpace:
+ key.Name = "space"
+ key.Text = " "
+ case KeyUp:
+ key.Name = "up"
+ case KeyDown:
+ key.Name = "down"
+ case KeyLeft:
+ key.Name = "left"
+ case KeyRight:
+ key.Name = "right"
+ case KeyHome:
+ key.Name = "home"
+ case KeyEnd:
+ key.Name = "end"
+ case KeyBackspace:
+ key.Name = "backspace"
+ case KeyDelete:
+ key.Name = "delete"
+ }
+ return Decision{UI: key, Interrupt: true}
+}
diff --git a/internal/input/ui_test.go b/internal/input/ui_test.go
new file mode 100644
index 00000000..35df9b8b
--- /dev/null
+++ b/internal/input/ui_test.go
@@ -0,0 +1,118 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package input
+
+import (
+ "strings"
+ "testing"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+)
+
+func TestMenuChordAndCaptureSwallowEveryKey(t *testing.T) {
+ router := desktop_router(t)
+ router.Key(1, KeyInsert, 1, false, false)
+ if got := router.Key(1, KeyF, 1, false, false); got.Command != command.FenrirMenu || got.Forward {
+ t.Fatal(got)
+ }
+ router.Capture(true)
+ router.Key(1, KeyF, 0, false, false)
+ router.Key(1, KeyInsert, 0, false, false)
+ for _, numLock := range []bool{false, true} {
+ for code := uint16(1); code <= 767; code++ {
+ for _, value := range []int32{1, 2, 0} {
+ got := router.Key(2, code, value, numLock, false)
+ if got.Forward || got.Command != "" {
+ t.Fatalf("key %d value %d leaked: %+v", code, value, got)
+ }
+ }
+ }
+ }
+}
+
+func TestCaptureClearsPassNextAndDrainsHeldKeys(t *testing.T) {
+ router := desktop_router(t)
+ if !router.Key(1, KeyCtrl, 1, false, false).Forward {
+ t.Fatal("fixture modifier not forwarded")
+ }
+ router.Pass_next()
+ router.Capture(true)
+ if len(router.Forwarded(1)) != 0 {
+ t.Fatal("forwarded state survived capture")
+ }
+ if router.Key(1, KeyCtrl, 0, false, false).Forward {
+ t.Fatal("modifier release leaked")
+ }
+ router.Key(1, KeyDown, 1, false, false)
+ router.Capture(false)
+ for _, value := range []int32{2, 0} {
+ got := router.Key(1, KeyDown, value, false, false)
+ if got.Forward || got.UI != nil || got.Command != "" {
+ t.Fatal("held key leaked after close", got)
+ }
+ }
+ if got := router.Key(1, KeyKP8, 1, false, false); got.Command != command.CurrentLine {
+ t.Fatal("pass-next survived", got)
+ }
+ router.Reset()
+ if got := router.Key(1, KeyF, 1, false, false); !got.Forward {
+ t.Fatal("ordinary input did not recover", got)
+ }
+}
+
+func TestCapturedModifiersRepeatAndSync(t *testing.T) {
+ router := desktop_router(t)
+ router.Capture(true)
+ if got := router.Key(3, KeyCtrl, 1, false, false); !got.Interrupt || got.Forward || got.UI != nil {
+ t.Fatal("captured Control did not stop speech", got)
+ }
+ router.Key(3, KeyCtrl, 0, false, false)
+ router.Key(1, KeyShift, 1, false, false)
+ got := router.Key(2, KeyTab, 1, false, false)
+ if got.UI == nil || !got.UI.Shift || got.UI.Name != "tab" {
+ t.Fatal(got)
+ }
+ got = router.Key(2, KeyTab, 2, false, false)
+ if got.UI == nil {
+ t.Fatal("navigation did not repeat")
+ }
+ for _, key := range []uint16{KeyEnter, KeySpace, KeyEsc} {
+ router.Key(1, key, 1, false, false)
+ if got := router.Key(1, key, 2, false, false); got.UI != nil {
+ t.Fatal("activation repeated", got)
+ }
+ }
+ if got := router.Key(1, KeyDown, 1, false, true); got.UI != nil || got.Forward {
+ t.Fatal("sync navigated", got)
+ }
+}
+
+func TestDesktopTOMLValidated(t *testing.T) {
+ bindings, err := Decode_bindings(desktop_data(t))
+ if err != nil || len(bindings) == 0 {
+ t.Fatal(bindings, err)
+ }
+ for _, replacement := range []struct{ old, new string }{
+ {"version = 1", "version = 2"}, {"fenrir_menu", "unknown"},
+ {"\"FENRIR\", \"F\"", "\"FENRIR\", \"UNKNOWN\""},
+ } {
+ if _, err := Decode_bindings([]byte(strings.Replace(string(desktop_data(t)), replacement.old, replacement.new, 1))); err == nil {
+ t.Fatal("invalid layout accepted", replacement)
+ }
+ }
+}
diff --git a/internal/input/version_test.go b/internal/input/version_test.go
new file mode 100644
index 00000000..6d682770
--- /dev/null
+++ b/internal/input/version_test.go
@@ -0,0 +1,49 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package input
+
+import (
+ "testing"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+)
+
+func TestVersionChordConsumesRepeatsAndBothReleaseOrders(t *testing.T) {
+ for _, layout := range []string{"desktop", "laptop"} {
+ bindings, _, err := Load_bindings([]string{"../../config/keyboard/" + layout + ".toml"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, modifierFirst := range []bool{false, true} {
+ for _, numLock := range []bool{false, true} {
+ router, err := New_router(bindings)
+ if err != nil {
+ t.Fatal(err)
+ }
+ router.Key(0, KeyInsert, 1, numLock, false)
+ router.Key(0, KeyShift, 1, numLock, false)
+ if got := router.Key(0, 47, 1, numLock, false); got.Command != command.Version || got.Forward || !got.Interrupt {
+ t.Fatal(layout, got)
+ }
+ if got := router.Key(0, 47, 2, numLock, false); got.Command != "" || got.Forward {
+ t.Fatal("held version chord repeated or escaped", layout, got)
+ }
+ if modifierFirst {
+ router.Key(0, KeyInsert, 0, numLock, false)
+ router.Key(0, KeyShift, 0, numLock, false)
+ }
+ if got := router.Key(0, 47, 0, numLock, false); got.Forward {
+ t.Fatal("version release escaped", layout, got)
+ }
+ if !modifierFirst {
+ router.Key(0, KeyInsert, 0, numLock, false)
+ router.Key(0, KeyShift, 0, numLock, false)
+ }
+ if got := router.Key(0, 47, 1, numLock, false); !got.Forward {
+ t.Fatal("ordinary V trapped after version", layout, got)
+ }
+ }
+ }
+ }
+}
diff --git a/internal/input/vertical_character_test.go b/internal/input/vertical_character_test.go
new file mode 100644
index 00000000..758db25d
--- /dev/null
+++ b/internal/input/vertical_character_test.go
@@ -0,0 +1,67 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package input
+
+import (
+ "testing"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+)
+
+func TestVerticalCharacterChordsRepeatAndRelease(t *testing.T) {
+ for _, layout := range []string{"desktop", "laptop"} {
+ bindings, _, err := Load_bindings([]string{"../../config/keyboard/" + layout + ".toml"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, trial := range []struct {
+ desktop, laptop uint16
+ want command.ID
+ }{
+ {KeyKP8, 23, command.ReviewUp}, {KeyKP2, 51, command.ReviewDown},
+ } {
+ for _, controlKey := range []uint16{KeyCtrl, KeyRightCtrl} {
+ for _, modifierFirst := range []bool{false, true} {
+ for _, numLock := range []bool{false, true} {
+ router, err := New_router(bindings)
+ if err != nil {
+ t.Fatal(err)
+ }
+ keyCode := trial.desktop
+ if layout == "laptop" {
+ keyCode = trial.laptop
+ }
+ router.Key(0, KeyInsert, 1, numLock, false)
+ controlPress := router.Key(1, controlKey, 1, numLock, false)
+ for _, value := range []int32{1, 2, 2} {
+ if got := router.Key(0, keyCode, value, numLock, false); got.Command != trial.want || got.Forward || !got.Interrupt {
+ t.Fatal(layout, trial.want, value, got)
+ }
+ }
+ releases := []uint16{keyCode, KeyInsert, controlKey}
+ if modifierFirst {
+ releases = []uint16{KeyInsert, controlKey, keyCode}
+ }
+ for _, code := range releases {
+ device := 0
+ if code == controlKey {
+ device = 1
+ }
+ got := router.Key(device, code, 0, numLock, false)
+ if got.Forward != (code == controlKey && controlPress.Forward) || got.Command != "" {
+ t.Fatal("unbalanced release", layout, code, got)
+ }
+ }
+ if router.Held(0) || router.Held(1) {
+ t.Fatal("stuck vertical chord")
+ }
+ if got := router.Key(0, 30, 1, numLock, false); !got.Forward || got.Command != "" {
+ t.Fatal("typing after vertical chord", got)
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/internal/interaction/choice_test.go b/internal/interaction/choice_test.go
new file mode 100644
index 00000000..64c5b9b5
--- /dev/null
+++ b/internal/interaction/choice_test.go
@@ -0,0 +1,120 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package interaction
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestGroupedChoicesTraversalIsolationAndSelection(t *testing.T) {
+ form := Form{Type: TabForm, Title: "Settings", Tabs: []Tab{{ID: "presentation", Label: "Presentation", Controls: []Control{
+ {ID: "speech.driver", Label: "Driver", Group: "Speech", Kind: ComboBox, Choices: []Choice{{ID: "first", Label: "First"}, {ID: "second", Label: "Second", Unavailable: "fixture dependency missing"}}},
+ {ID: "speech.enabled", Label: "Speech enabled", Group: "Speech", Kind: CheckBox},
+ {ID: "sound.enabled", Label: "Sound enabled", Group: "Sound", Kind: CheckBox},
+ {ID: "ok", Label: "OK", Kind: Button, Action: "save"},
+ }}}}
+ session, err := New_session(form)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := session.Handle(Key{Name: "tab"}).Speech; got != "Speech. Driver combo box First" {
+ t.Fatal(got)
+ }
+ for _, key := range []Key{{Text: "invented"}, {Name: "enter"}, {Name: "space"}, {Ctrl: true, Text: "a"}} {
+ if result := session.Handle(key); result.Action != "" || result.Cancelled {
+ t.Fatal(result)
+ }
+ }
+ if got := session.Values()["presentation.speech.driver"].Choices[0].ID; got != "first" {
+ t.Fatal(got)
+ }
+ if got := session.Handle(Key{Name: "down"}).Speech; got != "Second. unavailable: fixture dependency missing" {
+ t.Fatal(got)
+ }
+ session.Handle(Key{Name: "down"})
+ if got := session.Values()["presentation.speech.driver"].Selected; got != 1 {
+ t.Fatal("combo wrapped", got)
+ }
+ values := session.Values()
+ values["presentation.speech.driver"].Choices[1].Label = "altered"
+ if strings.Contains(session.Describe(), "altered") || form.Tabs[0].Controls[0].Selected != 0 {
+ t.Fatal("aliased choice state")
+ }
+ session.Handle(Key{Name: "home"})
+ if got := session.Values()["presentation.speech.driver"].Selected; got != 0 {
+ t.Fatal(got)
+ }
+ if got := session.Handle(Key{Name: "tab"}).Speech; got != "Speech enabled unchecked" {
+ t.Fatal("repeated group", got)
+ }
+ if got := session.Handle(Key{Name: "tab"}).Speech; got != "Sound. Sound enabled unchecked" {
+ t.Fatal(got)
+ }
+ if got := session.Handle(Key{Name: "tab", Shift: true}).Speech; got != "Speech. Speech enabled unchecked" {
+ t.Fatal(got)
+ }
+ if !session.Handle(Key{Name: "escape"}).Cancelled {
+ t.Fatal("escape trapped")
+ }
+}
+
+func TestComboChangesSpeakOnlyValueAndFocusRepeatsLabel(t *testing.T) {
+ form := Form{Type: TabForm, Title: "Settings", Tabs: []Tab{{ID: "settings", Label: "Settings", Controls: []Control{
+ {ID: "format", Label: "Format", Kind: ComboBox, Choices: []Choice{{ID: "one", Label: "First"}, {ID: "two", Label: "Second"}, {ID: "three", Label: "Third"}}},
+ {ID: "ok", Label: "OK", Kind: Button, Action: "save"},
+ }}}}
+ session, err := New_session(form)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := session.Handle(Key{Name: "tab"}).Speech; got != "Format combo box First" {
+ t.Fatal(got)
+ }
+ for _, testCase := range []struct{ key, want string }{
+ {"down", "Second"}, {"right", "Third"}, {"up", "Second"},
+ {"left", "First"}, {"end", "Third"}, {"home", "First"},
+ } {
+ if got := session.Handle(Key{Name: testCase.key}).Speech; got != testCase.want {
+ t.Fatal(testCase.key, got, testCase.want)
+ }
+ }
+ session.Handle(Key{Name: "tab"})
+ if got := session.Handle(Key{Name: "tab", Shift: true}).Speech; got != "Format combo box First" {
+ t.Fatal("reverse focus omitted label", got)
+ }
+ session.Handle(Key{Name: "tab", Shift: true})
+ if got := session.Handle(Key{Name: "tab"}).Speech; got != "Format combo box First" {
+ t.Fatal("forward focus omitted label", got)
+ }
+}
+
+func TestChoiceDefinitionsRejectMissingAndDuplicateSelections(t *testing.T) {
+ for _, control := range []Control{
+ {Choices: nil},
+ {Choices: []Choice{{ID: "one", Label: "One"}}, Selected: 1},
+ {Choices: []Choice{{ID: "one", Label: "One"}, {ID: "one", Label: "Duplicate"}}},
+ {Choices: []Choice{{ID: "one"}}},
+ } {
+ control.ID, control.Label, control.Kind = "driver", "Driver", ComboBox
+ form := Form{Type: TabForm, Title: "Settings", Tabs: []Tab{{ID: "presentation", Label: "Presentation", Controls: []Control{control}}}}
+ if _, err := New_session(form); err == nil {
+ t.Fatal("invalid combo accepted", control)
+ }
+ }
+}
diff --git a/internal/interaction/dynamic_grid_test.go b/internal/interaction/dynamic_grid_test.go
new file mode 100644
index 00000000..619daeb1
--- /dev/null
+++ b/internal/interaction/dynamic_grid_test.go
@@ -0,0 +1,48 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package interaction
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestEmptyAndRefreshedGridKeepsFocusSearchAndLiteralWords(t *testing.T) {
+ form := grid_form()
+ form.Tabs[0].Controls[0].Choices = nil
+ form.Tabs[0].Controls[0].DeleteAction = "remove"
+ session, err := New_session(form)
+ if err != nil {
+ t.Fatal(err)
+ }
+ session.Handle(Key{Name: "tab"})
+ if got := session.Handle(Key{Name: "enter"}); got.Action != "" || got.Speech != "No matches" {
+ t.Fatal(got)
+ }
+ session.Handle(Key{Text: "c"})
+ session.Set_choices("one", "grid", []Choice{{ID: "cat", Label: "cat", Literal: true}, {ID: "car", Label: "car", Literal: true}})
+ session.Handle(Key{Name: "right"})
+ session.Set_choices("one", "grid", []Choice{{ID: "car", Label: "car", Literal: true}, {ID: "cat", Label: "cat", Literal: true}})
+ control, _ := session.Focused_control()
+ if control.Text != "c" || control.Choices[control.Selected].ID != "car" {
+ t.Fatal(control)
+ }
+ if got := session.Handle(Key{Name: "delete", Repeat: true}); got.Action != "" {
+ t.Fatal(got)
+ }
+ if got := session.Handle(Key{Name: "delete"}); got.Action != "remove" {
+ t.Fatal(got)
+ }
+ session.Set_choices("one", "grid", nil)
+ if !strings.Contains(session.Describe(), "No matches") {
+ t.Fatal(session.Describe())
+ }
+ // Updating a grid must own its choices, independently of worker buffers.
+ choices := []Choice{{ID: "cat", Label: "cat", Literal: true}}
+ session.Set_choices("one", "grid", choices)
+ choices[0].Label = "wrong"
+ if !strings.Contains(session.Describe(), "cat") {
+ t.Fatal(session.Describe())
+ }
+}
diff --git a/internal/interaction/entry.go b/internal/interaction/entry.go
new file mode 100644
index 00000000..10a0450a
--- /dev/null
+++ b/internal/interaction/entry.go
@@ -0,0 +1,91 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package interaction
+
+import (
+ "git.stormux.org/storm/fenrir/internal/i18n"
+ "strings"
+ "unicode"
+)
+
+func (s *Session) edit(control *Control, key Key) Result {
+ runes := []rune(control.Text)
+ if key.Ctrl {
+ if strings.EqualFold(key.Text, "a") {
+ s.selectAll = true
+ return Result{Speech: i18n.T("selected") + " " + control.Text}
+ }
+ return Result{}
+ }
+ switch key.Name {
+ case "enter":
+ return Result{Action: control.Action}
+ case "left":
+ s.cursor = max(0, s.cursor-1)
+ s.selectAll = false
+ case "right":
+ s.cursor = min(len(runes), s.cursor+1)
+ s.selectAll = false
+ case "home":
+ s.cursor = 0
+ s.selectAll = false
+ case "end":
+ s.cursor = len(runes)
+ s.selectAll = false
+ case "backspace", "delete":
+ if s.selectAll {
+ runes = nil
+ s.cursor = 0
+ } else if key.Name == "backspace" && s.cursor > 0 {
+ runes = append(runes[:s.cursor-1], runes[s.cursor:]...)
+ s.cursor--
+ } else if key.Name == "delete" && s.cursor < len(runes) {
+ runes = append(runes[:s.cursor], runes[s.cursor+1:]...)
+ }
+ s.selectAll = false
+ default:
+ text := []rune(key.Text)
+ if len(text) == 0 {
+ return Result{}
+ }
+ for _, char := range text {
+ if !unicode.IsPrint(char) {
+ return Result{}
+ }
+ }
+ length := len(runes)
+ if s.selectAll {
+ length = 0
+ }
+ if length+len(text) > 4096 {
+ return Result{}
+ }
+ if s.selectAll {
+ runes = nil
+ s.cursor = 0
+ }
+ runes = append(runes[:s.cursor], append(text, runes[s.cursor:]...)...)
+ s.cursor += len(text)
+ s.selectAll = false
+ }
+ control.Text = string(runes)
+ if (key.Name == "left" || key.Name == "right" || key.Name == "home" || key.Name == "end") && s.cursor < len(runes) {
+ return Result{Speech: string(runes[s.cursor])}
+ }
+ return Result{Speech: s.Describe()}
+}
diff --git a/internal/interaction/form.go b/internal/interaction/form.go
new file mode 100644
index 00000000..eb9273df
--- /dev/null
+++ b/internal/interaction/form.go
@@ -0,0 +1,80 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package interaction
+
+type Kind int
+
+const (
+ CheckBox Kind = iota
+ Number
+ Entry
+ List
+ Button
+ ComboBox
+ Grid
+)
+
+type Control struct {
+ ID, Label string
+ Group string
+ Hidden bool // Retains draft values while excluded from navigation.
+ Kind Kind
+ Checked bool
+ Number float64
+ Min, Max, Step float64
+ Text string
+ Items []string
+ Selected int
+ Action string
+ Choices []Choice
+ Columns int // Grid width; Text is its prefix filter.
+ DeleteAction string
+}
+
+type Choice struct {
+ ID, Label, Unavailable string
+ Literal bool // User/dictionary data must not be translated.
+}
+
+type Tab struct {
+ ID, Label string
+ Controls []Control
+}
+
+type FormType string
+
+const TabForm FormType = "tab_form"
+
+type Form struct {
+ Type FormType
+ Title string
+ Tabs []Tab
+}
+
+type Key struct {
+ Name string
+ Text string
+ Shift, Ctrl, Alt, AltGr bool
+ Repeat bool
+}
+
+type Result struct {
+ Speech string
+ Action string
+ Cancelled bool
+}
diff --git a/internal/interaction/grid.go b/internal/interaction/grid.go
new file mode 100644
index 00000000..8a1aebde
--- /dev/null
+++ b/internal/interaction/grid.go
@@ -0,0 +1,117 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package interaction
+
+import (
+ "strconv"
+ "strings"
+ "unicode"
+
+ "git.stormux.org/storm/fenrir/internal/i18n"
+)
+
+// Grid selections index Choices, even while the visible results are filtered.
+// Choice IDs remain opaque to the model and can carry a host-owned value.
+func (c Control) grid_matches() []int {
+ var matches []int
+ prefix := strings.ToLower(c.Text)
+ for index, choice := range c.Choices {
+ if strings.HasPrefix(strings.ToLower(choice_label(choice)), prefix) {
+ matches = append(matches, index)
+ }
+ }
+ return matches
+}
+
+func (c *Control) filter_grid() {
+ c.Selected = -1
+ if matches := c.grid_matches(); len(matches) > 0 {
+ c.Selected = matches[0]
+ }
+}
+
+func describe_grid(c Control) string {
+ matches := c.grid_matches()
+ for position, index := range matches {
+ if index == c.Selected {
+ return i18n.Format("{0}. row {1}, column {2}. {3} of {4}",
+ choice_label(c.Choices[index]), strconv.Itoa(position/c.Columns+1), strconv.Itoa(position%c.Columns+1), strconv.Itoa(position+1), strconv.Itoa(len(matches)))
+ }
+ }
+ return i18n.T("No matches")
+}
+
+func (s *Session) grid(c *Control, key Key) Result {
+ if key.Ctrl && key.Name != "backspace" {
+ return Result{}
+ }
+ if key.Name == "backspace" || (key.Name == "" || key.Name == "space") && key.Text != "" && !key.Ctrl {
+ runes := []rune(c.Text)
+ if key.Name == "backspace" {
+ if key.Ctrl {
+ runes = nil
+ } else if len(runes) > 0 {
+ runes = runes[:len(runes)-1]
+ }
+ } else {
+ for _, char := range key.Text {
+ if !unicode.IsPrint(char) {
+ return Result{}
+ }
+ }
+ if len(runes)+len([]rune(key.Text)) > 4096 {
+ return Result{}
+ }
+ runes = append(runes, []rune(key.Text)...)
+ }
+ c.Text = string(runes)
+ c.filter_grid()
+ query := i18n.T("Search cleared")
+ if c.Text != "" {
+ query = i18n.Format("Search {0}", c.Text)
+ }
+ return Result{Speech: query + ". " + describe_grid(*c)}
+ }
+ matches := c.grid_matches()
+ if len(matches) == 0 {
+ return Result{Speech: i18n.T("No matches")}
+ }
+ position := 0
+ for current, index := range matches {
+ if index == c.Selected {
+ position = current
+ break
+ }
+ }
+ switch key.Name {
+ case "left":
+ position = max(0, position-1)
+ case "right":
+ position = min(len(matches)-1, position+1)
+ case "up":
+ if position >= c.Columns {
+ position -= c.Columns
+ }
+ case "down":
+ if position/c.Columns < (len(matches)-1)/c.Columns {
+ position = min(len(matches)-1, position+c.Columns)
+ }
+ case "home":
+ position = 0
+ case "end":
+ position = len(matches) - 1
+ case "enter":
+ return Result{Action: c.Action}
+ case "delete":
+ if key.Repeat {
+ return Result{}
+ }
+ return Result{Action: c.DeleteAction}
+ default:
+ return Result{}
+ }
+ c.Selected = matches[position]
+ return Result{Speech: describe_grid(*c)}
+}
diff --git a/internal/interaction/grid_test.go b/internal/interaction/grid_test.go
new file mode 100644
index 00000000..12dbd3e7
--- /dev/null
+++ b/internal/interaction/grid_test.go
@@ -0,0 +1,93 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package interaction
+
+import (
+ "strings"
+ "testing"
+)
+
+func grid_form() Form {
+ return Form{Type: TabForm, Title: "Picker", Tabs: []Tab{{ID: "one", Label: "One", Controls: []Control{{
+ ID: "grid", Label: "Items", Kind: Grid, Columns: 3, Action: "choose",
+ Choices: []Choice{{ID: "a", Label: "Apple"}, {ID: "b", Label: "Bat"}, {ID: "c", Label: "Cat"},
+ {ID: "d", Label: "Dog"}, {ID: "j", Label: "Jack o'lantern"}, {ID: "jp", Label: "Japan"}, {ID: "Ć©", Label: "Ćclair"}},
+ }}}}}
+}
+
+func TestGridNavigationRaggedRowsAndActivation(t *testing.T) {
+ session, err := New_session(grid_form())
+ if err != nil {
+ t.Fatal(err)
+ }
+ session.Handle(Key{Name: "tab"})
+ for _, trial := range []struct{ key, want string }{
+ {"right", "b"}, {"up", "b"}, {"down", "j"}, {"down", "Ć©"}, {"down", "Ć©"},
+ {"up", "d"}, {"left", "c"}, {"home", "a"}, {"left", "a"}, {"end", "Ć©"}, {"right", "Ć©"},
+ } {
+ session.Handle(Key{Name: trial.key})
+ control, _ := session.Focused_control()
+ if got := control.Choices[control.Selected].ID; got != trial.want {
+ t.Fatalf("%s: got %s, want %s", trial.key, got, trial.want)
+ }
+ }
+ // XLookupString supplies carriage return alongside the named Enter key.
+ if got := session.Handle(Key{Name: "enter", Text: "\r"}); got.Action != "choose" {
+ t.Fatal(got)
+ }
+}
+
+func TestGridPrefixEmptyResultsUnicodeAndIsolation(t *testing.T) {
+ form := grid_form()
+ session, err := New_session(form)
+ if err != nil {
+ t.Fatal(err)
+ }
+ session.Handle(Key{Name: "tab"})
+ if got := session.Handle(Key{Text: "J"}); !strings.Contains(got.Speech, "Jack o'lantern") || !strings.Contains(got.Speech, "1 of 2") {
+ t.Fatal(got)
+ }
+ session.Handle(Key{Name: "right"})
+ control, _ := session.Focused_control()
+ if control.Selected != 5 {
+ t.Fatal("filtered selection lost catalog index", control)
+ }
+ control.Choices[5].Label = "changed"
+ if got := session.Handle(Key{Text: "z"}); !strings.Contains(got.Speech, "No matches") {
+ t.Fatal(got)
+ }
+ if got := session.Handle(Key{Name: "enter"}); got.Action != "" {
+ t.Fatal("empty results activated", got)
+ }
+ session.Handle(Key{Name: "backspace"})
+ if got := session.Handle(Key{Name: "right"}); !strings.Contains(got.Speech, "Japan") {
+ t.Fatal("focused value aliased session", got)
+ }
+ session.Handle(Key{Name: "backspace", Ctrl: true})
+ if got := session.Handle(Key{Text: "Ć©"}); !strings.Contains(got.Speech, "Ćclair") {
+ t.Fatal(got)
+ }
+ session.Handle(Key{Name: "backspace"})
+ control, _ = session.Focused_control()
+ if control.Text != "" || form.Tabs[0].Controls[0].Selected != 0 || form.Tabs[0].Controls[0].Text != "" {
+ t.Fatal("Unicode deletion or form isolation failed", control)
+ }
+ if got := session.Handle(Key{Name: "escape"}); !got.Cancelled {
+ t.Fatal(got)
+ }
+}
+
+func TestGridValidation(t *testing.T) {
+ form := grid_form()
+ form.Tabs[0].Controls[0].Columns = 0
+ if _, err := New_session(form); err == nil {
+ t.Fatal("zero-width grid accepted")
+ }
+ form = grid_form()
+ form.Tabs[0].Controls[0].Choices[1].ID = "a"
+ if _, err := New_session(form); err == nil {
+ t.Fatal("duplicate values accepted")
+ }
+}
diff --git a/internal/interaction/presentation.go b/internal/interaction/presentation.go
new file mode 100644
index 00000000..482a75d0
--- /dev/null
+++ b/internal/interaction/presentation.go
@@ -0,0 +1,86 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package interaction
+
+import (
+ "strconv"
+
+ "git.stormux.org/storm/fenrir/internal/i18n"
+)
+
+func (s *Session) Open() string { return i18n.T(s.form.Title) + ". " + s.Describe() }
+
+func (s *Session) Describe() string {
+ tab := s.form.Tabs[s.tab]
+ if s.focus == -1 {
+ return i18n.Format("{0} tab", i18n.T(tab.Label))
+ }
+ control := tab.Controls[s.focus]
+ label := i18n.T(control.Label)
+ switch control.Kind {
+ case CheckBox:
+ state := "unchecked"
+ if control.Checked {
+ state = "checked"
+ }
+ return label + " " + i18n.T(state)
+ case Number:
+ return label + " " + strconv.FormatFloat(control.Number, 'f', -1, 64)
+ case Entry:
+ return i18n.Format("{0} entry {1}", label, control.Text)
+ case List:
+ return label + " " + control.Items[control.Selected]
+ case Grid:
+ return i18n.Format("{0} grid. {1}", label, describe_grid(control))
+ case ComboBox:
+ return i18n.Format("{0} combo box {1}", label, describe_choice(control))
+ case Button:
+ return i18n.Format("{0} button", label)
+ }
+ return label
+}
+
+func describe_choice(control Control) string {
+ choice := control.Choices[control.Selected]
+ text := choice_label(choice)
+ if choice.Unavailable != "" {
+ text += ". " + i18n.T("unavailable") + ": " + choice.Unavailable
+ }
+ return text
+}
+
+func choice_label(choice Choice) string {
+ if choice.Literal {
+ return choice.Label
+ }
+ return i18n.T(choice.Label)
+}
+
+func (s *Session) focused_group() string {
+ if s.focus < 0 {
+ return ""
+ }
+ return s.form.Tabs[s.tab].Controls[s.focus].Group
+}
+
+func (s *Session) describe_focus(previousGroup string) string {
+ if group := s.focused_group(); group != "" && group != previousGroup {
+ return i18n.T(group) + ". " + s.Describe()
+ }
+ return s.Describe()
+}
diff --git a/internal/interaction/session.go b/internal/interaction/session.go
new file mode 100644
index 00000000..3c67b431
--- /dev/null
+++ b/internal/interaction/session.go
@@ -0,0 +1,312 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+// Package interaction models reader-owned forms without device, speech or
+// settings dependencies. Hosts own capture and perform the returned actions.
+package interaction
+
+import (
+ "fmt"
+ "math"
+)
+
+type Session struct {
+ form Form
+ tab, focus, cursor int // focus -1 is the tab strip.
+ selectAll bool
+}
+
+func New_session(form Form) (*Session, error) {
+ if form.Type != TabForm {
+ return nil, fmt.Errorf("unsupported form type %q", form.Type)
+ }
+ if form.Title == "" || len(form.Tabs) == 0 {
+ return nil, fmt.Errorf("form requires a title and tabs")
+ }
+ // Every session owns its mutable values, including nested slices.
+ form.Tabs = append([]Tab(nil), form.Tabs...)
+ ids := make(map[string]bool)
+ for tabIndex := range form.Tabs {
+ tab := &form.Tabs[tabIndex]
+ if tab.ID == "" || tab.Label == "" || ids[tab.ID] {
+ return nil, fmt.Errorf("invalid or duplicate tab %q", tab.ID)
+ }
+ ids[tab.ID] = true
+ tab.Controls = append([]Control(nil), tab.Controls...)
+ controlIDs := make(map[string]bool)
+ for controlIndex := range tab.Controls {
+ control := &tab.Controls[controlIndex]
+ if control.ID == "" || control.Label == "" || controlIDs[control.ID] || control.Kind < CheckBox || control.Kind > Grid {
+ return nil, fmt.Errorf("invalid control %q", control.ID)
+ }
+ controlIDs[control.ID] = true
+ if control.Kind == Number && (math.IsNaN(control.Number) || math.IsInf(control.Number, 0) || math.IsNaN(control.Min) || math.IsInf(control.Min, 0) || math.IsNaN(control.Max) || math.IsInf(control.Max, 0) || math.IsNaN(control.Step) || math.IsInf(control.Step, 0) || control.Min > control.Max || control.Step <= 0 || control.Number < control.Min || control.Number > control.Max) {
+ return nil, fmt.Errorf("invalid range %q", control.ID)
+ }
+ control.Items = append([]string(nil), control.Items...)
+ control.Choices = append([]Choice(nil), control.Choices...)
+ if control.Kind == ComboBox || control.Kind == Grid {
+ if !(control.Kind == Grid && len(control.Choices) == 0) && (len(control.Choices) == 0 || control.Selected < 0 || control.Selected >= len(control.Choices)) {
+ return nil, fmt.Errorf("invalid choices in %q", control.ID)
+ }
+ choiceIDs := make(map[string]bool)
+ for _, choice := range control.Choices {
+ if choice.Label == "" || choiceIDs[choice.ID] {
+ return nil, fmt.Errorf("invalid choice in %q", control.ID)
+ }
+ choiceIDs[choice.ID] = true
+ }
+ }
+ if control.Kind == Grid {
+ if control.Columns < 1 {
+ return nil, fmt.Errorf("invalid grid width %q", control.ID)
+ }
+ control.filter_grid()
+ }
+ if control.Kind == List && (len(control.Items) == 0 || control.Selected < 0 || control.Selected >= len(control.Items)) {
+ return nil, fmt.Errorf("invalid list %q", control.ID)
+ }
+ }
+ }
+ return &Session{form: form, focus: -1}, nil
+}
+
+// Values returns a copy suitable for validation or submission by the host.
+func (s *Session) Values() map[string]Control {
+ values := make(map[string]Control)
+ for _, tab := range s.form.Tabs {
+ for _, control := range tab.Controls {
+ control.Items = append([]string(nil), control.Items...)
+ control.Choices = append([]Choice(nil), control.Choices...)
+ values[tab.ID+"."+control.ID] = control
+ }
+ }
+ return values
+}
+
+// Focused_control returns an independent value for the host's current action.
+func (s *Session) Focused_control() (Control, bool) {
+ if s.focus < 0 {
+ return Control{}, false
+ }
+ control := s.form.Tabs[s.tab].Controls[s.focus]
+ control.Items = append([]string(nil), control.Items...)
+ control.Choices = append([]Choice(nil), control.Choices...)
+ return control, true
+}
+
+// Set_choices updates an asynchronous grid without moving keyboard focus or
+// discarding its search. Keep the selected word when it still matches.
+func (s *Session) Set_choices(tabID, controlID string, choices []Choice) {
+ for tabIndex := range s.form.Tabs {
+ tab := &s.form.Tabs[tabIndex]
+ if tab.ID != tabID {
+ continue
+ }
+ for index := range tab.Controls {
+ control := &tab.Controls[index]
+ if control.ID != controlID || control.Kind != Grid {
+ continue
+ }
+ selected := ""
+ position := control.Selected
+ if position >= 0 && position < len(control.Choices) {
+ selected = control.Choices[position].ID
+ }
+ control.Choices = append([]Choice(nil), choices...)
+ control.filter_grid()
+ matches := control.grid_matches()
+ for _, match := range matches {
+ if control.Choices[match].ID == selected {
+ control.Selected = match
+ return
+ }
+ }
+ if len(matches) > 0 {
+ control.Selected = matches[min(max(position, 0), len(matches)-1)]
+ }
+ return
+ }
+ }
+}
+
+func (s *Session) move_focus(delta int) {
+ count := len(s.form.Tabs[s.tab].Controls) + 1
+ for range count {
+ s.focus = (s.focus+1+delta+count)%count - 1
+ if s.focus == -1 || !s.form.Tabs[s.tab].Controls[s.focus].Hidden {
+ break
+ }
+ }
+ s.selectAll = false
+ if s.focus >= 0 {
+ s.cursor = len([]rune(s.form.Tabs[s.tab].Controls[s.focus].Text))
+ }
+}
+
+// Set_hidden changes navigation without rebuilding the form or losing entry
+// edits. If the focused control disappears, move forward to a visible target.
+func (s *Session) Set_hidden(tabID, controlID string, hidden bool) {
+ for tabIndex := range s.form.Tabs {
+ tab := &s.form.Tabs[tabIndex]
+ if tab.ID != tabID {
+ continue
+ }
+ for index := range tab.Controls {
+ if tab.Controls[index].ID != controlID {
+ continue
+ }
+ tab.Controls[index].Hidden = hidden
+ if hidden && s.tab == tabIndex && s.focus == index {
+ s.move_focus(1)
+ }
+ return
+ }
+ }
+}
+
+func (s *Session) Handle(key Key) Result {
+ if key.Name == "escape" {
+ return Result{Cancelled: true}
+ }
+ if key.Name == "tab" && !key.Ctrl && !key.Alt {
+ previousGroup := s.focused_group()
+ delta := 1
+ if key.Shift {
+ delta = -1
+ }
+ s.move_focus(delta)
+ return Result{Speech: s.describe_focus(previousGroup)}
+ }
+ if key.Alt {
+ return Result{}
+ }
+ if key.Ctrl && s.focus == -1 {
+ return Result{}
+ }
+ if s.focus == -1 {
+ delta := 0
+ switch key.Name {
+ case "left", "up":
+ delta = -1
+ case "right", "down":
+ delta = 1
+ case "enter", "space":
+ s.move_focus(1)
+ }
+ if delta != 0 {
+ s.tab = (s.tab + delta + len(s.form.Tabs)) % len(s.form.Tabs)
+ }
+ if delta != 0 || key.Name == "enter" || key.Name == "space" {
+ return Result{Speech: s.describe_focus("")}
+ }
+ return Result{}
+ }
+ control := &s.form.Tabs[s.tab].Controls[s.focus]
+ if control.Kind == Entry {
+ return s.edit(control, key)
+ }
+ if control.Kind == Grid {
+ return s.grid(control, key)
+ }
+ if key.Ctrl {
+ return Result{}
+ }
+ switch control.Kind {
+ case CheckBox:
+ switch key.Name {
+ case "enter", "space":
+ control.Checked = !control.Checked
+ default:
+ return Result{}
+ }
+ case Number:
+ delta := 0.0
+ switch key.Name {
+ case "up":
+ delta = 1
+ case "down":
+ delta = -1
+ case "right":
+ delta = control.Step
+ case "left":
+ delta = -control.Step
+ default:
+ return Result{}
+ }
+ control.Number = math.Max(control.Min, math.Min(control.Max, math.Round((control.Number+delta)*1e9)/1e9))
+ case List:
+ switch key.Name {
+ case "up":
+ control.Selected = (control.Selected + len(control.Items) - 1) % len(control.Items)
+ case "down":
+ control.Selected = (control.Selected + 1) % len(control.Items)
+ case "enter":
+ return Result{Action: control.Action}
+ default:
+ return Result{}
+ }
+ case ComboBox:
+ switch key.Name {
+ case "up", "left":
+ control.Selected = max(0, control.Selected-1)
+ case "down", "right":
+ control.Selected = min(len(control.Choices)-1, control.Selected+1)
+ case "home":
+ control.Selected = 0
+ case "end":
+ control.Selected = len(control.Choices) - 1
+ default:
+ return Result{}
+ }
+ return Result{Speech: describe_choice(*control)}
+ case Button:
+ if key.Name == "enter" || key.Name == "space" {
+ return Result{Action: control.Action}
+ }
+ return Result{}
+ }
+ return Result{Speech: s.Describe()}
+}
+
+// Replace_choices updates a dependent combo without changing focus.
+func (s *Session) Replace_choices(tabID, controlID string, choices []Choice, selected string) {
+ if len(choices) == 0 {
+ return
+ }
+ for ti := range s.form.Tabs {
+ tab := &s.form.Tabs[ti]
+ if tab.ID != tabID {
+ continue
+ }
+ for ci := range tab.Controls {
+ control := &tab.Controls[ci]
+ if control.ID != controlID || control.Kind != ComboBox {
+ continue
+ }
+ control.Choices = append([]Choice(nil), choices...)
+ control.Selected = 0
+ for index, choice := range choices {
+ if choice.ID == selected {
+ control.Selected = index
+ break
+ }
+ }
+ return
+ }
+ }
+}
diff --git a/internal/interaction/session_test.go b/internal/interaction/session_test.go
new file mode 100644
index 00000000..e5b061aa
--- /dev/null
+++ b/internal/interaction/session_test.go
@@ -0,0 +1,148 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package interaction
+
+import "testing"
+
+func test_form() Form {
+ return Form{Type: TabForm, Title: "Settings", Tabs: []Tab{
+ {ID: "speech", Label: "Speech", Controls: []Control{
+ {ID: "enabled", Label: "Speech enabled", Kind: CheckBox, Checked: true},
+ {ID: "range", Label: "Range", Kind: Number, Number: 2.5, Min: -5, Max: 5, Step: 0.1},
+ {ID: "text", Label: "Text", Kind: Entry, Text: "aēc"},
+ {ID: "ok", Label: "OK", Kind: Button, Action: "save"},
+ {ID: "cancel", Label: "Cancel", Kind: Button, Action: "cancel"},
+ }},
+ {ID: "sound", Label: "Sound"},
+ }}
+}
+
+func TestTabFormTraversalControlsAndIsolation(t *testing.T) {
+ form := test_form()
+ session, err := New_session(form)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if session.Open() != "Settings. Speech tab" {
+ t.Fatal(session.Open())
+ }
+ for _, name := range []string{"right", "left", "down", "up"} {
+ result := session.Handle(Key{Name: name})
+ if result.Speech == "" {
+ t.Fatal("silent tab", name)
+ }
+ }
+ if session.Describe() != "Speech tab" {
+ t.Fatal(session.Describe())
+ }
+ if got := session.Handle(Key{Name: "tab"}).Speech; got != "Speech enabled checked" {
+ t.Fatal(got)
+ }
+ if got := session.Handle(Key{Name: "space"}).Speech; got != "Speech enabled unchecked" {
+ t.Fatal(got)
+ }
+ if !form.Tabs[0].Controls[0].Checked {
+ t.Fatal("form mutated outside session")
+ }
+ session.Handle(Key{Name: "tab"})
+ for _, fixture := range []struct {
+ name string
+ want float64
+ }{
+ {"up", 3.5}, {"right", 3.6}, {"left", 3.5}, {"down", 2.5},
+ } {
+ session.Handle(Key{Name: fixture.name})
+ if got := session.Values()["speech.range"].Number; got != fixture.want {
+ t.Fatal(fixture, got)
+ }
+ }
+ for range 100 {
+ session.Handle(Key{Name: "up"})
+ }
+ if got := session.Values()["speech.range"].Number; got != 5 {
+ t.Fatal("upper bound", got)
+ }
+ for range 100 {
+ session.Handle(Key{Name: "down"})
+ }
+ if got := session.Values()["speech.range"].Number; got != -5 {
+ t.Fatal("lower bound", got)
+ }
+ session.Handle(Key{Name: "tab"})
+ session.Handle(Key{Name: "home"})
+ session.Handle(Key{Name: "right"})
+ session.Handle(Key{Name: "delete"})
+ session.Handle(Key{Text: "Ć©"})
+ if got := session.Values()["speech.text"].Text; got != "aƩc" {
+ t.Fatal("Unicode editing", got)
+ }
+ session.Handle(Key{Ctrl: true, Text: "a"})
+ session.Handle(Key{Text: "replacement"})
+ if got := session.Values()["speech.text"].Text; got != "replacement" {
+ t.Fatal("select all", got)
+ }
+ if got := session.Handle(Key{Name: "tab"}).Speech; got != "OK button" {
+ t.Fatal(got)
+ }
+ if got := session.Handle(Key{Name: "enter"}).Action; got != "save" {
+ t.Fatal(got)
+ }
+ if got := session.Handle(Key{Name: "tab"}).Speech; got != "Cancel button" {
+ t.Fatal(got)
+ }
+ if got := session.Handle(Key{Name: "enter"}).Action; got != "cancel" {
+ t.Fatal(got)
+ }
+ if got := session.Handle(Key{Name: "tab"}).Speech; got != "Speech tab" {
+ t.Fatal("tab cycle", got)
+ }
+ if got := session.Handle(Key{Name: "tab", Shift: true}).Speech; got != "Cancel button" {
+ t.Fatal("reverse cycle", got)
+ }
+ if !session.Handle(Key{Name: "escape"}).Cancelled {
+ t.Fatal("escape did not cancel")
+ }
+}
+
+func TestListCategoryFormReturnsSelectionWithoutSideEffects(t *testing.T) {
+ form := Form{Type: TabForm, Title: "Picker", Tabs: []Tab{{ID: "category", Label: "Category", Controls: []Control{{ID: "items", Label: "Items", Kind: List, Items: []string{"First", "Second"}, Action: "choose"}}}}}
+ session, err := New_session(form)
+ if err != nil {
+ t.Fatal(err)
+ }
+ session.Handle(Key{Name: "tab"})
+ if got := session.Handle(Key{Name: "up"}).Speech; got != "Items Second" {
+ t.Fatal(got)
+ }
+ if got := session.Handle(Key{Name: "enter"}).Action; got != "choose" {
+ t.Fatal(got)
+ }
+ values := session.Values()
+ values["category.items"].Items[1] = "changed"
+ if got := session.Describe(); got != "Items Second" {
+ t.Fatal("values aliased", got)
+ }
+}
+
+func TestUnknownFormTypeRejected(t *testing.T) {
+ form := test_form()
+ form.Type = "unknown"
+ if _, err := New_session(form); err == nil {
+ t.Fatal("unknown form type accepted")
+ }
+}
diff --git a/internal/interaction/visibility_test.go b/internal/interaction/visibility_test.go
new file mode 100644
index 00000000..7e864969
--- /dev/null
+++ b/internal/interaction/visibility_test.go
@@ -0,0 +1,47 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package interaction
+
+import "testing"
+
+func TestHiddenControlsRetainDraftAndRemainOutsideBothTabOrders(t *testing.T) {
+ session, err := New_session(test_form())
+ if err != nil {
+ t.Fatal(err)
+ }
+ session.Set_hidden("speech", "range", true)
+ for _, want := range []string{"enabled", "text", "ok", "cancel", ""} {
+ session.Handle(Key{Name: "tab"})
+ got, focused := session.Focused_control()
+ if focused != (want != "") || got.ID != want {
+ t.Fatal(got, want)
+ }
+ }
+ for _, want := range []string{"cancel", "ok", "text", "enabled", ""} {
+ session.Handle(Key{Name: "tab", Shift: true})
+ got, focused := session.Focused_control()
+ if focused != (want != "") || got.ID != want {
+ t.Fatal(got, want)
+ }
+ }
+ session.Handle(Key{Name: "tab"})
+ session.Handle(Key{Name: "tab"})
+ session.Handle(Key{Ctrl: true, Text: "a"})
+ session.Handle(Key{Text: "draft"})
+ session.Set_hidden("speech", "text", true)
+ if got, _ := session.Focused_control(); got.ID != "ok" {
+ t.Fatal("hidden focus retained", got)
+ }
+ session.Set_hidden("speech", "text", false)
+ session.Handle(Key{Name: "tab", Shift: true})
+ if got := session.Values()["speech.text"]; got.Text != "draft" || got.Hidden {
+ t.Fatal(got)
+ }
+ for _, id := range []string{"enabled", "range", "text", "ok", "cancel"} {
+ session.Set_hidden("speech", id, true)
+ }
+ if got := session.Handle(Key{Name: "tab"}).Speech; got != "Speech tab" {
+ t.Fatal("all-hidden tab trapped focus", got)
+ }
+}
diff --git a/internal/preferences/discovery.go b/internal/preferences/discovery.go
new file mode 100644
index 00000000..c5707f09
--- /dev/null
+++ b/internal/preferences/discovery.go
@@ -0,0 +1,178 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package preferences
+
+import (
+ "slices"
+ "strings"
+
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/interaction"
+ "git.stormux.org/storm/fenrir/internal/speech"
+)
+
+type Discovery struct {
+ Driver string
+ Catalog speech.Catalog
+ Err error
+}
+
+// Discover_choices runs only in a worker, never on the input loop.
+func Discover_choices(settings config.Settings) Discovery {
+ driver, err := speech.Find_driver(settings.Presentation.Speech.Driver)
+ if err == nil {
+ err = driver.Availability()
+ }
+ if err != nil {
+ return Discovery{Driver: settings.Presentation.Speech.Driver, Err: err}
+ }
+ if driver.Catalog == nil {
+ return Discovery{Driver: driver.ID}
+ }
+ catalog, err := driver.Catalog(speech.Options{})
+ return Discovery{Driver: driver.ID, Catalog: catalog, Err: err}
+}
+
+func (d Discovery) Choices(settings config.Settings) Choices {
+ selected := settings.Presentation.Speech
+ // Driver changes apply on relaunch. A catalog captured for the old driver
+ // must never supply Speech Dispatcher voices to a hardware selection.
+ if d.Driver != "" && d.Driver != selected.Driver {
+ d = Discovery{}
+ }
+ result := Choices{}
+ modules := []config.Choice{{ID: "", Label: "default"}}
+ for _, module := range d.Catalog.Modules {
+ modules = append(modules, config.Choice{ID: module, Label: module})
+ }
+ result["presentation.speech.module"] = preserve_choice(modules, selected.Module, d.Err)
+ voices := d.Catalog.Voices[selected.Module]
+ err := d.Err
+ if moduleErr := d.Catalog.Errors[selected.Module]; moduleErr != nil {
+ err = moduleErr
+ }
+ languages := []config.Choice{{ID: "", Label: "default"}}
+ seen := map[string]bool{}
+ for _, voice := range voices {
+ if !seen[voice.Language] {
+ languages = append(languages, config.Choice{ID: voice.Language, Label: voice.Language})
+ seen[voice.Language] = true
+ }
+ }
+ result["presentation.speech.language"] = preserve_choice(languages, selected.Language, err)
+ filtered := voices
+ if selected.Language != "" {
+ filtered = speech.Language_voices(voices, selected.Language)
+ } else {
+ espeak := strings.Contains(strings.ToLower(selected.Module), "espeak")
+ if selected.Module == "" {
+ for module, candidates := range d.Catalog.Voices {
+ if strings.Contains(strings.ToLower(module), "espeak") && slices.Equal(voices, candidates) {
+ espeak = true
+ break
+ }
+ }
+ }
+ if espeak {
+ filtered = speech.Language_voices(voices, "")
+ }
+ }
+ voiceChoices := []config.Choice{{ID: "", Label: "default"}}
+ for _, voice := range filtered {
+ voiceChoices = append(voiceChoices, config.Choice{ID: voice.Name, Label: voice.Name})
+ }
+ // Locale filtering must not turn a valid saved voice into an offline choice.
+ for _, voice := range voices {
+ if selected.Language == "" && voice.Name == selected.Voice && !slices.ContainsFunc(voiceChoices, func(c config.Choice) bool { return c.ID == voice.Name }) {
+ voiceChoices = append(voiceChoices, config.Choice{ID: voice.Name, Label: voice.Name})
+ }
+ }
+ result["presentation.speech.voice"] = preserve_choice(voiceChoices, selected.Voice, err)
+ return result
+}
+
+func preserve_choice(choices []config.Choice, saved string, err error) []config.Choice {
+ if err != nil {
+ choices[0].Unavailable = err.Error()
+ }
+ if slices.ContainsFunc(choices, func(c config.Choice) bool { return c.ID == saved }) {
+ return choices
+ }
+ reason := "not reported by the selected speech driver"
+ if err != nil {
+ reason = err.Error()
+ }
+ return append(choices, config.Choice{ID: saved, Label: saved, Unavailable: reason})
+}
+
+// Refresh_speech updates driver visibility and dependent choices. Driver changes
+// keep hidden edits; module/language changes clear their dependent selections.
+// The result tells the host when a different driver's catalog may be needed.
+func (d Discovery) Refresh_speech(session *interaction.Session, previous *config.Settings) bool {
+ next := *previous
+ values := session.Values()
+ for path, target := range map[string]*string{
+ "presentation.speech.driver": &next.Presentation.Speech.Driver,
+ "presentation.speech.module": &next.Presentation.Speech.Module,
+ "presentation.speech.language": &next.Presentation.Speech.Language,
+ "presentation.speech.voice": &next.Presentation.Speech.Voice,
+ } {
+ control, ok := values[path]
+ if !ok || control.Selected < 0 || control.Selected >= len(control.Choices) {
+ return false
+ }
+ *target = control.Choices[control.Selected].ID
+ }
+ moduleChanged := next.Presentation.Speech.Module != previous.Presentation.Speech.Module
+ languageChanged := next.Presentation.Speech.Language != previous.Presentation.Speech.Language
+ driverChanged := next.Presentation.Speech.Driver != previous.Presentation.Speech.Driver
+ *previous = next
+ if driverChanged {
+ driver, _ := speech.Find_driver(next.Presentation.Speech.Driver)
+ session.Set_hidden("presentation", "speech.test", !driver.Hardware)
+ for _, definition := range config.Definitions() {
+ if definition.SpeechCapability != "" {
+ session.Set_hidden("presentation", strings.TrimPrefix(definition.Path, "presentation."), !definition.Available(next))
+ }
+ }
+ // Keep each backend's hidden selections intact while another driver is
+ // selected. The host supplies that driver's cached/discovered catalog.
+ return true
+ }
+ if !driverChanged && !moduleChanged && !languageChanged {
+ return false
+ }
+ if moduleChanged {
+ next.Presentation.Speech.Language = ""
+ }
+ next.Presentation.Speech.Voice = ""
+ choices := d.Choices(next)
+ if moduleChanged {
+ session.Replace_choices("presentation", "speech.language", interaction_choices(choices["presentation.speech.language"]), "")
+ }
+ session.Replace_choices("presentation", "speech.voice", interaction_choices(choices["presentation.speech.voice"]), "")
+ *previous = next
+ return false
+}
+
+// Refresh_catalog applies a worker result to the current selector draft. It
+// never moves focus or resets a selection made while discovery was pending.
+func (d Discovery) Refresh_catalog(session *interaction.Session, selected config.Settings) {
+ choices := d.Choices(selected)
+ for path, value := range map[string]string{
+ "presentation.speech.module": selected.Presentation.Speech.Module,
+ "presentation.speech.language": selected.Presentation.Speech.Language,
+ "presentation.speech.voice": selected.Presentation.Speech.Voice,
+ } {
+ session.Replace_choices("presentation", strings.TrimPrefix(path, "presentation."), interaction_choices(choices[path]), value)
+ }
+}
+
+func interaction_choices(choices []config.Choice) []interaction.Choice {
+ result := make([]interaction.Choice, 0, len(choices))
+ for _, choice := range choices {
+ result = append(result, interaction.Choice{ID: choice.ID, Label: choice.Label, Unavailable: choice.Unavailable})
+ }
+ return result
+}
diff --git a/internal/preferences/discovery_test.go b/internal/preferences/discovery_test.go
new file mode 100644
index 00000000..5cc1ee19
--- /dev/null
+++ b/internal/preferences/discovery_test.go
@@ -0,0 +1,177 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package preferences
+
+import (
+ "bufio"
+ "errors"
+ "fmt"
+ "net"
+ "path/filepath"
+ "testing"
+
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/speech"
+)
+
+func TestModuleDiscoveryAndOfflinePreservation(t *testing.T) {
+ base := config.Defaults()
+ base.Presentation.Speech.Module = "saved-module"
+ t.Setenv("SPEECHD_ADDRESS", "unix_socket:"+filepath.Join(t.TempDir(), "missing.sock"))
+ offline := Discover_choices(base).Choices(base)
+ choices := offline["presentation.speech.module"]
+ if len(choices) != 2 || choices[1].ID != "saved-module" || choices[1].Unavailable == "" {
+ t.Fatal(choices)
+ }
+ session, err := New_session(base, nil, offline)
+ if err != nil {
+ t.Fatal(err)
+ }
+ draft, err := Settings(session, base)
+ if err != nil || draft.Presentation.Speech.Module != "saved-module" {
+ t.Fatal(draft, err)
+ }
+ path := filepath.Join(t.TempDir(), "speech.sock")
+ listener, err := net.Listen("unix", path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer listener.Close()
+ done := make(chan string, 1)
+ go func() {
+ conn, err := listener.Accept()
+ if err != nil {
+ done <- err.Error()
+ return
+ }
+ defer conn.Close()
+ request, err := bufio.NewReader(conn).ReadString('\n')
+ if err != nil {
+ done <- err.Error()
+ return
+ }
+ fmt.Fprint(conn, "200-fixture-module\r\n200-saved-module\r\n200 OK\r\n")
+ done <- request
+ }()
+ t.Setenv("SPEECHD_ADDRESS", "unix_socket:"+path)
+ online := Discover_choices(base).Choices(base)["presentation.speech.module"]
+ if request := <-done; request != "LIST OUTPUT_MODULES\r\n" {
+ t.Fatal(request)
+ }
+ if len(online) != 3 || online[1].ID != "fixture-module" || online[2].ID != "saved-module" || online[2].Unavailable != "" {
+ t.Fatal(online)
+ }
+}
+
+func TestDependentSpeechChoicesKeepSavedVoicesAndFilterEspeak(t *testing.T) {
+ t.Setenv("LC_ALL", "en_US.UTF-8")
+ base := config.Defaults()
+ base.Presentation.Speech.Module = "espeak-ng"
+ catalog := speech.Catalog{Modules: []string{"espeak-ng", "fixture"}, Voices: map[string][]speech.Voice{
+ "espeak-ng": {{Name: "US+Variant", Language: "en-US"}, {Name: "German", Language: "de"}, {Name: "US", Language: "en-US"}},
+ "fixture": {{Name: "Fixture", Language: "en"}},
+ }}
+ discovery := Discovery{Catalog: catalog}
+ choices := discovery.Choices(base)
+ if got := choices["presentation.speech.voice"]; len(got) != 3 || got[1].ID != "US" || got[2].ID != "US+Variant" {
+ t.Fatal(got)
+ }
+ base.Presentation.Speech.Voice = "German"
+ choices = discovery.Choices(base)
+ if got := choices["presentation.speech.voice"]; len(got) != 4 || got[3].ID != "German" || got[3].Unavailable != "" {
+ t.Fatal("valid saved voice lost", got)
+ }
+ session, err := New_session(base, nil, choices)
+ if err != nil {
+ t.Fatal(err)
+ }
+ previous := base
+ session.Replace_choices("presentation", "speech.language", interaction_choices(choices["presentation.speech.language"]), "de")
+ discovery.Refresh_speech(session, &previous)
+ values := session.Values()
+ if got := values["presentation.speech.voice"]; len(got.Choices) != 2 || got.Choices[1].ID != "German" || got.Selected != 0 {
+ t.Fatal(got)
+ }
+ session.Replace_choices("presentation", "speech.module", interaction_choices(choices["presentation.speech.module"]), "fixture")
+ discovery.Refresh_speech(session, &previous)
+ values = session.Values()
+ if values["presentation.speech.language"].Selected != 0 || values["presentation.speech.voice"].Selected != 0 || len(values["presentation.speech.voice"].Choices) != 2 {
+ t.Fatal(values)
+ }
+ base.Presentation.Speech.Language = "zz"
+ base.Presentation.Speech.Voice = "Missing"
+ offline := Discovery{Err: errors.New("fixture offline")}.Choices(base)
+ for _, path := range []string{"presentation.speech.module", "presentation.speech.language", "presentation.speech.voice"} {
+ if got := offline[path]; len(got) != 2 || got[1].Unavailable == "" {
+ t.Fatal(path, got)
+ }
+ }
+ session, err = New_session(base, nil, offline)
+ if err != nil {
+ t.Fatal(err)
+ }
+ saved, err := Settings(session, base)
+ if err != nil || saved.Presentation.Speech != base.Presentation.Speech {
+ t.Fatal(saved, err)
+ }
+}
+
+func TestHardwareDriverChangesHideDispatcherChoicesAndPreserveDevice(t *testing.T) {
+ base := config.Defaults()
+ base.Presentation.Speech.HardwareDevice = "/dev/fixture-synth"
+ base.Presentation.Speech.HardwareBaudRate = 19200
+ base.Presentation.Speech.Module = "fixture"
+ base.Presentation.Speech.Language = "en"
+ base.Presentation.Speech.Voice = "Fixture Voice"
+ discovery := Discovery{Driver: "speech-dispatcher", Catalog: speech.Catalog{Modules: []string{"fixture"}, Voices: map[string][]speech.Voice{"fixture": {{Name: "Fixture Voice", Language: "en"}}}}}
+ session, err := New_session(base, nil, discovery.Choices(base))
+ if err != nil {
+ t.Fatal(err)
+ }
+ previous := base
+ for _, id := range []string{"dectalk", "litetalk", "doubletalk", "tripletalk", "speech-dispatcher"} {
+ var choices []config.Choice
+ for _, definition := range config.Definitions() {
+ if definition.Path == "presentation.speech.driver" {
+ choices = definition.Choices(base)
+ }
+ }
+ session.Replace_choices("presentation", "speech.driver", interaction_choices(choices), id)
+ discovery.Refresh_speech(session, &previous)
+ draft, err := Settings(session, base)
+ if err != nil {
+ t.Fatal(id, err)
+ }
+ selected := draft.Presentation.Speech
+ if selected.Driver != id || selected.HardwareDevice != base.Presentation.Speech.HardwareDevice || selected.HardwareBaudRate != 19200 {
+ t.Fatal(selected)
+ }
+ if id != "speech-dispatcher" {
+ if selected.Module != "" || selected.Language != "" || selected.Voice != "" {
+ t.Fatal("inactive selections saved", selected)
+ }
+ for _, path := range []string{"presentation.speech.module", "presentation.speech.language", "presentation.speech.voice"} {
+ if got := session.Values()[path]; !got.Hidden {
+ t.Fatal("dispatcher choices leaked", id, path, got)
+ }
+ }
+ } else if selected.Module != base.Presentation.Speech.Module || selected.Language != base.Presentation.Speech.Language || selected.Voice != base.Presentation.Speech.Voice {
+ t.Fatal("returning to Speech Dispatcher lost the draft", selected)
+ }
+ }
+}
diff --git a/internal/preferences/hardware_form_test.go b/internal/preferences/hardware_form_test.go
new file mode 100644
index 00000000..465a805b
--- /dev/null
+++ b/internal/preferences/hardware_form_test.go
@@ -0,0 +1,108 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package preferences
+
+import (
+ "testing"
+
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/interaction"
+)
+
+func focus_setting(t *testing.T, session *interaction.Session, id string) {
+ t.Helper()
+ for range 100 {
+ if control, ok := session.Focused_control(); ok && control.ID == id {
+ return
+ }
+ session.Handle(interaction.Key{Name: "tab"})
+ }
+ t.Fatal("control not reachable", id)
+}
+
+func TestHardwareControlsUpdateImmediatelyAndKeepDraftEdits(t *testing.T) {
+ base := config.Defaults()
+ session, err := New_session(base, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ previous := base
+ discovery := Discovery{Driver: "speech-dispatcher"}
+ for _, path := range []string{"presentation.speech.hardware_device", "presentation.speech.hardware_baud_rate", "presentation.speech.test"} {
+ if !session.Values()[path].Hidden {
+ t.Fatal("hardware control initially visible", path)
+ }
+ }
+ focus_setting(t, session, "speech.driver")
+ session.Handle(interaction.Key{Name: "down"})
+ if !discovery.Refresh_speech(session, &previous) {
+ t.Fatal("driver change not detected")
+ }
+ if control, _ := session.Focused_control(); control.ID != "speech.driver" {
+ t.Fatal("driver change moved focus", control)
+ }
+ focus_setting(t, session, "speech.hardware_device")
+ session.Handle(interaction.Key{Text: "/dev/fixture-synth"})
+ focus_setting(t, session, "speech.hardware_baud_rate")
+ session.Handle(interaction.Key{Name: "end"})
+ draft, err := Settings(session, base)
+ if err != nil || draft.Presentation.Speech.HardwareDevice != "/dev/fixture-synth" || draft.Presentation.Speech.HardwareBaudRate != 115200 {
+ t.Fatal(draft, err)
+ }
+ focus_setting(t, session, "speech.driver")
+ session.Handle(interaction.Key{Name: "down"})
+ discovery.Refresh_speech(session, &previous)
+ for _, path := range []string{"presentation.speech.hardware_device", "presentation.speech.hardware_baud_rate", "presentation.speech.module", "presentation.speech.language", "presentation.speech.voice"} {
+ if !session.Values()[path].Hidden {
+ t.Fatal("irrelevant USB control visible", path)
+ }
+ }
+ if session.Values()["presentation.speech.test"].Hidden {
+ t.Fatal("USB test button hidden")
+ }
+ session.Handle(interaction.Key{Name: "up"})
+ discovery.Refresh_speech(session, &previous)
+ draft, err = Settings(session, base)
+ if err != nil || draft.Presentation.Speech.HardwareDevice != "/dev/fixture-synth" || draft.Presentation.Speech.HardwareBaudRate != 115200 {
+ t.Fatal("driver round trip lost edits", draft, err)
+ }
+ session.Handle(interaction.Key{Name: "home"})
+ discovery.Refresh_speech(session, &previous)
+ if !session.Values()["presentation.speech.test"].Hidden || session.Values()["presentation.speech.module"].Hidden {
+ t.Fatal("Speech Dispatcher controls not restored")
+ }
+}
+
+func TestEveryHardwareFormCanSavePortAndTypedBaud(t *testing.T) {
+ for _, driver := range []string{"dectalk", "dectalk-usb", "litetalk", "doubletalk", "tripletalk"} {
+ base := config.Defaults()
+ base.Presentation.Speech.Driver = driver
+ base.Presentation.Speech.HardwareDevice = "/dev/fixture-original"
+ session, err := New_session(base, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if driver != "dectalk-usb" {
+ focus_setting(t, session, "speech.hardware_device")
+ session.Handle(interaction.Key{Ctrl: true, Text: "a"})
+ session.Handle(interaction.Key{Text: "/dev/fixture-updated"})
+ focus_setting(t, session, "speech.hardware_baud_rate")
+ session.Handle(interaction.Key{Name: "home"})
+ session.Handle(interaction.Key{Name: "down"})
+ }
+ draft, err := Settings(session, base)
+ if err != nil {
+ t.Fatal(driver, err)
+ }
+ if driver != "dectalk-usb" && (draft.Presentation.Speech.HardwareDevice != "/dev/fixture-updated" || draft.Presentation.Speech.HardwareBaudRate != 1200) {
+ t.Fatal(draft)
+ }
+ if !session.Values()["presentation.speech.module"].Hidden {
+ t.Fatal("dispatcher control exposed", driver)
+ }
+ if base.Presentation.Speech.HardwareDevice != "/dev/fixture-original" {
+ t.Fatal("base mutated")
+ }
+ }
+}
diff --git a/internal/preferences/settings.go b/internal/preferences/settings.go
new file mode 100644
index 00000000..97b240be
--- /dev/null
+++ b/internal/preferences/settings.go
@@ -0,0 +1,205 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+// Package preferences adapts the shared typed setting definitions to reusable
+// interaction controls. Hosts own input capture and disk I/O.
+package preferences
+
+import (
+ "encoding/json"
+ "fmt"
+ "math"
+ "reflect"
+ "strconv"
+ "strings"
+
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/interaction"
+ "git.stormux.org/storm/fenrir/internal/speech"
+ "github.com/pelletier/go-toml/v2"
+)
+
+type Choices map[string][]config.Choice
+
+const MenuTitle = "Fenrir Menu"
+
+func New_session(settings config.Settings, order []string, discovered ...Choices) (*interaction.Session, error) {
+ if err := settings.Validate(); err != nil {
+ return nil, err
+ }
+ form := interaction.Form{Type: interaction.TabForm, Title: MenuTitle}
+ for _, section := range config.Ordered_sections(config.Sections(), order) {
+ if section.Label == "" {
+ continue
+ }
+ tab := interaction.Tab{ID: section.ID, Label: section.Label}
+ var append_section func(config.Section, string) error
+ append_section = func(section config.Section, group string) error {
+ for _, definition := range section.Settings {
+ control, err := make_control(definition, settings, discovered)
+ if err != nil {
+ return err
+ }
+ control.ID = strings.TrimPrefix(definition.Path, tab.ID+".")
+ control.Group = group
+ tab.Controls = append(tab.Controls, control)
+ }
+ for _, child := range config.Ordered_sections(section.Sections, order) {
+ label := child.Label
+ if group != "" {
+ label = group + ". " + label
+ }
+ if err := append_section(child, label); err != nil {
+ return err
+ }
+ }
+ if section.ID == "presentation.speech" {
+ driver, _ := speech.Find_driver(settings.Presentation.Speech.Driver)
+ tab.Controls = append(tab.Controls, interaction.Control{ID: "speech.test", Label: "Test synthesizer", Group: "Speech", Kind: interaction.Button, Action: "test-synthesizer", Hidden: !driver.Hardware})
+ }
+ return nil
+ }
+ if err := append_section(section, ""); err != nil {
+ return nil, err
+ }
+ tab.Controls = append(tab.Controls, interaction.Control{ID: "ok", Label: "OK", Kind: interaction.Button, Action: "save"}, interaction.Control{ID: "cancel", Label: "Cancel", Kind: interaction.Button, Action: "cancel"})
+ form.Tabs = append(form.Tabs, tab)
+ }
+ return interaction.New_session(form)
+}
+
+func make_control(definition config.Definition, settings config.Settings, discovered []Choices) (interaction.Control, error) {
+ control := interaction.Control{Label: definition.Label, Hidden: !definition.Available(settings)}
+ value := definition.Value(settings)
+ if definition.ChoiceSource != "" {
+ choices := definition.Choices(settings)
+ // Only backend discovery can supply module choices. Driver support always
+ // comes from the implemented registry, never a user-editable choice list.
+ if (definition.ChoiceSource == "speech-module" || definition.ChoiceSource == "speech-language" || definition.ChoiceSource == "speech-voice") && len(discovered) > 0 {
+ if supplied, ok := discovered[0][definition.Path]; ok {
+ choices = supplied
+ }
+ }
+ control.Kind = interaction.ComboBox
+ control.Selected = -1
+ for index, choice := range choices {
+ control.Choices = append(control.Choices, interaction.Choice{ID: choice.ID, Label: choice.Label, Unavailable: choice.Unavailable})
+ if choice.ID == fmt.Sprint(value) {
+ control.Selected = index
+ }
+ }
+ if control.Selected < 0 {
+ return control, fmt.Errorf("missing saved choice for %s", definition.Path)
+ }
+ return control, nil
+ }
+ switch definition.Kind {
+ case reflect.Bool:
+ control.Kind, control.Checked = interaction.CheckBox, value.(bool)
+ case reflect.Int, reflect.Float64:
+ control.Kind = interaction.Number
+ if definition.Kind == reflect.Int {
+ control.Number = float64(value.(int))
+ } else {
+ control.Number = value.(float64)
+ }
+ control.Min, control.Max, control.Step = definition.Min, definition.Max, definition.Step
+ case reflect.String:
+ control.Kind, control.Text = interaction.Entry, value.(string)
+ case reflect.Slice:
+ devices := value.([]string)
+ if devices == nil {
+ devices = []string{}
+ }
+ encoded, err := json.Marshal(devices)
+ if err != nil {
+ return control, err
+ }
+ control.Kind, control.Text = interaction.Entry, string(encoded)
+ default:
+ return control, fmt.Errorf("unsupported control for %s", definition.Path)
+ }
+ return control, nil
+}
+
+func Settings(session *interaction.Session, base config.Settings) (config.Settings, error) {
+ values := session.Values()
+ // Visibility follows the form's selected driver, including unsaved switches.
+ driver := values["presentation.speech.driver"]
+ if driver.Selected < 0 || driver.Selected >= len(driver.Choices) {
+ return config.Settings{}, fmt.Errorf("invalid speech driver selection")
+ }
+ base.Presentation.Speech.Driver = driver.Choices[driver.Selected].ID
+ for _, definition := range config.Definitions() {
+ if !definition.Available(base) {
+ continue
+ }
+ control, ok := values[definition.Path]
+ if !ok {
+ return config.Settings{}, fmt.Errorf("missing setting %s", definition.Path)
+ }
+ var value any
+ if definition.ChoiceSource != "" {
+ if control.Selected < 0 || control.Selected >= len(control.Choices) {
+ return config.Settings{}, fmt.Errorf("invalid selection for %s", definition.Path)
+ }
+ value = control.Choices[control.Selected].ID
+ if definition.Kind == reflect.Int {
+ number, err := strconv.Atoi(value.(string))
+ if err != nil {
+ return config.Settings{}, fmt.Errorf("invalid selection for %s", definition.Path)
+ }
+ value = number
+ }
+ } else {
+ switch definition.Kind {
+ case reflect.Bool:
+ value = control.Checked
+ case reflect.Int:
+ if math.Trunc(control.Number) != control.Number {
+ return config.Settings{}, fmt.Errorf("%s requires a whole number", definition.Path)
+ }
+ value = int(control.Number)
+ case reflect.Float64:
+ value = control.Number
+ case reflect.String:
+ value = control.Text
+ case reflect.Slice:
+ var array struct {
+ Value []string `toml:"value"`
+ }
+ if err := toml.NewDecoder(strings.NewReader("value = " + control.Text)).DisallowUnknownFields().Decode(&array); err != nil {
+ return config.Settings{}, fmt.Errorf("%s: %w", definition.Path, err)
+ }
+ value = array.Value
+ }
+ }
+ if err := definition.Set(&base, value); err != nil {
+ return config.Settings{}, err
+ }
+ }
+ // Inactive Speech Dispatcher choices stay in the form's draft for switching
+ // back, but cannot be sent to or saved as selections for a hardware driver.
+ for _, definition := range config.Definitions() {
+ if (definition.SpeechCapability == "modules" || definition.SpeechCapability == "voices") && !definition.Available(base) {
+ if err := definition.Set(&base, ""); err != nil {
+ return config.Settings{}, err
+ }
+ }
+ }
+ return base, base.Validate()
+}
diff --git a/internal/preferences/settings_test.go b/internal/preferences/settings_test.go
new file mode 100644
index 00000000..d120428b
--- /dev/null
+++ b/internal/preferences/settings_test.go
@@ -0,0 +1,249 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package preferences
+
+import (
+ "reflect"
+ "strings"
+ "testing"
+
+ "git.stormux.org/storm/fenrir/internal/config"
+ "git.stormux.org/storm/fenrir/internal/interaction"
+)
+
+func TestSettingsFormCoversSchemaAndReturnsIsolatedDraft(t *testing.T) {
+ base := config.Defaults()
+ base.Keyboard.Devices = []string{"/dev/input/event-test"}
+ session, err := New_session(base, []string{"presentation", "presentation.speech", "presentation.sound"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := session.Open(); got != "Fenrir Menu. Presentation tab" {
+ t.Fatal(got)
+ }
+ values := session.Values()
+ if len(values) != 51 {
+ t.Fatal("missing settings or buttons", len(values))
+ }
+ unchanged, err := Settings(session, base)
+ if err != nil || !reflect.DeepEqual(unchanged, base) {
+ t.Fatal(unchanged, err)
+ }
+ session.Handle(interaction.Key{Name: "tab"})
+ session.Handle(interaction.Key{Name: "space"})
+ session.Handle(interaction.Key{Name: "tab"})
+ session.Handle(interaction.Key{Name: "up"})
+ draft, err := Settings(session, base)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if draft.Presentation.Speech.Enabled || draft.Presentation.Speech.Rate != 81 || !base.Presentation.Speech.Enabled || base.Presentation.Speech.Rate != 80 {
+ t.Fatal("draft isolation", draft, base)
+ }
+}
+
+func TestReadingModeIsConstrainedAndSavedInDraft(t *testing.T) {
+ base := config.Defaults()
+ session, err := New_session(base, []string{"reading"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ session.Handle(interaction.Key{Name: "tab"})
+ session.Handle(interaction.Key{Name: "tab"})
+ if got := session.Handle(interaction.Key{Name: "tab"}).Speech; got != "Tracking mode combo box cursor tracking" {
+ t.Fatal(got)
+ }
+ session.Handle(interaction.Key{Name: "down"})
+ draft, err := Settings(session, base)
+ if err != nil || draft.Reading.Mode != "table" || base.Reading.Mode != "cursor" {
+ t.Fatal("mode not saved or draft leaked", draft, err)
+ }
+}
+
+func TestCapitalCheckboxesRoundTripIndependently(t *testing.T) {
+ for choices := range 8 {
+ base := config.Defaults()
+ base.Reading.RaisePitchForCapitals = choices&1 != 0
+ base.Reading.BeepForCapitals = choices&2 != 0
+ base.Reading.SayCapForCapitals = choices&4 != 0
+ session, err := New_session(base, []string{"reading"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ values := session.Values()
+ for path, want := range map[string]bool{
+ "reading.raise_pitch_for_capitals": base.Reading.RaisePitchForCapitals,
+ "reading.beep_for_capitals": base.Reading.BeepForCapitals,
+ "reading.say_cap_for_capitals": base.Reading.SayCapForCapitals,
+ } {
+ if got := values[path]; got.Kind != interaction.CheckBox || got.Checked != want {
+ t.Fatal(path, got)
+ }
+ }
+ for range 6 {
+ session.Handle(interaction.Key{Name: "tab"})
+ }
+ session.Handle(interaction.Key{Name: "space"})
+ draft, err := Settings(session, base)
+ if err != nil || draft.Reading.RaisePitchForCapitals == base.Reading.RaisePitchForCapitals || draft.Reading.BeepForCapitals != base.Reading.BeepForCapitals || draft.Reading.SayCapForCapitals != base.Reading.SayCapForCapitals {
+ t.Fatal(draft.Reading, err)
+ }
+ }
+}
+
+func TestInvalidSettingsEntriesDoNotSubmit(t *testing.T) {
+ for _, field := range []string{"devices", "layout"} {
+ session, err := New_session(config.Defaults(), []string{"keyboard"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ session.Handle(interaction.Key{Name: "tab"})
+ if field == "layout" {
+ session.Handle(interaction.Key{Name: "tab"})
+ }
+ session.Handle(interaction.Key{Ctrl: true, Text: "a"})
+ value := "invalid value"
+ if field == "layout" {
+ value = "old.conf"
+ }
+ session.Handle(interaction.Key{Text: value})
+ if _, err := Settings(session, config.Defaults()); err == nil {
+ t.Fatal("invalid field accepted", field)
+ }
+ }
+}
+
+func TestKeyboardEchoCheckboxesSaveIndependently(t *testing.T) {
+ base := config.Defaults()
+ session, err := New_session(base, []string{"keyboard"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, want := range []string{"Devices", "Layout", "Character echo checked"} {
+ if got := session.Handle(interaction.Key{Name: "tab"}).Speech; !strings.Contains(got, want) {
+ t.Fatal("echo checkbox traversal", got, want)
+ }
+ }
+ for _, want := range []string{"Character echo unchecked", "Word echo checked", "Echo on CapsLock checked"} {
+ if got := session.Handle(interaction.Key{Name: "space"}).Speech; got != want {
+ t.Fatal("echo checkbox did not toggle", got, want)
+ }
+ session.Handle(interaction.Key{Name: "tab"})
+ }
+ draft, err := Settings(session, base)
+ if err != nil || draft.Keyboard.CharacterEcho || !draft.Keyboard.WordEcho || !draft.Keyboard.EchoOnCapsLock || !draft.Reading.CursorEcho || !base.Keyboard.CharacterEcho {
+ t.Fatal("echo settings not independent", draft, err)
+ }
+}
+
+func TestClipboardSyncCheckboxPreservesAdvancedSettings(t *testing.T) {
+ base := config.Defaults()
+ base.Clipboard.SyncDisplay, base.Clipboard.ExportPath, base.Clipboard.NumberOfClipboards = ":42", "/tmp/fixture-clipboard", 3
+ session, err := New_session(base, []string{"clipboard"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := session.Handle(interaction.Key{Name: "tab"}).Speech; got != "Clipboard sync unchecked" {
+ t.Fatal(got)
+ }
+ session.Handle(interaction.Key{Name: "space"})
+ settings, err := Settings(session, base)
+ if err != nil || !settings.Clipboard.Sync || settings.Clipboard.SyncDisplay != ":42" || settings.Clipboard.ExportPath != base.Clipboard.ExportPath || settings.Clipboard.NumberOfClipboards != 3 {
+ t.Fatal(settings, err)
+ }
+}
+
+func TestPresentationGroupsFollowSchemaAndFileOrder(t *testing.T) {
+ base := config.Defaults()
+ session, err := New_session(base, []string{"reading", "presentation", "presentation.sound", "presentation.speech"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if session.Open() != "Fenrir Menu. Reading tab" {
+ t.Fatal(session.Open())
+ }
+ if got := session.Handle(interaction.Key{Name: "right"}).Speech; got != "Presentation tab" {
+ t.Fatal(got)
+ }
+ if got := session.Handle(interaction.Key{Name: "tab"}).Speech; got != "Sound. Sound enabled checked" {
+ t.Fatal(got)
+ }
+ for range 3 {
+ session.Handle(interaction.Key{Name: "tab"})
+ }
+ if got := session.Handle(interaction.Key{Name: "tab"}).Speech; got != "Speech. Speech enabled checked" {
+ t.Fatal(got)
+ }
+ values := session.Values()
+ for _, definition := range config.Definitions() {
+ if _, ok := values[definition.Path]; !ok {
+ t.Fatal("missing schema control", definition.Path)
+ }
+ }
+}
+
+func TestConstrainedChoicesKeepStableIDsAndIgnoreTextEditing(t *testing.T) {
+ base := config.Defaults()
+ base.Presentation.Speech.Module = "fixture-module"
+ base.Keyboard.Devices = []string{}
+ session, err := New_session(base, nil, Choices{"presentation.speech.module": {
+ {ID: "", Label: "default"}, {ID: "fixture-module", Label: "Fixture module", Unavailable: "fixture server offline"},
+ }})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for range 3 {
+ session.Handle(interaction.Key{Name: "tab"})
+ }
+ session.Handle(interaction.Key{Ctrl: true, Text: "a"})
+ session.Handle(interaction.Key{Text: "invented-driver"})
+ session.Handle(interaction.Key{Name: "tab"})
+ if !strings.Contains(session.Describe(), "Fixture module. unavailable: fixture server offline") {
+ t.Fatal(session.Describe())
+ }
+ unchanged, err := Settings(session, base)
+ if err != nil || !reflect.DeepEqual(unchanged, base) {
+ t.Fatal(unchanged, err)
+ }
+ session.Handle(interaction.Key{Name: "home"})
+ draft, err := Settings(session, base)
+ if err != nil || draft.Presentation.Speech.Module != "" || draft.Presentation.Speech.Driver != "speech-dispatcher" {
+ t.Fatal(draft, err)
+ }
+}
+
+func TestSoundChoicesPreserveUnavailableThemeAndFileOnlyDevice(t *testing.T) {
+ base := config.Defaults()
+ base.Presentation.Sound.Theme = "missing-fixture-theme"
+ base.Presentation.Sound.Device = "plughw:CARD=Generic,DEV=0"
+ session, err := New_session(base, []string{"presentation", "presentation.sound"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for range 3 {
+ session.Handle(interaction.Key{Name: "tab"})
+ }
+ if got := session.Values()["presentation.sound.driver"]; got.Selected != 0 {
+ t.Fatal(got)
+ }
+ session.Handle(interaction.Key{Name: "down"})
+ draft, err := Settings(session, base)
+ if err != nil || draft.Presentation.Sound.Driver != "alsa" || draft.Presentation.Sound.Theme != base.Presentation.Sound.Theme || draft.Presentation.Sound.Device != base.Presentation.Sound.Device {
+ t.Fatal(draft.Presentation.Sound, err)
+ }
+}
diff --git a/internal/presentation/attributes.go b/internal/presentation/attributes.go
new file mode 100644
index 00000000..120a9ce8
--- /dev/null
+++ b/internal/presentation/attributes.go
@@ -0,0 +1,72 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package presentation
+
+import (
+ "fmt"
+ "git.stormux.org/storm/fenrir/internal/i18n"
+ "git.stormux.org/storm/fenrir/internal/screen"
+ "strings"
+)
+
+// Default_attribute_format is Python Fenrir's default, including its tokens.
+const Default_attribute_format = "Background fenrirBGColor,Foreground fenrirFGColor,fenrirUnderline,fenrirBold,fenrirBlink, Font fenrirFont,Fontsize fenrirFontSize"
+
+func attribute_color(color uint32, background bool) string {
+ if color == screen.ColorDefault {
+ return i18n.T("default")
+ }
+ if color&screen.ColorIndexed == 0 {
+ return fmt.Sprintf("#%06x", color)
+ }
+ index := color &^ screen.ColorIndexed
+ names := []string{"black", "red", "green", "brown/yellow", "blue", "magenta", "cyan", "light gray", "dark gray", "light red", "light green", "light yellow", "light blue", "light magenta", "light cyan", "white"}
+ if index < 16 {
+ if background && index == 7 {
+ return i18n.T("white")
+ }
+ return i18n.T(names[index])
+ }
+ // xterm's fixed 256-color cube and grayscale, without approximate names.
+ if index < 232 {
+ level := []uint32{0, 95, 135, 175, 215, 255}
+ index -= 16
+ return fmt.Sprintf("#%02x%02x%02x", level[index/36], level[index/6%6], level[index%6])
+ }
+ value := 8 + 10*(index-232)
+ return fmt.Sprintf("#%02x%02x%02x", value, value, value)
+}
+
+// Format_attributes substitutes the Python tokens. Font metadata is not
+// available from either backend; default means unspecified, not a detected font.
+func Format_attributes(attribute screen.Attribute, format string) string {
+ pairs := []string{"fenrirFGColor", attribute_color(attribute.Foreground, false), "fenrirBGColor", attribute_color(attribute.Background, true), "fenrirFontSize", i18n.T("default"), "fenrirFont", i18n.T("default")}
+ for _, style := range []struct {
+ token, text string
+ mask uint32
+ }{
+ {"fenrirBold", "bold", screen.Bold}, {"fenrirItalics", "italic", screen.Italic},
+ {"fenrirUnderline", "underline", screen.Underline}, {"fenrirStrikethrough", "strikethrough", screen.Strike},
+ {"fenrirReverse", "reverse", screen.Reverse}, {"fenrirBlink", "blink", screen.Blink},
+ } {
+ text := ""
+ if attribute.Style&style.mask != 0 {
+ text = i18n.T(style.text)
+ }
+ pairs = append(pairs, style.token, text)
+ }
+ return strings.NewReplacer(pairs...).Replace(format)
+}
+
+func Attribute_message(attribute screen.Attribute, mode, format string) Message {
+ text := Format_attributes(attribute, format)
+ if mode == "beeps" || strings.TrimSpace(text) == "" && !attribute.Default() {
+ text = i18n.T("has attribute")
+ }
+ message := Message{Text: text, Attributes: &Attributes{Speech: mode != "beeps"}}
+ if mode == "beeps" || mode == "both" {
+ message.Cue = HasAttributes
+ }
+ return message
+}
diff --git a/internal/presentation/attributes_test.go b/internal/presentation/attributes_test.go
new file mode 100644
index 00000000..c8cfa0b0
--- /dev/null
+++ b/internal/presentation/attributes_test.go
@@ -0,0 +1,37 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package presentation
+
+import (
+ "git.stormux.org/storm/fenrir/internal/screen"
+ "strings"
+ "testing"
+)
+
+func TestAttributeColorsStylesAndPythonFormat(t *testing.T) {
+ format := "fenrirFGColor|fenrirBGColor|fenrirBold|fenrirUnderline|fenrirItalics|fenrirBlink|fenrirReverse|fenrirStrikethrough|fenrirFontSize|fenrirFont"
+ a := screen.Attribute{Foreground: screen.ColorIndexed | 1, Background: screen.ColorIndexed | 4, Style: screen.Bold | screen.Underline | screen.Italic | screen.Blink | screen.Reverse | screen.Strike}
+ if got := Format_attributes(a, format); got != "red|blue|bold|underline|italic|blink|reverse|strikethrough|default|default" {
+ t.Fatal(got)
+ }
+ for _, trial := range []struct {
+ color uint32
+ want string
+ }{
+ {screen.ColorDefault, "default"}, {0, "#000000"}, {0x0c2238, "#0c2238"},
+ {screen.ColorIndexed | 7, "light gray"}, {screen.ColorIndexed | 9, "light red"},
+ {screen.ColorIndexed | 16, "#000000"}, {screen.ColorIndexed | 196, "#ff0000"},
+ {screen.ColorIndexed | 231, "#ffffff"}, {screen.ColorIndexed | 232, "#080808"}, {screen.ColorIndexed | 255, "#eeeeee"},
+ } {
+ if got := Format_attributes(screen.Attribute{Foreground: trial.color}, "fenrirFGColor"); got != trial.want {
+ t.Fatal(trial, got)
+ }
+ }
+ if got := Format_attributes(screen.Attribute{}, "fenrirBold fenrirUnderline fenrirBlink"); strings.TrimSpace(got) != "" {
+ t.Fatal(got)
+ }
+ if got := Format_attributes(a, ""); got != "" {
+ t.Fatal(got)
+ }
+}
diff --git a/internal/presentation/cue.go b/internal/presentation/cue.go
new file mode 100644
index 00000000..60a1a624
--- /dev/null
+++ b/internal/presentation/cue.go
@@ -0,0 +1,71 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package presentation
+
+// Cue identifies an optional sound independently of the spoken text.
+type Cue string
+
+// Message pairs text with an optional sound that replaces the spoken message.
+// The cue comes from the event, never from matching the text's spelling.
+type Message struct {
+ // Character preserves the source rune for profile-based character naming.
+ Character *rune
+ Attributes *Attributes
+ Indentation *Indentation
+ Text string
+ Cue Cue
+}
+
+// Attributes marks feedback that obeys mute and may combine a cue and speech.
+type Attributes struct{ Speech bool }
+
+// Indentation requests a generated tone, with optional simultaneous speech.
+// Message.Text remains the speech fallback when sound is unavailable.
+type Indentation struct {
+ Level int
+ Speech bool
+}
+
+// Spelling carries source text separately from announcements and punctuation
+// names. Each rune is presented once, in order, by the shared speech presenter.
+type Spelling struct {
+ Text string
+ Phonetic bool
+}
+
+const (
+ Ready Cue = "ready"
+ Stopped Cue = "stopped"
+ TTYChanged Cue = "tty_changed"
+ SpeechOn Cue = "speech_on"
+ SpeechOff Cue = "speech_off"
+ Blank Cue = "blank"
+ HasAttributes Cue = "has_attributes"
+ Misspelled Cue = "misspelled"
+ Capital Cue = "capital"
+ Top Cue = "top"
+ Bottom Cue = "bottom"
+ LineStart Cue = "line_start"
+ LineEnd Cue = "line_end"
+ Error Cue = "error"
+ Accept Cue = "accept"
+ PlaceStartMark Cue = "place_start_mark"
+ PlaceEndMark Cue = "place_end_mark"
+ CopyToClipboard Cue = "copy_to_clipboard"
+ PasteClipboard Cue = "paste_clipboard"
+)
diff --git a/internal/presentation/indentation.go b/internal/presentation/indentation.go
new file mode 100644
index 00000000..373b6412
--- /dev/null
+++ b/internal/presentation/indentation.go
@@ -0,0 +1,37 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package presentation
+
+import (
+ "strconv"
+ "unicode"
+
+ "git.stormux.org/storm/fenrir/internal/i18n"
+)
+
+// Indentation_level counts leading visible whitespace cells. Terminal backends
+// normally expand tabs already; a literal tab advances to the next 8-cell stop.
+// Blank rows have no meaningful indentation, including terminal padding.
+func Indentation_level(line []rune) (int, bool) {
+ level := 0
+ for _, char := range line {
+ if !unicode.IsSpace(char) {
+ return level, true
+ }
+ if char == '\t' {
+ level += 8 - level%8
+ } else {
+ level++
+ }
+ }
+ return 0, false
+}
+
+func Indentation_message(level int, mode string) Message {
+ message := Message{Text: i18n.T("indented ") + strconv.Itoa(level) + " "}
+ if mode == "beeps" || mode == "both" {
+ message.Indentation = &Indentation{Level: level, Speech: mode == "both"}
+ }
+ return message
+}
diff --git a/internal/presentation/indentation_test.go b/internal/presentation/indentation_test.go
new file mode 100644
index 00000000..99baf2b3
--- /dev/null
+++ b/internal/presentation/indentation_test.go
@@ -0,0 +1,23 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package presentation
+
+import "testing"
+
+func TestIndentationCountsVisibleWhitespaceAndIgnoresBlankRows(t *testing.T) {
+ for _, trial := range []struct {
+ text string
+ level int
+ valid bool
+ }{
+ {"value", 0, true}, {" value ", 4, true}, {" \tvalue", 8, true},
+ {"\t \tvalue", 16, true}, {"\u00a0\u2003Ć", 2, true},
+ {"", 0, false}, {" ", 0, false}, {"\t ", 0, false},
+ } {
+ level, valid := Indentation_level([]rune(trial.text))
+ if level != trial.level || valid != trial.valid {
+ t.Fatalf("%q: %d %v", trial.text, level, valid)
+ }
+ }
+}
diff --git a/internal/presentation/phonetic.go b/internal/presentation/phonetic.go
new file mode 100644
index 00000000..d7d10c99
--- /dev/null
+++ b/internal/presentation/phonetic.go
@@ -0,0 +1,21 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package presentation
+
+import "unicode"
+
+// Phonetic_letter matches Python Fenrir's alphabet, including its capitalization.
+// Other runes use the active punctuation profile's character naming instead.
+func Phonetic_letter(char rune) string {
+ words := [...]string{"alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel", "india", "juliet", "kilo", "lima", "mike", "november", "oscar", "papa", "quebec", "romeo", "sierra", "tango", "uniform", "victor", "whisky", "x ray", "yankee", "zulu"}
+ upper := unicode.ToUpper(char)
+ if upper < 'A' || upper > 'Z' {
+ return ""
+ }
+ word := words[upper-'A']
+ if unicode.IsUpper(char) {
+ word = string(unicode.ToUpper(rune(word[0]))) + word[1:]
+ }
+ return word
+}
diff --git a/internal/presentation/phonetic_test.go b/internal/presentation/phonetic_test.go
new file mode 100644
index 00000000..91acf378
--- /dev/null
+++ b/internal/presentation/phonetic_test.go
@@ -0,0 +1,21 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package presentation
+
+import "testing"
+
+func TestPythonPhoneticNamesAndUnicodeFallback(t *testing.T) {
+ for _, trial := range []struct {
+ char rune
+ want string
+ }{
+ {'a', "alpha"}, {'A', "Alpha"}, {'j', "juliet"},
+ {'w', "whisky"}, {'W', "Whisky"}, {'x', "x ray"}, {'X', "X ray"},
+ {'Ć', ""}, {'ē', ""}, {'1', ""}, {'?', ""}, {' ', ""},
+ } {
+ if got := Phonetic_letter(trial.char); got != trial.want {
+ t.Fatal(trial, got)
+ }
+ }
+}
diff --git a/internal/presentation/result.go b/internal/presentation/result.go
new file mode 100644
index 00000000..a87ec552
--- /dev/null
+++ b/internal/presentation/result.go
@@ -0,0 +1,17 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package presentation
+
+// CommandResult contains all feedback from one reader or review command.
+// Present Attributes and Indentation before the main text, spelling or character,
+// then After. Results remain valid when subsequent commands or updates run.
+type CommandResult struct {
+ Text string
+ Cue Cue
+ Character *rune
+ Spelling *Spelling
+ Attributes *Message
+ Indentation *Message
+ After Message
+}
diff --git a/internal/presentation/text.go b/internal/presentation/text.go
new file mode 100644
index 00000000..4864fb46
--- /dev/null
+++ b/internal/presentation/text.go
@@ -0,0 +1,71 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+// Package presentation names screen text and characters for reader output.
+package presentation
+
+import (
+ "strconv"
+ "strings"
+ "unicode"
+
+ "git.stormux.org/storm/fenrir/internal/i18n"
+
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+// Line describes a visible row, including blank rows.
+func Line(s screen.Snapshot, row int) string {
+ text := s.Text(row)
+ if strings.TrimSpace(text) == "" {
+ return i18n.T("blank")
+ }
+ return text
+}
+
+// Line_message describes a row with Python's blank-line sound alternative.
+func Line_message(s screen.Snapshot, row int) Message {
+ message := Message{Text: Line(s, row)}
+ if strings.TrimSpace(s.Text(row)) == "" {
+ message.Cue = Blank
+ }
+ return message
+}
+
+func Character_message(char rune) Message {
+ return Message{Text: Character(char), Character: &char}
+}
+
+// Character describes punctuation and whitespace. Capital cues are applied by
+// the shared speech presenter using the original character.
+func Character(char rune) string {
+ names := map[rune]string{
+ ' ': "space", '\t': "tab", '.': "dot", ',': "comma", ':': "colon", ';': "semicolon",
+ '!': "bang", '?': "question?", '-': "dash", '_': "line", '/': "slash",
+ '\\': "backslash", '(': "left paren", ')': "right paren", '[': "left bracket",
+ ']': "right bracket", '{': "left brace", '}': "right brace", '<': "less", '>': "greater",
+ '\'': "apostrophe", '"': "quote", '@': "at", '#': "hash", '$': "dollar", '%': "percent",
+ '^': "carrot", '&': "and", '*': "star", '+': "plus", '=': "equals", '|': "bar", '~': "tilde", '`': "grave",
+ }
+ if name, ok := names[char]; ok {
+ return i18n.T(name)
+ }
+ if unicode.IsControl(char) {
+ return i18n.Format("character {0}", strconv.Itoa(int(char)))
+ }
+ return string(char)
+}
diff --git a/internal/presentation/text_test.go b/internal/presentation/text_test.go
new file mode 100644
index 00000000..358d9800
--- /dev/null
+++ b/internal/presentation/text_test.go
@@ -0,0 +1,48 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package presentation
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "git.stormux.org/storm/fenrir/internal/i18n"
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+func TestTranslationsDoNotTranslateApplicationContent(t *testing.T) {
+ root := t.TempDir()
+ dir := filepath.Join(root, "xx", "LC_MESSAGES")
+ if err := os.MkdirAll(dir, 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "fenrir.po"), []byte("msgid \"blank\"\nmsgstr \"translated-blank\"\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ previous := i18n.Use(i18n.New(root, "xx"))
+ t.Cleanup(func() { i18n.Use(previous) })
+ snapshot := screen.Snapshot{Rows: 1, Cols: 5, Cells: []rune("blank")}
+ if got := Line(snapshot, 0); got != "blank" {
+ t.Fatal("translated application text", got)
+ }
+ snapshot.Cells = []rune(" ")
+ if got := Line(snapshot, 0); got != "translated-blank" {
+ t.Fatal("reader label untranslated", got)
+ }
+}
diff --git a/internal/progress/monitor.go b/internal/progress/monitor.go
new file mode 100644
index 00000000..1c13378f
--- /dev/null
+++ b/internal/progress/monitor.go
@@ -0,0 +1,160 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package progress
+
+import (
+ "slices"
+ "strings"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/diagnostic"
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+// Monitor owns detection state on the reader loop. No goroutines, subprocesses,
+// whole-screen strings, or regexp compilation are needed for an update.
+type Monitor struct {
+ active bool
+ pending *Indicator
+ last Indicator
+ lastAt time.Time
+}
+
+func (m *Monitor) Reset() { *m = Monitor{} }
+
+// Update ignores editingRow when a screen-confirmed input deletion changed it.
+// Pass -1 when no row should be excluded. Other output remains eligible.
+func (m *Monitor) Update(previous, next screen.Snapshot, editingRow int) {
+ reason := "session-change"
+ changedCount, retainedCount, candidateCount := 0, 0, 0
+ if diagnostic.Enabled() {
+ defer func() {
+ diagnostic.Record("progress detection", "reason", reason, "changed_rows", changedCount, "retained_rows", retainedCount, "candidate_rows", candidateCount)
+ }()
+ }
+ if !next.Active || !previous.Active || previous.Terminal != next.Terminal || previous.Generation != next.Generation || previous.Rows != next.Rows || previous.Cols != next.Cols || next.Rows == 0 || next.Cols == 0 {
+ m.Reset()
+ return
+ }
+ reason = "prompt"
+ if promptPattern.MatchString(next.Text(next.Y)) || promptPattern.MatchString(next.Text(next.Rows-1)) {
+ m.Reset()
+ return
+ }
+ reason = "cursor-jump"
+ if next.Y != previous.Y && (abs(next.Y-previous.Y) > 2 || abs(next.X-previous.X) > 20) {
+ m.Reset()
+ return
+ }
+ // A cursor move over unchanged text is navigation, not ongoing work.
+ reason = "unchanged"
+ if slices.Equal(previous.Cells, next.Cells) {
+ if previous.X != next.X || previous.Y != next.Y {
+ m.Reset()
+ }
+ return
+ }
+ // Scrolling moves old output through many rows. Remove the retained
+ // overlap before applying size/multiline filters to genuinely new output.
+ var changedRows []int
+ for row := range next.Rows {
+ if !slices.Equal(previous.Line(row), next.Line(row)) {
+ changedRows = append(changedRows, row)
+ }
+ }
+ changedCount = len(changedRows)
+ scrollStart, scrollEnd := 0, 0
+ if len(changedRows) > 1 {
+ scrollStart, scrollEnd = screen.Scrolled_overlap(previous, next)
+ }
+ var lines []string
+ length := 0
+ for _, row := range changedRows {
+ if row == editingRow {
+ continue
+ }
+ if row >= scrollStart && row < scrollEnd {
+ retainedCount++
+ continue
+ }
+ line := next.Text(row)
+ // Erased rows are not new multiline output (common after a scroll).
+ if strings.TrimSpace(line) == "" {
+ continue
+ }
+ lines = append(lines, line)
+ length += len([]rune(line))
+ }
+ reason = "no-pattern"
+ if editingRow >= 0 && len(lines) == 0 {
+ reason = "editing"
+ }
+ var selected Indicator
+ found := false
+ for _, line := range lines {
+ if (length > 200 || len(lines) > 1) && !explicit(line) {
+ reason = "repaint-filter"
+ continue
+ }
+ candidateCount++
+ if value, ok := Detect(strings.TrimSpace(line)); ok {
+ selected, found = value, true
+ }
+ }
+ if !found {
+ m.Reset()
+ return
+ }
+ // Replace pending output even when throttled. The final value is flushed by
+ // the loop's existing timer if the program finishes between two audio ticks.
+ reason = "measured"
+ if selected.Interval != 0 {
+ reason = "activity"
+ }
+ m.active = true
+ m.pending = &selected
+}
+
+func (m *Monitor) Next(now time.Time) (float64, bool) {
+ if m.pending == nil {
+ return 0, false
+ }
+ value := *m.pending
+ interval := value.Interval
+ if interval == 0 {
+ interval = 100 * time.Millisecond
+ }
+ if !m.lastAt.IsZero() && now.Sub(m.lastAt) < interval {
+ return 0, false
+ }
+ m.pending = nil
+ if value.Interval == 0 && !m.lastAt.IsZero() && value == m.last {
+ return 0, false
+ }
+ m.last, m.lastAt = value, now
+ return value.Percentage, true
+}
+
+func abs(value int) int {
+ if value < 0 {
+ return -value
+ }
+ return value
+}
+
+func (m *Monitor) Active() bool { return m.active }
diff --git a/internal/progress/monitor_test.go b/internal/progress/monitor_test.go
new file mode 100644
index 00000000..a9d27c08
--- /dev/null
+++ b/internal/progress/monitor_test.go
@@ -0,0 +1,155 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package progress
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+func snapshot(lines ...string) screen.Snapshot {
+ s := screen.Snapshot{Terminal: 1, Active: true, Rows: len(lines), Cols: 300}
+ for _, line := range lines {
+ s.Cells = append(s.Cells, []rune(line+strings.Repeat(" ", s.Cols-len([]rune(line))))...)
+ }
+ return s
+}
+
+func TestWholeRowThrottlingAndFinalValue(t *testing.T) {
+ var m Monitor
+ now := time.Unix(100, 0)
+ before := snapshot("Download 49%", "")
+ after := snapshot("Download 50%", "")
+ m.Update(before, after, -1)
+ if value, ok := m.Next(now); !ok || value != 50 {
+ t.Fatal(value, ok)
+ }
+ for percentage := 51; percentage <= 100; percentage++ {
+ next := snapshot("Download "+format_percent(percentage), "")
+ m.Update(after, next, -1)
+ after = next
+ }
+ if _, ok := m.Next(now.Add(50 * time.Millisecond)); ok {
+ t.Fatal("flood not throttled")
+ }
+ if value, ok := m.Next(now.Add(100 * time.Millisecond)); !ok || value != 100 {
+ t.Fatal("lost final pending value", value, ok)
+ }
+ if _, ok := m.Next(now.Add(time.Second)); ok {
+ t.Fatal("repeated without output")
+ }
+ next := snapshot("Download 100% complete", "")
+ m.Update(after, next, -1)
+ if _, ok := m.Next(now.Add(time.Second)); ok {
+ t.Fatal("duplicate percentage")
+ }
+ // A new operation may decrease; completion never disables monitoring.
+ m.Update(next, snapshot("Download 1%", ""), -1)
+ if value, ok := m.Next(now.Add(2 * time.Second)); !ok || value != 1 {
+ t.Fatal(value, ok)
+ }
+}
+
+func TestMonitorResetFilters(t *testing.T) {
+ for _, mode := range []string{"initial", "inactive", "terminal", "generation", "resize", "typing", "prompt", "password", "navigation", "large cursor", "ordinary output"} {
+ t.Run(mode, func(t *testing.T) {
+ var m Monitor
+ now := time.Unix(100, 0)
+ before, after := snapshot("Download 20%", "", "", ""), snapshot("Download 30%", "", "", "")
+ m.Update(before, after, -1)
+ m.Next(now)
+ next := snapshot("Download 40%", "", "", "")
+ editingRow := -1
+ switch mode {
+ case "initial":
+ after = screen.Snapshot{}
+ case "inactive":
+ next.Active = false
+ case "terminal":
+ next.Terminal++
+ case "generation":
+ next.Generation++
+ case "resize":
+ next = snapshot("Download 40%", "")
+ case "typing":
+ editingRow = next.Y
+ case "prompt":
+ next = snapshot("Download 40%", "Username@host:~$", "", "")
+ next.Y = 1
+ case "password":
+ next = snapshot("Download 40%", "Password:", "", "")
+ next.Y = 1
+ case "navigation":
+ next = after
+ next.X = 1
+ case "large cursor":
+ next.Y = 3
+ case "ordinary output":
+ next = snapshot("done", "", "", "")
+ }
+ m.Update(after, next, editingRow)
+ if _, ok := m.Next(now.Add(time.Second)); ok || m.Active() {
+ t.Fatal("retained progress")
+ }
+ })
+ }
+}
+
+func TestMultilineLongRowsAndActivity(t *testing.T) {
+ now := time.Unix(100, 0)
+ for _, testCase := range []struct {
+ lines []string
+ want float64
+ ok bool
+ }{
+ {[]string{"package 20% [## ]", "Total 80% [######## ]", ""}, 80, true},
+ {[]string{strings.Repeat("package", 35) + " 60% |###### |", "", ""}, 60, true},
+ {[]string{"humidity 50%", "some other output", ""}, 0, false},
+ {[]string{strings.Repeat("unrelated", 30) + " 50%", "", ""}, 0, false},
+ {[]string{"50%", "some other output", ""}, 0, false},
+ } {
+ var m Monitor
+ m.Update(snapshot("", "", ""), snapshot(testCase.lines...), -1)
+ value, ok := m.Next(now)
+ if ok != testCase.ok || ok && value != testCase.want {
+ t.Fatal(testCase, value, ok)
+ }
+ }
+ var m Monitor
+ before, after := snapshot("", ""), snapshot("ā Working", "")
+ m.Update(before, after, -1)
+ if value, ok := m.Next(now); !ok || value != 50 {
+ t.Fatal(value, ok)
+ }
+ m.Update(after, snapshot("ā Working", ""), -1)
+ if _, ok := m.Next(now.Add(500 * time.Millisecond)); ok {
+ t.Fatal("activity flood")
+ }
+ if _, ok := m.Next(now.Add(time.Second)); !ok {
+ t.Fatal("missing activity")
+ }
+ if _, ok := m.Next(now.Add(2 * time.Second)); ok {
+ t.Fatal("beeping stopped spinner")
+ }
+}
+
+func format_percent(value int) string { return fmt.Sprint(value) + "%" }
diff --git a/internal/progress/patterns.go b/internal/progress/patterns.go
new file mode 100644
index 00000000..ffa2091f
--- /dev/null
+++ b/internal/progress/patterns.go
@@ -0,0 +1,175 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+// Detection patterns adapted from Python Fenrir (Chrys, Storm Dragon, and contributors).
+//
+// Package progress recognizes terminal progress without performing I/O.
+package progress
+
+import (
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+ "unicode/utf8"
+)
+
+type Indicator struct {
+ Percentage float64
+ Interval time.Duration // zero for measured progress, otherwise activity
+}
+
+var (
+ percentPattern = regexp.MustCompile(`(?:^|[^\d.\-])(\d+(?:\.\d+)?)\s*%`)
+ statsPattern = regexp.MustCompile(`(?i)\b(?:humidity|cpu|memory|disk|usage|temp|weather|forecast)\b`)
+ fractionPattern = regexp.MustCompile(`\b(\d+)\s*(?:of|/)\s*(\d+)\b`)
+ fractionNoise = regexp.MustCompile(`(?i)\b(?:page|chapter|section|line|row|column|year|month|day)\b|\d{1,2}/\d{1,2}/\d{2,4}`)
+ barPattern = regexp.MustCompile(`\[[#=*>āāāāāāāāāāā. \t-]{2,}\]|\|[#=*>āāāāāāāāāāā. \t-]{2,}\|`)
+ urlPattern = regexp.MustCompile(`(?i)\S+://\S+|www\.[^\s]+\.[a-z]{2,}`)
+ ratePattern = regexp.MustCompile(`(?i)\b\d+(?:\.\d+)?\s*[kmgt](?:i?b)?/s\b`)
+ fieldPattern = regexp.MustCompile(`(?i)\b([a-z][a-z0-9_-]*)=\s*\S+`)
+ timeField = regexp.MustCompile(`(?i)\b(?:time|elapsed|eta)=\s*\d+(?::\d{2}){1,2}(?:\.\d+)?\b`)
+ rateField = regexp.MustCompile(`(?i)\b(?:speed|fps|bitrate)=\s*[\d.]+[a-z/]*\b`)
+ transferPattern = regexp.MustCompile(`(?i)(?:remaining|elapsed|left|eta)[:;\s]*\d+s|\d+s\s+(?:remaining|elapsed|left)|(?:processing|generating|used|consumed)\s+\d+\s+tokens|\d+\s+bytes.*copied.*\d+\s+s.*[kmgt]?b/s|\d+(?:\.\d+)?\s+[kmgt]ib\s+\d+(?:\.\d+)?\s+[kmgt]ib/s\s+\d+:\d+|\d+\s+\d+[kmgt]?\s+\d+\s+\d+[kmgt]?.*\d+\.\d+[kmgt].*\d+:\d+:\d+`)
+ activityWords = `thinking|working|processing|analyzing|searching|reading|writing|planning|running|executing|updating|building|installing|compiling|downloading|reviewing|generating|responding|applying|fixing|editing|creating|preparing|checking|opening|loading|fetching|retrieving|scanning|indexing|summarizing`
+ activityPattern = regexp.MustCompile(`(?i)\b(?:loading|processing|working|installing|downloading|compiling|building)\b.*\.{2,}`)
+ interruptPattern = regexp.MustCompile(`(?i)(?:[*ā¢ā½ā¶ā»Ā·ā¢ā¦āāā]\s+\S+|^\s*(?:` + activityWords + `)\b).*\([^)]*(?:esc|ctrl\+c) to interrupt[^)]*\)`)
+ bulletPattern = regexp.MustCompile(`(?i)^\s*[ā¢ā¦]\s+.*(?:ā¦|\.{3,}|\b(?:` + activityWords + `)\b)`)
+ thinkingPattern = regexp.MustCompile(`(?i)š[^\pL\d_]*(?:thinking|processing|working|analyzing)[^\d]*(\d+)s?\b`)
+ taskPattern = regexp.MustCompile(`(?i)[āāā]\s+(?:task\s+)?(?:output|running|pending|working|processing)\s+[a-z0-9]+`)
+ promptPattern = regexp.MustCompile(`(?i)^\s*(?:[$#>]|\[.*\]\s*[\\$#>]|[a-z0-9._-]+[\\$#>]|\S+@\S+.*[$#>])\s*$|\?\s*\[(?:y/n|yes/no)\]\s*$|(?:\[sudo\]\s*password for .*|password)\s*:\s*$|^\s*(?:press any key to continue|are you sure\?).*$`)
+ curlPattern = regexp.MustCompile(`^\s*(\d+)\s+` + sizeField + `\s+(\d+)\s+` + sizeField + `\s+(\d+)\s+` + sizeField + `\s+` + sizeField + `\s+` + sizeField + `\s+` + clockField + `\s+` + clockField + `\s+` + clockField + `\s+` + sizeField + `(?:\s|$)`)
+)
+
+const sizeField = `\d+(?:\.\d+)?(?:[kKmMgGtT](?:i?[bB])?)?`
+const clockField = `(?:(?:\d+:)?\d{2}:\d{2}|--:--:--)`
+
+func curl_percentage(text string) (float64, bool) {
+ match := curlPattern.FindStringSubmatch(text)
+ if match == nil {
+ return 0, false
+ }
+ total, _ := strconv.ParseFloat(match[1], 64)
+ received, _ := strconv.ParseFloat(match[2], 64)
+ uploaded, _ := strconv.ParseFloat(match[3], 64)
+ return total, total <= 100 && total == received && uploaded <= 100
+}
+
+func status_fields(text string) bool {
+ names := make(map[string]bool)
+ for _, match := range fieldPattern.FindAllStringSubmatch(text, -1) {
+ switch name := strings.ToLower(match[1]); name {
+ case "frame", "fps", "size", "time", "bitrate", "speed", "elapsed", "dup", "drop":
+ names[name] = true
+ }
+ }
+ return len(names) >= 3 && timeField.MatchString(text) && rateField.MatchString(text)
+}
+
+// Explicit rows are safe to inspect in large/multiline repaints. This follows
+// Python's structural filters instead of accepting arbitrary numeric tables.
+func explicit(text string) bool {
+ if urlPattern.MatchString(text) {
+ return false
+ }
+ if _, ok := curl_percentage(text); ok {
+ return true
+ }
+ return status_fields(text) || percentPattern.MatchString(text) && (barPattern.MatchString(text) || ratePattern.MatchString(text))
+}
+
+// Detect ports Python's screen-update detector. Prefer measured progress, then
+// activity. Indeterminate indicators use the same pitch as measured 50%.
+func Detect(text string) (Indicator, bool) {
+ if strings.ContainsAny(text, "\r\n") || urlPattern.MatchString(text) {
+ return Indicator{}, false
+ }
+ if match := percentPattern.FindStringSubmatch(text); match != nil {
+ value, _ := strconv.ParseFloat(match[1], 64)
+ if value <= 100 && !statsPattern.MatchString(text) {
+ return Indicator{Percentage: value}, true
+ }
+ return Indicator{}, false
+ }
+ if value, ok := curl_percentage(text); ok {
+ return Indicator{Percentage: value}, true
+ }
+ if transferPattern.MatchString(text) || status_fields(text) {
+ return activity(2 * time.Second), true
+ }
+ if match := fractionPattern.FindStringSubmatch(text); match != nil && !fractionNoise.MatchString(text) {
+ current, _ := strconv.ParseFloat(match[1], 64)
+ total, _ := strconv.ParseFloat(match[2], 64)
+ if total > 0 && total <= 1000 && current <= total {
+ return Indicator{Percentage: 100 * current / total}, true
+ }
+ }
+ if bar := barPattern.FindString(text); bar != "" {
+ content := []rune(bar)[1 : utf8.RuneCountInString(bar)-1]
+ filled, empty := 0.0, false
+ valid := true
+ for _, char := range content {
+ weight := 1.0
+ switch char {
+ case ' ', '\t', '.', '-', 'ā':
+ weight = 0
+ empty = true
+ case 'ā':
+ weight = .125
+ case 'ā':
+ weight = .25
+ case 'ā':
+ weight = .375
+ case 'ā':
+ weight = .5
+ case 'ā':
+ weight = .625
+ case 'ā':
+ weight = .75
+ case 'ā':
+ weight = .875
+ }
+ if weight > 0 && empty {
+ valid = false
+ }
+ filled += weight
+ }
+ if valid && filled > 0 {
+ return Indicator{Percentage: 100 * filled / float64(len(content))}, true
+ }
+ }
+ if activityPattern.MatchString(text) {
+ return activity(2 * time.Second), true
+ }
+ if strings.ContainsAny(text, "ā ā ⠹⠸⠼⠓⠦⠧ā ā ┿ā£ā£Æā£·ā£¾ā£½ā£»ā¢æššššššššāāāā") || interruptPattern.MatchString(text) || bulletPattern.MatchString(text) {
+ return activity(time.Second), true
+ }
+ if match := thinkingPattern.FindStringSubmatch(text); match != nil {
+ seconds, _ := strconv.Atoi(match[1])
+ interval := 1500 * time.Millisecond
+ if seconds >= 10 {
+ interval = 2 * time.Second
+ }
+ return activity(interval), true
+ }
+ if taskPattern.MatchString(text) {
+ return activity(1500 * time.Millisecond), true
+ }
+ return Indicator{}, false
+}
+
+func activity(interval time.Duration) Indicator { return Indicator{Percentage: 50, Interval: interval} }
diff --git a/internal/progress/patterns_test.go b/internal/progress/patterns_test.go
new file mode 100644
index 00000000..2346829c
--- /dev/null
+++ b/internal/progress/patterns_test.go
@@ -0,0 +1,79 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package progress
+
+import (
+ "math"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestPythonProgressFormats(t *testing.T) {
+ for _, testCase := range []struct {
+ text string
+ percentage float64
+ interval time.Duration
+ }{
+ {"Downloading 0%", 0, 0}, {"Downloading 25.5%", 25.5, 0}, {"100%", 100, 0},
+ {"15/100", 15, 0}, {"3 of 10", 30, 0}, {"[#### ]", 50, 0}, {"[====> ]", 50, 0}, {"|āāāāā |", 50, 0}, {"[ā ]", 12.5, 0},
+ {" 50 100M 50 50M 0 0 10M 0 0:00:10 0:00:05 0:00:05 10M", 50, 0},
+ {"frame= 10 fps=25 size=100kB time=00:00:03.50 bitrate=200kbits/s speed=1.0x", 50, 2 * time.Second},
+ {"remaining: 12s", 50, 2 * time.Second}, {"generating 20 tokens", 50, 2 * time.Second},
+ {"1024 bytes copied, 1 s, 1 kB/s", 50, 2 * time.Second}, {"2.5 MiB 1.2 MiB/s 00:02", 50, 2 * time.Second},
+ {"Loading...", 50, 2 * time.Second}, {"ā preparing", 50, time.Second}, {"š working", 50, time.Second}, {"ā", 50, time.Second},
+ {"⻠Generating answer⦠(esc to interrupt)", 50, time.Second}, {"Working (ctrl+c to interrupt)", 50, time.Second},
+ {"⢠Checking filesā¦", 50, time.Second}, {"⦠Working on files", 50, time.Second},
+ {"š Thinking 3s", 50, 1500 * time.Millisecond}, {"š Thinking 23s", 50, 2 * time.Second},
+ {"ā Task Running job", 50, 1500 * time.Millisecond},
+ } {
+ t.Run(testCase.text, func(t *testing.T) {
+ got, ok := Detect(testCase.text)
+ if !ok || math.Abs(got.Percentage-testCase.percentage) > 0.001 || got.Interval != testCase.interval {
+ t.Fatalf("got %+v, %v", got, ok)
+ }
+ })
+ }
+}
+
+func TestFalsePositives(t *testing.T) {
+ for _, text := range []string{
+ "https://example.invalid/50%", "www.example.invalid/3/10", "humidity 50%", "CPU usage 70%", "weather 30%",
+ "-1%", "1000%", "1.2.50%", "Page 3 of 10", "chapter 1/5", "10/25/2026", "5/0", "20/10", "1/1001",
+ "[#channel]", "[item]", "[*]", "[--]", "10 20 30 40 50 60", "Speed", "normal text", "frame=10 fps=25 size=20",
+ "150 100M 150 150M 0 0 10M 0 0:00:10 0:00:05 0:00:05 10M",
+ "50 100M 30 30M 0 0 10M 0 0:00:10 0:00:05 0:00:05 10M",
+ } {
+ if got, ok := Detect(text); ok {
+ t.Errorf("false positive %q: %+v", text, got)
+ }
+ }
+}
+
+func TestExplicitRows(t *testing.T) {
+ for _, text := range []string{"package 50% [#### ]", "package 50% |#### |", "package 50% 2 MiB/s", "frame=10 time=00:01:20 fps=25"} {
+ if !explicit(strings.Repeat("long-name", 30) + " " + text) {
+ t.Error(text)
+ }
+ }
+ for _, text := range []string{"50%", "Speed", "https://example.invalid 50% [#### ]", "normal table 1 2 3"} {
+ if explicit(text) {
+ t.Error(text)
+ }
+ }
+}
diff --git a/internal/prompt/detector.go b/internal/prompt/detector.go
new file mode 100644
index 00000000..3c532bb2
--- /dev/null
+++ b/internal/prompt/detector.go
@@ -0,0 +1,92 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+// Package prompt recognizes fresh prompts and work-completion lines.
+package prompt
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+var defaultPatterns = []*regexp.Regexp{
+ regexp.MustCompile(`^(?:[$#>]|\[.*\]\s*[\\$#>]|[a-zA-Z0-9._-]+[\\$#>]|\S+@\S+.*[$#>])\s*$`),
+ regexp.MustCompile(`(?i)\?\s*(?:\[(?:y/n|yes/no)\]|\((?:y/n|yes/no)\))\s*:?\s*$`),
+ regexp.MustCompile(`(?i)^.*\bok\s*\[y/n\]\s*:?\s*$`),
+ regexp.MustCompile(`(?i)^(?:\[sudo\]\s*password\s+for\s+.+|.*\bpassword|(?:please\s+)?enter\s+passphrase.*)\s*:\s*$`),
+ regexp.MustCompile(`(?i)^(?:press\s+any\s+key\s+to\s+continue.*|are\s+you\s+sure\?.*|please\s+confirm.*)$`),
+}
+
+var completionPattern = regexp.MustCompile(`^Worked for (?:[0-9]+h(?: [0-9]+m)?(?: [0-9]+s)?|[0-9]+m(?: [0-9]+s)?|[0-9]+s)\s+ā¢\s+(?:[1-9]|1[0-2]):[0-5][0-9]\s+(?:AM|PM)$`)
+
+type Detector struct {
+ exactMatches []string
+ patterns []*regexp.Regexp
+ previous map[string]int
+ terminal int
+ generation uint64
+ rows, cols int
+ active bool
+}
+
+func New(exactMatches, customPatterns []string) (*Detector, error) {
+ detector := &Detector{exactMatches: append([]string(nil), exactMatches...)}
+ for _, pattern := range customPatterns {
+ compiled, err := regexp.Compile(pattern)
+ if err != nil {
+ return nil, fmt.Errorf("invalid prompt custom pattern %q: %w", pattern, err)
+ }
+ detector.patterns = append(detector.patterns, compiled)
+ }
+ return detector, nil
+}
+
+func (d *Detector) matches_prompt(text string) bool {
+ if text == "" {
+ return false
+ }
+ for _, exact := range d.exactMatches {
+ if text == strings.TrimSpace(exact) {
+ return true
+ }
+ }
+ for _, patterns := range [][]*regexp.Regexp{d.patterns, defaultPatterns} {
+ for _, pattern := range patterns {
+ if pattern.MatchString(text) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// Observe runs even when speech is enabled, establishing a baseline before
+// silence. Counts ignore row movement, so old prompts/completions scrolling
+// through the screen cannot restore speech. Session/buffer/size changes only
+// establish a new baseline; they never restore from another screen's history.
+// Shell/interactive prompts must be on the cursor row. Completion lines can
+// appear above an input area, as in a terminal coding assistant.
+func (d *Detector) Observe(next screen.Snapshot) string {
+ counts := make(map[string]int)
+ for row := 0; row < next.Rows; row++ {
+ counts[strings.TrimSpace(next.Text(row))]++
+ }
+ stable := d.previous != nil && d.active && next.Active && d.terminal == next.Terminal &&
+ d.generation == next.Generation && d.rows == next.Rows && d.cols == next.Cols
+ var result string
+ if stable {
+ for row := 0; row < next.Rows; row++ {
+ text := strings.TrimSpace(next.Text(row))
+ if counts[text] > d.previous[text] && (completionPattern.MatchString(text) || row == next.Y && d.matches_prompt(text)) {
+ result = text
+ break
+ }
+ }
+ }
+ d.previous = counts
+ d.terminal, d.generation, d.rows, d.cols, d.active = next.Terminal, next.Generation, next.Rows, next.Cols, next.Active
+ return result
+}
diff --git a/internal/prompt/detector_test.go b/internal/prompt/detector_test.go
new file mode 100644
index 00000000..628ac427
--- /dev/null
+++ b/internal/prompt/detector_test.go
@@ -0,0 +1,113 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package prompt
+
+import (
+ "testing"
+
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+func snapshot(lines ...string) screen.Snapshot {
+ result := screen.Snapshot{Terminal: 1, Active: true, Rows: len(lines), Cols: 100, Y: len(lines) - 1}
+ for _, line := range lines {
+ cells := make([]rune, result.Cols)
+ for index := range cells {
+ cells[index] = ' '
+ }
+ copy(cells, []rune(line))
+ result.Cells = append(result.Cells, cells...)
+ }
+ return result
+}
+
+func TestRecognizesFreshPromptsAndCompletion(t *testing.T) {
+ for _, line := range []string{
+ "$", "#", ">", "bash-5.3$", "[Username@Host ~/code] $", "Username@Host:~$",
+ "Proceed? [Y/n]", "Continue? (Yes/No)", "Is this ok [y/N]:", ":: Proceed? [Y/n]",
+ "Password:", "[sudo] password for Username:", "Username's password:",
+ "Please enter passphrase for key:", "Press any key to continue...", "Are you sure? [yes/no]",
+ "Please confirm:", "CUSTOM READY", "Device 42 ready:",
+ "Worked for 3m 35s ⢠8:05 PM", "Worked for 19s ⢠7:46 PM", "Worked for 1h 2m 3s ⢠12:00 AM",
+ } {
+ t.Run(line, func(t *testing.T) {
+ detector, err := New([]string{"CUSTOM READY"}, []string{`^Device [0-9]+ ready:$`})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := detector.Observe(snapshot("working")); got != "" {
+ t.Fatal(got)
+ }
+ if got := detector.Observe(snapshot(" " + line)); got != line {
+ t.Fatal(got)
+ }
+ if got := detector.Observe(snapshot(" " + line)); got != "" {
+ t.Fatal("stale line restored", got)
+ }
+ })
+ }
+}
+
+func TestRejectsFalseAndIncompletePrompts(t *testing.T) {
+ for _, line := range []string{
+ "Price is $", "C# language", "Downloading 20%", "$ echo hello", "Password: secret",
+ "Please enter passphrase: secret", "Worked for 19s", "Worked for 19s ⢠7:4",
+ "Worked for 19s ⢠17:46 PM", "Worked for 19s ⢠7:99 PM", "Worked for 19s ⢠7:46 PM more",
+ "quoted Worked for 19s ⢠7:46 PM", "Worked for 3m nonsense ⢠8:05 PM",
+ } {
+ t.Run(line, func(t *testing.T) {
+ detector, _ := New(nil, nil)
+ detector.Observe(snapshot("working"))
+ if got := detector.Observe(snapshot(line)); got != "" {
+ t.Fatal(got)
+ }
+ })
+ }
+}
+
+func TestSplitCompletionAboveCursorAndOldScroll(t *testing.T) {
+ detector, _ := New(nil, nil)
+ detector.Observe(snapshot("old", "working", "input"))
+ if got := detector.Observe(snapshot("old", "Worked for 19s ⢠7:4", "input")); got != "" {
+ t.Fatal(got)
+ }
+ line := "Worked for 19s ⢠7:46 PM"
+ if got := detector.Observe(snapshot("old", line, "input")); got != line {
+ t.Fatal(got)
+ }
+ if got := detector.Observe(snapshot(line, "input", "still working")); got != "" {
+ t.Fatal("scrolled history restored", got)
+ }
+ // A shell prompt elsewhere on the screen is not the active prompt.
+ if got := detector.Observe(snapshot(line, "$", "still working")); got != "" {
+ t.Fatal(got)
+ }
+}
+
+func TestSessionChangesOnlyEstablishBaseline(t *testing.T) {
+ for _, mode := range []string{"terminal", "generation", "resize", "inactive"} {
+ t.Run(mode, func(t *testing.T) {
+ detector, _ := New(nil, nil)
+ detector.Observe(snapshot("working"))
+ next := snapshot("$")
+ switch mode {
+ case "terminal":
+ next.Terminal++
+ case "generation":
+ next.Generation++
+ case "resize":
+ next = snapshot("", "$")
+ case "inactive":
+ next.Active = false
+ }
+ if got := detector.Observe(next); got != "" {
+ t.Fatal(got)
+ }
+ next.Active = true
+ if got := detector.Observe(next); got != "" {
+ t.Fatal(got)
+ }
+ })
+ }
+}
diff --git a/internal/punctuation/emoticons.go b/internal/punctuation/emoticons.go
new file mode 100644
index 00000000..5d0bbfa1
--- /dev/null
+++ b/internal/punctuation/emoticons.go
@@ -0,0 +1,40 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package punctuation
+
+// Spellings and names from Python Fenrir's default.conf emoticonDict
+// (LGPL-3.0-or-later). Matching is case-sensitive, as in Python's rules.
+var emoticonNames = map[string]string{
+ ":-)": "smile", ":)": "smile",
+ ":-(": "frown", ":(": "frown",
+ ":-D": "big grin", ":D": "big grin",
+ ";-)": "wink", ";)": "wink",
+ ":-P": "tongue out", ":P": "tongue out",
+ ":-O": "surprised", ":O": "surprised",
+ ":-/": "skeptical", ":/": "skeptical",
+ ">:-(": "angry", ">:(": "angry",
+ ":'(": "crying", ":'-(": "crying",
+ ":-|": "neutral", ":|": "neutral",
+ "8-)": "cool", "B-)": "cool",
+ "8-X": "skull and crossbones",
+ "X-X": "knocked out", "x-x": "knocked out",
+ ":-X": "lips sealed", ":-x": "lips sealed", ":X": "lips sealed", ":x": "lips sealed",
+ "XD": "laughing hard",
+ "^_^": "happy",
+ "-_-": "annoyed",
+ "o_O": "confused",
+ ">_<": "frustrated",
+ `\o/`: "hurray",
+ "<3": "heart",
+ "3": "broken heart",
+ "Oo": "WTF?", "oO": "WTF?",
+ "<{-.-}>": "Raves",
+ "-.-": "bugged",
+ ">.<": "laughing",
+ ">:)": "evil smile", ">:-)": "evil smile",
+ "^^": "enjoy smile",
+ "->": "arrow right",
+ "<-": "arrow left",
+}
diff --git a/internal/punctuation/profile.go b/internal/punctuation/profile.go
new file mode 100644
index 00000000..d7082da7
--- /dev/null
+++ b/internal/punctuation/profile.go
@@ -0,0 +1,182 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+// Package punctuation prepares speech independently of the speech driver.
+package punctuation
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "unicode/utf8"
+
+ "git.stormux.org/storm/fenrir/internal/i18n"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "github.com/pelletier/go-toml/v2"
+)
+
+type Profile struct {
+ Version int `toml:"version"`
+ Translate bool `toml:"translate"`
+ Levels map[string]string `toml:"levels"`
+ Symbols map[string]string `toml:"symbols"`
+}
+
+// Processor owns validated, immutable profile data. Replacements are emitted
+// once; punctuation inside a name is never processed again.
+type Processor struct {
+ levels map[string]map[rune]bool
+ symbols map[rune]string
+ translate bool
+}
+
+func Levels() []string { return []string{"none", "some", "most", "all"} }
+
+func Validate_name(name string) error {
+ if strings.TrimSpace(name) == "" || strings.ContainsAny(name, "\x00\r\n") {
+ return fmt.Errorf("punctuation profile cannot be empty or contain control characters")
+ }
+ if extension := filepath.Ext(name); extension != "" && extension != ".toml" || strings.ContainsRune(name, filepath.Separator) && extension != ".toml" {
+ return fmt.Errorf("punctuation profile must be a name or a .toml path")
+ }
+ if stem := strings.TrimSuffix(name, ".toml"); stem == "" || stem == "." || stem == ".." {
+ return fmt.Errorf("invalid punctuation profile name")
+ }
+ return nil
+}
+
+func Paths(name, home string, uid int, bundledDirectory string) ([]string, error) {
+ if err := Validate_name(name); err != nil {
+ return nil, err
+ }
+ if strings.ContainsRune(name, filepath.Separator) {
+ return []string{name}, nil
+ }
+ name = strings.TrimSuffix(name, ".toml") + ".toml"
+ var paths []string
+ if uid != 0 {
+ paths = append(paths, filepath.Join(home, ".local/share/stormux/fenrirscreenreader/punctuation", name))
+ }
+ paths = append(paths, filepath.Join("/etc/fenrirscreenreader/punctuation", name))
+ if bundledDirectory != "" {
+ paths = append(paths, filepath.Join(bundledDirectory, name))
+ }
+ return paths, nil
+}
+
+func Open(name string) (*Processor, string, error) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return nil, "", err
+ }
+ executable, err := os.Executable()
+ if err != nil {
+ return nil, "", err
+ }
+ paths, err := Paths(name, home, os.Geteuid(), filepath.Join(filepath.Dir(executable), "config", "punctuation"))
+ if err != nil {
+ return nil, "", err
+ }
+ return Load(paths)
+}
+
+func Load(paths []string) (*Processor, string, error) {
+ for _, path := range paths {
+ file, err := os.Open(path)
+ if errors.Is(err, os.ErrNotExist) {
+ continue
+ }
+ if err != nil {
+ return nil, path, err
+ }
+ data, readErr := io.ReadAll(io.LimitReader(file, (1<<20)+1))
+ if err := errors.Join(readErr, file.Close()); err != nil {
+ return nil, path, err
+ }
+ if len(data) > 1<<20 {
+ return nil, path, fmt.Errorf("punctuation profile exceeds 1 MiB")
+ }
+ processor, err := Decode(data)
+ return processor, path, err
+ }
+ return nil, "", fmt.Errorf("punctuation profile not found (searched %s)", strings.Join(paths, ", "))
+}
+
+func Decode(data []byte) (*Processor, error) {
+ var profile Profile
+ if err := toml.NewDecoder(bytes.NewReader(data)).DisallowUnknownFields().Decode(&profile); err != nil {
+ return nil, err
+ }
+ if profile.Version != 1 {
+ return nil, fmt.Errorf("unsupported punctuation profile version %d", profile.Version)
+ }
+ processor := &Processor{levels: make(map[string]map[rune]bool), symbols: make(map[rune]string), translate: profile.Translate}
+ for symbol, name := range profile.Symbols {
+ if !utf8.ValidString(symbol) || utf8.RuneCountInString(symbol) != 1 || strings.TrimSpace(name) == "" || strings.ContainsAny(name, "\x00\r\n") {
+ return nil, fmt.Errorf("invalid punctuation name for %q", symbol)
+ }
+ char, _ := utf8.DecodeRuneInString(symbol)
+ processor.symbols[char] = name
+ }
+ for _, level := range Levels() {
+ symbols, exists := profile.Levels[level]
+ if !exists || level == "none" && symbols != "" {
+ return nil, fmt.Errorf("invalid or missing punctuation level %q", level)
+ }
+ selected := make(map[rune]bool)
+ for _, char := range symbols {
+ if _, exists := processor.symbols[char]; !exists {
+ return nil, fmt.Errorf("punctuation level %q has no name for %q", level, char)
+ }
+ selected[char] = true
+ }
+ processor.levels[level] = selected
+ }
+ if len(profile.Levels) != len(Levels()) {
+ return nil, fmt.Errorf("unknown punctuation level")
+ }
+ if len(processor.symbols) == 0 {
+ return nil, fmt.Errorf("punctuation profile has no symbols")
+ }
+ return processor, nil
+}
+
+func (p *Processor) Character(char rune) string {
+ if name, exists := p.symbols[char]; exists {
+ if p.translate {
+ return i18n.T(name)
+ }
+ return name
+ }
+ return presentation.Character(char)
+}
+
+// Text follows Python's levels and pause handling, retaining unspoken sentence
+// punctuation and replacing other unspoken symbols with spaces. Numbers and
+// contractions therefore retain their original punctuation at lower levels.
+func (p *Processor) Text(text, level string) string {
+ selected := p.levels[level]
+ var result strings.Builder
+ for _, char := range text {
+ if selected[char] {
+ result.WriteByte(' ')
+ result.WriteString(p.Character(char))
+ if strings.ContainsRune("',.;:?!", char) {
+ result.WriteRune(char)
+ }
+ result.WriteByte(' ')
+ } else if strings.ContainsRune("'. ,;:?!-", char) {
+ result.WriteRune(char)
+ } else if _, known := p.symbols[char]; known || strings.ContainsRune("!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~§", char) {
+ result.WriteByte(' ')
+ } else {
+ result.WriteRune(char)
+ }
+ }
+ return result.String()
+}
diff --git a/internal/punctuation/profile_test.go b/internal/punctuation/profile_test.go
new file mode 100644
index 00000000..4cef2e2e
--- /dev/null
+++ b/internal/punctuation/profile_test.go
@@ -0,0 +1,111 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package punctuation
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestShippedProfilesAndLevels(t *testing.T) {
+ paths, err := filepath.Glob("../../config/punctuation/*.toml")
+ if err != nil || len(paths) != 7 {
+ t.Fatal(paths, err)
+ }
+ for _, path := range paths {
+ processor, _, err := Load([]string{path})
+ if err != nil {
+ t.Fatal(path, err)
+ }
+ for _, level := range Levels() {
+ if _, exists := processor.levels[level]; !exists {
+ t.Fatal(path, level)
+ }
+ }
+ if processor.Character('.') == "." {
+ t.Fatal("missing character name", path)
+ }
+ }
+}
+
+func TestTextLevelsPreservePausesAndDoNotReprocessNames(t *testing.T) {
+ processor, _, err := Load([]string{"../../config/punctuation/default.toml"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, testCase := range []struct{ level, input, want string }{
+ {"none", "don't 3.14 [X]mute a_b", "don't 3.14 X mute a b"},
+ {"some", "a/b#c!", "a slash b hash c!"},
+ {"most", "a,b!", "a comma, b bang! "},
+ {"all", "?", " question?? "},
+ {"all", "a b", "a space b"},
+ {"some", "Ć©āĪ©", "Ć©āĪ©"},
+ } {
+ if got := processor.Text(testCase.input, testCase.level); got != testCase.want {
+ t.Errorf("%s %q: %q, want %q", testCase.level, testCase.input, got, testCase.want)
+ }
+ }
+ if got := processor.Character('?'); got != "question?" {
+ t.Fatal(got)
+ }
+ if got := processor.Character('E'); got != "E" {
+ t.Fatal(got)
+ }
+}
+
+func TestCustomNamesAndInvalidProfiles(t *testing.T) {
+ data, err := os.ReadFile("../../config/punctuation/default.toml")
+ if err != nil {
+ t.Fatal(err)
+ }
+ custom := strings.Replace(string(data), `"/" = "slash"`, `"/" = "path/separator"`, 1)
+ processor, err := Decode([]byte(custom))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := processor.Text("a/b", "some"); got != "a path/separator b" {
+ t.Fatal("recursive replacement", got)
+ }
+ for _, invalid := range []string{
+ strings.Replace(string(data), "version = 1", "version = 99", 1),
+ strings.Replace(string(data), `"none" = ""`, `"none" = "/"`, 1),
+ strings.Replace(string(data), `"/" = "slash"`, `"//" = "slash"`, 1),
+ strings.Replace(string(data), `"/" = "slash"`, `"/" = ""`, 1),
+ string(data) + "\n[unknown]\nkey = 1\n",
+ } {
+ if _, err := Decode([]byte(invalid)); err == nil {
+ t.Fatal("accepted malformed profile", invalid)
+ }
+ }
+}
+
+func TestProfileSelectionAndInvalidShadow(t *testing.T) {
+ home := t.TempDir()
+ paths, err := Paths("default", home, 1000, "bundle")
+ if err != nil || len(paths) != 3 || !strings.HasPrefix(paths[0], home) || paths[2] != filepath.Join("bundle", "default.toml") {
+ t.Fatal(paths, err)
+ }
+ paths, err = Paths("default.toml", home, 0, "bundle")
+ if err != nil || len(paths) != 2 || strings.HasPrefix(paths[0], home) {
+ t.Fatal(paths, err)
+ }
+ for _, name := range []string{"", ".", "..", "old.conf", "dir/profile", "\x00"} {
+ if _, err := Paths(name, home, 0, "bundle"); err == nil {
+ t.Fatal("accepted invalid selection", name)
+ }
+ }
+ shadow := filepath.Join(home, "bad.toml")
+ if err := os.WriteFile(shadow, []byte("not TOML"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if _, selected, err := Load([]string{shadow, "../../config/punctuation/default.toml"}); err == nil || selected != shadow {
+ t.Fatal("invalid shadow was skipped", selected, err)
+ }
+ if _, _, err := Load([]string{filepath.Join(home, "missing.toml"), "../../config/punctuation/default.toml"}); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/internal/punctuation/text_processing.go b/internal/punctuation/text_processing.go
new file mode 100644
index 00000000..e09df9f7
--- /dev/null
+++ b/internal/punctuation/text_processing.go
@@ -0,0 +1,74 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package punctuation
+
+import (
+ "strconv"
+ "strings"
+ "unicode"
+ "unicode/utf8"
+
+ "git.stormux.org/storm/fenrir/internal/i18n"
+)
+
+type TextOptions struct {
+ Emoticons bool
+ RepeatedSymbols bool
+ RepeatedSymbolsMin int
+}
+
+// Process_text substitutes complete tokens before ordinary punctuation. Each
+// generated name is emitted once, without processing punctuation inside it.
+// Whitespace belongs to neither neighboring token, so adjacent emoticons work.
+func (p *Processor) Process_text(text, level string, options TextOptions) string {
+ if !options.Emoticons && !options.RepeatedSymbols {
+ return p.Text(text, level)
+ }
+ var result strings.Builder
+ tokenStart := 0
+ for index, char := range text {
+ if !unicode.IsSpace(char) {
+ continue
+ }
+ result.WriteString(p.process_token(text[tokenStart:index], level, options))
+ result.WriteString(p.Text(string(char), level))
+ tokenStart = index + utf8.RuneLen(char)
+ }
+ result.WriteString(p.process_token(text[tokenStart:], level, options))
+ return result.String()
+}
+
+func (p *Processor) process_token(token, level string, options TextOptions) string {
+ if options.Emoticons {
+ if name, exists := emoticonNames[token]; exists {
+ return i18n.T(name)
+ }
+ }
+ if options.RepeatedSymbols && options.RepeatedSymbolsMin >= 2 && token != "" {
+ char, _ := utf8.DecodeRuneInString(token)
+ if p.levels[level][char] && (unicode.IsPunct(char) || unicode.IsSymbol(char)) {
+ count := 0
+ for _, next := range token {
+ if next != char {
+ return p.Text(token, level)
+ }
+ count++
+ }
+ if count >= options.RepeatedSymbolsMin {
+ name := p.Character(char)
+ // G13's requested English summary is "5 number". Keep ordinary
+ // hash feedback, custom names and localized profile names intact.
+ if char == '#' && p.symbols[char] == "hash" {
+ name = "number"
+ if p.translate {
+ name = i18n.T(name)
+ }
+ }
+ return i18n.Format("{0} {1}", strconv.Itoa(count), name)
+ }
+ }
+ }
+ return p.Text(token, level)
+}
diff --git a/internal/punctuation/text_processing_test.go b/internal/punctuation/text_processing_test.go
new file mode 100644
index 00000000..b7a554e4
--- /dev/null
+++ b/internal/punctuation/text_processing_test.go
@@ -0,0 +1,136 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package punctuation
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "git.stormux.org/storm/fenrir/internal/i18n"
+)
+
+func TestTextSubstitutionsAndBoundaries(t *testing.T) {
+ processor, _, err := Load([]string{"../../config/punctuation/default.toml"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ options := TextOptions{Emoticons: true, RepeatedSymbols: true, RepeatedSymbolsMin: 4}
+ for _, trial := range []struct{ text, want string }{
+ {":)", "smile"},
+ {":) :) :)", "smile smile smile"},
+ {":) :(", "smile frown"},
+ {"hello :) there", "hello smile there"},
+ {"ä½ å„½ :) äøē", "ä½ å„½ smile äøē"},
+ {"#####", "5 number"},
+ {"#### ####", "4 number 4 number"},
+ {"##### title #####", "5 number title 5 number"},
+ {"////", "4 slash"},
+ {"Oo", "WTF?"},
+ } {
+ if got := processor.Process_text(trial.text, "some", options); got != trial.want {
+ t.Errorf("%q: %q, want %q", trial.text, got, trial.want)
+ }
+ }
+ for _, text := range []string{"hello :)!", "path/to:)file", "https://example.invalid/:)", "https://example.invalid/:-/", "(o_O)", "###", "##--##", "a####b", "../....", "https://example.invalid/####", "10000", "aaaa", "šššš", ""} {
+ for _, level := range Levels() {
+ if got, want := processor.Process_text(text, level, options), processor.Text(text, level); got != want {
+ t.Errorf("%q, %s changed ordinary processing: %q, want %q", text, level, got, want)
+ }
+ }
+ }
+ if got := processor.Process_text("\t:)\u00a0:) \n", "none", options); got != "\tsmile\u00a0smile \n" {
+ t.Fatal("whitespace changed", got)
+ }
+ if got := processor.Process_text("##### ####", "all", options); got != "5 number space 4 number" {
+ t.Fatal("generated names were processed again", got)
+ }
+ if got := processor.Process_text("Oo", "all", options); got != "WTF?" {
+ t.Fatal("punctuation in emoticon name processed again", got)
+ }
+}
+
+func TestSubstitutionOptionsAndProfileNames(t *testing.T) {
+ processor, _, err := Load([]string{"../../config/punctuation/default.toml"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ text := ":) #####"
+ for _, level := range Levels() {
+ if got := processor.Process_text(text, level, TextOptions{}); got != processor.Text(text, level) {
+ t.Fatal("disabled substitutions changed text", level, got)
+ }
+ }
+ options := TextOptions{RepeatedSymbols: true, RepeatedSymbolsMin: 5}
+ if got := processor.Process_text("####", "some", options); got != processor.Text("####", "some") {
+ t.Fatal("minimum ignored", got)
+ }
+ if got := processor.Process_text("#####", "none", options); got != processor.Text("#####", "none") {
+ t.Fatal("summarized unselected symbol", got)
+ }
+ for _, minimum := range []int{0, 1} {
+ options.RepeatedSymbolsMin = minimum
+ if got := processor.Process_text("#####", "some", options); got != processor.Text("#####", "some") {
+ t.Fatal("invalid options summarized text", got)
+ }
+ }
+ options.RepeatedSymbolsMin = 4
+ data, err := os.ReadFile("../../config/punctuation/default.toml")
+ if err != nil {
+ t.Fatal(err)
+ }
+ data = []byte(strings.Replace(string(data), `"/" = "slash"`, `"/" = "path/separator"`, 1))
+ custom, err := Decode(data)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := custom.Process_text("////", "some", options); got != "4 path/separator" {
+ t.Fatal("custom name changed or processed twice", got)
+ }
+ french, _, err := Load([]string{"../../config/punctuation/fr.toml"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := french.Process_text("#####", "most", options); got != "5 diĆØse" {
+ t.Fatal("localized profile name lost", got)
+ }
+ if got := processor.Character('#'); got != "hash" {
+ t.Fatal("summary changed character naming", got)
+ }
+}
+
+func TestTextSubstitutionTranslationsAndFieldOrder(t *testing.T) {
+ root := t.TempDir()
+ directory := filepath.Join(root, "xx", "LC_MESSAGES")
+ if err := os.MkdirAll(directory, 0700); err != nil {
+ t.Fatal(err)
+ }
+ catalog := `msgid ""
+msgstr ""
+"Content-Type: text/plain; charset=UTF-8\n"
+
+msgid "smile"
+msgstr "translated-smile"
+
+msgid "number"
+msgstr "translated-number"
+
+msgid "{0} {1}"
+msgstr "{1}: {0}"
+`
+ if err := os.WriteFile(filepath.Join(directory, "fenrir.po"), []byte(catalog), 0600); err != nil {
+ t.Fatal(err)
+ }
+ previous := i18n.Use(i18n.New(root, "xx"))
+ t.Cleanup(func() { i18n.Use(previous) })
+ processor, _, err := Load([]string{"../../config/punctuation/default.toml"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ options := TextOptions{Emoticons: true, RepeatedSymbols: true, RepeatedSymbolsMin: 4}
+ if got := processor.Process_text(":) #####", "some", options); got != "translated-smile translated-number: 5" {
+ t.Fatal("translation or positional order lost", got)
+ }
+}
diff --git a/internal/reader/attributes.go b/internal/reader/attributes.go
new file mode 100644
index 00000000..8935ea44
--- /dev/null
+++ b/internal/reader/attributes.go
@@ -0,0 +1,74 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package reader
+
+import (
+ "git.stormux.org/storm/fenrir/internal/i18n"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "git.stormux.org/storm/fenrir/internal/screen"
+ "strings"
+ "time"
+)
+
+func (r *Reader) Configure_attributes(mode, format string) {
+ // Configuration is the saved/quick-menu choice. Reapplying unchanged
+ // settings must not overwrite the shortcut's temporary session state.
+ if mode != r.attributeConfigured {
+ r.attributeConfigured, r.attributeMode = mode, mode
+ if mode != "" && mode != "none" {
+ r.attributeResumeMode = mode
+ }
+ }
+ r.attributeFormat = format
+ r.review.Configure_attributes(format)
+}
+
+func (r *Reader) toggle_attributes() string {
+ if r.attributeMode == "" || r.attributeMode == "none" {
+ r.attributeMode = r.attributeResumeMode
+ if r.attributeMode == "" {
+ r.attributeMode = "beeps"
+ }
+ return i18n.T("announcement of attributes enabled")
+ }
+ r.attributeResumeMode, r.attributeMode = r.attributeMode, "none"
+ return i18n.T("announcement of attributes disabled")
+}
+
+func (r *Reader) attribute_feedback(announce bool) *presentation.Message {
+ attribute, valid := r.review.Attribute()
+ if !valid {
+ r.attributeKnown = false
+ return nil
+ }
+ changed := r.attributeKnown && attribute != r.lastAttribute
+ r.lastAttribute, r.attributeKnown = attribute, true
+ if !announce || !changed || r.attributeMode == "" || r.attributeMode == "none" {
+ return nil
+ }
+ // Presence tones describe emphasis; returning to defaults is described by
+ // speech modes, but must not play a misleading has-attribute cue.
+ if attribute.Default() && r.attributeMode == "beeps" {
+ return nil
+ }
+ message := presentation.Attribute_message(attribute, r.attributeMode, r.attributeFormat)
+ if attribute.Default() {
+ message.Cue = ""
+ }
+ if message.Cue == "" && strings.TrimSpace(message.Text) == "" {
+ return nil
+ }
+ return &message
+}
+
+func (r *Reader) automatic_attributes(previous, next screen.Snapshot, switched bool) *presentation.Message {
+ if switched {
+ r.attributeKnown = false
+ }
+ announce := !switched && next.Active && r.automatic && r.cursorEcho && r.trackingMode == "cursor" && !r.review.Active() && r.region_state().allows(next.X, next.Y, next.Cols, true, true)
+ if announce && (previous.X != next.X || previous.Y != next.Y) {
+ announce = !r.applicationInputAt.IsZero() && time.Since(r.applicationInputAt) < 300*time.Millisecond
+ }
+ return r.attribute_feedback(announce)
+}
diff --git a/internal/reader/attributes_test.go b/internal/reader/attributes_test.go
new file mode 100644
index 00000000..570b00f2
--- /dev/null
+++ b/internal/reader/attributes_test.go
@@ -0,0 +1,213 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package reader
+
+import (
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "git.stormux.org/storm/fenrir/internal/screen"
+ "strings"
+ "testing"
+ "time"
+)
+
+func attribute_fixture() screen.Snapshot {
+ s := fixture("alpha", "bravo", "third")
+ s.Attributes = make([]screen.Attribute, len(s.Cells))
+ for index := range s.Attributes {
+ s.Attributes[index] = screen.Attribute{Foreground: screen.ColorDefault, Background: screen.ColorDefault}
+ }
+ s.Attributes[s.Cols] = screen.Attribute{Foreground: screen.ColorIndexed | 1, Background: screen.ColorIndexed | 4, Style: screen.Bold | screen.Blink}
+ return s
+}
+
+func TestAttributeManualAndReviewOwnership(t *testing.T) {
+ r := New()
+ r.Configure_attributes("both", "fenrirFGColor fenrirBold fenrirBlink")
+ s := attribute_fixture()
+ r.Update(s)
+ if got := r.Command(command.AttributeCursor).Text; got != "default " || r.review.Active() {
+ t.Fatal(got, r.review.Active())
+ }
+ result := r.Command(command.NextLine)
+ if got := result.Attributes; got == nil || got.Cue != presentation.HasAttributes || got.Text != "red bold blink" {
+ t.Fatal(got)
+ }
+ for range 2 {
+ result := r.Command(command.AttributeCursor)
+ if got := result.Text; got != "red bold blink" || result.Attributes != nil {
+ t.Fatal(got, result.Attributes)
+ }
+ }
+ result = r.Command(command.CurrentLine)
+ if result.Attributes != nil {
+ t.Fatal("same position repeated")
+ }
+ s.Y = 2
+ r.Update(s) // Detached review must still describe row 1.
+ if got := r.Command(command.AttributeCursor).Text; got != "red bold blink" {
+ t.Fatal(got)
+ }
+ r.Command(command.FollowCursor)
+ if got := r.Command(command.AttributeCursor).Text; !strings.HasPrefix(got, "default") {
+ t.Fatal(got)
+ }
+ s.Attributes = nil
+ r.Update(s)
+ if got := r.Command(command.AttributeCursor).Text; got != "" {
+ t.Fatal("missing attributes invented", got)
+ }
+}
+
+func TestAutomaticAttributeNavigationStyleOnlyDefaultsAndModes(t *testing.T) {
+ for _, mode := range []string{"none", "speech", "beeps", "both"} {
+ t.Run(mode, func(t *testing.T) {
+ r := New()
+ r.Configure_attributes(mode, "fenrirFGColor fenrirBold fenrirBlink")
+ s := attribute_fixture()
+ r.Update(s)
+ r.Note_input(true, false, false)
+ s.Y = 1
+ output := r.Update(s)
+ feedback := attribute_messages(output)
+ if (len(feedback) == 1) != (mode != "none") {
+ t.Fatal(output)
+ }
+ if len(feedback) > 0 && ((feedback[0].Cue == presentation.HasAttributes) != (mode == "beeps" || mode == "both")) {
+ t.Fatal(feedback)
+ }
+ if got := attribute_messages(r.Update(s)); len(got) != 0 {
+ t.Fatal("repeat", got)
+ }
+ // Same cell/text/cursor, only its style changes, even after input expires.
+ r.applicationInputAt = time.Time{}
+ s.Attributes = append([]screen.Attribute(nil), s.Attributes...)
+ s.Attributes[s.Cols].Style = screen.Underline
+ if got := attribute_messages(r.Update(s)); (len(got) == 1) != (mode != "none") {
+ t.Fatal("style-only", got)
+ }
+ r.Note_input(true, false, false)
+ s.Y = 2
+ got := attribute_messages(r.Update(s))
+ if (len(got) == 1) != (mode == "speech" || mode == "both") {
+ t.Fatal("default", got)
+ }
+ if len(got) > 0 && got[0].Cue != "" {
+ t.Fatal("default presence cue", got)
+ }
+ })
+ }
+}
+
+func attribute_messages(messages []presentation.Message) []presentation.Message {
+ var result []presentation.Message
+ for _, message := range messages {
+ if message.Attributes != nil {
+ result = append(result, message)
+ }
+ }
+ return result
+}
+
+func TestAttributeSessionMissingDataAndSuppression(t *testing.T) {
+ for _, trial := range []string{"session", "inactive", "automatic", "cursor echo", "table", "highlight", "detached", "passive cursor", "missing"} {
+ t.Run(trial, func(t *testing.T) {
+ r := New()
+ r.Configure_attributes("both", "fenrirBold")
+ s := attribute_fixture()
+ r.Update(s)
+ r.Note_input(true, false, false)
+ s.Y = 1
+ switch trial {
+ case "session":
+ s.Generation++
+ case "inactive":
+ s.Active = false
+ case "automatic":
+ r.Configure(false, true)
+ case "cursor echo":
+ r.Configure(true, false)
+ case "table", "highlight":
+ r.Configure_tracking(trial)
+ case "detached":
+ r.Command(command.CurrentLine)
+ case "passive cursor":
+ r.applicationInputAt = time.Time{}
+ case "missing":
+ s.Attributes = nil
+ }
+ if got := attribute_messages(r.Update(s)); len(got) > 0 {
+ t.Fatal(got)
+ }
+ })
+ }
+}
+
+func TestAttributeEmptyFormatDoesNotClaimDefaultHasAttributes(t *testing.T) {
+ r := New()
+ r.Configure_attributes("both", "")
+ s := attribute_fixture()
+ r.Update(s)
+ r.Note_input(true, false, false)
+ s.Y = 1
+ if got := attribute_messages(r.Update(s)); len(got) != 1 || got[0].Text != "has attribute" {
+ t.Fatal(got)
+ }
+ r.Note_input(true, false, false)
+ s.Y = 2
+ if got := attribute_messages(r.Update(s)); len(got) != 0 {
+ t.Fatal(got)
+ }
+}
+
+func TestAttributeSessionToggleResumesModeAndKeepsConfiguredDefault(t *testing.T) {
+ for _, mode := range []string{"none", "speech", "beeps", "both"} {
+ t.Run(mode, func(t *testing.T) {
+ r := New()
+ r.Configure_attributes(mode, "fenrirBold")
+ s := attribute_fixture()
+ r.Update(s)
+ expected := "announcement of attributes disabled"
+ if mode == "none" {
+ expected = "announcement of attributes enabled"
+ }
+ if got := r.Command(command.ToggleAttributes).Text; got != expected {
+ t.Fatal(got)
+ }
+ r.Configure_attributes(mode, "fenrirBold") // Unrelated quick/menu activity.
+ result := r.Command(command.NextLine)
+ message := result.Attributes
+ if (message != nil) != (mode == "none") {
+ t.Fatal(mode, message)
+ }
+ if message != nil && message.Cue != presentation.HasAttributes {
+ t.Fatal(message)
+ }
+ if got := r.Command(command.AttributeCursor).Text; got != "bold" {
+ t.Fatal("manual report", got)
+ }
+ r.Command(command.ToggleAttributes)
+ r.Command(command.PreviousLine)
+ result = r.Command(command.NextLine)
+ message = result.Attributes
+ if (message != nil) != (mode != "none") {
+ t.Fatal("restored", mode, message)
+ }
+ if message != nil && message.Attributes.Speech != (mode != "beeps") {
+ t.Fatal("lost mode", mode, message)
+ }
+ // Changing the actual preference explicitly applies its new mode.
+ nextMode := "speech"
+ if mode == nextMode {
+ nextMode = "both"
+ }
+ r.Configure_attributes(nextMode, "fenrirBold")
+ r.Command(command.PreviousLine)
+ result = r.Command(command.NextLine)
+ if got := result.Attributes; got == nil || got.Text != "bold" || !got.Attributes.Speech {
+ t.Fatal(got)
+ }
+ })
+ }
+}
diff --git a/internal/reader/changes.go b/internal/reader/changes.go
new file mode 100644
index 00000000..d77955c0
--- /dev/null
+++ b/internal/reader/changes.go
@@ -0,0 +1,112 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package reader
+
+import (
+ "slices"
+ "strings"
+ "unicode"
+
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+func changes(previous, next screen.Snapshot, cursorEcho bool) []presentation.Message {
+ // TUI navigation can move over unchanged text while a status line also
+ // changes. That status delta must not hide the newly focused row (mutt).
+ if cursorEcho && next.Y != previous.Y && strings.TrimSpace(next.Text(next.Y)) != "" && slices.Equal(previous.Line(next.Y), next.Line(next.Y)) {
+ return []presentation.Message{presentation.Line_message(next, next.Y)}
+ }
+ if slices.Equal(previous.Cells, next.Cells) {
+ if cursorEcho && next.Y != previous.Y {
+ return []presentation.Message{presentation.Line_message(next, next.Y)}
+ }
+ if cursorEcho && next.X != previous.X {
+ return []presentation.Message{presentation.Character_message(next.Line(next.Y)[next.X])}
+ }
+ return nil
+ }
+ // Match a scroll as one shift so old lines aren't read a second time.
+ // Find the first non-space cell once. Successively shorter overlaps
+ // cannot be meaningful scroll matches once they contain only whitespace.
+ // Avoid converting each candidate overlap into a whole-screen string.
+ firstText := slices.IndexFunc(next.Cells, func(cell rune) bool { return !unicode.IsSpace(cell) })
+ for shift := 1; shift < next.Rows && firstText >= 0 && firstText < (next.Rows-shift)*next.Cols; shift++ {
+ if !slices.Equal(previous.Cells[shift*next.Cols:], next.Cells[:(next.Rows-shift)*next.Cols]) {
+ continue
+ }
+ var result []presentation.Message
+ for row := next.Rows - shift; row < next.Rows; row++ {
+ if text := next.Text(row); strings.TrimSpace(text) != "" {
+ result = append(result, presentation.Message{Text: text})
+ }
+ }
+ return result
+ }
+ var changedRows []int
+ for row := 0; row < next.Rows; row++ {
+ if !slices.Equal(previous.Line(row), next.Line(row)) {
+ changedRows = append(changedRows, row)
+ }
+ }
+ scrollStart, scrollEnd := 0, 0
+ if len(changedRows) > 1 {
+ scrollStart, scrollEnd = screen.Scrolled_overlap(previous, next)
+ kept := changedRows[:0]
+ for _, row := range changedRows {
+ if row < scrollStart || row >= scrollEnd {
+ kept = append(kept, row)
+ }
+ }
+ changedRows = kept
+ }
+ if len(changedRows) == 1 && scrollStart == scrollEnd {
+ row := changedRows[0]
+ before, after := previous.Line(row), next.Line(row)
+ if next.Y == previous.Y && next.Y == row && next.X < previous.X && previous.X-next.X <= 4 && len([]rune(next.Text(row))) < len([]rune(previous.Text(row))) {
+ removed := before[next.X:previous.X]
+ if len(removed) == 1 {
+ return []presentation.Message{presentation.Character_message(removed[0])}
+ }
+ return []presentation.Message{{Text: string(removed)}}
+ }
+ start := 0
+ for start < min(len(before), len(after)) && before[start] == after[start] {
+ start++
+ }
+ endBefore, endAfter := len(before), len(after)
+ for endBefore > start && endAfter > start && before[endBefore-1] == after[endAfter-1] {
+ endBefore--
+ endAfter--
+ }
+ inserted := after[start:endAfter]
+ if len(inserted) != 0 && strings.TrimSpace(string(inserted)) != "" {
+ if len(inserted) == 1 {
+ return []presentation.Message{presentation.Character_message(inserted[0])}
+ }
+ return []presentation.Message{{Text: string(inserted)}}
+ }
+ }
+ var result []presentation.Message
+ for _, row := range changedRows {
+ if text := next.Text(row); strings.TrimSpace(text) != "" {
+ result = append(result, presentation.Message{Text: text})
+ }
+ }
+ return result
+}
diff --git a/internal/reader/command_result_test.go b/internal/reader/command_result_test.go
new file mode 100644
index 00000000..dc58755e
--- /dev/null
+++ b/internal/reader/command_result_test.go
@@ -0,0 +1,97 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package reader
+
+import (
+ "os"
+ "path/filepath"
+ "reflect"
+ "testing"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/i18n"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+func TestFallbackMessagesTranslateWithoutChangingScreenContentOrMetadata(t *testing.T) {
+ if got := presentation.Character('\x7f'); got != "character 127" {
+ t.Fatal("English control-character fallback changed", got)
+ }
+ root := t.TempDir()
+ directory := filepath.Join(root, "xx", "LC_MESSAGES")
+ if err := os.MkdirAll(directory, 0700); err != nil {
+ t.Fatal(err)
+ }
+ data := "msgid \"character {0}\"\nmsgstr \"translated-character {0}\"\n\nmsgid \"no text console\"\nmsgstr \"translated-unavailable\"\n"
+ if err := os.WriteFile(filepath.Join(directory, "fenrir.po"), []byte(data), 0600); err != nil {
+ t.Fatal(err)
+ }
+ previous := i18n.Use(i18n.New(root, "xx"))
+ t.Cleanup(func() { i18n.Use(previous) })
+ r := New()
+ r.Update(fixture("\x7f"))
+ character := r.Command(command.CurrentChar)
+ if character.Text != "translated-character 127" || character.Character == nil || *character.Character != '\x7f' {
+ t.Fatalf("control-character translation lost its value or metadata: %+v", character)
+ }
+ r.Update(fixture("no text console"))
+ if result := r.Command(command.CurrentLine); result.Text != "no text console" {
+ t.Fatal("translated application content", result)
+ }
+ r.Update(screen.Snapshot{})
+ for _, id := range []command.ID{command.CurrentChar, command.SetLiveRegion} {
+ result := r.Command(id)
+ if result.Text != "translated-unavailable" || result.Cue != presentation.Error || result.Character != nil || result.Spelling != nil {
+ t.Fatalf("%s: unavailable feedback changed: %+v", id, result)
+ }
+ }
+}
+
+func TestCommandResultsRetainFeedbackAcrossCommandsAndUpdates(t *testing.T) {
+ r := New()
+ r.Configure_attributes("both", "fenrirBold")
+ r.Configure_indentation("speech")
+ s := fixture("root", " child")
+ s.Attributes = make([]screen.Attribute, len(s.Cells))
+ for index := range s.Attributes {
+ s.Attributes[index] = screen.Attribute{Foreground: screen.ColorDefault, Background: screen.ColorDefault}
+ if index >= s.Cols {
+ s.Attributes[index].Style = screen.Bold
+ }
+ }
+ r.Update(s)
+ line := r.Command(command.NextLine)
+ character := r.Command(command.LastText)
+ spelling := r.Command(command.SpellWord)
+ boundary := r.Command(command.NextLine)
+ if line.Text != " child" || line.Attributes == nil || line.Attributes.Text != "bold" || line.Indentation == nil || line.Indentation.Text != "indented 4 " {
+ t.Fatalf("incomplete line result: %+v", line)
+ }
+ if character.Character == nil || *character.Character != 'd' || character.After.Text != "last character in line" {
+ t.Fatalf("incomplete character result: %+v", character)
+ }
+ if spelling.Spelling == nil || spelling.Spelling.Text != "child" || boundary.Cue != presentation.Bottom {
+ t.Fatalf("spelling=%+v boundary=%+v", spelling, boundary)
+ }
+ for _, id := range []command.ID{command.AutoRead, command.ToggleAttributes, command.ToggleTracking, command.Unbound} {
+ result := r.Command(id)
+ want := presentation.CommandResult{Text: result.Text}
+ if id == command.Unbound {
+ want.Cue = presentation.Error
+ }
+ if !reflect.DeepEqual(result, want) {
+ t.Fatalf("%s retained prior feedback: %+v", id, result)
+ }
+ }
+ r.Update(screen.Snapshot{})
+ inactive := r.Command(command.CurrentChar)
+ if inactive.Text != "no text console" || inactive.Cue != presentation.Error || inactive.Character != nil || inactive.Spelling != nil {
+ t.Fatalf("incomplete inactive result: %+v", inactive)
+ }
+ // Retained pointer metadata belongs to its result, not the next dispatch.
+ if *character.Character != 'd' || character.After.Text != "last character in line" || spelling.Spelling.Text != "child" || line.Attributes.Text != "bold" || line.Indentation.Text != "indented 4 " {
+ t.Fatal("later dispatch or screen update modified a saved result")
+ }
+}
diff --git a/internal/reader/diagnostics.go b/internal/reader/diagnostics.go
new file mode 100644
index 00000000..96af54d0
--- /dev/null
+++ b/internal/reader/diagnostics.go
@@ -0,0 +1,96 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package reader
+
+import (
+ "slices"
+ "unicode/utf8"
+
+ "git.stormux.org/storm/fenrir/internal/diagnostic"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+// AttributeChange contains only geometry and appearance, never cell text.
+// End is exclusive. Keep details bounded even during a whole-screen repaint.
+type AttributeChange struct {
+ Row, Start, End int
+ Before, After screen.Attribute
+}
+
+func (r *Reader) trace_update(previous, next screen.Snapshot, switched bool, pendingInput int, path string, output []presentation.Message) {
+ comparable := previous.Rows == next.Rows && previous.Cols == next.Cols &&
+ next.Rows > 0 && next.Cols > 0 && len(previous.Cells) == next.Rows*next.Cols && len(next.Cells) == len(previous.Cells)
+ previousAttributes := len(previous.Attributes) > 0 && len(previous.Attributes) == len(previous.Cells)
+ nextAttributes := len(next.Attributes) > 0 && len(next.Attributes) == len(next.Cells)
+ textRows, attributeRows := []int{}, []int{}
+ attributeChanges := []AttributeChange{}
+ truncated := false
+ changedCells, changedAttributes := 0, 0
+ if comparable {
+ for row := range next.Rows {
+ if !slices.Equal(previous.Line(row), next.Line(row)) {
+ textRows = append(textRows, row)
+ }
+ rowChanged := false
+ for col := range next.Cols {
+ index := row*next.Cols + col
+ if previous.Cells[index] != next.Cells[index] {
+ changedCells++
+ }
+ if !previousAttributes || !nextAttributes || previous.Attributes[index] == next.Attributes[index] {
+ continue
+ }
+ rowChanged = true
+ changedAttributes++
+ before, after := previous.Attributes[index], next.Attributes[index]
+ last := len(attributeChanges) - 1
+ if last >= 0 && attributeChanges[last].Row == row && attributeChanges[last].End == col &&
+ attributeChanges[last].Before == before && attributeChanges[last].After == after {
+ attributeChanges[last].End++
+ } else if len(attributeChanges) < 32 {
+ attributeChanges = append(attributeChanges, AttributeChange{Row: row, Start: col, End: col + 1, Before: before, After: after})
+ } else {
+ truncated = true
+ }
+ }
+ if rowChanged {
+ attributeRows = append(attributeRows, row)
+ }
+ }
+ }
+ outputLengths := make([]int, len(output))
+ for index, message := range output {
+ outputLengths[index] = utf8.RuneCountInString(message.Text)
+ }
+ diagnostic.Record("reader update",
+ "terminal", next.Terminal, "previous_terminal", previous.Terminal,
+ "generation", next.Generation, "previous_generation", previous.Generation,
+ "active", next.Active, "switched", switched,
+ "size", [2]int{next.Rows, next.Cols}, "previous_size", [2]int{previous.Rows, previous.Cols},
+ "cursor", [2]int{next.X, next.Y}, "previous_cursor", [2]int{previous.X, previous.Y},
+ "mode", r.trackingMode, "automatic", r.automatic, "cursor_echo", r.cursorEcho,
+ "review", r.review.Active(), "pending_typing", pendingInput,
+ "path", path, "comparable", comparable,
+ "text_rows", textRows, "text_cells", changedCells,
+ "previous_attributes", previousAttributes, "attributes", nextAttributes,
+ "attribute_rows", attributeRows, "attribute_cells", changedAttributes,
+ "attribute_changes", attributeChanges,
+ "attribute_changes_truncated", truncated,
+ "messages", len(output), "message_lengths", outputLengths)
+}
diff --git a/internal/reader/diagnostics_test.go b/internal/reader/diagnostics_test.go
new file mode 100644
index 00000000..2f4ac2bd
--- /dev/null
+++ b/internal/reader/diagnostics_test.go
@@ -0,0 +1,156 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package reader
+
+import (
+ "bytes"
+ "encoding/json"
+ "log/slog"
+ "reflect"
+ "strings"
+ "testing"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+func capture_diagnostics(t *testing.T, level slog.Leveler) *bytes.Buffer {
+ t.Helper()
+ var log bytes.Buffer
+ previousLogger := slog.Default()
+ slog.SetDefault(slog.New(slog.NewJSONHandler(&log, &slog.HandlerOptions{Level: level})))
+ t.Cleanup(func() { slog.SetDefault(previousLogger) })
+ return &log
+}
+
+func check_update_record(t *testing.T, log *bytes.Buffer, fields map[string]string) map[string]json.RawMessage {
+ t.Helper()
+ lines := bytes.Split(bytes.TrimSpace(log.Bytes()), []byte("\n"))
+ var record map[string]json.RawMessage
+ if err := json.Unmarshal(lines[len(lines)-1], &record); err != nil {
+ t.Fatal(err, log.String())
+ }
+ fields["msg"] = `"reader update"`
+ for field, want := range fields {
+ if got := string(record[field]); got != want {
+ t.Errorf("%s=%s, want %s", field, got, want)
+ }
+ }
+ if strings.Contains(log.String(), "PRIVATE") {
+ t.Fatal("diagnostics exposed screen or output text")
+ }
+ return record
+}
+
+func TestReaderDiagnosticsCursorAndStatusWithoutText(t *testing.T) {
+ var level slog.LevelVar
+ level.Set(slog.LevelDebug)
+ log := capture_diagnostics(t, &level)
+ r := New()
+ r.Update(fixture("PRIVATE first", "PRIVATE second", "1/2 PRIVATE"))
+ r.Command(command.CurrentLine)
+ r.Command(command.NextLine)
+ log.Reset()
+ next := fixture("PRIVATE first", "PRIVATE second", "2/2 PRIVATE")
+ next.Y = 1
+ r.Note_input(true, false, false)
+ if got := r.Update(next); !reflect.DeepEqual(got, []presentation.Message{{Text: "PRIVATE second"}}) {
+ t.Fatal("diagnostics changed cursor feedback", got)
+ }
+ check_update_record(t, log, map[string]string{
+ "cursor": "[0,1]", "previous_cursor": "[0,0]", "mode": `"cursor"`,
+ "text_rows": "[2]", "text_cells": "1", "messages": "1", "message_lengths": "[14]",
+ "path": `"screen-changes"`, "review": "false", "cursor_echo": "true",
+ })
+ log.Reset()
+ level.Set(slog.LevelInfo)
+ r.Update(next)
+ if log.Len() != 0 {
+ t.Fatal("reader diagnostics were not opt-in", log.String())
+ }
+}
+
+func TestReaderDiagnosticsStationaryCursorAndSuppression(t *testing.T) {
+ for _, testCase := range []struct {
+ name, mode, path string
+ review, automatic bool
+ messages string
+ }{
+ {"cursor", "cursor", "screen-changes", false, true, "0"},
+ {"highlight", "highlight", "highlight", false, true, "1"},
+ {"review", "cursor", "screen-changes", true, true, "0"},
+ {"automatic off", "cursor", "automatic-disabled", false, false, "0"},
+ } {
+ t.Run(testCase.name, func(t *testing.T) {
+ log := capture_diagnostics(t, slog.LevelDebug)
+ before := fixture("PRIVATE first", "PRIVATE second", "third", "status")
+ before.Attributes = make([]screen.Attribute, len(before.Cells))
+ next := before
+ next.Attributes = make([]screen.Attribute, len(before.Cells))
+ selected := screen.Attribute{Foreground: 7, Background: 4}
+ for col := range before.Cols {
+ before.Attributes[col] = selected
+ next.Attributes[next.Cols+col] = selected
+ }
+ r := New()
+ r.Configure(testCase.automatic, true)
+ r.Configure_tracking(testCase.mode)
+ r.Update(before)
+ if testCase.review {
+ r.Command(command.CurrentLine)
+ }
+ log.Reset()
+ r.Update(next)
+ check_update_record(t, log, map[string]string{
+ "cursor": "[0,0]", "previous_cursor": "[0,0]", "text_rows": "[]",
+ "attribute_rows": "[0,1]", "attribute_cells": "40", "attributes": "true",
+ "path": `"` + testCase.path + `"`, "messages": testCase.messages,
+ "review": map[bool]string{true: "true", false: "false"}[testCase.review],
+ })
+ })
+ }
+}
+
+func TestReaderDiagnosticsBoundAttributeDetailsAndHandleSessionChanges(t *testing.T) {
+ log := capture_diagnostics(t, slog.LevelDebug)
+ r := New()
+ r.Update(screen.Snapshot{}) // Inactive captures may have no geometry or cells.
+ check_update_record(t, log, map[string]string{"path": `"inactive"`, "comparable": "false"})
+ before := fixture("PRIVATE", "PRIVATE", "PRIVATE", "PRIVATE")
+ before.Attributes = make([]screen.Attribute, len(before.Cells))
+ r.Update(before)
+ check_update_record(t, log, map[string]string{"path": `"session-change"`, "comparable": "false"})
+ next := before
+ next.Attributes = make([]screen.Attribute, len(before.Cells))
+ for index := range next.Attributes {
+ next.Attributes[index].Background = uint32(index%2 + 1)
+ }
+ log.Reset()
+ r.Update(next)
+ record := check_update_record(t, log, map[string]string{
+ "attribute_changes_truncated": "true", "attribute_cells": "80", "attribute_rows": "[0,1,2,3]",
+ })
+ var changes []AttributeChange
+ if err := json.Unmarshal(record["attribute_changes"], &changes); err != nil {
+ t.Fatal(err)
+ }
+ if len(changes) != 32 || changes[0].Row != 0 || changes[0].Start != 0 || changes[0].End != 1 || changes[0].After.Background != 1 {
+ t.Fatal("attribute details lost geometry/appearance or exceeded their bound", changes)
+ }
+}
diff --git a/internal/reader/echo.go b/internal/reader/echo.go
new file mode 100644
index 00000000..652da326
--- /dev/null
+++ b/internal/reader/echo.go
@@ -0,0 +1,140 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package reader
+
+import (
+ "slices"
+ "time"
+ "unicode"
+
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+func (r *Reader) Configure_echo(characterEcho, wordEcho, echoOnCapsLock bool) {
+ r.characterEcho, r.wordEcho, r.echoOnCapsLock = characterEcho, wordEcho, echoOnCapsLock
+}
+
+const echoWindow = 500 * time.Millisecond
+
+// Note_input associates direct application input with cursor movement and echo.
+// It never speaks raw keys: password fields without screen echo stay silent.
+func (r *Reader) Note_input(forwarded, typing, wordBoundary bool) {
+ r.progressDeletionAt = time.Time{}
+ r.applicationInputAt = time.Time{}
+ if forwarded {
+ r.applicationInputAt = time.Now()
+ }
+ if !forwarded || !typing {
+ r.inputCount = 0
+ r.inputBoundary = false
+ return
+ }
+ if time.Since(r.inputAt) > echoWindow {
+ r.inputCount = 0
+ r.inputBoundary = false
+ }
+ r.inputCount = min(r.inputCount+1, len(r.screen.Cells))
+ r.inputBoundary = r.inputBoundary || wordBoundary
+ r.inputAt = time.Now()
+}
+
+// typing_echo handles a cursor-anchored edit following text input. Unrelated
+// output still goes through changes, even with all typing echo disabled.
+func (r *Reader) typing_echo(previous, next screen.Snapshot) ([]presentation.Message, bool) {
+ if r.inputCount == 0 {
+ return nil, false
+ }
+ count := r.inputCount
+ if time.Since(r.inputAt) > echoWindow {
+ r.inputCount = 0
+ r.inputBoundary = false
+ return nil, false
+ }
+ if previous.X == next.X && previous.Y == next.Y && slices.Equal(previous.Cells, next.Cells) {
+ return nil, false // A readiness event may precede the application's echo.
+ }
+ r.inputCount = 0
+ boundary := r.inputBoundary
+ r.inputBoundary = false
+ before := previous.Cells
+ start := previous.Y*previous.Cols + previous.X
+ end := next.Y*next.Cols + next.X
+ // Align one screen scroll caused by typing across the bottom margin.
+ if end < start && previous.Y == previous.Rows-1 && next.Y == next.Rows-1 {
+ before = make([]rune, len(previous.Cells))
+ copy(before, previous.Cells[previous.Cols:])
+ for i := len(before) - previous.Cols; i < len(before); i++ {
+ before[i] = ' '
+ }
+ start -= previous.Cols
+ }
+ // Some terminals keep the cursor on the last column until the next key
+ // wraps it. Account for the already-written cell, or the newly-filled one.
+ if previous.X == previous.Cols-1 && end-start > count && start >= 0 && before[start] == next.Cells[start] {
+ start++
+ }
+ if next.X == next.Cols-1 && end < len(before) && before[end] != next.Cells[end] {
+ end++
+ }
+ if start < 0 || end <= start || end-start > count*2 || end > len(before) {
+ return nil, false
+ }
+ if !slices.Equal(before[:start], next.Cells[:start]) {
+ return nil, false
+ }
+ if !slices.Equal(before[end:], next.Cells[end:]) {
+ // Inserting in the middle of a line shifts the old suffix right.
+ rowEnd := (previous.Y + 1) * previous.Cols
+ advance := end - start
+ if next.Y != previous.Y || end > rowEnd || !slices.Equal(before[start:rowEnd-advance], next.Cells[end:rowEnd]) ||
+ !slices.Equal(before[rowEnd:], next.Cells[rowEnd:]) ||
+ !all_spaces(before[rowEnd-advance:rowEnd]) {
+ return nil, false
+ }
+ }
+ var output []presentation.Message
+ for index := start; index < end; index++ {
+ if !r.region_state().allows(index%next.Cols, index/next.Cols, next.Cols, false, true) {
+ continue
+ }
+ r.completed_word(index, next)
+ char := next.Cells[index]
+ if r.wordEcho && boundary && unicode.IsSpace(char) {
+ wordStart := index
+ for wordStart > 0 && !unicode.IsSpace(next.Cells[wordStart-1]) {
+ wordStart--
+ }
+ if wordStart < index {
+ output = append(output, presentation.Message{Text: string(next.Cells[wordStart:index])})
+ }
+ } else if !unicode.IsSpace(char) && (r.characterEcho || r.echoOnCapsLock && next.CapsLock) {
+ output = append(output, presentation.Character_message(char))
+ }
+ }
+ return output, true
+}
+
+func all_spaces(cells []rune) bool {
+ for _, char := range cells {
+ if !unicode.IsSpace(char) {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/reader/echo_test.go b/internal/reader/echo_test.go
new file mode 100644
index 00000000..ee9748bb
--- /dev/null
+++ b/internal/reader/echo_test.go
@@ -0,0 +1,159 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package reader
+
+import (
+ "reflect"
+ "testing"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+)
+
+func TestTypingEchoBooleanCombinations(t *testing.T) {
+ for _, character := range []bool{false, true} {
+ for _, word := range []bool{false, true} {
+ for _, capsOnly := range []bool{false, true} {
+ for _, capsLock := range []bool{false, true} {
+ r := New()
+ r.Configure_echo(character, word, capsOnly)
+ before := fixture("$ ")
+ before.X, before.CapsLock = 2, capsLock
+ r.Update(before)
+ r.Note_input(true, true, false)
+ after := fixture("$ a")
+ after.X, after.CapsLock = 3, capsLock
+ var want []presentation.Message
+ if character || capsOnly && capsLock {
+ want = []presentation.Message{presentation.Character_message('a')}
+ }
+ if got := r.Update(after); !reflect.DeepEqual(got, want) {
+ t.Fatal("character policy", character, word, capsOnly, capsLock, got, want)
+ }
+ r.Note_input(true, true, true)
+ after.X = 4 // Space advances over an already-blank cell.
+ want = nil
+ if word {
+ want = []presentation.Message{{Text: "a"}}
+ }
+ if got := r.Update(after); !reflect.DeepEqual(got, want) {
+ t.Fatal("word policy", character, word, capsOnly, capsLock, got, want)
+ }
+ }
+ }
+ }
+ }
+}
+
+func TestEchoSeparatesTypingNavigationDeletionAndIncomingOutput(t *testing.T) {
+ r := New()
+ r.Configure_echo(false, false, false)
+ before := fixture("$ a", "output")
+ before.X = 3
+ r.Update(before)
+ r.Note_input(true, false, false)
+ after := fixture("$ ", "output")
+ after.X = 2
+ if got := r.Update(after); !reflect.DeepEqual(got, []presentation.Message{presentation.Character_message('a')}) {
+ t.Fatal("typing settings disabled deletion feedback", got)
+ }
+ after.X = 1
+ if got := r.Update(after); !reflect.DeepEqual(got, []presentation.Message{presentation.Character_message(' ')}) {
+ t.Fatal("typing settings disabled cursor navigation", got)
+ }
+ r.Configure(true, false)
+ after.Y = 1
+ if got := r.Update(after); len(got) != 0 {
+ t.Fatal("cursor echo setting ignored", got)
+ }
+ changed := fixture("$ ", "output!")
+ changed.Y, changed.X = 1, 7
+ if got := r.Update(changed); !reflect.DeepEqual(got, []presentation.Message{presentation.Character_message('!')}) {
+ t.Fatal("incoming output was suppressed as typing", got)
+ }
+ r.Note_input(true, true, false)
+ if got := r.Update(changed); len(got) != 0 {
+ t.Fatal("un-echoed input spoke raw keys", got)
+ }
+ r.inputAt = time.Now().Add(-time.Second)
+ changed = fixture("$ ", "output!x")
+ changed.Y, changed.X = 1, 8
+ if got := r.Update(changed); !reflect.DeepEqual(got, []presentation.Message{presentation.Character_message('x')}) {
+ t.Fatal("stale input suppressed later output", got)
+ }
+}
+
+func TestWordEchoBatchUnicodeMidlineAndSpaceNavigation(t *testing.T) {
+ for _, character := range []bool{false, true} {
+ r := New()
+ r.Configure_echo(character, true, false)
+ before := fixture("$ ")
+ before.X = 2
+ r.Update(before)
+ for _, char := range "cafƩ two " {
+ r.Note_input(true, true, char == ' ')
+ }
+ after := fixture("$ cafƩ two ")
+ after.X = 11
+ want := []presentation.Message{{Text: "cafƩ"}, {Text: "two"}}
+ if character {
+ want = []presentation.Message{presentation.Character_message('c'), presentation.Character_message('a'), presentation.Character_message('f'), presentation.Character_message('Ʃ'), {Text: "cafƩ"}, presentation.Character_message('t'), presentation.Character_message('w'), presentation.Character_message('o'), {Text: "two"}}
+ }
+ if got := r.Update(after); !reflect.DeepEqual(got, want) {
+ t.Fatal("batched typing", character, got, want)
+ }
+ r.Note_input(true, false, false)
+ after.X = 12
+ if got := r.Update(after); !reflect.DeepEqual(got, []presentation.Message{presentation.Character_message(' ')}) {
+ t.Fatal("navigation triggered word echo", got)
+ }
+ }
+ r := New()
+ r.Configure_echo(false, true, false)
+ before := fixture("$ one two")
+ before.X = 4
+ r.Update(before)
+ r.Note_input(true, true, false)
+ after := fixture("$ onXe two")
+ after.X = 5
+ if got := r.Update(after); len(got) != 0 {
+ t.Fatal("midline insertion escaped disabled character echo", got)
+ }
+ r.Note_input(true, true, true)
+ after = fixture("$ onX e two")
+ after.X = 6
+ if got := r.Update(after); !reflect.DeepEqual(got, []presentation.Message{{Text: "onX"}}) {
+ t.Fatal("midline word completion", got)
+ }
+}
+
+func TestTypingEchoIndependentOfAutomaticReadingAndReview(t *testing.T) {
+ r := New()
+ r.Configure(false, false)
+ before := fixture("$ ")
+ before.X = 2
+ r.Update(before)
+ r.Command(command.CurrentLine)
+ r.Note_input(true, true, false)
+ after := fixture("$ a")
+ after.X = 3
+ if got := r.Update(after); !reflect.DeepEqual(got, []presentation.Message{presentation.Character_message('a')}) {
+ t.Fatal("automatic reading or review disabled typing echo", got)
+ }
+}
diff --git a/internal/reader/highlights.go b/internal/reader/highlights.go
new file mode 100644
index 00000000..30a1a415
--- /dev/null
+++ b/internal/reader/highlights.go
@@ -0,0 +1,62 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package reader
+
+import (
+ "strings"
+
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+// Track newly emphasized spans, excluding the formerly selected row returning
+// to its common appearance. No attributes means no highlight evidence.
+func highlights(previous, next screen.Snapshot) []presentation.Message {
+ if len(previous.Attributes) != len(next.Cells) || len(next.Attributes) != len(next.Cells) {
+ return nil
+ }
+ counts := make(map[screen.Attribute]int)
+ for _, attribute := range next.Attributes {
+ counts[attribute]++
+ }
+ var result []presentation.Message
+ for row := range next.Rows {
+ start := -1
+ flush := func(end int) {
+ if start >= 0 {
+ if text := strings.TrimSpace(string(next.Line(row)[start:end])); text != "" {
+ result = append(result, presentation.Message{Text: text})
+ }
+ start = -1
+ }
+ }
+ for col := range next.Cols {
+ index := row*next.Cols + col
+ before, after := previous.Attributes[index], next.Attributes[index]
+ if before != after && counts[after] < counts[before] {
+ if start < 0 {
+ start = col
+ }
+ } else {
+ flush(col)
+ }
+ }
+ flush(next.Cols)
+ }
+ return result
+}
diff --git a/internal/reader/indentation.go b/internal/reader/indentation.go
new file mode 100644
index 00000000..6c7e2197
--- /dev/null
+++ b/internal/reader/indentation.go
@@ -0,0 +1,64 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package reader
+
+import (
+ "slices"
+ "strings"
+ "time"
+ "unicode"
+
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+func (r *Reader) Configure_indentation(mode string) { r.indentationMode = mode }
+
+func (r *Reader) indentation_feedback(announce bool) *presentation.Message {
+ level, valid := r.review.Indentation()
+ if !valid {
+ return nil // Blank rows preserve the last meaningful level.
+ }
+ changed := r.indentKnown && level != r.lastIndent
+ r.lastIndent, r.indentKnown = level, true
+ if !announce || !changed || r.indentationMode == "" || r.indentationMode == "none" {
+ return nil
+ }
+ message := presentation.Indentation_message(level, r.indentationMode)
+ return &message
+}
+
+func (r *Reader) automatic_indentation(previous, next screen.Snapshot, switched bool) *presentation.Message {
+ if switched {
+ r.indentKnown = false
+ }
+ // Passive program output must not create indentation chatter. Match Python's
+ // cursor-input window and suppress automatic cursor feedback during review.
+ announce := !switched && next.Active && r.automatic && r.cursorEcho && r.trackingMode == "cursor" && !r.review.Active() &&
+ r.region_state().allows(next.X, next.Y, next.Cols, true, true) &&
+ !r.applicationInputAt.IsZero() && time.Since(r.applicationInputAt) < 300*time.Millisecond
+ if announce {
+ // A hidden leading-whitespace edit must not produce indentation cues
+ // at an otherwise visible cursor farther along the same row.
+ for col, char := range next.Line(next.Y) {
+ if !r.region_state().allows(col, next.Y, next.Cols, true, true) {
+ announce = false
+ break
+ }
+ if !unicode.IsSpace(char) {
+ break
+ }
+ }
+ }
+ if announce && previous.Y == next.Y {
+ // Horizontal input only reports a genuine leading-whitespace edit, not
+ // movement within a line or ordinary typing/deletion in its content.
+ before, after := previous.Text(previous.Y), next.Text(next.Y)
+ announce = strings.TrimSpace(before) == strings.TrimSpace(after) && before != after
+ } else if announce {
+ // A cursor moving with new program output is not line navigation.
+ announce = slices.Equal(previous.Line(next.Y), next.Line(next.Y))
+ }
+ return r.indentation_feedback(announce)
+}
diff --git a/internal/reader/indentation_test.go b/internal/reader/indentation_test.go
new file mode 100644
index 00000000..e9415601
--- /dev/null
+++ b/internal/reader/indentation_test.go
@@ -0,0 +1,132 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package reader
+
+import (
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "testing"
+)
+
+func TestIndentationNavigationModesBlankRowsAndRepeatedLevels(t *testing.T) {
+ for _, mode := range []string{"none", "speech", "beeps", "both"} {
+ t.Run(mode, func(t *testing.T) {
+ r := New()
+ r.Configure_indentation(mode)
+ s := fixture("root", " first", " second", "", " third", "root again")
+ r.Update(s)
+ for _, row := range []int{1, 2, 3, 4, 5} {
+ r.Note_input(true, false, false)
+ s.Y = row
+ output := r.Update(s)
+ wantIndent := mode != "none" && (row == 1 || row == 5)
+ if (len(output) == 2) != wantIndent {
+ t.Fatalf("row %d: %+v", row, output)
+ }
+ if wantIndent {
+ level := 4
+ if row == 5 {
+ level = 0
+ }
+ if output[0].Text != presentation.Indentation_message(level, mode).Text ||
+ (output[0].Indentation != nil) != (mode == "beeps" || mode == "both") {
+ t.Fatal(output[0])
+ }
+ if output[0].Indentation != nil && output[0].Indentation.Speech != (mode == "both") {
+ t.Fatal(output[0])
+ }
+ }
+ if got := r.Update(s); len(got) != 0 {
+ t.Fatal("stationary update repeated feedback", got)
+ }
+ }
+ })
+ }
+}
+
+func TestIndentationEditsIncludeDedentToZeroWithoutOrdinaryTypingChatter(t *testing.T) {
+ r := New()
+ r.Configure_indentation("speech")
+ s := fixture("value")
+ r.Update(s)
+ for _, trial := range []struct {
+ text string
+ level int
+ }{{" value", 4}, {" value", 2}, {"value", 0}} {
+ s = fixture(trial.text)
+ s.X = trial.level
+ r.Note_input(true, false, false)
+ output := r.Update(s)
+ if len(output) == 0 || output[0].Text != presentation.Indentation_message(trial.level, "speech").Text {
+ t.Fatal(output)
+ }
+ }
+ r.Note_input(true, true, false)
+ s = fixture("Zvalue")
+ s.X = 1
+ for _, message := range r.Update(s) {
+ if message.Text == "indented 0 " {
+ t.Fatal("typing repeated indentation", message)
+ }
+ }
+}
+
+func TestIndentationDoesNotFollowPassiveOutputDisabledReadingOrOtherTracking(t *testing.T) {
+ for _, trial := range []string{"passive", "automatic off", "cursor off", "table", "highlight", "session", "output", "detached"} {
+ t.Run(trial, func(t *testing.T) {
+ r := New()
+ r.Configure_indentation("both")
+ s := fixture("root", " child")
+ r.Update(s)
+ r.Note_input(true, false, false)
+ s.Y = 1
+ switch trial {
+ case "passive":
+ r.Note_input(false, false, false)
+ case "automatic off":
+ r.Configure(false, true)
+ case "cursor off":
+ r.Configure(true, false)
+ case "table", "highlight":
+ r.Configure_tracking(trial)
+ case "session":
+ s.Generation++
+ case "output":
+ s = fixture("root", " new output")
+ s.Y = 1
+ case "detached":
+ r.Command(command.CurrentLine)
+ r.Note_input(false, false, false)
+ }
+ for _, message := range r.Update(s) {
+ if message.Indentation != nil {
+ t.Fatal(message)
+ }
+ }
+ })
+ }
+}
+
+func TestReviewIndentationWithAutomaticReadingOffAndApplicationCursorElsewhere(t *testing.T) {
+ r := New()
+ r.Configure_indentation("speech")
+ r.Configure(false, false)
+ s := fixture("root", " child", "", " peer", "root again")
+ r.Update(s)
+ for index, want := range []string{"indented 4 ", "", "", "indented 0 "} {
+ result := r.Command(command.NextLine)
+ got := ""
+ if message := result.Indentation; message != nil {
+ got = message.Text
+ }
+ if got != want {
+ t.Fatal(index, got, want)
+ }
+ r.Update(s) // Passive application state must not reset review's baseline.
+ }
+ result := r.Command(command.CurrentLine)
+ if result.Indentation != nil {
+ t.Fatal("same-line review repeated indentation")
+ }
+}
diff --git a/internal/reader/latency_test.go b/internal/reader/latency_test.go
new file mode 100644
index 00000000..6a8f49d4
--- /dev/null
+++ b/internal/reader/latency_test.go
@@ -0,0 +1,53 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package reader
+
+import (
+ "testing"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+func BenchmarkTypingAndReview(b *testing.B) {
+ for _, geometry := range []struct {
+ name string
+ rows, cols int
+ }{
+ {"80x25", 25, 80}, {"240x67", 67, 240},
+ } {
+ b.Run(geometry.name, func(b *testing.B) {
+ first := screen.Snapshot{Terminal: 1, Active: true, Rows: geometry.rows, Cols: geometry.cols, Cells: make([]rune, geometry.rows*geometry.cols)}
+ for i := range first.Cells {
+ first.Cells[i] = ' '
+ }
+ second := first
+ second.Cells = append([]rune(nil), first.Cells...)
+ second.Cells[0], second.X = 'a', 1
+ r := New()
+ r.Update(first)
+ b.ReportAllocs()
+ b.ResetTimer()
+ for b.Loop() {
+ r.Update(second)
+ r.Command(command.CurrentChar)
+ r.Update(first)
+ }
+ })
+ }
+}
diff --git a/internal/reader/progress.go b/internal/reader/progress.go
new file mode 100644
index 00000000..f37ab480
--- /dev/null
+++ b/internal/reader/progress.go
@@ -0,0 +1,68 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package reader
+
+import (
+ "slices"
+ "time"
+ "unicode"
+
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+// Progress is independent of automatic reading and speech mute.
+func (r *Reader) Configure_progress(enabled bool) {
+ if r.progressEnabled != enabled {
+ r.progress.Reset()
+ }
+ r.progressEnabled = enabled
+}
+func (r *Reader) Next_progress(now time.Time) (float64, bool) { return r.progress.Next(now) }
+func (r *Reader) Reset_progress() { r.progress.Reset() }
+
+func (r *Reader) Progress_active() bool { return r.progress.Active() }
+
+func (r *Reader) Note_deletion() { r.progressDeletionAt = time.Now() }
+
+// A recent deletion key alone is insufficient: match the actual cursor-row
+// deletion so background progress, including another row in the same snapshot,
+// remains available to the detector. Automatic deletion speech is unchanged.
+func (r *Reader) progress_deletion_row(previous, next screen.Snapshot) int {
+ if r.progressDeletionAt.IsZero() || time.Since(r.progressDeletionAt) > echoWindow || previous.Y != next.Y || next.X > previous.X {
+ return -1
+ }
+ before, after := previous.Line(next.Y), next.Line(next.Y)
+ count := previous.X - next.X
+ if count == 0 {
+ // Forward Delete leaves the cursor in place but shortens the line.
+ beforeEnd, afterEnd := len(before), len(after)
+ for beforeEnd > 0 && unicode.IsSpace(before[beforeEnd-1]) {
+ beforeEnd--
+ }
+ for afterEnd > 0 && unicode.IsSpace(after[afterEnd-1]) {
+ afterEnd--
+ }
+ count = beforeEnd - afterEnd
+ }
+ if count <= 0 || next.X+count > next.Cols || !slices.Equal(before[:next.X], after[:next.X]) ||
+ !slices.Equal(before[next.X+count:], after[next.X:next.Cols-count]) || !all_spaces(after[next.Cols-count:]) {
+ return -1
+ }
+ r.progressDeletionAt = time.Time{}
+ return next.Y
+}
diff --git a/internal/reader/progress_test.go b/internal/reader/progress_test.go
new file mode 100644
index 00000000..c9dc010e
--- /dev/null
+++ b/internal/reader/progress_test.go
@@ -0,0 +1,119 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package reader
+
+import (
+ "reflect"
+ "testing"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/presentation"
+)
+
+func TestProgressIndependentOfAutomaticReadingAndTypingEcho(t *testing.T) {
+ r := New()
+ r.Configure_progress(true)
+ r.Configure(false, false)
+ before, after := fixture("Download 20%", ""), fixture("Download 30%", "")
+ r.Update(before)
+ if _, ok := r.Next_progress(time.Now()); ok {
+ t.Fatal("initial screen")
+ }
+ if output := r.Update(after); len(output) != 0 {
+ t.Fatal("enabled autoread")
+ }
+ if value, ok := r.Next_progress(time.Now()); !ok || value != 30 {
+ t.Fatal(value, ok)
+ }
+ r.Configure_progress(false)
+ r.Update(fixture("Download 40%", ""))
+ if _, ok := r.Next_progress(time.Now().Add(time.Second)); ok {
+ t.Fatal("disabled progress")
+ }
+ r.Configure_progress(true)
+ // Typing a percentage must stay silent even when key echo is disabled.
+ r.Configure_echo(false, false, false)
+ before, after = fixture("50", ""), fixture("50%", "")
+ before.X, after.X = 2, 3
+ r.Update(before)
+ r.Note_input(true, true, false)
+ r.Update(after)
+ if _, ok := r.Next_progress(time.Now().Add(2 * time.Second)); ok || r.Progress_active() {
+ t.Fatal("typing recognized as progress")
+ }
+}
+
+func TestProgressIgnoresDeletionButKeepsOtherRows(t *testing.T) {
+ for _, mode := range []string{"backspace", "delete", "word deletion", "background together", "background before edit", "passive change", "expired"} {
+ t.Run(mode, func(t *testing.T) {
+ r := New()
+ r.Configure_progress(true)
+ r.Configure_echo(false, false, false)
+ before, after := fixture("type 2% abc", "Download 20%"), fixture("type 2% ab", "Download 20%")
+ before.X, after.X = 11, 10
+ if mode == "delete" {
+ before.X, after.X = 10, 10
+ }
+ if mode == "word deletion" {
+ after = fixture("type 2% ", "Download 20%")
+ after.X = 8
+ }
+ if mode == "background together" {
+ after = fixture("type 2% ab", "Download 30%")
+ after.X = 10
+ }
+ r.Update(before)
+ if mode != "passive change" {
+ r.Note_input(true, false, false)
+ r.Note_deletion()
+ if mode == "expired" {
+ r.progressDeletionAt = time.Now().Add(-time.Second)
+ }
+ }
+ if mode == "background before edit" {
+ update := fixture("type 2% abc", "Download 30%")
+ update.X = 11
+ r.Update(update)
+ if value, ok := r.Next_progress(time.Now()); !ok || value != 30 {
+ t.Fatal("lost background output", value, ok)
+ }
+ after = fixture("type 2% ab", "Download 30%")
+ after.X = 10
+ }
+ output := r.Update(after)
+ value, ok := r.Next_progress(time.Now().Add(time.Second))
+ switch mode {
+ case "background together":
+ if !ok || value != 30 {
+ t.Fatal("concurrent progress lost", value, ok)
+ }
+ case "passive change", "expired":
+ if !ok || value != 2 {
+ t.Fatal("passive output suppressed", value, ok)
+ }
+ default:
+ if ok {
+ t.Fatal("deletion produced progress", value)
+ }
+ }
+ if mode == "backspace" && !reflect.DeepEqual(output, []presentation.Message{presentation.Character_message('c')}) {
+ t.Fatal("lost deletion speech", output)
+ }
+ })
+ }
+}
diff --git a/internal/reader/reader.go b/internal/reader/reader.go
new file mode 100644
index 00000000..2335b948
--- /dev/null
+++ b/internal/reader/reader.go
@@ -0,0 +1,208 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+// Package reader produces automatic output and dispatches review commands.
+package reader
+
+import (
+ "strconv"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/diagnostic"
+ "git.stormux.org/storm/fenrir/internal/i18n"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "git.stormux.org/storm/fenrir/internal/progress"
+ "git.stormux.org/storm/fenrir/internal/review"
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+type Reader struct {
+ progress progress.Monitor
+ progressEnabled bool
+ progressDeletionAt time.Time
+ screen screen.Snapshot
+ regions map[int]*terminalRegions
+ regionRevision uint64
+ review review.Review
+ automatic bool
+ cursorEcho bool
+ trackingMode string
+ keepReviewOnCursorChange bool
+ characterEcho, wordEcho, echoOnCapsLock bool
+ inputCount int
+ inputBoundary bool
+ completedWords []string
+ lastCompletedWord string
+ inputAt time.Time
+ applicationInputAt time.Time
+ attributeConfigured, attributeResumeMode string
+ attributeMode, attributeFormat string
+ lastAttribute screen.Attribute
+ attributeKnown bool
+ indentationMode string
+ lastIndent int
+ indentKnown bool
+}
+
+func New() *Reader {
+ r := &Reader{automatic: true, cursorEcho: true, characterEcho: true, trackingMode: "cursor"}
+ r.Configure_attributes("none", presentation.Default_attribute_format)
+ return r
+}
+
+func (r *Reader) Configure(automatic, cursorEcho bool) {
+ r.automatic, r.cursorEcho = automatic, cursorEcho
+}
+
+func (r *Reader) Configure_tracking(mode string) {
+ r.trackingMode = mode
+ r.review.Set_table_mode(mode == "table")
+}
+
+func (r *Reader) Tracking_mode() string { return r.trackingMode }
+func (r *Reader) Automatic_reading() bool { return r.automatic }
+
+func (r *Reader) Configure_review(lineBreak, endOfScreen, leaveOnCursorChange, leaveOnScreenChange bool) {
+ r.review.Configure(lineBreak, endOfScreen, leaveOnScreenChange)
+ r.keepReviewOnCursorChange = !leaveOnCursorChange
+}
+func (r *Reader) Set_mark() presentation.Message { return r.review.Set_mark() }
+func (r *Reader) Clear_marks() { r.review.Clear_marks() }
+func (r *Reader) Marked_text(copying bool) (string, bool) { return r.review.Marked_text(copying) }
+
+func (r *Reader) Update(next screen.Snapshot) (output []presentation.Message) {
+ r.completedWords = nil
+ previous := r.screen
+ // Python runs cursor-change commands only after recent direct application
+ // input (300 ms). Reader commands and passive output must preserve review.
+ if !r.keepReviewOnCursorChange && r.review.Active() && !r.applicationInputAt.IsZero() && time.Since(r.applicationInputAt) < 300*time.Millisecond &&
+ previous.Active && next.Active && previous.Terminal == next.Terminal && previous.Generation == next.Generation &&
+ previous.Rows == next.Rows && previous.Cols == next.Cols && (previous.X != next.X || previous.Y != next.Y) {
+ r.review.Resume_tracking()
+ }
+ switched := r.review.Update(next)
+ r.screen = next
+ path := "screen-changes"
+ if diagnostic.Enabled() {
+ pendingInput := r.inputCount
+ defer func() { r.trace_update(previous, next, switched, pendingInput, path, output) }()
+ }
+ defer func() {
+ if message := r.automatic_attributes(previous, next, switched); message != nil {
+ output = append([]presentation.Message{*message}, output...)
+ }
+ if message := r.automatic_indentation(previous, next, switched); message != nil {
+ output = append([]presentation.Message{*message}, output...)
+ }
+ }()
+ if switched || !next.Active {
+ r.lastCompletedWord = ""
+ r.inputCount = 0
+ r.inputBoundary = false
+ r.applicationInputAt = time.Time{}
+ r.progressDeletionAt = time.Time{}
+ }
+ if !next.Active {
+ r.progress.Reset()
+ path = "inactive"
+ return nil
+ }
+ if switched {
+ r.progress.Reset()
+ path = "session-change"
+ if !r.automatic {
+ path = "automatic-disabled"
+ return nil
+ }
+ output := []presentation.Message{{Text: i18n.Format("screen {0}", strconv.Itoa(next.Terminal)), Cue: presentation.TTYChanged}}
+ if r.region_state().allows(next.X, next.Y, next.Cols, true, true) {
+ output = append(output, presentation.Line_message(r.Automatic_snapshot(), next.Y))
+ }
+ return output
+ }
+ regions := r.region_state()
+ typingPrevious := regions.filtered_snapshot(previous, false, true)
+ typingNext := regions.filtered_snapshot(next, false, true)
+ if echo, matched := r.typing_echo(typingPrevious, typingNext); matched {
+ r.progress.Reset()
+ if r.progressEnabled && regions.has_live() {
+ // Typing can update a ruler/progress region in the same snapshot.
+ // Inspect only live regions here, so typed percentages elsewhere
+ // cannot become progress cues, even on the same physical row.
+ r.progress.Update(regions.live_snapshot(previous), regions.live_snapshot(next), -1)
+ }
+ path = "typing-echo"
+ return echo
+ }
+ if r.progressEnabled {
+ r.progress.Update(regions.progress_snapshot(previous), regions.progress_snapshot(next), r.progress_deletion_row(previous, next))
+ }
+ if !r.automatic {
+ path = "automatic-disabled"
+ return nil
+ }
+ readingPrevious := regions.filtered_snapshot(previous, true, true)
+ readingNext := r.Automatic_snapshot()
+ if r.trackingMode == "highlight" && !r.review.Active() {
+ if output := highlights(readingPrevious, readingNext); len(output) > 0 {
+ path = "highlight"
+ return output
+ }
+ }
+ return changes(readingPrevious, readingNext, r.trackingMode == "cursor" && r.cursorEcho && !r.review.Active() && regions.allows(next.X, next.Y, next.Cols, true, true))
+}
+
+// Command returns the complete feedback for one action, without retaining
+// presentation state for callers to retrieve after dispatch.
+func (r *Reader) Command(id command.ID) presentation.CommandResult {
+ r.applicationInputAt = time.Time{}
+ if result, handled := r.region_command(id); handled {
+ return result
+ }
+ switch id {
+ case command.ToggleAttributes:
+ return presentation.CommandResult{Text: r.toggle_attributes()}
+ case command.ToggleTracking:
+ switch r.trackingMode {
+ case "cursor":
+ r.Configure_tracking("table")
+ return presentation.CommandResult{Text: i18n.T("table mode")}
+ case "table":
+ r.Configure_tracking("highlight")
+ return presentation.CommandResult{Text: i18n.T("highlight tracking")}
+ default:
+ r.Configure_tracking("cursor")
+ return presentation.CommandResult{Text: i18n.T("cursor tracking")}
+ }
+ case command.AutoRead:
+ r.automatic = !r.automatic
+ if r.automatic {
+ return presentation.CommandResult{Text: i18n.T("autoread enabled")}
+ }
+ return presentation.CommandResult{Text: i18n.T("autoread disabled")}
+ case command.Unbound:
+ return presentation.CommandResult{Text: i18n.T("unbound"), Cue: presentation.Error}
+ }
+ beforeAttribute, beforeKnown := r.review.Attribute()
+ previousRow := r.review.Row()
+ result := r.review.Execute(id)
+ afterAttribute, afterKnown := r.review.Attribute()
+ result.Attributes = r.attribute_feedback(id != command.AttributeCursor && beforeKnown && afterKnown && beforeAttribute != afterAttribute)
+ result.Indentation = r.indentation_feedback(r.review.Row() != previousRow)
+ return result
+}
diff --git a/internal/reader/reader_test.go b/internal/reader/reader_test.go
new file mode 100644
index 00000000..f720a248
--- /dev/null
+++ b/internal/reader/reader_test.go
@@ -0,0 +1,269 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package reader
+
+import (
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+func fixture(lines ...string) screen.Snapshot {
+ const width = 20
+ s := screen.Snapshot{Terminal: 2, Active: true, Rows: len(lines), Cols: width}
+ for _, text := range lines {
+ line := []rune(text)
+ s.Cells = append(s.Cells, line...)
+ s.Cells = append(s.Cells, []rune(strings.Repeat(" ", width-len(line)))...)
+ }
+ return s
+}
+
+func TestAutomaticTypingDeletionCursorAndScroll(t *testing.T) {
+ before, after := fixture("$ ", ""), fixture("$ a", "")
+ before.X, after.X = 2, 3
+ if got := changes(before, after, true); !reflect.DeepEqual(got, []presentation.Message{presentation.Character_message('a')}) {
+ t.Fatal(got)
+ }
+ if got := changes(after, before, true); !reflect.DeepEqual(got, []presentation.Message{presentation.Character_message('a')}) {
+ t.Fatal(got)
+ }
+ before, after = fixture("first", "second", "third"), fixture("second", "third", "fourth")
+ if got := changes(before, after, true); !reflect.DeepEqual(got, []presentation.Message{{Text: "fourth"}}) {
+ t.Fatal(got)
+ }
+ after = before
+ after.Y = 1
+ if got := changes(before, after, true); !reflect.DeepEqual(got, []presentation.Message{{Text: "second"}}) {
+ t.Fatal(got)
+ }
+ if got := changes(before, after, false); len(got) != 0 {
+ t.Fatal("cursor echo disrupted review", got)
+ }
+ r := New()
+ r.Update(before)
+ r.Command(command.AutoRead)
+ if got := r.Update(fixture("changed", "second", "third")); len(got) != 0 {
+ t.Fatal(got)
+ }
+}
+
+func TestReviewSurvivesStationaryUpdatesAndExitsOnCursorMovement(t *testing.T) {
+ r := New()
+ s := fixture("alpha", "omega")
+ r.Update(s)
+ r.Command(command.CurrentLine)
+ r.Command(command.NextLine)
+ if got := r.Update(s); len(got) != 0 {
+ t.Fatal("unchanged screen repeated output", got)
+ }
+ if got := r.Command(command.CurrentLine).Text; got != "omega" {
+ t.Fatal("stationary application cursor reset review", got)
+ }
+ next := fixture("alpha", "omega!")
+ if got := r.Update(next); !reflect.DeepEqual(got, []presentation.Message{presentation.Character_message('!')}) {
+ t.Fatal("review suppressed new output", got)
+ }
+ if !r.review.Active() {
+ t.Fatal("text change without cursor movement left review")
+ }
+ next.Y = 1
+ r.Note_input(true, false, false)
+ if got := r.Update(next); !reflect.DeepEqual(got, []presentation.Message{{Text: "omega!"}}) || r.review.Active() {
+ t.Fatal("vertical cursor movement did not restore line feedback", got)
+ }
+ r.Command(command.CurrentLine)
+ next.X = 1
+ r.Note_input(true, false, false)
+ if got := r.Update(next); !reflect.DeepEqual(got, []presentation.Message{presentation.Character_message('m')}) || r.review.Active() {
+ t.Fatal("horizontal cursor movement did not restore character feedback", got)
+ }
+}
+
+func TestCursorMovementLeavesReviewWithFeedbackDisabled(t *testing.T) {
+ for _, options := range [][2]bool{{false, true}, {true, false}, {false, false}} {
+ r := New()
+ r.Configure(options[0], options[1])
+ s := fixture("alpha", "omega")
+ r.Update(s)
+ r.Command(command.CurrentLine)
+ s.Y = 1
+ r.Note_input(true, false, false)
+ if got := r.Update(s); len(got) != 0 || r.review.Active() {
+ t.Fatal("disabled feedback changed review exit behavior", options, got, r.review.Active())
+ }
+ if got := r.Command(command.CurrentLine).Text; got != "omega" {
+ t.Fatal("review did not resume at the new application cursor", got)
+ }
+ }
+}
+
+func TestCursorMovementPreservesReviewWithoutRecentApplicationInput(t *testing.T) {
+ for _, testCase := range []string{"passive output", "reader command", "expired input"} {
+ t.Run(testCase, func(t *testing.T) {
+ r := New()
+ s := fixture("alpha", "omega")
+ r.Update(s)
+ r.Command(command.CurrentLine)
+ switch testCase {
+ case "reader command":
+ r.Note_input(true, false, false)
+ r.Note_input(false, false, false) // A reader paste is not application input.
+ case "expired input":
+ r.Note_input(true, false, false)
+ r.applicationInputAt = time.Now().Add(-time.Second)
+ }
+ s.Y = 1
+ if got := r.Update(s); len(got) != 0 || !r.review.Active() {
+ t.Fatal("cursor movement without recent application input left review", got)
+ }
+ if got := r.Command(command.CurrentLine).Text; got != "alpha" {
+ t.Fatal("passive cursor movement displaced review", got)
+ }
+ })
+ }
+}
+
+func TestAutomaticReadingToggleKeepsReviewCurrent(t *testing.T) {
+ r := New()
+ if got := r.Command(command.Unbound).Text; got != "unbound" {
+ t.Fatal(got)
+ }
+ if got := r.Command(command.AutoRead).Text; got != "autoread disabled" {
+ t.Fatal(got)
+ }
+ s := fixture("alpha", "omega")
+ s.Y = 1
+ if got := r.Update(s); len(got) != 0 {
+ t.Fatal("automatic output while disabled", got)
+ }
+ if got := r.Command(command.CurrentLine).Text; got != "omega" {
+ t.Fatal("disabled automatic reading left review stale", got)
+ }
+ if got := r.Command(command.AutoRead).Text; got != "autoread enabled" {
+ t.Fatal(got)
+ }
+ r.Command(command.FollowCursor)
+ s.Y = 0
+ if got := r.Update(s); !reflect.DeepEqual(got, []presentation.Message{{Text: "alpha"}}) {
+ t.Fatal("automatic reading did not resume", got)
+ }
+}
+
+func TestReadingSettingsPreserveReviewAndNewOutput(t *testing.T) {
+ r := New()
+ r.Configure(false, false)
+ before := fixture("alpha", "omega")
+ if got := r.Update(before); len(got) != 0 {
+ t.Fatal("automatic setting ignored", got)
+ }
+ if got := r.Command(command.CurrentLine).Text; got != "alpha" {
+ t.Fatal("disabled automatic reading broke review", got)
+ }
+ r.Command(command.FollowCursor)
+ r.Configure(true, false)
+ after := before
+ after.Y = 1
+ if got := r.Update(after); len(got) != 0 {
+ t.Fatal("cursor echo setting ignored", got)
+ }
+ changed := fixture("alpha", "omega!")
+ changed.Y = 1
+ if got := r.Update(changed); !reflect.DeepEqual(got, []presentation.Message{presentation.Character_message('!')}) {
+ t.Fatal("cursor echo setting suppressed new output", got)
+ }
+}
+
+func TestBackspaceSpeaksRemovedTextWithoutDeletedLabel(t *testing.T) {
+ for _, testCase := range []struct {
+ text string
+ want string
+ }{
+ {"e", "e"},
+ {"Ć©", "Ć©"},
+ {".", "dot"},
+ {"E", "E"},
+ {"ab", "ab"},
+ } {
+ t.Run(testCase.text, func(t *testing.T) {
+ before, after := fixture("$ "+testCase.text), fixture("$ ")
+ before.X, after.X = 2+len([]rune(testCase.text)), 2
+ want := []presentation.Message{{Text: testCase.want}}
+ if chars := []rune(testCase.text); len(chars) == 1 {
+ want = []presentation.Message{presentation.Character_message(chars[0])}
+ }
+ if got := changes(before, after, true); !reflect.DeepEqual(got, want) {
+ t.Fatalf("got %v, want %q", got, testCase.want)
+ }
+ })
+ }
+}
+
+func TestScrollWithBlankAndUnicodeWhitespaceRows(t *testing.T) {
+ for _, testCase := range []struct {
+ name string
+ before, after []string
+ want []presentation.Message
+ }{
+ {"leading blank", []string{"gone", "", "keep", "tail"}, []string{"", "keep", "tail", "fresh"}, []presentation.Message{{Text: "fresh"}}},
+ {"two rows", []string{"gone", "", "keep", "tail"}, []string{"keep", "tail", "fresh", "last"}, []presentation.Message{{Text: "fresh"}, {Text: "last"}}},
+ {"whitespace overlap", []string{"gone", "\u2003", ""}, []string{"\u2003", "", "fresh"}, []presentation.Message{{Text: "fresh"}}},
+ } {
+ t.Run(testCase.name, func(t *testing.T) {
+ got := changes(fixture(testCase.before...), fixture(testCase.after...), true)
+ if !reflect.DeepEqual(got, testCase.want) {
+ t.Fatalf("got %v, want %v", got, testCase.want)
+ }
+ })
+ }
+ before, after := fixture("a", "", ""), fixture("", "", "")
+ before.X = 1
+ if got := changes(before, after, true); !reflect.DeepEqual(got, []presentation.Message{presentation.Character_message('a')}) {
+ t.Fatalf("deleting last character on blank screen: %v", got)
+ }
+}
+
+func TestAutomaticBlankLinesCarryCuesWithoutMatchingWords(t *testing.T) {
+ for _, line := range []string{"", "\u2003", "blank", "start of screen"} {
+ t.Run(line, func(t *testing.T) {
+ r := New()
+ snapshot := fixture("hello", line)
+ var wantCue presentation.Cue
+ wantText := line
+ if strings.TrimSpace(line) == "" {
+ wantCue, wantText = presentation.Blank, "blank"
+ }
+ want := presentation.Message{Text: wantText, Cue: wantCue}
+ snapshot.Y = 1
+ if got := r.Update(snapshot); len(got) != 2 || got[1] != want {
+ t.Fatal("initial line", got, want)
+ }
+ snapshot.Y = 0
+ r.Update(snapshot)
+ snapshot.Y = 1
+ if got := r.Update(snapshot); !reflect.DeepEqual(got, []presentation.Message{want}) {
+ t.Fatal("cursor line", got, want)
+ }
+ })
+ }
+}
diff --git a/internal/reader/regions.go b/internal/reader/regions.go
new file mode 100644
index 00000000..fe57b125
--- /dev/null
+++ b/internal/reader/regions.go
@@ -0,0 +1,262 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package reader
+
+import (
+ "fmt"
+ "slices"
+ "strconv"
+ "strings"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/i18n"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+type terminalRegions struct {
+ window *screen.Region
+ live [10]*screen.Region
+}
+
+var readLiveRegions = [...]command.ID{
+ command.ReadLiveRegion1, command.ReadLiveRegion2, command.ReadLiveRegion3,
+ command.ReadLiveRegion4, command.ReadLiveRegion5, command.ReadLiveRegion6,
+ command.ReadLiveRegion7, command.ReadLiveRegion8, command.ReadLiveRegion9, command.ReadLiveRegion10,
+}
+
+var clearLiveRegions = [...]command.ID{
+ command.ClearLiveRegion1, command.ClearLiveRegion2, command.ClearLiveRegion3,
+ command.ClearLiveRegion4, command.ClearLiveRegion5, command.ClearLiveRegion6,
+ command.ClearLiveRegion7, command.ClearLiveRegion8, command.ClearLiveRegion9, command.ClearLiveRegion10,
+}
+
+func (r *Reader) region_state() *terminalRegions { return r.regions[r.screen.Terminal] }
+
+func (r *Reader) ensure_regions() *terminalRegions {
+ if r.regions == nil {
+ r.regions = make(map[int]*terminalRegions)
+ }
+ if r.region_state() == nil {
+ r.regions[r.screen.Terminal] = &terminalRegions{}
+ }
+ return r.region_state()
+}
+
+func (s *terminalRegions) has_live() bool {
+ if s != nil {
+ for _, region := range s.live {
+ if region != nil {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func (s *terminalRegions) allows(x, y, cols int, window, live bool) bool {
+ if s == nil {
+ return true
+ }
+ if window && s.window != nil && !s.window.Contains(x, y, cols) {
+ return false
+ }
+ if live {
+ for _, region := range s.live {
+ if region != nil && region.Contains(x, y, cols) {
+ return false
+ }
+ }
+ }
+ return true
+}
+
+// filtered_snapshot never alters the source snapshot. Masking before diffing
+// keeps excluded churn out of typing/scroll classification, rather than trying
+// to remove strings from an already flattened presentation.
+func (s *terminalRegions) filtered_snapshot(snapshot screen.Snapshot, window, live bool) screen.Snapshot {
+ if s == nil || (!window || s.window == nil) && (!live || !s.has_live()) {
+ return snapshot
+ }
+ snapshot.Cells = slices.Clone(snapshot.Cells)
+ snapshot.Attributes = slices.Clone(snapshot.Attributes)
+ for row := range snapshot.Rows {
+ for col := range snapshot.Cols {
+ if s.allows(col, row, snapshot.Cols, window, live) {
+ continue
+ }
+ index := row*snapshot.Cols + col
+ snapshot.Cells[index] = ' '
+ if len(snapshot.Attributes) == len(snapshot.Cells) {
+ snapshot.Attributes[index] = screen.Attribute{}
+ }
+ }
+ }
+ return snapshot
+}
+
+// Automatic_snapshot is also used by prompt restoration: an excluded update
+// must not restore speech or bypass the automatic-reading filter.
+func (r *Reader) Automatic_snapshot() screen.Snapshot {
+ return r.region_state().filtered_snapshot(r.screen, true, true)
+}
+
+func (r *Reader) Region_revision() uint64 { return r.regionRevision }
+
+// live_snapshot retains only live-region cells for progress detection alongside
+// a confirmed typing edit. Unlike automatic speech, progress may inspect them.
+func (s *terminalRegions) live_snapshot(snapshot screen.Snapshot) screen.Snapshot {
+ snapshot.Cells = slices.Clone(snapshot.Cells)
+ for row := range snapshot.Rows {
+ for col := range snapshot.Cols {
+ if s.allows(col, row, snapshot.Cols, false, true) {
+ snapshot.Cells[row*snapshot.Cols+col] = ' '
+ }
+ }
+ }
+ return snapshot
+}
+
+// An explicit live region remains eligible for progress even when it lies
+// outside a reading window. The window otherwise excludes outside progress.
+func (s *terminalRegions) progress_snapshot(snapshot screen.Snapshot) screen.Snapshot {
+ if s == nil || s.window == nil {
+ return snapshot
+ }
+ snapshot.Cells = slices.Clone(snapshot.Cells)
+ for row := range snapshot.Rows {
+ for col := range snapshot.Cols {
+ if !s.window.Contains(col, row, snapshot.Cols) && s.allows(col, row, snapshot.Cols, false, true) {
+ snapshot.Cells[row*snapshot.Cols+col] = ' '
+ }
+ }
+ }
+ return snapshot
+}
+
+func (r *Reader) reset_region_feedback() {
+ r.regionRevision++
+ r.progress.Reset()
+ r.inputCount, r.inputBoundary = 0, false
+ r.lastCompletedWord = ""
+ r.attributeKnown, r.indentKnown = false, false
+}
+
+// Set_window is shared by marked keyboard commands and validated remote
+// coordinates. Regions belong to the terminal, never a guessed process name.
+func (r *Reader) Set_window(x1, y1, x2, y2 int) error {
+ if !r.screen.Active || min(x1, x2, y1, y2) < 0 || max(x1, x2) >= r.screen.Cols || max(y1, y2) >= r.screen.Rows {
+ return fmt.Errorf("Window coordinates must be inside the active screen")
+ }
+ r.ensure_regions().window = &screen.Region{Left: min(x1, x2), Top: min(y1, y2), Right: max(x1, x2), Bottom: max(y1, y2)}
+ r.reset_region_feedback()
+ return nil
+}
+
+func (r *Reader) Clear_window() {
+ if state := r.region_state(); state != nil {
+ state.window = nil
+ }
+ r.reset_region_feedback()
+}
+
+func (r *Reader) region_command(id command.ID) (presentation.CommandResult, bool) {
+ readSlot, clearSlot := slices.Index(readLiveRegions[:], id), slices.Index(clearLiveRegions[:], id)
+ known := readSlot >= 0 || clearSlot >= 0 || id == command.SetWindow || id == command.ClearWindow || id == command.SetLiveRegion ||
+ id == command.ListReadingRegions || id == command.ClearLiveRegions || id == command.ClearReadingRegions
+ if !known {
+ return presentation.CommandResult{}, false
+ }
+ result := presentation.CommandResult{}
+ if !r.screen.Active {
+ return presentation.CommandResult{Text: i18n.T("no text console"), Cue: presentation.Error}, true
+ }
+ state := r.region_state()
+ switch {
+ case readSlot >= 0 || clearSlot >= 0:
+ slot := max(readSlot, clearSlot)
+ number := strconv.Itoa(slot + 1)
+ if state == nil || state.live[slot] == nil {
+ result.Text = i18n.Format("Live region {0} not set", number)
+ } else if clearSlot >= 0 {
+ state.live[slot] = nil
+ r.reset_region_feedback()
+ result.Text = i18n.Format("Live region {0} cleared", number)
+ } else {
+ result.Text = state.live[slot].Text(r.screen)
+ if state.live[slot].Left >= r.screen.Cols || state.live[slot].Top >= r.screen.Rows {
+ result.Text = i18n.Format("Live region {0} outside screen", number)
+ } else if strings.TrimSpace(result.Text) == "" {
+ result.Text = i18n.T("blank")
+ }
+ }
+ case id == command.SetLiveRegion:
+ region, ok := r.review.Marked_region(true)
+ if !ok {
+ result.Text = i18n.T("One or two marks are needed")
+ break
+ }
+ state = r.ensure_regions()
+ slot := slices.Index(state.live[:], nil)
+ if slot < 0 {
+ result.Text = i18n.T("All 10 live regions are set")
+ break
+ }
+ state.live[slot] = ®ion
+ r.review.Clear_marks()
+ r.reset_region_feedback()
+ result.Text = i18n.Format("Live region {0} set", strconv.Itoa(slot+1))
+ case id == command.SetWindow:
+ region, ok := r.review.Marked_region(false)
+ if !ok {
+ result.Text = i18n.T("Set window begin and end marks")
+ break
+ }
+ r.ensure_regions().window = ®ion
+ r.review.Clear_marks()
+ r.reset_region_feedback()
+ result.Text = i18n.Format("Window Mode on for terminal {0}", strconv.Itoa(r.screen.Terminal))
+ case id == command.ClearWindow:
+ if state == nil || state.window == nil {
+ result.Text = i18n.T("Not in window Mode")
+ } else {
+ r.Clear_window()
+ result.Text = i18n.Format("Window Mode off for terminal {0}", strconv.Itoa(r.screen.Terminal))
+ }
+ case id == command.ClearLiveRegions:
+ if state != nil {
+ state.live = [10]*screen.Region{}
+ }
+ r.reset_region_feedback()
+ result.Text = i18n.T("Live regions cleared")
+ case id == command.ClearReadingRegions:
+ delete(r.regions, r.screen.Terminal)
+ r.reset_region_feedback()
+ result.Text = i18n.T("Reading regions cleared")
+ case id == command.ListReadingRegions:
+ var entries []string
+ if state != nil {
+ if state.window != nil {
+ entries = append(entries, i18n.Format("Reading window: {0}", region_description(*state.window)))
+ }
+ for slot, region := range state.live {
+ if region != nil {
+ entries = append(entries, i18n.Format("Live region {0}: {1}", strconv.Itoa(slot+1), region_description(*region)))
+ }
+ }
+ }
+ result.Text = strings.Join(entries, ". ")
+ if len(entries) == 0 {
+ result.Text = i18n.T("No reading regions set")
+ }
+ }
+ return result, true
+}
+
+func region_description(region screen.Region) string {
+ if region.ToLineEnd {
+ return i18n.Format("row {0}, column {1} to end of line", strconv.Itoa(region.Top+1), strconv.Itoa(region.Left+1))
+ }
+ return i18n.Format("row {0}, column {1} to row {2}, column {3}", strconv.Itoa(region.Top+1), strconv.Itoa(region.Left+1), strconv.Itoa(region.Bottom+1), strconv.Itoa(region.Right+1))
+}
diff --git a/internal/reader/regions_test.go b/internal/reader/regions_test.go
new file mode 100644
index 00000000..a687bbe2
--- /dev/null
+++ b/internal/reader/regions_test.go
@@ -0,0 +1,323 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package reader
+
+import (
+ "reflect"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+func region_mark(t *testing.T, r *Reader, x, y int) {
+ t.Helper()
+ snapshot := r.screen
+ snapshot.X, snapshot.Y = x, y
+ r.Update(snapshot)
+ r.Command(command.FollowCursor)
+ r.Command(command.CurrentChar)
+ if result := r.Set_mark(); result.Text != "set mark" {
+ t.Fatal(result)
+ }
+}
+
+func region_command(t *testing.T, r *Reader, id command.ID, want string) {
+ t.Helper()
+ if result := r.Command(id); result.Text != want {
+ t.Fatalf("%s: %+v; want %q", id, result, want)
+ }
+}
+
+func TestLiveRegionVimRulerTypingLatestReadAndClear(t *testing.T) {
+ for _, echo := range []bool{false, true} {
+ t.Run(map[bool]string{false: "echo disabled", true: "echo enabled"}[echo], func(t *testing.T) {
+ r := New()
+ r.Configure_echo(echo, false, false)
+ before := fixture("compose", "1,8 All")
+ before.X = 7
+ r.Update(before)
+ region_mark(t, r, 0, 1)
+ region_command(t, r, command.SetLiveRegion, "Live region 1 set")
+ if _, ok := r.Marked_text(true); ok {
+ t.Fatal("successful creation retained marks")
+ }
+ r.Command(command.FollowCursor)
+ r.Update(before)
+ after := fixture("composeX", "1,9 All")
+ after.X = 8
+ r.Note_input(true, true, false)
+ got := r.Update(after)
+ var want []presentation.Message
+ if echo {
+ want = []presentation.Message{presentation.Character_message('X')}
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Fatal("ruler broke typing echo", got, want)
+ }
+ region_command(t, r, command.ReadLiveRegion1, "1,9 All")
+ if !strings.Contains(r.Command(command.ReadScreen).Text, "1,9 All") {
+ t.Fatal("manual read lost excluded text")
+ }
+ after = fixture("composeX", "2,9 All")
+ after.X = 8
+ if got := r.Update(after); len(got) != 0 {
+ t.Fatal("passive ruler spoke", got)
+ }
+ region_command(t, r, command.ClearLiveRegion1, "Live region 1 cleared")
+ if got := r.Update(after); len(got) != 0 {
+ t.Fatal("clear replayed hidden history", got)
+ }
+ after = fixture("composeX", "3,9 All")
+ after.X = 8
+ if got := r.Update(after); len(got) == 0 {
+ t.Fatal("clearing did not restore automatic reading")
+ }
+ })
+ }
+}
+
+func TestLiveRegionRectangleUnicodeEdgesAndReversedMarks(t *testing.T) {
+ r := New()
+ s := fixture("LEFTabcRIGHT", "LEFTēĆ©xRIGHT", "untouched")
+ r.Update(s)
+ region_mark(t, r, 6, 1)
+ region_mark(t, r, 4, 0)
+ region_command(t, r, command.SetLiveRegion, "Live region 1 set")
+ region_command(t, r, command.ReadLiveRegion1, "abc\nēĆ©x")
+ r.Command(command.FollowCursor)
+ next := fixture("LEFTxyzRIGHT", "LEFTĆ©ēqRIGHT", "untouched")
+ next.X, next.Y = r.screen.X, r.screen.Y
+ if got := r.Update(next); len(got) != 0 {
+ t.Fatal("rectangle boundary leaked", got)
+ }
+ next = fixture("LEFTxyzRIGH!", "LEFTĆ©ēqRIGHT", "untouched")
+ next.X, next.Y = r.screen.X, r.screen.Y
+ if got := r.Update(next); !reflect.DeepEqual(got, []presentation.Message{presentation.Character_message('!')}) {
+ t.Fatal("adjacent character lost", got)
+ }
+ if s.Text(0) != "LEFTabcRIGHT" {
+ t.Fatal("filter mutated the original snapshot")
+ }
+}
+
+func TestLiveRegionSlotsReuseLimitListingAndIndependentTerminals(t *testing.T) {
+ r := New()
+ r.Update(fixture("first", "second"))
+ region_command(t, r, command.ReadLiveRegion2, "Live region 2 not set")
+ region_command(t, r, command.SetLiveRegion, "One or two marks are needed")
+ for slot := range 10 {
+ region_mark(t, r, 0, slot%2)
+ region_command(t, r, command.SetLiveRegion, "Live region "+strconv.Itoa(slot+1)+" set")
+ }
+ region_mark(t, r, 0, 0)
+ region_command(t, r, command.SetLiveRegion, "All 10 live regions are set")
+ if _, ok := r.Marked_text(true); !ok {
+ t.Fatal("failed creation discarded marks")
+ }
+ listing := r.Command(command.ListReadingRegions).Text
+ if !strings.Contains(listing, "Live region 10: row 2, column 1 to end of line") {
+ t.Fatal(listing)
+ }
+ region_command(t, r, command.ClearLiveRegion4, "Live region 4 cleared")
+ region_command(t, r, command.SetLiveRegion, "Live region 4 set")
+ region_command(t, r, command.ReadLiveRegion4, "first")
+ other := fixture("another terminal", "status")
+ other.Terminal = 3
+ r.Update(other)
+ region_command(t, r, command.ReadLiveRegion1, "Live region 1 not set")
+ region_command(t, r, command.ListReadingRegions, "No reading regions set")
+ region_mark(t, r, 0, 1)
+ region_command(t, r, command.SetLiveRegion, "Live region 1 set")
+ region_command(t, r, command.ClearReadingRegions, "Reading regions cleared")
+ r.Update(fixture("new first", "new second"))
+ region_command(t, r, command.ReadLiveRegion1, "new first")
+ region_command(t, r, command.ClearLiveRegions, "Live regions cleared")
+ region_command(t, r, command.ReadLiveRegion10, "Live region 10 not set")
+ fresh := New()
+ fresh.Update(r.screen)
+ region_command(t, fresh, command.ReadLiveRegion1, "Live region 1 not set")
+}
+
+func TestRegionsSurviveFocusBufferAndResizeWithClippedReads(t *testing.T) {
+ r := New()
+ r.Update(fixture("editor", "status at bottom"))
+ region_mark(t, r, 7, 1)
+ region_command(t, r, command.SetLiveRegion, "Live region 1 set")
+ s := r.screen
+ s.Active = false
+ r.Update(s)
+ s.Active = true
+ r.Update(s)
+ region_command(t, r, command.ReadLiveRegion1, "at bottom")
+ s.Generation++
+ r.Update(s)
+ region_command(t, r, command.ReadLiveRegion1, "at bottom")
+ s = screen.Snapshot{Terminal: s.Terminal, Active: true, Rows: 1, Cols: 5, Cells: []rune("small")}
+ r.Update(s)
+ region_command(t, r, command.ReadLiveRegion1, "Live region 1 outside screen")
+ r.Update(fixture("editor", "status new contents"))
+ region_command(t, r, command.ReadLiveRegion1, "new contents")
+}
+
+func TestReadingWindowAndLiveRegionComposeWithoutRestrictingReview(t *testing.T) {
+ r := New()
+ r.Update(fixture("outer text", "IN abc OUT", "IN def OUT"))
+ region_command(t, r, command.SetWindow, "Set window begin and end marks")
+ region_mark(t, r, 5, 2)
+ region_mark(t, r, 3, 1)
+ region_command(t, r, command.SetWindow, "Window Mode on for terminal 2")
+ if got := r.Automatic_snapshot().Text(1); got != " abc" {
+ t.Fatal(got)
+ }
+ region_mark(t, r, 3, 2)
+ region_command(t, r, command.SetLiveRegion, "Live region 1 set")
+ next := fixture("changed outer", "IN abc NEW", "IN XYZ NEW")
+ next.X, next.Y = r.screen.X, r.screen.Y
+ if got := r.Update(next); len(got) != 0 {
+ t.Fatal("outside window/live region spoke", got)
+ }
+ region_command(t, r, command.ReadLiveRegion1, "XYZ NEW")
+ if got := r.Command(command.ReadScreen).Text; !strings.Contains(got, "changed outer") || !strings.Contains(got, "XYZ NEW") {
+ t.Fatal("window clipped manual review", got)
+ }
+ next = fixture("changed outer", "IN qbc NEW", "IN XYZ NEW")
+ next.X, next.Y = r.screen.X, r.screen.Y
+ if got := r.Update(next); !reflect.DeepEqual(got, []presentation.Message{presentation.Character_message('q')}) {
+ t.Fatal("inside window did not speak", got)
+ }
+ region_command(t, r, command.ClearWindow, "Window Mode off for terminal 2")
+ region_command(t, r, command.ReadLiveRegion1, "XYZ NEW")
+ region_command(t, r, command.ClearReadingRegions, "Reading regions cleared")
+ region_command(t, r, command.ListReadingRegions, "No reading regions set")
+}
+
+func TestLiveRegionProgressAloneAndConcurrentTyping(t *testing.T) {
+ for _, typing := range []bool{false, true} {
+ r := New()
+ r.Configure_progress(true)
+ r.Configure_echo(true, false, false)
+ before := fixture("compose", "Download 20%")
+ before.X = 7
+ r.Update(before)
+ region_mark(t, r, 0, 1)
+ region_command(t, r, command.SetLiveRegion, "Live region 1 set")
+ r.Command(command.FollowCursor)
+ r.Update(before)
+ after := fixture("compose", "Download 30%")
+ after.X = 7
+ if typing {
+ after = fixture("composeX", "Download 30%")
+ after.X = 8
+ r.Note_input(true, true, false)
+ }
+ output := r.Update(after)
+ if len(output) > 0 && (!typing || len(output) != 1 || output[0].Character == nil || *output[0].Character != 'X') {
+ t.Fatal("progress region spoke", typing, output)
+ }
+ if value, ok := r.Next_progress(time.Now().Add(time.Second)); !ok || value != 30 {
+ t.Fatal("lost region progress", typing, value, ok)
+ }
+ r.Configure_progress(false)
+ after = fixture("composeX", "Download 40%")
+ after.X = 8
+ r.Update(after)
+ if _, ok := r.Next_progress(time.Now().Add(2 * time.Second)); ok {
+ t.Fatal("disabled monitor beeped")
+ }
+ }
+}
+
+func TestLiveRegionKeepsProgressOutsideWindowAndSuppressesSameRowTypedPercentages(t *testing.T) {
+ r := New()
+ r.Configure_progress(true)
+ r.Update(fixture("editor", "Download 20%"))
+ if err := r.Set_window(0, 0, 19, 0); err != nil {
+ t.Fatal(err)
+ }
+ region_mark(t, r, 0, 1)
+ region_command(t, r, command.SetLiveRegion, "Live region 1 set")
+ next := fixture("editor", "Download 30%")
+ next.Y = 1
+ if got := r.Update(next); len(got) != 0 {
+ t.Fatal(got)
+ }
+ if value, ok := r.Next_progress(time.Now()); !ok || value != 30 {
+ t.Fatal("window hid an explicit live progress region", value, ok)
+ }
+ // A progress field and typing can also share one physical row.
+ r = New()
+ r.Configure_progress(true)
+ s := fixture("20 Download 20%")
+ s.X = 2
+ r.Update(s)
+ region_mark(t, r, 4, 0)
+ region_command(t, r, command.SetLiveRegion, "Live region 1 set")
+ r.Command(command.FollowCursor)
+ r.Update(s)
+ next = fixture("20% Download 30%")
+ next.X = 3
+ r.Note_input(true, true, false)
+ r.Update(next)
+ if value, ok := r.Next_progress(time.Now()); !ok || value != 30 {
+ t.Fatal("typed percentage won over live progress on the same row", value, ok)
+ }
+}
+
+func TestLiveRegionFilteringPreservesScrollAndDeletion(t *testing.T) {
+ r := New()
+ s := fixture("old first", "old second", "old third", "status 1")
+ r.Update(s)
+ region_mark(t, r, 0, 3)
+ region_command(t, r, command.SetLiveRegion, "Live region 1 set")
+ r.Command(command.FollowCursor)
+ next := fixture("old second", "old third", "new fourth", "status 2")
+ next.Y = 3
+ if got := r.Update(next); !reflect.DeepEqual(got, []presentation.Message{{Text: "new fourth"}}) {
+ t.Fatal("retained scroll history repeated", got)
+ }
+ s = fixture("composeX", "old third", "new fourth", "status 2")
+ s.X = 8
+ r.Update(s)
+ r.Note_input(true, false, false)
+ r.Note_deletion()
+ next = fixture("compose", "old third", "new fourth", "status 3")
+ next.X = 7
+ if got := r.Update(next); !reflect.DeepEqual(got, []presentation.Message{presentation.Character_message('X')}) {
+ t.Fatal("ruler hid deletion feedback", got)
+ }
+}
+
+func TestLiveRegionSuppressesCursorHighlightAttributesIndentationAndWordFeedback(t *testing.T) {
+ for _, mode := range []string{"cursor", "table", "highlight"} {
+ r := New()
+ s := fixture("outside", " helo")
+ s.Attributes = make([]screen.Attribute, len(s.Cells))
+ r.Update(s)
+ region_mark(t, r, 0, 1)
+ region_command(t, r, command.SetLiveRegion, "Live region 1 set")
+ r.Command(command.FollowCursor)
+ r.Configure_tracking(mode)
+ r.Configure_indentation("both")
+ r.Configure_attributes("both", "fenrirBold")
+ r.Configure_echo(true, true, true)
+ next := fixture("outside", " helo ")
+ next.Y, next.X = 1, 9
+ next.Attributes = make([]screen.Attribute, len(next.Cells))
+ for col := range 9 {
+ next.Attributes[next.Cols+col].Style = screen.Bold
+ }
+ r.Note_input(true, true, true)
+ if got := r.Update(next); len(got) != 0 || len(r.Completed_words()) != 0 {
+ t.Fatal("excluded automatic feedback", mode, got, r.Completed_words())
+ }
+ if got := r.Command(command.CurrentLine).Text; !strings.Contains(got, "helo") {
+ t.Fatal("manual review lost live content", mode, got)
+ }
+ }
+}
diff --git a/internal/reader/scroll_test.go b/internal/reader/scroll_test.go
new file mode 100644
index 00000000..d7e00a50
--- /dev/null
+++ b/internal/reader/scroll_test.go
@@ -0,0 +1,46 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package reader
+
+import (
+ "reflect"
+ "testing"
+
+ "git.stormux.org/storm/fenrir/internal/presentation"
+)
+
+func TestScrollInsideFixedStatusAndInputRows(t *testing.T) {
+ for _, testCase := range []struct {
+ name string
+ before, after []string
+ want []presentation.Message
+ }{
+ {"send message", []string{"channel", "old", "keep one", "keep two", "last", "status", "> message"}, []string{"channel", "keep one", "keep two", "last", "new message", "status", "> "}, []presentation.Message{{Text: "new message"}, {Text: ">"}}},
+ {"incoming message", []string{"channel", "old", "keep one", "keep two", "last", "status", "> draft"}, []string{"channel", "keep one", "keep two", "last", "new message", "status", "> draft"}, []presentation.Message{{Text: "new message"}}},
+ {"shared prefix", []string{"channel", "old", "keep one", "keep two", "chat: earlier", "status"}, []string{"channel", "keep one", "keep two", "chat: earlier", "chat: new", "status"}, []presentation.Message{{Text: "chat: new"}}},
+ {"batched lines", []string{"header", "old one", "old two", "keep one", "keep two", "status"}, []string{"header", "keep one", "keep two", "new one", "new two", "new status"}, []presentation.Message{{Text: "new one"}, {Text: "new two"}, {Text: "new status"}}},
+ {"unrelated redraw", []string{"header", "old one", "old two", "old three", "status"}, []string{"header", "new one", "new two", "new three", "status"}, []presentation.Message{{Text: "new one"}, {Text: "new two"}, {Text: "new three"}}},
+ {"isolated coincident row", []string{"header", "old", "same", "last", "status"}, []string{"header", "same", "different", "fresh", "status"}, []presentation.Message{{Text: "same"}, {Text: "different"}, {Text: "fresh"}}},
+ } {
+ t.Run(testCase.name, func(t *testing.T) {
+ if got := changes(fixture(testCase.before...), fixture(testCase.after...), true); !reflect.DeepEqual(got, testCase.want) {
+ t.Fatalf("got %v, want %v", got, testCase.want)
+ }
+ })
+ }
+}
diff --git a/internal/reader/spellcheck.go b/internal/reader/spellcheck.go
new file mode 100644
index 00000000..17c038f3
--- /dev/null
+++ b/internal/reader/spellcheck.go
@@ -0,0 +1,48 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package reader
+
+import (
+ "fmt"
+ "git.stormux.org/storm/fenrir/internal/screen"
+ "git.stormux.org/storm/fenrir/internal/spellcheck"
+)
+
+// Spellcheck_word observes the active cursor without moving it or entering review.
+func (r *Reader) Spellcheck_word() string { return r.review.Spellcheck_word() }
+
+// Completed_words contains only screen-confirmed typing from the latest Update.
+// Raw keys, cursor navigation, deletion and passive output never supply words.
+func (r *Reader) Completed_words() []string { return r.completedWords }
+
+func (r *Reader) completed_word(index int, snapshot screen.Snapshot) {
+ char := snapshot.Cells[index]
+ if !spellcheck.Separator(char) {
+ return
+ }
+ end := index
+ for end > 0 && spellcheck.Separator(snapshot.Cells[end-1]) {
+ end--
+ }
+ start := end
+ for start > 0 && !spellcheck.Separator(snapshot.Cells[start-1]) {
+ start--
+ }
+ if start == end {
+ return
+ }
+ value := string(snapshot.Cells[start:end])
+ word := spellcheck.Word(value)
+ if word == "" {
+ return
+ }
+ key := fmt.Sprintf("%d:%d:%s", start, snapshot.Cols, word)
+ if key == r.lastCompletedWord {
+ return
+ }
+ r.lastCompletedWord = key
+ if len(r.completedWords) < 32 {
+ r.completedWords = append(r.completedWords, word)
+ }
+}
diff --git a/internal/reader/spellcheck_test.go b/internal/reader/spellcheck_test.go
new file mode 100644
index 00000000..99f59370
--- /dev/null
+++ b/internal/reader/spellcheck_test.go
@@ -0,0 +1,82 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package reader
+
+import (
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/screen"
+ "reflect"
+ "strings"
+ "testing"
+)
+
+func spell_snapshot(text string) screen.Snapshot {
+ return screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 80, X: len([]rune(text)), Cells: []rune(text + strings.Repeat(" ", 80-len([]rune(text))))}
+}
+
+func TestSpellcheckOnlyUsesConfirmedCompletedWords(t *testing.T) {
+ for _, text := range []string{"helo ", "cafƩ!", "don't ", "one,two ", "https://example.com ", "foo.bar ", "./program ", "--option "} {
+ r := New()
+ r.Configure_echo(false, false, false)
+ before := spell_snapshot("$ ")
+ before.X = 2
+ r.Update(before)
+ for _, char := range text {
+ r.Note_input(true, true, char == ' ')
+ }
+ after := spell_snapshot("$ " + text)
+ after.X = 2 + len([]rune(text))
+ r.Update(after)
+ want := map[string][]string{"helo ": {"helo"}, "cafƩ!": {"cafƩ"}, "don't ": {"don't"}, "one,two ": {"one", "two"}}[text]
+ if !reflect.DeepEqual(r.Completed_words(), want) {
+ t.Fatal(text, r.Completed_words(), want)
+ }
+ r.Update(after)
+ if len(r.Completed_words()) != 0 {
+ t.Fatal("repeated unchanged word")
+ }
+ }
+ for _, input := range []bool{false, true} {
+ r := New()
+ before := spell_snapshot("$ ")
+ before.X = 2
+ r.Update(before)
+ if input {
+ for range 8 {
+ r.Note_input(true, true, true)
+ }
+ }
+ r.Update(before)
+ if len(r.Completed_words()) != 0 {
+ t.Fatal("password input leaked")
+ }
+ r.Note_input(false, false, false)
+ after := fixture("$ typo ")
+ after.X = 7
+ r.Update(after)
+ if len(r.Completed_words()) != 0 {
+ t.Fatal("passive output checked")
+ }
+ r.Note_input(true, false, false)
+ after.X--
+ r.Update(after)
+ if len(r.Completed_words()) != 0 {
+ t.Fatal("navigation checked")
+ }
+ }
+}
+
+func TestSpellcheckSourceRespectsReviewWithoutMovingCursors(t *testing.T) {
+ r := New()
+ snapshot := fixture("alpha beta")
+ snapshot.X = 7
+ r.Update(snapshot)
+ if got := r.Spellcheck_word(); got != "beta" || r.review.Active() {
+ t.Fatal(got, r.review.Active())
+ }
+ r.Command(command.PreviousWord)
+ if got := r.Spellcheck_word(); got != "alpha" || !r.review.Active() || r.screen.X != 7 {
+ t.Fatal(got, r.screen.X)
+ }
+}
diff --git a/internal/reader/tracking_test.go b/internal/reader/tracking_test.go
new file mode 100644
index 00000000..e4a72277
--- /dev/null
+++ b/internal/reader/tracking_test.go
@@ -0,0 +1,173 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package reader
+
+import (
+ "reflect"
+ "testing"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+func TestCursorNavigationWithStatusChanges(t *testing.T) {
+ r := New()
+ before := fixture("Message one", "Message two", "1/2 messages")
+ r.Update(before)
+ // The reported Mutt failure began after line review, then application
+ // arrows moved over unchanged messages while only status/attributes changed.
+ r.Command(command.CurrentLine)
+ r.Command(command.NextLine)
+ after := fixture("Message one", "Message two", "2/2 messages")
+ after.Y = 1
+ r.Note_input(true, false, false)
+ if got := r.Update(after); !reflect.DeepEqual(got, []presentation.Message{{Text: "Message two"}}) {
+ t.Fatal("status line hid focused message", got)
+ }
+ if r.review.Active() {
+ t.Fatal("message navigation retained detached review")
+ }
+ // Cursor feedback disabled still leaves incoming text available.
+ r.Configure(true, false)
+ if got := r.Update(before); !reflect.DeepEqual(got, []presentation.Message{presentation.Character_message('1')}) {
+ t.Fatal("cursor setting suppressed status output", got)
+ }
+ // New command output commonly leaves the cursor on an unchanged blank row.
+ // That row is not a TUI focus target and must not replace the output.
+ before = fixture("$ command", "", "")
+ after = fixture("$ command", "program output", "")
+ after.Y = 2
+ if got := changes(before, after, true); !reflect.DeepEqual(got, []presentation.Message{{Text: "program output"}}) {
+ t.Fatal("blank cursor row hid command output", got)
+ }
+}
+
+func TestTrackingCycleDefaultsAndReview(t *testing.T) {
+ r := New()
+ if r.Tracking_mode() != "cursor" {
+ t.Fatal(r.Tracking_mode())
+ }
+ r.Update(fixture("one | two"))
+ for range 2 {
+ for _, want := range []string{"table mode", "highlight tracking", "cursor tracking"} {
+ if got := r.Command(command.ToggleTracking).Text; got != want {
+ t.Fatal(got, want)
+ }
+ }
+ }
+ r.Configure_tracking("table")
+ if got := r.Command(command.CurrentWord).Text; got != "one Column 1" {
+ t.Fatal("table command was not dispatched", got)
+ }
+ r.Configure_tracking("cursor")
+ if got := r.Command(command.CurrentWord).Text; got != "one" {
+ t.Fatal("leaving table mode broke ordinary review", got)
+ }
+}
+
+func TestHighlightSelectionAndSuppression(t *testing.T) {
+ selected := screen.Attribute{Foreground: 7, Background: 4}
+ base := fixture("First message", "Second message", "Third message", "status")
+ base.Attributes = make([]screen.Attribute, len(base.Cells))
+ selectRow := func(row int) screen.Snapshot {
+ next := base
+ next.Attributes = make([]screen.Attribute, len(base.Cells))
+ for col := range next.Cols {
+ next.Attributes[row*next.Cols+col] = selected
+ }
+ return next
+ }
+ r := New()
+ r.Configure_tracking("highlight")
+ r.Update(selectRow(0))
+ next := selectRow(1)
+ if got := r.Update(next); !reflect.DeepEqual(got, []presentation.Message{{Text: "Second message"}}) {
+ t.Fatal("attribute-only selection not read once", got)
+ }
+ if got := r.Update(next); len(got) > 0 {
+ t.Fatal("unchanged highlight repeated", got)
+ }
+ if got := r.Update(base); len(got) > 0 {
+ t.Fatal("deselection read as selection", got)
+ }
+ r.Configure(false, true)
+ if got := r.Update(selectRow(2)); len(got) > 0 {
+ t.Fatal("automatic off still read highlight", got)
+ }
+ r.Configure(true, true)
+ r.Command(command.CurrentLine)
+ if got := r.Update(selectRow(1)); len(got) > 0 {
+ t.Fatal("highlight interrupted detached review", got)
+ }
+ r.Command(command.FollowCursor)
+ if got := r.Update(selectRow(0)); !reflect.DeepEqual(got, []presentation.Message{{Text: "First message"}}) {
+ t.Fatal("follow cursor did not restore highlights", got)
+ }
+ // Lost attribute data must not replay a previous highlight or suppress text.
+ noAttributes := fixture("New program output", "Second message", "Third message", "status")
+ if got := r.Update(noAttributes); len(got) == 0 || got[0].Text == "First message" {
+ t.Fatal("missing attributes left stale tracking", got)
+ }
+}
+
+func TestReviewCursorExitPolicyPreservesMuttWorkflowAndPassiveReview(t *testing.T) {
+ for _, leave := range []bool{false, true} {
+ for _, id := range []command.ID{command.CurrentLine, command.CurrentWord, command.NextChar, command.LastText, command.Top, command.Bottom} {
+ r := New()
+ r.Configure_review(true, true, leave, true)
+ before := fixture("First message", "Second message", "status")
+ r.Update(before)
+ r.Command(id)
+ next := before
+ next.Y = 1
+ r.Update(next) // Passive cursor movement must keep review.
+ if !r.review.Active() {
+ t.Fatal("passive movement left review", id)
+ }
+ next.Y = 0
+ r.Note_input(true, false, false)
+ r.Update(next)
+ if r.review.Active() == leave {
+ t.Fatal("cursor exit policy ignored", id, leave)
+ }
+ if !leave {
+ r.Command(command.FollowCursor)
+ if r.review.Active() {
+ t.Fatal("explicit exit ignored")
+ }
+ }
+ }
+ }
+}
+
+func TestScreenExitPolicyIsIndependentOfRecentApplicationInput(t *testing.T) {
+ r := New()
+ r.Configure_review(true, true, true, false)
+ before := fixture("first", "second")
+ r.Update(before)
+ r.Command(command.Bottom)
+ r.Note_input(true, false, false)
+ next := before
+ next.Terminal++
+ next.Y = 1
+ r.Update(next)
+ if !r.review.Active() {
+ t.Fatal("cursor policy overrode explicit screen policy")
+ }
+}
diff --git a/internal/review/additional_actions_test.go b/internal/review/additional_actions_test.go
new file mode 100644
index 00000000..981a84db
--- /dev/null
+++ b/internal/review/additional_actions_test.go
@@ -0,0 +1,165 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package review
+
+import (
+ "strings"
+ "testing"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+func TestAdditionalReportsUseActiveCursorWithoutMovingIt(t *testing.T) {
+ for _, detached := range []bool{false, true} {
+ var r Review
+ s := fixture("top", " café Ω! ", "bottom")
+ s.X, s.Y = 7, 1
+ r.Update(s)
+ kind := " text cursor "
+ if detached {
+ r.Execute(command.CurrentChar)
+ r.Set_mark()
+ s.X, s.Y = 0, 2
+ r.Update(s)
+ kind = " review cursor "
+ }
+ for _, trial := range []struct {
+ id command.ID
+ text, after string
+ }{
+ {command.CursorLine, "2", kind + " line number"},
+ {command.CursorColumn, "8", kind + " column number"},
+ {command.LineToCursor, " cafƩ", kind},
+ {command.LineFromCursor, "Ī©!", kind},
+ } {
+ result := r.Execute(trial.id)
+ if got := result.Text; got != trial.text || result.After.Text != trial.after || result.Cue != "" {
+ t.Fatalf("%s detached=%v: %q after=%q cue=%q", trial.id, detached, got, result.After.Text, result.Cue)
+ }
+ if r.x != 7 || r.y != 1 || r.Active() != detached || r.screen.X != s.X || r.screen.Y != s.Y {
+ t.Fatal("report moved or detached a cursor")
+ }
+ }
+ if detached && r.markCount != 1 {
+ t.Fatal("reports cleared marks")
+ }
+ // Attached reports must continue following subsequent application motion.
+ s.X, s.Y = 1, 0
+ r.Update(s)
+ want := "2"
+ if detached {
+ want = "8"
+ }
+ if got := r.Execute(command.CursorColumn).Text; got != want {
+ t.Fatal("wrong cursor after passive update", got)
+ }
+ r.Execute(command.FollowCursor)
+ if got := r.Execute(command.CursorLine).Text; got != "1" || r.Active() {
+ t.Fatal("follow cursor failed", got)
+ }
+ result := r.Execute(command.CurrentChar)
+ if result.After.Text != "" {
+ t.Fatal("stale cursor announcement")
+ }
+ }
+}
+
+func TestAdditionalLineRangesEdgesAndBlankCues(t *testing.T) {
+ for _, trial := range []struct {
+ line string
+ x int
+ before, after string
+ blankBefore, blankAfter bool
+ }{
+ {"abēcd", 2, "ab", "ēcd", false, false},
+ {"alpha", 0, "blank", "alpha", true, false},
+ {"alpha", 19, "alpha", "blank", false, true},
+ {"", 4, "blank", "blank", true, true},
+ {"blank", 0, "blank", "blank", true, false},
+ {strings.Repeat("z", 20), 19, strings.Repeat("z", 19), "z", false, false},
+ } {
+ var r Review
+ s := fixture(trial.line)
+ s.X = trial.x
+ r.Update(s)
+ for _, action := range []struct {
+ id command.ID
+ text string
+ blank bool
+ }{
+ {command.LineToCursor, trial.before, trial.blankBefore},
+ {command.LineFromCursor, trial.after, trial.blankAfter},
+ } {
+ result := r.Execute(action.id)
+ if got := result.Text; got != action.text || (result.Cue == presentation.Blank) != action.blank || result.After.Text != " text cursor " {
+ t.Fatalf("%s %q x=%d: %q cue=%q after=%q", action.id, trial.line, trial.x, got, result.Cue, result.After.Text)
+ }
+ }
+ }
+}
+
+func TestAdditionalScreenCharactersUsePhysicalCorners(t *testing.T) {
+ for _, lines := range [][]string{{"First row ends Z", " last"}, {"", ""}, {"Ī©"}, {strings.Repeat("x", 19) + "Ć"}} {
+ var r Review
+ s := fixture(lines...)
+ r.Update(s)
+ r.Set_table_mode(true)
+ for _, trial := range []struct {
+ id command.ID
+ x, y int
+ label string
+ }{
+ {command.ScreenLastChar, 19, len(lines) - 1, "last character in screen"},
+ {command.ScreenFirstChar, 0, 0, "first character in screen"},
+ } {
+ char := s.Line(trial.y)[trial.x]
+ result := r.Execute(trial.id)
+ if got := result.Text; got != presentation.Character(char)+" "+trial.label || r.x != trial.x || r.y != trial.y {
+ t.Fatalf("%s: %q at %d,%d", trial.id, got, r.x, r.y)
+ }
+ if got := result.Character; got == nil || *got != char || !r.Active() || r.screen.X != 0 || r.screen.Y != 0 {
+ t.Fatal("wrong character metadata or cursor ownership")
+ }
+ }
+ }
+}
+
+func TestAdditionalReportsReadWholeTableRowAndResetWithSession(t *testing.T) {
+ var r Review
+ s := fixture("Name|Subject", "Ć©|long word", "only")
+ s.X = 7
+ r.Update(s)
+ r.Execute(command.CurrentChar)
+ r.Set_mark()
+ r.Set_table_mode(true)
+ r.Execute(command.CurrentWord)
+ r.Set_mark()
+ if got := r.Execute(command.LineToCursor).Text; got != "Name|Su" {
+ t.Fatal("line report lost row scope", got)
+ }
+ if got := r.Execute(command.LineFromCursor).Text; got != "bject" || r.tableColumn != 1 || r.markCount != 1 {
+ t.Fatal("line report disturbed table state", got)
+ }
+ r.Execute(command.ReviewDown)
+ r.Execute(command.ReviewDown)
+ if got := r.Execute(command.LineToCursor).Text; got != "only" || r.tableColumn != 1 {
+ t.Fatal("missing cell changed whole-row report", got)
+ }
+ s.Generation++
+ s.X = 0
+ r.Update(s)
+ result := r.Execute(command.CursorColumn)
+ if got := result.Text; got != "1" || result.After.Text != " text cursor column number" {
+ t.Fatal("session kept detached cursor", got, result.After.Text)
+ }
+ r.Update(screen.Snapshot{})
+ for _, id := range []command.ID{command.CursorLine, command.CursorColumn, command.LineToCursor, command.LineFromCursor, command.ScreenFirstChar, command.ScreenLastChar} {
+ result := r.Execute(id)
+ if got := result.Text; got != "no text console" || result.Cue != presentation.Error || result.After.Text != "" {
+ t.Fatal("inactive screen", id, got)
+ }
+ }
+}
diff --git a/internal/review/attributes.go b/internal/review/attributes.go
new file mode 100644
index 00000000..b9c648cc
--- /dev/null
+++ b/internal/review/attributes.go
@@ -0,0 +1,26 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package review
+
+import (
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+func (r *Review) Configure_attributes(format string) { r.attributeFormat = format }
+
+func (r *Review) Attribute() (screen.Attribute, bool) {
+ if !r.ready || len(r.screen.Attributes) != len(r.screen.Cells) || len(r.screen.Attributes) == 0 {
+ return screen.Attribute{}, false
+ }
+ return r.screen.Attributes[r.y*r.screen.Cols+r.x], true
+}
+
+func (r *Review) attribute_cursor() string {
+ attribute, valid := r.Attribute()
+ if !valid {
+ return ""
+ }
+ return presentation.Format_attributes(attribute, r.attributeFormat)
+}
diff --git a/internal/review/bottom.go b/internal/review/bottom.go
new file mode 100644
index 00000000..5862a452
--- /dev/null
+++ b/internal/review/bottom.go
@@ -0,0 +1,30 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package review
+
+import (
+ "git.stormux.org/storm/fenrir/internal/i18n"
+)
+
+func (r *Review) bottom() string {
+ r.x, r.y = 0, r.screen.Rows-1
+ if r.tableMode {
+ r.sync_table_column()
+ }
+ return i18n.T("Bottom")
+}
diff --git a/internal/review/cue_test.go b/internal/review/cue_test.go
new file mode 100644
index 00000000..840f9a24
--- /dev/null
+++ b/internal/review/cue_test.go
@@ -0,0 +1,51 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package review
+
+import (
+ "testing"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+func TestCuesDoNotChangeWordsOrLeakToNextCommand(t *testing.T) {
+ var r Review
+ r.Update(screen.Snapshot{Terminal: 1, Active: true, Rows: 2, Cols: 3, Cells: []rune("abc ")})
+ result := r.Execute(command.PreviousLine)
+ if text := result.Text; text != "start of screen" || result.Cue != presentation.Top {
+ t.Fatal(text, result.Cue)
+ }
+ result = r.Execute(command.CurrentLine)
+ if text := result.Text; text != "abc" || result.Cue != "" {
+ t.Fatal(text, result.Cue)
+ }
+ result = r.Execute(command.NextLine)
+ if text := result.Text; text != "blank" || result.Cue != presentation.Blank {
+ t.Fatal(text, result.Cue)
+ }
+ result = r.Execute(command.NextLine)
+ if text := result.Text; text != "end of screen" || result.Cue != presentation.Bottom {
+ t.Fatal(text, result.Cue)
+ }
+ result = r.Execute(command.PreviousChar)
+ if text := result.Text; text != "c" || result.Cue != "" || result.After.Text != "line break" || result.After.Cue != presentation.LineEnd {
+ t.Fatal(text, result.Cue)
+ }
+}
diff --git a/internal/review/current_character.go b/internal/review/current_character.go
new file mode 100644
index 00000000..eb9489ed
--- /dev/null
+++ b/internal/review/current_character.go
@@ -0,0 +1,28 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package review
+
+import (
+ "git.stormux.org/storm/fenrir/internal/presentation"
+)
+
+func (r *Review) currentCharacter() string {
+ char := r.screen.Line(r.y)[r.x]
+ r.result.Character = &char
+ return presentation.Character(char)
+}
diff --git a/internal/review/current_line.go b/internal/review/current_line.go
new file mode 100644
index 00000000..a49f65ad
--- /dev/null
+++ b/internal/review/current_line.go
@@ -0,0 +1,35 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package review
+
+import (
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "strings"
+)
+
+func (r *Review) currentLine() string {
+ r.x = 0
+ return r.line_text()
+}
+
+func (r *Review) line_text() string {
+ if strings.TrimSpace(r.screen.Text(r.y)) == "" {
+ r.result.Cue = presentation.Blank
+ }
+ return presentation.Line(r.screen, r.y)
+}
diff --git a/internal/review/current_word.go b/internal/review/current_word.go
new file mode 100644
index 00000000..e3e4e64f
--- /dev/null
+++ b/internal/review/current_word.go
@@ -0,0 +1,22 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package review
+
+func (r *Review) currentWord() string {
+ return r.word()
+}
diff --git a/internal/review/cursor_column.go b/internal/review/cursor_column.go
new file mode 100644
index 00000000..9e39ed56
--- /dev/null
+++ b/internal/review/cursor_column.go
@@ -0,0 +1,15 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package review
+
+import (
+ "strconv"
+
+ "git.stormux.org/storm/fenrir/internal/i18n"
+)
+
+func (r *Review) cursor_column() string {
+ r.result.After.Text = r.cursor_scope() + i18n.T(" column number")
+ return strconv.Itoa(r.x + 1)
+}
diff --git a/internal/review/cursor_line.go b/internal/review/cursor_line.go
new file mode 100644
index 00000000..5b745573
--- /dev/null
+++ b/internal/review/cursor_line.go
@@ -0,0 +1,15 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package review
+
+import (
+ "strconv"
+
+ "git.stormux.org/storm/fenrir/internal/i18n"
+)
+
+func (r *Review) cursor_line() string {
+ r.result.After.Text = r.cursor_scope() + i18n.T(" line number")
+ return strconv.Itoa(r.y + 1)
+}
diff --git a/internal/review/cursor_scope.go b/internal/review/cursor_scope.go
new file mode 100644
index 00000000..de7a461b
--- /dev/null
+++ b/internal/review/cursor_scope.go
@@ -0,0 +1,13 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package review
+
+import "git.stormux.org/storm/fenrir/internal/i18n"
+
+func (r *Review) cursor_scope() string {
+ if r.reviewing {
+ return i18n.T(" review cursor ")
+ }
+ return i18n.T(" text cursor ")
+}
diff --git a/internal/review/first_text.go b/internal/review/first_text.go
new file mode 100644
index 00000000..4f31bde3
--- /dev/null
+++ b/internal/review/first_text.go
@@ -0,0 +1,37 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package review
+
+import (
+ "git.stormux.org/storm/fenrir/internal/i18n"
+ "strconv"
+ "strings"
+ "unicode"
+)
+
+func (r *Review) firstText() string {
+ if strings.TrimSpace(r.screen.Text(r.y)) == "" {
+ return i18n.T("line is empty")
+ }
+ r.x = 0
+ for r.x < r.screen.Cols-1 && unicode.IsSpace(r.screen.Line(r.y)[r.x]) {
+ r.x++
+ }
+ r.result.After.Text = i18n.Format("first character in line indent {0}", strconv.Itoa(r.x))
+ return r.currentCharacter()
+}
diff --git a/internal/review/follow_cursor.go b/internal/review/follow_cursor.go
new file mode 100644
index 00000000..89627149
--- /dev/null
+++ b/internal/review/follow_cursor.go
@@ -0,0 +1,36 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package review
+
+import (
+ "git.stormux.org/storm/fenrir/internal/i18n"
+)
+
+func (r *Review) followCursor() string {
+ if !r.reviewing {
+ return i18n.T("Not in Review Mode")
+ }
+ r.Resume_tracking()
+ return i18n.T("Exiting Review Mode")
+}
+
+// Resume_tracking silently returns review ownership to the application cursor.
+func (r *Review) Resume_tracking() {
+ r.reviewing = false
+ r.x, r.y = r.screen.X, r.screen.Y
+}
diff --git a/internal/review/indentation.go b/internal/review/indentation.go
new file mode 100644
index 00000000..ba970bdb
--- /dev/null
+++ b/internal/review/indentation.go
@@ -0,0 +1,16 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package review
+
+import "git.stormux.org/storm/fenrir/internal/presentation"
+
+// Row and Indentation describe the effective review/text cursor without moving it.
+func (r *Review) Row() int { return r.y }
+
+func (r *Review) Indentation() (int, bool) {
+ if !r.ready {
+ return 0, false
+ }
+ return presentation.Indentation_level(r.screen.Line(r.y))
+}
diff --git a/internal/review/last_text.go b/internal/review/last_text.go
new file mode 100644
index 00000000..06f5c6d0
--- /dev/null
+++ b/internal/review/last_text.go
@@ -0,0 +1,36 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package review
+
+import (
+ "git.stormux.org/storm/fenrir/internal/i18n"
+ "strings"
+ "unicode"
+)
+
+func (r *Review) lastText() string {
+ if strings.TrimSpace(r.screen.Text(r.y)) == "" {
+ return i18n.T("line is empty")
+ }
+ r.x = r.screen.Cols - 1
+ for r.x > 0 && unicode.IsSpace(r.screen.Line(r.y)[r.x]) {
+ r.x--
+ }
+ r.result.After.Text = i18n.T("last character in line")
+ return r.currentCharacter()
+}
diff --git a/internal/review/line_end.go b/internal/review/line_end.go
new file mode 100644
index 00000000..fdc00337
--- /dev/null
+++ b/internal/review/line_end.go
@@ -0,0 +1,26 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package review
+
+import "git.stormux.org/storm/fenrir/internal/i18n"
+
+func (r *Review) lineEnd() string {
+ r.x = r.screen.Cols - 1
+ r.result.After.Text = i18n.T("end of line")
+ return r.currentCharacter()
+}
diff --git a/internal/review/line_from_cursor.go b/internal/review/line_from_cursor.go
new file mode 100644
index 00000000..cb946d09
--- /dev/null
+++ b/internal/review/line_from_cursor.go
@@ -0,0 +1,9 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package review
+
+func (r *Review) line_from_cursor() string {
+ r.result.After.Text = r.cursor_scope()
+ return r.screen_range(r.y*r.screen.Cols+r.x, (r.y+1)*r.screen.Cols)
+}
diff --git a/internal/review/line_start.go b/internal/review/line_start.go
new file mode 100644
index 00000000..087284b6
--- /dev/null
+++ b/internal/review/line_start.go
@@ -0,0 +1,34 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package review
+
+import (
+ "git.stormux.org/storm/fenrir/internal/i18n"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "unicode"
+)
+
+func (r *Review) lineStart() string {
+ r.x = 0
+ r.result.After.Text = i18n.T("beginning of line")
+ if unicode.IsSpace(r.screen.Line(r.y)[r.x]) {
+ r.result.Cue = presentation.Blank
+ return i18n.T("blank")
+ }
+ return r.currentCharacter()
+}
diff --git a/internal/review/line_to_cursor.go b/internal/review/line_to_cursor.go
new file mode 100644
index 00000000..b23ddff2
--- /dev/null
+++ b/internal/review/line_to_cursor.go
@@ -0,0 +1,9 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package review
+
+func (r *Review) line_to_cursor() string {
+ r.result.After.Text = r.cursor_scope()
+ return r.screen_range(r.y*r.screen.Cols, r.y*r.screen.Cols+r.x)
+}
diff --git a/internal/review/marks.go b/internal/review/marks.go
new file mode 100644
index 00000000..ba93fce3
--- /dev/null
+++ b/internal/review/marks.go
@@ -0,0 +1,93 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package review
+
+import (
+ "strings"
+
+ "git.stormux.org/storm/fenrir/internal/i18n"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+func (r *Review) Set_mark() presentation.Message {
+ if !r.ready || !r.reviewing {
+ return presentation.Message{Text: i18n.T("no review cursor")}
+ }
+ if r.tableMode {
+ return r.set_table_headers()
+ }
+ point := r.y*r.screen.Cols + r.x
+ cue := presentation.PlaceStartMark
+ if r.markCount == 0 {
+ r.startMark, r.markCount = point, 1
+ } else {
+ r.endMark, r.markCount = point, 2
+ cue = presentation.PlaceEndMark
+ }
+ return presentation.Message{Text: i18n.T("set mark"), Cue: cue}
+}
+
+func (r *Review) Clear_marks() { r.markCount = 0 }
+
+// Marked_region is rectangular, unlike clipboard selection. A single mark
+// optionally selects the rest of its physical row. Table header marks never
+// enter markCount and cannot accidentally become reading regions.
+func (r *Review) Marked_region(singleLine bool) (screen.Region, bool) {
+ if !r.ready || r.markCount == 0 || r.markCount < 2 && !singleLine {
+ return screen.Region{}, false
+ }
+ x1, y1 := r.startMark%r.screen.Cols, r.startMark/r.screen.Cols
+ if r.markCount == 1 {
+ return screen.Region{Left: x1, Top: y1, Bottom: y1, ToLineEnd: true}, true
+ }
+ x2, y2 := r.endMark%r.screen.Cols, r.endMark/r.screen.Cols
+ return screen.Region{Left: min(x1, x2), Top: min(y1, y2), Right: max(x1, x2), Bottom: max(y1, y2)}, true
+}
+
+// Marked_text uses inclusive Unicode cell positions in either direction. Copy
+// with one mark takes the current review cursor as its second endpoint.
+func (r *Review) Marked_text(copying bool) (string, bool) {
+ if !r.ready || r.markCount == 0 || !copying && r.markCount < 2 {
+ return "", false
+ }
+ end := r.endMark
+ if r.markCount == 1 {
+ end = r.y*r.screen.Cols + r.x
+ }
+ start := r.startMark
+ if start > end {
+ start, end = end, start
+ }
+ var lines []string
+ for row := start / r.screen.Cols; row <= end/r.screen.Cols; row++ {
+ left, right := 0, r.screen.Cols
+ if row == start/r.screen.Cols {
+ left = start % r.screen.Cols
+ }
+ if row == end/r.screen.Cols {
+ right = end%r.screen.Cols + 1
+ }
+ text := string(r.screen.Line(row)[left:right])
+ if start/r.screen.Cols != end/r.screen.Cols {
+ text = strings.TrimRight(text, " \x00\t\r")
+ }
+ lines = append(lines, text)
+ }
+ return strings.Join(lines, "\n"), true
+}
diff --git a/internal/review/marks_test.go b/internal/review/marks_test.go
new file mode 100644
index 00000000..a640e7ff
--- /dev/null
+++ b/internal/review/marks_test.go
@@ -0,0 +1,66 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package review
+
+import (
+ "testing"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+func TestMarksUnicodeReverseSingleMarkAndSessionRetirement(t *testing.T) {
+ snapshot := screen.Snapshot{Active: true, Terminal: 1, Rows: 2, Cols: 5, Cells: []rune("Ć©ēabcnext ")}
+ var review Review
+ review.Update(snapshot)
+ if got := review.Set_mark(); got.Text != "no review cursor" {
+ t.Fatal(got)
+ }
+ review.Execute(command.CurrentChar)
+ if got := review.Set_mark(); got.Cue != presentation.PlaceStartMark {
+ t.Fatal(got)
+ }
+ review.Execute(command.NextChar)
+ if text, ok := review.Marked_text(true); !ok || text != "Ć©ē" {
+ t.Fatal(text, ok)
+ }
+ if _, ok := review.Marked_text(false); ok {
+ t.Fatal("preview accepted one mark")
+ }
+ review.Execute(command.NextLine)
+ if got := review.Set_mark(); got.Cue != presentation.PlaceEndMark {
+ t.Fatal(got)
+ }
+ if text, ok := review.Marked_text(false); !ok || text != "Ć©ēabc\nne" {
+ t.Fatal(text, ok)
+ }
+ review.Clear_marks()
+ review.Set_mark()
+ review.Execute(command.Top)
+ review.Execute(command.LineStart)
+ review.Set_mark()
+ if text, ok := review.Marked_text(false); !ok || text != "Ć©ēabc\nne" {
+ t.Fatal("reverse selection", text, ok)
+ }
+ snapshot.Generation++
+ review.Update(snapshot)
+ if _, ok := review.Marked_text(true); ok {
+ t.Fatal("marks survived screen replacement")
+ }
+}
diff --git a/internal/review/navigation.go b/internal/review/navigation.go
new file mode 100644
index 00000000..e45c3e84
--- /dev/null
+++ b/internal/review/navigation.go
@@ -0,0 +1,52 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package review
+
+import (
+ "git.stormux.org/storm/fenrir/internal/i18n"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+)
+
+func (r *Review) screen_boundary(forward bool) string {
+ if r.suppressScreenBoundary {
+ return ""
+ }
+ if forward {
+ r.result.Cue = presentation.Bottom
+ return i18n.T("end of screen")
+ }
+ r.result.Cue = presentation.Top
+ return i18n.T("start of screen")
+}
+
+func (r *Review) line_break() {
+ if !r.suppressLineBreak {
+ r.result.After.Text, r.result.After.Cue = i18n.T("line break"), presentation.LineEnd
+ }
+}
+
+func (r *Review) move_character(forward bool) string {
+ if forward {
+ if r.x == r.screen.Cols-1 {
+ if r.y == r.screen.Rows-1 {
+ return r.screen_boundary(true)
+ }
+ r.x, r.y = 0, r.y+1
+ r.line_break()
+ } else {
+ r.x++
+ }
+ } else {
+ if r.x == 0 {
+ if r.y == 0 {
+ return r.screen_boundary(false)
+ }
+ r.x, r.y = r.screen.Cols-1, r.y-1
+ r.line_break()
+ } else {
+ r.x--
+ }
+ }
+ return r.currentCharacter()
+}
diff --git a/internal/review/navigation_test.go b/internal/review/navigation_test.go
new file mode 100644
index 00000000..21a40b8e
--- /dev/null
+++ b/internal/review/navigation_test.go
@@ -0,0 +1,213 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package review
+
+import (
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "git.stormux.org/storm/fenrir/internal/screen"
+ "testing"
+)
+
+func TestCharacterCrossesPhysicalRowsAndKeepsCharacterMetadata(t *testing.T) {
+ for _, feedback := range []bool{false, true} {
+ var r Review
+ r.Configure(feedback, feedback, true)
+ s := screen.Snapshot{Active: true, Rows: 3, Cols: 3, Cells: []rune("abĆ ēcd"), X: 2}
+ r.Update(s)
+ for _, trial := range []struct {
+ id command.ID
+ text string
+ x, y int
+ crossing bool
+ }{
+ {command.NextChar, "space", 0, 1, true},
+ {command.PreviousChar, "Ć", 2, 0, true},
+ {command.NextChar, "space", 0, 1, true},
+ {command.NextChar, "space", 1, 1, false},
+ {command.NextChar, "space", 2, 1, false},
+ {command.NextChar, "ē", 0, 2, true},
+ } {
+ result := r.Execute(trial.id)
+ if got := result.Text; got != trial.text || r.x != trial.x || r.y != trial.y || result.Cue != "" {
+ t.Fatalf("%s: %q at %d,%d", trial.id, got, r.x, r.y)
+ }
+ if result.Character == nil {
+ t.Fatal("lost character metadata")
+ }
+ if (result.After.Text == "line break") != (feedback && trial.crossing) || (result.After.Cue == presentation.LineEnd) != (feedback && trial.crossing) {
+ t.Fatal(result.After.Text, result.After.Cue)
+ }
+ }
+ r.Execute(command.ScreenLastChar)
+ result := r.Execute(command.NextChar)
+ text := result.Text
+ if feedback && (text != "end of screen" || result.Cue != presentation.Bottom) || !feedback && (text != "" || result.Cue != "") {
+ t.Fatal(text, result.Cue)
+ }
+ if r.x != 2 || r.y != 2 || result.After.Text != "" {
+ t.Fatal("screen edge moved cursor or retained line break")
+ }
+ r.Execute(command.Top)
+ text = r.Execute(command.PreviousChar).Text
+ if feedback && text != "start of screen" || !feedback && text != "" {
+ t.Fatal(text)
+ }
+ }
+}
+
+func TestWordSelectionFromInsideAndBetweenWords(t *testing.T) {
+ for _, trial := range []struct {
+ x, y int
+ id command.ID
+ text string
+ targetX, targetY int
+ crossing bool
+ }{
+ {4, 0, command.CurrentWord, "alpha", 2, 0, false},
+ {4, 0, command.PreviousWord, "start of screen", 4, 0, false},
+ {9, 0, command.PreviousWord, "alpha", 2, 0, false},
+ {8, 0, command.CurrentWord, "alpha", 2, 0, false},
+ {8, 0, command.NextWord, "beta!", 9, 0, false},
+ {0, 1, command.CurrentWord, "beta!", 9, 0, true},
+ {0, 1, command.PreviousWord, "alpha", 2, 0, true},
+ {9, 0, command.NextWord, "ēĆ©", 1, 2, true},
+ {1, 2, command.PreviousWord, "beta!", 9, 0, true},
+ {0, 0, command.CurrentWord, "start of screen", 0, 0, false},
+ {8, 2, command.NextWord, "end of screen", 8, 2, false},
+ } {
+ var r Review
+ s := fixture(" alpha beta!", "", " ēĆ© last")
+ s.X, s.Y = trial.x, trial.y
+ r.Update(s)
+ result := r.Execute(trial.id)
+ got := result.Text
+ if got != trial.text || r.x != trial.targetX || r.y != trial.targetY || (result.After.Text == "line break") != trial.crossing {
+ t.Fatalf("%+v: %q at %d,%d after=%q", trial, got, r.x, r.y, result.After.Text)
+ }
+ if s.X != r.screen.X || s.Y != r.screen.Y {
+ t.Fatal("moved application cursor")
+ }
+ }
+ var r Review
+ r.Update(fixture("", ""))
+ for _, id := range []command.ID{command.CurrentWord, command.PreviousWord, command.NextWord} {
+ result := r.Execute(id)
+ if got := result.Text; got != "blank" || result.Cue != presentation.Blank {
+ t.Fatal(id, got, result.Cue)
+ }
+ }
+}
+
+func TestReviewLineEndpointsAndTopBottom(t *testing.T) {
+ var r Review
+ s := fixture(" Ćx!", "\t\u2003", "last")
+ s.X = 3
+ r.Update(s)
+ for _, trial := range []struct {
+ id command.ID
+ text, after string
+ x, y int
+ }{
+ {command.FirstText, "Ć", "first character in line indent 2", 2, 0},
+ {command.LastText, "bang", "last character in line", 4, 0},
+ {command.NextLine, "\t\u2003", "", 4, 1},
+ {command.FirstText, "line is empty", "", 4, 1},
+ {command.LastText, "line is empty", "", 4, 1},
+ {command.LineStart, "blank", "beginning of line", 0, 1},
+ {command.LineEnd, "space", "end of line", 19, 1},
+ {command.CurrentLine, "\t\u2003", "", 0, 1},
+ {command.Bottom, "Bottom", "", 0, 2},
+ {command.NextChar, "a", "", 1, 2},
+ {command.Top, "Top", "", 0, 0},
+ } {
+ // Whitespace-only row content is presented as blank, including Unicode.
+ want := trial.text
+ if trial.id == command.NextLine || trial.id == command.CurrentLine {
+ want = "blank"
+ }
+ result := r.Execute(trial.id)
+ if got := result.Text; got != want || result.After.Text != trial.after || r.x != trial.x || r.y != trial.y {
+ t.Fatalf("%s: %q after=%q at %d,%d", trial.id, got, result.After.Text, r.x, r.y)
+ }
+ }
+}
+
+func TestTableBoundaryContextAndModeSynchronization(t *testing.T) {
+ var r Review
+ r.Update(fixture("Name|Subject|Empty", "JosĆ©|Ć!|"))
+ r.Set_table_mode(true)
+ r.Execute(command.CurrentWord)
+ r.Set_mark()
+ r.Execute(command.NextLine)
+ for _, trial := range []struct {
+ id command.ID
+ text, after string
+ cue presentation.Cue
+ }{
+ {command.NextWord, "Ć! Subject", "", ""},
+ {command.PreviousChar, "Ć", "start of cell", presentation.LineStart},
+ {command.NextChar, "bang", "", ""},
+ {command.NextChar, "bang", "end of cell", presentation.LineEnd},
+ {command.NextWord, "blank Empty", "", ""},
+ {command.NextChar, "blank", "end of cell", presentation.LineEnd},
+ {command.NextWord, "blank Empty", "end of line", presentation.LineEnd},
+ {command.LineStart, "JosƩ Name", "first cell", ""},
+ {command.PreviousWord, "JosƩ Name", "start of line", presentation.LineStart},
+ {command.LineEnd, "blank Empty", "last cell", ""},
+ } {
+ result := r.Execute(trial.id)
+ if got := result.Text; got != trial.text || result.After.Text != trial.after || result.After.Cue != trial.cue {
+ t.Fatalf("%s: %q after=%q cue=%q", trial.id, got, result.After.Text, result.After.Cue)
+ }
+ }
+ r.Execute(command.Top)
+ if got := r.Execute(command.CurrentWord).Text; got != "Name Name" {
+ t.Fatal("Top kept old table column", got)
+ }
+ r.Execute(command.LineEnd)
+ r.Execute(command.Bottom)
+ if got := r.Execute(command.CurrentWord).Text; got != "JosƩ Name" {
+ t.Fatal("Bottom kept old table column", got)
+ }
+ r.Execute(command.FollowCursor)
+ r.Set_table_mode(false)
+ r.Execute(command.LineEnd)
+ r.Set_table_mode(true)
+ if got := r.Execute(command.CurrentWord).Text; got != "Empty Column 3" {
+ t.Fatal("mode change did not resynchronize", got)
+ }
+}
+
+func TestReviewScreenChangePolicyAndClamping(t *testing.T) {
+ for _, leave := range []bool{false, true} {
+ var r Review
+ r.Configure(true, true, leave)
+ s := fixture("header|value", "data|cell")
+ r.Update(s)
+ r.Set_table_mode(true)
+ r.Execute(command.CurrentWord)
+ r.Set_mark()
+ r.Execute(command.Bottom)
+ r.Execute(command.LineEnd)
+ s.Terminal++
+ s.Generation++
+ r.Update(s)
+ if r.Active() == leave || len(r.tableHeaders) != 0 {
+ t.Fatal("wrong review/header ownership", r.Active())
+ }
+ if leave && (r.x != 0 || r.y != 0) || !leave && (r.x != 5 || r.y != 1) {
+ t.Fatal(r.x, r.y)
+ }
+ // A resized screen keeps the anchor only when explicitly requested,
+ // and bounds it before any action can index cells.
+ r.Execute(command.ScreenLastChar)
+ r.Update(fixture("x"))
+ r.Execute(command.CurrentChar)
+ r.Update(screen.Snapshot{})
+ if r.Active() {
+ t.Fatal("inactive session retained review")
+ }
+ }
+}
diff --git a/internal/review/next_character.go b/internal/review/next_character.go
new file mode 100644
index 00000000..20e97275
--- /dev/null
+++ b/internal/review/next_character.go
@@ -0,0 +1,22 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package review
+
+func (r *Review) nextCharacter() string {
+ return r.move_character(true)
+}
diff --git a/internal/review/next_line.go b/internal/review/next_line.go
new file mode 100644
index 00000000..9e45285e
--- /dev/null
+++ b/internal/review/next_line.go
@@ -0,0 +1,26 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package review
+
+func (r *Review) nextLine() string {
+ if r.y == r.screen.Rows-1 {
+ return r.screen_boundary(true)
+ }
+ r.y++
+ return r.line_text()
+}
diff --git a/internal/review/next_word.go b/internal/review/next_word.go
new file mode 100644
index 00000000..33f9eac1
--- /dev/null
+++ b/internal/review/next_word.go
@@ -0,0 +1,22 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package review
+
+func (r *Review) nextWord() string {
+ return r.moveWord(true)
+}
diff --git a/internal/review/phonetic_character.go b/internal/review/phonetic_character.go
new file mode 100644
index 00000000..e111875a
--- /dev/null
+++ b/internal/review/phonetic_character.go
@@ -0,0 +1,8 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package review
+
+import "git.stormux.org/storm/fenrir/internal/command"
+
+func (r *Review) phonetic_character() string { return r.spelling_text(command.CurrentChar, true) }
diff --git a/internal/review/phonetic_line.go b/internal/review/phonetic_line.go
new file mode 100644
index 00000000..803ad78a
--- /dev/null
+++ b/internal/review/phonetic_line.go
@@ -0,0 +1,8 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package review
+
+import "git.stormux.org/storm/fenrir/internal/command"
+
+func (r *Review) phonetic_line() string { return r.spelling_text(command.CurrentLine, true) }
diff --git a/internal/review/phonetic_word.go b/internal/review/phonetic_word.go
new file mode 100644
index 00000000..57988296
--- /dev/null
+++ b/internal/review/phonetic_word.go
@@ -0,0 +1,8 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package review
+
+import "git.stormux.org/storm/fenrir/internal/command"
+
+func (r *Review) phonetic_word() string { return r.spelling_text(command.CurrentWord, true) }
diff --git a/internal/review/position.go b/internal/review/position.go
new file mode 100644
index 00000000..d37964d2
--- /dev/null
+++ b/internal/review/position.go
@@ -0,0 +1,28 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package review
+
+import (
+ "git.stormux.org/storm/fenrir/internal/i18n"
+ "strconv"
+)
+
+func (r *Review) position() string {
+ s := r.screen
+ return i18n.Format("line {0}, column {1}, Terminal {2}", strconv.Itoa(r.y+1), strconv.Itoa(r.x+1), strconv.Itoa(s.Terminal))
+}
diff --git a/internal/review/previous_character.go b/internal/review/previous_character.go
new file mode 100644
index 00000000..46bada6a
--- /dev/null
+++ b/internal/review/previous_character.go
@@ -0,0 +1,22 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package review
+
+func (r *Review) previousCharacter() string {
+ return r.move_character(false)
+}
diff --git a/internal/review/previous_line.go b/internal/review/previous_line.go
new file mode 100644
index 00000000..17c0bd42
--- /dev/null
+++ b/internal/review/previous_line.go
@@ -0,0 +1,26 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package review
+
+func (r *Review) previousLine() string {
+ if r.y == 0 {
+ return r.screen_boundary(false)
+ }
+ r.y--
+ return r.line_text()
+}
diff --git a/internal/review/previous_word.go b/internal/review/previous_word.go
new file mode 100644
index 00000000..5311cc29
--- /dev/null
+++ b/internal/review/previous_word.go
@@ -0,0 +1,22 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package review
+
+func (r *Review) previousWord() string {
+ return r.moveWord(false)
+}
diff --git a/internal/review/read_screen.go b/internal/review/read_screen.go
new file mode 100644
index 00000000..232c6cef
--- /dev/null
+++ b/internal/review/read_screen.go
@@ -0,0 +1,37 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package review
+
+import (
+ "git.stormux.org/storm/fenrir/internal/i18n"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "strings"
+)
+
+func (r *Review) readScreen() string {
+ lines := make([]string, r.screen.Rows)
+ for row := range lines {
+ lines[row] = r.screen.Text(row)
+ }
+ text := strings.TrimSpace(strings.Join(lines, "\n"))
+ if text == "" {
+ r.result.Cue = presentation.Blank
+ return i18n.T("screen is empty")
+ }
+ return text
+}
diff --git a/internal/review/review.go b/internal/review/review.go
new file mode 100644
index 00000000..5a716295
--- /dev/null
+++ b/internal/review/review.go
@@ -0,0 +1,154 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+// Package review owns the review cursor and actions on a visible screen.
+// All state is used by the reader's event loop; actions never perform device or speech I/O.
+package review
+
+import (
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/i18n"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+// Review has a cursor independent of the application. Its zero value is ready for Update.
+type Review struct {
+ result presentation.CommandResult
+ attributeFormat string
+ screen screen.Snapshot
+ suppressLineBreak bool
+ suppressScreenBoundary bool
+ keepOnScreenChange bool
+ ready bool
+ reviewing bool
+ x, y int
+ markCount, startMark, endMark int
+ tableMode bool
+ tableColumn int
+ tableHeaders []string
+}
+
+func (r *Review) Configure(lineBreak, endOfScreen, leaveOnScreenChange bool) {
+ r.suppressLineBreak = !lineBreak
+ r.suppressScreenBoundary = !endOfScreen
+ r.keepOnScreenChange = !leaveOnScreenChange
+}
+
+// handlers is the explicit list of review actions. Add an action file and an
+// entry here; input bindings live separately in package input.
+var handlers = map[command.ID]func(*Review) string{
+ command.AttributeCursor: (*Review).attribute_cursor,
+ command.CursorLine: (*Review).cursor_line,
+ command.CursorColumn: (*Review).cursor_column,
+ command.LineToCursor: (*Review).line_to_cursor,
+ command.LineFromCursor: (*Review).line_from_cursor,
+ command.ScreenFirstChar: (*Review).screen_first_character,
+ command.ScreenLastChar: (*Review).screen_last_character,
+ command.ScreenBeforeCursor: (*Review).screen_before_cursor,
+ command.ScreenAfterCursor: (*Review).screen_after_cursor,
+ command.ReviewUp: (*Review).review_up,
+ command.ReviewDown: (*Review).review_down,
+
+ command.SpellWord: (*Review).spell_word,
+ command.PhoneticWord: (*Review).phonetic_word,
+ command.SpellLine: (*Review).spell_line,
+ command.PhoneticLine: (*Review).phonetic_line,
+ command.PhoneticChar: (*Review).phonetic_character,
+ command.PreviousLine: (*Review).previousLine,
+ command.CurrentLine: (*Review).currentLine,
+ command.NextLine: (*Review).nextLine,
+ command.Top: (*Review).top,
+ command.Bottom: (*Review).bottom,
+ command.PreviousChar: (*Review).previousCharacter,
+ command.CurrentChar: (*Review).currentCharacter,
+ command.NextChar: (*Review).nextCharacter,
+ command.LineStart: (*Review).lineStart,
+ command.LineEnd: (*Review).lineEnd,
+ command.FirstText: (*Review).firstText,
+ command.LastText: (*Review).lastText,
+ command.PreviousWord: (*Review).previousWord,
+ command.CurrentWord: (*Review).currentWord,
+ command.NextWord: (*Review).nextWord,
+ command.Position: (*Review).position,
+ command.ReadScreen: (*Review).readScreen,
+ command.FollowCursor: (*Review).followCursor,
+}
+
+// Update refreshes the screen, applying the configured review-exit policy.
+// It reports whether the screen session changed, for automatic reading.
+func (r *Review) Update(next screen.Snapshot) bool {
+ previous := r.screen
+ switched := !r.ready || previous.Terminal != next.Terminal || previous.Generation != next.Generation || previous.Active != next.Active || previous.Rows != next.Rows || previous.Cols != next.Cols
+ r.screen = next
+ r.ready = next.Active
+ if switched {
+ r.Clear_marks()
+ r.tableHeaders = nil
+ r.tableColumn = 0
+ }
+ if !next.Active {
+ r.reviewing = false
+ return switched
+ }
+ if switched && !r.keepOnScreenChange || !r.reviewing {
+ r.x, r.y = next.X, next.Y
+ if switched {
+ r.reviewing = false
+ }
+ }
+ r.x, r.y = max(0, min(r.x, next.Cols-1)), max(0, min(r.y, next.Rows-1))
+ if r.tableMode && (switched || !r.reviewing) {
+ r.sync_table_column()
+ }
+ return switched
+}
+
+// Active reports whether review is detached from the application cursor.
+func (r *Review) Active() bool { return r.reviewing }
+
+// Execute checks screen availability before invoking an action. Action methods
+// share this guard; the caller owns presentation and interruption.
+func (r *Review) Execute(id command.ID) presentation.CommandResult {
+ r.result = presentation.CommandResult{}
+ r.result.Text = r.execute(id)
+ return r.result
+}
+
+func (r *Review) execute(id command.ID) string {
+ if !r.ready {
+ r.result.Cue = presentation.Error
+ return i18n.T("no text console")
+ }
+ action, ok := handlers[id]
+ if !ok {
+ r.result.Cue = presentation.Error
+ return i18n.T("unbound")
+ }
+ switch id {
+ case command.AttributeCursor, command.CursorLine, command.CursorColumn, command.LineToCursor, command.LineFromCursor, command.FollowCursor:
+ // Reports use the active cursor without entering detached review.
+ default:
+ r.reviewing = true
+ }
+ if r.tableMode {
+ if text, handled := r.table_command(id); handled {
+ return text
+ }
+ }
+ return action(r)
+}
diff --git a/internal/review/review_down.go b/internal/review/review_down.go
new file mode 100644
index 00000000..b2a5a649
--- /dev/null
+++ b/internal/review/review_down.go
@@ -0,0 +1,8 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package review
+
+func (r *Review) review_down() string {
+ return r.vertical_character(1)
+}
diff --git a/internal/review/review_test.go b/internal/review/review_test.go
new file mode 100644
index 00000000..fa9972ba
--- /dev/null
+++ b/internal/review/review_test.go
@@ -0,0 +1,175 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package review
+
+import (
+ "strings"
+ "testing"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+func fixture(lines ...string) screen.Snapshot {
+ const width = 20
+ s := screen.Snapshot{Terminal: 2, Active: true, Rows: len(lines), Cols: width}
+ for _, text := range lines {
+ line := []rune(text)
+ s.Cells = append(s.Cells, line...)
+ s.Cells = append(s.Cells, []rune(strings.Repeat(" ", width-len(line)))...)
+ }
+ return s
+}
+
+func TestReviewDoesNotMoveApplicationCursor(t *testing.T) {
+ r := &Review{}
+ s := fixture("one two", "cafĆ© ē", "last")
+ s.X, s.Y = 2, 1
+ r.Update(s)
+ if got := r.Execute(command.CurrentWord).Text; got != "cafƩ" {
+ t.Fatal(got)
+ }
+ if got := r.Execute(command.PreviousLine).Text; got != "one two" {
+ t.Fatal(got)
+ }
+ if got := r.Execute(command.NextWord).Text; got != "two" {
+ t.Fatal(got)
+ }
+ if got := r.Execute(command.PreviousWord).Text; got != "one" {
+ t.Fatal(got)
+ }
+ if got := r.Execute(command.NextChar).Text; got != "n" {
+ t.Fatal(got)
+ }
+ if r.screen.X != 2 || r.screen.Y != 1 {
+ t.Fatal("review moved application cursor")
+ }
+ s.X, s.Y = 0, 2
+ r.Update(s)
+ if r.x != 1 || r.y != 0 {
+ t.Fatal("review followed application cursor")
+ }
+ r.Execute(command.FollowCursor)
+ if r.x != 0 || r.y != 2 {
+ t.Fatal("follow did not reset review")
+ }
+ if got := r.Execute(command.ReadScreen).Text; got != "one two\ncafĆ© ē\nlast" {
+ t.Fatal(got)
+ }
+}
+
+func TestReviewBoundariesAndResize(t *testing.T) {
+ r := &Review{}
+ r.Update(fixture(" alpha", "omega"))
+ if got := r.Execute(command.PreviousLine).Text; got != "start of screen" {
+ t.Fatal(got)
+ }
+ if got := r.Execute(command.FirstText).Text; got != "a" {
+ t.Fatal(got)
+ }
+ if got := r.Execute(command.LastText).Text; got != "a" {
+ t.Fatal(got)
+ }
+ r.Execute(command.Bottom)
+ if got := r.Execute(command.NextLine).Text; got != "end of screen" {
+ t.Fatal(got)
+ }
+ if got := r.Execute(command.Position).Text; !strings.Contains(got, "line 2, column") {
+ t.Fatal(got)
+ }
+ r.Update(fixture("new"))
+ if got := r.Execute(command.CurrentLine).Text; got != "new" {
+ t.Fatal(got)
+ }
+ r.Update(screen.Snapshot{Terminal: 1})
+ if got := r.Execute(command.CurrentLine).Text; got != "no text console" {
+ t.Fatal(got)
+ }
+}
+
+func TestReviewAcrossBlankRowsAndScreenEdges(t *testing.T) {
+ var r Review
+ r.Update(fixture("one two", "", "cafĆ© ē"))
+ for _, step := range []struct {
+ id command.ID
+ text string
+ }{
+ {command.CurrentLine, "one two"},
+ {command.PreviousChar, "start of screen"},
+ {command.PreviousWord, "start of screen"},
+ {command.CurrentChar, "o"},
+ {command.NextWord, "two"},
+ {command.CurrentWord, "two"},
+ {command.NextChar, "w"},
+ {command.PreviousChar, "t"},
+ {command.NextWord, "cafƩ"},
+ {command.NextWord, "ē"},
+ {command.NextWord, "end of screen"},
+ {command.PreviousWord, "cafƩ"},
+ {command.NextLine, "end of screen"},
+ {command.Top, "Top"},
+ {command.NextLine, "blank"},
+ {command.CurrentWord, "two"},
+ {command.LineEnd, "space"},
+ {command.NextChar, "space"},
+ {command.Position, "line 2, column 1, Terminal 2"},
+ {command.Bottom, "Bottom"},
+ {command.LastText, "ē"},
+ {command.FirstText, "c"},
+ {command.LineStart, "c"},
+ {command.FollowCursor, "Exiting Review Mode"},
+ {command.ReadScreen, "one two\n\ncafĆ© ē"},
+ } {
+ if got := r.Execute(step.id).Text; got != step.text {
+ t.Fatalf("%s: got %q, want %q", step.id, got, step.text)
+ }
+ }
+}
+
+func TestReviewAvailabilityAndSessionChanges(t *testing.T) {
+ var r Review
+ if got := r.Execute(command.NextChar).Text; got != "no text console" || r.Active() {
+ t.Fatalf("review without a screen: %q, active %v", got, r.Active())
+ }
+ s := fixture("alpha", "omega")
+ r.Update(s)
+ if got := r.Execute(command.ID("unknown")).Text; got != "unbound" || r.Active() {
+ t.Fatalf("unknown command: %q, active %v", got, r.Active())
+ }
+ r.Execute(command.Bottom)
+ s.Terminal = 3
+ if !r.Update(s) || r.Active() {
+ t.Fatal("VT switch did not reset review")
+ }
+ if got := r.Execute(command.CurrentLine).Text; got != "alpha" {
+ t.Fatal(got)
+ }
+ r.Update(screen.Snapshot{Terminal: 1})
+ if r.Active() {
+ t.Fatal("graphical VT retained review")
+ }
+ s.Y = 1
+ r.Update(s)
+ if got := r.Execute(command.CurrentLine).Text; got != "omega" {
+ t.Fatal("returning from graphics did not follow the application cursor", got)
+ }
+ r.Update(fixture(""))
+ if got := r.Execute(command.ReadScreen).Text; got != "screen is empty" {
+ t.Fatal(got)
+ }
+}
diff --git a/internal/review/review_up.go b/internal/review/review_up.go
new file mode 100644
index 00000000..08d2ffc9
--- /dev/null
+++ b/internal/review/review_up.go
@@ -0,0 +1,8 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package review
+
+func (r *Review) review_up() string {
+ return r.vertical_character(-1)
+}
diff --git a/internal/review/screen_after_cursor.go b/internal/review/screen_after_cursor.go
new file mode 100644
index 00000000..418242d0
--- /dev/null
+++ b/internal/review/screen_after_cursor.go
@@ -0,0 +1,22 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package review
+
+func (r *Review) screen_after_cursor() string {
+ return r.screen_range(r.y*r.screen.Cols+r.x, r.screen.Rows*r.screen.Cols)
+}
diff --git a/internal/review/screen_before_cursor.go b/internal/review/screen_before_cursor.go
new file mode 100644
index 00000000..34c68991
--- /dev/null
+++ b/internal/review/screen_before_cursor.go
@@ -0,0 +1,22 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package review
+
+func (r *Review) screen_before_cursor() string {
+ return r.screen_range(0, r.y*r.screen.Cols+r.x+1)
+}
diff --git a/internal/review/screen_first_character.go b/internal/review/screen_first_character.go
new file mode 100644
index 00000000..f03c52c1
--- /dev/null
+++ b/internal/review/screen_first_character.go
@@ -0,0 +1,14 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package review
+
+import "git.stormux.org/storm/fenrir/internal/i18n"
+
+func (r *Review) screen_first_character() string {
+ r.x, r.y = 0, 0
+ if r.tableMode {
+ r.sync_table_column()
+ }
+ return r.currentCharacter() + " " + i18n.T("first character in screen")
+}
diff --git a/internal/review/screen_last_character.go b/internal/review/screen_last_character.go
new file mode 100644
index 00000000..60c481e8
--- /dev/null
+++ b/internal/review/screen_last_character.go
@@ -0,0 +1,14 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package review
+
+import "git.stormux.org/storm/fenrir/internal/i18n"
+
+func (r *Review) screen_last_character() string {
+ r.x, r.y = r.screen.Cols-1, r.screen.Rows-1
+ if r.tableMode {
+ r.sync_table_column()
+ }
+ return r.currentCharacter() + " " + i18n.T("last character in screen")
+}
diff --git a/internal/review/screen_range.go b/internal/review/screen_range.go
new file mode 100644
index 00000000..39672bab
--- /dev/null
+++ b/internal/review/screen_range.go
@@ -0,0 +1,42 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package review
+
+import (
+ "strings"
+
+ "git.stormux.org/storm/fenrir/internal/i18n"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+)
+
+// screen_range reads a half-open range of screen cells, retaining row breaks
+// and indentation while omitting terminal padding. It never moves either cursor.
+func (r *Review) screen_range(start, end int) string {
+ var lines []string
+ for start < end {
+ rowEnd := min((start/r.screen.Cols+1)*r.screen.Cols, end)
+ lines = append(lines, strings.TrimRight(string(r.screen.Cells[start:rowEnd]), " \x00"))
+ start = rowEnd
+ }
+ text := strings.Join(lines, "\n")
+ if strings.TrimSpace(text) == "" {
+ r.result.Cue = presentation.Blank
+ return i18n.T("blank")
+ }
+ return text
+}
diff --git a/internal/review/screen_range_test.go b/internal/review/screen_range_test.go
new file mode 100644
index 00000000..652258c2
--- /dev/null
+++ b/internal/review/screen_range_test.go
@@ -0,0 +1,120 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package review
+
+import (
+ "testing"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+func TestScreenRangesIncludeCursorAndPreserveRows(t *testing.T) {
+ for _, testCase := range []struct {
+ name string
+ lines []string
+ x, y int
+ before, after string
+ }{
+ {"unicode middle", []string{" first", "", "cafĆ© ē!", " last"}, 5, 2, " first\n\ncafĆ© ē", "ē!\n last"},
+ {"top left", []string{"alpha", "bravo"}, 0, 0, "a", "alpha\nbravo"},
+ {"bottom right", []string{"alpha", "bravo"}, 19, 1, "alpha\nbravo", "blank"},
+ {"one row", []string{"abēcd"}, 2, 0, "abē", "ēcd"},
+ {"blank row", []string{"alpha", "", "last"}, 7, 1, "alpha\n", "\nlast"},
+ {"trailing padding", []string{"alpha", " last"}, 14, 0, "alpha", "\n last"},
+ {"blank screen", []string{"", ""}, 4, 1, "blank", "blank"},
+ {"literal blank", []string{"blank"}, 0, 0, "b", "blank"},
+ {"last visible character", []string{"ē"}, 0, 0, "ē", "ē"},
+ } {
+ t.Run(testCase.name, func(t *testing.T) {
+ var review Review
+ snapshot := fixture(testCase.lines...)
+ snapshot.X, snapshot.Y = testCase.x, testCase.y
+ review.Update(snapshot)
+ for _, action := range []struct {
+ id command.ID
+ want string
+ }{
+ {command.ScreenBeforeCursor, testCase.before},
+ {command.ScreenAfterCursor, testCase.after},
+ } {
+ result := review.Execute(action.id)
+ if got := result.Text; got != action.want {
+ t.Fatalf("%s: got %q, want %q", action.id, got, action.want)
+ }
+ wantCue := presentation.Cue("")
+ if testCase.name == "blank screen" || testCase.name == "bottom right" && action.id == command.ScreenAfterCursor {
+ wantCue = presentation.Blank
+ }
+ if result.Cue != wantCue {
+ t.Fatalf("%s: cue %q, want %q", action.id, result.Cue, wantCue)
+ }
+ if review.x != testCase.x || review.y != testCase.y || review.screen.X != testCase.x || review.screen.Y != testCase.y {
+ t.Fatal("range action moved a cursor")
+ }
+ }
+ })
+ }
+}
+
+func TestScreenRangesPreferDetachedReviewAndKeepTableScope(t *testing.T) {
+ var review Review
+ snapshot := fixture("Name | City", "Ana | Paris", "Zoƫ | Rome")
+ snapshot.X, snapshot.Y = 7, 0
+ review.Update(snapshot)
+ review.Execute(command.CurrentChar)
+ review.Set_mark()
+ review.Set_table_mode(true)
+ review.Execute(command.NextLine)
+ // Application cursor moves while detached review stays on the second row.
+ snapshot.X, snapshot.Y = 0, 2
+ review.Update(snapshot)
+ for _, action := range []struct {
+ id command.ID
+ want string
+ }{
+ {command.ScreenBeforeCursor, "Name | City\nAna | Pa"},
+ {command.ScreenAfterCursor, "aris\nZoƫ | Rome"},
+ } {
+ if got := review.Execute(action.id).Text; got != action.want {
+ t.Fatalf("%s: %q, want %q", action.id, got, action.want)
+ }
+ if review.x != 7 || review.y != 1 || review.tableColumn != 1 || review.markCount != 1 || !review.Active() {
+ t.Fatal("range action disturbed review, column or mark state")
+ }
+ }
+ review.Execute(command.FollowCursor)
+ if got := review.Execute(command.ScreenAfterCursor).Text; got != "Zoƫ | Rome" {
+ t.Fatal("follow did not restore application cursor scope", got)
+ }
+ // Screen generation and geometry changes must discard the old anchor.
+ snapshot = fixture("new")
+ snapshot.Generation = 1
+ review.Update(snapshot)
+ if got := review.Execute(command.ScreenBeforeCursor).Text; got != "n" {
+ t.Fatal("range kept an anchor from another screen", got)
+ }
+ review.Update(screen.Snapshot{})
+ for _, id := range []command.ID{command.ScreenBeforeCursor, command.ScreenAfterCursor} {
+ result := review.Execute(id)
+ if got := result.Text; got != "no text console" || result.Cue != presentation.Error {
+ t.Fatal("inactive screen", got, result.Cue)
+ }
+ }
+}
diff --git a/internal/review/spell_line.go b/internal/review/spell_line.go
new file mode 100644
index 00000000..d5aa4efe
--- /dev/null
+++ b/internal/review/spell_line.go
@@ -0,0 +1,8 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package review
+
+import "git.stormux.org/storm/fenrir/internal/command"
+
+func (r *Review) spell_line() string { return r.spelling_text(command.CurrentLine, false) }
diff --git a/internal/review/spell_word.go b/internal/review/spell_word.go
new file mode 100644
index 00000000..d111032e
--- /dev/null
+++ b/internal/review/spell_word.go
@@ -0,0 +1,8 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package review
+
+import "git.stormux.org/storm/fenrir/internal/command"
+
+func (r *Review) spell_word() string { return r.spelling_text(command.CurrentWord, false) }
diff --git a/internal/review/spellcheck.go b/internal/review/spellcheck.go
new file mode 100644
index 00000000..812add57
--- /dev/null
+++ b/internal/review/spellcheck.go
@@ -0,0 +1,11 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package review
+
+func (r *Review) Spellcheck_word() string {
+ if !r.ready || r.screen.Cols <= 0 || len(r.screen.Cells) == 0 {
+ return ""
+ }
+ return r.raw_word()
+}
diff --git a/internal/review/spelling.go b/internal/review/spelling.go
new file mode 100644
index 00000000..72820653
--- /dev/null
+++ b/internal/review/spelling.go
@@ -0,0 +1,48 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package review
+
+import (
+ "strings"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/i18n"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+)
+
+func (r *Review) spelling_text(scope command.ID, phonetic bool) string {
+ var text string
+ if r.tableMode && scope != command.CurrentLine {
+ cells := table_cells(r.screen.Line(r.y))
+ if len(cells) == 0 || r.tableColumn >= len(cells) {
+ return i18n.T("no table data")
+ }
+ cell := cells[r.tableColumn]
+ if scope == command.CurrentWord {
+ // Spell the complete selected cell, excluding its spoken header.
+ text = string(r.screen.Line(r.y)[cell.start:cell.end])
+ } else if cell.start != cell.end {
+ first, last := min(cell.start, r.screen.Cols-1), min(cell.end-1, r.screen.Cols-1)
+ if r.x < first || r.x > last {
+ r.x = first
+ }
+ text = string(r.screen.Line(r.y)[r.x])
+ }
+ } else {
+ switch scope {
+ case command.CurrentLine:
+ text = r.screen.Text(r.y)
+ case command.CurrentWord:
+ text = r.raw_word()
+ case command.CurrentChar:
+ text = string(r.screen.Line(r.y)[r.x])
+ }
+ }
+ if strings.TrimSpace(text) == "" {
+ r.result.Cue = presentation.Blank
+ return i18n.T("blank")
+ }
+ r.result.Spelling = &presentation.Spelling{Text: text, Phonetic: phonetic}
+ return text
+}
diff --git a/internal/review/spelling_test.go b/internal/review/spelling_test.go
new file mode 100644
index 00000000..9332993a
--- /dev/null
+++ b/internal/review/spelling_test.go
@@ -0,0 +1,114 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package review
+
+import (
+ "testing"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+)
+
+func TestSpellingUsesSourceAtDetachedReviewPosition(t *testing.T) {
+ var review Review
+ snapshot := fixture("A book!?Ćē", "other")
+ snapshot.X = 3
+ review.Update(snapshot)
+ for _, trial := range []struct {
+ id command.ID
+ text string
+ phonetic bool
+ }{
+ {command.SpellWord, "book!?Ćē", false},
+ {command.PhoneticWord, "book!?Ćē", true},
+ {command.SpellLine, "A book!?Ćē", false},
+ {command.PhoneticLine, "A book!?Ćē", true},
+ {command.PhoneticChar, "o", true},
+ } {
+ result := review.Execute(trial.id)
+ if got := result.Text; got != trial.text {
+ t.Fatal(trial.id, got)
+ }
+ spelling := result.Spelling
+ if spelling == nil || spelling.Text != trial.text || spelling.Phonetic != trial.phonetic {
+ t.Fatal(trial.id, spelling)
+ }
+ if result.Character != nil || result.Cue != "" {
+ t.Fatal("source was converted to character feedback or a cue")
+ }
+ // Application motion and passive updates must not move detached review.
+ snapshot.X, snapshot.Y = 0, 1
+ review.Update(snapshot)
+ if review.x != 3 || review.y != 0 || !review.Active() {
+ t.Fatal("spelling moved or lost review")
+ }
+ }
+ result := review.Execute(command.CurrentWord)
+ if result.Spelling != nil {
+ t.Fatal("spelling leaked into ordinary review")
+ }
+ snapshot.Active = false
+ review.Update(snapshot)
+ result = review.Execute(command.SpellWord)
+ if result.Spelling != nil || result.Cue != presentation.Error {
+ t.Fatal("unavailable screen was spelled")
+ }
+}
+
+func TestSpellingBlanksAndLiteralAnnouncements(t *testing.T) {
+ for _, text := range []string{"", " ", "blank", "space"} {
+ for _, id := range []command.ID{command.SpellWord, command.PhoneticWord, command.SpellLine, command.PhoneticLine, command.PhoneticChar} {
+ var review Review
+ review.Update(fixture(text))
+ result := review.Execute(id)
+ if text == "" || text == " " {
+ if result.Spelling != nil || result.Cue != presentation.Blank {
+ t.Fatal(text, id, result.Spelling, result.Cue)
+ }
+ } else if result.Spelling == nil || result.Cue != "" {
+ t.Fatal("literal announcement was not spelled", text, id)
+ }
+ }
+ }
+}
+
+func TestTableSpellingKeepsCellScopeAndExcludesHeaders(t *testing.T) {
+ var review Review
+ snapshot := fixture("Name|Subject|Empty", "Same|A book!||Tail")
+ review.Update(snapshot)
+ review.Set_table_mode(true)
+ review.Execute(command.CurrentWord)
+ review.Set_mark()
+ review.Execute(command.NextLine)
+ review.Execute(command.NextWord)
+ if got := review.Execute(command.CurrentWord).Text; got != "A book! Subject" {
+ t.Fatal(got)
+ }
+ for _, id := range []command.ID{command.SpellWord, command.PhoneticWord} {
+ result := review.Execute(id)
+ if got := result.Text; got != "A book!" || result.Spelling == nil {
+ t.Fatal(id, got, result.Spelling)
+ }
+ }
+ if got := review.Execute(command.PhoneticChar).Text; got != "A" {
+ t.Fatal(got)
+ }
+ if got := review.Execute(command.PhoneticLine).Text; got != "Same|A book!||Tail" {
+ t.Fatal("line scope was restricted to a cell", got)
+ }
+ review.Execute(command.NextWord)
+ result := review.Execute(command.PhoneticWord)
+ if result.Spelling != nil || result.Cue != presentation.Blank {
+ t.Fatal("empty cell was spelled as a header", result.Spelling)
+ }
+ result = review.Execute(command.PhoneticChar)
+ if result.Spelling != nil || result.Cue != presentation.Blank {
+ t.Fatal("empty cell character escaped its cell")
+ }
+ review.Update(fixture(""))
+ result = review.Execute(command.PhoneticWord)
+ if got := result.Text; got != "no table data" || result.Spelling != nil {
+ t.Fatal("table error was spelled", got)
+ }
+}
diff --git a/internal/review/table.go b/internal/review/table.go
new file mode 100644
index 00000000..9843b8ea
--- /dev/null
+++ b/internal/review/table.go
@@ -0,0 +1,230 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package review
+
+import (
+ "strconv"
+ "strings"
+ "unicode"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/i18n"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+)
+
+type TableCell struct {
+ start, end int // trimmed, half-open Unicode cell bounds; empty cells retained
+}
+
+// Match Python's delimiter preference and two-or-more-space aligned columns.
+// Bounds come directly from screen positions, never byte offsets or searches
+// for cell text (which can repeat within a row).
+func table_cells(line []rune) []TableCell {
+ end := len(line)
+ for end > 0 && (line[end-1] == ' ' || line[end-1] == 0) {
+ end--
+ }
+ if end == 0 {
+ return nil
+ }
+ var separator rune
+ for _, candidate := range []rune{',', '|', ';', '\t'} {
+ if strings.ContainsRune(string(line[:end]), candidate) {
+ separator = candidate
+ break
+ }
+ }
+ var cells []TableCell
+ appendCell := func(start, end int) {
+ for start < end && unicode.IsSpace(line[start]) {
+ start++
+ }
+ for end > start && unicode.IsSpace(line[end-1]) {
+ end--
+ }
+ cells = append(cells, TableCell{start: start, end: end})
+ }
+ start := 0
+ for index := 0; index < end; index++ {
+ if separator != 0 {
+ if line[index] == separator {
+ appendCell(start, index)
+ start = index + 1
+ }
+ continue
+ }
+ if line[index] != ' ' {
+ continue
+ }
+ gap := index
+ for index+1 < end && line[index+1] == ' ' {
+ index++
+ }
+ if index-gap >= 1 {
+ // Leading indentation is not an empty aligned column.
+ if gap > start {
+ appendCell(start, gap)
+ }
+ start = index + 1
+ }
+ }
+ appendCell(start, end)
+ return cells
+}
+
+func (r *Review) Set_table_mode(enabled bool) {
+ if r.tableMode == enabled {
+ return
+ }
+ r.tableMode = enabled
+ r.tableHeaders = nil
+ if enabled && r.ready {
+ r.sync_table_column()
+ }
+}
+
+func (r *Review) sync_table_column() {
+ cells := table_cells(r.screen.Line(r.y))
+ r.tableColumn = 0
+ for index, cell := range cells {
+ if r.x < cell.start {
+ break
+ }
+ r.tableColumn = index
+ }
+}
+
+func (r *Review) column_header() string {
+ if r.tableColumn < len(r.tableHeaders) && r.tableHeaders[r.tableColumn] != "" {
+ return r.tableHeaders[r.tableColumn]
+ }
+ // Python's generic column label is not a gettext message ID.
+ return "Column " + strconv.Itoa(r.tableColumn+1)
+}
+
+func (r *Review) table_cell_text(cells []TableCell) string {
+ cell := cells[r.tableColumn]
+ text := string(r.screen.Line(r.y)[cell.start:cell.end])
+ if text == "" {
+ text = i18n.T("blank")
+ }
+ return text + " " + r.column_header()
+}
+
+func (r *Review) set_table_headers() presentation.Message {
+ cells := table_cells(r.screen.Line(r.y))
+ if len(cells) == 0 {
+ return presentation.Message{Text: i18n.T("could not set header row")}
+ }
+ r.tableHeaders = nil
+ for _, cell := range cells {
+ r.tableHeaders = append(r.tableHeaders, string(r.screen.Line(r.y)[cell.start:cell.end]))
+ }
+ return presentation.Message{Text: i18n.T("header row set"), Cue: presentation.PlaceStartMark}
+}
+
+func (r *Review) table_command(id command.ID) (string, bool) {
+ switch id {
+ case command.CurrentWord, command.PreviousWord, command.NextWord, command.LineStart, command.LineEnd,
+ command.CurrentChar, command.PreviousChar, command.NextChar, command.FirstText, command.LastText:
+ default:
+ return "", false
+ }
+ cells := table_cells(r.screen.Line(r.y))
+ if len(cells) == 0 {
+ return i18n.T("no table data"), true
+ }
+ if id == command.LineStart {
+ r.tableColumn = 0
+ } else if id == command.LineEnd {
+ r.tableColumn = len(cells) - 1
+ }
+ if r.tableColumn >= len(cells) {
+ return i18n.T("no table data"), true
+ }
+ switch id {
+ case command.CurrentWord:
+ return r.table_cell_text(cells), true
+ case command.PreviousWord, command.NextWord, command.LineStart, command.LineEnd:
+ boundary := ""
+ switch id {
+ case command.PreviousWord:
+ if r.tableColumn == 0 {
+ boundary = i18n.T("start of line")
+ r.result.After.Cue = presentation.LineStart
+ } else {
+ r.tableColumn--
+ }
+ case command.NextWord:
+ if r.tableColumn == len(cells)-1 {
+ boundary = i18n.T("end of line")
+ r.result.After.Cue = presentation.LineEnd
+ } else {
+ r.tableColumn++
+ }
+ case command.LineStart:
+ r.tableColumn = 0
+ case command.LineEnd:
+ r.tableColumn = len(cells) - 1
+ }
+ r.x = min(cells[r.tableColumn].start, r.screen.Cols-1)
+ text := r.table_cell_text(cells)
+ if boundary != "" {
+ r.result.After.Text = boundary
+ } else if id == command.LineStart {
+ r.result.After.Text = i18n.T("first cell")
+ } else if id == command.LineEnd {
+ r.result.After.Text = i18n.T("last cell")
+ }
+ return text, true
+ }
+ cell := cells[r.tableColumn]
+ first, last := min(cell.start, r.screen.Cols-1), max(cell.start, cell.end-1)
+ last = min(last, r.screen.Cols-1)
+ if r.x < first || r.x > last {
+ r.x = first
+ }
+ character := func() string {
+ if cell.start == cell.end {
+ r.result.Cue = presentation.Blank
+ return i18n.T("blank")
+ }
+ return r.currentCharacter()
+ }
+ switch id {
+ case command.PreviousChar:
+ if r.x == first {
+ r.result.After.Text, r.result.After.Cue = i18n.T("start of cell"), presentation.LineStart
+ return character(), true
+ }
+ r.x--
+ case command.NextChar:
+ if r.x == last {
+ r.result.After.Text, r.result.After.Cue = i18n.T("end of cell"), presentation.LineEnd
+ return character(), true
+ }
+ r.x++
+ case command.FirstText:
+ r.x = first
+ return character() + " " + i18n.Format("first character in cell {0}", r.column_header()), true
+ case command.LastText:
+ r.x = last
+ return character() + " " + i18n.Format("last character in cell {0}", r.column_header()), true
+ }
+ return character(), true
+}
diff --git a/internal/review/table_test.go b/internal/review/table_test.go
new file mode 100644
index 00000000..0df3fdc4
--- /dev/null
+++ b/internal/review/table_test.go
@@ -0,0 +1,147 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package review
+
+import (
+ "testing"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+func TestTableHeadersColumnsCharactersAndMarks(t *testing.T) {
+ r := Review{}
+ r.Update(fixture("Name | Subject", "JosƩ | Hello world", "Other |", "", "Name | Subject"))
+ r.Set_table_mode(true)
+ if got := r.Set_mark(); got.Text != "no review cursor" {
+ t.Fatal("header command silently entered review", got)
+ }
+ check := func(id command.ID, want string) {
+ t.Helper()
+ if got := r.Execute(id).Text; got != want {
+ t.Fatalf("%s: got %q, want %q", id, got, want)
+ }
+ }
+ check(command.CurrentWord, "Name Column 1")
+ if got := r.Set_mark(); got.Text != "header row set" {
+ t.Fatal(got)
+ }
+ if _, ok := r.Marked_text(true); ok {
+ t.Fatal("header command created a clipboard mark")
+ }
+ check(command.NextLine, "JosƩ | Hello world")
+ check(command.CurrentWord, "JosƩ Name")
+ check(command.NextWord, "Hello world Subject")
+ check(command.CurrentChar, "H")
+ check(command.NextChar, "e")
+ check(command.FirstText, "H first character in cell Subject")
+ check(command.PreviousChar, "H")
+ check(command.LastText, "d last character in cell Subject")
+ check(command.NextChar, "d")
+ check(command.NextWord, "Hello world Subject")
+ check(command.LineStart, "JosƩ Name")
+ check(command.PreviousWord, "JosƩ Name")
+ check(command.LineEnd, "Hello world Subject")
+ check(command.NextLine, "Other |")
+ check(command.CurrentWord, "blank Subject")
+ check(command.NextLine, "blank")
+ check(command.NextWord, "no table data")
+ if got := r.Set_mark(); got.Text != "could not set header row" {
+ t.Fatal(got)
+ }
+ // An empty row must not discard the selected column or stored headers.
+ check(command.NextLine, "Name | Subject")
+ check(command.CurrentWord, "Subject Subject")
+ r.Set_table_mode(false)
+ check(command.CurrentWord, "Subject")
+ if got := r.Set_mark(); got.Text != "set mark" {
+ t.Fatal("ordinary clipboard marks did not return", got)
+ }
+ r.Set_table_mode(true)
+ check(command.CurrentWord, "Subject Column 2")
+}
+
+func TestTableRuneBoundsEmptyCellsAndShortRows(t *testing.T) {
+ for _, text := range []string{"Ć©,Ć©,,ē", "Ć©|Ć©||ē", "Ć©;Ć©;;ē", "Ć©\tĆ©\t\tē", " Ć© Ć© ē "} {
+ r := Review{}
+ r.Update(fixture(text, "short"))
+ r.Set_table_mode(true)
+ if got := r.Execute(command.NextWord).Text; got != "Ć© Column 2" {
+ t.Fatal(text, got)
+ }
+ if got := r.Execute(command.CurrentChar).Text; got != "Ć©" {
+ t.Fatal("byte offset or repeated-content search", text, got)
+ }
+ r.Execute(command.LineEnd)
+ if got := r.Execute(command.CurrentChar).Text; got != "ē" {
+ t.Fatal(text, got)
+ }
+ r.Execute(command.NextLine)
+ if got := r.Execute(command.NextWord).Text; got != "no table data" {
+ t.Fatal("short row switched columns silently", text, got)
+ }
+ if got := r.Execute(command.LineStart).Text; got != "short Column 1" {
+ t.Fatal(text, got)
+ }
+ }
+}
+
+func TestTableSessionChangesClearHeadersAndPosition(t *testing.T) {
+ r := Review{}
+ s := fixture("Name|Subject", "Ć©|hello")
+ r.Update(s)
+ r.Set_table_mode(true)
+ r.Execute(command.CurrentWord)
+ r.Set_mark()
+ r.Execute(command.NextWord)
+ for _, next := range []screen.Snapshot{
+ {Terminal: s.Terminal},
+ s,
+ } {
+ r.Update(next)
+ }
+ if got := r.Execute(command.CurrentWord).Text; got != "Name Column 1" {
+ t.Fatal("VT change reused headers/position", got)
+ }
+ s.Generation++
+ r.Update(s)
+ if got := r.Execute(command.CurrentWord).Text; got != "Name Column 1" {
+ t.Fatal("alternate screen reused table", got)
+ }
+}
+
+func TestTableResumingTrackingPreservesHeaders(t *testing.T) {
+ var r Review
+ s := fixture("Name|Subject", "Sender|Hello")
+ r.Update(s)
+ r.Set_table_mode(true)
+ r.Execute(command.CurrentWord)
+ r.Set_mark()
+ if got := r.Execute(command.NextWord).Text; got != "Subject Subject" {
+ t.Fatal(got)
+ }
+ s.X, s.Y = 1, 1
+ r.Resume_tracking()
+ r.Update(s)
+ if r.Active() {
+ t.Fatal("table review retained the old position after cursor movement")
+ }
+ if got := r.Execute(command.CurrentWord).Text; got != "Sender Name" {
+ t.Fatal("cursor movement lost table headers or retained the old column", got)
+ }
+}
diff --git a/internal/review/top.go b/internal/review/top.go
new file mode 100644
index 00000000..8b0a4376
--- /dev/null
+++ b/internal/review/top.go
@@ -0,0 +1,30 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package review
+
+import (
+ "git.stormux.org/storm/fenrir/internal/i18n"
+)
+
+func (r *Review) top() string {
+ r.x, r.y = 0, 0
+ if r.tableMode {
+ r.sync_table_column()
+ }
+ return i18n.T("Top")
+}
diff --git a/internal/review/vertical_character.go b/internal/review/vertical_character.go
new file mode 100644
index 00000000..2372ec8c
--- /dev/null
+++ b/internal/review/vertical_character.go
@@ -0,0 +1,38 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package review
+
+import (
+ "git.stormux.org/storm/fenrir/internal/i18n"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+)
+
+func (r *Review) vertical_character(direction int) string {
+ nextRow := r.y + direction
+ if nextRow < 0 {
+ return r.screen_boundary(false)
+ }
+ if nextRow >= r.screen.Rows {
+ return r.screen_boundary(true)
+ }
+ r.y = nextRow
+ // Snapshots retain terminal padding, so a short or blank row need not
+ // clamp the column. Table review also retains the selected cell: an
+ // absolute column outside it must not read a neighbouring cell instead.
+ if r.tableMode {
+ cells := table_cells(r.screen.Line(r.y))
+ if r.tableColumn >= len(cells) {
+ return i18n.T("no table data")
+ }
+ cell := cells[r.tableColumn]
+ if cell.start == cell.end {
+ r.result.Cue = presentation.Blank
+ return i18n.T("blank")
+ }
+ if r.x < cell.start || r.x >= cell.end {
+ return i18n.T("no table data")
+ }
+ }
+ return r.currentCharacter()
+}
diff --git a/internal/review/vertical_character_test.go b/internal/review/vertical_character_test.go
new file mode 100644
index 00000000..a9666194
--- /dev/null
+++ b/internal/review/vertical_character_test.go
@@ -0,0 +1,125 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package review
+
+import (
+ "testing"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+func TestVerticalCharacterRetainsColumnAndDetachedReview(t *testing.T) {
+ var r Review
+ s := fixture("abcdĆ!", "x", "", "wxyzĪ©?")
+ s.X = 4
+ r.Update(s)
+ for _, trial := range []struct {
+ action command.ID
+ want string
+ row int
+ cue presentation.Cue
+ character rune
+ }{
+ {command.ReviewUp, "start of screen", 0, presentation.Top, 0},
+ {command.ReviewDown, "space", 1, "", ' '},
+ {command.ReviewDown, "space", 2, "", ' '},
+ {command.ReviewDown, "Ī©", 3, "", 'Ī©'},
+ {command.ReviewDown, "end of screen", 3, presentation.Bottom, 0},
+ {command.ReviewUp, "space", 2, "", ' '},
+ {command.ReviewUp, "space", 1, "", ' '},
+ {command.ReviewUp, "Ć", 0, "", 'Ć'},
+ } {
+ result := r.Execute(trial.action)
+ if got := result.Text; got != trial.want || r.x != 4 || r.y != trial.row || result.Cue != trial.cue {
+ t.Fatalf("%s: %q at %d,%d cue %q; want %+v", trial.action, got, r.x, r.y, result.Cue, trial)
+ }
+ char := result.Character
+ if (char != nil) != (trial.character != 0) || char != nil && *char != trial.character {
+ t.Fatalf("character metadata: %v; want %q", char, trial.character)
+ }
+ if !r.Active() || r.screen.X != 4 || r.screen.Y != 0 {
+ t.Fatal("vertical review changed application ownership or position")
+ }
+ }
+ // Passive application cursor movement must leave detached review intact.
+ s.X, s.Y = 0, 3
+ r.Update(s)
+ if got := r.Execute(command.ReviewDown).Text; got != "space" || r.x != 4 || r.y != 1 {
+ t.Fatal("review followed application cursor", got, r.x, r.y)
+ }
+ r.Execute(command.FollowCursor)
+ if got := r.Execute(command.ReviewUp).Text; got != "space" || r.x != 0 || r.y != 2 {
+ t.Fatal("follow-cursor did not reset vertical review", got, r.x, r.y)
+ }
+}
+
+func TestVerticalCharacterScreenEdgesAndSessionReset(t *testing.T) {
+ for _, column := range []int{0, 19} {
+ var r Review
+ s := fixture("", "")
+ s.X = column
+ r.Update(s)
+ if got := r.Execute(command.ReviewDown).Text; got != "space" || r.x != column {
+ t.Fatal("empty screen or edge column", got, r.x)
+ }
+ for _, change := range []string{"generation", "terminal", "resize", "inactive"} {
+ next := fixture("new", "last")
+ switch change {
+ case "generation":
+ next.Generation++
+ case "terminal":
+ next.Terminal++
+ case "resize":
+ next = fixture("new", "last", "extra")
+ case "inactive":
+ r.Update(screen.Snapshot{})
+ if got := r.Execute(command.ReviewUp).Text; got != "no text console" {
+ t.Fatal(got)
+ }
+ }
+ r.Update(next)
+ if got := r.Execute(command.ReviewDown).Text; got != "l" || r.x != 0 || r.y != 1 {
+ t.Fatal("stale vertical position after", change, got, r.x, r.y)
+ }
+ }
+ }
+}
+
+func TestVerticalCharacterTableScopeRetainsAbsoluteColumn(t *testing.T) {
+ var r Review
+ r.Update(fixture("Name|Subject", "Ć©|long word", "wide label|other", "only", "x|", "Name|Subject"))
+ r.Set_table_mode(true)
+ r.Execute(command.CurrentWord)
+ r.Set_mark()
+ r.Execute(command.NextWord)
+ for _, trial := range []struct {
+ action command.ID
+ want string
+ row int
+ }{
+ {command.ReviewDown, "g", 1},
+ {command.ReviewDown, "no table data", 2}, // column six belongs to the first cell
+ {command.ReviewDown, "no table data", 3}, // selected cell missing
+ {command.ReviewDown, "blank", 4},
+ {command.ReviewDown, "S", 5},
+ {command.ReviewUp, "blank", 4},
+ {command.ReviewUp, "no table data", 3},
+ {command.ReviewUp, "no table data", 2},
+ {command.ReviewUp, "g", 1},
+ {command.ReviewUp, "S", 0},
+ } {
+ if got := r.Execute(trial.action).Text; got != trial.want || r.x != 5 || r.y != trial.row || r.tableColumn != 1 {
+ t.Fatalf("%s: %q at %d,%d cell %d; want %+v", trial.action, got, r.x, r.y, r.tableColumn, trial)
+ }
+ }
+ if got := r.Execute(command.CurrentWord).Text; got != "Subject Subject" {
+ t.Fatal("vertical review lost header or selected cell", got)
+ }
+ r.Set_table_mode(false)
+ if got := r.Execute(command.ReviewDown).Text; got != "g" {
+ t.Fatal("ordinary review did not return", got)
+ }
+}
diff --git a/internal/review/word.go b/internal/review/word.go
new file mode 100644
index 00000000..f10c88bf
--- /dev/null
+++ b/internal/review/word.go
@@ -0,0 +1,118 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package review
+
+import (
+ "git.stormux.org/storm/fenrir/internal/i18n"
+ "unicode"
+
+ "git.stormux.org/storm/fenrir/internal/presentation"
+)
+
+// word_start selects the containing word, or searches backward through whitespace.
+// Coordinates are screen cells, so Unicode never becomes a byte offset.
+func (r *Review) word_start(position int) (int, bool) {
+ s := r.screen
+ for position >= 0 && unicode.IsSpace(s.Cells[position]) {
+ position--
+ }
+ if position < 0 {
+ return 0, false
+ }
+ for position%s.Cols > 0 && !unicode.IsSpace(s.Cells[position-1]) {
+ position--
+ }
+ return position, true
+}
+
+func (r *Review) word_at(position int) string {
+ s := r.screen
+ line := s.Line(position / s.Cols)
+ start, end := position%s.Cols, position%s.Cols
+ for end < len(line) && !unicode.IsSpace(line[end]) {
+ end++
+ }
+ return string(line[start:end])
+}
+
+func (r *Review) screen_blank() bool {
+ for _, char := range r.screen.Cells {
+ if !unicode.IsSpace(char) {
+ return false
+ }
+ }
+ return true
+}
+
+func (r *Review) blank_word() string {
+ r.result.Cue = presentation.Blank
+ return i18n.T("blank")
+}
+
+func (r *Review) word() string {
+ if r.screen_blank() {
+ return r.blank_word()
+ }
+ start, ok := r.word_start(r.y*r.screen.Cols + r.x)
+ if !ok {
+ return r.screen_boundary(false)
+ }
+ return r.select_word(start)
+}
+
+// raw_word shares selection with spelling without moving the review cursor.
+func (r *Review) raw_word() string {
+ start, ok := r.word_start(r.y*r.screen.Cols + r.x)
+ if !ok {
+ return ""
+ }
+ return r.word_at(start)
+}
+
+func (r *Review) select_word(position int) string {
+ if r.y != position/r.screen.Cols {
+ r.line_break()
+ }
+ r.x, r.y = position%r.screen.Cols, position/r.screen.Cols
+ return r.word_at(position)
+}
+
+func (r *Review) moveWord(forward bool) string {
+ if r.screen_blank() {
+ return r.blank_word()
+ }
+ s := r.screen
+ position := r.y*s.Cols + r.x
+ if !forward {
+ start, ok := r.word_start(position)
+ if !ok {
+ return r.screen_boundary(false)
+ }
+ previous, ok := r.word_start(start - 1)
+ if !ok {
+ return r.screen_boundary(false)
+ }
+ return r.select_word(previous)
+ }
+ for target := position + 1; target < s.Rows*s.Cols; target++ {
+ if !unicode.IsSpace(s.Cells[target]) && (target%s.Cols == 0 || unicode.IsSpace(s.Cells[target-1])) {
+ return r.select_word(target)
+ }
+ }
+ return r.screen_boundary(true)
+}
diff --git a/internal/screen/attributes.go b/internal/screen/attributes.go
new file mode 100644
index 00000000..bb598ee0
--- /dev/null
+++ b/internal/screen/attributes.go
@@ -0,0 +1,24 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package screen
+
+// Colors are RGB, tagged ANSI palette indices, or the terminal default.
+const (
+ ColorDefault uint32 = 1 << 31
+ ColorIndexed uint32 = 1 << 30
+ Bold uint32 = 1
+ Underline uint32 = 6 // libvterm retains single/double/curly underline.
+ Italic uint32 = 8
+ Blink uint32 = 16
+ Reverse uint32 = 32
+ Strike uint32 = 64
+ // VCSA high bit can mean blink, background intensity or a font bit. Keep
+ // it for change/highlight detection without claiming a specific style.
+ ConsoleHigh uint32 = 128
+)
+
+func (a Attribute) Default() bool {
+ return a.Style == 0 && (a.Foreground == ColorDefault || a.Foreground == ColorIndexed|7) &&
+ (a.Background == ColorDefault || a.Background == ColorIndexed)
+}
diff --git a/internal/screen/region.go b/internal/screen/region.go
new file mode 100644
index 00000000..b911800e
--- /dev/null
+++ b/internal/screen/region.go
@@ -0,0 +1,37 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package screen
+
+import "strings"
+
+// Region uses inclusive screen-cell coordinates, independently of text width
+// in bytes. ToLineEnd follows the current right edge when the terminal resizes.
+// Other coordinates stay fixed and are clipped only while reading/filtering.
+type Region struct {
+ Left, Top, Right, Bottom int
+ ToLineEnd bool
+}
+
+func (r Region) Right_edge(cols int) int {
+ if r.ToLineEnd {
+ return cols - 1
+ }
+ return min(r.Right, cols-1)
+}
+
+func (r Region) Contains(x, y, cols int) bool {
+ return x >= r.Left && x <= r.Right_edge(cols) && y >= r.Top && y <= r.Bottom
+}
+
+func (r Region) Text(s Snapshot) string {
+ right := r.Right_edge(s.Cols)
+ if r.Left > right || r.Top >= s.Rows {
+ return ""
+ }
+ var lines []string
+ for row := r.Top; row <= min(r.Bottom, s.Rows-1); row++ {
+ lines = append(lines, strings.TrimRight(string(s.Line(row)[r.Left:right+1]), " \x00"))
+ }
+ return strings.Join(lines, "\n")
+}
diff --git a/internal/screen/screen.go b/internal/screen/screen.go
new file mode 100644
index 00000000..ed9b711d
--- /dev/null
+++ b/internal/screen/screen.go
@@ -0,0 +1,49 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+// Package screen describes the visible cells of one terminal session.
+package screen
+
+import "strings"
+
+type Snapshot struct {
+ Terminal int
+ // Generation changes when a terminal replaces its logical screen buffer.
+ Generation uint64
+ Active bool
+ NumLock bool
+ CapsLock bool
+ Rows, Cols int
+ X, Y int
+ Cells []rune
+ // Attributes is either absent or has one entry per cell. Both adapters keep
+ // visual changes even when the text and hardware cursor stay unchanged.
+ Attributes []Attribute
+}
+
+type Attribute struct {
+ Foreground, Background uint32
+ Style uint32
+}
+
+func (s Snapshot) Line(row int) []rune {
+ return s.Cells[row*s.Cols : (row+1)*s.Cols]
+}
+
+func (s Snapshot) Text(row int) string {
+ return strings.TrimRight(string(s.Line(row)), " \x00")
+}
diff --git a/internal/screen/scroll.go b/internal/screen/scroll.go
new file mode 100644
index 00000000..3c3d3f19
--- /dev/null
+++ b/internal/screen/scroll.go
@@ -0,0 +1,55 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package screen
+
+import (
+ "slices"
+ "unicode"
+)
+
+// A TUI can scroll only its message area, leaving headers, status and input
+// rows fixed. Suppress the longest contiguous shifted overlap, while leaving
+// every other changed row available for normal feedback. Partial overlaps
+// require two nonblank rows: a lone repeated label or a blank area is ambiguous.
+func Scrolled_overlap(previous, next Snapshot) (bestStart, bestEnd int) {
+ // Match a whole-screen scroll first, including sparse screens. Ignore
+ // whitespace-only overlaps, which cannot establish that anything scrolled.
+ firstText := slices.IndexFunc(next.Cells, func(cell rune) bool { return !unicode.IsSpace(cell) })
+ for shift := 1; shift < next.Rows && firstText >= 0 && firstText < (next.Rows-shift)*next.Cols; shift++ {
+ if slices.Equal(previous.Cells[shift*next.Cols:], next.Cells[:(next.Rows-shift)*next.Cols]) {
+ return 0, next.Rows - shift
+ }
+ }
+ bestText := 0
+ for shift := 1; shift < next.Rows; shift++ {
+ start, textRows := 0, 0
+ for row := 0; row <= next.Rows-shift; row++ {
+ if row < next.Rows-shift && slices.Equal(previous.Line(row+shift), next.Line(row)) {
+ if !slices.Equal(previous.Line(row), next.Line(row)) && slices.ContainsFunc(next.Line(row), func(cell rune) bool { return !unicode.IsSpace(cell) }) {
+ textRows++
+ }
+ continue
+ }
+ if textRows >= 2 && textRows > bestText {
+ bestStart, bestEnd, bestText = start, row, textRows
+ }
+ start, textRows = row+1, 0
+ }
+ }
+ return bestStart, bestEnd
+}
diff --git a/internal/scripts/discovery.go b/internal/scripts/discovery.go
new file mode 100644
index 00000000..27282e9f
--- /dev/null
+++ b/internal/scripts/discovery.go
@@ -0,0 +1,135 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+// Package scripts preserves Python Fenrir's external script-key contract.
+// Scripts are executables, not in-process extensions.
+package scripts
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/input"
+)
+
+const SystemPath = "/usr/share/fenrirscreenreader/scripts"
+
+type Script struct {
+ ID command.ID
+ Name, Path string
+ Keys []string
+}
+
+// Paths follows CommandManager.get_script_paths, including the resource root
+// itself (there is no additional user "scripts" subdirectory).
+func Paths(home, configured, bundled string) []string {
+ var paths []string
+ seen := map[string]bool{}
+ for _, path := range []string{filepath.Join(home, ".local/stormux/fenrir"), configured, bundled} {
+ if path == "" {
+ continue
+ }
+ if path == "~" {
+ path = home
+ } else if strings.HasPrefix(path, "~/") {
+ path = filepath.Join(home, path[2:])
+ }
+ path, err := filepath.Abs(path)
+ if err == nil && !seen[path] {
+ paths = append(paths, path)
+ seen[path] = true
+ }
+ }
+ return paths
+}
+
+func Parse_filename(filename string) (Script, bool) {
+ if strings.HasPrefix(filename, ".") {
+ return Script{}, false
+ }
+ stem := strings.TrimSuffix(filename, filepath.Ext(filename))
+ if strings.HasPrefix(stem, "__") {
+ return Script{}, false
+ }
+ parts := strings.Split(strings.ToUpper(stem), "__-__")
+ if len(parts) > 2 {
+ return Script{}, false
+ }
+ keys := strings.Split(parts[len(parts)-1], "__+__")
+ hasScript := false
+ for _, key := range keys {
+ if !input.Valid_script_key(key) {
+ return Script{}, false
+ }
+ hasScript = hasScript || key == "KEY_SCRIPT"
+ }
+ if !hasScript {
+ keys = append(keys, "KEY_SCRIPT")
+ }
+ return Script{ID: command.ID("script:" + strings.ToUpper(stem)), Name: parts[0], Keys: keys}, true
+}
+
+// Discover does not execute files or require their executable bit. Python
+// checks executability on invocation. Directory errors affect that directory
+// only. Same-directory variants may share a name; earlier directories shadow
+// all later variants of that name. ReadDir provides Python's sorted order.
+func Discover(paths []string, occupied map[string]bool) ([]Script, []error) {
+ loadedNames := map[string]bool{}
+ loadedIDs := map[command.ID]bool{}
+ shortcuts := map[string]bool{}
+ for chord, used := range occupied {
+ shortcuts[chord] = used
+ }
+ var result []Script
+ var problems []error
+ for _, path := range paths {
+ entries, err := os.ReadDir(path)
+ if os.IsNotExist(err) {
+ continue
+ }
+ if err != nil {
+ problems = append(problems, fmt.Errorf("scripts %s: %w", path, err))
+ continue
+ }
+ names := map[string]bool{}
+ for _, entry := range entries {
+ fullPath := filepath.Join(path, entry.Name())
+ info, err := os.Stat(fullPath) // Follow symlinks, as os.path.isfile does.
+ if err != nil || !info.Mode().IsRegular() {
+ continue
+ }
+ script, ok := Parse_filename(entry.Name())
+ if !ok || loadedNames[script.Name] || loadedIDs[script.ID] {
+ continue
+ }
+ chord := input.Script_chord(script.Keys)
+ if shortcuts[chord] {
+ continue
+ }
+ script.Path = fullPath
+ result = append(result, script)
+ names[script.Name], loadedIDs[script.ID], shortcuts[chord] = true, true, true
+ }
+ for name := range names {
+ loadedNames[name] = true
+ }
+ }
+ return result, problems
+}
diff --git a/internal/scripts/discovery_test.go b/internal/scripts/discovery_test.go
new file mode 100644
index 00000000..b67d5a04
--- /dev/null
+++ b/internal/scripts/discovery_test.go
@@ -0,0 +1,104 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package scripts
+
+import (
+ "os"
+ "path/filepath"
+ "reflect"
+ "testing"
+
+ "git.stormux.org/storm/fenrir/internal/input"
+)
+
+func TestPythonFilenameGrammar(t *testing.T) {
+ for _, fixture := range []struct {
+ name string
+ keys []string
+ }{
+ {"musicplayer__-__KEY_SHIFT__+__KEY_U.sh", []string{"KEY_SHIFT", "KEY_U", "KEY_SCRIPT"}},
+ {"KEY_A", []string{"KEY_A", "KEY_SCRIPT"}},
+ {"Name with spaces__-__key_ctrl__+__key_f12.py", []string{"KEY_CTRL", "KEY_F12", "KEY_SCRIPT"}},
+ {"explicit__-__KEY_SCRIPT__+__KEY_KPPLUS", []string{"KEY_SCRIPT", "KEY_KPPLUS"}},
+ {"keys__-__KEY_A__+__KEY_B", []string{"KEY_A", "KEY_B", "KEY_SCRIPT"}},
+ {"media__-__KEY_PLAYPAUSE", []string{"KEY_PLAYPAUSE", "KEY_SCRIPT"}},
+ } {
+ script, ok := Parse_filename(fixture.name)
+ if !ok || !reflect.DeepEqual(script.Keys, fixture.keys) {
+ t.Fatal(fixture.name, script, ok)
+ }
+ }
+ for _, name := range []string{"__hidden__-__KEY_A.sh", ".hidden__-__KEY_A", "bad__-__KEY_A__-__KEY_B", "bad__-__KEY_NO_SUCH_KEY", "bad__-__KEY_A__+__", "unbound.sh"} {
+ if _, ok := Parse_filename(name); ok {
+ t.Fatal("accepted invalid filename", name)
+ }
+ }
+}
+
+func TestPythonDiscoveryPrecedenceVariantsAndConflicts(t *testing.T) {
+ root := t.TempDir()
+ local, system := filepath.Join(root, "local"), filepath.Join(root, "system")
+ for _, directory := range []string{local, system} {
+ if err := os.Mkdir(directory, 0700); err != nil {
+ t.Fatal(err)
+ }
+ }
+ write := func(directory, name string) {
+ t.Helper()
+ if err := os.WriteFile(filepath.Join(directory, name), []byte("#!/bin/sh\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ write(local, "same__-__KEY_A.sh")
+ write(local, "music__-__KEY_SHIFT__+__KEY_U.sh")
+ write(local, "music__-__KEY_SHIFT__+__KEY_X.sh")
+ write(system, "same__-__KEY_B.sh")
+ write(system, "other__-__KEY_C.sh")
+ write(system, "conflict__-__KEY_D.sh")
+ write(system, "occupied__-__KEY_C.sh")
+ if err := os.Symlink(filepath.Join(local, "same__-__KEY_A.sh"), filepath.Join(local, "link__-__KEY_E.sh")); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Mkdir(filepath.Join(local, "directory__-__KEY_F.sh"), 0700); err != nil {
+ t.Fatal(err)
+ }
+ result, problems := Discover([]string{filepath.Join(root, "missing"), local, system}, map[string]bool{input.Script_chord([]string{"KEY_D", "KEY_SCRIPT"}): true})
+ if len(problems) != 0 {
+ t.Fatal(problems)
+ }
+ var names []string
+ for _, script := range result {
+ names = append(names, filepath.Base(script.Path))
+ }
+ want := []string{"link__-__KEY_E.sh", "music__-__KEY_SHIFT__+__KEY_U.sh", "music__-__KEY_SHIFT__+__KEY_X.sh", "same__-__KEY_A.sh", "occupied__-__KEY_C.sh"}
+ if !reflect.DeepEqual(names, want) {
+ t.Fatal(names, want)
+ }
+}
+
+func TestPathsKeepPythonResourceRootAndDeduplicate(t *testing.T) {
+ home := t.TempDir()
+ got := Paths(home, "~/.local/stormux/fenrir", "")
+ want := []string{filepath.Join(home, ".local/stormux/fenrir")}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatal(got, want)
+ }
+ if got := Paths(home, SystemPath, "/bundle/config/scripts"); len(got) != 3 || got[1] != SystemPath {
+ t.Fatal(got)
+ }
+}
diff --git a/internal/scripts/music_test.go b/internal/scripts/music_test.go
new file mode 100644
index 00000000..5090208f
--- /dev/null
+++ b/internal/scripts/music_test.go
@@ -0,0 +1,78 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package scripts
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+// Exercise an unchanged real music controller, with every external action
+// replaced by a private executable. Never control the contributor's player.
+func TestInstalledMusicControllerWithFakePlayerctl(t *testing.T) {
+ source := os.Getenv("FENRIR_MUSIC_SCRIPT")
+ if source == "" {
+ t.Skip("set FENRIR_MUSIC_SCRIPT to test an existing music controller")
+ }
+ data, err := os.ReadFile(source)
+ if err != nil {
+ t.Fatal(err)
+ }
+ root := t.TempDir()
+ bin := filepath.Join(root, "bin")
+ if err := os.Mkdir(bin, 0700); err != nil {
+ t.Fatal(err)
+ }
+ write := func(path, body string) {
+ t.Helper()
+ if err := os.WriteFile(path, []byte(body), 0700); err != nil {
+ t.Fatal(err)
+ }
+ }
+ write(filepath.Join(bin, "id"), "#!/bin/sh\ncase \"$1\" in -un) echo Username;; *) echo 1000;; esac\n")
+ write(filepath.Join(bin, "sleep"), "#!/bin/sh\nexit 0\n")
+ write(filepath.Join(bin, "sudo"), "#!/bin/sh\nexit 99\n")
+ write(filepath.Join(bin, "playerctl"), "#!/bin/sh\necho \"$*\" >> \"$SCRIPT_ACTION_LOG\"\ncase \"$*\" in *metadata*) echo 'Sample title by Sample artist';; esac\n")
+ t.Setenv("PATH", bin)
+ t.Setenv("SCRIPT_ACTION_LOG", filepath.Join(root, "actions"))
+ path := filepath.Join(root, "musicplayer.sh")
+ if err := os.WriteFile(path, data, 0700); err != nil {
+ t.Fatal(err)
+ }
+ runner := New_runner(context.Background(), Limits{Workers: 1, Queue: 1, OutputBytes: 4096, Timeout: time.Second}, func(int) string { return "Username" })
+ defer runner.Close()
+ for _, fixture := range []struct{ key, action string }{{"z", "previous"}, {"x", "play"}, {"c", "play-pause"}, {"v", "stop"}, {"b", "next"}, {"s", "shuffle toggle"}, {"u", "metadata"}, {"minus", "volume 0.05-"}, {"equal", "volume 0.05+"}} {
+ link := filepath.Join(root, "musicplayer__-__key_shift__+__key_"+fixture.key+".sh")
+ if err := os.Symlink(path, link); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(root, "actions"), nil, 0600); err != nil {
+ t.Fatal(err)
+ }
+ runner.Submit(Invocation{Script: Script{Path: link}})
+ result := receive_result(t, runner)
+ actions, err := os.ReadFile(filepath.Join(root, "actions"))
+ if err != nil || result.Err != nil || result.Stderr != "" || !strings.Contains(string(actions), fixture.action) {
+ t.Fatal(fixture, result, string(actions), err)
+ }
+ }
+}
diff --git a/internal/scripts/python_parity_test.go b/internal/scripts/python_parity_test.go
new file mode 100644
index 00000000..f9758e0d
--- /dev/null
+++ b/internal/scripts/python_parity_test.go
@@ -0,0 +1,84 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package scripts
+
+import (
+ "encoding/json"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "reflect"
+ "sort"
+ "strings"
+ "testing"
+)
+
+// This is an optional differential test against a contributor's real Python
+// checkout. Normal Go builds/tests never require Python Fenrir to be installed.
+func TestPythonLoaderParity(t *testing.T) {
+ source := os.Getenv("FENRIR_PYTHON_SOURCE")
+ if source == "" {
+ t.Skip("set FENRIR_PYTHON_SOURCE to compare the real Python loader")
+ }
+ root := t.TempDir()
+ local, system := filepath.Join(root, "local"), filepath.Join(root, "system")
+ for _, directory := range []string{local, system} {
+ if err := os.Mkdir(directory, 0700); err != nil {
+ t.Fatal(err)
+ }
+ }
+ names := []string{"Music__-__KEY_SHIFT__+__KEY_U.sh", "Music__-__KEY_SHIFT__+__KEY_X.sh", "same__-__KEY_B.py", "KEY_A", "explicit__-__KEY_SCRIPT__+__KEY_KPPLUS", "space name__-__key_ctrl__+__key_f12.sh", "multi__-__KEY_A__+__KEY_B", "bad__-__KEY_UNKNOWN", "bad__-__KEY_A__+__", "__skip__-__KEY_Z.sh", ".hidden__-__KEY_V.sh", "bad__-__KEY_A__-__KEY_B"}
+ for _, name := range names {
+ if err := os.WriteFile(filepath.Join(local, name), nil, 0600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ for _, name := range []string{"same__-__KEY_N.sh", "other__-__KEY_Q", "another__-__KEY_A", "Music__-__KEY_Y.sh"} {
+ if err := os.WriteFile(filepath.Join(system, name), nil, 0600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ command := exec.Command("python3", "../../tests/python_script_contract.py", source, local, system)
+ command.Env = append(os.Environ(), "PYTHONDONTWRITEBYTECODE=1")
+ output, err := command.CombinedOutput()
+ if err != nil {
+ t.Fatalf("Python contract fixture: %v\n%s", err, output)
+ }
+ type record struct {
+ Name string `json:"name"`
+ Path string `json:"path"`
+ Keys []string `json:"keys"`
+ }
+ var python []record
+ if err := json.Unmarshal(output, &python); err != nil {
+ t.Fatal(err, string(output))
+ }
+ installed, problems := Discover([]string{local, system}, nil)
+ if len(problems) != 0 {
+ t.Fatal(problems)
+ }
+ var goRecords []record
+ for _, script := range installed {
+ keys := append([]string(nil), script.Keys...)
+ sort.Strings(keys)
+ goRecords = append(goRecords, record{Name: strings.TrimPrefix(string(script.ID), "script:"), Path: script.Path, Keys: keys})
+ }
+ if !reflect.DeepEqual(goRecords, python) {
+ t.Fatalf("Go: %+v\nPython: %+v", goRecords, python)
+ }
+}
diff --git a/internal/scripts/runner_linux.go b/internal/scripts/runner_linux.go
new file mode 100644
index 00000000..42099c93
--- /dev/null
+++ b/internal/scripts/runner_linux.go
@@ -0,0 +1,219 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package scripts
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "strings"
+ "sync"
+ "syscall"
+ "time"
+ "unicode/utf8"
+)
+
+type Limits struct {
+ Workers int
+ Queue int
+ OutputBytes int
+ Timeout time.Duration // Zero preserves Python's unlimited invocation lifetime.
+}
+
+type Invocation struct {
+ Script Script
+ Terminal int
+ Generation uint64
+}
+
+type Result struct {
+ Invocation
+ Stdout, Stderr string
+ Err error
+ Truncated bool
+}
+
+// Runner owns subprocesses only. The application loop owns bindings, session
+// state and speech. Its bounded channels never block the input dispatcher.
+type Runner struct {
+ ctx context.Context
+ cancel context.CancelFunc
+ limits Limits
+ username func(int) string
+ jobs chan Invocation
+ results chan Result
+ workers sync.WaitGroup
+ mu sync.Mutex
+ generation uint64
+ active map[*Invocation]context.CancelFunc
+ done chan struct{}
+}
+
+func New_runner(ctx context.Context, limits Limits, username func(int) string) *Runner {
+ ctx, cancel := context.WithCancel(ctx)
+ r := &Runner{ctx: ctx, cancel: cancel, limits: limits, username: username, jobs: make(chan Invocation, limits.Queue), results: make(chan Result, limits.Workers), active: make(map[*Invocation]context.CancelFunc), done: make(chan struct{})}
+ for range limits.Workers {
+ r.workers.Go(r.work)
+ }
+ go func() { r.workers.Wait(); close(r.done) }()
+ return r
+}
+
+func (r *Runner) Submit(invocation Invocation) bool {
+ if r.ctx.Err() != nil {
+ return false
+ }
+ select {
+ case r.jobs <- invocation:
+ return true
+ default:
+ return false
+ }
+}
+
+func (r *Runner) Results() <-chan Result { return r.results }
+func (r *Runner) Close() {
+ r.cancel()
+ // A kernel task stuck in uninterruptible filesystem I/O cannot be reaped
+ // until the kernel releases it. It still must not delay reader shutdown.
+ select {
+ case <-r.done:
+ case <-time.After(750 * time.Millisecond):
+ }
+}
+
+// Cancel invalidates queued work and cancels active invocations without waiting
+// on the caller. Session handoff therefore never waits for a script to exit.
+func (r *Runner) Cancel() uint64 {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.generation++
+ for _, cancel := range r.active {
+ cancel()
+ }
+ return r.generation
+}
+
+func (r *Runner) work() {
+ for {
+ select {
+ case <-r.ctx.Done():
+ return
+ case invocation := <-r.jobs:
+ if r.ctx.Err() != nil {
+ return
+ }
+ result := r.execute(invocation)
+ select {
+ case r.results <- result:
+ case <-r.ctx.Done():
+ return
+ }
+ }
+ }
+}
+
+type boundedOutput struct {
+ data []byte
+ limit int
+ truncated bool
+}
+
+func (b *boundedOutput) Write(data []byte) (int, error) {
+ count := min(len(data), b.limit-len(b.data))
+ b.data = append(b.data, data[:count]...)
+ b.truncated = b.truncated || count < len(data)
+ return len(data), nil // Keep draining after reaching the retention limit.
+}
+
+func (b *boundedOutput) text() string {
+ text := string(b.data)
+ if b.truncated {
+ text = strings.ToValidUTF8(text, "")
+ }
+ return strings.ReplaceAll(strings.ReplaceAll(text, "\r\n", "\n"), "\r", "\n")
+}
+
+func (r *Runner) execute(invocation Invocation) Result {
+ result := Result{Invocation: invocation}
+ r.mu.Lock()
+ if invocation.Generation != r.generation {
+ r.mu.Unlock()
+ result.Err = context.Canceled
+ return result
+ }
+ ctx, cancelInvocation := context.WithCancel(r.ctx)
+ r.active[&invocation] = cancelInvocation
+ r.mu.Unlock()
+ defer func() { cancelInvocation(); r.mu.Lock(); delete(r.active, &invocation); r.mu.Unlock() }()
+ info, err := os.Stat(invocation.Script.Path)
+ switch {
+ case errors.Is(err, os.ErrNotExist):
+ result.Err = errors.New("Script file not found")
+ return result
+ case err != nil:
+ result.Err = err
+ return result
+ case !info.Mode().IsRegular():
+ result.Err = errors.New("Script source is not a valid file")
+ return result
+ case syscall.Access(invocation.Script.Path, 1) != nil:
+ result.Err = errors.New("Script file is not executable")
+ return result
+ }
+ if r.limits.Timeout > 0 {
+ var cancel context.CancelFunc
+ ctx, cancel = context.WithTimeout(ctx, r.limits.Timeout)
+ defer cancel()
+ }
+ // Preserve Python's [script_path, current_user], inherited identity,
+ // environment, cwd and stdin. No shell command string is constructed.
+ name := ""
+ if r.username != nil {
+ name = r.username(invocation.Terminal)
+ }
+ cmd := exec.CommandContext(ctx, invocation.Script.Path, name)
+ cmd.Stdin = os.Stdin
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+ stdout, stderr := &boundedOutput{limit: r.limits.OutputBytes}, &boundedOutput{limit: r.limits.OutputBytes}
+ cmd.Stdout, cmd.Stderr = stdout, stderr
+ // CommandContext's default cancellation kills only the shell. Signal its
+ // process group, allow bounded cleanup, then kill any remaining group members.
+ cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) }
+ cmd.WaitDelay = 250 * time.Millisecond
+ err = cmd.Run()
+ if (errors.Is(err, exec.ErrWaitDelay) || ctx.Err() != nil) && cmd.Process != nil {
+ _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
+ }
+ result.Stdout, result.Stderr = stdout.text(), stderr.text()
+ result.Truncated = stdout.truncated || stderr.truncated
+ // Python presents stderr then stdout even for a nonzero exit status.
+ var exitError *exec.ExitError
+ if err != nil && !errors.As(err, &exitError) {
+ result.Err = err
+ }
+ if ctx.Err() != nil {
+ result.Err = ctx.Err()
+ }
+ if !utf8.Valid(stdout.data) && !stdout.truncated || !utf8.Valid(stderr.data) && !stderr.truncated {
+ result.Stdout, result.Stderr, result.Err = "", "", fmt.Errorf("script output is not UTF-8")
+ }
+ return result
+}
diff --git a/internal/scripts/runner_test.go b/internal/scripts/runner_test.go
new file mode 100644
index 00000000..375c42fb
--- /dev/null
+++ b/internal/scripts/runner_test.go
@@ -0,0 +1,141 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package scripts
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func fixture_script(t *testing.T, body string) Script {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), "script with spaces__-__KEY_A.sh")
+ if err := os.WriteFile(path, []byte("#!/bin/sh\n"+body+"\n"), 0700); err != nil {
+ t.Fatal(err)
+ }
+ return Script{Path: path}
+}
+
+func receive_result(t *testing.T, runner *Runner) Result {
+ t.Helper()
+ select {
+ case result := <-runner.Results():
+ return result
+ case <-time.After(3 * time.Second):
+ t.Fatal("script did not finish")
+ return Result{}
+ }
+}
+
+func TestRunnerPreservesArgumentsOutputAndNonzeroExit(t *testing.T) {
+ runner := New_runner(context.Background(), Limits{Workers: 1, Queue: 2, OutputBytes: 4096}, func(terminal int) string {
+ if terminal != 3 {
+ t.Error(terminal)
+ }
+ return "Username; echo not-a-shell"
+ })
+ defer runner.Close()
+ script := fixture_script(t, "echo \"$1\"\necho problem >&2\nexit 9")
+ if !runner.Submit(Invocation{Script: script, Terminal: 3}) {
+ t.Fatal("submit failed")
+ }
+ result := receive_result(t, runner)
+ if result.Err != nil || result.Stdout != "Username; echo not-a-shell\n" || result.Stderr != "problem\n" {
+ t.Fatal(result)
+ }
+}
+
+func TestRunnerDrainsFloodedOutputAndBoundsRetention(t *testing.T) {
+ runner := New_runner(context.Background(), Limits{Workers: 1, Queue: 1, OutputBytes: 64, Timeout: time.Second}, nil)
+ defer runner.Close()
+ script := fixture_script(t, "i=0; while [ \"$i\" -lt 10000 ]; do echo output; echo error >&2; i=$((i+1)); done")
+ runner.Submit(Invocation{Script: script})
+ result := receive_result(t, runner)
+ if result.Err != nil || !result.Truncated || len(result.Stdout) != 64 || len(result.Stderr) != 64 {
+ t.Fatal(result)
+ }
+}
+
+func TestRunnerTimeoutFreesWorkerAndCancellationInvalidatesQueuedWork(t *testing.T) {
+ runner := New_runner(context.Background(), Limits{Workers: 1, Queue: 2, OutputBytes: 4096, Timeout: 150 * time.Millisecond}, nil)
+ defer runner.Close()
+ slow := fixture_script(t, "sleep 30 &\nwait")
+ runner.Submit(Invocation{Script: slow})
+ if result := receive_result(t, runner); !errors.Is(result.Err, context.DeadlineExceeded) {
+ t.Fatal(result)
+ }
+ runner.Submit(Invocation{Script: fixture_script(t, "echo recovered")})
+ if result := receive_result(t, runner); result.Err != nil || result.Stdout != "recovered\n" {
+ t.Fatal(result)
+ }
+ generation := runner.Cancel()
+ marker := filepath.Join(t.TempDir(), "must-not-run")
+ runner.Submit(Invocation{Script: fixture_script(t, "touch '"+marker+"'")})
+ if result := receive_result(t, runner); !errors.Is(result.Err, context.Canceled) {
+ t.Fatal(result)
+ }
+ if _, err := os.Stat(marker); !os.IsNotExist(err) {
+ t.Fatal("stale invocation executed", err)
+ }
+ runner.Submit(Invocation{Script: fixture_script(t, "echo new-session"), Generation: generation})
+ if result := receive_result(t, runner); result.Stdout != "new-session\n" {
+ t.Fatal(result)
+ }
+}
+
+func TestRunnerBoundsInheritedPipeWaitAndShutdown(t *testing.T) {
+ runner := New_runner(context.Background(), Limits{Workers: 1, Queue: 1, OutputBytes: 4096}, nil)
+ script := fixture_script(t, "sleep 30 &\necho parent-finished")
+ runner.Submit(Invocation{Script: script})
+ result := receive_result(t, runner)
+ if result.Err == nil || !strings.Contains(result.Stdout, "parent-finished") {
+ t.Fatal(result)
+ }
+ runner.Submit(Invocation{Script: fixture_script(t, "sleep 30")})
+ started := time.Now()
+ runner.Close()
+ if time.Since(started) > time.Second {
+ t.Fatal("shutdown waited for script")
+ }
+}
+
+func TestRunnerFileChecksAndQueueBound(t *testing.T) {
+ runner := New_runner(context.Background(), Limits{Workers: 1, Queue: 1, OutputBytes: 32}, nil)
+ defer runner.Close()
+ path := filepath.Join(t.TempDir(), "not-executable")
+ if err := os.WriteFile(path, []byte("#!/bin/sh\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ for _, fixture := range []struct{ path, want string }{{path, "Script file is not executable"}, {filepath.Dir(path), "Script source is not a valid file"}, {path + "missing", "Script file not found"}} {
+ runner.Submit(Invocation{Script: Script{Path: fixture.path}})
+ result := receive_result(t, runner)
+ if result.Err == nil || result.Err.Error() != fixture.want {
+ t.Fatal(result)
+ }
+ }
+ // Directly fill a runner without workers to prove admission cannot block.
+ bounded := &Runner{ctx: context.Background(), jobs: make(chan Invocation, 1)}
+ if !bounded.Submit(Invocation{}) || bounded.Submit(Invocation{}) {
+ t.Fatal("unbounded admission")
+ }
+}
diff --git a/internal/sound/alsa.go b/internal/sound/alsa.go
new file mode 100644
index 00000000..11650e10
--- /dev/null
+++ b/internal/sound/alsa.go
@@ -0,0 +1,62 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package sound
+
+/*
+#cgo pkg-config: alsa
+#include
+#include
+
+static int fenrir_pcm_open(snd_pcm_t **pcm, const char *device) {
+ int result = snd_pcm_open(pcm, device, SND_PCM_STREAM_PLAYBACK, SND_PCM_NONBLOCK);
+ if (result < 0) return result;
+ result = snd_pcm_set_params(*pcm, SND_PCM_FORMAT_FLOAT,
+ SND_PCM_ACCESS_RW_INTERLEAVED, 2, 48000, 1, 20000);
+ if (result < 0) {
+ snd_pcm_close(*pcm);
+ *pcm = NULL;
+ }
+ return result;
+}
+*/
+import "C"
+
+import (
+ "fmt"
+ "syscall"
+ "unsafe"
+)
+
+type alsaPCM struct{ handle *C.snd_pcm_t }
+
+func open_alsa(s *Service, device string) error {
+ name := C.CString(device)
+ defer C.free(unsafe.Pointer(name))
+ pcm := &alsaPCM{}
+ if result := C.fenrir_pcm_open(&pcm.handle, name); result < 0 {
+ return fmt.Errorf("open ALSA PCM %q: %s", device, C.GoString(C.snd_strerror(result)))
+ }
+ s.start_pcm(pcm)
+ return nil
+}
+
+// ALSA receives interleaved native-endian stereo float samples. All PCM calls
+// after setup belong to the playback worker; Close runs after that worker exits.
+func (p *alsaPCM) write(samples []float32) (int, error) {
+ result := C.snd_pcm_writei(p.handle, unsafe.Pointer(&samples[0]), C.snd_pcm_uframes_t(len(samples)/2))
+ if result < 0 {
+ return 0, syscall.Errno(-result)
+ }
+ return int(result) * 2, nil
+}
+func (p *alsaPCM) prepare() error { return pcm_error(C.snd_pcm_prepare(p.handle)) }
+func (p *alsaPCM) drain() error { return pcm_error(C.snd_pcm_drain(p.handle)) }
+func (p *alsaPCM) close() { C.snd_pcm_drop(p.handle); C.snd_pcm_close(p.handle) }
+func pcm_error(result C.int) error {
+ if result < 0 {
+ return syscall.Errno(-result)
+ }
+ return nil
+}
diff --git a/internal/sound/drivers.go b/internal/sound/drivers.go
new file mode 100644
index 00000000..60b7cfd9
--- /dev/null
+++ b/internal/sound/drivers.go
@@ -0,0 +1,59 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package sound
+
+import (
+ "fmt"
+ "strings"
+)
+
+type Options struct {
+ Driver string
+ Device string
+ Volume float64
+}
+
+type Driver struct {
+ ID, Label string
+ open func(*Service, string) error
+}
+
+// Drivers is the shared startup, validation and form registry. PipeWire uses
+// its PulseAudio service; explicit selections never switch drivers on failure.
+func Drivers() []Driver {
+ return []Driver{{ID: "pulse", Label: "PulseAudio", open: open_pulse}, {ID: "alsa", Label: "ALSA", open: open_alsa}}
+}
+
+func Find_driver(id string) (Driver, error) {
+ for _, driver := range Drivers() {
+ if driver.ID == id {
+ return driver, nil
+ }
+ }
+ return Driver{}, fmt.Errorf("unknown sound driver %q", id)
+}
+
+func Validate_selection(driver, device string) error {
+ if _, err := Find_driver(driver); err != nil {
+ return err
+ }
+ if strings.TrimSpace(device) == "" || strings.ContainsAny(device, "\x00\r\n") {
+ return fmt.Errorf("sound device must be a nonempty ALSA PCM name")
+ }
+ return nil
+}
diff --git a/internal/sound/indentation.go b/internal/sound/indentation.go
new file mode 100644
index 00000000..bd77c23e
--- /dev/null
+++ b/internal/sound/indentation.go
@@ -0,0 +1,28 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package sound
+
+// Use progress's pitch mapping with a triangle wave to distinguish indentation
+// from progress's clipped sine. Cache 0..100 columns before input opens.
+func indentation_clips() [101]*clip {
+ var clips [101]*clip
+ for level := range clips {
+ clips[level] = progress_tone_shape(float64(level), true)
+ }
+ return clips
+}
+
+// Indentation uses ordinary cue admission, independently of the progress
+// monitor and its cancellation/coalescing state. Deeper levels use tone 100.
+func (s *Service) Play_indentation(level int) bool {
+ if s == nil || s.closed.Load() || s.finishing.Load() || level < 0 {
+ return false
+ }
+ item := s.indentationTones[min(level, len(s.indentationTones)-1)]
+ if item == nil {
+ return false
+ }
+ s.pending.Store(item)
+ return true
+}
diff --git a/internal/sound/indentation_test.go b/internal/sound/indentation_test.go
new file mode 100644
index 00000000..01bcd58e
--- /dev/null
+++ b/internal/sound/indentation_test.go
@@ -0,0 +1,51 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package sound
+
+import (
+ "slices"
+ "testing"
+)
+
+func TestIndentationTriangleCacheAndIndependentProgressCancellation(t *testing.T) {
+ s := &Service{tones: progress_clips(), indentationTones: indentation_clips()}
+ s.Set_volume(0.5)
+ for level, item := range s.indentationTones {
+ if item == nil || len(item.samples) != len(s.tones[level].samples) || slices.Equal(item.samples, s.tones[level].samples) {
+ t.Fatal("missing or undifferentiated triangle tone", level)
+ }
+ if level > 0 && slices.Equal(item.samples, s.indentationTones[level-1].samples) {
+ t.Fatal("neighboring levels have the same tone", level)
+ }
+ }
+ for _, level := range []int{0, 1, 50, 100, 101, 10000} {
+ item := s.indentationTones[min(level, 100)]
+ progress := s.tones[min(level, 100)]
+ if len(item.samples) != len(progress.samples) || slices.Equal(item.samples, progress.samples) {
+ t.Fatal("tone shape/duration", level)
+ }
+ if !s.Play_indentation(level) || s.pending.Load() != item || s.progress.Load() != nil {
+ t.Fatal(level)
+ }
+ s.Clear_progress()
+ s.Discard_progress()
+ buffer := make([]float32, 100)
+ s.read_samples(buffer)
+ if s.current != item || s.progressing {
+ t.Fatal("progress cancellation affected indentation", level)
+ }
+ for index, value := range buffer {
+ if value != item.samples[index]*0.5 {
+ t.Fatal("cached tone/volume changed", level)
+ }
+ }
+ }
+ if s.Play_indentation(-1) {
+ t.Fatal("negative indentation accepted")
+ }
+ s.closed.Store(true)
+ if s.Play_indentation(1) {
+ t.Fatal("closed service accepted sound")
+ }
+}
diff --git a/internal/sound/pcm.go b/internal/sound/pcm.go
new file mode 100644
index 00000000..16e6ed09
--- /dev/null
+++ b/internal/sound/pcm.go
@@ -0,0 +1,137 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package sound
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "syscall"
+ "time"
+
+ "github.com/jfreymuth/pulse"
+)
+
+const audioPeriod = 5 * time.Millisecond
+const pcmTimeout = 500 * time.Millisecond
+
+type pcmOutput interface {
+ write([]float32) (int, error)
+ prepare() error
+ drain() error
+ close()
+}
+
+func (s *Service) start_pcm(pcm pcmOutput) {
+ drained := make(chan error, 1)
+ s.closeBackend = pcm.close
+ s.drainBackend = func(ctx context.Context) error {
+ select {
+ case err := <-drained:
+ return err
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-s.done:
+ return nil
+ }
+ }
+ s.workers.Go(func() {
+ err := s.run_pcm(pcm)
+ drained <- err
+ if err != nil && !s.closed.Load() {
+ select {
+ case s.errors <- fmt.Errorf("ALSA playback: %w", err):
+ default:
+ }
+ }
+ })
+}
+
+func (s *Service) run_pcm(pcm pcmOutput) error {
+ // Pace even a virtual PCM such as ALSA's null sink. No busy silence loop,
+ // and the same cancellation and cue priority apply to both audio drivers.
+ tick := time.NewTicker(audioPeriod)
+ defer tick.Stop()
+ samples := make([]float32, int(int64(sampleRate*2)*int64(audioPeriod)/int64(time.Second)))
+ for {
+ select {
+ case <-s.done:
+ return nil
+ case <-tick.C:
+ }
+ count, err := s.read_samples(samples)
+ if err == pulse.EndOfData {
+ ctx, cancel := context.WithTimeout(context.Background(), pcmTimeout)
+ defer cancel()
+ return s.drain_pcm(ctx, pcm)
+ }
+ if err != nil {
+ return err
+ }
+ if err := s.write_pcm(pcm, samples[:count]); err != nil {
+ return err
+ }
+ }
+}
+
+func (s *Service) write_pcm(pcm pcmOutput, samples []float32) error {
+ ctx, cancel := context.WithTimeout(context.Background(), pcmTimeout)
+ defer cancel()
+ for len(samples) > 0 {
+ select {
+ case <-s.done:
+ return nil
+ case <-ctx.Done():
+ return fmt.Errorf("PCM write stalled: %w", ctx.Err())
+ default:
+ }
+ count, err := pcm.write(samples)
+ if count < 0 || count > len(samples) || count%2 != 0 {
+ return fmt.Errorf("invalid PCM sample count %d", count)
+ }
+ samples = samples[count:]
+ switch {
+ case errors.Is(err, syscall.EPIPE), errors.Is(err, syscall.ESTRPIPE):
+ // prepare drops the broken buffer without snd_pcm_resume/recover's
+ // potentially blocking suspend retry loop. Bound repeated failures.
+ if err := pcm.prepare(); err != nil {
+ return err
+ }
+ case err != nil && !errors.Is(err, syscall.EAGAIN) && !errors.Is(err, syscall.EINTR):
+ return err
+ case err == nil && count > 0:
+ continue
+ }
+ if err := s.wait_pcm(ctx); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (s *Service) drain_pcm(ctx context.Context, pcm pcmOutput) error {
+ for {
+ err := pcm.drain()
+ if !errors.Is(err, syscall.EAGAIN) && !errors.Is(err, syscall.EINTR) {
+ return err
+ }
+ if err := s.wait_pcm(ctx); err != nil {
+ return err
+ }
+ }
+}
+
+func (s *Service) wait_pcm(ctx context.Context) error {
+ timer := time.NewTimer(audioPeriod)
+ defer timer.Stop()
+ select {
+ case <-s.done:
+ return context.Canceled
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-timer.C:
+ return nil
+ }
+}
diff --git a/internal/sound/pcm_test.go b/internal/sound/pcm_test.go
new file mode 100644
index 00000000..5e36eb7e
--- /dev/null
+++ b/internal/sound/pcm_test.go
@@ -0,0 +1,151 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package sound
+
+import (
+ "context"
+ "errors"
+ "slices"
+ "syscall"
+ "testing"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/presentation"
+)
+
+type fakePCM struct {
+ writeSamples func([]float32) (int, error)
+ drainResult func() error
+ received []float32
+ preparations, drains, closes int
+}
+
+func (p *fakePCM) write(samples []float32) (int, error) {
+ if p.writeSamples != nil {
+ return p.writeSamples(samples)
+ }
+ p.received = append(p.received, samples...)
+ return len(samples), nil
+}
+func (p *fakePCM) prepare() error { p.preparations++; return nil }
+func (p *fakePCM) drain() error {
+ p.drains++
+ if p.drainResult != nil {
+ return p.drainResult()
+ }
+ return nil
+}
+func (p *fakePCM) close() { p.closes++ }
+
+func TestPCMShortWritesAndUnderrunRecoveryPreserveSampleOrder(t *testing.T) {
+ s := &Service{done: make(chan struct{})}
+ pcm := &fakePCM{}
+ calls := 0
+ pcm.writeSamples = func(samples []float32) (int, error) {
+ calls++
+ switch calls {
+ case 1:
+ return 0, syscall.EPIPE
+ case 2:
+ return 0, syscall.EAGAIN
+ case 3:
+ return 0, syscall.ESTRPIPE
+ case 4:
+ return 0, syscall.EINTR
+ }
+ pcm.received = append(pcm.received, samples[:2]...)
+ return 2, nil
+ }
+ samples := []float32{1, -1, .5, -.5, .25, -.25}
+ if err := s.write_pcm(pcm, samples); err != nil {
+ t.Fatal(err)
+ }
+ if !slices.Equal(pcm.received, samples) || pcm.preparations != 2 {
+ t.Fatal(pcm.received, pcm.preparations)
+ }
+}
+
+func TestPCMStallsAndDrainRespectCancellation(t *testing.T) {
+ s := &Service{done: make(chan struct{})}
+ pcm := &fakePCM{writeSamples: func([]float32) (int, error) { return 0, syscall.EAGAIN }, drainResult: func() error { return syscall.EAGAIN }}
+ if err := s.write_pcm(pcm, []float32{1, 1}); !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatal("write did not expire", err)
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ if err := s.drain_pcm(ctx, pcm); !errors.Is(err, context.Canceled) {
+ t.Fatal("drain did not cancel", err)
+ }
+ close(s.done)
+ if err := s.drain_pcm(context.Background(), pcm); !errors.Is(err, context.Canceled) {
+ t.Fatal("close did not cancel drain", err)
+ }
+}
+
+func TestPCMShutdownUsesSharedCueVolumeAndDrainsBeforeClose(t *testing.T) {
+ s := &Service{clips: map[presentation.Cue]*clip{presentation.Stopped: {samples: []float32{.8, -.8, .4, -.4}}}, done: make(chan struct{}), errors: make(chan error, 1)}
+ s.Set_volume(.5)
+ pcm := &fakePCM{}
+ s.start_pcm(pcm)
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ if err := s.Finish(ctx); err != nil {
+ t.Fatal(err)
+ }
+ // Finish waits for the worker's completed drain, making its samples readable.
+ if pcm.drains != 1 || pcm.closes != 0 || !slices.Equal(pcm.received[:4], []float32{.4, -.4, .2, -.2}) {
+ t.Fatal(pcm)
+ }
+ s.Close()
+ s.Close()
+ if pcm.closes != 1 {
+ t.Fatal("PCM closed incorrectly", pcm.closes)
+ }
+}
+
+func TestPCMFailureDisablesOutputWithoutBlockingAdmission(t *testing.T) {
+ s := &Service{done: make(chan struct{}), errors: make(chan error, 1)}
+ pcm := &fakePCM{writeSamples: func([]float32) (int, error) { return 0, syscall.ENODEV }}
+ s.start_pcm(pcm)
+ select {
+ case err := <-s.Errors():
+ if !errors.Is(err, syscall.ENODEV) {
+ t.Fatal(err)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("PCM failure was not reported")
+ }
+ s.Close()
+ if pcm.closes != 1 {
+ t.Fatal(pcm.closes)
+ }
+}
+
+func TestALSANullSinkLifecycleWithoutHardwareOrDesktopAudio(t *testing.T) {
+ s, err := Open([]string{"../../config/sound/default"}, Options{Driver: "alsa", Device: "null", Volume: .7})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer s.Close()
+ if !s.Play(presentation.Ready) || !s.Play_progress(50) {
+ t.Fatal("shared audio admission failed")
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ if err := s.Finish(ctx); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestMissingPackKeepsGeneratedTonesOnALSANull(t *testing.T) {
+ s, err := Open([]string{t.TempDir()}, Options{Driver: "alsa", Device: "null", Volume: .7})
+ if s == nil || err == nil {
+ t.Fatal(s, err)
+ }
+ defer s.Close()
+ if s.Play(presentation.Ready) || !s.Play_progress(50) {
+ t.Fatal("missing cue or generated tone admission")
+ }
+}
diff --git a/internal/sound/progress.go b/internal/sound/progress.go
new file mode 100644
index 00000000..f3715f98
--- /dev/null
+++ b/internal/sound/progress.go
@@ -0,0 +1,104 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+// Tone synthesis adapted from Cthulhu (Storm Dragon and Angus Kola).
+package sound
+
+import "math"
+
+// A small gain lift for progress tones, independent of normal cue volume.
+const progressGain = 1.03 * 1.02
+
+// Precompute integer percentages before opening the stream. Admission and the
+// audio callback only move pointers; fractional progress rounds to the nearest
+// percent. The cache is bounded to 101 short clips (about 1.6 MiB).
+func progress_clips() [101]*clip {
+ var clips [101]*clip
+ for percentage := range clips {
+ clips[percentage] = progress_tone(float64(percentage))
+ }
+ return clips
+}
+
+// Match Cthulhu's NVDA-style tones: 110 * 2^(percent/25) Hz, clipped sine,
+// 40 ms padded to an integer cycle, 7000/32768 amplitude per channel. Generate
+// at its 44100 Hz rate, then interpolate onto our shared 48000 Hz stream.
+func progress_tone(percentage float64) *clip {
+ return progress_tone_shape(percentage, false)
+}
+
+// The triangle variant shares progress's pitch, duration and resampling.
+func progress_tone_shape(percentage float64, triangle bool) *clip {
+ const sourceRate = 44100
+ frequency := float32(110 * math.Pow(2, percentage/25))
+ cycleLength := float64(float32(sourceRate) / frequency)
+ samplesPerCycle := int(cycleLength)
+ count := sourceRate * 40 / 1000
+ count += samplesPerCycle - count%samplesPerCycle
+ source := make([]float32, count)
+ for index := range source {
+ phase := float64(index) * 2 * math.Pi / cycleLength
+ wave := min(1.0, max(-1.0, math.Sin(phase)*2))
+ if triangle {
+ wave = 2 / math.Pi * math.Asin(math.Sin(phase))
+ }
+ value := int16(wave * 7000)
+ source[index] = float32(value) / 32768
+ }
+ frames := (count*sampleRate + sourceRate - 1) / sourceRate
+ samples := make([]float32, frames*2)
+ for frame := range frames {
+ position := float64(frame) * sourceRate / sampleRate
+ left := min(int(position), count-1)
+ right := min(left+1, count-1)
+ value := source[left] + (source[right]-source[left])*float32(position-math.Floor(position))
+ samples[frame*2], samples[frame*2+1] = value, value
+ }
+ return &clip{samples: samples}
+}
+
+// Play_progress coalesces progress separately from ordinary cues. Progress
+// never replaces startup/review sounds and never cuts a progress tone short.
+func (s *Service) Play_progress(percentage float64) bool {
+ if s == nil || s.closed.Load() || s.finishing.Load() || math.IsNaN(percentage) || math.IsInf(percentage, 0) || percentage < 0 || percentage > 100 {
+ return false
+ }
+ item := s.tones[int(math.Round(percentage))]
+ if item == nil {
+ return false
+ }
+ s.progress.Store(item)
+ return true
+}
+
+// Discard_progress drops waiting tones after passive redraws or prompts while
+// letting the short tone already playing finish.
+func (s *Service) Discard_progress() {
+ if s != nil {
+ s.progress.Store(nil)
+ }
+}
+
+// Clear_progress drops pending/playing tones at input, menu capture, and
+// session handoff without touching any ordinary sound cue.
+func (s *Service) Clear_progress() {
+ if s == nil {
+ return
+ }
+ s.progress.Store(nil)
+ s.cancelProgress.Store(true)
+}
diff --git a/internal/sound/progress_test.go b/internal/sound/progress_test.go
new file mode 100644
index 00000000..f9fd301f
--- /dev/null
+++ b/internal/sound/progress_test.go
@@ -0,0 +1,209 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package sound
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/binary"
+ "fmt"
+ "math"
+ "slices"
+ "sync"
+ "testing"
+
+ "git.stormux.org/storm/fenrir/internal/presentation"
+)
+
+func TestProgressTonePitchDurationStereoAndVolume(t *testing.T) {
+ for _, percentage := range []float64{0, 25, 50, 75, 100} {
+ item := progress_tone(percentage)
+ frames := len(item.samples) / 2
+ if frames < sampleRate*40/1000 || frames > sampleRate*50/1000 {
+ t.Fatal("duration", frames)
+ }
+ crossings := 0
+ for frame := range frames {
+ left, right := item.samples[frame*2], item.samples[frame*2+1]
+ if left != right || math.Abs(float64(left)) > 7000.0/32768+0.00001 {
+ t.Fatal("stereo/amplitude", left, right)
+ }
+ if frame > 0 && item.samples[(frame-1)*2] <= 0 && left > 0 {
+ crossings++
+ }
+ }
+ gotHz := float64(crossings) * sampleRate / float64(frames)
+ wantHz := 110 * math.Pow(2, percentage/25)
+ if math.Abs(gotHz-wantHz) > 26 {
+ t.Fatal("pitch", percentage, gotHz, wantHz)
+ }
+ }
+ s := &Service{tones: progress_clips()}
+ s.Set_volume(.25)
+ s.Play_progress(50)
+ buffer := make([]float32, len(s.tones[50].samples))
+ s.read_samples(buffer)
+ for index, value := range buffer {
+ if value != s.tones[50].samples[index]*(float32(.25)*float32(1.0506)) {
+ t.Fatal("unscaled progress")
+ }
+ }
+}
+
+func TestProgressPriorityCoalescingAndCancellation(t *testing.T) {
+ s := &Service{clips: map[presentation.Cue]*clip{
+ presentation.Ready: {samples: []float32{1, 1, 2, 2}},
+ presentation.Top: {samples: []float32{3, 3, 4, 4}},
+ }, tones: [101]*clip{0: {samples: []float32{5, 5, 6, 6}}, 50: {samples: []float32{7, 7, 8, 8}}, 100: {samples: []float32{9, 9}}}}
+ s.Set_volume(1)
+ buffer := make([]float32, 2)
+ read := func(want float32) {
+ t.Helper()
+ s.read_samples(buffer)
+ if math.Abs(float64(buffer[0]-want)) > 0.00001 {
+ t.Fatal("wrong audio", buffer, want)
+ }
+ }
+ s.Play(presentation.Ready)
+ s.Play_progress(0)
+ read(1)
+ s.Play(presentation.Top)
+ s.Play_progress(50)
+ read(2)
+ read(3)
+ read(4)
+ read(7 * 1.0506)
+ s.Play_progress(0)
+ s.Play_progress(100)
+ read(8 * 1.0506)
+ read(9 * 1.0506)
+ read(0)
+ // Ordinary cues can interrupt a tone, and a pending tone survives that cue.
+ s.Play_progress(50)
+ read(7 * 1.0506)
+ s.Play_progress(100)
+ s.Play(presentation.Top)
+ read(3)
+ read(4)
+ read(9 * 1.0506)
+ s.Play_progress(50)
+ read(7 * 1.0506)
+ s.Play_progress(100)
+ s.Clear_progress()
+ read(0)
+ s.Play(presentation.Top)
+ read(3)
+ s.Clear_progress()
+ read(4)
+ // Shutdown stops progress admission and discards pending tones.
+ s.Play_progress(100)
+ s.Finish(context.Background())
+ read(0)
+ if s.Play_progress(50) {
+ t.Fatal("accepted during shutdown")
+ }
+ s.Close()
+ if s.Play_progress(50) {
+ t.Fatal("accepted after close")
+ }
+}
+
+func TestProgressAdmissionIsBoundedAndConcurrent(t *testing.T) {
+ s := &Service{tones: progress_clips()}
+ s.Set_volume(1)
+ for _, value := range []float64{-1, 101, math.NaN(), math.Inf(1)} {
+ if s.Play_progress(value) {
+ t.Fatal("invalid progress", value)
+ }
+ }
+ s.Play_progress(49.6)
+ if s.progress.Load() != s.tones[50] {
+ t.Fatal("fraction rounding")
+ }
+ cached := slices.Clone(s.tones[50].samples)
+ var workers sync.WaitGroup
+ workers.Go(func() {
+ for range 10000 {
+ s.Play_progress(50)
+ s.Discard_progress()
+ s.Clear_progress()
+ s.Play_progress(100)
+ }
+ })
+ buffer := make([]float32, 128)
+ for range 10000 {
+ s.read_samples(buffer)
+ }
+ workers.Wait()
+ if !slices.Equal(cached, s.tones[50].samples) {
+ t.Fatal("mutated cache")
+ }
+ if allocations := testing.AllocsPerRun(100, func() { s.Play_progress(50) }); allocations != 0 {
+ t.Fatal("admission allocates", allocations)
+ }
+ if allocations := testing.AllocsPerRun(100, func() { s.read_samples(buffer) }); allocations != 0 {
+ t.Fatal("callback allocates", allocations)
+ }
+}
+
+func TestDiscardProgressPreservesEntirePlayingTone(t *testing.T) {
+ s := &Service{tones: progress_clips()}
+ s.Set_volume(1)
+ s.Play_progress(50)
+ tone := s.tones[50].samples
+ split := len(tone) / 4 * 2 // Keep stereo frames paired.
+ first := make([]float32, split)
+ s.read_samples(first)
+ s.Play_progress(100)
+ s.Discard_progress()
+ tail := make([]float32, len(tone)-split)
+ s.read_samples(tail)
+ for index, value := range append(first, tail...) {
+ if value != tone[index]*float32(progressGain) {
+ t.Fatalf("tone cut short or altered at sample %d: got %v, want %v", index, value, tone[index]*float32(progressGain))
+ }
+ }
+ s.read_samples(tail)
+ for _, value := range tail {
+ if value != 0 {
+ t.Fatal("discarded waiting tone still played")
+ }
+ }
+ // A reset must not prevent the next job from making progress sounds.
+ s.Play_progress(100)
+ s.read_samples(tail)
+ if !slices.ContainsFunc(tail, func(value float32) bool { return value != 0 }) {
+ t.Fatal("new progress tone was lost after discard")
+ }
+}
+
+func TestProgressSamplesMatchCthulhuReference(t *testing.T) {
+ // Digest of Cthulhu's unmodified progress_tone_wav for integer 0..100,
+ // independently resampled from 44100 Hz PCM16 to our 48000 Hz stereo float.
+ hash := sha256.New()
+ for _, item := range progress_clips() {
+ for _, sample := range item.samples {
+ if err := binary.Write(hash, binary.LittleEndian, sample); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ if got := fmt.Sprintf("%x", hash.Sum(nil)); got != "b6e0adff86b6659465ff6942c4651b84e76d855ba6a8b7e3a982a88f8dde4e9b" {
+ t.Fatal("Cthulhu tone samples differ", got)
+ }
+}
diff --git a/internal/sound/pulse.go b/internal/sound/pulse.go
new file mode 100644
index 00000000..e7b5b2de
--- /dev/null
+++ b/internal/sound/pulse.go
@@ -0,0 +1,70 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package sound
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "time"
+
+ "github.com/jfreymuth/pulse"
+ "github.com/jfreymuth/pulse/proto"
+)
+
+func open_pulse(s *Service, _ string) error {
+ options := []pulse.ClientOption{pulse.ClientApplicationName("Fenrir sounds"), pulse.ClientTimeout(500 * time.Millisecond)}
+ // Match the invoking user's session when sudo strips XDG_RUNTIME_DIR.
+ // An explicit PULSE_SERVER continues to take precedence.
+ if server := Server(os.Getenv("PULSE_SERVER"), os.Getenv("SUDO_UID")); server != "" {
+ options = append(options, pulse.ClientServerString(server))
+ }
+ client, err := pulse.NewClient(options...)
+ if err != nil {
+ return fmt.Errorf("connect sound server: %w", err)
+ }
+ stream, err := client.NewPlayback(pulse.Float32Reader(s.read_samples), pulse.PlaybackStereo, pulse.PlaybackSampleRate(sampleRate), pulse.PlaybackLatency(0.02), pulse.PlaybackMediaName("Fenrir sound cues"))
+ if err != nil {
+ client.Close()
+ return err
+ }
+ s.closeBackend = func() { stream.Close(); client.Close() }
+ // Drain even after the final reader callback reports EndOfData. The
+ // library's Drain helper skips idle streams, whose buffered tail may
+ // still be audible on the server.
+ s.drainBackend = func(_ context.Context) error {
+ return client.RawRequest(&proto.DrainPlaybackStream{StreamIndex: stream.StreamIndex()}, nil)
+ }
+ stream.Start()
+ s.workers.Go(func() {
+ tick := time.NewTicker(time.Second)
+ defer tick.Stop()
+ for {
+ select {
+ case <-s.done:
+ return
+ case <-tick.C:
+ if stream.Closed() {
+ s.errors <- fmt.Errorf("sound server connection closed")
+ return
+ }
+ }
+ }
+ })
+ return nil
+}
diff --git a/internal/sound/service.go b/internal/sound/service.go
new file mode 100644
index 00000000..1a7ae9c5
--- /dev/null
+++ b/internal/sound/service.go
@@ -0,0 +1,209 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+// Package sound plays preloaded cues through one persistent in-process stream.
+package sound
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "math"
+ "strconv"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/jfreymuth/pulse"
+
+ "git.stormux.org/storm/fenrir/internal/presentation"
+)
+
+type Service struct {
+ clips map[presentation.Cue]*clip
+ pending atomic.Pointer[clip]
+ tones [101]*clip
+ indentationTones [101]*clip
+ progress atomic.Pointer[clip]
+ cancelProgress atomic.Bool
+ progressing bool // audio callback owns this flag
+ startup atomic.Pointer[clip]
+ closed atomic.Bool
+ finishing atomic.Bool
+ volume atomic.Uint32 // float32 bits shared with the audio callback
+ current *clip // audio callback owns current and offset
+ offset int
+ starting bool // audio callback keeps the startup cue through its last sample
+ closeBackend func()
+ drainBackend func(context.Context) error
+ errors chan error
+ done chan struct{}
+ workers sync.WaitGroup
+}
+
+// Open may return a usable service plus warnings for missing individual cues.
+// Availability failures are nonfatal to speech and keyboard operation.
+func Open(roots []string, options Options) (*Service, error) {
+ if err := Validate_selection(options.Driver, options.Device); err != nil {
+ return nil, err
+ }
+ volume := options.Volume
+ if math.IsNaN(volume) || math.IsInf(volume, 0) || volume < 0 || volume > 1 {
+ return nil, fmt.Errorf("sound volume must be between 0.0 and 1.0")
+ }
+ clips, warning := load_theme(roots)
+ // Generated tones remain available even without an installed sound pack.
+ s := &Service{clips: clips, tones: progress_clips(), indentationTones: indentation_clips(), errors: make(chan error, 1), done: make(chan struct{})}
+ s.Set_volume(volume)
+ driver, _ := Find_driver(options.Driver)
+ if err := driver.open(s, options.Device); err != nil {
+ return nil, errors.Join(warning, err)
+ }
+ return s, warning
+}
+
+func (s *Service) Errors() <-chan error { return s.errors }
+
+// Set_volume applies a validated 0..1 gain to the next audio buffer, including
+// any cue or progress tone already playing. Cached samples remain unchanged.
+func (s *Service) Set_volume(volume float64) {
+ s.volume.Store(math.Float32bits(float32(volume)))
+}
+
+func Server(explicit, sudoUID string) string {
+ if explicit != "" {
+ return explicit
+ }
+ if uid, err := strconv.ParseUint(sudoUID, 10, 32); err == nil {
+ return fmt.Sprintf("unix:/run/user/%d/pulse/native", uid)
+ }
+ return ""
+}
+
+// Play only replaces a mailbox pointer. Startup has its own slot so initial
+// screen feedback cannot replace it. Other cues retain only the latest request.
+// Admission never waits for audio or touches the filesystem.
+func (s *Service) Play(cue presentation.Cue) bool {
+ if s == nil || s.closed.Load() || s.finishing.Load() {
+ return false
+ }
+ if item := s.clips[cue]; item != nil {
+ if cue == presentation.Ready {
+ s.startup.Store(item)
+ } else {
+ s.pending.Store(item)
+ }
+ return true
+ }
+ return false
+}
+
+func (s *Service) read_samples(samples []float32) (int, error) {
+ clear(samples)
+ if s.closed.Load() {
+ return len(samples), nil
+ }
+ if s.cancelProgress.Swap(false) && s.progressing {
+ s.current, s.progressing = nil, false
+ }
+ if s.finishing.Load() {
+ s.progress.Store(nil)
+ s.startup.Store(nil)
+ s.starting = false
+ } else if next := s.startup.Swap(nil); next != nil {
+ s.current, s.offset, s.starting, s.progressing = next, 0, true, false
+ }
+ if !s.starting {
+ if next := s.pending.Swap(nil); next != nil {
+ s.current, s.offset, s.progressing = next, 0, false
+ }
+ }
+ if s.current == nil && !s.finishing.Load() {
+ if next := s.progress.Swap(nil); next != nil {
+ s.current, s.offset, s.progressing = next, 0, true
+ }
+ }
+ if s.current != nil && s.offset == len(s.current.samples) && s.current.finished != nil {
+ close(s.current.finished)
+ s.current = nil
+ return 0, pulse.EndOfData
+ }
+ if s.current != nil {
+ count := copy(samples, s.current.samples[s.offset:])
+ volume := math.Float32frombits(s.volume.Load())
+ if s.progressing {
+ volume *= progressGain
+ }
+ for index := range count {
+ samples[index] *= volume
+ }
+ s.offset += count
+ if s.offset == len(s.current.samples) && s.current.finished == nil {
+ s.current = nil
+ s.starting, s.progressing = false, false
+ }
+ }
+ return len(samples), nil
+}
+
+// Finish is called after input/review admission stops and keyboards are
+// released. Wait for the final cue's samples to be submitted, then drain the
+// server before Close destroys the stream. Waiting is bounded by the clip's
+// duration plus one second even when callbacks stop arriving.
+func (s *Service) Finish(ctx context.Context) error {
+ if s == nil || s.closed.Load() || s.finishing.Swap(true) {
+ return nil
+ }
+ s.Clear_progress()
+ item := s.clips[presentation.Stopped]
+ if item == nil || math.Float32frombits(s.volume.Load()) == 0 {
+ return nil
+ }
+ ctx, cancel := context.WithTimeout(ctx, time.Second+time.Duration(len(item.samples))*time.Second/(sampleRate*2))
+ defer cancel()
+ final := &clip{samples: item.samples, finished: make(chan struct{})}
+ s.pending.Store(final)
+ select {
+ case <-final.finished:
+ if s.drainBackend != nil {
+ return s.drainBackend(ctx)
+ }
+ return nil
+ case <-s.done:
+ return nil
+ case err := <-s.errors:
+ return err
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+}
+
+func (s *Service) Close() {
+ if s == nil || s.closed.Swap(true) {
+ return
+ }
+ s.pending.Store(nil)
+ s.Clear_progress()
+ s.startup.Store(nil)
+ if s.done != nil {
+ close(s.done)
+ }
+ s.workers.Wait()
+ if s.closeBackend != nil {
+ s.closeBackend()
+ }
+}
diff --git a/internal/sound/service_test.go b/internal/sound/service_test.go
new file mode 100644
index 00000000..aaece80a
--- /dev/null
+++ b/internal/sound/service_test.go
@@ -0,0 +1,287 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package sound
+
+import (
+ "context"
+ "errors"
+ "runtime"
+ "slices"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/jfreymuth/pulse"
+
+ "git.stormux.org/storm/fenrir/internal/presentation"
+)
+
+func TestVolumeScalesSamplesWithoutChangingCachedCues(t *testing.T) {
+ item := &clip{samples: []float32{0.8, -0.4}}
+ s := &Service{clips: map[presentation.Cue]*clip{presentation.Ready: item}}
+ s.Set_volume(0.25)
+ buffer := make([]float32, 4)
+ s.Play(presentation.Ready)
+ s.read_samples(buffer)
+ if buffer[0] != 0.2 || buffer[1] != -0.1 || buffer[2] != 0 || item.samples[0] != 0.8 {
+ t.Fatal("volume changed samples or source cue", buffer, item.samples)
+ }
+ s.Set_volume(0)
+ s.Play(presentation.Ready)
+ s.read_samples(buffer)
+ if buffer[0] != 0 || buffer[1] != 0 {
+ t.Fatal("zero volume was audible", buffer)
+ }
+}
+
+func TestVolumeChangesApplyToPlayingCuesAndProgress(t *testing.T) {
+ for _, kind := range []string{"cue", "startup", "progress"} {
+ t.Run(kind, func(t *testing.T) {
+ original := []float32{1, -1, 0.5, -0.5, 1, -1, 0.25, -0.25}
+ item := &clip{samples: slices.Clone(original)}
+ s := &Service{clips: map[presentation.Cue]*clip{presentation.Top: item, presentation.Ready: item}}
+ gain := float32(1)
+ switch kind {
+ case "cue":
+ s.Play(presentation.Top)
+ case "startup":
+ s.Play(presentation.Ready)
+ case "progress":
+ s.tones[50] = item
+ s.Play_progress(50)
+ gain = progressGain
+ }
+ buffer := make([]float32, 2)
+ for index, volume := range []float64{0.7, 0.1, 0, 1} {
+ s.Set_volume(volume)
+ s.read_samples(buffer)
+ for channel, sample := range buffer {
+ want := original[index*2+channel] * (float32(volume) * gain)
+ if sample != want {
+ t.Fatal("playing volume/position", volume, buffer, "want", want)
+ }
+ }
+ }
+ if !slices.Equal(item.samples, original) {
+ t.Fatal("volume modified cached samples", item.samples)
+ }
+ })
+ }
+}
+
+func TestZeroVolumeSkipsShutdownPlayback(t *testing.T) {
+ s := &Service{clips: map[presentation.Cue]*clip{presentation.Stopped: {samples: []float32{1, 1}}}}
+ s.Set_volume(0.7)
+ s.Set_volume(0)
+ if err := s.Finish(context.Background()); err != nil || s.pending.Load() != nil {
+ t.Fatal("zero volume queued shutdown audio", err)
+ }
+}
+
+func TestShutdownSubmitsFinalCueThenDrainsBeforeClose(t *testing.T) {
+ s := &Service{clips: map[presentation.Cue]*clip{
+ presentation.Ready: {samples: []float32{0.8, 0.8, 0.8, 0.8, 0.8, 0.8}},
+ presentation.Stopped: {samples: []float32{0.4, -0.4, 0.8, -0.8}},
+ }}
+ s.Set_volume(0.5)
+ var operations []string
+ s.drainBackend = func(context.Context) error { operations = append(operations, "drain"); return nil }
+ s.closeBackend = func() { operations = append(operations, "close") }
+ s.Play(presentation.Ready)
+ buffer := make([]float32, 2)
+ s.read_samples(buffer) // Shutdown must replace even an unfinished startup.
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ result := make(chan error, 1)
+ go func() { result <- s.Finish(ctx) }()
+ for s.pending.Load() == nil {
+ if ctx.Err() != nil {
+ t.Fatal("shutdown cue never submitted")
+ }
+ runtime.Gosched()
+ }
+ s.Play(presentation.Ready) // Admission has stopped; must not replace shutdown.
+ s.read_samples(buffer)
+ if buffer[0] != 0.2 || buffer[1] != -0.2 {
+ t.Fatal("wrong shutdown start", buffer)
+ }
+ s.read_samples(buffer)
+ if buffer[0] != 0.4 || buffer[1] != -0.4 {
+ t.Fatal("shutdown tail lost", buffer)
+ }
+ if n, err := s.read_samples(buffer); n != 0 || err != pulse.EndOfData {
+ t.Fatal("final stream did not terminate", n, err)
+ }
+ if err := <-result; err != nil {
+ t.Fatal(err)
+ }
+ if len(operations) != 1 || operations[0] != "drain" {
+ t.Fatal("closed before draining", operations)
+ }
+ s.Close()
+ if len(operations) != 2 || operations[1] != "close" {
+ t.Fatal("backend not closed after drain", operations)
+ }
+}
+
+func TestShutdownDoesNotHangWithoutAudioCallbacks(t *testing.T) {
+ s := &Service{clips: map[presentation.Cue]*clip{presentation.Stopped: {samples: []float32{1, 1}}}}
+ s.Set_volume(1)
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ if err := s.Finish(ctx); !errors.Is(err, context.Canceled) {
+ t.Fatal("ignored shutdown cancellation", err)
+ }
+ s.Close()
+}
+
+func TestLatestCueReplacesPendingAndPlayingAudio(t *testing.T) {
+ s := &Service{clips: map[presentation.Cue]*clip{
+ presentation.Top: {samples: []float32{1, 2, 3, 4, 5, 6}},
+ presentation.Bottom: {samples: []float32{7, 8}},
+ }}
+ s.Set_volume(1)
+ buffer := make([]float32, 4)
+ s.Play(presentation.Top)
+ s.Play(presentation.Bottom)
+ s.read_samples(buffer)
+ if buffer[0] != 7 || buffer[1] != 8 || buffer[2] != 0 {
+ t.Fatal("stale pending cue", buffer)
+ }
+ s.Play(presentation.Top)
+ s.read_samples(buffer)
+ if buffer[0] != 1 || buffer[3] != 4 {
+ t.Fatal("wrong first chunk", buffer)
+ }
+ s.Play(presentation.Bottom)
+ s.read_samples(buffer)
+ if buffer[0] != 7 || buffer[2] != 0 {
+ t.Fatal("old playing cue continued", buffer)
+ }
+ s.read_samples(buffer)
+ if buffer[0] != 0 {
+ t.Fatal("cue replayed", buffer)
+ }
+ closed := 0
+ s.closeBackend = func() { closed++ }
+ s.Close()
+ s.Close()
+ s.Play(presentation.Top)
+ s.read_samples(buffer)
+ if buffer[0] != 0 || closed != 1 {
+ t.Fatal("closed service replayed or closed twice")
+ }
+}
+
+func TestStartupCompletesBeforeLatestScreenCue(t *testing.T) {
+ s := &Service{clips: map[presentation.Cue]*clip{
+ presentation.Ready: {samples: []float32{1, 2, 3, 4}},
+ presentation.TTYChanged: {samples: []float32{5, 6}},
+ presentation.Blank: {samples: []float32{7, 8}},
+ presentation.Top: {samples: []float32{9, 10}},
+ }}
+ s.Set_volume(1)
+ s.Play(presentation.Ready)
+ s.Play(presentation.TTYChanged)
+ s.Play(presentation.Blank)
+ buffer := make([]float32, 2)
+ s.read_samples(buffer)
+ if buffer[0] != 1 || buffer[1] != 2 {
+ t.Fatal("initial blank replaced startup", buffer)
+ }
+ s.Play(presentation.Top)
+ s.read_samples(buffer)
+ if buffer[0] != 3 || buffer[1] != 4 {
+ t.Fatal("startup tail was interrupted", buffer)
+ }
+ s.read_samples(buffer)
+ if buffer[0] != 9 || buffer[1] != 10 {
+ t.Fatal("latest cue did not follow startup", buffer)
+ }
+ s.read_samples(buffer)
+ if buffer[0] != 0 || buffer[1] != 0 {
+ t.Fatal("stale startup cues replayed", buffer)
+ }
+}
+
+func TestRapidCueAdmissionDoesNotWaitForAudio(t *testing.T) {
+ s := &Service{clips: map[presentation.Cue]*clip{presentation.Blank: {samples: []float32{1, 1}}}}
+ s.Set_volume(1)
+ var workers sync.WaitGroup
+ workers.Go(func() {
+ for index := range 10000 {
+ s.Set_volume(float64(index%11) / 10)
+ s.Play(presentation.Blank)
+ }
+ })
+ buffer := make([]float32, 128)
+ for range 10000 {
+ s.read_samples(buffer)
+ }
+ workers.Wait()
+ if allocations := testing.AllocsPerRun(100, func() { s.Play(presentation.Blank) }); allocations != 0 {
+ t.Fatalf("cue admission allocates: %v", allocations)
+ }
+ s.Close()
+}
+
+func TestPlayReportsUnavailableCues(t *testing.T) {
+ var absent *Service
+ if absent.Play(presentation.Blank) {
+ t.Fatal("absent player accepted a cue")
+ }
+ s := &Service{clips: map[presentation.Cue]*clip{presentation.Blank: {samples: []float32{1, 1}}}}
+ if s.Play(presentation.Top) || s.pending.Load() != nil {
+ t.Fatal("missing cue accepted")
+ }
+ if !s.Play(presentation.Blank) || s.pending.Load() == nil {
+ t.Fatal("available cue rejected")
+ }
+ s.finishing.Store(true)
+ if s.Play(presentation.Blank) {
+ t.Fatal("finishing player accepted a cue")
+ }
+ s.finishing.Store(false)
+ s.Close()
+ if s.Play(presentation.Blank) {
+ t.Fatal("closed player accepted a cue")
+ }
+}
+
+func TestSoundServerFollowsExplicitAndSudoSession(t *testing.T) {
+ if got := Server("unix:/tmp/example-pulse", "1000"); got != "unix:/tmp/example-pulse" {
+ t.Fatal(got)
+ }
+ if got := Server("", "1000"); got != "unix:/run/user/1000/pulse/native" {
+ t.Fatal(got)
+ }
+ for _, uid := range []string{"", "invalid", "../other"} {
+ if got := Server("", uid); got != "" {
+ t.Fatal(got)
+ }
+ }
+}
+
+func BenchmarkCueAdmission(b *testing.B) {
+ s := &Service{clips: map[presentation.Cue]*clip{presentation.Blank: {samples: []float32{1, 1}}}}
+ s.Set_volume(1)
+ b.ReportAllocs()
+ for b.Loop() {
+ s.Play(presentation.Blank)
+ }
+}
diff --git a/internal/sound/soundicons.toml b/internal/sound/soundicons.toml
new file mode 100644
index 00000000..95cf8dbd
--- /dev/null
+++ b/internal/sound/soundicons.toml
@@ -0,0 +1,21 @@
+# Filenames in Fenrir's installed default sound pack.
+[icons]
+ready = "ScreenReaderOn.wav"
+stopped = "ScreenReaderOff.wav"
+tty_changed = "ChangeTTY.wav"
+speech_on = "SpeechOn.wav"
+speech_off = "SpeechOff.wav"
+blank = "EmptyLine.wav"
+capital = "Caps.wav"
+top = "StartOfScreen.wav"
+bottom = "EndOfScreen.wav"
+line_start = "StartOfLine.wav"
+line_end = "EndOfLine.wav"
+error = "ErrorScreen.wav"
+accept = "Accept.wav"
+place_start_mark = "PlaceStartMark.wav"
+place_end_mark = "PlaceEndMark.wav"
+copy_to_clipboard = "CopyToClipboard.wav"
+paste_clipboard = "PasteClipboardOnScreen.wav"
+has_attributes = "has_attribute.wav"
+misspelled = "mispell.wav"
diff --git a/internal/sound/theme.go b/internal/sound/theme.go
new file mode 100644
index 00000000..71cdaf79
--- /dev/null
+++ b/internal/sound/theme.go
@@ -0,0 +1,172 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package sound
+
+import (
+ "bytes"
+ _ "embed"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/pelletier/go-toml/v2"
+
+ "git.stormux.org/storm/fenrir/internal/presentation"
+)
+
+//go:embed soundicons.toml
+var defaultIcons []byte
+
+const sampleRate = 48000
+const maxDuration = 10 * time.Second
+
+type clip struct {
+ samples []float32
+ finished chan struct{} // only the final shutdown clip has a completion signal
+}
+type iconMap struct {
+ Icons map[presentation.Cue]string `toml:"icons"`
+}
+
+// Validate_theme accepts a single pack name or an absolute pack directory.
+func Validate_theme(theme string) error {
+ if strings.TrimSpace(theme) == "" || strings.ContainsAny(theme, "\x00\r\n") || theme == "." || theme == ".." || (!filepath.IsAbs(theme) && filepath.Base(theme) != theme) {
+ return fmt.Errorf("sound theme must be a name or an absolute directory")
+ }
+ return nil
+}
+
+func Theme_paths(home, theme string) []string {
+ if filepath.IsAbs(theme) {
+ return []string{theme}
+ }
+ paths := []string{filepath.Join(home, ".local/stormux/fenrir/sounds", theme)}
+ if executable, err := os.Executable(); err == nil {
+ paths = append(paths, filepath.Join(filepath.Dir(executable), "config", "sound", theme))
+ }
+ return append(paths,
+ filepath.Join("/usr/share/sounds/fenrir", theme),
+ filepath.Join("/usr/share/sounds/fenrirscreenreader", theme),
+ )
+}
+
+// Theme_names is used by the settings worker, never by the input/audio loop.
+// Limit directory enumeration; retain saved choices separately in the form.
+func Theme_names(home string) []string {
+ names := map[string]bool{"default": true}
+ for _, path := range Theme_paths(home, "default") {
+ directory, err := os.Open(filepath.Dir(path))
+ if err != nil {
+ continue
+ }
+ entries, _ := directory.ReadDir(256)
+ directory.Close()
+ for _, entry := range entries {
+ if entry.IsDir() && Validate_theme(entry.Name()) == nil {
+ names[entry.Name()] = true
+ }
+ }
+ }
+ result := []string{"default"}
+ for name := range names {
+ if name != "default" {
+ result = append(result, name)
+ }
+ }
+ slices.Sort(result[1:])
+ return result
+}
+
+// Load and resample once, before input grabs. No disk or decoding work belongs
+// on the input loop or the audio callback. Python soundicons.conf is not read.
+func load_theme(roots []string) (map[presentation.Cue]*clip, error) {
+ var root string
+ for _, candidate := range roots {
+ info, err := os.Stat(candidate)
+ if err == nil && info.IsDir() {
+ root = candidate
+ break
+ }
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return nil, err
+ }
+ }
+ if root == "" {
+ return nil, fmt.Errorf("selected Fenrir sound pack not found")
+ }
+ file, err := os.Open(filepath.Join(root, "soundicons.toml"))
+ var data []byte
+ if errors.Is(err, os.ErrNotExist) {
+ data, err = defaultIcons, nil
+ } else if err == nil {
+ data, err = io.ReadAll(io.LimitReader(file, (64<<10)+1))
+ err = errors.Join(err, file.Close())
+ }
+ if err != nil {
+ return nil, err
+ }
+ if len(data) > 64<<10 {
+ return nil, fmt.Errorf("soundicons.toml exceeds 64 KiB")
+ }
+ var icons iconMap
+ if err := toml.NewDecoder(bytes.NewReader(data)).DisallowUnknownFields().Decode(&icons); err != nil {
+ return nil, fmt.Errorf("soundicons.toml: %w", err)
+ }
+ var known iconMap
+ if err := toml.Unmarshal(defaultIcons, &known); err != nil {
+ return nil, err
+ }
+ clips := make(map[presentation.Cue]*clip)
+ var problems []error
+ for cue, name := range icons.Icons {
+ if _, ok := known.Icons[cue]; !ok {
+ return nil, fmt.Errorf("unknown sound cue %q", cue)
+ }
+ if name == "" || filepath.Base(name) != name || name == "." || name == ".." {
+ return nil, fmt.Errorf("sound cue %q requires a filename in the theme directory", cue)
+ }
+ item, err := decode_file(filepath.Join(root, name))
+ if err != nil {
+ problems = append(problems, fmt.Errorf("%s: %w", cue, err))
+ continue
+ }
+ clips[cue] = item
+ }
+ return clips, errors.Join(problems...)
+}
+
+func decode_file(path string) (*clip, error) {
+ file, err := os.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
+ info, err := file.Stat()
+ if err != nil {
+ return nil, err
+ }
+ if !info.Mode().IsRegular() || info.Size() > 4<<20 {
+ return nil, fmt.Errorf("sound must be a regular WAV file at most 4 MiB")
+ }
+ return decode_wav(io.LimitReader(file, (4<<20)+1))
+}
diff --git a/internal/sound/theme_test.go b/internal/sound/theme_test.go
new file mode 100644
index 00000000..7ae9b8f5
--- /dev/null
+++ b/internal/sound/theme_test.go
@@ -0,0 +1,203 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package sound
+
+import (
+ "bytes"
+ "encoding/binary"
+ "math"
+ "os"
+ "path/filepath"
+ "slices"
+ "testing"
+
+ "git.stormux.org/storm/fenrir/internal/presentation"
+)
+
+func TestBundledNormalizedPackDecodesAndContainsEveryCue(t *testing.T) {
+ root := "../../config/sound/default"
+ clips, err := load_theme([]string{root})
+ if err != nil || len(clips) != 19 {
+ t.Fatal(len(clips), err)
+ }
+ paths, err := filepath.Glob(filepath.Join(root, "*.wav"))
+ if err != nil || len(paths) != 28 {
+ t.Fatal(paths, err)
+ }
+ for _, path := range paths {
+ item, err := decode_file(path)
+ if err != nil || item == nil || len(item.samples) == 0 {
+ t.Fatal(path, err)
+ }
+ for _, sample := range item.samples {
+ if math.IsNaN(float64(sample)) || math.Abs(float64(sample)) > 0.81 {
+ t.Fatal("invalid or excessive peak", path, sample)
+ }
+ }
+ }
+}
+
+func test_wav(rate uint32) []byte {
+ var data bytes.Buffer
+ data.WriteString("RIFF")
+ binary.Write(&data, binary.LittleEndian, uint32(40))
+ data.WriteString("WAVEfmt ")
+ binary.Write(&data, binary.LittleEndian, uint32(16))
+ for _, value := range []uint16{1, 1} {
+ binary.Write(&data, binary.LittleEndian, value)
+ }
+ binary.Write(&data, binary.LittleEndian, rate)
+ binary.Write(&data, binary.LittleEndian, rate*2)
+ for _, value := range []uint16{2, 16} {
+ binary.Write(&data, binary.LittleEndian, value)
+ }
+ data.WriteString("data")
+ binary.Write(&data, binary.LittleEndian, uint32(4))
+ binary.Write(&data, binary.LittleEndian, []int16{8192, -8192})
+ return data.Bytes()
+}
+
+func TestWAVDecodeAndResample(t *testing.T) {
+ item, err := decode_wav(bytes.NewReader(test_wav(48000)))
+ if err != nil || len(item.samples) != 4 || item.samples[0] != 0.25 || item.samples[1] != 0.25 || item.samples[2] != -0.25 {
+ t.Fatalf("mono decoding: %+v, %v", item, err)
+ }
+ item, err = decode_wav(bytes.NewReader(test_wav(24000)))
+ if err != nil || len(item.samples) != 8 {
+ t.Fatalf("resampling: %+v, %v", item, err)
+ }
+ for _, data := range [][]byte{[]byte("invalid"), test_wav(1), test_wav(48000)[:45]} {
+ if _, err := decode_wav(bytes.NewReader(data)); err == nil {
+ t.Fatal("accepted invalid or truncated WAV")
+ }
+ }
+ base := test_wav(48000)
+ extended := append([]byte(nil), base[:36]...)
+ extended = append(extended, 22, 0, 16, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0x10, 0, 0x80, 0, 0, 0xaa, 0, 0x38, 0x9b, 0x71)
+ extended = append(extended, base[36:]...)
+ binary.LittleEndian.PutUint32(extended[4:], uint32(len(extended)-8))
+ binary.LittleEndian.PutUint32(extended[16:], 40)
+ binary.LittleEndian.PutUint16(extended[20:], 0xfffe)
+ item, err = decode_wav(bytes.NewReader(extended))
+ if err != nil || len(item.samples) != 4 || item.samples[0] != 0.25 {
+ t.Fatalf("extended PCM: %+v %v", item, err)
+ }
+}
+
+func TestThemeTOMLAndPartialAvailability(t *testing.T) {
+ root := t.TempDir()
+ write := func(name string, data []byte) {
+ t.Helper()
+ if err := os.WriteFile(filepath.Join(root, name), data, 0600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ write("cue.wav", test_wav(48000))
+ write("soundicons.toml", []byte("[icons]\nready = 'cue.wav'\nblank = 'missing.wav'\n"))
+ clips, err := load_theme([]string{root})
+ if err == nil || len(clips) != 1 || clips[presentation.Ready] == nil {
+ t.Fatal("partial theme disabled good cue", clips, err)
+ }
+ for _, data := range []string{"[icons]\nready = '../other.wav'", "[icons]\nunknown = 'cue.wav'", "driver = 'external'", "[icons"} {
+ write("soundicons.toml", []byte(data))
+ if _, err := load_theme([]string{root}); err == nil {
+ t.Fatal("accepted invalid theme", data)
+ }
+ }
+ if _, err := load_theme([]string{filepath.Join(root, "missing")}); err == nil {
+ t.Fatal("missing sound pack was not reported")
+ }
+}
+
+func TestInstalledThemeWhenRequested(t *testing.T) {
+ root := os.Getenv("FENRIR_TEST_SOUND_THEME")
+ if root == "" {
+ t.Skip("set FENRIR_TEST_SOUND_THEME to decode an installed pack without playing it")
+ }
+ clips, err := load_theme([]string{root})
+ if err != nil || len(clips) != 16 {
+ t.Fatalf("installed cues=%d, %v", len(clips), err)
+ }
+}
+
+func FuzzWAVDecode(f *testing.F) {
+ f.Add(test_wav(48000))
+ f.Add(test_wav(24000))
+ f.Add([]byte("RIFF"))
+ f.Fuzz(func(t *testing.T, data []byte) {
+ item, err := decode_wav(bytes.NewReader(data))
+ if err == nil && (len(item.samples) == 0 || len(item.samples) > sampleRate*2*10) {
+ t.Fatal("invalid decoded size", len(item.samples))
+ }
+ })
+}
+
+func TestSelectedThemesNeverMergeOrSubstitutePacks(t *testing.T) {
+ first, second := t.TempDir(), t.TempDir()
+ for _, root := range []string{first, second} {
+ if err := os.WriteFile(filepath.Join(root, "cue.wav"), test_wav(48000), 0600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := os.WriteFile(filepath.Join(first, "soundicons.toml"), []byte("[icons]\nready='absent.wav'\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(second, "soundicons.toml"), []byte("[icons]\nready='cue.wav'\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ clips, err := load_theme([]string{first, second})
+ if err == nil || clips[presentation.Ready] != nil {
+ t.Fatal("missing cue substituted from a lower priority pack", err)
+ }
+ clips, err = load_theme([]string{filepath.Join(first, "missing"), second})
+ if err != nil || clips[presentation.Ready] == nil {
+ t.Fatal("named search did not find later installation", err)
+ }
+}
+
+func TestThemeNamesPathsAndValidation(t *testing.T) {
+ home := t.TempDir()
+ root := filepath.Join(home, ".local/stormux/fenrir/sounds")
+ for _, name := range []string{"zeta", "alpha"} {
+ if err := os.MkdirAll(filepath.Join(root, name), 0700); err != nil {
+ t.Fatal(err)
+ }
+ }
+ names := Theme_names(home)
+ if names[0] != "default" || !slices.Contains(names, "alpha") || !slices.Contains(names, "zeta") {
+ t.Fatal(names)
+ }
+ paths := Theme_paths(home, "alpha")
+ if paths[0] != filepath.Join(root, "alpha") {
+ t.Fatal(paths)
+ }
+ explicit := filepath.Join(home, "custom")
+ if got := Theme_paths(home, explicit); !slices.Equal(got, []string{explicit}) {
+ t.Fatal(got)
+ }
+ for _, name := range []string{"default", "alpha", explicit} {
+ if err := Validate_theme(name); err != nil {
+ t.Fatal(name, err)
+ }
+ }
+ for _, name := range []string{"", " ", "..", ".", "../other", "sub/pack", "bad\x00name", "bad\nname"} {
+ if Validate_theme(name) == nil {
+ t.Fatal("invalid theme accepted", name)
+ }
+ }
+}
diff --git a/internal/sound/wav.go b/internal/sound/wav.go
new file mode 100644
index 00000000..c29e7925
--- /dev/null
+++ b/internal/sound/wav.go
@@ -0,0 +1,135 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package sound
+
+import (
+ "bytes"
+ "encoding/binary"
+ "fmt"
+ "io"
+ "math"
+)
+
+// Fenrir's cues are short PCM WAV files. Validate RIFF chunk bounds before
+// decoding, then resample once to the persistent stream's stereo rate.
+func decode_wav(reader io.Reader) (*clip, error) {
+ data, err := io.ReadAll(io.LimitReader(reader, (4<<20)+1))
+ if err != nil {
+ return nil, err
+ }
+ if len(data) > 4<<20 {
+ return nil, fmt.Errorf("WAV exceeds 4 MiB")
+ }
+ if len(data) < 12 || string(data[:4]) != "RIFF" || string(data[8:12]) != "WAVE" {
+ return nil, fmt.Errorf("not a RIFF WAV file")
+ }
+ end := uint64(binary.LittleEndian.Uint32(data[4:8])) + 8
+ if end < 12 || end > uint64(len(data)) {
+ return nil, fmt.Errorf("truncated WAV container")
+ }
+ var format, audio []byte
+ for offset := uint64(12); offset < end; {
+ if end-offset < 8 {
+ return nil, fmt.Errorf("truncated WAV chunk header")
+ }
+ name := string(data[offset : offset+4])
+ size := uint64(binary.LittleEndian.Uint32(data[offset+4 : offset+8]))
+ offset += 8
+ if size > end-offset {
+ return nil, fmt.Errorf("truncated WAV %s chunk", name)
+ }
+ switch name {
+ case "fmt ":
+ if format != nil {
+ return nil, fmt.Errorf("duplicate WAV format")
+ }
+ format = data[offset : offset+size]
+ case "data":
+ if audio != nil {
+ return nil, fmt.Errorf("duplicate WAV data")
+ }
+ audio = data[offset : offset+size]
+ }
+ offset += size + size%2
+ if offset > end {
+ return nil, fmt.Errorf("missing WAV padding")
+ }
+ }
+ if len(format) < 16 || len(audio) == 0 {
+ return nil, fmt.Errorf("WAV needs format and audio data")
+ }
+ encoding := binary.LittleEndian.Uint16(format)
+ channels := int(binary.LittleEndian.Uint16(format[2:]))
+ rate := int(binary.LittleEndian.Uint32(format[4:]))
+ align := int(binary.LittleEndian.Uint16(format[12:]))
+ bits := int(binary.LittleEndian.Uint16(format[14:]))
+ if encoding == 0xfffe {
+ // WAVEFORMATEXTENSIBLE stores PCM's subtype and valid-bit count in
+ // the extended header. Fenrir's installed default pack uses this.
+ pcmGUID := []byte{1, 0, 0, 0, 0, 0, 0x10, 0, 0x80, 0, 0, 0xaa, 0, 0x38, 0x9b, 0x71}
+ if len(format) < 40 || binary.LittleEndian.Uint16(format[16:]) < 22 || !bytes.Equal(format[24:40], pcmGUID) {
+ return nil, fmt.Errorf("unsupported extended WAV format")
+ }
+ validBits := int(binary.LittleEndian.Uint16(format[18:]))
+ if validBits < 1 || validBits > bits {
+ return nil, fmt.Errorf("invalid WAV valid-bit count")
+ }
+ encoding = 1
+ }
+ if encoding != 1 || channels < 1 || channels > 2 || rate < 8000 || rate > 192000 || (bits != 8 && bits != 16 && bits != 24 && bits != 32) {
+ return nil, fmt.Errorf("unsupported PCM WAV format")
+ }
+ bytesPerSample := bits / 8
+ if align != channels*bytesPerSample || len(audio)%align != 0 {
+ return nil, fmt.Errorf("invalid WAV frame alignment")
+ }
+ frames := len(audio) / align
+ if frames > rate*int(maxDuration.Seconds()) {
+ return nil, fmt.Errorf("sound exceeds ten seconds")
+ }
+ readSample := func(frame, channel int) float32 {
+ if channels == 1 {
+ channel = 0
+ }
+ value := audio[frame*align+channel*bytesPerSample:]
+ switch bits {
+ case 8:
+ return (float32(value[0]) - 128) / 128
+ case 16:
+ return float32(int16(binary.LittleEndian.Uint16(value))) / 32768
+ case 24:
+ signed := int32(value[0]) | int32(value[1])<<8 | int32(value[2])<<16
+ return float32(signed<<8>>8) / 8388608
+ default:
+ return float32(int32(binary.LittleEndian.Uint32(value))) / 2147483648
+ }
+ }
+ count := int((int64(frames)*sampleRate + int64(rate) - 1) / int64(rate))
+ samples := make([]float32, count*2)
+ for frame := range count {
+ position := float64(frame) * float64(rate) / sampleRate
+ left := min(int(position), frames-1)
+ right := min(left+1, frames-1)
+ fraction := float32(position - math.Floor(position))
+ for channel := range 2 {
+ first, second := readSample(left, channel), readSample(right, channel)
+ samples[frame*2+channel] = first + (second-first)*fraction
+ }
+ }
+ return &clip{samples: samples}, nil
+}
diff --git a/internal/speech/catalog.go b/internal/speech/catalog.go
new file mode 100644
index 00000000..7b251025
--- /dev/null
+++ b/internal/speech/catalog.go
@@ -0,0 +1,251 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package speech
+
+import (
+ "fmt"
+ "os"
+ "sort"
+ "strings"
+ "time"
+ "unicode"
+ "unicode/utf8"
+)
+
+type Voice struct{ Name, Language, Variant string }
+
+type Catalog struct {
+ Modules []string
+ Voices map[string][]Voice
+ Errors map[string]error
+}
+
+func Validate_selection(module, language, voice string) error {
+ if !valid_module(module) || !valid_module(language) {
+ return fmt.Errorf("invalid speech module or language")
+ }
+ if !utf8.ValidString(voice) || len(voice) > 1024 || voice != "" && strings.TrimSpace(voice) == "" {
+ return fmt.Errorf("invalid speech voice")
+ }
+ for _, char := range voice {
+ if unicode.IsControl(char) {
+ return fmt.Errorf("invalid speech voice")
+ }
+ }
+ return nil
+}
+
+// Discover never speaks or changes the active client. Each query has a short
+// deadline and the entire catalog has a four-second budget. Partial failures
+// retain successful modules and mark unavailable voice lists explicitly.
+func Discover(options Options) (Catalog, error) {
+ if options.Socket == "" {
+ options.Socket = Default_socket()
+ }
+ result := Catalog{Voices: map[string][]Voice{}, Errors: map[string]error{}}
+ w, err := connect(options.Socket)
+ if err != nil {
+ return result, err
+ }
+ defer w.conn.Close()
+ deadline := time.Now().Add(4 * time.Second)
+ query := func(command string) ([]string, error) {
+ if time.Now().After(deadline) {
+ return nil, fmt.Errorf("speech discovery timed out")
+ }
+ if err := w.conn.SetDeadline(min_time(deadline, time.Now().Add(commandTimeout))); err != nil {
+ return nil, err
+ }
+ if _, err := fmt.Fprintf(w.conn, "%s\r\n", command); err != nil {
+ return nil, err
+ }
+ return w.reply()
+ }
+ modules, err := query("LIST OUTPUT_MODULES")
+ if err != nil {
+ return result, err
+ }
+ seen := map[string]bool{}
+ for _, module := range modules {
+ if module == "" || !valid_module(module) || seen[module] {
+ return result, fmt.Errorf("invalid Speech Dispatcher module list")
+ }
+ seen[module] = true
+ }
+ result.Modules = modules
+ // Query the server default before selecting any named module.
+ for _, module := range append([]string{""}, modules...) {
+ var rows []string
+ var err error
+ if module != "" {
+ _, err = query("SET SELF OUTPUT_MODULE " + module)
+ }
+ if err == nil {
+ rows, err = query("LIST SYNTHESIS_VOICES")
+ }
+ if err == nil {
+ result.Voices[module], err = parse_voices(rows)
+ }
+ if err != nil {
+ result.Errors[module] = err
+ // A rejected command ends a complete reply. A broken stream must
+ // never be reused: a late reply could belong to the previous module.
+ if _, ok := err.(*Rejection); !ok {
+ break
+ }
+ }
+ }
+ return result, nil
+}
+
+func min_time(a, b time.Time) time.Time {
+ if a.Before(b) {
+ return a
+ }
+ return b
+}
+
+func parse_voices(rows []string) ([]Voice, error) {
+ voices := make([]Voice, 0, len(rows))
+ seen := map[string]bool{}
+ for _, row := range rows {
+ fields := strings.Split(row, "\t")
+ if len(fields) < 2 || len(fields) > 3 || fields[0] == "" || fields[1] == "" {
+ return nil, fmt.Errorf("invalid speech voice list")
+ }
+ if err := Validate_selection("", fields[1], fields[0]); err != nil {
+ return nil, err
+ }
+ // Some modules repeat aliases. Stable IDs are the synthesis names.
+ if seen[fields[0]] {
+ continue
+ }
+ seen[fields[0]] = true
+ voice := Voice{Name: fields[0], Language: fields[1]}
+ if len(fields) == 3 {
+ voice.Variant = fields[2]
+ }
+ voices = append(voices, voice)
+ }
+ return voices, nil
+}
+
+func Normalize_language(value string) string {
+ value = strings.Split(strings.Split(value, ".")[0], "@")[0]
+ return strings.ToLower(strings.ReplaceAll(value, "_", "-"))
+}
+
+// Language_voices follows Cthulhu's eSpeak selection: exact dialect, then base
+// language, with unmodified names before '+' variants. Empty means locale.
+// Unknown locales retain the full catalog; explicit languages remain exact.
+func Language_voices(voices []Voice, language string) []Voice {
+ explicit := language != ""
+ if !explicit {
+ for _, key := range []string{"LC_ALL", "LC_MESSAGES", "LANG"} {
+ if language = os.Getenv(key); language != "" {
+ break
+ }
+ }
+ }
+ language = Normalize_language(language)
+ if !explicit && (language == "" || language == "c" || language == "posix") {
+ language = "en-us"
+ }
+ languages := []string{language}
+ if !explicit {
+ languages = append(languages, strings.SplitN(language, "-", 2)[0])
+ }
+ for _, match := range languages {
+ var selected []Voice
+ for _, voice := range voices {
+ if Normalize_language(voice.Language) == match {
+ selected = append(selected, voice)
+ }
+ }
+ if len(selected) > 0 {
+ sort.SliceStable(selected, func(i, j int) bool {
+ return !strings.Contains(selected[i].Name, "+") && strings.Contains(selected[j].Name, "+")
+ })
+ return selected
+ }
+ }
+ if explicit {
+ return nil
+ }
+ return voices
+}
+
+// Prepare checks membership because SSIP SET may accept unknown synthesis
+// names without verifying them. The resulting client has not spoken anything.
+func Prepare(driver string, options Options) (Transport, error) {
+ if err := Validate_selection(options.Module, options.Language, options.Voice); err != nil {
+ return nil, err
+ }
+ d, err := Find_driver(driver)
+ if err != nil {
+ return nil, err
+ }
+ if d.Voices == nil {
+ if options.Module == "" && options.Language == "" && options.Voice == "" {
+ return Open_driver(driver, options)
+ }
+ return nil, fmt.Errorf("speech driver does not support voice discovery")
+ }
+ if err = d.Availability(); err != nil {
+ return nil, err
+ }
+ if options.Module != "" {
+ modules, err := d.Modules(options)
+ if err != nil {
+ return nil, err
+ }
+ found := false
+ for _, module := range modules {
+ found = found || module == options.Module
+ }
+ if !found {
+ return nil, fmt.Errorf("speech module %q is unavailable", options.Module)
+ }
+ }
+ voices, err := d.Voices(options)
+ if err != nil {
+ return nil, err
+ }
+ languageFound, voiceFound := options.Language == "", options.Voice == ""
+ for _, voice := range voices {
+ languageMatches := options.Language == "" || Normalize_language(voice.Language) == Normalize_language(options.Language)
+ languageFound = languageFound || languageMatches
+ voiceFound = voiceFound || voice.Name == options.Voice && languageMatches
+ }
+ if !languageFound || !voiceFound {
+ return nil, fmt.Errorf("speech language or voice is unavailable for module %q", options.Module)
+ }
+ return Open_driver(driver, options)
+}
+
+// A live change queries only its target, so a slow unrelated module cannot
+// consume its discovery budget or prevent selecting a working synthesizer.
+func discover_voices(options Options) ([]Voice, error) {
+ if err := Validate_selection(options.Module, options.Language, options.Voice); err != nil {
+ return nil, err
+ }
+ if options.Socket == "" {
+ options.Socket = Default_socket()
+ }
+ w, err := connect(options.Socket)
+ if err != nil {
+ return nil, err
+ }
+ defer w.conn.Close()
+ if options.Module != "" {
+ if _, err = w.command("SET SELF OUTPUT_MODULE " + options.Module); err != nil {
+ return nil, err
+ }
+ }
+ rows, err := w.command("LIST SYNTHESIS_VOICES")
+ if err != nil {
+ return nil, err
+ }
+ return parse_voices(rows)
+}
diff --git a/internal/speech/catalog_test.go b/internal/speech/catalog_test.go
new file mode 100644
index 00000000..46c49f40
--- /dev/null
+++ b/internal/speech/catalog_test.go
@@ -0,0 +1,129 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package speech
+
+import (
+ "bufio"
+ "fmt"
+ "reflect"
+ "strings"
+ "testing"
+)
+
+func TestLargeVoiceCatalogAndSilentPreparation(t *testing.T) {
+ server := new_fake_server(t, false)
+ server.mu.Lock()
+ for index := range 14805 {
+ server.voices = append(server.voices, fmt.Sprintf("Voice %d\ten-US\tnone", index))
+ }
+ server.mu.Unlock()
+ catalog, err := Discover(Options{Socket: server.path})
+ if err != nil || len(catalog.Voices["fixture"]) != 14805 || len(catalog.Errors) != 0 {
+ t.Fatal(len(catalog.Voices["fixture"]), err, catalog.Errors)
+ }
+ for _, options := range []Options{
+ {Socket: server.path, Module: "absent"},
+ {Socket: server.path, Module: "fixture", Language: "de"},
+ {Socket: server.path, Module: "fixture", Voice: "missing"},
+ {Socket: server.path, Voice: "bad\r\nCANCEL ALL"},
+ } {
+ if client, err := Prepare("speech-dispatcher", options); err == nil {
+ client.Close()
+ t.Fatal("accepted unavailable selection", options)
+ }
+ }
+ candidate, err := Prepare("speech-dispatcher", Options{Socket: server.path, Module: "fixture", Language: "en-US", Voice: "Voice 12000"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case event := <-server.events:
+ t.Fatal("preparation spoke or canceled active speech", event)
+ default:
+ }
+ server.mu.Lock()
+ commands := strings.Join(server.settings, "|")
+ server.mu.Unlock()
+ if !strings.Contains(commands, "SET SELF LANGUAGE en-US|SET SELF SYNTHESIS_VOICE Voice 12000") {
+ t.Fatal("voice must follow language")
+ }
+ candidate.Close()
+}
+
+func TestBoundedRepliesAndInjection(t *testing.T) {
+ for _, reply := range []string{
+ "249-" + strings.Repeat("a", 5000) + "\n249 OK\n",
+ strings.Repeat("249-"+strings.Repeat("a", 3000)+"\n", 3000) + "249 OK\n",
+ "249-one\n250 OK\n",
+ } {
+ w := wire{reader: bufio.NewReaderSize(strings.NewReader(reply), 4096)}
+ if _, err := w.reply(); err == nil {
+ t.Fatal("accepted unbounded or malformed reply")
+ }
+ }
+ for _, row := range []string{"Missing tabs", "name\ten\tvariant\textra", "name\ten\rCANCEL ALL", "bad\x00name\ten"} {
+ if _, err := parse_voices([]string{row}); err == nil {
+ t.Fatal("accepted malformed catalog row", row)
+ }
+ }
+}
+
+func TestEspeakLocaleDialectBaseAndExplicitLanguage(t *testing.T) {
+ voices := []Voice{{Name: "US+Variant", Language: "en-US"}, {Name: "German", Language: "de"}, {Name: "US", Language: "en-US"}, {Name: "English", Language: "en"}}
+ t.Setenv("LC_ALL", "en_US.UTF-8")
+ got := Language_voices(voices, "")
+ if !reflect.DeepEqual(got, []Voice{voices[2], voices[0]}) {
+ t.Fatal(got)
+ }
+ t.Setenv("LC_ALL", "en_AU.UTF-8")
+ if got := Language_voices(voices, ""); !reflect.DeepEqual(got, []Voice{voices[3]}) {
+ t.Fatal(got)
+ }
+ if got := Language_voices(voices, "de"); !reflect.DeepEqual(got, []Voice{voices[1]}) {
+ t.Fatal(got)
+ }
+ if got := Language_voices(voices, "unknown"); len(got) != 0 {
+ t.Fatal(got)
+ }
+ t.Setenv("LC_ALL", "zz_ZZ")
+ if got := Language_voices(voices, ""); !reflect.DeepEqual(got, voices) {
+ t.Fatal(got)
+ }
+ if voices[0].Name != "US+Variant" {
+ t.Fatal("catalog reordered in place")
+ }
+}
+
+func TestCandidateRejectionLeavesOriginalClientUsable(t *testing.T) {
+ server := new_fake_server(t, false)
+ original, err := Open(Options{Socket: server.path})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer original.Close()
+ server.mu.Lock()
+ server.reject = "SET SELF SYNTHESIS_VOICE"
+ server.mu.Unlock()
+ if candidate, err := Prepare("speech-dispatcher", Options{Socket: server.path, Module: "fixture", Voice: "Base Voice"}); err == nil {
+ candidate.Close()
+ t.Fatal("accepted rejected voice")
+ }
+ if err := original.Say("still working"); err != nil {
+ t.Fatal(err)
+ }
+ wait_event(t, server.events, "say:still working")
+}
+
+func TestCatalogPreservesEspeakVoiceWhitespace(t *testing.T) {
+ voices, err := parse_voices([]string{"Cherokee \tchr\tnone"})
+ if err != nil || len(voices) != 1 || voices[0].Name != "Cherokee " {
+ t.Fatal(voices, err)
+ }
+ if err := Validate_selection("espeak-ng", "chr", "Cherokee "); err != nil {
+ t.Fatal(err)
+ }
+ if err := Validate_selection("espeak-ng", "chr", " "); err == nil {
+ t.Fatal("accepted whitespace-only name")
+ }
+}
diff --git a/internal/speech/client.go b/internal/speech/client.go
new file mode 100644
index 00000000..358a649b
--- /dev/null
+++ b/internal/speech/client.go
@@ -0,0 +1,303 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+// Package speech implements the Speech Dispatcher SSIP boundary and admission.
+package speech
+
+import (
+ "bufio"
+ "fmt"
+ "net"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+ "unicode"
+ "unicode/utf8"
+
+ "git.stormux.org/storm/fenrir/internal/diagnostic"
+)
+
+const commandTimeout = 750 * time.Millisecond
+
+type Rejection struct{ Code string }
+
+func (r *Rejection) Error() string { return "Speech Dispatcher rejected a command: " + r.Code }
+
+type wire struct {
+ conn net.Conn
+ reader *bufio.Reader
+}
+
+func (w *wire) reply() ([]string, error) {
+ var data []string
+ expected := ""
+ total := 0
+ for count := 0; count < 65536; count++ {
+ part, err := w.reader.ReadSlice('\n')
+ line := string(part)
+ total += len(line)
+ if total > 8<<20 {
+ return nil, fmt.Errorf("SSIP reply exceeds byte limit")
+ }
+ if err != nil {
+ return nil, err
+ }
+ line = strings.TrimRight(line, "\r\n")
+ if len(line) < 4 || line[3] != '-' && line[3] != ' ' {
+ return nil, fmt.Errorf("malformed SSIP reply")
+ }
+ if _, err := strconv.Atoi(line[:3]); err != nil {
+ return nil, fmt.Errorf("invalid SSIP reply code")
+ }
+ if expected == "" {
+ expected = line[:3]
+ }
+ if expected != line[:3] {
+ return nil, fmt.Errorf("inconsistent SSIP reply codes")
+ }
+ if line[3] == ' ' {
+ if line[0] != '2' {
+ return nil, &Rejection{Code: line[:3]}
+ }
+ return data, nil
+ }
+ data = append(data, line[4:])
+ }
+ return nil, fmt.Errorf("SSIP reply exceeds line limit")
+}
+
+func (w *wire) command(command string) ([]string, error) {
+ if err := w.conn.SetDeadline(time.Now().Add(commandTimeout)); err != nil {
+ return nil, err
+ }
+ if _, err := fmt.Fprintf(w.conn, "%s\r\n", command); err != nil {
+ return nil, err
+ }
+ return w.reply()
+}
+
+func connect(path string) (*wire, error) {
+ conn, err := net.DialTimeout("unix", path, commandTimeout)
+ if err != nil {
+ return nil, err
+ }
+ return &wire{conn: conn, reader: bufio.NewReaderSize(conn, 4096)}, nil
+}
+
+type Client struct {
+ data, control *wire
+ id string
+ sendMu sync.Mutex
+ controlMu sync.Mutex
+ closeOnce sync.Once
+}
+
+type Options struct {
+ Socket string
+ Rate int
+ Pitch *int
+ Volume *int
+ Module string
+ Language string
+ Voice string
+ Hardware HardwareOptions
+}
+
+type Parameters struct {
+ Rate, Pitch, Volume int
+}
+
+func (p Parameters) Validate() error {
+ if p.Rate < -100 || p.Rate > 100 || p.Pitch < -100 || p.Pitch > 100 || p.Volume < -100 || p.Volume > 100 {
+ return fmt.Errorf("speech rate, pitch and volume must be between -100 and 100")
+ }
+ return nil
+}
+
+func Default_socket() string {
+ if value := os.Getenv("SPEECHD_ADDRESS"); strings.HasPrefix(value, "unix_socket:") {
+ return strings.TrimPrefix(value, "unix_socket:")
+ }
+ if uid := os.Getenv("SUDO_UID"); uid != "" {
+ if _, err := strconv.ParseUint(uid, 10, 32); err == nil {
+ return filepath.Join("/run/user", uid, "speech-dispatcher/speechd.sock")
+ }
+ }
+ runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
+ if runtimeDir == "" {
+ runtimeDir = filepath.Join("/run/user", strconv.Itoa(os.Getuid()))
+ }
+ return filepath.Join(runtimeDir, "speech-dispatcher/speechd.sock")
+}
+
+func Open(options Options) (*Client, error) {
+ if options.Rate < -100 || options.Rate > 100 {
+ return nil, fmt.Errorf("speech rate must be between -100 and 100")
+ }
+ for _, value := range []*int{options.Pitch, options.Volume} {
+ if value != nil && (*value < -100 || *value > 100) {
+ return nil, fmt.Errorf("speech pitch and volume must be between -100 and 100")
+ }
+ }
+ if err := Validate_selection(options.Module, options.Language, options.Voice); err != nil {
+ return nil, err
+ }
+ data, err := connect(options.Socket)
+ if err != nil {
+ return nil, fmt.Errorf("connect Speech Dispatcher at %s: %w", options.Socket, err)
+ }
+ client := &Client{data: data}
+ fail := func(err error) (*Client, error) {
+ data.conn.Close()
+ if client.control != nil {
+ client.control.conn.Close()
+ }
+ return nil, err
+ }
+ commands := []string{
+ `SET SELF CLIENT_NAME "fenrir:fenrir:main"`,
+ "SET SELF PRIORITY MESSAGE",
+ "SET SELF PUNCTUATION NONE",
+ "SET SELF CAP_LET_RECOGN NONE",
+ fmt.Sprintf("SET SELF RATE %d", options.Rate),
+ }
+ if options.Module != "" {
+ commands = append(commands, "SET SELF OUTPUT_MODULE "+options.Module)
+ }
+ if options.Pitch != nil {
+ commands = append(commands, fmt.Sprintf("SET SELF PITCH %d", *options.Pitch))
+ }
+ if options.Volume != nil {
+ commands = append(commands, fmt.Sprintf("SET SELF VOLUME %d", *options.Volume))
+ }
+ if options.Language != "" {
+ commands = append(commands, "SET SELF LANGUAGE "+options.Language)
+ }
+ if options.Voice != "" {
+ commands = append(commands, "SET SELF SYNTHESIS_VOICE "+options.Voice)
+ }
+ for _, command := range commands {
+ if _, err := data.command(command); err != nil {
+ return fail(err)
+ }
+ }
+ identifiers, err := data.command("HISTORY GET CLIENT_ID")
+ if err != nil {
+ return fail(err)
+ }
+ if len(identifiers) != 1 {
+ return fail(fmt.Errorf("missing Speech Dispatcher client ID"))
+ }
+ id, err := strconv.ParseUint(identifiers[0], 10, 64)
+ if err != nil {
+ return fail(fmt.Errorf("invalid Speech Dispatcher client ID"))
+ }
+ client.id = strconv.FormatUint(id, 10)
+ client.control, err = connect(options.Socket)
+ if err != nil {
+ return fail(err)
+ }
+ if _, err := client.control.command(`SET SELF CLIENT_NAME "fenrir:fenrir:control"`); err != nil {
+ return fail(err)
+ }
+ return client, nil
+}
+
+// Configure shares the speaking connection, so subsequent utterances use the
+// new values. The service worker calls this away from the input loop.
+func (c *Client) Configure(parameters Parameters) error {
+ if err := parameters.Validate(); err != nil {
+ return err
+ }
+ c.sendMu.Lock()
+ defer c.sendMu.Unlock()
+ for _, setting := range []struct {
+ name string
+ value int
+ }{{"RATE", parameters.Rate}, {"PITCH", parameters.Pitch}, {"VOLUME", parameters.Volume}} {
+ if _, err := c.data.command(fmt.Sprintf("SET SELF %s %d", setting.name, setting.value)); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (c *Client) Say(text string) error {
+ c.sendMu.Lock()
+ defer c.sendMu.Unlock()
+ started := time.Now()
+ if char, size := utf8.DecodeRuneInString(text); size == len(text) && size > 0 && utf8.ValidString(text) && !unicode.IsControl(char) && !unicode.IsSpace(char) {
+ // Single-letter feedback needs one command/acknowledgement, rather
+ // than a SPEAK handshake followed by a separate text transaction.
+ _, err := c.data.command("CHAR " + text)
+ diagnostic.Timing("speech CHAR acknowledgement", started)
+ return err
+ }
+ if _, err := c.data.command("SPEAK"); err != nil {
+ return err
+ }
+ diagnostic.Timing("speech SPEAK acknowledgement", started)
+ text = strings.ReplaceAll(text, "\r\n", "\n")
+ text = strings.ReplaceAll(text, "\r", "\n")
+ text = strings.ReplaceAll(text, "\x00", "")
+ lines := strings.Split(text, "\n")
+ for index, line := range lines {
+ if strings.HasPrefix(line, ".") {
+ lines[index] = "." + line
+ }
+ }
+ if err := c.data.conn.SetDeadline(time.Now().Add(commandTimeout)); err != nil {
+ return err
+ }
+ if _, err := fmt.Fprintf(c.data.conn, "%s\r\n.\r\n", strings.Join(lines, "\r\n")); err != nil {
+ return err
+ }
+ _, err := c.data.reply()
+ return err
+}
+
+// Set_pitch is called by the serialized speech worker, never the input loop.
+func (c *Client) Set_pitch(pitch int) error {
+ if pitch < -100 || pitch > 100 {
+ return fmt.Errorf("speech pitch must be between -100 and 100")
+ }
+ c.sendMu.Lock()
+ defer c.sendMu.Unlock()
+ _, err := c.data.command(fmt.Sprintf("SET SELF PITCH %d", pitch))
+ return err
+}
+
+// Cancel uses the independent control socket and targets only this client.
+// Service holds the dispatch barrier for the final cancellation.
+func (c *Client) Cancel() error {
+ c.controlMu.Lock()
+ defer c.controlMu.Unlock()
+ _, err := c.control.command("CANCEL " + c.id)
+ return err
+}
+
+func (c *Client) Close() {
+ c.closeOnce.Do(func() {
+ // Closing the data socket releases a blocked sender before cancellation.
+ c.data.conn.Close()
+ c.Cancel()
+ c.control.conn.Close()
+ })
+}
diff --git a/internal/speech/client_test.go b/internal/speech/client_test.go
new file mode 100644
index 00000000..74ae2e62
--- /dev/null
+++ b/internal/speech/client_test.go
@@ -0,0 +1,286 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package speech
+
+import (
+ "bufio"
+ "fmt"
+ "net"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+)
+
+type fakeServer struct {
+ path string
+ events chan string
+ ack chan struct{}
+ stop chan struct{}
+ mu sync.Mutex
+ connections []net.Conn
+ settings []string
+ voices []string
+ reject string
+ nextID int
+ wg sync.WaitGroup
+}
+
+func new_fake_server(t *testing.T, block bool) *fakeServer {
+ t.Helper()
+ server := &fakeServer{path: filepath.Join(t.TempDir(), "s"), events: make(chan string, 64), ack: make(chan struct{}), stop: make(chan struct{})}
+ if !block {
+ close(server.ack)
+ }
+ listener, err := net.Listen("unix", server.path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ server.wg.Add(1)
+ acceptedDone := make(chan struct{})
+ go func() {
+ defer server.wg.Done()
+ defer close(acceptedDone)
+ for {
+ conn, err := listener.Accept()
+ if err != nil {
+ return
+ }
+ server.mu.Lock()
+ server.connections = append(server.connections, conn)
+ server.mu.Unlock()
+ server.wg.Add(1)
+ go func() { defer server.wg.Done(); defer conn.Close(); server.serve(conn) }()
+ }
+ }()
+ t.Cleanup(func() {
+ close(server.stop)
+ listener.Close()
+ <-acceptedDone
+ // Wait for Accept to finish before taking the connection list.
+ server.mu.Lock()
+ for _, conn := range server.connections {
+ conn.Close()
+ }
+ server.mu.Unlock()
+ server.wg.Wait()
+ })
+ return server
+}
+
+func (s *fakeServer) serve(conn net.Conn) {
+ reader := bufio.NewReader(conn)
+ for {
+ line, err := reader.ReadString('\n')
+ if err != nil {
+ return
+ }
+ line = strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r")
+ switch {
+ case strings.HasPrefix(line, "SET "):
+ s.mu.Lock()
+ s.settings = append(s.settings, line)
+ reject := s.reject != "" && strings.HasPrefix(line, s.reject)
+ s.mu.Unlock()
+ if reject {
+ fmt.Fprint(conn, "500 REJECTED\r\n")
+ } else {
+ fmt.Fprint(conn, "200 OK\r\n")
+ }
+ case line == "LIST OUTPUT_MODULES":
+ fmt.Fprint(conn, "200-fixture\r\n200-other\r\n200 OK\r\n")
+ case line == "LIST SYNTHESIS_VOICES":
+ s.mu.Lock()
+ voices := append([]string(nil), s.voices...)
+ s.mu.Unlock()
+ if voices == nil {
+ voices = []string{"Base Voice\ten-US\tnone", "Base Voice+Variant\ten-US\tVariant", "German\tde\tnone"}
+ }
+ for _, voice := range voices {
+ fmt.Fprintf(conn, "249-%s\r\n", voice)
+ }
+ fmt.Fprint(conn, "249 OK VOICES SENT\r\n")
+ case line == "HISTORY GET CLIENT_ID":
+ s.mu.Lock()
+ s.nextID++
+ id := 40 + s.nextID
+ s.mu.Unlock()
+ fmt.Fprintf(conn, "200-%d\r\n200 OK CLIENT ID SENT\r\n", id)
+ case strings.HasPrefix(line, "CHAR "):
+ s.events <- "char:" + strings.TrimPrefix(line, "CHAR ")
+ fmt.Fprint(conn, "200 OK\r\n")
+ case line == "SPEAK":
+ fmt.Fprint(conn, "230 OK RECEIVING DATA\r\n")
+ var lines []string
+ for {
+ text, err := reader.ReadString('\n')
+ if err != nil {
+ return
+ }
+ text = strings.TrimSuffix(strings.TrimSuffix(text, "\n"), "\r")
+ if text == "." {
+ break
+ }
+ if strings.HasPrefix(text, "..") {
+ text = text[1:]
+ }
+ lines = append(lines, text)
+ }
+ s.events <- "say:" + strings.Join(lines, "\n")
+ select {
+ case <-s.ack:
+ case <-s.stop:
+ return
+ }
+ fmt.Fprint(conn, "225-17\r\n225 OK MESSAGE QUEUED\r\n")
+ case strings.HasPrefix(line, "CANCEL "):
+ s.events <- line
+ fmt.Fprint(conn, "200 OK CANCELED\r\n")
+ default:
+ s.events <- "unexpected:" + line
+ fmt.Fprint(conn, "500 INVALID COMMAND\r\n")
+ }
+ }
+}
+
+func TestSSIPDotStuffingAndTargetedCancellation(t *testing.T) {
+ server := new_fake_server(t, false)
+ client, err := Open(Options{Socket: server.path})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer client.Close()
+ text := ".\nCANCEL ALL\n..literal\nƩ"
+ if err := client.Say(text); err != nil {
+ t.Fatal(err)
+ }
+ wait_event(t, server.events, "say:"+text)
+ if err := client.Cancel(); err != nil {
+ t.Fatal(err)
+ }
+ wait_event(t, server.events, "CANCEL 41")
+}
+
+func TestSingleCharactersUseOneCommandWithoutSpeakHandshake(t *testing.T) {
+ server := new_fake_server(t, false)
+ client, err := Open(Options{Socket: server.path})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer client.Close()
+ for _, text := range []string{"a", "Ć©", "ē", ".", "\\", "\""} {
+ if err := client.Say(text); err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case got := <-server.events:
+ if got != "char:"+text {
+ t.Fatalf("single character used extra speech transaction: %q", got)
+ }
+ default:
+ t.Fatal("character acknowledgement did not follow its command")
+ }
+ }
+ for _, text := range []string{"capital E", "space", "\nCANCEL ALL", "\x00"} {
+ if err := client.Say(text); err != nil {
+ t.Fatal(err)
+ }
+ wait_event(t, server.events, "say:"+strings.ReplaceAll(text, "\x00", ""))
+ }
+}
+
+func TestSSIPControlWorksWhileSpeakAcknowledgementIsBlocked(t *testing.T) {
+ server := new_fake_server(t, true)
+ client, err := Open(Options{Socket: server.path})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer client.Close()
+ result := make(chan error, 1)
+ go func() { result <- client.Say("blocked") }()
+ wait_event(t, server.events, "say:blocked")
+ if err := client.Cancel(); err != nil {
+ t.Fatal(err)
+ }
+ wait_event(t, server.events, "CANCEL 41")
+ close(server.ack)
+ if err := <-result; err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestSpeechOptionsRejectCommandInjection(t *testing.T) {
+ for _, options := range []Options{{Rate: 101}, {Rate: -101}, {Module: "voice\r\nCANCEL ALL"}} {
+ if _, err := Open(options); err == nil {
+ t.Fatalf("accepted unsafe options: %+v", options)
+ }
+ }
+}
+
+func TestLiveParametersUseSpeakingClientAndRejectInvalidValues(t *testing.T) {
+ server := new_fake_server(t, false)
+ pitch, volume := -20, 90
+ client, err := Open(Options{Socket: server.path, Rate: 80, Pitch: &pitch, Volume: &volume})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer client.Close()
+ if err := client.Configure(Parameters{Rate: 90, Pitch: 10, Volume: 80}); err != nil {
+ t.Fatal(err)
+ }
+ server.mu.Lock()
+ commands := append([]string(nil), server.settings...)
+ server.mu.Unlock()
+ for _, expected := range []string{"SET SELF RATE 80", "SET SELF PITCH -20", "SET SELF VOLUME 90", "SET SELF RATE 90", "SET SELF PITCH 10", "SET SELF VOLUME 80"} {
+ found := false
+ for _, got := range commands {
+ if got == expected {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatal(expected, commands)
+ }
+ }
+ for _, invalid := range []Parameters{{Rate: 101}, {Pitch: -101}, {Volume: 101}} {
+ if err := client.Configure(invalid); err == nil {
+ t.Fatal(invalid)
+ }
+ }
+ if err := client.Say("feedback"); err != nil {
+ t.Fatal(err)
+ }
+ wait_event(t, server.events, "say:feedback")
+ if err := client.Cancel(); err != nil {
+ t.Fatal(err)
+ }
+ wait_event(t, server.events, "CANCEL 41")
+}
+
+func TestDefaultSocketForSudo(t *testing.T) {
+ t.Setenv("SPEECHD_ADDRESS", "")
+ t.Setenv("SUDO_UID", "1000")
+ t.Setenv("XDG_RUNTIME_DIR", "/run/user/0")
+ if got := Default_socket(); got != "/run/user/1000/speech-dispatcher/speechd.sock" {
+ t.Fatal(got)
+ }
+ t.Setenv("SPEECHD_ADDRESS", "unix_socket:/tmp/example-speechd.sock")
+ if got := Default_socket(); got != "/tmp/example-speechd.sock" {
+ t.Fatal(got)
+ }
+}
diff --git a/internal/speech/drivers.go b/internal/speech/drivers.go
new file mode 100644
index 00000000..4d0378b6
--- /dev/null
+++ b/internal/speech/drivers.go
@@ -0,0 +1,134 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package speech
+
+import "fmt"
+
+// Driver binds a stable file/CLI ID to its implementation. Availability must
+// be a cheap dependency/hardware check, never connect, speak or start a service.
+// Connection failures are reported by Open and never select another driver.
+type Driver struct {
+ ID, Label string
+ Serial bool
+ Hardware bool
+ Check func() error
+ Open func(Options) (Transport, error)
+ Modules func(Options) ([]string, error)
+ Catalog func(Options) (Catalog, error)
+ Voices func(Options) ([]Voice, error)
+}
+
+func (d Driver) Availability() error {
+ if d.Check != nil {
+ return d.Check()
+ }
+ return nil
+}
+
+var drivers = append([]Driver{{
+ ID: "speech-dispatcher", Label: "Speech Dispatcher",
+ Open: func(options Options) (Transport, error) {
+ if options.Socket == "" {
+ options.Socket = Default_socket()
+ }
+ return Open(options)
+ },
+ Modules: discover_modules,
+ Catalog: Discover,
+ Voices: discover_voices,
+}}, hardware_drivers()...)
+
+func hardware_drivers() []Driver {
+ var result []Driver
+ for _, entry := range []struct{ id, label string }{
+ {"dectalk", "DECtalk"}, {"dectalk-usb", "DECtalk USB"},
+ {"litetalk", "LiteTalk"}, {"doubletalk", "DoubleTalk LT"}, {"tripletalk", "TripleTalk"},
+ } {
+ driver := Driver{ID: entry.id, Label: entry.label,
+ Serial: entry.id != "dectalk-usb",
+ Hardware: true,
+ Open: func(options Options) (Transport, error) { return open_hardware(entry.id, options) },
+ }
+ if entry.id == "dectalk-usb" {
+ driver.Check = usb_available
+ }
+ result = append(result, driver)
+ }
+ return result
+}
+
+// Drivers returns a copy; registering a completed implementation here supplies
+// settings validation, UI choices, discovery and startup from the same entry.
+func Drivers() []Driver { return append([]Driver(nil), drivers...) }
+
+func Find_driver(id string) (Driver, error) {
+ for _, driver := range drivers {
+ if driver.ID == id {
+ return driver, nil
+ }
+ }
+ return Driver{}, fmt.Errorf("unknown speech driver %q", id)
+}
+
+func Open_driver(id string, options Options) (Transport, error) {
+ driver, err := Find_driver(id)
+ if err != nil {
+ return nil, err
+ }
+ if err := Validate_driver_options(id, options); err != nil {
+ return nil, err
+ }
+ if err = driver.Availability(); err != nil {
+ return nil, fmt.Errorf("speech driver %s unavailable: %w", id, err)
+ }
+ return driver.Open(options)
+}
+
+// discover_modules uses a separate, bounded connection. It never changes the
+// active speaking client, cancels speech or starts a speech service.
+func discover_modules(options Options) ([]string, error) {
+ if options.Socket == "" {
+ options.Socket = Default_socket()
+ }
+ wire, err := connect(options.Socket)
+ if err != nil {
+ return nil, err
+ }
+ defer wire.conn.Close()
+ modules, err := wire.command("LIST OUTPUT_MODULES")
+ if err != nil {
+ return nil, err
+ }
+ seen := map[string]bool{}
+ for _, module := range modules {
+ if !valid_module(module) || module == "" || seen[module] {
+ return nil, fmt.Errorf("invalid Speech Dispatcher module list")
+ }
+ seen[module] = true
+ }
+ return modules, nil
+}
+
+func valid_module(module string) bool {
+ for _, char := range module {
+ if !(char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9' || char == '.' || char == '_' || char == '+' || char == '-') {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/speech/drivers_test.go b/internal/speech/drivers_test.go
new file mode 100644
index 00000000..75032aac
--- /dev/null
+++ b/internal/speech/drivers_test.go
@@ -0,0 +1,76 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package speech
+
+import (
+ "errors"
+ "reflect"
+ "testing"
+)
+
+func TestRegistryIsTheImplementationAndSelectionBoundary(t *testing.T) {
+ available := Drivers()
+ if len(available) != 6 || available[0].ID != "speech-dispatcher" || available[0].Open == nil || available[0].Modules == nil {
+ t.Fatal(available)
+ }
+ available[0].ID = "altered"
+ if _, err := Find_driver("speech-dispatcher"); err != nil {
+ t.Fatal("registry aliased", err)
+ }
+ if _, err := Open_driver("invented", Options{}); err == nil {
+ t.Fatal("unknown driver opened")
+ }
+ // A supported but locally unavailable backend must not block another driver,
+ // and selecting it must never call its constructor or silently fall back.
+ original := drivers
+ opened := false
+ drivers = append(Drivers(), Driver{ID: "fixture", Label: "Fixture", Check: func() error { return errors.New("fixture dependency missing") }, Open: func(Options) (Transport, error) { opened = true; return nil, nil }})
+ defer func() { drivers = original }()
+ if _, err := Find_driver("fixture"); err != nil {
+ t.Fatal("unavailable support disappeared", err)
+ }
+ if err := Drivers()[0].Availability(); err != nil {
+ t.Fatal("unselected dependency blocked supported driver", err)
+ }
+ if _, err := Open_driver("fixture", Options{}); err == nil || opened {
+ t.Fatal("unavailable driver constructed or fell back", opened, err)
+ }
+}
+
+func TestModuleDiscoveryDoesNotSpeakOrChangeActiveClient(t *testing.T) {
+ server := new_fake_server(t, false)
+ driver, err := Find_driver("speech-dispatcher")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("SPEECHD_ADDRESS", "unix_socket:"+server.path)
+ client, err := Open_driver(driver.ID, Options{Rate: 80})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer client.Close()
+ modules, err := driver.Modules(Options{})
+ if err != nil || !reflect.DeepEqual(modules, []string{"fixture", "other"}) {
+ t.Fatal(modules, err)
+ }
+ select {
+ case event := <-server.events:
+ t.Fatal("discovery changed speech", event)
+ default:
+ }
+}
diff --git a/internal/speech/hardware.go b/internal/speech/hardware.go
new file mode 100644
index 00000000..a6de1f2b
--- /dev/null
+++ b/internal/speech/hardware.go
@@ -0,0 +1,227 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package speech
+
+import (
+ "errors"
+ "fmt"
+ "math"
+ "path/filepath"
+ "slices"
+ "strings"
+ "sync"
+ "sync/atomic"
+)
+
+var errHardwareCanceled = errors.New("hardware speech write canceled")
+
+// Hardware device settings take effect at launch. Serial paths are explicit;
+// DECtalk USB finds a single native device, without probing serial ports.
+type HardwareOptions struct {
+ Device string
+ BaudRate int
+}
+
+// Baud_rates supplies validation and settings choices from the same list.
+// Zero retains the saved default, which selects 9600 on open.
+func Baud_rates() []int { return []int{0, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200} }
+
+func hardware_driver(id string) bool {
+ switch id {
+ case "dectalk", "dectalk-usb", "litetalk", "doubletalk", "tripletalk":
+ return true
+ }
+ return false
+}
+
+func Validate_driver_options(id string, options Options) error {
+ if err := Validate_selection(options.Module, options.Language, options.Voice); err != nil {
+ return err
+ }
+ if !hardware_driver(id) {
+ return nil
+ }
+ if options.Module != "" || options.Language != "" || options.Voice != "" {
+ return fmt.Errorf("%s supports default module, language and voice only", id)
+ }
+ if id == "dectalk-usb" {
+ return nil
+ }
+ device := options.Hardware.Device
+ if device == "" || !filepath.IsAbs(device) || strings.ContainsAny(device, "\x00\r\n") {
+ return fmt.Errorf("%s requires an explicit absolute hardware_device path", id)
+ }
+ if slices.Contains(Baud_rates(), options.Hardware.BaudRate) {
+ return nil
+ }
+ return fmt.Errorf("unsupported hardware_baud_rate %d", options.Hardware.BaudRate)
+}
+
+// Write checks cancellation between packets. Cancel must bypass a blocked
+// normal write; Close releases resources after stopping in-flight operations.
+type hardwarePort interface {
+ Write([]byte, func() bool) error
+ Cancel([]byte) error
+ Close()
+}
+
+type HardwareClient struct {
+ port hardwarePort
+ dectalk bool
+ usb bool
+ mu sync.Mutex // Serialized settings/text; cancellation never takes this lock.
+ parameters Parameters
+ cache [3]string
+ cacheGeneration uint64
+ generation atomic.Uint64
+ closed atomic.Bool
+}
+
+func new_hardware_client(id string, options Options, port hardwarePort) *HardwareClient {
+ parameters := Parameters{Rate: options.Rate}
+ if options.Pitch != nil {
+ parameters.Pitch = *options.Pitch
+ }
+ if options.Volume != nil {
+ parameters.Volume = *options.Volume
+ }
+ return &HardwareClient{port: port, dectalk: id == "dectalk" || id == "dectalk-usb", usb: id == "dectalk-usb", parameters: parameters}
+}
+
+func hardware_parameters(options Options) error {
+ parameters := Parameters{Rate: options.Rate}
+ if options.Pitch != nil {
+ parameters.Pitch = *options.Pitch
+ }
+ if options.Volume != nil {
+ parameters.Volume = *options.Volume
+ }
+ return parameters.Validate()
+}
+
+// Python's normalized 0..1 values correspond to the shared -100..100 scale.
+// RoundToEven matches Python round, including LiteTalk's midpoint volume 4.
+func hardware_scale(value, minimum, maximum int) int {
+ return int(math.RoundToEven(float64(minimum) + float64(value+100)*float64(maximum-minimum)/200))
+}
+
+func dectalk_scale(value, minimum, normal, maximum int) int {
+ if value <= 0 {
+ return hardware_scale(value*2+100, minimum, normal)
+ }
+ return hardware_scale(value*2-100, normal, maximum)
+}
+
+func (c *HardwareClient) commands() [3]string {
+ p := c.parameters
+ if c.dectalk {
+ return [3]string{fmt.Sprintf("[:ra %d]", dectalk_scale(p.Rate, 75, 180, 650)), fmt.Sprintf("[:dv ap %d]", dectalk_scale(p.Pitch, 50, 122, 350)), fmt.Sprintf("[:dv g5 %d]", hardware_scale(p.Volume, 60, 86))}
+ }
+ return [3]string{fmt.Sprintf("\x01%dS", hardware_scale(p.Rate, 0, 9)), fmt.Sprintf("\x01%dP", hardware_scale(p.Pitch, 0, 99)), fmt.Sprintf("\x01%dV", hardware_scale(p.Volume, 0, 9))}
+}
+
+func (c *HardwareClient) write(data []byte, generation uint64) error {
+ if c.closed.Load() {
+ return errors.New("hardware speech closed")
+ }
+ return c.port.Write(data, func() bool { return c.closed.Load() || c.generation.Load() != generation })
+}
+
+func (c *HardwareClient) configure(generation uint64) error {
+ if c.cacheGeneration != generation {
+ c.cache = [3]string{}
+ c.cacheGeneration = generation
+ }
+ for index, command := range c.commands() {
+ if command == c.cache[index] {
+ continue
+ }
+ if err := c.write([]byte(command), generation); err != nil {
+ return err
+ }
+ c.cache[index] = command
+ }
+ return nil
+}
+
+func ignore_hardware_cancel(err error) error {
+ if errors.Is(err, errHardwareCanceled) {
+ return nil
+ }
+ return err
+}
+
+func (c *HardwareClient) Configure(parameters Parameters) error {
+ if err := parameters.Validate(); err != nil {
+ return err
+ }
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.parameters = parameters
+ return ignore_hardware_cancel(c.configure(c.generation.Load()))
+}
+
+func (c *HardwareClient) Set_pitch(pitch int) error {
+ if pitch < -100 || pitch > 100 {
+ return fmt.Errorf("speech pitch must be between -100 and 100")
+ }
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.parameters.Pitch = pitch
+ return ignore_hardware_cancel(c.configure(c.generation.Load()))
+}
+
+func (c *HardwareClient) Say(text string) error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ generation := c.generation.Load()
+ if err := c.configure(generation); err != nil {
+ return ignore_hardware_cancel(err)
+ }
+ var clean strings.Builder
+ for _, char := range text {
+ if char < 0x20 || char > 0x7e {
+ char = ' '
+ }
+ clean.WriteRune(char)
+ }
+ prepared := clean.String()
+ if c.dectalk {
+ prepared = strings.ReplaceAll(prepared, "[:", "[ :") + "\x0b"
+ } else {
+ prepared += "\r"
+ }
+ return ignore_hardware_cancel(c.write([]byte(prepared), generation))
+}
+
+func (c *HardwareClient) Cancel() error {
+ c.generation.Add(1)
+ if c.closed.Load() {
+ return nil
+ }
+ command := []byte{0x18}
+ if c.usb {
+ command = []byte{0x06}
+ } else if c.dectalk {
+ command = []byte{0x03}
+ }
+ return c.port.Cancel(command)
+}
+
+func (c *HardwareClient) Close() {
+ if c.closed.Swap(true) {
+ return
+ }
+ c.generation.Add(1)
+ // Cancel audible device buffers before releasing ownership. Port writes
+ // are bounded, including when the device is disconnected or flow-stopped.
+ command := []byte{0x18}
+ if c.usb {
+ command = []byte{0x06}
+ } else if c.dectalk {
+ command = []byte{0x03}
+ }
+ _ = c.port.Cancel(command)
+ c.port.Close()
+}
diff --git a/internal/speech/hardware_linux.go b/internal/speech/hardware_linux.go
new file mode 100644
index 00000000..f591accc
--- /dev/null
+++ b/internal/speech/hardware_linux.go
@@ -0,0 +1,219 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+//go:build linux && cgo
+
+package speech
+
+/*
+#cgo LDFLAGS: -ldl
+#include
+#include "hardware_native.h"
+*/
+import "C"
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "sync"
+ "syscall"
+ "time"
+ "unsafe"
+)
+
+type serialPort struct {
+ mu sync.Mutex
+ native *C.SpeechSerial
+}
+
+func open_hardware(id string, options Options) (Transport, error) {
+ if err := Validate_driver_options(id, options); err != nil {
+ return nil, err
+ }
+ if err := hardware_parameters(options); err != nil {
+ return nil, err
+ }
+ var port hardwarePort
+ if id == "dectalk-usb" {
+ var status C.int
+ native := C.speech_usb_open(&status)
+ if native == nil {
+ return nil, usb_error(status)
+ }
+ usb := &usbPort{
+ send: func(endpoint byte, data []byte) error {
+ status := C.speech_usb_write(native, C.uchar(endpoint), unsafe.Pointer(&data[0]), C.int(len(data)))
+ if status != 0 {
+ return usb_error(status)
+ }
+ return nil
+ },
+ close: func() { C.speech_usb_close(native) },
+ }
+ if err := usb.Write([]byte("[:timeout 1]"), func() bool { return false }); err != nil {
+ usb.Close()
+ return nil, err
+ }
+ port = usb
+ } else {
+ path := C.CString(options.Hardware.Device)
+ defer C.free(unsafe.Pointer(path))
+ baud := options.Hardware.BaudRate
+ if baud == 0 {
+ baud = 9600
+ }
+ xon := C.int(0)
+ if id == "dectalk" {
+ xon = 1
+ }
+ var status C.int
+ native := C.speech_serial_open(path, C.int(baud), xon, &status)
+ if native == nil {
+ return nil, fmt.Errorf("open hardware speech %s: %w", options.Hardware.Device, syscall.Errno(-status))
+ }
+ port = &serialPort{native: native}
+ }
+ return new_hardware_client(id, options, port), nil
+}
+
+func (p *serialPort) Write(data []byte, canceled func() bool) error {
+ for len(data) > 0 {
+ p.mu.Lock()
+ if canceled() {
+ p.mu.Unlock()
+ return errHardwareCanceled
+ }
+ if p.native == nil {
+ p.mu.Unlock()
+ return errors.New("serial speech closed")
+ }
+ count := int(C.speech_serial_write(p.native, unsafe.Pointer(&data[0]), C.size_t(min(len(data), 64))))
+ p.mu.Unlock()
+ if count > 0 {
+ data = data[count:]
+ continue
+ }
+ if count == 0 {
+ return io.ErrNoProgress
+ }
+ err := syscall.Errno(-count)
+ if err != syscall.EAGAIN && err != syscall.EINTR {
+ return fmt.Errorf("write serial speech: %w", err)
+ }
+ // XOFF can legitimately last while buffered speech is being spoken.
+ // Each write is nonblocking; only this worker waits, and cancellation
+ // or shutdown breaks the retry loop without waiting for XON.
+ time.Sleep(5 * time.Millisecond)
+ }
+ return nil
+}
+
+func (p *serialPort) Cancel(command []byte) error {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if p.native == nil {
+ return nil
+ }
+ deadline := time.Now().Add(commandTimeout)
+ for {
+ count := int(C.speech_serial_cancel(p.native, C.uchar(command[0])))
+ if count == 1 {
+ return nil
+ }
+ if count == 0 {
+ return io.ErrNoProgress
+ }
+ err := syscall.Errno(-count)
+ if (err != syscall.EAGAIN && err != syscall.EINTR) || time.Now().After(deadline) {
+ return fmt.Errorf("cancel serial speech: %w", err)
+ }
+ // Resuming terminal output can complete asynchronously. Retry the
+ // immediate byte within a bounded interval, rather than treating the
+ // first EAGAIN after TCOON as a device failure.
+ time.Sleep(5 * time.Millisecond)
+ }
+}
+
+func (p *serialPort) Close() {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if p.native != nil {
+ C.speech_serial_close(p.native)
+ p.native = nil
+ }
+}
+
+// Different endpoints can transfer concurrently. Only Close takes exclusive
+// ownership, after each bounded native call has returned.
+type usbPort struct {
+ mu sync.RWMutex
+ send func(byte, []byte) error
+ close func()
+}
+
+func usb_available() error {
+ if C.speech_usb_available() == 0 {
+ return errors.New("DECtalk USB requires libusb-1.0.so.0")
+ }
+ return nil
+}
+
+func usb_error(status C.int) error {
+ switch status {
+ case -100:
+ return errors.New("DECtalk USB requires libusb-1.0.so.0")
+ case -101:
+ return errors.New("multiple DECtalk USB devices found; connect one for this reader")
+ case -102:
+ return errors.New("short DECtalk USB write")
+ case -4:
+ return errors.New("DECtalk USB device 0dd0:2001 unavailable or disconnected")
+ case -3:
+ return errors.New("DECtalk USB access denied")
+ case -6:
+ return errors.New("DECtalk USB interface is busy")
+ case -7:
+ return errors.New("DECtalk USB write timed out")
+ }
+ return fmt.Errorf("DECtalk USB error %d", status)
+}
+
+func (p *usbPort) transfer(endpoint byte, data []byte, canceled func() bool) error {
+ p.mu.RLock()
+ defer p.mu.RUnlock()
+ if p.send == nil {
+ return errors.New("USB speech closed")
+ }
+ for len(data) > 0 {
+ if canceled() {
+ return errHardwareCanceled
+ }
+ count := min(64, len(data))
+ if err := p.send(endpoint, data[:count]); err != nil {
+ if canceled() {
+ return errHardwareCanceled
+ }
+ return err
+ }
+ data = data[count:]
+ }
+ return nil
+}
+
+func (p *usbPort) Write(data []byte, canceled func() bool) error {
+ return p.transfer(0x02, data, canceled)
+}
+func (p *usbPort) Cancel(command []byte) error {
+ return p.transfer(0x04, command, func() bool { return false })
+}
+func (p *usbPort) Close() {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if p.send != nil {
+ if p.close != nil {
+ p.close()
+ }
+ p.send, p.close = nil, nil
+ }
+}
diff --git a/internal/speech/hardware_linux_test.go b/internal/speech/hardware_linux_test.go
new file mode 100644
index 00000000..920fb77c
--- /dev/null
+++ b/internal/speech/hardware_linux_test.go
@@ -0,0 +1,382 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+//go:build linux && cgo
+
+package speech
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "reflect"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "syscall"
+ "testing"
+ "time"
+ "unsafe"
+)
+
+func TestHardwarePreviewKeepsTemporaryPortUntilCanceledAndReleasesIt(t *testing.T) {
+ master, path := hardware_pty(t)
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ done := make(chan error, 1)
+ go func() {
+ done <- Test_hardware(ctx, "dectalk", Options{Hardware: HardwareOptions{Device: path}}, "test sample")
+ }()
+ want := "[:ra 180][:dv ap 122][:dv g5 73]test sample\x0b"
+ if got := string(read_hardware(t, master, len(want))); got != want {
+ t.Fatal(got)
+ }
+ if other, err := Open_driver("dectalk", Options{Hardware: HardwareOptions{Device: path}}); err == nil {
+ other.Close()
+ t.Fatal("preview closed before device could speak")
+ }
+ cancel()
+ select {
+ case err := <-done:
+ if !errors.Is(err, context.Canceled) {
+ t.Fatal(err)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("preview did not cancel")
+ }
+ client, err := Open_driver("dectalk", Options{Hardware: HardwareOptions{Device: path}})
+ if err != nil {
+ t.Fatal("preview retained port", err)
+ }
+ client.Close()
+}
+
+func hardware_pty(t *testing.T) (*os.File, string) {
+ t.Helper()
+ fd, err := syscall.Open("/dev/ptmx", syscall.O_RDWR|syscall.O_NOCTTY|syscall.O_NONBLOCK|syscall.O_CLOEXEC, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ master := os.NewFile(uintptr(fd), "hardware fixture")
+ t.Cleanup(func() { master.Close() })
+ var unlock int32
+ if _, _, err := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), syscall.TIOCSPTLCK, uintptr(unsafe.Pointer(&unlock))); err != 0 {
+ t.Fatal(err)
+ }
+ var number uint32
+ if _, _, err := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), syscall.TIOCGPTN, uintptr(unsafe.Pointer(&number))); err != 0 {
+ t.Fatal(err)
+ }
+ return master, fmt.Sprintf("/dev/pts/%d", number)
+}
+
+func read_hardware(t *testing.T, master *os.File, count int) []byte {
+ t.Helper()
+ var result []byte
+ deadline := time.Now().Add(2 * time.Second)
+ for len(result) < count && time.Now().Before(deadline) {
+ buffer := make([]byte, count-len(result))
+ n, err := syscall.Read(int(master.Fd()), buffer)
+ if n > 0 {
+ result = append(result, buffer[:n]...)
+ }
+ if err != nil && err != syscall.EAGAIN && err != syscall.EINTR {
+ t.Fatal(err)
+ }
+ if n <= 0 {
+ time.Sleep(time.Millisecond)
+ }
+ }
+ if len(result) != count {
+ t.Fatalf("read %q (%d of %d)", result, len(result), count)
+ }
+ return result
+}
+
+func assert_hardware_idle(t *testing.T, master *os.File) {
+ t.Helper()
+ var buffer [128]byte
+ if n, err := syscall.Read(int(master.Fd()), buffer[:]); n > 0 || err != syscall.EAGAIN {
+ t.Fatalf("unexpected bytes %q, %v", buffer[:max(n, 0)], err)
+ }
+}
+
+func hardware_termios(t *testing.T, fd int) syscall.Termios {
+ t.Helper()
+ var value syscall.Termios
+ if _, _, err := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), syscall.TCGETS, uintptr(unsafe.Pointer(&value))); err != 0 {
+ t.Fatal(err)
+ }
+ return value
+}
+
+func TestNativeSerialHardwareLifecycle(t *testing.T) {
+ for _, id := range []string{"dectalk", "litetalk", "doubletalk", "tripletalk"} {
+ t.Run(id, func(t *testing.T) {
+ master, path := hardware_pty(t)
+ slave, err := syscall.Open(path, syscall.O_RDWR|syscall.O_NOCTTY|syscall.O_CLOEXEC, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer syscall.Close(slave)
+ original := hardware_termios(t, slave)
+ options := Options{Hardware: HardwareOptions{Device: path, BaudRate: 19200}}
+ transport, err := Open_driver(id, options)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer transport.Close()
+ configured := hardware_termios(t, slave)
+ if configured.Cflag&syscall.CS8 == 0 || configured.Lflag&(syscall.ICANON|syscall.ECHO) != 0 || configured.Iflag&(syscall.IXOFF|syscall.IXANY) != 0 || (configured.Iflag&syscall.IXON != 0) != (id == "dectalk") {
+ t.Fatal("serial mode", configured)
+ }
+ assert_hardware_idle(t, master) // Opening a candidate never speaks.
+ if other, err := Open_driver(id, options); err == nil {
+ other.Close()
+ t.Fatal("another reader acquired an owned serial device")
+ }
+ if err := transport.Configure(Parameters{Volume: 100}); err != nil {
+ t.Fatal(err)
+ }
+ if err := transport.Say("Hello\nworld ā"); err != nil {
+ t.Fatal(err)
+ }
+ want := "\x014S\x0150P\x019VHello world \r"
+ cancel := "\x18"
+ if id == "dectalk" {
+ want = "[:ra 180][:dv ap 122][:dv g5 86]Hello world \x0b"
+ cancel = "\x03"
+ }
+ if got := string(read_hardware(t, master, len(want))); got != want {
+ t.Fatalf("%q != %q", got, want)
+ }
+ if err := transport.Configure(Parameters{Volume: 100}); err != nil {
+ t.Fatal(err)
+ }
+ assert_hardware_idle(t, master)
+ if err := transport.Cancel(); err != nil {
+ t.Fatal(err)
+ }
+ if got := string(read_hardware(t, master, 1)); got != cancel {
+ t.Fatal(got)
+ }
+ transport.Close()
+ if restored := hardware_termios(t, slave); restored != original {
+ t.Fatal("termios not restored", restored, original)
+ }
+ reopened, err := Open_driver(id, options)
+ if err != nil {
+ t.Fatal("serial lock survived close", err)
+ }
+ reopened.Close()
+ })
+ }
+}
+
+func TestSerialCancellationStopsLargeWriteAndAllowsFreshSpeech(t *testing.T) {
+ master, path := hardware_pty(t)
+ transport, err := Open_driver("dectalk", Options{Hardware: HardwareOptions{Device: path}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer transport.Close()
+ if err := transport.Configure(Parameters{}); err != nil {
+ t.Fatal(err)
+ }
+ read_hardware(t, master, len("[:ra 180][:dv ap 122][:dv g5 73]"))
+ done := make(chan error, 1)
+ go func() { done <- transport.Say(strings.Repeat("x", 1<<20)) }()
+ if string(read_hardware(t, master, 1)) != "x" {
+ t.Fatal("text write did not start")
+ }
+ if err := transport.Cancel(); err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case err := <-done:
+ if err != nil {
+ t.Fatal(err)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("cancellation waited behind speech")
+ }
+ // Discard already transmitted fixture bytes, then ensure nothing from the
+ // canceled utterance can appear after a fresh request.
+ var buffer [32768]byte
+ for {
+ if _, err := syscall.Read(int(master.Fd()), buffer[:]); err == syscall.EAGAIN {
+ break
+ } else if err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := transport.Say("fresh"); err != nil {
+ t.Fatal(err)
+ }
+ want := "[:ra 180][:dv ap 122][:dv g5 73]fresh\x0b"
+ if got := string(read_hardware(t, master, len(want))); got != want {
+ t.Fatalf("stale output %q", got)
+ }
+ assert_hardware_idle(t, master)
+}
+
+func TestSerialDisconnectReturnsError(t *testing.T) {
+ master, path := hardware_pty(t)
+ transport, err := Open_driver("litetalk", Options{Hardware: HardwareOptions{Device: path}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer transport.Close()
+ master.Close()
+ if err := transport.Say("after disconnect"); err == nil {
+ t.Fatal("disconnected serial device accepted speech")
+ }
+}
+
+func TestSerialXoffPauseRemainsCancelable(t *testing.T) {
+ master, path := hardware_pty(t)
+ transport, err := Open_driver("dectalk", Options{Hardware: HardwareOptions{Device: path}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer transport.Close()
+ if err := transport.Configure(Parameters{}); err != nil {
+ t.Fatal(err)
+ }
+ read_hardware(t, master, len("[:ra 180][:dv ap 122][:dv g5 73]"))
+ // Packet mode acknowledges that the terminal processed XOFF before the
+ // write starts; this does not rely on a sleep or on filling a PTY buffer.
+ packetMode := int32(1)
+ if _, _, err := syscall.Syscall(syscall.SYS_IOCTL, master.Fd(), syscall.TIOCPKT, uintptr(unsafe.Pointer(&packetMode))); err != 0 {
+ t.Fatal(err)
+ }
+ if _, err := syscall.Write(int(master.Fd()), []byte{0x13}); err != nil {
+ t.Fatal(err)
+ }
+ if got := read_hardware(t, master, 1)[0]; got&syscall.TIOCPKT_STOP == 0 {
+ t.Fatal("XOFF was not processed", got)
+ }
+ packetMode = 0
+ if _, _, err := syscall.Syscall(syscall.SYS_IOCTL, master.Fd(), syscall.TIOCPKT, uintptr(unsafe.Pointer(&packetMode))); err != 0 {
+ t.Fatal(err)
+ }
+ done := make(chan error, 1)
+ go func() { done <- transport.Say("paused") }()
+ select {
+ case err := <-done:
+ t.Fatal("normal flow pause terminated speech", err)
+ case <-time.After(time.Second):
+ }
+ if err := transport.Cancel(); err != nil {
+ t.Fatal(err)
+ }
+ slave, err := syscall.Open(path, syscall.O_RDWR|syscall.O_NOCTTY, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ configured := hardware_termios(t, slave)
+ syscall.Close(slave)
+ if configured.Iflag&syscall.IXON == 0 {
+ t.Fatal("cancellation did not restore XON/XOFF")
+ }
+ select {
+ case err := <-done:
+ if err != nil {
+ t.Fatal(err)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("cancel failed during flow pause")
+ }
+ if got := string(read_hardware(t, master, 1)); got != "\x03" {
+ t.Fatal(got)
+ }
+ if err := transport.Say("resumed"); err != nil {
+ t.Fatal(err)
+ }
+ want := "[:ra 180][:dv ap 122][:dv g5 73]resumed\x0b"
+ if got := string(read_hardware(t, master, len(want))); got != want {
+ t.Fatal(got)
+ }
+}
+
+func TestUSBCancelBypassesBlockedPacketAndDiscardsRemainingText(t *testing.T) {
+ started, release := make(chan struct{}), make(chan struct{})
+ var releaseOnce sync.Once
+ unblock := func() { releaseOnce.Do(func() { close(release) }) }
+ defer unblock()
+ var normal, immediate atomic.Int32
+ port := &usbPort{send: func(endpoint byte, data []byte) error {
+ if endpoint == 0x04 {
+ if !bytes.Equal(data, []byte{0x06}) {
+ return fmt.Errorf("bad cancellation %x", data)
+ }
+ immediate.Add(1)
+ } else if bytes.HasPrefix(data, []byte("xxx")) {
+ if normal.Add(1) == 1 {
+ close(started)
+ <-release
+ }
+ }
+ return nil
+ }}
+ client := new_hardware_client("dectalk-usb", Options{}, port)
+ done := make(chan error, 1)
+ go func() { done <- client.Say(strings.Repeat("x", 200)) }()
+ select {
+ case <-started:
+ case <-time.After(time.Second):
+ t.Fatal("packet not started")
+ }
+ if err := client.Cancel(); err != nil {
+ t.Fatal(err)
+ }
+ if immediate.Load() != 1 {
+ t.Fatal("immediate endpoint was blocked")
+ }
+ unblock()
+ select {
+ case err := <-done:
+ if err != nil {
+ t.Fatal(err)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("stale send stuck")
+ }
+ if normal.Load() != 1 {
+ t.Fatal("more stale packets transmitted", normal.Load())
+ }
+ client.Close()
+}
+
+func TestUSBPacketLimitsFailureAndCleanup(t *testing.T) {
+ var sizes []int
+ closed := 0
+ port := &usbPort{send: func(endpoint byte, data []byte) error {
+ if endpoint != 2 {
+ t.Fatal(endpoint)
+ }
+ sizes = append(sizes, len(data))
+ return nil
+ }, close: func() { closed++ }}
+ if err := port.Write(make([]byte, 130), func() bool { return false }); err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(sizes, []int{64, 64, 2}) {
+ t.Fatal(sizes)
+ }
+ failure := errors.New("short transfer")
+ port.send = func(byte, []byte) error { return failure }
+ if err := port.Write(make([]byte, 70), func() bool { return false }); !errors.Is(err, failure) {
+ t.Fatal(err)
+ }
+ port.Close()
+ port.Close()
+ if closed != 1 {
+ t.Fatal(closed)
+ }
+ if err := port.Write([]byte{1}, func() bool { return false }); err == nil {
+ t.Fatal("closed USB device accepted write")
+ }
+}
diff --git a/internal/speech/hardware_native.c b/internal/speech/hardware_native.c
new file mode 100644
index 00000000..758290b7
--- /dev/null
+++ b/internal/speech/hardware_native.c
@@ -0,0 +1,203 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "hardware_native.h"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+struct SpeechSerial {
+ int fd;
+ struct termios original;
+};
+
+SpeechSerial *speech_serial_open(const char *path, int baud, int xon, int *status) {
+ SpeechSerial *port = calloc(1, sizeof(*port));
+ *status = -ENOMEM;
+ if (!port) return NULL;
+ port->fd = open(path, O_RDWR | O_NOCTTY | O_NONBLOCK | O_CLOEXEC);
+ if (port->fd < 0) goto failed;
+ // A short-lived candidate must never configure another reader's device.
+ if (flock(port->fd, LOCK_EX | LOCK_NB) < 0) goto failed;
+ if (tcgetattr(port->fd, &port->original) < 0) goto failed;
+ struct termios options = port->original;
+ cfmakeraw(&options);
+ options.c_cflag |= CLOCAL | CREAD;
+ options.c_cflag &= ~(CRTSCTS | CSTOPB);
+ options.c_iflag &= ~(IXON | IXOFF | IXANY);
+ if (xon) options.c_iflag |= IXON;
+ options.c_cc[VSTART] = 0x11;
+ options.c_cc[VSTOP] = 0x13;
+ speed_t speed;
+ switch (baud) {
+ case 1200: speed = B1200; break;
+ case 2400: speed = B2400; break;
+ case 4800: speed = B4800; break;
+ case 9600: speed = B9600; break;
+ case 19200: speed = B19200; break;
+ case 38400: speed = B38400; break;
+ case 57600: speed = B57600; break;
+ case 115200: speed = B115200; break;
+ default: errno = EINVAL; goto failed;
+ }
+ cfsetispeed(&options, speed);
+ cfsetospeed(&options, speed);
+ options.c_cc[VMIN] = 0;
+ options.c_cc[VTIME] = 0;
+ if (tcsetattr(port->fd, TCSANOW, &options) < 0) goto failed;
+ *status = 0;
+ return port;
+failed:
+ *status = -errno;
+ if (port->fd >= 0) close(port->fd);
+ free(port);
+ return NULL;
+}
+
+int speech_serial_write(SpeechSerial *port, const void *data, size_t length) {
+ ssize_t result = write(port->fd, data, length);
+ return result < 0 ? -errno : (int)result;
+}
+
+int speech_serial_cancel(SpeechSerial *port, unsigned char command) {
+ if (tcflush(port->fd, TCOFLUSH) < 0) return -errno;
+ if (tcflow(port->fd, TCOON) < 0) return -errno;
+ // TCOON clears an explicit tcflow stop, but not a received XOFF in Linux's
+ // N_TTY discipline. Temporarily clearing IXON restarts that output path.
+ // Restore software flow control immediately after submitting the cancel.
+ struct termios options;
+ if (tcgetattr(port->fd, &options) < 0) return -errno;
+ int xon = (options.c_iflag & IXON) != 0;
+ if (xon) {
+ struct termios resumed = options;
+ resumed.c_iflag &= ~IXON;
+ if (tcsetattr(port->fd, TCSANOW, &resumed) < 0) return -errno;
+ }
+ int result = speech_serial_write(port, &command, 1);
+ if (xon && tcsetattr(port->fd, TCSANOW, &options) < 0) return -errno;
+ return result;
+}
+
+void speech_serial_close(SpeechSerial *port) {
+ tcsetattr(port->fd, TCSANOW, &port->original);
+ close(port->fd);
+ free(port);
+}
+
+// Optional libusb 1.0 ABI, resolved at runtime like Enchant. Neither headers
+// nor a link-time libusb dependency are required for other speech drivers.
+typedef struct libusb_context USBContext;
+typedef struct libusb_device USBDevice;
+typedef struct libusb_device_handle USBHandle;
+struct USBDescriptor {
+ uint8_t length, type;
+ uint16_t usb;
+ uint8_t device_class, subclass, protocol, packet_size;
+ uint16_t vendor, product, device;
+ uint8_t manufacturer, product_name, serial, configurations;
+};
+
+struct SpeechUSB {
+ void *library;
+ USBContext *context;
+ USBHandle *handle;
+ int claimed;
+ int (*init)(USBContext **);
+ void (*exit)(USBContext *);
+ ssize_t (*get_device_list)(USBContext *, USBDevice ***);
+ void (*free_device_list)(USBDevice **, int);
+ int (*get_device_descriptor)(USBDevice *, struct USBDescriptor *);
+ int (*open)(USBDevice *, USBHandle **);
+ void (*close)(USBHandle *);
+ int (*get_configuration)(USBHandle *, int *);
+ int (*set_configuration)(USBHandle *, int);
+ int (*claim_interface)(USBHandle *, int);
+ int (*release_interface)(USBHandle *, int);
+ int (*bulk_transfer)(USBHandle *, unsigned char, unsigned char *, int, int *, unsigned int);
+};
+
+void speech_usb_close(SpeechUSB *port) {
+ if (!port) return;
+ if (port->claimed) port->release_interface(port->handle, 0);
+ if (port->handle) port->close(port->handle);
+ if (port->context) port->exit(port->context);
+ if (port->library) dlclose(port->library);
+ free(port);
+}
+
+static SpeechUSB *load_usb(void) {
+ SpeechUSB *port = calloc(1, sizeof(*port));
+ if (!port) return NULL;
+ port->library = dlopen("libusb-1.0.so.0", RTLD_NOW | RTLD_LOCAL);
+ if (!port->library) goto failed;
+#define LOAD(field) port->field = dlsym(port->library, "libusb_" #field); if (!port->field) goto failed
+ LOAD(init); LOAD(exit); LOAD(get_device_list); LOAD(free_device_list);
+ LOAD(get_device_descriptor); LOAD(open); LOAD(close);
+ LOAD(get_configuration); LOAD(set_configuration);
+ LOAD(claim_interface); LOAD(release_interface); LOAD(bulk_transfer);
+#undef LOAD
+ return port;
+failed:
+ speech_usb_close(port);
+ return NULL;
+}
+
+int speech_usb_available(void) {
+ SpeechUSB *port = load_usb();
+ if (!port) return 0;
+ speech_usb_close(port);
+ return 1;
+}
+
+SpeechUSB *speech_usb_open(int *status) {
+ *status = -100;
+ SpeechUSB *port = load_usb();
+ if (!port) return NULL;
+ *status = port->init(&port->context);
+ if (*status < 0) goto failed;
+ USBDevice **devices = NULL;
+ ssize_t count = port->get_device_list(port->context, &devices);
+ if (count < 0) { *status = (int)count; goto failed; }
+ USBDevice *selected = NULL;
+ int matches = 0;
+ for (ssize_t index = 0; index < count; index++) {
+ struct USBDescriptor descriptor;
+ if (port->get_device_descriptor(devices[index], &descriptor) == 0 &&
+ descriptor.vendor == 0x0dd0 && descriptor.product == 0x2001) {
+ selected = devices[index];
+ matches++;
+ }
+ }
+ *status = matches == 0 ? -4 : -101;
+ if (matches == 1) *status = port->open(selected, &port->handle);
+ port->free_device_list(devices, 1);
+ if (*status < 0) goto failed;
+ int configuration = 0;
+ *status = port->get_configuration(port->handle, &configuration);
+ if (*status < 0) goto failed;
+ if (configuration == 0) {
+ *status = port->set_configuration(port->handle, 1);
+ if (*status < 0) goto failed;
+ }
+ *status = port->claim_interface(port->handle, 0);
+ if (*status < 0) goto failed;
+ port->claimed = 1;
+ return port;
+failed:
+ speech_usb_close(port);
+ return NULL;
+}
+
+int speech_usb_write(SpeechUSB *port, unsigned char endpoint, void *data, int length) {
+ int transferred = 0;
+ int result = port->bulk_transfer(port->handle, endpoint, data, length, &transferred, 1000);
+ // Never replay a partial failed transfer: it may already be on the wire.
+ if (result < 0) return result;
+ return transferred == length ? 0 : -102;
+}
diff --git a/internal/speech/hardware_native.h b/internal/speech/hardware_native.h
new file mode 100644
index 00000000..aca116a4
--- /dev/null
+++ b/internal/speech/hardware_native.h
@@ -0,0 +1,18 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+#ifndef FENRIR_SPEECH_HARDWARE_H
+#define FENRIR_SPEECH_HARDWARE_H
+#include
+
+typedef struct SpeechSerial SpeechSerial;
+SpeechSerial *speech_serial_open(const char *path, int baud, int xon, int *status);
+int speech_serial_write(SpeechSerial *port, const void *data, size_t length);
+int speech_serial_cancel(SpeechSerial *port, unsigned char command);
+void speech_serial_close(SpeechSerial *port);
+
+typedef struct SpeechUSB SpeechUSB;
+int speech_usb_available(void);
+SpeechUSB *speech_usb_open(int *status);
+int speech_usb_write(SpeechUSB *port, unsigned char endpoint, void *data, int length);
+void speech_usb_close(SpeechUSB *port);
+#endif
diff --git a/internal/speech/hardware_preview.go b/internal/speech/hardware_preview.go
new file mode 100644
index 00000000..39225868
--- /dev/null
+++ b/internal/speech/hardware_preview.go
@@ -0,0 +1,56 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package speech
+
+import (
+ "context"
+ "fmt"
+ "time"
+)
+
+// Test_hardware opens a temporary client; it never replaces the reader's
+// transport. Keep the connection while the device speaks: Close cancels its
+// buffer, and these devices provide no speech-completion acknowledgement.
+func Test_hardware(ctx context.Context, driverID string, options Options, text string) error {
+ driver, err := Find_driver(driverID)
+ if err != nil {
+ return err
+ }
+ if !driver.Hardware {
+ return fmt.Errorf("selected driver is not a hardware synthesizer")
+ }
+ ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
+ defer cancel()
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ client, err := Open_driver(driverID, options)
+ if err != nil {
+ return err
+ }
+ stop, stopped := make(chan struct{}), make(chan struct{})
+ go func() {
+ defer close(stopped)
+ select {
+ case <-ctx.Done():
+ _ = client.Cancel()
+ case <-stop:
+ }
+ }()
+ defer func() { close(stop); <-stopped; client.Close() }()
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ if err := client.Say(text); err != nil {
+ return err
+ }
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ <-ctx.Done()
+ if ctx.Err() == context.DeadlineExceeded {
+ return nil
+ }
+ return ctx.Err()
+}
diff --git a/internal/speech/hardware_test.go b/internal/speech/hardware_test.go
new file mode 100644
index 00000000..9560568a
--- /dev/null
+++ b/internal/speech/hardware_test.go
@@ -0,0 +1,160 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package speech
+
+import (
+ "errors"
+ "reflect"
+ "strings"
+ "testing"
+)
+
+type recordingHardware struct {
+ writes []string
+ cancels []string
+ closed int
+ fail error
+}
+
+func (p *recordingHardware) Write(data []byte, canceled func() bool) error {
+ if canceled() {
+ return errHardwareCanceled
+ }
+ if p.fail != nil {
+ return p.fail
+ }
+ p.writes = append(p.writes, string(data))
+ return nil
+}
+func (p *recordingHardware) Cancel(data []byte) error {
+ p.cancels = append(p.cancels, string(data))
+ return nil
+}
+func (p *recordingHardware) Close() { p.closed++ }
+
+func TestHardwareProtocolsMatchPythonFixtures(t *testing.T) {
+ for _, id := range []string{"dectalk", "dectalk-usb", "litetalk", "doubletalk", "tripletalk"} {
+ t.Run(id, func(t *testing.T) {
+ port := &recordingHardware{}
+ client := new_hardware_client(id, Options{}, port)
+ parameters := Parameters{Rate: 100, Pitch: -100, Volume: 0}
+ if err := client.Configure(parameters); err != nil {
+ t.Fatal(err)
+ }
+ if err := client.Configure(parameters); err != nil {
+ t.Fatal(err)
+ }
+ if err := client.Say("Hello\nworld ā [:dv g5 10]\x01"); err != nil {
+ t.Fatal(err)
+ }
+ want := []string{"\x019S", "\x010P", "\x014V", "Hello world [:dv g5 10] \r"}
+ cancel := "\x18"
+ if strings.HasPrefix(id, "dectalk") {
+ want = []string{"[:ra 650]", "[:dv ap 50]", "[:dv g5 73]", "Hello world [ :dv g5 10] \x0b"}
+ cancel = "\x03"
+ if id == "dectalk-usb" {
+ cancel = "\x06"
+ }
+ }
+ if !reflect.DeepEqual(port.writes, want) {
+ t.Fatalf("got %q, want %q", port.writes, want)
+ }
+ if err := client.Cancel(); err != nil {
+ t.Fatal(err)
+ }
+ // A flush can discard partially queued settings; the next utterance
+ // must reapply them even when their values have not changed.
+ port.writes = nil
+ if err := client.Say("fresh"); err != nil {
+ t.Fatal(err)
+ }
+ if len(port.writes) != 4 || !reflect.DeepEqual(port.writes[:3], want[:3]) {
+ t.Fatal(port.writes)
+ }
+ client.Close()
+ client.Close()
+ if port.closed != 1 || !reflect.DeepEqual(port.cancels, []string{cancel, cancel}) {
+ t.Fatal(port)
+ }
+ })
+ }
+}
+
+func TestHardwareRangesAndNativeMidpoints(t *testing.T) {
+ for _, test := range []struct {
+ value int
+ dectalk, lite [3]string
+ }{
+ {-100, [3]string{"[:ra 75]", "[:dv ap 50]", "[:dv g5 60]"}, [3]string{"\x010S", "\x010P", "\x010V"}},
+ {0, [3]string{"[:ra 180]", "[:dv ap 122]", "[:dv g5 73]"}, [3]string{"\x014S", "\x0150P", "\x014V"}},
+ {100, [3]string{"[:ra 650]", "[:dv ap 350]", "[:dv g5 86]"}, [3]string{"\x019S", "\x0199P", "\x019V"}},
+ } {
+ for _, dectalk := range []bool{false, true} {
+ client := HardwareClient{dectalk: dectalk, parameters: Parameters{Rate: test.value, Pitch: test.value, Volume: test.value}}
+ want := test.lite
+ if dectalk {
+ want = test.dectalk
+ }
+ if got := client.commands(); got != want {
+ t.Fatal(test.value, got, want)
+ }
+ }
+ }
+}
+
+func TestHardwareFailedSettingsAreRetriedAndPitchRestores(t *testing.T) {
+ port := &recordingHardware{fail: errors.New("disconnected")}
+ client := new_hardware_client("dectalk", Options{}, port)
+ if err := client.Configure(Parameters{}); err == nil {
+ t.Fatal("failed write accepted")
+ }
+ port.fail = nil
+ if err := client.Configure(Parameters{}); err != nil {
+ t.Fatal(err)
+ }
+ if err := client.Set_pitch(100); err != nil {
+ t.Fatal(err)
+ }
+ if err := client.Say("A"); err != nil {
+ t.Fatal(err)
+ }
+ if err := client.Set_pitch(0); err != nil {
+ t.Fatal(err)
+ }
+ want := []string{"[:ra 180]", "[:dv ap 122]", "[:dv g5 73]", "[:dv ap 350]", "A\x0b", "[:dv ap 122]"}
+ if !reflect.DeepEqual(port.writes, want) {
+ t.Fatal(port.writes)
+ }
+ if err := client.Configure(Parameters{Rate: 101}); err == nil {
+ t.Fatal("invalid rate accepted")
+ }
+ if err := client.Set_pitch(-101); err == nil {
+ t.Fatal("invalid pitch accepted")
+ }
+}
+
+func TestHardwareValidationDoesNotOpenDevices(t *testing.T) {
+ for _, id := range []string{"dectalk", "litetalk", "doubletalk", "tripletalk"} {
+ options := Options{Hardware: HardwareOptions{Device: "/dev/fixture-synth"}}
+ if err := Validate_driver_options(id, options); err != nil {
+ t.Fatal(id, err)
+ }
+ for _, path := range []string{"", "auto", "ttyUSB0", "/dev/ttyUSB0\n"} {
+ options.Hardware.Device = path
+ if err := Validate_driver_options(id, options); err == nil {
+ t.Fatal(id, path)
+ }
+ }
+ options.Hardware = HardwareOptions{Device: "/dev/fixture-synth", BaudRate: 12345}
+ if err := Validate_driver_options(id, options); err == nil {
+ t.Fatal("unsupported baud accepted")
+ }
+ }
+ if err := Validate_driver_options("dectalk-usb", Options{}); err != nil {
+ t.Fatal(err)
+ }
+ if err := Validate_driver_options("dectalk-usb", Options{Voice: "invented"}); err == nil {
+ t.Fatal("hardware voice accepted")
+ }
+}
diff --git a/internal/speech/hardware_usb_native_test.go b/internal/speech/hardware_usb_native_test.go
new file mode 100644
index 00000000..ee0d0ebf
--- /dev/null
+++ b/internal/speech/hardware_usb_native_test.go
@@ -0,0 +1,87 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+//go:build linux && cgo
+
+package speech
+
+import (
+ "encoding/hex"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestNativeUSBUsesOptionalABIAndReleasesResources(t *testing.T) {
+ root := t.TempDir()
+ library := filepath.Join(root, "libusb-1.0.so.0")
+ if output, err := exec.Command("cc", "-shared", "-fPIC", "-Wall", "-Wextra", "-Werror", "-o", library, "testdata/usb_fixture.c").CombinedOutput(); err != nil {
+ t.Fatalf("build USB fixture: %v: %s", err, output)
+ }
+ for _, mode := range []string{"ok", "unconfigured", "missing", "multiple", "denied", "busy", "short", "timeout"} {
+ t.Run(mode, func(t *testing.T) {
+ log := filepath.Join(t.TempDir(), "usb.log")
+ cmd := exec.Command(os.Args[0], "-test.run=^TestNativeUSBFixtureProcess$")
+ cmd.Env = append(os.Environ(), "LD_LIBRARY_PATH="+root, "FENRIR_USB_FIXTURE=1", "USB_FIXTURE_MODE="+mode, "USB_FIXTURE_LOG="+log)
+ if output, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("fixture process: %v: %s", err, output)
+ }
+ data, err := os.ReadFile(log)
+ if err != nil {
+ t.Fatal(err)
+ }
+ text := string(data)
+ if !strings.HasPrefix(text, "init\nlist\n") || !strings.Contains(text, "free-list\n") || !strings.HasSuffix(text, "exit\n") {
+ t.Fatal("USB cleanup", text)
+ }
+ opened := mode != "missing" && mode != "multiple" && mode != "denied"
+ if strings.Contains(text, "\nclose\n") != opened {
+ t.Fatal("handle cleanup", text)
+ }
+ claimed := opened && mode != "busy"
+ if strings.Contains(text, "\nrelease\n") != claimed {
+ t.Fatal("interface cleanup", text)
+ }
+ if mode == "ok" || mode == "unconfigured" {
+ for _, want := range []string{"write-02:" + hex.EncodeToString([]byte("[:timeout 1]")), "write-02:" + hex.EncodeToString([]byte("[:ra 180]")), "write-02:" + hex.EncodeToString([]byte("hello\x0b")), "write-04:06"} {
+ if !strings.Contains(text, want+"\n") {
+ t.Fatal("missing transfer", want, text)
+ }
+ }
+ }
+ if strings.Contains(text, "set-configuration\n") != (mode == "unconfigured") {
+ t.Fatal("unexpected USB configuration change", text)
+ }
+ })
+ }
+}
+
+func TestNativeUSBFixtureProcess(t *testing.T) {
+ if os.Getenv("FENRIR_USB_FIXTURE") != "1" {
+ return
+ }
+ transport, err := Open_driver("dectalk-usb", Options{})
+ mode := os.Getenv("USB_FIXTURE_MODE")
+ if mode != "ok" && mode != "unconfigured" {
+ if err == nil {
+ transport.Close()
+ t.Fatal("USB failure accepted", mode)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer transport.Close()
+ if err := transport.Configure(Parameters{}); err != nil {
+ t.Fatal(err)
+ }
+ if err := transport.Say("hello"); err != nil {
+ t.Fatal(err)
+ }
+ if err := transport.Cancel(); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/internal/speech/latency_test.go b/internal/speech/latency_test.go
new file mode 100644
index 00000000..23c56675
--- /dev/null
+++ b/internal/speech/latency_test.go
@@ -0,0 +1,72 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package speech
+
+import (
+ "bytes"
+ "log/slog"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestTimingDiagnosticsExcludeSpeechText(t *testing.T) {
+ var output bytes.Buffer
+ previousLogger := slog.Default()
+ slog.SetDefault(slog.New(slog.NewTextHandler(&output, &slog.HandlerOptions{Level: slog.LevelDebug})))
+ defer slog.SetDefault(previousLogger)
+ fake := new_recording_transport()
+ s := New(fake)
+ defer s.Close()
+ const privateText = "private speech fixture"
+ s.Interrupt()
+ s.Say(privateText)
+ wait_event(t, fake.changed, "sent:"+privateText)
+ s.Close()
+ if strings.Contains(output.String(), privateText) {
+ t.Fatal("timing diagnostics included speech text")
+ }
+ for _, stage := range []string{"speech queue wait", "speech send", "speech cancel barrier"} {
+ if !strings.Contains(output.String(), stage) {
+ t.Fatalf("missing timing stage %q", stage)
+ }
+ }
+}
+
+type latencyTransport struct {
+ sent chan struct{}
+}
+
+func (f *latencyTransport) Say(string) error { f.sent <- struct{}{}; return nil }
+func (f *latencyTransport) Cancel() error { time.Sleep(time.Millisecond); return nil }
+func (f *latencyTransport) Close() {}
+func (f *latencyTransport) Set_pitch(int) error { return nil }
+func (f *latencyTransport) Configure(Parameters) error { return nil }
+
+// Model a fixed acknowledgement cost to expose unnecessary cancellation waits.
+// This measures admission/transport scheduling, not audible speech latency.
+func BenchmarkIdleInterruptToSpeech(b *testing.B) {
+ fake := &latencyTransport{sent: make(chan struct{})}
+ s := New(fake)
+ defer s.Close()
+ for b.Loop() {
+ s.Interrupt()
+ s.Say("response")
+ <-fake.sent
+ }
+}
diff --git a/internal/speech/replacement_test.go b/internal/speech/replacement_test.go
new file mode 100644
index 00000000..531a8b01
--- /dev/null
+++ b/internal/speech/replacement_test.go
@@ -0,0 +1,104 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package speech
+
+import (
+ "errors"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+type replacementTransport struct {
+ *recordingTransport
+ closed atomic.Bool
+ configureErr error
+ configured chan struct{}
+ resume chan struct{}
+}
+
+func (f *replacementTransport) Close() { f.closed.Store(true); f.recordingTransport.Close() }
+func (f *replacementTransport) Configure(p Parameters) error {
+ if f.configured != nil {
+ close(f.configured)
+ <-f.resume
+ }
+ if f.configureErr != nil {
+ return f.configureErr
+ }
+ return f.recordingTransport.Configure(p)
+}
+
+func TestReplacementPreservesMuteParametersAndFailureRecovery(t *testing.T) {
+ old := &replacementTransport{recordingTransport: new_recording_transport()}
+ service := New(old)
+ defer service.Close()
+ service.Configure(Parameters{Rate: 55, Pitch: 12, Volume: 80})
+ wait_event(t, old.changed, "configure:55:12:80")
+ bad := &replacementTransport{recordingTransport: new_recording_transport(), configureErr: errors.New("fixture refusal")}
+ if err := service.Replace(bad); err == nil || !bad.closed.Load() || old.closed.Load() {
+ t.Fatal("failed replacement changed active client", err)
+ }
+ service.Say("old survives")
+ wait_event(t, old.changed, "sent:old survives")
+ service.Toggle_mute()
+ service.Say("discarded")
+ next := &replacementTransport{recordingTransport: new_recording_transport()}
+ if err := service.Replace(next); err != nil {
+ t.Fatal(err)
+ }
+ if !old.closed.Load() {
+ t.Fatal("old client not released")
+ }
+ wait_event(t, next.changed, "configure:55:12:80")
+ service.Toggle_mute()
+ service.Say_with_pitch("E", 70)
+ service.Say("new feedback")
+ wait_event(t, next.changed, "sent:new feedback")
+ next.mu.Lock()
+ defer next.mu.Unlock()
+ for _, event := range next.events {
+ if event == "say:discarded" {
+ t.Fatal("muted backlog replayed")
+ }
+ }
+}
+
+func TestShutdownDuringReplacementClosesBothClients(t *testing.T) {
+ old := &replacementTransport{recordingTransport: new_recording_transport()}
+ service := New(old)
+ service.Configure(Parameters{Volume: 100})
+ wait_event(t, old.changed, "configure:0:0:100")
+ next := &replacementTransport{recordingTransport: new_recording_transport(), configured: make(chan struct{}), resume: make(chan struct{})}
+ done := make(chan error, 1)
+ go func() { done <- service.Replace(next) }()
+ <-next.configured
+ closed := make(chan struct{})
+ go func() { service.Close(); close(closed) }()
+ // Observe shutdown admission before releasing the in-flight candidate.
+ deadline := time.After(time.Second)
+ for {
+ service.mu.Lock()
+ closing := service.closed
+ service.mu.Unlock()
+ if closing {
+ break
+ }
+ select {
+ case <-deadline:
+ t.Fatal("shutdown not admitted")
+ default:
+ time.Sleep(time.Millisecond)
+ }
+ }
+ close(next.resume)
+ select {
+ case <-closed:
+ case <-time.After(time.Second):
+ t.Fatal("shutdown stalled")
+ }
+ if err := <-done; err == nil || !old.closed.Load() || !next.closed.Load() {
+ t.Fatal("candidate leaked", err, old.closed.Load(), next.closed.Load())
+ }
+}
diff --git a/internal/speech/service.go b/internal/speech/service.go
new file mode 100644
index 00000000..596cc3c0
--- /dev/null
+++ b/internal/speech/service.go
@@ -0,0 +1,370 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package speech
+
+import (
+ "errors"
+ "strings"
+ "sync"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/diagnostic"
+)
+
+type Transport interface {
+ // Say receives prepared text; backends must disable additional punctuation
+ // naming so the shared punctuation processor's names are spoken once.
+ Say(string) error
+ Configure(Parameters) error
+ Set_pitch(int) error
+ Cancel() error
+ Close()
+}
+
+type Service struct {
+ transport Transport
+ transportMu sync.RWMutex
+ replacements []replacement
+ mu sync.Mutex
+ dispatch sync.Mutex
+ queue []utterance
+ parameters *Parameters
+ generation, canceled uint64
+ muted, closed bool
+ wake, cancelWake chan struct{}
+ done chan struct{}
+ errors chan error
+ workers sync.WaitGroup
+}
+
+type replacement struct {
+ transport Transport
+ done chan error
+}
+
+// Replace commits an already prepared client on the serialized speech worker.
+// Mute, cancellation generations and pending numeric adjustments survive.
+// Ownership transfers to the service even if shutdown races the request.
+func (s *Service) Replace(transport Transport) error {
+ request := replacement{transport: transport, done: make(chan error, 1)}
+ s.mu.Lock()
+ if s.closed {
+ s.mu.Unlock()
+ transport.Close()
+ return errors.New("speech service closed")
+ }
+ s.replacements = append(s.replacements, request)
+ signal(s.wake)
+ s.mu.Unlock()
+ return <-request.done
+}
+
+type utterance struct {
+ text string
+ queuedAt time.Time
+ pitch *int
+ parameters *Parameters
+}
+
+func New(transport Transport) *Service {
+ s := &Service{transport: transport, wake: make(chan struct{}, 1), cancelWake: make(chan struct{}, 1), done: make(chan struct{}), errors: make(chan error, 1)}
+ s.workers.Add(2)
+ go s.deliver()
+ go s.cancel()
+ return s
+}
+
+func signal(wake chan struct{}) {
+ select {
+ case wake <- struct{}{}:
+ default:
+ }
+}
+
+func (s *Service) Errors() <-chan error { return s.errors }
+
+// Configure coalesces adjustments without dropping them on speech interruption
+// or mute. Delivery applies the latest values before admitting more speech.
+func (s *Service) Configure(parameters Parameters) {
+ if err := parameters.Validate(); err != nil {
+ s.fail(err)
+ return
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if !s.closed {
+ s.parameters = ¶meters
+ signal(s.wake)
+ }
+}
+
+func (s *Service) Say(text string) {
+ s.enqueue(utterance{text: text})
+}
+
+// Say_with_parameters previews one utterance without changing saved or live
+// defaults. The serialized worker restores its latest normal parameters.
+func (s *Service) Say_with_parameters(text string, parameters Parameters) {
+ if err := parameters.Validate(); err != nil {
+ s.fail(err)
+ return
+ }
+ s.enqueue(utterance{text: text, parameters: ¶meters})
+}
+
+// Say_with_pitch scopes the override and restoration to one queued request.
+// Interruption discards both together; configuration never leaks into later speech.
+func (s *Service) Say_with_pitch(text string, pitch int) {
+ if pitch < -100 || pitch > 100 {
+ s.fail(errors.New("speech pitch must be between -100 and 100"))
+ return
+ }
+ s.enqueue(utterance{text: text, pitch: &pitch})
+}
+
+func (s *Service) enqueue(item utterance) {
+ if strings.TrimSpace(item.text) == "" {
+ return
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.closed || s.muted {
+ return
+ }
+ item.queuedAt = time.Now()
+ s.queue = append(s.queue, item)
+ signal(s.wake)
+}
+
+func (s *Service) Interrupt() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.closed {
+ return
+ }
+ s.interrupt()
+}
+
+func (s *Service) interrupt() {
+ clear(s.queue)
+ s.queue = nil
+ s.generation++
+ signal(s.cancelWake)
+}
+
+func (s *Service) Toggle_mute() bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.closed {
+ return s.muted
+ }
+ s.muted = !s.muted
+ if s.muted {
+ s.interrupt()
+ }
+ return s.muted
+}
+
+func (s *Service) fail(err error) {
+ if err != nil {
+ select {
+ case s.errors <- err:
+ default:
+ }
+ }
+}
+
+func (s *Service) deliver() {
+ defer s.workers.Done()
+ normalPitch := 0
+ var currentParameters *Parameters
+ for {
+ select {
+ case <-s.done:
+ return
+ case <-s.wake:
+ }
+ for {
+ s.mu.Lock()
+ if !s.closed && s.generation == s.canceled && s.parameters != nil {
+ parameters := *s.parameters
+ currentParameters = ¶meters
+ normalPitch = parameters.Pitch
+ s.parameters = nil
+ s.dispatch.Lock()
+ s.mu.Unlock()
+ err := s.transport.Configure(parameters)
+ s.dispatch.Unlock()
+ if err != nil {
+ s.fail(err)
+ return
+ }
+ continue
+ }
+ if !s.closed && s.generation == s.canceled && len(s.replacements) > 0 {
+ request := s.replacements[0]
+ s.replacements = s.replacements[1:]
+ s.dispatch.Lock()
+ s.mu.Unlock()
+ var err error
+ if currentParameters != nil {
+ err = request.transport.Configure(*currentParameters)
+ }
+ s.mu.Lock()
+ if s.closed {
+ err = errors.New("speech service closed")
+ }
+ if err == nil {
+ s.transportMu.Lock()
+ old := s.transport
+ s.transport = request.transport
+ s.transportMu.Unlock()
+ s.mu.Unlock()
+ old.Close()
+ } else {
+ s.mu.Unlock()
+ request.transport.Close()
+ }
+ s.dispatch.Unlock()
+ request.done <- err
+ continue
+ }
+ if s.closed || s.muted || s.generation != s.canceled || len(s.queue) == 0 {
+ s.mu.Unlock()
+ break
+ }
+ item := s.queue[0]
+ s.queue[0] = utterance{}
+ s.queue = s.queue[1:]
+ // Claim dispatch before releasing admission state. An interrupt
+ // must account for this send even if it hasn't reached the socket.
+ s.dispatch.Lock()
+ generation := s.generation
+ s.mu.Unlock()
+ diagnostic.Timing("speech queue wait", item.queuedAt, "generation", generation)
+ started := time.Now()
+ err := s.say(item, normalPitch, currentParameters)
+ diagnostic.Timing("speech send", started, "generation", generation)
+ s.dispatch.Unlock()
+ if err != nil {
+ s.fail(err)
+ return
+ }
+ }
+ }
+}
+
+func (s *Service) say(item utterance, normalPitch int, normalParameters *Parameters) error {
+ if item.parameters != nil {
+ if normalParameters == nil {
+ return errors.New("speech parameters unavailable for preview")
+ }
+ if err := s.transport.Configure(*item.parameters); err != nil {
+ return errors.Join(err, s.transport.Configure(*normalParameters))
+ }
+ err := s.transport.Say(item.text)
+ return errors.Join(err, s.transport.Configure(*normalParameters))
+ }
+ if item.pitch == nil {
+ return s.transport.Say(item.text)
+ }
+ if err := s.transport.Set_pitch(*item.pitch); err != nil {
+ return err
+ }
+ err := s.transport.Say(item.text)
+ return errors.Join(err, s.transport.Set_pitch(normalPitch))
+}
+
+func (s *Service) cancel_transport() error {
+ s.transportMu.RLock()
+ defer s.transportMu.RUnlock()
+ return s.transport.Cancel()
+}
+
+func (s *Service) cancel() {
+ defer s.workers.Done()
+ for {
+ select {
+ case <-s.done:
+ return
+ case <-s.cancelWake:
+ }
+ for {
+ s.mu.Lock()
+ generation := s.generation
+ closed := s.closed
+ settled := s.canceled == generation
+ s.mu.Unlock()
+ if closed {
+ return
+ }
+ if settled {
+ break
+ }
+ started := time.Now()
+ if !s.dispatch.TryLock() {
+ // Stop audible speech even while a stale send is blocked. A
+ // second cancel after that send finishes closes the late-send race.
+ if err := s.cancel_transport(); err != nil {
+ s.fail(err)
+ return
+ }
+ s.dispatch.Lock()
+ }
+ // With dispatch held, no send can race this cancel. The usual
+ // idle-send path needs only this one acknowledgement.
+ err := s.cancel_transport()
+ diagnostic.Timing("speech cancel barrier", started, "generation", generation)
+ if err != nil {
+ s.dispatch.Unlock()
+ s.fail(err)
+ return
+ }
+ s.mu.Lock()
+ // Every intervening interrupt is covered: fresh sends have been
+ // gated throughout. Coalesce them instead of canceling again.
+ s.canceled = s.generation
+ s.mu.Unlock()
+ s.dispatch.Unlock()
+ signal(s.wake)
+ break
+ }
+ }
+}
+
+func (s *Service) Close() {
+ s.mu.Lock()
+ if s.closed {
+ s.mu.Unlock()
+ return
+ }
+ s.closed = true
+ s.queue = nil
+ close(s.done)
+ pending := s.replacements
+ s.replacements = nil
+ s.mu.Unlock()
+ for _, request := range pending {
+ request.transport.Close()
+ request.done <- errors.New("speech service closed")
+ }
+ s.transportMu.RLock()
+ s.transport.Close()
+ s.transportMu.RUnlock()
+ s.workers.Wait()
+}
diff --git a/internal/speech/service_test.go b/internal/speech/service_test.go
new file mode 100644
index 00000000..bba81fbd
--- /dev/null
+++ b/internal/speech/service_test.go
@@ -0,0 +1,319 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package speech
+
+import (
+ "fmt"
+ "reflect"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+type recordingTransport struct {
+ mu sync.Mutex
+ events []string
+ changed chan string
+ release chan struct{}
+ once sync.Once
+}
+
+func new_recording_transport() *recordingTransport {
+ return &recordingTransport{changed: make(chan string, 64), release: make(chan struct{})}
+}
+
+func (f *recordingTransport) record(event string) {
+ f.mu.Lock()
+ f.events = append(f.events, event)
+ f.mu.Unlock()
+ f.changed <- event
+}
+
+func (f *recordingTransport) Say(text string) error {
+ f.record("say:" + text)
+ if text == "old" {
+ <-f.release
+ }
+ f.record("sent:" + text)
+ return nil
+}
+
+func (f *recordingTransport) Set_pitch(pitch int) error {
+ f.record(fmt.Sprintf("pitch:%d", pitch))
+ return nil
+}
+
+func (f *recordingTransport) Cancel() error { f.record("cancel"); return nil }
+func (f *recordingTransport) Configure(parameters Parameters) error {
+ f.record(fmt.Sprintf("configure:%d:%d:%d", parameters.Rate, parameters.Pitch, parameters.Volume))
+ return nil
+}
+func (f *recordingTransport) unblock() { f.once.Do(func() { close(f.release) }) }
+func (f *recordingTransport) Close() { f.unblock() }
+
+func TestConfigurationSurvivesInterruptAndMuteBeforeFeedback(t *testing.T) {
+ fake := new_recording_transport()
+ service := New(fake)
+ defer service.Close()
+ service.Say("old")
+ wait_event(t, fake.changed, "say:old")
+ service.Configure(Parameters{Rate: 70, Pitch: 10, Volume: 90})
+ service.Interrupt()
+ service.Configure(Parameters{Rate: 90, Pitch: 20, Volume: 80})
+ service.Say("new feedback")
+ fake.unblock()
+ wait_event(t, fake.changed, "sent:new feedback")
+ fake.mu.Lock()
+ events := append([]string(nil), fake.events...)
+ fake.mu.Unlock()
+ configured, spoken := -1, -1
+ for index, event := range events {
+ if event == "configure:90:20:80" {
+ configured = index
+ }
+ if event == "say:new feedback" {
+ spoken = index
+ }
+ }
+ if configured < 0 || configured >= spoken {
+ t.Fatal(events)
+ }
+ service.Toggle_mute()
+ service.Configure(Parameters{Rate: 100, Pitch: 0, Volume: 100})
+ wait_event(t, fake.changed, "configure:100:0:100")
+ service.Toggle_mute()
+ service.Say("unmuted feedback")
+ wait_event(t, fake.changed, "sent:unmuted feedback")
+}
+
+func TestParameterPreviewRestoresNormalParametersBeforeLaterSpeech(t *testing.T) {
+ fake := new_recording_transport()
+ service := New(fake)
+ defer service.Close()
+ service.Configure(Parameters{Rate: 40, Pitch: 10, Volume: 90})
+ service.Say_with_parameters("preview", Parameters{Rate: -20, Pitch: 30, Volume: 50})
+ service.Say("normal")
+ wait_event(t, fake.changed, "sent:normal")
+ fake.mu.Lock()
+ defer fake.mu.Unlock()
+ want := []string{"configure:40:10:90", "configure:-20:30:50", "say:preview", "sent:preview", "configure:40:10:90", "say:normal", "sent:normal"}
+ if !reflect.DeepEqual(fake.events, want) {
+ t.Fatal(fake.events)
+ }
+}
+
+func TestCapitalPitchRestoresBeforeFollowingSpeech(t *testing.T) {
+ fake := new_recording_transport()
+ service := New(fake)
+ defer service.Close()
+ service.Configure(Parameters{Rate: 80, Pitch: 10, Volume: 100})
+ service.Say_with_pitch("E", 60)
+ service.Say("e")
+ wait_event(t, fake.changed, "sent:e")
+ fake.mu.Lock()
+ defer fake.mu.Unlock()
+ want := []string{"configure:80:10:100", "pitch:60", "say:E", "sent:E", "pitch:10", "say:e", "sent:e"}
+ if !reflect.DeepEqual(fake.events, want) {
+ t.Fatal(fake.events)
+ }
+}
+
+func TestInterruptedCapitalDoesNotLeakPitchOrReplayQueuedCapital(t *testing.T) {
+ fake := new_recording_transport()
+ service := New(fake)
+ defer service.Close()
+ service.Configure(Parameters{Rate: 80, Pitch: -10, Volume: 100})
+ service.Say_with_pitch("old", 60)
+ wait_event(t, fake.changed, "say:old")
+ service.Say_with_pitch("discarded", 70)
+ service.Interrupt()
+ service.Configure(Parameters{Rate: 90, Pitch: 20, Volume: 100})
+ service.Say_with_pitch("E", 80)
+ service.Say("fresh")
+ wait_event(t, fake.changed, "cancel")
+ fake.unblock()
+ wait_event(t, fake.changed, "sent:fresh")
+ fake.mu.Lock()
+ defer fake.mu.Unlock()
+ joined := strings.Join(fake.events, "|")
+ if strings.Contains(joined, "discarded") || !strings.Contains(joined, "pitch:80|say:E|sent:E|pitch:20|say:fresh") {
+ t.Fatal(fake.events)
+ }
+}
+
+func wait_event(t *testing.T, events <-chan string, expected string) {
+ t.Helper()
+ timeout := time.NewTimer(2 * time.Second)
+ defer timeout.Stop()
+ for {
+ select {
+ case event := <-events:
+ if event == expected {
+ return
+ }
+ case <-timeout.C:
+ t.Fatalf("timed out waiting for %q", expected)
+ }
+ }
+}
+
+func TestPassiveSpeechIsFIFOWithoutCancellation(t *testing.T) {
+ fake := new_recording_transport()
+ s := New(fake)
+ defer s.Close()
+ s.Say("old")
+ wait_event(t, fake.changed, "say:old")
+ s.Say("second")
+ s.Say("third")
+ fake.unblock()
+ wait_event(t, fake.changed, "sent:third")
+ fake.mu.Lock()
+ defer fake.mu.Unlock()
+ if want := []string{"say:old", "sent:old", "say:second", "sent:second", "say:third", "sent:third"}; !reflect.DeepEqual(fake.events, want) {
+ t.Fatalf("got %v", fake.events)
+ }
+}
+
+func TestIdleInterruptNeedsOnlyOneCancellation(t *testing.T) {
+ fake := new_recording_transport()
+ s := New(fake)
+ defer s.Close()
+ s.Interrupt()
+ s.Say("fresh")
+ wait_event(t, fake.changed, "sent:fresh")
+ fake.mu.Lock()
+ defer fake.mu.Unlock()
+ want := []string{"cancel", "say:fresh", "sent:fresh"}
+ if !reflect.DeepEqual(fake.events, want) {
+ t.Fatalf("idle interruption performed extra work: %v", fake.events)
+ }
+}
+
+func TestInterruptCancelsBlockedSendAndGatesFreshSpeech(t *testing.T) {
+ fake := new_recording_transport()
+ s := New(fake)
+ defer s.Close()
+ s.Say("old")
+ wait_event(t, fake.changed, "say:old")
+ s.Say("discarded")
+ s.Interrupt()
+ s.Say("fresh")
+ // This must arrive while Say is blocked, before its acknowledgement.
+ wait_event(t, fake.changed, "cancel")
+ fake.unblock()
+ wait_event(t, fake.changed, "sent:fresh")
+ fake.mu.Lock()
+ defer fake.mu.Unlock()
+ want := []string{"say:old", "cancel", "sent:old", "cancel", "say:fresh", "sent:fresh"}
+ if !reflect.DeepEqual(fake.events, want) {
+ t.Fatalf("generation barrier failed: %v", fake.events)
+ }
+}
+
+func TestMuteDiscardsSuppressedSpeechAndUnmuteDoesNotReplay(t *testing.T) {
+ fake := new_recording_transport()
+ s := New(fake)
+ defer s.Close()
+ s.Say("old")
+ wait_event(t, fake.changed, "say:old")
+ s.Say("discarded")
+ if !s.Toggle_mute() {
+ t.Fatal("mute failed")
+ }
+ s.Say("suppressed")
+ if s.Toggle_mute() {
+ t.Fatal("unmute failed")
+ }
+ s.Say("fresh")
+ fake.unblock()
+ wait_event(t, fake.changed, "sent:fresh")
+ fake.mu.Lock()
+ defer fake.mu.Unlock()
+ for _, event := range fake.events {
+ if strings.Contains(event, "discarded") || strings.Contains(event, "suppressed") {
+ t.Fatalf("muted speech replayed: %v", fake.events)
+ }
+ }
+}
+
+type gatedCancelTransport struct {
+ *recordingTransport
+ gate chan struct{}
+ gateOnce sync.Once
+ calls atomic.Int32
+}
+
+func (f *gatedCancelTransport) Cancel() error {
+ call := f.calls.Add(1)
+ f.record(fmt.Sprintf("cancel:%d", call))
+ if call == 1 {
+ <-f.gate
+ }
+ return nil
+}
+
+func (f *gatedCancelTransport) open_gate() { f.gateOnce.Do(func() { close(f.gate) }) }
+func (f *gatedCancelTransport) Close() { f.open_gate(); f.recordingTransport.Close() }
+
+func TestRapidInterruptsDoNotRecancelSettledGeneration(t *testing.T) {
+ fake := &gatedCancelTransport{recordingTransport: new_recording_transport(), gate: make(chan struct{})}
+ s := New(fake)
+ defer s.Close()
+ s.Say("old")
+ wait_event(t, fake.changed, "say:old")
+ s.Interrupt()
+ wait_event(t, fake.changed, "cancel:1")
+ s.Interrupt() // Leaves a wake token while cancellation is already running.
+ s.Say("fresh")
+ fake.open_gate()
+ fake.unblock()
+ wait_event(t, fake.changed, "sent:fresh")
+ s.Close()
+ if got := fake.calls.Load(); got != 2 {
+ t.Fatalf("intervening interrupts should share the blocked-send barrier, got %d cancels", got)
+ }
+}
+
+func TestInterruptsDuringIdleCancelShareOneBarrier(t *testing.T) {
+ fake := &gatedCancelTransport{recordingTransport: new_recording_transport(), gate: make(chan struct{})}
+ s := New(fake)
+ defer s.Close()
+ s.Interrupt()
+ wait_event(t, fake.changed, "cancel:1")
+ for range 20 {
+ s.Interrupt()
+ s.Say("discarded")
+ }
+ s.Interrupt()
+ s.Say("fresh")
+ fake.open_gate()
+ wait_event(t, fake.changed, "sent:fresh")
+ s.Close()
+ if got := fake.calls.Load(); got != 1 {
+ t.Fatalf("idle cancellation should cover intervening input, got %d calls", got)
+ }
+ fake.mu.Lock()
+ defer fake.mu.Unlock()
+ for _, event := range fake.events {
+ if strings.Contains(event, "discarded") {
+ t.Fatal("interrupted output was replayed", fake.events)
+ }
+ }
+}
diff --git a/internal/speech/testdata/usb_fixture.c b/internal/speech/testdata/usb_fixture.c
new file mode 100644
index 00000000..8f9a3862
--- /dev/null
+++ b/internal/speech/testdata/usb_fixture.c
@@ -0,0 +1,58 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Private libusb ABI fixture. It never enumerates or opens real USB devices.
+#include
+#include
+#include
+#include
+#include
+
+typedef struct { int unused; } Context;
+typedef struct { int unused; } Device;
+typedef struct { int unused; } Handle;
+typedef struct {
+ uint8_t length, type;
+ uint16_t usb;
+ uint8_t device_class, subclass, protocol, packet_size;
+ uint16_t vendor, product, device;
+ uint8_t manufacturer, product_name, serial, configurations;
+} Descriptor;
+static Context context;
+static Device device;
+static Handle handle;
+static Device *devices[] = {&device, &device, NULL};
+
+static int mode(const char *value) { return strcmp(getenv("USB_FIXTURE_MODE"), value) == 0; }
+static void record(const char *message) {
+ FILE *log = fopen(getenv("USB_FIXTURE_LOG"), "a");
+ if (!log) abort();
+ fprintf(log, "%s\n", message);
+ fclose(log);
+}
+int libusb_init(Context **result) { record("init"); *result = &context; return 0; }
+void libusb_exit(Context *value) { (void)value; record("exit"); }
+ssize_t libusb_get_device_list(Context *value, Device ***result) { (void)value; record("list"); *result = devices; return mode("multiple") ? 2 : 1; }
+void libusb_free_device_list(Device **value, int unref) { (void)value; (void)unref; record("free-list"); }
+int libusb_get_device_descriptor(Device *value, Descriptor *result) {
+ (void)value;
+ memset(result, 0, sizeof(*result));
+ result->vendor = 0x0dd0;
+ result->product = mode("missing") ? 0x1002 : 0x2001;
+ return 0;
+}
+int libusb_open(Device *value, Handle **result) { (void)value; record("open"); if (mode("denied")) return -3; *result = &handle; return 0; }
+void libusb_close(Handle *value) { (void)value; record("close"); }
+int libusb_get_configuration(Handle *value, int *result) { (void)value; record("configuration"); *result = mode("unconfigured") ? 0 : 1; return 0; }
+int libusb_set_configuration(Handle *value, int configuration) { (void)value; if (configuration != 1) abort(); record("set-configuration"); return 0; }
+int libusb_claim_interface(Handle *value, int interface) { (void)value; if (interface != 0) abort(); record("claim"); return mode("busy") ? -6 : 0; }
+int libusb_release_interface(Handle *value, int interface) { (void)value; if (interface != 0) abort(); record("release"); return 0; }
+int libusb_bulk_transfer(Handle *value, unsigned char endpoint, unsigned char *data, int length, int *transferred, unsigned int timeout) {
+ (void)value;
+ if (length > 64 || timeout != 1000) abort();
+ char message[256];
+ int offset = snprintf(message, sizeof(message), "write-%02x:", endpoint);
+ for (int index = 0; index < length; index++) offset += snprintf(message + offset, sizeof(message) - offset, "%02x", data[index]);
+ record(message);
+ *transferred = mode("short") ? length - 1 : length;
+ return mode("timeout") ? -7 : 0;
+}
diff --git a/internal/spellcheck/dictionary.go b/internal/spellcheck/dictionary.go
new file mode 100644
index 00000000..36732134
--- /dev/null
+++ b/internal/spellcheck/dictionary.go
@@ -0,0 +1,87 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+// Package spellcheck owns offline dictionary work and personal exceptions.
+package spellcheck
+
+/*
+#cgo LDFLAGS: -ldl
+#include "native.h"
+#include
+*/
+import "C"
+
+import (
+ "fmt"
+ "unsafe"
+)
+
+// Dictionary is used only by the service's worker, including Close.
+type Dictionary interface {
+ Check(string) (bool, error)
+ Suggest(string) ([]string, error)
+ Close()
+}
+
+type EnchantDictionary struct {
+ native *C.SpellDictionary
+}
+
+func open_dictionary(language string) (Dictionary, error) {
+ if language == "" {
+ return nil, fmt.Errorf("the session has no spelling language")
+ }
+ tag := C.CString(language)
+ defer C.free(unsafe.Pointer(tag))
+ var status C.int
+ native := C.spell_dictionary_open(tag, &status)
+ if native == nil {
+ switch status {
+ case 1:
+ return nil, fmt.Errorf("Enchant 2 could not be loaded")
+ case 2:
+ return nil, fmt.Errorf("Enchant 2 has an incompatible interface")
+ case 4:
+ return nil, fmt.Errorf("dictionary for %s is unavailable", language)
+ default:
+ return nil, fmt.Errorf("could not initialize Enchant")
+ }
+ }
+ return &EnchantDictionary{native: native}, nil
+}
+
+func (d *EnchantDictionary) Check(word string) (bool, error) {
+ value := C.CString(word)
+ defer C.free(unsafe.Pointer(value))
+ result := C.spell_dictionary_check(d.native, value, C.size_t(len(word)))
+ if result < 0 {
+ return false, fmt.Errorf("dictionary check failed")
+ }
+ return result == 0, nil
+}
+
+func (d *EnchantDictionary) Suggest(word string) ([]string, error) {
+ value := C.CString(word)
+ defer C.free(unsafe.Pointer(value))
+ var count C.size_t
+ list := C.spell_dictionary_suggest(d.native, value, C.size_t(len(word)), &count)
+ if list == nil {
+ return nil, nil
+ }
+ defer C.spell_dictionary_free_suggestions(d.native, list)
+ // Preserve provider ranking, with a bounded result set for the picker.
+ var suggestions []string
+ seen := make(map[string]bool)
+ for _, item := range unsafe.Slice(list, int(min(count, 128))) {
+ word := C.GoString(item)
+ if word != "" && !seen[word] {
+ seen[word] = true
+ suggestions = append(suggestions, word)
+ }
+ }
+ return suggestions, nil
+}
+
+func (d *EnchantDictionary) Close() {
+ C.spell_dictionary_close(d.native)
+}
diff --git a/internal/spellcheck/exceptions.go b/internal/spellcheck/exceptions.go
new file mode 100644
index 00000000..795227c6
--- /dev/null
+++ b/internal/spellcheck/exceptions.go
@@ -0,0 +1,147 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package spellcheck
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "syscall"
+ "time"
+)
+
+func Exceptions_path(home string) string {
+ return filepath.Join(home, ".local/share/stormux/fenrirscreenreader/spelling/exceptions.json")
+}
+
+func load_exceptions(path string) (map[string]string, error) {
+ words := make(map[string]string)
+ file, err := os.Open(path)
+ if errors.Is(err, os.ErrNotExist) {
+ return words, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
+ data, err := io.ReadAll(io.LimitReader(file, (1<<20)+1))
+ if err != nil {
+ return nil, err
+ }
+ if len(data) > 1<<20 {
+ return nil, fmt.Errorf("exceptions file exceeds 1 MiB")
+ }
+ var entries []string
+ if err := json.Unmarshal(data, &entries); err != nil {
+ return nil, err
+ }
+ for _, word := range entries {
+ if !valid_word(word) {
+ return nil, fmt.Errorf("invalid exceptions file")
+ }
+ words[strings.ToLower(word)] = word
+ }
+ return words, nil
+}
+
+func exception_list(words map[string]string) []string {
+ entries := make([]string, 0, len(words))
+ for _, word := range words {
+ entries = append(entries, word)
+ }
+ sort.Slice(entries, func(i, j int) bool { return strings.ToLower(entries[i]) < strings.ToLower(entries[j]) })
+ return entries
+}
+
+// Each edit reloads the latest list while holding a stable, per-file lock.
+// Atomic replacement alone cannot protect against another reader's stale copy.
+func update_exceptions(ctx context.Context, path string, operation Operation, word string) (map[string]string, error) {
+ if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
+ return nil, err
+ }
+ lock, err := os.OpenFile(path+".lock", os.O_CREATE|os.O_RDWR|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0600)
+ if err != nil {
+ return nil, err
+ }
+ defer lock.Close()
+ info, err := lock.Stat()
+ if err != nil {
+ return nil, err
+ }
+ if !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 || info.Sys().(*syscall.Stat_t).Uid != uint32(os.Geteuid()) {
+ return nil, fmt.Errorf("exceptions lock must be private and owned by the current account")
+ }
+ waitCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
+ defer cancel()
+ for {
+ if err := waitCtx.Err(); err != nil {
+ return nil, err
+ }
+ err = syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
+ if err == nil {
+ break
+ }
+ if !errors.Is(err, syscall.EWOULDBLOCK) {
+ return nil, err
+ }
+ timer := time.NewTimer(10 * time.Millisecond)
+ select {
+ case <-waitCtx.Done():
+ timer.Stop()
+ return nil, waitCtx.Err()
+ case <-timer.C:
+ }
+ }
+ words, err := load_exceptions(path)
+ if err != nil {
+ return nil, err
+ }
+ key := strings.ToLower(word)
+ if operation == Add {
+ words[key] = word
+ } else {
+ delete(words, key)
+ }
+ if err := save_exceptions(path, words); err != nil {
+ return nil, err
+ }
+ return words, nil
+}
+
+func save_exceptions(path string, words map[string]string) error {
+ data, err := json.MarshalIndent(exception_list(words), "", " ")
+ if err != nil {
+ return err
+ }
+ if len(data) > 1<<20 {
+ return fmt.Errorf("exceptions file exceeds 1 MiB")
+ }
+ dir := filepath.Dir(path)
+ if err := os.MkdirAll(dir, 0700); err != nil {
+ return err
+ }
+ file, err := os.CreateTemp(dir, ".exceptions-*.json")
+ if err != nil {
+ return err
+ }
+ defer os.Remove(file.Name())
+ if _, err := file.Write(append(data, '\n')); err != nil {
+ file.Close()
+ return err
+ }
+ if err := file.Sync(); err != nil {
+ file.Close()
+ return err
+ }
+ if err := file.Close(); err != nil {
+ return err
+ }
+ return os.Rename(file.Name(), path)
+}
diff --git a/internal/spellcheck/exceptions_test.go b/internal/spellcheck/exceptions_test.go
new file mode 100644
index 00000000..b0d55511
--- /dev/null
+++ b/internal/spellcheck/exceptions_test.go
@@ -0,0 +1,188 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package spellcheck
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "reflect"
+ "syscall"
+ "testing"
+ "time"
+)
+
+func TestExceptionEditsPreserveOtherReadersChanges(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "exceptions.json")
+ options := Options{ExceptionsPath: path, OpenDictionary: func(string) (Dictionary, error) { return &TestDictionary{}, nil }}
+ first, second := New_service(context.Background(), options), New_service(context.Background(), options)
+ defer first.Close()
+ defer second.Close()
+ // Both readers load the original list, then the user switches between them.
+ receive_result(t, first, Request{Operation: Inspect})
+ receive_result(t, second, Request{Operation: Inspect})
+ for _, edit := range []struct {
+ service *Service
+ request Request
+ want []string
+ }{
+ {first, Request{Operation: Add, Word: "Alpha"}, []string{"Alpha"}},
+ {second, Request{Operation: Add, Word: "Beta"}, []string{"Alpha", "Beta"}},
+ {first, Request{Operation: Remove, Word: "ALPHA"}, []string{"Beta"}},
+ {second, Request{Operation: Add, Word: "Gamma"}, []string{"Beta", "Gamma"}},
+ {first, Request{Operation: Add, Word: "Alpha"}, []string{"Alpha", "Beta", "Gamma"}},
+ } {
+ result := receive_result(t, edit.service, edit.request)
+ if result.Err != nil || !reflect.DeepEqual(result.Exceptions, edit.want) {
+ t.Fatalf("edit %v: result=%+v, want=%v", edit.request, result, edit.want)
+ }
+ words, err := load_exceptions(path)
+ if err != nil || !reflect.DeepEqual(exception_list(words), edit.want) {
+ t.Fatalf("saved exceptions=%v, error=%v, want=%v", words, err, edit.want)
+ }
+ }
+}
+
+// Separate processes verify that the transaction lock works across readers.
+func TestExceptionWriterProcess(t *testing.T) {
+ path := os.Getenv("FENRIR_EXCEPTION_TEST_PATH")
+ if path == "" {
+ return
+ }
+ service := New_service(context.Background(), Options{ExceptionsPath: path, OpenDictionary: func(string) (Dictionary, error) { return &TestDictionary{}, nil }})
+ defer service.Close()
+ encoder, decoder := json.NewEncoder(os.Stdout), json.NewDecoder(os.Stdin)
+ result := receive_result(t, service, Request{Operation: Inspect})
+ if result.Err != nil {
+ t.Fatal(result.Err)
+ }
+ if err := encoder.Encode(result.Exceptions); err != nil {
+ t.Fatal(err)
+ }
+ for {
+ var request Request
+ if err := decoder.Decode(&request); err != nil {
+ if errors.Is(err, io.EOF) {
+ return
+ }
+ t.Fatal(err)
+ }
+ result := receive_result(t, service, request)
+ if result.Err != nil {
+ t.Fatal(result.Err)
+ }
+ if err := encoder.Encode(result.Exceptions); err != nil {
+ t.Fatal(err)
+ }
+ }
+}
+
+func TestConcurrentExceptionProcessesKeepBothEdits(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "exceptions.json")
+ type Writer struct {
+ encoder *json.Encoder
+ decoder *json.Decoder
+ }
+ start_writer := func() Writer {
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestExceptionWriterProcess$")
+ cmd.Env = append(os.Environ(), "FENRIR_EXCEPTION_TEST_PATH="+path)
+ cmd.Stderr = os.Stderr
+ input, err := cmd.StdinPipe()
+ if err != nil {
+ cancel()
+ t.Fatal(err)
+ }
+ output, err := cmd.StdoutPipe()
+ if err != nil {
+ cancel()
+ t.Fatal(err)
+ }
+ if err := cmd.Start(); err != nil {
+ cancel()
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ input.Close()
+ err := cmd.Wait()
+ cancel()
+ if err != nil {
+ t.Error(err)
+ }
+ })
+ writer := Writer{json.NewEncoder(input), json.NewDecoder(output)}
+ var ready []string
+ if err := writer.decoder.Decode(&ready); err != nil {
+ t.Fatal(err)
+ }
+ return writer
+ }
+ first, second := start_writer(), start_writer()
+ for _, edit := range []struct {
+ writer Writer
+ word string
+ }{{first, "Alpha"}, {second, "Beta"}} {
+ if err := edit.writer.encoder.Encode(Request{Operation: Add, Word: edit.word}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ for _, writer := range []Writer{first, second} {
+ var words []string
+ if err := writer.decoder.Decode(&words); err != nil {
+ t.Fatal(err)
+ }
+ }
+ words, err := load_exceptions(path)
+ if err != nil || !reflect.DeepEqual(exception_list(words), []string{"Alpha", "Beta"}) {
+ t.Fatal("lost concurrent addition", words, err)
+ }
+ info, err := os.Stat(path + ".lock")
+ if err != nil || info.Mode().Perm() != 0600 {
+ t.Fatal("lock permissions", info, err)
+ }
+}
+
+func TestExceptionLockCancellationAndInvalidStorePreserveData(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "exceptions.json")
+ if err := save_exceptions(path, map[string]string{"alpha": "Alpha"}); err != nil {
+ t.Fatal(err)
+ }
+ lock, err := os.OpenFile(path+".lock", os.O_CREATE|os.O_RDWR, 0600)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer lock.Close()
+ if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX); err != nil {
+ t.Fatal(err)
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
+ defer cancel()
+ if _, err := update_exceptions(ctx, path, Add, "Beta"); !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatal("lock wait ignored cancellation", err)
+ }
+ words, err := load_exceptions(path)
+ if err != nil || !reflect.DeepEqual(exception_list(words), []string{"Alpha"}) {
+ t.Fatal("cancelled edit changed data", words, err)
+ }
+ if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_UN); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := update_exceptions(context.Background(), path, Add, "Beta"); err != nil {
+ t.Fatal("edit failed after lock release", err)
+ }
+ if err := os.WriteFile(path, []byte("invalid JSON"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := update_exceptions(context.Background(), path, Add, "Gamma"); err == nil {
+ t.Fatal("edit overwrote invalid shared file")
+ }
+ data, err := os.ReadFile(path)
+ if err != nil || string(data) != "invalid JSON" {
+ t.Fatal("invalid file was replaced", string(data), err)
+ }
+}
diff --git a/internal/spellcheck/native.c b/internal/spellcheck/native.c
new file mode 100644
index 00000000..b2605bc5
--- /dev/null
+++ b/internal/spellcheck/native.c
@@ -0,0 +1,80 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "native.h"
+#include
+#include
+#include
+
+// Enchant 2's public ABI uses opaque broker/dictionary pointers. Resolve that
+// ABI at runtime so the dynamic linker never requires Enchant to start Fenrir.
+typedef struct _EnchantBroker EnchantBroker;
+typedef struct _EnchantDict EnchantDict;
+
+struct SpellDictionary {
+ void *library;
+ EnchantBroker *broker;
+ EnchantDict *dictionary;
+ EnchantBroker *(*broker_init)(void);
+ void (*broker_free)(EnchantBroker *broker);
+ EnchantDict *(*request_dict)(EnchantBroker *broker, const char *language);
+ void (*free_dict)(EnchantBroker *broker, EnchantDict *dictionary);
+ int (*check)(EnchantDict *dictionary, const char *word, ssize_t length);
+ char **(*suggest)(EnchantDict *dictionary, const char *word, ssize_t length, size_t *count);
+ void (*free_suggestions)(EnchantDict *dictionary, char **suggestions);
+};
+
+void spell_dictionary_close(SpellDictionary *context) {
+ if (!context) return;
+ if (context->dictionary) context->free_dict(context->broker, context->dictionary);
+ if (context->broker) context->broker_free(context->broker);
+ if (context->library) dlclose(context->library);
+ free(context);
+}
+
+SpellDictionary *spell_dictionary_open(const char *language, int *status) {
+ *status = 3;
+ SpellDictionary *context = calloc(1, sizeof(*context));
+ if (!context) return NULL;
+ context->library = dlopen("libenchant-2.so.2", RTLD_NOW | RTLD_LOCAL);
+ if (!context->library) {
+ *status = 1;
+ goto failed;
+ }
+ *status = 2;
+#define LOAD(field, symbol) \
+ context->field = dlsym(context->library, symbol); \
+ if (!context->field) goto failed
+ LOAD(broker_init, "enchant_broker_init");
+ LOAD(broker_free, "enchant_broker_free");
+ LOAD(request_dict, "enchant_broker_request_dict");
+ LOAD(free_dict, "enchant_broker_free_dict");
+ LOAD(check, "enchant_dict_check");
+ LOAD(suggest, "enchant_dict_suggest");
+ LOAD(free_suggestions, "enchant_dict_free_string_list");
+#undef LOAD
+ *status = 3;
+ context->broker = context->broker_init();
+ if (!context->broker) goto failed;
+ *status = 4;
+ context->dictionary = context->request_dict(context->broker, language);
+ if (!context->dictionary) goto failed;
+ *status = 0;
+ return context;
+failed:
+ spell_dictionary_close(context);
+ return NULL;
+}
+
+int spell_dictionary_check(SpellDictionary *context, const char *word, size_t length) {
+ return context->check(context->dictionary, word, (ssize_t)length);
+}
+
+char **spell_dictionary_suggest(SpellDictionary *context, const char *word,
+ size_t length, size_t *count) {
+ return context->suggest(context->dictionary, word, (ssize_t)length, count);
+}
+
+void spell_dictionary_free_suggestions(SpellDictionary *context, char **suggestions) {
+ context->free_suggestions(context->dictionary, suggestions);
+}
diff --git a/internal/spellcheck/native.h b/internal/spellcheck/native.h
new file mode 100644
index 00000000..c681e75a
--- /dev/null
+++ b/internal/spellcheck/native.h
@@ -0,0 +1,18 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#ifndef FENRIR_SPELLCHECK_NATIVE_H
+#define FENRIR_SPELLCHECK_NATIVE_H
+
+#include
+
+typedef struct SpellDictionary SpellDictionary;
+
+SpellDictionary *spell_dictionary_open(const char *language, int *status);
+int spell_dictionary_check(SpellDictionary *context, const char *word, size_t length);
+char **spell_dictionary_suggest(SpellDictionary *context, const char *word,
+ size_t length, size_t *count);
+void spell_dictionary_free_suggestions(SpellDictionary *context, char **suggestions);
+void spell_dictionary_close(SpellDictionary *context);
+
+#endif
diff --git a/internal/spellcheck/service.go b/internal/spellcheck/service.go
new file mode 100644
index 00000000..14239e1c
--- /dev/null
+++ b/internal/spellcheck/service.go
@@ -0,0 +1,167 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package spellcheck
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os/exec"
+ "strings"
+ "sync"
+ "time"
+)
+
+type Operation int
+
+const (
+ Inspect Operation = iota
+ Automatic
+ Add
+ Remove
+)
+
+type Request struct {
+ Operation Operation
+ Word string
+ Generation, Revision uint64
+}
+type Result struct {
+ Request
+ Language string
+ Word string
+ Correct, Available bool
+ Suggestions, Exceptions []string
+ Err, DictionaryError error
+}
+type Options struct {
+ Language, ExceptionsPath string
+ OpenDictionary func(string) (Dictionary, error)
+}
+type Service struct {
+ ctx context.Context
+ cancel context.CancelFunc
+ requests, automatic chan Request
+ results chan Result
+ done chan struct{}
+ once sync.Once
+}
+
+func New_service(parent context.Context, options Options) *Service {
+ ctx, cancel := context.WithCancel(parent)
+ s := &Service{ctx: ctx, cancel: cancel, requests: make(chan Request, 16), automatic: make(chan Request, 32), results: make(chan Result, 32), done: make(chan struct{})}
+ go s.run(options)
+ return s
+}
+func (s *Service) Results() <-chan Result { return s.results }
+func (s *Service) Submit(request Request) bool {
+ queue := s.requests
+ if request.Operation == Automatic {
+ queue = s.automatic
+ }
+ select {
+ case <-s.ctx.Done():
+ return false
+ default:
+ }
+ select {
+ case queue <- request:
+ return true
+ case <-s.ctx.Done():
+ return false
+ default:
+ return false
+ }
+}
+func (s *Service) Close() { s.once.Do(s.cancel); <-s.done }
+
+type commandEntry struct {
+ valid bool
+ expires time.Time
+}
+
+// Commands are tested as executable PATH entries, never executed. Builtins are
+// accepted separately because they have no executable file. Aliases/functions
+// belong to the child shell and cannot be discovered from the reader process.
+func valid_command(word string, cache map[string]commandEntry) bool {
+ if strings.ContainsAny(word, "/\\\x00") {
+ return false
+ }
+ if strings.Contains(" alias bg bind break builtin caller cd command compgen complete compopt continue declare dirs disown echo enable eval exec exit export false fc fg getopts hash help history jobs kill let local logout mapfile popd printf pushd pwd read readarray readonly return set shift shopt source test times trap true type typeset ulimit umask unalias unset wait ", " "+word+" ") {
+ return true
+ }
+ if entry, ok := cache[word]; ok && time.Now().Before(entry.expires) {
+ return entry.valid
+ }
+ _, err := exec.LookPath(word)
+ valid := err == nil || errors.Is(err, exec.ErrDot)
+ if len(cache) >= 1024 {
+ clear(cache)
+ }
+ cache[word] = commandEntry{valid, time.Now().Add(30 * time.Second)}
+ return valid
+}
+
+func (s *Service) run(options Options) {
+ defer close(s.done)
+ open := options.OpenDictionary
+ if open == nil {
+ open = open_dictionary
+ }
+ dict, dictionaryErr := open(options.Language)
+ if dict != nil {
+ defer dict.Close()
+ }
+ words, storeErr := load_exceptions(options.ExceptionsPath)
+ cache := make(map[string]commandEntry)
+ for {
+ var request Request
+ // Prefer interactive actions over a backlog of automatic checks.
+ select {
+ case <-s.ctx.Done():
+ return
+ case request = <-s.requests:
+ default:
+ select {
+ case <-s.ctx.Done():
+ return
+ case request = <-s.requests:
+ case request = <-s.automatic:
+ }
+ }
+ result := Result{Request: request, Language: options.Language, Word: Word(request.Word), Available: dict != nil && dictionaryErr == nil, DictionaryError: dictionaryErr, Err: storeErr}
+ if request.Operation == Add || request.Operation == Remove {
+ result.Word = strings.TrimSpace(request.Word)
+ if result.Err == nil && !valid_word(result.Word) {
+ result.Err = fmt.Errorf("enter a single word of at most 128 characters")
+ }
+ if result.Err == nil {
+ var draft map[string]string
+ draft, result.Err = update_exceptions(s.ctx, options.ExceptionsPath, request.Operation, result.Word)
+ if result.Err == nil {
+ words = draft
+ }
+ }
+ } else if result.Err == nil && result.Word != "" {
+ _, result.Correct = words[strings.ToLower(result.Word)]
+ if request.Operation == Automatic && !result.Correct {
+ result.Correct = valid_command(result.Word, cache)
+ }
+ if !result.Correct && result.Available {
+ result.Correct, result.Err = dict.Check(result.Word)
+ }
+ if result.Err == nil && !result.Correct && result.Available && request.Operation == Inspect {
+ result.Suggestions, result.Err = dict.Suggest(result.Word)
+ }
+ }
+ if request.Operation != Automatic {
+ result.Exceptions = exception_list(words)
+ }
+ select {
+ case s.results <- result:
+ case <-s.ctx.Done():
+ return
+ }
+ }
+}
diff --git a/internal/spellcheck/service_test.go b/internal/spellcheck/service_test.go
new file mode 100644
index 00000000..3d1125ca
--- /dev/null
+++ b/internal/spellcheck/service_test.go
@@ -0,0 +1,227 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package spellcheck
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "reflect"
+ "testing"
+ "time"
+)
+
+type TestDictionary struct {
+ calls []string
+ gate <-chan struct{}
+}
+
+func (d *TestDictionary) Check(word string) (bool, error) {
+ if d.gate != nil {
+ <-d.gate
+ }
+ d.calls = append(d.calls, word)
+ return word == "hello", nil
+}
+func (*TestDictionary) Suggest(string) ([]string, error) {
+ return []string{"hello", "help", "held"}, nil
+}
+func (*TestDictionary) Close() {}
+
+func receive_result(t *testing.T, service *Service, request Request) Result {
+ t.Helper()
+ if !service.Submit(request) {
+ t.Fatal("request not accepted")
+ }
+ select {
+ case result := <-service.Results():
+ return result
+ case <-time.After(2 * time.Second):
+ t.Fatal("worker timed out")
+ return Result{}
+ }
+}
+
+func TestSessionLanguagePrecedenceAndNoInventedFallback(t *testing.T) {
+ for _, name := range []string{"LANGUAGE", "LC_ALL", "LC_MESSAGES", "LANG"} {
+ t.Setenv(name, "")
+ }
+ t.Setenv("LANG", "en-GB.UTF-8")
+ t.Setenv("LANGUAGE", "fr:de")
+ if Language() != "en_GB" {
+ t.Fatal(Language())
+ }
+ t.Setenv("LC_MESSAGES", "de_DE.UTF-8@euro")
+ if Language() != "de_DE" {
+ t.Fatal(Language())
+ }
+ t.Setenv("LC_ALL", "fr_FR.UTF-8")
+ if Language() != "fr_FR" {
+ t.Fatal(Language())
+ }
+ for _, language := range []string{"C", "C.UTF-8", "POSIX", "../../en_US", "en_US:fr"} {
+ t.Setenv("LC_ALL", language)
+ if Language() != "" {
+ t.Fatal(language, Language())
+ }
+ }
+}
+
+func TestWordExtractionUnicodeContractionsAndTerminalTokens(t *testing.T) {
+ for input, want := range map[string]string{"hello,": "hello", "(cafĆ©)": "cafĆ©", "don't": "don't", "lāĆ©tĆ©": "lāĆ©tĆ©", "naĆÆve": "naĆÆve", "co-operate": "co-operate", "ē«": "ē«", "123": "", "ļ¼ļ¼ļ¼": "", "foo42": "", "-foo": "", "./hello": "", "/tmp/hello": "", "name@example.com": "", "https://example.com": "", "some_identifier": "", "foo.bar": "", "--option": "", "@name": "", "#topic": "", "VAR=value": "", "\x00": "", "": ""} {
+ if got := Word(input); got != want {
+ t.Errorf("%q: %q, want %q", input, got, want)
+ }
+ }
+}
+
+func TestGlobalExceptionsPersistenceRemoveAndFailedWriteIsolation(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "spelling", "exceptions.json")
+ dict := &TestDictionary{}
+ options := Options{Language: "en_US", ExceptionsPath: path, OpenDictionary: func(string) (Dictionary, error) { return dict, nil }}
+ service := New_service(context.Background(), options)
+ defer service.Close()
+ if got := receive_result(t, service, Request{Word: "helo"}); got.Correct || !reflect.DeepEqual(got.Suggestions, []string{"hello", "help", "held"}) {
+ t.Fatal(got)
+ }
+ for _, word := range []string{"Fenrir", "CafƩ"} {
+ if got := receive_result(t, service, Request{Operation: Add, Word: word}); got.Err != nil {
+ t.Fatal(got)
+ }
+ }
+ for _, operation := range []Operation{Inspect, Automatic} {
+ if got := receive_result(t, service, Request{Operation: operation, Word: "FENRIR"}); !got.Correct || len(got.Suggestions) != 0 {
+ t.Fatal(got)
+ }
+ }
+ if len(dict.calls) != 1 {
+ t.Fatal("exception reached dictionary", dict.calls)
+ }
+ info, err := os.Stat(path)
+ if err != nil || info.Mode().Perm() != 0600 {
+ t.Fatal(info, err)
+ }
+ service.Close()
+ options.Language = "de_DE"
+ service = New_service(context.Background(), options)
+ defer service.Close()
+ if got := receive_result(t, service, Request{Word: "cafƩ"}); !got.Correct {
+ t.Fatal("exception was language-specific", got)
+ }
+ if got := receive_result(t, service, Request{Operation: Remove, Word: "Fenrir"}); got.Err != nil || !reflect.DeepEqual(got.Exceptions, []string{"CafƩ"}) {
+ t.Fatal(got)
+ }
+ if got := receive_result(t, service, Request{Word: "Fenrir"}); got.Correct {
+ t.Fatal("removed exception accepted", got)
+ }
+ // Replace the parent with a file so failure is deterministic even as root.
+ if err := os.Rename(filepath.Dir(path), filepath.Dir(path)+"-saved"); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Dir(path), []byte("blocked"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if got := receive_result(t, service, Request{Operation: Add, Word: "wrongword"}); got.Err == nil {
+ t.Fatal("failed write announced success", got)
+ }
+ if got := receive_result(t, service, Request{Word: "wrongword"}); got.Correct {
+ t.Fatal("failed addition changed memory", got)
+ }
+ if got := receive_result(t, service, Request{Operation: Remove, Word: "CafƩ"}); got.Err == nil {
+ t.Fatal(got)
+ }
+ if got := receive_result(t, service, Request{Word: "cafƩ"}); !got.Correct {
+ t.Fatal("failed removal changed memory", got)
+ }
+}
+
+func TestPATHCommandsBuiltinsAndNeverExecute(t *testing.T) {
+ root := t.TempDir()
+ t.Setenv("PATH", root)
+ marker := filepath.Join(root, "executed")
+ if err := os.WriteFile(filepath.Join(root, "tac"), []byte("#!/bin/sh\necho bad > '"+marker+"'\n"), 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(root, "notexecutable"), []byte("data"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Mkdir(filepath.Join(root, "directory"), 0700); err != nil {
+ t.Fatal(err)
+ }
+ dict := &TestDictionary{}
+ service := New_service(context.Background(), Options{ExceptionsPath: filepath.Join(root, "exceptions.json"), OpenDictionary: func(string) (Dictionary, error) { return dict, nil }})
+ defer service.Close()
+ for _, word := range []string{"tac", "cd", "readarray", "ulimit"} {
+ if got := receive_result(t, service, Request{Operation: Automatic, Word: word}); !got.Correct {
+ t.Fatal(word, got)
+ }
+ }
+ if len(dict.calls) != 0 {
+ t.Fatal(dict.calls)
+ }
+ for _, word := range []string{"notexecutable", "directory", "typo"} {
+ if got := receive_result(t, service, Request{Operation: Automatic, Word: word}); got.Correct {
+ t.Fatal(word, got)
+ }
+ }
+ if _, err := os.Stat(marker); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("command executed", err)
+ }
+ if got := receive_result(t, service, Request{Word: "tac"}); got.Correct {
+ t.Fatal("manual lexical check skipped", got)
+ }
+}
+
+func TestMissingDictionaryStillAllowsExceptionManagement(t *testing.T) {
+ service := New_service(context.Background(), Options{ExceptionsPath: filepath.Join(t.TempDir(), "exceptions.json"), OpenDictionary: func(string) (Dictionary, error) { return nil, errors.New("missing dictionary") }})
+ defer service.Close()
+ if got := receive_result(t, service, Request{Word: "hello"}); got.Available || got.DictionaryError == nil || got.Correct {
+ t.Fatal(got)
+ }
+ if got := receive_result(t, service, Request{Operation: Add, Word: "Fenrir"}); got.Err != nil {
+ t.Fatal(got)
+ }
+ if got := receive_result(t, service, Request{Word: "fenrir"}); !got.Correct {
+ t.Fatal(got)
+ }
+}
+
+func TestSlowDictionaryDoesNotBlockInputSubmissionOrShutdown(t *testing.T) {
+ gate := make(chan struct{})
+ service := New_service(context.Background(), Options{ExceptionsPath: filepath.Join(t.TempDir(), "exceptions.json"), OpenDictionary: func(string) (Dictionary, error) { return &TestDictionary{gate: gate}, nil }})
+ started := time.Now()
+ for range 100 {
+ service.Submit(Request{Operation: Automatic, Word: "helo"})
+ }
+ if time.Since(started) > 100*time.Millisecond {
+ t.Fatal("input blocked")
+ }
+ close(gate)
+ service.Close()
+ if service.Submit(Request{}) {
+ t.Fatal("closed worker accepted request")
+ }
+}
+
+func TestActualEnchantDictionaryAndRankedSuggestions(t *testing.T) {
+ dict, err := open_dictionary("en_US")
+ if err != nil {
+ t.Skip(err)
+ }
+ defer dict.Close()
+ if correct, err := dict.Check("hello"); err != nil || !correct {
+ t.Fatal(correct, err)
+ }
+ if correct, err := dict.Check("helo"); err != nil || correct {
+ t.Fatal(correct, err)
+ }
+ if suggestions, err := dict.Suggest("helo"); err != nil || len(suggestions) == 0 {
+ t.Fatal(suggestions, err)
+ }
+ if dict, err := open_dictionary("zz_ZZ"); err == nil {
+ dict.Close()
+ t.Fatal("missing dictionary accepted")
+ }
+}
diff --git a/internal/spellcheck/words.go b/internal/spellcheck/words.go
new file mode 100644
index 00000000..d57a23e0
--- /dev/null
+++ b/internal/spellcheck/words.go
@@ -0,0 +1,71 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package spellcheck
+
+import (
+ "os"
+ "strings"
+ "unicode"
+ "unicode/utf8"
+)
+
+// Language uses the effective session message locale, with no English fallback.
+// LANGUAGE is a translation fallback list, not the session's regional locale.
+func Language() string {
+ for _, name := range []string{"LC_ALL", "LC_MESSAGES", "LANG"} {
+ if value := os.Getenv(name); value != "" {
+ return language_tag(value)
+ }
+ }
+ return ""
+}
+
+func language_tag(value string) string {
+ value, _, _ = strings.Cut(strings.TrimSpace(value), ".")
+ value, _, _ = strings.Cut(value, "@")
+ if value == "C" || value == "POSIX" {
+ return ""
+ }
+ value = strings.ReplaceAll(value, "-", "_")
+ for _, char := range value {
+ if !unicode.IsLetter(char) && char != '_' {
+ return ""
+ }
+ }
+ return value
+}
+
+// Word strips surrounding prose punctuation but leaves structured terminal
+// tokens intact so they can be excluded rather than checked as fragments.
+func Word(value string) string {
+ value = strings.TrimSpace(value)
+ if strings.HasPrefix(value, "-") || strings.HasPrefix(value, "#") || strings.HasPrefix(value, "@") ||
+ strings.ContainsAny(value, "/\\@_=0123456789") || strings.Contains(value, "://") {
+ return ""
+ }
+ value = strings.TrimFunc(value, func(char rune) bool { return !unicode.IsLetter(char) && !unicode.IsMark(char) })
+ if !valid_word(value) {
+ return ""
+ }
+ return value
+}
+
+func valid_word(value string) bool {
+ if !utf8.ValidString(value) || utf8.RuneCountInString(value) > 128 || value == "" {
+ return false
+ }
+ letters := false
+ for _, char := range value {
+ if unicode.IsLetter(char) {
+ letters = true
+ } else if !unicode.IsMark(char) && char != '\'' && char != 'ā' && char != '-' && char != 'ā' {
+ return false
+ }
+ }
+ return letters
+}
+
+func Separator(char rune) bool {
+ return unicode.IsSpace(char) || unicode.IsPunct(char) && !strings.ContainsRune("'.:/@\\_=-āā#", char)
+}
diff --git a/internal/terminal/emulator_linux.go b/internal/terminal/emulator_linux.go
new file mode 100644
index 00000000..b0fc277d
--- /dev/null
+++ b/internal/terminal/emulator_linux.go
@@ -0,0 +1,76 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+//go:build linux && cgo
+
+package terminal
+
+/*
+#include "native.h"
+*/
+import "C"
+
+import (
+ "fmt"
+ "unsafe"
+
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+type Emulator struct {
+ native *C.VTerm
+ rows, cols int
+}
+
+func new_emulator(rows, cols int) (*Emulator, error) {
+ if !valid_size(rows, cols) {
+ return nil, fmt.Errorf("unsupported terminal geometry %dx%d", cols, rows)
+ }
+ vt := C.terminal_vterm(C.int(rows), C.int(cols))
+ if vt == nil {
+ return nil, fmt.Errorf("allocate terminal emulator")
+ }
+ return &Emulator{native: vt, rows: rows, cols: cols}, nil
+}
+
+func (e *Emulator) close() { C.terminal_vterm_free(e.native) }
+
+func (e *Emulator) feed(bytes []byte) {
+ if len(bytes) == 0 {
+ return
+ }
+ C.vterm_input_write(e.native, (*C.char)(unsafe.Pointer(&bytes[0])), C.size_t(len(bytes)))
+ // The outer terminal answers queries. Discard duplicate emulator replies.
+ var discard [4096]byte
+ for C.vterm_output_read(e.native, (*C.char)(unsafe.Pointer(&discard[0])), C.size_t(len(discard))) > 0 {
+ }
+}
+
+func (e *Emulator) resize(rows, cols int) {
+ C.vterm_set_size(e.native, C.int(rows), C.int(cols))
+ e.rows, e.cols = rows, cols
+}
+
+func (e *Emulator) snapshot(active, lock bool) screen.Snapshot {
+ snapshot := screen.Snapshot{Terminal: 1, Active: active, NumLock: lock, Rows: e.rows, Cols: e.cols, Cells: make([]rune, e.rows*e.cols)}
+ snapshot.Generation = uint64(C.terminal_generation(e.native))
+ snapshot.Attributes = make([]screen.Attribute, len(snapshot.Cells))
+ var x, y C.int
+ C.terminal_cells(e.native, (*C.uint32_t)(unsafe.Pointer(&snapshot.Cells[0])), (*C.TerminalAttribute)(unsafe.Pointer(&snapshot.Attributes[0])), C.int(len(snapshot.Cells)), &x, &y)
+ snapshot.X, snapshot.Y = min(int(x), e.cols-1), min(int(y), e.rows-1)
+ return snapshot
+}
diff --git a/internal/terminal/emulator_linux_test.go b/internal/terminal/emulator_linux_test.go
new file mode 100644
index 00000000..72f5c0b8
--- /dev/null
+++ b/internal/terminal/emulator_linux_test.go
@@ -0,0 +1,329 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+//go:build linux && cgo
+
+package terminal
+
+import (
+ "fmt"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/input"
+ "git.stormux.org/storm/fenrir/internal/presentation"
+ "git.stormux.org/storm/fenrir/internal/reader"
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+func TestPTYAttributeOnlyHighlightMovement(t *testing.T) {
+ emulator, err := new_emulator(4, 30)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer emulator.close()
+ emulator.feed([]byte("\x1b[7mFirst message\x1b[0m\r\nSecond message\r\nThird message\x1b[4;1H"))
+ before := emulator.snapshot(true, false)
+ if len(before.Attributes) != len(before.Cells) || before.Attributes[0] == before.Attributes[30] {
+ t.Fatal("reverse video attributes were discarded")
+ }
+ engine := reader.New()
+ engine.Configure_tracking("highlight")
+ engine.Update(before)
+ emulator.feed([]byte("\x1b[1;1HFirst message\x1b[2;1H\x1b[7mSecond message\x1b[0m\x1b[4;1H"))
+ after := emulator.snapshot(true, false)
+ if !reflect.DeepEqual(before.Cells, after.Cells) || before.X != after.X || before.Y != after.Y {
+ t.Fatal("fixture changed text or hardware cursor")
+ }
+ if got := engine.Update(after); !reflect.DeepEqual(got, []presentation.Message{{Text: "Second message"}}) {
+ t.Fatal("PTY selection was silent or read deselected text", got)
+ }
+ if before.Attributes[0] == before.Attributes[30] {
+ t.Fatal("snapshot attribute storage was reused")
+ }
+ // Colours as well as style changes must survive conversion, including RGB.
+ emulator.feed([]byte("\x1b[1;1H\x1b[1;38;2;12;34;56;48;2;65;43;21mColor\x1b[0m"))
+ colored := emulator.snapshot(true, false)
+ want := screen.Attribute{Foreground: 0x0c2238, Background: 0x412b15, Style: 1}
+ if colored.Attributes[0] != want {
+ t.Fatal("RGB/style attributes", colored.Attributes[0], want)
+ }
+}
+
+func TestPTYUnicodeSplitSequencesAndSharedReview(t *testing.T) {
+ emulator, err := new_emulator(4, 20)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer emulator.close()
+ bytes := []byte("alpha\r\nĆ©ē\r\nthird\x1b[2;2H")
+ // Split every UTF-8 character and escape sequence across reads.
+ for _, value := range bytes {
+ emulator.feed([]byte{value})
+ }
+ snapshot := emulator.snapshot(true, false)
+ if snapshot.Text(0) != "alpha" || snapshot.Text(1) != "Ć©ē" || snapshot.X != 1 || snapshot.Y != 1 {
+ t.Fatalf("wrong PTY screen: %+v, %q", snapshot, snapshot.Cells)
+ }
+ engine := reader.New()
+ engine.Update(snapshot)
+ for _, fixture := range []struct {
+ action command.ID
+ want string
+ }{
+ {command.CurrentLine, "Ć©ē"}, {command.PreviousLine, "alpha"},
+ {command.CurrentWord, "alpha"}, {command.CurrentChar, "a"},
+ {command.ReadScreen, "alpha\nĆ©ē\nthird"},
+ } {
+ if got := engine.Command(fixture.action).Text; got != fixture.want {
+ t.Fatalf("shared %s: got %q, want %q", fixture.action, got, fixture.want)
+ }
+ }
+}
+
+func TestPTYAlternateScreenResizeAndScroll(t *testing.T) {
+ emulator, err := new_emulator(3, 12)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer emulator.close()
+ engine := reader.New()
+ emulator.feed([]byte("shell\x1b[?1049h\x1b[2J\x1b[Heditor"))
+ alternate := emulator.snapshot(true, false)
+ engine.Update(alternate)
+ engine.Command(command.NextLine)
+ if got := emulator.snapshot(true, false).Text(0); got != "editor" {
+ t.Fatal("alternate screen", got)
+ }
+ emulator.feed([]byte("\x1b[?1049l"))
+ main := emulator.snapshot(true, false)
+ if got := main.Text(0); got != "shell" {
+ t.Fatal("main screen lost", got)
+ }
+ if main.Generation == alternate.Generation {
+ t.Fatal("alternate buffer reused its screen generation")
+ }
+ engine.Update(main)
+ if got := engine.Command(command.CurrentLine).Text; got != "shell" {
+ t.Fatal("review was not reset for the restored buffer", got)
+ }
+ emulator.resize(4, 20)
+ snapshot := emulator.snapshot(true, false)
+ if snapshot.Rows != 4 || snapshot.Cols != 20 || len(snapshot.Cells) != 80 || snapshot.Text(0) != "shell" {
+ t.Fatal("resize", snapshot)
+ }
+ emulator.feed([]byte("\x1b[2J\x1b[Hone\r\ntwo\r\nthree\r\nfour\r\nfive"))
+ snapshot = emulator.snapshot(true, false)
+ var lines []string
+ for row := range snapshot.Rows {
+ lines = append(lines, snapshot.Text(row))
+ }
+ if !reflect.DeepEqual(lines, []string{"two", "three", "four", "five"}) {
+ t.Fatal("scroll", lines)
+ }
+}
+
+func TestPTYCarriageReturnEraseAndCursorEcho(t *testing.T) {
+ emulator, err := new_emulator(3, 20)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer emulator.close()
+ engine := reader.New()
+ engine.Update(emulator.snapshot(true, false))
+ emulator.feed([]byte("hello"))
+ if got := engine.Update(emulator.snapshot(true, false)); !reflect.DeepEqual(got, []presentation.Message{{Text: "hello"}}) {
+ t.Fatal("shared automatic reading", got)
+ }
+ emulator.feed([]byte("\b \b"))
+ if got := engine.Update(emulator.snapshot(true, false)); !reflect.DeepEqual(got, []presentation.Message{presentation.Character_message('o')}) {
+ t.Fatal("shared deletion echo", got)
+ }
+ emulator.feed([]byte("\rnew\x1b[K"))
+ if got := emulator.snapshot(true, false).Text(0); got != "new" {
+ t.Fatal("repaint/erase", got)
+ }
+ emulator.feed([]byte("\x1b[2;1Hnext\x1b[1;1H"))
+ engine.Update(emulator.snapshot(true, false))
+ emulator.feed([]byte("\x1b[2;1H"))
+ if got := engine.Update(emulator.snapshot(true, false)); !reflect.DeepEqual(got, []presentation.Message{{Text: "next"}}) {
+ t.Fatal("shared cursor echo", got)
+ }
+}
+
+func TestPTYChatScrollRegionPreservesFixedRows(t *testing.T) {
+ emulator, err := new_emulator(7, 30)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer emulator.close()
+ engine := reader.New()
+ emulator.feed([]byte("header\r\nold\r\nkeep one\r\nkeep two\r\nlast\r\nstatus\r\n> draft\x1b[2;5r\x1b[7;8H"))
+ engine.Update(emulator.snapshot(true, false))
+ // DECSTBM + line feed shifts only the chat area, as in irssi. The
+ // application restores the cursor to its unchanged input line afterward.
+ emulator.feed([]byte("\x1b[5;1H\n\rnew message\x1b[7;8H"))
+ if got := engine.Update(emulator.snapshot(true, false)); !reflect.DeepEqual(got, []presentation.Message{{Text: "new message"}}) {
+ t.Fatal(got)
+ }
+ if got := emulator.snapshot(true, false); got.Text(0) != "header" || got.Text(5) != "status" || got.Text(6) != "> draft" {
+ t.Fatal(got)
+ }
+}
+
+func TestTerminalEnvironmentValidation(t *testing.T) {
+ t.Setenv("WINDOWID", "invalid")
+ if _, err := Open(nil, nil, input.Router{}); err == nil || !strings.Contains(err.Error(), "WINDOWID") {
+ t.Fatal("invalid target", err)
+ }
+ t.Setenv("WINDOWID", "123")
+ t.Setenv("SHELL", "")
+ if _, err := Open(nil, nil, input.Router{}); err == nil || !strings.Contains(err.Error(), "SHELL") {
+ t.Fatal("missing shell", err)
+ }
+ for _, size := range [][2]int{{0, 80}, {25, 0}, {1001, 80}, {1000, 2000}} {
+ if emulator, err := new_emulator(size[0], size[1]); err == nil {
+ emulator.close()
+ t.Fatal("accepted excessive geometry", size)
+ }
+ }
+}
+
+func TestPTYTypingEchoAtMarginsAndWithWideCharacters(t *testing.T) {
+ for _, enabled := range []bool{false, true} {
+ emulator, err := new_emulator(3, 5)
+ if err != nil {
+ t.Fatal(err)
+ }
+ engine := reader.New()
+ engine.Configure_echo(enabled, false, false)
+ engine.Update(emulator.snapshot(true, false))
+ for _, char := range "abcdefghijklmnop q ēr" {
+ engine.Note_input(true, true, char == ' ')
+ emulator.feed([]byte(string(char)))
+ var want []presentation.Message
+ if enabled && char != ' ' {
+ want = []presentation.Message{presentation.Character_message(char)}
+ }
+ if got := engine.Update(emulator.snapshot(true, false)); !reflect.DeepEqual(got, want) {
+ t.Fatalf("echo=%v, char=%q: got %v, want %v", enabled, char, got, want)
+ }
+ }
+ emulator.close()
+ }
+}
+
+func TestProgressAfterTerminalScroll(t *testing.T) {
+ for _, mode := range []string{"percent", "spinner", "scroll region"} {
+ t.Run(mode, func(t *testing.T) {
+ emulator, err := new_emulator(79, 318)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer emulator.close()
+ engine := reader.New()
+ engine.Configure_progress(true)
+ // Fill the screen, then produce newline-terminated updates exactly as a
+ // script does at the bottom of the viewport. A fixed status row exercises
+ // a terminal multiplexer-style scrolling region too.
+ if mode == "scroll region" {
+ emulator.feed([]byte("\x1b[1;78r\x1b[79;1Hstatus\x1b[78;1H"))
+ }
+ for index := 0; index < 90; index++ {
+ emulator.feed([]byte(fmt.Sprintf("previous output %03d\r\n", index)))
+ }
+ engine.Update(emulator.snapshot(true, false))
+ now := time.Now()
+ for index := 1; index <= 3; index++ {
+ text := fmt.Sprintf("Progress %d%%\r\n", index*10)
+ want := float64(index * 10)
+ if mode == "spinner" {
+ text = string([]rune("ā ā ā ¹")[index-1]) + " Preparing files\r\n"
+ want = 50
+ }
+ emulator.feed([]byte(text))
+ engine.Update(emulator.snapshot(true, false))
+ if value, ok := engine.Next_progress(now.Add(time.Duration(index) * 2 * time.Second)); !ok || value != want {
+ t.Fatalf("scroll update %d: value=%v accepted=%v", index, value, ok)
+ }
+ }
+ })
+ }
+}
+
+func TestProgressIgnoresTerminalBackspaceAndDelete(t *testing.T) {
+ for _, key := range []string{"backspace", "delete"} {
+ t.Run(key, func(t *testing.T) {
+ emulator, err := new_emulator(3, 40)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer emulator.close()
+ engine := reader.New()
+ engine.Configure_progress(true)
+ emulator.feed([]byte("type 2% abc"))
+ if key == "delete" {
+ emulator.feed([]byte("\x1b[1;11H"))
+ }
+ engine.Update(emulator.snapshot(true, false))
+ engine.Note_input(true, false, false)
+ engine.Note_deletion()
+ if key == "backspace" {
+ emulator.feed([]byte("\b \b"))
+ } else {
+ emulator.feed([]byte("\x1b[P"))
+ }
+ output := engine.Update(emulator.snapshot(true, false))
+ if value, ok := engine.Next_progress(time.Now()); ok {
+ t.Fatal("deletion beeped", value)
+ }
+ if key == "backspace" && !reflect.DeepEqual(output, []presentation.Message{presentation.Character_message('c')}) {
+ t.Fatal("deletion feedback lost", output)
+ }
+ emulator.feed([]byte("\x1b[2;1HDownload 30%\x1b[1;11H"))
+ engine.Update(emulator.snapshot(true, false))
+ if value, ok := engine.Next_progress(time.Now()); !ok || value != 30 {
+ t.Fatal("background progress stopped", value, ok)
+ }
+ })
+ }
+}
+
+func TestPTYAttributesReportStylesPaletteRGBAndDefaults(t *testing.T) {
+ emulator, err := new_emulator(2, 30)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer emulator.close()
+ engine := reader.New()
+ engine.Configure_attributes("speech", "fenrirFGColor|fenrirBGColor|fenrirBold|fenrirUnderline|fenrirItalics|fenrirBlink|fenrirReverse|fenrirStrikethrough")
+ for _, trial := range []struct{ sgr, want string }{
+ {"1;4;3;5;7;9;31;44", "red|blue|bold|underline|italic|blink|reverse|strikethrough"},
+ {"38;5;196;48;5;232", "#ff0000|#080808||||||"},
+ {"38;2;12;34;56;48;2;65;43;21", "#0c2238|#412b15||||||"},
+ {"0", "default|default||||||"},
+ } {
+ emulator.feed([]byte("\x1b[H\x1b[0m\x1b[" + trial.sgr + "mX\x1b[0m\x1b[H"))
+ engine.Update(emulator.snapshot(true, false))
+ if got := engine.Command(command.AttributeCursor).Text; got != trial.want {
+ t.Fatal(trial, got)
+ }
+ }
+}
diff --git a/internal/terminal/keyboard_linux.go b/internal/terminal/keyboard_linux.go
new file mode 100644
index 00000000..ce36ec32
--- /dev/null
+++ b/internal/terminal/keyboard_linux.go
@@ -0,0 +1,204 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+//go:build linux && cgo
+
+package terminal
+
+/*
+#include "native.h"
+*/
+import "C"
+
+import (
+ "fmt"
+ "unsafe"
+
+ "git.stormux.org/storm/fenrir/internal/diagnostic"
+ "git.stormux.org/storm/fenrir/internal/input"
+)
+
+// Keyboard returns the input side of this session. Commands use exactly the
+// same router, modal capture and app dispatcher as evdev.
+func (s *Session) Keyboard() *Keyboard { return &Keyboard{session: s} }
+
+type Keyboard struct{ session *Session }
+
+func (k *Keyboard) Events() <-chan error { return k.session.inputEvents }
+func (k *Keyboard) Scan() error { return nil }
+func (k *Keyboard) Pass_next() { k.session.router.Pass_next() }
+func (k *Keyboard) Capture(enabled bool) error {
+ s := k.session
+ if enabled && !s.grabbed {
+ status, err := s.capture_keyboard()
+ if err != nil {
+ return err
+ }
+ if status != 0 {
+ return fmt.Errorf("X11 terminal capture unavailable (status %d)", status)
+ }
+ }
+ s.router.Capture(enabled)
+ if enabled {
+ // Bytes already replayed by xterm must not reach the child during a form.
+ s.pending = nil
+ s.pasteStart, s.pasteEnd = 0, 0
+ }
+ // The closing key is already in the router but may not yet be in s.held:
+ // Capture(false) can run from handle inside Poll. Keep that grab until its
+ // release rather than ungrabbing and regrabbing with an older timestamp.
+ if !enabled && !s.router.Held(0) && len(s.held) == 0 && s.grabbed {
+ C.terminal_x_release(s.x)
+ s.grabbed = false
+ }
+ return nil
+}
+func (k *Keyboard) Close() error {
+ C.terminal_x_release(k.session.x)
+ return nil
+}
+
+// capture_keyboard treats desktop/focus contention as a refused chord, not a
+// terminal-session failure. Keep modal intent while discarding stale key state.
+func (s *Session) capture_keyboard() (int, error) {
+ status := C.terminal_x_consume(s.x)
+ if err := native_error(status); err != nil {
+ return 0, fmt.Errorf("acquire X11 terminal keyboard: %w", err)
+ }
+ if status == 0 {
+ s.grabbed = true
+ return 0, nil
+ }
+ diagnostic.Record("X11 keyboard capture refused", "status", int(status))
+ C.terminal_x_release(s.x)
+ captured := s.router.Captured()
+ s.router.Reset()
+ s.router.Capture(captured)
+ clear(s.held)
+ clear(s.replayed)
+ s.grabbed = false
+ s.captureBlocked = true
+ return int(status), nil
+}
+
+func (k *Keyboard) Poll(active, numLock bool, handle func(input.Decision)) error {
+ s := k.session
+ for code, key := range s.replayed {
+ if C.terminal_x_down(s.x, C.int(code)) == 0 {
+ s.router.Key(0, key, 0, numLock, true)
+ delete(s.replayed, code)
+ }
+ }
+ for {
+ var event C.TerminalEvent
+ code := C.terminal_x_next(s.x, &event)
+ if code == 0 {
+ break
+ }
+ if err := native_error(code); err != nil {
+ return fmt.Errorf("X11 terminal input: %w", err)
+ }
+ if event._type != 1 {
+ continue
+ }
+ if s.captureBlocked {
+ // Do not reinterpret the tail of a refused Fenrir chord as a bare
+ // command after its modifier state was cleared. Drain until key-up.
+ C.terminal_x_release(s.x)
+ continue
+ }
+ if !active {
+ C.terminal_x_release(s.x)
+ continue
+ }
+ // ReplayKeyboard redelivers a forwarded press to normal event
+ // observers, including us. It must not interrupt speech twice.
+ if event.value == 1 && s.lastReplay.code == int(event.keycode) && s.lastReplay.time == uint64(event.time) {
+ continue
+ }
+ key := uint16(C.terminal_key_code(event.symbol, event.keycode))
+ if held, exists := s.held[int(event.keycode)]; exists {
+ key = held
+ }
+ // Synchronize standard modifiers that xterm received before our grab.
+ for _, modifier := range []struct {
+ key uint16
+ mask uint32
+ }{
+ {input.KeyShift, 1}, {input.KeyCtrl, 4}, {input.KeyAlt, 8},
+ } {
+ value := int32(0)
+ if uint32(event.state)&modifier.mask != 0 {
+ value = 1
+ }
+ if modifier.key != key {
+ s.router.Observe_modifier(1, modifier.key, value, numLock)
+ }
+ }
+ value := int32(event.value)
+ if value == 1 {
+ if _, exists := s.held[int(event.keycode)]; exists {
+ value = 2
+ }
+ }
+ decision := s.router.Key(0, key, value, numLock, false)
+ // Acquire before dispatch: a failed capture must not execute a command
+ // or open a form from a partial, stale chord.
+ if event.value == 1 && !decision.Forward && !s.grabbed {
+ status, err := s.capture_keyboard()
+ if err != nil {
+ return err
+ }
+ if status != 0 {
+ continue
+ }
+ }
+ if decision.UI != nil {
+ decision.UI.Text = C.GoStringN(&event.text[0], event.length)
+ // Control+A arrives as a control character from XLookupString.
+ if decision.UI.Ctrl && event.symbol >= 'a' && event.symbol <= 'z' {
+ decision.UI.Text = string(rune(event.symbol))
+ }
+ }
+ handle(decision)
+ if event.value == 1 {
+ if decision.Forward && !s.grabbed {
+ C.terminal_x_replay(s.x, event.time)
+ s.lastReplay.code, s.lastReplay.time = int(event.keycode), uint64(event.time)
+ s.replayed[int(event.keycode)] = key
+ } else {
+ s.held[int(event.keycode)] = key
+ if decision.Forward {
+ var bytes [256]byte
+ n := int(C.terminal_key(s.vt.native, &event, (*C.char)(unsafe.Pointer(&bytes[0])), C.int(len(bytes))))
+ s.pending = append(s.pending, bytes[:n]...)
+ }
+ }
+ } else {
+ delete(s.replayed, int(event.keycode))
+ delete(s.held, int(event.keycode))
+ if len(s.held) == 0 && s.grabbed && !s.router.Captured() {
+ C.terminal_x_release(s.x)
+ s.grabbed = false
+ }
+ }
+ }
+ if s.captureBlocked && C.terminal_x_keys_down(s.x) == 0 {
+ s.captureBlocked = false
+ }
+ return s.relay_input()
+}
diff --git a/internal/terminal/mutt_linux_test.go b/internal/terminal/mutt_linux_test.go
new file mode 100644
index 00000000..42a9c959
--- /dev/null
+++ b/internal/terminal/mutt_linux_test.go
@@ -0,0 +1,148 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+//go:build linux && cgo
+
+package terminal
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "syscall"
+ "testing"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/command"
+ "git.stormux.org/storm/fenrir/internal/reader"
+)
+
+// Uses an isolated local mailbox and no personal/system mutt configuration.
+// This verifies the actual application's terminal output, not audible speech.
+func TestMuttCursorNavigation(t *testing.T) {
+ if os.Getenv("FENRIR_MUTT_TEST") != "1" {
+ t.Skip("set FENRIR_MUTT_TEST=1 to exercise installed mutt with a private mailbox")
+ }
+ for _, mode := range []string{"cursor", "highlight"} {
+ t.Run(mode, func(t *testing.T) { test_mutt_navigation(t, mode) })
+ }
+}
+
+func test_mutt_navigation(t *testing.T, mode string) {
+ program, err := exec.LookPath("mutt")
+ if err != nil {
+ t.Fatal(err)
+ }
+ root := t.TempDir()
+ if err := os.Mkdir(filepath.Join(root, "Mail"), 0700); err != nil {
+ t.Fatal(err)
+ }
+ mailbox := filepath.Join(root, "mailbox")
+ var messages strings.Builder
+ for index := 1; index <= 30; index++ {
+ fmt.Fprintf(&messages, "From sender@example.invalid Mon Jan 1 00:00:00 2024\nFrom: Sender \nTo: Username \nDate: Mon, 1 Jan 2024 00:%02d:00 +0000\nSubject: Fixture message %02d\nMessage-ID: \n\nBody %d\n\n", index, index, index, index)
+ }
+ if err := os.WriteFile(mailbox, []byte(messages.String()), 0600); err != nil {
+ t.Fatal(err)
+ }
+ master, slave, err := open_pty(12, 80)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer syscall.Close(master)
+ slaveFile := os.NewFile(uintptr(slave), "mutt-pty")
+ defer slaveFile.Close()
+ child := exec.Command(program, "-n", "-F", "/dev/null", "-R", "-f", mailbox,
+ "-e", "set sort=date; set arrow_cursor=no; set index_format='%4C %s'; set timeout=60")
+ child.Env = []string{"HOME=" + root, "TERM=xterm-256color", "LC_ALL=C", "PATH=/usr/bin:/bin"}
+ child.Stdin, child.Stdout, child.Stderr = slaveFile, slaveFile, slaveFile
+ child.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true, Ctty: 0}
+ if err := child.Start(); err != nil {
+ t.Fatal(err)
+ }
+ defer func() { child.Process.Kill(); child.Wait() }()
+ if err := syscall.SetNonblock(master, true); err != nil {
+ t.Fatal(err)
+ }
+ emulator, err := new_emulator(12, 80)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer emulator.close()
+ drain := func() {
+ t.Helper()
+ deadline := time.Now().Add(3 * time.Second)
+ quietAt := time.Now()
+ var buffer [32768]byte
+ for time.Now().Before(deadline) {
+ count, err := syscall.Read(master, buffer[:])
+ if count > 0 {
+ emulator.feed(buffer[:count])
+ quietAt = time.Now()
+ } else if err != nil && !errors.Is(err, syscall.EAGAIN) {
+ t.Fatal(err)
+ } else if time.Since(quietAt) > 150*time.Millisecond {
+ return
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+ t.Fatal("mutt did not settle")
+ }
+ drain()
+ initial := emulator.snapshot(true, false)
+ if !strings.Contains(string(string(initial.Cells)), "Fixture message 01") {
+ t.Fatalf("mailbox did not open: %q", string(initial.Cells))
+ }
+ engine := reader.New()
+ engine.Configure_tracking(mode)
+ engine.Update(initial)
+ var selections []int
+ for index := 2; index <= 15; index++ {
+ selections = append(selections, index)
+ }
+ for index := 14; index >= 1; index-- {
+ selections = append(selections, index)
+ }
+ previousIndex := 1
+ for _, index := range selections {
+ // Reviewing a message must not suppress the next application arrow.
+ engine.Command(command.CurrentLine)
+ engine.Note_input(true, false, false)
+ arrow := "\x1bOB" // ncurses enables application cursor keys
+ if index < previousIndex {
+ arrow = "\x1bOA"
+ }
+ if _, err := syscall.Write(master, []byte(arrow)); err != nil {
+ t.Fatal(err)
+ }
+ drain()
+ next := emulator.snapshot(true, false)
+ output := engine.Update(next)
+ var spoken strings.Builder
+ for _, message := range output {
+ spoken.WriteString(message.Text)
+ }
+ if !strings.Contains(spoken.String(), fmt.Sprintf("Fixture message %02d", index)) {
+ t.Fatalf("selection %d cursor (%d,%d) -> (%d,%d): output %v", index, initial.X, initial.Y, next.X, next.Y, output)
+ }
+ initial = next
+ previousIndex = index
+ }
+}
diff --git a/internal/terminal/native.c b/internal/terminal/native.c
new file mode 100644
index 00000000..9260caaf
--- /dev/null
+++ b/internal/terminal/native.c
@@ -0,0 +1,477 @@
+#define _DEFAULT_SOURCE
+#include "native.h"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+int terminal_pty(int *master, int *slave, int rows, int cols) {
+ struct winsize size = {.ws_row = rows, .ws_col = cols};
+ if (openpty(master, slave, NULL, NULL, &size) < 0) return -errno;
+ fcntl(*master, F_SETFD, FD_CLOEXEC);
+ fcntl(*slave, F_SETFD, FD_CLOEXEC);
+ return 0;
+}
+int terminal_raw(int fd, struct termios *saved, int *flags) {
+ if (tcgetattr(fd, saved) < 0) return -errno;
+ *flags = fcntl(fd, F_GETFL);
+ if (*flags < 0) return -errno;
+ struct termios raw = *saved;
+ cfmakeraw(&raw);
+ if (tcsetattr(fd, TCSANOW, &raw) < 0) return -errno;
+ if (fcntl(fd, F_SETFL, *flags | O_NONBLOCK) < 0) {
+ int error = errno;
+ tcsetattr(fd, TCSANOW, saved);
+ return -error;
+ }
+ return 0;
+}
+int terminal_restore(int fd, struct termios *saved, int flags) {
+ int result = 0;
+ if (tcsetattr(fd, TCSANOW, saved) < 0) result = -errno;
+ if (fcntl(fd, F_SETFL, flags) < 0 && !result) result = -errno;
+ return result;
+}
+int terminal_size(int fd, int *rows, int *cols) {
+ struct winsize size;
+ if (ioctl(fd, TIOCGWINSZ, &size) < 0) return -errno;
+ *rows = size.ws_row;
+ *cols = size.ws_col;
+ return 0;
+}
+int terminal_resize(int fd, int rows, int cols) {
+ struct winsize size = {.ws_row = rows, .ws_col = cols};
+ return ioctl(fd, TIOCSWINSZ, &size) < 0 ? -errno : 0;
+}
+int terminal_wait(int master, int input, int output, int xfd, int wake, int writing, int output_pending) {
+ struct pollfd fds[] = {
+ {.fd=output_pending && !writing ? -1 : master,
+ .events=(output_pending ? 0 : POLLIN) | (writing ? POLLOUT : 0)},
+ {.fd=writing ? -1 : input, .events=POLLIN}, {.fd=xfd, .events=POLLIN},
+ {.fd=wake, .events=POLLIN},
+ {.fd=output_pending ? output : -1, .events=POLLOUT}
+ };
+ int result;
+ do { result = poll(fds, 5, -1); } while (result < 0 && errno == EINTR);
+ if (result < 0) return -errno;
+ if (fds[3].revents) return 1;
+ if (fds[2].revents & (POLLERR | POLLHUP | POLLNVAL)) return -EPIPE;
+ return (fds[0].revents || fds[4].revents ? 2 : 0) |
+ (fds[1].revents || fds[2].revents || (fds[0].revents & POLLOUT) ? 4 : 0);
+}
+
+static int terminal_property(VTermProp prop, VTermValue *value, void *user) {
+ (void)value;
+ if (prop == VTERM_PROP_ALTSCREEN) (*(uint64_t *)user)++;
+ return 1;
+}
+static const VTermScreenCallbacks screen_callbacks = {.settermprop=terminal_property};
+VTerm *terminal_vterm(int rows, int cols) {
+ VTerm *vt = vterm_new(rows, cols);
+ if (!vt) return NULL;
+ vterm_set_utf8(vt, 1);
+ VTermScreen *screen = vterm_obtain_screen(vt);
+ vterm_screen_enable_altscreen(screen, 1);
+ vterm_screen_reset(screen, 1);
+ uint64_t *generation = calloc(1, sizeof(*generation));
+ if (!generation) { vterm_free(vt); return NULL; }
+ vterm_screen_set_callbacks(screen, &screen_callbacks, generation);
+ return vt;
+}
+void terminal_vterm_free(VTerm *vt) {
+ free(vterm_screen_get_cbdata(vterm_obtain_screen(vt)));
+ vterm_free(vt);
+}
+uint64_t terminal_generation(VTerm *vt) {
+ return *(uint64_t *)vterm_screen_get_cbdata(vterm_obtain_screen(vt));
+}
+static uint32_t cell_color(VTermScreen *screen, VTermColor color) {
+ uint32_t defaults = color.type & (VTERM_COLOR_DEFAULT_FG | VTERM_COLOR_DEFAULT_BG);
+ // Match screen.ColorDefault/ColorIndexed; preserve palette identity rather
+ // than guessing a named color from its current RGB value.
+ if (defaults) return UINT32_C(1) << 31;
+ if (VTERM_COLOR_IS_INDEXED(&color)) return (UINT32_C(1) << 30) | color.indexed.idx;
+ vterm_screen_convert_color_to_rgb(screen, &color);
+ return (color.rgb.red << 16) | (color.rgb.green << 8) | color.rgb.blue;
+}
+void terminal_cells(VTerm *vt, uint32_t *cells, TerminalAttribute *attributes, int count, int *x, int *y) {
+ int rows, cols;
+ vterm_get_size(vt, &rows, &cols);
+ VTermScreen *screen = vterm_obtain_screen(vt);
+ vterm_screen_flush_damage(screen);
+ for (int i = 0; i < count && i < rows * cols; i++) {
+ VTermScreenCell cell;
+ memset(&cell, 0, sizeof(cell));
+ vterm_screen_get_cell(screen, (VTermPos){.row=i/cols, .col=i%cols}, &cell);
+ // A wide glyph's continuation occupies one cell, without repeating it.
+ cells[i] = cell.chars[0] && cell.chars[0] != (uint32_t)-1 ? cell.chars[0] : ' ';
+ attributes[i] = (TerminalAttribute){
+ .foreground = cell_color(screen, cell.fg),
+ .background = cell_color(screen, cell.bg),
+ .style = cell.attrs.bold | (cell.attrs.underline << 1) |
+ (cell.attrs.italic << 3) | (cell.attrs.blink << 4) |
+ (cell.attrs.reverse << 5) | (cell.attrs.strike << 6)
+ };
+ }
+ VTermPos cursor;
+ vterm_state_get_cursorpos(vterm_obtain_state(vt), &cursor);
+ *x = cursor.col;
+ *y = cursor.row;
+}
+int terminal_key(VTerm *vt, const TerminalEvent *event, char *bytes, int size) {
+ VTermKey key = VTERM_KEY_NONE;
+ switch (event->symbol) {
+ case XK_Return: key=VTERM_KEY_ENTER; break;
+ case XK_Tab: case XK_ISO_Left_Tab: key=VTERM_KEY_TAB; break;
+ case XK_BackSpace: key=VTERM_KEY_BACKSPACE; break;
+ case XK_Escape: key=VTERM_KEY_ESCAPE; break;
+ case XK_Up: key=VTERM_KEY_UP; break;
+ case XK_Down: key=VTERM_KEY_DOWN; break;
+ case XK_Left: key=VTERM_KEY_LEFT; break;
+ case XK_Right: key=VTERM_KEY_RIGHT; break;
+ case XK_Insert: key=VTERM_KEY_INS; break;
+ case XK_Delete: key=VTERM_KEY_DEL; break;
+ case XK_Home: key=VTERM_KEY_HOME; break;
+ case XK_End: key=VTERM_KEY_END; break;
+ case XK_Page_Up: key=VTERM_KEY_PAGEUP; break;
+ case XK_Page_Down: key=VTERM_KEY_PAGEDOWN; break;
+ case XK_KP_Enter: key=VTERM_KEY_KP_ENTER; break;
+ case XK_KP_Insert: key=VTERM_KEY_INS; break;
+ case XK_KP_Delete: key=VTERM_KEY_DEL; break;
+ case XK_KP_Home: key=VTERM_KEY_HOME; break;
+ case XK_KP_End: key=VTERM_KEY_END; break;
+ case XK_KP_Up: key=VTERM_KEY_UP; break;
+ case XK_KP_Down: key=VTERM_KEY_DOWN; break;
+ case XK_KP_Left: key=VTERM_KEY_LEFT; break;
+ case XK_KP_Right: key=VTERM_KEY_RIGHT; break;
+ case XK_KP_Page_Up: key=VTERM_KEY_PAGEUP; break;
+ case XK_KP_Page_Down: key=VTERM_KEY_PAGEDOWN; break;
+ default:
+ if (event->symbol >= XK_F1 && event->symbol <= XK_F35)
+ key = VTERM_KEY_FUNCTION(event->symbol - XK_F1 + 1);
+ }
+ // Output queries are answered by the outer terminal. Never send a second
+ // response from the reading emulator to the child.
+ while (vterm_output_read(vt, bytes, size)) {}
+ if (key != VTERM_KEY_NONE) {
+ VTermModifier modifiers = VTERM_MOD_NONE;
+ if (event->state & ShiftMask) modifiers |= VTERM_MOD_SHIFT;
+ if (event->state & ControlMask) modifiers |= VTERM_MOD_CTRL;
+ if (event->state & Mod1Mask) modifiers |= VTERM_MOD_ALT;
+ vterm_keyboard_key(vt, key, modifiers);
+ return vterm_output_read(vt, bytes, size);
+ }
+ int length = event->length < size ? event->length : size;
+ memcpy(bytes, event->text, length);
+ return length;
+}
+
+struct TerminalX { Display *display; Window window; unsigned int numlock; int grabbed; XIM im; XIC ic; int scriptKeys[KEY_MAX+1]; int scriptKeyCount; };
+static int x_error;
+static int trap_error(Display *display, XErrorEvent *event) {
+ (void)display;
+ x_error = event->error_code;
+ return 0;
+}
+static XErrorHandler begin_trap(Display *display) {
+ XSync(display, False);
+ x_error = 0;
+ return XSetErrorHandler(trap_error);
+}
+static int end_trap(Display *display, XErrorHandler previous) {
+ XSync(display, False);
+ XSetErrorHandler(previous);
+ return x_error;
+}
+static void observe_windows(TerminalX *x, Window window, int depth, int *remaining) {
+ if (depth > 16 || --*remaining < 0) { x_error = BadAlloc; return; }
+ // Xterm's WINDOWID can be its outer window while ordinary keys are
+ // delivered to the VT widget below it. Observe the whole target subtree.
+ XSelectInput(x->display, window, KeyPressMask | KeyReleaseMask |
+ FocusChangeMask | StructureNotifyMask | SubstructureNotifyMask);
+ Window root, parent, *children=NULL;
+ unsigned int count;
+ if (XQueryTree(x->display, window, &root, &parent, &children, &count)) {
+ for (unsigned int i=0; i0; i++)
+ observe_windows(x, children[i], depth+1, remaining);
+ }
+ if (children) XFree(children);
+}
+static void install_grabs(TerminalX *x) {
+ Window window = x->window;
+ x->numlock = 0;
+ XModifierKeymap *modifiers = XGetModifierMapping(x->display);
+ KeyCode numlock = XKeysymToKeycode(x->display, XK_Num_Lock);
+ if (modifiers) {
+ for (int mask=0; mask<8; mask++)
+ for (int k=0; kmax_keypermod; k++)
+ if (numlock && modifiers->modifiermap[mask*modifiers->max_keypermod+k] == numlock)
+ x->numlock |= 1u << mask;
+ XFreeModifiermap(modifiers);
+ }
+ KeySym keys[] = {XK_Insert, XK_Super_L, XK_Super_R, XK_Meta_L, XK_Meta_R,
+ XK_KP_Insert, XK_KP_End, XK_KP_Down, XK_KP_Page_Down, XK_KP_Left,
+ XK_KP_Begin, XK_KP_Right, XK_KP_Home, XK_KP_Up, XK_KP_Page_Up,
+ XK_KP_Delete, XK_KP_Enter, XK_KP_Add, XK_KP_Subtract, XK_KP_Divide,
+ XK_Control_L, XK_Control_R};
+ for (unsigned int i=0; idisplay, keys[i]);
+ if (code) XGrabKey(x->display, code, AnyModifier, window, False, GrabModeAsync, GrabModeSync);
+ }
+ // Script modifiers may be any Linux key, not just Compose. Resolve through
+ // the same symbol translation as event routing instead of assuming that
+ // the X server always uses evdev keycodes plus eight.
+ int first, last;
+ XDisplayKeycodes(x->display, &first, &last);
+ for (int code=first; code<=last; code++) {
+ KeySym symbol = XkbKeycodeToKeysym(x->display, code, 0, 0);
+ int key = terminal_key_code(symbol, code);
+ for (int i=0; iscriptKeyCount; i++) {
+ if (key == x->scriptKeys[i]) {
+ XGrabKey(x->display, code, AnyModifier, window, False, GrabModeAsync, GrabModeSync);
+ break;
+ }
+ }
+ }
+}
+int terminal_x_script_keys(TerminalX *x, const int *keys, int count) {
+ if (count < 0 || count > KEY_MAX+1) return -EINVAL;
+ XErrorHandler previous = begin_trap(x->display);
+ x->scriptKeyCount = count;
+ for (int i=0; iscriptKeys[i] = keys[i];
+ install_grabs(x);
+ int error = end_trap(x->display, previous);
+ return error ? -error : 0;
+}
+TerminalX *terminal_x_open(unsigned long window, int *error) {
+ TerminalX *x = calloc(1, sizeof(*x));
+ if (!x) { *error = ENOMEM; return NULL; }
+ x->display = XOpenDisplay(NULL);
+ if (!x->display) { *error = ENXIO; free(x); return NULL; }
+ x->window = window;
+ XErrorHandler previous = begin_trap(x->display);
+ XWindowAttributes attributes;
+ if (!XGetWindowAttributes(x->display, window, &attributes) ||
+ attributes.map_state != IsViewable || window == attributes.root) {
+ end_trap(x->display, previous);
+ *error = BadWindow;
+ terminal_x_close(x);
+ return NULL;
+ }
+ // Observing ordinary keys does not consume them. Only passive grabs and
+ // an active reader chord withhold events from xterm.
+ int remaining = 1024;
+ observe_windows(x, window, 0, &remaining);
+ install_grabs(x);
+ Bool supported;
+ XkbSetDetectableAutoRepeat(x->display, True, &supported);
+ if (!supported) x_error = BadImplementation;
+ int failure = end_trap(x->display, previous);
+ if (failure) { *error=failure; terminal_x_close(x); return NULL; }
+ setlocale(LC_CTYPE, "");
+ XSetLocaleModifiers("");
+ x->im = XOpenIM(x->display, NULL, NULL, NULL);
+ if (x->im) x->ic = XCreateIC(x->im, XNInputStyle, XIMPreeditNothing | XIMStatusNothing,
+ XNClientWindow, window, XNFocusWindow, window, NULL);
+ return x;
+}
+void terminal_x_release(TerminalX *x) {
+ XUngrabKeyboard(x->display, CurrentTime);
+ XAllowEvents(x->display, AsyncKeyboard, CurrentTime);
+ XFlush(x->display);
+ x->grabbed = 0;
+}
+void terminal_x_close(TerminalX *x) {
+ if (!x) return;
+ terminal_x_release(x);
+ XErrorHandler previous = begin_trap(x->display);
+ XUngrabKey(x->display, AnyKey, AnyModifier, x->window);
+ end_trap(x->display, previous);
+ if (x->ic) XDestroyIC(x->ic);
+ if (x->im) XCloseIM(x->im);
+ XCloseDisplay(x->display);
+ free(x);
+}
+int terminal_x_fd(TerminalX *x) { return ConnectionNumber(x->display); }
+int terminal_x_pending(TerminalX *x) { return XPending(x->display); }
+int terminal_x_down(TerminalX *x, int code) {
+ char keys[32];
+ XQueryKeymap(x->display, keys);
+ return (unsigned char)keys[code/8] & (1u << (code%8));
+}
+int terminal_x_keys_down(TerminalX *x) {
+ char keys[32];
+ XQueryKeymap(x->display, keys);
+ for (int i=0; i<32; i++) if (keys[i]) return 1;
+ return 0;
+}
+int terminal_x_state(TerminalX *x, int *active, int *numlock, int *capslock) {
+ XErrorHandler previous = begin_trap(x->display);
+ Window focus;
+ int revert;
+ XGetInputFocus(x->display, &focus, &revert);
+ *active = 0;
+ for (int depth=0; focus != None && focus != PointerRoot && depth<64; depth++) {
+ if (focus == x->window) { *active=1; break; }
+ Window root, parent, *children=NULL;
+ unsigned int count;
+ if (!XQueryTree(x->display, focus, &root, &parent, &children, &count)) break;
+ if (children) XFree(children);
+ if (focus == parent || focus == root) break;
+ focus = parent;
+ }
+ XkbStateRec state;
+ int haveState = XkbGetState(x->display, XkbUseCoreKbd, &state) == Success;
+ *numlock = haveState && (state.mods & x->numlock);
+ *capslock = haveState && (state.locked_mods & LockMask);
+ int error = end_trap(x->display, previous);
+ // The focused window belongs to another client. It can disappear between
+ // XGetInputFocus and XQueryTree while a dialog closes or focus changes.
+ // Treat that race as inactive; the next query will find the new focus.
+ if (error == BadWindow) { *active = 0; return 0; }
+ return error ? -EIO : 0;
+}
+int terminal_x_next(TerminalX *x, TerminalEvent *event) {
+ if (!XPending(x->display)) return 0;
+ XEvent native;
+ XNextEvent(x->display, &native);
+ memset(event, 0, sizeof(*event));
+ if (native.type == DestroyNotify) return native.xdestroywindow.window == x->window ? -EPIPE : 1;
+ if (native.type == CreateNotify) {
+ XErrorHandler previous = begin_trap(x->display);
+ int remaining = 1024;
+ observe_windows(x, native.xcreatewindow.window, 0, &remaining);
+ return end_trap(x->display, previous) ? -EIO : 1;
+ }
+ if (native.type == MappingNotify) {
+ XRefreshKeyboardMapping(&native.xmapping);
+ XErrorHandler previous = begin_trap(x->display);
+ XUngrabKey(x->display, AnyKey, AnyModifier, x->window);
+ install_grabs(x);
+ return end_trap(x->display, previous) ? -EBUSY : 1;
+ }
+ if (native.type != KeyPress && native.type != KeyRelease) return 1;
+ event->type = 1;
+ event->keycode = native.xkey.keycode;
+ event->value = native.type == KeyPress;
+ event->state = native.xkey.state;
+ event->time = native.xkey.time;
+ // The unshifted symbol identifies bindings independently of CapsLock.
+ event->symbol = XLookupKeysym(&native.xkey, 0);
+ if (native.type == KeyPress) {
+ KeySym translated;
+ if (x->ic) {
+ Status status;
+ event->length = Xutf8LookupString(x->ic, &native.xkey, event->text, sizeof(event->text), &translated, &status);
+ if (status == XBufferOverflow) event->length = 0;
+ } else event->length = XLookupString(&native.xkey, event->text, sizeof(event->text), &translated, NULL);
+ // Keypad navigation/numeric meaning depends on NumLock and Shift.
+ if (event->symbol >= XK_KP_Space && event->symbol <= XK_KP_Equal) event->symbol = translated;
+ }
+ return 1;
+}
+int terminal_x_consume(TerminalX *x) {
+ if (!x->grabbed) {
+ int active, numlock, capslock;
+ int error = terminal_x_state(x, &active, &numlock, &capslock);
+ if (error < 0) return error;
+ if (!active) return TERMINAL_CAPTURE_INACTIVE;
+ XErrorHandler previous = begin_trap(x->display);
+ // A queued key can predate a desktop grab or our own release. Its
+ // timestamp then fails with GrabInvalidTime even after focus returns.
+ // Recheck focus above and acquire at the current server time.
+ int result = XGrabKeyboard(x->display, x->window, False, GrabModeAsync, GrabModeAsync, CurrentTime);
+ error = end_trap(x->display, previous);
+ if (error == BadWindow) return GrabNotViewable;
+ if (error) return -EIO;
+ if (result != GrabSuccess) return result;
+ x->grabbed = 1;
+ }
+ XAllowEvents(x->display, AsyncKeyboard, CurrentTime);
+ XFlush(x->display);
+ return 0;
+}
+void terminal_x_replay(TerminalX *x, unsigned long time) {
+ XAllowEvents(x->display, ReplayKeyboard, time);
+ XFlush(x->display);
+}
+int terminal_key_code(unsigned long symbol, int keycode) {
+ // Translate every letter/function key, so adding a shared binding (such
+ // as Fenrir+F) never requires a second command map in this adapter.
+ static const int letters[] = {KEY_A, KEY_B, KEY_C, KEY_D, KEY_E, KEY_F,
+ KEY_G, KEY_H, KEY_I, KEY_J, KEY_K, KEY_L, KEY_M, KEY_N, KEY_O, KEY_P,
+ KEY_Q, KEY_R, KEY_S, KEY_T, KEY_U, KEY_V, KEY_W, KEY_X, KEY_Y, KEY_Z};
+ if (symbol >= XK_a && symbol <= XK_z) return letters[symbol-XK_a];
+ if (symbol >= XK_A && symbol <= XK_Z) return letters[symbol-XK_A];
+ if (symbol >= XK_1 && symbol <= XK_9) return KEY_1 + symbol-XK_1;
+ if (symbol >= XK_F1 && symbol <= XK_F10) return KEY_F1 + symbol-XK_F1;
+ if (symbol == XK_F11) return KEY_F11;
+ if (symbol == XK_F12) return KEY_F12;
+ switch (symbol) {
+ case XK_0: return KEY_0;
+ case XK_Escape: return KEY_ESC;
+ case XK_Return: return KEY_ENTER;
+ case XK_Tab: return KEY_TAB;
+ case XK_space: return KEY_SPACE;
+ case XK_minus: return KEY_MINUS;
+ case XK_equal: return KEY_EQUAL;
+ case XK_bracketleft: return KEY_LEFTBRACE;
+ case XK_bracketright: return KEY_RIGHTBRACE;
+ case XK_backslash: return KEY_BACKSLASH;
+ case XK_semicolon: return KEY_SEMICOLON;
+ case XK_apostrophe: return KEY_APOSTROPHE;
+ case XK_grave: return KEY_GRAVE;
+ case XK_comma: return KEY_COMMA;
+ case XK_period: return KEY_DOT;
+ case XK_slash: return KEY_SLASH;
+ case XK_Up: return KEY_UP;
+ case XK_Down: return KEY_DOWN;
+ case XK_Left: return KEY_LEFT;
+ case XK_Right: return KEY_RIGHT;
+ case XK_Home: return KEY_HOME;
+ case XK_End: return KEY_END;
+ case XK_Delete: return KEY_DELETE;
+ case XK_Page_Up: return KEY_PAGEUP;
+ case XK_Page_Down: return KEY_PAGEDOWN;
+ case XK_Insert: return KEY_INSERT;
+ case XK_Multi_key: return KEY_COMPOSE;
+ case XK_Menu: return KEY_MENU;
+ case XK_Super_L: case XK_Meta_L: return KEY_LEFTMETA;
+ case XK_Super_R: case XK_Meta_R: return KEY_RIGHTMETA;
+ case XK_Control_L: return KEY_LEFTCTRL;
+ case XK_Control_R: return KEY_RIGHTCTRL;
+ case XK_Shift_L: return KEY_LEFTSHIFT;
+ case XK_Shift_R: return KEY_RIGHTSHIFT;
+ case XK_Alt_L: return KEY_LEFTALT;
+ case XK_Alt_R: case XK_ISO_Level3_Shift: return KEY_RIGHTALT;
+ case XK_BackSpace: return KEY_BACKSPACE;
+ case XK_KP_Insert: case XK_KP_0: return KEY_KP0;
+ case XK_KP_End: case XK_KP_1: return KEY_KP1;
+ case XK_KP_Down: case XK_KP_2: return KEY_KP2;
+ case XK_KP_Page_Down: case XK_KP_3: return KEY_KP3;
+ case XK_KP_Left: case XK_KP_4: return KEY_KP4;
+ case XK_KP_Begin: case XK_KP_5: return KEY_KP5;
+ case XK_KP_Right: case XK_KP_6: return KEY_KP6;
+ case XK_KP_Home: case XK_KP_7: return KEY_KP7;
+ case XK_KP_Up: case XK_KP_8: return KEY_KP8;
+ case XK_KP_Page_Up: case XK_KP_9: return KEY_KP9;
+ case XK_KP_Delete: case XK_KP_Decimal: return KEY_KPDOT;
+ case XK_KP_Enter: return KEY_KPENTER;
+ case XK_KP_Add: return KEY_KPPLUS;
+ case XK_KP_Subtract: return KEY_KPMINUS;
+ case XK_KP_Multiply: return KEY_KPASTERISK;
+ case XK_KP_Divide: return KEY_KPSLASH;
+ // Xorg's evdev keymap uses Linux codes plus eight for otherwise unknown
+ // symbols (multimedia/international keys). Known symbols above take priority.
+ default: return keycode >= 8 && keycode <= KEY_MAX+8 ? keycode-8 : KEY_MAX+1+keycode;
+ }
+}
diff --git a/internal/terminal/native.h b/internal/terminal/native.h
new file mode 100644
index 00000000..e95b763e
--- /dev/null
+++ b/internal/terminal/native.h
@@ -0,0 +1,44 @@
+#ifndef FENRIR_TERMINAL_NATIVE_H
+#define FENRIR_TERMINAL_NATIVE_H
+#include
+#include
+#include
+
+typedef struct TerminalX TerminalX;
+typedef struct {
+ int type, keycode, value;
+ unsigned int state;
+ unsigned long symbol, time;
+ char text[128];
+ int length;
+} TerminalEvent;
+
+int terminal_pty(int *master, int *slave, int rows, int cols);
+int terminal_raw(int fd, struct termios *saved, int *flags);
+int terminal_restore(int fd, struct termios *saved, int flags);
+int terminal_size(int fd, int *rows, int *cols);
+int terminal_resize(int fd, int rows, int cols);
+int terminal_wait(int master, int input, int output, int xfd, int wake, int writing, int output_pending);
+VTerm *terminal_vterm(int rows, int cols);
+void terminal_vterm_free(VTerm *vt);
+uint64_t terminal_generation(VTerm *vt);
+typedef struct { uint32_t foreground, background, style; } TerminalAttribute;
+void terminal_cells(VTerm *vt, uint32_t *cells, TerminalAttribute *attributes, int count, int *x, int *y);
+int terminal_key(VTerm *vt, const TerminalEvent *event, char *bytes, int size);
+TerminalX *terminal_x_open(unsigned long window, int *error);
+int terminal_x_script_keys(TerminalX *x, const int *keys, int count);
+void terminal_x_close(TerminalX *x);
+int terminal_x_fd(TerminalX *x);
+int terminal_x_down(TerminalX *x, int code);
+int terminal_x_keys_down(TerminalX *x);
+int terminal_x_pending(TerminalX *x);
+int terminal_x_state(TerminalX *x, int *active, int *numlock, int *capslock);
+int terminal_x_next(TerminalX *x, TerminalEvent *event);
+enum { TERMINAL_CAPTURE_INACTIVE = 5 };
+// Zero means captured; positive values are X grab refusal statuses or
+// TERMINAL_CAPTURE_INACTIVE, distinct from GrabSuccess through GrabFrozen (0..4).
+int terminal_x_consume(TerminalX *x);
+void terminal_x_replay(TerminalX *x, unsigned long time);
+void terminal_x_release(TerminalX *x);
+int terminal_key_code(unsigned long symbol, int keycode);
+#endif
diff --git a/internal/terminal/output_linux_test.go b/internal/terminal/output_linux_test.go
new file mode 100644
index 00000000..8ebefe02
--- /dev/null
+++ b/internal/terminal/output_linux_test.go
@@ -0,0 +1,82 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+//go:build linux && cgo
+
+package terminal
+
+import (
+ "bytes"
+ "errors"
+ "os"
+ "syscall"
+ "testing"
+)
+
+func TestTerminalOutputBackpressurePreservesBytes(t *testing.T) {
+ var pipeFds [2]int
+ if err := syscall.Pipe2(pipeFds[:], syscall.O_NONBLOCK|syscall.O_CLOEXEC); err != nil {
+ t.Fatal(err)
+ }
+ defer func() {
+ if pipeFds[0] >= 0 {
+ syscall.Close(pipeFds[0])
+ }
+ }()
+ output := os.NewFile(uintptr(pipeFds[1]), "terminal-output")
+ defer output.Close()
+ want := bytes.Repeat([]byte("\x1b[1mredraw Ć©ē\x1b[0m\r\n"), 16384)
+ session := &Session{output: output, outputPending: bytes.Clone(want)}
+ if err := session.flush_output(); err != nil {
+ t.Fatal(err)
+ }
+ remaining := len(session.outputPending)
+ if remaining == 0 || remaining == len(want) {
+ t.Fatalf("expected partial write, %d of %d bytes remain", remaining, len(want))
+ }
+ if err := session.flush_output(); err != nil || len(session.outputPending) != remaining {
+ t.Fatalf("full output must defer without losing data: remaining=%d err=%v", len(session.outputPending), err)
+ }
+ var got []byte
+ var buffer [4096]byte
+ for {
+ n, err := syscall.Read(pipeFds[0], buffer[:])
+ if n > 0 {
+ got = append(got, buffer[:n]...)
+ }
+ if errors.Is(err, syscall.EAGAIN) {
+ if len(session.outputPending) == 0 {
+ break
+ }
+ if err := session.flush_output(); err != nil {
+ t.Fatal(err)
+ }
+ } else if err != nil {
+ t.Fatal(err)
+ }
+ }
+ if !bytes.Equal(got, want) {
+ t.Fatalf("terminal output changed: got %d bytes, want %d", len(got), len(want))
+ }
+ // A closed terminal remains an error; only temporary fullness is retried.
+ syscall.Close(pipeFds[0])
+ pipeFds[0] = -1
+ session.outputPending = []byte("closed")
+ if err := session.flush_output(); !errors.Is(err, syscall.EPIPE) {
+ t.Fatalf("closed output: %v", err)
+ }
+}
diff --git a/internal/terminal/paste_linux.go b/internal/terminal/paste_linux.go
new file mode 100644
index 00000000..79e7f9c3
--- /dev/null
+++ b/internal/terminal/paste_linux.go
@@ -0,0 +1,74 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+//go:build linux && cgo
+
+package terminal
+
+/*
+#include "native.h"
+*/
+import "C"
+
+import (
+ "fmt"
+ "unsafe"
+
+ "git.stormux.org/storm/fenrir/internal/clipboard"
+)
+
+// Queue_paste belongs to the app loop. The existing nonblocking PTY relay
+// drains it, preserving byte order and the child application's paste mode.
+func (s *Session) Queue_paste(text string) error {
+ if err := clipboard.Validate_text(text); err != nil {
+ return err
+ }
+ if !s.active || s.eof || s.closed {
+ return fmt.Errorf("clipboard paste: terminal inactive")
+ }
+ if len(s.pending)+len(text) > clipboard.MaxBytes {
+ return fmt.Errorf("clipboard paste queue full")
+ }
+ if s.pasteEnd > 0 {
+ return fmt.Errorf("clipboard paste already queued")
+ }
+ s.pasteStart = len(s.pending)
+ s.pasteGeneration = uint64(C.terminal_generation(s.vt.native))
+ var bytes [32]byte
+ C.vterm_keyboard_start_paste(s.vt.native)
+ n := C.vterm_output_read(s.vt.native, (*C.char)(unsafe.Pointer(&bytes[0])), C.size_t(len(bytes)))
+ s.pending = append(s.pending, bytes[:int(n)]...)
+ s.pending = append(s.pending, text...)
+ C.vterm_keyboard_end_paste(s.vt.native)
+ n = C.vterm_output_read(s.vt.native, (*C.char)(unsafe.Pointer(&bytes[0])), C.size_t(len(bytes)))
+ s.pending = append(s.pending, bytes[:int(n)]...)
+ s.pasteEnd = len(s.pending)
+ s.arm_watch()
+ return nil
+}
+
+func (s *Session) retire_paste() {
+ if s.pasteEnd == 0 {
+ return
+ }
+ if s.active && s.pasteGeneration == uint64(C.terminal_generation(s.vt.native)) {
+ return
+ }
+ copy(s.pending[s.pasteStart:], s.pending[s.pasteEnd:])
+ s.pending = s.pending[:len(s.pending)-(s.pasteEnd-s.pasteStart)]
+ s.pasteStart, s.pasteEnd = 0, 0
+}
diff --git a/internal/terminal/paste_linux_test.go b/internal/terminal/paste_linux_test.go
new file mode 100644
index 00000000..97d40247
--- /dev/null
+++ b/internal/terminal/paste_linux_test.go
@@ -0,0 +1,63 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+//go:build linux && cgo
+
+package terminal
+
+import (
+ "testing"
+)
+
+func TestQueuedPasteUsesChildBracketedModeAndRetiresWithTarget(t *testing.T) {
+ for _, bracketed := range []bool{false, true} {
+ vt, err := new_emulator(2, 20)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if bracketed {
+ vt.feed([]byte("\x1b[?2004h"))
+ }
+ session := &Session{vt: vt, active: true, pending: []byte("before"), arm: make(chan waitState, 1)}
+ if err := session.Queue_paste("Ć©ē\n"); err != nil {
+ t.Fatal(err)
+ }
+ want := "beforeĆ©ē\n"
+ if bracketed {
+ want = "before\x1b[200~Ć©ē\n\x1b[201~"
+ }
+ if string(session.pending) != want {
+ t.Fatalf("bracketed=%v: %q", bracketed, session.pending)
+ }
+ session.pending = append(session.pending, "after"...)
+ session.active = false
+ session.retire_paste()
+ if string(session.pending) != "beforeafter" {
+ t.Fatalf("paste survived focus loss: %q", session.pending)
+ }
+ session.active = true
+ if err := session.Queue_paste("old buffer"); err != nil {
+ t.Fatal(err)
+ }
+ vt.feed([]byte("\x1b[?1049h"))
+ session.retire_paste()
+ if string(session.pending) != "beforeafter" {
+ t.Fatal("paste survived screen replacement")
+ }
+ vt.close()
+ }
+}
diff --git a/internal/terminal/pty_linux.go b/internal/terminal/pty_linux.go
new file mode 100644
index 00000000..64ff4be1
--- /dev/null
+++ b/internal/terminal/pty_linux.go
@@ -0,0 +1,211 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+//go:build linux && cgo
+
+package terminal
+
+/*
+#include "native.h"
+*/
+import "C"
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "syscall"
+
+ "git.stormux.org/storm/fenrir/internal/screen"
+)
+
+func open_pty(rows, cols int) (int, int, error) {
+ var master, slave C.int
+ err := native_error(C.terminal_pty(&master, &slave, C.int(rows), C.int(cols)))
+ return int(master), int(slave), err
+}
+
+type waitState struct {
+ inputPending, outputPending bool
+}
+
+// The app loop replaces stale requests before handing readiness back to watch.
+func (s *Session) arm_watch() {
+ select {
+ case <-s.arm:
+ default:
+ }
+ s.arm <- waitState{inputPending: len(s.pending) > 0, outputPending: len(s.outputPending) > 0}
+}
+
+func (s *Session) watch(xfd int) {
+ defer s.workers.Done()
+ for {
+ var state waitState
+ select {
+ case <-s.stop:
+ return
+ case state = <-s.arm:
+ }
+ writeFlag := C.int(0)
+ if state.inputPending {
+ writeFlag = 1
+ }
+ outputFlag := C.int(0)
+ if state.outputPending {
+ outputFlag = 1
+ }
+ code := C.terminal_wait(C.int(s.master), C.int(s.input.Fd()), C.int(s.output.Fd()), C.int(xfd), C.int(s.wake[0]), writeFlag, outputFlag)
+ if code == 1 {
+ return
+ }
+ if code < 0 || code&2 != 0 {
+ select {
+ case s.events <- native_error(code):
+ default:
+ }
+ }
+ if code&4 != 0 {
+ select {
+ case s.inputEvents <- nil:
+ default:
+ }
+ }
+ if code < 0 {
+ return
+ }
+ }
+}
+
+func (s *Session) Events() <-chan error { return s.events }
+
+// Read drains bounded PTY output and snapshots the emulator. Terminal responses
+// come from the outer xterm through stdin, avoiding duplicate query replies.
+func (s *Session) Read() (screen.Snapshot, error) {
+ if s.eof && len(s.outputPending) == 0 {
+ return screen.Snapshot{}, ErrClosed
+ }
+ if _, _, _, err := s.State(); err != nil {
+ return screen.Snapshot{}, err
+ }
+ if err := s.flush_output(); err != nil {
+ return screen.Snapshot{}, err
+ }
+ var buffer [32768]byte
+ // Stop reading the child while xterm is full. At most one chunk remains
+ // queued, and stdout readiness resumes delivery without blocking input.
+ for total := 0; total < 131072 && len(s.outputPending) == 0 && !s.eof; {
+ n, err := syscall.Read(s.master, buffer[:])
+ if errors.Is(err, syscall.EAGAIN) {
+ break
+ }
+ if errors.Is(err, syscall.EINTR) {
+ continue
+ }
+ if errors.Is(err, syscall.EIO) || n == 0 && err == nil {
+ s.eof = true
+ if total == 0 {
+ return screen.Snapshot{}, ErrClosed
+ }
+ break
+ }
+ if err != nil {
+ return screen.Snapshot{}, err
+ }
+ total += n
+ s.vt.feed(buffer[:n])
+ s.outputPending = append(s.outputPending, buffer[:n]...)
+ if err := s.flush_output(); err != nil {
+ return screen.Snapshot{}, err
+ }
+ }
+ snapshot := s.vt.snapshot(s.active, s.lock)
+ s.retire_paste()
+ snapshot.CapsLock = s.capsLock
+ s.arm_watch()
+ return snapshot, nil
+}
+
+// flush_output preserves partial writes and retries after stdout becomes writable.
+// Raw nonblocking writes keep a slow terminal from holding the app event loop.
+func (s *Session) flush_output() error {
+ for len(s.outputPending) > 0 {
+ n, err := syscall.Write(int(s.output.Fd()), s.outputPending)
+ if n > 0 {
+ s.outputPending = s.outputPending[n:]
+ }
+ if errors.Is(err, syscall.EAGAIN) {
+ return nil
+ }
+ if errors.Is(err, syscall.EINTR) {
+ continue
+ }
+ if err != nil {
+ return fmt.Errorf("write terminal output: %w", err)
+ }
+ if n == 0 {
+ return io.ErrNoProgress
+ }
+ }
+ s.outputPending = nil
+ return nil
+}
+
+// relay_input forwards stdin and queued key bytes to the child PTY.
+// The caller handles X11 key events before relaying terminal input.
+func (s *Session) relay_input() error {
+ var bytes [4096]byte
+ for total := 0; total < 65536 && len(s.pending) < 1<<20; {
+ n, err := syscall.Read(int(s.input.Fd()), bytes[:])
+ if errors.Is(err, syscall.EAGAIN) {
+ break
+ }
+ if errors.Is(err, syscall.EINTR) {
+ continue
+ }
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return ErrClosed
+ }
+ total += n
+ // X key events own interruption. Stdin also carries terminal query
+ // responses, which must never cancel speech as if they were typing.
+ if !s.router.Captured() {
+ s.pending = append(s.pending, bytes[:n]...)
+ }
+ }
+ for len(s.pending) > 0 {
+ n, err := syscall.Write(s.master, s.pending)
+ if n > 0 {
+ s.pending = s.pending[n:]
+ s.pasteStart, s.pasteEnd = max(0, s.pasteStart-n), max(0, s.pasteEnd-n)
+ }
+ if errors.Is(err, syscall.EAGAIN) {
+ break
+ }
+ if errors.Is(err, syscall.EINTR) {
+ continue
+ }
+ if err != nil {
+ return err
+ }
+ }
+ s.arm_watch()
+ return nil
+}
diff --git a/internal/terminal/session_linux.go b/internal/terminal/session_linux.go
new file mode 100644
index 00000000..2b25a3db
--- /dev/null
+++ b/internal/terminal/session_linux.go
@@ -0,0 +1,267 @@
+// Fenrir console screen reader.
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+//go:build linux && cgo
+
+// Package terminal adapts an X11 terminal and child PTY to the shared reader.
+// The application loop owns all X11, emulator, input and screen state. Only
+// readiness waiting and child reaping run in background goroutines.
+package terminal
+
+/*
+#cgo pkg-config: x11 vterm
+#cgo LDFLAGS: -lutil
+#include "native.h"
+*/
+import "C"
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "strconv"
+ "sync"
+ "syscall"
+ "time"
+
+ "git.stormux.org/storm/fenrir/internal/input"
+)
+
+// ErrClosed marks normal PTY or launching-terminal EOF, rather than an I/O
+// failure in another service such as Speech Dispatcher.
+var ErrClosed = errors.New("terminal session ended")
+
+type Session struct {
+ input, output *os.File
+ master int
+ x *C.TerminalX
+ vt *Emulator
+ saved C.struct_termios
+ flags C.int
+ raw bool
+ rows, cols int
+ active, lock bool
+ capsLock bool
+ router input.Router
+ held map[int]uint16
+ replayed map[int]uint16
+ grabbed bool
+ captureBlocked bool
+ pending []byte
+ outputPending []byte
+ pasteStart, pasteEnd int
+ pasteGeneration uint64
+ events chan error
+ inputEvents chan error
+ arm chan waitState
+ stop chan struct{}
+ wake [2]int
+ workers sync.WaitGroup
+ child *exec.Cmd
+ exited chan struct{}
+ closed bool
+ eof bool
+ lastReplay struct {
+ code int
+ time uint64
+ }
+}
+
+func native_error(code C.int) error {
+ if code < 0 {
+ return syscall.Errno(-code)
+ }
+ return nil
+}
+
+func valid_size(rows, cols int) bool {
+ return rows > 0 && cols > 0 && rows <= 1000 && cols <= 2000 && rows*cols <= 1_000_000
+}
+
+// Open starts the user's shell in a fresh controlling PTY. WINDOWID must name
+// the launching terminal; guessing the currently focused window risks grabbing
+// an unrelated application. There is no console-device or privilege access.
+func Open(inputFile, outputFile *os.File, router input.Router) (*Session, error) {
+ window, err := strconv.ParseUint(os.Getenv("WINDOWID"), 0, 32)
+ if err != nil || window == 0 {
+ return nil, fmt.Errorf("-x requires a valid WINDOWID from the launching X11 terminal")
+ }
+ shell := os.Getenv("SHELL")
+ if shell == "" {
+ return nil, fmt.Errorf("-x requires SHELL to name your shell")
+ }
+ return open_session(inputFile, outputFile, window, exec.Command(shell), router)
+}
+
+func open_session(inputFile, outputFile *os.File, window uint64, child *exec.Cmd, router input.Router) (_ *Session, result error) {
+ s := &Session{input: inputFile, output: outputFile, master: -1, router: router,
+ held: make(map[int]uint16), replayed: make(map[int]uint16),
+ events: make(chan error, 1), inputEvents: make(chan error, 1), arm: make(chan waitState, 1), stop: make(chan struct{}), exited: make(chan struct{}),
+ wake: [2]int{-1, -1}}
+ defer func() {
+ if result != nil {
+ result = errors.Join(result, s.Close())
+ }
+ }()
+ var rows, cols C.int
+ if err := native_error(C.terminal_size(C.int(inputFile.Fd()), &rows, &cols)); err != nil {
+ return nil, fmt.Errorf("-x requires a terminal on stdin: %w", err)
+ }
+ s.rows, s.cols = int(rows), int(cols)
+ if !valid_size(s.rows, s.cols) {
+ return nil, fmt.Errorf("unsupported terminal geometry %dx%d", s.cols, s.rows)
+ }
+ var xError C.int
+ s.x = C.terminal_x_open(C.ulong(window), &xError)
+ if s.x == nil {
+ return nil, fmt.Errorf("open X11 terminal or acquire its keys (error %d); check DISPLAY, WINDOWID and competing grabs", xError)
+ }
+ var scriptKeys []C.int
+ for _, key := range router.Script_keys() {
+ scriptKeys = append(scriptKeys, C.int(key))
+ }
+ if len(scriptKeys) > 0 {
+ if err := native_error(C.terminal_x_script_keys(s.x, &scriptKeys[0], C.int(len(scriptKeys)))); err != nil {
+ return nil, fmt.Errorf("acquire script modifier keys: %w", err)
+ }
+ }
+ emulator, err := new_emulator(s.rows, s.cols)
+ if err != nil {
+ return nil, err
+ }
+ s.vt = emulator
+ master, slave, err := open_pty(s.rows, s.cols)
+ if err != nil {
+ return nil, err
+ }
+ s.master = master
+ slaveFile := os.NewFile(uintptr(slave), "child PTY")
+ defer slaveFile.Close()
+ if err := syscall.SetNonblock(master, true); err != nil {
+ return nil, err
+ }
+ if err := syscall.Pipe2(s.wake[:], syscall.O_CLOEXEC|syscall.O_NONBLOCK); err != nil {
+ return nil, err
+ }
+ if err := native_error(C.terminal_raw(C.int(inputFile.Fd()), &s.saved, &s.flags)); err != nil {
+ return nil, fmt.Errorf("set terminal mode: %w", err)
+ }
+ s.raw = true
+ child.Stdin, child.Stdout, child.Stderr = slaveFile, slaveFile, slaveFile
+ child.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true, Ctty: 0}
+ child.Env = append(os.Environ(), "FENRIR_TERMINAL=1")
+ if err := child.Start(); err != nil {
+ return nil, fmt.Errorf("start PTY shell: %w", err)
+ }
+ s.child = child
+ go func() {
+ _ = child.Wait()
+ close(s.exited)
+ }()
+ s.workers.Add(1)
+ go s.watch(int(C.terminal_x_fd(s.x)))
+ return s, nil
+}
+
+func (s *Session) State() (int, bool, bool, error) {
+ var active, lock, capsLock, rows, cols C.int
+ if err := native_error(C.terminal_x_state(s.x, &active, &lock, &capsLock)); err != nil {
+ return 0, false, false, err
+ }
+ if s.active && active == 0 {
+ C.terminal_x_release(s.x)
+ s.router.Reset()
+ clear(s.held)
+ clear(s.replayed)
+ s.grabbed = false
+ }
+ s.active, s.lock = active != 0, lock != 0
+ s.retire_paste()
+ s.capsLock = capsLock != 0
+ // Synchronous X queries can buffer key events before the readiness worker
+ // sees the socket. Preserve that wakeup even when Xlib drained the fd.
+ if C.terminal_x_pending(s.x) != 0 {
+ select {
+ case s.inputEvents <- nil:
+ default:
+ }
+ }
+ if err := native_error(C.terminal_size(C.int(s.input.Fd()), &rows, &cols)); err != nil {
+ return 0, false, false, err
+ }
+ if int(rows) != s.rows || int(cols) != s.cols {
+ if !valid_size(int(rows), int(cols)) {
+ return 0, false, false, fmt.Errorf("unsupported terminal geometry %dx%d", cols, rows)
+ }
+ if err := native_error(C.terminal_resize(C.int(s.master), rows, cols)); err != nil {
+ return 0, false, false, err
+ }
+ s.vt.resize(int(rows), int(cols))
+ s.rows, s.cols = int(rows), int(cols)
+ select {
+ case s.events <- nil:
+ default:
+ }
+ }
+ return 1, s.active, s.lock, nil
+}
+
+func (s *Session) Close() error {
+ if s.closed {
+ return nil
+ }
+ s.closed = true
+ close(s.stop)
+ if s.wake[1] >= 0 {
+ _, _ = syscall.Write(s.wake[1], []byte{1})
+ }
+ s.workers.Wait()
+ var result error
+ if s.raw {
+ result = native_error(C.terminal_restore(C.int(s.input.Fd()), &s.saved, s.flags))
+ // A child may exit while its alternate screen or input modes are
+ // active. Restore xterm's normal screen, cursor and keyboard modes.
+ _, err := s.output.WriteString("\x1b[?1049l\x1b[?1l\x1b>\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?2004l\x1b[?25h\x1b[0m")
+ result = errors.Join(result, err)
+ }
+ if s.x != nil {
+ C.terminal_x_close(s.x)
+ }
+ if s.master >= 0 {
+ result = errors.Join(result, syscall.Close(s.master))
+ }
+ if s.child != nil {
+ _ = s.child.Process.Signal(syscall.SIGHUP)
+ select {
+ case <-s.exited:
+ case <-time.After(500 * time.Millisecond):
+ _ = syscall.Kill(-s.child.Process.Pid, syscall.SIGKILL)
+ _ = s.child.Process.Kill()
+ <-s.exited
+ }
+ }
+ if s.vt != nil {
+ s.vt.close()
+ }
+ for _, fd := range s.wake {
+ if fd >= 0 {
+ result = errors.Join(result, syscall.Close(fd))
+ }
+ }
+ return result
+}
diff --git a/internal/version/version.go b/internal/version/version.go
new file mode 100644
index 00000000..1a2fe431
--- /dev/null
+++ b/internal/version/version.go
@@ -0,0 +1,16 @@
+// Copyright (c) 2026 Storm Dragon
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+// Package version supplies the version and release channel for CLI and speech.
+package version
+
+// Keep editable release metadata here, following Cthulhu's version package.
+const Version = "2026.10.27"
+const Channel = "testing"
+
+// Build defaults to Version; release builders can override it using -ldflags -X.
+var Build = Version
+
+func Label() string {
+ return Build + "-" + Channel
+}
diff --git a/locale/de/LC_MESSAGES/fenrir.mo b/locale/de/LC_MESSAGES/fenrir.mo
deleted file mode 100644
index 028ee815..00000000
Binary files a/locale/de/LC_MESSAGES/fenrir.mo and /dev/null differ
diff --git a/locale/de/LC_MESSAGES/fenrir.po b/locale/de/LC_MESSAGES/fenrir.po
deleted file mode 100644
index 5c534e67..00000000
--- a/locale/de/LC_MESSAGES/fenrir.po
+++ /dev/null
@@ -1,1005 +0,0 @@
-# SOME DESCRIPTIVE TITLE.
-# Copyright (C) YEAR ORGANIZATION
-# FIRST AUTHOR , YEAR.
-#
-msgid ""
-msgstr ""
-"Project-Id-Version: \n"
-"POT-Creation-Date: 2017-12-27 17:34+CET\n"
-"PO-Revision-Date: 2017-12-27 22:42+0100\n"
-"Last-Translator: Steffen Schultz