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)(?:\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)(?:\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)(?:\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)(?:\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)(?:\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)(?:\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)(?:\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", + "": "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": "", "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 \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" - -#: ../src/fenrir/commands/commands/add_word_to_spell_check.py:27 -msgid "adds the current word to the exceptions dictionary" -msgstr "Fügt das aktuelle Wort dem Ausnahmewƶrterbuch hinzu" - -#: ../src/fenrir/commands/commands/add_word_to_spell_check.py:34 -#: ../src/fenrir/commands/commands/remove_word_from_spell_check.py:34 -#: ../src/fenrir/commands/commands/spell_check.py:29 -#: ../src/fenrir/commands/commands/spell_check.py:36 -msgid "pyenchant is not installed" -msgstr "Pyenchant ist nicht installiert" - -#: ../src/fenrir/commands/commands/add_word_to_spell_check.py:49 -msgid "{0} is already in dict" -msgstr "{0} ist bereits im Wƶrterbuch" - -#: ../src/fenrir/commands/commands/add_word_to_spell_check.py:52 -msgid "{0} added" -msgstr "{0} hinzugefügt" - -#: ../src/fenrir/commands/commands/bookmark_1.py:19 -#: ../src/fenrir/commands/commands/bookmark_10.py:19 -#: ../src/fenrir/commands/commands/bookmark_2.py:19 -#: ../src/fenrir/commands/commands/bookmark_3.py:19 -#: ../src/fenrir/commands/commands/bookmark_4.py:19 -#: ../src/fenrir/commands/commands/bookmark_5.py:19 -#: ../src/fenrir/commands/commands/bookmark_6.py:19 -#: ../src/fenrir/commands/commands/bookmark_7.py:19 -#: ../src/fenrir/commands/commands/bookmark_8.py:19 -#: ../src/fenrir/commands/commands/bookmark_9.py:19 -msgid "read Bookmark {0}" -msgstr "Lesezeichen {0}" - -#: ../src/fenrir/commands/commands/bookmark_1.py:24 -#: ../src/fenrir/commands/commands/bookmark_10.py:24 -#: ../src/fenrir/commands/commands/bookmark_2.py:24 -#: ../src/fenrir/commands/commands/bookmark_3.py:24 -#: ../src/fenrir/commands/commands/bookmark_4.py:24 -#: ../src/fenrir/commands/commands/bookmark_5.py:24 -#: ../src/fenrir/commands/commands/bookmark_6.py:24 -#: ../src/fenrir/commands/commands/bookmark_7.py:24 -#: ../src/fenrir/commands/commands/bookmark_8.py:24 -#: ../src/fenrir/commands/commands/bookmark_9.py:24 -msgid "Bookmark {0} not set" -msgstr "Lesezeichen {0} nicht gesetzt" - -#: ../src/fenrir/commands/commands/bookmark_1.py:27 -#: ../src/fenrir/commands/commands/bookmark_1.py:30 -#: ../src/fenrir/commands/commands/bookmark_10.py:27 -#: ../src/fenrir/commands/commands/bookmark_10.py:30 -#: ../src/fenrir/commands/commands/bookmark_2.py:27 -#: ../src/fenrir/commands/commands/bookmark_2.py:30 -#: ../src/fenrir/commands/commands/bookmark_3.py:27 -#: ../src/fenrir/commands/commands/bookmark_3.py:30 -#: ../src/fenrir/commands/commands/bookmark_4.py:27 -#: ../src/fenrir/commands/commands/bookmark_4.py:30 -#: ../src/fenrir/commands/commands/bookmark_5.py:27 -#: ../src/fenrir/commands/commands/bookmark_5.py:30 -#: ../src/fenrir/commands/commands/bookmark_6.py:27 -#: ../src/fenrir/commands/commands/bookmark_6.py:30 -#: ../src/fenrir/commands/commands/bookmark_7.py:27 -#: ../src/fenrir/commands/commands/bookmark_7.py:30 -#: ../src/fenrir/commands/commands/bookmark_8.py:27 -#: ../src/fenrir/commands/commands/bookmark_8.py:30 -#: ../src/fenrir/commands/commands/bookmark_9.py:27 -#: ../src/fenrir/commands/commands/bookmark_9.py:30 -msgid "Bookmark for application {0} not set" -msgstr "Lesezeichen für Anwendung {0} nicht gesetzt" - -#: ../src/fenrir/commands/commands/bookmark_1.py:43 -#: ../src/fenrir/commands/commands/bookmark_10.py:43 -#: ../src/fenrir/commands/commands/bookmark_2.py:43 -#: ../src/fenrir/commands/commands/bookmark_3.py:43 -#: ../src/fenrir/commands/commands/bookmark_4.py:43 -#: ../src/fenrir/commands/commands/bookmark_5.py:43 -#: ../src/fenrir/commands/commands/bookmark_6.py:43 -#: ../src/fenrir/commands/commands/bookmark_7.py:43 -#: ../src/fenrir/commands/commands/bookmark_8.py:43 -#: ../src/fenrir/commands/commands/bookmark_9.py:43 -#: ../src/fenrir/commands/commands/curr_screen_after_cursor.py:27 -#: ../src/fenrir/commands/commands/curr_screen_before_cursor.py:30 -#: ../src/fenrir/commands/commands/cursor_read_to_end_of_line.py:27 -#: ../src/fenrir/commands/commands/indent_curr_line.py:31 -#: ../src/fenrir/commands/commands/marked_text.py:33 -#: ../src/fenrir/commands/commands/present_first_line.py:25 -#: ../src/fenrir/commands/commands/present_last_line.py:25 -#: ../src/fenrir/commands/commands/review_curr_char_phonetic.py:27 -#: ../src/fenrir/commands/commands/review_curr_line.py:27 -#: ../src/fenrir/commands/commands/review_curr_word.py:27 -#: ../src/fenrir/commands/commands/review_curr_word_phonetic.py:27 -#: ../src/fenrir/commands/commands/review_line_begin.py:27 -#: ../src/fenrir/commands/commands/review_next_line.py:29 -#: ../src/fenrir/commands/commands/review_next_word.py:29 -#: ../src/fenrir/commands/commands/review_next_word_phonetic.py:27 -#: ../src/fenrir/commands/commands/review_prev_line.py:27 -#: ../src/fenrir/commands/commands/review_prev_word.py:27 -#: ../src/fenrir/commands/commands/review_prev_word_phonetic.py:27 -#: ../src/fenrir/commands/onCursorChange/55000-present_line_if_cursor_change_vertical.py:37 -#: ../src/fenrir/commands/onScreenUpdate/60000-history.py:49 -msgid "blank" -msgstr "Leer" - -#: ../src/fenrir/commands/commands/braille_flush.py:17 -msgid "flush the braille device if a message is written on" -msgstr "Braillezeile beim Erscheinen einer Nachricht leeren" - -#: ../src/fenrir/commands/commands/braille_pan_left.py:17 -msgid "Move braille view to the left." -msgstr "Braille-Ansicht nach links bewegen." - -#: ../src/fenrir/commands/commands/braille_pan_right.py:17 -msgid "Move braille view to the right." -msgstr "Braille-Ansicht nach rechts bewegen." - -#: ../src/fenrir/commands/commands/braille_return_to_cursor.py:17 -msgid "Set the braille view back to cursor." -msgstr "Braille-Ansicht auf Cursor zurücksetzen." - -#: ../src/fenrir/commands/commands/clear_bookmark_1.py:17 -#: ../src/fenrir/commands/commands/clear_bookmark_10.py:17 -#: ../src/fenrir/commands/commands/clear_bookmark_2.py:17 -#: ../src/fenrir/commands/commands/clear_bookmark_3.py:17 -#: ../src/fenrir/commands/commands/clear_bookmark_4.py:17 -#: ../src/fenrir/commands/commands/clear_bookmark_5.py:17 -#: ../src/fenrir/commands/commands/clear_bookmark_6.py:17 -#: ../src/fenrir/commands/commands/clear_bookmark_7.py:17 -#: ../src/fenrir/commands/commands/clear_bookmark_8.py:17 -#: ../src/fenrir/commands/commands/clear_bookmark_9.py:17 -msgid "remove Bookmark {0}" -msgstr "Lesezeichen {0} entfernen" - -#: ../src/fenrir/commands/commands/clear_bookmark_1.py:24 -#: ../src/fenrir/commands/commands/clear_bookmark_10.py:24 -#: ../src/fenrir/commands/commands/clear_bookmark_2.py:24 -#: ../src/fenrir/commands/commands/clear_bookmark_3.py:24 -#: ../src/fenrir/commands/commands/clear_bookmark_4.py:24 -#: ../src/fenrir/commands/commands/clear_bookmark_5.py:24 -#: ../src/fenrir/commands/commands/clear_bookmark_6.py:24 -#: ../src/fenrir/commands/commands/clear_bookmark_7.py:24 -#: ../src/fenrir/commands/commands/clear_bookmark_8.py:24 -#: ../src/fenrir/commands/commands/clear_bookmark_9.py:24 -msgid "Bookmark {0} removed for application {1}" -msgstr "Lesezeichen {0} für Anwendung {1} entfernt" - -#: ../src/fenrir/commands/commands/clear_clipboard.py:17 -msgid "clears the currently selected clipboard" -msgstr "Lƶscht die momentan gewƤhlte Zwischenablage" - -#: ../src/fenrir/commands/commands/clear_clipboard.py:22 -msgid "clipboard cleared" -msgstr "Zwischenablage gelƶscht" - -#: ../src/fenrir/commands/commands/clear_window_application.py:17 -msgid "Turn off window mode for application" -msgstr "Fenstermodus für Anwendung ausschalten" - -#: ../src/fenrir/commands/commands/clear_window_application.py:22 -msgid "Window Mode off for application {0}" -msgstr "Fenstermodus für Anwendung {0} ausgeschaltet" - -#: ../src/fenrir/commands/commands/clear_window_application.py:24 -msgid "Not in window Mode" -msgstr "Nicht im Fenstermodus" - -#: ../src/fenrir/commands/commands/copy_marked_to_clipboard.py:18 -msgid "copies marked text to the currently selected clipboard" -msgstr "Kopiert markierten Text in die gewƤhlte Zwischenablage" - -#: ../src/fenrir/commands/commands/copy_marked_to_clipboard.py:22 -msgid "one or two marks needed" -msgstr "Eine oder zwei Markierungen benƶtigt" - -#: ../src/fenrir/commands/commands/curr_clipboard.py:17 -msgid "speaks the contents of the currently selected clipboard" -msgstr "Liest den Inhalt der momentan gewƤhlten Zwischenablage vor" - -#: ../src/fenrir/commands/commands/curr_clipboard.py:21 -#: ../src/fenrir/commands/commands/export_clipboard_to_file.py:29 -#: ../src/fenrir/commands/commands/export_clipboard_to_file.py:32 -#: ../src/fenrir/commands/commands/export_clipboard_to_file.py:35 -#: ../src/fenrir/commands/commands/export_clipboard_to_file.py:38 -#: ../src/fenrir/commands/commands/export_clipboard_to_x.py:30 -#: ../src/fenrir/commands/commands/export_clipboard_to_x.py:33 -#: ../src/fenrir/commands/commands/export_clipboard_to_x.py:36 -#: ../src/fenrir/commands/commands/export_clipboard_to_x.py:39 -#: ../src/fenrir/commands/commands/first_clipboard.py:21 -#: ../src/fenrir/commands/commands/last_clipboard.py:21 -#: ../src/fenrir/commands/commands/next_clipboard.py:21 -#: ../src/fenrir/commands/commands/paste_clipboard.py:23 -#: ../src/fenrir/commands/commands/paste_clipboard.py:26 -#: ../src/fenrir/commands/commands/paste_clipboard.py:29 -#: ../src/fenrir/commands/commands/paste_clipboard.py:32 -#: ../src/fenrir/commands/commands/prev_clipboard.py:21 -msgid "clipboard empty" -msgstr "Zwischenablage ist leer" - -#: ../src/fenrir/commands/commands/curr_screen.py:17 -msgid "reads the contents of the current screen" -msgstr "Liest den Inhalt des aktuellen Bildschirms vor" - -#: ../src/fenrir/commands/commands/curr_screen.py:21 -msgid "screen is empty" -msgstr "Bildschirm ist leer" - -#: ../src/fenrir/commands/commands/curr_screen_after_cursor.py:18 -msgid "reads from the cursor to the bottom of the screen" -msgstr "Liest vom Cursor bis zum unteren Bildschirmrand" - -#: ../src/fenrir/commands/commands/curr_screen_before_cursor.py:18 -msgid "Reads from the top of the screen to the cursor position" -msgstr "Liest vom oberen Bildschirmrand bis zur Cursorposition" - -#: ../src/fenrir/commands/commands/cursor_column.py:17 -msgid "" -"presents the current column number for review cursor in review mode or the " -"text cursor if not. Starts with 1" -msgstr "" -"Gibt die aktuelle Spaltennummer für den Bildschirm-Cursor in der " -"Bildschirmdarstellung aus, anderenfalls den Textcursor. Beginnt mit 1" - -#: ../src/fenrir/commands/commands/cursor_lineno.py:17 -msgid "" -"presents the current line number for review cursor in review mode or the " -"text cursor if not. Starts with 1" -msgstr "" -"Gibt die aktuelle Zeilennummer für den Bildschirm-Cursor in der " -"Bildschirmdarstellung aus, anderenfalls den Textcursor. Beginnt mit 1" - -#: ../src/fenrir/commands/commands/cursor_position.py:17 -msgid "displays the position of the review cursor" -msgstr "Zeigt die Position des Bildschirmcursors an" - -#: ../src/fenrir/commands/commands/cursor_position.py:23 -msgid "line {0}, column {1}" -msgstr "Zeile {0}, Spalte {1}" - -#: ../src/fenrir/commands/commands/cursor_read_to_end_of_line.py:18 -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." - -#: ../src/fenrir/commands/commands/date.py:18 -msgid "presents the date" -msgstr "Gibt das Datum aus" - -#: ../src/fenrir/commands/commands/dec_alsa_volume.py:24 -msgid "Decrease system volume" -msgstr "SystemlautstƤrke verringern" - -#: ../src/fenrir/commands/commands/dec_alsa_volume.py:28 -#: ../src/fenrir/commands/commands/inc_alsa_volume.py:28 -msgid "alsaaudio is not installed" -msgstr "Alsaaudio ist nicht installiert" - -#: ../src/fenrir/commands/commands/dec_alsa_volume.py:36 -#: ../src/fenrir/commands/commands/inc_alsa_volume.py:36 -msgid "{0} percent system volume" -msgstr "{0} Prozent SystemlautstƤrke" - -#: ../src/fenrir/commands/commands/dec_sound_volume.py:18 -msgid "decrease sound volume" -msgstr "Verringert die TonlautstƤrke" - -#: ../src/fenrir/commands/commands/dec_sound_volume.py:29 -#: ../src/fenrir/commands/commands/inc_sound_volume.py:29 -msgid "{0} percent sound volume" -msgstr "{0} Prozent TonlautstƤrke" - -#: ../src/fenrir/commands/commands/dec_speech_pitch.py:18 -msgid "decreases the pitch of the speech" -msgstr "Verringert die Stimmhƶhe der Sprachausgabe" - -#: ../src/fenrir/commands/commands/dec_speech_pitch.py:26 -#: ../src/fenrir/commands/commands/inc_speech_pitch.py:27 -msgid "{0} percent speech pitch" -msgstr "{0} Prozent Stimmhƶhe" - -#: ../src/fenrir/commands/commands/dec_speech_rate.py:18 -msgid "decreases the rate of the speech" -msgstr "Verringert die Sprechgeschwindigkeit" - -#: ../src/fenrir/commands/commands/dec_speech_rate.py:27 -#: ../src/fenrir/commands/commands/inc_speech_rate.py:27 -msgid "{0} percent speech rate" -msgstr "{0} Prozent Sprechgeschwindigkeit" - -#: ../src/fenrir/commands/commands/dec_speech_volume.py:18 -msgid "decreases the volume of the speech" -msgstr "Verringert die SprachlautstƤrke" - -#: ../src/fenrir/commands/commands/dec_speech_volume.py:27 -#: ../src/fenrir/commands/commands/inc_speech_volume.py:27 -msgid "{0} percent speech volume" -msgstr "{0} Prozent SprachlautstƤrke" - -#: ../src/fenrir/commands/commands/exit_review.py:17 -#: ../src/fenrir/commands/onCursorChange/66000-exit_review_mode.py:17 -msgid "exits review mode" -msgstr "VerlƤsst die Bildschirmdarstellung" - -#: ../src/fenrir/commands/commands/exit_review.py:21 -msgid "Not in review mode" -msgstr "Nicht in der Bildschirmdarstellung" - -#: ../src/fenrir/commands/commands/exit_review.py:25 -msgid "leave review mode" -msgstr "Bildschirmdarstellung verlassen" - -#: ../src/fenrir/commands/commands/export_clipboard_to_file.py:19 -msgid "export the current fenrir clipboard to a file" -msgstr "Aktuelle Fenrir-Zwischenablage in Datei exportieren" - -#: ../src/fenrir/commands/commands/export_clipboard_to_file.py:43 -msgid "clipboard exported to file" -msgstr "Zwischenablage in Datei exportiert" - -#: ../src/fenrir/commands/commands/export_clipboard_to_x.py:21 -msgid "export the current fenrir clipboard to X clipboard" -msgstr "Aktuelle Fenrir-Zwischenablage in die X-Zwischenablage exportieren" - -#: ../src/fenrir/commands/commands/first_clipboard.py:17 -msgid "selects the first clipboard" -msgstr "WƤhlt die erste Zwischenablage" - -#: ../src/fenrir/commands/commands/forward_keypress.py:17 -msgid "sends the following keypress to the terminal" -msgstr "Sendet die nachfolgende Taste an das Terminal" - -#: ../src/fenrir/commands/commands/forward_keypress.py:21 -msgid "Forward next keypress" -msgstr "NƤchste Taste durchreichen" - -#: ../src/fenrir/commands/commands/import_clipboard_from_file.py:19 -msgid "imports text from clipboard file to the clipboard" -msgstr "Importiert Text aus Datei in die Zwischenablage" - -#: ../src/fenrir/commands/commands/import_clipboard_from_file.py:27 -msgid "File does not exist" -msgstr "Datei existiert nicht" - -#: ../src/fenrir/commands/commands/inc_alsa_volume.py:24 -msgid "Increase system volume" -msgstr "SystemlautstƤrke erhƶhen" - -#: ../src/fenrir/commands/commands/inc_sound_volume.py:18 -msgid "adjusts the volume for in coming sounds" -msgstr "Regelt die LautstƤrke für eingehende Tƶne" - -#: ../src/fenrir/commands/commands/inc_speech_pitch.py:18 -msgid "increases the pitch of the speech" -msgstr "Hebt die Stimmhƶhe an" - -#: ../src/fenrir/commands/commands/inc_speech_rate.py:18 -msgid "increase the speech rate" -msgstr "Hebt die Sprechgeschwindigkeit an" - -#: ../src/fenrir/commands/commands/inc_speech_volume.py:18 -msgid "increase the speech volume" -msgstr "Erhƶht die SprachlautstƤrke" - -#: ../src/fenrir/commands/commands/indent_curr_line.py:18 -msgid "shows the indention level for the current line" -msgstr "Zeigt den Einrückungsgrad der aktuellen Zeile an" - -#: ../src/fenrir/commands/commands/indent_curr_line.py:33 -msgid "indent {0}" -msgstr "Einrückung {0}" - -#: ../src/fenrir/commands/commands/last_clipboard.py:17 -msgid "selects the last clipboard" -msgstr "WƤhlt die letzte Zwischenablage" - -#: ../src/fenrir/commands/commands/last_incoming.py:17 -msgid "displays the last received text" -msgstr "Zeigt den zuletzt empfangenen Text an" - -#: ../src/fenrir/commands/commands/marked_text.py:18 -msgid "speaks the currently selected text that will be copied to the clipboard" -msgstr "" -"Liest den momentan gewƤhlten Text vor, der in die Zwischenablage kopiert wird" - -#: ../src/fenrir/commands/commands/marked_text.py:23 -msgid "please set begin and endmark" -msgstr "Bitte zuerst Start- und Endmarke setzen" - -#: ../src/fenrir/commands/commands/next_clipboard.py:17 -msgid "selects the next clipboard" -msgstr "WƤhlt die nƤchste Zwischenablage" - -#: ../src/fenrir/commands/commands/next_clipboard.py:26 -msgid "First clipboard " -msgstr "Erste Zwischenablage" - -#: ../src/fenrir/commands/commands/paste_clipboard.py:18 -msgid "pastes the text from the currently selected clipboard" -msgstr "Fügt den Text aus der momentan gewƤhlten Zwischenablage ein" - -#: ../src/fenrir/commands/commands/present_first_line.py:18 -msgid "present first line" -msgstr "Gibt die erste Zeile aus" - -#: ../src/fenrir/commands/commands/present_last_line.py:18 -#: ../src/fenrir/commands/commands/review_curr_line.py:18 -msgid "current line" -msgstr "Aktuelle Zeile" - -#: ../src/fenrir/commands/commands/prev_clipboard.py:17 -msgid "selects the previous clipboard" -msgstr "WƤhlt die vorherige Zwischenablage" - -#: ../src/fenrir/commands/commands/prev_clipboard.py:26 -msgid "Last clipboard " -msgstr "Letzte Zwischenablage" - -#: ../src/fenrir/commands/commands/quit_fenrir.py:17 -msgid "exits Fenrir" -msgstr "Beendet Fenrir" - -#: ../src/fenrir/commands/commands/remove_marks.py:17 -msgid "removes marks from selected text" -msgstr "Entfernt Marken aus gewƤhltem Text" - -#: ../src/fenrir/commands/commands/remove_marks.py:21 -msgid "Remove marks" -msgstr "Marken entfernen" - -#: ../src/fenrir/commands/commands/remove_word_from_spell_check.py:27 -msgid "removes the current word from the exceptions dictionary" -msgstr "Entfernt das aktuelle Wort aus dem Ausnahmenwƶrterbuch" - -#: ../src/fenrir/commands/commands/remove_word_from_spell_check.py:50 -msgid "{0} is already removed from dict" -msgstr "{0} wurde bereits aus dem Wƶrterbuch entfernt" - -#: ../src/fenrir/commands/commands/remove_word_from_spell_check.py:53 -msgid "{0} removed" -msgstr "{0} entfernt" - -#: ../src/fenrir/commands/commands/review_bottom.py:17 -msgid "move review to bottom of screen" -msgstr "Bildschirm-Cursor zum unteren Bildschirmrand bewegen" - -#: ../src/fenrir/commands/commands/review_bottom.py:21 -msgid "Bottom" -msgstr "Unten" - -#: ../src/fenrir/commands/commands/review_curr_char.py:18 -msgid "presents the current character." -msgstr "Gibt das aktuelle Zeichen aus." - -#: ../src/fenrir/commands/commands/review_curr_char_phonetic.py:18 -msgid "set review and phonetically presents the current character" -msgstr "Setzt die Ansicht auf das aktuelle Zeichen und gibt es phonetisch aus" - -#: ../src/fenrir/commands/commands/review_curr_word.py:18 -msgid "current word." -msgstr "Aktuelles Wort." - -#: ../src/fenrir/commands/commands/review_curr_word.py:32 -#: ../src/fenrir/commands/commands/review_curr_word_phonetic.py:36 -#: ../src/fenrir/commands/commands/review_down.py:27 -#: ../src/fenrir/commands/commands/review_next_char.py:28 -#: ../src/fenrir/commands/commands/review_next_char_phonetic.py:30 -#: ../src/fenrir/commands/commands/review_next_line.py:34 -#: ../src/fenrir/commands/commands/review_next_word.py:34 -#: ../src/fenrir/commands/commands/review_next_word_phonetic.py:36 -#: ../src/fenrir/commands/commands/review_prev_char.py:31 -#: ../src/fenrir/commands/commands/review_prev_char_phonetic.py:30 -#: ../src/fenrir/commands/commands/review_prev_line.py:32 -#: ../src/fenrir/commands/commands/review_prev_word.py:32 -#: ../src/fenrir/commands/commands/review_prev_word_phonetic.py:36 -#: ../src/fenrir/commands/commands/review_up.py:27 -msgid "end of screen" -msgstr "Ende des Bildschirms" - -#: ../src/fenrir/commands/commands/review_curr_word.py:35 -#: ../src/fenrir/commands/commands/review_curr_word_phonetic.py:39 -#: ../src/fenrir/commands/commands/review_next_char.py:31 -#: ../src/fenrir/commands/commands/review_next_char_phonetic.py:33 -#: ../src/fenrir/commands/commands/review_next_word.py:37 -#: ../src/fenrir/commands/commands/review_next_word_phonetic.py:39 -#: ../src/fenrir/commands/commands/review_prev_char.py:34 -#: ../src/fenrir/commands/commands/review_prev_char_phonetic.py:33 -#: ../src/fenrir/commands/commands/review_prev_word.py:35 -#: ../src/fenrir/commands/commands/review_prev_word_phonetic.py:39 -#: ../src/fenrir/commands/commands/review_up.py:30 -msgid "line break" -msgstr "Zeilenumbruch" - -#: ../src/fenrir/commands/commands/review_curr_word_phonetic.py:19 -#: ../src/fenrir/commands/commands/review_next_word_phonetic.py:19 -#: ../src/fenrir/commands/commands/review_prev_word_phonetic.py:19 -msgid "phonetically spells the current word and set review to it" -msgstr "Buchstabiert das aktuelle Wort phonetisch und setzt die Ansicht darauf" - -#: ../src/fenrir/commands/commands/review_down.py:18 -msgid "set review cursor to char below the current char and present it." -msgstr "" -"Zieht den Bildschirm-Cursor auf das Zeichen unterhalb des aktuellen Zeichens " -"und gibt es aus." - -#: ../src/fenrir/commands/commands/review_line_begin.py:18 -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" - -#: ../src/fenrir/commands/commands/review_line_begin.py:30 -msgid "beginning of line" -msgstr "Anfang der Zeile" - -#: ../src/fenrir/commands/commands/review_line_end.py:18 -#: ../src/fenrir/commands/commands/review_line_first_char.py:19 -#: ../src/fenrir/commands/commands/review_line_last_char.py:18 -msgid "set review cursor to end of current line and display the content" -msgstr "" -"Setzt den Bildschirm-Cursor ans Ende der aktuellen Zeile und zeigt deren " -"Inhalt an" - -#: ../src/fenrir/commands/commands/review_line_end.py:27 -msgid "end of line" -msgstr "Ende der Zeile" - -#: ../src/fenrir/commands/commands/review_line_first_char.py:26 -msgid "line is empty" -msgstr "Zeile ist leer" - -#: ../src/fenrir/commands/commands/review_line_first_char.py:33 -msgid "first char in line indent {0}" -msgstr "Erstes Zeichen in Zeileneinrückung {0}" - -#: ../src/fenrir/commands/commands/review_line_last_char.py:27 -msgid "last char in line" -msgstr "Letztes Zeichen in Zeile" - -#: ../src/fenrir/commands/commands/review_next_char.py:18 -msgid "moves review to the next character and presents it" -msgstr "Bewegt die Ansicht zum nƤchsten Zeichen und gibt es aus" - -#: ../src/fenrir/commands/commands/review_next_char_phonetic.py:18 -msgid "phonetically presents the next character and set review to it" -msgstr "Gibt das nƤchste Zeichen phonetisch aus und setzt die Ansicht darauf" - -#: ../src/fenrir/commands/commands/review_next_line.py:18 -msgid "moves review to the next line and presents it" -msgstr "Bewegt die Ansicht zur nƤchsten Zeile und gibt diese aus" - -#: ../src/fenrir/commands/commands/review_next_word.py:18 -msgid "moves review to the next word and presents it" -msgstr "Bewegt die Ansicht zum nƤchsten Wort und gibt es aus" - -#: ../src/fenrir/commands/commands/review_prev_char.py:18 -msgid "moves review to the previous character and presents it" -msgstr "Bewegt die Ansicht zum vorherigen Zeichen und gibt es aus" - -#: ../src/fenrir/commands/commands/review_prev_char_phonetic.py:18 -msgid "phonetically presents the previous character and set review to it" -msgstr "Gibt das vorherige Zeichen phonetisch aus und setzt die Ansicht darauf" - -#: ../src/fenrir/commands/commands/review_prev_line.py:18 -msgid "moves review to the previous line and presents it" -msgstr "Bewegt die Ansicht zur vorherigen Zeile und gibt diese aus" - -#: ../src/fenrir/commands/commands/review_prev_word.py:18 -msgid "moves review focus to the previous word and presents it" -msgstr "Bewegt den Ansichts-Fokus zum vorherigen Wort und gibt dieses aus" - -#: ../src/fenrir/commands/commands/review_top.py:18 -msgid "move review to top of screen" -msgstr "Bewegt die Ansicht an den oberen Bildschirmrand" - -#: ../src/fenrir/commands/commands/review_top.py:22 -msgid "Top" -msgstr "Oben" - -#: ../src/fenrir/commands/commands/review_up.py:18 -msgid "set review cursor to the char in the line below and present it" -msgstr "" -"Setzt den Bildschirm-Cursor auf das Zeichen in der darunter liegenden Zeile " -"und gibt dieses aus" - -#: ../src/fenrir/commands/commands/set_bookmark_1.py:18 -#: ../src/fenrir/commands/commands/set_bookmark_10.py:18 -#: ../src/fenrir/commands/commands/set_bookmark_2.py:18 -#: ../src/fenrir/commands/commands/set_bookmark_3.py:18 -#: ../src/fenrir/commands/commands/set_bookmark_4.py:18 -#: ../src/fenrir/commands/commands/set_bookmark_5.py:18 -#: ../src/fenrir/commands/commands/set_bookmark_6.py:18 -#: ../src/fenrir/commands/commands/set_bookmark_7.py:18 -#: ../src/fenrir/commands/commands/set_bookmark_8.py:18 -#: ../src/fenrir/commands/commands/set_bookmark_9.py:18 -msgid "set Bookmark {0}" -msgstr "Lesezeichen {0} setzen" - -#: ../src/fenrir/commands/commands/set_bookmark_1.py:22 -#: ../src/fenrir/commands/commands/set_bookmark_10.py:22 -#: ../src/fenrir/commands/commands/set_bookmark_2.py:22 -#: ../src/fenrir/commands/commands/set_bookmark_3.py:22 -#: ../src/fenrir/commands/commands/set_bookmark_4.py:22 -#: ../src/fenrir/commands/commands/set_bookmark_5.py:22 -#: ../src/fenrir/commands/commands/set_bookmark_6.py:22 -#: ../src/fenrir/commands/commands/set_bookmark_7.py:22 -#: ../src/fenrir/commands/commands/set_bookmark_8.py:22 -#: ../src/fenrir/commands/commands/set_bookmark_9.py:22 -msgid "No Mark found" -msgstr "Keine Marke gefunden" - -#: ../src/fenrir/commands/commands/set_bookmark_1.py:32 -#: ../src/fenrir/commands/commands/set_bookmark_10.py:32 -#: ../src/fenrir/commands/commands/set_bookmark_2.py:32 -#: ../src/fenrir/commands/commands/set_bookmark_3.py:32 -#: ../src/fenrir/commands/commands/set_bookmark_4.py:32 -#: ../src/fenrir/commands/commands/set_bookmark_5.py:32 -#: ../src/fenrir/commands/commands/set_bookmark_6.py:32 -#: ../src/fenrir/commands/commands/set_bookmark_7.py:32 -#: ../src/fenrir/commands/commands/set_bookmark_8.py:32 -#: ../src/fenrir/commands/commands/set_bookmark_9.py:32 -msgid "Bookmark {0} set for application {1}" -msgstr "Lesezeichen {0} für Anwendung {1} gesetzt" - -#: ../src/fenrir/commands/commands/set_mark.py:17 -msgid "places marks to select text to copy to the clipboard" -msgstr "Setzt Marken zum AuswƤhlen und Kopieren von Text in die Zwischenablage" - -#: ../src/fenrir/commands/commands/set_mark.py:21 -msgid "no review cursor" -msgstr "Kein Bildschirm-Cursor" - -#: ../src/fenrir/commands/commands/set_mark.py:26 -#: ../src/fenrir/commands/commands/set_mark.py:28 -msgid "set mark" -msgstr "Marke setzen" - -#: ../src/fenrir/commands/commands/set_window_application.py:17 -msgid "set Window Mode, needs 2 marks " -msgstr "Fenstermodus setzen, benƶtigt 2 Marken" - -#: ../src/fenrir/commands/commands/set_window_application.py:22 -msgid "Window Mode on for application {0}" -msgstr "Fenstermodus für Anwendung {0} eingeschaltet" - -#: ../src/fenrir/commands/commands/set_window_application.py:25 -msgid "Set window begin and end marks" -msgstr "Setze Fensteranfangs- und -endmarken" - -#: ../src/fenrir/commands/commands/shut_up.py:17 -msgid "interrupts the current presentation" -msgstr "Unterbricht die aktuelle Ausgabe" - -#: ../src/fenrir/commands/commands/spell_check.py:26 -msgid "checks the spelling of the current word" -msgstr "Prüft die Rechtschreibung des aktuellen Wortes" - -#: ../src/fenrir/commands/commands/spell_check.py:52 -#: ../src/fenrir/commands/onCursorChange/62000-spell_check.py:130 -msgid "misspelled" -msgstr "Falsch geschrieben" - -#: ../src/fenrir/commands/commands/spell_check.py:54 -msgid "correct" -msgstr "Korrekt" - -#: ../src/fenrir/commands/commands/subprocess.py:21 -msgid "script: {0} fullpath: {1}" -msgstr "Skript: {0} VollstƤndiger Pfad: {1}" - -#: ../src/fenrir/commands/commands/subprocess.py:24 -msgid "scriptfile does not exist" -msgstr "Skript-Datei existiert nicht" - -#: ../src/fenrir/commands/commands/subprocess.py:27 -msgid "scriptfile is not a file" -msgstr "Skript-Datei ist keine Datei" - -#: ../src/fenrir/commands/commands/subprocess.py:30 -msgid "scriptfile is not executable" -msgstr "Skript-Datei ist nicht ausführbar" - -#: ../src/fenrir/commands/commands/temp_disable_speech.py:17 -#: ../src/fenrir/commands/onInput/15000-enable_temp_speech.py:17 -msgid "disables speech until next keypress" -msgstr "Sprachausgabe bis zum nƤchsten Tastendruck ausschalten" - -#: ../src/fenrir/commands/commands/temp_disable_speech.py:21 -msgid "speech temporary disabled" -msgstr "Sprachausgabe vorübergehend ausgeschaltet" - -#: ../src/fenrir/commands/commands/time.py:18 -msgid "presents the time" -msgstr "Gibt die Zeit aus" - -#: ../src/fenrir/commands/commands/toggle_auto_read.py:16 -msgid "enables or disables automatic reading of new text as it appears" -msgstr "Automatisches Vorlesen von neuem Text ein- oder ausschalten" - -#: ../src/fenrir/commands/commands/toggle_auto_read.py:21 -msgid "autoread enabled" -msgstr "Automatisches Vorlesen eingeschaltet" - -#: ../src/fenrir/commands/commands/toggle_auto_read.py:23 -msgid "autoread disabled" -msgstr "Automatisches Vorlesen ausgeschaltet" - -#: ../src/fenrir/commands/commands/toggle_auto_spell_check.py:17 -msgid "enables or disables automatic spell checking" -msgstr "Schaltet die automatische Rechtschreibprüfung ein oder aus" - -#: ../src/fenrir/commands/commands/toggle_auto_spell_check.py:22 -msgid "auto spellcheck enabled" -msgstr "Automatische Rechtschreibprüfung eingeschaltet" - -#: ../src/fenrir/commands/commands/toggle_auto_spell_check.py:24 -msgid "auto spellcheck disabled" -msgstr "Automatische Rechtschreibprüfung ausgeschaltet" - -#: ../src/fenrir/commands/commands/toggle_auto_time.py:16 -msgid "enables or disables automatic reading of time after an period" -msgstr "Schaltet das automatische Vorlesen der Zeit ein oder aus" - -#: ../src/fenrir/commands/commands/toggle_auto_time.py:21 -msgid "autotime enabled" -msgstr "Automatische Zeitansage eingeschaltet" - -#: ../src/fenrir/commands/commands/toggle_auto_time.py:23 -msgid "autotime disabled" -msgstr "Automatische Zeitansage ausgeschaltet" - -#: ../src/fenrir/commands/commands/toggle_braille.py:17 -msgid "enables and disables output in braille" -msgstr "Schaltet die Braille-Ausgabe ein oder aus" - -#: ../src/fenrir/commands/commands/toggle_braille.py:21 -msgid "braille disabled" -msgstr "Braille ausgeschaltet" - -#: ../src/fenrir/commands/commands/toggle_braille.py:24 -msgid "braille enabled" -msgstr "Braille eingeschaltet" - -#: ../src/fenrir/commands/commands/toggle_emoticons.py:16 -msgid "enables or disables announcement of emoticons instead of chars" -msgstr "Schaltet die Ansage von Emoticons anstelle der Zeichen ein oder aus" - -#: ../src/fenrir/commands/commands/toggle_emoticons.py:21 -msgid "emoticons enabled" -msgstr "Emoticons eingeschaltet" - -#: ../src/fenrir/commands/commands/toggle_emoticons.py:23 -msgid "emoticons disabled" -msgstr "Emoticons ausgeschaltet" - -#: ../src/fenrir/commands/commands/toggle_highlight_tracking.py:16 -msgid "enables or disables tracking of highlighted text" -msgstr "Schaltet die Verfolgung von hervorgehobenem Text ein oder aus" - -#: ../src/fenrir/commands/commands/toggle_highlight_tracking.py:24 -msgid "highlight tracking" -msgstr "Hervorhebungen verfolgen" - -#: ../src/fenrir/commands/commands/toggle_highlight_tracking.py:26 -msgid "cursor tracking" -msgstr "Cursor-Verfolgung" - -#: ../src/fenrir/commands/commands/toggle_output.py:17 -msgid "toggles all output settings" -msgstr "Schaltet alle Ausgabe-Einstellungen um" - -#: ../src/fenrir/commands/commands/toggle_output.py:23 -msgid "Fenrir muted" -msgstr "Fenrir stummgeschaltet" - -#: ../src/fenrir/commands/commands/toggle_output.py:31 -msgid "Fenrir unmuted" -msgstr "Stummschaltung aus" - -#: ../src/fenrir/commands/commands/toggle_punctuation_level.py:23 -msgid "No punctuation found." -msgstr "Keine Satzzeichen gefunden." - -#: ../src/fenrir/commands/commands/toggle_sound.py:17 -msgid "enables or disables sound" -msgstr "Schaltet Tƶne ein oder aus" - -#: ../src/fenrir/commands/commands/toggle_sound.py:21 -msgid "sound disabled" -msgstr "Tƶne ausgeschaltet" - -#: ../src/fenrir/commands/commands/toggle_sound.py:24 -msgid "sound enabled" -msgstr "Tƶne eingeschaltet" - -#: ../src/fenrir/commands/commands/toggle_speech.py:17 -msgid "enables or disables speech" -msgstr "Schaltet die Sprachausgabe ein oder aus" - -#: ../src/fenrir/commands/commands/toggle_speech.py:21 -msgid "speech disabled" -msgstr "Sprachausgabe ausgeschaltet" - -#: ../src/fenrir/commands/commands/toggle_speech.py:24 -#: ../src/fenrir/commands/onInput/15000-enable_temp_speech.py:28 -msgid "speech enabled" -msgstr "Sprachausgabe eingeschaltet" - -#: ../src/fenrir/commands/commands/toggle_tutorial_mode.py:19 -msgid "" -"You are leaving the tutorial mode. Press that shortcut again to enter the " -"tutorial mode again." -msgstr "" -"Lernmodus wird verlassen. Zum erneuten Betreten ein weiteres Mal diese " -"Tastenkombination drücken." - -#: ../src/fenrir/commands/commands/toggle_tutorial_mode.py:22 -msgid "" -"you entered the tutorial mode. In that mode the commands are not executed. " -"but you get a description of what the shortcut does. To leave the tutorial " -"mode, press that shortcut again." -msgstr "" -"Lernmodus gestartet. In diesem Modus werden keine Befehle ausgeführt, jedoch " -"wird die Funktion für jede Tastenkombination beschrieben. Zum Verlassen " -"erneut diese Tastenkombination drücken." - -#: ../src/fenrir/commands/help/curr_help.py:17 -msgid "get current help message" -msgstr "Aktuelle Hilfenachricht abrufen" - -#: ../src/fenrir/commands/help/next_help.py:17 -msgid "get next help message" -msgstr "NƤchste Hilfenachricht abrufen" - -#: ../src/fenrir/commands/help/prev_help.py:17 -msgid "get prev help message" -msgstr "Vorherige Hilfenachricht abrufen" - -#: ../src/fenrir/commands/onHeartBeat/76000-time.py:66 -msgid "Autotime: {0}" -msgstr "Die Zeit: {0}" - -#: ../src/fenrir/commands/onInput/80000-capslock.py:22 -msgid "Capslock on" -msgstr "Feststelltaste ein" - -#: ../src/fenrir/commands/onInput/80000-capslock.py:24 -msgid "Capslock off" -msgstr "Feststelltaste aus" - -#: ../src/fenrir/commands/onInput/80300-scrolllock.py:22 -msgid "Scrolllock on" -msgstr "Rollen ein" - -#: ../src/fenrir/commands/onInput/80300-scrolllock.py:24 -msgid "Scrolllock off" -msgstr "Rollen aus" - -#: ../src/fenrir/commands/onInput/80500-numlock.py:22 -msgid "Numlock on" -msgstr "Nummernblock ein" - -#: ../src/fenrir/commands/onInput/80500-numlock.py:24 -msgid "Numlock off" -msgstr "Nummernblock aus" - -#: ../src/fenrir/commands/onScreenChanged/80000-screen_change_announcement.py:20 -msgid "screen {0}" -msgstr "Bildschirm {0}" - -#: ../src/fenrir/commands/onScreenUpdate/56000-highlight_tracking.py:16 -msgid "enables or disables tracking of highlighted" -msgstr "Schaltet die Verfolgung von Hervorhebungen ein oder aus" - -#: ../src/fenrir/core/fenrirManager.py:27 -msgid "Start Fenrir" -msgstr "Fenrir starten" - -#: ../src/fenrir/core/fenrirManager.py:170 -msgid "Quit Fenrir" -msgstr "Fenrir beenden" - -#: ../src/fenrir/core/helpManager.py:44 -msgid "toggles the tutorial mode" -msgstr "Schaltet den Lernmodus um" - -#: ../src/fenrir/screenDriver/vcsaDriver.py:38 -msgid "black" -msgstr "schwarz" - -#: ../src/fenrir/screenDriver/vcsaDriver.py:38 -msgid "blue" -msgstr "blau" - -#: ../src/fenrir/screenDriver/vcsaDriver.py:38 -msgid "cyan" -msgstr "türkis" - -#: ../src/fenrir/screenDriver/vcsaDriver.py:38 -msgid "green" -msgstr "grün" - -#: ../src/fenrir/screenDriver/vcsaDriver.py:38 -msgid "red" -msgstr "rot" - -#: ../src/fenrir/screenDriver/vcsaDriver.py:38 -msgid "white" -msgstr "weiß" - -#: ../src/fenrir/screenDriver/vcsaDriver.py:38 -#: ../src/fenrir/screenDriver/vcsaDriver.py:39 -msgid "Magenta" -msgstr "Magenta" - -#: ../src/fenrir/screenDriver/vcsaDriver.py:38 -#: ../src/fenrir/screenDriver/vcsaDriver.py:39 -msgid "brown/yellow" -msgstr "braun/gelb" - -#: ../src/fenrir/screenDriver/vcsaDriver.py:39 -msgid "Black" -msgstr "Schwarz" - -#: ../src/fenrir/screenDriver/vcsaDriver.py:39 -msgid "Blue" -msgstr "Blau" - -#: ../src/fenrir/screenDriver/vcsaDriver.py:39 -msgid "Cyan" -msgstr "Türkis" - -#: ../src/fenrir/screenDriver/vcsaDriver.py:39 -msgid "Dark gray" -msgstr "Dunkelgrau" - -#: ../src/fenrir/screenDriver/vcsaDriver.py:39 -msgid "Green" -msgstr "Grün" - -#: ../src/fenrir/screenDriver/vcsaDriver.py:39 -msgid "Light blue" -msgstr "Hellblau" - -#: ../src/fenrir/screenDriver/vcsaDriver.py:39 -msgid "Light cyan" -msgstr "Helles türkis" - -#: ../src/fenrir/screenDriver/vcsaDriver.py:39 -msgid "Light gray" -msgstr "Hellgrau" - -#: ../src/fenrir/screenDriver/vcsaDriver.py:39 -msgid "Light magenta" -msgstr "Helles Magenta" - -#: ../src/fenrir/screenDriver/vcsaDriver.py:39 -msgid "Light red" -msgstr "Hellrot" - -#: ../src/fenrir/screenDriver/vcsaDriver.py:39 -msgid "Light yellow" -msgstr "Hellgelb" - -#: ../src/fenrir/screenDriver/vcsaDriver.py:39 -msgid "Red" -msgstr "Rot" - -#: ../src/fenrir/screenDriver/vcsaDriver.py:39 -msgid "White" -msgstr "Weiß" - -#: ../src/fenrir/screenDriver/vcsaDriver.py:262 -msgid "underlined" -msgstr "unterstrichen" - -#: ../src/fenrir/screenDriver/vcsaDriver.py:266 -msgid "bold" -msgstr "fett" - -#: ../src/fenrir/screenDriver/vcsaDriver.py:270 -msgid "blink" -msgstr "blinkend" - -#: ../src/fenrir/screenDriver/vcsaDriver.py:273 -#: ../src/fenrir/screenDriver/vcsaDriver.py:275 -msgid "Default" -msgstr "Standard" diff --git a/locale/es/LC_MESSAGES/fenrir.mo b/locale/es/LC_MESSAGES/fenrir.mo deleted file mode 100644 index f5255a83..00000000 Binary files a/locale/es/LC_MESSAGES/fenrir.mo and /dev/null differ diff --git a/locale/es/LC_MESSAGES/fenrir.po b/locale/es/LC_MESSAGES/fenrir.po deleted file mode 100644 index bcb5e21f..00000000 --- a/locale/es/LC_MESSAGES/fenrir.po +++ /dev/null @@ -1,840 +0,0 @@ -# SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR ORGANIZATION -# FIRST AUTHOR , YEAR. -# -msgid "" -msgstr "" -"Project-Id-Version: \n" -"POT-Creation-Date: 2017-02-26 22:19+UTC\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" - -#: ../src/fenrir/commands/commands/add_word_to_spell_check.py:27 -msgid "adds the current word to the exceptions dictionary" -msgstr "aƱade la palabra actual al diccionario" - -#: ../src/fenrir/commands/commands/add_word_to_spell_check.py:34 -#: ../src/fenrir/commands/commands/remove_word_from_spell_check.py:34 -#: ../src/fenrir/commands/commands/spell_check.py:29 -#: ../src/fenrir/commands/commands/spell_check.py:36 -msgid "pyenchant is not installed" -msgstr "Pyenchant no estĆ” instalado" - -#: ../src/fenrir/commands/commands/add_word_to_spell_check.py:49 -msgid "{0} is already in dict" -msgstr "{0} ya se encuentra en el diccionario" - -#: ../src/fenrir/commands/commands/add_word_to_spell_check.py:52 -msgid "{0} added" -msgstr "{0} se ha aƱadido" - -#: ../src/fenrir/commands/commands/bookmark_1.py:19 -#: ../src/fenrir/commands/commands/bookmark_10.py:19 -#: ../src/fenrir/commands/commands/bookmark_2.py:19 -#: ../src/fenrir/commands/commands/bookmark_3.py:19 -#: ../src/fenrir/commands/commands/bookmark_4.py:19 -#: ../src/fenrir/commands/commands/bookmark_5.py:19 -#: ../src/fenrir/commands/commands/bookmark_6.py:19 -#: ../src/fenrir/commands/commands/bookmark_7.py:19 -#: ../src/fenrir/commands/commands/bookmark_8.py:19 -#: ../src/fenrir/commands/commands/bookmark_9.py:19 -msgid "read Bookmark {0}" -msgstr "Leer marcador {0}" - -#: ../src/fenrir/commands/commands/bookmark_1.py:24 -#: ../src/fenrir/commands/commands/bookmark_10.py:24 -#: ../src/fenrir/commands/commands/bookmark_2.py:24 -#: ../src/fenrir/commands/commands/bookmark_3.py:24 -#: ../src/fenrir/commands/commands/bookmark_4.py:24 -#: ../src/fenrir/commands/commands/bookmark_5.py:24 -#: ../src/fenrir/commands/commands/bookmark_6.py:24 -#: ../src/fenrir/commands/commands/bookmark_7.py:24 -#: ../src/fenrir/commands/commands/bookmark_8.py:24 -#: ../src/fenrir/commands/commands/bookmark_9.py:24 -msgid "Bookmark {0} not set" -msgstr "No se ha establecido el marcador {0}" - -#: ../src/fenrir/commands/commands/bookmark_1.py:27 -#: ../src/fenrir/commands/commands/bookmark_1.py:30 -#: ../src/fenrir/commands/commands/bookmark_10.py:27 -#: ../src/fenrir/commands/commands/bookmark_10.py:30 -#: ../src/fenrir/commands/commands/bookmark_2.py:27 -#: ../src/fenrir/commands/commands/bookmark_2.py:30 -#: ../src/fenrir/commands/commands/bookmark_3.py:27 -#: ../src/fenrir/commands/commands/bookmark_3.py:30 -#: ../src/fenrir/commands/commands/bookmark_4.py:27 -#: ../src/fenrir/commands/commands/bookmark_4.py:30 -#: ../src/fenrir/commands/commands/bookmark_5.py:27 -#: ../src/fenrir/commands/commands/bookmark_5.py:30 -#: ../src/fenrir/commands/commands/bookmark_6.py:27 -#: ../src/fenrir/commands/commands/bookmark_6.py:30 -#: ../src/fenrir/commands/commands/bookmark_7.py:27 -#: ../src/fenrir/commands/commands/bookmark_7.py:30 -#: ../src/fenrir/commands/commands/bookmark_8.py:27 -#: ../src/fenrir/commands/commands/bookmark_8.py:30 -#: ../src/fenrir/commands/commands/bookmark_9.py:27 -#: ../src/fenrir/commands/commands/bookmark_9.py:30 -msgid "Bookmark for application {0} not set" -msgstr "El marcador para la aplicación {0} no se ha establecido" - -#: ../src/fenrir/commands/commands/bookmark_1.py:43 -#: ../src/fenrir/commands/commands/bookmark_10.py:43 -#: ../src/fenrir/commands/commands/bookmark_2.py:43 -#: ../src/fenrir/commands/commands/bookmark_3.py:43 -#: ../src/fenrir/commands/commands/bookmark_4.py:43 -#: ../src/fenrir/commands/commands/bookmark_5.py:43 -#: ../src/fenrir/commands/commands/bookmark_6.py:43 -#: ../src/fenrir/commands/commands/bookmark_7.py:43 -#: ../src/fenrir/commands/commands/bookmark_8.py:43 -#: ../src/fenrir/commands/commands/bookmark_9.py:43 -#: ../src/fenrir/commands/commands/curr_screen_after_cursor.py:27 -#: ../src/fenrir/commands/commands/curr_screen_before_cursor.py:30 -#: ../src/fenrir/commands/commands/cursor_read_to_end_of_line.py:27 -#: ../src/fenrir/commands/commands/indent_curr_line.py:31 -#: ../src/fenrir/commands/commands/marked_text.py:33 -#: ../src/fenrir/commands/commands/present_first_line.py:25 -#: ../src/fenrir/commands/commands/present_last_line.py:25 -#: ../src/fenrir/commands/commands/review_curr_char_phonetic.py:27 -#: ../src/fenrir/commands/commands/review_curr_line.py:27 -#: ../src/fenrir/commands/commands/review_curr_word.py:27 -#: ../src/fenrir/commands/commands/review_curr_word_phonetic.py:27 -#: ../src/fenrir/commands/commands/review_line_begin.py:27 -#: ../src/fenrir/commands/commands/review_next_line.py:29 -#: ../src/fenrir/commands/commands/review_next_word.py:29 -#: ../src/fenrir/commands/commands/review_next_word_phonetic.py:27 -#: ../src/fenrir/commands/commands/review_prev_line.py:27 -#: ../src/fenrir/commands/commands/review_prev_word.py:27 -#: ../src/fenrir/commands/commands/review_prev_word_phonetic.py:27 -#: ../src/fenrir/commands/onInput/55000-present_line_if_cursor_change_vertical.py:38 -#: ../src/fenrir/commands/onInput/72000-history.py:50 -msgid "blank" -msgstr "en blanco" - -#: ../src/fenrir/commands/commands/braille_flush.py:17 -msgid "flush the braille device if a message is written on" -msgstr "Limpia el dispositivo braille al escribir un mensaje en Ć©l." - -#: ../src/fenrir/commands/commands/braille_pan_left.py:17 -msgid "Move braille view to the left." -msgstr "Mover la vista Braille a la izquierda" - -#: ../src/fenrir/commands/commands/braille_pan_right.py:17 -msgid "Move braille view to the right." -msgstr "Mover la vista braille a la derecha" - -#: ../src/fenrir/commands/commands/braille_return_to_cursor.py:17 -msgid "Set the braille view back to cursor." -msgstr "Establecer la vista Braille en el cursor" - -#: ../src/fenrir/commands/commands/clear_bookmark_1.py:17 -#: ../src/fenrir/commands/commands/clear_bookmark_10.py:17 -#: ../src/fenrir/commands/commands/clear_bookmark_2.py:17 -#: ../src/fenrir/commands/commands/clear_bookmark_3.py:17 -#: ../src/fenrir/commands/commands/clear_bookmark_4.py:17 -#: ../src/fenrir/commands/commands/clear_bookmark_5.py:17 -#: ../src/fenrir/commands/commands/clear_bookmark_6.py:17 -#: ../src/fenrir/commands/commands/clear_bookmark_7.py:17 -#: ../src/fenrir/commands/commands/clear_bookmark_8.py:17 -#: ../src/fenrir/commands/commands/clear_bookmark_9.py:17 -msgid "remove Bookmark {0}" -msgstr "Eliminar marcador {0}" - -#: ../src/fenrir/commands/commands/clear_bookmark_1.py:24 -#: ../src/fenrir/commands/commands/clear_bookmark_10.py:24 -#: ../src/fenrir/commands/commands/clear_bookmark_2.py:24 -#: ../src/fenrir/commands/commands/clear_bookmark_3.py:24 -#: ../src/fenrir/commands/commands/clear_bookmark_4.py:24 -#: ../src/fenrir/commands/commands/clear_bookmark_5.py:24 -#: ../src/fenrir/commands/commands/clear_bookmark_6.py:24 -#: ../src/fenrir/commands/commands/clear_bookmark_7.py:24 -#: ../src/fenrir/commands/commands/clear_bookmark_8.py:24 -#: ../src/fenrir/commands/commands/clear_bookmark_9.py:24 -msgid "Bookmark {0} removed for application {1}" -msgstr "el marcador {0} de la aplicación {1} ha sido eliminado" - -#: ../src/fenrir/commands/commands/clear_clipboard.py:17 -msgid "clears the currently selected clipboard" -msgstr "VacĆ­a el portapapeles seleccionado" - -#: ../src/fenrir/commands/commands/clear_clipboard.py:22 -msgid "clipboard cleared" -msgstr "Portapapeles vacĆ­o" - -#: ../src/fenrir/commands/commands/clear_window_application.py:17 -msgid "Turn off window mode for application" -msgstr "Desactiva el modo de ventana para la aplicación" - -#: ../src/fenrir/commands/commands/clear_window_application.py:22 -msgid "Window Mode off for application {0}" -msgstr "El modo de ventana estĆ” desactivado para la aplicación {0}" - -#: ../src/fenrir/commands/commands/clear_window_application.py:24 -msgid "Not in window Mode" -msgstr "No estĆ” en modo ventana" - -#: ../src/fenrir/commands/commands/copy_marked_to_clipboard.py:18 -msgid "copies marked text to the currently selected clipboard" -msgstr "Copia el texto marcado al portapapeles en uso" - -#: ../src/fenrir/commands/commands/copy_marked_to_clipboard.py:22 -msgid "one or two marks needed" -msgstr "se necesita una o dos marcas" - -#: ../src/fenrir/commands/commands/curr_clipboard.py:17 -msgid "speaks the contents of the currently selected clipboard" -msgstr "Habla el contenido del portapapeles seleccionado" - -#: ../src/fenrir/commands/commands/curr_clipboard.py:21 -#: ../src/fenrir/commands/commands/export_clipboard_to_x.py:29 -#: ../src/fenrir/commands/commands/export_clipboard_to_x.py:32 -#: ../src/fenrir/commands/commands/export_clipboard_to_x.py:35 -#: ../src/fenrir/commands/commands/export_clipboard_to_x.py:38 -#: ../src/fenrir/commands/commands/first_clipboard.py:21 -#: ../src/fenrir/commands/commands/last_clipboard.py:21 -#: ../src/fenrir/commands/commands/next_clipboard.py:21 -#: ../src/fenrir/commands/commands/paste_clipboard.py:23 -#: ../src/fenrir/commands/commands/paste_clipboard.py:26 -#: ../src/fenrir/commands/commands/paste_clipboard.py:29 -#: ../src/fenrir/commands/commands/paste_clipboard.py:32 -#: ../src/fenrir/commands/commands/prev_clipboard.py:21 -msgid "clipboard empty" -msgstr "Portapapeles vacĆ­o" - -#: ../src/fenrir/commands/commands/curr_screen.py:17 -msgid "reads the contents of the current screen" -msgstr "Lee el contenido de la pantalla actual" - -#: ../src/fenrir/commands/commands/curr_screen.py:21 -msgid "screen is empty" -msgstr "La pantalla estĆ” vacĆ­a" - -#: ../src/fenrir/commands/commands/curr_screen_after_cursor.py:18 -msgid "reads from the cursor to the bottom of the screen" -msgstr "Lee desde el cursor hasta el final de la pantalla" - -#: ../src/fenrir/commands/commands/curr_screen_before_cursor.py:18 -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." - -#: ../src/fenrir/commands/commands/cursor_column.py:17 -msgid "" -"presents the current column number for review cursor in review mode or the " -"text cursor if not. Starts with 1" -msgstr "" -"Indica el nĆŗmero de columna actual para el cursor de revisión si se estĆ” en " -"el modo de revisión, o el cursor de texto si no." - -#: ../src/fenrir/commands/commands/cursor_lineno.py:17 -msgid "" -"presents the current line number for review cursor in review mode or the " -"text cursor if not. Starts with 1" -msgstr "" -"Indica el nĆŗmero de lĆ­nea actual para el cursor de revisión si se estĆ” en el " -"modo de revisión, o el cursor de texto si no." - -#: ../src/fenrir/commands/commands/cursor_position.py:17 -msgid "displays the position of the review cursor" -msgstr "Indica la posición del cursor de revisión" - -#: ../src/fenrir/commands/commands/cursor_position.py:23 -msgid "line {0}, column {1}" -msgstr "LĆ­nea {0}, columna {1}" - -#: ../src/fenrir/commands/commands/cursor_read_to_end_of_line.py:18 -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." - -#: ../src/fenrir/commands/commands/date.py:18 -msgid "presents the date" -msgstr "Lee la fecha" - -#: ../src/fenrir/commands/commands/dec_sound_volume.py:18 -msgid "decrease sound volume" -msgstr "Reduce el volumen del sonido" - -#: ../src/fenrir/commands/commands/dec_sound_volume.py:29 -#: ../src/fenrir/commands/commands/inc_sound_volume.py:29 -msgid "{0} percent sound volume" -msgstr "volumen de sonido al {0} porciento" - -#: ../src/fenrir/commands/commands/dec_speech_pitch.py:18 -msgid "decreases the pitch of the speech" -msgstr "Reduce el tono de voz" - -#: ../src/fenrir/commands/commands/dec_speech_pitch.py:27 -#: ../src/fenrir/commands/commands/inc_speech_pitch.py:27 -msgid "{0} percent speech pitch" -msgstr "Tono de voz al {0} porciento" - -#: ../src/fenrir/commands/commands/dec_speech_rate.py:18 -msgid "decreases the rate of the speech" -msgstr "Reduce la velocidad de voz" - -#: ../src/fenrir/commands/commands/dec_speech_rate.py:27 -#: ../src/fenrir/commands/commands/inc_speech_rate.py:27 -msgid "{0} percent speech rate" -msgstr "Velocidad de voz al {0} porciento" - -#: ../src/fenrir/commands/commands/dec_speech_volume.py:18 -msgid "decreases the volume of the speech" -msgstr "Reduce el volumen de voz" - -#: ../src/fenrir/commands/commands/dec_speech_volume.py:27 -#: ../src/fenrir/commands/commands/inc_speech_volume.py:27 -msgid "{0} percent speech volume" -msgstr "Volumen de voz al {0} porciento" - -#: ../src/fenrir/commands/commands/exit_review.py:17 -msgid "exits review mode" -msgstr "Sale del modo de revisión" - -#: ../src/fenrir/commands/commands/exit_review.py:21 -msgid "Not in review mode" -msgstr "No estĆ” en modo de revisión" - -#: ../src/fenrir/commands/commands/exit_review.py:25 -msgid "leave review mode" -msgstr "Saliendo del modo de revisión" - -#: ../src/fenrir/commands/commands/export_clipboard_to_x.py:21 -msgid "export the current fenrir clipboard to X clipboard" -msgstr "Exporta el contenido del portapapeles de Fenrir al servidor X" - -#: ../src/fenrir/commands/commands/first_clipboard.py:17 -msgid "selects the first clipboard" -msgstr "Selecciona el primer portapapeles" - -#: ../src/fenrir/commands/commands/forward_keypress.py:17 -msgid "sends the following keypress to the terminal" -msgstr "EnvĆ­a la siguiente tecla a la terminal" - -#: ../src/fenrir/commands/commands/forward_keypress.py:21 -msgid "Forward next keypress" -msgstr "Enviar la siguiente tecla" - -#: ../src/fenrir/commands/commands/inc_sound_volume.py:18 -msgid "adjusts the volume for in coming sounds" -msgstr "aumenta el volumen del sonido" - -#: ../src/fenrir/commands/commands/inc_speech_pitch.py:18 -msgid "increases the pitch of the speech" -msgstr "Aumenta el tono de voz" - -#: ../src/fenrir/commands/commands/inc_speech_rate.py:18 -msgid "increase the speech rate" -msgstr "Aumenta la velocidad de voz" - -#: ../src/fenrir/commands/commands/inc_speech_volume.py:18 -msgid "increase the speech volume" -msgstr "Aumenta el volumen de voz" - -#: ../src/fenrir/commands/commands/indent_curr_line.py:18 -msgid "shows the indention level for the current line" -msgstr "Lee el nivel de indentación de la lĆ­nea actual" - -#: ../src/fenrir/commands/commands/indent_curr_line.py:33 -msgid "indent {0}" -msgstr "" - -#: ../src/fenrir/commands/commands/last_clipboard.py:17 -msgid "selects the last clipboard" -msgstr "Selecciona el Ćŗltimo portapapeles" - -#: ../src/fenrir/commands/commands/last_incoming.py:17 -msgid "displays the last received text" -msgstr "Lee el Ćŗltimo mensaje recibido" - -#: ../src/fenrir/commands/commands/marked_text.py:18 -msgid "speaks the currently selected text that will be copied to the clipboard" -msgstr "Lee el texto que serĆ” copiado al portapapeles" - -#: ../src/fenrir/commands/commands/marked_text.py:23 -msgid "please set begin and endmark" -msgstr "Establece una marca de principio y otra para el final de la selección." - -#: ../src/fenrir/commands/commands/next_clipboard.py:17 -msgid "selects the next clipboard" -msgstr "Selecciona el siguiente portapapeles" - -#: ../src/fenrir/commands/commands/next_clipboard.py:26 -msgid "First clipboard " -msgstr "Primer portapapeles" - -#: ../src/fenrir/commands/commands/paste_clipboard.py:18 -msgid "pastes the text from the currently selected clipboard" -msgstr "Pega el texto del portapapeles en uso" - -#: ../src/fenrir/commands/commands/present_first_line.py:18 -msgid "present first line" -msgstr "lee la primera lĆ­nea" - -#: ../src/fenrir/commands/commands/present_last_line.py:18 -#: ../src/fenrir/commands/commands/review_curr_line.py:18 -msgid "current line" -msgstr "lĆ­nea actual" - -#: ../src/fenrir/commands/commands/prev_clipboard.py:17 -msgid "selects the previous clipboard" -msgstr "Selecciona el portapapeles anterior" - -#: ../src/fenrir/commands/commands/prev_clipboard.py:26 -msgid "Last clipboard " -msgstr "Último portapapeles" - -#: ../src/fenrir/commands/commands/quit_fenrir.py:17 -msgid "exits Fenrir" -msgstr "Sale de Fenrir" - -#: ../src/fenrir/commands/commands/remove_marks.py:17 -msgid "removes marks from selected text" -msgstr "Quitar marcas del texto seleccionado" - -#: ../src/fenrir/commands/commands/remove_marks.py:21 -msgid "Remove marks" -msgstr "Marcas eliminadas" - -#: ../src/fenrir/commands/commands/remove_word_from_spell_check.py:27 -msgid "removes the current word from the exceptions dictionary" -msgstr "Quita la palabra actual del diccionario de excepciones" - -#: ../src/fenrir/commands/commands/remove_word_from_spell_check.py:50 -msgid "{0} is already removed from dict" -msgstr "{0} no estĆ” en el diccionario" - -#: ../src/fenrir/commands/commands/remove_word_from_spell_check.py:53 -msgid "{0} removed" -msgstr "se ha eliminado {0}" - -#: ../src/fenrir/commands/commands/review_bottom.py:17 -msgid "move review to bottom of screen" -msgstr "Mueve el cursor de revisión al final de la pantalla" - -#: ../src/fenrir/commands/commands/review_bottom.py:21 -msgid "Bottom" -msgstr "Fin" - -#: ../src/fenrir/commands/commands/review_curr_char.py:18 -msgid "presents the current character." -msgstr "Lee el carĆ”cter actual" - -#: ../src/fenrir/commands/commands/review_curr_char_phonetic.py:18 -msgid "set review and phonetically presents the current character" -msgstr "Lee fonĆ©ticamente el Ćŗltimo carĆ”cter" - -#: ../src/fenrir/commands/commands/review_curr_word.py:18 -msgid "current word." -msgstr "Palabra actual. " - -#: ../src/fenrir/commands/commands/review_curr_word.py:32 -#: ../src/fenrir/commands/commands/review_curr_word_phonetic.py:36 -#: ../src/fenrir/commands/commands/review_down.py:27 -#: ../src/fenrir/commands/commands/review_next_char.py:28 -#: ../src/fenrir/commands/commands/review_next_char_phonetic.py:30 -#: ../src/fenrir/commands/commands/review_next_line.py:34 -#: ../src/fenrir/commands/commands/review_next_word.py:34 -#: ../src/fenrir/commands/commands/review_next_word_phonetic.py:36 -#: ../src/fenrir/commands/commands/review_prev_char.py:31 -#: ../src/fenrir/commands/commands/review_prev_char_phonetic.py:30 -#: ../src/fenrir/commands/commands/review_prev_line.py:32 -#: ../src/fenrir/commands/commands/review_prev_word.py:32 -#: ../src/fenrir/commands/commands/review_prev_word_phonetic.py:36 -#: ../src/fenrir/commands/commands/review_up.py:27 -msgid "end of screen" -msgstr "Fin de pantalla" - -#: ../src/fenrir/commands/commands/review_curr_word.py:35 -#: ../src/fenrir/commands/commands/review_curr_word_phonetic.py:39 -#: ../src/fenrir/commands/commands/review_next_char.py:31 -#: ../src/fenrir/commands/commands/review_next_char_phonetic.py:33 -#: ../src/fenrir/commands/commands/review_next_word.py:37 -#: ../src/fenrir/commands/commands/review_next_word_phonetic.py:39 -#: ../src/fenrir/commands/commands/review_prev_char.py:34 -#: ../src/fenrir/commands/commands/review_prev_char_phonetic.py:33 -#: ../src/fenrir/commands/commands/review_prev_word.py:35 -#: ../src/fenrir/commands/commands/review_prev_word_phonetic.py:39 -#: ../src/fenrir/commands/commands/review_up.py:30 -msgid "line break" -msgstr "Nueva lĆ­nea" - -#: ../src/fenrir/commands/commands/review_curr_word_phonetic.py:19 -#: ../src/fenrir/commands/commands/review_next_word_phonetic.py:19 -#: ../src/fenrir/commands/commands/review_prev_word_phonetic.py:19 -msgid "phonetically spells the current word and set review to it" -msgstr "Deletrea fonĆ©ticamente la palabra actual" - -#: ../src/fenrir/commands/commands/review_down.py:18 -msgid "set review cursor to char below the current char and present it." -msgstr "Mueve el cursor de revisión al carĆ”cter despuĆ©s del actual y lo lee" - -#: ../src/fenrir/commands/commands/review_line_begin.py:18 -msgid "set review cursor to begin of current line and display the content" -msgstr "lee la lĆ­nea actual desde el principio" - -#: ../src/fenrir/commands/commands/review_line_begin.py:30 -msgid "beginning of line" -msgstr "principio de lĆ­nea" - -#: ../src/fenrir/commands/commands/review_line_end.py:18 -#: ../src/fenrir/commands/commands/review_line_first_char.py:19 -#: ../src/fenrir/commands/commands/review_line_last_char.py:18 -msgid "set review cursor to end of current line and display the content" -msgstr "Lee el final de la lĆ­nea" - -#: ../src/fenrir/commands/commands/review_line_end.py:27 -msgid "end of line" -msgstr "Fin de lĆ­nea" - -#: ../src/fenrir/commands/commands/review_line_first_char.py:26 -msgid "line is empty" -msgstr "LĆ­nea en blanco" - -#: ../src/fenrir/commands/commands/review_line_first_char.py:33 -msgid "first char in line indent {0}" -msgstr "" - -#: ../src/fenrir/commands/commands/review_line_last_char.py:27 -msgid "last char in line" -msgstr "Ćŗltimo carĆ”cter de la lĆ­nea" - -#: ../src/fenrir/commands/commands/review_next_char.py:18 -msgid "moves review to the next character and presents it" -msgstr "Lee el siguiente carĆ”cter" - -#: ../src/fenrir/commands/commands/review_next_char_phonetic.py:18 -msgid "phonetically presents the next character and set review to it" -msgstr "Lee fonĆ©ticamente el siguiente carĆ”cter" - -#: ../src/fenrir/commands/commands/review_next_line.py:18 -msgid "moves review to the next line and presents it" -msgstr "Lee la siguiente lĆ­nea" - -#: ../src/fenrir/commands/commands/review_next_word.py:18 -msgid "moves review to the next word and presents it" -msgstr "Lee la siguiente palabra" - -#: ../src/fenrir/commands/commands/review_prev_char.py:18 -msgid "moves review to the previous character and presents it" -msgstr "Lee el carĆ”cter anterior" - -#: ../src/fenrir/commands/commands/review_prev_char_phonetic.py:18 -msgid "phonetically presents the previous character and set review to it" -msgstr "Lee fonĆ©ticamente el carĆ”cter anterior" - -#: ../src/fenrir/commands/commands/review_prev_line.py:18 -msgid "moves review to the previous line and presents it" -msgstr "Lee la lĆ­nea anterior" - -#: ../src/fenrir/commands/commands/review_prev_word.py:18 -msgid "moves review focus to the previous word and presents it" -msgstr "Lee la palabra anterior" - -#: ../src/fenrir/commands/commands/review_top.py:18 -msgid "move review to top of screen" -msgstr "ir al final de la pantalla" - -#: ../src/fenrir/commands/commands/review_top.py:22 -msgid "Top" -msgstr "Fin" - -#: ../src/fenrir/commands/commands/review_up.py:18 -msgid "set review cursor to the char in the line below and present it" -msgstr "Mueve el cursor de revisión al carĆ”cter en la siguiente lĆ­nea y lo lee" - -#: ../src/fenrir/commands/commands/set_bookmark_1.py:18 -#: ../src/fenrir/commands/commands/set_bookmark_10.py:18 -#: ../src/fenrir/commands/commands/set_bookmark_2.py:18 -#: ../src/fenrir/commands/commands/set_bookmark_3.py:18 -#: ../src/fenrir/commands/commands/set_bookmark_4.py:18 -#: ../src/fenrir/commands/commands/set_bookmark_5.py:18 -#: ../src/fenrir/commands/commands/set_bookmark_6.py:18 -#: ../src/fenrir/commands/commands/set_bookmark_7.py:18 -#: ../src/fenrir/commands/commands/set_bookmark_8.py:18 -#: ../src/fenrir/commands/commands/set_bookmark_9.py:18 -msgid "set Bookmark {0}" -msgstr "AƱadir marcador {0}" - -#: ../src/fenrir/commands/commands/set_bookmark_1.py:22 -#: ../src/fenrir/commands/commands/set_bookmark_10.py:22 -#: ../src/fenrir/commands/commands/set_bookmark_2.py:22 -#: ../src/fenrir/commands/commands/set_bookmark_3.py:22 -#: ../src/fenrir/commands/commands/set_bookmark_4.py:22 -#: ../src/fenrir/commands/commands/set_bookmark_5.py:22 -#: ../src/fenrir/commands/commands/set_bookmark_6.py:22 -#: ../src/fenrir/commands/commands/set_bookmark_7.py:22 -#: ../src/fenrir/commands/commands/set_bookmark_8.py:22 -#: ../src/fenrir/commands/commands/set_bookmark_9.py:22 -msgid "No Mark found" -msgstr "No se han encontrado marcas" - -#: ../src/fenrir/commands/commands/set_bookmark_1.py:32 -#: ../src/fenrir/commands/commands/set_bookmark_10.py:32 -#: ../src/fenrir/commands/commands/set_bookmark_2.py:32 -#: ../src/fenrir/commands/commands/set_bookmark_3.py:32 -#: ../src/fenrir/commands/commands/set_bookmark_4.py:32 -#: ../src/fenrir/commands/commands/set_bookmark_5.py:32 -#: ../src/fenrir/commands/commands/set_bookmark_6.py:32 -#: ../src/fenrir/commands/commands/set_bookmark_7.py:32 -#: ../src/fenrir/commands/commands/set_bookmark_8.py:32 -#: ../src/fenrir/commands/commands/set_bookmark_9.py:32 -msgid "Bookmark {0} set for application {1}" -msgstr "AƱadido marcador {0} para aplicación {1}" - -#: ../src/fenrir/commands/commands/set_mark.py:17 -msgid "places marks to select text to copy to the clipboard" -msgstr "" -"Establece marcas para seleccionar texto que serĆ” copiado al portapapeles" - -#: ../src/fenrir/commands/commands/set_mark.py:21 -msgid "no review cursor" -msgstr "No hay cursor de revisión" - -#: ../src/fenrir/commands/commands/set_mark.py:26 -#: ../src/fenrir/commands/commands/set_mark.py:28 -msgid "set mark" -msgstr "Marcar" - -#: ../src/fenrir/commands/commands/set_window_application.py:17 -msgid "set Window Mode, needs 2 marks " -msgstr "se necesitan dos marcas para establecer el modo ventana" - -#: ../src/fenrir/commands/commands/set_window_application.py:22 -msgid "Window Mode on for application {0}" -msgstr "El modo de ventana estĆ” activado para la aplicación {0}" - -#: ../src/fenrir/commands/commands/set_window_application.py:25 -msgid "Set window begin and end marks" -msgstr "Establece una marca de principio y otra para el final de la ventana" - -#: ../src/fenrir/commands/commands/shut_up.py:17 -msgid "interrupts the current presentation" -msgstr "Interrumpe el habla y braille." - -#: ../src/fenrir/commands/commands/spell_check.py:26 -msgid "checks the spelling of the current word" -msgstr "Revisa la ortografĆ­a para la palabra actual" - -#: ../src/fenrir/commands/commands/spell_check.py:52 -#: ../src/fenrir/commands/onInput/62000-spell_check.py:132 -msgid "misspelled" -msgstr "mal escrito" - -#: ../src/fenrir/commands/commands/spell_check.py:54 -msgid "correct" -msgstr "Correcto" - -#: ../src/fenrir/commands/commands/subprocess.py:21 -msgid "script: {0} fullpath: {1}" -msgstr "Script: {0} Ruta de archivo: {1}" - -#: ../src/fenrir/commands/commands/subprocess.py:24 -msgid "scriptfile does not exist" -msgstr "el archivo del script no existe" - -#: ../src/fenrir/commands/commands/subprocess.py:27 -msgid "scriptfile is not a file" -msgstr "El script no es un archivo" - -#: ../src/fenrir/commands/commands/subprocess.py:30 -msgid "scriptfile is not executable" -msgstr "el script no es ejecutable" - -#: ../src/fenrir/commands/commands/temp_disable_speech.py:17 -#: ../src/fenrir/commands/onInput/15000-enable_temp_speech.py:17 -msgid "disables speech until next keypress" -msgstr "Desactiva el habla hasta la siguiente pulsación de teclas" - -#: ../src/fenrir/commands/commands/temp_disable_speech.py:21 -msgid "speech temporary disabled" -msgstr "Habla desactivada temporalmente" - -#: ../src/fenrir/commands/commands/time.py:18 -msgid "presents the time" -msgstr "Lee la hora" - -#: ../src/fenrir/commands/commands/toggle_auto_read.py:16 -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" - -#: ../src/fenrir/commands/commands/toggle_auto_read.py:21 -msgid "autoread enabled" -msgstr "Lectura automĆ”tica activada" - -#: ../src/fenrir/commands/commands/toggle_auto_read.py:23 -msgid "autoread disabled" -msgstr "Lectura automĆ”tica desactivada" - -#: ../src/fenrir/commands/commands/toggle_auto_spell_check.py:17 -msgid "enables or disables automatic spell checking" -msgstr "Activa o desactiva la corrección ortogrĆ”fica automĆ”tica" - -#: ../src/fenrir/commands/commands/toggle_auto_spell_check.py:22 -msgid "auto spellcheck enabled" -msgstr "corrección ortogrĆ”fica automĆ”tica activada" - -#: ../src/fenrir/commands/commands/toggle_auto_spell_check.py:24 -msgid "auto spellcheck disabled" -msgstr "corrección ortogrĆ”fica automĆ”tica desactivada" - -#: ../src/fenrir/commands/commands/toggle_auto_time.py:16 -msgid "enables or disables automatic reading of time after an period" -msgstr "" -"Activa o desactiva la lectura automĆ”tica de la hora despuĆ©s de un intervalo " -"de tiempo" - -#: ../src/fenrir/commands/commands/toggle_auto_time.py:21 -msgid "autotime enabled" -msgstr "lectura automĆ”tica de hora activada" - -#: ../src/fenrir/commands/commands/toggle_auto_time.py:23 -msgid "autotime disabled" -msgstr "Lectura automĆ”tica de hora desactivada" - -#: ../src/fenrir/commands/commands/toggle_braille.py:17 -msgid "enables and disables output in braille" -msgstr "Activa y desactiva la salida Braille" - -#: ../src/fenrir/commands/commands/toggle_braille.py:21 -msgid "braille disabled" -msgstr "Braille desactivado" - -#: ../src/fenrir/commands/commands/toggle_braille.py:24 -msgid "braille enabled" -msgstr "Braille activado" - -#: ../src/fenrir/commands/commands/toggle_emoticons.py:16 -msgid "enables or disables announcement of emoticons instead of chars" -msgstr "Activa o desactiva el anunciado de emoticonos en lugar de caracteres" - -#: ../src/fenrir/commands/commands/toggle_emoticons.py:21 -msgid "emoticons enabled" -msgstr "Emoticonos activados" - -#: ../src/fenrir/commands/commands/toggle_emoticons.py:23 -msgid "emoticons disabled" -msgstr "Emoticonos desactivados" - -#: ../src/fenrir/commands/commands/toggle_highlight_tracking.py:16 -#: ../src/fenrir/commands/onInput/56000-highlight_tracking.py:16 -msgid "enables or disables tracking of highlighted" -msgstr "Activa o desactiva el seguimiento de texto resaltado" - -#: ../src/fenrir/commands/commands/toggle_highlight_tracking.py:24 -msgid "highlight tracking" -msgstr "Seguimiento del texto resaltado" - -#: ../src/fenrir/commands/commands/toggle_highlight_tracking.py:26 -msgid "cursor tracking" -msgstr "Seguimiento del cursor" - -#: ../src/fenrir/commands/commands/toggle_output.py:17 -msgid "toggles all output settings" -msgstr "Conmuta el silencio del lector de pantalla" - -#: ../src/fenrir/commands/commands/toggle_output.py:23 -msgid "Fenrir muted" -msgstr "Fenrir silenciado" - -#: ../src/fenrir/commands/commands/toggle_output.py:31 -msgid "Fenrir unmuted" -msgstr "Desactivado el silencio" - -#: ../src/fenrir/commands/commands/toggle_punctuation_level.py:23 -msgid "No punctuation found." -msgstr "No se ha encontrado la puntuación" - -#: ../src/fenrir/commands/commands/toggle_sound.py:17 -msgid "enables or disables sound" -msgstr "Activa o desactiva el sonido" - -#: ../src/fenrir/commands/commands/toggle_sound.py:21 -msgid "sound disabled" -msgstr "sonido desactivado" - -#: ../src/fenrir/commands/commands/toggle_sound.py:24 -msgid "sound enabled" -msgstr "Sonido activado" - -#: ../src/fenrir/commands/commands/toggle_speech.py:17 -msgid "enables or disables speech" -msgstr "Activa o desactiva el habla" - -#: ../src/fenrir/commands/commands/toggle_speech.py:21 -msgid "speech disabled" -msgstr "Habla desactivada" - -#: ../src/fenrir/commands/commands/toggle_speech.py:24 -#: ../src/fenrir/commands/onInput/15000-enable_temp_speech.py:28 -msgid "speech enabled" -msgstr "Habla activada" - -#: ../src/fenrir/commands/commands/toggle_tutorial_mode.py:18 -msgid "" -"You are leaving the tutorial mode. Press that shortcut again to enter the " -"tutorial mode again." -msgstr "" -"Has salido del tutorial. Presiona la misma combinación de teclado para " -"entrar al tutorial de nuevo." - -#: ../src/fenrir/commands/commands/toggle_tutorial_mode.py:21 -msgid "" -"you entered the tutorial mode. In that mode the commands are not executed. " -"but you get a description of what the shortcut does. To leave the tutorial " -"mode, press that shortcut again." -msgstr "" -"Has entrado al tutorial. En este modo las combinaciones de teclado no se " -"ejecutarĆ”n pero te darĆ”n una descripción de lo que hacen. Para salir del " -"tutorial, presiona la misma combinación de teclado de nuevo." - -#: ../src/fenrir/commands/onInput/80000-capslock.py:22 -msgid "Capslock on" -msgstr "Bloqueo mayĆŗsculas activado" - -#: ../src/fenrir/commands/onInput/80000-capslock.py:24 -msgid "Capslock off" -msgstr "Bloqueo mayĆŗsculas desactivado" - -#: ../src/fenrir/commands/onInput/80300-scrolllock.py:22 -msgid "Scrolllock on" -msgstr "Bloqueo de desplazamiento activado" - -#: ../src/fenrir/commands/onInput/80300-scrolllock.py:24 -msgid "Scrolllock off" -msgstr "Bloqueo de desplazamiento desactivado" - -#: ../src/fenrir/commands/onInput/80500-numlock.py:22 -msgid "Numlock on" -msgstr "Bloqueo numĆ©rico activado" - -#: ../src/fenrir/commands/onInput/80500-numlock.py:24 -msgid "Numlock off" -msgstr "Bloqueo numĆ©rico desactivado" - -#: ../src/fenrir/commands/onScreenChanged/80000-screen_change_announcement.py:20 -msgid "screen {0}" -msgstr "pantalla {0}" - -#: ../src/fenrir/commands/onScreenUpdate/76000-time.py:66 -msgid "Autotime: {0}" -msgstr "Hora automĆ”tica: {0}" - -#: ../src/fenrir/fenrir.py:24 -msgid "Start Fenrir" -msgstr "Iniciando fenrir" - -#: ../src/fenrir/fenrir.py:99 -msgid "Quit Fenrir" -msgstr "Cerrando Fenrir" diff --git a/locale/pl/LC_MESSAGES/fenrir.mo b/locale/pl/LC_MESSAGES/fenrir.mo deleted file mode 100644 index 640c1a7a..00000000 Binary files a/locale/pl/LC_MESSAGES/fenrir.mo and /dev/null differ diff --git a/locale/pl/LC_MESSAGES/fenrir.po b/locale/pl/LC_MESSAGES/fenrir.po deleted file mode 100644 index 6f00e6bc..00000000 --- a/locale/pl/LC_MESSAGES/fenrir.po +++ /dev/null @@ -1,997 +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: 2018-10-23 16:35+0200\n" -"Language-Team: \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" -"Last-Translator: \n" -"Language: pl_PL\n" - -#: ../src/fenrir/commands/commands/add_word_to_spell_check.py:27 -msgid "adds the current word to the exceptions dictionary" -msgstr "Dodaje aktualne słowo do słownika wyjątków." - -#: ../src/fenrir/commands/commands/add_word_to_spell_check.py:34 -#: ../src/fenrir/commands/commands/remove_word_from_spell_check.py:34 -#: ../src/fenrir/commands/commands/spell_check.py:29 -#: ../src/fenrir/commands/commands/spell_check.py:36 -msgid "pyenchant is not installed" -msgstr "Pyenchant nie jest zainstalowany." - -#: ../src/fenrir/commands/commands/add_word_to_spell_check.py:49 -msgid "{0} is already in dict" -msgstr "{0} już znajduje się w słowniku." - -#: ../src/fenrir/commands/commands/add_word_to_spell_check.py:52 -msgid "{0} added" -msgstr "Słowo {0} dodane do słownika." - -#: ../src/fenrir/commands/commands/bookmark_1.py:19 -#: ../src/fenrir/commands/commands/bookmark_10.py:19 -#: ../src/fenrir/commands/commands/bookmark_2.py:19 -#: ../src/fenrir/commands/commands/bookmark_3.py:19 -#: ../src/fenrir/commands/commands/bookmark_4.py:19 -#: ../src/fenrir/commands/commands/bookmark_5.py:19 -#: ../src/fenrir/commands/commands/bookmark_6.py:19 -#: ../src/fenrir/commands/commands/bookmark_7.py:19 -#: ../src/fenrir/commands/commands/bookmark_8.py:19 -#: ../src/fenrir/commands/commands/bookmark_9.py:19 -msgid "read Bookmark {0}" -msgstr "Przeczytaj Zakładkę {0}." - -#: ../src/fenrir/commands/commands/bookmark_1.py:24 -#: ../src/fenrir/commands/commands/bookmark_10.py:24 -#: ../src/fenrir/commands/commands/bookmark_2.py:24 -#: ../src/fenrir/commands/commands/bookmark_3.py:24 -#: ../src/fenrir/commands/commands/bookmark_4.py:24 -#: ../src/fenrir/commands/commands/bookmark_5.py:24 -#: ../src/fenrir/commands/commands/bookmark_6.py:24 -#: ../src/fenrir/commands/commands/bookmark_7.py:24 -#: ../src/fenrir/commands/commands/bookmark_8.py:24 -#: ../src/fenrir/commands/commands/bookmark_9.py:24 -msgid "Bookmark {0} not set" -msgstr "Zakładka {0} nie ustawiona." - -#: ../src/fenrir/commands/commands/bookmark_1.py:27 -#: ../src/fenrir/commands/commands/bookmark_1.py:30 -#: ../src/fenrir/commands/commands/bookmark_10.py:27 -#: ../src/fenrir/commands/commands/bookmark_10.py:30 -#: ../src/fenrir/commands/commands/bookmark_2.py:27 -#: ../src/fenrir/commands/commands/bookmark_2.py:30 -#: ../src/fenrir/commands/commands/bookmark_3.py:27 -#: ../src/fenrir/commands/commands/bookmark_3.py:30 -#: ../src/fenrir/commands/commands/bookmark_4.py:27 -#: ../src/fenrir/commands/commands/bookmark_4.py:30 -#: ../src/fenrir/commands/commands/bookmark_5.py:27 -#: ../src/fenrir/commands/commands/bookmark_5.py:30 -#: ../src/fenrir/commands/commands/bookmark_6.py:27 -#: ../src/fenrir/commands/commands/bookmark_6.py:30 -#: ../src/fenrir/commands/commands/bookmark_7.py:27 -#: ../src/fenrir/commands/commands/bookmark_7.py:30 -#: ../src/fenrir/commands/commands/bookmark_8.py:27 -#: ../src/fenrir/commands/commands/bookmark_8.py:30 -#: ../src/fenrir/commands/commands/bookmark_9.py:27 -#: ../src/fenrir/commands/commands/bookmark_9.py:30 -msgid "Bookmark for application {0} not set" -msgstr "Zakładka dla aplikacji {0} nie ustawiona." - -#: ../src/fenrir/commands/commands/bookmark_1.py:43 -#: ../src/fenrir/commands/commands/bookmark_10.py:43 -#: ../src/fenrir/commands/commands/bookmark_2.py:43 -#: ../src/fenrir/commands/commands/bookmark_3.py:43 -#: ../src/fenrir/commands/commands/bookmark_4.py:43 -#: ../src/fenrir/commands/commands/bookmark_5.py:43 -#: ../src/fenrir/commands/commands/bookmark_6.py:43 -#: ../src/fenrir/commands/commands/bookmark_7.py:43 -#: ../src/fenrir/commands/commands/bookmark_8.py:43 -#: ../src/fenrir/commands/commands/bookmark_9.py:43 -#: ../src/fenrir/commands/commands/curr_screen_after_cursor.py:27 -#: ../src/fenrir/commands/commands/curr_screen_before_cursor.py:30 -#: ../src/fenrir/commands/commands/cursor_read_to_end_of_line.py:27 -#: ../src/fenrir/commands/commands/indent_curr_line.py:31 -#: ../src/fenrir/commands/commands/marked_text.py:33 -#: ../src/fenrir/commands/commands/present_first_line.py:25 -#: ../src/fenrir/commands/commands/present_last_line.py:25 -#: ../src/fenrir/commands/commands/review_curr_char_phonetic.py:27 -#: ../src/fenrir/commands/commands/review_curr_line.py:27 -#: ../src/fenrir/commands/commands/review_curr_word.py:27 -#: ../src/fenrir/commands/commands/review_curr_word_phonetic.py:27 -#: ../src/fenrir/commands/commands/review_line_begin.py:27 -#: ../src/fenrir/commands/commands/review_next_line.py:29 -#: ../src/fenrir/commands/commands/review_next_word.py:29 -#: ../src/fenrir/commands/commands/review_next_word_phonetic.py:27 -#: ../src/fenrir/commands/commands/review_prev_line.py:27 -#: ../src/fenrir/commands/commands/review_prev_word.py:27 -#: ../src/fenrir/commands/commands/review_prev_word_phonetic.py:27 -#: ../src/fenrir/commands/onCursorChange/55000-present_line_if_cursor_change_vertical.py:37 -#: ../src/fenrir/commands/onScreenUpdate/60000-history.py:49 -msgid "blank" -msgstr "Pusta" - -#: ../src/fenrir/commands/commands/braille_flush.py:17 -msgid "flush the braille device if a message is written on" -msgstr "Przeładowuje urządzenie brajla w przypadku nadpisywania tekstu." - -#: ../src/fenrir/commands/commands/braille_pan_left.py:17 -msgid "Move braille view to the left." -msgstr "Przesuwa widok brajla w lewo." - -#: ../src/fenrir/commands/commands/braille_pan_right.py:17 -msgid "Move braille view to the right." -msgstr "Przesuwa widok brajla w prawo." - -#: ../src/fenrir/commands/commands/braille_return_to_cursor.py:17 -msgid "Set the braille view back to cursor." -msgstr "Przesuwa widok brajla do pozycji kursora." - -#: ../src/fenrir/commands/commands/clear_bookmark_1.py:17 -#: ../src/fenrir/commands/commands/clear_bookmark_10.py:17 -#: ../src/fenrir/commands/commands/clear_bookmark_2.py:17 -#: ../src/fenrir/commands/commands/clear_bookmark_3.py:17 -#: ../src/fenrir/commands/commands/clear_bookmark_4.py:17 -#: ../src/fenrir/commands/commands/clear_bookmark_5.py:17 -#: ../src/fenrir/commands/commands/clear_bookmark_6.py:17 -#: ../src/fenrir/commands/commands/clear_bookmark_7.py:17 -#: ../src/fenrir/commands/commands/clear_bookmark_8.py:17 -#: ../src/fenrir/commands/commands/clear_bookmark_9.py:17 -msgid "remove Bookmark {0}" -msgstr "Usuń zakładkę {0}." - -#: ../src/fenrir/commands/commands/clear_bookmark_1.py:24 -#: ../src/fenrir/commands/commands/clear_bookmark_10.py:24 -#: ../src/fenrir/commands/commands/clear_bookmark_2.py:24 -#: ../src/fenrir/commands/commands/clear_bookmark_3.py:24 -#: ../src/fenrir/commands/commands/clear_bookmark_4.py:24 -#: ../src/fenrir/commands/commands/clear_bookmark_5.py:24 -#: ../src/fenrir/commands/commands/clear_bookmark_6.py:24 -#: ../src/fenrir/commands/commands/clear_bookmark_7.py:24 -#: ../src/fenrir/commands/commands/clear_bookmark_8.py:24 -#: ../src/fenrir/commands/commands/clear_bookmark_9.py:24 -msgid "Bookmark {0} removed for application {1}" -msgstr "Zakładka {0} usunięta dla aplikacji {1}." - -#: ../src/fenrir/commands/commands/clear_clipboard.py:17 -msgid "clears the currently selected clipboard" -msgstr "Czyści aktualnie zaznaczony showek." - -#: ../src/fenrir/commands/commands/clear_clipboard.py:22 -msgid "clipboard cleared" -msgstr "Showek wyczyszczony" - -#: ../src/fenrir/commands/commands/clear_window_application.py:17 -msgid "Turn off window mode for application" -msgstr "Wyłącza tryb okna dla aplikacji." - -#: ../src/fenrir/commands/commands/clear_window_application.py:22 -msgid "Window Mode off for application {0}" -msgstr "Tryb okna dla aplikacji {0} wyłączony." - -#: ../src/fenrir/commands/commands/clear_window_application.py:24 -msgid "Not in window Mode" -msgstr "Nie znajdujesz się w trybie okna." - -#: ../src/fenrir/commands/commands/copy_marked_to_clipboard.py:18 -msgid "copies marked text to the currently selected clipboard" -msgstr "Kopiuje zaznaczony tekst do aktualnie wybranego schowka." - -#: ../src/fenrir/commands/commands/copy_marked_to_clipboard.py:22 -msgid "one or two marks needed" -msgstr "Wymagane jeden lub dwa znaczniki" - -#: ../src/fenrir/commands/commands/curr_clipboard.py:17 -msgid "speaks the contents of the currently selected clipboard" -msgstr "Wypowiada treść aktualnie wybranego showka" - -#: ../src/fenrir/commands/commands/curr_clipboard.py:21 -#: ../src/fenrir/commands/commands/export_clipboard_to_file.py:29 -#: ../src/fenrir/commands/commands/export_clipboard_to_file.py:32 -#: ../src/fenrir/commands/commands/export_clipboard_to_file.py:35 -#: ../src/fenrir/commands/commands/export_clipboard_to_file.py:38 -#: ../src/fenrir/commands/commands/export_clipboard_to_x.py:30 -#: ../src/fenrir/commands/commands/export_clipboard_to_x.py:33 -#: ../src/fenrir/commands/commands/export_clipboard_to_x.py:36 -#: ../src/fenrir/commands/commands/export_clipboard_to_x.py:39 -#: ../src/fenrir/commands/commands/first_clipboard.py:21 -#: ../src/fenrir/commands/commands/last_clipboard.py:21 -#: ../src/fenrir/commands/commands/next_clipboard.py:21 -#: ../src/fenrir/commands/commands/paste_clipboard.py:23 -#: ../src/fenrir/commands/commands/paste_clipboard.py:26 -#: ../src/fenrir/commands/commands/paste_clipboard.py:29 -#: ../src/fenrir/commands/commands/paste_clipboard.py:32 -#: ../src/fenrir/commands/commands/prev_clipboard.py:21 -msgid "clipboard empty" -msgstr "Showek pusty" - -#: ../src/fenrir/commands/commands/curr_screen.py:17 -msgid "reads the contents of the current screen" -msgstr "Wypowiada treść aktualnego ekranu." - -#: ../src/fenrir/commands/commands/curr_screen.py:21 -msgid "screen is empty" -msgstr "Ekran jest pusty." - -#: ../src/fenrir/commands/commands/curr_screen_after_cursor.py:18 -msgid "reads from the cursor to the bottom of the screen" -msgstr "Czyta od pozycji kursora do końca ekranu." - -#: ../src/fenrir/commands/commands/curr_screen_before_cursor.py:18 -msgid "Reads from the top of the screen to the cursor position" -msgstr "Czyta od góry ekranu do pozycji kursora." - -#: ../src/fenrir/commands/commands/cursor_column.py:17 -msgid "" -"presents the current column number for review cursor in review mode or the " -"text cursor if not. Starts with 1" -msgstr "" -"Odczytuje numer aktualnej kolumny dla kursora przeglądu (jeśli w trybie " -"przeglądu), lub dla kursora tekstu." - -#: ../src/fenrir/commands/commands/cursor_lineno.py:17 -msgid "" -"presents the current line number for review cursor in review mode or the " -"text cursor if not. Starts with 1" -msgstr "" - -#: ../src/fenrir/commands/commands/cursor_position.py:17 -msgid "displays the position of the review cursor" -msgstr "Wyświetla pozycję kursora przeglądu" - -#: ../src/fenrir/commands/commands/cursor_position.py:23 -msgid "line {0}, column {1}" -msgstr "Linia {0}, kolumna {1}" - -#: ../src/fenrir/commands/commands/cursor_read_to_end_of_line.py:18 -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." - -#: ../src/fenrir/commands/commands/date.py:18 -msgid "presents the date" -msgstr "Odczytuje aktualną datę." - -#: ../src/fenrir/commands/commands/dec_alsa_volume.py:24 -msgid "Decrease system volume" -msgstr "Zmniejsza głośność systemu" - -#: ../src/fenrir/commands/commands/dec_alsa_volume.py:28 -#: ../src/fenrir/commands/commands/inc_alsa_volume.py:28 -msgid "alsaaudio is not installed" -msgstr "Alsaaudio nie jest zainstalowany." - -#: ../src/fenrir/commands/commands/dec_alsa_volume.py:36 -#: ../src/fenrir/commands/commands/inc_alsa_volume.py:36 -msgid "{0} percent system volume" -msgstr "Głośność systemowa: {0} procent." - -#: ../src/fenrir/commands/commands/dec_sound_volume.py:18 -msgid "decrease sound volume" -msgstr "Zmniejsza głośność dÅŗwięku." - -#: ../src/fenrir/commands/commands/dec_sound_volume.py:29 -#: ../src/fenrir/commands/commands/inc_sound_volume.py:29 -msgid "{0} percent sound volume" -msgstr "Głośność dÅŗwięku: {0} procent." - -#: ../src/fenrir/commands/commands/dec_speech_pitch.py:18 -msgid "decreases the pitch of the speech" -msgstr "Zmniejsza wysokość mowy." - -#: ../src/fenrir/commands/commands/dec_speech_pitch.py:26 -#: ../src/fenrir/commands/commands/inc_speech_pitch.py:27 -msgid "{0} percent speech pitch" -msgstr "Wysokość mowy: {0} procent." - -#: ../src/fenrir/commands/commands/dec_speech_rate.py:18 -msgid "decreases the rate of the speech" -msgstr "Zmniejsza prędkość mowy" - -#: ../src/fenrir/commands/commands/dec_speech_rate.py:27 -#: ../src/fenrir/commands/commands/inc_speech_rate.py:27 -msgid "{0} percent speech rate" -msgstr "Prędkość mowy: {0} procent." - -#: ../src/fenrir/commands/commands/dec_speech_volume.py:18 -msgid "decreases the volume of the speech" -msgstr "Zmniejsza głośność mowy" - -#: ../src/fenrir/commands/commands/dec_speech_volume.py:27 -#: ../src/fenrir/commands/commands/inc_speech_volume.py:27 -msgid "{0} percent speech volume" -msgstr "Głośność mowy: {0} procent." - -#: ../src/fenrir/commands/commands/exit_review.py:17 -#: ../src/fenrir/commands/onCursorChange/66000-exit_review_mode.py:17 -msgid "exits review mode" -msgstr "Wychodzi z trybu przeglądu." - -#: ../src/fenrir/commands/commands/exit_review.py:21 -msgid "Not in review mode" -msgstr "Nie znajdujesz się w trybie przeglądu." - -#: ../src/fenrir/commands/commands/exit_review.py:25 -msgid "leave review mode" -msgstr "Opuść tryb przeglądu" - -#: ../src/fenrir/commands/commands/export_clipboard_to_file.py:19 -msgid "export the current fenrir clipboard to a file" -msgstr "Wyeksportuj aktualny showek Fenrir do pliku." - -#: ../src/fenrir/commands/commands/export_clipboard_to_file.py:43 -msgid "clipboard exported to file" -msgstr "Showek wyeksportowany do pliku." - -#: ../src/fenrir/commands/commands/export_clipboard_to_x.py:21 -msgid "export the current fenrir clipboard to X clipboard" -msgstr "Wyeksportuj aktualny showek Fenrir do schowka X." - -#: ../src/fenrir/commands/commands/first_clipboard.py:17 -msgid "selects the first clipboard" -msgstr "Wybiera pierwszy schowek." - -#: ../src/fenrir/commands/commands/forward_keypress.py:17 -msgid "sends the following keypress to the terminal" -msgstr "Wysyła następny klawisz do terminala." - -#: ../src/fenrir/commands/commands/forward_keypress.py:21 -msgid "Forward next keypress" -msgstr "Przepuść następny klawisz" - -#: ../src/fenrir/commands/commands/import_clipboard_from_file.py:19 -msgid "imports text from clipboard file to the clipboard" -msgstr "Importuje tekst z pliku do schowka." - -#: ../src/fenrir/commands/commands/import_clipboard_from_file.py:27 -msgid "File does not exist" -msgstr "Plik nie istnieje." - -#: ../src/fenrir/commands/commands/inc_alsa_volume.py:24 -msgid "Increase system volume" -msgstr "Zwiększa głośność systemu" - -#: ../src/fenrir/commands/commands/inc_sound_volume.py:18 -msgid "adjusts the volume for in coming sounds" -msgstr "Dostosowuje głośność zdarzeń dÅŗwiękowych." - -#: ../src/fenrir/commands/commands/inc_speech_pitch.py:18 -msgid "increases the pitch of the speech" -msgstr "Zwiększa wysokość mowy." - -#: ../src/fenrir/commands/commands/inc_speech_rate.py:18 -msgid "increase the speech rate" -msgstr "Zwiększa prędkość mowy." - -#: ../src/fenrir/commands/commands/inc_speech_volume.py:18 -msgid "increase the speech volume" -msgstr "Zwiększa głośność mowy." - -#: ../src/fenrir/commands/commands/indent_curr_line.py:18 -msgid "shows the indention level for the current line" -msgstr "Pokazuje poziom wcięcia dla aktualnej linii." - -#: ../src/fenrir/commands/commands/indent_curr_line.py:33 -msgid "indent {0}" -msgstr "Wcięcie {0}" - -#: ../src/fenrir/commands/commands/last_clipboard.py:17 -msgid "selects the last clipboard" -msgstr "Wybiera ostatni showek." - -#: ../src/fenrir/commands/commands/last_incoming.py:17 -msgid "displays the last received text" -msgstr "Wyświetla ostatnio odebrany tekst." - -#: ../src/fenrir/commands/commands/marked_text.py:18 -msgid "speaks the currently selected text that will be copied to the clipboard" -msgstr "Wypowiada zaznaczony tekst który zostanie skopiowany do schowka." - -#: ../src/fenrir/commands/commands/marked_text.py:23 -msgid "please set begin and endmark" -msgstr "ustaw znacznik początku i końca." - -#: ../src/fenrir/commands/commands/next_clipboard.py:17 -msgid "selects the next clipboard" -msgstr "Wybiera następny showek." - -#: ../src/fenrir/commands/commands/next_clipboard.py:26 -msgid "First clipboard " -msgstr "Pierwszy showek" - -#: ../src/fenrir/commands/commands/paste_clipboard.py:18 -msgid "pastes the text from the currently selected clipboard" -msgstr "Wkleja tekst z aktualnie wybranego schowka." - -#: ../src/fenrir/commands/commands/present_first_line.py:18 -msgid "present first line" -msgstr "Odczytuje pierwszą linię." - -#: ../src/fenrir/commands/commands/present_last_line.py:18 -#: ../src/fenrir/commands/commands/review_curr_line.py:18 -msgid "current line" -msgstr "Aktualna linia" - -#: ../src/fenrir/commands/commands/prev_clipboard.py:17 -msgid "selects the previous clipboard" -msgstr "Wybiera poprzedni showek." - -#: ../src/fenrir/commands/commands/prev_clipboard.py:26 -msgid "Last clipboard " -msgstr "Ostatni showek" - -#: ../src/fenrir/commands/commands/quit_fenrir.py:17 -msgid "exits Fenrir" -msgstr "Zamyka Fenrir." - -#: ../src/fenrir/commands/commands/remove_marks.py:17 -msgid "removes marks from selected text" -msgstr "Usuwa znaczniki z zaznaczonego tekstu" - -#: ../src/fenrir/commands/commands/remove_marks.py:21 -msgid "Remove marks" -msgstr "Usuń znaczniki" - -#: ../src/fenrir/commands/commands/remove_word_from_spell_check.py:27 -msgid "removes the current word from the exceptions dictionary" -msgstr "Usuwa aktualne słowo ze słownika wyjątków." - -#: ../src/fenrir/commands/commands/remove_word_from_spell_check.py:50 -msgid "{0} is already removed from dict" -msgstr "{0} już jest usunięte ze słownika." - -#: ../src/fenrir/commands/commands/remove_word_from_spell_check.py:53 -msgid "{0} removed" -msgstr "{0} usunięte." - -#: ../src/fenrir/commands/commands/review_bottom.py:17 -msgid "move review to bottom of screen" -msgstr "Przenieś kursor przeglądu na dół ekranu." - -#: ../src/fenrir/commands/commands/review_bottom.py:21 -msgid "Bottom" -msgstr "Dół." - -#: ../src/fenrir/commands/commands/review_curr_char.py:18 -msgid "presents the current character." -msgstr "Odczytuje aktualny znak." - -#: ../src/fenrir/commands/commands/review_curr_char_phonetic.py:18 -msgid "set review and phonetically presents the current character" -msgstr "Ustawia kursor przeglądu i fonetycznie odczytuje zaznaczony znak." - -#: ../src/fenrir/commands/commands/review_curr_word.py:18 -msgid "current word." -msgstr "Aktualne słowo" - -#: ../src/fenrir/commands/commands/review_curr_word.py:32 -#: ../src/fenrir/commands/commands/review_curr_word_phonetic.py:36 -#: ../src/fenrir/commands/commands/review_down.py:27 -#: ../src/fenrir/commands/commands/review_next_char.py:28 -#: ../src/fenrir/commands/commands/review_next_char_phonetic.py:30 -#: ../src/fenrir/commands/commands/review_next_line.py:34 -#: ../src/fenrir/commands/commands/review_next_word.py:34 -#: ../src/fenrir/commands/commands/review_next_word_phonetic.py:36 -#: ../src/fenrir/commands/commands/review_prev_char.py:31 -#: ../src/fenrir/commands/commands/review_prev_char_phonetic.py:30 -#: ../src/fenrir/commands/commands/review_prev_line.py:32 -#: ../src/fenrir/commands/commands/review_prev_word.py:32 -#: ../src/fenrir/commands/commands/review_prev_word_phonetic.py:36 -#: ../src/fenrir/commands/commands/review_up.py:27 -msgid "end of screen" -msgstr "Koniec ekranu" - -#: ../src/fenrir/commands/commands/review_curr_word.py:35 -#: ../src/fenrir/commands/commands/review_curr_word_phonetic.py:39 -#: ../src/fenrir/commands/commands/review_next_char.py:31 -#: ../src/fenrir/commands/commands/review_next_char_phonetic.py:33 -#: ../src/fenrir/commands/commands/review_next_word.py:37 -#: ../src/fenrir/commands/commands/review_next_word_phonetic.py:39 -#: ../src/fenrir/commands/commands/review_prev_char.py:34 -#: ../src/fenrir/commands/commands/review_prev_char_phonetic.py:33 -#: ../src/fenrir/commands/commands/review_prev_word.py:35 -#: ../src/fenrir/commands/commands/review_prev_word_phonetic.py:39 -#: ../src/fenrir/commands/commands/review_up.py:30 -msgid "line break" -msgstr "Znak końca wiersza." - -#: ../src/fenrir/commands/commands/review_curr_word_phonetic.py:19 -#: ../src/fenrir/commands/commands/review_next_word_phonetic.py:19 -#: ../src/fenrir/commands/commands/review_prev_word_phonetic.py:19 -msgid "phonetically spells the current word and set review to it" -msgstr "" -"Fonetycznie odczytuje aktualne słowo i przenosi do niego kursor przeglądu." - -#: ../src/fenrir/commands/commands/review_down.py:18 -msgid "set review cursor to char below the current char and present it." -msgstr "" -"ustawia kursor przeglądu do znaku pod aktualnym znakiem i odczytuje go." - -#: ../src/fenrir/commands/commands/review_line_begin.py:18 -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ść." - -#: ../src/fenrir/commands/commands/review_line_begin.py:30 -msgid "beginning of line" -msgstr "Początek linii." - -#: ../src/fenrir/commands/commands/review_line_end.py:18 -#: ../src/fenrir/commands/commands/review_line_first_char.py:19 -#: ../src/fenrir/commands/commands/review_line_last_char.py:18 -msgid "set review cursor to end of current line and display the content" -msgstr "Ustawia kursor przeglądu na końcu linii i odczytuje jej treść." - -#: ../src/fenrir/commands/commands/review_line_end.py:27 -msgid "end of line" -msgstr "Koniec linii." - -#: ../src/fenrir/commands/commands/review_line_first_char.py:26 -msgid "line is empty" -msgstr "Linia jest pusta." - -#: ../src/fenrir/commands/commands/review_line_first_char.py:33 -msgid "first char in line indent {0}" -msgstr "Pierwszy znak w linii o wcięciu {0}" - -#: ../src/fenrir/commands/commands/review_line_last_char.py:27 -msgid "last char in line" -msgstr "Ostatni znak w linii." - -#: ../src/fenrir/commands/commands/review_next_char.py:18 -msgid "moves review to the next character and presents it" -msgstr "Przesuwa kursor przeglądu do następnego znaku i odczytuje go." - -#: ../src/fenrir/commands/commands/review_next_char_phonetic.py:18 -msgid "phonetically presents the next character and set review to it" -msgstr "" -"Fonetycznie odczytuje następny znak i przenosi do niego kursor przeglądu." - -#: ../src/fenrir/commands/commands/review_next_line.py:18 -msgid "moves review to the next line and presents it" -msgstr "Przesuwa kursor przeglądu do następnej linii i odczytuje ją." - -#: ../src/fenrir/commands/commands/review_next_word.py:18 -msgid "moves review to the next word and presents it" -msgstr "Przesuwa kursor przeglądu do następnego słowa i odczytuje je." - -#: ../src/fenrir/commands/commands/review_prev_char.py:18 -msgid "moves review to the previous character and presents it" -msgstr "Przesuwa kursor przeglądu do poprzedniego znaku i odczytuje go." - -#: ../src/fenrir/commands/commands/review_prev_char_phonetic.py:18 -msgid "phonetically presents the previous character and set review to it" -msgstr "" -"Odczytuje fonetycznie poprzedni znak i przenosi do niego kursor przeglądu." - -#: ../src/fenrir/commands/commands/review_prev_line.py:18 -msgid "moves review to the previous line and presents it" -msgstr "Przenosi kursor przeglądu do poprzedniej linii i odczytuje ją." - -#: ../src/fenrir/commands/commands/review_prev_word.py:18 -msgid "moves review focus to the previous word and presents it" -msgstr "Przesuwa kursor przeglądu do poprzedniego słowa i odczytuje je." - -#: ../src/fenrir/commands/commands/review_top.py:18 -msgid "move review to top of screen" -msgstr "Przenieś kursor przeglądu na górę ekranu." - -#: ../src/fenrir/commands/commands/review_top.py:22 -msgid "Top" -msgstr "Góra." - -#: ../src/fenrir/commands/commands/review_up.py:18 -msgid "set review cursor to the char in the line below and present it" -msgstr "Przenieś kursor przeglądu do znaku w linii poniżej i odczytaj go." - -#: ../src/fenrir/commands/commands/set_bookmark_1.py:18 -#: ../src/fenrir/commands/commands/set_bookmark_10.py:18 -#: ../src/fenrir/commands/commands/set_bookmark_2.py:18 -#: ../src/fenrir/commands/commands/set_bookmark_3.py:18 -#: ../src/fenrir/commands/commands/set_bookmark_4.py:18 -#: ../src/fenrir/commands/commands/set_bookmark_5.py:18 -#: ../src/fenrir/commands/commands/set_bookmark_6.py:18 -#: ../src/fenrir/commands/commands/set_bookmark_7.py:18 -#: ../src/fenrir/commands/commands/set_bookmark_8.py:18 -#: ../src/fenrir/commands/commands/set_bookmark_9.py:18 -msgid "set Bookmark {0}" -msgstr "Ustaw zakładkę {0}" - -#: ../src/fenrir/commands/commands/set_bookmark_1.py:22 -#: ../src/fenrir/commands/commands/set_bookmark_10.py:22 -#: ../src/fenrir/commands/commands/set_bookmark_2.py:22 -#: ../src/fenrir/commands/commands/set_bookmark_3.py:22 -#: ../src/fenrir/commands/commands/set_bookmark_4.py:22 -#: ../src/fenrir/commands/commands/set_bookmark_5.py:22 -#: ../src/fenrir/commands/commands/set_bookmark_6.py:22 -#: ../src/fenrir/commands/commands/set_bookmark_7.py:22 -#: ../src/fenrir/commands/commands/set_bookmark_8.py:22 -#: ../src/fenrir/commands/commands/set_bookmark_9.py:22 -msgid "No Mark found" -msgstr "Nie znaleziono znacznika." - -#: ../src/fenrir/commands/commands/set_bookmark_1.py:32 -#: ../src/fenrir/commands/commands/set_bookmark_10.py:32 -#: ../src/fenrir/commands/commands/set_bookmark_2.py:32 -#: ../src/fenrir/commands/commands/set_bookmark_3.py:32 -#: ../src/fenrir/commands/commands/set_bookmark_4.py:32 -#: ../src/fenrir/commands/commands/set_bookmark_5.py:32 -#: ../src/fenrir/commands/commands/set_bookmark_6.py:32 -#: ../src/fenrir/commands/commands/set_bookmark_7.py:32 -#: ../src/fenrir/commands/commands/set_bookmark_8.py:32 -#: ../src/fenrir/commands/commands/set_bookmark_9.py:32 -msgid "Bookmark {0} set for application {1}" -msgstr "Zakładka {0} ustawiona dla aplikacji {1}" - -#: ../src/fenrir/commands/commands/set_mark.py:17 -msgid "places marks to select text to copy to the clipboard" -msgstr "Ustawia znaczniki w tekście do skopiowania do schowka." - -#: ../src/fenrir/commands/commands/set_mark.py:21 -msgid "no review cursor" -msgstr "Brak kursora przeglądu." - -#: ../src/fenrir/commands/commands/set_mark.py:26 -#: ../src/fenrir/commands/commands/set_mark.py:28 -msgid "set mark" -msgstr "ustaw znacznik." - -#: ../src/fenrir/commands/commands/set_window_application.py:17 -msgid "set Window Mode, needs 2 marks " -msgstr "ustaw tryb okna, wymagane dwa znaczniki." - -#: ../src/fenrir/commands/commands/set_window_application.py:22 -msgid "Window Mode on for application {0}" -msgstr "Tryb okna włączony dla aplikacji {0}" - -#: ../src/fenrir/commands/commands/set_window_application.py:25 -msgid "Set window begin and end marks" -msgstr "Ustaw początek i koniec okna." - -#: ../src/fenrir/commands/commands/shut_up.py:17 -msgid "interrupts the current presentation" -msgstr "Przerywa aktualną prezentację." - -#: ../src/fenrir/commands/commands/spell_check.py:26 -msgid "checks the spelling of the current word" -msgstr "Sprawdza pisownię aktualnego słowa." - -#: ../src/fenrir/commands/commands/spell_check.py:52 -#: ../src/fenrir/commands/onCursorChange/62000-spell_check.py:130 -msgid "misspelled" -msgstr "Błąd pisowni" - -#: ../src/fenrir/commands/commands/spell_check.py:54 -msgid "correct" -msgstr "Poprawny" - -#: ../src/fenrir/commands/commands/subprocess.py:21 -msgid "script: {0} fullpath: {1}" -msgstr "Skrypt: {0}, ścieżka dostępu: {1}" - -#: ../src/fenrir/commands/commands/subprocess.py:24 -msgid "scriptfile does not exist" -msgstr "Plik skryptu nie istnieje." - -#: ../src/fenrir/commands/commands/subprocess.py:27 -msgid "scriptfile is not a file" -msgstr "Plik skryptu nie jest plikiem." - -#: ../src/fenrir/commands/commands/subprocess.py:30 -msgid "scriptfile is not executable" -msgstr "Plik skryptu nie jest wykonywalny." - -#: ../src/fenrir/commands/commands/temp_disable_speech.py:17 -#: ../src/fenrir/commands/onInput/15000-enable_temp_speech.py:17 -msgid "disables speech until next keypress" -msgstr "Wyłącza mowę do następnego naciśnięcia klawisza." - -#: ../src/fenrir/commands/commands/temp_disable_speech.py:21 -msgid "speech temporary disabled" -msgstr "Mowa tymczasowo wyłączona." - -#: ../src/fenrir/commands/commands/time.py:18 -msgid "presents the time" -msgstr "Wyświetla i odczytuje aktualny czas." - -#: ../src/fenrir/commands/commands/toggle_auto_read.py:16 -msgid "enables or disables automatic reading of new text as it appears" -msgstr "Włącza lub wyłącza informowanie o dynamicznych zmianach treści." - -#: ../src/fenrir/commands/commands/toggle_auto_read.py:21 -msgid "autoread enabled" -msgstr "Włączono ogłaszanie dynamicznych zmian treści." - -#: ../src/fenrir/commands/commands/toggle_auto_read.py:23 -msgid "autoread disabled" -msgstr "Wyłączono automatyczne ogłaszanie dynamicznych zmian treści." - -#: ../src/fenrir/commands/commands/toggle_auto_spell_check.py:17 -msgid "enables or disables automatic spell checking" -msgstr "Włącza lub wyłącza automatyczne sprawdzanie błędów pisowni." - -#: ../src/fenrir/commands/commands/toggle_auto_spell_check.py:22 -msgid "auto spellcheck enabled" -msgstr "Automatyczne sprawdzanie pisowni włączone." - -#: ../src/fenrir/commands/commands/toggle_auto_spell_check.py:24 -msgid "auto spellcheck disabled" -msgstr "Automatyczne sprawdzanie pisowni wyłączone." - -#: ../src/fenrir/commands/commands/toggle_auto_time.py:16 -msgid "enables or disables automatic reading of time after an period" -msgstr "Włącza lub wyłącza okresowe, automatyczne odczytywanie czasu." - -#: ../src/fenrir/commands/commands/toggle_auto_time.py:21 -msgid "autotime enabled" -msgstr "Włączono okresowe odczytywanie czasu." - -#: ../src/fenrir/commands/commands/toggle_auto_time.py:23 -msgid "autotime disabled" -msgstr "Wyłączono okresowe odczytywanie czasu." - -#: ../src/fenrir/commands/commands/toggle_braille.py:17 -msgid "enables and disables output in braille" -msgstr "Włącza lub wyłącza wyjście brajla." - -#: ../src/fenrir/commands/commands/toggle_braille.py:21 -msgid "braille disabled" -msgstr "Brajl wyłączony." - -#: ../src/fenrir/commands/commands/toggle_braille.py:24 -msgid "braille enabled" -msgstr "Brajl włączony." - -#: ../src/fenrir/commands/commands/toggle_emoticons.py:16 -msgid "enables or disables announcement of emoticons instead of chars" -msgstr "Włącza lub wyłącza odczytywanie emotikon." - -#: ../src/fenrir/commands/commands/toggle_emoticons.py:21 -msgid "emoticons enabled" -msgstr "Emotikony włączone." - -#: ../src/fenrir/commands/commands/toggle_emoticons.py:23 -msgid "emoticons disabled" -msgstr "Emotikony wyłączone." - -#: ../src/fenrir/commands/commands/toggle_highlight_tracking.py:16 -msgid "enables or disables tracking of highlighted text" -msgstr "Włącza lub wyłącza śledzenie podświetlonego tekstu." - -#: ../src/fenrir/commands/commands/toggle_highlight_tracking.py:24 -msgid "highlight tracking" -msgstr "Śledzenie podświetlenia." - -#: ../src/fenrir/commands/commands/toggle_highlight_tracking.py:26 -msgid "cursor tracking" -msgstr "Śledzenie kursora." - -#: ../src/fenrir/commands/commands/toggle_output.py:17 -msgid "toggles all output settings" -msgstr "Przełącza wszystkie ustawienia wyjścia." - -#: ../src/fenrir/commands/commands/toggle_output.py:23 -msgid "Fenrir muted" -msgstr "Fenrir wyciszony." - -#: ../src/fenrir/commands/commands/toggle_output.py:31 -msgid "Fenrir unmuted" -msgstr "Fenrir odciszony" - -#: ../src/fenrir/commands/commands/toggle_punctuation_level.py:23 -msgid "No punctuation found." -msgstr "Nie znaleziono interpunkcji." - -#: ../src/fenrir/commands/commands/toggle_sound.py:17 -msgid "enables or disables sound" -msgstr "Włącza lub wyłącza dÅŗwięk." - -#: ../src/fenrir/commands/commands/toggle_sound.py:21 -msgid "sound disabled" -msgstr "DÅŗwięk wyłączony" - -#: ../src/fenrir/commands/commands/toggle_sound.py:24 -msgid "sound enabled" -msgstr "DÅŗwięk włączony" - -#: ../src/fenrir/commands/commands/toggle_speech.py:17 -msgid "enables or disables speech" -msgstr "Włącza lub wyłącza mowę." - -#: ../src/fenrir/commands/commands/toggle_speech.py:21 -msgid "speech disabled" -msgstr "Mowa wyłączona." - -#: ../src/fenrir/commands/commands/toggle_speech.py:24 -#: ../src/fenrir/commands/onInput/15000-enable_temp_speech.py:28 -msgid "speech enabled" -msgstr "Mowa włączona." - -#: ../src/fenrir/commands/commands/toggle_tutorial_mode.py:19 -msgid "" -"You are leaving the tutorial mode. Press that shortcut again to enter the " -"tutorial mode again." -msgstr "Opuszczasz tryb pomocy." - -#: ../src/fenrir/commands/commands/toggle_tutorial_mode.py:22 -msgid "" -"you entered the tutorial mode. In that mode the commands are not executed. " -"but you get a description of what the shortcut does. To leave the tutorial " -"mode, press that shortcut again." -msgstr "" -"Wszedłeś w tryb pomocy. W tym trybie naciskane klawisze nie są wykonywane a " -"jedynie odczytywane jest ich przeznaczenie. Aby opuścić tryb pomocy, " -"naciśnij ten skrót ponownie." - -#: ../src/fenrir/commands/help/curr_help.py:17 -msgid "get current help message" -msgstr "Odczytaj aktualną wiadomość pomocy" - -#: ../src/fenrir/commands/help/next_help.py:17 -msgid "get next help message" -msgstr "Odczytaj następną wiadomość pomocy." - -#: ../src/fenrir/commands/help/prev_help.py:17 -msgid "get prev help message" -msgstr "Odczytaj poprzednią wiadomość pomocy." - -#: ../src/fenrir/commands/onHeartBeat/76000-time.py:66 -msgid "Autotime: {0}" -msgstr "Jest godzina: {0}" - -#: ../src/fenrir/commands/onInput/80000-capslock.py:22 -msgid "Capslock on" -msgstr "Capslock włączony" - -#: ../src/fenrir/commands/onInput/80000-capslock.py:24 -msgid "Capslock off" -msgstr "Capslock wyłączony." - -#: ../src/fenrir/commands/onInput/80300-scrolllock.py:22 -msgid "Scrolllock on" -msgstr "Scrollock włączony." - -#: ../src/fenrir/commands/onInput/80300-scrolllock.py:24 -msgid "Scrolllock off" -msgstr "Scrollock wyłączony." - -#: ../src/fenrir/commands/onInput/80500-numlock.py:22 -msgid "Numlock on" -msgstr "Numlock włączony." - -#: ../src/fenrir/commands/onInput/80500-numlock.py:24 -msgid "Numlock off" -msgstr "Numlock wyłączony." - -#: ../src/fenrir/commands/onScreenChanged/80000-screen_change_announcement.py:20 -msgid "screen {0}" -msgstr "Ekran {0}" - -#: ../src/fenrir/commands/onScreenUpdate/56000-highlight_tracking.py:16 -msgid "enables or disables tracking of highlighted" -msgstr "Włącza lub wyłącza śledzenie podświetlenia." - -#: ../src/fenrir/core/fenrirManager.py:27 -msgid "Start Fenrir" -msgstr "Uruchom Fenrir" - -#: ../src/fenrir/core/fenrirManager.py:170 -msgid "Quit Fenrir" -msgstr "Zamknij Fenrir" - -#: ../src/fenrir/core/helpManager.py:44 -msgid "toggles the tutorial mode" -msgstr "Przełącza tryb pomocy." - -#: ../src/fenrir/screenDriver/vcsaDriver.py:38 -msgid "black" -msgstr "Czarny" - -#: ../src/fenrir/screenDriver/vcsaDriver.py:38 -msgid "blue" -msgstr "niebieski" - -#: ../src/fenrir/screenDriver/vcsaDriver.py:38 -msgid "cyan" -msgstr "Turkusowy" - -#: ../src/fenrir/screenDriver/vcsaDriver.py:38 -msgid "green" -msgstr "Zielony" - -#: ../src/fenrir/screenDriver/vcsaDriver.py:38 -msgid "red" -msgstr "Czerwony" - -#: ../src/fenrir/screenDriver/vcsaDriver.py:38 -msgid "white" -msgstr "Biały" - -#: ../src/fenrir/screenDriver/vcsaDriver.py:38 -#: ../src/fenrir/screenDriver/vcsaDriver.py:39 -msgid "Magenta" -msgstr "Fuksja" - -#: ../src/fenrir/screenDriver/vcsaDriver.py:38 -#: ../src/fenrir/screenDriver/vcsaDriver.py:39 -msgid "brown/yellow" -msgstr "Brązowo-żółty" - -#: ../src/fenrir/screenDriver/vcsaDriver.py:39 -msgid "Black" -msgstr "Czarny" - -#: ../src/fenrir/screenDriver/vcsaDriver.py:39 -msgid "Blue" -msgstr "Niebieski" - -#: ../src/fenrir/screenDriver/vcsaDriver.py:39 -msgid "Cyan" -msgstr "Turkusowy" - -#: ../src/fenrir/screenDriver/vcsaDriver.py:39 -msgid "Dark gray" -msgstr "Ciemno-szary" - -#: ../src/fenrir/screenDriver/vcsaDriver.py:39 -msgid "Green" -msgstr "Zielony" - -#: ../src/fenrir/screenDriver/vcsaDriver.py:39 -msgid "Light blue" -msgstr "Jasno-niebieski." - -#: ../src/fenrir/screenDriver/vcsaDriver.py:39 -msgid "Light cyan" -msgstr "Jasny turkus" - -#: ../src/fenrir/screenDriver/vcsaDriver.py:39 -msgid "Light gray" -msgstr "Jasno-szary." - -#: ../src/fenrir/screenDriver/vcsaDriver.py:39 -msgid "Light magenta" -msgstr "Jasna fuksja" - -#: ../src/fenrir/screenDriver/vcsaDriver.py:39 -msgid "Light red" -msgstr "Jasno-czerwony." - -#: ../src/fenrir/screenDriver/vcsaDriver.py:39 -msgid "Light yellow" -msgstr "Jasno-żółty." - -#: ../src/fenrir/screenDriver/vcsaDriver.py:39 -msgid "Red" -msgstr "Czerwony" - -#: ../src/fenrir/screenDriver/vcsaDriver.py:39 -msgid "White" -msgstr "Biały" - -#: ../src/fenrir/screenDriver/vcsaDriver.py:262 -msgid "underlined" -msgstr "Podkreślony" - -#: ../src/fenrir/screenDriver/vcsaDriver.py:266 -msgid "bold" -msgstr "Pogrubiony" - -#: ../src/fenrir/screenDriver/vcsaDriver.py:270 -msgid "blink" -msgstr "Migający" - -#: ../src/fenrir/screenDriver/vcsaDriver.py:273 -#: ../src/fenrir/screenDriver/vcsaDriver.py:275 -msgid "Default" -msgstr "Domyślny" diff --git a/locale/pt/LC_MESSAGES/fenrir.mo b/locale/pt/LC_MESSAGES/fenrir.mo deleted file mode 100644 index 5c7285e8..00000000 Binary files a/locale/pt/LC_MESSAGES/fenrir.mo and /dev/null differ diff --git a/locale/pt/LC_MESSAGES/pt_BR.po b/locale/pt/LC_MESSAGES/pt_BR.po deleted file mode 100644 index 47aa2156..00000000 --- a/locale/pt/LC_MESSAGES/pt_BR.po +++ /dev/null @@ -1,837 +0,0 @@ -# Portuguese translations for PACKAGE fenrir -# TraduƧƵes em portuguĆŖs brasileiro para o pacote fenrir. -# Copyright (C) 2018 F123 Consulting -# Kyle , 2018. -# Roberta Santos , 2018. -# -msgid "" -msgstr "" -"Project-Id-Version: PACKAGE VERSION\n" -"POT-Creation-Date: 2017-02-26 22:19+UTC\n" -"PO-Revision-Date: 2018-07-10 22:55-0400\n" -"Last-Translator: Roberta Santos \n" -"Language-Team: Brazilian Portuguese\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" -"Language: pt_BR\n" -"Plural-Forms: nplurals=2; plural=(n > 1);\n" - -#: ../src/fenrir/commands/commands/add_word_to_spell_check.py:27 -msgid "adds the current word to the exceptions dictionary" -msgstr "adiciona a palavra atual ao dicionĆ”rio de exceƧƵes" - -#: ../src/fenrir/commands/commands/add_word_to_spell_check.py:34 -#: ../src/fenrir/commands/commands/remove_word_from_spell_check.py:34 -#: ../src/fenrir/commands/commands/spell_check.py:29 -#: ../src/fenrir/commands/commands/spell_check.py:36 -msgid "pyenchant is not installed" -msgstr "pyenchant nĆ£o estĆ” instalado" - -#: ../src/fenrir/commands/commands/add_word_to_spell_check.py:49 -msgid "{0} is already in dict" -msgstr "{0} jĆ” estĆ” no dicionĆ”rio" - -#: ../src/fenrir/commands/commands/add_word_to_spell_check.py:52 -msgid "{0} added" -msgstr "{0} adicionado" - -#: ../src/fenrir/commands/commands/bookmark_1.py:19 -#: ../src/fenrir/commands/commands/bookmark_10.py:19 -#: ../src/fenrir/commands/commands/bookmark_2.py:19 -#: ../src/fenrir/commands/commands/bookmark_3.py:19 -#: ../src/fenrir/commands/commands/bookmark_4.py:19 -#: ../src/fenrir/commands/commands/bookmark_5.py:19 -#: ../src/fenrir/commands/commands/bookmark_6.py:19 -#: ../src/fenrir/commands/commands/bookmark_7.py:19 -#: ../src/fenrir/commands/commands/bookmark_8.py:19 -#: ../src/fenrir/commands/commands/bookmark_9.py:19 -msgid "read Bookmark {0}" -msgstr "ler o marcador {0}" - -#: ../src/fenrir/commands/commands/bookmark_1.py:24 -#: ../src/fenrir/commands/commands/bookmark_10.py:24 -#: ../src/fenrir/commands/commands/bookmark_2.py:24 -#: ../src/fenrir/commands/commands/bookmark_3.py:24 -#: ../src/fenrir/commands/commands/bookmark_4.py:24 -#: ../src/fenrir/commands/commands/bookmark_5.py:24 -#: ../src/fenrir/commands/commands/bookmark_6.py:24 -#: ../src/fenrir/commands/commands/bookmark_7.py:24 -#: ../src/fenrir/commands/commands/bookmark_8.py:24 -#: ../src/fenrir/commands/commands/bookmark_9.py:24 -msgid "Bookmark {0} not set" -msgstr "Marcador {0} nĆ£o definido" - -#: ../src/fenrir/commands/commands/bookmark_1.py:27 -#: ../src/fenrir/commands/commands/bookmark_1.py:30 -#: ../src/fenrir/commands/commands/bookmark_10.py:27 -#: ../src/fenrir/commands/commands/bookmark_10.py:30 -#: ../src/fenrir/commands/commands/bookmark_2.py:27 -#: ../src/fenrir/commands/commands/bookmark_2.py:30 -#: ../src/fenrir/commands/commands/bookmark_3.py:27 -#: ../src/fenrir/commands/commands/bookmark_3.py:30 -#: ../src/fenrir/commands/commands/bookmark_4.py:27 -#: ../src/fenrir/commands/commands/bookmark_4.py:30 -#: ../src/fenrir/commands/commands/bookmark_5.py:27 -#: ../src/fenrir/commands/commands/bookmark_5.py:30 -#: ../src/fenrir/commands/commands/bookmark_6.py:27 -#: ../src/fenrir/commands/commands/bookmark_6.py:30 -#: ../src/fenrir/commands/commands/bookmark_7.py:27 -#: ../src/fenrir/commands/commands/bookmark_7.py:30 -#: ../src/fenrir/commands/commands/bookmark_8.py:27 -#: ../src/fenrir/commands/commands/bookmark_8.py:30 -#: ../src/fenrir/commands/commands/bookmark_9.py:27 -#: ../src/fenrir/commands/commands/bookmark_9.py:30 -msgid "Bookmark for application {0} not set" -msgstr "Marcador para o aplicativo {0} nĆ£o definido" - -#: ../src/fenrir/commands/commands/bookmark_1.py:43 -#: ../src/fenrir/commands/commands/bookmark_10.py:43 -#: ../src/fenrir/commands/commands/bookmark_2.py:43 -#: ../src/fenrir/commands/commands/bookmark_3.py:43 -#: ../src/fenrir/commands/commands/bookmark_4.py:43 -#: ../src/fenrir/commands/commands/bookmark_5.py:43 -#: ../src/fenrir/commands/commands/bookmark_6.py:43 -#: ../src/fenrir/commands/commands/bookmark_7.py:43 -#: ../src/fenrir/commands/commands/bookmark_8.py:43 -#: ../src/fenrir/commands/commands/bookmark_9.py:43 -#: ../src/fenrir/commands/commands/curr_screen_after_cursor.py:27 -#: ../src/fenrir/commands/commands/curr_screen_before_cursor.py:30 -#: ../src/fenrir/commands/commands/cursor_read_to_end_of_line.py:27 -#: ../src/fenrir/commands/commands/indent_curr_line.py:31 -#: ../src/fenrir/commands/commands/marked_text.py:33 -#: ../src/fenrir/commands/commands/present_first_line.py:25 -#: ../src/fenrir/commands/commands/present_last_line.py:25 -#: ../src/fenrir/commands/commands/review_curr_char_phonetic.py:27 -#: ../src/fenrir/commands/commands/review_curr_line.py:27 -#: ../src/fenrir/commands/commands/review_curr_word.py:27 -#: ../src/fenrir/commands/commands/review_curr_word_phonetic.py:27 -#: ../src/fenrir/commands/commands/review_line_begin.py:27 -#: ../src/fenrir/commands/commands/review_next_line.py:29 -#: ../src/fenrir/commands/commands/review_next_word.py:29 -#: ../src/fenrir/commands/commands/review_next_word_phonetic.py:27 -#: ../src/fenrir/commands/commands/review_prev_line.py:27 -#: ../src/fenrir/commands/commands/review_prev_word.py:27 -#: ../src/fenrir/commands/commands/review_prev_word_phonetic.py:27 -#: ../src/fenrir/commands/onInput/55000-present_line_if_cursor_change_vertical.py:38 -#: ../src/fenrir/commands/onInput/72000-history.py:50 -msgid "blank" -msgstr "em branco" - -#: ../src/fenrir/commands/commands/braille_flush.py:17 -msgid "flush the braille device if a message is written on" -msgstr "liberar o dispositivo braile se uma mensagem estiver escrita em" - -#: ../src/fenrir/commands/commands/braille_pan_left.py:17 -msgid "Move braille view to the left." -msgstr "Mover a visualização braille para a esquerda." - -#: ../src/fenrir/commands/commands/braille_pan_right.py:17 -msgid "Move braille view to the right." -msgstr "Mover a visualização braille para a direita." - -#: ../src/fenrir/commands/commands/braille_return_to_cursor.py:17 -msgid "Set the braille view back to cursor." -msgstr "Define a visualização braille de volta ao cursor." - -#: ../src/fenrir/commands/commands/clear_bookmark_1.py:17 -#: ../src/fenrir/commands/commands/clear_bookmark_10.py:17 -#: ../src/fenrir/commands/commands/clear_bookmark_2.py:17 -#: ../src/fenrir/commands/commands/clear_bookmark_3.py:17 -#: ../src/fenrir/commands/commands/clear_bookmark_4.py:17 -#: ../src/fenrir/commands/commands/clear_bookmark_5.py:17 -#: ../src/fenrir/commands/commands/clear_bookmark_6.py:17 -#: ../src/fenrir/commands/commands/clear_bookmark_7.py:17 -#: ../src/fenrir/commands/commands/clear_bookmark_8.py:17 -#: ../src/fenrir/commands/commands/clear_bookmark_9.py:17 -msgid "remove Bookmark {0}" -msgstr "remove o marcador {0}" - -#: ../src/fenrir/commands/commands/clear_bookmark_1.py:24 -#: ../src/fenrir/commands/commands/clear_bookmark_10.py:24 -#: ../src/fenrir/commands/commands/clear_bookmark_2.py:24 -#: ../src/fenrir/commands/commands/clear_bookmark_3.py:24 -#: ../src/fenrir/commands/commands/clear_bookmark_4.py:24 -#: ../src/fenrir/commands/commands/clear_bookmark_5.py:24 -#: ../src/fenrir/commands/commands/clear_bookmark_6.py:24 -#: ../src/fenrir/commands/commands/clear_bookmark_7.py:24 -#: ../src/fenrir/commands/commands/clear_bookmark_8.py:24 -#: ../src/fenrir/commands/commands/clear_bookmark_9.py:24 -msgid "Bookmark {0} removed for application {1}" -msgstr "Marcador {0} removido para o aplicativo {1}" - -#: ../src/fenrir/commands/commands/clear_clipboard.py:17 -msgid "clears the currently selected clipboard" -msgstr "limpa a Ć”rea de transferĆŖncia atualmente selecionada" - -#: ../src/fenrir/commands/commands/clear_clipboard.py:22 -msgid "clipboard cleared" -msgstr "Ć”rea de transferĆŖncia limpa" - -#: ../src/fenrir/commands/commands/clear_window_application.py:17 -msgid "Turn off window mode for application" -msgstr "Desativar o Modo de Janela para aplicativo" - -#: ../src/fenrir/commands/commands/clear_window_application.py:22 -msgid "Window Mode off for application {0}" -msgstr "Modo de Janela desativado para aplicativo {0}" - -#: ../src/fenrir/commands/commands/clear_window_application.py:24 -msgid "Not in window Mode" -msgstr "NĆ£o no Modo de Janela" - -#: ../src/fenrir/commands/commands/copy_marked_to_clipboard.py:18 -msgid "copies marked text to the currently selected clipboard" -msgstr "copia o texto marcado para a Ć”rea de transferĆŖncia atualmente selecionada" - -#: ../src/fenrir/commands/commands/copy_marked_to_clipboard.py:22 -msgid "one or two marks needed" -msgstr "uma ou duas marcas necessĆ”rias" - -#: ../src/fenrir/commands/commands/curr_clipboard.py:17 -msgid "speaks the contents of the currently selected clipboard" -msgstr "fala o conteĆŗdo da Ć”rea de transferĆŖncia atualmente selecionada" - -#: ../src/fenrir/commands/commands/curr_clipboard.py:21 -#: ../src/fenrir/commands/commands/export_clipboard_to_x.py:29 -#: ../src/fenrir/commands/commands/export_clipboard_to_x.py:32 -#: ../src/fenrir/commands/commands/export_clipboard_to_x.py:35 -#: ../src/fenrir/commands/commands/export_clipboard_to_x.py:38 -#: ../src/fenrir/commands/commands/first_clipboard.py:21 -#: ../src/fenrir/commands/commands/last_clipboard.py:21 -#: ../src/fenrir/commands/commands/next_clipboard.py:21 -#: ../src/fenrir/commands/commands/paste_clipboard.py:23 -#: ../src/fenrir/commands/commands/paste_clipboard.py:26 -#: ../src/fenrir/commands/commands/paste_clipboard.py:29 -#: ../src/fenrir/commands/commands/paste_clipboard.py:32 -#: ../src/fenrir/commands/commands/prev_clipboard.py:21 -msgid "clipboard empty" -msgstr "Ć”rea de transferĆŖncia vazia" - -#: ../src/fenrir/commands/commands/curr_screen.py:17 -msgid "reads the contents of the current screen" -msgstr "lĆŖ o conteĆŗdo da tela atual" - -#: ../src/fenrir/commands/commands/curr_screen.py:21 -msgid "screen is empty" -msgstr "a tela estĆ” vazia" - -#: ../src/fenrir/commands/commands/curr_screen_after_cursor.py:18 -msgid "reads from the cursor to the bottom of the screen" -msgstr "lĆŖ a partir do cursor atĆ© a parte inferior da tela" - -#: ../src/fenrir/commands/commands/curr_screen_before_cursor.py:18 -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" - -#: ../src/fenrir/commands/commands/cursor_column.py:17 -msgid "" -"presents the current column number for review cursor in review mode or the " -"text cursor if not. Starts with 1" -msgstr "" -"apresenta o nĆŗmero da coluna atual do cursor de revisĆ£o no modo de revisĆ£o ou do " -"cursor de texto. ComeƧa com 1" - -#: ../src/fenrir/commands/commands/cursor_lineno.py:17 -msgid "" -"presents the current line number for review cursor in review mode or the " -"text cursor if not. Starts with 1" -msgstr "" -"apresenta o nĆŗmero da linha atual do cursor de revisĆ£o no modo de revisĆ£o ou do " -"cursor de texto. ComeƧa com 1" - -#: ../src/fenrir/commands/commands/cursor_position.py:17 -msgid "displays the position of the review cursor" -msgstr "mostra a posição do cursor de revisĆ£o" - -#: ../src/fenrir/commands/commands/cursor_position.py:23 -msgid "line {0}, column {1}" -msgstr "linha {0}, coluna {1}" - -#: ../src/fenrir/commands/commands/cursor_read_to_end_of_line.py:18 -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" - -#: ../src/fenrir/commands/commands/date.py:18 -msgid "presents the date" -msgstr "apresenta a data" - -#: ../src/fenrir/commands/commands/dec_sound_volume.py:18 -msgid "decrease sound volume" -msgstr "diminui o volume do som" - -#: ../src/fenrir/commands/commands/dec_sound_volume.py:29 -#: ../src/fenrir/commands/commands/inc_sound_volume.py:29 -msgid "{0} percent sound volume" -msgstr "{0} porcento do volume do som" - -#: ../src/fenrir/commands/commands/dec_speech_pitch.py:18 -msgid "decreases the pitch of the speech" -msgstr "diminui o tom da fala" - -#: ../src/fenrir/commands/commands/dec_speech_pitch.py:27 -#: ../src/fenrir/commands/commands/inc_speech_pitch.py:27 -msgid "{0} percent speech pitch" -msgstr "{0} porcento do tom da fala" - -#: ../src/fenrir/commands/commands/dec_speech_rate.py:18 -msgid "decreases the rate of the speech" -msgstr "diminui a velocidade da fala" - -#: ../src/fenrir/commands/commands/dec_speech_rate.py:27 -#: ../src/fenrir/commands/commands/inc_speech_rate.py:27 -msgid "{0} percent speech rate" -msgstr "{0} porcento da velocidade da fala" - -#: ../src/fenrir/commands/commands/dec_speech_volume.py:18 -msgid "decreases the volume of the speech" -msgstr "diminui o volume da fala" - -#: ../src/fenrir/commands/commands/dec_speech_volume.py:27 -#: ../src/fenrir/commands/commands/inc_speech_volume.py:27 -msgid "{0} percent speech volume" -msgstr "{0} porcento do volume da fala" - -#: ../src/fenrir/commands/commands/exit_review.py:17 -msgid "exits review mode" -msgstr "sai do modo de revisĆ£o" - -#: ../src/fenrir/commands/commands/exit_review.py:21 -msgid "Not in review mode" -msgstr "NĆ£o em modo de revisĆ£o" - -#: ../src/fenrir/commands/commands/exit_review.py:25 -msgid "leave review mode" -msgstr "sair do modo de revisĆ£o" - -#: ../src/fenrir/commands/commands/export_clipboard_to_x.py:21 -msgid "export the current fenrir clipboard to X clipboard" -msgstr "exportar a Ć”rea de transferĆŖncia atual do fenrir para a Ć”rea de transferĆŖncia X" - -#: ../src/fenrir/commands/commands/first_clipboard.py:17 -msgid "selects the first clipboard" -msgstr "seleciona a primeira Ć”rea de transferĆŖncia" - -#: ../src/fenrir/commands/commands/forward_keypress.py:17 -msgid "sends the following keypress to the terminal" -msgstr "envia a próxima tecla pressionada para o terminal" - -#: ../src/fenrir/commands/commands/forward_keypress.py:21 -msgid "Forward next keypress" -msgstr "AvanƧar próxima tecla pressionada" - -#: ../src/fenrir/commands/commands/inc_sound_volume.py:18 -msgid "adjusts the volume for in coming sounds" -msgstr "ajusta o volume para próximos sons" - -#: ../src/fenrir/commands/commands/inc_speech_pitch.py:18 -msgid "increases the pitch of the speech" -msgstr "aumenta o tom da fala" - -#: ../src/fenrir/commands/commands/inc_speech_rate.py:18 -msgid "increase the speech rate" -msgstr "aumenta a velocidade da fala" - -#: ../src/fenrir/commands/commands/inc_speech_volume.py:18 -msgid "increase the speech volume" -msgstr "aumenta o volume da fala" - -#: ../src/fenrir/commands/commands/indent_curr_line.py:18 -msgid "shows the indention level for the current line" -msgstr "mostra o nĆ­vel de recuo da linha atual" - -#: ../src/fenrir/commands/commands/indent_curr_line.py:33 -msgid "indent {0}" -msgstr "recuo {0}" - -#: ../src/fenrir/commands/commands/last_clipboard.py:17 -msgid "selects the last clipboard" -msgstr "seleciona a Ćŗltima Ć”rea de transferĆŖncia" - -#: ../src/fenrir/commands/commands/last_incoming.py:17 -msgid "displays the last received text" -msgstr "exibe o Ćŗltimo texto recebido" - -#: ../src/fenrir/commands/commands/marked_text.py:18 -msgid "speaks the currently selected text that will be copied to the clipboard" -msgstr "fala o texto atualmente selecionado que serĆ” copiado para a Ć”rea de transferĆŖncia" - -#: ../src/fenrir/commands/commands/marked_text.py:23 -msgid "please set begin and endmark" -msgstr "por favor, defina o inĆ­cio e o fim da seleção" - -#: ../src/fenrir/commands/commands/next_clipboard.py:17 -msgid "selects the next clipboard" -msgstr "seleciona a próxima Ć”rea de transferĆŖncia" - -#: ../src/fenrir/commands/commands/next_clipboard.py:26 -msgid "First clipboard " -msgstr "Primeira Ć”rea de transferĆŖncia" - -#: ../src/fenrir/commands/commands/paste_clipboard.py:18 -msgid "pastes the text from the currently selected clipboard" -msgstr "cola o texto da Ć”rea de transferĆŖncia atualmente selecionada" - -#: ../src/fenrir/commands/commands/present_first_line.py:18 -msgid "present first line" -msgstr "apresenta a primeira linha" - -#: ../src/fenrir/commands/commands/present_last_line.py:18 -#: ../src/fenrir/commands/commands/review_curr_line.py:18 -msgid "current line" -msgstr "linha atual" - -#: ../src/fenrir/commands/commands/prev_clipboard.py:17 -msgid "selects the previous clipboard" -msgstr "seleciona a Ć”rea de transferĆŖncia anterior" - -#: ../src/fenrir/commands/commands/prev_clipboard.py:26 -msgid "Last clipboard " -msgstr "Última Ć”rea de transferĆŖncia" - -#: ../src/fenrir/commands/commands/quit_fenrir.py:17 -msgid "exits Fenrir" -msgstr "sai do Fenrir" - -#: ../src/fenrir/commands/commands/remove_marks.py:17 -msgid "removes marks from selected text" -msgstr "remove marcas do texto selecionado" - -#: ../src/fenrir/commands/commands/remove_marks.py:21 -msgid "Remove marks" -msgstr "Remove marcas" - -#: ../src/fenrir/commands/commands/remove_word_from_spell_check.py:27 -msgid "removes the current word from the exceptions dictionary" -msgstr "remove a palavra atual do dicionĆ”rio de exceƧƵes" - -#: ../src/fenrir/commands/commands/remove_word_from_spell_check.py:50 -msgid "{0} is already removed from dict" -msgstr "{0} jĆ” foi removido do dicionĆ”rio" - -#: ../src/fenrir/commands/commands/remove_word_from_spell_check.py:53 -msgid "{0} removed" -msgstr "{0} removido" - -#: ../src/fenrir/commands/commands/review_bottom.py:17 -msgid "move review to bottom of screen" -msgstr "mover revisĆ£o para a parte inferior da tela" - -#: ../src/fenrir/commands/commands/review_bottom.py:21 -msgid "Bottom" -msgstr "Inferior" - -#: ../src/fenrir/commands/commands/review_curr_char.py:18 -msgid "presents the current character." -msgstr "apresenta o caracter atual." - -#: ../src/fenrir/commands/commands/review_curr_char_phonetic.py:18 -msgid "set review and phonetically presents the current character" -msgstr "define revisĆ£o e apresenta o caracter atual foneticamente" - -#: ../src/fenrir/commands/commands/review_curr_word.py:18 -msgid "current word." -msgstr "palavra atual." - -#: ../src/fenrir/commands/commands/review_curr_word.py:32 -#: ../src/fenrir/commands/commands/review_curr_word_phonetic.py:36 -#: ../src/fenrir/commands/commands/review_down.py:27 -#: ../src/fenrir/commands/commands/review_next_char.py:28 -#: ../src/fenrir/commands/commands/review_next_char_phonetic.py:30 -#: ../src/fenrir/commands/commands/review_next_line.py:34 -#: ../src/fenrir/commands/commands/review_next_word.py:34 -#: ../src/fenrir/commands/commands/review_next_word_phonetic.py:36 -#: ../src/fenrir/commands/commands/review_prev_char.py:31 -#: ../src/fenrir/commands/commands/review_prev_char_phonetic.py:30 -#: ../src/fenrir/commands/commands/review_prev_line.py:32 -#: ../src/fenrir/commands/commands/review_prev_word.py:32 -#: ../src/fenrir/commands/commands/review_prev_word_phonetic.py:36 -#: ../src/fenrir/commands/commands/review_up.py:27 -msgid "end of screen" -msgstr "fim da tela" - -#: ../src/fenrir/commands/commands/review_curr_word.py:35 -#: ../src/fenrir/commands/commands/review_curr_word_phonetic.py:39 -#: ../src/fenrir/commands/commands/review_next_char.py:31 -#: ../src/fenrir/commands/commands/review_next_char_phonetic.py:33 -#: ../src/fenrir/commands/commands/review_next_word.py:37 -#: ../src/fenrir/commands/commands/review_next_word_phonetic.py:39 -#: ../src/fenrir/commands/commands/review_prev_char.py:34 -#: ../src/fenrir/commands/commands/review_prev_char_phonetic.py:33 -#: ../src/fenrir/commands/commands/review_prev_word.py:35 -#: ../src/fenrir/commands/commands/review_prev_word_phonetic.py:39 -#: ../src/fenrir/commands/commands/review_up.py:30 -msgid "line break" -msgstr "quebra de linha" - -#: ../src/fenrir/commands/commands/review_curr_word_phonetic.py:19 -#: ../src/fenrir/commands/commands/review_next_word_phonetic.py:19 -#: ../src/fenrir/commands/commands/review_prev_word_phonetic.py:19 -msgid "phonetically spells the current word and set review to it" -msgstr "soletra foneticamente a palavra atual e define a revisĆ£o para ela" - -#: ../src/fenrir/commands/commands/review_down.py:18 -msgid "set review cursor to char below the current char and present it." -msgstr "define o cursor de revisĆ£o para o caracter abaixo do caracter atual e o apresenta." - -#: ../src/fenrir/commands/commands/review_line_begin.py:18 -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" - -#: ../src/fenrir/commands/commands/review_line_begin.py:30 -msgid "beginning of line" -msgstr "inĆ­cio da linha" - -#: ../src/fenrir/commands/commands/review_line_end.py:18 -#: ../src/fenrir/commands/commands/review_line_first_char.py:19 -#: ../src/fenrir/commands/commands/review_line_last_char.py:18 -msgid "set review cursor to end of current line and display the content" -msgstr "define o cursor de revisĆ£o para o fim da linha atual e exibe o conteĆŗdo" - -#: ../src/fenrir/commands/commands/review_line_end.py:27 -msgid "end of line" -msgstr "fim da linha" - -#: ../src/fenrir/commands/commands/review_line_first_char.py:26 -msgid "line is empty" -msgstr "a linha estĆ” vazia" - -#: ../src/fenrir/commands/commands/review_line_first_char.py:33 -msgid "first char in line indent {0}" -msgstr "primeiro caracter no recuo da linha {0}" - -#: ../src/fenrir/commands/commands/review_line_last_char.py:27 -msgid "last char in line" -msgstr "Ćŗltimo caracter na linha" - -#: ../src/fenrir/commands/commands/review_next_char.py:18 -msgid "moves review to the next character and presents it" -msgstr "move a revisĆ£o para o próximo caracter e o apresenta" - -#: ../src/fenrir/commands/commands/review_next_char_phonetic.py:18 -msgid "phonetically presents the next character and set review to it" -msgstr "apresenta o próximo caracter foneticamente e define a revisĆ£o para ele" - -#: ../src/fenrir/commands/commands/review_next_line.py:18 -msgid "moves review to the next line and presents it" -msgstr "move a revisĆ£o para a próxima linha e a apresenta" - -#: ../src/fenrir/commands/commands/review_next_word.py:18 -msgid "moves review to the next word and presents it" -msgstr "move a revisĆ£o para a próxima palavra e a apresenta" - -#: ../src/fenrir/commands/commands/review_prev_char.py:18 -msgid "moves review to the previous character and presents it" -msgstr "move a revisĆ£o para o caracter anterior e o apresenta" - -#: ../src/fenrir/commands/commands/review_prev_char_phonetic.py:18 -msgid "phonetically presents the previous character and set review to it" -msgstr "apresenta o caracter anterior foneticamente e define a revisĆ£o para ele" - -#: ../src/fenrir/commands/commands/review_prev_line.py:18 -msgid "moves review to the previous line and presents it" -msgstr "move a revisĆ£o para a linha anterior e a apresenta" - -#: ../src/fenrir/commands/commands/review_prev_word.py:18 -msgid "moves review focus to the previous word and presents it" -msgstr "move o foco da revisĆ£o para a palavra anterior e a apresenta" - -#: ../src/fenrir/commands/commands/review_top.py:18 -msgid "move review to top of screen" -msgstr "mover a revisĆ£o para o topo da tela" - -#: ../src/fenrir/commands/commands/review_top.py:22 -msgid "Top" -msgstr "Topo" - -#: ../src/fenrir/commands/commands/review_up.py:18 -msgid "set review cursor to the char in the line below and present it" -msgstr "define o cursor de revisĆ£o para o caracter na linha abaixo e o apresenta" - -#: ../src/fenrir/commands/commands/set_bookmark_1.py:18 -#: ../src/fenrir/commands/commands/set_bookmark_10.py:18 -#: ../src/fenrir/commands/commands/set_bookmark_2.py:18 -#: ../src/fenrir/commands/commands/set_bookmark_3.py:18 -#: ../src/fenrir/commands/commands/set_bookmark_4.py:18 -#: ../src/fenrir/commands/commands/set_bookmark_5.py:18 -#: ../src/fenrir/commands/commands/set_bookmark_6.py:18 -#: ../src/fenrir/commands/commands/set_bookmark_7.py:18 -#: ../src/fenrir/commands/commands/set_bookmark_8.py:18 -#: ../src/fenrir/commands/commands/set_bookmark_9.py:18 -msgid "set Bookmark {0}" -msgstr "define Marcador {0}" - -#: ../src/fenrir/commands/commands/set_bookmark_1.py:22 -#: ../src/fenrir/commands/commands/set_bookmark_10.py:22 -#: ../src/fenrir/commands/commands/set_bookmark_2.py:22 -#: ../src/fenrir/commands/commands/set_bookmark_3.py:22 -#: ../src/fenrir/commands/commands/set_bookmark_4.py:22 -#: ../src/fenrir/commands/commands/set_bookmark_5.py:22 -#: ../src/fenrir/commands/commands/set_bookmark_6.py:22 -#: ../src/fenrir/commands/commands/set_bookmark_7.py:22 -#: ../src/fenrir/commands/commands/set_bookmark_8.py:22 -#: ../src/fenrir/commands/commands/set_bookmark_9.py:22 -msgid "No Mark found" -msgstr "Nenhuma marca encontrada" - -#: ../src/fenrir/commands/commands/set_bookmark_1.py:32 -#: ../src/fenrir/commands/commands/set_bookmark_10.py:32 -#: ../src/fenrir/commands/commands/set_bookmark_2.py:32 -#: ../src/fenrir/commands/commands/set_bookmark_3.py:32 -#: ../src/fenrir/commands/commands/set_bookmark_4.py:32 -#: ../src/fenrir/commands/commands/set_bookmark_5.py:32 -#: ../src/fenrir/commands/commands/set_bookmark_6.py:32 -#: ../src/fenrir/commands/commands/set_bookmark_7.py:32 -#: ../src/fenrir/commands/commands/set_bookmark_8.py:32 -#: ../src/fenrir/commands/commands/set_bookmark_9.py:32 -msgid "Bookmark {0} set for application {1}" -msgstr "Marcador {0} definido para o aplicativo {1}" - -#: ../src/fenrir/commands/commands/set_mark.py:17 -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" - -#: ../src/fenrir/commands/commands/set_mark.py:21 -msgid "no review cursor" -msgstr "sem cursor de revisĆ£o" - -#: ../src/fenrir/commands/commands/set_mark.py:26 -#: ../src/fenrir/commands/commands/set_mark.py:28 -msgid "set mark" -msgstr "define marca" - -#: ../src/fenrir/commands/commands/set_window_application.py:17 -msgid "set Window Mode, needs 2 marks " -msgstr "definir o Modo de Janela, precisa de 2 marcas" - -#: ../src/fenrir/commands/commands/set_window_application.py:22 -msgid "Window Mode on for application {0}" -msgstr "Modo de Janela ativado para o aplicativo {0}" - -#: ../src/fenrir/commands/commands/set_window_application.py:25 -msgid "Set window begin and end marks" -msgstr "Definir marcas de inĆ­cio e fim da janela" - -#: ../src/fenrir/commands/commands/shut_up.py:17 -msgid "interrupts the current presentation" -msgstr "interrompe a apresentação atual" - -#: ../src/fenrir/commands/commands/spell_check.py:26 -msgid "checks the spelling of the current word" -msgstr "verifica a ortografia da palavra atual" - -#: ../src/fenrir/commands/commands/spell_check.py:52 -#: ../src/fenrir/commands/onInput/62000-spell_check.py:132 -msgid "misspelled" -msgstr "com erros ortogrĆ”ficos" - -#: ../src/fenrir/commands/commands/spell_check.py:54 -msgid "correct" -msgstr "correto" - -#: ../src/fenrir/commands/commands/subprocess.py:21 -msgid "script: {0} fullpath: {1}" -msgstr "script: {0} caminho completo: {1}" - -#: ../src/fenrir/commands/commands/subprocess.py:24 -msgid "scriptfile does not exist" -msgstr "arquivo de script nĆ£o existe" - -#: ../src/fenrir/commands/commands/subprocess.py:27 -msgid "scriptfile is not a file" -msgstr "arquivo de script nĆ£o Ć© um arquivo" - -#: ../src/fenrir/commands/commands/subprocess.py:30 -msgid "scriptfile is not executable" -msgstr "arquivo de script nĆ£o Ć© executĆ”vel" - -#: ../src/fenrir/commands/commands/temp_disable_speech.py:17 -#: ../src/fenrir/commands/onInput/15000-enable_temp_speech.py:17 -msgid "disables speech until next keypress" -msgstr "desativa a fala atĆ© a próxima tecla pressionada" - -#: ../src/fenrir/commands/commands/temp_disable_speech.py:21 -msgid "speech temporary disabled" -msgstr "fala temporariamente desativada" - -#: ../src/fenrir/commands/commands/time.py:18 -msgid "presents the time" -msgstr "apresenta a hora" - -#: ../src/fenrir/commands/commands/toggle_auto_read.py:16 -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" - -#: ../src/fenrir/commands/commands/toggle_auto_read.py:21 -msgid "autoread enabled" -msgstr "leitura automĆ”tica ativada" - -#: ../src/fenrir/commands/commands/toggle_auto_read.py:23 -msgid "autoread disabled" -msgstr "leitura automĆ”tica desativada" - -#: ../src/fenrir/commands/commands/toggle_auto_spell_check.py:17 -msgid "enables or disables automatic spell checking" -msgstr "ativa ou desativa a verificação ortogrĆ”fica automĆ”tica" - -#: ../src/fenrir/commands/commands/toggle_auto_spell_check.py:22 -msgid "auto spellcheck enabled" -msgstr "verificação ortogrĆ”fica automĆ”tica ativada" - -#: ../src/fenrir/commands/commands/toggle_auto_spell_check.py:24 -msgid "auto spellcheck disabled" -msgstr "verificação ortogrĆ”fica automĆ”tica desativada" - -#: ../src/fenrir/commands/commands/toggle_auto_time.py:16 -msgid "enables or disables automatic reading of time after an period" -msgstr "ativa ou desativa a leitura automĆ”tica do tempo após um perĆ­odo" - -#: ../src/fenrir/commands/commands/toggle_auto_time.py:21 -msgid "autotime enabled" -msgstr "tempo automĆ”tico ativado" - -#: ../src/fenrir/commands/commands/toggle_auto_time.py:23 -msgid "autotime disabled" -msgstr "tempo automĆ”tico desativado" - -#: ../src/fenrir/commands/commands/toggle_braille.py:17 -msgid "enables and disables output in braille" -msgstr "ativa e desativa a saĆ­da em braille" - -#: ../src/fenrir/commands/commands/toggle_braille.py:21 -msgid "braille disabled" -msgstr "braille desativado" - -#: ../src/fenrir/commands/commands/toggle_braille.py:24 -msgid "braille enabled" -msgstr "braille ativado" - -#: ../src/fenrir/commands/commands/toggle_emoticons.py:16 -msgid "enables or disables announcement of emoticons instead of chars" -msgstr "ativa ou desativa o anĆŗncio de emoticons em vez de caracteres" - -#: ../src/fenrir/commands/commands/toggle_emoticons.py:21 -msgid "emoticons enabled" -msgstr "emoticons ativados" - -#: ../src/fenrir/commands/commands/toggle_emoticons.py:23 -msgid "emoticons disabled" -msgstr "emoticons desativados" - -#: ../src/fenrir/commands/commands/toggle_highlight_tracking.py:16 -#: ../src/fenrir/commands/onInput/56000-highlight_tracking.py:16 -msgid "enables or disables tracking of highlighted" -msgstr "ativa ou desativa o rastreamento em destaque" - -#: ../src/fenrir/commands/commands/toggle_highlight_tracking.py:24 -msgid "highlight tracking" -msgstr "rastreamento em destaque" - -#: ../src/fenrir/commands/commands/toggle_highlight_tracking.py:26 -msgid "cursor tracking" -msgstr "rastreamento do cursor" - -#: ../src/fenrir/commands/commands/toggle_output.py:17 -msgid "toggles all output settings" -msgstr "alterna todas as configuraƧƵes de saĆ­da" - -#: ../src/fenrir/commands/commands/toggle_output.py:23 -msgid "Fenrir muted" -msgstr "Fenrir silenciado" - -#: ../src/fenrir/commands/commands/toggle_output.py:31 -msgid "Fenrir unmuted" -msgstr "Fenrir nĆ£o silenciado" - -#: ../src/fenrir/commands/commands/toggle_punctuation_level.py:23 -msgid "No punctuation found." -msgstr "Nenhuma pontuação encontrada." - -#: ../src/fenrir/commands/commands/toggle_sound.py:17 -msgid "enables or disables sound" -msgstr "ativa ou desativa o som" - -#: ../src/fenrir/commands/commands/toggle_sound.py:21 -msgid "sound disabled" -msgstr "som desativado" - -#: ../src/fenrir/commands/commands/toggle_sound.py:24 -msgid "sound enabled" -msgstr "som ativado" - -#: ../src/fenrir/commands/commands/toggle_speech.py:17 -msgid "enables or disables speech" -msgstr "ativa ou desativa a fala" - -#: ../src/fenrir/commands/commands/toggle_speech.py:21 -msgid "speech disabled" -msgstr "fala desativada" - -#: ../src/fenrir/commands/commands/toggle_speech.py:24 -#: ../src/fenrir/commands/onInput/15000-enable_temp_speech.py:28 -msgid "speech enabled" -msgstr "fala ativada" - -#: ../src/fenrir/commands/commands/toggle_tutorial_mode.py:18 -msgid "" -"You are leaving the tutorial mode. Press that shortcut again to enter the " -"tutorial mode again." -msgstr "" -"VocĆŖ estĆ” saindo do modo tutorial. Pressione esse atalho novamente para entrar no " -"modo tutorial novamente." - -#: ../src/fenrir/commands/commands/toggle_tutorial_mode.py:21 -msgid "" -"you entered the tutorial mode. In that mode the commands are not executed. " -"but you get a description of what the shortcut does. To leave the tutorial " -"mode, press that shortcut again." -msgstr "" -"vocĆŖ entrou no modo tutorial. Nesse modo os comandos nĆ£o sĆ£o executados. " -"Mas vocĆŖ obtĆ©m uma descrição do que o atalho faz. Para sair do modo " -"tutorial, pressione esse atalho novamente." - -#: ../src/fenrir/commands/onInput/80000-capslock.py:22 -msgid "Capslock on" -msgstr "Capslock ligado" - -#: ../src/fenrir/commands/onInput/80000-capslock.py:24 -msgid "Capslock off" -msgstr "Capslock desligado" - -#: ../src/fenrir/commands/onInput/80300-scrolllock.py:22 -msgid "Scrolllock on" -msgstr "Scrolllock ligado" - -#: ../src/fenrir/commands/onInput/80300-scrolllock.py:24 -msgid "Scrolllock off" -msgstr "Scrolllock desligado" - -#: ../src/fenrir/commands/onInput/80500-numlock.py:22 -msgid "Numlock on" -msgstr "Numlock ligado" - -#: ../src/fenrir/commands/onInput/80500-numlock.py:24 -msgid "Numlock off" -msgstr "Numlock desligado" - -#: ../src/fenrir/commands/onScreenChanged/80000-screen_change_announcement.py:20 -msgid "screen {0}" -msgstr "tela {0}" - -#: ../src/fenrir/commands/onScreenUpdate/76000-time.py:66 -msgid "Autotime: {0}" -msgstr "Tempo automĆ”tico: {0}" - -#: ../src/fenrir/fenrir.py:24 -msgid "Start Fenrir" -msgstr "Iniciar o Fenrir" - -#: ../src/fenrir/fenrir.py:99 -msgid "Quit Fenrir" -msgstr "Sair do Fenrir" - diff --git a/locale/ru/LC_MESSAGES/fenrir.mo b/locale/ru/LC_MESSAGES/fenrir.mo deleted file mode 100644 index 081a85a0..00000000 Binary files a/locale/ru/LC_MESSAGES/fenrir.mo and /dev/null differ diff --git a/locale/ru/LC_MESSAGES/fenrir.po b/locale/ru/LC_MESSAGES/fenrir.po deleted file mode 100644 index d9650571..00000000 --- a/locale/ru/LC_MESSAGES/fenrir.po +++ /dev/null @@ -1,1207 +0,0 @@ -# SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR ORGANIZATION -# FIRST AUTHOR , YEAR. -# -msgid "" -msgstr "" -"Project-Id-Version: PACKAGE VERSION\n" -"POT-Creation-Date: 2020-04-19 09:11+0700\n" -"PO-Revision-Date: 2020-04-19 19:26+07\n" -"Last-Translator: denis rybin \n" -"Language-Team: russian \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" - - -#: ../src/fenrirscreenreader/commands/commands/add_word_to_spell_check.py:27 -msgid "adds the current word to the exceptions dictionary" -msgstr "Š“Š¾Š±Š°Š²Š»ŃŠµŃ‚ Ń‚ŠµŠŗŃƒŃ‰ŠµŠµ слово в ŃŠ»Š¾Š²Š°Ń€ŃŒ ŠøŃŠŗŠ»ŃŽŃ‡ŠµŠ½ŠøŠµ" - -#: ../src/fenrirscreenreader/commands/commands/add_word_to_spell_check.py:34 -#: ../src/fenrirscreenreader/commands/commands/remove_word_from_spell_check.py:34 -#: ../src/fenrirscreenreader/commands/commands/spell_check.py:35 -msgid "pyenchant is not installed" -msgstr "pyenchant не ŃƒŃŃ‚Š°Š½Š¾Š²Š»ŠµŠ½" - -#: ../src/fenrirscreenreader/commands/commands/add_word_to_spell_check.py:49 -msgid "{0} is already in dictionary" -msgstr "{0} уже Š½Š°Ń…Š¾Š“ŠøŃ‚ŃŃ в словаре" - -#: ../src/fenrirscreenreader/commands/commands/add_word_to_spell_check.py:52 -msgid "{0} added to dictionary" -msgstr "{0} Гобавлено в ŃŠ»Š¾Š²Š°Ń€ŃŒ" - -#: ../src/fenrirscreenreader/commands/commands/attribute_cursor.py:18 -#: ../src/fenrirscreenreader/commands/onCursorChange/85000-has_attribute.py:18 -msgid "Reads attributes of current cursor position" -msgstr "Читает Š°Ń‚Ń€ŠøŠ±ŃƒŃ‚Ń‹ Ń‚ŠµŠŗŃƒŃ‰ŠµŠ¹ позиции ŠŗŃƒŃ€ŃŠ¾Ń€Š°" - -#: ../src/fenrirscreenreader/commands/commands/bookmark_1.py:19 -#: ../src/fenrirscreenreader/commands/commands/bookmark_10.py:19 -#: ../src/fenrirscreenreader/commands/commands/bookmark_2.py:19 -#: ../src/fenrirscreenreader/commands/commands/bookmark_3.py:19 -#: ../src/fenrirscreenreader/commands/commands/bookmark_4.py:19 -#: ../src/fenrirscreenreader/commands/commands/bookmark_5.py:19 -#: ../src/fenrirscreenreader/commands/commands/bookmark_6.py:19 -#: ../src/fenrirscreenreader/commands/commands/bookmark_7.py:19 -#: ../src/fenrirscreenreader/commands/commands/bookmark_8.py:19 -#: ../src/fenrirscreenreader/commands/commands/bookmark_9.py:19 -msgid "read Bookmark {0}" -msgstr "чтение заклаГки {0}" - -#: ../src/fenrirscreenreader/commands/commands/bookmark_1.py:24 -#: ../src/fenrirscreenreader/commands/commands/bookmark_10.py:24 -#: ../src/fenrirscreenreader/commands/commands/bookmark_2.py:24 -#: ../src/fenrirscreenreader/commands/commands/bookmark_3.py:24 -#: ../src/fenrirscreenreader/commands/commands/bookmark_4.py:24 -#: ../src/fenrirscreenreader/commands/commands/bookmark_5.py:24 -#: ../src/fenrirscreenreader/commands/commands/bookmark_6.py:24 -#: ../src/fenrirscreenreader/commands/commands/bookmark_7.py:24 -#: ../src/fenrirscreenreader/commands/commands/bookmark_8.py:24 -#: ../src/fenrirscreenreader/commands/commands/bookmark_9.py:24 -msgid "Bookmark {0} not set" -msgstr "ЗаклаГка {0} не ŃƒŃŃ‚Š°Š½Š¾Š²Š»ŠµŠ½Š°" - -#: ../src/fenrirscreenreader/commands/commands/bookmark_1.py:27 -#: ../src/fenrirscreenreader/commands/commands/bookmark_1.py:30 -#: ../src/fenrirscreenreader/commands/commands/bookmark_10.py:27 -#: ../src/fenrirscreenreader/commands/commands/bookmark_10.py:30 -#: ../src/fenrirscreenreader/commands/commands/bookmark_2.py:27 -#: ../src/fenrirscreenreader/commands/commands/bookmark_2.py:30 -#: ../src/fenrirscreenreader/commands/commands/bookmark_3.py:27 -#: ../src/fenrirscreenreader/commands/commands/bookmark_3.py:30 -#: ../src/fenrirscreenreader/commands/commands/bookmark_4.py:27 -#: ../src/fenrirscreenreader/commands/commands/bookmark_4.py:30 -#: ../src/fenrirscreenreader/commands/commands/bookmark_5.py:27 -#: ../src/fenrirscreenreader/commands/commands/bookmark_5.py:30 -#: ../src/fenrirscreenreader/commands/commands/bookmark_6.py:27 -#: ../src/fenrirscreenreader/commands/commands/bookmark_6.py:30 -#: ../src/fenrirscreenreader/commands/commands/bookmark_7.py:27 -#: ../src/fenrirscreenreader/commands/commands/bookmark_7.py:30 -#: ../src/fenrirscreenreader/commands/commands/bookmark_8.py:27 -#: ../src/fenrirscreenreader/commands/commands/bookmark_8.py:30 -#: ../src/fenrirscreenreader/commands/commands/bookmark_9.py:27 -#: ../src/fenrirscreenreader/commands/commands/bookmark_9.py:30 -msgid "Bookmark for application {0} not set" -msgstr "ЗаклаГка Š“Š»Ń ŠæŃ€ŠøŠ»Š¾Š¶ŠµŠ½ŠøŃ {0} не ŃƒŃŃ‚Š°Š½Š¾Š²Š»ŠµŠ½Š°" - -#: ../src/fenrirscreenreader/commands/commands/bookmark_1.py:43 -#: ../src/fenrirscreenreader/commands/commands/bookmark_10.py:43 -#: ../src/fenrirscreenreader/commands/commands/bookmark_2.py:43 -#: ../src/fenrirscreenreader/commands/commands/bookmark_3.py:43 -#: ../src/fenrirscreenreader/commands/commands/bookmark_4.py:43 -#: ../src/fenrirscreenreader/commands/commands/bookmark_5.py:43 -#: ../src/fenrirscreenreader/commands/commands/bookmark_6.py:43 -#: ../src/fenrirscreenreader/commands/commands/bookmark_7.py:43 -#: ../src/fenrirscreenreader/commands/commands/bookmark_8.py:43 -#: ../src/fenrirscreenreader/commands/commands/bookmark_9.py:43 -#: ../src/fenrirscreenreader/commands/commands/curr_screen_after_cursor.py:27 -#: ../src/fenrirscreenreader/commands/commands/curr_screen_before_cursor.py:30 -#: ../src/fenrirscreenreader/commands/commands/cursor_read_to_end_of_line.py:27 -#: ../src/fenrirscreenreader/commands/commands/indent_curr_line.py:31 -#: ../src/fenrirscreenreader/commands/commands/marked_text.py:33 -#: ../src/fenrirscreenreader/commands/commands/present_first_line.py:25 -#: ../src/fenrirscreenreader/commands/commands/present_last_line.py:25 -#: ../src/fenrirscreenreader/commands/commands/review_curr_char_phonetic.py:27 -#: ../src/fenrirscreenreader/commands/commands/review_curr_line.py:27 -#: ../src/fenrirscreenreader/commands/commands/review_curr_word.py:27 -#: ../src/fenrirscreenreader/commands/commands/review_curr_word_phonetic.py:27 -#: ../src/fenrirscreenreader/commands/commands/review_line_begin.py:27 -#: ../src/fenrirscreenreader/commands/commands/review_next_line.py:29 -#: ../src/fenrirscreenreader/commands/commands/review_next_word.py:29 -#: ../src/fenrirscreenreader/commands/commands/review_next_word_phonetic.py:27 -#: ../src/fenrirscreenreader/commands/commands/review_prev_line.py:27 -#: ../src/fenrirscreenreader/commands/commands/review_prev_word.py:27 -#: ../src/fenrirscreenreader/commands/commands/review_prev_word_phonetic.py:27 -#: ../src/fenrirscreenreader/commands/onCursorChange/65000-present_line_if_cursor_change_vertical.py:37 -#: ../src/fenrirscreenreader/commands/onScreenUpdate/60000-history.py:59 -msgid "blank" -msgstr "ŠæŃƒŃŃ‚Š¾" - -#: ../src/fenrirscreenreader/commands/commands/braille_flush.py:17 -msgid "Clear the Braille device if it is displaying a message" -msgstr "ŠžŃ‡ŠøŃŃ‚ŠøŃ‚ŃŒ ŃƒŃŃ‚Ń€Š¾Š¹ŃŃ‚Š²Š¾ Š‘Ń€Š°Š¹Š»Ń, если оно отображает сообщение." - -#: ../src/fenrirscreenreader/commands/commands/braille_pan_left.py:17 -msgid "Move braille view to the left." -msgstr "ŠŸŠµŃ€ŠµŠ¼ŠµŃŃ‚ŠøŃ‚ŃŒ брайлевский виГ влево." - -#: ../src/fenrirscreenreader/commands/commands/braille_pan_right.py:17 -msgid "Move braille view to the right." -msgstr "ŠŸŠµŃ€ŠµŠ¼ŠµŃŃ‚ŠøŃ‚ŃŒ брайлевский виГ вправо." - -#: ../src/fenrirscreenreader/commands/commands/braille_return_to_cursor.py:17 -msgid "Set the braille view back to cursor." -msgstr "Š£ŃŃ‚Š°Š½Š¾Š²ŠøŃ‚ŃŒ брайлевский виГ обратно на ŠŗŃƒŃ€ŃŠ¾Ń€." - -#: ../src/fenrirscreenreader/commands/commands/clear_bookmark_1.py:17 -#: ../src/fenrirscreenreader/commands/commands/clear_bookmark_10.py:17 -#: ../src/fenrirscreenreader/commands/commands/clear_bookmark_2.py:17 -#: ../src/fenrirscreenreader/commands/commands/clear_bookmark_3.py:17 -#: ../src/fenrirscreenreader/commands/commands/clear_bookmark_4.py:17 -#: ../src/fenrirscreenreader/commands/commands/clear_bookmark_5.py:17 -#: ../src/fenrirscreenreader/commands/commands/clear_bookmark_6.py:17 -#: ../src/fenrirscreenreader/commands/commands/clear_bookmark_7.py:17 -#: ../src/fenrirscreenreader/commands/commands/clear_bookmark_8.py:17 -#: ../src/fenrirscreenreader/commands/commands/clear_bookmark_9.py:17 -msgid "remove Bookmark {0}" -msgstr "Š£Š“Š°Š»ŠøŃ‚ŃŒ заклаГку {0}" - -#: ../src/fenrirscreenreader/commands/commands/clear_bookmark_1.py:24 -#: ../src/fenrirscreenreader/commands/commands/clear_bookmark_10.py:24 -#: ../src/fenrirscreenreader/commands/commands/clear_bookmark_2.py:24 -#: ../src/fenrirscreenreader/commands/commands/clear_bookmark_3.py:24 -#: ../src/fenrirscreenreader/commands/commands/clear_bookmark_4.py:24 -#: ../src/fenrirscreenreader/commands/commands/clear_bookmark_5.py:24 -#: ../src/fenrirscreenreader/commands/commands/clear_bookmark_6.py:24 -#: ../src/fenrirscreenreader/commands/commands/clear_bookmark_7.py:24 -#: ../src/fenrirscreenreader/commands/commands/clear_bookmark_8.py:24 -#: ../src/fenrirscreenreader/commands/commands/clear_bookmark_9.py:24 -msgid "Bookmark {0} removed for application {1}" -msgstr "ЗаклаГка {0} уГалена Š“Š»Ń ŠæŃ€ŠøŠ»Š¾Š¶ŠµŠ½ŠøŃ {1}" - -#: ../src/fenrirscreenreader/commands/commands/clear_clipboard.py:17 -msgid "clears the currently selected clipboard" -msgstr "ŠžŃ‡ŠøŃ‰Š°ŠµŃ‚ Ń‚ŠµŠŗŃƒŃ‰ŠøŠ¹ выбранный Š±ŃƒŃ„ер обмена." - -#: ../src/fenrirscreenreader/commands/commands/clear_clipboard.py:21 -msgid "clipboard cleared" -msgstr "Š‘ŃƒŃ„ŠµŃ€ обмена очищен." - -#: ../src/fenrirscreenreader/commands/commands/clear_window_application.py:17 -msgid "Turn off window mode for application" -msgstr "ŠžŃ‚ŠŗŠ»ŃŽŃ‡ŠøŃ‚ŃŒ режим окна Š“Š»Ń ŠæŃ€ŠøŠ»Š¾Š¶ŠµŠ½ŠøŃ." - -#: ../src/fenrirscreenreader/commands/commands/clear_window_application.py:22 -msgid "Window Mode off for application {0}" -msgstr "Режим окна Š²Ń‹ŠŗŠ»ŃŽŃ‡ŠµŠ½ Š“Š»Ń ŠæŃ€ŠøŠ»Š¾Š¶ŠµŠ½ŠøŃ {0}" - -#: ../src/fenrirscreenreader/commands/commands/clear_window_application.py:24 -msgid "Not in window Mode" -msgstr "ŠŠµ в режиме окна." - -#: -#: ../src/fenrirscreenreader/commands/commands/copy_last_echo_to_clipboard.py:18 -msgid "copies last presented text to the clipboard" -msgstr "ŠšŠ¾ŠæŠøŃ€ŃƒŠµŃ‚ послеГний преГставленный текст в Š±ŃƒŃ„ер обмена." - -#: ../src/fenrirscreenreader/commands/commands/copy_marked_to_clipboard.py:18 -msgid "copies marked text to the currently selected clipboard" -msgstr "ŠšŠ¾ŠæŠøŃ€ŃƒŠµŃ‚ помеченный текст в выбранный Š±ŃƒŃ„ер обмена." - -#: ../src/fenrirscreenreader/commands/commands/copy_marked_to_clipboard.py:22 -msgid "One or two marks are needed" -msgstr "ŠŠµŠ¾Š±Ń…Š¾Š“ŠøŠ¼Ń‹ оГна или Гве ментки." - -#: ../src/fenrirscreenreader/commands/commands/curr_clipboard.py:17 -msgid "speaks the contents of the currently selected clipboard" -msgstr "Говорит соГержимое выбранного Š±ŃƒŃ„ера обмена." - -#: ../src/fenrirscreenreader/commands/commands/curr_clipboard.py:21 -#: ../src/fenrirscreenreader/commands/commands/export_clipboard_to_file.py:28 -#: ../src/fenrirscreenreader/commands/commands/export_clipboard_to_x.py:27 -#: ../src/fenrirscreenreader/commands/commands/first_clipboard.py:21 -#: ../src/fenrirscreenreader/commands/commands/last_clipboard.py:21 -#: ../src/fenrirscreenreader/commands/commands/next_clipboard.py:21 -#: ../src/fenrirscreenreader/commands/commands/paste_clipboard.py:23 -#: ../src/fenrirscreenreader/commands/commands/prev_clipboard.py:21 -msgid "clipboard empty" -msgstr "Š‘ŃƒŃ„ŠµŃ€ обмена ŠæŃƒŃŃ‚ой." - -#: ../src/fenrirscreenreader/commands/commands/curr_screen.py:17 -msgid "reads the contents of the current screen" -msgstr "Чтение соГержимого Ń‚ŠµŠŗŃƒŃ‰ŠµŠ³Š¾ ŃŠŗŃ€Š°Š½Š°." - -#: ../src/fenrirscreenreader/commands/commands/curr_screen.py:21 -msgid "screen is empty" -msgstr "Экран ŠæŃƒŃŃ‚ой." - -#: ../src/fenrirscreenreader/commands/commands/curr_screen_after_cursor.py:18 -msgid "reads from the cursor to the bottom of the screen" -msgstr "Чтение от ŠŗŃƒŃ€ŃŠ¾Ń€Š° Го конца ŃŠŗŃ€Š°Š½Š°." - -#: ../src/fenrirscreenreader/commands/commands/curr_screen_before_cursor.py:18 -msgid "Reads from the top of the screen to the cursor position" -msgstr "Чтение сначала ŃŠŗŃ€Š°Š½Š° Го ŠŗŃƒŃ€ŃŠ¾Ń€Š°." - -#: ../src/fenrirscreenreader/commands/commands/current_quick_menu_entry.py:17 -#: ../src/fenrirscreenreader/commands/quickMenu/current_quick_menu_entry.py:17 -msgid "get current quick menu entry" -msgstr "ŠŸŠ¾Š»ŃƒŃ‡ŠøŃ‚ŃŒ Ń‚ŠµŠŗŃƒŃ‰ŠøŠ¹ быстрый ŠæŃƒŠ½ŠŗŃ‚ Š¼ŠµŠ½ŃŽ" - -#: ../src/fenrirscreenreader/commands/commands/current_quick_menu_value.py:17 -#: ../src/fenrirscreenreader/commands/quickMenu/current_quick_menu_value.py:17 -msgid "get current quick menu value" -msgstr "ŠŸŠ¾Š»ŃƒŃ‡ŠøŃ‚ŃŒ Ń‚ŠµŠŗŃƒŃ‰ŠµŠµ значение быстрого Š¼ŠµŠ½ŃŽ." - -#: ../src/fenrirscreenreader/commands/commands/cursor_column.py:17 -msgid "Column number for cursor" -msgstr "ŠŠ¾Š¼ŠµŃ€ столбца Š“Š»Ń ŠŗŃƒŃ€ŃŠ¾Ń€Š°." - -#: ../src/fenrirscreenreader/commands/commands/cursor_lineno.py:17 -msgid "Line number for cursor" -msgstr "ŠŠ¾Š¼ŠµŃ€ строки Š“Š»Ń ŠŗŃƒŃ€ŃŠ¾Ń€Š°." - -#: ../src/fenrirscreenreader/commands/commands/cursor_position.py:17 -msgid "displays the position of the review cursor" -msgstr "ŠžŃ‚Š¾Š±Ń€Š°Š¶Š°ŠµŃ‚ положение ŠŗŃƒŃ€ŃŠ¾Ń€Š° обзора." - -#: ../src/fenrirscreenreader/commands/commands/cursor_position.py:23 -msgid "line {0}, column {1}, Terminal {2}" -msgstr "Дтрока {0}, столбец {1}, терминал {2}" - -#: -#: ../src/fenrirscreenreader/commands/commands/cursor_read_to_end_of_line.py:18 -msgid "read to end of line, use review cursor if you are in review mode, otherwhise use text cursor" -msgstr "Чтение Го конца строки, ŠøŃŠæŠ¾Š»ŃŒŠ·ŃƒŠ¹Ń‚Šµ ŠŗŃƒŃ€ŃŠ¾Ń€ обзора, если вы Š½Š°Ń…Š¾Š“ŠøŃ‚ŠµŃŃŒ в режиме просмотра, иначе ŠøŃŠæŠ¾Š»ŃŒŠ·ŃƒŠ¹Ń‚е текстовый ŠŗŃƒŃ€ŃŠ¾Ń€." - -#: ../src/fenrirscreenreader/commands/commands/date.py:18 -msgid "presents the date" -msgstr "Š”Š¾Š¾Š±Ń‰ŠøŃ‚ŃŒ Š“Š°Ń‚Ńƒ." - -#: ../src/fenrirscreenreader/commands/commands/dec_alsa_volume.py:24 -msgid "Decrease system volume" -msgstr "Š£Š¼ŠµŠ½ŃŒŃˆŠøŃ‚ŃŒ Š³Ń€Š¾Š¼ŠŗŠ¾ŃŃ‚ŃŒ системы." - -#: ../src/fenrirscreenreader/commands/commands/dec_alsa_volume.py:28 -#: ../src/fenrirscreenreader/commands/commands/inc_alsa_volume.py:28 -msgid "alsaaudio is not installed" -msgstr "alsaaudio не ŃƒŃŃ‚Š°Š½Š¾Š²Š»ŠµŠ½Š¾" - -#: ../src/fenrirscreenreader/commands/commands/dec_alsa_volume.py:36 -#: ../src/fenrirscreenreader/commands/commands/inc_alsa_volume.py:36 -msgid "{0} percent system volume" -msgstr "Š“Ń€Š¾Š¼ŠŗŠ¾ŃŃ‚ŃŒ системы {0} процентов." - -#: ../src/fenrirscreenreader/commands/commands/dec_sound_volume.py:18 -msgid "decrease sound volume" -msgstr "Š£Š¼ŠµŠ½ŃŒŃˆŠøŃ‚ŃŒ Š³Ń€Š¾Š¼ŠŗŠ¾ŃŃ‚ŃŒ звука." - -#: ../src/fenrirscreenreader/commands/commands/dec_sound_volume.py:29 -#: ../src/fenrirscreenreader/commands/commands/inc_sound_volume.py:29 -msgid "{0} percent sound volume" -msgstr "Š“Ń€Š¾Š¼ŠŗŠ¾ŃŃ‚ŃŒ звука {0} процентов." - -#: ../src/fenrirscreenreader/commands/commands/dec_speech_pitch.py:18 -msgid "Decreases the pitch of the speech" -msgstr "Š£Š¼ŠµŠ½ŃŒŃˆŠøŃ‚ŃŒ тон речи." - -#: ../src/fenrirscreenreader/commands/commands/dec_speech_pitch.py:26 -#: ../src/fenrirscreenreader/commands/commands/inc_speech_pitch.py:27 -msgid "{0} percent speech pitch" -msgstr "Тон речи {0} процентов." - -#: ../src/fenrirscreenreader/commands/commands/dec_speech_rate.py:18 -msgid "Decreases the rate of the speech" -msgstr "Š£Š¼ŠµŠ½ŃŒŃˆŠøŃ‚ŃŒ ŃŠŗŠ¾Ń€Š¾ŃŃ‚ŃŒ речи." - -#: ../src/fenrirscreenreader/commands/commands/dec_speech_rate.py:27 -#: ../src/fenrirscreenreader/commands/commands/inc_speech_rate.py:27 -msgid "{0} percent speech rate" -msgstr "Š”ŠŗŠ¾Ń€Š¾ŃŃ‚ŃŒ речи {0} процентов." - -#: ../src/fenrirscreenreader/commands/commands/dec_speech_volume.py:18 -msgid "Decreases the volume of the speech" -msgstr "Š£Š¼ŠµŠ½ŃŒŃˆŠøŃ‚ŃŒ Š³Ń€Š¾Š¼ŠŗŠ¾ŃŃ‚ŃŒ речи." - -#: ../src/fenrirscreenreader/commands/commands/dec_speech_volume.py:27 -#: ../src/fenrirscreenreader/commands/commands/inc_speech_volume.py:27 -msgid "{0} percent speech volume" -msgstr "Š“Ń€Š¾Š¼ŠŗŠ¾ŃŃ‚ŃŒ речи {0} процентов." - -#: ../src/fenrirscreenreader/commands/commands/exit_review.py:17 -#: ../src/fenrirscreenreader/commands/onCursorChange/95000-exit_review_mode.py:17 -msgid "exits review mode" -msgstr "ВыхоГ ŠøŠ· режима просмотра." - -#: ../src/fenrirscreenreader/commands/commands/exit_review.py:21 -msgid "Not in Review Mode" -msgstr "ŠŠµŃ‚ в режиме просмотра." - -#: ../src/fenrirscreenreader/commands/commands/exit_review.py:25 -msgid "Exiting Review Mode" -msgstr "Закрытие режима просмотра." - -#: ../src/fenrirscreenreader/commands/commands/export_clipboard_to_file.py:19 -msgid "export the current fenrir clipboard to a file" -msgstr "Š­ŠŗŃŠæŠ¾Ń€Ń‚ŠøŃ€Š¾Š²Š°Ń‚ŃŒ Ń‚ŠµŠŗŃƒŃ‰ŠøŠ¹ Š±ŃƒŃ„ŠµŃ€ обмена Fenrir в файл." - -#: ../src/fenrirscreenreader/commands/commands/export_clipboard_to_file.py:34 -msgid "clipboard exported to file" -msgstr "Š‘ŃƒŃ„ŠµŃ€ обмена ŃŠŗŃŠæŠ¾Ń€Ń‚ŠøŃ€Š¾Š²Š°Š½ в файл." - -#: ../src/fenrirscreenreader/commands/commands/export_clipboard_to_x.py:20 -msgid "Export current fenrir clipboard to X or GUI clipboard" -msgstr "Экспорт Ń‚ŠµŠŗŃƒŃ‰ŠµŠ³Š¾ Š±ŃƒŃ„ŠµŃ€Š° обмена fenrir в Š±ŃƒŃ„ер обмена X или GUI" - -#: ../src/fenrirscreenreader/commands/commands/first_clipboard.py:17 -msgid "selects the first clipboard" -msgstr "Выбирает первый Š±ŃƒŃ„ер обмена." - -#: ../src/fenrirscreenreader/commands/commands/forward_keypress.py:17 -msgid "sends the following keypress to the terminal or application" -msgstr "ŠžŃ‚ŠæŃ€Š°Š²Š»ŃŠµŃ‚ ŃŠ»ŠµŠ“ŃƒŃŽŃ‰ŠµŠµ нажатие на терминал или приложение." - -#: ../src/fenrirscreenreader/commands/commands/forward_keypress.py:21 -msgid "Forward next keypress" -msgstr "ŠŸŠµŃ€ŠµŃŠ»Š°Ń‚ŃŒ ŃŠ»ŠµŠ“ŃƒŃŽŃ‰ŃƒŃŽ клавишу." - -#: -#: ../src/fenrirscreenreader/commands/commands/import_clipboard_from_file.py:19 -msgid "imports text from clipboard file to the clipboard" -msgstr "ŠøŠ¼ŠæŠ¾Ń€Ń‚ŠøŃ€ŃƒŠµŃ‚ текст ŠøŠ· Š±ŃƒŃ„ŠµŃ€Š° файла в Š±ŃƒŃ„ер обмена" - -#: -#: ../src/fenrirscreenreader/commands/commands/import_clipboard_from_file.py:27 -msgid "File does not exist" -msgstr "Файл не ŃŃƒŃ‰ŠµŃŃ‚Š²ŃƒŠµŃ‚." - -#: ../src/fenrirscreenreader/commands/commands/import_clipboard_from_x.py:21 -msgid "imports the graphical clipboard to Fenrir's clipboard" -msgstr "Š˜Š¼ŠæŠ¾Ń€Ń‚ŠøŃ€ŃƒŠµŃ‚ графический Š±ŃƒŃ„ер обмена в Š±ŃƒŃ„ер обмена Фенрира." - -#: ../src/fenrirscreenreader/commands/commands/inc_alsa_volume.py:24 -msgid "Increase system volume" -msgstr "Увеличение громкости системы." - -#: ../src/fenrirscreenreader/commands/commands/inc_sound_volume.py:18 -msgid "adjusts the volume for in coming sounds" -msgstr "Š ŠµŠ³ŃƒŠ»ŠøŃ€ŃƒŠµŃ‚ Š³Ń€Š¾Š¼ŠŗŠ¾ŃŃ‚ŃŒ Š²Ń…Š¾Š“ŃŃ‰ŠøŃ… звуков." - -#: ../src/fenrirscreenreader/commands/commands/inc_speech_pitch.py:18 -msgid "Increases the pitch of the speech" -msgstr "Увеличение тона речи." - -#: ../src/fenrirscreenreader/commands/commands/inc_speech_rate.py:18 -msgid "Increase the speech rate" -msgstr "Увеличение скорости речи." - -#: ../src/fenrirscreenreader/commands/commands/inc_speech_volume.py:18 -msgid "Increase the speech volume" -msgstr "Увеличение громкости речи." - -#: ../src/fenrirscreenreader/commands/commands/indent_curr_line.py:18 -msgid "Presents the indentation level for the current line" -msgstr "Дообщает ŃƒŃ€Š¾Š²ŠµŠ½ŃŒ Š¾Ń‚ŃŃ‚ŃƒŠæŠ° Š“Š»Ń Ń‚ŠµŠŗŃƒŃ‰ŠµŠ¹ строки." - -#: ../src/fenrirscreenreader/commands/commands/indent_curr_line.py:33 -msgid "indent {0}" -msgstr "ŠžŃ‚ŃŃ‚ŃƒŠæ {0}" - -#: ../src/fenrirscreenreader/commands/commands/last_clipboard.py:17 -msgid "selects the last clipboard" -msgstr "Выбирает послеГний Š±ŃƒŃ„ер обмена." - -#: ../src/fenrirscreenreader/commands/commands/last_incoming.py:17 -msgid "Presents the text which was last received" -msgstr "Š”Š¾Š¾Š±Ń‰Š°Ń‚ŃŒ текст, который был ŠæŠ¾Š»ŃƒŃ‡ŠµŠ½ послеГним." - -#: ../src/fenrirscreenreader/commands/commands/marked_text.py:18 -msgid "Presents the currently selected text that will be copied to the clipboard" -msgstr "Дообщает Ń‚ŠµŠŗŃƒŃ‰ŠøŠ¹ выбранный текст, который Š±ŃƒŠ“ет скопирован в Š±ŃƒŃ„ер обмена." - -#: ../src/fenrirscreenreader/commands/commands/marked_text.py:23 -msgid "please set begin and endmark" -msgstr "ŠŸŠ¾Š¶Š°Š»ŃƒŠ¹ŃŃ‚Š°, ŃƒŃŃ‚Š°Š½Š¾Š²ŠøŃ‚Šµ начало Šø конец." - -#: ../src/fenrirscreenreader/commands/commands/next_clipboard.py:17 -msgid "selects the next clipboard" -msgstr "Выбирает ŃŠ»ŠµŠ“ŃƒŃŽŃ‰ŠøŠ¹ Š±ŃƒŃ„ŠµŃ€ обмена." - -#: ../src/fenrirscreenreader/commands/commands/next_clipboard.py:28 -#: ../src/fenrirscreenreader/commands/commands/prev_clipboard.py:28 -msgid "First clipboard " -msgstr "ŠŸŠµŃ€Š²Ń‹Š¹ Š±ŃƒŃ„ŠµŃ€ обмена." - -#: ../src/fenrirscreenreader/commands/commands/next_clipboard.py:30 -#: ../src/fenrirscreenreader/commands/commands/prev_clipboard.py:30 -msgid "Last clipboard " -msgstr "ПослеГний Š±ŃƒŃ„ер обмена." - -#: ../src/fenrirscreenreader/commands/commands/next_quick_menu_entry.py:17 -#: ../src/fenrirscreenreader/commands/quickMenu/next_quick_menu_entry.py:17 -msgid "get next quick menu entry" -msgstr "ŠŸŠ¾Š»ŃƒŃ‡ŠøŃ‚ŃŒ ŃŠ»ŠµŠ“ŃƒŃŽŃ‰ŠøŠ¹ быстрый ŠæŃƒŠ½ŠŗŃ‚ Š¼ŠµŠ½ŃŽ." - -#: ../src/fenrirscreenreader/commands/commands/next_quick_menu_entry.py:27 -#: ../src/fenrirscreenreader/commands/commands/prev_quick_menu_entry.py:27 -#: ../src/fenrirscreenreader/commands/quickMenu/next_quick_menu_entry.py:27 -#: ../src/fenrirscreenreader/commands/quickMenu/prev_quick_menu_entry.py:27 -msgid "Quick menu not available" -msgstr "Быстрое Š¼ŠµŠ½ŃŽ Š½ŠµŠ“Š¾ŃŃ‚ŃƒŠæŠ½Š¾." - -#: ../src/fenrirscreenreader/commands/commands/next_quick_menu_value.py:17 -#: ../src/fenrirscreenreader/commands/quickMenu/next_quick_menu_value.py:17 -msgid "get next quick menu value" -msgstr "ŠŸŠ¾Š»ŃƒŃ‡ŠøŃ‚ŃŒ ŃŠ»ŠµŠ“ŃƒŃŽŃ‰ŠµŠµ значение быстрого Š¼ŠµŠ½ŃŽ." - -#: ../src/fenrirscreenreader/commands/commands/paste_clipboard.py:19 -msgid "pastes the text from the currently selected clipboard" -msgstr "Š’ŃŃ‚Š°Š²Š»ŃŠµŃ‚ текст ŠøŠ· Ń‚ŠµŠŗŃƒŃ‰ŠµŠ³Š¾ выбранного Š±ŃƒŃ„ера обмена." - -#: ../src/fenrirscreenreader/commands/commands/present_first_line.py:18 -msgid "present first line" -msgstr "Š”Š¾Š¾Š±Ń‰Š°Ń‚ŃŒ ŠæŠµŃ€Š²ŃƒŃŽ ŃŃ‚Ń€Š¾ŠŗŃƒ." - -#: ../src/fenrirscreenreader/commands/commands/present_last_line.py:18 -#: ../src/fenrirscreenreader/commands/commands/review_curr_line.py:18 -msgid "current line" -msgstr "Š¢ŠµŠŗŃƒŃ‰Š°Ń строка." - -#: ../src/fenrirscreenreader/commands/commands/prev_clipboard.py:17 -msgid "selects the previous clipboard" -msgstr "Выбирает ŠæŃ€ŠµŠ“Ń‹Š“ŃƒŃ‰ŠøŠ¹ Š±ŃƒŃ„ŠµŃ€ обмена." - -#: ../src/fenrirscreenreader/commands/commands/prev_quick_menu_entry.py:17 -#: ../src/fenrirscreenreader/commands/quickMenu/prev_quick_menu_entry.py:17 -msgid "get previous quick menu entry" -msgstr "ŠŸŠ¾Š»ŃƒŃ‡ŠøŃ‚ŃŒ ŠæŃ€ŠµŠ“Ń‹Š“ŃƒŃ‰ŠøŠ¹ ŠæŃƒŠ½ŠŗŃ‚ быстрого Š¼ŠµŠ½ŃŽ." - -#: ../src/fenrirscreenreader/commands/commands/prev_quick_menu_value.py:17 -#: ../src/fenrirscreenreader/commands/quickMenu/prev_quick_menu_value.py:17 -msgid "get previous quick menu value" -msgstr "ŠŸŠ¾Š»ŃƒŃ‡ŠøŃ‚ŃŒ ŠæŃ€ŠµŠ“Ń‹Š“ŃƒŃ‰ŠµŠµ значение быстрого Š¼ŠµŠ½ŃŽ." - -#: ../src/fenrirscreenreader/commands/commands/quit_fenrir.py:17 -msgid "exits Fenrir" -msgstr "ВыхоГ ŠøŠ· fenrir." - -#: ../src/fenrirscreenreader/commands/commands/remove_marks.py:17 -msgid "Removes marks from selected text" -msgstr "Š£Š“Š°Š»ŠøŃ‚ŃŒ метки ŠøŠ· выбранного текста." - -#: ../src/fenrirscreenreader/commands/commands/remove_marks.py:21 -msgid "Remove marks" -msgstr "Š£Š“Š°Š»ŠøŃ‚ŃŒ метки." - -#: -#: ../src/fenrirscreenreader/commands/commands/remove_word_from_spell_check.py:27 -msgid "removes the current word from the exceptions dictionary" -msgstr "Š£Š“Š°Š»ŃŠµŃ‚ Ń‚ŠµŠŗŃƒŃ‰ŠµŠµ слова ŠøŠ· ŃŠ»Š¾Š²Š°Ń€Ń ŠøŃŠŗŠ»ŃŽŃ‡ŠµŠ½ŠøŠ¹." - -#: -#: ../src/fenrirscreenreader/commands/commands/remove_word_from_spell_check.py:50 -msgid "{0} is not in the dictionary" -msgstr "{0} нет в словаре" - -#: -#: ../src/fenrirscreenreader/commands/commands/remove_word_from_spell_check.py:53 -msgid "{0} removed" -msgstr "{0} уГалено" - -#: ../src/fenrirscreenreader/commands/commands/review_bottom.py:17 -msgid "Move review to the bottom of the screen" -msgstr "ŠŸŠµŃ€ŠµŠ¼ŠµŃŃ‚ŠøŃ‚ŃŒ просмотр в конец ŃŠŗŃ€Š°Š½Š°." - -#: ../src/fenrirscreenreader/commands/commands/review_bottom.py:21 -msgid "Bottom" -msgstr "ŠšŠ¾Š½ŠµŃ†" - -#: ../src/fenrirscreenreader/commands/commands/review_curr_char.py:18 -msgid "presents the current character." -msgstr "Š”Š¾Š¾Š±Ń‰Š°Ń‚ŃŒ Ń‚ŠµŠŗŃƒŃ‰ŠøŠ¹ символ." - -#: ../src/fenrirscreenreader/commands/commands/review_curr_char_phonetic.py:18 -msgid "set review and phonetically presents the current character" -msgstr "Š£ŃŃ‚Š°Š½Š¾Š²ŠøŃ‚ŃŒ обзор Šø фонетически ŠæŃ€ŠµŠ“ŃŃ‚Š°Š²Š»ŃŃ‚ŃŒ Ń‚ŠµŠŗŃƒŃ‰ŠøŠ¹ символ." - -#: ../src/fenrirscreenreader/commands/commands/review_curr_word.py:18 -msgid "current word." -msgstr "Š¢ŠµŠŗŃƒŃ‰ŠµŠµ слово." - -#: ../src/fenrirscreenreader/commands/commands/review_curr_word.py:32 -#: ../src/fenrirscreenreader/commands/commands/review_curr_word_phonetic.py:36 -#: ../src/fenrirscreenreader/commands/commands/review_down.py:27 -#: ../src/fenrirscreenreader/commands/commands/review_next_char.py:28 -#: ../src/fenrirscreenreader/commands/commands/review_next_char_phonetic.py:30 -#: ../src/fenrirscreenreader/commands/commands/review_next_line.py:34 -#: ../src/fenrirscreenreader/commands/commands/review_next_word.py:34 -#: ../src/fenrirscreenreader/commands/commands/review_next_word_phonetic.py:36 -#: ../src/fenrirscreenreader/commands/commands/review_prev_char.py:31 -#: ../src/fenrirscreenreader/commands/commands/review_prev_char_phonetic.py:30 -#: ../src/fenrirscreenreader/commands/commands/review_prev_line.py:32 -#: ../src/fenrirscreenreader/commands/commands/review_prev_word.py:32 -#: ../src/fenrirscreenreader/commands/commands/review_prev_word_phonetic.py:36 -#: ../src/fenrirscreenreader/commands/commands/review_up.py:27 -msgid "end of screen" -msgstr "ŠšŠ¾Š½ŠµŃ† ŃŠŗŃ€Š°Š½Š°." - -#: ../src/fenrirscreenreader/commands/commands/review_curr_word.py:35 -#: ../src/fenrirscreenreader/commands/commands/review_curr_word_phonetic.py:39 -#: ../src/fenrirscreenreader/commands/commands/review_next_char.py:31 -#: ../src/fenrirscreenreader/commands/commands/review_next_char_phonetic.py:33 -#: ../src/fenrirscreenreader/commands/commands/review_next_word.py:37 -#: ../src/fenrirscreenreader/commands/commands/review_next_word_phonetic.py:39 -#: ../src/fenrirscreenreader/commands/commands/review_prev_char.py:34 -#: ../src/fenrirscreenreader/commands/commands/review_prev_char_phonetic.py:33 -#: ../src/fenrirscreenreader/commands/commands/review_prev_word.py:35 -#: ../src/fenrirscreenreader/commands/commands/review_prev_word_phonetic.py:39 -#: ../src/fenrirscreenreader/commands/commands/review_up.py:30 -msgid "line break" -msgstr "Разрыв строки" - -#: ../src/fenrirscreenreader/commands/commands/review_curr_word_phonetic.py:19 -msgid "Phonetically spells the current word" -msgstr "ŠŸŃ€ŠµŠ“ŃŃ‚Š°Š²Š»ŃŠµŃ‚ фонетически Ń‚ŠµŠŗŃƒŃ‰ŠµŠµ слово" - -#: ../src/fenrirscreenreader/commands/commands/review_down.py:18 -msgid "Move review to the character below the current position" -msgstr "ŠŸŠµŃ€ŠµŠ¼ŠµŃŃ‚ŠøŃ‚ŃŒ обзор на символ ниже Ń‚ŠµŠŗŃƒŃ‰ŠµŠ¹ позиции." - -#: ../src/fenrirscreenreader/commands/commands/review_line_begin.py:18 -msgid "set review cursor to begin of current line and display the content" -msgstr "Š£ŃŃ‚Š°Š½Š¾Š²ŠøŃ‚ŃŒ ŠŗŃƒŃ€ŃŠ¾Ń€ просмотра в начало Ń‚ŠµŠŗŃƒŃ‰ŠµŠ¹ строки Šø Š¾Ń‚Š¾Š±Ń€Š°Š·ŠøŃ‚ŃŒ соГержимое." - -#: ../src/fenrirscreenreader/commands/commands/review_line_begin.py:30 -msgid "beginning of line" -msgstr "ŠŠ°Ń‡Š°Š»Š¾ строки" - -#: ../src/fenrirscreenreader/commands/commands/review_line_end.py:18 -msgid "Move Review to the end of current line and display the content" -msgstr "Š£ŃŃ‚Š°Š½Š¾Š²ŠøŃ‚ŃŒ ŠŗŃƒŃ€ŃŠ¾Ń€ просмотра в конец Ń‚ŠµŠŗŃƒŃ‰ŠµŠ¹ строки Šø Š¾Ń‚Š¾Š±Ń€Š°Š·ŠøŃ‚ŃŒ соГержимое." - -#: ../src/fenrirscreenreader/commands/commands/review_line_end.py:27 -msgid "end of line" -msgstr "ŠšŠ¾Š½ŠµŃ† строки" - -#: ../src/fenrirscreenreader/commands/commands/review_line_first_char.py:19 -msgid "Move Review to the first character on the line" -msgstr "ŠŸŠµŃ€ŠµŠ¼ŠµŃŃ‚ŠøŃ‚ŃŒ ŠŗŃƒŃ€ŃŠ¾Ń€ просмотра Šŗ ŠæŠµŃ€Š²Š¾Š¼Ńƒ символу строки" - -#: ../src/fenrirscreenreader/commands/commands/review_line_first_char.py:26 -msgid "line is empty" -msgstr "Дтрока ŠæŃƒŃŃ‚Š°Ń" - -#: ../src/fenrirscreenreader/commands/commands/review_line_first_char.py:33 -msgid "first character in line indent {0}" -msgstr "ŠŸŠµŃ€Š²Ń‹Š¹ символ в строке {0}" - -#: ../src/fenrirscreenreader/commands/commands/review_line_last_char.py:18 -msgid "Move Review to the last character on the line" -msgstr "ŠŸŠµŃ€ŠµŠ¼ŠµŃŃ‚ŠøŃ‚ŃŒ ŠŗŃƒŃ€ŃŠ¾Ń€ Šŗ послеГнему символу на строке" - -#: ../src/fenrirscreenreader/commands/commands/review_line_last_char.py:27 -msgid "last character in line" -msgstr "ПослеГний символ в строке" - -#: ../src/fenrirscreenreader/commands/commands/review_next_char.py:18 -msgid "Moves review to the next character " -msgstr "ŠŸŠµŃ€ŠµŠ¼ŠµŃŃ‚ŠøŃ‚ŃŒ ŠŗŃƒŃ€ŃŠ¾Ń€ просмотра Šŗ ŃŠ»ŠµŠ“ŃƒŃŽŃ‰ŠµŠ¼Ńƒ символу " - -#: ../src/fenrirscreenreader/commands/commands/review_next_char_phonetic.py:18 -msgid "phonetically presents the next character and set review to it" -msgstr "Фонетически ŠæŃ€ŠµŠ“ŃŃ‚Š°Š²Š»ŃŠµŃ‚ ŃŠ»ŠµŠ“ŃƒŃŽŃ‰ŠøŠ¹ символ Šø ŃƒŃŃ‚Š°Š½Š°Š²Š»ŠøŠ²Š°ŠµŃ‚ ŠŗŃƒŃ€ŃŠ¾Ń€ обзора Šŗ нему." - -#: ../src/fenrirscreenreader/commands/commands/review_next_line.py:18 -msgid "moves review to the next line " -msgstr "ŠŸŠµŃ€ŠµŠ¼ŠµŃŃ‚ŠøŃ‚ŃŒ ŠŗŃƒŃ€ŃŠ¾Ń€ просмотра Šŗ ŃŠ»ŠµŠ“ŃƒŃŽŃ‰ŠµŠ¹ строке" - -#: ../src/fenrirscreenreader/commands/commands/review_next_word.py:18 -msgid "moves review to the next word " -msgstr "ŠŸŠµŃ€ŠµŠ¼ŠµŃŃ‚ŠøŃ‚ŃŒ ŠŗŃƒŃ€ŃŠ¾Ń€ просмотра Šŗ ŃŠ»ŠµŠ“ŃƒŃŽŃ‰ŠµŠ¼Ńƒ слову" - -#: ../src/fenrirscreenreader/commands/commands/review_next_word_phonetic.py:19 -msgid "Phonetically spells the next word and moves review to it" -msgstr "Фонетически ŠæŃ€ŠµŠ“ŃŃ‚Š°Š²Š»ŃŠµŃ‚ ŃŠ»ŠµŠ“ŃƒŃŽŃ‰ŠµŠµ слово Šø перемещает ŠŗŃƒŃ€ŃŠ¾Ń€ обзора Šŗ нему." - -#: ../src/fenrirscreenreader/commands/commands/review_prev_char.py:18 -msgid "moves review to the previous character " -msgstr "ŠŸŠµŃ€ŠµŠ¼ŠµŃŃ‚ŠøŃ‚ŃŒ ŠŗŃƒŃ€ŃŠ¾Ń€ просмотра Šŗ ŠæŃ€ŠµŠ“Ń‹Š“ŃƒŃ‰ŠµŠ¼Ńƒ символу" - -#: ../src/fenrirscreenreader/commands/commands/review_prev_char_phonetic.py:18 -msgid "phonetically presents the previous character and set review to it" -msgstr "Фонетически ŠæŃ€ŠµŠ“ŃŃ‚Š°Š²Š»ŃŠµŃ‚ ŠæŃ€ŠµŠ“Ń‹Š“ŃƒŃ‰ŠøŠ¹ символ Šø ŃƒŃŃ‚Š°Š½Š°Š²Š»ŠøŠ²Š°ŠµŃ‚ ŠŗŃƒŃ€ŃŠ¾Ń€ обзора Šŗ нему." - -#: ../src/fenrirscreenreader/commands/commands/review_prev_line.py:18 -msgid "moves review to the previous line " -msgstr "ŠŸŠµŃ€ŠµŠ¼ŠµŃŃ‚ŠøŃ‚ŃŒ ŠŗŃƒŃ€ŃŠ¾Ń€ просмотра Šŗ ŠæŃ€ŠµŠ“Ń‹Š“ŃƒŃ‰ŠµŠ¹ строке" - -#: ../src/fenrirscreenreader/commands/commands/review_prev_word.py:18 -msgid "moves review focus to the previous word " -msgstr "ŠŸŠµŃ€ŠµŠ¼ŠµŃŃ‚ŠøŃ‚ŃŒ ŠŗŃƒŃ€ŃŠ¾Ń€ просмотра Šŗ ŠæŃ€ŠµŠ“Ń‹Š“ŃƒŃ‰ŠµŠ¼Ńƒ слову" - -#: ../src/fenrirscreenreader/commands/commands/review_prev_word_phonetic.py:19 -msgid "Phonetically spells the previous word and moves review to it" -msgstr "Фонетически ŠæŃ€ŠµŠ“ŃŃ‚Š°Š²Š»ŃŠµŃ‚ ŠæŃ€ŠµŠ“Ń‹Š“ŃƒŃ‰ŠµŠµ слово Šø перемещает ŠŗŃƒŃ€ŃŠ¾Ń€ обзора Šŗ нему." - -#: ../src/fenrirscreenreader/commands/commands/review_top.py:18 -msgid "move review to top of screen" -msgstr "ŠŸŠµŃ€ŠµŠ¼ŠµŃŃ‚ŠøŃ‚ŃŒ ŠŗŃƒŃ€ŃŠ¾Ń€ просмотра в начало ŃŠŗŃ€Š°Š½Š°" - -#: ../src/fenrirscreenreader/commands/commands/review_top.py:22 -msgid "Top" -msgstr "ŠŠ°Ń‡Š°Š»Š¾" - -#: ../src/fenrirscreenreader/commands/commands/review_up.py:18 -msgid "Move review to the character in the line above the current position" -msgstr "ŠŸŠµŃ€ŠµŠ¼ŠµŃŃ‚ŠøŃ‚ŃŒ ŠŗŃƒŃ€ŃŠ¾Ń€ обзора на символ в строке Š²Ń‹ŃˆŠµ Ń‚ŠµŠŗŃƒŃ‰ŠµŠ¹ позиции." - -#: ../src/fenrirscreenreader/commands/commands/save_settings.py:18 -msgid "Saves your current Fenrir settings so they are the default." -msgstr "Š”Š¾Ń…Ń€Š°Š½ŠøŃ‚ŃŒ Ń‚ŠµŠŗŃƒŃ‰ŠøŠµ настройки fenrir как настройки по ŃƒŠ¼Š¾Š»Ń‡Š°Š½ŠøŃŽ." - -#: ../src/fenrirscreenreader/commands/commands/save_settings.py:22 -msgid "Settings saved." -msgstr "ŠŠ°ŃŃ‚Ń€Š¾Š¹ŠŗŠø сохранены" - -#: ../src/fenrirscreenreader/commands/commands/set_bookmark_1.py:18 -#: ../src/fenrirscreenreader/commands/commands/set_bookmark_10.py:18 -#: ../src/fenrirscreenreader/commands/commands/set_bookmark_2.py:18 -#: ../src/fenrirscreenreader/commands/commands/set_bookmark_3.py:18 -#: ../src/fenrirscreenreader/commands/commands/set_bookmark_4.py:18 -#: ../src/fenrirscreenreader/commands/commands/set_bookmark_5.py:18 -#: ../src/fenrirscreenreader/commands/commands/set_bookmark_6.py:18 -#: ../src/fenrirscreenreader/commands/commands/set_bookmark_7.py:18 -#: ../src/fenrirscreenreader/commands/commands/set_bookmark_8.py:18 -#: ../src/fenrirscreenreader/commands/commands/set_bookmark_9.py:18 -msgid "set Bookmark {0}" -msgstr "Š£ŃŃ‚Š°Š½Š¾Š²ŠøŃ‚ŃŒ заклаГку {0}" - -#: ../src/fenrirscreenreader/commands/commands/set_bookmark_1.py:22 -#: ../src/fenrirscreenreader/commands/commands/set_bookmark_10.py:22 -#: ../src/fenrirscreenreader/commands/commands/set_bookmark_2.py:22 -#: ../src/fenrirscreenreader/commands/commands/set_bookmark_3.py:22 -#: ../src/fenrirscreenreader/commands/commands/set_bookmark_4.py:22 -#: ../src/fenrirscreenreader/commands/commands/set_bookmark_5.py:22 -#: ../src/fenrirscreenreader/commands/commands/set_bookmark_6.py:22 -#: ../src/fenrirscreenreader/commands/commands/set_bookmark_7.py:22 -#: ../src/fenrirscreenreader/commands/commands/set_bookmark_8.py:22 -#: ../src/fenrirscreenreader/commands/commands/set_bookmark_9.py:22 -msgid "No mark found" -msgstr "ŠœŠµŃ‚ŠŗŠ° не найГена." - -#: ../src/fenrirscreenreader/commands/commands/set_bookmark_1.py:32 -#: ../src/fenrirscreenreader/commands/commands/set_bookmark_10.py:32 -#: ../src/fenrirscreenreader/commands/commands/set_bookmark_2.py:32 -#: ../src/fenrirscreenreader/commands/commands/set_bookmark_3.py:32 -#: ../src/fenrirscreenreader/commands/commands/set_bookmark_4.py:32 -#: ../src/fenrirscreenreader/commands/commands/set_bookmark_5.py:32 -#: ../src/fenrirscreenreader/commands/commands/set_bookmark_6.py:32 -#: ../src/fenrirscreenreader/commands/commands/set_bookmark_7.py:32 -#: ../src/fenrirscreenreader/commands/commands/set_bookmark_8.py:32 -#: ../src/fenrirscreenreader/commands/commands/set_bookmark_9.py:32 -msgid "Bookmark {0} set for application {1}" -msgstr "ЗаклаГка {0} ŃƒŃŃ‚Š°Š½Š¾Š²Š»ŠµŠ½Š° Š“Š»Ń ŠæŃ€ŠøŠ»Š¾Š¶ŠµŠ½ŠøŃ {1}" - -#: ../src/fenrirscreenreader/commands/commands/set_mark.py:17 -msgid "places marks to select text to copy to the clipboard" -msgstr "Дтавит метки Š“Š»Ń выбора текста Š“Š»Ń ŠŗŠ¾ŠæŠøŃ€Š¾Š²Š°Š½ŠøŃ в Š±ŃƒŃ„ер обмена." - -#: ../src/fenrirscreenreader/commands/commands/set_mark.py:21 -msgid "no review cursor" -msgstr "ŠŠµŃ‚ ŠŗŃƒŃ€ŃŠ¾Ń€Š° просмотра" - -#: ../src/fenrirscreenreader/commands/commands/set_mark.py:26 -#: ../src/fenrirscreenreader/commands/commands/set_mark.py:28 -msgid "set mark" -msgstr "Установка метки." - -#: ../src/fenrirscreenreader/commands/commands/set_window_application.py:17 -msgid "set Window Mode, needs 2 marks " -msgstr "Установка режима окна Š½ŃƒŠ¶Š“Š°ŠµŃ‚ŃŃ в Š“Š²ŃƒŃ… метках" - -#: ../src/fenrirscreenreader/commands/commands/set_window_application.py:22 -msgid "Window Mode on for application {0}" -msgstr "Режим окна Š“Š»Ń ŠæŃ€ŠøŠ»Š¾Š¶ŠµŠ½ŠøŃ {0}" - -#: ../src/fenrirscreenreader/commands/commands/set_window_application.py:25 -msgid "Set window begin and end marks" -msgstr "Установка начала Šø конца окна" - -#: ../src/fenrirscreenreader/commands/commands/shut_up.py:17 -msgid "Interrupts the current presentation" -msgstr "ŠŸŃ€ŠµŃ€Ń‹Š²Š°Ń‚ŃŒ Ń‚ŠµŠŗŃƒŃ‰ŃƒŃŽ Ń€ŠµŃ‡ŃŒ." - -#: ../src/fenrirscreenreader/commands/commands/spell_check.py:26 -msgid "checks the spelling of the current word" -msgstr "ŠŸŃ€Š¾Š²ŠµŃ€ŃŠµŃ‚ написание Ń‚ŠµŠŗŃƒŃ‰ŠµŠ³Š¾ слова." - -#: ../src/fenrirscreenreader/commands/commands/spell_check.py:51 -#: ../src/fenrirscreenreader/commands/onCursorChange/35000-spell_check.py:129 -msgid "misspelled" -msgstr "ŠžŃ€Ń„Š¾Š³Ń€Š°Ń„ŠøŃ‡ŠµŃŠŗŠ°Ń ошибка" - -#: ../src/fenrirscreenreader/commands/commands/spell_check.py:53 -msgid "correct" -msgstr "ŠŸŃ€Š°Š²ŠøŠ»ŃŒŠ½Š¾" - -#: ../src/fenrirscreenreader/commands/commands/subprocess.py:21 -msgid "script: {0} fullpath: {1}" -msgstr "Дкрипт; {0}, ŠæŃƒŃ‚ŃŒ {1}" - -#: ../src/fenrirscreenreader/commands/commands/subprocess.py:24 -msgid "Script file not found" -msgstr "Дкрипт не найГен" - -#: ../src/fenrirscreenreader/commands/commands/subprocess.py:27 -msgid "Script source is not a valid file" -msgstr "Š˜ŃŃ…Š¾Š“Š½ŠøŠŗ скрипта Š½ŠµŠæŃ€Š°Š²ŠøŠ»ŃŒŠ½Ń‹Š¹ файл" - -#: ../src/fenrirscreenreader/commands/commands/subprocess.py:30 -msgid "Script file is not executable" -msgstr "Файл скрипта не ŠøŃŠæŠ¾Š»Š½ŃŠµŠ¼Ń‹Š¹" - -#: ../src/fenrirscreenreader/commands/commands/temp_disable_speech.py:17 -#: ../src/fenrirscreenreader/commands/onKeyInput/15000-enable_temp_speech.py:17 -msgid "disables speech until next keypress" -msgstr "ŠžŃ‚ŠŗŠ»ŃŽŃ‡ŠøŃ‚ŃŒ Ń€ŠµŃ‡ŃŒ пока не нажата ŃŠ»ŠµŠ“ŃƒŃŽŃ‰Š°Ń клавиша" - -#: ../src/fenrirscreenreader/commands/commands/time.py:18 -msgid "presents the time" -msgstr "Š”Š¾Š¾Š±Ń‰ŠøŃ‚ŃŒ Š²Ń€ŠµŠ¼Ń" - -#: ../src/fenrirscreenreader/commands/commands/toggle_auto_indent.py:16 -msgid "enables or disables automatic reading of indentation level changes" -msgstr "Š²ŠŗŠ»ŃŽŃ‡ŠøŃ‚ŃŒ или Š¾Ń‚ŠŗŠ»ŃŽŃ‡ŠøŃ‚ŃŒ чтение ŠøŠ·Š¼ŠµŠ½ŠµŠ½ŠøŃ ŃƒŃ€Š¾Š²Š½Ń Š¾Ń‚ŃŃ‚ŃƒŠæŠ¾Š²." - -#: ../src/fenrirscreenreader/commands/commands/toggle_auto_indent.py:21 -msgid "autoindent enabled" -msgstr "чтение Š¾Ń‚ŃŃ‚ŃƒŠæŠ¾Š² Š²ŠŗŠ»ŃŽŃ‡ŠµŠ½Š¾" - -#: ../src/fenrirscreenreader/commands/commands/toggle_auto_indent.py:23 -msgid "autoindent disabled" -msgstr "чтение Š¾Ń‚ŃŃ‚ŃƒŠæŠ¾Š² Š¾Ń‚ŠŗŠ»ŃŽŃ‡ŠµŠ½Š¾" - -#: ../src/fenrirscreenreader/commands/commands/toggle_auto_read.py:16 -msgid "enables or disables automatic reading of new text as it appears" -msgstr "Š²ŠŗŠ»ŃŽŃ‡ŠøŃ‚ŃŒ или Š¾Ń‚ŠŗŠ»ŃŽŃ‡ŠøŃ‚ŃŒ Ń‡Ń‚ŠµŠ½ŠøŃ нового текста" - -#: ../src/fenrirscreenreader/commands/commands/toggle_auto_read.py:21 -msgid "autoread enabled" -msgstr "Автоматическое чтение нового текста Š²ŠŗŠ»ŃŽŃ‡ŠµŠ½Š¾" - -#: ../src/fenrirscreenreader/commands/commands/toggle_auto_read.py:23 -msgid "autoread disabled" -msgstr "Автоматическое чтение нового текста Š²Ń‹ŠŗŠ»ŃŽŃ‡ŠµŠ½Š¾" - -#: ../src/fenrirscreenreader/commands/commands/toggle_auto_spell_check.py:17 -msgid "enables or disables automatic spell checking" -msgstr "Š’ŠŗŠ»ŃŽŃ‡ŠøŃ‚ŃŒ или Š¾Ń‚ŠŗŠ»ŃŽŃ‡ŠøŃ‚ŃŒ ŠæŃ€Š¾Š²ŠµŃ€ŠŗŃƒ орфографических ошибок." - -#: ../src/fenrirscreenreader/commands/commands/toggle_auto_spell_check.py:22 -msgid "auto spellcheck enabled" -msgstr "ŠŸŃ€Š¾Š²ŠµŃ€ŠŗŠ° орфографических ошибок Š²ŠŗŠ»ŃŽŃ‡ŠµŠ½Š°" - -#: ../src/fenrirscreenreader/commands/commands/toggle_auto_spell_check.py:24 -msgid "auto spellcheck disabled" -msgstr "ŠŸŃ€Š¾Š²ŠµŃ€ŠŗŠ° орфографических ошибок Š²Ń‹ŠŗŠ»ŃŽŃ‡ŠµŠ½Š°" - -#: ../src/fenrirscreenreader/commands/commands/toggle_auto_time.py:16 -msgid "Enables or disables automatic reading of time after specified intervals" -msgstr "Š’ŠŗŠ»ŃŽŃ‡ŠµŠ½ŠøŠµ или Š²Ń‹ŠŗŠ»ŃŽŃ‡ŠµŠ½ŠøŠµ автоматического сообщение времени в указанном интервале." - -#: ../src/fenrirscreenreader/commands/commands/toggle_auto_time.py:21 -msgid "Automatic time announcement enabled" -msgstr "Автоматическое сообщение времени Š²ŠŗŠ»ŃŽŃ‡ŠµŠ½Š¾" - -#: ../src/fenrirscreenreader/commands/commands/toggle_auto_time.py:23 -msgid "Automatic time announcement disabled" -msgstr "Автоматическое сообщение времени Š¾Ń‚ŠŗŠ»ŃŽŃ‡ŠµŠ½Š¾" - -#: ../src/fenrirscreenreader/commands/commands/toggle_barrier.py:16 -msgid "enables or disables the barrier mode" -msgstr "Š’ŠŗŠ»ŃŽŃ‡ŠµŠ½ŠøŠµ или Š¾Ń‚ŠŗŠ»ŃŽŃ‡ŠµŠ½ŠøŠµ режима Š±Š°Ń€ŃŒŠµŃ€Š°" - -#: ../src/fenrirscreenreader/commands/commands/toggle_barrier.py:21 -msgid "barrier mode enabled" -msgstr "Режим Š±Š°Ń€ŃŒŠµŃ€Š° Š²ŠŗŠ»ŃŽŃ‡ŠµŠ½" - -#: ../src/fenrirscreenreader/commands/commands/toggle_barrier.py:23 -msgid "barrier mode disabled" -msgstr "Режим Š±Š°Ń€ŃŒŠµŃ€Š° Š²Ń‹ŠŗŠ»ŃŽŃ‡ŠµŠ½" - -#: ../src/fenrirscreenreader/commands/commands/toggle_braille.py:17 -msgid "Enables and disables Braille output" -msgstr "Š’ŠŗŠ»ŃŽŃ‡ŠµŠ½ŠøŠµ Šø Š¾Ń‚ŠŗŠ»ŃŽŃ‡ŠµŠ½ŠøŠµ ŃƒŃŃ‚Ń€Š¾Š¹ŃŃ‚Š²Š° Š‘Ń€Š°Š¹Š»Ń." - -#: ../src/fenrirscreenreader/commands/commands/toggle_braille.py:21 -msgid "braille disabled" -msgstr "Š‘Ń€Š°ŠøŠ»ŃŒ Š²Ń‹ŠŗŠ»ŃŽŃ‡ŠµŠ½" - -#: ../src/fenrirscreenreader/commands/commands/toggle_braille.py:24 -msgid "braille enabled" -msgstr "Š‘Ń€Š°ŠøŠ»ŃŒ Š²ŠŗŠ»ŃŽŃ‡ŠµŠ½" - -#: ../src/fenrirscreenreader/commands/commands/toggle_emoticons.py:16 -msgid "enables or disables announcement of emoticons instead of chars" -msgstr "Š’ŠŗŠ»ŃŽŃ‡ŠµŠ½ŠøŠµ или Š¾Ń‚ŠŗŠ»ŃŽŃ‡ŠµŠ½ŠøŠµ смайлов вместо символов." - -#: ../src/fenrirscreenreader/commands/commands/toggle_emoticons.py:21 -msgid "emoticons enabled" -msgstr "Дмайлы Š²ŠŗŠ»ŃŽŃ‡ŠµŠ½Ń‹" - -#: ../src/fenrirscreenreader/commands/commands/toggle_emoticons.py:23 -msgid "emoticons disabled" -msgstr "Дмайлы Š²Ń‹ŠŗŠ»ŃŽŃ‡ŠµŠ½Ń‹" - -#: ../src/fenrirscreenreader/commands/commands/toggle_has_attribute.py:16 -msgid "enables or disables the announcement of attributes" -msgstr "Š’ŠŗŠ»ŃŽŃ‡ŠµŠ½ŠøŠµ или Š¾Ń‚ŠŗŠ»ŃŽŃ‡ŠµŠ½ŠøŠµ Ń‡Ń‚ŠµŠ½ŠøŃ Š°Ń‚Ń€ŠøŠ±ŃƒŃ‚Š¾Š²" - -#: ../src/fenrirscreenreader/commands/commands/toggle_has_attribute.py:21 -msgid "announcement of attributes enabled" -msgstr "Чтение Š°Ń‚Ń€ŠøŠ±ŃƒŃ‚Š¾Š² Š²ŠŗŠ»ŃŽŃ‡ŠµŠ½Š¾" - -#: ../src/fenrirscreenreader/commands/commands/toggle_has_attribute.py:23 -msgid "announcement of attributes disabled" -msgstr "Чтение Š°Ń‚Ń€ŠøŠ±ŃƒŃ‚Š¾Š² Š²Ń‹ŠŗŠ»ŃŽŃ‡ŠµŠ½Š¾" - -#: ../src/fenrirscreenreader/commands/commands/toggle_highlight_tracking.py:16 -msgid "enables or disables tracking of highlighted text" -msgstr "Š’ŠŗŠ»ŃŽŃ‡Š°ŠµŃ‚ или Š¾Ń‚ŠŗŠ»ŃŽŃ‡Š°ŠµŃ‚ отслеживание выГеленного текста" - -#: ../src/fenrirscreenreader/commands/commands/toggle_highlight_tracking.py:24 -msgid "highlight tracking" -msgstr "ŠžŃ‚ŃŠ»ŠµŠ¶ŠøŠ²Š°Š½ŠøŠµ Š²Ń‹Š“ŠµŠ»ŠµŠ½ŠøŃ" - -#: ../src/fenrirscreenreader/commands/commands/toggle_highlight_tracking.py:26 -msgid "cursor tracking" -msgstr "ŠžŃ‚ŃŠ»ŠµŠ¶ŠøŠ²Š°Š½ŠøŠµ ŠŗŃƒŃ€ŃŠ¾Ń€Š°" - -#: ../src/fenrirscreenreader/commands/commands/toggle_output.py:17 -msgid "toggles all output settings" -msgstr "ŠŸŠµŃ€ŠµŠŗŠ»ŃŽŃ‡Š°ŠµŃ‚ все настройки вывоГа" - -#: ../src/fenrirscreenreader/commands/commands/toggle_output.py:23 -msgid "Fenrir muted" -msgstr "Š ŠµŃ‡ŃŒ fenrir Š¾Ń‚ŠŗŠ»ŃŽŃ‡ŠµŠ½Š°" - -#: ../src/fenrirscreenreader/commands/commands/toggle_output.py:31 -msgid "Fenrir unmuted" -msgstr "Š ŠµŃ‡ŃŒ fenrir Š²ŠŗŠ»ŃŽŃ‡ŠµŠ½Š°" - -#: ../src/fenrirscreenreader/commands/commands/toggle_punctuation_level.py:23 -msgid "No punctuation found." -msgstr "ŠŸŃƒŠ½ŠŗŃ‚ŃƒŠ°Ń†ŠøŃ не найГена" - -#: ../src/fenrirscreenreader/commands/commands/toggle_sound.py:17 -msgid "enables or disables sound" -msgstr "Š’ŠŗŠ»ŃŽŃ‡ŠµŠ½ŠøŠµ или Š¾Ń‚ŠŗŠ»ŃŽŃ‡ŠµŠ½ŠøŠµ Š·Š²ŃƒŠŗŠ¾Š²Ń‹Ń… ŃŃ„Ń„ŠµŠŗŃ‚Š¾Š²" - -#: ../src/fenrirscreenreader/commands/commands/toggle_sound.py:21 -msgid "sound disabled" -msgstr "Š—Š²ŃƒŠŗŠ¾Š²Ń‹Šµ ŃŃ„Ń„ŠµŠŗŃ‚Ń‹ Š²Ń‹ŠŗŠ»ŃŽŃ‡ŠµŠ½Ń‹" - -#: ../src/fenrirscreenreader/commands/commands/toggle_sound.py:24 -msgid "sound enabled" -msgstr "Š—Š²ŃƒŠŗŠ¾Š²Ń‹Šµ ŃŃ„Ń„ŠµŠŗŃ‚Ń‹ Š²ŠŗŠ»ŃŽŃ‡ŠµŠ½Ń‹" - -#: ../src/fenrirscreenreader/commands/commands/toggle_speech.py:17 -msgid "enables or disables speech" -msgstr "Š’ŠŗŠ»ŃŽŃ‡ŠµŠ½ŠøŠµ или Š¾Ń‚ŠŗŠ»ŃŽŃ‡ŠµŠ½ŠøŠµ речи" - -#: ../src/fenrirscreenreader/commands/commands/toggle_speech.py:22 -msgid "speech disabled" -msgstr "Š ŠµŃ‡ŃŒ Š²Ń‹ŠŗŠ»ŃŽŃ‡ŠµŠ½Š°" - -#: ../src/fenrirscreenreader/commands/commands/toggle_speech.py:25 -#: ../src/fenrirscreenreader/commands/onKeyInput/15000-enable_temp_speech.py:28 -msgid "speech enabled" -msgstr "Š ŠµŃ‡ŃŒ Š²ŠŗŠ»ŃŽŃ‡ŠµŠ½Š°" - -#: ../src/fenrirscreenreader/commands/commands/toggle_tutorial_mode.py:18 -msgid "Exiting tutorial mode. To enter tutorial mode again press Fenrir+f1" -msgstr "Режим Š¾Š±ŃƒŃ‡ŠµŠ½ŠøŃ закрыт. Чтобы снова войти в режим Š¾Š±ŃƒŃ‡ŠµŠ½ŠøŃ нажмите fenrir+f1" - -#: ../src/fenrirscreenreader/commands/commands/toggle_tutorial_mode.py:22 -msgid "Entering tutorial mode. In this mode commands are described but not executed. You can move through the list of commands with the up and down arrow keys. To Exit tutorial mode press Fenrir+f1." -msgstr "Š’ŠŗŠ»ŃŽŃ‡ŠµŠ½ режим Š¾Š±ŃƒŃ‡ŠµŠ½ŠøŃ. Š’ ŃŃ‚Š¾Š¼ режиме можно Š¾Š·Š½Š°ŠŗŠ¾Š¼ŠøŃ‚ŃŒŃŃ с описанием команГ сами команГы не Š²Ń‹ŠæŠ¾Š»Š½ŃŃŽŃ‚ся. ŠŸŠµŃ€ŠµŠ¼ŠµŃ‰Š°Ń‚ŃŒŃŃ по списку можно с ŠæŠ¾Š¼Š¾Ń‰ŃŒŃŽ клавиш стрелок вверх, вниз. Š”Š»Ń Š·Š°ŠŗŃ€Ń‹Ń‚ŠøŃ нажмите fenrir+f1" - -#: ../src/fenrirscreenreader/commands/commands/toggle_vmenu_mode.py:18 -msgid "Entering or Leaving v menu mode." -msgstr "вхоГ Šø выхоГ ŠøŠ· v Š¼ŠµŠ½ŃŽ" - -#: ../src/fenrirscreenreader/commands/commands/toggle_vmenu_mode.py:22 -msgid "Entering v menu." -msgstr "вхоГ в v Š¼ŠµŠ½ŃŽ" - -#: ../src/fenrirscreenreader/commands/commands/toggle_vmenu_mode.py:24 -msgid "Leaving v menu." -msgstr "Š²Ń‹ŃŠŃ…Š¾Š“ ŠøŠ· v Š¼ŠµŠ½ŃŽ" - -#: ../src/fenrirscreenreader/commands/help/curr_help.py:17 -msgid "get current help message" -msgstr "ŠŸŠ¾Š»ŃƒŃ‡ŠøŃ‚ŃŒ Ń‚ŠµŠŗŃƒŃ‰ŠµŠµ сообщение поГсказки" - -#: ../src/fenrirscreenreader/commands/help/next_help.py:17 -msgid "get next help message" -msgstr "ŠŸŠ¾Š»ŃƒŃ‡ŠøŃ‚ŃŒ ŃŠ»ŠµŠ“ŃƒŃŽŃ‰ŠµŠµ сообщение поГсказки" - -#: ../src/fenrirscreenreader/commands/help/prev_help.py:17 -msgid "get prev help message" -msgstr "ŠŸŠ¾Š»ŃƒŃ‡ŠøŃ‚ŃŒ ŠæŃ€ŠµŠ“Ń‹Š“ŃƒŃ‰ŠµŠµ сообщение поГсказки" - -#: -#: ../src/fenrirscreenreader/commands/onCursorChange/65000-present_line_if_cursor_change_vertical.py:46 -msgid "indented " -msgstr "" - -#: ../src/fenrirscreenreader/commands/onHeartBeat/76000-time.py:66 -msgid "Autotime: {0}" -msgstr "Š¾Š±ŃŠŃŠ²Š»ŠµŠ½ŠøŠµ времени {0}" - -#: ../src/fenrirscreenreader/commands/onKeyInput/80000-capslock.py:22 -msgid "Capslock on" -msgstr "Capslock Š²ŠŗŠ»ŃŽŃ‡ŠµŠ½" - -#: ../src/fenrirscreenreader/commands/onKeyInput/80000-capslock.py:24 -msgid "Capslock off" -msgstr "Capslock Š²Ń‹ŠŗŠ»ŃŽŃ‡ŠµŠ½" - -#: ../src/fenrirscreenreader/commands/onKeyInput/80300-scrolllock.py:22 -msgid "Scrolllock on" -msgstr "Scrolllock Š²ŠŗŠ»ŃŽŃ‡ŠµŠ½" - -#: ../src/fenrirscreenreader/commands/onKeyInput/80300-scrolllock.py:24 -msgid "Scrolllock off" -msgstr "Scrolllock Š²Ń‹ŠŗŠ»ŃŽŃ‡ŠµŠ½" - -#: ../src/fenrirscreenreader/commands/onKeyInput/80500-numlock.py:22 -msgid "Numlock on" -msgstr "Numlock Š²ŠŗŠ»ŃŽŃ‡ŠµŠ½" - -#: ../src/fenrirscreenreader/commands/onKeyInput/80500-numlock.py:24 -msgid "Numlock off" -msgstr "Numlock Š²Ń‹ŠŗŠ»ŃŽŃ‡ŠµŠ½" - -#: -#: ../src/fenrirscreenreader/commands/onScreenChanged/80000-screen_change_announcement.py:20 -msgid "screen {0}" -msgstr "Экран {0} " - -#: -#: ../src/fenrirscreenreader/commands/onScreenUpdate/56000-highlight_tracking.py:16 -msgid "enables or disables tracking of highlighted" -msgstr "Š’ŠŗŠ»ŃŽŃ‡Š°ŠµŃ‚ или Š¾Ń‚ŠŗŠ»ŃŽŃ‡Š°ŠµŃ‚ отслеживание Š²Ń‹Š“ŠµŠ»ŠµŠ½ŠøŃ" - -#: ../src/fenrirscreenreader/commands/vmenu-navigation/curr_vmenu_entry.py:17 -msgid "get current v menu entry" -msgstr "ŠŸŠ¾Š»ŃƒŃ‡ŠøŃ‚ŃŒ Ń‚ŠµŠŗŃƒŃ‰ŠøŠ¹ ŠæŃƒŠ½ŠŗŃ‚ Š¼ŠµŠ½ŃŽ v" - -#: ../src/fenrirscreenreader/commands/vmenu-navigation/dec_level_vmenu.py:17 -msgid "leave v menu submenu" -msgstr "Выйти v Š¼ŠµŠ½ŃŽ ŠæŠ¾Š“Š¼ŠµŠ½ŃŽ" - -#: ../src/fenrirscreenreader/commands/vmenu-navigation/exec_vmenu_entry.py:17 -msgid "execute v menu entry" -msgstr "Š’Ń‹ŠæŠ¾Š»Š½ŠøŃ‚ŃŒ ŠæŃƒŠ½ŠŗŃ‚ v Š¼ŠµŠ½ŃŽ" - -#: ../src/fenrirscreenreader/commands/vmenu-navigation/inc_level_vmenu.py:17 -msgid "enter v menu submenu" -msgstr "войти в ŠæŠ¾Š“Š¼ŠµŠ½ŃŽ Š¼ŠµŠ½ŃŽ" - -#: ../src/fenrirscreenreader/commands/vmenu-navigation/next_vmenu_entry.py:17 -msgid "get next v menu entry" -msgstr "ŠæŠ¾Š»ŃƒŃ‡ŠøŃ‚ŃŒ ŃŠ»ŠµŠ“ŃƒŃŽŃ‰ŠøŠ¹ ŠæŃƒŠ½ŠŗŃ‚ Š¼ŠµŠ½ŃŽ v" - -#: ../src/fenrirscreenreader/commands/vmenu-navigation/prev_vmenu_entry.py:17 -msgid "get prev v menu entry" -msgstr "ŠæŠ¾Š»ŃƒŃ‡ŠøŃ‚ŃŒ ŠæŃ€ŠµŠ“Ń‹Š“ŃƒŃ‰ŠøŠ¹ ŠæŃƒŠ½ŠŗŃ‚ Š¼ŠµŠ½ŃŽ v" - -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_a.py:17 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_b.py:17 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_c.py:17 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_d.py:17 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_e.py:17 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_f.py:17 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_g.py:17 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_h.py:17 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_i.py:17 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_j.py:17 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_k.py:17 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_l.py:17 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_m.py:17 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_n.py:17 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_o.py:17 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_p.py:17 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_q.py:17 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_r.py:17 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_s.py:17 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_t.py:17 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_u.py:17 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_v.py:17 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_w.py:17 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_x.py:17 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_y.py:17 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_z.py:17 -msgid "search for an menu entry" -msgstr "поиск ŠæŃƒŠ½ŠŗŃ‚а Š¼ŠµŠ½ŃŽ" - -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_a.py:23 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_b.py:23 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_c.py:23 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_d.py:23 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_e.py:23 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_f.py:23 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_g.py:23 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_h.py:23 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_i.py:23 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_j.py:23 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_k.py:23 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_l.py:23 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_m.py:23 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_n.py:23 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_o.py:23 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_p.py:23 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_q.py:23 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_r.py:23 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_s.py:23 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_t.py:23 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_u.py:23 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_v.py:23 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_w.py:23 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_x.py:23 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_y.py:23 -#: ../src/fenrirscreenreader/commands/vmenu-navigation/search_z.py:23 -msgid "not found" -msgstr "ŠŠµ найГено" - -#: ../src/fenrirscreenreader/core/attributeManager.py:168 -msgid "bold" -msgstr "Жирный" - -#: ../src/fenrirscreenreader/core/attributeManager.py:176 -msgid "italic" -msgstr "ŠšŃƒŃ€ŃŠøŠ²Š½Ń‹Š¹" - -#: ../src/fenrirscreenreader/core/attributeManager.py:184 -msgid "underline" -msgstr "ŠŸŠ¾Š“Ń‡ŠµŃ€ŠŗŠ½ŃƒŃ‚Ń‹Š¹" - -#: ../src/fenrirscreenreader/core/attributeManager.py:192 -msgid "strikethrough" -msgstr "Š—Š°Ń‡ŠµŃ€ŠŗŠ½ŃƒŃ‚Ń‹Š¹" - -#: ../src/fenrirscreenreader/core/attributeManager.py:200 -msgid "reverse" -msgstr "ŠžŠ±Ń€Š°Ń‚Š½Ń‹Š¹" - -#: ../src/fenrirscreenreader/core/attributeManager.py:208 -msgid "blink" -msgstr "ŠœŠµŃ€Ń†Š°Š½ŠøŠµ" - -#: ../src/fenrirscreenreader/core/attributeManager.py:225 -#: ../src/fenrirscreenreader/core/attributeManager.py:232 -msgid "default" -msgstr "По ŃƒŠ¼Š¾Š»Ń‡Š°Š½ŠøŃŽ" - -#: ../src/fenrirscreenreader/core/fenrirManager.py:26 -msgid "Start Fenrir" -msgstr "fenrir Š·Š°ŠæŃƒŃ‰ŠµŠ½" - -#: ../src/fenrirscreenreader/core/fenrirManager.py:234 -msgid "Quit Fenrir" -msgstr "Š²Ń‹ŠŗŠ»ŃŽŃ‡ŠøŃ‚ŃŒ fenrir" - -#: ../src/fenrirscreenreader/core/helpManager.py:77 -msgid "toggles the tutorial mode" -msgstr "ŠŸŠµŃ€ŠµŠŗŠ»ŃŽŃ‡ŠµŠ½ŠøŠµ в режим Š¾Š±ŃƒŃ‡ŠµŠ½ŠøŃ" - -#: ../src/fenrirscreenreader/core/outputManager.py:297 -msgid "speech temporary disabled" -msgstr "Š ŠµŃ‡ŃŒ временно Š¾Ń‚ŠŗŠ»ŃŽŃ‡ŠµŠ½Š°" - -#: ../src/fenrirscreenreader/core/quickMenuManager.py:124 -msgid "setting invalid" -msgstr "ŠŠµŠæŃ€Š°Š²ŠøŠ»ŃŒŠ½Š°Ń настройка" - -#: ../src/fenrirscreenreader/core/quickMenuManager.py:131 -msgid "setting value invalid" -msgstr "Значение настройки Š½ŠµŠæŃ€Š°Š²ŠøŠ»ŃŒŠ½Š¾Šµ" - -#: ../src/fenrirscreenreader/core/screenDriver.py:12 -msgid "black" -msgstr "черный" - -#: ../src/fenrirscreenreader/core/screenDriver.py:12 -msgid "blue" -msgstr "синий" - -#: ../src/fenrirscreenreader/core/screenDriver.py:12 -msgid "cyan" -msgstr "" - -#: ../src/fenrirscreenreader/core/screenDriver.py:12 -msgid "green" -msgstr "зеленый" - -#: ../src/fenrirscreenreader/core/screenDriver.py:12 -msgid "red" -msgstr "красный" - -#: ../src/fenrirscreenreader/core/screenDriver.py:12 -msgid "white" -msgstr "белый" - -#: ../src/fenrirscreenreader/core/screenDriver.py:12 -#: ../src/fenrirscreenreader/core/screenDriver.py:13 -msgid "Magenta" -msgstr "ŠæŃƒŃ€ŠæŃƒŃ€Š½Ń‹Š¹" - -#: ../src/fenrirscreenreader/core/screenDriver.py:12 -#: ../src/fenrirscreenreader/core/screenDriver.py:13 -msgid "brown/yellow" -msgstr "коричневый/желтый" - -#: ../src/fenrirscreenreader/core/screenDriver.py:13 -msgid "Black" -msgstr "черный" - -#: ../src/fenrirscreenreader/core/screenDriver.py:13 -msgid "Blue" -msgstr "синий" - -#: ../src/fenrirscreenreader/core/screenDriver.py:13 -msgid "Cyan" -msgstr "" - -#: ../src/fenrirscreenreader/core/screenDriver.py:13 -msgid "Dark gray" -msgstr "темно серый" - -#: ../src/fenrirscreenreader/core/screenDriver.py:13 -msgid "Green" -msgstr "зеленый" - -#: ../src/fenrirscreenreader/core/screenDriver.py:13 -msgid "Light blue" -msgstr "голубой" - -#: ../src/fenrirscreenreader/core/screenDriver.py:13 -msgid "Light cyan" -msgstr "светло-голубой" - -#: ../src/fenrirscreenreader/core/screenDriver.py:13 -msgid "Light gray" -msgstr "светлосерый" - -#: ../src/fenrirscreenreader/core/screenDriver.py:13 -msgid "Light magenta" -msgstr "Дветло-ŠæŃƒŃ€ŠæŃƒŃ€Š½Ń‹Š¹" - -#: ../src/fenrirscreenreader/core/screenDriver.py:13 -msgid "Light red" -msgstr "розовый" - -#: ../src/fenrirscreenreader/core/screenDriver.py:13 -msgid "Light yellow" -msgstr "светло желтый" - -#: ../src/fenrirscreenreader/core/screenDriver.py:13 -msgid "Red" -msgstr "красный" - -#: ../src/fenrirscreenreader/core/screenDriver.py:13 -msgid "White" -msgstr "белый" - -#: ../src/fenrirscreenreader/core/vmenuManager.py:66 -#: ../src/fenrirscreenreader/core/vmenuManager.py:226 -msgid "Menu" -msgstr "ŠœŠµŠ½ŃŽ" - -#: ../src/fenrirscreenreader/core/vmenuManager.py:234 -msgid "Action" -msgstr "Действие" diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index cec3f90c..00000000 --- a/pytest.ini +++ /dev/null @@ -1,69 +0,0 @@ -[pytest] -# Pytest configuration for Fenrir screen reader - -# Test discovery patterns -python_files = test_*.py *_test.py -python_classes = Test* -python_functions = test_* - -# Test paths -testpaths = tests - -# Minimum Python version -minversion = 3.7 - -# Output options -addopts = - # Verbose output with test names - -v - # Show extra test summary info - -ra - # Enable strict markers (only registered markers allowed) - --strict-markers - # Show local variables in tracebacks - --showlocals - # Warnings configuration - -W ignore::DeprecationWarning - # Optional plugins (uncomment if installed): - # --timeout=30 # Requires pytest-timeout - # --cov-report=term-missing # Requires pytest-cov - # -x # Stop on first failure - -# Register custom markers -markers = - unit: Unit tests (fast, no mocking) - integration: Integration tests (require mocking) - driver: Driver tests (require root access) - slow: Tests that take more than 1 second - remote: Tests for remote control functionality - settings: Tests for settings and configuration - commands: Tests for command system - vmenu: Tests for VMenu system - -# Coverage configuration -[coverage:run] -source = src/fenrirscreenreader -omit = - */tests/* - */vmenu-profiles/* - */__pycache__/* - */site-packages/* - -[coverage:report] -# Fail if coverage falls below this percentage -# fail_under = 70 -exclude_lines = - # Standard pragma - pragma: no cover - # Don't complain about missing debug code - def __repr__ - # Don't complain if tests don't hit defensive assertion code - raise AssertionError - raise NotImplementedError - # Don't complain about abstract methods - @abstractmethod - # Don't complain about initialization - if __name__ == .__main__.: - -[coverage:html] -directory = htmlcov diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index de203a24..00000000 --- a/requirements.txt +++ /dev/null @@ -1,9 +0,0 @@ -daemonize -dbus-python -evdev -pyenchant -pyperclip -pyte -pyudev -pyxdg -setproctitle diff --git a/setup.py b/setup.py deleted file mode 100755 index 5a9ca6a8..00000000 --- a/setup.py +++ /dev/null @@ -1,135 +0,0 @@ -#!/usr/bin/env python3 -import os, glob, sys -import os.path -from shutil import copyfile -from setuptools import find_namespace_packages -from setuptools import setup - -# handle flags for package manager like aurman and pacaur. -# Allow both environment variable and command line flag -forceSettingsFlag = ( - "--force-settings" in sys.argv or - os.environ.get('FENRIR_FORCE_SETTINGS') == '1' -) -if "--force-settings" in sys.argv: - sys.argv.remove("--force-settings") - -dataFiles = [] - -# Handle locale files -localeFiles = glob.glob('locale/*/LC_MESSAGES/*.mo') -for localeFile in localeFiles: - lang = localeFile.split(os.sep)[1] - destDir = f'/usr/share/locale/{lang}/LC_MESSAGES' - dataFiles.append((destDir, [localeFile])) - -# Handle other configuration files -directories = glob.glob('config/*') -for directory in directories: - files = glob.glob(directory+'/*') - destDir = '' - if 'config/punctuation' in directory : - destDir = '/etc/fenrirscreenreader/punctuation' - elif 'config/keyboard' in directory: - destDir = '/etc/fenrirscreenreader/keyboard' - elif 'config/settings' in directory: - destDir = '/etc/fenrirscreenreader/settings' - if not forceSettingsFlag: - try: - files = [f for f in files if not f.endswith('settings.conf')] - except Exception as e: - pass - elif 'config/scripts' in directory: - destDir = '/usr/share/fenrirscreenreader/scripts' - if destDir != '': - dataFiles.append((destDir, files)) - -files = glob.glob('config/sound/default/*') -destDir = '/usr/share/sounds/fenrirscreenreader/default' -dataFiles.append((destDir, files)) -files = glob.glob('config/sound//template/*') -destDir = '/usr/share/sounds/fenrirscreenreader/template' -dataFiles.append((destDir, files)) -files = glob.glob('tools/*') -dataFiles.append(('/usr/share/fenrirscreenreader/tools', files)) -dataFiles.append(('/usr/share/man/man1', ['docs/fenrir.1'])) - -def read(fname): - return open(os.path.join(os.path.dirname(__file__), fname)).read() - -setup( - # Application name: - name="fenrir-screenreader", - # description - description="A TTY Screen Reader for Linux.", - long_description=read('README.md'), - long_description_content_type="text/markdown", - keywords=['screenreader', 'a11y', 'accessibility', 'terminal', 'TTY', 'console'], - license="License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", - url="https://git.stormux.org/storm/fenrir/", - classifiers=[ - "Programming Language :: Python", - "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)", - "Development Status :: 5 - Production/Stable", - "Topic :: Multimedia :: Sound/Audio :: Speech", - "Environment :: Console", - ], - - # Application author details: - author="Storm Dragon, Jeremiah, Chrys and others", - author_email="storm_dragon@stormux.org", - - # Packages - package_dir={'': 'src'}, - packages=find_namespace_packages( - where='src', - include=['fenrirscreenreader*'] - ), - scripts=['src/fenrir'], - - # Include additional files into the package - include_package_data=True, - zip_safe=False, - - data_files=dataFiles, - - # Dependent packages (distributions) - python_requires='>=3.6', - install_requires=[ - "evdev>=1.1.2", - "daemonize>=2.5.0", - "dbus-python>=1.2.8", - "pyperclip", - "pyudev>=0.21.0", - "setuptools", - "setproctitle", - "pyte>=0.7.0", - ], - extras_require={ - "dectalk_usb": ["pyusb>=1.2.1"], - "x11": ["python-xlib>=0.33"], - }, -) - -if not forceSettingsFlag: - print('') - # create settings file from example if not exist - if not os.path.isfile('/etc/fenrirscreenreader/settings/settings.conf'): - try: - copyfile('config/fenrirscreenreader/settings/settings.conf', '/etc/fenrirscreenreader/settings/settings.conf') - print('create settings file in /etc/fenrirscreenreader/settings/settings.conf') - except OSError as e: - print(f"Could not copy settings file to destination: {e}") - else: - print('settings.conf file found. It is not overwritten automatical') - -print('') -print('To have Fenrir start at boot:') -print('sudo systemctl enable fenrir') -print('Pulseaudio users may want to run:') -print('/usr/share/fenrirscreenreader/tools/configure_pulse.sh') -print('once as their user account and once as root to configure Pulseaudio.') -print('Please install the following packages manually:') -print('- Speech-dispatcher: for the default speech driver') -print('- Espeak: as basic TTS engine') -print('- sox: is a player for the generic sound driver') diff --git a/src/__init__.py b/src/__init__.py deleted file mode 100755 index e69de29b..00000000 diff --git a/src/fenrir b/src/fenrir deleted file mode 100755 index 5c61f48e..00000000 --- a/src/fenrir +++ /dev/null @@ -1,179 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import argparse -import inspect -import os -import sys - -from daemonize import Daemonize - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader import fenrirVersion -from fenrirscreenreader.core import fenrirManager - -# Get the fenrir installation path -fenrirPath = os.path.dirname(os.path.realpath( - os.path.abspath(inspect.getfile(inspect.currentframe())))) -if fenrirPath not in sys.path: - sys.path.append(fenrirPath) - - -def create_argument_parser(): - """Create and return the argument parser for Fenrir""" - argumentParser = argparse.ArgumentParser( - description=_("Fenrir - A console screen reader for Linux"), - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - argumentParser.add_argument( - "-v", - "--version", - action="version", - version=_("Fenrir screen reader version {0}-{1}").format( - fenrirVersion.version, fenrirVersion.code_name - ), - help=_("Show version information and exit"), - ) - argumentParser.add_argument( - "-f", - "--foreground", - action="store_true", - help=_("Run Fenrir in the foreground (default: run as daemon)"), - ) - argumentParser.add_argument( - "-s", - "--setting", - metavar="SETTING-FILE", - default=None, - help=_("Path to custom settings file"), - ) - argumentParser.add_argument( - "-o", - "--options", - metavar="SECTION#SETTING=VALUE;..", - default="", - help=_( - "Override settings file options. Format: SECTION#SETTING=VALUE;... (case sensitive)" - ), - ) - argumentParser.add_argument( - "-d", "--debug", action="store_true", help=_("Enable debug mode") - ) - argumentParser.add_argument( - "-p", "--print", action="store_true", help=_("Print debug messages to screen") - ) - argumentParser.add_argument( - "-x", - "--x11", - action="store_true", - help=_( - "Use PTY emulation with X11 keyboard input scoped to the terminal window" - ), - ) - argumentParser.add_argument( - "--x11-window-id", - metavar="WINDOWID", - default="", - help=_("X11 window id to use for --x11 terminal mode"), - ) - argumentParser.add_argument( - "-F", - "--force-all-screens", - action="store_true", - help=_("Force Fenrir to respond on all screens, ignoring ignoreScreen setting"), - ) - argumentParser.add_argument( - "-i", - "-I", - "--ignore-screen", - metavar="SCREEN", - action="append", - help=_( - "Ignore specific screen(s). Can be used multiple times. Same as ignoreScreen setting." - ), - ) - return argumentParser - - -def validate_arguments(cliArgs): - """Validate command line arguments""" - if cliArgs.options: - for option in cliArgs.options.split(';'): - if option and ('#' not in option or '=' not in option): - return False, _( - "Invalid option format: {0}\nExpected format: SECTION#SETTING=VALUE" - ).format(option) - - if cliArgs.x11_window_id and not cliArgs.x11: - return False, _("--x11-window-id requires --x11") - - return True, None - - -def run_fenrir(): - """Main function that runs Fenrir""" - fenrirApp = None - try: - fenrirApp = fenrirManager.FenrirManager(cliArgs) - fenrirApp.proceed() - except Exception as e: - print(_("Error starting Fenrir: {0}").format(e), file=sys.stderr) - sys.exit(1) - except KeyboardInterrupt: - print(_("Interrupted"), file=sys.stderr) - sys.exit(1) - finally: - if fenrirApp and hasattr(fenrirApp, 'cleanup_on_error'): - try: - fenrirApp.cleanup_on_error() - except Exception as cleanup_error: - print( - _("Error during cleanup: {0}").format(cleanup_error), - file=sys.stderr, - ) - if fenrirApp: - del fenrirApp - # Clean up PID file if it exists - pidFile = "/run/fenrir.pid" - if os.path.exists(pidFile): - try: - os.remove(pidFile) - except Exception: - pass - - -def should_run_foreground(cliArgs): - return cliArgs.foreground or cliArgs.x11 - - -def main(): - global cliArgs - argumentParser = create_argument_parser() - cliArgs = argumentParser.parse_args() - - # Validate arguments - isValid, errorMsg = validate_arguments(cliArgs) - if not isValid: - argumentParser.error(errorMsg) - sys.exit(1) - - if should_run_foreground(cliArgs): - # Run directly in foreground - run_fenrir() - else: - # Run as daemon - pidFile = "/run/fenrir.pid" - daemonProcess = Daemonize( - app="fenrir", - pid=pidFile, - action=run_fenrir, - chdir=fenrirPath - ) - daemonProcess.start() - - -if __name__ == "__main__": - main() diff --git a/src/fenrirscreenreader/__init__.py b/src/fenrirscreenreader/__init__.py deleted file mode 100755 index e69de29b..00000000 diff --git a/src/fenrirscreenreader/commands/__init__.py b/src/fenrirscreenreader/commands/__init__.py deleted file mode 100755 index e69de29b..00000000 diff --git a/src/fenrirscreenreader/commands/command_template.py b/src/fenrirscreenreader/commands/command_template.py deleted file mode 100644 index d96dbdb8..00000000 --- a/src/fenrirscreenreader/commands/command_template.py +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return "No description found" - - def run(self): - pass - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/00_init_commands.py b/src/fenrirscreenreader/commands/commands/00_init_commands.py deleted file mode 100644 index 28e9cf5f..00000000 --- a/src/fenrirscreenreader/commands/commands/00_init_commands.py +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -# this command is just to initialize stuff. -# like init index lists in MemoryManager -# it is not useful to execute it - -from fenrirscreenreader.core.i18n import _ - - -class command: - help_visible = False - - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - # clipboard - self.env["runtime"]["MemoryManager"].add_index_list( - "clipboardHistory", - self.env["runtime"]["SettingsManager"].get_setting_as_int( - "clipboard", "number_of_clipboards" - ), - ) - - def shutdown(self): - pass - - def get_description(self): - return "No description found" - - def run(self): - pass - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/__init__.py b/src/fenrirscreenreader/commands/commands/__init__.py deleted file mode 100755 index e69de29b..00000000 diff --git a/src/fenrirscreenreader/commands/commands/add_word_to_spell_check.py b/src/fenrirscreenreader/commands/commands/add_word_to_spell_check.py deleted file mode 100644 index ba9a58a8..00000000 --- a/src/fenrirscreenreader/commands/commands/add_word_to_spell_check.py +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import string - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import word_utils - -initialized = False -try: - import enchant - - initialized = True -except Exception as e: - pass - - -class command: - def __init__(self): - self.language = "" - self.spellChecker = None - - def initialize(self, environment): - self.env = environment - self.update_spell_language() - - def shutdown(self): - pass - - def get_description(self): - return _("adds the current word to the exceptions dictionary") - - def update_spell_language(self): - self.spellChecker = enchant.Dict( - self.env["runtime"]["SettingsManager"].get_setting( - "general", "spell_check_language" - ) - ) - self.language = self.env["runtime"]["SettingsManager"].get_setting( - "general", "spell_check_language" - ) - - def run(self): - if not initialized: - return - if ( - self.env["runtime"]["SettingsManager"].get_setting( - "general", "spell_check_language" - ) - != self.language - ): - try: - self.update_spell_language() - except Exception as e: - return - cursor_pos = self.env["runtime"][ - "CursorManager" - ].get_review_or_text_cursor() - # get the word - new_content = self.env["screen"]["new_content_text"].split("\n")[ - cursor_pos["y"] - ] - x, y, curr_word, end_of_screen, line_break = ( - word_utils.get_current_word(cursor_pos["x"], 0, new_content) - ) - curr_word = curr_word.strip( - string.whitespace + r'!"#$%&\()*+,-./:;<=Ƃ?@[\\]^_{|}~' - ) - - if curr_word != "": - if self.spellChecker.is_added(curr_word): - self.env["runtime"]["OutputManager"].present_text( - _("{0} is already in dictionary").format( - curr_word, - ), - sound_icon="Cancel", - interrupt=True, - ) - else: - self.spellChecker.add(curr_word) - self.env["runtime"]["OutputManager"].present_text( - _("{0} added to dictionary").format( - curr_word, - ), - sound_icon="Accept", - interrupt=True, - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/adjustment_base.py b/src/fenrirscreenreader/commands/commands/adjustment_base.py deleted file mode 100644 index cf90b07c..00000000 --- a/src/fenrirscreenreader/commands/commands/adjustment_base.py +++ /dev/null @@ -1,112 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import math - -from fenrirscreenreader.core.i18n import _ - - -class adjustment_command: - """Base class for speech and sound adjustment commands""" - - def __init__(self, section, setting, direction, step=0.1): - self.section = section # 'speech' or 'sound' - self.setting = setting # 'rate', 'pitch', 'volume' - self.direction = direction # 'inc' or 'dec' - self.step = step - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - descriptions = { - ("speech", "rate", "inc"): _("Increase the speech rate"), - ("speech", "rate", "dec"): _("Decrease the speech rate"), - ("speech", "pitch", "inc"): _("Increase the speech pitch"), - ("speech", "pitch", "dec"): _("Decrease the speech pitch"), - ("speech", "volume", "inc"): _("Increase the speech volume"), - ("speech", "volume", "dec"): _("Decrease the speech volume"), - ("sound", "volume", "inc"): _("Increase the sound volume"), - ("sound", "volume", "dec"): _("Decrease the sound volume"), - } - return descriptions[(self.section, self.setting, self.direction)] - - def run(self): - if self.section == "sound" and self.setting == "volume": - # Sound volume uses different method - self._adjust_sound_volume() - else: - # Speech rate, pitch, volume use standard method - self._adjust_speech_setting() - - def _adjust_speech_setting(self): - """Adjust speech settings (rate, pitch, volume)""" - value = self.env["runtime"]["SettingsManager"].get_setting_as_float( - self.section, self.setting - ) - - # Apply adjustment with rounding - if self.direction == "inc": - value = round((math.ceil(10 * value) / 10) + self.step, 2) - if value > 1.0: - value = 1.0 - else: # dec - value = round((math.ceil(10 * value) / 10) - self.step, 2) - if value < 0.0: - value = 0.0 - - # Set the new value - self.env["runtime"]["SettingsManager"].set_setting( - self.section, self.setting, str(value) - ) - - # Present feedback - percentage = int(value * 100) - messages = { - "rate": _("{0} percent speech rate"), - "pitch": _("{0} percent speech pitch"), - "volume": _("{0} percent speech volume"), - } - feedback = messages[self.setting].format(percentage) - - self.env["runtime"]["OutputManager"].present_text( - feedback, sound_icon="", interrupt=True - ) - - def _adjust_sound_volume(self): - """Adjust sound volume using same logic as speech""" - value = self.env["runtime"]["SettingsManager"].get_setting_as_float( - self.section, self.setting - ) - - # Sound volume uses same math as speech settings - if self.direction == "inc": - value = round((math.ceil(10 * value) / 10) + self.step, 2) - if value > 1.0: - value = 1.0 - else: # dec - value = round((math.ceil(10 * value) / 10) - self.step, 2) - if value < 0.0: - value = 0.0 - - # Set the new value - self.env["runtime"]["SettingsManager"].set_setting( - self.section, self.setting, str(value) - ) - - # Present feedback with appropriate sound icon - percentage = int(value * 100) - sound_icon = "SoundOn" if self.direction == "inc" else "SoundOff" - feedback = _("{0} percent sound volume").format(percentage) - self.env["runtime"]["OutputManager"].present_text( - feedback, sound_icon=sound_icon, interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/announce_fenrir_version.py b/src/fenrirscreenreader/commands/commands/announce_fenrir_version.py deleted file mode 100644 index b6cf0b7b..00000000 --- a/src/fenrirscreenreader/commands/commands/announce_fenrir_version.py +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader import fenrirVersion -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Present the version of Fenrir currently in use.") - - def run(self): - try: - self.env["runtime"]["OutputManager"].present_text( - _("Fenrir screen reader version {0}-{1}").format( - fenrirVersion.version, fenrirVersion.code_name - ), - interrupt=True, - ) - except Exception as e: - self.env["runtime"]["OutputManager"].present_text( - _("Version information is unavailable."), interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/apply_tested_voice.py b/src/fenrirscreenreader/commands/commands/apply_tested_voice.py deleted file mode 100644 index a5947a25..00000000 --- a/src/fenrirscreenreader/commands/commands/apply_tested_voice.py +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env python3 - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Apply the last tested voice from safe voice browser") - - def run(self): - try: - # Check if we have a tested voice - if ( - "commandBuffer" not in self.env - or "lastTestedModule" not in self.env["commandBuffer"] - or "lastTestedVoice" not in self.env["commandBuffer"] - ): - self.env["runtime"]["OutputManager"].present_text( - _("No voice has been tested yet"), interrupt=True - ) - self.env["runtime"]["OutputManager"].present_text( - _("Use voice browser first"), interrupt=True - ) - return - - module = self.env["commandBuffer"]["lastTestedModule"] - voice = self.env["commandBuffer"]["lastTestedVoice"] - language = self.env["commandBuffer"].get("lastTestedLanguage") - - self.env["runtime"]["OutputManager"].present_text( - _("Applying {0} from {1}").format(voice, module), interrupt=True - ) - - # Apply to runtime settings only (temporary until saved) - SettingsManager = self.env["runtime"]["SettingsManager"] - - # Store old values for safety - old_driver = SettingsManager.get_setting("speech", "driver") - old_module = SettingsManager.get_setting("speech", "module") - old_voice = SettingsManager.get_setting("speech", "voice") - old_language = SettingsManager.get_setting("speech", "language") - - try: - # Apply new settings to runtime only (use set_setting to update - # settingArgDict) - SettingsManager.set_setting( - "speech", "driver", "speechdDriver" - ) - SettingsManager.set_setting("speech", "module", module) - SettingsManager.set_setting("speech", "voice", voice) - if language: - SettingsManager.set_setting( - "speech", "language", language - ) - - # Apply to speech driver instance directly - if "SpeechDriver" in self.env["runtime"]: - SpeechDriver = self.env["runtime"]["SpeechDriver"] - - # Set the module and voice on the driver instance - SpeechDriver.set_module(module) - if language: - SpeechDriver.set_language(language) - SpeechDriver.set_voice(voice) - - self.env["runtime"]["OutputManager"].present_text( - _("Voice applied successfully!"), interrupt=True - ) - self.env["runtime"]["OutputManager"].present_text( - _("Use save settings to make permanent"), interrupt=True - ) - self.env["runtime"]["OutputManager"].play_sound("Accept") - - except Exception as e: - # Revert on failure - SettingsManager.set_setting("speech", "driver", old_driver) - SettingsManager.set_setting("speech", "module", old_module) - SettingsManager.set_setting("speech", "voice", old_voice) - SettingsManager.set_setting( - "speech", "language", old_language - ) - - self.env["runtime"]["OutputManager"].present_text( - _("Failed to apply voice, reverted: {0}").format(str(e)), - interrupt=True, - ) - self.env["runtime"]["OutputManager"].play_sound("Error") - - except Exception as e: - self.env["runtime"]["OutputManager"].present_text( - _("Apply voice error: {0}").format(str(e)), interrupt=True - ) - self.env["runtime"]["OutputManager"].play_sound("Error") - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/attribute_cursor.py b/src/fenrirscreenreader/commands/commands/attribute_cursor.py deleted file mode 100644 index 298eb326..00000000 --- a/src/fenrirscreenreader/commands/commands/attribute_cursor.py +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import screen_utils - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Reads attributes of current cursor position") - - def run(self): - cursor_pos = self.env["runtime"][ - "CursorManager" - ].get_review_or_text_cursor() - try: - attributes = self.env["runtime"][ - "AttributeManager" - ].get_attribute_by_xy(cursor_pos["x"], cursor_pos["y"]) - except Exception as e: - print(e) - attribute_format_string = self.env["runtime"][ - "SettingsManager" - ].get_setting("general", "attribute_format_string") - attribute_format_string = self.env["runtime"][ - "AttributeManager" - ].format_attributes(attributes, attribute_format_string) - - self.env["runtime"]["OutputManager"].present_text( - attribute_format_string, sound_icon="", interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/bookmark_1.py b/src/fenrirscreenreader/commands/commands/bookmark_1.py deleted file mode 100644 index ac1113d9..00000000 --- a/src/fenrirscreenreader/commands/commands/bookmark_1.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_base_path = os.path.join(os.path.dirname(__file__), "bookmark_base.py") -_spec = importlib.util.spec_from_file_location("bookmark_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -bookmark_command = _module.bookmark_command - - -class command(bookmark_command): - def __init__(self): - super().__init__(1, "read") diff --git a/src/fenrirscreenreader/commands/commands/bookmark_10.py b/src/fenrirscreenreader/commands/commands/bookmark_10.py deleted file mode 100644 index dee21e88..00000000 --- a/src/fenrirscreenreader/commands/commands/bookmark_10.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_base_path = os.path.join(os.path.dirname(__file__), "bookmark_base.py") -_spec = importlib.util.spec_from_file_location("bookmark_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -bookmark_command = _module.bookmark_command - - -class command(bookmark_command): - def __init__(self): - super().__init__(10, "read") diff --git a/src/fenrirscreenreader/commands/commands/bookmark_2.py b/src/fenrirscreenreader/commands/commands/bookmark_2.py deleted file mode 100644 index e747516c..00000000 --- a/src/fenrirscreenreader/commands/commands/bookmark_2.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_base_path = os.path.join(os.path.dirname(__file__), "bookmark_base.py") -_spec = importlib.util.spec_from_file_location("bookmark_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -bookmark_command = _module.bookmark_command - - -class command(bookmark_command): - def __init__(self): - super().__init__(2, "read") diff --git a/src/fenrirscreenreader/commands/commands/bookmark_3.py b/src/fenrirscreenreader/commands/commands/bookmark_3.py deleted file mode 100644 index 56fbe47a..00000000 --- a/src/fenrirscreenreader/commands/commands/bookmark_3.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_base_path = os.path.join(os.path.dirname(__file__), "bookmark_base.py") -_spec = importlib.util.spec_from_file_location("bookmark_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -bookmark_command = _module.bookmark_command - - -class command(bookmark_command): - def __init__(self): - super().__init__(3, "read") diff --git a/src/fenrirscreenreader/commands/commands/bookmark_4.py b/src/fenrirscreenreader/commands/commands/bookmark_4.py deleted file mode 100644 index f7931aa2..00000000 --- a/src/fenrirscreenreader/commands/commands/bookmark_4.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_base_path = os.path.join(os.path.dirname(__file__), "bookmark_base.py") -_spec = importlib.util.spec_from_file_location("bookmark_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -bookmark_command = _module.bookmark_command - - -class command(bookmark_command): - def __init__(self): - super().__init__(4, "read") diff --git a/src/fenrirscreenreader/commands/commands/bookmark_5.py b/src/fenrirscreenreader/commands/commands/bookmark_5.py deleted file mode 100644 index 3e4f7e7e..00000000 --- a/src/fenrirscreenreader/commands/commands/bookmark_5.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_base_path = os.path.join(os.path.dirname(__file__), "bookmark_base.py") -_spec = importlib.util.spec_from_file_location("bookmark_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -bookmark_command = _module.bookmark_command - - -class command(bookmark_command): - def __init__(self): - super().__init__(5, "read") diff --git a/src/fenrirscreenreader/commands/commands/bookmark_6.py b/src/fenrirscreenreader/commands/commands/bookmark_6.py deleted file mode 100644 index 2e0fd983..00000000 --- a/src/fenrirscreenreader/commands/commands/bookmark_6.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_base_path = os.path.join(os.path.dirname(__file__), "bookmark_base.py") -_spec = importlib.util.spec_from_file_location("bookmark_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -bookmark_command = _module.bookmark_command - - -class command(bookmark_command): - def __init__(self): - super().__init__(6, "read") diff --git a/src/fenrirscreenreader/commands/commands/bookmark_7.py b/src/fenrirscreenreader/commands/commands/bookmark_7.py deleted file mode 100644 index 04c4ce35..00000000 --- a/src/fenrirscreenreader/commands/commands/bookmark_7.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_base_path = os.path.join(os.path.dirname(__file__), "bookmark_base.py") -_spec = importlib.util.spec_from_file_location("bookmark_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -bookmark_command = _module.bookmark_command - - -class command(bookmark_command): - def __init__(self): - super().__init__(7, "read") diff --git a/src/fenrirscreenreader/commands/commands/bookmark_8.py b/src/fenrirscreenreader/commands/commands/bookmark_8.py deleted file mode 100644 index fc28647d..00000000 --- a/src/fenrirscreenreader/commands/commands/bookmark_8.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_base_path = os.path.join(os.path.dirname(__file__), "bookmark_base.py") -_spec = importlib.util.spec_from_file_location("bookmark_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -bookmark_command = _module.bookmark_command - - -class command(bookmark_command): - def __init__(self): - super().__init__(8, "read") diff --git a/src/fenrirscreenreader/commands/commands/bookmark_9.py b/src/fenrirscreenreader/commands/commands/bookmark_9.py deleted file mode 100644 index ac1a83f5..00000000 --- a/src/fenrirscreenreader/commands/commands/bookmark_9.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_base_path = os.path.join(os.path.dirname(__file__), "bookmark_base.py") -_spec = importlib.util.spec_from_file_location("bookmark_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -bookmark_command = _module.bookmark_command - - -class command(bookmark_command): - def __init__(self): - super().__init__(9, "read") diff --git a/src/fenrirscreenreader/commands/commands/bookmark_base.py b/src/fenrirscreenreader/commands/commands/bookmark_base.py deleted file mode 100644 index a85dfbbf..00000000 --- a/src/fenrirscreenreader/commands/commands/bookmark_base.py +++ /dev/null @@ -1,211 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import line_utils -from fenrirscreenreader.utils import mark_utils - - -class bookmark_command: - """Base class for bookmark operations - read, set, clear""" - - def __init__(self, bookmark_id, action="read"): - self.ID = str(bookmark_id) - self.action = action - - def initialize(self, environment): - self.env = environment - # Always initialize bookmark structure - all commands need this - if self.ID not in self.env["commandBuffer"]["bookMarks"]: - self.env["commandBuffer"]["bookMarks"][self.ID] = {} - - def shutdown(self): - pass - - def get_description(self): - if self.action == "read": - return _("read Bookmark {0}").format(self.ID) - elif self.action == "set": - return _("set Bookmark {0}").format(self.ID) - elif self.action == "clear": - return _("remove Bookmark {0}").format(self.ID) - return _("{0} Bookmark {1}").format(self.action, self.ID) - - def run(self): - if self.action == "read": - self._read_bookmark() - elif self.action == "set": - self._set_bookmark() - elif self.action == "clear": - self._clear_bookmark() - - def _read_bookmark(self): - curr_app = self.env["runtime"][ - "ApplicationManager" - ].get_current_application() - - if not self.env["commandBuffer"]["bookMarks"][self.ID]: - self.env["runtime"]["OutputManager"].present_text( - _("Bookmark {0} not set").format(self.ID), interrupt=True - ) - return - - if curr_app not in self.env["commandBuffer"]["bookMarks"][self.ID]: - self.env["runtime"]["OutputManager"].present_text( - _("Bookmark for application {0} not set").format(curr_app), - interrupt=True, - ) - return - - if not self.env["commandBuffer"]["bookMarks"][self.ID][curr_app].get( - "1" - ): - self.env["runtime"]["OutputManager"].present_text( - _("Bookmark for application {0} not set").format(curr_app), - interrupt=True, - ) - return - - # Get bookmarked text - marked = "" - start_mark = self.env["commandBuffer"]["bookMarks"][self.ID][curr_app][ - "1" - ].copy() - - if self.env["commandBuffer"]["bookMarks"][self.ID][curr_app]["2"]: - end_mark = self.env["commandBuffer"]["bookMarks"][self.ID][ - curr_app - ]["2"].copy() - marked = mark_utils.get_text_between_marks( - start_mark, end_mark, self.env["screen"]["new_content_text"] - ) - else: - x, y, marked = line_utils.get_current_line( - start_mark["x"], - start_mark["y"], - self.env["screen"]["new_content_text"], - ) - - if marked.isspace(): - self.env["runtime"]["OutputManager"].present_text( - _("blank"), sound_icon="EmptyLine", interrupt=True - ) - else: - self.env["runtime"]["OutputManager"].present_text( - marked, interrupt=True - ) - - def _set_bookmark(self): - if not self.env["commandBuffer"]["Marks"]["1"]: - self.env["runtime"]["OutputManager"].present_text( - _("No mark found"), interrupt=True - ) - return - - curr_app = self.env["runtime"][ - "ApplicationManager" - ].get_current_application() - self.env["commandBuffer"]["bookMarks"][self.ID][curr_app] = {} - - bookmarks = self.env["commandBuffer"]["bookMarks"][self.ID][curr_app] - bookmarks["1"] = self.env["commandBuffer"]["Marks"]["1"].copy() - - if self.env["commandBuffer"]["Marks"]["2"]: - bookmarks["2"] = self.env["commandBuffer"]["Marks"]["2"].copy() - else: - bookmarks["2"] = None - - self.env["runtime"]["OutputManager"].present_text( - _("Bookmark {0} set for application {1}").format( - self.ID, curr_app - ), - interrupt=True, - ) - - # Clear marks after setting bookmark - self.env["commandBuffer"]["Marks"]["1"] = None - self.env["commandBuffer"]["Marks"]["2"] = None - - def _clear_bookmark(self): - curr_app = self.env["runtime"][ - "ApplicationManager" - ].get_current_application() - - bookmarks = self.env["commandBuffer"]["bookMarks"] - if self.ID in bookmarks and curr_app in bookmarks[self.ID]: - del self.env["commandBuffer"]["bookMarks"][self.ID][curr_app] - self.env["runtime"]["OutputManager"].present_text( - _("Bookmark {0} removed for application {1}").format( - self.ID, curr_app - ), - interrupt=True, - ) - else: - self.env["runtime"]["OutputManager"].present_text( - _("Bookmark {0} not set for application {1}").format( - self.ID, curr_app - ), - interrupt=True, - ) - - def set_callback(self, callback): - pass - - -# Factory function to create bookmark command instances - - -def create_bookmark_commands(): - """Create all bookmark command instances""" - commands = {} - - # Create read bookmark commands (bookmark_1 through bookmark_10) - for i in range(1, 11): - commands[f"bookmark_{i}"] = lambda i=i: bookmark_command(i, "read") - - # Create set bookmark commands (set_bookmark_1 through set_bookmark_10) - for i in range(1, 11): - commands[f"set_bookmark_{i}"] = lambda i=i: bookmark_command(i, "set") - - # Create clear bookmark commands (clear_bookmark_1 through - # clear_bookmark_10) - for i in range(1, 11): - commands[f"clear_bookmark_{i}"] = lambda i=i: bookmark_command( - i, "clear" - ) - - return commands - - -# For backwards compatibility, provide individual command classes -# This allows the existing command loading system to work unchanged - - -def _make_command_class(bookmark_id, action): - """Create a command class for a specific bookmark and action""" - - class command(bookmark_command): - def __init__(self): - super().__init__(bookmark_id, action) - - return command - - -# Generate individual command classes for each bookmark operation -# These will be used by the existing command loading system - - -# Read bookmarks (bookmark_1.py style) -for i in range(1, 11): - globals()[f"bookmark_{i}_command"] = _make_command_class(i, "read") - -# Set bookmarks (set_bookmark_1.py style) -for i in range(1, 11): - globals()[f"set_bookmark_{i}_command"] = _make_command_class(i, "set") - -# Clear bookmarks (clear_bookmark_1.py style) -for i in range(1, 11): - globals()[f"clear_bookmark_{i}_command"] = _make_command_class(i, "clear") diff --git a/src/fenrirscreenreader/commands/commands/clear_bookmark_1.py b/src/fenrirscreenreader/commands/commands/clear_bookmark_1.py deleted file mode 100644 index 2b618d07..00000000 --- a/src/fenrirscreenreader/commands/commands/clear_bookmark_1.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_base_path = os.path.join(os.path.dirname(__file__), "bookmark_base.py") -_spec = importlib.util.spec_from_file_location("bookmark_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -bookmark_command = _module.bookmark_command - - -class command(bookmark_command): - def __init__(self): - super().__init__(1, "clear") diff --git a/src/fenrirscreenreader/commands/commands/clear_bookmark_10.py b/src/fenrirscreenreader/commands/commands/clear_bookmark_10.py deleted file mode 100644 index ee4ecbd7..00000000 --- a/src/fenrirscreenreader/commands/commands/clear_bookmark_10.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_base_path = os.path.join(os.path.dirname(__file__), "bookmark_base.py") -_spec = importlib.util.spec_from_file_location("bookmark_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -bookmark_command = _module.bookmark_command - - -class command(bookmark_command): - def __init__(self): - super().__init__(10, "clear") diff --git a/src/fenrirscreenreader/commands/commands/clear_bookmark_2.py b/src/fenrirscreenreader/commands/commands/clear_bookmark_2.py deleted file mode 100644 index 9b0daf79..00000000 --- a/src/fenrirscreenreader/commands/commands/clear_bookmark_2.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_base_path = os.path.join(os.path.dirname(__file__), "bookmark_base.py") -_spec = importlib.util.spec_from_file_location("bookmark_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -bookmark_command = _module.bookmark_command - - -class command(bookmark_command): - def __init__(self): - super().__init__(2, "clear") diff --git a/src/fenrirscreenreader/commands/commands/clear_bookmark_3.py b/src/fenrirscreenreader/commands/commands/clear_bookmark_3.py deleted file mode 100644 index 902c2b79..00000000 --- a/src/fenrirscreenreader/commands/commands/clear_bookmark_3.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_base_path = os.path.join(os.path.dirname(__file__), "bookmark_base.py") -_spec = importlib.util.spec_from_file_location("bookmark_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -bookmark_command = _module.bookmark_command - - -class command(bookmark_command): - def __init__(self): - super().__init__(3, "clear") diff --git a/src/fenrirscreenreader/commands/commands/clear_bookmark_4.py b/src/fenrirscreenreader/commands/commands/clear_bookmark_4.py deleted file mode 100644 index b7320b0a..00000000 --- a/src/fenrirscreenreader/commands/commands/clear_bookmark_4.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_base_path = os.path.join(os.path.dirname(__file__), "bookmark_base.py") -_spec = importlib.util.spec_from_file_location("bookmark_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -bookmark_command = _module.bookmark_command - - -class command(bookmark_command): - def __init__(self): - super().__init__(4, "clear") diff --git a/src/fenrirscreenreader/commands/commands/clear_bookmark_5.py b/src/fenrirscreenreader/commands/commands/clear_bookmark_5.py deleted file mode 100644 index 4bd4c68c..00000000 --- a/src/fenrirscreenreader/commands/commands/clear_bookmark_5.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_base_path = os.path.join(os.path.dirname(__file__), "bookmark_base.py") -_spec = importlib.util.spec_from_file_location("bookmark_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -bookmark_command = _module.bookmark_command - - -class command(bookmark_command): - def __init__(self): - super().__init__(5, "clear") diff --git a/src/fenrirscreenreader/commands/commands/clear_bookmark_6.py b/src/fenrirscreenreader/commands/commands/clear_bookmark_6.py deleted file mode 100644 index 8866a407..00000000 --- a/src/fenrirscreenreader/commands/commands/clear_bookmark_6.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_base_path = os.path.join(os.path.dirname(__file__), "bookmark_base.py") -_spec = importlib.util.spec_from_file_location("bookmark_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -bookmark_command = _module.bookmark_command - - -class command(bookmark_command): - def __init__(self): - super().__init__(6, "clear") diff --git a/src/fenrirscreenreader/commands/commands/clear_bookmark_7.py b/src/fenrirscreenreader/commands/commands/clear_bookmark_7.py deleted file mode 100644 index 27e365b6..00000000 --- a/src/fenrirscreenreader/commands/commands/clear_bookmark_7.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_base_path = os.path.join(os.path.dirname(__file__), "bookmark_base.py") -_spec = importlib.util.spec_from_file_location("bookmark_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -bookmark_command = _module.bookmark_command - - -class command(bookmark_command): - def __init__(self): - super().__init__(7, "clear") diff --git a/src/fenrirscreenreader/commands/commands/clear_bookmark_8.py b/src/fenrirscreenreader/commands/commands/clear_bookmark_8.py deleted file mode 100644 index 36de85b0..00000000 --- a/src/fenrirscreenreader/commands/commands/clear_bookmark_8.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_base_path = os.path.join(os.path.dirname(__file__), "bookmark_base.py") -_spec = importlib.util.spec_from_file_location("bookmark_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -bookmark_command = _module.bookmark_command - - -class command(bookmark_command): - def __init__(self): - super().__init__(8, "clear") diff --git a/src/fenrirscreenreader/commands/commands/clear_bookmark_9.py b/src/fenrirscreenreader/commands/commands/clear_bookmark_9.py deleted file mode 100644 index b7162589..00000000 --- a/src/fenrirscreenreader/commands/commands/clear_bookmark_9.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_base_path = os.path.join(os.path.dirname(__file__), "bookmark_base.py") -_spec = importlib.util.spec_from_file_location("bookmark_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -bookmark_command = _module.bookmark_command - - -class command(bookmark_command): - def __init__(self): - super().__init__(9, "clear") diff --git a/src/fenrirscreenreader/commands/commands/clear_clipboard.py b/src/fenrirscreenreader/commands/commands/clear_clipboard.py deleted file mode 100644 index 04767a34..00000000 --- a/src/fenrirscreenreader/commands/commands/clear_clipboard.py +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("clears the currently selected clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].clear_current_index_list( - "clipboardHistory" - ) - self.env["runtime"]["OutputManager"].present_text( - _("clipboard cleared"), interrupt=True - ) - return - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/clear_window_application.py b/src/fenrirscreenreader/commands/commands/clear_window_application.py deleted file mode 100644 index e36f4052..00000000 --- a/src/fenrirscreenreader/commands/commands/clear_window_application.py +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Turn off window mode for application") - - def run(self): - if self.env["runtime"]["CursorManager"].clear_window_for_application(): - curr_app = self.env["runtime"][ - "ApplicationManager" - ].get_current_application() - self.env["runtime"]["OutputManager"].present_text( - _("Window Mode off for application {0}").format( - curr_app, - ), - interrupt=True, - ) - else: - self.env["runtime"]["OutputManager"].present_text( - _("Not in window Mode"), interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/copy_last_echo_to_clipboard.py b/src/fenrirscreenreader/commands/commands/copy_last_echo_to_clipboard.py deleted file mode 100644 index f148e827..00000000 --- a/src/fenrirscreenreader/commands/commands/copy_last_echo_to_clipboard.py +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import mark_utils - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("copies last presented text to the clipboard") - - def run(self): - last_echo = self.env["runtime"]["OutputManager"].get_last_echo() - if last_echo.rstrip() != "": - last_echo = last_echo.rstrip() - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", last_echo - ) - self.env["runtime"]["OutputManager"].present_text( - last_echo, sound_icon="CopyToClipboard", interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/copy_marked_to_clipboard.py b/src/fenrirscreenreader/commands/commands/copy_marked_to_clipboard.py deleted file mode 100644 index 26040c9f..00000000 --- a/src/fenrirscreenreader/commands/commands/copy_marked_to_clipboard.py +++ /dev/null @@ -1,82 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import mark_utils - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("copies marked text to the currently selected clipboard") - - def get_text_from_screen(self, start_mark, end_mark): - screen_content = self.env["screen"]["new_content_text"] - screen_lines = screen_content.split("\n") - - start_y = min(start_mark["y"], len(screen_lines) - 1) - end_y = min(end_mark["y"], len(screen_lines) - 1) - - # If marks are on the same line - if start_y == end_y: - line = screen_lines[start_y] - start_x = min(start_mark["x"], len(line)) - end_x = min(end_mark["x"], len(line)) + 1 - return line[start_x:end_x] - - # Handle multi-line selection - result = [] - - # First line (from start mark to end of line) - first_line = screen_lines[start_y] - start_x = min(start_mark["x"], len(first_line)) - result.append(first_line[start_x:].rstrip()) - - # Middle lines (complete lines) - for lineNum in range(start_y + 1, end_y): - result.append(screen_lines[lineNum].rstrip()) - - # Last line (from start to end mark) - if end_y > start_y: - last_line = screen_lines[end_y] - end_x = min(end_mark["x"], len(last_line)) + 1 - result.append(last_line[:end_x].rstrip()) - - return "\n".join(result) - - def run(self): - if not self.env["commandBuffer"]["Marks"]["1"]: - self.env["runtime"]["OutputManager"].present_text( - _("One or two marks are needed"), interrupt=True - ) - return - if not self.env["commandBuffer"]["Marks"]["2"]: - self.env["runtime"]["CursorManager"].set_mark() - - # use the last first and the last setted mark as range - start_mark = self.env["commandBuffer"]["Marks"]["1"].copy() - end_mark = self.env["commandBuffer"]["Marks"]["2"].copy() - - # Replace mark_utils.get_text_between_marks with our new method - marked = self.get_text_from_screen(start_mark, end_mark) - - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", marked - ) - # reset marks - self.env["runtime"]["CursorManager"].clear_marks() - self.env["runtime"]["OutputManager"].present_text( - marked, sound_icon="CopyToClipboard", interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/curr_clipboard.py b/src/fenrirscreenreader/commands/commands/curr_clipboard.py deleted file mode 100644 index 2925f6b7..00000000 --- a/src/fenrirscreenreader/commands/commands/curr_clipboard.py +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("speaks the contents of the currently selected clipboard") - - def run(self): - if self.env["runtime"]["MemoryManager"].is_index_list_empty( - "clipboardHistory" - ): - self.env["runtime"]["OutputManager"].present_text( - _("clipboard empty"), interrupt=True - ) - return - clipboard = self.env["runtime"][ - "MemoryManager" - ].get_index_list_element("clipboardHistory") - self.env["runtime"]["OutputManager"].present_text( - clipboard, interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/curr_screen.py b/src/fenrirscreenreader/commands/commands/curr_screen.py deleted file mode 100644 index 8a4f4223..00000000 --- a/src/fenrirscreenreader/commands/commands/curr_screen.py +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("reads the contents of the current screen") - - def run(self): - if self.env["screen"]["new_content_text"].isspace(): - self.env["runtime"]["OutputManager"].present_text( - _("screen is empty"), sound_icon="EmptyLine", interrupt=True - ) - else: - self.env["runtime"]["OutputManager"].present_text( - self.env["screen"]["new_content_text"], interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/curr_screen_after_cursor.py b/src/fenrirscreenreader/commands/commands/curr_screen_after_cursor.py deleted file mode 100644 index e2fb7ca0..00000000 --- a/src/fenrirscreenreader/commands/commands/curr_screen_after_cursor.py +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import mark_utils - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("reads from the cursor to the bottom of the screen") - - def run(self): - # Prefer review cursor over text cursor - cursor_pos = self.env["runtime"][ - "CursorManager" - ].get_review_or_text_cursor() - - text_after_cursor = mark_utils.get_text_after_mark( - cursor_pos, self.env["screen"]["new_content_text"] - ) - - if text_after_cursor.isspace(): - self.env["runtime"]["OutputManager"].present_text( - _("blank"), sound_icon="EmptyLine", interrupt=True - ) - else: - self.env["runtime"]["OutputManager"].present_text( - text_after_cursor, interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/curr_screen_before_cursor.py b/src/fenrirscreenreader/commands/commands/curr_screen_before_cursor.py deleted file mode 100644 index d6ab716e..00000000 --- a/src/fenrirscreenreader/commands/commands/curr_screen_before_cursor.py +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import mark_utils - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Reads from the top of the screen to the cursor position") - - def run(self): - # Prefer review cursor over text cursor - if self.env["screen"]["newCursorReview"]: - cursor_pos = self.env["screen"]["newCursorReview"].copy() - else: - cursor_pos = self.env["screen"]["new_cursor"].copy() - - text_before_cursor = mark_utils.get_text_before_mark( - cursor_pos, self.env["screen"]["new_content_text"] - ) - - if text_before_cursor.isspace(): - self.env["runtime"]["OutputManager"].present_text( - _("blank"), sound_icon="EmptyLine", interrupt=True - ) - else: - self.env["runtime"]["OutputManager"].present_text( - text_before_cursor, interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/current_quick_menu_entry.py b/src/fenrirscreenreader/commands/commands/current_quick_menu_entry.py deleted file mode 100644 index 82880896..00000000 --- a/src/fenrirscreenreader/commands/commands/current_quick_menu_entry.py +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("get current quick menu entry") - - def run(self): - menu = "" - value = "" - menu = self.env["runtime"]["QuickMenuManager"].get_current_entry() - if menu != "": - value = self.env["runtime"]["QuickMenuManager"].get_current_value() - self.env["runtime"]["OutputManager"].present_text( - menu + " " + value, interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/current_quick_menu_value.py b/src/fenrirscreenreader/commands/commands/current_quick_menu_value.py deleted file mode 100644 index 11acac5e..00000000 --- a/src/fenrirscreenreader/commands/commands/current_quick_menu_value.py +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("get current quick menu value") - - def run(self): - value = self.env["runtime"]["QuickMenuManager"].get_current_value() - self.env["runtime"]["OutputManager"].present_text( - value, interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/cursor_column.py b/src/fenrirscreenreader/commands/commands/cursor_column.py deleted file mode 100644 index 4234425d..00000000 --- a/src/fenrirscreenreader/commands/commands/cursor_column.py +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Column number for cursor") - - def run(self): - cursor_pos = self.env["runtime"][ - "CursorManager" - ].get_review_or_text_cursor() - self.env["runtime"]["OutputManager"].present_text( - str(cursor_pos["x"] + 1), interrupt=True - ) - self.env["runtime"]["OutputManager"].announce_active_cursor() - self.env["runtime"]["OutputManager"].present_text( - _(" column number"), interrupt=False - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/cursor_lineno.py b/src/fenrirscreenreader/commands/commands/cursor_lineno.py deleted file mode 100644 index 019bbe49..00000000 --- a/src/fenrirscreenreader/commands/commands/cursor_lineno.py +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Line number for cursor") - - def run(self): - cursor_pos = self.env["runtime"][ - "CursorManager" - ].get_review_or_text_cursor() - self.env["runtime"]["OutputManager"].present_text( - str(cursor_pos["y"] + 1), interrupt=True - ) - self.env["runtime"]["OutputManager"].announce_active_cursor() - self.env["runtime"]["OutputManager"].present_text( - _(" line number"), interrupt=False - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/cursor_position.py b/src/fenrirscreenreader/commands/commands/cursor_position.py deleted file mode 100644 index bd491838..00000000 --- a/src/fenrirscreenreader/commands/commands/cursor_position.py +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("displays the position of the review cursor") - - def run(self): - # Prefer review cursor over text cursor - cursor_pos = self.env["runtime"][ - "CursorManager" - ].get_review_or_text_cursor() - - self.env["runtime"]["OutputManager"].present_text( - _("line {0}, column {1}, Terminal {2}").format( - cursor_pos["y"] + 1, - cursor_pos["x"] + 1, - self.env["screen"]["newTTY"], - ), - interrupt=True, - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/cursor_read_line_to_cursor.py b/src/fenrirscreenreader/commands/commands/cursor_read_line_to_cursor.py deleted file mode 100644 index 233e7b03..00000000 --- a/src/fenrirscreenreader/commands/commands/cursor_read_line_to_cursor.py +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import line_utils - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _( - "read line to cursor pos, use review cursor if you are in review mode, " - "otherwhise use text cursor" - ) - - def run(self): - cursor_pos = self.env["runtime"][ - "CursorManager" - ].get_review_or_text_cursor() - - x, y, curr_line = line_utils.get_current_line( - cursor_pos["x"], - cursor_pos["y"], - self.env["screen"]["new_content_text"], - ) - - if curr_line.isspace(): - self.env["runtime"]["OutputManager"].present_text( - _("blank"), sound_icon="EmptyLine", interrupt=True - ) - else: - self.env["runtime"]["OutputManager"].present_text( - curr_line[: cursor_pos["x"]], interrupt=True - ) - self.env["runtime"]["OutputManager"].announce_active_cursor() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/cursor_read_to_end_of_line.py b/src/fenrirscreenreader/commands/commands/cursor_read_to_end_of_line.py deleted file mode 100644 index b8212d1e..00000000 --- a/src/fenrirscreenreader/commands/commands/cursor_read_to_end_of_line.py +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import line_utils - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _( - "read to end of line, use review cursor if you are in review mode, " - "otherwhise use text cursor" - ) - - def run(self): - cursor_pos = self.env["runtime"][ - "CursorManager" - ].get_review_or_text_cursor() - - x, y, curr_line = line_utils.get_current_line( - cursor_pos["x"], - cursor_pos["y"], - self.env["screen"]["new_content_text"], - ) - - if curr_line.isspace(): - self.env["runtime"]["OutputManager"].present_text( - _("blank"), sound_icon="EmptyLine", interrupt=True - ) - else: - self.env["runtime"]["OutputManager"].present_text( - curr_line[cursor_pos["x"] :], interrupt=True - ) - self.env["runtime"]["OutputManager"].announce_active_cursor() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/cycle_key_echo.py b/src/fenrirscreenreader/commands/commands/cycle_key_echo.py deleted file mode 100644 index 5264070e..00000000 --- a/src/fenrirscreenreader/commands/commands/cycle_key_echo.py +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Cycle through key echo modes: character, word, off") - - def run(self): - settings_manager = self.env["runtime"]["SettingsManager"] - output_manager = self.env["runtime"]["OutputManager"] - - # Get current settings - char_echo_mode = settings_manager.get_setting("keyboard", "char_echo_mode") - word_echo = settings_manager.get_setting_as_bool("keyboard", "word_echo") - - # Determine current state and cycle to next - # States: character (char=1, word=False) -> word (char=0, word=True) -> off (char=0, word=False) - if char_echo_mode == "1" and not word_echo: - # Currently character echo, switch to word echo - settings_manager.set_setting("keyboard", "char_echo_mode", "0") - settings_manager.set_setting("keyboard", "word_echo", "True") - output_manager.present_text( - _("Echo by word"), interrupt=True - ) - elif word_echo: - # Currently word echo, switch to off - settings_manager.set_setting("keyboard", "char_echo_mode", "0") - settings_manager.set_setting("keyboard", "word_echo", "False") - output_manager.present_text( - _("Echo off"), interrupt=True - ) - else: - # Currently off (or caps mode), switch to character echo - settings_manager.set_setting("keyboard", "char_echo_mode", "1") - settings_manager.set_setting("keyboard", "word_echo", "False") - output_manager.present_text( - _("Echo by character"), interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/date.py b/src/fenrirscreenreader/commands/commands/date.py deleted file mode 100644 index 3b7ae543..00000000 --- a/src/fenrirscreenreader/commands/commands/date.py +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import datetime - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("presents the date") - - def run(self): - date_format = self.env["runtime"]["SettingsManager"].get_setting( - "general", "date_format" - ) - - # get the time formatted - date_string = datetime.datetime.strftime( - datetime.datetime.now(), date_format - ) - - # present the time via speak and braile, there is no soundicon, - # interrupt the current speech - self.env["runtime"]["OutputManager"].present_text( - date_string, sound_icon="", interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/dec_sound_volume.py b/src/fenrirscreenreader/commands/commands/dec_sound_volume.py deleted file mode 100644 index 593a5c8d..00000000 --- a/src/fenrirscreenreader/commands/commands/dec_sound_volume.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_base_path = os.path.join(os.path.dirname(__file__), "adjustment_base.py") -_spec = importlib.util.spec_from_file_location("adjustment_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -adjustment_command = _module.adjustment_command - - -class command(adjustment_command): - def __init__(self): - super().__init__("sound", "volume", "dec") diff --git a/src/fenrirscreenreader/commands/commands/dec_speech_pitch.py b/src/fenrirscreenreader/commands/commands/dec_speech_pitch.py deleted file mode 100644 index edc8adf6..00000000 --- a/src/fenrirscreenreader/commands/commands/dec_speech_pitch.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_base_path = os.path.join(os.path.dirname(__file__), "adjustment_base.py") -_spec = importlib.util.spec_from_file_location("adjustment_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -adjustment_command = _module.adjustment_command - - -class command(adjustment_command): - def __init__(self): - super().__init__("speech", "pitch", "dec") diff --git a/src/fenrirscreenreader/commands/commands/dec_speech_rate.py b/src/fenrirscreenreader/commands/commands/dec_speech_rate.py deleted file mode 100644 index c043d7be..00000000 --- a/src/fenrirscreenreader/commands/commands/dec_speech_rate.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_base_path = os.path.join(os.path.dirname(__file__), "adjustment_base.py") -_spec = importlib.util.spec_from_file_location("adjustment_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -adjustment_command = _module.adjustment_command - - -class command(adjustment_command): - def __init__(self): - super().__init__("speech", "rate", "dec") diff --git a/src/fenrirscreenreader/commands/commands/dec_speech_volume.py b/src/fenrirscreenreader/commands/commands/dec_speech_volume.py deleted file mode 100644 index 95646767..00000000 --- a/src/fenrirscreenreader/commands/commands/dec_speech_volume.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_base_path = os.path.join(os.path.dirname(__file__), "adjustment_base.py") -_spec = importlib.util.spec_from_file_location("adjustment_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -adjustment_command = _module.adjustment_command - - -class command(adjustment_command): - def __init__(self): - super().__init__("speech", "volume", "dec") diff --git a/src/fenrirscreenreader/commands/commands/diff_key_help.py b/src/fenrirscreenreader/commands/commands/diff_key_help.py deleted file mode 100644 index 7fdc5cea..00000000 --- a/src/fenrirscreenreader/commands/commands/diff_key_help.py +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("speak diff review key help") - - def run(self): - self.env["runtime"]["DiffReviewManager"].present_key_help() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/diff_next_added.py b/src/fenrirscreenreader/commands/commands/diff_next_added.py deleted file mode 100644 index b15b9c5f..00000000 --- a/src/fenrirscreenreader/commands/commands/diff_next_added.py +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("move to next added diff line") - - def run(self): - self.env["runtime"]["DiffReviewManager"].next_added() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/diff_next_char.py b/src/fenrirscreenreader/commands/commands/diff_next_char.py deleted file mode 100644 index 0d109302..00000000 --- a/src/fenrirscreenreader/commands/commands/diff_next_char.py +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("move to next character in diff line") - - def run(self): - self.env["runtime"]["DiffReviewManager"].next_char() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/diff_next_file_section.py b/src/fenrirscreenreader/commands/commands/diff_next_file_section.py deleted file mode 100644 index d459d959..00000000 --- a/src/fenrirscreenreader/commands/commands/diff_next_file_section.py +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("move to next diff file section") - - def run(self): - self.env["runtime"]["DiffReviewManager"].next_file_section() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/diff_next_hunk.py b/src/fenrirscreenreader/commands/commands/diff_next_hunk.py deleted file mode 100644 index 477f894d..00000000 --- a/src/fenrirscreenreader/commands/commands/diff_next_hunk.py +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("move to next diff hunk") - - def run(self): - self.env["runtime"]["DiffReviewManager"].next_hunk() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/diff_next_line.py b/src/fenrirscreenreader/commands/commands/diff_next_line.py deleted file mode 100644 index bec32ae9..00000000 --- a/src/fenrirscreenreader/commands/commands/diff_next_line.py +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("move to next diff line") - - def run(self): - self.env["runtime"]["DiffReviewManager"].next_line() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/diff_next_removed.py b/src/fenrirscreenreader/commands/commands/diff_next_removed.py deleted file mode 100644 index 6778d433..00000000 --- a/src/fenrirscreenreader/commands/commands/diff_next_removed.py +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("move to next removed diff line") - - def run(self): - self.env["runtime"]["DiffReviewManager"].next_removed() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/diff_next_word.py b/src/fenrirscreenreader/commands/commands/diff_next_word.py deleted file mode 100644 index 0741f2c8..00000000 --- a/src/fenrirscreenreader/commands/commands/diff_next_word.py +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("move to next word in diff line") - - def run(self): - self.env["runtime"]["DiffReviewManager"].next_word() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/diff_prev_added.py b/src/fenrirscreenreader/commands/commands/diff_prev_added.py deleted file mode 100644 index 507e2acd..00000000 --- a/src/fenrirscreenreader/commands/commands/diff_prev_added.py +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("move to previous added diff line") - - def run(self): - self.env["runtime"]["DiffReviewManager"].prev_added() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/diff_prev_char.py b/src/fenrirscreenreader/commands/commands/diff_prev_char.py deleted file mode 100644 index 24d0d256..00000000 --- a/src/fenrirscreenreader/commands/commands/diff_prev_char.py +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("move to previous character in diff line") - - def run(self): - self.env["runtime"]["DiffReviewManager"].prev_char() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/diff_prev_file_section.py b/src/fenrirscreenreader/commands/commands/diff_prev_file_section.py deleted file mode 100644 index 1c9ca8ff..00000000 --- a/src/fenrirscreenreader/commands/commands/diff_prev_file_section.py +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("move to previous diff file section") - - def run(self): - self.env["runtime"]["DiffReviewManager"].prev_file_section() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/diff_prev_hunk.py b/src/fenrirscreenreader/commands/commands/diff_prev_hunk.py deleted file mode 100644 index d25226bc..00000000 --- a/src/fenrirscreenreader/commands/commands/diff_prev_hunk.py +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("move to previous diff hunk") - - def run(self): - self.env["runtime"]["DiffReviewManager"].prev_hunk() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/diff_prev_line.py b/src/fenrirscreenreader/commands/commands/diff_prev_line.py deleted file mode 100644 index 971736ef..00000000 --- a/src/fenrirscreenreader/commands/commands/diff_prev_line.py +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("move to previous diff line") - - def run(self): - self.env["runtime"]["DiffReviewManager"].prev_line() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/diff_prev_removed.py b/src/fenrirscreenreader/commands/commands/diff_prev_removed.py deleted file mode 100644 index 6f3af2e1..00000000 --- a/src/fenrirscreenreader/commands/commands/diff_prev_removed.py +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("move to previous removed diff line") - - def run(self): - self.env["runtime"]["DiffReviewManager"].prev_removed() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/diff_prev_word.py b/src/fenrirscreenreader/commands/commands/diff_prev_word.py deleted file mode 100644 index c0658e6f..00000000 --- a/src/fenrirscreenreader/commands/commands/diff_prev_word.py +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("move to previous word in diff line") - - def run(self): - self.env["runtime"]["DiffReviewManager"].prev_word() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/diff_summary.py b/src/fenrirscreenreader/commands/commands/diff_summary.py deleted file mode 100644 index 83ef984c..00000000 --- a/src/fenrirscreenreader/commands/commands/diff_summary.py +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("speak diff summary") - - def run(self): - self.env["runtime"]["DiffReviewManager"].present_summary() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/exit_diff_mode.py b/src/fenrirscreenreader/commands/commands/exit_diff_mode.py deleted file mode 100644 index 862e02d6..00000000 --- a/src/fenrirscreenreader/commands/commands/exit_diff_mode.py +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("exit diff review mode") - - def run(self): - if not self.env["runtime"]["DiffReviewManager"].is_active(): - return - self.env["runtime"]["DiffReviewManager"].disable_mode() - self.env["runtime"]["DiffReviewManager"].present_mode_message( - _("Diff review mode disabled."), - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/exit_review.py b/src/fenrirscreenreader/commands/commands/exit_review.py deleted file mode 100644 index 0a9f06a3..00000000 --- a/src/fenrirscreenreader/commands/commands/exit_review.py +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("exits review mode") - - def run(self): - if not self.env["runtime"]["CursorManager"].is_review_mode(): - self.env["runtime"]["OutputManager"].present_text( - _("Not in Review Mode"), interrupt=True - ) - return - - self.env["runtime"]["CursorManager"].clear_review_cursor() - self.env["runtime"]["OutputManager"].present_text( - _("Exiting Review Mode"), interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/export_clipboard_to_file.py b/src/fenrirscreenreader/commands/commands/export_clipboard_to_file.py deleted file mode 100644 index ae89b44e..00000000 --- a/src/fenrirscreenreader/commands/commands/export_clipboard_to_file.py +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import os - -from fenrirscreenreader.core import debug -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment, script_path=""): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("export the current fenrir clipboard to a file") - - def run(self): - clipboard_file_path = self.env["runtime"][ - "SettingsManager" - ].get_setting("clipboard", "clipboard_export_path") - clipboard_file_path = clipboard_file_path.replace( - "$user", self.env["general"]["curr_user"] - ) - clipboard_file_path = clipboard_file_path.replace( - "$USER", self.env["general"]["curr_user"] - ) - clipboard_file_path = clipboard_file_path.replace( - "$User", self.env["general"]["curr_user"] - ) - clipboard_file = open(clipboard_file_path, "w") - try: - if self.env["runtime"]["MemoryManager"].is_index_list_empty( - "clipboardHistory" - ): - self.env["runtime"]["OutputManager"].present_text( - _("clipboard empty"), interrupt=True - ) - return - clipboard = self.env["runtime"][ - "MemoryManager" - ].get_index_list_element("clipboardHistory") - clipboard_file.write(clipboard) - clipboard_file.close() - os.chmod(clipboard_file_path, 0o644) - self.env["runtime"]["OutputManager"].present_text( - _("clipboard exported to file"), interrupt=True - ) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "export_clipboard_to_file:run: Filepath:" - + clipboard_file - + " trace:" - + str(e), - debug.DebugLevel.ERROR, - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/export_clipboard_to_x.py b/src/fenrirscreenreader/commands/commands/export_clipboard_to_x.py deleted file mode 100644 index 90d83169..00000000 --- a/src/fenrirscreenreader/commands/commands/export_clipboard_to_x.py +++ /dev/null @@ -1,94 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core import debug -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import x_clipboard - - -def write_clipboard(clipboard, display): - """Write the graphical clipboard without accessing Fenrir state.""" - return x_clipboard.write_text(clipboard, display, scan_displays=True) - - -class command: - def __init__(self): - self._task_ids = set() - - def initialize(self, environment, script_path=""): - self.env = environment - self.script_path = script_path - - def shutdown(self): - task_manager = self.env["runtime"].get("BackgroundTaskManager") - if task_manager: - for task_id in self._task_ids: - task_manager.cancel_task(task_id) - self._task_ids.clear() - - def get_description(self): - return _("Export current fenrir clipboard to X or GUI clipboard") - - def run(self): - try: - # Check if clipboard is empty - if self.env["runtime"]["MemoryManager"].is_index_list_empty( - "clipboardHistory" - ): - self.env["runtime"]["OutputManager"].present_text( - _("clipboard empty"), interrupt=True - ) - return - - # Get current clipboard content - clipboard = self.env["runtime"][ - "MemoryManager" - ].get_index_list_element("clipboardHistory") - display = self.env["runtime"]["SettingsManager"].get_setting( - "clipboard", "sync_display" - ) - - task_id = self.env["runtime"][ - "BackgroundTaskManager" - ].submit_task( - write_clipboard, - lambda result: self._handle_result(clipboard, result), - clipboard, - display, - ) - if task_id is not None: - self._task_ids.add(task_id) - - except Exception as e: - self.env["runtime"]["OutputManager"].present_text( - str(e), sound_icon="", interrupt=False - ) - - def _handle_result(self, clipboard, result): - self._task_ids.discard(result.get("task_id")) - if result.get("succeeded") and result.get("value"): - sync_manager = self.env["runtime"].get("ClipboardSyncManager") - if sync_manager: - sync_manager.mark_written_to_x(clipboard) - self.env["runtime"]["OutputManager"].present_text( - _("exported to the X session."), interrupt=True - ) - return - - if not result.get("succeeded") and result.get("error"): - self.env["runtime"]["DebugManager"].write_debug_out( - "Clipboard export failed: " + result["error"], - debug.DebugLevel.ERROR, - ) - self.env["runtime"]["OutputManager"].present_text( - _( - "failed to export to X clipboard. No available display found." - ), - interrupt=True, - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/first_clipboard.py b/src/fenrirscreenreader/commands/commands/first_clipboard.py deleted file mode 100644 index 749d9eb3..00000000 --- a/src/fenrirscreenreader/commands/commands/first_clipboard.py +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("selects the first clipboard") - - def run(self): - if self.env["runtime"]["MemoryManager"].is_index_list_empty( - "clipboardHistory" - ): - self.env["runtime"]["OutputManager"].present_text( - _("clipboard empty"), interrupt=True - ) - return - self.env["runtime"]["MemoryManager"].set_first_index( - "clipboardHistory" - ) - clipboard = self.env["runtime"][ - "MemoryManager" - ].get_index_list_element("clipboardHistory") - self.env["runtime"]["OutputManager"].present_text( - clipboard, interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/forward_keypress.py b/src/fenrirscreenreader/commands/commands/forward_keypress.py deleted file mode 100644 index acc67228..00000000 --- a/src/fenrirscreenreader/commands/commands/forward_keypress.py +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("sends the following keypress to the terminal or application") - - def run(self): - if self.env["runtime"]["InputManager"].no_key_pressed(): - self.env["input"]["key_forward"] = 1 - else: - self.env["input"]["key_forward"] = -1 - self.env["runtime"]["OutputManager"].present_text( - _("Forward next keypress"), interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/import_clipboard_from_file.py b/src/fenrirscreenreader/commands/commands/import_clipboard_from_file.py deleted file mode 100644 index a3111a76..00000000 --- a/src/fenrirscreenreader/commands/commands/import_clipboard_from_file.py +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import os - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import mark_utils - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("imports text from clipboard file to the clipboard") - - def run(self): - clipboard_file_path = self.env["runtime"][ - "SettingsManager" - ].get_setting("clipboard", "clipboard_export_path") - clipboard_file_path = clipboard_file_path.replace( - "$user", self.env["general"]["curr_user"] - ) - clipboard_file_path = clipboard_file_path.replace( - "$USER", self.env["general"]["curr_user"] - ) - clipboard_file_path = clipboard_file_path.replace( - "$User", self.env["general"]["curr_user"] - ) - if not os.path.exists(clipboard_file_path): - self.env["runtime"]["OutputManager"].present_text( - _("File does not exist"), sound_icon="", interrupt=True - ) - return - clipboard_file = open(clipboard_file_path, "r") - imported = clipboard_file.read() - clipboard_file.close() - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", imported - ) - - self.env["runtime"]["OutputManager"].present_text( - _("Import to Clipboard"), sound_icon="CopyToClipboard", interrupt=True - ) - self.env["runtime"]["OutputManager"].present_text( - imported, sound_icon="", interrupt=False - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/import_clipboard_from_x.py b/src/fenrirscreenreader/commands/commands/import_clipboard_from_x.py deleted file mode 100644 index 3eb9c1a4..00000000 --- a/src/fenrirscreenreader/commands/commands/import_clipboard_from_x.py +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import x_clipboard - - -def read_clipboard(display): - """Read the graphical clipboard without accessing Fenrir state.""" - return x_clipboard.read_text(display, scan_displays=True) - - -class command: - def __init__(self): - self._task_ids = set() - - def initialize(self, environment, script_path=""): - self.env = environment - self.script_path = script_path - - def shutdown(self): - task_manager = self.env["runtime"].get("BackgroundTaskManager") - if task_manager: - for task_id in self._task_ids: - task_manager.cancel_task(task_id) - self._task_ids.clear() - - def get_description(self): - return _("imports the graphical clipboard to Fenrir's clipboard") - - def run(self): - display = self.env["runtime"]["SettingsManager"].get_setting( - "clipboard", "sync_display" - ) - task_id = self.env["runtime"]["BackgroundTaskManager"].submit_task( - read_clipboard, self._handle_result, display - ) - if task_id is not None: - self._task_ids.add(task_id) - - def _handle_result(self, result): - self._task_ids.discard(result.get("task_id")) - clipboard_content = result.get("value") - if not result.get("succeeded"): - self.env["runtime"]["OutputManager"].present_text( - result.get("error", _("Clipboard import failed")), - sound_icon="", - interrupt=False, - ) - return - if not clipboard_content or not isinstance(clipboard_content, str): - self.env["runtime"]["OutputManager"].present_text( - _("No text found in clipboard or no accessible display"), - interrupt=True, - ) - return - - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", clipboard_content - ) - sync_manager = self.env["runtime"].get("ClipboardSyncManager") - if sync_manager: - sync_manager.mark_imported_from_x(clipboard_content) - self.env["runtime"]["OutputManager"].present_text( - _("Import to Clipboard"), - sound_icon="CopyToClipboard", - interrupt=True, - ) - self.env["runtime"]["OutputManager"].present_text( - clipboard_content, sound_icon="", interrupt=False - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/inc_sound_volume.py b/src/fenrirscreenreader/commands/commands/inc_sound_volume.py deleted file mode 100644 index 7c7599dc..00000000 --- a/src/fenrirscreenreader/commands/commands/inc_sound_volume.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_base_path = os.path.join(os.path.dirname(__file__), "adjustment_base.py") -_spec = importlib.util.spec_from_file_location("adjustment_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -adjustment_command = _module.adjustment_command - - -class command(adjustment_command): - def __init__(self): - super().__init__("sound", "volume", "inc") diff --git a/src/fenrirscreenreader/commands/commands/inc_speech_pitch.py b/src/fenrirscreenreader/commands/commands/inc_speech_pitch.py deleted file mode 100644 index f59f63c5..00000000 --- a/src/fenrirscreenreader/commands/commands/inc_speech_pitch.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_base_path = os.path.join(os.path.dirname(__file__), "adjustment_base.py") -_spec = importlib.util.spec_from_file_location("adjustment_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -adjustment_command = _module.adjustment_command - - -class command(adjustment_command): - def __init__(self): - super().__init__("speech", "pitch", "inc") diff --git a/src/fenrirscreenreader/commands/commands/inc_speech_rate.py b/src/fenrirscreenreader/commands/commands/inc_speech_rate.py deleted file mode 100644 index 074c3ba9..00000000 --- a/src/fenrirscreenreader/commands/commands/inc_speech_rate.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_base_path = os.path.join(os.path.dirname(__file__), "adjustment_base.py") -_spec = importlib.util.spec_from_file_location("adjustment_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -adjustment_command = _module.adjustment_command - - -class command(adjustment_command): - def __init__(self): - super().__init__("speech", "rate", "inc") diff --git a/src/fenrirscreenreader/commands/commands/inc_speech_volume.py b/src/fenrirscreenreader/commands/commands/inc_speech_volume.py deleted file mode 100644 index 484fadd1..00000000 --- a/src/fenrirscreenreader/commands/commands/inc_speech_volume.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_base_path = os.path.join(os.path.dirname(__file__), "adjustment_base.py") -_spec = importlib.util.spec_from_file_location("adjustment_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -adjustment_command = _module.adjustment_command - - -class command(adjustment_command): - def __init__(self): - super().__init__("speech", "volume", "inc") diff --git a/src/fenrirscreenreader/commands/commands/indent_curr_line.py b/src/fenrirscreenreader/commands/commands/indent_curr_line.py deleted file mode 100644 index fb64887c..00000000 --- a/src/fenrirscreenreader/commands/commands/indent_curr_line.py +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import line_utils - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Presents the indentation level for the current line") - - def run(self): - # Prefer review cursor over text cursor - - if self.env["screen"]["newCursorReview"]: - cursor_pos = self.env["screen"]["newCursorReview"].copy() - else: - cursor_pos = self.env["screen"]["new_cursor"].copy() - x, y, curr_line = line_utils.get_current_line( - cursor_pos["x"], - cursor_pos["y"], - self.env["screen"]["new_content_text"], - ) - - if curr_line.isspace(): - self.env["runtime"]["OutputManager"].present_text( - _("blank"), sound_icon="EmptyLine", interrupt=True - ) - else: - self.env["runtime"]["OutputManager"].present_text( - _("indent {0}").format( - len(curr_line) - len(curr_line.lstrip()) - ), - interrupt=True, - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/last_clipboard.py b/src/fenrirscreenreader/commands/commands/last_clipboard.py deleted file mode 100644 index 1e7aa6a0..00000000 --- a/src/fenrirscreenreader/commands/commands/last_clipboard.py +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("selects the last clipboard") - - def run(self): - if self.env["runtime"]["MemoryManager"].is_index_list_empty( - "clipboardHistory" - ): - self.env["runtime"]["OutputManager"].present_text( - _("clipboard empty"), interrupt=True - ) - return - self.env["runtime"]["MemoryManager"].set_last_index("clipboardHistory") - clipboard = self.env["runtime"][ - "MemoryManager" - ].get_index_list_element("clipboardHistory") - self.env["runtime"]["OutputManager"].present_text( - clipboard, interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/marked_text.py b/src/fenrirscreenreader/commands/commands/marked_text.py deleted file mode 100644 index 9a0a835c..00000000 --- a/src/fenrirscreenreader/commands/commands/marked_text.py +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import mark_utils - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _( - "Presents the currently selected text that will be copied to the " - "clipboard" - ) - - def run(self): - if not ( - self.env["commandBuffer"]["Marks"]["1"] - and self.env["commandBuffer"]["Marks"]["2"] - ): - self.env["runtime"]["OutputManager"].present_text( - _("please set begin and endmark"), interrupt=True - ) - return - - # use the last first and the last setted mark as range - start_mark = self.env["commandBuffer"]["Marks"]["1"].copy() - end_mark = self.env["commandBuffer"]["Marks"]["2"].copy() - - marked = mark_utils.get_text_between_marks( - start_mark, end_mark, self.env["screen"]["new_content_text"] - ) - - if marked.isspace(): - self.env["runtime"]["OutputManager"].present_text( - _("blank"), sound_icon="EmptyLine", interrupt=True - ) - else: - self.env["runtime"]["OutputManager"].present_text( - marked, interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/next_clipboard.py b/src/fenrirscreenreader/commands/commands/next_clipboard.py deleted file mode 100644 index 31d6f9c3..00000000 --- a/src/fenrirscreenreader/commands/commands/next_clipboard.py +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("selects the next clipboard") - - def run(self): - if self.env["runtime"]["MemoryManager"].is_index_list_empty( - "clipboardHistory" - ): - self.env["runtime"]["OutputManager"].present_text( - _("clipboard empty"), interrupt=True - ) - return - self.env["runtime"]["MemoryManager"].get_next_index("clipboardHistory") - is_first = self.env["runtime"]["MemoryManager"].is_first_index( - "clipboardHistory" - ) - is_last = self.env["runtime"]["MemoryManager"].is_last_index( - "clipboardHistory" - ) - clipboard = self.env["runtime"][ - "MemoryManager" - ].get_index_list_element("clipboardHistory") - if is_first: - self.env["runtime"]["OutputManager"].present_text( - _("First clipboard "), interrupt=True - ) - if is_last: - self.env["runtime"]["OutputManager"].present_text( - _("Last clipboard "), interrupt=True - ) - - speech_interrupt = not (is_last or is_first) - self.env["runtime"]["OutputManager"].present_text( - clipboard, interrupt=speech_interrupt - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/next_quick_menu_entry.py b/src/fenrirscreenreader/commands/commands/next_quick_menu_entry.py deleted file mode 100644 index 0a66a36e..00000000 --- a/src/fenrirscreenreader/commands/commands/next_quick_menu_entry.py +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("get next quick menu entry") - - def run(self): - menu = "" - value = "" - if self.env["runtime"]["QuickMenuManager"].next_entry(): - menu = self.env["runtime"]["QuickMenuManager"].get_current_entry() - if menu != "": - value = self.env["runtime"][ - "QuickMenuManager" - ].get_current_value() - self.env["runtime"]["OutputManager"].present_text( - menu + " " + value, interrupt=True - ) - else: - self.env["runtime"]["OutputManager"].present_text( - _("Quick menu not available"), interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/next_quick_menu_value.py b/src/fenrirscreenreader/commands/commands/next_quick_menu_value.py deleted file mode 100644 index 1ce866cf..00000000 --- a/src/fenrirscreenreader/commands/commands/next_quick_menu_value.py +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("get next quick menu value") - - def run(self): - if self.env["runtime"]["QuickMenuManager"].next_value(): - value = self.env["runtime"]["QuickMenuManager"].get_current_value() - self.env["runtime"]["OutputManager"].present_text( - value, interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/paste_clipboard.py b/src/fenrirscreenreader/commands/commands/paste_clipboard.py deleted file mode 100644 index 3568432d..00000000 --- a/src/fenrirscreenreader/commands/commands/paste_clipboard.py +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import time - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - self.env["runtime"]["MemoryManager"].add_index_list( - "clipboardHistory", - self.env["runtime"]["SettingsManager"].get_setting_as_int( - "clipboard", "number_of_clipboards" - ), - ) - - def shutdown(self): - pass - - def get_description(self): - return _("pastes the text from the currently selected clipboard") - - def run(self): - if self.env["runtime"]["MemoryManager"].is_index_list_empty( - "clipboardHistory" - ): - self.env["runtime"]["OutputManager"].present_text( - _("clipboard empty"), interrupt=True - ) - return - self.env["runtime"]["OutputManager"].present_text( - _("paste clipboard"), - sound_icon="PasteClipboardOnScreen", - interrupt=True, - ) - clipboard = self.env["runtime"][ - "MemoryManager" - ].get_index_list_element("clipboardHistory") - self.env["runtime"]["ScreenManager"].inject_text_to_screen(clipboard) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/present_first_line.py b/src/fenrirscreenreader/commands/commands/present_first_line.py deleted file mode 100644 index 9eb3d3ea..00000000 --- a/src/fenrirscreenreader/commands/commands/present_first_line.py +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import line_utils - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("present first line") - - def run(self): - x, y, first_line = line_utils.get_current_line( - 0, 0, self.env["screen"]["new_content_text"] - ) - - if first_line.isspace(): - self.env["runtime"]["OutputManager"].present_text( - _("blank"), sound_icon="EmptyLine", interrupt=True - ) - else: - self.env["runtime"]["OutputManager"].present_text( - first_line, interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/present_last_line.py b/src/fenrirscreenreader/commands/commands/present_last_line.py deleted file mode 100644 index 228879c6..00000000 --- a/src/fenrirscreenreader/commands/commands/present_last_line.py +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import line_utils - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("current line") - - def run(self): - x, y, last_line = line_utils.get_current_line( - 0, - self.env["screen"]["lines"] - 1, - self.env["screen"]["new_content_text"], - ) - - if last_line.isspace(): - self.env["runtime"]["OutputManager"].present_text( - _("blank"), sound_icon="EmptyLine", interrupt=True - ) - else: - self.env["runtime"]["OutputManager"].present_text( - last_line, interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/prev_clipboard.py b/src/fenrirscreenreader/commands/commands/prev_clipboard.py deleted file mode 100644 index 6fd54902..00000000 --- a/src/fenrirscreenreader/commands/commands/prev_clipboard.py +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("selects the previous clipboard") - - def run(self): - if self.env["runtime"]["MemoryManager"].is_index_list_empty( - "clipboardHistory" - ): - self.env["runtime"]["OutputManager"].present_text( - _("clipboard empty"), interrupt=True - ) - return - self.env["runtime"]["MemoryManager"].set_pref_index("clipboardHistory") - is_first = self.env["runtime"]["MemoryManager"].is_first_index( - "clipboardHistory" - ) - is_last = self.env["runtime"]["MemoryManager"].is_last_index( - "clipboardHistory" - ) - clipboard = self.env["runtime"][ - "MemoryManager" - ].get_index_list_element("clipboardHistory") - if is_first: - self.env["runtime"]["OutputManager"].present_text( - _("First clipboard "), interrupt=True - ) - if is_last: - self.env["runtime"]["OutputManager"].present_text( - _("Last clipboard "), interrupt=True - ) - speech_interrupt = not (is_last or is_first) - self.env["runtime"]["OutputManager"].present_text( - clipboard, interrupt=speech_interrupt - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/prev_quick_menu_entry.py b/src/fenrirscreenreader/commands/commands/prev_quick_menu_entry.py deleted file mode 100644 index 0a288416..00000000 --- a/src/fenrirscreenreader/commands/commands/prev_quick_menu_entry.py +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("get previous quick menu entry") - - def run(self): - menu = "" - value = "" - if self.env["runtime"]["QuickMenuManager"].prev_entry(): - menu = self.env["runtime"]["QuickMenuManager"].get_current_entry() - if menu != "": - value = self.env["runtime"][ - "QuickMenuManager" - ].get_current_value() - self.env["runtime"]["OutputManager"].present_text( - menu + " " + value, interrupt=True - ) - else: - self.env["runtime"]["OutputManager"].present_text( - _("Quick menu not available"), interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/prev_quick_menu_value.py b/src/fenrirscreenreader/commands/commands/prev_quick_menu_value.py deleted file mode 100644 index aa28e124..00000000 --- a/src/fenrirscreenreader/commands/commands/prev_quick_menu_value.py +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("get previous quick menu value") - - def run(self): - if self.env["runtime"]["QuickMenuManager"].prev_value(): - value = self.env["runtime"]["QuickMenuManager"].get_current_value() - self.env["runtime"]["OutputManager"].present_text( - value, interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/progress_bar_monitor.py b/src/fenrirscreenreader/commands/commands/progress_bar_monitor.py deleted file mode 100644 index e70cacf6..00000000 --- a/src/fenrirscreenreader/commands/commands/progress_bar_monitor.py +++ /dev/null @@ -1,255 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import re -import threading -import time - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - # Use commandBuffer like other commands - if "progress_monitoring" not in self.env["commandBuffer"]: - # Check if progress monitoring should be enabled by default from - # settings - try: - default_enabled = self.env["runtime"][ - "SettingsManager" - ].get_setting_as_bool("sound", "progress_monitoring") - except Exception as e: - # If setting doesn't exist, default to False - default_enabled = False - self.env["commandBuffer"]["progress_monitoring"] = default_enabled - self.env["commandBuffer"]["lastProgressTime"] = 0 - self.env["commandBuffer"]["lastProgressValue"] = -1 - - def shutdown(self): - self.stop_progress_monitoring() - - def get_description(self): - return _("Toggle progress bar monitoring with ascending tones") - - def run(self): - # Check if commandBuffer exists - if "progress_monitoring" not in self.env["commandBuffer"]: - self.env["commandBuffer"]["progress_monitoring"] = False - self.env["commandBuffer"]["lastProgressTime"] = 0 - self.env["commandBuffer"]["lastProgressValue"] = -1 - - if self.env["commandBuffer"]["progress_monitoring"]: - self.stop_progress_monitoring() - self.env["runtime"]["OutputManager"].present_text( - _("Progress monitoring disabled"), interrupt=True - ) - else: - self.start_progress_monitoring() - self.env["runtime"]["OutputManager"].present_text( - _("Progress monitoring enabled"), interrupt=True - ) - - def start_progress_monitoring(self): - self.env["commandBuffer"]["progress_monitoring"] = True - self.env["commandBuffer"]["lastProgressTime"] = time.time() - self.env["commandBuffer"]["lastProgressValue"] = -1 - # Don't control speech - let user decide with silence_until_prompt - - def stop_progress_monitoring(self): - self.env["commandBuffer"]["progress_monitoring"] = False - # Don't control speech - progress monitor is beep-only - - def detect_progress(self, text): - if not self.env["commandBuffer"]["progress_monitoring"]: - return - - # Skip progress detection if current screen looks like a prompt - if self.is_current_line_prompt(): - return - - current_time = time.time() - - # Pattern 1: Percentage (50%, 25.5%, etc.) - percent_match = re.search(r"(\d+(?:\.\d+)?)\s*%", text) - if percent_match: - percentage = float(percent_match.group(1)) - if percentage != self.env["commandBuffer"]["lastProgressValue"]: - self.play_progress_tone(percentage) - self.env["commandBuffer"]["lastProgressValue"] = percentage - self.env["commandBuffer"]["lastProgressTime"] = current_time - return - - # Pattern 2: Fraction (15/100, 3 of 10, etc.) - fraction_match = re.search(r"(\d+)\s*(?:of|/)\s*(\d+)", text) - if fraction_match: - current = int(fraction_match.group(1)) - total = int(fraction_match.group(2)) - if total > 0: - percentage = (current / total) * 100 - if ( - percentage - != self.env["commandBuffer"]["lastProgressValue"] - ): - self.play_progress_tone(percentage) - self.env["commandBuffer"]["lastProgressValue"] = percentage - self.env["commandBuffer"][ - "lastProgressTime" - ] = current_time - return - - # Pattern 3: Progress bars ([#### ], [====> ], etc.) - # Improved pattern to avoid matching IRC channels like [#channel] - bar_match = re.search(r"\[([#=\-\*]+)([\s\.]*)\]", text) - if bar_match: - filled = len(bar_match.group(1)) - unfilled = len(bar_match.group(2)) - total = filled + unfilled - # Require at least 2 progress chars total and unfilled portion must - # be spaces/dots - if total >= 2 and ( - not bar_match.group(2) - or re.match(r"^[\s\.]*$", bar_match.group(2)) - ): - percentage = (filled / total) * 100 - if ( - percentage - != self.env["commandBuffer"]["lastProgressValue"] - ): - self.play_progress_tone(percentage) - self.env["commandBuffer"]["lastProgressValue"] = percentage - self.env["commandBuffer"][ - "lastProgressTime" - ] = current_time - return - - # Pattern 4: Generic activity indicators (Loading..., Working..., etc.) - activity_pattern = re.search( - ( - r"(loading|processing|working|installing|downloading|" - r"compiling|building).*\.{2,}" - ), - text, - re.IGNORECASE, - ) - if activity_pattern: - # Play a steady beep every 2 seconds for ongoing activity - if ( - current_time - self.env["commandBuffer"]["lastProgressTime"] - >= 2.0 - ): - self.play_activity_beep() - self.env["commandBuffer"]["lastProgressTime"] = current_time - - def play_progress_tone(self, percentage): - # Map 0-100% to 400-1200Hz frequency range - frequency = 400 + (percentage * 8) - frequency = max(400, min(1200, frequency)) # Clamp to safe range - self.env["runtime"]["OutputManager"].play_frequence( - frequency, 0.15, interrupt=False - ) - - def play_activity_beep(self): - # Single tone for generic activity - self.env["runtime"]["OutputManager"].play_frequence( - 800, 0.1, interrupt=False - ) - - def is_current_line_prompt(self): - """Check if the current line looks like a standalone prompt (not command with progress)""" - import re - - try: - # Get the current screen content - if not self.env["screen"]["new_content_text"]: - return False - - lines = self.env["screen"]["new_content_text"].split("\n") - if not lines: - return False - - # Check the last line (most common) and current cursor line for - # prompt patterns - lines_to_check = [] - - # Add last line (most common for prompts) - if lines: - lines_to_check.append(lines[-1]) - - # Add current cursor line if different from last line - if ( - self.env["screen"]["new_cursor"]["y"] < len(lines) - and self.env["screen"]["new_cursor"]["y"] != len(lines) - 1 - ): - lines_to_check.append( - lines[self.env["screen"]["new_cursor"]["y"]] - ) - - # Standalone prompt patterns (no commands mixed in) - standalone_prompt_patterns = [ - r"^\s*\$\s*$", # Just $ (with whitespace) - r"^\s*#\s*$", # Just # (with whitespace) - r"^\s*>\s*$", # Just > (with whitespace) - r"^\[.*\]\s*[\\\$#>]\s*$", # [path]$ without commands - r"^[a-zA-Z0-9._-]+[\\\$#>]\s*$", # bash-5.1$ without commands - # Interactive prompt patterns (these ARE standalone) - r".*\?\s*\[[YyNn]/[YyNn]\]\s*$", # ? [Y/n] or ? [y/N] style - r".*\?\s*\[[Yy]es/[Nn]o\]\s*$", # ? [Yes/No] style - # "continue? [Y/n]" style - r".*continue\?\s*\[[YyNn]/[YyNn]\].*$", - r"^::.*\?\s*\[[YyNn]/[YyNn]\].*$", # pacman style prompts - # Authentication prompts (these ARE standalone) - r"^\[[Ss]udo\]\s*[Pp]assword\s*for\s+.*:\s*$", # [sudo] password - r"^[Pp]assword\s*:\s*$", # Password: - r".*[Pp]assword\s*:\s*$", # general password prompts - # Continuation prompts (these ARE standalone) - r"^[Pp]ress\s+any\s+key\s+to\s+continue.*$", # Press any key - r"^[Aa]re\s+you\s+sure\?\s*.*$", # Are you sure? - ] - - for line in lines_to_check: - line = line.strip() - if not line: - continue - - # Check if this line contains both a prompt AND other content (like commands) - # If so, don't treat it as a standalone prompt - has_prompt_marker = bool( - re.search(r".*@.*[\\\$#>]", line) - or re.search(r"^\[.*\]\s*[\\\$#>]", line) - ) - if has_prompt_marker: - # If line has prompt marker but also has significant content after it, - # it's a command line, not a standalone prompt - prompt_end = max( - line.rfind("$"), - line.rfind("#"), - line.rfind(">"), - line.rfind("\\"), - ) - if ( - prompt_end >= 0 and prompt_end < len(line) - 5 - ): # More than just whitespace after prompt - continue # This is a command line, not a standalone prompt - - for pattern in standalone_prompt_patterns: - try: - if re.search(pattern, line): - return True - except re.error: - continue - - return False - - except Exception as e: - # If anything fails, assume it's not a prompt to be safe - return False - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/quit_fenrir.py b/src/fenrirscreenreader/commands/commands/quit_fenrir.py deleted file mode 100644 index d103bf77..00000000 --- a/src/fenrirscreenreader/commands/commands/quit_fenrir.py +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("exits Fenrir") - - def run(self): - self.env["runtime"]["EventManager"].stop_main_event_loop() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/read_all_by_line.py b/src/fenrirscreenreader/commands/commands/read_all_by_line.py deleted file mode 100644 index 9f02bf1b..00000000 --- a/src/fenrirscreenreader/commands/commands/read_all_by_line.py +++ /dev/null @@ -1,70 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.core import debug - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Read all text line by line from current position") - - def run(self): - self.env["runtime"]["OutputManager"].present_text( - _("Read-all functionality temporarily disabled"), - interrupt=True - ) - return - # Check if speechd is available for callbacks - try: - speech_driver = self.env['runtime']['SpeechDriver'] - if not (hasattr(speech_driver, '_sd') and speech_driver._sd): - self.env["runtime"]["OutputManager"].present_text( - _("Read all requires speech-dispatcher - not available with current speech driver"), - interrupt=True - ) - return - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - f"ReadAllByLine run: Error checking speech driver: {e}", - debug.DebugLevel.ERROR - ) - return - - # Check if ReadAllManager is available - if "ReadAllManager" not in self.env["runtime"]: - self.env["runtime"]["OutputManager"].present_text( - _("Read all manager not in runtime"), - interrupt=True - ) - return - elif not self.env["runtime"]["ReadAllManager"]: - self.env["runtime"]["OutputManager"].present_text( - _("Read all manager is None"), - interrupt=True - ) - return - - # Start continuous line-by-line reading - if self.env["runtime"]["ReadAllManager"].is_active(): - # Stop if already active - self.env["runtime"]["ReadAllManager"].stop_read_all() - else: - # Start line-by-line reading - self.env["runtime"]["ReadAllManager"].start_read_all('line') - - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/read_all_by_page.py b/src/fenrirscreenreader/commands/commands/read_all_by_page.py deleted file mode 100644 index 1b06ef5e..00000000 --- a/src/fenrirscreenreader/commands/commands/read_all_by_page.py +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.core import debug - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Read all text page by page from current position") - - def run(self): - # Check if speechd is available for callbacks - try: - speech_driver = self.env['runtime']['SpeechDriver'] - if not (hasattr(speech_driver, '_sd') and speech_driver._sd): - self.env["runtime"]["OutputManager"].present_text( - _("Read all requires speech-dispatcher - not available with current speech driver"), - interrupt=True - ) - return - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - f"ReadAllByPage run: Error checking speech driver: {e}", - debug.DebugLevel.ERROR - ) - return - - # Check if ReadAllManager is available - if "ReadAllManager" not in self.env["runtime"]: - self.env["runtime"]["OutputManager"].present_text( - _("Read all manager not in runtime"), - interrupt=True - ) - return - elif not self.env["runtime"]["ReadAllManager"]: - self.env["runtime"]["OutputManager"].present_text( - _("Read all manager is None"), - interrupt=True - ) - return - - # Start continuous page-by-page reading - if self.env["runtime"]["ReadAllManager"].is_active(): - # Stop if already active - self.env["runtime"]["ReadAllManager"].stop_read_all() - else: - # Start page-by-page reading - self.env["runtime"]["ReadAllManager"].start_read_all('page') - - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/remove_marks.py b/src/fenrirscreenreader/commands/commands/remove_marks.py deleted file mode 100644 index cde557a6..00000000 --- a/src/fenrirscreenreader/commands/commands/remove_marks.py +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Removes marks from selected text") - - def run(self): - self.env["runtime"]["CursorManager"].clear_marks() - self.env["runtime"]["OutputManager"].present_text( - _("Remove marks"), interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/remove_word_from_spell_check.py b/src/fenrirscreenreader/commands/commands/remove_word_from_spell_check.py deleted file mode 100644 index 337b3711..00000000 --- a/src/fenrirscreenreader/commands/commands/remove_word_from_spell_check.py +++ /dev/null @@ -1,97 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import string - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import word_utils - -initialized = False -try: - import enchant - - initialized = True -except Exception as e: - pass - - -class command: - def __init__(self): - self.language = "" - self.spellChecker = None - - def initialize(self, environment): - self.env = environment - self.update_spell_language() - - def shutdown(self): - pass - - def get_description(self): - return _("removes the current word from the exceptions dictionary") - - def update_spell_language(self): - self.spellChecker = enchant.Dict( - self.env["runtime"]["SettingsManager"].get_setting( - "general", "spell_check_language" - ) - ) - self.language = self.env["runtime"]["SettingsManager"].get_setting( - "general", "spell_check_language" - ) - - def run(self): - if not initialized: - self.env["runtime"]["OutputManager"].present_text( - _("pyenchant is not installed"), interrupt=True - ) - return - if ( - self.env["runtime"]["SettingsManager"].get_setting( - "general", "spell_check_language" - ) - != self.language - ): - try: - self.update_spell_language() - except Exception as e: - return - - cursor_pos = self.env["runtime"][ - "CursorManager" - ].get_review_or_text_cursor() - - # get the word - new_content = self.env["screen"]["new_content_text"].split("\n")[ - cursor_pos["y"] - ] - x, y, curr_word, end_of_screen, line_break = ( - word_utils.get_current_word(cursor_pos["x"], 0, new_content) - ) - curr_word = curr_word.strip( - string.whitespace + r'!"#$%&()*+,-./:;<=Ƃ?@[\\]^_{|}~' - ) - if not curr_word.isspace(): - if self.spellChecker.is_removed(curr_word): - self.env["runtime"]["OutputManager"].present_text( - _("{0} is not in the dictionary").format( - curr_word, - ), - sound_icon="Cancel", - interrupt=True, - ) - else: - self.spellChecker.remove(curr_word) - self.env["runtime"]["OutputManager"].present_text( - _("{0} removed").format( - curr_word, - ), - sound_icon="Accept", - interrupt=True, - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/review_bottom.py b/src/fenrirscreenreader/commands/commands/review_bottom.py deleted file mode 100644 index 5c19d2ad..00000000 --- a/src/fenrirscreenreader/commands/commands/review_bottom.py +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Move review to the bottom of the screen") - - def run(self): - self.env["screen"]["newCursorReview"] = { - "x": 0, - "y": self.env["screen"]["lines"] - 1, - } - self.env["runtime"]["OutputManager"].present_text( - _("Bottom"), interrupt=True, flush=False - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/review_curr_char.py b/src/fenrirscreenreader/commands/commands/review_curr_char.py deleted file mode 100644 index dabc7a35..00000000 --- a/src/fenrirscreenreader/commands/commands/review_curr_char.py +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import char_utils - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("presents the current character.") - - def run(self): - self.env["runtime"][ - "CursorManager" - ].enter_review_mode_curr_text_cursor() - - # In table mode, sync to cell start if cursor is outside current cell - if self.env["runtime"]["TableManager"].is_table_mode(): - table_info = self.env["runtime"]["TableManager"].get_current_table_cell_info() - if table_info: - cursor_pos = self.env["screen"]["newCursorReview"] - line_text = self.env["runtime"]["ScreenManager"].get_line_text( - cursor_pos["y"] - ) - if line_text: - column_start = self.env["runtime"]["TableManager"].get_column_start_position( - line_text, table_info["column_index"] - ) - cell_content = table_info["cell_content"] - cell_end = column_start + len(cell_content) - - # If cursor is outside the current cell, move to cell start - if ( - cursor_pos["x"] < column_start or - cursor_pos["x"] >= cell_end - ): - self.env["screen"]["newCursorReview"]["x"] = column_start - - ( - self.env["screen"]["newCursorReview"]["x"], - self.env["screen"]["newCursorReview"]["y"], - curr_char, - ) = char_utils.get_current_char( - self.env["screen"]["newCursorReview"]["x"], - self.env["screen"]["newCursorReview"]["y"], - self.env["screen"]["new_content_text"], - ) - - char_utils.present_char_for_review( - self.env, - curr_char, - interrupt=True, - announce_capital=True, - flush=False, - ) - # is has attribute it enabled? - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "general", "hasattributes" - ): - cursor_pos = self.env["screen"]["newCursorReview"] - - if not self.env["runtime"]["AttributeManager"].has_attributes( - cursor_pos - ): - return - self.env["runtime"]["OutputManager"].present_text( - _("has attribute"), sound_icon="HasAttributes", interrupt=False - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/review_curr_char_phonetic.py b/src/fenrirscreenreader/commands/commands/review_curr_char_phonetic.py deleted file mode 100644 index 153d51b7..00000000 --- a/src/fenrirscreenreader/commands/commands/review_curr_char_phonetic.py +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import char_utils - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("set review and phonetically presents the current character") - - def run(self): - self.env["runtime"][ - "CursorManager" - ].enter_review_mode_curr_text_cursor() - - ( - self.env["screen"]["newCursorReview"]["x"], - self.env["screen"]["newCursorReview"]["y"], - curr_char, - ) = char_utils.get_current_char( - self.env["screen"]["newCursorReview"]["x"], - self.env["screen"]["newCursorReview"]["y"], - self.env["screen"]["new_content_text"], - ) - - if curr_char.isspace(): - self.env["runtime"]["OutputManager"].present_text( - _("blank"), interrupt=True, flush=False - ) - else: - curr_char = char_utils.get_phonetic(curr_char) - self.env["runtime"]["OutputManager"].present_text( - curr_char, interrupt=True, announce_capital=True, flush=False - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/review_curr_line.py b/src/fenrirscreenreader/commands/commands/review_curr_line.py deleted file mode 100644 index 8ab1f96c..00000000 --- a/src/fenrirscreenreader/commands/commands/review_curr_line.py +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import line_utils - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("current line") - - def run(self): - self.env["runtime"][ - "CursorManager" - ].enter_review_mode_curr_text_cursor() - - ( - self.env["screen"]["newCursorReview"]["x"], - self.env["screen"]["newCursorReview"]["y"], - curr_line, - ) = line_utils.get_current_line( - self.env["screen"]["newCursorReview"]["x"], - self.env["screen"]["newCursorReview"]["y"], - self.env["screen"]["new_content_text"], - ) - - if curr_line.isspace(): - self.env["runtime"]["OutputManager"].present_text( - _("blank"), sound_icon="EmptyLine", interrupt=True, flush=False - ) - else: - self.env["runtime"]["OutputManager"].present_text( - curr_line, interrupt=True, flush=False - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/review_curr_word.py b/src/fenrirscreenreader/commands/commands/review_curr_word.py deleted file mode 100644 index 1e8cc372..00000000 --- a/src/fenrirscreenreader/commands/commands/review_curr_word.py +++ /dev/null @@ -1,100 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import word_utils -from fenrirscreenreader.core import debug - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("current word.") - - def run(self): - self.env["runtime"][ - "CursorManager" - ].enter_review_mode_curr_text_cursor() - - ( - self.env["screen"]["newCursorReview"]["x"], - self.env["screen"]["newCursorReview"]["y"], - curr_word, - end_of_screen, - line_break, - ) = word_utils.get_current_word( - self.env["screen"]["newCursorReview"]["x"], - self.env["screen"]["newCursorReview"]["y"], - self.env["screen"]["new_content_text"], - ) - - # Check if we're in table mode and provide table-aware output - is_table_mode = self.env["runtime"]["TableManager"].is_table_mode() - self.env["runtime"]["DebugManager"].write_debug_out( - f"review_curr_word: is_table_mode={is_table_mode}", - debug.DebugLevel.INFO - ) - if is_table_mode: - # Get current cell info using internal column tracking - table_info = ( - self.env["runtime"]["TableManager"].get_current_table_cell_info() - ) - if table_info: - # Announce with table context - cell content first, then header - output_text = ( - f"{table_info['cell_content']} {table_info['column_header']}" - ) - self.env["runtime"]["OutputManager"].present_text( - output_text, interrupt=True, flush=False - ) - return # Exit early for table mode - else: - # Fallback to regular word announcement - if curr_word.isspace(): - self.env["runtime"]["OutputManager"].present_text( - _("blank"), interrupt=True, flush=False - ) - else: - self.env["runtime"]["OutputManager"].present_text( - curr_word, interrupt=True, flush=False - ) - else: - # Regular word announcement - if curr_word.isspace(): - self.env["runtime"]["OutputManager"].present_text( - _("blank"), interrupt=True, flush=False - ) - else: - self.env["runtime"]["OutputManager"].present_text( - curr_word, interrupt=True, flush=False - ) - if end_of_screen: - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "review", "end_of_screen" - ): - self.env["runtime"]["OutputManager"].present_text( - _("end of screen"), - interrupt=True, - sound_icon="EndOfScreen", - ) - if line_break: - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "review", "line_break" - ): - self.env["runtime"]["OutputManager"].present_text( - _("line break"), interrupt=False, sound_icon="EndOfLine" - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/review_curr_word_phonetic.py b/src/fenrirscreenreader/commands/commands/review_curr_word_phonetic.py deleted file mode 100644 index 7a4c80e0..00000000 --- a/src/fenrirscreenreader/commands/commands/review_curr_word_phonetic.py +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import char_utils -from fenrirscreenreader.utils import word_utils - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Phonetically spells the current word") - - def run(self): - self.env["runtime"][ - "CursorManager" - ].enter_review_mode_curr_text_cursor() - ( - self.env["screen"]["newCursorReview"]["x"], - self.env["screen"]["newCursorReview"]["y"], - curr_word, - end_of_screen, - line_break, - ) = word_utils.get_current_word( - self.env["screen"]["newCursorReview"]["x"], - self.env["screen"]["newCursorReview"]["y"], - self.env["screen"]["new_content_text"], - ) - - if not curr_word or curr_word.isspace(): - self.env["runtime"]["OutputManager"].present_text( - _("blank"), interrupt=True, flush=False - ) - else: - first_sequence = True - for c in curr_word: - curr_char = char_utils.get_phonetic(c) - self.env["runtime"]["OutputManager"].present_text( - curr_char, - interrupt=first_sequence, - announce_capital=True, - flush=False, - ) - first_sequence = False - if end_of_screen: - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "review", "end_of_screen" - ): - self.env["runtime"]["OutputManager"].present_text( - _("end of screen"), - interrupt=True, - sound_icon="EndOfScreen", - ) - if line_break: - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "review", "line_break" - ): - self.env["runtime"]["OutputManager"].present_text( - _("line break"), interrupt=False, sound_icon="EndOfLine" - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/review_curr_word_spell.py b/src/fenrirscreenreader/commands/commands/review_curr_word_spell.py deleted file mode 100644 index b64e6cb7..00000000 --- a/src/fenrirscreenreader/commands/commands/review_curr_word_spell.py +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import char_utils -from fenrirscreenreader.utils import word_utils - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Spells the current word") - - def run(self): - self.env["runtime"][ - "CursorManager" - ].enter_review_mode_curr_text_cursor() - ( - self.env["screen"]["newCursorReview"]["x"], - self.env["screen"]["newCursorReview"]["y"], - curr_word, - end_of_screen, - line_break, - ) = word_utils.get_current_word( - self.env["screen"]["newCursorReview"]["x"], - self.env["screen"]["newCursorReview"]["y"], - self.env["screen"]["new_content_text"], - ) - - if not curr_word or curr_word.isspace(): - self.env["runtime"]["OutputManager"].present_text( - _("blank"), interrupt=True, flush=False - ) - else: - for index, curr_char in enumerate(curr_word): - char_utils.present_char_for_review( - self.env, - curr_char, - interrupt=index == 0, - announce_capital=True, - flush=False, - ) - if end_of_screen: - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "review", "end_of_screen" - ): - self.env["runtime"]["OutputManager"].present_text( - _("end of screen"), - interrupt=True, - sound_icon="EndOfScreen", - ) - if line_break: - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "review", "line_break" - ): - self.env["runtime"]["OutputManager"].present_text( - _("line break"), interrupt=False, sound_icon="EndOfLine" - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/review_down.py b/src/fenrirscreenreader/commands/commands/review_down.py deleted file mode 100644 index 25a7d3f0..00000000 --- a/src/fenrirscreenreader/commands/commands/review_down.py +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import char_utils - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Move review to the character below the current position") - - def run(self): - cursor_pos = self.env["runtime"][ - "CursorManager" - ].get_review_or_text_cursor() - ( - self.env["screen"]["newCursorReview"]["x"], - self.env["screen"]["newCursorReview"]["y"], - downChar, - end_of_screen, - ) = char_utils.get_down_char( - self.env["screen"]["newCursorReview"]["x"], - self.env["screen"]["newCursorReview"]["y"], - self.env["screen"]["new_content_text"], - ) - self.env["runtime"]["OutputManager"].present_text( - downChar, - interrupt=True, - ignore_punctuation=True, - announce_capital=True, - flush=False, - ) - if end_of_screen: - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "review", "end_of_screen" - ): - self.env["runtime"]["OutputManager"].present_text( - _("end of screen"), - interrupt=True, - sound_icon="EndOfScreen", - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/review_line_begin.py b/src/fenrirscreenreader/commands/commands/review_line_begin.py deleted file mode 100644 index 1d4f1021..00000000 --- a/src/fenrirscreenreader/commands/commands/review_line_begin.py +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import char_utils - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _( - "set review cursor to begin of current line and display the content" - ) - - def run(self): - # Check if we're in table mode first - is_table_mode = self.env["runtime"]["TableManager"].is_table_mode() - if is_table_mode: - table_info = self.env["runtime"]["TableManager"].move_to_first_cell() - if table_info: - output_text = ( - f"{table_info['cell_content']} {table_info['column_header']}" - ) - self.env["runtime"]["OutputManager"].present_text( - output_text, interrupt=True, flush=False - ) - self.env["runtime"]["OutputManager"].present_text( - _("first cell"), interrupt=False - ) - else: - self.env["runtime"]["OutputManager"].present_text( - _("no table data"), interrupt=True, flush=False - ) - return - - # Regular line begin navigation (when not in table mode) - cursor_pos = self.env["runtime"][ - "CursorManager" - ].get_review_or_text_cursor() - self.env["runtime"]["CursorManager"].set_review_cursor_position( - 0, cursor_pos["y"] - ) - ( - self.env["screen"]["newCursorReview"]["x"], - self.env["screen"]["newCursorReview"]["y"], - curr_char, - ) = char_utils.get_current_char( - self.env["screen"]["newCursorReview"]["x"], - self.env["screen"]["newCursorReview"]["y"], - self.env["screen"]["new_content_text"], - ) - - if curr_char.isspace(): - self.env["runtime"]["OutputManager"].present_text( - _("blank"), interrupt=True, flush=False - ) - else: - self.env["runtime"]["OutputManager"].present_text( - curr_char, - interrupt=True, - ignore_punctuation=True, - announce_capital=True, - flush=False, - ) - self.env["runtime"]["OutputManager"].present_text( - _("beginning of line"), interrupt=False - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/review_line_end.py b/src/fenrirscreenreader/commands/commands/review_line_end.py deleted file mode 100644 index 385ad375..00000000 --- a/src/fenrirscreenreader/commands/commands/review_line_end.py +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import char_utils - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _( - "Move Review to the end of current line and display the content" - ) - - def run(self): - # Check if we're in table mode first - is_table_mode = self.env["runtime"]["TableManager"].is_table_mode() - if is_table_mode: - table_info = self.env["runtime"]["TableManager"].move_to_last_cell() - if table_info: - output_text = ( - f"{table_info['cell_content']} {table_info['column_header']}" - ) - self.env["runtime"]["OutputManager"].present_text( - output_text, interrupt=True, flush=False - ) - self.env["runtime"]["OutputManager"].present_text( - _("last cell"), interrupt=False - ) - else: - self.env["runtime"]["OutputManager"].present_text( - _("no table data"), interrupt=True, flush=False - ) - return - - # Regular line end navigation (when not in table mode) - cursor_pos = self.env["runtime"][ - "CursorManager" - ].get_review_or_text_cursor() - self.env["runtime"]["CursorManager"].set_review_cursor_position( - self.env["screen"]["columns"] - 1, cursor_pos["y"] - ) - ( - self.env["screen"]["newCursorReview"]["x"], - self.env["screen"]["newCursorReview"]["y"], - curr_char, - ) = char_utils.get_current_char( - self.env["screen"]["newCursorReview"]["x"], - self.env["screen"]["newCursorReview"]["y"], - self.env["screen"]["new_content_text"], - ) - - self.env["runtime"]["OutputManager"].present_text( - curr_char, - interrupt=True, - ignore_punctuation=True, - announce_capital=True, - flush=False, - ) - self.env["runtime"]["OutputManager"].present_text( - _("end of line"), interrupt=False - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/review_line_first_char.py b/src/fenrirscreenreader/commands/commands/review_line_first_char.py deleted file mode 100644 index 16222cb5..00000000 --- a/src/fenrirscreenreader/commands/commands/review_line_first_char.py +++ /dev/null @@ -1,90 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import char_utils -from fenrirscreenreader.utils import line_utils - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Move Review to the first character on the line") - - def run(self): - # Check if we're in table mode first - is_table_mode = self.env["runtime"]["TableManager"].is_table_mode() - if is_table_mode: - table_info = self.env["runtime"]["TableManager"].move_to_first_char_in_cell() - if table_info: - char_utils.present_char_for_review( - self.env, - table_info['character'], - interrupt=True, - announce_capital=True, - flush=False, - ) - self.env["runtime"]["OutputManager"].present_text( - _("first character in cell {0}").format(table_info['column_header']), - interrupt=False, - ) - else: - self.env["runtime"]["OutputManager"].present_text( - _("no table data"), interrupt=True, flush=False - ) - return - - # Regular line first character navigation (when not in table mode) - cursor_pos = self.env["runtime"][ - "CursorManager" - ].get_review_or_text_cursor() - x, y, curr_line = line_utils.get_current_line( - cursor_pos["x"], - cursor_pos["y"], - self.env["screen"]["new_content_text"], - ) - if curr_line.isspace(): - self.env["runtime"]["OutputManager"].present_text( - _("line is empty"), interrupt=True - ) - return - self.env["runtime"]["CursorManager"].set_review_cursor_position( - (len(curr_line) - len(curr_line.lstrip())), cursor_pos["y"] - ) - ( - self.env["screen"]["newCursorReview"]["x"], - self.env["screen"]["newCursorReview"]["y"], - curr_char, - ) = char_utils.get_current_char( - self.env["screen"]["newCursorReview"]["x"], - self.env["screen"]["newCursorReview"]["y"], - self.env["screen"]["new_content_text"], - ) - - char_utils.present_char_for_review( - self.env, - curr_char, - interrupt=True, - announce_capital=True, - flush=False, - ) - self.env["runtime"]["OutputManager"].present_text( - _("first character in line indent {0}").format( - str(len(curr_line) - len(curr_line.lstrip())) - ), - interrupt=False, - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/review_line_last_char.py b/src/fenrirscreenreader/commands/commands/review_line_last_char.py deleted file mode 100644 index ebfb4692..00000000 --- a/src/fenrirscreenreader/commands/commands/review_line_last_char.py +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import char_utils - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Move Review to the last character on the line") - - def run(self): - # Check if we're in table mode first - is_table_mode = self.env["runtime"]["TableManager"].is_table_mode() - if is_table_mode: - table_info = self.env["runtime"]["TableManager"].move_to_last_char_in_cell() - if table_info: - char_utils.present_char_for_review( - self.env, - table_info['character'], - interrupt=True, - announce_capital=True, - flush=False, - ) - self.env["runtime"]["OutputManager"].present_text( - _("last character in cell {0}").format(table_info['column_header']), - interrupt=False, - ) - else: - self.env["runtime"]["OutputManager"].present_text( - _("no table data"), interrupt=True, flush=False - ) - return - - # Regular line last character navigation (when not in table mode) - cursor_pos = self.env["runtime"][ - "CursorManager" - ].get_review_or_text_cursor() - self.env["runtime"]["CursorManager"].set_review_cursor_position( - self.env["screen"]["columns"] - 1, cursor_pos["y"] - ) - ( - self.env["screen"]["newCursorReview"]["x"], - self.env["screen"]["newCursorReview"]["y"], - lastChar, - ) = char_utils.get_last_char_in_line( - self.env["screen"]["newCursorReview"]["y"], - self.env["screen"]["new_content_text"], - ) - - char_utils.present_char_for_review( - self.env, - lastChar, - interrupt=True, - announce_capital=True, - flush=False, - ) - self.env["runtime"]["OutputManager"].present_text( - _("last character in line"), interrupt=False - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/review_next_char.py b/src/fenrirscreenreader/commands/commands/review_next_char.py deleted file mode 100644 index 5c802e76..00000000 --- a/src/fenrirscreenreader/commands/commands/review_next_char.py +++ /dev/null @@ -1,135 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import char_utils - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Moves review to the next character") - - def run(self): - self.env["runtime"][ - "CursorManager" - ].enter_review_mode_curr_text_cursor() - - # Check if we're in table mode for bounded navigation - if self.env["runtime"]["TableManager"].is_table_mode(): - table_info = self.env["runtime"]["TableManager"].get_current_table_cell_info() - if table_info: - cursor_pos = self.env["screen"]["newCursorReview"] - line_text = self.env["runtime"]["ScreenManager"].get_line_text( - cursor_pos["y"] - ) - if line_text: - column_start = self.env["runtime"]["TableManager"].get_column_start_position( - line_text, table_info["column_index"] - ) - cell_content = table_info["cell_content"] - cell_end = column_start + len(cell_content) - - # Check if we're already at the end of the cell - if cursor_pos["x"] >= cell_end - 1: - # At cell boundary - announce end and don't move - char_utils.present_char_for_review( - self.env, - cell_content[-1] if cell_content else "", - interrupt=True, - announce_capital=True, - flush=False, - ) - self.env["runtime"]["OutputManager"].present_text( - _("end of cell"), - interrupt=False, - sound_icon="EndOfLine" - ) - return - - # Move within cell bounds - relative_pos = cursor_pos["x"] - column_start - if relative_pos < len(cell_content) - 1: - new_relative_pos = relative_pos + 1 - self.env["screen"]["newCursorReview"]["x"] = ( - column_start + new_relative_pos - ) - - # Get character at new position - if new_relative_pos < len(cell_content): - next_char = cell_content[new_relative_pos] - else: - next_char = "" - - char_utils.present_char_for_review( - self.env, - next_char, - interrupt=True, - announce_capital=True, - flush=False, - ) - return - - # Regular navigation for non-table mode - ( - self.env["screen"]["newCursorReview"]["x"], - self.env["screen"]["newCursorReview"]["y"], - next_char, - end_of_screen, - line_break, - ) = char_utils.get_next_char( - self.env["screen"]["newCursorReview"]["x"], - self.env["screen"]["newCursorReview"]["y"], - self.env["screen"]["new_content_text"], - ) - - char_utils.present_char_for_review( - self.env, - next_char, - interrupt=True, - announce_capital=True, - flush=False, - ) - if end_of_screen: - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "review", "end_of_screen" - ): - self.env["runtime"]["OutputManager"].present_text( - _("end of screen"), - interrupt=True, - sound_icon="EndOfScreen", - ) - if line_break: - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "review", "line_break" - ): - self.env["runtime"]["OutputManager"].present_text( - _("line break"), interrupt=False, sound_icon="EndOfLine" - ) - # is has attribute it enabled? - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "general", "hasattributes" - ): - cursor_pos = self.env["screen"]["newCursorReview"] - - if not self.env["runtime"]["AttributeManager"].has_attributes( - cursor_pos - ): - return - self.env["runtime"]["OutputManager"].present_text( - _("has attribute"), sound_icon="HasAttributes", interrupt=False - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/review_next_line.py b/src/fenrirscreenreader/commands/commands/review_next_line.py deleted file mode 100644 index 7ee8fb43..00000000 --- a/src/fenrirscreenreader/commands/commands/review_next_line.py +++ /dev/null @@ -1,63 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import line_utils - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("moves review to the next line ") - - def run(self): - self.env["screen"]["oldCursorReview"] = self.env["screen"][ - "newCursorReview" - ] - if not self.env["screen"]["newCursorReview"]: - self.env["screen"]["newCursorReview"] = self.env["screen"][ - "new_cursor" - ].copy() - - ( - self.env["screen"]["newCursorReview"]["x"], - self.env["screen"]["newCursorReview"]["y"], - nextLine, - end_of_screen, - ) = line_utils.get_next_line( - self.env["screen"]["newCursorReview"]["x"], - self.env["screen"]["newCursorReview"]["y"], - self.env["screen"]["new_content_text"], - ) - - if nextLine.isspace(): - self.env["runtime"]["OutputManager"].present_text( - _("blank"), sound_icon="EmptyLine", interrupt=True, flush=False - ) - else: - self.env["runtime"]["OutputManager"].present_text( - nextLine, interrupt=True, flush=False - ) - if end_of_screen: - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "review", "end_of_screen" - ): - self.env["runtime"]["OutputManager"].present_text( - _("end of screen"), - interrupt=True, - sound_icon="EndOfScreen", - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/review_next_word.py b/src/fenrirscreenreader/commands/commands/review_next_word.py deleted file mode 100644 index 46387b78..00000000 --- a/src/fenrirscreenreader/commands/commands/review_next_word.py +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import word_utils -from fenrirscreenreader.core import debug - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("moves review to the next word ") - - def run(self): - # Check if we're in table mode FIRST - before any word navigation - is_table_mode = self.env["runtime"]["TableManager"].is_table_mode() - self.env["runtime"]["DebugManager"].write_debug_out( - f"review_next_word: is_table_mode={is_table_mode}", - debug.DebugLevel.INFO - ) - if is_table_mode: - table_info = ( - self.env["runtime"]["TableManager"].move_to_next_column() - ) - if table_info and table_info.get("at_end"): - # Stay on current cell and play end of line sound - current_info = table_info["current_info"] - if current_info: - output_text = f"{current_info['cell_content']}" - self.env["runtime"]["OutputManager"].present_text( - output_text, interrupt=True, flush=False - ) - # Play end of line sound - self.env["runtime"]["OutputManager"].present_text( - _("end of line"), - interrupt=False, - sound_icon="EndOfLine" - ) - elif table_info: - # Normal column navigation - announce cell content with column info - output_text = ( - f"{table_info['cell_content']} {table_info['column_header']}" - ) - self.env["runtime"]["OutputManager"].present_text( - output_text, interrupt=True, flush=False - ) - else: - # No table info available, but still in table mode - self.env["runtime"]["OutputManager"].present_text( - _("no table data"), interrupt=True, flush=False - ) - return # ALWAYS return in table mode to prevent regular word navigation - - # Regular word navigation (only when NOT in table mode) - self.env["runtime"][ - "CursorManager" - ].enter_review_mode_curr_text_cursor() - - ( - self.env["screen"]["newCursorReview"]["x"], - self.env["screen"]["newCursorReview"]["y"], - next_word, - end_of_screen, - line_break, - ) = word_utils.get_next_word( - self.env["screen"]["newCursorReview"]["x"], - self.env["screen"]["newCursorReview"]["y"], - self.env["screen"]["new_content_text"], - ) - - # Regular word announcement - if next_word.isspace(): - self.env["runtime"]["OutputManager"].present_text( - _("blank"), interrupt=True, flush=False - ) - else: - self.env["runtime"]["OutputManager"].present_text( - next_word, interrupt=True, flush=False - ) - if end_of_screen: - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "review", "end_of_screen" - ): - self.env["runtime"]["OutputManager"].present_text( - _("end of screen"), - interrupt=True, - sound_icon="EndOfScreen", - ) - if line_break: - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "review", "line_break" - ): - self.env["runtime"]["OutputManager"].present_text( - _("line break"), interrupt=False, sound_icon="EndOfLine" - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/review_prev_char.py b/src/fenrirscreenreader/commands/commands/review_prev_char.py deleted file mode 100644 index a990588c..00000000 --- a/src/fenrirscreenreader/commands/commands/review_prev_char.py +++ /dev/null @@ -1,141 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import char_utils - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("moves review to the previous character") - - def run(self): - self.env["screen"]["oldCursorReview"] = self.env["screen"][ - "newCursorReview" - ] - if not self.env["screen"]["newCursorReview"]: - self.env["screen"]["newCursorReview"] = self.env["screen"][ - "new_cursor" - ].copy() - - # Check if we're in table mode for bounded navigation - if self.env["runtime"]["TableManager"].is_table_mode(): - table_info = self.env["runtime"]["TableManager"].get_current_table_cell_info() - if table_info: - cursor_pos = self.env["screen"]["newCursorReview"] - line_text = self.env["runtime"]["ScreenManager"].get_line_text( - cursor_pos["y"] - ) - if line_text: - column_start = self.env["runtime"]["TableManager"].get_column_start_position( - line_text, table_info["column_index"] - ) - - # Check if we're already at the start of the cell - if cursor_pos["x"] <= column_start: - # At cell boundary - announce start and don't move - char_utils.present_char_for_review( - self.env, - ( - table_info["cell_content"][0] - if table_info["cell_content"] else "" - ), - interrupt=True, - announce_capital=True, - flush=False, - ) - self.env["runtime"]["OutputManager"].present_text( - _("start of cell"), - interrupt=False, - sound_icon="StartOfLine" - ) - return - - # Move within cell bounds - cell_content = table_info["cell_content"] - relative_pos = cursor_pos["x"] - column_start - if relative_pos > 0: - new_relative_pos = relative_pos - 1 - self.env["screen"]["newCursorReview"]["x"] = ( - column_start + new_relative_pos - ) - - # Get character at new position - if new_relative_pos < len(cell_content): - prev_char = cell_content[new_relative_pos] - else: - prev_char = "" - - char_utils.present_char_for_review( - self.env, - prev_char, - interrupt=True, - announce_capital=True, - flush=False, - ) - return - - # Regular navigation for non-table mode - ( - self.env["screen"]["newCursorReview"]["x"], - self.env["screen"]["newCursorReview"]["y"], - prev_char, - end_of_screen, - line_break, - ) = char_utils.get_prev_char( - self.env["screen"]["newCursorReview"]["x"], - self.env["screen"]["newCursorReview"]["y"], - self.env["screen"]["new_content_text"], - ) - - char_utils.present_char_for_review( - self.env, - prev_char, - interrupt=True, - announce_capital=True, - flush=False, - ) - if end_of_screen: - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "review", "end_of_screen" - ): - self.env["runtime"]["OutputManager"].present_text( - _("start of screen"), - interrupt=True, - sound_icon="StartOfScreen", - ) - if line_break: - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "review", "line_break" - ): - self.env["runtime"]["OutputManager"].present_text( - _("line break"), interrupt=False, sound_icon="EndOfLine" - ) - # is has attribute it enabled? - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "general", "hasattributes" - ): - cursor_pos = self.env["screen"]["newCursorReview"] - - if not self.env["runtime"]["AttributeManager"].has_attributes( - cursor_pos - ): - return - self.env["runtime"]["OutputManager"].present_text( - _("has attribute"), sound_icon="HasAttributes", interrupt=False - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/review_prev_line.py b/src/fenrirscreenreader/commands/commands/review_prev_line.py deleted file mode 100644 index c1c07df8..00000000 --- a/src/fenrirscreenreader/commands/commands/review_prev_line.py +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import line_utils - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("moves review to the previous line ") - - def run(self): - self.env["runtime"][ - "CursorManager" - ].enter_review_mode_curr_text_cursor() - - ( - self.env["screen"]["newCursorReview"]["x"], - self.env["screen"]["newCursorReview"]["y"], - prev_line, - end_of_screen, - ) = line_utils.get_prev_line( - self.env["screen"]["newCursorReview"]["x"], - self.env["screen"]["newCursorReview"]["y"], - self.env["screen"]["new_content_text"], - ) - - if prev_line.isspace(): - self.env["runtime"]["OutputManager"].present_text( - _("blank"), sound_icon="EmptyLine", interrupt=True, flush=False - ) - else: - self.env["runtime"]["OutputManager"].present_text( - prev_line, interrupt=True, flush=False - ) - if end_of_screen: - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "review", "end_of_screen" - ): - self.env["runtime"]["OutputManager"].present_text( - _("start of screen"), - interrupt=True, - sound_icon="StartOfScreen", - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/review_prev_word.py b/src/fenrirscreenreader/commands/commands/review_prev_word.py deleted file mode 100644 index 36e0f62a..00000000 --- a/src/fenrirscreenreader/commands/commands/review_prev_word.py +++ /dev/null @@ -1,111 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import word_utils -from fenrirscreenreader.core import debug - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("moves review focus to the previous word ") - - def run(self): - # Check if we're in table mode FIRST - before any word navigation - is_table_mode = self.env["runtime"]["TableManager"].is_table_mode() - self.env["runtime"]["DebugManager"].write_debug_out( - f"review_prev_word: is_table_mode={is_table_mode}", - debug.DebugLevel.INFO - ) - if is_table_mode: - table_info = ( - self.env["runtime"]["TableManager"].move_to_prev_column() - ) - if table_info and table_info.get("at_start"): - # Stay on current cell at beginning of line - current_info = table_info["current_info"] - if current_info: - output_text = ( - f"{current_info['cell_content']} {current_info['column_header']}" - ) - self.env["runtime"]["OutputManager"].present_text( - output_text, interrupt=True, flush=False - ) - # Play start of line sound - self.env["runtime"]["OutputManager"].present_text( - _("start of line"), - interrupt=False, - sound_icon="StartOfLine" - ) - elif table_info: - # Normal column navigation - announce cell content with column info - output_text = ( - f"{table_info['cell_content']} {table_info['column_header']}" - ) - self.env["runtime"]["OutputManager"].present_text( - output_text, interrupt=True, flush=False - ) - else: - # No table info available, but still in table mode - self.env["runtime"]["OutputManager"].present_text( - _("no table data"), interrupt=True, flush=False - ) - return # ALWAYS return in table mode to prevent regular word navigation - - # Regular word navigation (only when NOT in table mode) - self.env["runtime"][ - "CursorManager" - ].enter_review_mode_curr_text_cursor() - - ( - self.env["screen"]["newCursorReview"]["x"], - self.env["screen"]["newCursorReview"]["y"], - prev_word, - end_of_screen, - line_break, - ) = word_utils.get_prev_word( - self.env["screen"]["newCursorReview"]["x"], - self.env["screen"]["newCursorReview"]["y"], - self.env["screen"]["new_content_text"], - ) - - # Regular word announcement - if prev_word.isspace(): - self.env["runtime"]["OutputManager"].present_text( - _("blank"), interrupt=True, flush=False - ) - else: - self.env["runtime"]["OutputManager"].present_text( - prev_word, interrupt=True, flush=False - ) - if end_of_screen: - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "review", "end_of_screen" - ): - self.env["runtime"]["OutputManager"].present_text( - _("start of screen"), - interrupt=False, - sound_icon="StartOfScreen", - ) - if line_break: - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "review", "line_break" - ): - self.env["runtime"]["OutputManager"].present_text( - _("line break"), interrupt=False, sound_icon="EndOfLine" - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/review_screen_first_char.py b/src/fenrirscreenreader/commands/commands/review_screen_first_char.py deleted file mode 100644 index ebd63160..00000000 --- a/src/fenrirscreenreader/commands/commands/review_screen_first_char.py +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import char_utils - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Move Review to the first character on the screen (left top)") - - def run(self): - cursor_pos = self.env["runtime"][ - "CursorManager" - ].get_review_or_text_cursor() - self.env["runtime"]["CursorManager"].set_review_cursor_position(0, 0) - ( - self.env["screen"]["newCursorReview"]["x"], - self.env["screen"]["newCursorReview"]["y"], - lastChar, - ) = char_utils.get_last_char_in_line( - self.env["screen"]["newCursorReview"]["y"], - self.env["screen"]["new_content_text"], - ) - - char_utils.present_char_for_review( - self.env, - lastChar, - interrupt=True, - announce_capital=True, - flush=False, - ) - self.env["runtime"]["OutputManager"].present_text( - _("first character in screen"), interrupt=False - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/review_screen_last_char.py b/src/fenrirscreenreader/commands/commands/review_screen_last_char.py deleted file mode 100644 index af5b0cc3..00000000 --- a/src/fenrirscreenreader/commands/commands/review_screen_last_char.py +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import char_utils - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _( - "Move Review to the last character on the screen (right bottom)" - ) - - def run(self): - cursor_pos = self.env["runtime"][ - "CursorManager" - ].get_review_or_text_cursor() - self.env["runtime"]["CursorManager"].set_review_cursor_position( - self.env["screen"]["columns"] - 1, self.env["screen"]["lines"] - 1 - ) - ( - self.env["screen"]["newCursorReview"]["x"], - self.env["screen"]["newCursorReview"]["y"], - lastChar, - ) = char_utils.get_last_char_in_line( - self.env["screen"]["newCursorReview"]["y"], - self.env["screen"]["new_content_text"], - ) - - char_utils.present_char_for_review( - self.env, - lastChar, - interrupt=True, - announce_capital=True, - flush=False, - ) - self.env["runtime"]["OutputManager"].present_text( - _("last character in screen"), interrupt=False - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/review_top.py b/src/fenrirscreenreader/commands/commands/review_top.py deleted file mode 100644 index 8767810e..00000000 --- a/src/fenrirscreenreader/commands/commands/review_top.py +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import char_utils - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("move review to top of screen") - - def run(self): - self.env["screen"]["newCursorReview"] = {"x": 0, "y": 0} - self.env["runtime"]["OutputManager"].present_text( - _("Top"), interrupt=True, flush=False - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/review_up.py b/src/fenrirscreenreader/commands/commands/review_up.py deleted file mode 100644 index b51c03b7..00000000 --- a/src/fenrirscreenreader/commands/commands/review_up.py +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import char_utils - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _( - "Move review to the character in the line above the current position" - ) - - def run(self): - cursor_pos = self.env["runtime"][ - "CursorManager" - ].get_review_or_text_cursor() - ( - self.env["screen"]["newCursorReview"]["x"], - self.env["screen"]["newCursorReview"]["y"], - upChar, - end_of_screen, - ) = char_utils.get_up_char( - self.env["screen"]["newCursorReview"]["x"], - self.env["screen"]["newCursorReview"]["y"], - self.env["screen"]["new_content_text"], - ) - line_break = False - self.env["runtime"]["OutputManager"].present_text( - upChar, - interrupt=True, - ignore_punctuation=True, - announce_capital=True, - flush=False, - ) - if end_of_screen: - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "review", "end_of_screen" - ): - self.env["runtime"]["OutputManager"].present_text( - _("start of screen"), - interrupt=True, - sound_icon="StartOfScreen", - ) - if line_break: - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "review", "line_break" - ): - self.env["runtime"]["OutputManager"].present_text( - _("line break"), interrupt=False, sound_icon="EndOfLine" - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/save_settings.py b/src/fenrirscreenreader/commands/commands/save_settings.py deleted file mode 100644 index 91b7299c..00000000 --- a/src/fenrirscreenreader/commands/commands/save_settings.py +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core import settingsManager -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Saves your current Fenrir settings so they are the default.") - - def run(self): - if not self.env["runtime"]["SettingsManager"].save_settings(): - self.env["runtime"]["OutputManager"].present_text( - _("Failed to save settings."), interrupt=True - ) - return - self.env["runtime"]["OutputManager"].present_text( - _("Settings saved."), interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/set_bookmark_1.py b/src/fenrirscreenreader/commands/commands/set_bookmark_1.py deleted file mode 100644 index bb98e1cb..00000000 --- a/src/fenrirscreenreader/commands/commands/set_bookmark_1.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_base_path = os.path.join(os.path.dirname(__file__), "bookmark_base.py") -_spec = importlib.util.spec_from_file_location("bookmark_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -bookmark_command = _module.bookmark_command - - -class command(bookmark_command): - def __init__(self): - super().__init__(1, "set") diff --git a/src/fenrirscreenreader/commands/commands/set_bookmark_10.py b/src/fenrirscreenreader/commands/commands/set_bookmark_10.py deleted file mode 100644 index 824862d8..00000000 --- a/src/fenrirscreenreader/commands/commands/set_bookmark_10.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_base_path = os.path.join(os.path.dirname(__file__), "bookmark_base.py") -_spec = importlib.util.spec_from_file_location("bookmark_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -bookmark_command = _module.bookmark_command - - -class command(bookmark_command): - def __init__(self): - super().__init__(10, "set") diff --git a/src/fenrirscreenreader/commands/commands/set_bookmark_2.py b/src/fenrirscreenreader/commands/commands/set_bookmark_2.py deleted file mode 100644 index 6874c3ac..00000000 --- a/src/fenrirscreenreader/commands/commands/set_bookmark_2.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_base_path = os.path.join(os.path.dirname(__file__), "bookmark_base.py") -_spec = importlib.util.spec_from_file_location("bookmark_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -bookmark_command = _module.bookmark_command - - -class command(bookmark_command): - def __init__(self): - super().__init__(2, "set") diff --git a/src/fenrirscreenreader/commands/commands/set_bookmark_3.py b/src/fenrirscreenreader/commands/commands/set_bookmark_3.py deleted file mode 100644 index a6628608..00000000 --- a/src/fenrirscreenreader/commands/commands/set_bookmark_3.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_base_path = os.path.join(os.path.dirname(__file__), "bookmark_base.py") -_spec = importlib.util.spec_from_file_location("bookmark_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -bookmark_command = _module.bookmark_command - - -class command(bookmark_command): - def __init__(self): - super().__init__(3, "set") diff --git a/src/fenrirscreenreader/commands/commands/set_bookmark_4.py b/src/fenrirscreenreader/commands/commands/set_bookmark_4.py deleted file mode 100644 index 47206450..00000000 --- a/src/fenrirscreenreader/commands/commands/set_bookmark_4.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_base_path = os.path.join(os.path.dirname(__file__), "bookmark_base.py") -_spec = importlib.util.spec_from_file_location("bookmark_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -bookmark_command = _module.bookmark_command - - -class command(bookmark_command): - def __init__(self): - super().__init__(4, "set") diff --git a/src/fenrirscreenreader/commands/commands/set_bookmark_5.py b/src/fenrirscreenreader/commands/commands/set_bookmark_5.py deleted file mode 100644 index 7bc36180..00000000 --- a/src/fenrirscreenreader/commands/commands/set_bookmark_5.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_base_path = os.path.join(os.path.dirname(__file__), "bookmark_base.py") -_spec = importlib.util.spec_from_file_location("bookmark_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -bookmark_command = _module.bookmark_command - - -class command(bookmark_command): - def __init__(self): - super().__init__(5, "set") diff --git a/src/fenrirscreenreader/commands/commands/set_bookmark_6.py b/src/fenrirscreenreader/commands/commands/set_bookmark_6.py deleted file mode 100644 index e0499ecd..00000000 --- a/src/fenrirscreenreader/commands/commands/set_bookmark_6.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_base_path = os.path.join(os.path.dirname(__file__), "bookmark_base.py") -_spec = importlib.util.spec_from_file_location("bookmark_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -bookmark_command = _module.bookmark_command - - -class command(bookmark_command): - def __init__(self): - super().__init__(6, "set") diff --git a/src/fenrirscreenreader/commands/commands/set_bookmark_7.py b/src/fenrirscreenreader/commands/commands/set_bookmark_7.py deleted file mode 100644 index 4092610e..00000000 --- a/src/fenrirscreenreader/commands/commands/set_bookmark_7.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_base_path = os.path.join(os.path.dirname(__file__), "bookmark_base.py") -_spec = importlib.util.spec_from_file_location("bookmark_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -bookmark_command = _module.bookmark_command - - -class command(bookmark_command): - def __init__(self): - super().__init__(7, "set") diff --git a/src/fenrirscreenreader/commands/commands/set_bookmark_8.py b/src/fenrirscreenreader/commands/commands/set_bookmark_8.py deleted file mode 100644 index 585959ca..00000000 --- a/src/fenrirscreenreader/commands/commands/set_bookmark_8.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_base_path = os.path.join(os.path.dirname(__file__), "bookmark_base.py") -_spec = importlib.util.spec_from_file_location("bookmark_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -bookmark_command = _module.bookmark_command - - -class command(bookmark_command): - def __init__(self): - super().__init__(8, "set") diff --git a/src/fenrirscreenreader/commands/commands/set_bookmark_9.py b/src/fenrirscreenreader/commands/commands/set_bookmark_9.py deleted file mode 100644 index a765117c..00000000 --- a/src/fenrirscreenreader/commands/commands/set_bookmark_9.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_base_path = os.path.join(os.path.dirname(__file__), "bookmark_base.py") -_spec = importlib.util.spec_from_file_location("bookmark_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -bookmark_command = _module.bookmark_command - - -class command(bookmark_command): - def __init__(self): - super().__init__(9, "set") diff --git a/src/fenrirscreenreader/commands/commands/set_mark.py b/src/fenrirscreenreader/commands/commands/set_mark.py deleted file mode 100644 index 6f50e4b4..00000000 --- a/src/fenrirscreenreader/commands/commands/set_mark.py +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("places marks to select text to copy to the clipboard") - - def run(self): - if not self.env["runtime"]["CursorManager"].is_review_mode(): - self.env["runtime"]["OutputManager"].present_text( - _("no review cursor"), interrupt=True - ) - return - - # If in table mode, set header row instead of regular mark - if self.env["runtime"]["TableManager"].is_table_mode(): - success = self.env["runtime"]["TableManager"].set_header_row_from_cursor() - if success: - self.env["runtime"]["OutputManager"].present_text( - _("header row set"), sound_icon="PlaceStartMark", interrupt=True - ) - else: - self.env["runtime"]["OutputManager"].present_text( - _("could not set header row"), interrupt=True - ) - return - - # Regular mark functionality - curr_mark = self.env["runtime"]["CursorManager"].set_mark() - if curr_mark == 1: - self.env["runtime"]["OutputManager"].present_text( - _("set mark"), sound_icon="PlaceStartMark", interrupt=True - ) - elif curr_mark == 2: - self.env["runtime"]["OutputManager"].present_text( - _("set mark"), sound_icon="PlaceEndMark", interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/set_window_application.py b/src/fenrirscreenreader/commands/commands/set_window_application.py deleted file mode 100644 index 5e8c63ea..00000000 --- a/src/fenrirscreenreader/commands/commands/set_window_application.py +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("set Window Mode, needs 2 marks ") - - def run(self): - if self.env["runtime"]["CursorManager"].set_window_for_application(): - curr_app = self.env["runtime"][ - "ApplicationManager" - ].get_current_application() - self.env["runtime"]["OutputManager"].present_text( - _("Window Mode on for application {0}").format(curr_app), - interrupt=True, - ) - self.env["runtime"]["CursorManager"].clear_marks() - else: - self.env["runtime"]["OutputManager"].present_text( - _("Set window begin and end marks"), interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/shut_up.py b/src/fenrirscreenreader/commands/commands/shut_up.py deleted file mode 100644 index 3a62961d..00000000 --- a/src/fenrirscreenreader/commands/commands/shut_up.py +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Interrupts the current presentation") - - def run(self): - if len(self.env["input"]["prev_deepest_input"]) > len( - self.env["input"]["curr_input"] - ): - return - self.env["runtime"]["OutputManager"].interrupt_output() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/silence_until_prompt.py b/src/fenrirscreenreader/commands/commands/silence_until_prompt.py deleted file mode 100644 index 366692ab..00000000 --- a/src/fenrirscreenreader/commands/commands/silence_until_prompt.py +++ /dev/null @@ -1,166 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import re - -from fenrirscreenreader.core import debug -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - # Use commandBuffer like other commands - if "silenceUntilPrompt" not in self.env["commandBuffer"]: - self.env["commandBuffer"]["silenceUntilPrompt"] = False - - def shutdown(self): - pass - - def get_description(self): - return _("Toggle speech silence until shell prompt returns") - - def run(self): - if self.env["commandBuffer"]["silenceUntilPrompt"]: - self.disable_silence() - else: - self.enable_silence() - - def enable_silence(self): - self.env["commandBuffer"]["silenceUntilPrompt"] = True - self.env["runtime"]["OutputManager"].present_text( - _("Speech silenced until prompt returns"), - sound_icon="SpeechOff", - interrupt=True, - ) - # Disable speech but don't use the normal temp disable that reactivates - # on keypress - self.env["runtime"]["SettingsManager"].set_setting( - "speech", "enabled", "False" - ) - - def disable_silence(self): - self.env["commandBuffer"]["silenceUntilPrompt"] = False - # Re-enable speech - self.env["runtime"]["SettingsManager"].set_setting( - "speech", "enabled", "True" - ) - self.env["runtime"]["OutputManager"].present_text( - _("Speech restored"), sound_icon="SpeechOn", interrupt=True - ) - - def check_for_prompt(self, text): - """Check if the current line contains a shell prompt pattern""" - if not self.env["commandBuffer"]["silenceUntilPrompt"]: - return False - - # First check for exact matches from settings (with backward - # compatibility) - try: - exact_matches = self.env["runtime"]["SettingsManager"].get_setting( - "prompt", "exact_matches" - ) - if exact_matches: - exact_list = [ - match.strip() - for match in exact_matches.split(",") - if match.strip() - ] - for exactMatch in exact_list: - if text.strip() == exactMatch: - self.env["runtime"]["DebugManager"].write_debug_out( - "found exact prompt match: " + exactMatch, - debug.DebugLevel.INFO, - ) - self.disable_silence() - return True - except Exception as e: - # Prompt section doesn't exist in settings, skip custom exact - # matches - pass - - # Get custom patterns from settings (with backward compatibility) - prompt_patterns = [] - try: - custom_patterns = self.env["runtime"][ - "SettingsManager" - ].get_setting("prompt", "custom_patterns") - # Add custom patterns from settings if they exist - if custom_patterns: - custom_list = [ - pattern.strip() - for pattern in custom_patterns.split(",") - if pattern.strip() - ] - prompt_patterns.extend(custom_list) - except Exception as e: - # Prompt section doesn't exist in settings, skip custom patterns - pass - - # Add default shell prompt patterns - prompt_patterns.extend( - [ - r"^\s*\$\s*$", # Just $ (with whitespace) - r"^\s*#\s*$", # Just # (with whitespace) - r"^\s*>\s*$", # Just > (with whitespace) - r".*@.*[\\\$#>]\s*$", - # Contains @ and ends with prompt char (user@host style) - r"^\[.*\]\s*[\\\$#>]\s*$", # [anything]$ style prompts - # Simple shell names like bash-5.1$ - r"^[a-zA-Z0-9._-]+[\\\$#>]\s*$", - # Interactive prompt patterns - # Package manager confirmation prompts - r".*\?\s*\[[YyNn]/[YyNn]\]\s*$", # ? [Y/n] or ? [y/N] style - r".*\?\s*\[[Yy]es/[Nn]o\]\s*$", # ? [Yes/No] style - r".*\?\s*\([YyNn]/[YyNn]\)\s*$", # ? (Y/n) or ? (y/N) style - r".*\?\s*\([Yy]es/[Nn]o\)\s*$", # ? (Yes/No) style - r".*continue\?\s*\[[YyNn]/[YyNn]\].*$", # "continue? [Y/n]" style - r".*ok\s*\[[YyNn]/[YyNn]\].*$", # "Is this ok [y/N]:" style - # pacman ":: Proceed? [Y/n]" style - r"^::.*\?\s*\[[YyNn]/[YyNn]\].*$", - # Authentication prompts - # [sudo] password for user: - r"^\[[Ss]udo\]\s*[Pp]assword\s*for\s+.*:\s*$", - r"^[Pp]assword\s*:\s*$", # Password: - r".*[Pp]assword\s*:\s*$", # general password prompts - r".*'s\s*[Pp]assword\s*:\s*$", # user's password: - r"^[Ee]nter\s+[Pp]assphrase.*:\s*$", # Enter passphrase: - # Please enter passphrase: - r"^[Pp]lease\s+enter\s+[Pp]assphrase.*:\s*$", - # General confirmation and continuation prompts - # Press any key to continue - r"^[Pp]ress\s+any\s+key\s+to\s+continue.*$", - r"^[Aa]re\s+you\s+sure\?\s*.*$", # Are you sure? - r"^[Pp]lease\s+confirm.*$", # Please confirm - r".*confirm.*\([YyNn]/[YyNn]\).*$", # confirm (y/n) - r".*\([Yy]/[Nn]\)\s*$", # ends with (Y/n) or (y/N) - ] - ) - - for pattern in prompt_patterns: - try: - if re.search(pattern, text.strip()): - self.env["runtime"]["DebugManager"].write_debug_out( - "found prompt pattern: " + pattern, - debug.DebugLevel.INFO, - ) - self.disable_silence() - return True - except re.error as e: - # Invalid regex pattern, skip it and log the error - self.env["runtime"]["DebugManager"].write_debug_out( - "Invalid prompt pattern: " + pattern + " Error: " + str(e), - debug.DebugLevel.ERROR, - ) - continue - - return False - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/speech_history.py b/src/fenrirscreenreader/commands/commands/speech_history.py deleted file mode 100644 index ab877e31..00000000 --- a/src/fenrirscreenreader/commands/commands/speech_history.py +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("opens speech history") - - def run(self): - self.env["runtime"]["SpeechHistoryManager"].open_history() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/spell_check.py b/src/fenrirscreenreader/commands/commands/spell_check.py deleted file mode 100644 index 82b82bc5..00000000 --- a/src/fenrirscreenreader/commands/commands/spell_check.py +++ /dev/null @@ -1,86 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import word_utils - -initialized = False -try: - import enchant - - initialized = True -except Exception as e: - pass - - -class command: - def __init__(self): - self.language = "" - self.spellChecker = None - - def initialize(self, environment): - self.env = environment - self.update_spell_language() - - def shutdown(self): - pass - - def get_description(self): - return _("checks the spelling of the current word") - - def update_spell_language(self): - if not initialized: - return - self.spellChecker = enchant.Dict( - self.env["runtime"]["SettingsManager"].get_setting( - "general", "spell_check_language" - ) - ) - self.language = self.env["runtime"]["SettingsManager"].get_setting( - "general", "spell_check_language" - ) - - def run(self): - if not initialized: - self.env["runtime"]["OutputManager"].present_text( - _("pyenchant is not installed"), interrupt=True - ) - return - if ( - self.env["runtime"]["SettingsManager"].get_setting( - "general", "spell_check_language" - ) - != self.language - ): - try: - self.update_spell_language() - except Exception as e: - return - - cursor_pos = self.env["runtime"][ - "CursorManager" - ].get_review_or_text_cursor() - - # get the word - new_content = self.env["screen"]["new_content_text"].split("\n")[ - cursor_pos["y"] - ] - x, y, curr_word, end_of_screen, line_break = ( - word_utils.get_current_word(cursor_pos["x"], 0, new_content) - ) - - if not curr_word.isspace(): - if not self.spellChecker.check(curr_word): - self.env["runtime"]["OutputManager"].present_text( - _("misspelled"), sound_icon="mispell", interrupt=True - ) - else: - self.env["runtime"]["OutputManager"].present_text( - _("correct"), sound_icon="", interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/subprocess.py b/src/fenrirscreenreader/commands/commands/subprocess.py deleted file mode 100644 index 73f4084b..00000000 --- a/src/fenrirscreenreader/commands/commands/subprocess.py +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import os -import subprocess - -from fenrirscreenreader.core.i18n import _ - - -def run_script(script_path, current_user): - """Run an external command without accessing Fenrir's shared state.""" - process = subprocess.Popen( - [script_path, current_user], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - stdout, stderr = process.communicate() - return { - "return_code": process.returncode, - "stdout": stdout, - "stderr": stderr, - } - - -class command: - def __init__(self): - self._task_ids = set() - - def initialize(self, environment, script_path=""): - self.env = environment - self.script_path = script_path - - def shutdown(self): - task_manager = self.env["runtime"].get("BackgroundTaskManager") - if task_manager: - for task_id in self._task_ids: - task_manager.cancel_task(task_id) - self._task_ids.clear() - - def get_description(self): - return _("script: {0} fullpath: {1}").format( - os.path.basename(self.script_path), self.script_path - ) - - def run(self): - if not os.path.exists(self.script_path): - self.env["runtime"]["OutputManager"].present_text( - _("Script file not found"), sound_icon="", interrupt=False - ) - return - if not os.path.isfile(self.script_path): - self.env["runtime"]["OutputManager"].present_text( - _("Script source is not a valid file"), - sound_icon="", - interrupt=False, - ) - return - if not os.access(self.script_path, os.X_OK): - self.env["runtime"]["OutputManager"].present_text( - _("Script file is not executable"), - sound_icon="", - interrupt=False, - ) - return - - task_id = self.env["runtime"][ - "BackgroundTaskManager" - ].submit_external_task( - run_script, - self._handle_result, - self.script_path, - self.env["general"]["curr_user"], - ) - if task_id is not None: - self._task_ids.add(task_id) - - def _handle_result(self, result): - self._task_ids.discard(result.get("task_id")) - if not result.get("succeeded"): - self.env["runtime"]["OutputManager"].present_text( - result.get("error", _("Script failed")), - sound_icon="", - interrupt=False, - ) - return - - output = result.get("value") or {} - stderr = output.get("stderr", "") - stdout = output.get("stdout", "") - self.env["runtime"]["OutputManager"].interrupt_output() - if stderr: - self.env["runtime"]["OutputManager"].present_text( - stderr, sound_icon="", interrupt=False - ) - if stdout: - self.env["runtime"]["OutputManager"].present_text( - stdout, sound_icon="", interrupt=False - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/temp_disable_speech.py b/src/fenrirscreenreader/commands/commands/temp_disable_speech.py deleted file mode 100644 index 0dcb25b8..00000000 --- a/src/fenrirscreenreader/commands/commands/temp_disable_speech.py +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("disables speech until next keypress") - - def run(self): - self.env["runtime"]["OutputManager"].temp_disable_speech() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/time.py b/src/fenrirscreenreader/commands/commands/time.py deleted file mode 100644 index 698af3c8..00000000 --- a/src/fenrirscreenreader/commands/commands/time.py +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import datetime - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("presents the time") - - def run(self): - time_format = self.env["runtime"]["SettingsManager"].get_setting( - "general", "time_format" - ) - - # get the time formatted - time_string = datetime.datetime.strftime( - datetime.datetime.now(), time_format - ) - - # present the time via speak and braile, there is no soundicon, - # interrupt the current speech - self.env["runtime"]["OutputManager"].present_text( - time_string, sound_icon="", interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/toggle_auto_indent.py b/src/fenrirscreenreader/commands/commands/toggle_auto_indent.py deleted file mode 100644 index c1169e53..00000000 --- a/src/fenrirscreenreader/commands/commands/toggle_auto_indent.py +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _( - "enables or disables automatic reading of indentation level changes" - ) - - def run(self): - self.env["runtime"]["SettingsManager"].set_setting( - "general", - "auto_present_indent", - str( - not self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "general", "auto_present_indent" - ) - ), - ) - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "general", "auto_present_indent" - ): - self.env["runtime"]["OutputManager"].present_text( - _("autoindent enabled"), sound_icon="", interrupt=True - ) - else: - self.env["runtime"]["OutputManager"].present_text( - _("autoindent disabled"), sound_icon="", interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/toggle_auto_read.py b/src/fenrirscreenreader/commands/commands/toggle_auto_read.py deleted file mode 100644 index 90722ac0..00000000 --- a/src/fenrirscreenreader/commands/commands/toggle_auto_read.py +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _( - "enables or disables automatic reading of new text as it appears" - ) - - def run(self): - self.env["runtime"]["SettingsManager"].set_setting( - "speech", - "auto_read_incoming", - str( - not self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "speech", "auto_read_incoming" - ) - ), - ) - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "speech", "auto_read_incoming" - ): - self.env["runtime"]["OutputManager"].present_text( - _("autoread enabled"), sound_icon="", interrupt=True - ) - else: - self.env["runtime"]["OutputManager"].present_text( - _("autoread disabled"), sound_icon="", interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/toggle_auto_spell_check.py b/src/fenrirscreenreader/commands/commands/toggle_auto_spell_check.py deleted file mode 100644 index f2223fbd..00000000 --- a/src/fenrirscreenreader/commands/commands/toggle_auto_spell_check.py +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("enables or disables automatic spell checking") - - def run(self): - self.env["runtime"]["SettingsManager"].set_setting( - "general", - "auto_spell_check", - str( - not self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "general", "auto_spell_check" - ) - ), - ) - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "general", "auto_spell_check" - ): - self.env["runtime"]["OutputManager"].present_text( - _("auto spellcheck enabled"), sound_icon="", interrupt=True - ) - else: - self.env["runtime"]["OutputManager"].present_text( - _("auto spellcheck disabled"), sound_icon="", interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/toggle_auto_time.py b/src/fenrirscreenreader/commands/commands/toggle_auto_time.py deleted file mode 100644 index 7b361c73..00000000 --- a/src/fenrirscreenreader/commands/commands/toggle_auto_time.py +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _( - "Enables or disables automatic reading of time after specified " - "intervals" - ) - - def run(self): - self.env["runtime"]["SettingsManager"].set_setting( - "time", - "enabled", - str( - not self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "time", "enabled" - ) - ), - ) - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "time", "enabled" - ): - self.env["runtime"]["OutputManager"].present_text( - _("Automatic time announcement enabled"), - sound_icon="", - interrupt=True, - ) - else: - self.env["runtime"]["OutputManager"].present_text( - _("Automatic time announcement disabled"), - sound_icon="", - interrupt=True, - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/toggle_barrier.py b/src/fenrirscreenreader/commands/commands/toggle_barrier.py deleted file mode 100644 index e72beea5..00000000 --- a/src/fenrirscreenreader/commands/commands/toggle_barrier.py +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("enables or disables the barrier mode") - - def run(self): - self.env["runtime"]["SettingsManager"].set_setting( - "barrier", - "enabled", - str( - not self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "barrier", "enabled" - ) - ), - ) - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "barrier", "enabled" - ): - self.env["runtime"]["OutputManager"].present_text( - _("barrier mode enabled"), sound_icon="", interrupt=True - ) - else: - self.env["runtime"]["OutputManager"].present_text( - _("barrier mode disabled"), sound_icon="", interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/toggle_diff_mode.py b/src/fenrirscreenreader/commands/commands/toggle_diff_mode.py deleted file mode 100644 index 558712c5..00000000 --- a/src/fenrirscreenreader/commands/commands/toggle_diff_mode.py +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("toggle diff review mode") - - def run(self): - self.env["runtime"]["DiffReviewManager"].toggle_mode() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/toggle_emoticons.py b/src/fenrirscreenreader/commands/commands/toggle_emoticons.py deleted file mode 100644 index b05c1340..00000000 --- a/src/fenrirscreenreader/commands/commands/toggle_emoticons.py +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _( - "enables or disables announcement of emoticons instead of chars" - ) - - def run(self): - self.env["runtime"]["SettingsManager"].set_setting( - "general", - "emoticons", - str( - not self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "general", "emoticons" - ) - ), - ) - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "general", "emoticons" - ): - self.env["runtime"]["OutputManager"].present_text( - _("emoticons enabled"), sound_icon="", interrupt=True - ) - else: - self.env["runtime"]["OutputManager"].present_text( - _("emoticons disabled"), sound_icon="", interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/toggle_has_attribute.py b/src/fenrirscreenreader/commands/commands/toggle_has_attribute.py deleted file mode 100644 index 001c81c9..00000000 --- a/src/fenrirscreenreader/commands/commands/toggle_has_attribute.py +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("enables or disables the announcement of attributes") - - def run(self): - self.env["runtime"]["SettingsManager"].set_setting( - "general", - "hasattributes", - str( - not self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "general", "hasattributes" - ) - ), - ) - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "general", "hasattributes" - ): - self.env["runtime"]["OutputManager"].present_text( - _("announcement of attributes enabled"), - sound_icon="", - interrupt=True, - ) - else: - self.env["runtime"]["OutputManager"].present_text( - _("announcement of attributes disabled"), - sound_icon="", - interrupt=True, - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/toggle_highlight_tracking.py b/src/fenrirscreenreader/commands/commands/toggle_highlight_tracking.py deleted file mode 100644 index bc995647..00000000 --- a/src/fenrirscreenreader/commands/commands/toggle_highlight_tracking.py +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.core import debug - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("enables or disables tracking of highlighted text") - - def run(self): - highlight_mode = self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "focus", "highlight" - ) - cursor_mode = self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "focus", "cursor" - ) - table_mode = self.env["runtime"]["TableManager"].is_table_mode() - - self.env["runtime"]["DebugManager"].write_debug_out( - f"toggle_highlight_tracking: highlight={highlight_mode}, " - f"cursor={cursor_mode}, table={table_mode}", - debug.DebugLevel.INFO - ) - - # Cycle through modes: highlight → cursor → table → highlight - if highlight_mode and not table_mode: - # Switch to cursor mode - self.env["runtime"]["SettingsManager"].set_setting( - "focus", "highlight", "False" - ) - self.env["runtime"]["SettingsManager"].set_setting( - "focus", "cursor", "True" - ) - self.env["runtime"]["TableManager"].set_table_mode(False) - self.env["runtime"]["OutputManager"].present_text( - _("cursor tracking"), sound_icon="", interrupt=True - ) - elif cursor_mode and not table_mode: - # Switch to table mode - self.env["runtime"]["SettingsManager"].set_setting( - "focus", "highlight", "False" - ) - self.env["runtime"]["SettingsManager"].set_setting( - "focus", "cursor", "False" - ) - self.env["runtime"]["TableManager"].set_table_mode(True) - self.env["runtime"]["OutputManager"].present_text( - _("table mode"), sound_icon="", interrupt=True - ) - else: - # Switch to highlight mode (default) - also handles stuck table mode - self.env["runtime"]["SettingsManager"].set_setting( - "focus", "highlight", "True" - ) - self.env["runtime"]["SettingsManager"].set_setting( - "focus", "cursor", "False" - ) - self.env["runtime"]["TableManager"].set_table_mode(False) - self.env["runtime"]["OutputManager"].present_text( - _("highlight tracking"), sound_icon="", interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/toggle_output.py b/src/fenrirscreenreader/commands/commands/toggle_output.py deleted file mode 100644 index 86548336..00000000 --- a/src/fenrirscreenreader/commands/commands/toggle_output.py +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("toggles all output settings") - - def run(self): - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "speech", "enabled" - ) or self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "sound", "enabled" - ): - self.env["runtime"]["OutputManager"].present_text( - _("Fenrir muted"), sound_icon="Accept", interrupt=True - ) - self.env["runtime"]["SettingsManager"].set_setting( - "speech", "enabled", "False" - ) - self.env["runtime"]["SettingsManager"].set_setting( - "sound", "enabled", "False" - ) - else: - self.env["runtime"]["SettingsManager"].set_setting( - "speech", "enabled", "True" - ) - self.env["runtime"]["SettingsManager"].set_setting( - "sound", "enabled", "True" - ) - self.env["runtime"]["OutputManager"].present_text( - _("Fenrir unmuted"), sound_icon="Cancel", interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/toggle_punctuation_level.py b/src/fenrirscreenreader/commands/commands/toggle_punctuation_level.py deleted file mode 100644 index 015dc11c..00000000 --- a/src/fenrirscreenreader/commands/commands/toggle_punctuation_level.py +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return "" - - def run(self): - if self.env["runtime"]["PunctuationManager"].cycle_punctuation(): - self.env["runtime"]["OutputManager"].present_text( - self.env["runtime"]["SettingsManager"].get_setting( - "general", "punctuation_level" - ), - interrupt=True, - ignore_punctuation=True, - ) - else: - self.env["runtime"]["OutputManager"].present_text( - _("No punctuation found."), - interrupt=True, - ignore_punctuation=True, - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/toggle_sound.py b/src/fenrirscreenreader/commands/commands/toggle_sound.py deleted file mode 100644 index e5ca2b82..00000000 --- a/src/fenrirscreenreader/commands/commands/toggle_sound.py +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("enables or disables sound") - - def run(self): - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "sound", "enabled" - ): - self.env["runtime"]["OutputManager"].present_text( - _("sound disabled"), sound_icon="SoundOff", interrupt=True - ) - self.env["runtime"]["SettingsManager"].set_setting( - "sound", - "enabled", - str( - not self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "sound", "enabled" - ) - ), - ) - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "sound", "enabled" - ): - self.env["runtime"]["OutputManager"].present_text( - _("sound enabled"), sound_icon="SoundOn", interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/toggle_speech.py b/src/fenrirscreenreader/commands/commands/toggle_speech.py deleted file mode 100644 index 8d2786cf..00000000 --- a/src/fenrirscreenreader/commands/commands/toggle_speech.py +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("enables or disables speech") - - def run(self): - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "speech", "enabled" - ): - self.env["runtime"]["OutputManager"].interrupt_output() - self.env["runtime"]["OutputManager"].present_text( - _("speech disabled"), sound_icon="SpeechOff", interrupt=True - ) - self.env["runtime"]["SettingsManager"].set_setting( - "speech", - "enabled", - str( - not self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "speech", "enabled" - ) - ), - ) - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "speech", "enabled" - ): - self.env["runtime"]["OutputManager"].present_text( - _("speech enabled"), sound_icon="SpeechOn", interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/toggle_tutorial_mode.py b/src/fenrirscreenreader/commands/commands/toggle_tutorial_mode.py deleted file mode 100644 index 33ef14ec..00000000 --- a/src/fenrirscreenreader/commands/commands/toggle_tutorial_mode.py +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("enter or leave tutorial mode") - - def run(self): - help_manager = self.env["runtime"]["HelpManager"] - was_active = help_manager.is_tutorial_mode() - help_manager.toggle_tutorial_mode() - is_active = help_manager.is_tutorial_mode() - if was_active and is_active: - self.env["runtime"]["OutputManager"].present_text( - _( - "Unable to exit tutorial mode because exclusive keyboard " - "capture could not be released. Press Fenrir+F1 or Escape " - "to try again." - ), - interrupt=True, - ) - elif is_active: - self.env["runtime"]["OutputManager"].present_text( - _( - "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." - ), - interrupt=True, - ) - if self.env["runtime"]["HelpManager"].is_capture_degraded(): - self.env["runtime"]["OutputManager"].present_text( - _( - "Warning: full keyboard capture is unavailable. " - "Unbound keys may reach the active application." - ), - interrupt=False, - ) - else: - self.env["runtime"]["OutputManager"].present_text( - _( - "Exiting tutorial mode. To enter tutorial mode again " - "press Fenrir+F1" - ), - interrupt=True, - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/toggle_vmenu_mode.py b/src/fenrirscreenreader/commands/commands/toggle_vmenu_mode.py deleted file mode 100644 index b0e1e293..00000000 --- a/src/fenrirscreenreader/commands/commands/toggle_vmenu_mode.py +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Entering or Leaving v menu mode.") - - def run(self): - self.env["runtime"]["VmenuManager"].toggle_vmenu_mode() - if self.env["runtime"]["VmenuManager"].get_active(): - self.env["runtime"]["OutputManager"].present_text( - _("Entering v menu."), interrupt=True - ) - else: - self.env["runtime"]["OutputManager"].present_text( - _("Leaving v menu."), interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/voice_browser.py b/src/fenrirscreenreader/commands/commands/voice_browser.py deleted file mode 100644 index a69e0a6b..00000000 --- a/src/fenrirscreenreader/commands/commands/voice_browser.py +++ /dev/null @@ -1,298 +0,0 @@ -#!/usr/bin/env python3 - -import time - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - self._request_generation = 0 - self._loading = False - - def initialize(self, environment): - self.env = environment - self.test_message = _( - "This is a voice test. The quick brown fox jumps over the lazy dog." - ) - self.modules = [] - self.voices = [] - self.module_index = 0 - self.voice_index = 0 - self.browserActive = False - self.originalBindings = {} - self.lastAnnounceTime = 0 - - def shutdown(self): - self._request_generation += 1 - self._loading = False - self._leave_voice_browser(False) - - def get_description(self): - return _("Interactive voice browser with arrow key navigation") - - def run(self): - if self.browserActive: - self.exit_voice_browser() - return - - self.env["runtime"]["OutputManager"].present_text( - _("Starting voice browser"), interrupt=True - ) - - if self._loading: - return - self._loading = True - self._request_generation += 1 - generation = self._request_generation - self.env["runtime"]["SpeechDiscoveryManager"].request_modules( - lambda modules, error: self._modules_ready( - generation, modules, error - ) - ) - - def _modules_ready(self, generation, modules, error): - if generation != self._request_generation: - return - if error or not modules: - self._loading = False - self.env["runtime"]["OutputManager"].present_text( - _("No speech modules found"), interrupt=True - ) - return - self.modules = modules - current_module = self.env["runtime"]["SettingsManager"].get_setting( - "speech", "module" - ) - if current_module and current_module in self.modules: - self.module_index = self.modules.index(current_module) - self._request_current_module_voices(generation, True) - - def _request_current_module_voices(self, generation, enter_browser=False): - module = self.modules[self.module_index] - self.env["runtime"]["OutputManager"].present_text( - _("Loading voices for {0}").format(module), interrupt=True - ) - self.env["runtime"]["SpeechDiscoveryManager"].request_voices( - module, - lambda _module, voices, error: self._voices_ready( - generation, voices, error, enter_browser - ), - ) - - def _voices_ready(self, generation, voices, error, enter_browser): - if generation != self._request_generation: - return - self._loading = False - self.voices = voices if not error else [] - self.voice_index = 0 - current_voice = self.env["runtime"]["SettingsManager"].get_setting( - "speech", "voice" - ) - if current_voice and current_voice in self.voices: - self.voice_index = self.voices.index(current_voice) - if enter_browser: - self.enter_voice_browser() - self.announce_current_selection() - - def enter_voice_browser(self): - """Enter voice browser mode with custom key bindings""" - self.browserActive = True - - # Store original bindings - self.originalBindings = self.env["bindings"].copy() - - # Override navigation keys for voice browsing - # Use lambda to capture self and create bound methods - self.env["bindings"][str([1, ["KEY_UP"]])] = "VOICE_BROWSER_PREV_VOICE" - self.env["bindings"][ - str([1, ["KEY_DOWN"]]) - ] = "VOICE_BROWSER_NEXT_VOICE" - self.env["bindings"][ - str([1, ["KEY_LEFT"]]) - ] = "VOICE_BROWSER_PREV_MODULE" - self.env["bindings"][ - str([1, ["KEY_RIGHT"]]) - ] = "VOICE_BROWSER_NEXT_MODULE" - self.env["bindings"][str([1, ["KEY_ENTER"]])] = "VOICE_BROWSER_TEST" - self.env["bindings"][str([1, ["KEY_SPACE"]])] = "VOICE_BROWSER_APPLY" - self.env["bindings"][str([1, ["KEY_ESC"]])] = "VOICE_BROWSER_EXIT" - - # Register browser commands - self.register_browser_commands() - - self.env["runtime"]["OutputManager"].present_text( - _("Voice browser active"), interrupt=True - ) - self.env["runtime"]["OutputManager"].present_text( - _("Up/Down=voices, Left/Right=modules, Enter=test, Space=apply, Esc=exit"), - interrupt=True, - ) - - def register_browser_commands(self): - """Register voice browser commands with the command manager""" - # Create command objects for voice browser actions - CommandManager = self.env["runtime"]["CommandManager"] - - # This is a hack - we'll store references to our methods in the environment - # so the key handlers can call them - if "voiceBrowserInstance" not in self.env["runtime"]: - self.env["runtime"]["voiceBrowserInstance"] = self - - def exit_voice_browser(self): - """Exit voice browser and restore normal key bindings""" - self._leave_voice_browser(True) - - def _leave_voice_browser(self, announce): - if not self.browserActive: - return - - self.browserActive = False - - # Restore original bindings - self.env["bindings"] = self.originalBindings - - # Clean up - if "voiceBrowserInstance" in self.env["runtime"]: - del self.env["runtime"]["voiceBrowserInstance"] - - if announce: - self.env["runtime"]["OutputManager"].present_text( - _("Voice browser exited"), interrupt=True - ) - - def announce_current_selection(self): - """Announce current module and voice""" - # Throttle announcements to avoid spam - now = time.time() - if now - self.lastAnnounceTime < 0.3: - return - self.lastAnnounceTime = now - - if not self.modules or self.module_index >= len(self.modules): - return - - module = self.modules[self.module_index] - if self.voices and self.voice_index < len(self.voices): - voice = self.voices[self.voice_index].split("|", 1)[0] - self.env["runtime"]["OutputManager"].present_text( - _("{0}: {1} ({2}/{3})").format( - module, voice, self.voice_index + 1, len(self.voices) - ), - interrupt=True, - ) - else: - self.env["runtime"]["OutputManager"].present_text( - _("{0}: No voices").format(module), interrupt=True - ) - - def next_voice(self): - """Move to next voice""" - if not self.voices: - return - self.voice_index = (self.voice_index + 1) % len(self.voices) - self.announce_current_selection() - - def prev_voice(self): - """Move to previous voice""" - if not self.voices: - return - self.voice_index = (self.voice_index - 1) % len(self.voices) - self.announce_current_selection() - - def next_module(self): - self._change_module(1) - - def prev_module(self): - self._change_module(-1) - - def _change_module(self, offset): - if self._loading or not self.modules: - return - self._loading = True - self._request_generation += 1 - generation = self._request_generation - self.module_index = (self.module_index + offset) % len(self.modules) - self._request_current_module_voices(generation) - - def test_voice(self): - """Test current voice""" - if not self.voices or self.voice_index >= len(self.voices): - self.env["runtime"]["OutputManager"].present_text( - _("No voice selected"), interrupt=True - ) - return - - module = self.modules[self.module_index] - encoded_voice = self.voices[self.voice_index] - voice_name = encoded_voice.split("|", 1)[0] - - self.env["runtime"]["OutputManager"].present_text( - _("Testing..."), interrupt=True - ) - self._request_generation += 1 - generation = self._request_generation - self._loading = True - self.env["runtime"]["SpeechDiscoveryManager"].request_voice_test( - module, - voice_name, - self.test_message, - lambda succeeded, error: self._voice_test_ready( - generation, module, encoded_voice, succeeded, error - ), - ) - - def _voice_test_ready( - self, generation, module, voice, succeeded, _error - ): - if generation != self._request_generation: - return - self._loading = False - if succeeded: - self.env["commandBuffer"]["lastTestedModule"] = module - voice_name, separator, language = voice.partition("|") - self.env["commandBuffer"]["lastTestedVoice"] = voice_name - if separator: - self.env["commandBuffer"]["lastTestedLanguage"] = language - else: - self.env["commandBuffer"].pop("lastTestedLanguage", None) - self.env["runtime"]["OutputManager"].play_sound("Accept") - else: - self.env["runtime"]["OutputManager"].play_sound("Error") - - def apply_voice(self): - """Apply current voice to Fenrir""" - if not self.voices or self.voice_index >= len(self.voices): - return - - module = self.modules[self.module_index] - voice, separator, language = self.voices[self.voice_index].partition( - "|" - ) - - try: - SettingsManager = self.env["runtime"]["SettingsManager"] - SettingsManager.settings["speech"]["driver"] = "speechdDriver" - SettingsManager.settings["speech"]["module"] = module - SettingsManager.settings["speech"]["voice"] = voice - if separator: - SettingsManager.settings["speech"]["language"] = language - - if "SpeechDriver" in self.env["runtime"]: - SpeechDriver = self.env["runtime"]["SpeechDriver"] - SpeechDriver.shutdown() - SpeechDriver.initialize(self.env) - - self.env["runtime"]["OutputManager"].present_text( - _("Voice applied!"), interrupt=True - ) - self.env["runtime"]["OutputManager"].play_sound("Accept") - - except Exception as e: - self.env["runtime"]["OutputManager"].present_text( - _("Apply failed"), interrupt=True - ) - self.env["runtime"]["OutputManager"].play_sound("Error") - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/voice_browser_apply.py b/src/fenrirscreenreader/commands/commands/voice_browser_apply.py deleted file mode 100644 index 6fdb6298..00000000 --- a/src/fenrirscreenreader/commands/commands/voice_browser_apply.py +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env python3 - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Voice browser: apply current voice") - - def run(self): - if "voiceBrowserInstance" in self.env["runtime"]: - self.env["runtime"]["voiceBrowserInstance"].apply_voice() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/voice_browser_exit.py b/src/fenrirscreenreader/commands/commands/voice_browser_exit.py deleted file mode 100644 index 6acef8b7..00000000 --- a/src/fenrirscreenreader/commands/commands/voice_browser_exit.py +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env python3 - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Voice browser: exit browser mode") - - def run(self): - if "voiceBrowserInstance" in self.env["runtime"]: - self.env["runtime"]["voiceBrowserInstance"].exit_voice_browser() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/voice_browser_next_module.py b/src/fenrirscreenreader/commands/commands/voice_browser_next_module.py deleted file mode 100644 index 47d46155..00000000 --- a/src/fenrirscreenreader/commands/commands/voice_browser_next_module.py +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env python3 - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Voice browser: next module") - - def run(self): - if "voiceBrowserInstance" in self.env["runtime"]: - self.env["runtime"]["voiceBrowserInstance"].next_module() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/voice_browser_next_voice.py b/src/fenrirscreenreader/commands/commands/voice_browser_next_voice.py deleted file mode 100644 index cbc353dd..00000000 --- a/src/fenrirscreenreader/commands/commands/voice_browser_next_voice.py +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env python3 - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Voice browser: next voice") - - def run(self): - if "voiceBrowserInstance" in self.env["runtime"]: - self.env["runtime"]["voiceBrowserInstance"].next_voice() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/voice_browser_prev_module.py b/src/fenrirscreenreader/commands/commands/voice_browser_prev_module.py deleted file mode 100644 index b6359c05..00000000 --- a/src/fenrirscreenreader/commands/commands/voice_browser_prev_module.py +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env python3 - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Voice browser: previous module") - - def run(self): - if "voiceBrowserInstance" in self.env["runtime"]: - self.env["runtime"]["voiceBrowserInstance"].prev_module() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/voice_browser_prev_voice.py b/src/fenrirscreenreader/commands/commands/voice_browser_prev_voice.py deleted file mode 100644 index dfc7d85e..00000000 --- a/src/fenrirscreenreader/commands/commands/voice_browser_prev_voice.py +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env python3 - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Voice browser: previous voice") - - def run(self): - if "voiceBrowserInstance" in self.env["runtime"]: - self.env["runtime"]["voiceBrowserInstance"].prev_voice() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/voice_browser_safe.py b/src/fenrirscreenreader/commands/commands/voice_browser_safe.py deleted file mode 100644 index ad6b99ff..00000000 --- a/src/fenrirscreenreader/commands/commands/voice_browser_safe.py +++ /dev/null @@ -1,160 +0,0 @@ -#!/usr/bin/env python3 - -from fenrirscreenreader.core import debug -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - self._request_generation = 0 - self._loading = False - - def initialize(self, environment): - self.env = environment - self.test_message = ( - _("Voice test: The quick brown fox jumps over the lazy dog.") - ) - - def shutdown(self): - self._request_generation += 1 - self._loading = False - - def get_description(self): - return _("browse and test Speech Dispatcher voices") - - def run(self): - if self._loading: - return - self._loading = True - self._request_generation += 1 - generation = self._request_generation - self.env["runtime"]["OutputManager"].present_text( - _("Loading speech modules"), interrupt=True - ) - self.env["runtime"]["SpeechDiscoveryManager"].request_modules( - lambda modules, error: self._modules_ready( - generation, modules, error - ) - ) - - def _modules_ready(self, generation, modules, error): - if generation != self._request_generation: - return - if error or not modules: - self._loading = False - self._report_error(_("No speech modules found"), error) - return - - module_index = self.env["commandBuffer"].get( - "safeBrowserModuleIndex", 0 - ) - if module_index >= len(modules): - module_index = 0 - module = modules[module_index] - self.env["runtime"]["OutputManager"].present_text( - _("Loading voices for {module}").format(module=module), - interrupt=True, - ) - self.env["runtime"]["SpeechDiscoveryManager"].request_voices( - module, - lambda _module, voices, voice_error: self._voices_ready( - generation, - modules, - module_index, - voices, - voice_error, - ), - ) - - def _voices_ready( - self, generation, modules, module_index, voices, error - ): - if generation != self._request_generation: - return - if error or not voices: - self._loading = False - next_module = (module_index + 1) % len(modules) - self.env["commandBuffer"]["safeBrowserModuleIndex"] = next_module - self.env["commandBuffer"]["safeBrowserVoiceIndex"] = 0 - self._report_error(_("No voices found"), error) - return - - voice_index = self.env["commandBuffer"].get( - "safeBrowserVoiceIndex", 0 - ) - if voice_index >= len(voices): - voice_index = 0 - module = modules[module_index] - voice = voices[voice_index] - voice_name = voice.split("|", 1)[0] - self.env["runtime"]["OutputManager"].present_text( - _("Module: {module}, voice: {voice}").format( - module=module, voice=voice_name - ), - interrupt=True, - ) - self.env["runtime"]["OutputManager"].present_text( - _("Testing voice"), interrupt=True - ) - self.env["runtime"]["SpeechDiscoveryManager"].request_voice_test( - module, - voice_name, - self.test_message, - lambda succeeded, test_error: self._test_ready( - generation, - modules, - module_index, - voices, - voice_index, - succeeded, - test_error, - ), - ) - - def _test_ready( - self, - generation, - modules, - module_index, - voices, - voice_index, - succeeded, - error, - ): - if generation != self._request_generation: - return - self._loading = False - module = modules[module_index] - voice_name, separator, language = voices[voice_index].partition("|") - if succeeded: - self.env["commandBuffer"]["lastTestedModule"] = module - self.env["commandBuffer"]["lastTestedVoice"] = voice_name - if separator: - self.env["commandBuffer"]["lastTestedLanguage"] = language - else: - self.env["commandBuffer"].pop("lastTestedLanguage", None) - self.env["runtime"]["OutputManager"].present_text( - _("Voice test completed successfully"), interrupt=True - ) - else: - self._report_error(_("Voice test failed"), error) - - voice_index += 1 - if voice_index >= len(voices): - voice_index = 0 - module_index = (module_index + 1) % len(modules) - self.env["commandBuffer"]["safeBrowserModuleIndex"] = module_index - self.env["commandBuffer"]["safeBrowserVoiceIndex"] = voice_index - - def _report_error(self, message, detail): - if detail: - self.env["runtime"]["DebugManager"].write_debug_out( - f"voice_browser_safe: {detail}", - debug.DebugLevel.ERROR, - ) - self.env["runtime"]["OutputManager"].present_text( - message, interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/voice_browser_test.py b/src/fenrirscreenreader/commands/commands/voice_browser_test.py deleted file mode 100644 index 20109304..00000000 --- a/src/fenrirscreenreader/commands/commands/voice_browser_test.py +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env python3 - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Voice browser: test current voice") - - def run(self): - if "voiceBrowserInstance" in self.env["runtime"]: - self.env["runtime"]["voiceBrowserInstance"].test_voice() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/help/Readme.txt b/src/fenrirscreenreader/commands/help/Readme.txt deleted file mode 100644 index c90f7a6b..00000000 --- a/src/fenrirscreenreader/commands/help/Readme.txt +++ /dev/null @@ -1,2 +0,0 @@ -this folder contains help and tutorial related functions. -those are not bindable but hard coded. diff --git a/src/fenrirscreenreader/commands/help/__init__.py b/src/fenrirscreenreader/commands/help/__init__.py deleted file mode 100755 index e69de29b..00000000 diff --git a/src/fenrirscreenreader/commands/help/curr_help.py b/src/fenrirscreenreader/commands/help/curr_help.py deleted file mode 100644 index b7123c9e..00000000 --- a/src/fenrirscreenreader/commands/help/curr_help.py +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("get current help message") - - def run(self): - text = self.env["runtime"]["HelpManager"].get_help_for_current_index() - self.env["runtime"]["OutputManager"].present_text(text, interrupt=True) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/help/next_help.py b/src/fenrirscreenreader/commands/help/next_help.py deleted file mode 100644 index bde46aab..00000000 --- a/src/fenrirscreenreader/commands/help/next_help.py +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("get next help message") - - def run(self): - self.env["runtime"]["HelpManager"].next_index() - text = self.env["runtime"]["HelpManager"].get_help_for_current_index() - self.env["runtime"]["OutputManager"].present_text(text, interrupt=True) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/help/next_help_section.py b/src/fenrirscreenreader/commands/help/next_help_section.py deleted file mode 100644 index 4624b62c..00000000 --- a/src/fenrirscreenreader/commands/help/next_help_section.py +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("show next help category") - - def run(self): - text = self.env["runtime"]["HelpManager"].next_help_section() - self.env["runtime"]["OutputManager"].present_text( - text, interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/help/prev_help.py b/src/fenrirscreenreader/commands/help/prev_help.py deleted file mode 100644 index 61eb614a..00000000 --- a/src/fenrirscreenreader/commands/help/prev_help.py +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("get prev help message") - - def run(self): - self.env["runtime"]["HelpManager"].prev_index() - text = self.env["runtime"]["HelpManager"].get_help_for_current_index() - self.env["runtime"]["OutputManager"].present_text(text, interrupt=True) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/help/prev_help_section.py b/src/fenrirscreenreader/commands/help/prev_help_section.py deleted file mode 100644 index 3050f36f..00000000 --- a/src/fenrirscreenreader/commands/help/prev_help_section.py +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("show previous help category") - - def run(self): - text = self.env["runtime"]["HelpManager"].prev_help_section() - self.env["runtime"]["OutputManager"].present_text( - text, interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/onCursorChange/15000-char_echo.py b/src/fenrirscreenreader/commands/onCursorChange/15000-char_echo.py deleted file mode 100644 index a676bad7..00000000 --- a/src/fenrirscreenreader/commands/onCursorChange/15000-char_echo.py +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Announces characters as they are typed") - - def run(self): - # enabled? - active = self.env["runtime"]["SettingsManager"].get_setting_as_int( - "keyboard", "char_echo_mode" - ) - # 0 = off - if active == 0: - return - # 2 = caps only - if active == 2: - if not self.env["input"]["new_caps_lock"]: - return - # big changes are no char (but the value is bigger than one maybe the - # differ needs longer than you can type, so a little strange random - # buffer for now) - x_move = abs( - self.env["screen"]["new_cursor"]["x"] - - self.env["screen"]["old_cursor"]["x"] - ) - if x_move > 3: - return - if self.env["runtime"][ - "InputManager" - ].get_last_deepest_input() in [["KEY_TAB"]]: - return - # detect deletion or chilling - if ( - self.env["screen"]["new_cursor"]["x"] - <= self.env["screen"]["old_cursor"]["x"] - ): - return - # is there any change? - if not self.env["runtime"]["ScreenManager"].is_delta(): - return - # filter unneded space on word begin - curr_delta = self.env["screen"]["new_delta"] - if ( - len(curr_delta.strip()) != len(curr_delta) - and curr_delta.strip() != "" - ): - curr_delta = curr_delta.strip() - self.env["runtime"]["OutputManager"].present_text( - curr_delta, - interrupt=True, - ignore_punctuation=True, - announce_capital=True, - flush=False, - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/onCursorChange/25000-word_echo_type.py b/src/fenrirscreenreader/commands/onCursorChange/25000-word_echo_type.py deleted file mode 100644 index 0357aefd..00000000 --- a/src/fenrirscreenreader/commands/onCursorChange/25000-word_echo_type.py +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import string - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import word_utils - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Announces completed words during typing") - - def run(self): - # is it enabled? - if not self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "keyboard", "word_echo" - ): - return - # is navigation? - if ( - self.env["screen"]["new_cursor"]["x"] - - self.env["screen"]["old_cursor"]["x"] - != 1 - ): - return - # just when cursor move worddetection is needed - if not self.env["runtime"][ - "CursorManager" - ].is_cursor_horizontal_move(): - return - # for now no new line - if self.env["runtime"]["CursorManager"].is_cursor_vertical_move(): - return - # currently writing - if self.env["runtime"]["ScreenManager"].is_delta(): - return - - # get the word - new_content = self.env["screen"]["new_content_text"].split("\n")[ - self.env["screen"]["new_cursor"]["y"] - ] - x, y, curr_word, end_of_screen, line_break = ( - word_utils.get_current_word( - self.env["screen"]["new_cursor"]["x"], 0, new_content - ) - ) - - # is there a word? - if curr_word == "": - return - # at the end of a word - if ( - self.env["screen"]["new_cursor"]["x"] >= len(new_content) - or not new_content[self.env["screen"]["new_cursor"]["x"]].isspace() - ): - return - # at the end of a word - if (x + len(curr_word) != self.env["screen"]["new_cursor"]["x"]) and ( - x + len(curr_word) != self.env["screen"]["new_cursor"]["x"] - 1 - ): - return - - self.env["runtime"]["OutputManager"].present_text( - curr_word, interrupt=True, flush=False - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/onCursorChange/35000-spell_check.py b/src/fenrirscreenreader/commands/onCursorChange/35000-spell_check.py deleted file mode 100644 index a285a2d8..00000000 --- a/src/fenrirscreenreader/commands/onCursorChange/35000-spell_check.py +++ /dev/null @@ -1,206 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import os -import string - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import word_utils - -initialized = False -try: - import enchant - - initialized = True -except Exception as e: - pass - - -class command: - def __init__(self): - self.language = "" - self.spellChecker = "" - - def initialize(self, environment): - self.env = environment - self.update_spell_language() - - def shutdown(self): - pass - - def get_description(self): - return "No Description found" - - def update_spell_language(self): - if not initialized: - return - self.spellChecker = enchant.Dict( - self.env["runtime"]["SettingsManager"].get_setting( - "general", "spell_check_language" - ) - ) - self.language = self.env["runtime"]["SettingsManager"].get_setting( - "general", "spell_check_language" - ) - - def run(self): - if not initialized: - return - if not self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "general", "auto_spell_check" - ): - return - if ( - self.env["runtime"]["SettingsManager"].get_setting( - "general", "spell_check_language" - ) - != self.language - ): - try: - self.update_spell_language() - except Exception as e: - return - - # just when horizontal cursor move worddetection is needed - if not self.env["runtime"][ - "CursorManager" - ].is_cursor_horizontal_move(): - return - - # for now no new line - if self.env["runtime"]["CursorManager"].is_cursor_vertical_move(): - return - # more than a keyecho? - if len(self.env["screen"]["new_delta"]) > 1: - return - # deletion - if self.env["runtime"]["ScreenManager"].is_negative_delta(): - return - # first place could not be the end of a word - if self.env["screen"]["new_cursor"]["x"] == 0: - return - - # get the word (just for speedup only look at current line - new_content = self.env["screen"]["new_content_text"].split("\n")[ - self.env["screen"]["new_cursor"]["y"] - ] - x, y, curr_word, end_of_screen, line_break = ( - word_utils.get_current_word( - self.env["screen"]["new_cursor"]["x"], 0, new_content - ) - ) - # was this a typed word? - if self.env["runtime"]["ScreenManager"].is_delta(): - if not ( - new_content[self.env["screen"]["old_cursor"]["x"]] - in string.whitespace + '!"#$%&()*+,-./:;<=>?@[\\]^_{|}~' - and x != self.env["screen"]["old_cursor"]["x"] - ): - return - else: - curr_word = curr_word.strip( - string.whitespace + '!"#$%&()*+,-./:;<=>?@[\\]^_{|}~' - ) - else: - # or just arrow arround? - if not new_content[ - self.env["screen"]["new_cursor"]["x"] - ].isspace(): - return - if ( - x + len(curr_word) != self.env["screen"]["new_cursor"]["x"] - ) and ( - x + len(curr_word) != self.env["screen"]["new_cursor"]["x"] - 1 - ): - return - - # just on end of word - if self.env["screen"]["new_cursor"]["x"] > 0: - if ( - not new_content[ - self.env["screen"]["old_cursor"]["x"] - 1 - ].lower() - in string.ascii_lowercase - ): - return - - # ignore bash buildins - if curr_word in [ - "cd", - "fg", - "bg", - "alias", - "bind", - "dir", - "caller", - "buildin", - "command", - "declare", - "echo", - "enable", - "help", - "let", - "local", - "logout", - "mapfile", - "printf", - "read", - "readarray", - "source", - "type", - "typeset", - "ulimit", - "unalias", - ]: - return - # ignore the application name - if curr_word.upper() == "FENRIR": - return - if curr_word[0] == "-": - return - if curr_word[0] == "/": - return - if curr_word[0] == "#": - return - if curr_word.startswith("./"): - return - if "@" in curr_word and "." in curr_word: - return - if curr_word[0] == "@": - return - if curr_word.isnumeric(): - return - if curr_word.isdecimal(): - return - if curr_word.isspace(): - return - - try: - if os.path.exists("/bin/" + curr_word): - return - except Exception as e: - pass - try: - if os.path.exists("/usr/bin/" + curr_word): - return - except Exception as e: - pass - try: - if os.path.exists("/sbin/" + curr_word): - return - except Exception as e: - pass - - if not self.spellChecker.check(curr_word): - self.env["runtime"]["OutputManager"].present_text( - _("misspelled"), - sound_icon="mispell", - interrupt=False, - flush=False, - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/onCursorChange/45000-char_delete_echo.py b/src/fenrirscreenreader/commands/onCursorChange/45000-char_delete_echo.py deleted file mode 100644 index 182bbf5f..00000000 --- a/src/fenrirscreenreader/commands/onCursorChange/45000-char_delete_echo.py +++ /dev/null @@ -1,64 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return "No Description found" - - def run(self): - if not self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "keyboard", "char_delete_echo" - ): - return - # detect typing or chilling - if ( - self.env["screen"]["new_cursor"]["x"] - >= self.env["screen"]["old_cursor"]["x"] - ): - return - - # More than just a deletion happend - if self.env["runtime"]["ScreenManager"].is_delta(ignoreSpace=True): - return - - # no deletion - if not self.env["runtime"]["ScreenManager"].is_negative_delta(): - return - - # too much for a single backspace... - # word begin produce a diff wiht len == 2 |a | others with 1 |a| - if len(self.env["screen"]["newNegativeDelta"]) > 2: - return - - curr_negative_delta = self.env["screen"]["newNegativeDelta"] - if ( - len(curr_negative_delta.strip()) != len(curr_negative_delta) - and curr_negative_delta.strip() != "" - ): - curr_negative_delta = curr_negative_delta.strip() - self.env["runtime"]["OutputManager"].present_text( - curr_negative_delta, - interrupt=True, - ignore_punctuation=True, - announce_capital=True, - flush=False, - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/onCursorChange/50000-present_char_if_cursor_change_horizontal.py b/src/fenrirscreenreader/commands/onCursorChange/50000-present_char_if_cursor_change_horizontal.py deleted file mode 100644 index 86c0a138..00000000 --- a/src/fenrirscreenreader/commands/onCursorChange/50000-present_char_if_cursor_change_horizontal.py +++ /dev/null @@ -1,110 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import char_utils -from fenrirscreenreader.utils import word_utils - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return "" - - def run(self): - if not self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "focus", "cursor" - ): - return - if self.env["runtime"]["ScreenManager"].is_screen_change(): - return - # detect an change on the screen, we just want to cursor arround, so no - # change should appear - if self.env["runtime"]["ScreenManager"].is_delta(): - return - if self.env["runtime"]["ScreenManager"].is_negative_delta(): - return - # is a vertical change? - if self.env["runtime"]["CursorManager"].is_cursor_vertical_move(): - return - # is it a horizontal change? - if not self.env["runtime"][ - "CursorManager" - ].is_cursor_horizontal_move(): - return - - # echo word insteed of char - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "keyboard", "word_echo" - ): - if ( - abs( - self.env["screen"]["old_cursor"]["x"] - - self.env["screen"]["new_cursor"]["x"] - ) - != 1 - ): - # get the word - new_content = self.env["screen"]["new_content_text"].split( - "\n" - )[self.env["screen"]["new_cursor"]["y"]] - x, y, curr_word, end_of_screen, line_break = ( - word_utils.get_current_word( - self.env["screen"]["new_cursor"]["x"], 0, new_content - ) - ) - if self.env["screen"]["new_cursor"]["x"] == x: - return - x, y, curr_char = char_utils.get_current_char( - self.env["screen"]["new_cursor"]["x"], - self.env["screen"]["new_cursor"]["y"], - self.env["screen"]["new_content_text"], - ) - # Don't interrupt ongoing auto-read announcements - do_interrupt = True - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "speech", "auto_read_incoming" - ): - do_interrupt = False - - if curr_char.isspace(): - # Only announce spaces during pure navigation (arrow keys) - # Check if this is really navigation by looking at input history - last_input = self.env["runtime"][ - "InputManager" - ].get_last_deepest_input() - if last_input and last_input[0] in [ - "KEY_LEFT", - "KEY_RIGHT", - "KEY_UP", - "KEY_DOWN", - ]: - char_utils.present_char_for_review( - self.env, - curr_char, - interrupt=do_interrupt, - announce_capital=True, - flush=False, - ) - else: - self.env["runtime"]["OutputManager"].present_text( - curr_char, - interrupt=do_interrupt, - ignore_punctuation=True, - announce_capital=True, - flush=False, - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/onCursorChange/54000-tab_completion.py b/src/fenrirscreenreader/commands/onCursorChange/54000-tab_completion.py deleted file mode 100644 index a925f937..00000000 --- a/src/fenrirscreenreader/commands/onCursorChange/54000-tab_completion.py +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.core.tabCompletionManager import TabCompletionManager - - -class command: - def __init__(self): - self.manager = TabCompletionManager() - - def initialize(self, environment): - self.env = environment - self.manager.initialize(environment) - - def shutdown(self): - pass - - def get_description(self): - return _("Announces tab completions on cursor movement") - - def run(self): - text = self.manager.process_update() - if not text: - return - - do_interrupt = True - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "speech", "auto_read_incoming" - ): - do_interrupt = False - - self.env["runtime"]["OutputManager"].present_text( - text, - interrupt=do_interrupt, - announce_capital=True, - flush=False, - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/onCursorChange/55000-large_insertion_echo.py b/src/fenrirscreenreader/commands/onCursorChange/55000-large_insertion_echo.py deleted file mode 100644 index d18a86f4..00000000 --- a/src/fenrirscreenreader/commands/onCursorChange/55000-large_insertion_echo.py +++ /dev/null @@ -1,95 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import time - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Announces large text insertions at the cursor") - - def run(self): - if self.env["runtime"]["ScreenManager"].is_screen_change(): - return - if not self.env["runtime"]["ScreenManager"].is_delta(): - return - if not self.env["screen"].get("new_delta_is_typing", False): - return - - x_move = ( - self.env["screen"]["new_cursor"]["x"] - - self.env["screen"]["old_cursor"]["x"] - ) - if x_move < 5: - return - if self._is_recent_tab_input(): - return - - delta_text = self.env["screen"]["new_delta"] - if not self._matches_cursor_insert(x_move, delta_text): - return - - curr_delta = delta_text - if ( - len(curr_delta.strip()) != len(curr_delta) - and curr_delta.strip() != "" - ): - curr_delta = curr_delta.strip() - if not curr_delta: - return - - do_interrupt = True - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "speech", "auto_read_incoming" - ): - do_interrupt = False - - self.env["runtime"]["OutputManager"].present_text( - curr_delta, - interrupt=do_interrupt, - announce_capital=True, - flush=False, - ) - - def _is_recent_tab_input(self): - input_manager = self.env["runtime"].get("InputManager") - if not input_manager: - return False - if input_manager.get_last_deepest_input() in [["KEY_TAB"]]: - return True - - last_event = input_manager.get_last_event() - if not last_event or last_event.get("event_name") != "KEY_TAB": - return False - try: - return time.time() - input_manager.get_last_input_time() <= 0.5 - except Exception: - return False - - def _matches_cursor_insert(self, x_move, delta_text): - if not delta_text or "\n" in delta_text: - return False - - delta_len = len(delta_text) - if x_move == delta_len: - return True - if abs(x_move - delta_len) <= 2: - return True - return delta_len > 10 and abs(x_move - delta_len) <= (delta_len * 0.2) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/onCursorChange/60000-word_echo_navigation.py b/src/fenrirscreenreader/commands/onCursorChange/60000-word_echo_navigation.py deleted file mode 100644 index d2de92c3..00000000 --- a/src/fenrirscreenreader/commands/onCursorChange/60000-word_echo_navigation.py +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import string - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import word_utils - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return "No Description found" - - def run(self): - # is navigation? - if ( - not abs( - self.env["screen"]["old_cursor"]["x"] - - self.env["screen"]["new_cursor"]["x"] - ) - > 1 - ): - return - - # just when cursor move worddetection is needed - if not self.env["runtime"][ - "CursorManager" - ].is_cursor_horizontal_move(): - return - # for now no new line - if self.env["runtime"]["CursorManager"].is_cursor_vertical_move(): - return - # currently writing - if self.env["runtime"]["ScreenManager"].is_delta(): - return - - # get the word - new_content = self.env["screen"]["new_content_text"].split("\n")[ - self.env["screen"]["new_cursor"]["y"] - ] - x, y, curr_word, end_of_screen, line_break = ( - word_utils.get_current_word( - self.env["screen"]["new_cursor"]["x"], 0, new_content - ) - ) - - # is there a word? - if curr_word == "": - return - - # at the start of a word - if (x + len(curr_word) != self.env["screen"]["new_cursor"]["x"]) and ( - self.env["screen"]["new_cursor"]["x"] != x - ): - return - - # Don't interrupt ongoing auto-read announcements - do_interrupt = True - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "speech", "auto_read_incoming" - ): - do_interrupt = False - - self.env["runtime"]["OutputManager"].present_text( - curr_word, interrupt=do_interrupt, flush=False - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/onCursorChange/65000-present_line_if_cursor_change_vertical.py b/src/fenrirscreenreader/commands/onCursorChange/65000-present_line_if_cursor_change_vertical.py deleted file mode 100644 index 0ec5fd66..00000000 --- a/src/fenrirscreenreader/commands/onCursorChange/65000-present_line_if_cursor_change_vertical.py +++ /dev/null @@ -1,104 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import line_utils -from fenrirscreenreader.utils import word_utils - - -class command: - def __init__(self): - self.lastIdent = -1 - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return "" - - def run(self): - if not self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "focus", "cursor" - ): - return - if self.env["runtime"]["ScreenManager"].is_screen_change(): - self.lastIdent = 0 - return - # Don't announce cursor movements when auto-read is handling incoming text - # This prevents interrupting ongoing auto-read announcements - if self.env["runtime"]["ScreenManager"].is_delta(): - return - - # is a vertical change? - if not self.env["runtime"]["CursorManager"].is_cursor_vertical_move(): - return - - x, y, curr_line = line_utils.get_current_line( - self.env["screen"]["new_cursor"]["x"], - self.env["screen"]["new_cursor"]["y"], - self.env["screen"]["new_content_text"], - ) - # Don't interrupt ongoing auto-read announcements with cursor movement - do_interrupt = True - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "speech", "auto_read_incoming" - ): - do_interrupt = False - - if curr_line.isspace(): - self.env["runtime"]["OutputManager"].present_text( - _("blank"), sound_icon="EmptyLine", interrupt=do_interrupt, flush=False - ) - else: - # ident - curr_ident = len(curr_line) - len(curr_line.lstrip()) - if self.lastIdent == -1: - self.lastIdent = curr_ident - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "general", "auto_present_indent" - ): - if self.env["runtime"]["SettingsManager"].get_setting_as_int( - "general", "auto_present_indent_mode" - ) in [0, 1]: - if self.lastIdent != curr_ident: - self.env["runtime"]["OutputManager"].play_frequence( - curr_ident * 50, 0.1, interrupt=do_interrupt - ) - if self.env["runtime"]["SettingsManager"].get_setting_as_int( - "general", "auto_present_indent_mode" - ) in [0, 2]: - if self.lastIdent != curr_ident: - self.env["runtime"]["OutputManager"].present_text( - _("indented ") + str(curr_ident) + " ", - interrupt=do_interrupt, - flush=False, - ) - do_interrupt = False - # barrier - say_line = curr_line - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "barrier", "enabled" - ): - is_barrier, barrierLine = self.env["runtime"][ - "BarrierManager" - ].handle_line_barrier( - self.env["screen"]["new_content_text"].split("\n"), - self.env["screen"]["new_cursor"]["x"], - self.env["screen"]["new_cursor"]["y"], - ) - if is_barrier: - say_line = barrierLine - # output - self.env["runtime"]["OutputManager"].present_text( - say_line, interrupt=do_interrupt, flush=False - ) - self.lastIdent = curr_ident - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/onCursorChange/68000-auto_identation_horizontal.py b/src/fenrirscreenreader/commands/onCursorChange/68000-auto_identation_horizontal.py deleted file mode 100644 index b9d6caa7..00000000 --- a/src/fenrirscreenreader/commands/onCursorChange/68000-auto_identation_horizontal.py +++ /dev/null @@ -1,92 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import line_utils -from fenrirscreenreader.utils import word_utils - - -class command: - def __init__(self): - self.lastIdent = -1 - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return "" - - def run(self): - if not self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "focus", "cursor" - ): - return - if self.env["runtime"]["ScreenManager"].is_screen_change(): - self.lastIdent = 0 - return - - # Skip if no cursor movement at all - if (not self.env["runtime"]["CursorManager"].is_cursor_horizontal_move() and - not self.env["runtime"]["CursorManager"].is_cursor_vertical_move()): - return - x, y, curr_line = line_utils.get_current_line( - self.env["screen"]["new_cursor"]["x"], - self.env["screen"]["new_cursor"]["y"], - self.env["screen"]["new_content_text"], - ) - curr_ident = self.env["screen"]["new_cursor"]["x"] - - if curr_line.isspace(): - # Don't beep for lines with only spaces - no meaningful indentation - return - - # Lines with actual content - calculate proper indentation - lastIdent, lastY, last_line = line_utils.get_current_line( - self.env["screen"]["new_cursor"]["x"], - self.env["screen"]["new_cursor"]["y"], - self.env["screen"]["old_content_text"], - ) - if curr_line.strip() != last_line.strip(): - return - if len(curr_line.lstrip()) == len(last_line.lstrip()): - return - - curr_ident = len(curr_line) - len(curr_line.lstrip()) - - if curr_ident <= 0: - return - - # Initialize lastIdent if needed - if self.lastIdent == -1: - self.lastIdent = curr_ident - - # Only beep/announce if indentation level has changed - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "general", "auto_present_indent" - ) and self.lastIdent != curr_ident: - if self.env["runtime"]["SettingsManager"].get_setting_as_int( - "general", "auto_present_indent_mode" - ) in [0, 1]: - self.env["runtime"]["OutputManager"].play_frequence( - curr_ident * 50, 0.1, interrupt=False - ) - if self.env["runtime"]["SettingsManager"].get_setting_as_int( - "general", "autePresentIndentMode" - ) in [0, 2]: - self.env["runtime"]["OutputManager"].present_text( - _("indented ") + str(curr_ident) + " ", - interrupt=False, - flush=False, - ) - - # Always update lastIdent for next comparison - self.lastIdent = curr_ident - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/onCursorChange/85000-has_attribute.py b/src/fenrirscreenreader/commands/onCursorChange/85000-has_attribute.py deleted file mode 100644 index 11e19d6c..00000000 --- a/src/fenrirscreenreader/commands/onCursorChange/85000-has_attribute.py +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import screen_utils - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Reads attributes of current cursor position") - - def run(self): - # is it enabled? - if not self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "general", "hasattributes" - ): - return - # is a vertical change? - if not ( - self.env["runtime"]["CursorManager"].is_cursor_vertical_move() - or self.env["runtime"]["CursorManager"].is_cursor_horizontal_move() - ): - return - - cursor_pos = self.env["screen"]["new_cursor"] - - if not self.env["runtime"]["AttributeManager"].has_attributes( - cursor_pos - ): - return - self.env["runtime"]["OutputManager"].present_text( - _("has attribute"), sound_icon="HasAttributes", interrupt=False - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/onCursorChange/95000-exit_review_mode.py b/src/fenrirscreenreader/commands/onCursorChange/95000-exit_review_mode.py deleted file mode 100644 index 19e75f12..00000000 --- a/src/fenrirscreenreader/commands/onCursorChange/95000-exit_review_mode.py +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("exits review mode") - - def run(self): - if not self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "review", "leave_review_on_cursor_change" - ): - return - if self.env["runtime"]["CursorManager"].is_review_mode(): - self.env["runtime"]["CursorManager"].clear_review_cursor() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/onCursorChange/__init__.py b/src/fenrirscreenreader/commands/onCursorChange/__init__.py deleted file mode 100755 index e69de29b..00000000 diff --git a/src/fenrirscreenreader/commands/onHeartBeat/2000-GetSessionInfo.py b/src/fenrirscreenreader/commands/onHeartBeat/2000-GetSessionInfo.py deleted file mode 100755 index 23539642..00000000 --- a/src/fenrirscreenreader/commands/onHeartBeat/2000-GetSessionInfo.py +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env python3 - -import datetime -import time - -from fenrirscreenreader.core.i18n import _ - -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - self.last_time = datetime.datetime.now() - self.lastDateString = "" - self.lastTimeString = "" - - def shutdown(self): - pass - - def get_description(self): - return "No Description found" - - def run(self): - self.env["runtime"]["ScreenDriver"].get_session_information() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/onHeartBeat/65000-prompt_flush.py b/src/fenrirscreenreader/commands/onHeartBeat/65000-prompt_flush.py deleted file mode 100644 index d9ecdcd1..00000000 --- a/src/fenrirscreenreader/commands/onHeartBeat/65000-prompt_flush.py +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import time - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return "" - - def run(self): - if not self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "speech", "auto_read_incoming" - ): - return - - if "pendingPromptText" not in self.env["commandBuffer"]: - return - - pending_text = self.env["commandBuffer"]["pendingPromptText"] - if not pending_text: - return - - pending_time = self.env["commandBuffer"].get("pendingPromptTime", 0) - delay = self.env["runtime"]["SettingsManager"].get_setting_as_float( - "speech", "batch_flush_interval" - ) - if time.time() - pending_time < delay: - return - - self.env["runtime"]["OutputManager"].present_text( - pending_text, interrupt=False, flush=False - ) - self.env["commandBuffer"]["pendingPromptText"] = "" - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/onHeartBeat/76000-time.py b/src/fenrirscreenreader/commands/onHeartBeat/76000-time.py deleted file mode 100755 index 5d3d459e..00000000 --- a/src/fenrirscreenreader/commands/onHeartBeat/76000-time.py +++ /dev/null @@ -1,170 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import datetime -import fcntl -import os -import tempfile -import time - -from fenrirscreenreader.core.i18n import _ - - -ANNOUNCEMENT_LOCK_TIMEOUT_SEC = 5.0 - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - self.last_time = datetime.datetime.now() - self.lastDateString = "" - self.lastTimeString = "" - - def shutdown(self): - pass - - def get_description(self): - return "No Description found" - - def _get_announcement_lock_path(self): - return os.path.join( - tempfile.gettempdir(), - "fenrirscreenreader-time-announcement.lock", - ) - - def _read_announcement_lock_slot(self, lock_file): - lock_file.seek(0) - lock_content = lock_file.readline().strip().split() - if len(lock_content) < 2: - return "" - return lock_content[1] - - def _claim_announcement_lock(self, announcement_slot): - now = time.time() - lock_path = self._get_announcement_lock_path() - try: - lock_fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o666) - except OSError: - return False - try: - os.chmod(lock_path, 0o666) - except OSError: - pass - - with os.fdopen(lock_fd, "r+", encoding="utf-8") as lock_file: - try: - fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) - lock_slot = self._read_announcement_lock_slot(lock_file) - lock_stat = os.fstat(lock_file.fileno()) - - if lock_slot == announcement_slot: - return False - - lock_file.seek(0) - lock_file.truncate() - lock_file.write(f"{os.getpid()} {announcement_slot} {now}\n") - lock_file.flush() - os.fsync(lock_file.fileno()) - return True - finally: - fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) - - def run(self): - if not self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "time", "enabled" - ): - return - - on_minutes = self.env["runtime"]["SettingsManager"].get_setting( - "time", "on_minutes" - ) - delay_sec = self.env["runtime"]["SettingsManager"].get_setting_as_int( - "time", "delay_sec" - ) - - # no need - if on_minutes == "" and delay_sec <= 0: - return - - on_minutes = on_minutes.split(",") - now = datetime.datetime.now() - - # ignore on_minutes if there is a delay_sec - if delay_sec > 0: - if int((now - self.last_time).total_seconds()) < delay_sec: - return - announcement_slot = f"delay:{int(now.timestamp()) // delay_sec}" - else: - # should announce? - if not str(now.minute).zfill(2) in on_minutes: - return - # already announced? - if now.hour == self.last_time.hour: - if now.minute == self.last_time.minute: - return - announcement_slot = f"minute:{datetime.datetime.strftime(now, '%Y%m%d%H%M')}" - - date_format = self.env["runtime"]["SettingsManager"].get_setting( - "general", "date_format" - ) - date_string = datetime.datetime.strftime(now, date_format) - - present_date = ( - self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "time", "present_date" - ) - and self.lastDateString != date_string - ) - present_time = self.env["runtime"][ - "SettingsManager" - ].get_setting_as_bool("time", "present_time") - - # no changed value to announce - if not (present_date or present_time): - return - - if not self._claim_announcement_lock(announcement_slot): - self.last_time = now - return - - time_format = self.env["runtime"]["SettingsManager"].get_setting( - "general", "time_format" - ) - time_string = datetime.datetime.strftime(now, time_format) - - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "time", "interrupt" - ): - self.env["runtime"]["OutputManager"].interrupt_output() - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "time", "announce" - ): - self.env["runtime"]["OutputManager"].play_sound_icon("announce") - - if present_time: - # present the time - self.env["runtime"]["OutputManager"].present_text( - _("It's {0}").format( - time_string.replace(":00", " O'clock ").lstrip("0") - ), - sound_icon="", - interrupt=False, - ) - # Check if it's 12:00 AM - if now.hour == 0 and now.minute == 0: - # present the date - self.env["runtime"]["OutputManager"].present_text( - date_string, sound_icon="", interrupt=False - ) - - self.last_time = datetime.datetime.now() - self.lastTimeString = time_string - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/onHeartBeat/__init__.py b/src/fenrirscreenreader/commands/onHeartBeat/__init__.py deleted file mode 100755 index e69de29b..00000000 diff --git a/src/fenrirscreenreader/commands/onKeyInput/10000-shut_up.py b/src/fenrirscreenreader/commands/onKeyInput/10000-shut_up.py deleted file mode 100644 index 7c05fc61..00000000 --- a/src/fenrirscreenreader/commands/onKeyInput/10000-shut_up.py +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return "" - - def run(self): - if not self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "keyboard", "interrupt_on_key_press" - ): - return - if self.env["runtime"]["InputManager"].no_key_pressed(): - return - if self.env["runtime"]["ScreenManager"].is_screen_change(): - return - if len(self.env["input"]["curr_input"]) <= len( - self.env["input"]["prev_input"] - ): - return - # if the filter is set - if ( - self.env["runtime"]["SettingsManager"] - .get_setting("keyboard", "interrupt_on_key_press_filter") - .strip() - != "" - ): - filter_list = ( - self.env["runtime"]["SettingsManager"] - .get_setting("keyboard", "interrupt_on_key_press_filter") - .split(",") - ) - for curr_key in self.env["input"]["curr_input"]: - if curr_key not in filter_list: - return - self.env["runtime"]["OutputManager"].interrupt_output_async() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/onKeyInput/15000-enable_temp_speech.py b/src/fenrirscreenreader/commands/onKeyInput/15000-enable_temp_speech.py deleted file mode 100644 index 97471e44..00000000 --- a/src/fenrirscreenreader/commands/onKeyInput/15000-enable_temp_speech.py +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("disables speech until next keypress") - - def run(self): - if self.env["runtime"]["InputManager"].no_key_pressed(): - return - if len(self.env["input"]["prev_input"]) > 0: - return - if not self.env["commandBuffer"]["enableSpeechOnKeypress"]: - return - self.env["runtime"]["SettingsManager"].set_setting( - "speech", - "enabled", - str(self.env["commandBuffer"]["enableSpeechOnKeypress"]), - ) - self.env["commandBuffer"]["enableSpeechOnKeypress"] = False - # Also disable prompt watching since speech was manually re-enabled - if "silenceUntilPrompt" in self.env["commandBuffer"]: - self.env["commandBuffer"]["silenceUntilPrompt"] = False - self.env["runtime"]["OutputManager"].present_text( - _("speech enabled"), sound_icon="SpeechOn", interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/onKeyInput/55000-tab_completion_capture.py b/src/fenrirscreenreader/commands/onKeyInput/55000-tab_completion_capture.py deleted file mode 100644 index 51428f69..00000000 --- a/src/fenrirscreenreader/commands/onKeyInput/55000-tab_completion_capture.py +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.core.tabCompletionManager import TabCompletionManager - - -class command: - def __init__(self): - self.manager = TabCompletionManager() - - def initialize(self, environment): - self.env = environment - self.manager.initialize(environment) - - def shutdown(self): - pass - - def get_description(self): - return _("Tracks tab keypresses for completion announcements") - - def run(self): - self.manager.capture_if_tab() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/onKeyInput/80000-capslock.py b/src/fenrirscreenreader/commands/onKeyInput/80000-capslock.py deleted file mode 100644 index 79ba6b5e..00000000 --- a/src/fenrirscreenreader/commands/onKeyInput/80000-capslock.py +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return "No description found" - - def run(self): - if ( - self.env["input"]["old_caps_lock"] - == self.env["input"]["new_caps_lock"] - ): - return - current_input = self.env["input"]["curr_input"] - previous_input = self.env["input"]["prev_input"] - relevant_input = current_input or previous_input - if "KEY_CAPSLOCK" not in relevant_input: - return - if self.env["input"]["new_caps_lock"]: - self.env["runtime"]["OutputManager"].present_text( - _("Capslock on"), interrupt=True - ) - else: - self.env["runtime"]["OutputManager"].present_text( - _("Capslock off"), interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/onKeyInput/80300-scrolllock.py b/src/fenrirscreenreader/commands/onKeyInput/80300-scrolllock.py deleted file mode 100644 index 4820a53d..00000000 --- a/src/fenrirscreenreader/commands/onKeyInput/80300-scrolllock.py +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return "No description found" - - def run(self): - if ( - self.env["input"]["old_scroll_lock"] - == self.env["input"]["new_scroll_lock"] - ): - return - current_input = self.env["input"]["curr_input"] - previous_input = self.env["input"]["prev_input"] - relevant_input = current_input or previous_input - if "KEY_SCROLLLOCK" not in relevant_input: - return - if self.env["input"]["new_scroll_lock"]: - self.env["runtime"]["OutputManager"].present_text( - _("Scrolllock on"), interrupt=True - ) - else: - self.env["runtime"]["OutputManager"].present_text( - _("Scrolllock off"), interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/onKeyInput/80500-numlock.py b/src/fenrirscreenreader/commands/onKeyInput/80500-numlock.py deleted file mode 100644 index 188e9e38..00000000 --- a/src/fenrirscreenreader/commands/onKeyInput/80500-numlock.py +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return "No description found" - - def run(self): - if self.env["input"]["old_num_lock"] == self.env["input"]["new_num_lock"]: - return - - # Only announce numlock changes if an actual numlock key was pressed - # AND the LED state actually changed (some numpads send spurious NUMLOCK events) - current_input = self.env["input"]["curr_input"] - previous_input = self.env["input"]["prev_input"] - relevant_input = current_input or previous_input - - # Check if this is a genuine numlock key press by verifying: - # 1. KEY_NUMLOCK is in the current input sequence - # 2. The LED state has actually changed - # 3. This isn't just a side effect from a KP_ key (which some buggy numpads do) - is_genuine_numlock = ( - relevant_input and - "KEY_NUMLOCK" in relevant_input and - not any( - key.startswith("KEY_KP") - for key in relevant_input - if isinstance(key, str) - ) - ) - - if is_genuine_numlock: - if self.env["input"]["new_num_lock"]: - self.env["runtime"]["OutputManager"].present_text( - _("Numlock on"), interrupt=True - ) - else: - self.env["runtime"]["OutputManager"].present_text( - _("Numlock off"), interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/onKeyInput/81000-key_echo.py b/src/fenrirscreenreader/commands/onKeyInput/81000-key_echo.py deleted file mode 100644 index 5f168595..00000000 --- a/src/fenrirscreenreader/commands/onKeyInput/81000-key_echo.py +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return "No description found" - - def run(self): - if self.env["runtime"]["HelpManager"].is_tutorial_mode(): - self.env["runtime"]["InputManager"].key_echo() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/onKeyInput/__init__.py b/src/fenrirscreenreader/commands/onKeyInput/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/fenrirscreenreader/commands/onPlugInputDevice/50000-plugSound.py b/src/fenrirscreenreader/commands/onPlugInputDevice/50000-plugSound.py deleted file mode 100755 index fe4f4a3e..00000000 --- a/src/fenrirscreenreader/commands/onPlugInputDevice/50000-plugSound.py +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import time - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - self.last_time = time.time() - - def shutdown(self): - pass - - def get_description(self): - return "No description found" - - def run(self): - play_sound = False - device_list = self.env["runtime"][ - "InputManager" - ].get_last_detected_devices() - try: - for deviceEntry in device_list: - # dont play sounds for virtual devices - play_sound = play_sound or not deviceEntry["virtual"] - except Exception as e: - play_sound = True - if play_sound: - if time.time() - self.last_time > 5: - self.env["runtime"]["OutputManager"].play_sound_icon( - sound_icon="accept", interrupt=True - ) - last_time = time.time() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/onPlugInputDevice/__init__.py b/src/fenrirscreenreader/commands/onPlugInputDevice/__init__.py deleted file mode 100755 index e69de29b..00000000 diff --git a/src/fenrirscreenreader/commands/onScreenChanged/10000-shut_up.py b/src/fenrirscreenreader/commands/onScreenChanged/10000-shut_up.py deleted file mode 100644 index 0ddc03f2..00000000 --- a/src/fenrirscreenreader/commands/onScreenChanged/10000-shut_up.py +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return "" - - def run(self): - self.env["runtime"]["OutputManager"].interrupt_output() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/onScreenChanged/20000-reset_last_cursor_attribute.py b/src/fenrirscreenreader/commands/onScreenChanged/20000-reset_last_cursor_attribute.py deleted file mode 100644 index a0362dcc..00000000 --- a/src/fenrirscreenreader/commands/onScreenChanged/20000-reset_last_cursor_attribute.py +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return "No Description found" - - def run(self): - self.env["runtime"]["AttributeManager"].reset_last_cursor_attribute() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/onScreenChanged/21000-reset_barrier_change.py b/src/fenrirscreenreader/commands/onScreenChanged/21000-reset_barrier_change.py deleted file mode 100644 index c9bacda8..00000000 --- a/src/fenrirscreenreader/commands/onScreenChanged/21000-reset_barrier_change.py +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return "No Description found" - - def run(self): - self.env["runtime"]["BarrierManager"].reset_barrier_change() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/onScreenChanged/70000-barrier_detect.py b/src/fenrirscreenreader/commands/onScreenChanged/70000-barrier_detect.py deleted file mode 100644 index 16e64a3e..00000000 --- a/src/fenrirscreenreader/commands/onScreenChanged/70000-barrier_detect.py +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return "No Description found" - - def run(self): - if not self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "barrier", "enabled" - ): - return - - self.env["runtime"]["BarrierManager"].handle_line_barrier( - self.env["screen"]["new_content_text"].split("\n"), - self.env["screen"]["new_cursor"]["x"], - self.env["screen"]["new_cursor"]["y"], - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/onScreenChanged/80000-screen_change_announcement.py b/src/fenrirscreenreader/commands/onScreenChanged/80000-screen_change_announcement.py deleted file mode 100644 index 1a786559..00000000 --- a/src/fenrirscreenreader/commands/onScreenChanged/80000-screen_change_announcement.py +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return "No Description found" - - def run(self): - self.env["runtime"]["OutputManager"].present_text( - _("screen {0}").format(self.env["screen"]["newTTY"]), - sound_icon="ChangeTTY", - interrupt=True, - flush=False, - ) - self.env["runtime"]["OutputManager"].present_text( - self.env["screen"]["new_content_text"], - interrupt=False, - flush=False, - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/onScreenChanged/85000-reset_marks.py b/src/fenrirscreenreader/commands/onScreenChanged/85000-reset_marks.py deleted file mode 100644 index 4ba4da6b..00000000 --- a/src/fenrirscreenreader/commands/onScreenChanged/85000-reset_marks.py +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return "No Description found" - - def run(self): - self.env["runtime"]["CursorManager"].clear_marks() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/onScreenChanged/89000-leave_review_mode.py b/src/fenrirscreenreader/commands/onScreenChanged/89000-leave_review_mode.py deleted file mode 100644 index ead374a7..00000000 --- a/src/fenrirscreenreader/commands/onScreenChanged/89000-leave_review_mode.py +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return "No Description found" - - def run(self): - if not self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "review", "leave_review_on_screen_change" - ): - return - self.env["runtime"]["CursorManager"].clear_review_cursor() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/onScreenChanged/__init__.py b/src/fenrirscreenreader/commands/onScreenChanged/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/fenrirscreenreader/commands/onScreenUpdate/55000-tui_focus_handler.py b/src/fenrirscreenreader/commands/onScreenUpdate/55000-tui_focus_handler.py deleted file mode 100644 index 1c210e43..00000000 --- a/src/fenrirscreenreader/commands/onScreenUpdate/55000-tui_focus_handler.py +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.core import debug - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _( - "TUI focus mode handler - suppresses screen update spam " - "for interactive TUI applications" - ) - - def run(self): - # Check if TUI mode is enabled - if not self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "focus", "tui" - ): - return - - # TUI mode is active - set suppression flag for incoming handler - # This prevents the 70000-incoming.py command from announcing - # screen updates - self.env["commandBuffer"]["tuiSuppressIncoming"] = True - - self.env["runtime"]["DebugManager"].write_debug_out( - "tui_focus_handler: TUI mode active, suppressing incoming text", - debug.DebugLevel.INFO - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/onScreenUpdate/56000-highlight_tracking.py b/src/fenrirscreenreader/commands/onScreenUpdate/56000-highlight_tracking.py deleted file mode 100644 index 8fc085c8..00000000 --- a/src/fenrirscreenreader/commands/onScreenUpdate/56000-highlight_tracking.py +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("enables or disables tracking of highlighted") - - def run(self): - if not self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "focus", "highlight" - ): - return - attribute_delta = self.env["runtime"][ - "AttributeManager" - ].get_attribute_delta() - self.env["runtime"]["OutputManager"].present_text( - attribute_delta, sound_icon="", interrupt=True, flush=False - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/onScreenUpdate/60000-history.py b/src/fenrirscreenreader/commands/onScreenUpdate/60000-history.py deleted file mode 100644 index c26a00b9..00000000 --- a/src/fenrirscreenreader/commands/onScreenUpdate/60000-history.py +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return "" - - def run(self): - if self.env["screen"]["newAttribDelta"] != "": - return - if self.env["runtime"]["ScreenManager"].is_screen_change(): - return - if self.env["runtime"]["CursorManager"].is_cursor_vertical_move(): - return - if self.env["runtime"]["AttributeManager"].is_attribute_change(): - return - # hack for pdmenu and maybe other dialog apps that place the cursor at - # last cell/row - # this is not to be identified as history - if ( - self.env["screen"]["new_cursor"]["x"] - == self.env["runtime"]["ScreenManager"].get_columns() - 1 - ) and ( - self.env["screen"]["new_cursor"]["y"] - == self.env["runtime"]["ScreenManager"].get_rows() - 1 - ): - return - if not ( - self.env["runtime"]["InputManager"].get_last_deepest_input() - in [["KEY_UP"], ["KEY_DOWN"]] - ): - return - - # Get the current cursor's line from both old and new content - prev_line = self.env["screen"]["old_content_text"].split("\n")[ - self.env["screen"]["new_cursor"]["y"] - ] - curr_line = self.env["screen"]["new_content_text"].split("\n")[ - self.env["screen"]["new_cursor"]["y"] - ] - - is_blank = curr_line.strip() == "" - - if prev_line == curr_line: - if self.env["screen"]["new_delta"] != "": - return - - announce = curr_line - if not is_blank: - curr_prompt = curr_line.find("$") - root_prompt = curr_line.find("#") - if curr_prompt <= 0: - if root_prompt > 0: - curr_prompt = root_prompt - else: - announce = curr_line - if curr_prompt > 0: - remove_digits = str.maketrans("0123456789", " ") - if prev_line[:curr_prompt].translate( - remove_digits - ) == curr_line[:curr_prompt].translate(remove_digits): - announce = curr_line[curr_prompt + 1 :] - else: - announce = curr_line - - if is_blank: - self.env["runtime"]["OutputManager"].present_text( - _("blank"), sound_icon="EmptyLine", interrupt=True, flush=False - ) - else: - self.env["runtime"]["OutputManager"].present_text( - announce, interrupt=True, flush=False - ) - - self.env["commandsIgnore"]["onScreenUpdate"]["CHAR_DELETE_ECHO"] = True - self.env["commandsIgnore"]["onScreenUpdate"]["CHAR_ECHO"] = True - self.env["commandsIgnore"]["onScreenUpdate"]["INCOMING_IGNORE"] = True - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/onScreenUpdate/64000-tab_completion.py b/src/fenrirscreenreader/commands/onScreenUpdate/64000-tab_completion.py deleted file mode 100644 index 021094fe..00000000 --- a/src/fenrirscreenreader/commands/onScreenUpdate/64000-tab_completion.py +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.core.tabCompletionManager import TabCompletionManager - - -class command: - def __init__(self): - self.manager = TabCompletionManager() - - def initialize(self, environment): - self.env = environment - self.manager.initialize(environment) - - def shutdown(self): - pass - - def get_description(self): - return _("Announces visible tab completion results") - - def run(self): - text = self.manager.process_update() - if not text: - return - - do_interrupt = True - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "speech", "auto_read_incoming" - ): - do_interrupt = False - - self.env["runtime"]["OutputManager"].present_text( - text, - interrupt=do_interrupt, - announce_capital=True, - flush=False, - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/onScreenUpdate/65000-progress_detector.py b/src/fenrirscreenreader/commands/onScreenUpdate/65000-progress_detector.py deleted file mode 100644 index 5836d869..00000000 --- a/src/fenrirscreenreader/commands/onScreenUpdate/65000-progress_detector.py +++ /dev/null @@ -1,710 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core import debug -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Detects progress patterns for progress bar monitoring") - - def run(self): - # Only run if progress monitoring is enabled - try: - if ( - "progress_monitoring" in self.env["commandBuffer"] - and self.env["commandBuffer"]["progress_monitoring"] - ): - # Check if current line is a prompt - if so, reset progress - # state - if self.is_current_line_prompt(): - self.reset_progress_state() - # Only check new incoming text (new_delta), but filter out - # screen changes - elif ( - self.env["screen"]["new_delta"] - and not self.env["screen"].get("new_delta_is_typing", False) - and self.is_real_progress_update() - ): - self.detect_progress(self.env["screen"]["new_delta"]) - except Exception as e: - # Log errors for debugging instead of silently ignoring - self.env["runtime"]["DebugManager"].write_debug_out( - "Progress detector error: " + str(e), - debug.DebugLevel.ERROR, - ) - - def is_real_progress_update(self): - """Check if this is a real progress update vs screen change/window switch""" - # If the screen/application changed, it's not a progress update - if self.env["runtime"]["ScreenManager"].is_screen_change(): - self.env["runtime"]["DebugManager"].write_debug_out( - "Progress filter: screen change detected", debug.DebugLevel.INFO - ) - return False - - # If there was a large cursor movement, it's likely navigation, not - # progress - if self.env["runtime"]["CursorManager"].is_cursor_vertical_move(): - x_move = abs( - self.env["screen"]["new_cursor"]["x"] - - self.env["screen"]["old_cursor"]["x"] - ) - y_move = abs( - self.env["screen"]["new_cursor"]["y"] - - self.env["screen"]["old_cursor"]["y"] - ) - # Large movements suggest navigation, not progress output - if y_move > 2 or x_move > 20: - self.env["runtime"]["DebugManager"].write_debug_out( - f"Progress filter: large cursor move y={y_move} x={x_move}", - debug.DebugLevel.INFO, - ) - return False - - # Check if delta is too large (screen change) vs small incremental - # updates - delta_text = self.env["screen"]["new_delta"] - delta_length = len(delta_text) - explicit_progress_lines = self.get_explicit_progress_lines(delta_text) - if ( - delta_length > 200 - ): # Allow longer progress lines such as terminal status output - if not explicit_progress_lines: - self.env["runtime"]["DebugManager"].write_debug_out( - f"Progress filter: delta too long ({delta_length})", - debug.DebugLevel.INFO, - ) - return False - - # If delta contains newlines and is substantial, let incoming handler - # deal with it to avoid interfering with multi-line text output - if ( - "\n" in delta_text - and delta_length > 50 - and not explicit_progress_lines - ): - self.env["runtime"]["DebugManager"].write_debug_out( - f"Progress filter: multiline delta ({delta_length} chars)", - debug.DebugLevel.INFO, - ) - return False - - # Check if current line looks like a prompt - progress unlikely during - # prompts - if self.is_current_line_prompt(): - self.env["runtime"]["DebugManager"].write_debug_out( - "Progress filter: prompt detected", debug.DebugLevel.INFO - ) - return False - - return True - - def get_curl_classic_percentage(self, text): - """Return progress from a structurally valid curl classic row.""" - import re - - size_field = r"\d+(?:\.\d+)?(?:[kKmMgGtT](?:i?[bB])?)?" - time_field = r"(?:(?:\d+:)?\d{2}:\d{2}|--:--:--)" - curl_classic_match = re.match( - rf"^\s*(\d+)\s+{size_field}\s+" - rf"(\d+)\s+{size_field}\s+" - rf"(\d+)\s+{size_field}\s+" - rf"{size_field}\s+{size_field}\s+" - rf"{time_field}\s+{time_field}\s+{time_field}\s+" - rf"{size_field}(?=\s|$)", - text, - ) - if not curl_classic_match: - return None - - total_percentage = int(curl_classic_match.group(1)) - received_percentage = int(curl_classic_match.group(2)) - uploaded_percentage = int(curl_classic_match.group(3)) - if not all( - 0 <= percentage <= 100 - for percentage in ( - total_percentage, - received_percentage, - uploaded_percentage, - ) - ): - return None - if total_percentage != received_percentage: - return None - return float(total_percentage) - - def is_explicit_progress_delta(self, text): - """Allow long single-line deltas that still look like progress output.""" - import re - - if "\n" in text or self.contains_url(text): - return False - - if self.get_curl_classic_percentage(text) is not None: - return True - - has_percentage = re.search(r"(^|\s)\d+(?:\.\d+)?\s*%", text) - if not has_percentage: - return self.has_progress_status_fields(text) - - bracketed_bar = re.search( - r"\[(?=[#=*>ā–ˆā–‰ā–Šā–‹ā–Œā–ā–Žā–ā–’ā–“ā–‘.\s-]{2,}\])" - r"(?=[^\]]*[#=*>ā–ˆā–‰ā–Šā–‹ā–Œā–ā–Žā–ā–’ā–“ā–‘])" - r"[#=*>ā–ˆā–‰ā–Šā–‹ā–Œā–ā–Žā–ā–’ā–“ā–‘.\s-]+\]", - text, - ) - pipe_bar = re.search( - r"\|(?=[#=*>ā–ˆā–‰ā–Šā–‹ā–Œā–ā–Žā–ā–’ā–“ā–‘.\s-]{2,}\|)" - r"(?=[^|]*[#=*>ā–ˆā–‰ā–Šā–‹ā–Œā–ā–Žā–ā–’ā–“ā–‘])" - r"[#=*>ā–ˆā–‰ā–Šā–‹ā–Œā–ā–Žā–ā–’ā–“ā–‘.\s-]+\|", - text, - ) - transfer_rate = re.search( - r"\b\d+(?:\.\d+)?\s*[kKmMgGtT](?:i?B)?/s\b", - text, - ) - return bool( - bracketed_bar - or pipe_bar - or transfer_rate - ) - - def get_explicit_progress_lines(self, text): - """Return structurally explicit progress rows from a screen delta.""" - return [ - line - for line in text.splitlines() - if self.is_explicit_progress_delta(line) - ] - - def reset_progress_state(self): - """Reset progress state when a prompt is detected, allowing new progress operations to start fresh""" - self.env["runtime"]["DebugManager"].write_debug_out( - "Resetting progress state due to prompt detection", - debug.DebugLevel.INFO, - ) - self.env["commandBuffer"]["lastProgressValue"] = -1 - self.env["commandBuffer"]["lastProgressTime"] = 0 - - def detect_progress(self, text): - import re - import time - - if "\n" in text: - explicit_progress_lines = self.get_explicit_progress_lines(text) - if not explicit_progress_lines: - return - # Parallel transfer tools commonly repaint several package rows, - # followed by an aggregate row. One tone per update is enough, and - # the final explicit row is normally that aggregate. - text = explicit_progress_lines[-1] - - current_time = time.time() - - # Debug: Print what we're checking - self.env["runtime"]["DebugManager"].write_debug_out( - "Progress detector checking: '" + text + "'", debug.DebugLevel.INFO - ) - - # Filter out URLs to prevent false positives - if self.contains_url(text): - self.env["runtime"]["DebugManager"].write_debug_out( - "Skipping progress detection - text contains URL", debug.DebugLevel.INFO - ) - return - - # Note: Auto-disable on 100% completion removed to respect user - # settings - - # Pattern 1: Percentage (50%, 25.5%, etc.) - # Filter out common non-progress percentages (weather, system stats, - # etc.) - percent_match = re.search(r"(\d+(?:\.\d+)?)\s*%", text) - if percent_match: - percentage = float(percent_match.group(1)) - # Only trigger on realistic progress percentages (0-100%) - if 0 <= percentage <= 100: - # Filter out weather/system stats that contain percentages - if not re.search( - r"\b(?:humidity|cpu|memory|disk|usage|temp|weather|forecast)\b", - text, - re.IGNORECASE, - ): - self.env["runtime"]["DebugManager"].write_debug_out( - "found percentage: " + str(percentage), - debug.DebugLevel.INFO, - ) - if ( - percentage - != self.env["commandBuffer"]["lastProgressValue"] - ): - self.env["runtime"]["DebugManager"].write_debug_out( - "Playing tone for: " + str(percentage), - debug.DebugLevel.INFO, - ) - self.play_progress_tone(percentage) - self.env["commandBuffer"][ - "lastProgressValue" - ] = percentage - self.env["commandBuffer"][ - "lastProgressTime" - ] = current_time - return - - # Pattern 1a2: Curl classic progress format (percentage without % symbol) - percentage = self.get_curl_classic_percentage(text) - if percentage is not None: - self.env["runtime"]["DebugManager"].write_debug_out( - "found curl classic percentage: " + str(percentage), - debug.DebugLevel.INFO, - ) - if percentage != self.env["commandBuffer"]["lastProgressValue"]: - self.env["runtime"]["DebugManager"].write_debug_out( - "Playing tone for curl: " + str(percentage), - debug.DebugLevel.INFO, - ) - self.play_progress_tone(percentage) - self.env["commandBuffer"]["lastProgressValue"] = percentage - self.env["commandBuffer"]["lastProgressTime"] = current_time - return - - # Pattern 1b: Time/token activity (not percentage-based, so use single - # beep) - time_match = re.search(r"(?:(?:remaining|elapsed|left|ETA|eta)[:;\s]*(\d+)s|(\d+)s\s+(?:remaining|elapsed|left))", text, re.IGNORECASE) - token_match = re.search(r"(?:processing|generating|used|consumed)\s+(\d+)\s+tokens", text, re.IGNORECASE) - # Pattern 1c: dd command output (bytes copied with transfer rate) - dd_match = re.search(r"\d+\s+bytes.*copied.*\d+\s+s.*[kMGT]?B/s", text) - # Pattern 1d: Curl-style transfer data (bytes, speed indicators - legacy) - curl_match = re.search( - r"(\d+\s+\d+\s+\d+\s+\d+.*?(?:k|M|G)?.*?--:--:--|Speed)", text - ) - # Pattern 1e: General transfer progress (size, rate, time patterns) - transfer_match = re.search( - r"\d+\s+\d+[kMGT]?\s+\d+\s+\d+[kMGT]?.*?\d+\.\d+[kMGT].*?\d+:\d+:\d+", text - ) - # Pattern 1f: Pacman-style transfer progress (flexible size/speed/time) - pacman_match = re.search( - r"\d+(?:\.\d+)?\s+[kKmMgGtT]iB\s+\d+(?:\.\d+)?\s+[kKmMgGtT]iB/s\s+\d+:\d+", text - ) - # Pattern 1g: Key/value status lines from ffmpeg-like terminal tools. - # These do not expose a percentage in the status line, so treat them as - # activity rather than guessing progress from elapsed time. - status_fields_match = self.has_progress_status_fields(text) - - if ( - time_match - or token_match - or dd_match - or curl_match - or transfer_match - or pacman_match - or status_fields_match - ): - # For non-percentage progress, use a single activity beep every 2 - # seconds - if ( - current_time - self.env["commandBuffer"]["lastProgressTime"] - >= 2.0 - ): - self.env["runtime"]["DebugManager"].write_debug_out( - "Playing activity beep for transfer progress", - debug.DebugLevel.INFO, - ) - self.play_activity_beep() - self.env["commandBuffer"]["lastProgressTime"] = current_time - return - - # Pattern 2: Fraction (15/100, 3 of 10, etc.) - fraction_match = re.search(r"(\d+)\s*(?:of|/)\s*(\d+)", text) - if fraction_match: - current = int(fraction_match.group(1)) - total = int(fraction_match.group(2)) - # Filter out dates, page numbers, and other non-progress fractions - if (total > 0 and total <= 1000 and current <= total and - not re.search(r"\b(?:page|chapter|section|line|row|column|year|month|day)\b", text, re.IGNORECASE) and - not re.search(r"\d{1,2}/\d{1,2}/\d{2,4}", text)): # Date pattern - percentage = (current / total) * 100 - if ( - percentage - != self.env["commandBuffer"]["lastProgressValue"] - ): - self.play_progress_tone(percentage) - self.env["commandBuffer"]["lastProgressValue"] = percentage - self.env["commandBuffer"][ - "lastProgressTime" - ] = current_time - return - - # Pattern 3: Progress bars ([#### ], [====> ], etc.) - # Improved pattern to avoid matching IRC channels like [#channel] - bar_match = re.search(r"\[([#=\*]+)([\s\.\-]*)\]", text) - if bar_match: - filled = len(bar_match.group(1)) - unfilled = len(bar_match.group(2)) - total = filled + unfilled - # Require at least 2 progress chars total and unfilled portion must - # be spaces/dots - if total >= 2 and ( - not bar_match.group(2) - or re.match(r"^[\s\.]*$", bar_match.group(2)) - ): - percentage = (filled / total) * 100 - if ( - percentage - != self.env["commandBuffer"]["lastProgressValue"] - ): - self.play_progress_tone(percentage) - self.env["commandBuffer"]["lastProgressValue"] = percentage - self.env["commandBuffer"][ - "lastProgressTime" - ] = current_time - return - - # Pattern 4: Generic activity indicators (Loading..., Working..., etc.) - activity_pattern = re.search( - r"(loading|processing|working|installing|downloading|compiling|building).*\.{2,}", - text, - re.IGNORECASE, - ) - if activity_pattern: - # Play a steady beep every 2 seconds for ongoing activity - if ( - current_time - self.env["commandBuffer"]["lastProgressTime"] - >= 2.0 - ): - self.play_activity_beep() - self.env["commandBuffer"]["lastProgressTime"] = current_time - return - - # Pattern 5: Braille spinner indicators - braille_match = re.search(r'[ā ‹ā ™ā ¹ā øā ¼ā “ā ¦ā §ā ‡ā ā”æā£Ÿā£Æā£·ā£¾ā£½ā£»ā¢æ]', text) - if braille_match: - if current_time - self.env["commandBuffer"]["lastProgressTime"] >= 1.0: - self.play_activity_beep() - self.env["commandBuffer"]["lastProgressTime"] = current_time - return - - # Pattern 6: Interruptible terminal activity indicators - # Matches any: [symbol] [Task description][…] (... to interrupt ...) - # Symbols include: * ✢ ✽ ✶ ✻ Ā· • ā—¦ ā—‹ ā— ā—† and similar decorative characters - # Keep this structural rather than adding application-specific formats, - # which change too frequently to support reliably. - interruptible_activity_match = re.search( - r'[*āœ¢āœ½āœ¶āœ»Ā·ā€¢ā—¦ā—‹ā—ā—†]\s+\w+.*?(?:…\s*)?\(.*(?:esc|ctrl\+c) to interrupt.*\)', - text, - re.IGNORECASE, - ) - if not interruptible_activity_match: - interruptible_activity_match = re.search( - ( - r'^\s*(?:thinking|working|processing|analyzing|searching|' - r'reading|writing|planning|running|executing|updating|' - r'building|installing|compiling|downloading|reviewing|' - r'generating|responding|applying|fixing|editing|creating|' - r'preparing|checking|opening|loading|fetching|retrieving|' - r'scanning|indexing|summarizing)\b.*' - r'\([^)]*(?:esc|ctrl\+c) to interrupt[^)]*\)\s*$' - ), - text, - re.IGNORECASE, - ) - if interruptible_activity_match: - if current_time - self.env["commandBuffer"]["lastProgressTime"] >= 1.0: - self.env["runtime"]["DebugManager"].write_debug_out( - "Playing interruptible activity beep", - debug.DebugLevel.INFO, - ) - self.play_activity_beep() - self.env["commandBuffer"]["lastProgressTime"] = current_time - return - - # Pattern 6b: Bullet/white bullet activity lines (•/ā—¦ ...) - bullet_activity_match = re.search( - ( - r'^\s*[•◦]\s+.*(?:…|\.{3,}|\b(?:thinking|working|processing|' - r'analyzing|searching|reading|writing|planning|running|' - r'executing|updating|building|installing|compiling|downloading|' - r'reviewing|generating|responding|applying|fixing|editing|' - r'creating|preparing|checking|opening|loading|fetching|' - r'retrieving|scanning|indexing|summarizing)\b)' - ), - text, - re.IGNORECASE, - ) - if bullet_activity_match: - if current_time - self.env["commandBuffer"]["lastProgressTime"] >= 1.0: - self.env["runtime"]["DebugManager"].write_debug_out( - "Playing bullet activity beep", - debug.DebugLevel.INFO, - ) - self.play_activity_beep() - self.env["commandBuffer"]["lastProgressTime"] = current_time - return - - # Pattern 7: Moon phase spinner indicators - moon_match = re.search(r'[šŸŒ‘šŸŒ’šŸŒ“šŸŒ”šŸŒ•šŸŒ–šŸŒ—šŸŒ˜]', text) - if moon_match: - if current_time - self.env["commandBuffer"]["lastProgressTime"] >= 1.0: - self.play_activity_beep() - self.env["commandBuffer"]["lastProgressTime"] = current_time - return - - # Pattern 8: Thinking/processing with timing (šŸ”„ Thinking... 23s) - thinking_match = re.search(r'šŸ”„[^\w]*(?:thinking|processing|working|analyzing)[^\d]*(\d+)s?\b', text, re.IGNORECASE) - if thinking_match: - # Extract timing value for activity beep frequency adjustment - seconds = int(thinking_match.group(1)) - # Use slightly longer interval for thinking patterns to avoid spam - thinking_interval = 1.5 if seconds < 10 else 2.0 - if ( - current_time - self.env["commandBuffer"]["lastProgressTime"] - >= thinking_interval - ): - self.env["runtime"]["DebugManager"].write_debug_out( - f"Playing thinking activity beep (timing: {seconds}s)", - debug.DebugLevel.INFO, - ) - self.play_activity_beep() - self.env["commandBuffer"]["lastProgressTime"] = current_time - return - - # Pattern 9: Half-circle/circle progress indicators (◐ ā—“ ā—’ ā—‘) - circle_match = re.search(r'[◐◓◒◑]', text) - if circle_match: - if current_time - self.env["commandBuffer"]["lastProgressTime"] >= 1.0: - self.play_activity_beep() - self.env["commandBuffer"]["lastProgressTime"] = current_time - return - - # Pattern 10: Task status indicators (ā— Task Output, ā—‹ Task Running, etc.) - # Matches bullet points with task-related status text - task_status_match = re.search( - r'[ā—ā—‹ā—‰]\s+(?:Task\s+)?(?:Output|Running|Pending|Working|Processing)\s+[a-zA-Z0-9]+', - text, - re.IGNORECASE, - ) - if task_status_match: - if current_time - self.env["commandBuffer"]["lastProgressTime"] >= 1.5: - self.env["runtime"]["DebugManager"].write_debug_out( - "Playing task status activity beep", - debug.DebugLevel.INFO, - ) - self.play_activity_beep() - self.env["commandBuffer"]["lastProgressTime"] = current_time - return - - def play_progress_tone(self, percentage): - # Map 0-100% to 400-1200Hz frequency range - frequency = 400 + (percentage * 8) - frequency = max(400, min(1200, frequency)) # Clamp to safe range - - # Use Sox directly for clean quiet tones like: play -qn synth .1 tri - # 400 gain -8 - self.play_quiet_tone(frequency, 0.1) - - def play_activity_beep(self): - # Single tone for generic activity - self.play_quiet_tone(800, 0.08) - - def play_quiet_tone(self, frequency, duration): - """Play a quiet tone using Sox directly with flood protection""" - import shlex - import subprocess - import time - - # Flood protection: prevent beeps closer than 0.1 seconds apart - current_time = time.time() - if not hasattr(self, '_last_beep_time'): - self._last_beep_time = 0 - - if current_time - self._last_beep_time < 0.1: - # Skip this beep to prevent audio crackling on low-resource systems - return - - self._last_beep_time = current_time - - # Build the Sox command: play -qn synth tri gain - # -8 - command = f"play -qn synth {duration} tri {frequency} gain -8" - - try: - # Only play if sound is enabled - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "sound", "enabled" - ): - subprocess.Popen( - shlex.split(command), - stdin=None, - stdout=None, - stderr=None, - shell=False, - ) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "Sox tone error: " + str(e), debug.DebugLevel.ERROR - ) - - def is_current_line_prompt(self): - """Check if the current line looks like a standalone prompt (not command with progress)""" - import re - - try: - # Get the current screen content - if not self.env["screen"]["new_content_text"]: - return False - - lines = self.env["screen"]["new_content_text"].split("\n") - if not lines: - return False - - # Check the last line (most common) and current cursor line for - # prompt patterns - lines_to_check = [] - - # Add last line (most common for prompts) - if lines: - lines_to_check.append(lines[-1]) - - # Add current cursor line if different from last line - if ( - self.env["screen"]["new_cursor"]["y"] < len(lines) - and self.env["screen"]["new_cursor"]["y"] != len(lines) - 1 - ): - lines_to_check.append( - lines[self.env["screen"]["new_cursor"]["y"]] - ) - - # Standalone prompt patterns (no commands mixed in) - standalone_prompt_patterns = [ - r"^\s*\$\s*$", # Just $ (with whitespace) - r"^\s*#\s*$", # Just # (with whitespace) - r"^\s*>\s*$", # Just > (with whitespace) - r"^\[.*\]\s*[\\\$#>]\s*$", # [path]$ without commands - r"^[a-zA-Z0-9._-]+[\\\$#>]\s*$", # bash-5.1$ without commands - # Interactive prompt patterns (these ARE standalone) - r".*\?\s*\[[YyNn]/[YyNn]\]\s*$", # ? [Y/n] or ? [y/N] style - r".*\?\s*\[[Yy]es/[Nn]o\]\s*$", # ? [Yes/No] style - # "continue? [Y/n]" style - r".*continue\?\s*\[[YyNn]/[YyNn]\].*$", - r"^::.*\?\s*\[[YyNn]/[YyNn]\].*$", # pacman style prompts - # Authentication prompts (these ARE standalone) - r"^\[[Ss]udo\]\s*[Pp]assword\s*for\s+.*:\s*$", # [sudo] password - r"^[Pp]assword\s*:\s*$", # Password: - r".*[Pp]assword\s*:\s*$", # general password prompts - # Continuation prompts (these ARE standalone) - r"^[Pp]ress\s+any\s+key\s+to\s+continue.*$", # Press any key - r"^[Aa]re\s+you\s+sure\?\s*.*$", # Are you sure? - ] - - for line in lines_to_check: - line = line.strip() - if not line: - continue - - # Check if this line contains both a prompt AND other content (like commands) - # If so, don't treat it as a standalone prompt - has_prompt_marker = bool( - re.search(r".*@.*[\\\$#>]", line) - or re.search(r"^\[.*\]\s*[\\\$#>]", line) - ) - if has_prompt_marker: - # If line has prompt marker but also has significant content after it, - # it's a command line, not a standalone prompt - prompt_end = max( - line.rfind("$"), - line.rfind("#"), - line.rfind(">"), - line.rfind("\\"), - ) - if ( - prompt_end >= 0 and prompt_end < len(line) - 5 - ): # More than just whitespace after prompt - continue # This is a command line, not a standalone prompt - - for pattern in standalone_prompt_patterns: - try: - if re.search(pattern, line): - return True - except re.error: - continue - - return False - - except Exception: - # If anything fails, assume it's not a prompt to be safe - return False - - def contains_url(self, text): - """Check if text contains URLs that might cause false progress detection""" - import re - - # Specific URL patterns - only match actual URLs, not filenames - url_patterns = [ - r"\S+://\S+\.\S{2,}", # Any protocol:// with domain.ext - r"www\.[^\s]+\.[a-zA-Z]{2,}", # www.domain.ext patterns - ] - - for pattern in url_patterns: - if re.search(pattern, text, re.IGNORECASE): - return True - return False - - def has_progress_status_fields(self, text): - """Recognize dense key=value status lines from terminal tools.""" - import re - - if "\n" in text or self.contains_url(text): - return False - - fields = re.findall(r"\b([a-zA-Z][a-zA-Z0-9_-]*)=\s*\S+", text) - if len(fields) < 3: - return False - - field_names = {field.lower() for field in fields} - progress_fields = { - "frame", - "fps", - "size", - "time", - "bitrate", - "speed", - "elapsed", - "dup", - "drop", - } - if len(field_names & progress_fields) < 3: - return False - - has_time_field = bool( - re.search( - r"\b(?:time|elapsed|eta)=\s*\d+(?::\d{2}){1,2}(?:\.\d+)?\b", - text, - re.IGNORECASE, - ) - ) - has_rate_field = bool( - re.search( - r"\b(?:speed|fps|bitrate)=\s*[\d.]+[a-zA-Z/]*\b", - text, - re.IGNORECASE, - ) - ) - - return has_time_field and has_rate_field - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/onScreenUpdate/66000-prompt_detector.py b/src/fenrirscreenreader/commands/onScreenUpdate/66000-prompt_detector.py deleted file mode 100644 index b1a1011b..00000000 --- a/src/fenrirscreenreader/commands/onScreenUpdate/66000-prompt_detector.py +++ /dev/null @@ -1,186 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core import debug -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Detects shell prompts for silence until prompt feature") - - def run(self): - # Only run if silence until prompt is active - try: - if ( - "silenceUntilPrompt" in self.env["commandBuffer"] - and self.env["commandBuffer"]["silenceUntilPrompt"] - ): - # Check the current line for prompt patterns - if self.env["screen"]["new_content_text"]: - lines = self.env["screen"]["new_content_text"].split("\n") - if lines and self.env["screen"]["new_cursor"]["y"] < len( - lines - ): - current_line = lines[ - self.env["screen"]["new_cursor"]["y"] - ] - self.check_for_prompt(current_line) - except Exception as e: - # Silently ignore errors to avoid disrupting normal operation - self.env["runtime"]["DebugManager"].write_debug_out( - "prompt_detector run: Error in prompt detection: " + str(e), - debug.DebugLevel.ERROR, - ) - - def check_for_prompt(self, text): - """Check if the current line contains a shell prompt pattern""" - import re - - # Debug: Print what we're checking - self.env["runtime"]["DebugManager"].write_debug_out( - "Prompt detector checking: '" + text + "'", debug.DebugLevel.INFO - ) - - # First check for exact matches from settings (with backward - # compatibility) - try: - exact_matches = self.env["runtime"]["SettingsManager"].get_setting( - "prompt", "exact_matches" - ) - if exact_matches: - exact_list = [ - match.strip() - for match in exact_matches.split(",") - if match.strip() - ] - for exactMatch in exact_list: - if text.strip() == exactMatch: - self.env["runtime"]["DebugManager"].write_debug_out( - "found exact prompt match: " + exactMatch, - debug.DebugLevel.INFO, - ) - self._restore_speech() - return True - except Exception as e: - # Prompt section doesn't exist in settings, skip custom exact - # matches - pass - - # Get custom patterns from settings (with backward compatibility) - prompt_patterns = [] - try: - custom_patterns = self.env["runtime"][ - "SettingsManager" - ].get_setting("prompt", "custom_patterns") - # Add custom patterns from settings if they exist - if custom_patterns: - custom_list = [ - pattern.strip() - for pattern in custom_patterns.split(",") - if pattern.strip() - ] - prompt_patterns.extend(custom_list) - except Exception as e: - # Prompt section doesn't exist in settings, skip custom patterns - pass - - # Add default shell prompt patterns - prompt_patterns.extend( - [ - r"^\s*\$\s*$", # Just $ (with whitespace) - r"^\s*#\s*$", # Just # (with whitespace) - r"^\s*>\s*$", # Just > (with whitespace) - r".*@.*[\\\$#>]\s*$", - # Contains @ and ends with prompt char (user@host style) - r"^\[.*\]\s*[\\\$#>]\s*$", # [anything]$ style prompts - # Simple shell names like bash-5.1$ - r"^[a-zA-Z0-9._-]+[\\\$#>]\s*$", - # Interactive prompt patterns - # Package manager confirmation prompts - r".*\?\s*\[[YyNn]/[YyNn]\]\s*$", # ? [Y/n] or ? [y/N] style - r".*\?\s*\[[Yy]es/[Nn]o\]\s*$", # ? [Yes/No] style - r".*\?\s*\([YyNn]/[YyNn]\)\s*$", # ? (Y/n) or ? (y/N) style - r".*\?\s*\([Yy]es/[Nn]o\)\s*$", # ? (Yes/No) style - r".*continue\?\s*\[[YyNn]/[YyNn]\].*$", # "continue? [Y/n]" style - r".*ok\s*\[[YyNn]/[YyNn]\].*$", # "Is this ok [y/N]:" style - # pacman ":: Proceed? [Y/n]" style - r"^::.*\?\s*\[[YyNn]/[YyNn]\].*$", - # Authentication prompts - # [sudo] password for user: - r"^\[[Ss]udo\]\s*[Pp]assword\s*for\s+.*:\s*$", - r"^[Pp]assword\s*:\s*$", # Password: - r".*[Pp]assword\s*:\s*$", # general password prompts - r".*'s\s*[Pp]assword\s*:\s*$", # user's password: - r"^[Ee]nter\s+[Pp]assphrase.*:\s*$", # Enter passphrase: - # Please enter passphrase: - r"^[Pp]lease\s+enter\s+[Pp]assphrase.*:\s*$", - # General confirmation and continuation prompts - # Press any key to continue - r"^[Pp]ress\s+any\s+key\s+to\s+continue.*$", - r"^[Aa]re\s+you\s+sure\?\s*.*$", # Are you sure? - r"^[Pp]lease\s+confirm.*$", # Please confirm - r".*confirm.*\([YyNn]/[YyNn]\).*$", # confirm (y/n) - r".*\([Yy]/[Nn]\)\s*$", # ends with (Y/n) or (y/N) - ] - ) - - for pattern in prompt_patterns: - try: - if re.search(pattern, text.strip()): - self.env["runtime"]["DebugManager"].write_debug_out( - "found prompt pattern: " + pattern, - debug.DebugLevel.INFO, - ) - self._restore_speech() - return True - except re.error as e: - # Invalid regex pattern, skip it and log the error - self.env["runtime"]["DebugManager"].write_debug_out( - "Invalid prompt pattern: " + pattern + " Error: " + str(e), - debug.DebugLevel.ERROR, - ) - continue - - return False - - def _restore_speech(self): - """Helper method to restore speech when prompt is detected""" - # If speech is already enabled, just clear flags to avoid unnecessary - # interrupts on prompt return - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "speech", "enabled" - ): - self.env["commandBuffer"]["silenceUntilPrompt"] = False - if "enableSpeechOnKeypress" in self.env["commandBuffer"]: - self.env["commandBuffer"]["enableSpeechOnKeypress"] = False - return - - # Disable silence mode - self.env["commandBuffer"]["silenceUntilPrompt"] = False - # Also disable the keypress-based speech restoration since we're - # enabling it now - if "enableSpeechOnKeypress" in self.env["commandBuffer"]: - self.env["commandBuffer"]["enableSpeechOnKeypress"] = False - # Re-enable speech - self.env["runtime"]["SettingsManager"].set_setting( - "speech", "enabled", "True" - ) - self.env["runtime"]["OutputManager"].present_text( - _("Speech restored"), sound_icon="SpeechOn", interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/onScreenUpdate/70000-incoming.py b/src/fenrirscreenreader/commands/onScreenUpdate/70000-incoming.py deleted file mode 100644 index 085c90b1..00000000 --- a/src/fenrirscreenreader/commands/onScreenUpdate/70000-incoming.py +++ /dev/null @@ -1,318 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import difflib -import re -import time -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - self._update_times = [] - self._line_count_times = [] - self._batched_text = [] - self._last_flush_time = 0 - self._in_flood_mode = False - self._pending_focus_text = "" - self._pending_focus_time = 0 - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Announces incoming text changes") - - def _reset_flood_state(self): - self._update_times = [] - self._line_count_times = [] - self._batched_text = [] - self._last_flush_time = 0 - self._in_flood_mode = False - - def _is_rapid_updates(self): - current_time = time.time() - window = self.env["runtime"]["SettingsManager"].get_setting_as_float( - "speech", "rapid_update_window" - ) - threshold = self.env["runtime"]["SettingsManager"].get_setting_as_int( - "speech", "rapid_update_threshold" - ) - - self._update_times = [ - ts for ts in self._update_times if current_time - ts < window - ] - self._update_times.append(current_time) - - return len(self._update_times) >= threshold - - def _is_high_volume(self, delta_text): - current_time = time.time() - window = self.env["runtime"]["SettingsManager"].get_setting_as_float( - "speech", "rapid_update_window" - ) - threshold = self.env["runtime"]["SettingsManager"].get_setting_as_int( - "speech", "flood_line_threshold" - ) - - line_count = max(1, delta_text.count("\n") + 1) - self._line_count_times = [ - (ts, count) - for ts, count in self._line_count_times - if current_time - ts < window - ] - self._line_count_times.append((current_time, line_count)) - - total_lines = sum(count for _, count in self._line_count_times) - return total_lines >= threshold - - def _add_to_batch(self, text): - new_lines = text.splitlines() - if text.endswith("\n"): - new_lines.append("") - self._batched_text.extend(new_lines) - - max_lines = self.env["runtime"]["SettingsManager"].get_setting_as_int( - "speech", "max_batch_lines" - ) - if len(self._batched_text) > max_lines: - self._batched_text = self._batched_text[-max_lines:] - - def _flush_batch(self): - if not self._batched_text: - return - - text = "\n".join(self._batched_text) - self._batched_text = [] - self._last_flush_time = time.time() - - self.env["runtime"]["OutputManager"].present_text( - text, interrupt=False, flush=False - ) - - def _was_handled_by_tab_completion(self, delta_text): - """Check if this delta was already handled by tab completion to avoid duplicates""" - if "tabCompletion" not in self.env["commandBuffer"]: - return False - - tab_state = self.env["commandBuffer"]["tabCompletion"] - - # Check if this exact delta was processed recently by tab completion - if (tab_state.get("lastProcessedDelta") == delta_text and - tab_state.get("lastProcessedTime")): - - # Only suppress if processed within the last 50ms to avoid stale suppression - # Reduced from 100ms to minimize false positives with rapid multi-line updates - time_since_processed = time.time() - tab_state["lastProcessedTime"] - if time_since_processed <= 0.05: - return True - - return False - - def _normalize_line(self, line): - return " ".join(line.split()) - - def _is_focus_control_line(self, text): - stripped = self._normalize_line(text) - if not stripped: - return False - if re.fullmatch(r"<\s*[A-Za-z][A-Za-z0-9 _.-]{0,30}\s*>", stripped): - return True - return stripped.lower() in { - "ok", - "cancel", - "yes", - "no", - "retry", - "abort", - "ignore", - "continue", - } - - def _is_transient_focus_only_update(self, delta_text): - delta_lines = [ - self._normalize_line(line) - for line in delta_text.splitlines() - if line.strip() - ] - if len(delta_lines) != 1: - return False - if not self._is_focus_control_line(delta_lines[0]): - return False - - screen_lines = [ - self._normalize_line(line) - for line in self.env["screen"]["new_content_text"].split("\n") - if line.strip() - ] - return screen_lines == delta_lines - - def _store_pending_focus_text(self, delta_text): - delta_lines = [ - self._normalize_line(line) - for line in delta_text.splitlines() - if line.strip() - ] - self._pending_focus_text = "\n".join(delta_lines) - self._pending_focus_time = time.time() - - def _take_pending_focus_text(self): - if not self._pending_focus_text: - return "" - if time.time() - self._pending_focus_time > 0.5: - self._pending_focus_text = "" - self._pending_focus_time = 0 - return "" - pending = self._pending_focus_text - self._pending_focus_text = "" - self._pending_focus_time = 0 - return pending - - def _is_subsequence(self, subset_lines, source_lines): - subset_index = 0 - for source_line in source_lines: - if source_line == subset_lines[subset_index]: - subset_index += 1 - if subset_index == len(subset_lines): - return True - return False - - def _prefer_inserted_lower_screen_text(self, delta_text): - delta_lines = [ - line for line in delta_text.splitlines() if line.strip() != "" - ] - if len(delta_lines) < 2: - return delta_text - - old_lines = self.env["screen"]["old_content_text"].split("\n") - new_lines = self.env["screen"]["new_content_text"].split("\n") - if len(old_lines) != len(new_lines) or len(new_lines) < 4: - return delta_text - - normalized_old_lines = [ - self._normalize_line(line) for line in old_lines - ] - normalized_new_lines = [ - self._normalize_line(line) for line in new_lines - ] - matcher = difflib.SequenceMatcher( - None, normalized_old_lines, normalized_new_lines, autojunk=False - ) - - top_lines_changed = False - inserted_lines = [] - lower_screen_start = max(2, len(new_lines) // 2) - - for tag, old_start, old_end, new_start, new_end in matcher.get_opcodes(): - if tag in {"replace", "delete"} and old_start < 2: - top_lines_changed = True - if tag != "insert" or new_start < lower_screen_start: - continue - inserted_lines.extend( - line for line in new_lines[new_start:new_end] if line.strip() != "" - ) - - if not top_lines_changed or not inserted_lines: - return delta_text - if all( - self._is_focus_control_line(line) for line in inserted_lines - ): - return delta_text - - normalized_delta_lines = [ - self._normalize_line(line) for line in delta_lines - ] - normalized_inserted_lines = [ - self._normalize_line(line) for line in inserted_lines - ] - if not self._is_subsequence( - normalized_inserted_lines, normalized_delta_lines - ): - return delta_text - if normalized_delta_lines == normalized_inserted_lines: - return delta_text - - return "\n".join(inserted_lines) - - def run(self): - if not self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "speech", "auto_read_incoming" - ): - return - # is there something to read? - if not self.env["runtime"]["ScreenManager"].is_delta(ignoreSpace=True): - return - if self.env["screen"].get("new_delta_is_typing", False): - return - - delta_text = self.env["screen"]["new_delta"] - - if self.env["runtime"]["ScreenManager"].is_screen_change(): - self._reset_flood_state() - self._pending_focus_text = "" - self._pending_focus_time = 0 - - # Skip if tab completion already handled this delta - if self._was_handled_by_tab_completion(delta_text): - return - - # this must be a keyecho or something - # if len(self.env['screen']['new_delta'].strip(' \n\t')) <= 1: - x_move = abs( - self.env["screen"]["new_cursor"]["x"] - - self.env["screen"]["old_cursor"]["x"] - ) - y_move = abs( - self.env["screen"]["new_cursor"]["y"] - - self.env["screen"]["old_cursor"]["y"] - ) - - if (x_move >= 1) and x_move == len(delta_text): - # if len(self.env['screen']['new_delta'].strip(' \n\t0123456789')) - # <= 2: - if "\n" not in delta_text: - return - - if self._is_transient_focus_only_update(delta_text): - self._store_pending_focus_text(delta_text) - return - - rapid = self._is_rapid_updates() - high_volume = self._is_high_volume(delta_text) - - if (rapid and high_volume) or self._in_flood_mode: - if not self._in_flood_mode: - self._last_flush_time = time.time() - self._in_flood_mode = True - - self._add_to_batch(delta_text) - - interval = self.env["runtime"][ - "SettingsManager" - ].get_setting_as_float("speech", "batch_flush_interval") - if time.time() - self._last_flush_time >= interval: - self._flush_batch() - - if not rapid or not high_volume: - if self._batched_text: - self._flush_batch() - self._in_flood_mode = False - return - - # print(x_move, y_move, len(self.env['screen']['new_delta']), len(self.env['screen']['newNegativeDelta'])) - delta_text = self._prefer_inserted_lower_screen_text(delta_text) - pending_focus_text = self._take_pending_focus_text() - if pending_focus_text: - delta_text = "\n".join([delta_text, pending_focus_text]) - self.env["runtime"]["OutputManager"].present_text( - delta_text, interrupt=False, flush=False - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/onScreenUpdate/80000-barrier_detect.py b/src/fenrirscreenreader/commands/onScreenUpdate/80000-barrier_detect.py deleted file mode 100644 index 33aefd4e..00000000 --- a/src/fenrirscreenreader/commands/onScreenUpdate/80000-barrier_detect.py +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return "No Description found" - - def run(self): - if not self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "barrier", "enabled" - ): - return - if not self.env["runtime"]["ScreenManager"].is_delta(ignoreSpace=True): - return - self.env["runtime"]["BarrierManager"].handle_line_barrier( - self.env["screen"]["new_content_text"].split("\n"), - self.env["screen"]["new_cursor"]["x"], - self.env["screen"]["new_cursor"]["y"], - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/onScreenUpdate/__init__.py b/src/fenrirscreenreader/commands/onScreenUpdate/__init__.py deleted file mode 100755 index e69de29b..00000000 diff --git a/src/fenrirscreenreader/commands/onSwitchApplicationProfile/__init__.py b/src/fenrirscreenreader/commands/onSwitchApplicationProfile/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/fenrirscreenreader/commands/onSwitchApplicationProfile/default.py b/src/fenrirscreenreader/commands/onSwitchApplicationProfile/default.py deleted file mode 100644 index 69f7c650..00000000 --- a/src/fenrirscreenreader/commands/onSwitchApplicationProfile/default.py +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return "No description found" - - def load(self): - return - print("--------------") - print("default") - print("load new", self.env["screen"]["new_application"]) - print("--------------") - - def unload(self): - return - print("--------------") - print("default") - print("unload old", self.env["screen"]["oldApplication"]) - print("--------------") - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/quickMenu/current_quick_menu_entry.py b/src/fenrirscreenreader/commands/quickMenu/current_quick_menu_entry.py deleted file mode 100644 index 82880896..00000000 --- a/src/fenrirscreenreader/commands/quickMenu/current_quick_menu_entry.py +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("get current quick menu entry") - - def run(self): - menu = "" - value = "" - menu = self.env["runtime"]["QuickMenuManager"].get_current_entry() - if menu != "": - value = self.env["runtime"]["QuickMenuManager"].get_current_value() - self.env["runtime"]["OutputManager"].present_text( - menu + " " + value, interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/quickMenu/current_quick_menu_value.py b/src/fenrirscreenreader/commands/quickMenu/current_quick_menu_value.py deleted file mode 100644 index 11acac5e..00000000 --- a/src/fenrirscreenreader/commands/quickMenu/current_quick_menu_value.py +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("get current quick menu value") - - def run(self): - value = self.env["runtime"]["QuickMenuManager"].get_current_value() - self.env["runtime"]["OutputManager"].present_text( - value, interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/quickMenu/next_quick_menu_entry.py b/src/fenrirscreenreader/commands/quickMenu/next_quick_menu_entry.py deleted file mode 100644 index 0a66a36e..00000000 --- a/src/fenrirscreenreader/commands/quickMenu/next_quick_menu_entry.py +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("get next quick menu entry") - - def run(self): - menu = "" - value = "" - if self.env["runtime"]["QuickMenuManager"].next_entry(): - menu = self.env["runtime"]["QuickMenuManager"].get_current_entry() - if menu != "": - value = self.env["runtime"][ - "QuickMenuManager" - ].get_current_value() - self.env["runtime"]["OutputManager"].present_text( - menu + " " + value, interrupt=True - ) - else: - self.env["runtime"]["OutputManager"].present_text( - _("Quick menu not available"), interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/quickMenu/next_quick_menu_value.py b/src/fenrirscreenreader/commands/quickMenu/next_quick_menu_value.py deleted file mode 100644 index 1ce866cf..00000000 --- a/src/fenrirscreenreader/commands/quickMenu/next_quick_menu_value.py +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("get next quick menu value") - - def run(self): - if self.env["runtime"]["QuickMenuManager"].next_value(): - value = self.env["runtime"]["QuickMenuManager"].get_current_value() - self.env["runtime"]["OutputManager"].present_text( - value, interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/quickMenu/prev_quick_menu_entry.py b/src/fenrirscreenreader/commands/quickMenu/prev_quick_menu_entry.py deleted file mode 100644 index 0a288416..00000000 --- a/src/fenrirscreenreader/commands/quickMenu/prev_quick_menu_entry.py +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("get previous quick menu entry") - - def run(self): - menu = "" - value = "" - if self.env["runtime"]["QuickMenuManager"].prev_entry(): - menu = self.env["runtime"]["QuickMenuManager"].get_current_entry() - if menu != "": - value = self.env["runtime"][ - "QuickMenuManager" - ].get_current_value() - self.env["runtime"]["OutputManager"].present_text( - menu + " " + value, interrupt=True - ) - else: - self.env["runtime"]["OutputManager"].present_text( - _("Quick menu not available"), interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/quickMenu/prev_quick_menu_value.py b/src/fenrirscreenreader/commands/quickMenu/prev_quick_menu_value.py deleted file mode 100644 index aa28e124..00000000 --- a/src/fenrirscreenreader/commands/quickMenu/prev_quick_menu_value.py +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("get previous quick menu value") - - def run(self): - if self.env["runtime"]["QuickMenuManager"].prev_value(): - value = self.env["runtime"]["QuickMenuManager"].get_current_value() - self.env["runtime"]["OutputManager"].present_text( - value, interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/sayAll/__init__.py b/src/fenrirscreenreader/commands/sayAll/__init__.py deleted file mode 100755 index e69de29b..00000000 diff --git a/src/fenrirscreenreader/commands/speech-history/speech_history_close.py b/src/fenrirscreenreader/commands/speech-history/speech_history_close.py deleted file mode 100644 index 71c58721..00000000 --- a/src/fenrirscreenreader/commands/speech-history/speech_history_close.py +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("closes speech history") - - def run(self): - self.env["runtime"]["SpeechHistoryManager"].close_history() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/speech-history/speech_history_copy.py b/src/fenrirscreenreader/commands/speech-history/speech_history_copy.py deleted file mode 100644 index 36ee1c16..00000000 --- a/src/fenrirscreenreader/commands/speech-history/speech_history_copy.py +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("copies current speech history item to the clipboard") - - def run(self): - self.env["runtime"]["SpeechHistoryManager"].copy_current_to_clipboard() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/speech-history/speech_history_current.py b/src/fenrirscreenreader/commands/speech-history/speech_history_current.py deleted file mode 100644 index a5376a79..00000000 --- a/src/fenrirscreenreader/commands/speech-history/speech_history_current.py +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("speaks current speech history item") - - def run(self): - self.env["runtime"]["SpeechHistoryManager"].present_current() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/speech-history/speech_history_next.py b/src/fenrirscreenreader/commands/speech-history/speech_history_next.py deleted file mode 100644 index 405fb446..00000000 --- a/src/fenrirscreenreader/commands/speech-history/speech_history_next.py +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("selects the next speech history item") - - def run(self): - self.env["runtime"]["SpeechHistoryManager"].next_entry() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/speech-history/speech_history_prev.py b/src/fenrirscreenreader/commands/speech-history/speech_history_prev.py deleted file mode 100644 index 9e950611..00000000 --- a/src/fenrirscreenreader/commands/speech-history/speech_history_prev.py +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("selects the previous speech history item") - - def run(self): - self.env["runtime"]["SpeechHistoryManager"].prev_entry() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/switchTrigger_template.py b/src/fenrirscreenreader/commands/switchTrigger_template.py deleted file mode 100644 index 4ba1e79d..00000000 --- a/src/fenrirscreenreader/commands/switchTrigger_template.py +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return "No description found" - - def unload(self): - pass - - def load(self): - pass - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/vmenu-navigation/__init__.py b/src/fenrirscreenreader/commands/vmenu-navigation/__init__.py deleted file mode 100755 index e69de29b..00000000 diff --git a/src/fenrirscreenreader/commands/vmenu-navigation/curr_vmenu_entry.py b/src/fenrirscreenreader/commands/vmenu-navigation/curr_vmenu_entry.py deleted file mode 100644 index ed1790f5..00000000 --- a/src/fenrirscreenreader/commands/vmenu-navigation/curr_vmenu_entry.py +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("get current v menu entry") - - def run(self): - text = self.env["runtime"]["VmenuManager"].get_current_entry() - self.env["runtime"]["OutputManager"].present_text(text, interrupt=True) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/vmenu-navigation/dec_level_vmenu.py b/src/fenrirscreenreader/commands/vmenu-navigation/dec_level_vmenu.py deleted file mode 100644 index bcd8980f..00000000 --- a/src/fenrirscreenreader/commands/vmenu-navigation/dec_level_vmenu.py +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("leave v menu submenu") - - def run(self): - self.env["runtime"]["VmenuManager"].dec_level() - text = self.env["runtime"]["VmenuManager"].get_current_entry() - self.env["runtime"]["OutputManager"].present_text(text, interrupt=True) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/vmenu-navigation/exec_vmenu_entry.py b/src/fenrirscreenreader/commands/vmenu-navigation/exec_vmenu_entry.py deleted file mode 100644 index 8adce6f5..00000000 --- a/src/fenrirscreenreader/commands/vmenu-navigation/exec_vmenu_entry.py +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("execute v menu entry") - - def run(self): - self.env["runtime"]["VmenuManager"].execute_menu() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/vmenu-navigation/inc_level_vmenu.py b/src/fenrirscreenreader/commands/vmenu-navigation/inc_level_vmenu.py deleted file mode 100644 index 5f44c466..00000000 --- a/src/fenrirscreenreader/commands/vmenu-navigation/inc_level_vmenu.py +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("enter v menu submenu") - - def run(self): - self.env["runtime"]["VmenuManager"].inc_level() - text = self.env["runtime"]["VmenuManager"].get_current_entry() - self.env["runtime"]["OutputManager"].present_text(text, interrupt=True) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/vmenu-navigation/next_vmenu_entry.py b/src/fenrirscreenreader/commands/vmenu-navigation/next_vmenu_entry.py deleted file mode 100644 index d2c17708..00000000 --- a/src/fenrirscreenreader/commands/vmenu-navigation/next_vmenu_entry.py +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("get next v menu entry") - - def run(self): - self.env["runtime"]["VmenuManager"].next_index() - text = self.env["runtime"]["VmenuManager"].get_current_entry() - self.env["runtime"]["OutputManager"].present_text(text, interrupt=True) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/vmenu-navigation/page_down_vmenu.py b/src/fenrirscreenreader/commands/vmenu-navigation/page_down_vmenu.py deleted file mode 100644 index f8431345..00000000 --- a/src/fenrirscreenreader/commands/vmenu-navigation/page_down_vmenu.py +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("jump down 10% in v menu") - - def run(self): - self.env["runtime"]["VmenuManager"].page_down() - text = self.env["runtime"]["VmenuManager"].get_current_entry() - self.env["runtime"]["OutputManager"].present_text(text, interrupt=True) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/vmenu-navigation/page_up_vmenu.py b/src/fenrirscreenreader/commands/vmenu-navigation/page_up_vmenu.py deleted file mode 100644 index 76bab1fc..00000000 --- a/src/fenrirscreenreader/commands/vmenu-navigation/page_up_vmenu.py +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("jump up 10% in v menu") - - def run(self): - self.env["runtime"]["VmenuManager"].page_up() - text = self.env["runtime"]["VmenuManager"].get_current_entry() - self.env["runtime"]["OutputManager"].present_text(text, interrupt=True) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/vmenu-navigation/prev_vmenu_entry.py b/src/fenrirscreenreader/commands/vmenu-navigation/prev_vmenu_entry.py deleted file mode 100644 index 12c4dd3f..00000000 --- a/src/fenrirscreenreader/commands/vmenu-navigation/prev_vmenu_entry.py +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("get prev v menu entry") - - def run(self): - self.env["runtime"]["VmenuManager"].prev_index() - text = self.env["runtime"]["VmenuManager"].get_current_entry() - self.env["runtime"]["OutputManager"].present_text(text, interrupt=True) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/vmenu-navigation/search_a.py b/src/fenrirscreenreader/commands/vmenu-navigation/search_a.py deleted file mode 100644 index fec973bd..00000000 --- a/src/fenrirscreenreader/commands/vmenu-navigation/search_a.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_spec = importlib.util.spec_from_file_location( - "vmenu_search_base", - os.path.join(os.path.dirname(__file__), "vmenu_search_base.py"), -) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -vmenu_search_command = _module.vmenu_search_command - - -class command(vmenu_search_command): - def __init__(self): - super().__init__("a") diff --git a/src/fenrirscreenreader/commands/vmenu-navigation/search_b.py b/src/fenrirscreenreader/commands/vmenu-navigation/search_b.py deleted file mode 100644 index 069b0f35..00000000 --- a/src/fenrirscreenreader/commands/vmenu-navigation/search_b.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_spec = importlib.util.spec_from_file_location( - "vmenu_search_base", - os.path.join(os.path.dirname(__file__), "vmenu_search_base.py"), -) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -vmenu_search_command = _module.vmenu_search_command - - -class command(vmenu_search_command): - def __init__(self): - super().__init__("b") diff --git a/src/fenrirscreenreader/commands/vmenu-navigation/search_c.py b/src/fenrirscreenreader/commands/vmenu-navigation/search_c.py deleted file mode 100644 index 944396b8..00000000 --- a/src/fenrirscreenreader/commands/vmenu-navigation/search_c.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_spec = importlib.util.spec_from_file_location( - "vmenu_search_base", - os.path.join(os.path.dirname(__file__), "vmenu_search_base.py"), -) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -vmenu_search_command = _module.vmenu_search_command - - -class command(vmenu_search_command): - def __init__(self): - super().__init__("c") diff --git a/src/fenrirscreenreader/commands/vmenu-navigation/search_d.py b/src/fenrirscreenreader/commands/vmenu-navigation/search_d.py deleted file mode 100644 index 2147c19e..00000000 --- a/src/fenrirscreenreader/commands/vmenu-navigation/search_d.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_spec = importlib.util.spec_from_file_location( - "vmenu_search_base", - os.path.join(os.path.dirname(__file__), "vmenu_search_base.py"), -) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -vmenu_search_command = _module.vmenu_search_command - - -class command(vmenu_search_command): - def __init__(self): - super().__init__("d") diff --git a/src/fenrirscreenreader/commands/vmenu-navigation/search_e.py b/src/fenrirscreenreader/commands/vmenu-navigation/search_e.py deleted file mode 100644 index e9de9c51..00000000 --- a/src/fenrirscreenreader/commands/vmenu-navigation/search_e.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_spec = importlib.util.spec_from_file_location( - "vmenu_search_base", - os.path.join(os.path.dirname(__file__), "vmenu_search_base.py"), -) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -vmenu_search_command = _module.vmenu_search_command - - -class command(vmenu_search_command): - def __init__(self): - super().__init__("e") diff --git a/src/fenrirscreenreader/commands/vmenu-navigation/search_f.py b/src/fenrirscreenreader/commands/vmenu-navigation/search_f.py deleted file mode 100644 index 8b47e14f..00000000 --- a/src/fenrirscreenreader/commands/vmenu-navigation/search_f.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_spec = importlib.util.spec_from_file_location( - "vmenu_search_base", - os.path.join(os.path.dirname(__file__), "vmenu_search_base.py"), -) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -vmenu_search_command = _module.vmenu_search_command - - -class command(vmenu_search_command): - def __init__(self): - super().__init__("f") diff --git a/src/fenrirscreenreader/commands/vmenu-navigation/search_g.py b/src/fenrirscreenreader/commands/vmenu-navigation/search_g.py deleted file mode 100644 index 523553b4..00000000 --- a/src/fenrirscreenreader/commands/vmenu-navigation/search_g.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_spec = importlib.util.spec_from_file_location( - "vmenu_search_base", - os.path.join(os.path.dirname(__file__), "vmenu_search_base.py"), -) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -vmenu_search_command = _module.vmenu_search_command - - -class command(vmenu_search_command): - def __init__(self): - super().__init__("g") diff --git a/src/fenrirscreenreader/commands/vmenu-navigation/search_h.py b/src/fenrirscreenreader/commands/vmenu-navigation/search_h.py deleted file mode 100644 index 5ef84e3d..00000000 --- a/src/fenrirscreenreader/commands/vmenu-navigation/search_h.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_spec = importlib.util.spec_from_file_location( - "vmenu_search_base", - os.path.join(os.path.dirname(__file__), "vmenu_search_base.py"), -) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -vmenu_search_command = _module.vmenu_search_command - - -class command(vmenu_search_command): - def __init__(self): - super().__init__("h") diff --git a/src/fenrirscreenreader/commands/vmenu-navigation/search_i.py b/src/fenrirscreenreader/commands/vmenu-navigation/search_i.py deleted file mode 100644 index 6f1317bf..00000000 --- a/src/fenrirscreenreader/commands/vmenu-navigation/search_i.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_spec = importlib.util.spec_from_file_location( - "vmenu_search_base", - os.path.join(os.path.dirname(__file__), "vmenu_search_base.py"), -) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -vmenu_search_command = _module.vmenu_search_command - - -class command(vmenu_search_command): - def __init__(self): - super().__init__("i") diff --git a/src/fenrirscreenreader/commands/vmenu-navigation/search_j.py b/src/fenrirscreenreader/commands/vmenu-navigation/search_j.py deleted file mode 100644 index 6f787659..00000000 --- a/src/fenrirscreenreader/commands/vmenu-navigation/search_j.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_spec = importlib.util.spec_from_file_location( - "vmenu_search_base", - os.path.join(os.path.dirname(__file__), "vmenu_search_base.py"), -) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -vmenu_search_command = _module.vmenu_search_command - - -class command(vmenu_search_command): - def __init__(self): - super().__init__("j") diff --git a/src/fenrirscreenreader/commands/vmenu-navigation/search_k.py b/src/fenrirscreenreader/commands/vmenu-navigation/search_k.py deleted file mode 100644 index fe59fbaa..00000000 --- a/src/fenrirscreenreader/commands/vmenu-navigation/search_k.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_spec = importlib.util.spec_from_file_location( - "vmenu_search_base", - os.path.join(os.path.dirname(__file__), "vmenu_search_base.py"), -) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -vmenu_search_command = _module.vmenu_search_command - - -class command(vmenu_search_command): - def __init__(self): - super().__init__("k") diff --git a/src/fenrirscreenreader/commands/vmenu-navigation/search_l.py b/src/fenrirscreenreader/commands/vmenu-navigation/search_l.py deleted file mode 100644 index ce9bae91..00000000 --- a/src/fenrirscreenreader/commands/vmenu-navigation/search_l.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_spec = importlib.util.spec_from_file_location( - "vmenu_search_base", - os.path.join(os.path.dirname(__file__), "vmenu_search_base.py"), -) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -vmenu_search_command = _module.vmenu_search_command - - -class command(vmenu_search_command): - def __init__(self): - super().__init__("l") diff --git a/src/fenrirscreenreader/commands/vmenu-navigation/search_m.py b/src/fenrirscreenreader/commands/vmenu-navigation/search_m.py deleted file mode 100644 index 20dafa53..00000000 --- a/src/fenrirscreenreader/commands/vmenu-navigation/search_m.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_spec = importlib.util.spec_from_file_location( - "vmenu_search_base", - os.path.join(os.path.dirname(__file__), "vmenu_search_base.py"), -) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -vmenu_search_command = _module.vmenu_search_command - - -class command(vmenu_search_command): - def __init__(self): - super().__init__("m") diff --git a/src/fenrirscreenreader/commands/vmenu-navigation/search_n.py b/src/fenrirscreenreader/commands/vmenu-navigation/search_n.py deleted file mode 100644 index caa87a49..00000000 --- a/src/fenrirscreenreader/commands/vmenu-navigation/search_n.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_spec = importlib.util.spec_from_file_location( - "vmenu_search_base", - os.path.join(os.path.dirname(__file__), "vmenu_search_base.py"), -) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -vmenu_search_command = _module.vmenu_search_command - - -class command(vmenu_search_command): - def __init__(self): - super().__init__("n") diff --git a/src/fenrirscreenreader/commands/vmenu-navigation/search_o.py b/src/fenrirscreenreader/commands/vmenu-navigation/search_o.py deleted file mode 100644 index 6e1d2692..00000000 --- a/src/fenrirscreenreader/commands/vmenu-navigation/search_o.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_spec = importlib.util.spec_from_file_location( - "vmenu_search_base", - os.path.join(os.path.dirname(__file__), "vmenu_search_base.py"), -) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -vmenu_search_command = _module.vmenu_search_command - - -class command(vmenu_search_command): - def __init__(self): - super().__init__("o") diff --git a/src/fenrirscreenreader/commands/vmenu-navigation/search_p.py b/src/fenrirscreenreader/commands/vmenu-navigation/search_p.py deleted file mode 100644 index 8bfee74d..00000000 --- a/src/fenrirscreenreader/commands/vmenu-navigation/search_p.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_spec = importlib.util.spec_from_file_location( - "vmenu_search_base", - os.path.join(os.path.dirname(__file__), "vmenu_search_base.py"), -) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -vmenu_search_command = _module.vmenu_search_command - - -class command(vmenu_search_command): - def __init__(self): - super().__init__("p") diff --git a/src/fenrirscreenreader/commands/vmenu-navigation/search_q.py b/src/fenrirscreenreader/commands/vmenu-navigation/search_q.py deleted file mode 100644 index 03049d76..00000000 --- a/src/fenrirscreenreader/commands/vmenu-navigation/search_q.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_spec = importlib.util.spec_from_file_location( - "vmenu_search_base", - os.path.join(os.path.dirname(__file__), "vmenu_search_base.py"), -) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -vmenu_search_command = _module.vmenu_search_command - - -class command(vmenu_search_command): - def __init__(self): - super().__init__("q") diff --git a/src/fenrirscreenreader/commands/vmenu-navigation/search_r.py b/src/fenrirscreenreader/commands/vmenu-navigation/search_r.py deleted file mode 100644 index 2959cea9..00000000 --- a/src/fenrirscreenreader/commands/vmenu-navigation/search_r.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_spec = importlib.util.spec_from_file_location( - "vmenu_search_base", - os.path.join(os.path.dirname(__file__), "vmenu_search_base.py"), -) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -vmenu_search_command = _module.vmenu_search_command - - -class command(vmenu_search_command): - def __init__(self): - super().__init__("r") diff --git a/src/fenrirscreenreader/commands/vmenu-navigation/search_s.py b/src/fenrirscreenreader/commands/vmenu-navigation/search_s.py deleted file mode 100644 index 715ff0ae..00000000 --- a/src/fenrirscreenreader/commands/vmenu-navigation/search_s.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_spec = importlib.util.spec_from_file_location( - "vmenu_search_base", - os.path.join(os.path.dirname(__file__), "vmenu_search_base.py"), -) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -vmenu_search_command = _module.vmenu_search_command - - -class command(vmenu_search_command): - def __init__(self): - super().__init__("s") diff --git a/src/fenrirscreenreader/commands/vmenu-navigation/search_t.py b/src/fenrirscreenreader/commands/vmenu-navigation/search_t.py deleted file mode 100644 index 4ef74e45..00000000 --- a/src/fenrirscreenreader/commands/vmenu-navigation/search_t.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_spec = importlib.util.spec_from_file_location( - "vmenu_search_base", - os.path.join(os.path.dirname(__file__), "vmenu_search_base.py"), -) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -vmenu_search_command = _module.vmenu_search_command - - -class command(vmenu_search_command): - def __init__(self): - super().__init__("t") diff --git a/src/fenrirscreenreader/commands/vmenu-navigation/search_u.py b/src/fenrirscreenreader/commands/vmenu-navigation/search_u.py deleted file mode 100644 index 2adaf8bf..00000000 --- a/src/fenrirscreenreader/commands/vmenu-navigation/search_u.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_spec = importlib.util.spec_from_file_location( - "vmenu_search_base", - os.path.join(os.path.dirname(__file__), "vmenu_search_base.py"), -) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -vmenu_search_command = _module.vmenu_search_command - - -class command(vmenu_search_command): - def __init__(self): - super().__init__("u") diff --git a/src/fenrirscreenreader/commands/vmenu-navigation/search_v.py b/src/fenrirscreenreader/commands/vmenu-navigation/search_v.py deleted file mode 100644 index 8dc23241..00000000 --- a/src/fenrirscreenreader/commands/vmenu-navigation/search_v.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_spec = importlib.util.spec_from_file_location( - "vmenu_search_base", - os.path.join(os.path.dirname(__file__), "vmenu_search_base.py"), -) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -vmenu_search_command = _module.vmenu_search_command - - -class command(vmenu_search_command): - def __init__(self): - super().__init__("v") diff --git a/src/fenrirscreenreader/commands/vmenu-navigation/search_w.py b/src/fenrirscreenreader/commands/vmenu-navigation/search_w.py deleted file mode 100644 index b001e3b5..00000000 --- a/src/fenrirscreenreader/commands/vmenu-navigation/search_w.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_spec = importlib.util.spec_from_file_location( - "vmenu_search_base", - os.path.join(os.path.dirname(__file__), "vmenu_search_base.py"), -) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -vmenu_search_command = _module.vmenu_search_command - - -class command(vmenu_search_command): - def __init__(self): - super().__init__("w") diff --git a/src/fenrirscreenreader/commands/vmenu-navigation/search_x.py b/src/fenrirscreenreader/commands/vmenu-navigation/search_x.py deleted file mode 100644 index 7ab21315..00000000 --- a/src/fenrirscreenreader/commands/vmenu-navigation/search_x.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_spec = importlib.util.spec_from_file_location( - "vmenu_search_base", - os.path.join(os.path.dirname(__file__), "vmenu_search_base.py"), -) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -vmenu_search_command = _module.vmenu_search_command - - -class command(vmenu_search_command): - def __init__(self): - super().__init__("x") diff --git a/src/fenrirscreenreader/commands/vmenu-navigation/search_y.py b/src/fenrirscreenreader/commands/vmenu-navigation/search_y.py deleted file mode 100644 index 10fa02a6..00000000 --- a/src/fenrirscreenreader/commands/vmenu-navigation/search_y.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_spec = importlib.util.spec_from_file_location( - "vmenu_search_base", - os.path.join(os.path.dirname(__file__), "vmenu_search_base.py"), -) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -vmenu_search_command = _module.vmenu_search_command - - -class command(vmenu_search_command): - def __init__(self): - super().__init__("y") diff --git a/src/fenrirscreenreader/commands/vmenu-navigation/search_z.py b/src/fenrirscreenreader/commands/vmenu-navigation/search_z.py deleted file mode 100644 index 6af9f577..00000000 --- a/src/fenrirscreenreader/commands/vmenu-navigation/search_z.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -_spec = importlib.util.spec_from_file_location( - "vmenu_search_base", - os.path.join(os.path.dirname(__file__), "vmenu_search_base.py"), -) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -vmenu_search_command = _module.vmenu_search_command - - -class command(vmenu_search_command): - def __init__(self): - super().__init__("z") diff --git a/src/fenrirscreenreader/commands/vmenu-navigation/vmenu_search_base.py b/src/fenrirscreenreader/commands/vmenu-navigation/vmenu_search_base.py deleted file mode 100644 index b90c9c59..00000000 --- a/src/fenrirscreenreader/commands/vmenu-navigation/vmenu_search_base.py +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ - - -class vmenu_search_command: - """Base class for VMenu search commands""" - - def __init__(self, search_char): - self.search_char = search_char.lower() - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("search for a menu entry") - - def run(self): - text = self.env["runtime"]["VmenuManager"].search_entry( - self.search_char - ) - if text != "": - self.env["runtime"]["OutputManager"].present_text( - text, interrupt=True - ) - else: - self.env["runtime"]["OutputManager"].present_text( - _("not found"), sound_icon="ErrorScreen", interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/BYTE/__init__.py b/src/fenrirscreenreader/commands/vmenu-profiles/BYTE/__init__.py deleted file mode 100755 index e69de29b..00000000 diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/__init__.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/__init__.py deleted file mode 100755 index e69de29b..00000000 diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/__init__.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/__init__.py deleted file mode 100644 index c628ea8c..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Emoji VMenu category diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/__init__.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/__init__.py deleted file mode 100644 index e58bb582..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Flags emoji subcategory diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/argentina.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/argentina.py deleted file mode 100644 index 9bedbd46..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/argentina.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ‡¦šŸ‡·" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add Argentina flag emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added Argentina flag to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/australia.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/australia.py deleted file mode 100644 index 394dd3c2..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/australia.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ‡¦šŸ‡ŗ" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add Australia flag emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added Australia flag to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/belgium.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/belgium.py deleted file mode 100644 index ac6849d6..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/belgium.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ‡§šŸ‡Ŗ" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add Belgium flag emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added Belgium flag to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/brazil.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/brazil.py deleted file mode 100644 index c7e16aa5..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/brazil.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ‡§šŸ‡·" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add Brazil flag emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added Brazil flag to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/canada.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/canada.py deleted file mode 100644 index 52166637..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/canada.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ‡ØšŸ‡¦" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add Canada flag emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added Canada flag to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/china.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/china.py deleted file mode 100644 index 573c3bad..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/china.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ‡ØšŸ‡³" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add China flag emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added China flag to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/denmark.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/denmark.py deleted file mode 100644 index a6046988..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/denmark.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ‡©šŸ‡°" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add Denmark flag emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added Denmark flag to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/finland.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/finland.py deleted file mode 100644 index a3507073..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/finland.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ‡«šŸ‡®" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add Finland flag emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added Finland flag to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/france.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/france.py deleted file mode 100644 index e7efa73a..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/france.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ‡«šŸ‡·" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add France flag emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added France flag to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/germany.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/germany.py deleted file mode 100644 index b5213692..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/germany.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ‡©šŸ‡Ŗ" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add Germany flag emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added Germany flag to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/greece.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/greece.py deleted file mode 100644 index 9c1feab4..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/greece.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ‡¬šŸ‡·" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add Greece flag emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added Greece flag to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/india.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/india.py deleted file mode 100644 index fa1227c1..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/india.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ‡®šŸ‡³" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add India flag emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added India flag to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/ireland.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/ireland.py deleted file mode 100644 index d6b7af1d..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/ireland.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ‡®šŸ‡Ŗ" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add Ireland flag emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added Ireland flag to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/israel.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/israel.py deleted file mode 100644 index 569841c1..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/israel.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ‡®šŸ‡±" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add Israel flag emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added Israel flag to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/italy.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/italy.py deleted file mode 100644 index 815c8ab8..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/italy.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ‡®šŸ‡¹" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add Italy flag emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added Italy flag to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/japan.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/japan.py deleted file mode 100644 index cb4baad9..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/japan.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ‡ÆšŸ‡µ" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add Japan flag emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added Japan flag to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/mexico.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/mexico.py deleted file mode 100644 index ed47fa4e..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/mexico.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ‡²šŸ‡½" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add Mexico flag emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added Mexico flag to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/netherlands.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/netherlands.py deleted file mode 100644 index 2a020672..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/netherlands.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ‡³šŸ‡±" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add Netherlands flag emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added Netherlands flag to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/norway.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/norway.py deleted file mode 100644 index 48df5e05..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/norway.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ‡³šŸ‡“" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add Norway flag emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added Norway flag to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/poland.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/poland.py deleted file mode 100644 index ce718ccf..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/poland.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ‡µšŸ‡±" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add Poland flag emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added Poland flag to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/portugal.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/portugal.py deleted file mode 100644 index f5e1b057..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/portugal.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ‡µšŸ‡¹" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add Portugal flag emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added Portugal flag to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/russia.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/russia.py deleted file mode 100644 index b3f0cf84..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/russia.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ‡·šŸ‡ŗ" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add Russia flag emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added Russia flag to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/south_africa.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/south_africa.py deleted file mode 100644 index 50f800ff..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/south_africa.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ‡æšŸ‡¦" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add South Africa flag emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added South Africa flag to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/south_korea.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/south_korea.py deleted file mode 100644 index db33502f..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/south_korea.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ‡°šŸ‡·" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add South Korea flag emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added South Korea flag to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/spain.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/spain.py deleted file mode 100644 index 6c5a110a..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/spain.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ‡ŖšŸ‡ø" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add Spain flag emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added Spain flag to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/sweden.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/sweden.py deleted file mode 100644 index f683c674..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/sweden.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ‡øšŸ‡Ŗ" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add Sweden flag emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added Sweden flag to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/switzerland.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/switzerland.py deleted file mode 100644 index 13c7e1cf..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/switzerland.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ‡ØšŸ‡­" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add Switzerland flag emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added Switzerland flag to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/turkey.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/turkey.py deleted file mode 100644 index 8f59db2c..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/turkey.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ‡¹šŸ‡·" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add Turkey flag emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added Turkey flag to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/uk.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/uk.py deleted file mode 100644 index acacb89a..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/uk.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ‡¬šŸ‡§" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add UK flag emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added UK flag to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/ukraine.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/ukraine.py deleted file mode 100644 index e8fbf37c..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/ukraine.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ‡ŗšŸ‡¦" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add Ukraine flag emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added Ukraine flag to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/usa.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/usa.py deleted file mode 100644 index 4952cf93..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/usa.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ‡ŗšŸ‡ø" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add USA flag emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added USA flag to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/__init__.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/__init__.py deleted file mode 100644 index 3308fe74..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Food emoji subcategory diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/apple.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/apple.py deleted file mode 100644 index 6a711e54..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/apple.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸŽ" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Red apple emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added red apple to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/avocado.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/avocado.py deleted file mode 100644 index d6260d80..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/avocado.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ„‘" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Avocado emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added avocado to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/beer.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/beer.py deleted file mode 100644 index 7dae0d9e..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/beer.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸŗ" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Beer emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added beer to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/birthday_cake.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/birthday_cake.py deleted file mode 100644 index 91a728ce..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/birthday_cake.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸŽ‚" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Birthday cake emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added birthday cake to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/coffee.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/coffee.py deleted file mode 100644 index de564abf..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/coffee.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "ā˜•" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add coffee emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added coffee to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/donut.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/donut.py deleted file mode 100644 index 4d2d3762..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/donut.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ©" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Donut emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added donut to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/hamburger.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/hamburger.py deleted file mode 100644 index ff933c42..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/hamburger.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ”" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add hamburger emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added hamburger to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/pizza.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/pizza.py deleted file mode 100644 index 04495192..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/pizza.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ•" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add pizza emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added pizza to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/strawberry.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/strawberry.py deleted file mode 100644 index 89f4aca7..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/strawberry.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ“" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Strawberry emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added strawberry to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/taco.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/taco.py deleted file mode 100644 index a84599e0..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/taco.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "🌮" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Taco emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added taco to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/__init__.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/__init__.py deleted file mode 100644 index 3d58b151..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Holidays emoji subcategory diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/alien_monster.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/alien_monster.py deleted file mode 100644 index 2c901da9..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/alien_monster.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ‘¾" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Alien monster emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added alien monster to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/bat.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/bat.py deleted file mode 100644 index a50691f5..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/bat.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ¦‡" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add bat emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added bat to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/black_cat.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/black_cat.py deleted file mode 100644 index 7e7f50ec..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/black_cat.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸˆā€ā¬›" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Black cat emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added black cat to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/bunny.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/bunny.py deleted file mode 100644 index f2cd59f2..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/bunny.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "🐰" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add bunny emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added bunny to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/cauldron.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/cauldron.py deleted file mode 100644 index 758d8308..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/cauldron.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ§™" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Mage emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added mage to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/christmas_tree.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/christmas_tree.py deleted file mode 100644 index b675f2a5..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/christmas_tree.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸŽ„" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add Christmas tree emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added Christmas tree to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/coffin.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/coffin.py deleted file mode 100644 index 5b6ff483..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/coffin.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "āš°ļø" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Coffin emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added coffin to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/easter_egg.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/easter_egg.py deleted file mode 100644 index 67350d0b..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/easter_egg.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "🄚" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add Easter egg emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added Easter egg to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/fireworks.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/fireworks.py deleted file mode 100644 index fad2fe3d..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/fireworks.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸŽ†" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add fireworks emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added fireworks to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/ghost.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/ghost.py deleted file mode 100644 index e3e14144..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/ghost.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ‘»" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add ghost emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added ghost to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/gift.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/gift.py deleted file mode 100644 index 67af4514..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/gift.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸŽ" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add gift emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added gift to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/jack_o_lantern.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/jack_o_lantern.py deleted file mode 100644 index 5b9fa376..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/jack_o_lantern.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸŽƒ" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add jack o'lantern emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added jack o'lantern to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/mummy.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/mummy.py deleted file mode 100644 index 2cea17a0..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/mummy.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "🧟" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Mummy emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added mummy to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/santa.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/santa.py deleted file mode 100644 index ba4bf6f0..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/santa.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸŽ…" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add Santa emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added Santa to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/shamrock.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/shamrock.py deleted file mode 100644 index a1880f04..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/shamrock.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "ā˜˜ļø" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add shamrock emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added shamrock to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/skull.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/skull.py deleted file mode 100644 index e80e7f64..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/skull.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ’€" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add skull emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added skull to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/snowman.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/snowman.py deleted file mode 100644 index cad73b82..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/snowman.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "⛄" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add snowman emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added snowman to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/spider.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/spider.py deleted file mode 100644 index ca58f152..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/spider.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ•·" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add spider emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added spider to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/turkey.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/turkey.py deleted file mode 100644 index 5c8b66b8..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/turkey.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "🦃" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add turkey emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added turkey to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/vampire.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/vampire.py deleted file mode 100644 index 4ebf2c97..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/vampire.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ§›" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Vampire emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added vampire to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/web.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/web.py deleted file mode 100644 index 376d9ead..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/web.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ•øļø" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Spider web emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added spider web to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/witch.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/witch.py deleted file mode 100644 index cdfcb125..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/witch.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ§™ā€ā™€ļø" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Witch emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added witch to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/zombie.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/zombie.py deleted file mode 100644 index c6a3d03a..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/zombie.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "🧟" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Zombie emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added zombie to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/__init__.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/__init__.py deleted file mode 100644 index 8fad0c38..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Nature emoji subcategory diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/butterfly.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/butterfly.py deleted file mode 100644 index 5c00bd26..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/butterfly.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ¦‹" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Butterfly emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added butterfly to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/cat.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/cat.py deleted file mode 100644 index 57b4976c..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/cat.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "🐱" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Cat emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added cat to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/cherry_blossom.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/cherry_blossom.py deleted file mode 100644 index 4ce227dc..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/cherry_blossom.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "🌸" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Cherry blossom emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added cherry blossom to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/dog.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/dog.py deleted file mode 100644 index 25df84f5..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/dog.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "🐶" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Dog emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added dog to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/moon.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/moon.py deleted file mode 100644 index da3ea11d..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/moon.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸŒ™" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add moon emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added moon to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/rainbow.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/rainbow.py deleted file mode 100644 index 466d495c..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/rainbow.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "🌈" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Rainbow emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added rainbow to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/rose.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/rose.py deleted file mode 100644 index 3a94acc1..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/rose.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "🌹" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Rose emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added rose to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/sun.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/sun.py deleted file mode 100644 index e5a7f1cb..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/sun.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "ā˜€ļø" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add sun emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added sun to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/sunflower.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/sunflower.py deleted file mode 100644 index e7889a6b..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/sunflower.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "🌻" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Sunflower emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added sunflower to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/tree.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/tree.py deleted file mode 100644 index 648eddfd..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/tree.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "🌳" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add tree emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added tree to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/wolf.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/wolf.py deleted file mode 100644 index 0ec766c7..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/wolf.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "🐺" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Wolf emoji - The mighty Fenrir!") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added the mighty wolf Fenrir to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/__init__.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/__init__.py deleted file mode 100644 index fa593910..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# People emoji subcategory diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/a_grinning.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/a_grinning.py deleted file mode 100644 index 8f8397b5..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/a_grinning.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ˜€" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Grinning face emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added grinning face to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/angry.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/angry.py deleted file mode 100644 index 7b36c90a..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/angry.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "😠" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Angry face emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added angry face to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/beaming.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/beaming.py deleted file mode 100644 index 9ff9248f..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/beaming.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "😁" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Beaming face with smiling eyes emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added beaming face to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/blowing_kiss.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/blowing_kiss.py deleted file mode 100644 index 195a75c8..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/blowing_kiss.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "😘" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Face blowing a kiss emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added face blowing kiss to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/cool.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/cool.py deleted file mode 100644 index 3844e83d..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/cool.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ˜Ž" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Cool face emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added cool face to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/crying.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/crying.py deleted file mode 100644 index 5bea86ce..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/crying.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "😭" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Crying face emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added crying face to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/devil.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/devil.py deleted file mode 100644 index d4d1fd0b..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/devil.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "😈" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Devil face emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added devil face to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/dizzy.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/dizzy.py deleted file mode 100644 index 8f5e8f5e..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/dizzy.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "😵" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Dizzy face emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added dizzy face to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/exploding_head.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/exploding_head.py deleted file mode 100644 index c3481e6e..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/exploding_head.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "🤯" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Exploding head emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added exploding head to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/face_with_symbols.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/face_with_symbols.py deleted file mode 100644 index 7434c7c1..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/face_with_symbols.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "🤬" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Face with symbols over mouth emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added face with symbols to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/grinning_sweat.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/grinning_sweat.py deleted file mode 100644 index d759e868..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/grinning_sweat.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ˜…" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Grinning face with sweat emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added grinning face with sweat to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/heart_eyes.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/heart_eyes.py deleted file mode 100644 index f280333b..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/heart_eyes.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ˜" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Smiling face with heart-eyes emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added heart-eyes face to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/hearts_around.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/hearts_around.py deleted file mode 100644 index 721c19b5..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/hearts_around.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "🄰" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Smiling face with hearts emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added smiling face with hearts to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/imp.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/imp.py deleted file mode 100644 index d8d2f150..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/imp.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ‘æ" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Angry face with horns emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added angry face with horns to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/laughing.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/laughing.py deleted file mode 100644 index e4ae8851..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/laughing.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ˜‚" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add laughing face emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added laughing face to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/mindblown.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/mindblown.py deleted file mode 100644 index d65c17ce..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/mindblown.py +++ /dev/null @@ -1,25 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "🤯" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Mind blown emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added mind blown to clipboard"), - interrupt=False, flush=False - ) - diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/nauseated.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/nauseated.py deleted file mode 100644 index 43e2ed76..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/nauseated.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "🤢" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Nauseated face emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added nauseated face to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/pleading.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/pleading.py deleted file mode 100644 index 5e493f05..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/pleading.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "🄺" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Pleading face emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added pleading face to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/poop.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/poop.py deleted file mode 100644 index f156d324..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/poop.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ’©" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Poop emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added poop to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/rolling_laughing.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/rolling_laughing.py deleted file mode 100644 index 49ccc7cd..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/rolling_laughing.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "🤣" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Rolling on floor laughing emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added rolling on floor laughing to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/sad.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/sad.py deleted file mode 100644 index d74bb190..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/sad.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "😢" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Sad face emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added sad face to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/savoring_food.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/savoring_food.py deleted file mode 100644 index dabc57fd..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/savoring_food.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ˜‹" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Face savoring food emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added face savoring food to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/screaming.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/screaming.py deleted file mode 100644 index 1eabb21a..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/screaming.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "😱" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Screaming face emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added screaming face to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/shocked.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/shocked.py deleted file mode 100644 index 87df5110..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/shocked.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "😱" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Shocked face emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added shocked face to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/smiling.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/smiling.py deleted file mode 100644 index 4cfd995f..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/smiling.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "😊" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add smiling face emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added smiling face to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/smirking.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/smirking.py deleted file mode 100644 index 821cdb27..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/smirking.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ˜" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Smirking face emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added smirking face to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/thumbs_up.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/thumbs_up.py deleted file mode 100644 index 312f3cd9..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/thumbs_up.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ‘" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add thumbs up emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added thumbs up to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/winking.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/winking.py deleted file mode 100644 index 77122c58..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/winking.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ˜‰" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add winking face emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added winking face to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/__init__.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/__init__.py deleted file mode 100644 index 6884f316..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Symbols emoji subcategory diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/chains.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/chains.py deleted file mode 100644 index 1a1c6348..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/chains.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "ā›“ļø" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Chains emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added chains to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/checkmark.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/checkmark.py deleted file mode 100644 index 3e305816..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/checkmark.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "āœ…" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add checkmark emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added checkmark to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/crossbones.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/crossbones.py deleted file mode 100644 index df13d8a6..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/crossbones.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "🦓" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Bone emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added bone to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/dagger.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/dagger.py deleted file mode 100644 index 25203304..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/dagger.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ—”ļø" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Dagger emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added dagger to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/fire.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/fire.py deleted file mode 100644 index 02f194ff..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/fire.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ”„" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Fire emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added fire to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/folded_hands.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/folded_hands.py deleted file mode 100644 index bbd8eaf4..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/folded_hands.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ™" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Folded hands emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added folded hands to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/heart.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/heart.py deleted file mode 100644 index d362b8ed..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/heart.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "ā¤ļø" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add heart emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added heart to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/high_voltage.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/high_voltage.py deleted file mode 100644 index c0677699..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/high_voltage.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "⚔" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("High voltage lightning bolt emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added high voltage lightning bolt to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/hundred_points.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/hundred_points.py deleted file mode 100644 index 4a9e302d..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/hundred_points.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "šŸ’Æ" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Hundred points symbol emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added hundred points to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/lightning.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/lightning.py deleted file mode 100644 index 0cdb92e6..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/lightning.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "⚔" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Lightning emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added lightning to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/peace.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/peace.py deleted file mode 100644 index 9fe393a1..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/peace.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "āœŒļø" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Peace sign emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added peace sign to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/rock_on.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/rock_on.py deleted file mode 100644 index c8b71e41..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/rock_on.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "🤘" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Sign of the horns emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added sign of the horns to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/skull_crossbones.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/skull_crossbones.py deleted file mode 100644 index ea30360c..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/skull_crossbones.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "ā˜ ļø" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Skull and crossbones emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added skull and crossbones to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/sparkles.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/sparkles.py deleted file mode 100644 index e474b12a..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/sparkles.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "✨" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Sparkles emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added sparkles to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/star.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/star.py deleted file mode 100644 index d7fb3bb7..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/star.py +++ /dev/null @@ -1,24 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "⭐" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Add star emoji to clipboard") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added star to clipboard"), - interrupt=False, flush=False - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/sword.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/sword.py deleted file mode 100644 index e3537a4c..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/sword.py +++ /dev/null @@ -1,26 +0,0 @@ -from fenrirscreenreader.core.i18n import _ - - -class command(): - def initialize(self, environment): - self.env = environment - self.emoji = "āš”ļø" - - def shutdown(self): - pass - - def setCallback(self, callback): - pass - - def getDescription(self): - return _("Sword emoji") - - def run(self): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", self.emoji - ) - self.env["runtime"]["OutputManager"].present_text( - _("Added sword to clipboard"), - interrupt=False, flush=False - ) - diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/__init__.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/__init__.py deleted file mode 100644 index a107eb8b..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Fenrir Configuration VMenu Profile diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/config_base.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/config_base.py deleted file mode 100644 index 44083c95..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/config_base.py +++ /dev/null @@ -1,180 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import configparser -import os -import shutil -import tempfile -from datetime import datetime - -from fenrirscreenreader.core.i18n import _ - - -class config_command: - """Base class for configuration management commands""" - - def __init__(self): - self.env = None - self.settings_file = None - self.config = None - - def initialize(self, environment): - self.env = environment - self.settings_file = self.env["runtime"][ - "SettingsManager" - ].settings_file - self.config = self.env["runtime"]["SettingsManager"].settings - - def shutdown(self): - pass - - def set_callback(self, callback): - pass - - def present_text(self, text, interrupt=True, flush=True): - """Present text to user with proper speech handling""" - self.env["runtime"]["OutputManager"].present_text( - text, interrupt=interrupt, flush=flush - ) - - def play_sound(self, soundName): - """Play system sound""" - sound_icon = "" - if soundName == "Accept": - sound_icon = "Accept" - elif soundName == "Error": - sound_icon = "ErrorSound" - elif soundName == "Cancel": - sound_icon = "Cancel" - - if sound_icon: - self.env["runtime"]["OutputManager"].present_text( - "", sound_icon=sound_icon, interrupt=False - ) - - def backup_config(self, announce=True): - """Create backup of current configuration file""" - try: - if not os.path.exists(self.settings_file): - message = _("Configuration file not found") - if announce: - self.present_text(message) - return False, message - - # Create backup with timestamp - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - backup_path = f"{self.settings_file}.backup_{timestamp}" - - shutil.copy2(self.settings_file, backup_path) - - message = _("Configuration backed up to {0}").format(backup_path) - if announce: - self.present_text(_("Configuration backup created successfully")) - return True, message - - except Exception as e: - message = _("Failed to backup configuration: {0}").format(str(e)) - if announce: - self.present_text(message) - return False, message - - def reload_config(self): - """Reload configuration from file""" - try: - # Force settings manager to reload from file - self.env["runtime"]["SettingsManager"].load_settings() - self.config = self.env["runtime"]["SettingsManager"].settings - return True - - except Exception as e: - self.present_text(_("Failed to reload configuration: {0}").format(str(e))) - return False - - def find_default_config(self): - """Find default configuration file path""" - # Look for default config in multiple locations - default_paths = [ - "/etc/fenrir/settings/settings.conf.default", - "/usr/share/fenrir/settings/settings.conf", - os.path.join( - os.path.dirname(self.settings_file), "settings.conf.default" - ), - os.path.join( - os.path.dirname(os.path.dirname(self.settings_file)), - "settings", - "settings.conf.default", - ), - ] - - for path in default_paths: - if os.path.exists(path): - return path - - return None - - def create_basic_defaults(self): - """Create basic default configuration manually""" - try: - # Create a new config with basic defaults - self.config = configparser.ConfigParser() - - # Add basic sections and settings - self.config.add_section("speech") - self.config.set("speech", "driver", "speechdDriver") - self.config.set("speech", "rate", "0.75") - self.config.set("speech", "pitch", "0.5") - self.config.set("speech", "volume", "1.0") - self.config.set("speech", "hardware_device", "/dev/ttyS0") - self.config.set("speech", "hardware_baud_rate", "9600") - - self.config.add_section("sound") - self.config.set("sound", "driver", "genericDriver") - self.config.set("sound", "volume", "0.7") - - self.config.add_section("keyboard") - self.config.set("keyboard", "driver", "evdevDriver") - self.config.set("keyboard", "keyboard_layout", "desktop") - - self.config.add_section("screen") - self.config.set("screen", "driver", "vcsaDriver") - - self.config.add_section("general") - self.config.set("general", "debug_mode", "Off") - - return True - - except Exception as e: - self.present_text(_("Failed to create basic defaults: {0}").format(str(e))) - return False - - def get_setting(self, section, setting, default=None): - """Get setting value from settings manager""" - try: - return self.env["runtime"]["SettingsManager"].get_setting(section, setting) - except Exception: - return default - - def set_setting(self, section, setting, value): - """Set setting value via settings manager""" - try: - self.env["runtime"]["SettingsManager"].set_setting(section, setting, value) - return True - except Exception: - return False - - def toggle_boolean_setting(self, section, setting): - """Return the new state, or None when the effective setting did not change.""" - settings_manager = self.env["runtime"]["SettingsManager"] - try: - current_state = settings_manager.get_setting_as_bool(section, setting) - if not self.set_setting(section, setting, str(not current_state)): - return None - new_state = settings_manager.get_setting_as_bool(section, setting) - if new_state != current_state: - return new_state - except Exception: - pass - return None diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/__init__.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/__init__.py deleted file mode 100644 index f3594ed4..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Fenrir general configuration diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/set_punctuation_all.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/set_punctuation_all.py deleted file mode 100644 index 127c4311..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/set_punctuation_all.py +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env python3 - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -# Load base configuration class -_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py") -_spec = importlib.util.spec_from_file_location("config_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -config_command = _module.config_command - - -class command(config_command): - def __init__(self): - super().__init__() - - def get_description(self): - return _("Set punctuation to All (every punctuation mark)") - - def run(self): - current_level = self.get_setting("general", "punctuation_level", "some") - - if current_level.lower() == "all": - self.present_text(_("Punctuation level already set to All")) - return - - success = self.set_setting("general", "punctuation_level", "all") - - if success: - self.present_text( - _( - "Punctuation level set to All - every punctuation mark will be spoken" - ) - ) - self.play_sound("Accept") - else: - self.present_text(_("Failed to change punctuation level")) - self.play_sound("Error") diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/set_punctuation_most.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/set_punctuation_most.py deleted file mode 100644 index 41f96e7c..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/set_punctuation_most.py +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env python3 - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -# Load base configuration class -_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py") -_spec = importlib.util.spec_from_file_location("config_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -config_command = _module.config_command - - -class command(config_command): - def __init__(self): - super().__init__() - - def get_description(self): - return _("Set punctuation to Most (detailed punctuation)") - - def run(self): - current_level = self.get_setting("general", "punctuation_level", "some") - - if current_level.lower() == "most": - self.present_text(_("Punctuation level already set to Most")) - return - - success = self.set_setting("general", "punctuation_level", "most") - - if success: - self.present_text( - _("Punctuation level set to Most - detailed punctuation will be spoken") - ) - self.play_sound("Accept") - else: - self.present_text(_("Failed to change punctuation level")) - self.play_sound("Error") diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/set_punctuation_none.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/set_punctuation_none.py deleted file mode 100644 index 21960147..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/set_punctuation_none.py +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env python3 - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -# Load base configuration class -_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py") -_spec = importlib.util.spec_from_file_location("config_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -config_command = _module.config_command - - -class command(config_command): - def __init__(self): - super().__init__() - - def get_description(self): - return _("Set punctuation to None (no punctuation spoken)") - - def run(self): - current_level = self.get_setting("general", "punctuation_level", "some") - - if current_level.lower() == "none": - self.present_text(_("Punctuation level already set to None")) - return - - success = self.set_setting("general", "punctuation_level", "none") - - if success: - self.present_text( - _("Punctuation level set to None - no punctuation will be spoken") - ) - self.play_sound("Accept") - else: - self.present_text(_("Failed to change punctuation level")) - self.play_sound("Error") diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/set_punctuation_some.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/set_punctuation_some.py deleted file mode 100644 index e4f3c703..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/set_punctuation_some.py +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env python3 - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -# Load base configuration class -_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py") -_spec = importlib.util.spec_from_file_location("config_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -config_command = _module.config_command - - -class command(config_command): - def __init__(self): - super().__init__() - - def get_description(self): - return _("Set punctuation to Some (basic punctuation only)") - - def run(self): - current_level = self.get_setting("general", "punctuation_level", "some") - - if current_level.lower() == "some": - self.present_text(_("Punctuation level already set to Some")) - return - - success = self.set_setting("general", "punctuation_level", "some") - - if success: - self.present_text( - _("Punctuation level set to Some - basic punctuation will be spoken") - ) - self.play_sound("Accept") - else: - self.present_text(_("Failed to change punctuation level")) - self.play_sound("Error") diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/toggle_debug.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/toggle_debug.py deleted file mode 100644 index fc477f2d..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/toggle_debug.py +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env python3 - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -# Load base configuration class -_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py") -_spec = importlib.util.spec_from_file_location("config_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -config_command = _module.config_command - - -class command(config_command): - def __init__(self): - super().__init__() - - def get_description(self): - return _("Toggle debug mode") - - def run(self): - current_level = self.get_setting("general", "debug_level", "0") - - # Present current debug level - if current_level == "0": - self.present_text(_("Current debug mode: disabled")) - new_level = "1" - else: - self.present_text(_("Current debug level: {0}").format(current_level)) - new_level = "0" - - success = self.set_setting("general", "debug_level", new_level) - - if success: - self.present_text(_("Debug mode enabled") if new_level != "0" - else _("Debug mode disabled")) - if new_level != "0": - debug_mode = self.get_setting("general", "debug_mode", "File") - if debug_mode == "File": - self.present_text( - _("Debug output will be written to log file") - ) - else: - self.present_text( - _("Debug output will be printed to console") - ) - self.play_sound("Accept") - else: - self.present_text(_("Failed to change debug mode")) - self.play_sound("Error") diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/__init__.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/__init__.py deleted file mode 100644 index e130a0e1..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Fenrir keyboard configuration diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/set_char_echo_always.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/set_char_echo_always.py deleted file mode 100644 index 2121ce6b..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/set_char_echo_always.py +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env python3 - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -# Load base configuration class -_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py") -_spec = importlib.util.spec_from_file_location("config_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -config_command = _module.config_command - - -class command(config_command): - def __init__(self): - super().__init__() - - def get_description(self): - return _("Set character echo to Always (echo all typed characters)") - - def run(self): - current_mode = self.get_setting("keyboard", "char_echo_mode", "1") - - if current_mode == "1": - self.present_text(_("Character echo already set to Always")) - return - - success = self.set_setting("keyboard", "char_echo_mode", "1") - - if success: - self.present_text( - _("Character echo set to Always - all typed characters will be spoken") - ) - self.play_sound("Accept") - else: - self.present_text(_("Failed to change character echo mode")) - self.play_sound("Error") diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/set_char_echo_caps.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/set_char_echo_caps.py deleted file mode 100644 index acb9077e..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/set_char_echo_caps.py +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env python3 - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -# Load base configuration class -_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py") -_spec = importlib.util.spec_from_file_location("config_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -config_command = _module.config_command - - -class command(config_command): - def __init__(self): - super().__init__() - - def get_description(self): - return _("Set character echo to Caps Lock (echo only when caps lock is on)") - - def run(self): - current_mode = self.get_setting("keyboard", "char_echo_mode", "1") - - if current_mode == "2": - self.present_text(_("Character echo already set to Caps Lock mode")) - return - - success = self.set_setting("keyboard", "char_echo_mode", "2") - - if success: - self.present_text( - _( - "Character echo set to Caps Lock mode - characters will be spoken only when caps lock is on" - ) - ) - self.play_sound("Accept") - else: - self.present_text(_("Failed to change character echo mode")) - self.play_sound("Error") diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/set_char_echo_none.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/set_char_echo_none.py deleted file mode 100644 index fdfbb264..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/set_char_echo_none.py +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env python3 - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -# Load base configuration class -_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py") -_spec = importlib.util.spec_from_file_location("config_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -config_command = _module.config_command - - -class command(config_command): - def __init__(self): - super().__init__() - - def get_description(self): - return _("Set character echo to None (no character echo)") - - def run(self): - current_mode = self.get_setting("keyboard", "char_echo_mode", "1") - - if current_mode == "0": - self.present_text(_("Character echo already set to None")) - return - - success = self.set_setting("keyboard", "char_echo_mode", "0") - - if success: - self.present_text( - _("Character echo set to None - no typed characters will be spoken") - ) - self.play_sound("Accept") - else: - self.present_text(_("Failed to change character echo mode")) - self.play_sound("Error") diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/toggle_grab_devices.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/toggle_grab_devices.py deleted file mode 100644 index d6548599..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/toggle_grab_devices.py +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env python3 - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -# Load base configuration class -_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py") -_spec = importlib.util.spec_from_file_location("config_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -config_command = _module.config_command - - -class command(config_command): - def __init__(self): - super().__init__() - - def get_description(self): - return _("Toggle exclusive keyboard access") - - def run(self): - settings_manager = self.env["runtime"]["SettingsManager"] - try: - new_state = not settings_manager.get_grab_devices_for_restart() - settings_manager.stage_grab_devices(new_state) - except Exception: - new_state = None - - if new_state is not None: - self.present_text( - _( - "Exclusive keyboard access will be enabled after saving and restarting Fenrir" - ) - if new_state - else _( - "Exclusive keyboard access will be disabled after saving and restarting Fenrir" - ) - ) - self.present_text( - _("Save settings, then restart Fenrir for this change to take effect") - ) - self.play_sound("Accept") - else: - self.present_text(_("Failed to change keyboard grab setting")) - self.play_sound("Error") diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/toggle_word_echo.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/toggle_word_echo.py deleted file mode 100644 index 29a3073d..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/toggle_word_echo.py +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env python3 - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -# Load base configuration class -_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py") -_spec = importlib.util.spec_from_file_location("config_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -config_command = _module.config_command - - -class command(config_command): - def __init__(self): - super().__init__() - - def get_description(self): - return _("Toggle word echo when pressing space") - - def run(self): - new_state = self.toggle_boolean_setting("keyboard", "word_echo") - - if new_state is not None: - self.present_text( - _("Word echo enabled") if new_state else _("Word echo disabled") - ) - if new_state: - self.present_text(_("Words will be spoken when you press space")) - else: - self.present_text(_("Words will not be spoken when you press space")) - self.play_sound("Accept") - else: - self.present_text(_("Failed to change word echo setting")) - self.play_sound("Error") diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/__init__.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/__init__.py deleted file mode 100644 index cbfdfd39..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Fenrir management configuration diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/backup_config.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/backup_config.py deleted file mode 100644 index 5fcc80f6..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/backup_config.py +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env python3 - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -# Load base configuration class -_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py") -_spec = importlib.util.spec_from_file_location("config_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -config_command = _module.config_command - - -class command(config_command): - def __init__(self): - super().__init__() - - def get_description(self): - return _("Create backup of current configuration") - - def run(self): - self.present_text(_("Creating configuration backup...")) - - success, message = self.backup_config(announce=False) - - if success: - # Force the message to be queued and spoken - self.env["runtime"]["OutputManager"].present_text( - _("Configuration backup created successfully"), - interrupt=False, - flush=False, - ) - else: - self.env["runtime"]["OutputManager"].present_text( - _("Failed to create configuration backup"), - interrupt=False, - flush=False, - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/reload_config.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/reload_config.py deleted file mode 100644 index e8913655..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/reload_config.py +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env python3 - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -# Load base configuration class -_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py") -_spec = importlib.util.spec_from_file_location("config_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -config_command = _module.config_command - - -class command(config_command): - def __init__(self): - super().__init__() - - def get_description(self): - return _("Reload configuration from file") - - def run(self): - self.present_text(_("Reloading configuration from file...")) - - success = self.reload_config() - - if success: - self.present_text(_("Configuration reloaded successfully")) - self.play_sound("Accept") - else: - self.present_text(_("Failed to reload configuration")) - self.play_sound("Error") diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/reset_defaults.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/reset_defaults.py deleted file mode 100644 index cba8597d..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/reset_defaults.py +++ /dev/null @@ -1,172 +0,0 @@ -#!/usr/bin/env python3 - -import importlib.util -import os -import shutil - -from fenrirscreenreader.core.i18n import _ - -# Load base configuration class -_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py") -_spec = importlib.util.spec_from_file_location("config_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -config_command = _module.config_command - - -class command(config_command): - def __init__(self): - super().__init__() - - def get_description(self): - return _("Reset configuration to default settings") - - def run(self): - self.present_text(_("WARNING: This will reset all settings to defaults")) - self.present_text(_("Creating backup before reset...")) - - # Create backup first - backupSuccess, backupMessage = self.backup_config(announce=False) - if not backupSuccess: - self.present_text( - _("Failed to create backup. Reset cancelled for safety."), - interrupt=False, - ) - self.play_sound("Error") - return - - # Find default configuration file - default_config_path = self.find_default_config() - - if default_config_path and os.path.isfile(default_config_path): - try: - # Copy default configuration over current - shutil.copy2(default_config_path, self.settings_file) - - # Reload the configuration - self.reload_config() - - self.present_text( - _("Configuration reset to defaults successfully"), - interrupt=False, - flush=False, - ) - self.present_text( - _("Please restart Fenrir for all changes to take effect"), - interrupt=False, - flush=False, - ) - - except Exception as e: - self.present_text( - _("Failed to reset configuration: {0}").format(str(e)), - interrupt=False, - flush=False, - ) - else: - # Manually create basic default configuration - self.create_basic_defaults() - - def find_default_config(self): - """Find the default configuration file""" - possible_paths = [ - "/usr/share/fenrirscreenreader/config/settings/settings.conf", - "/etc/fenrirscreenreader/settings/settings.conf.default", - os.path.join( - os.path.dirname(self.settings_file), "settings.conf.default" - ), - # Development path - os.path.join( - os.path.dirname(__file__), - "..", - "..", - "..", - "..", - "..", - "..", - "config", - "settings", - "settings.conf", - ), - ] - - for path in possible_paths: - if os.path.isfile(path): - return path - - return None - - def create_basic_defaults(self): - """Create basic default configuration manually""" - try: - self.config.clear() - - # Basic speech defaults - self.config["speech"] = { - "enabled": "True", - "driver": "speechdDriver", - "rate": "0.5", - "pitch": "0.5", - "volume": "1.0", - "hardware_device": "/dev/ttyS0", - "hardware_baud_rate": "9600", - "auto_read_incoming": "True", - } - - # Basic sound defaults - self.config["sound"] = { - "enabled": "True", - "driver": "genericDriver", - "theme": "default", - "volume": "0.7", - } - - # Basic keyboard defaults - self.config["keyboard"] = { - "driver": "evdevDriver", - "device": "ALL", - "keyboard_layout": "desktop", - "char_echo_mode": "1", - "word_echo": "False", - "char_delete_echo": "True", - } - - # Basic screen defaults - self.config["screen"] = { - "driver": "vcsaDriver", - "encoding": "auto", - } - - # Basic general defaults - self.config["general"] = { - "punctuation_level": "some", - "debug_level": "0", - } - - # Basic clipboard defaults - self.config["clipboard"] = { - "number_of_clipboards": "50", - "clipboard_export_path": "/tmp/fenrirClipboard", - } - - # Write the configuration - with open(self.settings_file, "w") as configfile: - self.config.write(configfile) - - self.present_text( - _("Basic default configuration created"), - interrupt=False, - flush=False, - ) - self.present_text( - _("Please restart Fenrir for changes to take effect"), - interrupt=False, - flush=False, - ) - - except Exception as e: - self.present_text( - _("Failed to create default configuration: {0}").format(str(e)), - interrupt=False, - flush=False, - ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/revert_to_saved.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/revert_to_saved.py deleted file mode 100644 index 976f9642..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/revert_to_saved.py +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env python3 - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Discard temporary changes and revert to saved settings") - - def run(self): - self.env["runtime"]["OutputManager"].present_text( - _("Reverting to saved configuration..."), interrupt=True - ) - - try: - # Reload settings from file, discarding runtime changes - SettingsManager = self.env["runtime"]["SettingsManager"] - SettingsManager.load_settings() - - # Reinitialize speech system with restored settings - if "SpeechDriver" in self.env["runtime"]: - try: - SpeechDriver = self.env["runtime"]["SpeechDriver"] - SpeechDriver.shutdown() - SpeechDriver.initialize(self.env) - except Exception as e: - print( - f"revert_to_saved SpeechDriver: Error reinitializing speech driver: {str(e)}" - ) - - # Reinitialize sound system with restored settings - if "sound_driver" in self.env["runtime"]: - try: - sound_driver = self.env["runtime"]["sound_driver"] - sound_driver.shutdown() - sound_driver.initialize(self.env) - except Exception as e: - print( - f"revert_to_saved sound_driver: Error reinitializing sound driver: {str(e)}" - ) - - self.env["runtime"]["OutputManager"].present_text( - _("Successfully reverted to saved settings"), - interrupt=False, - flush=False, - ) - self.env["runtime"]["OutputManager"].present_text( - _("All temporary changes have been discarded"), - interrupt=False, - flush=False, - ) - - except Exception as e: - self.env["runtime"]["OutputManager"].present_text( - _("Error reverting settings: {0}").format(str(e)), - interrupt=False, - flush=False, - ) - self.env["runtime"]["OutputManager"].present_text( - _("You may need to restart Fenrir"), interrupt=False, flush=False - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/save_config.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/save_config.py deleted file mode 100644 index 31445a57..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/save_config.py +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env python3 - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -# Load base configuration class -_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py") -_spec = importlib.util.spec_from_file_location("config_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -config_command = _module.config_command - - -class command(config_command): - def __init__(self): - super().__init__() - - def get_description(self): - return _("Save current configuration to file") - - def run(self): - self.present_text(_("Saving current configuration...")) - - try: - if not self.env["runtime"]["SettingsManager"].save_settings(): - self.present_text(_("Failed to save configuration")) - self.play_sound("Error") - return - - self.present_text(_("Configuration saved successfully")) - self.play_sound("Accept") - - except Exception as e: - self.present_text(_("Failed to save configuration: {0}").format(str(e))) - self.play_sound("Error") diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/save_session_settings.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/save_session_settings.py deleted file mode 100644 index 4e46b519..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/save_session_settings.py +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env python3 - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Save current session settings to configuration file") - - def run(self): - self.env["runtime"]["OutputManager"].present_text( - _("Saving current session settings to configuration file..."), - interrupt=True, - ) - - try: - # This calls the settings manager's save method which writes - # current runtime settings to file - if not self.env["runtime"]["SettingsManager"].save_settings(): - self.env["runtime"]["OutputManager"].present_text( - _("Failed to save settings."), interrupt=False, flush=False - ) - return - - self.env["runtime"]["OutputManager"].present_text( - _("Session settings saved successfully!"), - interrupt=False, - flush=False, - ) - self.env["runtime"]["OutputManager"].present_text( - _("All temporary changes are now permanent."), - interrupt=False, - flush=False, - ) - - except Exception as e: - self.env["runtime"]["OutputManager"].present_text( - _("Failed to save settings: {0}").format(str(e)), - interrupt=False, - flush=False, - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/show_unsaved_changes.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/show_unsaved_changes.py deleted file mode 100644 index 40dc8e58..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/show_unsaved_changes.py +++ /dev/null @@ -1,104 +0,0 @@ -#!/usr/bin/env python3 - -import configparser - -from fenrirscreenreader.core.i18n import _, ngettext - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Show temporary changes not yet saved to file") - - def run(self): - self.env["runtime"]["OutputManager"].present_text( - _("Checking for unsaved changes..."), interrupt=True - ) - - try: - # Read the current config file - settings_file = self.env["runtime"][ - "SettingsManager" - ].settings_file - file_config = configparser.ConfigParser(interpolation=None) - file_config.read(settings_file) - - # Compare with runtime settings - runtime_settings = self.env["runtime"]["SettingsManager"].settings - - changes = [] - - # Check speech settings specifically - speech_sections = [ - "speech", - "sound", - "keyboard", - "screen", - "general", - ] - - for section in speech_sections: - if section in runtime_settings and section in file_config: - for option in runtime_settings[section]: - runtime_value = runtime_settings[section][option] - try: - file_value = file_config[section][option] - except Exception as e: - file_value = "" - - if runtime_value != file_value: - changes.append( - f"{section}.{option}: {file_value} → {runtime_value}" - ) - - if changes: - self.env["runtime"]["OutputManager"].present_text( - ngettext( - "found {0} unsaved change:", - "found {0} unsaved changes:", - len(changes), - ).format(len(changes)), - interrupt=True, - ) - for change in changes[:5]: # Limit to first 5 changes - self.env["runtime"]["OutputManager"].present_text( - change, interrupt=True - ) - - if len(changes) > 5: - self.env["runtime"]["OutputManager"].present_text( - ngettext( - "... and {0} more change", - "... and {0} more changes", - len(changes) - 5, - ).format(len(changes) - 5), - interrupt=True, - ) - - self.env["runtime"]["OutputManager"].present_text( - _("Use save command to make these changes permanent"), - interrupt=True, - ) - self.env["runtime"]["OutputManager"].play_sound("Accept") - else: - self.env["runtime"]["OutputManager"].present_text( - _("No unsaved changes found"), interrupt=True - ) - self.env["runtime"]["OutputManager"].play_sound("Cancel") - - except Exception as e: - self.env["runtime"]["OutputManager"].present_text( - _("Error checking for changes: {0}").format(str(e)), interrupt=True - ) - self.env["runtime"]["OutputManager"].play_sound("Error") - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/__init__.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/__init__.py deleted file mode 100644 index 428c13ab..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Fenrir screen configuration diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/select_driver.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/select_driver.py deleted file mode 100644 index 53198b04..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/select_driver.py +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env python3 - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -# Load base configuration class -_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py") -_spec = importlib.util.spec_from_file_location("config_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -config_command = _module.config_command - - -class command(config_command): - def __init__(self): - super().__init__() - - def get_description(self): - return _("Select screen driver") - - def run(self): - current_driver = self.get_setting("screen", "driver", "vcsaDriver") - - # Present current driver - driver_descriptions = { - "vcsaDriver": _("VCSA Driver - Linux TTY console access"), - "ptyDriver": _("PTY Driver - terminal emulation"), - "dummyDriver": _("Dummy Driver - for testing only"), - "debugDriver": _("Debug Driver - development/debugging"), - } - - current_description = driver_descriptions.get( - current_driver, _("Unknown driver") - ) - self.present_text(_("Current screen driver: {0}").format(current_description)) - - # Cycle through the available drivers - drivers = ["vcsaDriver", "ptyDriver", "dummyDriver"] - try: - current_index = drivers.index(current_driver) - next_index = (current_index + 1) % len(drivers) - new_driver = drivers[next_index] - except ValueError: - new_driver = "vcsaDriver" # Default to VCSA - - success = self.set_setting("screen", "driver", new_driver) - - if success: - new_description = driver_descriptions[new_driver] - self.present_text( - _("Screen driver changed to: {0}").format(new_description) - ) - self.present_text( - _("Please restart Fenrir for this change to take effect.") - ) - self.play_sound("Accept") - else: - self.present_text(_("Failed to change screen driver")) - self.play_sound("Error") diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/set_encoding_auto.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/set_encoding_auto.py deleted file mode 100644 index ffd45d07..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/set_encoding_auto.py +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env python3 - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -# Load base configuration class -_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py") -_spec = importlib.util.spec_from_file_location("config_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -config_command = _module.config_command - - -class command(config_command): - def __init__(self): - super().__init__() - - def get_description(self): - return _("Set screen encoding to Auto (automatic detection)") - - def run(self): - current_encoding = self.get_setting("screen", "encoding", "auto") - - if current_encoding.lower() == "auto": - self.present_text(_("Screen encoding already set to Auto")) - return - - success = self.set_setting("screen", "encoding", "auto") - - if success: - self.present_text( - _( - "Screen encoding set to Auto - Fenrir will automatically detect text encoding" - ) - ) - self.play_sound("Accept") - else: - self.present_text(_("Failed to change screen encoding")) - self.play_sound("Error") diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/set_encoding_cp1252.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/set_encoding_cp1252.py deleted file mode 100644 index fd862180..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/set_encoding_cp1252.py +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env python3 - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -# Load base configuration class -_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py") -_spec = importlib.util.spec_from_file_location("config_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -config_command = _module.config_command - - -class command(config_command): - def __init__(self): - super().__init__() - - def get_description(self): - return _("Set screen encoding to CP1252 (Windows Western European)") - - def run(self): - current_encoding = self.get_setting("screen", "encoding", "auto") - - if current_encoding.lower() == "cp1252": - self.present_text(_("Screen encoding already set to CP1252")) - return - - success = self.set_setting("screen", "encoding", "cp1252") - - if success: - self.present_text( - _("Screen encoding set to CP1252 - Windows Western European encoding") - ) - self.play_sound("Accept") - else: - self.present_text(_("Failed to change screen encoding")) - self.play_sound("Error") diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/set_encoding_iso88591.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/set_encoding_iso88591.py deleted file mode 100644 index 5e9fc718..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/set_encoding_iso88591.py +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env python3 - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -# Load base configuration class -_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py") -_spec = importlib.util.spec_from_file_location("config_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -config_command = _module.config_command - - -class command(config_command): - def __init__(self): - super().__init__() - - def get_description(self): - return _("Set screen encoding to ISO-8859-1 (Latin-1)") - - def run(self): - current_encoding = self.get_setting("screen", "encoding", "auto") - - if current_encoding.lower() == "iso-8859-1": - self.present_text(_("Screen encoding already set to ISO-8859-1")) - return - - success = self.set_setting("screen", "encoding", "iso-8859-1") - - if success: - self.present_text(_("Screen encoding set to ISO-8859-1 - Latin-1 encoding")) - self.play_sound("Accept") - else: - self.present_text(_("Failed to change screen encoding")) - self.play_sound("Error") \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/set_encoding_utf8.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/set_encoding_utf8.py deleted file mode 100644 index 717b55ac..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/set_encoding_utf8.py +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env python3 - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -# Load base configuration class -_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py") -_spec = importlib.util.spec_from_file_location("config_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -config_command = _module.config_command - - -class command(config_command): - def __init__(self): - super().__init__() - - def get_description(self): - return _("Set screen encoding to UTF-8 (Unicode)") - - def run(self): - current_encoding = self.get_setting("screen", "encoding", "auto") - - if current_encoding.lower() == "utf-8": - self.present_text(_("Screen encoding already set to UTF-8")) - return - - success = self.set_setting("screen", "encoding", "utf-8") - - if success: - self.present_text(_("Screen encoding set to UTF-8 - Unicode text encoding")) - self.play_sound("Accept") - else: - self.present_text(_("Failed to change screen encoding")) - self.play_sound("Error") \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/__init__.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/__init__.py deleted file mode 100644 index 2b385da2..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Fenrir sound configuration diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/adjust_volume.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/adjust_volume.py deleted file mode 100644 index 8b14ba00..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/adjust_volume.py +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env python3 - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -# Load base configuration class -_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py") -_spec = importlib.util.spec_from_file_location("config_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -config_command = _module.config_command - - -class command(config_command): - def __init__(self): - super().__init__() - - def get_description(self): - return _("Adjust sound volume") - - def run(self): - settings_manager = self.env["runtime"]["SettingsManager"] - try: - current_volume = settings_manager.get_setting_as_float("sound", "volume") - except Exception: - self.present_text(_("Failed to change sound volume")) - self.play_sound("Error") - return - - # Present current volume - volume_percent = int(current_volume * 100) - self.present_text(_("Current sound volume: {0} percent").format(volume_percent)) - - # Adjust volume by 10% - new_volume = min(1.0, round(current_volume + 0.1, 2)) - if new_volume == current_volume: - new_volume = max(0.0, round(current_volume - 0.1, 2)) - if not self.set_setting("sound", "volume", str(new_volume)): - self.present_text(_("Failed to change sound volume")) - self.play_sound("Error") - return - if settings_manager.get_setting_as_float("sound", "volume") != new_volume: - self.present_text(_("Failed to change sound volume")) - self.play_sound("Error") - return - - if new_volume != current_volume: - new_percent = int(new_volume * 100) - self.present_text( - _("Sound volume set to {0} percent").format(new_percent), - interrupt=False, - ) - self.play_sound("Accept") - else: - self.present_text(_("Sound volume unchanged"), interrupt=False) - self.play_sound("Cancel") diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/select_driver.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/select_driver.py deleted file mode 100644 index 957605a4..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/select_driver.py +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env python3 - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -# Load base configuration class -_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py") -_spec = importlib.util.spec_from_file_location("config_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -config_command = _module.config_command - - -class command(config_command): - def __init__(self): - super().__init__() - - def get_description(self): - return _("Select sound driver (genericDriver or gstreamerDriver)") - - def run(self): - current_driver = self.get_setting("sound", "driver", "genericDriver") - - # Present current driver - self.present_text(_("Current sound driver: {0}").format(current_driver)) - - # Toggle between the two available drivers - if current_driver == "genericDriver": - new_driver = "gstreamerDriver" - driver_description = _("GStreamer Driver - advanced multimedia framework") - else: - new_driver = "genericDriver" - driver_description = _("Generic Driver - uses SOX for audio playback") - - success = self.set_setting("sound", "driver", new_driver) - - if success: - self.present_text( - _("Sound driver changed to {0}. {1}").format( - new_driver, driver_description - ) - ) - self.present_text( - _("Please restart Fenrir for this change to take effect.") - ) - self.play_sound("Accept") - else: - self.present_text(_("Failed to change sound driver")) - self.play_sound("Error") diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/select_theme.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/select_theme.py deleted file mode 100644 index 32f947ee..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/select_theme.py +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env python3 - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -# Load base configuration class -_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py") -_spec = importlib.util.spec_from_file_location("config_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -config_command = _module.config_command - - -class command(config_command): - def __init__(self): - super().__init__() - - def get_description(self): - return _("Select sound theme") - - def run(self): - current_theme = self.get_setting("sound", "theme", "default") - current_theme_name = os.path.basename( - os.path.normpath(current_theme) - ) - - # Present current theme - self.present_text(_("Current sound theme: {0}").format(current_theme)) - - # Look for available sound themes - sound_paths = self.env[ - "runtime" - ]["SettingsManager"].get_sound_theme_roots() - - available_themes = self.get_available_themes(sound_paths) - - if len(available_themes) > 1: - # For this implementation, cycle through available themes - try: - current_index = available_themes.index(current_theme_name) - next_index = (current_index + 1) % len(available_themes) - new_theme = available_themes[next_index] - except ValueError: - # Current theme not found in available themes, use first one - new_theme = available_themes[0] - - success = self.set_setting("sound", "theme", new_theme) - - if success: - self.present_text(_("Sound theme changed to: {0}").format(new_theme)) - self.play_sound("Accept") - else: - self.present_text(_("Failed to change sound theme")) - self.play_sound("Error") - else: - self.present_text(_("Only default sound theme is available")) - self.play_sound("Cancel") - - def get_available_themes(self, searchPaths): - """Find available sound themes in the search paths""" - themes = ["default"] # Always include default - - for path in searchPaths: - if os.path.isdir(path): - try: - for item in os.listdir(path): - full_path = os.path.join(path, item) - if ( - os.path.isdir(full_path) - and item != "default" - and item not in themes - ): - # Check if it looks like a sound theme (contains - # sound files) - if self.is_valid_sound_theme(full_path): - themes.append(item) - except Exception: - continue - - return themes - - def is_valid_sound_theme(self, themePath): - """Check if a directory contains sound files""" - sound_extensions = [".wav", ".ogg", ".mp3", ".flac"] - try: - for file in os.listdir(themePath): - if any(file.lower().endswith(ext) for ext in sound_extensions): - return True - except Exception: - pass - return False diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/toggle_enabled.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/toggle_enabled.py deleted file mode 100644 index 58edd24e..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/toggle_enabled.py +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env python3 - -import importlib.util -import os - -from fenrirscreenreader.core.i18n import _ - -# Load base configuration class -_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py") -_spec = importlib.util.spec_from_file_location("config_base", _base_path) -_module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(_module) -config_command = _module.config_command - - -class command(config_command): - def __init__(self): - super().__init__() - - def get_description(self): - return _("Toggle sound output on or off") - - def run(self): - new_state = self.toggle_boolean_setting("sound", "enabled") - - if new_state is not None: - self.present_text(_("Sound enabled") if new_state else _("Sound disabled")) - # Only play sound if we enabled sound - if new_state: - self.play_sound("Accept") - else: - self.present_text(_("Failed to change sound setting")) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/__init__.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/__init__.py deleted file mode 100644 index 3b31c6d8..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Fenrir speech configuration diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/adjust_pitch.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/adjust_pitch.py deleted file mode 100644 index 45a38de4..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/adjust_pitch.py +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env python3 - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Adjust speech pitch (voice height)") - - def run(self): - try: - # Get current pitch - current_pitch = float( - self.env["runtime"]["SettingsManager"].get_setting( - "speech", "pitch" - ) - ) - except Exception as e: - current_pitch = 0.5 - - # Present current pitch - pitch_percent = int(current_pitch * 100) - self.env["runtime"]["OutputManager"].present_text( - _("Current speech pitch: {0} percent").format(pitch_percent), interrupt=True - ) - - # Increase by 10%, wrap around if at max - new_pitch = current_pitch + 0.1 - if new_pitch > 1.0: - new_pitch = 0.1 # Wrap to minimum - - # Apply the new pitch - self.env["runtime"]["SettingsManager"].set_setting( - "speech", "pitch", str(new_pitch) - ) - - new_percent = int(new_pitch * 100) - self.env["runtime"]["OutputManager"].present_text( - _("Speech pitch set to {0} percent").format(new_percent), interrupt=True - ) - self.env["runtime"]["OutputManager"].play_sound("Accept") - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/adjust_rate.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/adjust_rate.py deleted file mode 100644 index ae82cf55..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/adjust_rate.py +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env python3 - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Adjust speech rate (speed)") - - def run(self): - try: - # Get current rate - current_rate = float( - self.env["runtime"]["SettingsManager"].get_setting( - "speech", "rate" - ) - ) - except Exception as e: - current_rate = 0.5 - - # Present current rate - rate_percent = int(current_rate * 100) - self.env["runtime"]["OutputManager"].present_text( - _("Current speech rate: {0} percent").format(rate_percent), interrupt=True - ) - - # Increase by 10%, wrap around if at max - new_rate = current_rate + 0.1 - if new_rate > 1.0: - new_rate = 0.1 # Wrap to minimum - - # Apply the new rate - self.env["runtime"]["SettingsManager"].set_setting( - "speech", "rate", str(new_rate) - ) - - new_percent = int(new_rate * 100) - self.env["runtime"]["OutputManager"].present_text( - _("Speech rate set to {0} percent").format(new_percent), - interrupt=False, - flush=False, - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/adjust_speech_rate.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/adjust_speech_rate.py deleted file mode 100644 index cf855105..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/adjust_speech_rate.py +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env python3 - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Adjust speech rate (temporary - use save to make permanent)") - - def run(self): - try: - # Get current rate from runtime settings (may be temporary) - SettingsManager = self.env["runtime"]["SettingsManager"] - current_rate = float(SettingsManager.get_setting("speech", "rate")) - except Exception as e: - current_rate = 0.5 - - # Present current rate - rate_percent = int(current_rate * 100) - self.env["runtime"]["OutputManager"].present_text( - _("Current speech rate: {0} percent").format(rate_percent), interrupt=True - ) - - # Increase by 10%, wrap around if at max - new_rate = current_rate + 0.1 - if new_rate > 1.0: - new_rate = 0.1 # Wrap to minimum - - # Apply ONLY to runtime - this is temporary until saved - SettingsManager = self.env["runtime"]["SettingsManager"] - SettingsManager.settings["speech"]["rate"] = str(new_rate) - - # Apply to speech system immediately for this session - if "SpeechDriver" in self.env["runtime"]: - try: - self.env["runtime"]["SpeechDriver"].set_rate(new_rate) - except Exception as e: - print( - f"adjust_speech_rate set_rate: Error setting speech rate: {str(e)}" - ) - - new_percent = int(new_rate * 100) - self.env["runtime"]["OutputManager"].present_text( - _("Speech rate temporarily set to {0} percent").format(new_percent), - interrupt=False, - flush=False, - ) - self.env["runtime"]["OutputManager"].present_text( - _("Use save command to make permanent"), interrupt=False, flush=False - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/adjust_volume.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/adjust_volume.py deleted file mode 100644 index 06571d81..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/adjust_volume.py +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env python3 - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Adjust speech volume (loudness)") - - def run(self): - try: - # Get current volume - current_volume = float( - self.env["runtime"]["SettingsManager"].get_setting( - "speech", "volume" - ) - ) - except Exception as e: - current_volume = 1.0 - - # Present current volume - volume_percent = int(current_volume * 100) - self.env["runtime"]["OutputManager"].present_text( - _("Current speech volume: {0} percent").format(volume_percent), - interrupt=True, - ) - - # Increase by 10%, wrap around if at max - new_volume = current_volume + 0.1 - if new_volume > 1.0: - new_volume = 0.1 # Wrap to minimum - - # Apply the new volume - self.env["runtime"]["SettingsManager"].set_setting( - "speech", "volume", str(new_volume) - ) - - new_percent = int(new_volume * 100) - self.env["runtime"]["OutputManager"].present_text( - _("Speech volume set to {0} percent").format(new_percent), interrupt=True - ) - self.env["runtime"]["OutputManager"].play_sound("Accept") - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/show_current_settings.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/show_current_settings.py deleted file mode 100644 index 36637d35..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/show_current_settings.py +++ /dev/null @@ -1,98 +0,0 @@ -#!/usr/bin/env python3 - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Show current speech settings") - - def run(self): - # Get current speech settings - driver = self.env["runtime"]["SettingsManager"].get_setting( - "speech", "driver" - ) - module = self.env["runtime"]["SettingsManager"].get_setting( - "speech", "module" - ) - voice = self.env["runtime"]["SettingsManager"].get_setting( - "speech", "voice" - ) - rate = self.env["runtime"]["SettingsManager"].get_setting( - "speech", "rate" - ) - pitch = self.env["runtime"]["SettingsManager"].get_setting( - "speech", "pitch" - ) - volume = self.env["runtime"]["SettingsManager"].get_setting( - "speech", "volume" - ) - enabled = self.env["runtime"]["SettingsManager"].get_setting( - "speech", "enabled" - ) - - self.env["runtime"]["OutputManager"].present_text( - _("Current speech settings:"), interrupt=True - ) - - # Present all settings - self.env["runtime"]["OutputManager"].present_text( - _("Speech enabled: {0}").format(enabled), interrupt=True - ) - self.env["runtime"]["OutputManager"].present_text( - _("Driver: {0}").format(driver), interrupt=True - ) - - if module: - self.env["runtime"]["OutputManager"].present_text( - _("Module: {0}").format(module), interrupt=True - ) - - if voice: - self.env["runtime"]["OutputManager"].present_text( - _("Voice: {0}").format(voice), interrupt=True - ) - - try: - rate_percent = int(float(rate) * 100) - self.env["runtime"]["OutputManager"].present_text( - _("Rate: {0} percent").format(rate_percent), interrupt=True - ) - except Exception as e: - self.env["runtime"]["OutputManager"].present_text( - _("Rate: {0}").format(rate), interrupt=True - ) - - try: - pitch_percent = int(float(pitch) * 100) - self.env["runtime"]["OutputManager"].present_text( - _("Pitch: {0} percent").format(pitch_percent), interrupt=True - ) - except Exception as e: - self.env["runtime"]["OutputManager"].present_text( - _("Pitch: {0}").format(pitch), interrupt=True - ) - - try: - volume_percent = int(float(volume) * 100) - self.env["runtime"]["OutputManager"].present_text( - _("Volume: {0} percent").format(volume_percent), interrupt=True - ) - except Exception as e: - self.env["runtime"]["OutputManager"].present_text( - _("Volume: {0}").format(volume), interrupt=True - ) - - self.env["runtime"]["OutputManager"].play_sound("Accept") - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/mc/__init__.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/mc/__init__.py deleted file mode 100755 index e69de29b..00000000 diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/mc/file/__init__.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/mc/file/__init__.py deleted file mode 100755 index e69de29b..00000000 diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/mc/file/open.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/mc/file/open.py deleted file mode 100644 index 505ba961..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/mc/file/open.py +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return "No description found" - - def run(self): - self.env["runtime"]["OutputManager"].present_text( - _("ok i run open macro"), interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/mc/file/save.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/mc/file/save.py deleted file mode 100644 index cac8a834..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/mc/file/save.py +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return "No description found" - - def run(self): - self.env["runtime"]["OutputManager"].present_text( - _("ok i run open save"), interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/mc/search/__init__.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/mc/search/__init__.py deleted file mode 100755 index e69de29b..00000000 diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/mc/search/replace.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/mc/search/replace.py deleted file mode 100644 index 6daf73ef..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/mc/search/replace.py +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return "No description found" - - def run(self): - self.env["runtime"]["OutputManager"].present_text( - _("ok i run replace macro"), interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/mc/search/search.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/mc/search/search.py deleted file mode 100644 index 335f43d4..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/mc/search/search.py +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return "No description found" - - def run(self): - self.env["runtime"]["OutputManager"].present_text( - _("ok i run search macro"), interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/mc/search/test.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/mc/search/test.py deleted file mode 100644 index 4f721ce2..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/mc/search/test.py +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import datetime - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Test mc search functionality") - - def run(self): - # Test command for mc search operations - test_message = _("MC search test: This demonstrates search functionality") - - # present the test message - self.env["runtime"]["OutputManager"].present_text( - test_message, sound_icon="", interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/mutt/__init__.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/mutt/__init__.py deleted file mode 100755 index e69de29b..00000000 diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/mutt/file/__init__.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/mutt/file/__init__.py deleted file mode 100755 index e69de29b..00000000 diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/mutt/file/issue.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/mutt/file/issue.py deleted file mode 100644 index 8bf7f7c3..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/mutt/file/issue.py +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - self.key_macro = [ - [1, "KEY_LEFTCTRL"], - [1, "KEY_G"], - [0.05, "sleep"], - [0, "KEY_G"], - [0, "KEY_LEFTCTRL"], - ] - - def shutdown(self): - pass - - def get_description(self): - return "No description found" - - def run(self): - self.env["runtime"]["InputManager"].send_keys(self.key_macro) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/mutt/file/open.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/mutt/file/open.py deleted file mode 100644 index f34fabe6..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/mutt/file/open.py +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - self.macro = [ - [1, "KEY_LEFTSHIFT"], - [1, "KEY_LEFTCTRL"], - [1, "KEY_N"], - [0.05, "SLEEP"], - [0, "KEY_N"], - [0, "KEY_LEFTCTRL"], - [0, "KEY_LEFTSHIFT"], - ] - - def shutdown(self): - pass - - def get_description(self): - return "No description found" - - def run(self): - self.env["runtime"]["InputManager"].send_keys(self.macro) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/mutt/file/save.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/mutt/file/save.py deleted file mode 100644 index cac8a834..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/mutt/file/save.py +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return "No description found" - - def run(self): - self.env["runtime"]["OutputManager"].present_text( - _("ok i run open save"), interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/mutt/search/__init__.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/mutt/search/__init__.py deleted file mode 100755 index e69de29b..00000000 diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/mutt/search/replace.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/mutt/search/replace.py deleted file mode 100644 index 6daf73ef..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/mutt/search/replace.py +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return "No description found" - - def run(self): - self.env["runtime"]["OutputManager"].present_text( - _("ok i run replace macro"), interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/mutt/search/search.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/mutt/search/search.py deleted file mode 100644 index 335f43d4..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/mutt/search/search.py +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return "No description found" - - def run(self): - self.env["runtime"]["OutputManager"].present_text( - _("ok i run search macro"), interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/mutt/search/test.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/mutt/search/test.py deleted file mode 100644 index 37f4364e..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/mutt/search/test.py +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import datetime - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Test mutt search functionality") - - def run(self): - # Test command for mutt search operations - test_message = _("Mutt search test: This demonstrates search functionality") - - # present the test message - self.env["runtime"]["OutputManager"].present_text( - test_message, sound_icon="", interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/nano/Help/__init__.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/nano/Help/__init__.py deleted file mode 100755 index e69de29b..00000000 diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/nano/Help/about_nano.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/nano/Help/about_nano.py deleted file mode 100755 index 22cdb177..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/nano/Help/about_nano.py +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env python -# -*- encoding: utf-8 - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - self.key_macro = [ - [1, "KEY_LEFTCTRL"], - [1, "KEY_G"], - [0.05, "SLEEP"], - [0, "KEY_G"], - [0, "KEY_LEFTCTRL"], - ] - - def shutdown(self): - pass - - def get_description(self): - return _("Learn about the Nano text editor.") - - def run(self): - self.env["runtime"]["OutputManager"].present_text( - _("Okay, loading the information about Nano."), interrupt=True - ) - self.env["runtime"]["InputManager"].send_keys(self.key_macro) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/nano/__init__.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/nano/__init__.py deleted file mode 100755 index e69de29b..00000000 diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/nano/file/__init__.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/nano/file/__init__.py deleted file mode 100755 index e69de29b..00000000 diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/nano/file/open.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/nano/file/open.py deleted file mode 100644 index 505ba961..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/nano/file/open.py +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return "No description found" - - def run(self): - self.env["runtime"]["OutputManager"].present_text( - _("ok i run open macro"), interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/nano/file/save.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/nano/file/save.py deleted file mode 100755 index c68339eb..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/nano/file/save.py +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env python -# -*- encoding: utf-8 - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - self.key_macro = [ - [1, "KEY_LEFTCTRL"], - [1, "KEY_S"], - [0.05, "SLEEP"], - [0, "KEY_S"], - [0, "KEY_LEFTCTRL"], - ] - - def shutdown(self): - pass - - def get_description(self): - return _("Save your work.") - - def run(self): - self.env["runtime"]["OutputManager"].present_text( - _("Okay, you will now be asked to save your work."), interrupt=True - ) - self.env["runtime"]["InputManager"].send_keys(self.key_macro) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/nano/search/__init__.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/nano/search/__init__.py deleted file mode 100755 index e69de29b..00000000 diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/nano/search/replace.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/nano/search/replace.py deleted file mode 100644 index 6daf73ef..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/nano/search/replace.py +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return "No description found" - - def run(self): - self.env["runtime"]["OutputManager"].present_text( - _("ok i run replace macro"), interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/nano/search/search.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/nano/search/search.py deleted file mode 100644 index 335f43d4..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/nano/search/search.py +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return "No description found" - - def run(self): - self.env["runtime"]["OutputManager"].present_text( - _("ok i run search macro"), interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/nano/search/test.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/nano/search/test.py deleted file mode 100644 index 7bab4b1a..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/nano/search/test.py +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import datetime - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Test nano search functionality") - - def run(self): - # Test command for nano search operations - test_message = _("Nano search test: This demonstrates search functionality") - - # present the test message - self.env["runtime"]["OutputManager"].present_text( - test_message, sound_icon="", interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/vim/__init__.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/vim/__init__.py deleted file mode 100755 index e69de29b..00000000 diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/vim/file/__init__.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/vim/file/__init__.py deleted file mode 100755 index e69de29b..00000000 diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/vim/file/open.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/vim/file/open.py deleted file mode 100644 index 505ba961..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/vim/file/open.py +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return "No description found" - - def run(self): - self.env["runtime"]["OutputManager"].present_text( - _("ok i run open macro"), interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/vim/file/save.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/vim/file/save.py deleted file mode 100644 index cac8a834..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/vim/file/save.py +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return "No description found" - - def run(self): - self.env["runtime"]["OutputManager"].present_text( - _("ok i run open save"), interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/vim/search/__init__.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/vim/search/__init__.py deleted file mode 100755 index e69de29b..00000000 diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/vim/search/replace.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/vim/search/replace.py deleted file mode 100644 index 6daf73ef..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/vim/search/replace.py +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return "No description found" - - def run(self): - self.env["runtime"]["OutputManager"].present_text( - _("ok i run replace macro"), interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/vim/search/search.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/vim/search/search.py deleted file mode 100644 index 335f43d4..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/vim/search/search.py +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return "No description found" - - def run(self): - self.env["runtime"]["OutputManager"].present_text( - _("ok i run search macro"), interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/vim/search/test.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/vim/search/test.py deleted file mode 100644 index 221bdd83..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/vim/search/test.py +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import datetime - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Test vim search functionality") - - def run(self): - # Test command for vim search operations - test_message = _("Vim search test: This demonstrates search functionality") - - # present the test message - self.env["runtime"]["OutputManager"].present_text( - test_message, sound_icon="", interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/__init__.py b/src/fenrirscreenreader/commands/vmenu-profiles/__init__.py deleted file mode 100755 index e69de29b..00000000 diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/template.py b/src/fenrirscreenreader/commands/vmenu-profiles/template.py deleted file mode 100644 index a85c8dc5..00000000 --- a/src/fenrirscreenreader/commands/vmenu-profiles/template.py +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - # examples: - # self.key_macro = [[1,'KEY_LEFTCTRL'],[1,'KEY_O'],[0.05,'SLEEP'],[0,'KEY_O'],[0,'KEY_LEFTCTRL']] - # self.key_macro = [[1,'KEY_LEFTSHIFT'],[1,'KEY_LEFTCTRL'],[1,'KEY_N'],[0.05,'SLEEP'],[0,'KEY_N'],[0,'KEY_LEFTCTRL'],[0,'KEY_LEFTSHIFT']] - self.key_macro = [] - - def shutdown(self): - pass - - def get_description(self): - return "No description found" - - def run(self): - self.env["runtime"]["InputManager"].send_keys(self.key_macro) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/core/__init__.py b/src/fenrirscreenreader/core/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/fenrirscreenreader/core/applicationManager.py b/src/fenrirscreenreader/core/applicationManager.py deleted file mode 100644 index 14f7d426..00000000 --- a/src/fenrirscreenreader/core/applicationManager.py +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core import debug - - -class ApplicationManager: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_current_application(self): - curr_app = self.env["screen"]["new_application"].upper() - if not curr_app: - curr_app = "DEFAULT" - if curr_app == "": - curr_app = "DEFAULT" - return curr_app - - def get_prev_application(self): - prev_app = self.env["screen"]["oldApplication"].upper() - if not prev_app: - prev_app = "DEFAULT" - if prev_app == "": - prev_app = "DEFAULT" - return prev_app - - def is_application_change(self): - return ( - self.env["screen"]["oldApplication"] - != self.env["screen"]["new_application"] - ) diff --git a/src/fenrirscreenreader/core/attributeManager.py b/src/fenrirscreenreader/core/attributeManager.py deleted file mode 100644 index 543927d3..00000000 --- a/src/fenrirscreenreader/core/attributeManager.py +++ /dev/null @@ -1,770 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from collections import Counter - -from fenrirscreenreader.core import debug -from fenrirscreenreader.core.i18n import _ - - -class AttributeManager: - def __init__(self): - self.currAttributes = None - self.prevAttributes = None - self.currAttributeDelta = "" - self.currAttributeCursor = None - self.prefAttributeCursor = None - self.init_default_attributes() - self.prevLastCursorAttribute = None - self.currLastCursorAttribute = None - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def set_last_cursor_attribute(self, lastCursorAttribute): - self.prevLastCursorAttribute = self.currLastCursorAttribute - self.currLastCursorAttribute = lastCursorAttribute - - def reset_last_cursor_attribute(self): - self.prevLastCursorAttribute = None - self.currLastCursorAttribute = None - - def is_last_cursor_attribute_change(self): - if self.prevLastCursorAttribute is None: - return False - return self.prevLastCursorAttribute != self.currLastCursorAttribute - - def get_curr_attribute_cursor(self): - return self.currAttributeCursor - - def is_attribute_cursor_active(self): - return self.currAttributeCursor is not None - - def is_attribute_change(self): - if not self.prevAttributes: - return False - return self.currAttributes != self.prevAttributes - - def reset_attribute_all(self): - self.reset_attribute_delta() - self.reset_attribute_cursor() - - def get_attribute_delta(self): - return self.currAttributeDelta - - def reset_attribute_delta(self): - self.currAttributeDelta = "" - - def set_attribute_delta(self, currAttributeDelta): - self.currAttributeDelta = currAttributeDelta - - def reset_attribute_cursor(self): - self.currAttributeCursor = None - self.prefAttributeCursor = None - - def set_attribute_cursor(self, currAttributeCursor): - self.prefAttributeCursor = self.currAttributeCursor - self.currAttributeCursor = currAttributeCursor.copy() - - def reset_attributes(self, currAttributes): - self.prevAttributes = None - self.currAttributes = currAttributes - - def set_attributes(self, currAttributes): - self.prevAttributes = self.currAttributes - self.currAttributes = currAttributes.copy() - - def get_attribute_by_xy(self, x, y): - if not self.currAttributes: - return None - if len(self.currAttributes) < y: - return None - if len(self.currAttributes[y]) < x - 1: - return None - try: - return self.currAttributes[y][x] - except KeyError: - try: - return self.defaultAttributes[0] - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "AttributeManager get_attribute_by_xy: Error accessing default attributes: " - + str(e), - debug.DebugLevel.ERROR, - ) - return None - - def append_default_attributes(self, attribute): - if not attribute: - return - if len(attribute) != 10: - return - self.defaultAttributes.append(attribute) - - def init_default_attributes(self): - self.defaultAttributes = [None] - self.defaultAttributes.append( - [ - "default", # fg - "default", # bg - False, # bold - False, # italics - False, # underscore - False, # strikethrough - False, # reverse - False, # blink - "default", # fontsize - "default", # fontfamily - ] - ) # end attribute - - def is_default_attribute(self, attribute): - return attribute in self.defaultAttributes - - def has_attributes(self, cursor, update=True): - if not cursor: - return False - cursor_pos = cursor.copy() - try: - attribute = self.get_attribute_by_xy( - cursor_pos["x"], cursor_pos["y"] - ) - - if update: - self.set_last_cursor_attribute(attribute) - if not self.is_last_cursor_attribute_change(): - return False - - if self.is_default_attribute(attribute): - return False - - except Exception as e: - return False - return True - - def format_attributes(self, attribute, attribute_format_string=""): - # "black", - # "red", - # "green", - # "brown", - # "blue", - # "magenta", - # "cyan", - # "white", - # "default" # white. - # _order_ - # "fg", - # "bg", - # "bold", - # "italics", - # "underscore", - # "strikethrough", - # "reverse", - # "blink" - # "fontsieze" - # "fontfamily" - if attribute_format_string == "": - attribute_format_string = self.env["runtime"][ - "SettingsManager" - ].get_setting("general", "attribute_format_string") - if not attribute_format_string: - return "" - if attribute_format_string == "": - return "" - if not attribute: - return "" - if len(attribute) != 10: - return "" - - # 0 FG color (name) - try: - attribute_format_string = attribute_format_string.replace( - "fenrirFGColor", _(attribute[0]) - ) - except Exception as e: - attribute_format_string = attribute_format_string.replace( - "fenrirFGColor", "" - ) - - # 1 BG color (name) - try: - attribute_format_string = attribute_format_string.replace( - "fenrirBGColor", _(attribute[1]) - ) - except Exception as e: - attribute_format_string = attribute_format_string.replace( - "fenrirBGColor", "" - ) - - # 2 bold (True/ False) - try: - if attribute[2]: - attribute_format_string = attribute_format_string.replace( - "fenrirBold", _("bold") - ) - except Exception as e: - pass - attribute_format_string = attribute_format_string.replace( - "fenrirBold", "" - ) - - # 3 italics (True/ False) - try: - if attribute[3]: - attribute_format_string = attribute_format_string.replace( - "fenrirItalics", _("italic") - ) - except Exception as e: - pass - attribute_format_string = attribute_format_string.replace( - "fenrirItalics", "" - ) - - # 4 underline (True/ False) - try: - if attribute[4]: - attribute_format_string = attribute_format_string.replace( - "fenrirUnderline", _("underline") - ) - except Exception as e: - pass - attribute_format_string = attribute_format_string.replace( - "fenrirUnderline", "" - ) - - # 5 strikethrough (True/ False) - try: - if attribute[5]: - attribute_format_string = attribute_format_string.replace( - "fenrirStrikethrough", _("strikethrough") - ) - except Exception as e: - pass - attribute_format_string = attribute_format_string.replace( - "fenrirStrikethrough", "" - ) - - # 6 reverse (True/ False) - try: - if attribute[6]: - attribute_format_string = attribute_format_string.replace( - "fenrirReverse", _("reverse") - ) - except Exception as e: - pass - attribute_format_string = attribute_format_string.replace( - "fenrirReverse", "" - ) - - # 7 blink (True/ False) - try: - if attribute[7]: - attribute_format_string = attribute_format_string.replace( - "fenrirBlink", _("blink") - ) - except Exception as e: - pass - attribute_format_string = attribute_format_string.replace( - "fenrirBlink", "" - ) - - # 8 font size (int/ string) - try: - try: - attribute_format_string = attribute_format_string.replace( - "fenrirFontSize", int(attribute[8]) - ) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "AttributeManager formatAttributeToString: Error formatting font size as int: " - + str(e), - debug.DebugLevel.ERROR, - ) - try: - attribute_format_string = attribute_format_string.replace( - "fenrirFontSize", str(attribute[8]) - ) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "AttributeManager formatAttributeToString: Error formatting font size as string: " - + str(e), - debug.DebugLevel.ERROR, - ) - except Exception as e: - pass - attribute_format_string = attribute_format_string.replace( - "fenrirFontSize", _("default") - ) - - # 9 font family (string) - try: - attribute_format_string = attribute_format_string.replace( - "fenrirFont", attribute[9] - ) - except Exception as e: - pass - attribute_format_string = attribute_format_string.replace( - "fenrirFont", _("default") - ) - - return attribute_format_string - - def track_highlights(self): - """ - Detects text with changed attributes (highlighting) between screen updates. - - This is crucial for screen readers to announce when text becomes highlighted, - selected, or changes visual emphasis (bold, reverse video, color changes, etc.) - - Enhanced version includes bracket pattern detection for better context in applications - like ninjam that use patterns like [X]mute or [ ]mute. - - Returns: - tuple: (highlighted_text, cursor_position) - - highlighted_text: string of characters that gained highlighting - - cursor_position: dict {'x': col, 'y': row} of first highlighted char - """ - result = "" - curr_cursor = None - - # Early exit conditions - no attribute comparison possible - if self.prevAttributes is None: - # First screen load - no previous attributes to compare against - return result, curr_cursor - if self.prevAttributes == self.currAttributes: - # No attribute changes detected - return result, curr_cursor - if self.currAttributes is None: - # Error condition - current attributes missing - return result, curr_cursor - if len(self.currAttributes) == 0: - # Special case for PTY environments with no text content - return result, curr_cursor - - # Get current screen text to correlate with attribute changes - text = self.env["runtime"]["ScreenManager"].get_screen_text() - text_lines = text.split("\n") - - # Sanity check: text lines must match attribute array dimensions - if len(text_lines) != len(self.currAttributes): - return result, curr_cursor - - # Track highlighted positions for context analysis - highlighted_positions = [] - - # Compare attributes line by line, character by character - for line in range(len(self.prevAttributes)): - if self.prevAttributes[line] != self.currAttributes[line]: - # This line has attribute changes - examine each character - # position - for column in range(len(self.prevAttributes[line])): - if ( - self.prevAttributes[line][column] - == self.currAttributes[line][column] - ): - # No change at this position - continue - # Attribute changed at this position - check if it's worth - # announcing - if self.is_useful_for_tracking( - line, column, self.currAttributes, self.prevAttributes - ): - # First highlighted character becomes cursor position - # for navigation - if not curr_cursor: - curr_cursor = {"x": column, "y": line} - # Store position for context analysis - highlighted_positions.append((line, column)) - # Accumulate highlighted characters - result += text_lines[line][column] - # Add space between lines of highlighted text for speech - # clarity - result += " " - - # Enhanced bracket pattern detection for better context - if highlighted_positions: - try: - enhanced_result = self._detect_bracket_context(text_lines, highlighted_positions, result) - if enhanced_result and enhanced_result != result: - # Debug logging for bracket detection - try: - self.env["runtime"]["DebugManager"].write_debug_out( - f"AttributeManager bracket detection: Original='{result}' Enhanced='{enhanced_result}'", - debug.DebugLevel.INFO - ) - except Exception: - pass # Don't let debug logging break functionality - result = enhanced_result - except Exception as e: - # If bracket detection fails, fall back to original result - try: - self.env["runtime"]["DebugManager"].write_debug_out( - f"AttributeManager bracket detection error: {e}", - debug.DebugLevel.ERROR - ) - except Exception: - pass # Don't let debug logging break functionality - - return result, curr_cursor - - def _detect_bracket_context(self, text_lines, highlighted_positions, original_result): - """ - Analyzes highlighted positions to detect bracket patterns and provide better context. - - This method specifically looks for patterns like [X]mute, [ ]mute, [X]xmit, etc. - that are common in applications like ninjam where the bracket content indicates - state but the meaningful context is the surrounding text. - - Args: - text_lines: List of text lines from the screen - highlighted_positions: List of (line, column) tuples of highlighted characters - original_result: The original highlighted text result - - Returns: - str: Enhanced result with context, or None if no bracket pattern detected - """ - if not highlighted_positions: - return None - - # Group positions by line for easier analysis - positions_by_line = {} - for line, column in highlighted_positions: - if line not in positions_by_line: - positions_by_line[line] = [] - positions_by_line[line].append(column) - - enhanced_results = [] - - for line_num, columns in positions_by_line.items(): - if line_num >= len(text_lines): - continue - - line_text = text_lines[line_num] - columns.sort() # Process columns in order - - # Look for bracket patterns in this line - bracket_context = self._analyze_bracket_pattern(line_text, columns) - if bracket_context: - enhanced_results.append(bracket_context) - - # If we found enhanced context, return it; otherwise return None to use original - if enhanced_results: - # Remove duplicates while preserving order - unique_results = [] - seen = set() - for result in enhanced_results: - if result not in seen: - unique_results.append(result) - seen.add(result) - return " ".join(unique_results) - return None - - def _analyze_bracket_pattern(self, line_text, highlighted_columns): - """ - Analyzes a single line for bracket patterns around highlighted positions. - - Looks for patterns like: - - master: [X]mute -> "master mute on" - - metronome: [ ]mute -> "metronome mute off" - - [Left] [X]xmit -> "Left xmit on" - - Args: - line_text: The text of the line - highlighted_columns: List of column positions that are highlighted - - Returns: - str: Context-aware description, or None if no pattern found - """ - if not line_text or not highlighted_columns: - return None - - # Look for bracket patterns around highlighted positions - # Process columns in order and only return the first meaningful match - for col in highlighted_columns: - bracket_info = self._find_bracket_at_position(line_text, col) - if bracket_info: - bracket_start, bracket_end, bracket_content = bracket_info - - # Get context before and after the bracket - context_before = self._get_context_before(line_text, bracket_start) - context_after = self._get_context_after(line_text, bracket_end) - - # Build meaningful description - description = self._build_bracket_description( - context_before, bracket_content, context_after - ) - - # Only return if we have meaningful context (not just state) - if description and (context_before or context_after): - return description - - return None - - def _find_bracket_at_position(self, line_text, position): - """ - Determines if a position is within a bracket pattern like [X] or [ ]. - - Args: - line_text: The text line - position: Column position to check - - Returns: - tuple: (bracket_start, bracket_end, bracket_content) or None - """ - if position >= len(line_text): - return None - - # Look for opening bracket before or at position - bracket_start = None - for i in range(max(0, position - 2), min(len(line_text), position + 3)): - if line_text[i] == '[': - bracket_start = i - break - - if bracket_start is None: - return None - - # Look for closing bracket after bracket_start - bracket_end = None - for i in range(bracket_start + 1, min(len(line_text), bracket_start + 5)): - if line_text[i] == ']': - bracket_end = i - break - - if bracket_end is None: - return None - - # Check if our position is within the bracket - if bracket_start <= position <= bracket_end: - bracket_content = line_text[bracket_start + 1:bracket_end] - - # Filter out brackets that are likely not controls - # Skip brackets that contain complex content like "[-10.5dB center]" - if len(bracket_content) > 3 and ('dB' in bracket_content or 'Hz' in bracket_content): - return None - - return bracket_start, bracket_end, bracket_content - - return None - - def _get_context_before(self, line_text, bracket_start): - """ - Gets meaningful context words before a bracket. - - Args: - line_text: The text line - bracket_start: Position of opening bracket - - Returns: - str: Context words before bracket, or empty string - """ - if bracket_start <= 0: - return "" - - # Get text before bracket - before_text = line_text[:bracket_start].rstrip() - - # Special handling for common ninjam patterns - # Look for patterns like "master: " or "metronome: " or bracketed labels - - # Check if we have a colon-separated label immediately before - if ':' in before_text: - # Get the last colon-separated part - parts = before_text.split('|') # Split by pipe first - last_part = parts[-1].strip() - - if ':' in last_part: - # Extract the label before the colon - label_parts = last_part.split(':') - if len(label_parts) >= 2: - return label_parts[-2].strip() - - # Look for bracketed content that might be a label - # Pattern: [Something] [X]target -> "Something" - bracket_matches = [] - i = 0 - while i < len(before_text): - if before_text[i] == '[': - start = i - i += 1 - while i < len(before_text) and before_text[i] != ']': - i += 1 - if i < len(before_text): # Found closing bracket - content = before_text[start+1:i] - if content.strip(): - bracket_matches.append(content.strip()) - i += 1 - - # If we found bracketed content, use the last one - if bracket_matches: - return bracket_matches[-1] - - # Fall back to removing bracketed content and getting words - cleaned_text = "" - bracket_level = 0 - for char in before_text: - if char == '[': - bracket_level += 1 - elif char == ']': - bracket_level -= 1 - elif bracket_level == 0: - cleaned_text += char - - # Clean up separators and get meaningful words - words = [] - for word in cleaned_text.replace(':', '').replace('|', '').strip().split(): - if word and not word.startswith('[') and not word.endswith(']'): - words.append(word) - - # Return last word as context - if words: - return words[-1] - - return "" - - def _get_context_after(self, line_text, bracket_end): - """ - Gets meaningful context words after a bracket. - - Args: - line_text: The text line - bracket_end: Position of closing bracket - - Returns: - str: Context words after bracket, or empty string - """ - if bracket_end >= len(line_text) - 1: - return "" - - # Get text after bracket and find first meaningful word - after_text = line_text[bracket_end + 1:].lstrip() - - # Get first word, removing common separators - words = after_text.replace(':', '').replace('|', '').strip().split() - - if words: - return words[0] - - return "" - - def _build_bracket_description(self, context_before, bracket_content, context_after): - """ - Builds a human-readable description from bracket context. - - Args: - context_before: Words before the bracket - bracket_content: Content inside brackets (X, space, etc.) - context_after: Words after the bracket - - Returns: - str: Human-readable description - """ - # Clean up bracket content - bracket_content = bracket_content.strip() - - # Determine state based on bracket content - if bracket_content == 'X': - state = "on" - elif bracket_content == '' or bracket_content == ' ': - state = "off" - else: - state = bracket_content # For other patterns - - # Build description prioritizing context_before, then context_after - # Add spaces between components for better speech clarity - components = [] - - if context_before: - components.append(context_before) - if context_after: - components.append(context_after) - components.append(state) - - # Join with spaces for better speech flow - return " ".join(components) - - def is_useful_for_tracking( - self, - line, - column, - currAttributes, - prevAttributes, - attribute=1, - mode="zaxe", - ): - """ - Determines if an attribute change at a specific position is worth announcing. - - This prevents announcing every minor attribute change and focuses on meaningful - highlighting that indicates user selections, focus changes, or important emphasis. - - Args: - line, column: Position of the attribute change - currAttributes, prevAttributes: Current and previous attribute arrays - attribute: Which attribute to examine (1=background color by default) - mode: Detection algorithm ('zaxe', 'default', 'barrier') - - Returns: - bool: True if this attribute change should be announced to user - """ - # Sanity checks for valid position and sufficient screen content - if len(currAttributes) <= 3: - return False - if line < 0: - return False - if line > len(currAttributes): - return False - - useful = False - - if mode == "default": - # Simple mode: announce any non-default attribute - useful = not self.is_default_attribute( - currAttributes[line][column] - ) - - elif (mode == "zaxe") or (mode == ""): - # Context-aware mode: only announce attributes that stand out from surroundings - # This prevents announcing entire blocks of highlighted text character by character - # by checking if the attribute differs from adjacent lines - - if line == 0: - # Top line: compare against lines below - useful = ( - currAttributes[line][column][attribute] - != currAttributes[line + 1][column][attribute] - ) and ( - currAttributes[line][column][attribute] - != currAttributes[line + 2][column][attribute] - ) - elif line >= len(prevAttributes): - # Bottom line: compare against lines above - useful = ( - currAttributes[line][column][attribute] - != currAttributes[line - 1][column][attribute] - ) and ( - currAttributes[line][column][attribute] - != currAttributes[line - 2][column][attribute] - ) - else: - # Middle lines: compare against both directions - useful = ( - currAttributes[line][column][attribute] - != currAttributes[line + 1][column][attribute] - ) and ( - currAttributes[line][column][attribute] - != currAttributes[line - 1][column][attribute] - ) - - elif mode == "barrier": - # Barrier mode: future enhancement for detecting screen - # boundaries/separators - useful = True - - return useful diff --git a/src/fenrirscreenreader/core/backgroundTaskManager.py b/src/fenrirscreenreader/core/backgroundTaskManager.py deleted file mode 100644 index a91e54b5..00000000 --- a/src/fenrirscreenreader/core/backgroundTaskManager.py +++ /dev/null @@ -1,148 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import itertools -import queue -import threading - -from fenrirscreenreader.core import debug -from fenrirscreenreader.core.eventData import FenrirEventType - - -class BackgroundTaskManager: - """Run bounded auxiliary work without exposing Fenrir state to workers.""" - - def __init__(self, worker_count=4, external_worker_count=2): - self.worker_count = worker_count - self.external_worker_count = external_worker_count - self.env = None - self._accepting_tasks = False - self._callbacks = {} - self._callback_lock = threading.Lock() - self._task_ids = itertools.count(1) - self._task_queues = { - "default": queue.Queue(), - "external": queue.Queue(), - } - self._workers = [] - - def initialize(self, environment): - self.env = environment - self._accepting_tasks = True - self._start_workers("default", self.worker_count) - self._start_workers("external", self.external_worker_count) - - def _start_workers(self, lane, worker_count): - task_queue = self._task_queues[lane] - for worker_index in range(worker_count): - worker = threading.Thread( - target=self._run_worker, - args=(task_queue,), - name=f"fenrir-{lane}-{worker_index + 1}", - daemon=True, - ) - self._workers.append(worker) - worker.start() - - def shutdown(self): - self._accepting_tasks = False - with self._callback_lock: - self._callbacks.clear() - - for task_queue in self._task_queues.values(): - try: - while True: - task_queue.get_nowait() - task_queue.task_done() - except queue.Empty: - pass - - for _worker_index in range(self.worker_count): - self._task_queues["default"].put(None) - for _worker_index in range(self.external_worker_count): - self._task_queues["external"].put(None) - for worker in self._workers: - worker.join(timeout=0.25) - self._workers = [] - - def submit_task(self, function, callback, *args, **kwargs): - """Schedule a pure worker function and return its task identifier.""" - return self._submit_to_lane( - "default", function, callback, args, kwargs - ) - - def submit_external_task(self, function, callback, *args, **kwargs): - """Schedule potentially long-running user scripts on an isolated lane.""" - return self._submit_to_lane( - "external", function, callback, args, kwargs - ) - - def _submit_to_lane(self, lane, function, callback, args, kwargs): - if not self._accepting_tasks or not callable(function): - return None - if not callable(callback): - return None - - task_id = next(self._task_ids) - with self._callback_lock: - self._callbacks[task_id] = callback - self._task_queues[lane].put((task_id, function, args, kwargs)) - return task_id - - def cancel_task(self, task_id): - """Discard delivery for a task that is no longer relevant.""" - if task_id is None: - return - with self._callback_lock: - self._callbacks.pop(task_id, None) - - def handle_result(self, result): - """Deliver a completed task on Fenrir's main event-loop thread.""" - if not isinstance(result, dict): - return - task_id = result.get("task_id") - with self._callback_lock: - callback = self._callbacks.pop(task_id, None) - if callback is None: - return - try: - callback(result) - except Exception as error: - self.env["runtime"]["DebugManager"].write_debug_out( - "BackgroundTaskManager handle_result: " + str(error), - debug.DebugLevel.ERROR, - ) - - def _run_worker(self, task_queue): - while True: - task = task_queue.get() - try: - if task is None: - return - task_id, function, args, kwargs = task - try: - value = function(*args, **kwargs) - result = { - "task_id": task_id, - "succeeded": True, - "value": value, - "error": "", - } - except Exception as error: - result = { - "task_id": task_id, - "succeeded": False, - "value": None, - "error": str(error), - } - - if self._accepting_tasks: - self.env["runtime"]["EventManager"].put_to_event_queue( - FenrirEventType.background_task_result, - result, - ) - finally: - task_queue.task_done() diff --git a/src/fenrirscreenreader/core/barrierManager.py b/src/fenrirscreenreader/core/barrierManager.py deleted file mode 100644 index 14ee8259..00000000 --- a/src/fenrirscreenreader/core/barrierManager.py +++ /dev/null @@ -1,100 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import re -import string - -from fenrirscreenreader.core import debug - - -class BarrierManager: - def __init__(self): - self.currIsBarrier = False - self.prefIsBarrier = False - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def update_barrier_change(self, is_barrier): - self.prefIsBarrier = self.currIsBarrier - self.currIsBarrier = is_barrier - - def reset_barrier_change(self): - self.currIsBarrier = False - self.prefIsBarrier = False - - def is_barrier_change(self): - return self.currIsBarrier != self.prefIsBarrier - - def handle_line_barrier( - self, text, xCursor, yCursor, output=True, do_interrupt=True - ): - is_barrier = False - try: - is_barrier, say_line = self.get_barrier_text( - text, xCursor, yCursor - ) - except Exception as e: - return False, "" - - self.update_barrier_change(is_barrier) - if self.is_barrier_change(): - if output: - if is_barrier: - self.env["runtime"]["OutputManager"].play_sound_icon( - sound_icon="BarrierStart", interrupt=do_interrupt - ) - else: - self.env["runtime"]["OutputManager"].play_sound_icon( - sound_icon="BarrierEnd", interrupt=do_interrupt - ) - - if not is_barrier: - say_line = "" - return is_barrier, say_line - - def get_barrier_text(self, text, xCursor, yCursor): - line = text[yCursor] - if not self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "barrier", "enabled" - ): - return False, line - offset = xCursor - - left_barriers = self.env["runtime"]["SettingsManager"].get_setting( - "barrier", "left_barriers" - ) - right_barriers = self.env["runtime"]["SettingsManager"].get_setting( - "barrier", "right_barriers" - ) - # is the cursor at the begin or end of an entry: - # print(line[:offset + 1].count('│'),line[offset:].count('│')) - # start - - for b in left_barriers: - if line[: offset + 1].count(b) > line[offset:].count(b): - offset = xCursor - 1 - start = line[:offset].rfind(b) - if start != -1: - start += 1 - break - if start == -1: - return False, line - # end - for b in right_barriers: - end = line[start:].find(b) - if end != -1: - end = start + end - break - if end == -1: - return False, line - if start == end: - return False, line - - return True, line[start:end] diff --git a/src/fenrirscreenreader/core/clipboardSyncManager.py b/src/fenrirscreenreader/core/clipboardSyncManager.py deleted file mode 100644 index 5a20fda7..00000000 --- a/src/fenrirscreenreader/core/clipboardSyncManager.py +++ /dev/null @@ -1,310 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import os -import threading - -from fenrirscreenreader.core import debug -from fenrirscreenreader.core.eventData import FenrirEventType -from fenrirscreenreader.utils import x_clipboard - - -def synchronize_clipboards( - display, fenrir_text, last_observed_fenrir, last_observed_x -): - """Perform one clipboard I/O cycle without accessing Fenrir managers.""" - result = {} - try: - x_text = x_clipboard.read_text(display) - if not isinstance(x_text, str) or not x_text: - x_text = None - except Exception as error: - return {"error": "ClipboardSyncManager paste failed: " + str(error)} - - if fenrir_text and x_text and fenrir_text == x_text: - return { - "last_observed_fenrir": fenrir_text, - "last_observed_x": x_text, - } - - fenrir_changed = ( - fenrir_text and fenrir_text != last_observed_fenrir - ) - x_changed = x_text and x_text != last_observed_x - - if fenrir_changed: - try: - written = x_clipboard.write_text(fenrir_text, display) - except Exception as error: - written = False - result["error"] = ( - "ClipboardSyncManager copy failed: " + str(error) - ) - if written: - result["last_observed_fenrir"] = fenrir_text - result["last_written_to_x"] = fenrir_text - result["last_observed_x"] = fenrir_text - return result - - if x_changed: - return { - "import_text": x_text, - "last_imported_from_x": x_text, - "last_observed_fenrir": x_text, - "last_observed_x": x_text, - } - - if fenrir_text: - result["last_observed_fenrir"] = fenrir_text - if x_text: - result["last_observed_x"] = x_text - return result - - -class ClipboardSyncManager: - def __init__(self): - self.env = None - self.enabled = False - self.display = "" - self.interval = 0.5 - self.running = False - self._task_id = None - self._transfer_revision = 0 - self._scheduler_stop = threading.Event() - self._sync_event_pending = threading.Event() - self._scheduler_thread = None - self.last_written_to_x = None - self.last_imported_from_x = None - self.last_observed_fenrir = None - self.last_observed_x = None - - def initialize(self, environment): - self.env = environment - self.enabled, self.display = self._resolve_startup() - self.interval = max( - 0.1, - self.env["runtime"]["SettingsManager"].get_setting_as_float( - "clipboard", "sync_interval" - ), - ) - if self.enabled: - self._debug( - "Clipboard sync enabled for display " - + self.display - + " at interval " - + str(self.interval) - ) - self.start() - else: - self._debug("Clipboard sync disabled at startup") - - def shutdown(self): - self.stop() - - def mark_written_to_x(self, text): - if not isinstance(text, str) or not text: - return - self._transfer_revision += 1 - self.last_written_to_x = text - self.last_observed_fenrir = text - self.last_observed_x = text - - def mark_imported_from_x(self, text): - if not isinstance(text, str) or not text: - return - self._transfer_revision += 1 - self.last_imported_from_x = text - self.last_observed_fenrir = text - self.last_observed_x = text - - def _resolve_startup(self): - settings_manager = self.env["runtime"]["SettingsManager"] - if not settings_manager.get_setting_as_bool( - "clipboard", "sync_enabled" - ): - return False, "" - - configured_display = settings_manager.get_setting( - "clipboard", "sync_display" - ) - screen_driver = settings_manager.get_setting("screen", "driver") - keyboard_driver = settings_manager.get_setting("keyboard", "driver") - is_x_mode = ( - screen_driver == "ptyDriver" and keyboard_driver == "x11Driver" - ) - - if configured_display: - return True, configured_display - if is_x_mode: - current_display = os.environ.get("DISPLAY", "") - return bool(current_display), current_display - return False, "" - - def start(self): - if self.running: - return - self.running = True - self._scheduler_stop.clear() - self._scheduler_thread = threading.Thread( - target=self._schedule_sync_events, - name="fenrir-clipboard-scheduler", - daemon=True, - ) - self._scheduler_thread.start() - - def stop(self): - self.running = False - task_manager = self.env["runtime"].get("BackgroundTaskManager") - if task_manager and self._task_id is not None: - task_manager.cancel_task(self._task_id) - self._task_id = None - self._scheduler_stop.set() - self._sync_event_pending.clear() - if self._scheduler_thread: - self._scheduler_thread.join(timeout=1.0) - self._scheduler_thread = None - - def _schedule_sync_events(self): - while self.running: - if not self._sync_event_pending.is_set(): - self._sync_event_pending.set() - self.env["runtime"]["EventManager"].put_to_event_queue( - FenrirEventType.clipboard_sync, None - ) - if self._scheduler_stop.wait(self.interval): - return - - def handle_sync_event(self): - """Schedule clipboard I/O while keeping manager state on the loop.""" - if not self.running or self._task_id is not None: - return - - fenrir_text = self._get_fenrir_clipboard_text() - transfer_revision = self._transfer_revision - self._task_id = self.env["runtime"][ - "BackgroundTaskManager" - ].submit_task( - synchronize_clipboards, - lambda result: self._handle_poll_result( - fenrir_text, transfer_revision, result - ), - self.display, - fenrir_text, - self.last_observed_fenrir, - self.last_observed_x, - ) - if self._task_id is None: - self._sync_event_pending.clear() - - def _handle_poll_result( - self, fenrir_snapshot, transfer_revision, task_result - ): - self._task_id = None - self._sync_event_pending.clear() - if not self.running: - return - if not task_result.get("succeeded"): - self._debug( - "ClipboardSyncManager poll failed: " - + task_result.get("error", "unknown error") - ) - return - - result = task_result.get("value") or {} - if result.get("error"): - self._debug(result["error"]) - if ( - self._get_fenrir_clipboard_text() != fenrir_snapshot - or transfer_revision != self._transfer_revision - ): - return - import_text = result.get("import_text") - if import_text: - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", import_text - ) - for attribute in ( - "last_written_to_x", - "last_imported_from_x", - "last_observed_fenrir", - "last_observed_x", - ): - if attribute in result: - setattr(self, attribute, result[attribute]) - - def poll_once(self): - fenrir_text = self._get_fenrir_clipboard_text() - x_text = self._get_x_clipboard_text() - - if fenrir_text and x_text and fenrir_text == x_text: - self.last_observed_fenrir = fenrir_text - self.last_observed_x = x_text - return - - fenrir_changed = ( - fenrir_text - and fenrir_text != self.last_observed_fenrir - ) - x_changed = ( - x_text - and x_text != self.last_observed_x - ) - - if fenrir_changed: - if self._write_x_clipboard_text(fenrir_text): - self.last_written_to_x = fenrir_text - self.last_observed_x = fenrir_text - self.last_observed_fenrir = fenrir_text - return - - if x_changed: - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", x_text - ) - self.last_imported_from_x = x_text - self.last_observed_fenrir = x_text - self.last_observed_x = x_text - return - - if fenrir_text: - self.last_observed_fenrir = fenrir_text - if x_text: - self.last_observed_x = x_text - - def _get_fenrir_clipboard_text(self): - memory_manager = self.env["runtime"]["MemoryManager"] - if memory_manager.is_index_list_empty("clipboardHistory"): - return None - text = memory_manager.get_index_list_element("clipboardHistory") - if isinstance(text, str) and text: - return text - return None - - def _get_x_clipboard_text(self): - try: - text = x_clipboard.read_text(self.display) - if isinstance(text, str) and text: - return text - return None - except Exception as error: - self.env["runtime"]["DebugManager"].write_debug_out( - "ClipboardSyncManager paste failed: " + str(error), - debug.DebugLevel.INFO, - ) - return None - - def _write_x_clipboard_text(self, text): - try: - return x_clipboard.write_text(text, self.display) - except Exception as error: - self._debug("ClipboardSyncManager copy failed: " + str(error)) - return False - - def _debug(self, message): - self.env["runtime"]["DebugManager"].write_debug_out( - message, - debug.DebugLevel.INFO, - ) diff --git a/src/fenrirscreenreader/core/commandData.py b/src/fenrirscreenreader/core/commandData.py deleted file mode 100644 index 50bd32e9..00000000 --- a/src/fenrirscreenreader/core/commandData.py +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import time - -from fenrirscreenreader.core import debug - -# used as shared memory between commands -# use this in your own commands -command_buffer = { - "enableSpeechOnKeypress": False, - "Marks": {"1": None, "2": None}, - "bookMarks": {}, - "windowArea": {}, -} - -# used by the command_manager -command_info = { - # 'curr_command': '', - "lastCommandExecutionTime": time.time(), - "lastCommandRequestTime": time.time(), - "lastCommand": "", - "lastCommandSection": "", - "lastCommandRunTime": 0, -} diff --git a/src/fenrirscreenreader/core/commandManager.py b/src/fenrirscreenreader/core/commandManager.py deleted file mode 100644 index 1b80483b..00000000 --- a/src/fenrirscreenreader/core/commandManager.py +++ /dev/null @@ -1,562 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import glob -import inspect -import os -import time - -from fenrirscreenreader.core import debug -from fenrirscreenreader.utils import module_utils - -currentdir = os.path.dirname( - os.path.realpath(os.path.abspath(inspect.getfile(inspect.currentframe()))) -) -fenrir_path = os.path.dirname(currentdir) - - -class CommandManager: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - # commands - self.env["commands"] = {} - self.env["commandsIgnore"] = {} - for command_folder in self.env["general"]["commandFolderList"]: - self.env["runtime"]["CommandManager"].load_commands(command_folder) - if ( - self.env["runtime"]["SettingsManager"].get_setting( - "general", "command_path" - ) - != "" - ): - self.env["runtime"]["CommandManager"].load_commands( - command_folder, - self.env["runtime"]["SettingsManager"].get_setting( - "general", "command_path" - ), - ) - - # scripts for scriptKey - self.env["runtime"]["CommandManager"].load_script_commands() - - def shutdown(self): - for command_folder in self.env["general"]["commandFolderList"]: - self.env["runtime"]["CommandManager"].shutdown_commands( - command_folder - ) - - def load_file(self, filepath=""): - if filepath == "": - return None - if not os.path.exists(filepath): - self.env["runtime"]["DebugManager"].write_debug_out( - "load_file: filepath not exists:" + filepath, - debug.DebugLevel.WARNING, - ) - return None - if os.path.isdir(filepath): - self.env["runtime"]["DebugManager"].write_debug_out( - "load_file: filepath is a directory:" + filepath, - debug.DebugLevel.ERROR, - ) - return None - if not os.access(filepath, os.R_OK): - self.env["runtime"]["DebugManager"].write_debug_out( - "load_file: filepath not readable:" + filepath, - debug.DebugLevel.ERROR, - ) - return None - - try: - file_name, file_extension = os.path.splitext(filepath) - file_name = file_name.split("/")[-1] - if file_name.startswith("__"): - return None - if file_extension.lower() == ".py": - command_mod = module_utils.import_module(file_name, filepath) - command = command_mod.command() - command.initialize(self.env) - self.env["runtime"]["DebugManager"].write_debug_out( - "load_file: Load command:" + filepath, - debug.DebugLevel.INFO, - on_any_level=True, - ) - return command - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "load_file: Loading command:" + filepath, - debug.DebugLevel.ERROR, - ) - self.env["runtime"]["DebugManager"].write_debug_out( - str(e), debug.DebugLevel.ERROR - ) - return None - - def load_commands(self, section="commands", command_path=""): - if command_path == "": - command_path = fenrir_path + "/commands/" - if not command_path.endswith("/"): - command_path += "/" - command_folder = command_path + section + "/" - if not os.path.exists(command_folder): - self.env["runtime"]["DebugManager"].write_debug_out( - "load_commands: command_folder not exists:" + command_folder, - debug.DebugLevel.WARNING, - ) - return - if not os.path.isdir(command_folder): - self.env["runtime"]["DebugManager"].write_debug_out( - "load_commands: command_folder not a directory:" - + command_folder, - debug.DebugLevel.ERROR, - ) - return - if not os.access(command_folder, os.R_OK): - self.env["runtime"]["DebugManager"].write_debug_out( - "load_commands: command_folder not readable:" + command_folder, - debug.DebugLevel.ERROR, - ) - return - self.env["commands"][section] = {} - self.env["commandsIgnore"][section] = {} - command_list = glob.glob(command_folder + "*") - for command in command_list: - try: - file_name, file_extension = os.path.splitext(command) - file_name = file_name.split("/")[-1] - if file_name.startswith("__"): - continue - # Skip base classes that shouldn't be loaded as commands - if file_name.endswith("_base"): - continue - # Check if command already exists to prevent duplicate loading - if ( - file_name.upper() in self.env["commands"][section] - and self.env["commands"][section][file_name.upper()] - is not None - ): - continue - if file_extension.lower() == ".py": - command_mod = module_utils.import_module( - file_name, command - ) - self.env["commands"][section][ - file_name.upper() - ] = command_mod.command() - self.env["commandsIgnore"][section][ - file_name.upper()[file_name.upper().find("-") + 1 :] - + "_IGNORE" - ] = False - self.env["commands"][section][ - file_name.upper() - ].initialize(self.env) - self.env["runtime"]["DebugManager"].write_debug_out( - "load_commands: Load command:" - + section - + "." - + file_name.upper(), - debug.DebugLevel.INFO, - on_any_level=True, - ) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "load_commands: Loading command:" + command, - debug.DebugLevel.ERROR, - ) - self.env["runtime"]["DebugManager"].write_debug_out( - str(e), debug.DebugLevel.ERROR - ) - continue - - def get_script_paths(self, script_path=""): - if script_path: - candidate_paths = [script_path] - else: - settings_manager = self.env["runtime"]["SettingsManager"] - candidate_paths = [ - settings_manager.get_user_script_path(), - settings_manager.get_setting("general", "script_path"), - os.path.join(fenrir_path, "../../config/scripts/"), - ] - - script_paths = [] - seen_paths = set() - for path in candidate_paths: - if not path: - continue - normalized_path = os.path.abspath(os.path.expanduser(path)) - if normalized_path in seen_paths: - continue - script_paths.append(normalized_path) - seen_paths.add(normalized_path) - return script_paths - - def load_script_commands(self, section="commands", script_path=""): - script_paths = self.get_script_paths(script_path) - loaded_any_path = False - loaded_script_names = set() - for path in script_paths: - loaded_any_path = ( - self.load_script_commands_from_path( - section, path, loaded_script_names - ) - or loaded_any_path - ) - if not loaded_any_path: - self.env["runtime"]["DebugManager"].write_debug_out( - "No script paths available:" + str(script_paths), - debug.DebugLevel.WARNING, - ) - - def load_script_commands_from_path( - self, section, script_path, loaded_script_names=None - ): - if loaded_script_names is None: - loaded_script_names = set() - if not os.path.exists(script_path): - return False - if not os.path.isdir(script_path): - self.env["runtime"]["DebugManager"].write_debug_out( - "scriptpath not a directory:" + script_path, - debug.DebugLevel.ERROR, - ) - return False - if not os.access(script_path, os.R_OK): - self.env["runtime"]["DebugManager"].write_debug_out( - "scriptpath not readable:" + script_path, - debug.DebugLevel.ERROR, - ) - return False - command_list = sorted(glob.glob(os.path.join(script_path, "*"))) - sub_command = fenrir_path + "/commands/commands/subprocess.py" - path_script_names = set() - for command in command_list: - try: - if not os.path.isfile(command): - continue - file_name, file_extension = os.path.splitext(command) - file_name = file_name.split("/")[-1] - if file_name.startswith("__"): - continue - command_name = file_name.upper() - script_name = self.get_script_name(file_name) - if script_name in loaded_script_names: - self.env["runtime"]["DebugManager"].write_debug_out( - "Skip script with duplicate script name:" - + command_name, - debug.DebugLevel.INFO, - ) - continue - if command_name in self.env["commands"][section]: - self.env["runtime"]["DebugManager"].write_debug_out( - "Skip script with duplicate command name:" - + command_name, - debug.DebugLevel.INFO, - ) - continue - shortcut = self.get_script_shortcut(file_name) - if not shortcut: - continue - shortcut_key = str(shortcut) - if shortcut_key in self.env["bindings"]: - self.env["runtime"]["DebugManager"].write_debug_out( - "Skip script with duplicate shortcut:" - + command_name - + " " - + shortcut_key, - debug.DebugLevel.INFO, - ) - continue - command_mod = module_utils.import_module( - file_name, sub_command - ) - self.env["commands"][section][command_name] = ( - command_mod.command() - ) - self.env["commands"][section][command_name].initialize( - self.env, command - ) - self.env["bindings"][shortcut_key] = command_name - self.env["rawBindings"][shortcut_key] = shortcut - path_script_names.add(script_name) - self.env["runtime"]["DebugManager"].write_debug_out( - "Load script:" + section + "." + command_name, - debug.DebugLevel.INFO, - on_any_level=True, - ) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "Loading script:" + file_name, debug.DebugLevel.ERROR - ) - self.env["runtime"]["DebugManager"].write_debug_out( - str(e), debug.DebugLevel.ERROR - ) - continue - loaded_script_names.update(path_script_names) - return True - - def get_script_name(self, file_name): - return file_name.upper().split("__-__", 1)[0] - - def get_script_shortcut(self, file_name): - comm_settings = file_name.upper().split("__-__") - if len(comm_settings) == 1: - keys = comm_settings[0] - elif len(comm_settings) == 2: - keys = comm_settings[1] - else: - return None - - shortcut_keys = [] - for key in keys.split("__+__"): - key = key.upper() - if not key: - return None - if not self.env["runtime"]["InputManager"].is_valid_key(key): - self.env["runtime"]["DebugManager"].write_debug_out( - "invalid key : " + key + " script:" + file_name, - debug.DebugLevel.WARNING, - ) - return None - shortcut_keys.append(key) - if "KEY_SCRIPT" not in shortcut_keys: - shortcut_keys.append("KEY_SCRIPT") - return [1, sorted(shortcut_keys)] - - def shutdown_commands(self, section): - # Check if the section exists in the commands dictionary - if section not in self.env["commands"]: - self.env["runtime"]["DebugManager"].write_debug_out( - "shutdown_commands: section not found:" + section, - debug.DebugLevel.WARNING, - ) - return - - for command in sorted(self.env["commands"][section]): - try: - self.env["commands"][section][command].shutdown() - del self.env["commands"][section][command] - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "Shutdown command:" + section + "." + command, - debug.DebugLevel.ERROR, - ) - self.env["runtime"]["DebugManager"].write_debug_out( - str(e), debug.DebugLevel.ERROR - ) - continue - - def execute_switch_trigger(self, trigger, unLoadScript, loadScript): - if self.env["runtime"]["ScreenManager"].is_ignored_screen(): - return - # unload - old_script = unLoadScript - if self.command_exists(old_script, trigger): - try: - self.env["runtime"]["DebugManager"].write_debug_out( - "Executing switchtrigger.unload:" - + trigger - + "." - + old_script, - debug.DebugLevel.INFO, - ) - self.env["commands"][trigger][old_script].unload() - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "Executing trigger:" + trigger + "." + old_script, - debug.DebugLevel.ERROR, - ) - self.env["runtime"]["DebugManager"].write_debug_out( - str(e), debug.DebugLevel.ERROR - ) - # load - new_script = loadScript - if self.command_exists(new_script, trigger): - try: - self.env["runtime"]["DebugManager"].write_debug_out( - "Executing switchtrigger.load:" - + trigger - + "." - + new_script, - debug.DebugLevel.INFO, - ) - self.env["commands"][trigger][new_script].load() - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "Executing trigger:" + trigger + "." + new_script, - debug.DebugLevel.ERROR, - ) - self.env["runtime"]["DebugManager"].write_debug_out( - str(e), debug.DebugLevel.ERROR - ) - - def execute_default_trigger(self, trigger, force=False): - if not force: - if self.env["runtime"]["ScreenManager"].is_ignored_screen(): - return - for command in sorted(self.env["commands"][trigger]): - if self.command_exists(command, trigger): - try: - if self.env["commandsIgnore"][trigger][ - command[command.find("-") + 1 :] + "_IGNORE" - ]: - self.env["commandsIgnore"][trigger][ - command[command.find("-") + 1 :] + "_IGNORE" - ] = False - self.env["runtime"]["DebugManager"].write_debug_out( - "ignore trigger.command:" - + trigger - + "." - + command, - debug.DebugLevel.INFO, - ) - else: - self.env["runtime"]["DebugManager"].write_debug_out( - "Executing trigger.command:" - + trigger - + "." - + command, - debug.DebugLevel.INFO, - ) - self.env["commands"][trigger][command].run() - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "Executing trigger:" - + trigger - + "." - + command - + str(e), - debug.DebugLevel.ERROR, - ) - - def execute_command(self, command, section="commands"): - if ( - self.env["runtime"]["ScreenManager"].is_ignored_screen() - and not self.env["runtime"]["HelpManager"].is_tutorial_mode() - ): - return - if self.command_exists(command, section): - try: - if ( - self.env["runtime"]["HelpManager"].is_tutorial_mode() - and section != "help" - ): - self.env["runtime"]["DebugManager"].write_debug_out( - "Tutorial for command:" + section + "." + command, - debug.DebugLevel.INFO, - ) - description = self.get_command_description( - command, section - ) - self.env["runtime"]["OutputManager"].present_text( - description, interrupt=False - ) - else: - self.env["runtime"]["DebugManager"].write_debug_out( - "Executing command:" + section + "." + command, - debug.DebugLevel.INFO, - ) - self.run_command(command, section) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "Executing command:" - + section - + "." - + command - + " " - + str(e), - debug.DebugLevel.ERROR, - ) - - def run_command(self, command, section="commands"): - if self.command_exists(command, section): - try: - command_time = time.time() - self.env["commandInfo"]["lastCommand"] = command - self.env["commandInfo"]["lastCommandSection"] = section - self.env["commandInfo"]["lastCommandRunTime"] = command_time - self.env["runtime"]["DebugManager"].write_debug_out( - "run_command command:" + section + "." + command, - debug.DebugLevel.INFO, - ) - self.env["commands"][section][command].run() - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "run_command command:" - + section - + "." - + command - + " " - + str(e), - debug.DebugLevel.ERROR, - ) - self.env["commandInfo"]["lastCommandExecutionTime"] = time.time() - - def get_command_description(self, command, section="commands"): - if self.command_exists(command, section): - try: - return self.env["commands"][section][command].get_description() - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "command_manager.get_command_description:" + str(e), - debug.DebugLevel.ERROR, - ) - self.env["commandInfo"]["lastCommandExecutionTime"] = time.time() - - def command_exists(self, command, section="commands"): - try: - return command in self.env["commands"][section] - except Exception as e: - return False - - def get_shortcut_for_command(self, command, formatKeys=False): - shortcut = [] - try: - raw_shortcut = list(self.env["bindings"].keys())[ - list(self.env["bindings"].values()).index(command) - ] - # prefer numbers for multitap - for k in ["2", "3", "4", "5", "6", "7", "8"]: - if k in raw_shortcut: - formatted_key = k - if formatKeys: - formatted_key = formatted_key.lower() - formatted_key += " times " - shortcut.append(formatted_key) - raw_shortcut.remove(k) - # prefer metha keys - for k in [ - "KEY_FENRIR", - "KEY_SCRIPT", - "KEY_CTRL", - "KEY_SHIFT", - "KEY_ALT", - "KEY_META", - ]: - if k in raw_shortcut: - formatted_key = k - if formatKeys: - formatted_key = formatted_key.lower() - formatted_key = formatted_key.replace( - "key_kp", " keypad " - ) - formatted_key = formatted_key.replace("key_", " ") - shortcut.append(formatted_key) - raw_shortcut.remove(k) - # handle other keys - for k in raw_shortcut: - formatted_key = k - if formatKeys: - formatted_key = formatted_key.lower() - formatted_key = formatted_key.replace("key_kp", " keypad ") - formatted_key = formatted_key.replace("key_", " ") - shortcut.append(formatted_key) - except Exception as e: - pass - return shortcut diff --git a/src/fenrirscreenreader/core/cursorManager.py b/src/fenrirscreenreader/core/cursorManager.py deleted file mode 100644 index 0ca329fa..00000000 --- a/src/fenrirscreenreader/core/cursorManager.py +++ /dev/null @@ -1,204 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core import debug - - -class CursorManager: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def should_process_numpad_commands(self): - """ - Check if numpad commands should be processed based on numlock state - Return True if numlock is OFF (commands should work) - Return False if numlock is ON (let keys type numbers) - """ - # Return False if numlock is ON - return not self.env["input"]["new_num_lock"] - - def shutdown(self): - pass - - def clear_marks(self): - self.env["commandBuffer"]["Marks"]["1"] = None - self.env["commandBuffer"]["Marks"]["2"] = None - - def is_mark_set(self): - return self.env["commandBuffer"]["Marks"]["1"] is not None - - def is_single_mark(self): - return ( - self.env["commandBuffer"]["Marks"]["1"] is not None - and self.env["commandBuffer"]["Marks"]["2"] is None - ) - - def is_multible_mark(self): - return ( - self.env["commandBuffer"]["Marks"]["1"] is not None - and self.env["commandBuffer"]["Marks"]["2"] is not None - ) - - def set_mark(self): - curr_cursor = None - if self.env["screen"]["newCursorReview"]: - curr_cursor = self.env["screen"]["newCursorReview"].copy() - else: - curr_cursor = self.env["screen"]["new_cursor"].copy() - if not self.env["commandBuffer"]["Marks"]["1"]: - self.env["commandBuffer"]["Marks"]["1"] = curr_cursor.copy() - return 1 - else: - self.env["commandBuffer"]["Marks"]["2"] = curr_cursor.copy() - return 2 - return 0 - - def get_review_or_text_cursor(self): - if self.env["screen"]["newCursorReview"]: - return self.env["screen"]["newCursorReview"].copy() - else: - return self.env["screen"]["new_cursor"].copy() - - def clear_review_cursor(self): - if not self.is_review_mode(): - return - self.env["screen"]["oldCursorReview"] = None - self.env["screen"]["newCursorReview"] = None - - def is_cursor_horizontal_move(self): - return ( - self.env["screen"]["new_cursor"]["x"] - != self.env["screen"]["old_cursor"]["x"] - ) - - def is_cursor_vertical_move(self): - return ( - self.env["screen"]["new_cursor"]["y"] - != self.env["screen"]["old_cursor"]["y"] - ) - - def is_review_mode(self): - return self.env["screen"]["newCursorReview"] is not None - - def enter_review_mode_curr_text_cursor(self, overwrite=False): - if self.is_review_mode() and not overwrite: - return - self.env["screen"]["oldCursorReview"] = self.env["screen"][ - "newCursorReview" - ] - if not self.env["screen"]["newCursorReview"]: - self.env["screen"]["newCursorReview"] = self.env["screen"][ - "new_cursor" - ].copy() - if ( - self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "focus", "highlight" - ) - and self.env["runtime"][ - "AttributeManager" - ].is_attribute_cursor_active() - ): - self.env["screen"]["newCursorReview"] = ( - self.env["runtime"]["AttributeManager"] - .get_curr_attribute_cursor() - .copy() - ) - - def set_review_cursor_position(self, x, y): - if not self.is_review_mode(): - self.enter_review_mode_curr_text_cursor() - self.env["screen"]["oldCursorReview"] = self.env["screen"][ - "newCursorReview" - ] - self.env["screen"]["newCursorReview"]["x"] = x - self.env["screen"]["newCursorReview"]["y"] = y - - def is_application_window_set(self): - try: - curr_app = self.env["runtime"][ - "ApplicationManager" - ].get_current_application() - if ( - curr_app in self.env["commandBuffer"]["windowArea"] - and "1" in self.env["commandBuffer"]["windowArea"][curr_app] - and self.env["commandBuffer"]["windowArea"][curr_app]["1"] - is not None - ): - return True - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "CursorManager is_application_window_set: Error checking window area: " - + str(e), - debug.DebugLevel.ERROR, - ) - return False - - def set_window_for_application(self, start=None, end=None): - x1 = 0 - x2 = 0 - y1 = 0 - y2 = 0 - if start is None: - if not self.env["commandBuffer"]["Marks"]["1"]: - return False - else: - x1 = self.env["commandBuffer"]["Marks"]["1"]["x"] - y1 = self.env["commandBuffer"]["Marks"]["1"]["y"] - else: - x1 = start["x"] - y1 = start["y"] - if end is None: - if not self.env["commandBuffer"]["Marks"]["2"]: - return False - else: - x2 = self.env["commandBuffer"]["Marks"]["2"]["x"] - y2 = self.env["commandBuffer"]["Marks"]["2"]["y"] - else: - x2 = end["x"] - y2 = end["y"] - - curr_app = self.env["runtime"][ - "ApplicationManager" - ].get_current_application() - self.env["commandBuffer"]["windowArea"][curr_app] = {} - - if x1 * y1 <= x2 * y2: - self.env["commandBuffer"]["windowArea"][curr_app]["1"] = { - "x": x1, - "y": y1, - } - self.env["commandBuffer"]["windowArea"][curr_app]["2"] = { - "x": x2, - "y": y2, - } - else: - self.env["commandBuffer"]["windowArea"][curr_app]["1"] = { - "x": x2, - "y": y2, - } - self.env["commandBuffer"]["windowArea"][curr_app]["2"] = { - "x": x1, - "y": y1, - } - return True - - def clear_window_for_application(self): - curr_app = self.env["runtime"][ - "ApplicationManager" - ].get_current_application() - try: - del self.env["commandBuffer"]["windowArea"][curr_app] - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "CursorManager clear_window_for_application: Error clearing window area: " - + str(e), - debug.DebugLevel.ERROR, - ) - return False - return True diff --git a/src/fenrirscreenreader/core/debug.py b/src/fenrirscreenreader/core/debug.py deleted file mode 100644 index c9de68e0..00000000 --- a/src/fenrirscreenreader/core/debug.py +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env python3 - -from enum import Enum - - -class DebugLevel(Enum): - DEACTIVE = 0 - ERROR = 1 - WARNING = 2 - INFO = 3 - - def __int__(self): - return self.value - - def __str__(self): - return self.name diff --git a/src/fenrirscreenreader/core/debugManager.py b/src/fenrirscreenreader/core/debugManager.py deleted file mode 100644 index 124d27ce..00000000 --- a/src/fenrirscreenreader/core/debugManager.py +++ /dev/null @@ -1,162 +0,0 @@ -#!/usr/bin/env python3 -# Debugger module for the Fenrir screen reader. - -import os -import pathlib -import fcntl -from datetime import datetime - -from fenrirscreenreader.core import debug - - -class DebugManager: - DEFAULT_LOG_DIR = "/tmp" - DEFAULT_LOG_BASENAME = "fenrir" - DEFAULT_LOG_EXTENSION = ".log" - - def __init__(self, file_name=""): - self._file = None - self._fileOpened = False - self._fileName = file_name - self._useDefaultLogName = file_name == "" - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - self.close_debug_file() - - def __del__(self): - try: - self.shutdown() - except Exception as e: - # Cannot use debug manager to log its own shutdown errors - print(f"DebugManager shutdown: Error during shutdown: {e}") - - def open_debug_file(self, file_name=""): - self._fileOpened = False - if file_name != "": - self._fileName = file_name - self._useDefaultLogName = False - if self._useDefaultLogName: - self._open_default_debug_file() - return - if self._fileName != "": - directory = os.path.dirname(self._fileName) - if not os.path.exists(directory): - pathlib.Path(directory).mkdir(parents=True, exist_ok=True) - try: - self._file = open(self._fileName, "a") - # Set readable permissions for all users (644) - os.chmod(self._fileName, 0o644) - self._fileOpened = True - except Exception as e: - print(e) - - def _open_default_debug_file(self): - pathlib.Path(self.DEFAULT_LOG_DIR).mkdir(parents=True, exist_ok=True) - log_number = 1 - while True: - log_file = self._default_log_file_name(log_number) - try: - fd = os.open( - log_file, - os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, - 0o644, - ) - file_obj = os.fdopen(fd, "a") - fcntl.flock(file_obj.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) - file_obj.seek(0) - file_obj.truncate() - os.chmod(log_file, 0o644) - self._file = file_obj - self._fileName = log_file - self._fileOpened = True - return - except BlockingIOError: - try: - file_obj.close() - except Exception: - pass - log_number += 1 - except OSError as e: - print(e) - return - - def _default_log_file_name(self, log_number): - suffix = "" if log_number == 1 else str(log_number) - return os.path.join( - self.DEFAULT_LOG_DIR, - self.DEFAULT_LOG_BASENAME + suffix + self.DEFAULT_LOG_EXTENSION, - ) - - def write_debug_out( - self, text, level=debug.DebugLevel.DEACTIVE, on_any_level=False - ): - - mode = self.env["runtime"]["SettingsManager"].get_setting( - "general", "debug_mode" - ) - if mode == "": - mode = "FILE" - mode = mode.upper().split(",") - file_mode = "FILE" in mode - print_mode = "PRINT" in mode - - if ( - int( - self.env["runtime"]["SettingsManager"].get_setting_as_int( - "general", "debug_level" - ) - ) - < int(level) - ) and not ( - on_any_level - and int( - self.env["runtime"]["SettingsManager"].get_setting_as_int( - "general", "debug_level" - ) - ) - > int(debug.DebugLevel.DEACTIVE) - ): - if self._fileOpened: - self.close_debug_file() - return - else: - if not self._fileOpened and file_mode: - self.open_debug_file() - timestamp = str(datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S.%f")) - if on_any_level: - level_info = "INFO ANY" - else: - level_info = str(level) - - # Changed order: text comes first, then level and timestamp - msg = text + " - " + level_info + " " + timestamp - - if print_mode: - print(msg) - if file_mode: - try: - self._file.write(msg + "\n") - except Exception as e: - print(e) - - def close_debug_file(self): - if not self._fileOpened: - return False - if self._file is not None: - try: - self._file.close() - except Exception as e: - print(e) - self._fileOpened = False - return True - - def get_debug_file(self): - return self._fileName - - def set_debug_file(self, file_name): - self.close_debug_file() - self._fileName = file_name - self._useDefaultLogName = file_name == "" diff --git a/src/fenrirscreenreader/core/diffReviewManager.py b/src/fenrirscreenreader/core/diffReviewManager.py deleted file mode 100644 index 8f01eb94..00000000 --- a/src/fenrirscreenreader/core/diffReviewManager.py +++ /dev/null @@ -1,917 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import os -import re - -from fenrirscreenreader.core import debug -from fenrirscreenreader.core.i18n import _ - - -class DiffReviewManager: - def __init__(self): - self.env = None - self.active = False - self.loaded_path = "" - self.lines = [] - self.line_meta = [] - self.file_sections = [] - self.hunk_sections = [] - self.file_starts = [] - self.hunk_starts = [] - self.added_lines = [] - self.removed_lines = [] - self.current_line = -1 - self.bindings_backup = None - self.raw_bindings_backup = None - self.total_added = 0 - self.total_removed = 0 - self.current_char = 0 - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - if self.active: - self._restore_bindings() - self.active = False - - def is_active(self): - return self.active - - def has_loaded_diff(self): - return len(self.lines) > 0 - - def get_loaded_path(self): - return self.loaded_path - - def toggle_mode(self): - if self.active: - self.disable_mode() - self.present_mode_message( - _("Diff review mode disabled."), - ) - return False - - clipboard_path = self._get_clipboard_path_or_none() - if ( - clipboard_path - and ( - (not self.has_loaded_diff()) - or (self.loaded_path != clipboard_path) - ) - ): - if not self.load_file(clipboard_path): - return False - elif not self.has_loaded_diff(): - self.present_error( - _("Clipboard does not contain full path to diff file.") - ) - return False - - enabled = self.enable_mode() - if enabled: - self.present_mode_message( - _("Diff review mode enabled."), - ) - return enabled - - def enable_mode(self): - if self.active: - return True - if not self.has_loaded_diff(): - return False - self._install_diff_bindings() - self.active = True - return True - - def disable_mode(self): - if not self.active: - return - self._restore_bindings() - self.active = False - - def load_from_clipboard(self): - clipboard_path = self._get_clipboard_path_or_none() - if not clipboard_path: - self.present_error( - _("Clipboard does not contain full path to diff file.") - ) - return False - - return self.load_file(clipboard_path) - - def reload_file(self): - if not self.loaded_path: - self.present_error(_("No diff file loaded.")) - return False - return self.load_file(self.loaded_path) - - def load_file(self, file_path): - normalized_path = os.path.abspath(os.path.expanduser(file_path)) - if not os.path.exists(normalized_path): - self.present_error( - _("Diff file not found: ") + normalized_path - ) - return False - if os.path.isdir(normalized_path): - self.present_error( - _("Diff path is a directory: ") + normalized_path - ) - return False - if not os.access(normalized_path, os.R_OK): - self.present_error( - _("Cannot read diff file: ") + normalized_path - ) - return False - - try: - with open(normalized_path, "r", encoding="utf-8", errors="replace") as diff_file: - file_lines = diff_file.read().splitlines() - except Exception as load_error: - self._write_debug( - "DiffReviewManager load_file: " + str(load_error), - debug.DebugLevel.ERROR, - ) - self.present_error( - _("Failed to load diff file: ") + normalized_path - ) - return False - - self.present_mode_message( - _("Loading ") + normalized_path + ".", - ) - self._parse_lines(file_lines, normalized_path) - self.present_mode_message( - _("Diff loaded. ") - + str(len(self.file_sections)) - + _(" files changed. ") - + str(self.total_added) - + _(" additions. ") - + str(self.total_removed) - + _(" removals."), - ) - return True - - def _parse_lines(self, file_lines, loaded_path): - self.lines = file_lines - self.loaded_path = loaded_path - self.line_meta = [] - self.file_sections = [] - self.hunk_sections = [] - self.file_starts = [] - self.hunk_starts = [] - self.added_lines = [] - self.removed_lines = [] - self.current_line = -1 - self.total_added = 0 - self.total_removed = 0 - self.current_char = 0 - - has_git_header = any( - line.startswith("diff --git ") for line in self.lines - ) - - current_file_index = -1 - current_hunk_index = -1 - for line_index, line in enumerate(self.lines): - role = self._classify_line(line) - - if self._starts_new_file_section( - line=line, - has_git_header=has_git_header, - ): - current_file_index = self._add_file_section(line_index) - current_hunk_index = -1 - - if ( - current_file_index == -1 - and role - in { - "hunk_header", - "added_line", - "removed_line", - "context_line", - "note_line", - } - ): - current_file_index = self._add_file_section(line_index) - - if role == "hunk_header": - if current_file_index == -1: - current_file_index = self._add_file_section(line_index) - current_hunk_index = self._add_hunk_section( - line_index=line_index, - file_index=current_file_index, - ) - - if role in {"added_line", "removed_line"} and current_hunk_index == -1: - if current_file_index == -1: - current_file_index = self._add_file_section(line_index) - current_hunk_index = self._add_hunk_section( - line_index=line_index, - file_index=current_file_index, - ) - - if role == "added_line": - self.added_lines.append(line_index) - self.total_added += 1 - if current_file_index != -1: - self.file_sections[current_file_index]["added_count"] += 1 - if current_hunk_index != -1: - self.hunk_sections[current_hunk_index]["added_count"] += 1 - elif role == "removed_line": - self.removed_lines.append(line_index) - self.total_removed += 1 - if current_file_index != -1: - self.file_sections[current_file_index]["removed_count"] += 1 - if current_hunk_index != -1: - self.hunk_sections[current_hunk_index]["removed_count"] += 1 - - self.line_meta.append( - { - "role": role, - "file_index": current_file_index, - "hunk_index": current_hunk_index, - } - ) - - for file_index, file_section in enumerate(self.file_sections): - file_hunks = file_section["hunk_indices"] - for local_position, hunk_index in enumerate(file_hunks, start=1): - self.hunk_sections[hunk_index]["position_in_file"] = local_position - self.hunk_sections[hunk_index]["hunks_in_file"] = len(file_hunks) - - def _classify_line(self, line): - if line.startswith("\\ No newline at end of file"): - return "note_line" - if self._is_file_header(line): - return "file_header" - if line.startswith("@@"): - return "hunk_header" - if line.startswith("+") and not line.startswith("+++ "): - return "added_line" - if line.startswith("-") and not line.startswith("--- "): - return "removed_line" - return "context_line" - - def _get_clipboard_path_or_none(self): - if self.env["runtime"]["MemoryManager"].is_index_list_empty( - "clipboardHistory" - ): - return None - - clipboard_value = self.env["runtime"]["MemoryManager"].get_index_list_element( - "clipboardHistory" - ) - if not isinstance(clipboard_value, str): - return None - - clipboard_path = clipboard_value.strip().strip("\"'") - clipboard_path = os.path.expanduser(clipboard_path) - if not os.path.isabs(clipboard_path): - return None - return os.path.abspath(clipboard_path) - - def _is_file_header(self, line): - file_header_prefixes = ( - "diff --git ", - "--- ", - "+++ ", - "index ", - "old mode ", - "new mode ", - "deleted file mode ", - "new file mode ", - "rename from ", - "rename to ", - "similarity index ", - "dissimilarity index ", - "Binary files ", - ) - return line.startswith(file_header_prefixes) - - def _starts_new_file_section(self, line, has_git_header): - if has_git_header: - return line.startswith("diff --git ") - return line.startswith("--- ") - - def _add_file_section(self, line_index): - file_section = { - "start_line": line_index, - "added_count": 0, - "removed_count": 0, - "hunk_indices": [], - } - self.file_sections.append(file_section) - self.file_starts.append(line_index) - return len(self.file_sections) - 1 - - def _add_hunk_section(self, line_index, file_index): - hunk_section = { - "start_line": line_index, - "file_index": file_index, - "added_count": 0, - "removed_count": 0, - "position_in_file": 0, - "hunks_in_file": 0, - } - self.hunk_sections.append(hunk_section) - self.hunk_starts.append(line_index) - hunk_index = len(self.hunk_sections) - 1 - if file_index != -1: - self.file_sections[file_index]["hunk_indices"].append(hunk_index) - return hunk_index - - def next_file_section(self): - self._jump_to_anchor( - anchors=self.file_starts, - previous=False, - empty_message=_("No file sections in loaded diff."), - edge_message=_("No further file sections."), - announce_role="file", - ) - - def prev_file_section(self): - self._jump_to_anchor( - anchors=self.file_starts, - previous=True, - empty_message=_("No file sections in loaded diff."), - edge_message=_("No previous file sections."), - announce_role="file", - ) - - def next_hunk(self): - self._jump_to_anchor( - anchors=self.hunk_starts, - previous=False, - empty_message=_("No hunks in loaded diff."), - edge_message=_("No further hunks."), - announce_role="hunk", - ) - - def prev_hunk(self): - self._jump_to_anchor( - anchors=self.hunk_starts, - previous=True, - empty_message=_("No hunks in loaded diff."), - edge_message=_("No previous hunks."), - announce_role="hunk", - ) - - def next_added(self): - self._jump_to_anchor( - anchors=self.added_lines, - previous=False, - empty_message=_("No added lines in loaded diff."), - edge_message=_("No further added lines."), - announce_role="added", - ) - - def prev_added(self): - self._jump_to_anchor( - anchors=self.added_lines, - previous=True, - empty_message=_("No added lines in loaded diff."), - edge_message=_("No previous added lines."), - announce_role="added", - ) - - def next_removed(self): - self._jump_to_anchor( - anchors=self.removed_lines, - previous=False, - empty_message=_("No removed lines in loaded diff."), - edge_message=_("No further removed lines."), - announce_role="removed", - ) - - def prev_removed(self): - self._jump_to_anchor( - anchors=self.removed_lines, - previous=True, - empty_message=_("No removed lines in loaded diff."), - edge_message=_("No previous removed lines."), - announce_role="removed", - ) - - def present_summary(self): - if not self.has_loaded_diff(): - self.present_mode_message(_("No diff file loaded.")) - return - - summary = ( - _("Diff summary. Files changed: ") - + str(len(self.file_sections)) - + _(". Additions: ") - + str(self.total_added) - + _(". Removals: ") - + str(self.total_removed) - + "." - ) - - if self.current_line >= 0 and self.current_line < len(self.line_meta): - location = self._build_location_text(self.current_line) - if location: - summary += " " + location - - self.present_mode_message(summary) - - def present_key_help(self): - help_text = _( - "Diff keys: h next hunk, shift h previous hunk, a next addition, " - "shift a previous addition, d next removal, shift d previous removal, " - "f next file section, shift f previous file section, up previous line, " - "down next line, left previous character, right next character, " - "control left previous word, control right next word, s summary, " - "escape exit diff mode." - ) - self.present_mode_message(help_text) - - def next_line(self): - if not self.has_loaded_diff(): - self.present_mode_message(_("No diff file loaded.")) - return - if self.current_line < 0: - self.current_line = 0 - self.current_char = 0 - self._announce_current_line() - return - if self.current_line >= len(self.lines) - 1: - self.present_mode_message(_("End of diff.")) - return - self.current_line += 1 - self.current_char = 0 - self._announce_current_line() - - def prev_line(self): - if not self.has_loaded_diff(): - self.present_mode_message(_("No diff file loaded.")) - return - if self.current_line < 0: - self.current_line = 0 - self.current_char = 0 - self._announce_current_line() - return - if self.current_line <= 0: - self.present_mode_message(_("Start of diff.")) - return - self.current_line -= 1 - self.current_char = 0 - self._announce_current_line() - - def next_char(self): - if not self._ensure_current_line(): - return - line_text = self.lines[self.current_line] - if len(line_text) == 0: - self.present_mode_message(_("blank")) - return - if self.current_char >= len(line_text) - 1: - self.present_mode_message(_("End of line.")) - return - self.current_char += 1 - self._announce_current_char() - - def prev_char(self): - if not self._ensure_current_line(): - return - line_text = self.lines[self.current_line] - if len(line_text) == 0: - self.present_mode_message(_("blank")) - return - if self.current_char <= 0: - self.present_mode_message(_("Start of line.")) - return - self.current_char -= 1 - self._announce_current_char() - - def next_word(self): - if not self._ensure_current_line(): - return - line_text = self.lines[self.current_line] - word_spans = self._get_word_spans(line_text) - if len(word_spans) == 0: - self.present_mode_message(_("No words on this line.")) - return - - next_word_start = None - for start, end in word_spans: - if start > self.current_char: - next_word_start = start - break - if start <= self.current_char < end: - continue - - if next_word_start is None: - self.present_mode_message(_("No next word on this line.")) - return - - self.current_char = next_word_start - self._announce_word_at_current_char() - - def prev_word(self): - if not self._ensure_current_line(): - return - line_text = self.lines[self.current_line] - word_spans = self._get_word_spans(line_text) - if len(word_spans) == 0: - self.present_mode_message(_("No words on this line.")) - return - - prev_word_start = None - for start, end in word_spans: - if end - 1 < self.current_char: - prev_word_start = start - elif start <= self.current_char < end: - break - else: - break - - if prev_word_start is None: - self.present_mode_message(_("No previous word on this line.")) - return - - self.current_char = prev_word_start - self._announce_word_at_current_char() - - def _jump_to_anchor( - self, - anchors, - previous, - empty_message, - edge_message, - announce_role, - ): - if not self.has_loaded_diff(): - self.present_mode_message(_("No diff file loaded.")) - return - - if len(anchors) == 0: - self.present_mode_message(empty_message) - return - - new_line = self._find_next_anchor(anchors, previous) - if new_line is None: - self.present_mode_message(edge_message) - return - - self.current_line = new_line - self.current_char = 0 - self._announce_line(announce_role, new_line) - - def _find_next_anchor(self, anchors, previous): - if self.current_line < 0: - if previous: - return anchors[-1] - return anchors[0] - - if previous: - for anchor_line in reversed(anchors): - if anchor_line < self.current_line: - return anchor_line - return None - - for anchor_line in anchors: - if anchor_line > self.current_line: - return anchor_line - return None - - def _announce_line(self, announce_role, line_index): - line_text = self.lines[line_index] if line_index < len(self.lines) else "" - line_meta = self.line_meta[line_index] - role = line_meta["role"] - verbosity = self._get_diff_verbosity() - - if announce_role == "file": - self._announce_file_section(line_index, line_meta) - return - if announce_role == "hunk": - self._announce_hunk_section(line_index, line_meta, line_text) - return - - if role == "added_line": - base_message = _("Added line.") - elif role == "removed_line": - base_message = _("Removed line.") - else: - base_message = _("Diff line.") - - message = base_message + " " + self._build_location_text(line_index) - if verbosity == "verbose": - content = self._line_content_for_speech(line_text, role) - message = message + " " + content - self.present_mode_message(message) - - def _announce_current_line(self): - if self.current_line < 0 or self.current_line >= len(self.lines): - self.present_mode_message(_("No diff file loaded.")) - return - line_text = self.lines[self.current_line] - role = self.line_meta[self.current_line]["role"] - - if role == "context_line": - formatted_classic_diff = self._format_classic_diff_command(line_text) - if formatted_classic_diff: - self.present_mode_message(formatted_classic_diff) - return - - role_prefix = { - "added_line": _("Added"), - "removed_line": _("Removed"), - "hunk_header": _("Hunk header"), - "file_header": _("File header"), - "note_line": _("Note"), - "context_line": "", - }.get(role, _("Line")) - if line_text.strip() == "": - if role_prefix: - self.present_mode_message(role_prefix + ": " + _("blank")) - else: - self.present_mode_message(_("blank")) - return - if role_prefix: - line_text = self._normalize_diff_line_content(line_text, role) - if line_text.strip() == "": - self.present_mode_message(role_prefix + ": " + _("blank")) - return - if role in {"added_line", "removed_line"} and line_text in { - _("added"), - _("removed"), - }: - self.present_mode_message(line_text) - return - self.present_mode_message(role_prefix + ": " + line_text) - else: - self.present_mode_message(line_text) - - def _format_classic_diff_command(self, line_text): - match = re.match(r"^(\d+)(?:,(\d+))?([acd])(\d+)(?:,(\d+))?$", line_text.strip()) - if not match: - return None - - old_start = int(match.group(1)) - old_end = int(match.group(2) or match.group(1)) - op = match.group(3) - new_start = int(match.group(4)) - new_end = int(match.group(5) or match.group(4)) - - if op == "c": - return self._format_line_span(old_start, old_end) + " " + _("changed") - if op == "d": - return self._format_line_span(old_start, old_end) + " " + _("deleted") - if op == "a": - return self._format_line_span(new_start, new_end) + " " + _("added") - return None - - def _format_line_span(self, start_line, end_line): - if start_line == end_line: - return _("line ") + str(start_line) - return ( - _("lines ") - + str(start_line) - + _(" through ") - + str(end_line) - ) - - def _announce_current_char(self): - if self.current_line < 0 or self.current_line >= len(self.lines): - self.present_mode_message(_("No diff file loaded.")) - return - line_text = self.lines[self.current_line] - if len(line_text) == 0: - self.present_mode_message(_("blank")) - return - if self.current_char < 0: - self.current_char = 0 - if self.current_char >= len(line_text): - self.current_char = len(line_text) - 1 - char_value = line_text[self.current_char] - if char_value == " ": - self.present_mode_message(_("space")) - return - self.env["runtime"]["OutputManager"].present_text( - char_value, - interrupt=True, - ignore_punctuation=True, - announce_capital=True, - ) - - def _announce_word_at_current_char(self): - if self.current_line < 0 or self.current_line >= len(self.lines): - self.present_mode_message(_("No diff file loaded.")) - return - line_text = self.lines[self.current_line] - for start, end in self._get_word_spans(line_text): - if start <= self.current_char < end: - self.present_mode_message(line_text[start:end]) - return - self.present_mode_message(_("No word at current position.")) - - def _ensure_current_line(self): - if not self.has_loaded_diff(): - self.present_mode_message(_("No diff file loaded.")) - return False - if self.current_line < 0: - self.current_line = 0 - self.current_char = 0 - if self.current_line >= len(self.lines): - self.current_line = len(self.lines) - 1 - self.current_char = 0 - return True - - def _get_word_spans(self, line_text): - return [match.span() for match in re.finditer(r"\S+", line_text)] - - def _announce_file_section(self, line_index, line_meta): - file_index = line_meta["file_index"] - if file_index == -1 or file_index >= len(self.file_sections): - self.present_mode_message(_("File section.")) - return - file_section = self.file_sections[file_index] - message = ( - _("File section ") - + str(file_index + 1) - + _(" of ") - + str(len(self.file_sections)) - + _(". Plus ") - + str(file_section["added_count"]) - + _(", minus ") - + str(file_section["removed_count"]) - + "." - ) - self.present_mode_message(message) - - def _announce_hunk_section(self, line_index, line_meta, line_text): - hunk_index = line_meta["hunk_index"] - if hunk_index == -1 or hunk_index >= len(self.hunk_sections): - self.present_mode_message(_("Hunk section.")) - return - - hunk_section = self.hunk_sections[hunk_index] - file_index = hunk_section["file_index"] - file_position = file_index + 1 if file_index >= 0 else 0 - hunk_position = hunk_section["position_in_file"] - hunk_total = hunk_section["hunks_in_file"] - - message = ( - _("Hunk ") - + str(hunk_position) - + _(" of ") - + str(hunk_total) - + _(" in file ") - + str(file_position) - + _(" of ") - + str(len(self.file_sections)) - + _(". Plus ") - + str(hunk_section["added_count"]) - + _(", minus ") - + str(hunk_section["removed_count"]) - + "." - ) - - if self._get_diff_verbosity() == "verbose": - message += " " + self._line_content_for_speech(line_text, "hunk_header") - - self.present_mode_message(message) - - def _line_content_for_speech(self, line_text, role): - line_text = self._normalize_diff_line_content(line_text, role) - if line_text.strip() == "": - return _("blank") - return line_text - - def _normalize_diff_line_content(self, line_text, role): - if role == "added_line": - stripped_line = line_text.lstrip("+") - if stripped_line.strip() == "" and line_text.startswith("+"): - return _("added") - return stripped_line - if role == "removed_line": - stripped_line = line_text.lstrip("-") - if stripped_line.strip() == "" and line_text.startswith("-"): - return _("removed") - return stripped_line - return line_text - - def _build_location_text(self, line_index): - if line_index < 0 or line_index >= len(self.line_meta): - return "" - line_meta = self.line_meta[line_index] - file_index = line_meta["file_index"] - hunk_index = line_meta["hunk_index"] - location_parts = [] - - if file_index != -1 and file_index < len(self.file_sections): - location_parts.append( - _("File ") - + str(file_index + 1) - + _(" of ") - + str(len(self.file_sections)) - + ".") - - if hunk_index != -1 and hunk_index < len(self.hunk_sections): - hunk_info = self.hunk_sections[hunk_index] - location_parts.append( - _("Hunk ") - + str(hunk_info["position_in_file"]) - + _(" of ") - + str(hunk_info["hunks_in_file"]) - + "." - ) - - return " ".join(location_parts) - - def _get_diff_verbosity(self): - verbosity = self.env["runtime"]["SettingsManager"].get_setting( - "general", "diff_verbosity" - ).lower() - if verbosity not in {"compact", "verbose"}: - return "compact" - return verbosity - - def present_error(self, message): - self.present_mode_message(message) - - def present_mode_message(self, message): - self.env["runtime"]["OutputManager"].present_text( - message, - interrupt=True, - ) - - def _install_diff_bindings(self): - self.bindings_backup = self.env["bindings"].copy() - self.raw_bindings_backup = self.env["rawBindings"].copy() - - diff_bindings = { - str([1, ["KEY_H"]]): "DIFF_NEXT_HUNK", - str([1, sorted(["KEY_SHIFT", "KEY_H"])]): "DIFF_PREV_HUNK", - str([1, ["KEY_A"]]): "DIFF_NEXT_ADDED", - str([1, sorted(["KEY_SHIFT", "KEY_A"])]): "DIFF_PREV_ADDED", - str([1, ["KEY_D"]]): "DIFF_NEXT_REMOVED", - str([1, sorted(["KEY_SHIFT", "KEY_D"])]): "DIFF_PREV_REMOVED", - str([1, ["KEY_F"]]): "DIFF_NEXT_FILE_SECTION", - str([1, sorted(["KEY_SHIFT", "KEY_F"])]): "DIFF_PREV_FILE_SECTION", - str([1, ["KEY_S"]]): "DIFF_SUMMARY", - str([1, ["KEY_F1"]]): "DIFF_KEY_HELP", - str([1, ["KEY_UP"]]): "DIFF_PREV_LINE", - str([1, ["KEY_DOWN"]]): "DIFF_NEXT_LINE", - str([1, ["KEY_LEFT"]]): "DIFF_PREV_CHAR", - str([1, ["KEY_RIGHT"]]): "DIFF_NEXT_CHAR", - str([1, sorted(["KEY_CTRL", "KEY_LEFT"])]): "DIFF_PREV_WORD", - str([1, sorted(["KEY_CTRL", "KEY_RIGHT"])]): "DIFF_NEXT_WORD", - str([1, ["KEY_ESC"]]): "EXIT_DIFF_MODE", - str([1, sorted(["KEY_FENRIR", "KEY_CTRL", "KEY_D"])]): "TOGGLE_DIFF_MODE", - } - - diff_raw_bindings = { - shortcut: [1, keys] - for shortcut, keys in [ - (str([1, ["KEY_H"]]), ["KEY_H"]), - (str([1, sorted(["KEY_SHIFT", "KEY_H"])]), sorted(["KEY_SHIFT", "KEY_H"])), - (str([1, ["KEY_A"]]), ["KEY_A"]), - (str([1, sorted(["KEY_SHIFT", "KEY_A"])]), sorted(["KEY_SHIFT", "KEY_A"])), - (str([1, ["KEY_D"]]), ["KEY_D"]), - (str([1, sorted(["KEY_SHIFT", "KEY_D"])]), sorted(["KEY_SHIFT", "KEY_D"])), - (str([1, ["KEY_F"]]), ["KEY_F"]), - (str([1, sorted(["KEY_SHIFT", "KEY_F"])]), sorted(["KEY_SHIFT", "KEY_F"])), - (str([1, ["KEY_S"]]), ["KEY_S"]), - (str([1, ["KEY_F1"]]), ["KEY_F1"]), - (str([1, ["KEY_UP"]]), ["KEY_UP"]), - (str([1, ["KEY_DOWN"]]), ["KEY_DOWN"]), - (str([1, ["KEY_LEFT"]]), ["KEY_LEFT"]), - (str([1, ["KEY_RIGHT"]]), ["KEY_RIGHT"]), - ( - str([1, sorted(["KEY_CTRL", "KEY_LEFT"])]), - sorted(["KEY_CTRL", "KEY_LEFT"]), - ), - ( - str([1, sorted(["KEY_CTRL", "KEY_RIGHT"])]), - sorted(["KEY_CTRL", "KEY_RIGHT"]), - ), - (str([1, ["KEY_ESC"]]), ["KEY_ESC"]), - ( - str([1, sorted(["KEY_FENRIR", "KEY_CTRL", "KEY_D"])]), - sorted(["KEY_FENRIR", "KEY_CTRL", "KEY_D"]), - ), - ] - } - - self.env["bindings"] = diff_bindings - self.env["rawBindings"] = diff_raw_bindings - - def _restore_bindings(self): - if self.bindings_backup is not None: - self.env["bindings"] = self.bindings_backup - if self.raw_bindings_backup is not None: - self.env["rawBindings"] = self.raw_bindings_backup - self.bindings_backup = None - self.raw_bindings_backup = None - - def _write_debug(self, message, level): - self.env["runtime"]["DebugManager"].write_debug_out( - message, level - ) diff --git a/src/fenrirscreenreader/core/dynamicKeyboardLayoutMenu.py b/src/fenrirscreenreader/core/dynamicKeyboardLayoutMenu.py deleted file mode 100644 index 6e82fb90..00000000 --- a/src/fenrirscreenreader/core/dynamicKeyboardLayoutMenu.py +++ /dev/null @@ -1,205 +0,0 @@ -#!/usr/bin/env python3 - -import glob -import os - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.core import debug - - -class DynamicKeyboardLayoutCommand: - """Dynamic command class for keyboard layout selection""" - - def __init__(self, layoutName, layoutPath, env): - self.layoutName = layoutName - self.layoutPath = layoutPath - # Extract just the base name without extension for comparison - self.layoutBaseName = layoutName - self.env = env - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Set keyboard layout to {0}").format(self.layoutName) - - def run(self): - try: - settingsManager = self.env["runtime"]["SettingsManager"] - currentLayout = settingsManager.get_setting( - "keyboard", "keyboard_layout" - ) - - # Check if already set (compare both full path and base name) - currentBaseName = os.path.splitext(os.path.basename(currentLayout))[0] if currentLayout else "" - if currentBaseName.lower() == self.layoutBaseName.lower() or currentLayout.lower() == self.layoutPath.lower(): - self.env["runtime"]["OutputManager"].present_text( - _("Keyboard layout already set to {0}").format(self.layoutName) - ) - return - - # Set the new layout in the config file using full path - try: - # Update the setting in memory - settingsManager.set_setting( - "keyboard", "keyboard_layout", self.layoutPath - ) - - # Save to the actual config file - settingsManager.save_settings() - - self.env["runtime"]["OutputManager"].present_text( - _( - "Keyboard layout set to {0}. Please restart Fenrir for this change to take effect." - ).format(self.layoutName) - ) - # Play accept sound - self.env["runtime"]["OutputManager"].present_text( - "", sound_icon="Accept", interrupt=False - ) - except Exception as e: - self.env["runtime"]["OutputManager"].present_text( - _("Failed to change keyboard layout to {0}").format(self.layoutName) - ) - self.env["runtime"]["DebugManager"].write_debug_out( - f"DynamicKeyboardLayout: Error setting layout {self.layoutName}: {e}", - debug.DebugLevel.ERROR, - ) - # Play error sound - self.env["runtime"]["OutputManager"].present_text( - "", sound_icon="ErrorSound", interrupt=False - ) - - except Exception as e: - self.env["runtime"]["OutputManager"].present_text( - _("Keyboard layout change error: {0}").format(str(e)), interrupt=True - ) - self.env["runtime"]["DebugManager"].write_debug_out( - f"DynamicKeyboardLayout: Unexpected error for {self.layoutName}: {e}", - debug.DebugLevel.ERROR, - ) - - def set_callback(self, callback): - pass - - -def add_dynamic_keyboard_layout_menus(VmenuManager): - """Add dynamic keyboard layout menus to vmenu system""" - try: - env = VmenuManager.env - - # Get keyboard layout files - layouts = get_keyboard_layouts(env) - if not layouts: - return - - # Create keyboard layouts submenu - layoutMenu = {} - - # Add layout commands - for layoutName, layoutPath in layouts: - layoutCommand = DynamicKeyboardLayoutCommand( - layoutName, layoutPath, env - ) - layoutMenu[f"{layoutName} Action"] = layoutCommand - - # Find keyboard menu in existing vmenu structure - # If fenrir menu exists, add layouts under it - if "fenrir Menu" in VmenuManager.menuDict: - fenrirMenu = VmenuManager.menuDict["fenrir Menu"] - if "keyboard Menu" in fenrirMenu: - # Add dynamic layouts to existing keyboard menu - keyboardMenu = fenrirMenu["keyboard Menu"] - keyboardMenu["Keyboard Layouts Menu"] = layoutMenu - else: - # Create keyboard menu with layouts - fenrirMenu["keyboard Menu"] = { - "Keyboard Layouts Menu": layoutMenu - } - else: - # Create standalone keyboard layouts menu - VmenuManager.menuDict["Keyboard Layouts Menu"] = layoutMenu - - except Exception as e: - # Use debug manager for error logging - if "DebugManager" in env.get("runtime", {}): - env["runtime"]["DebugManager"].write_debug_out( - f"Error creating dynamic keyboard layout menus: {e}", - debug.DebugLevel.ERROR, - ) - else: - print(f"Error creating dynamic keyboard layout menus: {e}") - - -def get_keyboard_layouts(env): - """Get available keyboard layouts from keyboard directory""" - layouts = [] - - try: - # Get keyboard directory paths - keyboardDirs = [] - - # Check system installation path - systemKeyboardPath = "/etc/fenrirscreenreader/keyboard/" - if os.path.exists(systemKeyboardPath): - keyboardDirs.append(systemKeyboardPath) - - # Check source/development path - try: - import fenrirscreenreader - - fenrirPath = os.path.dirname(fenrirscreenreader.__file__) - devKeyboardPath = os.path.join( - fenrirPath, "..", "..", "config", "keyboard" - ) - devKeyboardPath = os.path.abspath(devKeyboardPath) - if os.path.exists(devKeyboardPath): - keyboardDirs.append(devKeyboardPath) - except Exception: - pass - - # Get current layout setting path - try: - currentLayoutSetting = env["runtime"]["SettingsManager"].get_setting( - "keyboard", "keyboard_layout" - ) - if currentLayoutSetting and os.path.exists(currentLayoutSetting): - currentLayoutDir = os.path.dirname(currentLayoutSetting) - if currentLayoutDir not in keyboardDirs: - keyboardDirs.append(currentLayoutDir) - except Exception: - pass - - # Scan for .conf files - seenLayouts = set() - for keyboardDir in keyboardDirs: - try: - confFiles = glob.glob(os.path.join(keyboardDir, "*.conf")) - for confFile in confFiles: - layoutName = os.path.splitext(os.path.basename(confFile))[ - 0 - ] - if layoutName not in seenLayouts: - seenLayouts.add(layoutName) - layouts.append((layoutName, confFile)) - except Exception as e: - if "DebugManager" in env.get("runtime", {}): - env["runtime"]["DebugManager"].write_debug_out( - f"Error scanning keyboard directory {keyboardDir}: {e}", - debug.DebugLevel.WARNING, - ) - - # Sort layouts alphabetically - layouts.sort(key=lambda x: x[0].lower()) - - except Exception as e: - if "DebugManager" in env.get("runtime", {}): - env["runtime"]["DebugManager"].write_debug_out( - f"Error getting keyboard layouts: {e}", - debug.DebugLevel.ERROR, - ) - - return layouts diff --git a/src/fenrirscreenreader/core/dynamicVoiceMenu.py b/src/fenrirscreenreader/core/dynamicVoiceMenu.py deleted file mode 100644 index bd79eea2..00000000 --- a/src/fenrirscreenreader/core/dynamicVoiceMenu.py +++ /dev/null @@ -1,386 +0,0 @@ -#!/usr/bin/env python3 - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.core import debug - - -class DynamicVoiceCommand: - """Dynamic command class for voice selection""" - - def __init__(self, module, voice, env): - self.module = module - self.voice, separator, self.language = voice.partition("|") - if not separator: - self.language = "" - self.env = env - self.test_message = _( - "This is a voice test. The quick brown fox jumps over the lazy dog." - ) - self._test_generation = 0 - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - self._test_generation += 1 - - def get_description(self): - return _("Select voice: {0}").format(self.voice) - - def run(self): - self._test_generation += 1 - generation = self._test_generation - try: - self.env["runtime"]["OutputManager"].present_text( - _("Testing voice {0} from {1}. Please wait.").format( - self.voice, self.module - ), - interrupt=True, - ) - self.env["runtime"]["SpeechDiscoveryManager"].request_voice_test( - self.module, - self.voice, - self.test_message, - lambda succeeded, error: self._finish_voice_test( - generation, succeeded, error - ), - ) - - except Exception as e: - self.env["runtime"]["OutputManager"].present_text( - _("Voice selection error: {0}").format(str(e)), - interrupt=False, - flush=False, - ) - - def _finish_voice_test(self, generation, succeeded, error): - if generation != self._test_generation: - return - if not succeeded: - self.env["runtime"]["OutputManager"].present_text( - _("Voice test failed: {0}").format(error), - interrupt=False, - flush=False, - ) - return - - self.env["commandBuffer"]["lastTestedModule"] = self.module - self.env["commandBuffer"]["lastTestedVoice"] = self.voice - self.env["commandBuffer"]["pendingVoiceModule"] = self.module - self.env["commandBuffer"]["pendingVoiceVoice"] = self.voice - if self.language: - self.env["commandBuffer"]["pendingVoiceLanguage"] = self.language - else: - self.env["commandBuffer"].pop("pendingVoiceLanguage", None) - self.env["commandBuffer"]["voiceTestCompleted"] = True - self.env["runtime"]["OutputManager"].present_text( - _( - "Voice test completed successfully. " - "Navigate to Apply Tested Voice to use this voice." - ), - interrupt=False, - flush=False, - ) - - def set_callback(self, callback): - pass - - -class DynamicApplyVoiceCommand: - """Command to apply the tested voice""" - - def __init__(self, env): - self.env = env - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("Apply tested voice to Fenrir") - - def run(self): - try: - if not self.env["commandBuffer"].get("voiceTestCompleted", False): - self.env["runtime"]["OutputManager"].present_text( - _("No voice has been tested yet"), interrupt=True - ) - return - - module = self.env["commandBuffer"]["pendingVoiceModule"] - voice = self.env["commandBuffer"]["pendingVoiceVoice"] - language = self.env["commandBuffer"].get("pendingVoiceLanguage") - - self.env["runtime"]["OutputManager"].present_text( - _("Applying {0} from {1}").format(voice, module), interrupt=True - ) - - # Debug: Show current settings - settings_manager = self.env["runtime"]["SettingsManager"] - current_module = settings_manager.get_setting("speech", "module") - current_voice = settings_manager.get_setting("speech", "voice") - self.env["runtime"]["OutputManager"].present_text( - _("Current: {0} from {1}").format(current_voice, current_module), - interrupt=False, - flush=False, - ) - - # Apply to runtime settings with fallback - settings_manager = self.env["runtime"]["SettingsManager"] - - # Store old values for safety - old_driver = settings_manager.get_setting("speech", "driver") - old_module = settings_manager.get_setting("speech", "module") - old_voice = settings_manager.get_setting("speech", "voice") - old_language = settings_manager.get_setting("speech", "language") - - try: - # Apply new settings to runtime only (use set_setting to update - # settingArgDict) - settings_manager.set_setting( - "speech", "driver", "speechdDriver" - ) - settings_manager.set_setting("speech", "module", module) - settings_manager.set_setting("speech", "voice", voice) - if language: - settings_manager.set_setting( - "speech", "language", language - ) - - # Apply settings to speech driver directly - if "SpeechDriver" in self.env["runtime"]: - speech_driver = self.env["runtime"]["SpeechDriver"] - - # Set module and voice on the driver instance. - speech_driver.set_module(module) - if language: - speech_driver.set_language(language) - speech_driver.set_voice(voice) - - # Debug: verify what was actually set - self.env["runtime"]["OutputManager"].present_text( - _("Speech driver now has module: {0}, voice: {1}").format( - speech_driver.module, speech_driver.voice - ), - interrupt=True, - ) - - # Force application by speaking a test message - self.env["runtime"]["OutputManager"].present_text( - _( - "Voice applied successfully! You should hear this in " - "the new voice." - ), - interrupt=True, - ) - - self.env["runtime"]["OutputManager"].present_text( - _("Use save settings to make permanent"), interrupt=True - ) - - # Clear pending state - self.env["commandBuffer"]["voiceTestCompleted"] = False - - # Exit vmenu after successful application - self.env["runtime"]["VmenuManager"].set_active(False) - - except Exception as e: - # Revert on failure - settings_manager.set_setting("speech", "driver", old_driver) - settings_manager.set_setting("speech", "module", old_module) - settings_manager.set_setting("speech", "voice", old_voice) - settings_manager.set_setting( - "speech", "language", old_language - ) - - # Try to reinitialize with old settings - if "SpeechDriver" in self.env["runtime"]: - try: - speech_driver = self.env["runtime"]["SpeechDriver"] - speech_driver.shutdown() - speech_driver.initialize(self.env) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "dynamicVoiceMenu: Error reinitializing speech driver: " - + str(e), - debug.DebugLevel.ERROR, - ) - - self.env["runtime"]["OutputManager"].present_text( - _("Failed to apply voice, reverted: {0}").format(str(e)), - interrupt=False, - flush=False, - ) - - except Exception as e: - self.env["runtime"]["OutputManager"].present_text( - _("Apply voice error: {0}").format(str(e)), interrupt=False, flush=False - ) - - def set_callback(self, callback): - pass - - -def add_dynamic_voice_menus(vmenu_manager): - """Populate cached voice menus and start non-blocking discovery if needed.""" - try: - env = vmenu_manager.env - discovery = env["runtime"]["SpeechDiscoveryManager"] - generation = getattr(vmenu_manager, "_voice_menu_generation", 0) + 1 - vmenu_manager._voice_menu_generation = generation - modules = discovery.get_cached_modules() - if modules is None: - _install_loading_menu(vmenu_manager) - discovery.request_modules( - lambda found, error: _request_dynamic_voice_lists( - vmenu_manager, generation, found, error, False - ) - ) - return - _request_dynamic_voice_lists( - vmenu_manager, generation, modules, "", True - ) - except Exception as error: - if "DebugManager" in env["runtime"]: - env["runtime"]["DebugManager"].write_debug_out( - f"Error creating dynamic voice menus: {error}", - debug.DebugLevel.ERROR, - ) - else: - print(f"Error creating dynamic voice menus: {error}") - - -def _request_dynamic_voice_lists( - vmenu_manager, generation, modules, error, allow_active -): - if generation != getattr(vmenu_manager, "_voice_menu_generation", 0): - return - if error or not modules: - if vmenu_manager.get_active() and not allow_active: - vmenu_manager._voice_menu_refresh_pending = True - return - _install_discovery_error_menu(vmenu_manager) - return - - discovery = vmenu_manager.env["runtime"]["SpeechDiscoveryManager"] - selected_modules = modules[:8] - voices_by_module = {} - missing_modules = [] - for module in selected_modules: - voices = discovery.get_cached_voices(module) - if voices is None: - missing_modules.append(module) - else: - voices_by_module[module] = voices - - if not missing_modules: - _install_dynamic_voice_menu( - vmenu_manager, - generation, - selected_modules, - voices_by_module, - allow_active, - ) - return - - _install_loading_menu(vmenu_manager) - remaining_modules = set(missing_modules) - - def voices_ready(module, voices, _voice_error): - if generation != getattr(vmenu_manager, "_voice_menu_generation", 0): - return - voices_by_module[module] = voices - remaining_modules.discard(module) - if not remaining_modules: - _install_dynamic_voice_menu( - vmenu_manager, - generation, - selected_modules, - voices_by_module, - False, - ) - - for module in missing_modules: - discovery.request_voices(module, voices_ready) - - -def _install_dynamic_voice_menu( - vmenu_manager, - generation, - modules, - voices_by_module, - allow_active, -): - if generation != getattr(vmenu_manager, "_voice_menu_generation", 0): - return - if vmenu_manager.get_active() and not allow_active: - vmenu_manager._voice_menu_refresh_pending = True - return - - env = vmenu_manager.env - voice_browser_menu = { - "Apply Tested Voice Action": DynamicApplyVoiceCommand(env) - } - for module in modules: - module_menu = {} - voices = voices_by_module.get(module, []) - for voice in voices: - voice_name = voice.split("|", 1)[0] - module_menu[f"{voice_name} Action"] = DynamicVoiceCommand( - module, voice, env - ) - if not module_menu: - module_menu["No voices available Action"] = create_info_command( - _("No voices found for {0}").format(module), env - ) - voice_browser_menu[f"{module} Menu"] = module_menu - vmenu_manager.menuDict["Voice Browser Menu"] = voice_browser_menu - vmenu_manager._voice_menu_refresh_pending = False - - -def _install_loading_menu(vmenu_manager): - env = vmenu_manager.env - vmenu_manager.menuDict["Voice Browser Menu"] = { - "Loading voices Action": create_info_command( - _("Voice information is still loading"), env - ) - } - - -def _install_discovery_error_menu(vmenu_manager): - env = vmenu_manager.env - vmenu_manager.menuDict["Voice Browser Menu"] = { - "Voice discovery failed Action": create_info_command( - _("Voice discovery failed"), env - ) - } - - -def create_info_command(message, env): - """Create a simple info command""" - - class InfoCommand: - def __init__(self, message, env): - self.message = message - self.env = env - - def initialize(self, environment): - pass - - def shutdown(self): - pass - - def get_description(self): - return self.message - - def run(self): - self.env["runtime"]["OutputManager"].present_text( - self.message, interrupt=True - ) - - def set_callback(self, callback): - pass - - return InfoCommand(message, env) diff --git a/src/fenrirscreenreader/core/environment.py b/src/fenrirscreenreader/core/environment.py deleted file mode 100644 index a14358b5..00000000 --- a/src/fenrirscreenreader/core/environment.py +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core import commandData -from fenrirscreenreader.core import debug -from fenrirscreenreader.core.generalData import general_data -from fenrirscreenreader.core.inputData import input_data -from fenrirscreenreader.core.outputData import output_data -from fenrirscreenreader.core.punctuationData import punctuation_data -from fenrirscreenreader.core.runtimeData import runtime_data -from fenrirscreenreader.core.screenData import screen_data -from fenrirscreenreader.core.settingsData import settings_data - -environment = { - "screen": screen_data, - "runtime": runtime_data, - "general": general_data, - "settings": settings_data, - "commandInfo": commandData.command_info, - "commandBuffer": commandData.command_buffer, - "input": input_data, - "punctuation": punctuation_data, - "output": output_data, - "soundIcons": {}, - "bindings": {}, - "rawBindings": {}, -} diff --git a/src/fenrirscreenreader/core/eventData.py b/src/fenrirscreenreader/core/eventData.py deleted file mode 100644 index 0a812d1f..00000000 --- a/src/fenrirscreenreader/core/eventData.py +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from enum import Enum - -from fenrirscreenreader.core import debug - - -class FenrirEventType(Enum): - ignore = 0 - stop_main_loop = 1 - screen_update = 2 - keyboard_input = 3 - plug_input_device = 4 - screen_changed = 5 - heart_beat = 6 - execute_command = 7 - remote_incomming = 8 - background_task_result = 9 - clipboard_sync = 10 - - def __int__(self): - return self.value - - def __str__(self): - return self.name diff --git a/src/fenrirscreenreader/core/eventManager.py b/src/fenrirscreenreader/core/eventManager.py deleted file mode 100644 index cacb97bf..00000000 --- a/src/fenrirscreenreader/core/eventManager.py +++ /dev/null @@ -1,114 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import time -from ctypes import c_bool -from multiprocessing import Queue -from multiprocessing.sharedctypes import Value -from queue import Empty - -from fenrirscreenreader.core import debug -from fenrirscreenreader.core.eventData import FenrirEventType - - -class EventManager: - def __init__(self): - self.running = Value(c_bool, True) - self._eventQueue = Queue() - self.clean_event_queue() - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - self.clean_event_queue() - - def proceed_event_loop(self): - event = self._eventQueue.get() - st = time.time() - self.event_dispatcher(event) - # print('NET loop ' + str(time.time() - st)) - - def event_dispatcher(self, event): - self.env["runtime"]["DebugManager"].write_debug_out( - "EventManager:event_dispatcher:start: event: " - + str(event["Type"]), - debug.DebugLevel.INFO, - ) - - if not event: - return - if not event["Type"]: - return - if event["Type"] == FenrirEventType.ignore: - return - elif event["Type"] == FenrirEventType.stop_main_loop: - self.handle_stop_main_loop(event) - elif event["Type"] == FenrirEventType.screen_update: - self.env["runtime"]["FenrirManager"].handle_screen_update(event) - elif event["Type"] == FenrirEventType.keyboard_input: - self.env["runtime"]["FenrirManager"].handle_input(event) - elif event["Type"] == FenrirEventType.plug_input_device: - self.env["runtime"]["FenrirManager"].handle_plug_input_device( - event - ) - elif event["Type"] == FenrirEventType.screen_changed: - self.env["runtime"]["FenrirManager"].handle_screen_change(event) - elif event["Type"] == FenrirEventType.heart_beat: - self.env["runtime"]["FenrirManager"].handle_heart_beat(event) - elif event["Type"] == FenrirEventType.execute_command: - self.env["runtime"]["FenrirManager"].handle_execute_command(event) - elif event["Type"] == FenrirEventType.remote_incomming: - self.env["runtime"]["FenrirManager"].handle_remote_incomming(event) - elif event["Type"] == FenrirEventType.background_task_result: - self.env["runtime"]["FenrirManager"].handle_background_task_result( - event - ) - elif event["Type"] == FenrirEventType.clipboard_sync: - self.env["runtime"]["FenrirManager"].handle_clipboard_sync(event) - - def is_main_event_loop_running(self): - return self.running.value == 1 - - def start_main_event_loop(self): - self.running.value = 1 - while self.is_main_event_loop_running(): - self.proceed_event_loop() - - def handle_stop_main_loop(self, event): - self.running.value = 0 - time.sleep(0.1) - - def stop_main_event_loop(self): - self._eventQueue.put( - {"Type": FenrirEventType.stop_main_loop, "data": None} - ) - - def clean_event_queue(self): - if self._eventQueue.empty(): - return - try: - while True: - self._eventQueue.get_nowait() - except Empty: - pass - - def get_event_queue(self): - return self._eventQueue - - def get_running(self): - return self.running - - def get_event_queue_size(self): - return self._eventQueue.qsize() - - def put_to_event_queue(self, event, data): - if not isinstance(event, FenrirEventType): - return False - if event == FenrirEventType.ignore: - return False - self._eventQueue.put({"Type": event, "data": data}) - return True diff --git a/src/fenrirscreenreader/core/fenrirManager.py b/src/fenrirscreenreader/core/fenrirManager.py deleted file mode 100644 index 18539e8e..00000000 --- a/src/fenrirscreenreader/core/fenrirManager.py +++ /dev/null @@ -1,598 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import os -import socket -import signal -import sys -import time - -from fenrirscreenreader.core import debug -from fenrirscreenreader.core import remoteInstanceRegistry -from fenrirscreenreader.core import settingsManager -from fenrirscreenreader.core.eventData import FenrirEventType -from fenrirscreenreader.core.i18n import _ - - -class FenrirManager: - def __init__(self, cliArgs): - self.is_initialized = False - self.environment = None - self.signal_handlers_set = False - - try: - self.environment = ( - settingsManager.SettingsManager().init_fenrir_config( - cliArgs, self - ) - ) - if not self.environment: - raise RuntimeError( - "Cannot Initialize. Maybe the configfile is not available or not parseable" - ) - - self.environment["runtime"]["OutputManager"].present_text( - _("Start Fenrir"), sound_icon="ScreenReaderOn", interrupt=True - ) - - # Set signal handlers after successful initialization - signal.signal(signal.SIGINT, self.capture_signal) - signal.signal(signal.SIGTERM, self.capture_signal) - signal.signal(signal.SIGHUP, self.capture_signal) - self.signal_handlers_set = True - - self.is_initialized = True - self.modifierInput = False - self.modifier_prefix_input = False - self.singleKeyCommand = False - self.command = "" - self.set_process_name() - - except Exception as e: - # Clean up any partial initialization - self.cleanup_on_error() - raise - - def proceed(self): - if not self.is_initialized: - return - self.environment["runtime"]["EventManager"].start_main_event_loop() - self.shutdown() - - def handle_input(self, event): - self.environment["runtime"]["DebugManager"].write_debug_out( - "DEBUG INPUT fenrirMan:" + str(event), debug.DebugLevel.INFO - ) - - if not event["data"]: - event["data"] = self.environment["runtime"][ - "InputManager" - ].get_input_event() - - if event["data"]: - event["data"]["event_name"] = self.environment["runtime"][ - "InputManager" - ].convert_event_name(event["data"]["event_name"]) - self.environment["runtime"]["InputManager"].handle_input_event( - event["data"] - ) - else: - return - - tutorial_mode = self.environment["runtime"][ - "HelpManager" - ].is_tutorial_mode() - if ( - self.environment["runtime"]["ScreenManager"].is_ignored_screen() - and not tutorial_mode - ): - self.environment["runtime"]["InputManager"].write_event_buffer() - else: - if tutorial_mode: - self.environment["runtime"][ - "InputManager" - ].clear_event_buffer() - if self.environment["runtime"][ - "ScreenManager" - ].is_ignored_screen(): - self.environment["runtime"]["InputManager"].key_echo( - event["data"] - ) - - if self.environment["runtime"]["VmenuManager"].get_active(): - self.environment["runtime"][ - "InputManager" - ].clear_event_buffer() - if self.environment["runtime"]["DiffReviewManager"].is_active(): - self.environment["runtime"][ - "InputManager" - ].clear_event_buffer() - if self.environment["runtime"]["SpeechHistoryManager"].is_active(): - self.environment["runtime"][ - "InputManager" - ].clear_event_buffer() - - self.detect_shortcut_command() - - if self.modifierInput: - self.environment["runtime"][ - "InputManager" - ].clear_event_buffer() - if self.singleKeyCommand: - if self.environment["runtime"][ - "InputManager" - ].no_key_pressed(): - self.environment["runtime"][ - "InputManager" - ].clear_event_buffer() - # Hold conventional modifier presses long enough to determine - # whether a Fenrir or Script modifier follows them. - elif not self.modifier_prefix_input: - self.environment["runtime"][ - "InputManager" - ].write_event_buffer() - - if self.environment["runtime"]["InputManager"].no_key_pressed(): - self.modifierInput = False - self.modifier_prefix_input = False - self.singleKeyCommand = False - self.environment["runtime"]["InputManager"].write_event_buffer() - self.environment["runtime"]["InputManager"].handle_device_grab() - - self.update_key_forward() - - self.environment["runtime"]["CommandManager"].execute_default_trigger( - "onKeyInput" - ) - - def handle_execute_command(self, event): - if not event["data"] or event["data"] == "": - return - current_command = event["data"] - - # Check for active read-all and interrupt it on any other key press - self._interrupt_read_all_if_active(current_command) - - # special modes - if self.environment["runtime"]["HelpManager"].is_tutorial_mode(): - if self.environment["runtime"]["CommandManager"].command_exists( - current_command, "help" - ): - self.environment["runtime"]["CommandManager"].run_command( - current_command, "help" - ) - return - if current_command == "TOGGLE_TUTORIAL_MODE": - self.environment["runtime"]["CommandManager"].run_command( - current_command, "commands" - ) - return - elif self.environment["runtime"]["VmenuManager"].get_active(): - if self.environment["runtime"]["CommandManager"].command_exists( - current_command, "vmenu-navigation" - ): - self.environment["runtime"]["CommandManager"].execute_command( - current_command, "vmenu-navigation" - ) - return - elif self.environment["runtime"]["SpeechHistoryManager"].is_active(): - if self.environment["runtime"]["CommandManager"].command_exists( - current_command, "speech-history" - ): - self.environment["runtime"]["CommandManager"].execute_command( - current_command, "speech-history" - ) - return - - # default - self.environment["runtime"]["CommandManager"].execute_command( - current_command, "commands" - ) - - def _interrupt_read_all_if_active(self, current_command): - """Check for active read-all and interrupt it on any other key press.""" - try: - # Skip interruption for read-all commands themselves (they toggle) - if current_command in ["read_all_by_line", "read_all_by_page"]: - return - - # Stop read-all if any other command is executed - if ("ReadAllManager" in self.environment["runtime"] and - self.environment["runtime"]["ReadAllManager"] and - self.environment["runtime"]["ReadAllManager"].is_active()): - - self.environment["runtime"]["ReadAllManager"].stop_read_all() - - except Exception as e: - # Ignore errors in interrupt logic to prevent cascading issues - self.environment["runtime"]["DebugManager"].write_debug_out( - f"_interrupt_read_all_if_active: {e}", - debug.DebugLevel.ERROR - ) - - - def handle_remote_incomming(self, event): - if not event["data"]: - return - self.environment["runtime"]["RemoteManager"].handle_remote_incomming( - event["data"] - ) - - def handle_background_task_result(self, event): - self.environment["runtime"]["BackgroundTaskManager"].handle_result( - event["data"] - ) - - def handle_screen_change(self, event): - self.environment["runtime"]["ScreenManager"].handle_screen_change( - event["data"] - ) - if self.environment["runtime"]["VmenuManager"].get_active(): - return - self.environment["runtime"]["CommandManager"].execute_default_trigger( - "onScreenChanged" - ) - self.environment["runtime"]["ScreenDriver"].get_curr_screen() - - def handle_screen_update(self, event): - self.environment["runtime"]["ScreenManager"].handle_screen_update( - event["data"] - ) - - if ( - time.time() - - self.environment["runtime"]["InputManager"].get_last_input_time() - >= 0.3 - ): - self.environment["runtime"]["InputManager"].clear_last_deep_input() - - if not self._input_target_is_active(): - self.environment["runtime"]["InputManager"].clear_last_deep_input() - return - - if ( - self._has_direct_user_input() - and ( - self.environment["runtime"][ - "CursorManager" - ].is_cursor_vertical_move() - or self.environment["runtime"][ - "CursorManager" - ].is_cursor_horizontal_move() - ) - ): - self.environment["runtime"][ - "CommandManager" - ].execute_default_trigger("onCursorChange") - - self.environment["runtime"]["CommandManager"].execute_default_trigger( - "onScreenUpdate" - ) - - def _input_target_is_active(self): - input_driver = self.environment["runtime"].get("InputDriver") - if input_driver is None: - return True - return input_driver.is_active() - - def _has_direct_user_input(self): - deepest_input = self.environment["runtime"][ - "InputManager" - ].get_last_deepest_input() - if not deepest_input: - return False - return not any( - key in deepest_input for key in ("KEY_FENRIR", "KEY_SCRIPT") - ) - - def handle_plug_input_device(self, event): - try: - self.environment["runtime"][ - "InputManager" - ].set_last_detected_devices(event["data"]) - except Exception as e: - self.environment["runtime"]["DebugManager"].write_debug_out( - "handle_plug_input_device: Error setting last detected devices: " - + str(e), - debug.DebugLevel.ERROR, - ) - self.environment["runtime"]["InputManager"].handle_plug_input_device( - event["data"] - ) - self.environment["runtime"]["CommandManager"].execute_default_trigger( - "onPlugInputDevice", force=True - ) - self.environment["runtime"]["InputManager"].set_last_detected_devices( - None - ) - - def handle_heart_beat(self, event): - if not self._input_target_is_active(): - return - self.environment["runtime"]["CommandManager"].execute_default_trigger( - "onHeartBeat", force=True - ) - - def handle_clipboard_sync(self, event): - clipboard_sync_manager = self.environment["runtime"].get( - "ClipboardSyncManager" - ) - if clipboard_sync_manager: - clipboard_sync_manager.handle_sync_event() - - def detect_shortcut_command(self): - if self.environment["input"]["key_forward"] != 0: - return - - if len(self.environment["input"]["prev_input"]) > len( - self.environment["input"]["curr_input"] - ): - return - - input_manager = self.environment["runtime"]["InputManager"] - if input_manager.is_key_press(): - self.modifierInput = input_manager.curr_input_has_command_modifier() - self.modifier_prefix_input = ( - not self.modifierInput - and input_manager.curr_input_is_modifier_prefix() - ) - else: - if ( - not self.modifierInput - and input_manager.curr_input_has_command_modifier() - ): - self.modifierInput = True - self.modifier_prefix_input = False - elif ( - self.modifier_prefix_input - and not input_manager.curr_input_is_modifier_prefix() - ): - self.modifier_prefix_input = False - if not input_manager.no_key_pressed(): - if self.singleKeyCommand: - self.singleKeyCommand = ( - len(self.environment["input"]["curr_input"]) == 1 - ) - - if not ( - self.singleKeyCommand - and self.environment["runtime"]["InputManager"].no_key_pressed() - ): - current_shortcut = self.environment["runtime"][ - "InputManager" - ].get_curr_shortcut() - self.command = self.environment["runtime"][ - "InputManager" - ].get_command_for_shortcut(current_shortcut) - - if not self.modifierInput: - if self.environment["runtime"]["InputManager"].is_key_press(): - if self.command != "": - self.singleKeyCommand = True - elif ( - ( - ( - "HelpManager" in self.environment["runtime"] - and self.environment["runtime"][ - "HelpManager" - ].is_tutorial_mode() - ) - or self.environment["runtime"]["VmenuManager"].get_active() - or self.environment["runtime"][ - "DiffReviewManager" - ].is_active() - or self.environment["runtime"][ - "SpeechHistoryManager" - ].is_active() - ) - and self.command != "" - ): - # Modal modes use non-Fenrir modified bindings. - # Promote resolved shortcuts to executable commands so - # combinations like Shift+H, Ctrl+Right, and plain arrows - # are dispatched. - self.singleKeyCommand = True - - if not (self.singleKeyCommand or self.modifierInput): - return - - # fire event - if self.command != "": - if self.modifierInput: - self.environment["runtime"]["EventManager"].put_to_event_queue( - FenrirEventType.execute_command, self.command - ) - self.command = "" - else: - if self.singleKeyCommand: - self.environment["runtime"][ - "EventManager" - ].put_to_event_queue( - FenrirEventType.execute_command, self.command - ) - self.command = "" - - def update_key_forward(self): - key_forward = self.environment["input"]["key_forward"] - input_manager = self.environment["runtime"]["InputManager"] - if key_forward == -1 and input_manager.no_key_pressed(): - self.environment["input"]["key_forward"] = 1 - elif key_forward == 1 and input_manager.no_key_pressed(): - self.environment["input"]["key_forward"] = 0 - - def set_process_name(self, name="fenrir"): - """Attempts to set the process name to 'fenrir'.""" - try: - from setproctitle import setproctitle - except ImportError: - pass - else: - setproctitle(name) - return True - - try: - from ctypes import byref - from ctypes import cdll - from ctypes import create_string_buffer - - libc = cdll.LoadLibrary("libc.so.6") - string_buffer = create_string_buffer(len(name) + 1) - string_buffer.value = bytes(name, "UTF-8") - libc.prctl(15, byref(string_buffer), 0, 0, 0) - return True - except Exception as e: - self.environment["runtime"]["DebugManager"].write_debug_out( - "setProcName: Error setting process name: " + str(e), - debug.DebugLevel.ERROR, - ) - - return False - - def shutdown_request(self): - try: - self.environment["runtime"]["EventManager"].stop_main_event_loop() - except Exception as e: - self.environment["runtime"]["DebugManager"].write_debug_out( - "shutdown_request: Error stopping main event loop: " + str(e), - debug.DebugLevel.ERROR, - ) - - def capture_signal(self, sigInit, frame): - self.shutdown_request() - - def shutdown(self): - self.environment["runtime"]["InputManager"].ungrab_all_devices() - self.environment["runtime"]["EventManager"].stop_main_event_loop() - self.environment["runtime"]["OutputManager"].present_text( - _("Quit Fenrir"), sound_icon="ScreenReaderOff", interrupt=True - ) - self.environment["runtime"]["EventManager"].clean_event_queue() - time.sleep(0.6) - - for currentManager in self.environment["general"]["managerList"]: - try: - if ( - currentManager in self.environment["runtime"] - and self.environment["runtime"][currentManager] - ): - self.environment["runtime"][currentManager].shutdown() - del self.environment["runtime"][currentManager] - except Exception: - pass # Ignore errors during individual manager shutdown - - self.environment = None - - def cleanup_on_error(self): - """Clean up partially initialized state when initialization fails""" - try: - # Reset signal handlers to default if they were set - if self.signal_handlers_set: - signal.signal(signal.SIGINT, signal.SIG_DFL) - signal.signal(signal.SIGTERM, signal.SIG_DFL) - signal.signal(signal.SIGHUP, signal.SIG_DFL) - self.signal_handlers_set = False - - # Clean up any initialized managers - if self.environment: - try: - # Try to ungrab devices if input manager exists - if ( - "runtime" in self.environment - and "InputManager" in self.environment["runtime"] - ): - if self.environment["runtime"]["InputManager"]: - self.environment["runtime"][ - "InputManager" - ].ungrab_all_devices() - except Exception: - pass # Ignore errors during cleanup - - try: - # Try to stop event manager if it exists - if ( - "runtime" in self.environment - and "EventManager" in self.environment["runtime"] - ): - if self.environment["runtime"]["EventManager"]: - self.environment["runtime"][ - "EventManager" - ].stop_main_event_loop() - except Exception: - pass # Ignore errors during cleanup - - try: - # Try to clean up all managers - if ( - "general" in self.environment - and "managerList" in self.environment["general"] - ): - for currentManager in self.environment["general"][ - "managerList" - ]: - if ( - "runtime" in self.environment - and currentManager - in self.environment["runtime"] - and self.environment["runtime"][currentManager] - ): - try: - self.environment["runtime"][ - currentManager - ].shutdown() - del self.environment["runtime"][ - currentManager - ] - except Exception: - pass # Ignore errors during cleanup - except Exception: - pass # Ignore errors during cleanup - - # Clean up socket files that might not be removed by the driver - try: - socket_file = None - if ( - "runtime" in self.environment - and "SettingsManager" in self.environment["runtime"] - ): - try: - socket_file = self.environment["runtime"][ - "SettingsManager" - ].get_setting("remote", "socket_file") - except Exception: - pass # Use default socket file path - - if not socket_file: - # Always clean up PID-specific socket - pid_socket_file = ( - "/tmp/fenrirscreenreader-" - + str(os.getpid()) - + ".sock" - ) - if os.path.exists(pid_socket_file): - os.unlink(pid_socket_file) - - # Clean up main socket only if it is stale (not active) - main_socket_file = "/tmp/fenrirscreenreader-daemon.sock" - if os.path.exists(main_socket_file): - try: - test_sock = socket.socket( - socket.AF_UNIX, socket.SOCK_STREAM - ) - test_sock.settimeout(0.2) - test_sock.connect(main_socket_file) - except OSError: - os.unlink(main_socket_file) - finally: - test_sock.close() - elif os.path.exists(socket_file): - os.unlink(socket_file) - remoteInstanceRegistry.remove_instance() - except Exception: - pass # Ignore errors during socket cleanup - - self.environment = None - except Exception: - pass # Ignore all errors during error cleanup diff --git a/src/fenrirscreenreader/core/generalData.py b/src/fenrirscreenreader/core/generalData.py deleted file mode 100644 index dcf9721d..00000000 --- a/src/fenrirscreenreader/core/generalData.py +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core import debug - -general_data = { - "args": None, - "tutorialMode": False, - "curr_user": "", - "prev_user": "", - "managerList": [ - "AttributeManager", - "PunctuationManager", - "CursorManager", - "ApplicationManager", - "CommandManager", - "ClipboardSyncManager", - "ScreenManager", - "InputManager", - "OutputManager", - "SpeechHistoryManager", - "HelpManager", - "MemoryManager", - "SpeechDiscoveryManager", - "BackgroundTaskManager", - "EventManager", - "ProcessManager", - "VmenuManager", - "QuickMenuManager", - "ReadAllManager", - "DiffReviewManager", - "RemoteManager", - "SettingsManager", - "SayAllManager", - "TableManager", - "TextManager", - "BarrierManager", - "DebugManager", - ], - "commandFolderList": [ - "commands", - "onKeyInput", - "onCursorChange", - "onScreenUpdate", - "onScreenChanged", - "onHeartBeat", - "onPlugInputDevice", - "onApplicationChange", - "onSwitchApplicationProfile", - "help", - "vmenu-navigation", - "speech-history", - ], -} diff --git a/src/fenrirscreenreader/core/helpManager.py b/src/fenrirscreenreader/core/helpManager.py deleted file mode 100755 index 704bf093..00000000 --- a/src/fenrirscreenreader/core/helpManager.py +++ /dev/null @@ -1,281 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import ast -import copy - -from fenrirscreenreader.core import debug -from fenrirscreenreader.core.i18n import _ - - -class HelpManager: - ACTIVE_SECTION = "active" - UNBOUND_SECTION = "unbound" - PLUGINS_SECTION = "plugins" - HELP_SECTIONS = ( - ACTIVE_SECTION, - UNBOUND_SECTION, - PLUGINS_SECTION, - ) - - def __init__(self): - self.env = None - self.help_lists = { - self.ACTIVE_SECTION: [], - self.UNBOUND_SECTION: [], - self.PLUGINS_SECTION: [], - } - self.help_section = self.ACTIVE_SECTION - self.tutorial_list_index = None - self.bindings_backup = None - self.raw_bindings_backup = None - self.capture_degraded = False - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - if self.is_tutorial_mode(): - self.set_tutorial_mode(False) - - def toggle_tutorial_mode(self): - self.set_tutorial_mode(not self.is_tutorial_mode()) - - def set_tutorial_mode(self, tutorial_mode): - if tutorial_mode == self.is_tutorial_mode(): - return True - if tutorial_mode and self.env["runtime"]["VmenuManager"].get_active(): - return False - - if tutorial_mode: - self.bindings_backup = self.env["bindings"].copy() - self.raw_bindings_backup = copy.deepcopy( - self.env["rawBindings"] - ) - self.create_help_dict() - self.env["general"]["tutorialMode"] = True - self._install_help_bindings() - self._refresh_input_bindings() - self.capture_degraded = not self.env["runtime"][ - "InputManager" - ].set_help_capture(True) - return True - - if not self.env["runtime"]["InputManager"].set_help_capture(False): - self.env["runtime"]["DebugManager"].write_debug_out( - "HelpManager could not release exclusive input capture", - debug.DebugLevel.ERROR, - ) - return False - self._restore_bindings() - self.env["general"]["tutorialMode"] = False - self.capture_degraded = False - self.env["runtime"]["InputManager"].reset_input_state() - self._refresh_input_bindings() - return True - - def _install_help_bindings(self): - help_bindings = { - str([1, ["KEY_ESC"]]): "TOGGLE_TUTORIAL_MODE", - str([1, ["KEY_UP"]]): "PREV_HELP", - str([1, ["KEY_DOWN"]]): "NEXT_HELP", - str([1, ["KEY_LEFT"]]): "PREV_HELP_SECTION", - str([1, ["KEY_RIGHT"]]): "NEXT_HELP_SECTION", - str([1, ["KEY_SPACE"]]): "CURR_HELP", - } - self.env["bindings"].update(help_bindings) - for shortcut in help_bindings: - self.env["rawBindings"][shortcut] = ast.literal_eval(shortcut) - - def _restore_bindings(self): - if self.bindings_backup is not None: - self.env["bindings"] = self.bindings_backup - if self.raw_bindings_backup is not None: - self.env["rawBindings"] = self.raw_bindings_backup - self.bindings_backup = None - self.raw_bindings_backup = None - - def _refresh_input_bindings(self): - try: - refresh_grabs = getattr( - self.env["runtime"]["InputDriver"], "refresh_grabs", None - ) - if refresh_grabs: - refresh_grabs(force=True) - except Exception as error: - self.env["runtime"]["DebugManager"].write_debug_out( - "HelpManager could not refresh input bindings: " - + str(error), - debug.DebugLevel.ERROR, - ) - - def is_tutorial_mode(self): - if self.env is None: - return False - return self.env["general"]["tutorialMode"] - - def is_capture_degraded(self): - return self.capture_degraded - - def _get_shortcuts_by_command(self): - bindings = ( - self.bindings_backup - if self.bindings_backup is not None - else self.env["bindings"] - ) - raw_bindings = ( - self.raw_bindings_backup - if self.raw_bindings_backup is not None - else self.env["rawBindings"] - ) - shortcuts_by_command = {} - for shortcut_key, command in bindings.items(): - raw_shortcut = raw_bindings.get(shortcut_key) - if raw_shortcut is None: - try: - raw_shortcut = ast.literal_eval(shortcut_key) - except (SyntaxError, ValueError): - continue - shortcuts_by_command.setdefault(command, []).append( - copy.deepcopy(raw_shortcut) - ) - return shortcuts_by_command - - def _format_shortcut(self, raw_shortcut): - shortcut_repeat, raw_keys = raw_shortcut - keys = list(raw_keys) - formatted_keys = [] - repeat_prefix = "" - if shortcut_repeat in range(2, 9): - repeat_prefix = _("{} times ").format(shortcut_repeat) - for key_name in [ - "KEY_FENRIR", - "KEY_SCRIPT", - "KEY_CTRL", - "KEY_SHIFT", - "KEY_ALT", - "KEY_META", - ]: - if key_name in keys: - formatted_keys.append(self._format_key_name(key_name)) - keys.remove(key_name) - formatted_keys.extend(self._format_key_name(key) for key in keys) - return repeat_prefix + ", ".join(formatted_keys) - - def _format_key_name(self, key_name): - formatted_key = key_name.lower() - formatted_key = formatted_key.replace("key_kp", "keypad ") - formatted_key = formatted_key.replace("key_", "") - return _(formatted_key.strip()) - - def get_formatted_shortcut_for_command(self, command): - shortcuts = self._get_shortcuts_by_command().get(command, []) - return "; ".join( - self._format_shortcut(shortcut) for shortcut in shortcuts - ) - - def get_command_help_text(self, command, shortcuts=None): - command_name = command.lower().split("__-__")[0].replace("_", " ") - if command == "TOGGLE_TUTORIAL_MODE": - command_description = _("toggles the tutorial mode") - else: - command_description = self.env["runtime"][ - "CommandManager" - ].get_command_description(command, section="commands") - if command_description == "": - command_description = _("no description available") - if shortcuts is None: - shortcuts = self._get_shortcuts_by_command().get(command, []) - command_shortcuts = "; ".join( - self._format_shortcut(shortcut) for shortcut in shortcuts - ) - if command_shortcuts == "": - command_shortcuts = _("unbound") - return _( - "{command_name}, Shortcuts {command_shortcuts}, Description " - "{command_description}" - ).format( - command_name=command_name, - command_shortcuts=command_shortcuts, - command_description=command_description, - ) - - def create_help_dict(self, section="commands"): - shortcuts_by_command = self._get_shortcuts_by_command() - self.help_lists = { - self.ACTIVE_SECTION: [], - self.UNBOUND_SECTION: [], - self.PLUGINS_SECTION: [], - } - for command in sorted(self.env["commands"][section]): - command_instance = self.env["commands"][section][command] - if not getattr(command_instance, "help_visible", True): - continue - shortcuts = shortcuts_by_command.get(command, []) - help_text = self.get_command_help_text(command, shortcuts) - if any("KEY_SCRIPT" in shortcut[1] for shortcut in shortcuts): - target_section = self.PLUGINS_SECTION - elif shortcuts: - target_section = self.ACTIVE_SECTION - else: - target_section = self.UNBOUND_SECTION - self.help_lists[target_section].append(help_text) - self.help_section = self.ACTIVE_SECTION - self.tutorial_list_index = None - - def get_help_section_name(self): - if self.help_section == self.PLUGINS_SECTION: - return _("Plugins") - if self.help_section == self.UNBOUND_SECTION: - return _("Unbound actions") - return _("Active actions") - - def select_help_section(self, section): - if section not in self.help_lists: - return self.get_help_section_name() - self.help_section = section - self.tutorial_list_index = None - return self.get_help_section_name() - - def next_help_section(self): - section_index = self.HELP_SECTIONS.index(self.help_section) - section_index = (section_index + 1) % len(self.HELP_SECTIONS) - return self.select_help_section(self.HELP_SECTIONS[section_index]) - - def prev_help_section(self): - section_index = self.HELP_SECTIONS.index(self.help_section) - section_index = (section_index - 1) % len(self.HELP_SECTIONS) - return self.select_help_section(self.HELP_SECTIONS[section_index]) - - def get_help_for_current_index(self): - entries = self.help_lists[self.help_section] - if self.tutorial_list_index is None or not entries: - return self.get_help_section_name() - return entries[self.tutorial_list_index] - - def next_index(self): - entries = self.help_lists[self.help_section] - if not entries: - self.tutorial_list_index = None - return - if self.tutorial_list_index is None: - self.tutorial_list_index = 0 - return - self.tutorial_list_index = (self.tutorial_list_index + 1) % len( - entries - ) - - def prev_index(self): - entries = self.help_lists[self.help_section] - if not entries: - self.tutorial_list_index = None - return - if self.tutorial_list_index is None: - self.tutorial_list_index = len(entries) - 1 - return - self.tutorial_list_index = (self.tutorial_list_index - 1) % len( - entries - ) diff --git a/src/fenrirscreenreader/core/i18n.py b/src/fenrirscreenreader/core/i18n.py deleted file mode 100644 index 55374e69..00000000 --- a/src/fenrirscreenreader/core/i18n.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import gettext - -# Singular and plural messages must use the same domain and language selection. -_translation = gettext.translation("fenrir", fallback=True) -_translation.install() - -# Make _ available for import to satisfy static analysis tools -_ = _translation.gettext -ngettext = _translation.ngettext diff --git a/src/fenrirscreenreader/core/inputData.py b/src/fenrirscreenreader/core/inputData.py deleted file mode 100644 index b4e55c78..00000000 --- a/src/fenrirscreenreader/core/inputData.py +++ /dev/null @@ -1,604 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import time - -from fenrirscreenreader.core import debug - -input_data = { - "curr_input": [], - "prev_input": [], - "prev_deepest_input": [], - "event_buffer": [], - "shortcut_repeat": 0, - "fenrir_key": [], - "script_key": [], - "key_forward": 0, - "last_input_time": time.time(), - "old_num_lock": True, - "new_num_lock": True, - "old_scroll_lock": True, - "new_scroll_lock": True, - "old_caps_lock": False, - "new_caps_lock": False, -} - -input_event = { - "event_name": "", - "event_value": "", - "event_sec": 0, - "event_usec": 0, - "event_state": 0, - "event_type": 0, -} - -key_names = [ - "KEY_RESERVED", - "KEY_ESC", - "KEY_1", - "KEY_2", - "KEY_3", - "KEY_4", - "KEY_5", - "KEY_6", - "KEY_7", - "KEY_8", - "KEY_9", - "KEY_0", - "KEY_MINUS", - "KEY_EQUAL", - "KEY_BACKSPACE", - "KEY_TAB", - "KEY_Q", - "KEY_W", - "KEY_E", - "KEY_R", - "KEY_T", - "KEY_Y", - "KEY_U", - "KEY_I", - "KEY_O", - "KEY_P", - "KEY_LEFTBRACE", - "KEY_RIGHTBRACE", - "KEY_ENTER", - "KEY_LEFTCTRL", - "KEY_A", - "KEY_S", - "KEY_D", - "KEY_F", - "KEY_G", - "KEY_H", - "KEY_J", - "KEY_K", - "KEY_L", - "KEY_SEMICOLON", - "KEY_APOSTROPHE", - "KEY_GRAVE", - "KEY_LEFTSHIFT", - "KEY_BACKSLASH", - "KEY_Z", - "KEY_X", - "KEY_C", - "KEY_V", - "KEY_B", - "KEY_N", - "KEY_M", - "KEY_COMMA", - "KEY_DOT", - "KEY_SLASH", - "KEY_RIGHTSHIFT", - "KEY_KPASTERISK", - "KEY_LEFTALT", - "KEY_SPACE", - "KEY_CAPSLOCK", - "KEY_F1", - "KEY_F2", - "KEY_F3", - "KEY_F4", - "KEY_F5", - "KEY_F6", - "KEY_F7", - "KEY_F8", - "KEY_F9", - "KEY_F10", - "KEY_NUMLOCK", - "KEY_SCROLLLOCK", - "KEY_KP7", - "KEY_KP8", - "KEY_KP9", - "KEY_KPMINUS", - "KEY_KP4", - "KEY_KP5", - "KEY_KP6", - "KEY_KPPLUS", - "KEY_KP1", - "KEY_KP2", - "KEY_KP3", - "KEY_KP0", - "KEY_KPDOT", - "KEY_ZENKAKUHANKAKU", - "KEY_102ND", - "KEY_F11", - "KEY_F12", - "KEY_RO", - "KEY_KATAKANA", - "KEY_HIRAGANA", - "KEY_HENKAN", - "KEY_KATAKANAHIRAGANA", - "KEY_MUHENKAN", - "KEY_KPJPCOMMA", - "KEY_KPENTER", - "KEY_RIGHTCTRL", - "KEY_KPSLASH", - "KEY_SYSRQ", - "KEY_RIGHTALT", - "KEY_LINEFEED", - "KEY_HOME", - "KEY_UP", - "KEY_PAGEUP", - "KEY_LEFT", - "KEY_RIGHT", - "KEY_END", - "KEY_DOWN", - "KEY_PAGEDOWN", - "KEY_INSERT", - "KEY_DELETE", - "KEY_MACRO", - "KEY_MIN_INTERESTING", - "KEY_MUTE", - "KEY_VOLUMEDOWN", - "KEY_VOLUMEUP", - "KEY_POWER", - "KEY_KPEQUAL", - "KEY_KPPLUSMINUS", - "KEY_PAUSE", - "KEY_SCALE", - "KEY_KPCOMMA", - "KEY_HANGEUL", - "KEY_HANGUEL", - "KEY_HANJA", - "KEY_YEN", - "KEY_LEFTMETA", - "KEY_RIGHTMETA", - "KEY_COMPOSE", - "KEY_STOP", - "KEY_AGAIN", - "KEY_PROPS", - "KEY_UNDO", - "KEY_FRONT", - "KEY_COPY", - "KEY_OPEN", - "KEY_PASTE", - "KEY_FIND", - "KEY_CUT", - "KEY_HELP", - "KEY_MENU", - "KEY_CALC", - "KEY_SETUP", - "KEY_SLEEP", - "KEY_WAKEUP", - "KEY_FILE", - "KEY_SENDFILE", - "KEY_DELETEFILE", - "KEY_XFER", - "KEY_PROG1", - "KEY_PROG2", - "KEY_WWW", - "KEY_MSDOS", - "KEY_COFFEE", - "KEY_SCREENLOCK", - "KEY_DIRECTION", - "KEY_ROTATE_DISPLAY", - "KEY_CYCLEWINDOWS", - "KEY_MAIL", - "KEY_BOOKMARKS", - "KEY_COMPUTER", - "KEY_BACK", - "KEY_FORWARD", - "KEY_CLOSECD", - "KEY_EJECTCD", - "KEY_EJECTCLOSECD", - "KEY_NEXTSONG", - "KEY_PLAYPAUSE", - "KEY_PREVIOUSSONG", - "KEY_STOPCD", - "KEY_RECORD", - "KEY_REWIND", - "KEY_PHONE", - "KEY_ISO", - "KEY_CONFIG", - "KEY_HOMEPAGE", - "KEY_REFRESH", - "KEY_EXIT", - "KEY_MOVE", - "KEY_EDIT", - "KEY_SCROLLUP", - "KEY_SCROLLDOWN", - "KEY_KPLEFTPAREN", - "KEY_KPRIGHTPAREN", - "KEY_NEW", - "KEY_REDO", - "KEY_F13", - "KEY_F14", - "KEY_F15", - "KEY_F16", - "KEY_F17", - "KEY_F18", - "KEY_F19", - "KEY_F20", - "KEY_F21", - "KEY_F22", - "KEY_F23", - "KEY_F24", - "KEY_PLAYCD", - "KEY_PAUSECD", - "KEY_PROG3", - "KEY_PROG4", - "KEY_DASHBOARD", - "KEY_SUSPEND", - "KEY_CLOSE", - "KEY_PLAY", - "KEY_FASTFORWARD", - "KEY_BASSBOOST", - "KEY_PRINT", - "KEY_HP", - "KEY_CAMERA", - "KEY_SOUND", - "KEY_QUESTION", - "KEY_EMAIL", - "KEY_CHAT", - "KEY_SEARCH", - "KEY_CONNECT", - "KEY_FINANCE", - "KEY_SPORT", - "KEY_SHOP", - "KEY_ALTERASE", - "KEY_CANCEL", - "KEY_BRIGHTNESSDOWN", - "KEY_BRIGHTNESSUP", - "KEY_MEDIA", - "KEY_SWITCHVIDEOMODE", - "KEY_KBDILLUMTOGGLE", - "KEY_KBDILLUMDOWN", - "KEY_KBDILLUMUP", - "KEY_SEND", - "KEY_REPLY", - "KEY_FORWARDMAIL", - "KEY_SAVE", - "KEY_DOCUMENTS", - "KEY_BATTERY", - "KEY_BLUETOOTH", - "KEY_WLAN", - "KEY_UWB", - "KEY_UNKNOWN", - "KEY_VIDEO_NEXT", - "KEY_VIDEO_PREV", - "KEY_BRIGHTNESS_CYCLE", - "KEY_BRIGHTNESS_AUTO", - "KEY_BRIGHTNESS_ZERO", - "KEY_DISPLAY_OFF", - "KEY_WIMAX", - "KEY_WWAN", - "KEY_RFKILL", - "KEY_MICMUTE", - "BTN_0", - "BTN_MISC", - "BTN_1", - "BTN_2", - "BTN_3", - "BTN_4", - "BTN_5", - "BTN_6", - "BTN_7", - "BTN_8", - "BTN_9", - "BTN_LEFT", - "BTN_MOUSE", - "BTN_RIGHT", - "BTN_MIDDLE", - "BTN_SIDE", - "BTN_EXTRA", - "BTN_FORWARD", - "BTN_BACK", - "BTN_TASK", - "BTN_JOYSTICK", - "BTN_TRIGGER", - "BTN_THUMB", - "BTN_THUMB2", - "BTN_TOP", - "BTN_TOP2", - "BTN_PINKIE", - "BTN_BASE", - "BTN_BASE2", - "BTN_BASE3", - "BTN_BASE4", - "BTN_BASE5", - "BTN_BASE6", - "BTN_DEAD", - "BTN_A", - "BTN_GAMEPAD", - "BTN_SOUTH", - "BTN_B", - "BTN_EAST", - "BTN_C", - "BTN_NORTH", - "BTN_X", - "BTN_WEST", - "BTN_Y", - "BTN_Z", - "BTN_TL", - "BTN_TR", - "BTN_TL2", - "BTN_TR2", - "BTN_SELECT", - "BTN_START", - "BTN_MODE", - "BTN_THUMBL", - "BTN_THUMBR", - "BTN_DIGI", - "BTN_TOOL_PEN", - "BTN_TOOL_RUBBER", - "BTN_TOOL_BRUSH", - "BTN_TOOL_PENCIL", - "BTN_TOOL_AIRBRUSH", - "BTN_TOOL_FINGER", - "BTN_TOOL_MOUSE", - "BTN_TOOL_LENS", - "BTN_TOOL_QUINTTAP", - "BTN_TOUCH", - "BTN_STYLUS", - "BTN_STYLUS2", - "BTN_TOOL_DOUBLETAP", - "BTN_TOOL_TRIPLETAP", - "BTN_TOOL_QUADTAP", - "BTN_GEAR_DOWN", - "BTN_WHEEL", - "BTN_GEAR_UP", - "KEY_OK", - "KEY_SELECT", - "KEY_GOTO", - "KEY_CLEAR", - "KEY_POWER2", - "KEY_OPTION", - "KEY_INFO", - "KEY_TIME", - "KEY_VENDOR", - "KEY_ARCHIVE", - "KEY_PROGRAM", - "KEY_CHANNEL", - "KEY_FAVORITES", - "KEY_EPG", - "KEY_PVR", - "KEY_MHP", - "KEY_LANGUAGE", - "KEY_TITLE", - "KEY_SUBTITLE", - "KEY_ANGLE", - "KEY_ZOOM", - "KEY_MODE", - "KEY_KEYBOARD", - "KEY_SCREEN", - "KEY_PC", - "KEY_TV", - "KEY_TV2", - "KEY_VCR", - "KEY_VCR2", - "KEY_SAT", - "KEY_SAT2", - "KEY_CD", - "KEY_TAPE", - "KEY_RADIO", - "KEY_TUNER", - "KEY_PLAYER", - "KEY_TEXT", - "KEY_DVD", - "KEY_AUX", - "KEY_MP3", - "KEY_AUDIO", - "KEY_VIDEO", - "KEY_DIRECTORY", - "KEY_LIST", - "KEY_MEMO", - "KEY_CALENDAR", - "KEY_RED", - "KEY_GREEN", - "KEY_YELLOW", - "KEY_BLUE", - "KEY_CHANNELUP", - "KEY_CHANNELDOWN", - "KEY_FIRST", - "KEY_LAST", - "KEY_AB", - "KEY_NEXT", - "KEY_RESTART", - "KEY_SLOW", - "KEY_SHUFFLE", - "KEY_BREAK", - "KEY_PREVIOUS", - "KEY_DIGITS", - "KEY_TEEN", - "KEY_TWEN", - "KEY_VIDEOPHONE", - "KEY_GAMES", - "KEY_ZOOMIN", - "KEY_ZOOMOUT", - "KEY_ZOOMRESET", - "KEY_WORDPROCESSOR", - "KEY_EDITOR", - "KEY_SPREADSHEET", - "KEY_GRAPHICSEDITOR", - "KEY_PRESENTATION", - "KEY_DATABASE", - "KEY_NEWS", - "KEY_VOICEMAIL", - "KEY_ADDRESSBOOK", - "KEY_MESSENGER", - "KEY_BRIGHTNESS_TOGGLE", - "KEY_DISPLAYTOGGLE", - "KEY_SPELLCHECK", - "KEY_LOGOFF", - "KEY_DOLLAR", - "KEY_EURO", - "KEY_FRAMEBACK", - "KEY_FRAMEFORWARD", - "KEY_CONTEXT_MENU", - "KEY_MEDIA_REPEAT", - "KEY_10CHANNELSUP", - "KEY_10CHANNELSDOWN", - "KEY_IMAGES", - "KEY_DEL_EOL", - "KEY_DEL_EOS", - "KEY_INS_LINE", - "KEY_DEL_LINE", - "KEY_FN", - "KEY_FN_ESC", - "KEY_FN_F1", - "KEY_FN_F2", - "KEY_FN_F3", - "KEY_FN_F4", - "KEY_FN_F5", - "KEY_FN_F6", - "KEY_FN_F7", - "KEY_FN_F8", - "KEY_FN_F9", - "KEY_FN_F10", - "KEY_FN_F11", - "KEY_FN_F12", - "KEY_FN_1", - "KEY_FN_2", - "KEY_FN_D", - "KEY_FN_E", - "KEY_FN_F", - "KEY_FN_S", - "KEY_FN_B", - "KEY_BRL_DOT1", - "KEY_BRL_DOT2", - "KEY_BRL_DOT3", - "KEY_BRL_DOT4", - "KEY_BRL_DOT5", - "KEY_BRL_DOT6", - "KEY_BRL_DOT7", - "KEY_BRL_DOT8", - "KEY_BRL_DOT9", - "KEY_BRL_DOT10", - "KEY_NUMERIC_0", - "KEY_NUMERIC_1", - "KEY_NUMERIC_2", - "KEY_NUMERIC_3", - "KEY_NUMERIC_4", - "KEY_NUMERIC_5", - "KEY_NUMERIC_6", - "KEY_NUMERIC_7", - "KEY_NUMERIC_8", - "KEY_NUMERIC_9", - "KEY_NUMERIC_STAR", - "KEY_NUMERIC_POUND", - "KEY_NUMERIC_A", - "KEY_NUMERIC_B", - "KEY_NUMERIC_C", - "KEY_NUMERIC_D", - "KEY_CAMERA_FOCUS", - "KEY_WPS_BUTTON", - "KEY_TOUCHPAD_TOGGLE", - "KEY_TOUCHPAD_ON", - "KEY_TOUCHPAD_OFF", - "KEY_CAMERA_ZOOMIN", - "KEY_CAMERA_ZOOMOUT", - "KEY_CAMERA_UP", - "KEY_CAMERA_DOWN", - "KEY_CAMERA_LEFT", - "KEY_CAMERA_RIGHT", - "KEY_ATTENDANT_ON", - "KEY_ATTENDANT_OFF", - "KEY_ATTENDANT_TOGGLE", - "KEY_LIGHTS_TOGGLE", - "BTN_DPAD_UP", - "BTN_DPAD_DOWN", - "BTN_DPAD_LEFT", - "BTN_DPAD_RIGHT", - "KEY_ALS_TOGGLE", - "KEY_BUTTONCONFIG", - "KEY_TASKMANAGER", - "KEY_JOURNAL", - "KEY_CONTROLPANEL", - "KEY_APPSELECT", - "KEY_SCREENSAVER", - "KEY_VOICECOMMAND", - "KEY_BRIGHTNESS_MIN", - "KEY_BRIGHTNESS_MAX", - "KEY_KBDINPUTASSIST_PREV", - "KEY_KBDINPUTASSIST_NEXT", - "KEY_KBDINPUTASSIST_PREVGROUP", - "KEY_KBDINPUTASSIST_NEXTGROUP", - "KEY_KBDINPUTASSIST_ACCEPT", - "KEY_KBDINPUTASSIST_CANCEL", - "KEY_RIGHT_UP", - "KEY_RIGHT_DOWN", - "KEY_LEFT_UP", - "KEY_LEFT_DOWN", - "KEY_ROOT_MENU", - "KEY_MEDIA_TOP_MENU", - "KEY_NUMERIC_11", - "KEY_NUMERIC_12", - "KEY_AUDIO_DESC", - "KEY_3D_MODE", - "KEY_NEXT_FAVORITE", - "KEY_STOP_RECORD", - "KEY_PAUSE_RECORD", - "KEY_VOD", - "KEY_UNMUTE", - "KEY_DATA", - "KEY_FASTREVERSE", - "KEY_SLOWREVERSE", - "BTN_TRIGGER_HAPPY", - "BTN_TRIGGER_HAPPY1", - "BTN_TRIGGER_HAPPY2", - "BTN_TRIGGER_HAPPY3", - "BTN_TRIGGER_HAPPY4", - "BTN_TRIGGER_HAPPY5", - "BTN_TRIGGER_HAPPY6", - "BTN_TRIGGER_HAPPY7", - "BTN_TRIGGER_HAPPY8", - "BTN_TRIGGER_HAPPY9", - "BTN_TRIGGER_HAPPY10", - "BTN_TRIGGER_HAPPY11", - "BTN_TRIGGER_HAPPY12", - "BTN_TRIGGER_HAPPY13", - "BTN_TRIGGER_HAPPY14", - "BTN_TRIGGER_HAPPY15", - "BTN_TRIGGER_HAPPY16", - "BTN_TRIGGER_HAPPY17", - "BTN_TRIGGER_HAPPY18", - "BTN_TRIGGER_HAPPY19", - "BTN_TRIGGER_HAPPY20", - "BTN_TRIGGER_HAPPY21", - "BTN_TRIGGER_HAPPY22", - "BTN_TRIGGER_HAPPY23", - "BTN_TRIGGER_HAPPY24", - "BTN_TRIGGER_HAPPY25", - "BTN_TRIGGER_HAPPY26", - "BTN_TRIGGER_HAPPY27", - "BTN_TRIGGER_HAPPY28", - "BTN_TRIGGER_HAPPY29", - "BTN_TRIGGER_HAPPY30", - "BTN_TRIGGER_HAPPY31", - "BTN_TRIGGER_HAPPY32", - "BTN_TRIGGER_HAPPY33", - "BTN_TRIGGER_HAPPY34", - "BTN_TRIGGER_HAPPY35", - "BTN_TRIGGER_HAPPY36", - "BTN_TRIGGER_HAPPY37", - "BTN_TRIGGER_HAPPY38", - "BTN_TRIGGER_HAPPY39", - "BTN_TRIGGER_HAPPY40", - "KEY_FENRIR", - "KEY_SCRIPT", - "KEY_ALT", - "KEY_CTRL", - "KEY_META", - "KEY_SHIFT", -] diff --git a/src/fenrirscreenreader/core/inputDriver.py b/src/fenrirscreenreader/core/inputDriver.py deleted file mode 100644 index df90e1f8..00000000 --- a/src/fenrirscreenreader/core/inputDriver.py +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import time - -from fenrirscreenreader.core import debug - - -class InputDriver: - def __init__(self): - self._initialized = False - - def initialize(self, environment): - self.env = environment - self._is_initialized = True - - def shutdown(self): - if self._initialized: - self.remove_all_devices() - self._is_initialized = False - - def get_input_event(self): - time.sleep(0.1) - return None - - def is_active(self): - """Return whether this input driver's target currently has focus.""" - return True - - def clear_event_buffer(self): - if not self._initialized: - return - del self.env["input"]["event_buffer"][:] - - def update_input_devices(self, new_devices=None, init=False): - if not self._initialized: - return - - def get_led_state(self, led=0): - if not self._initialized: - return False - return False - - def toggle_led_state(self, led=0): - if not self._initialized: - return - - def grab_all_devices(self): - if not self._initialized: - return True - return True - - def ungrab_all_devices(self): - if not self._initialized: - return True - return True - - def set_help_capture(self, enabled): - return True - - def force_ungrab(self): - """Emergency method to release grabbed devices in case of failure""" - if not self._initialized: - return True - try: - # Try standard ungrab first - return self.ungrab_all_devices() - except Exception as e: - # Just log the failure and inform the user - if ( - hasattr(self, "env") - and "runtime" in self.env - and "DebugManager" in self.env["runtime"] - ): - self.env["runtime"]["DebugManager"].write_debug_out( - f"Emergency device release failed: {str(e)}", - debug.DebugLevel.ERROR, - ) - else: - print(f"Emergency device release failed: {str(e)}") - return False - - def has_i_devices(self): - if not self._initialized: - return False - return True - - def remove_all_devices(self): - if not self._initialized: - return - - def send_key(self): - if not self._initialized: - return - - def __del__(self): - if not self._initialized: - return - try: - self.remove_all_devices() - except Exception as e: - print(f"InputDriver __del__: Error removing devices: {str(e)}") diff --git a/src/fenrirscreenreader/core/inputManager.py b/src/fenrirscreenreader/core/inputManager.py deleted file mode 100644 index 29680e0c..00000000 --- a/src/fenrirscreenreader/core/inputManager.py +++ /dev/null @@ -1,640 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import inspect -import os -import time -import traceback - -from fenrirscreenreader.core import debug -from fenrirscreenreader.core import inputData -from fenrirscreenreader.core.i18n import _ - -currentdir = os.path.dirname( - os.path.realpath(os.path.abspath(inspect.getfile(inspect.currentframe()))) -) -fenrir_path = os.path.dirname(currentdir) - -SHORTCUT_MODIFIER_KEYS = { - "KEY_ALT", - "KEY_CTRL", - "KEY_META", - "KEY_SHIFT", -} - - -class InputManager: - def __init__(self): - self.executeDeviceGrab = False - self.lastDetectedDevices = None - - def initialize(self, environment): - self.env = environment - self.env["runtime"]["SettingsManager"].load_driver( - self.env["runtime"]["SettingsManager"].get_setting( - "keyboard", "driver" - ), - "InputDriver", - ) - self.update_input_devices() - - # init LEDs with current state - self.env["input"]["new_num_lock"] = self.env["runtime"][ - "InputDriver" - ].get_led_state() - self.env["input"]["old_num_lock"] = self.env["input"]["new_num_lock"] - self.env["input"]["new_caps_lock"] = self.env["runtime"][ - "InputDriver" - ].get_led_state(1) - self.env["input"]["old_caps_lock"] = self.env["input"]["new_caps_lock"] - self.env["input"]["new_scroll_lock"] = self.env["runtime"][ - "InputDriver" - ].get_led_state(2) - self.env["input"]["old_scroll_lock"] = self.env["input"]["new_scroll_lock"] - self.lastDeepestInput = [] - self.lastEvent = None - self.env["input"]["shortcut_repeat"] = 1 - self.lastInputTime = time.time() - - def shutdown(self): - self.remove_all_devices() - self.env["runtime"]["SettingsManager"].shutdown_driver("InputDriver") - - def get_input_event(self): - event = None - try: - event = self.env["runtime"]["InputDriver"].get_input_event() - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "InputManager get_input_event: Error getting input event: " - + str(e), - debug.DebugLevel.ERROR, - ) - return event - - def set_execute_device_grab(self, newExecuteDeviceGrab=True): - self.executeDeviceGrab = newExecuteDeviceGrab - - def handle_device_grab(self, force=False): - if force: - self.set_execute_device_grab() - if not self.executeDeviceGrab: - return - if self.env["input"]["event_buffer"] != []: - return - if not self.no_key_pressed(): - return - if not self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "keyboard", "grab_devices" - ): - self.executeDeviceGrab = False - return - - # Add maximum retries to prevent infinite loops - max_retries = 5 - retry_count = 0 - grab_timeout = 3 # Timeout in seconds - start_time = time.time() - - if self.env["runtime"]["ScreenManager"].get_curr_screen_ignored(): - while not self.ungrab_all_devices(): - retry_count += 1 - if ( - retry_count >= max_retries - or (time.time() - start_time) > grab_timeout - ): - self.env["runtime"]["DebugManager"].write_debug_out( - "Failed to ungrab devices after multiple attempts", - debug.DebugLevel.ERROR, - ) - # Force a release of devices if possible through - # alternative means - try: - self.env["runtime"]["InputDriver"].force_ungrab() - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "InputManager handle_device_grab: Error forcing ungrab: " - + str(e), - debug.DebugLevel.ERROR, - ) - break - time.sleep(0.25) - self.env["runtime"]["DebugManager"].write_debug_out( - f"retry ungrab_all_devices {retry_count}/{max_retries}", - debug.DebugLevel.WARNING, - ) - else: - while not self.grab_all_devices(): - retry_count += 1 - if ( - retry_count >= max_retries - or (time.time() - start_time) > grab_timeout - ): - self.env["runtime"]["DebugManager"].write_debug_out( - "Failed to grab devices after multiple attempts", - debug.DebugLevel.ERROR, - ) - # Continue without grabbing input - limited functionality - # but not locked - break - time.sleep(0.25) - self.env["runtime"]["DebugManager"].write_debug_out( - f"retry grab_all_devices {retry_count}/{max_retries}", - debug.DebugLevel.WARNING, - ) - - self.executeDeviceGrab = False - - def send_keys(self, keyMacro): - for e in keyMacro: - key = "" - value = 0 - if len(e) != 2: - continue - if isinstance(e[0], int) and isinstance(e[1], str): - key = e[1].upper() - value = e[0] - elif isinstance(e[1], int) and isinstance(e[0], str): - key = e[0].upper() - value = e[1] - else: - continue - if key.upper() == "SLEEP": - time.sleep(value) - else: - self.env["runtime"]["InputDriver"].send_key(key, value) - - def get_last_event(self): - return self.lastEvent - - def record_unmanaged_keypress(self, event_name): - self.lastEvent = { - "event_name": event_name, - "event_state": 1, - } - self.set_last_deepest_input([event_name]) - self.lastInputTime = time.time() - - def handle_input_event(self, event_data): - if not event_data: - return - self.lastEvent = event_data - # a hang apears.. try to fix - if self.env["input"]["event_buffer"] == []: - if self.env["input"]["curr_input"] != []: - self.env["input"]["curr_input"] = [] - self.env["input"]["shortcut_repeat"] = 1 - - self.env["input"]["prev_input"] = self.env["input"]["curr_input"].copy() - if event_data["event_state"] == 0: - if event_data["event_name"] in self.env["input"]["curr_input"]: - self.env["input"]["curr_input"].remove(event_data["event_name"]) - if len(self.env["input"]["curr_input"]) > 1: - self.env["input"]["curr_input"] = sorted( - self.env["input"]["curr_input"] - ) - self.lastInputTime = time.time() - elif event_data["event_state"] == 1: - if not event_data["event_name"] in self.env["input"]["curr_input"]: - self.env["input"]["curr_input"].append(event_data["event_name"]) - if len(self.env["input"]["curr_input"]) > 1: - self.env["input"]["curr_input"] = sorted( - self.env["input"]["curr_input"] - ) - if self.lastDeepestInput == self.env["input"]["curr_input"]: - if time.time() - self.lastInputTime <= self.env["runtime"][ - "SettingsManager" - ].get_setting_as_float("keyboard", "double_tap_timeout"): - self.env["input"]["shortcut_repeat"] += 1 - else: - self.env["input"]["shortcut_repeat"] = 1 - else: - self.env["input"]["shortcut_repeat"] = 1 - self.set_last_deepest_input( - self.env["input"]["curr_input"].copy() - ) - self.handle_led_states(event_data) - self.lastInputTime = time.time() - elif event_data["event_state"] == 2: - self.lastInputTime = time.time() - - self.env["input"]["old_num_lock"] = self.env["input"]["new_num_lock"] - self.env["input"]["new_num_lock"] = self.env["runtime"][ - "InputDriver" - ].get_led_state() - self.env["input"]["old_caps_lock"] = self.env["input"]["new_caps_lock"] - self.env["input"]["new_caps_lock"] = self.env["runtime"][ - "InputDriver" - ].get_led_state(1) - self.env["input"]["old_scroll_lock"] = self.env["input"]["new_scroll_lock"] - self.env["input"]["new_scroll_lock"] = self.env["runtime"][ - "InputDriver" - ].get_led_state(2) - self.env["runtime"]["DebugManager"].write_debug_out( - "curr_input " + str(self.env["input"]["curr_input"]), - debug.DebugLevel.INFO, - ) - if self.no_key_pressed(): - self.env["input"]["prev_input"] = [] - - def handle_led_states(self, m_event): - if self.curr_key_is_modifier(): - return - try: - if m_event["event_name"] == "KEY_NUMLOCK": - self.env["runtime"]["InputDriver"].toggle_led_state() - elif m_event["event_name"] == "KEY_CAPSLOCK": - self.env["runtime"]["InputDriver"].toggle_led_state(1) - elif m_event["event_name"] == "KEY_SCROLLLOCK": - self.env["runtime"]["InputDriver"].toggle_led_state(2) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "InputManager handle_led_states: Error toggling LED state: " - + str(e), - debug.DebugLevel.ERROR, - ) - - def grab_all_devices(self): - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "keyboard", "grab_devices" - ): - try: - return self.env["runtime"]["InputDriver"].grab_all_devices() - except Exception as e: - return False - return True - - def ungrab_all_devices(self): - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "keyboard", "grab_devices" - ): - try: - return self.env["runtime"]["InputDriver"].ungrab_all_devices() - except Exception as e: - return False - return True - - def set_help_capture(self, enabled): - try: - return self.env["runtime"]["InputDriver"].set_help_capture( - enabled - ) - except Exception as error: - self.env["runtime"]["DebugManager"].write_debug_out( - "InputManager could not change help capture: " + str(error), - debug.DebugLevel.ERROR, - ) - return not enabled - - def handle_plug_input_device(self, event_data): - for deviceEntry in event_data: - self.update_input_devices(deviceEntry["device"]) - - def update_input_devices(self, newDevice=None): - try: - self.env["runtime"]["InputDriver"].update_input_devices(newDevice) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "InputManager update_input_devices: Error updating input devices: " - + str(e), - debug.DebugLevel.ERROR, - ) - try: - if self.env["runtime"]["ScreenManager"]: - self.handle_device_grab(force=True) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "InputManager update_input_devices: Error handling device grab: " - + str(e), - debug.DebugLevel.ERROR, - ) - - def remove_all_devices(self): - try: - self.env["runtime"]["InputDriver"].remove_all_devices() - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "InputManager remove_all_devices: Error removing devices: " - + str(e), - debug.DebugLevel.ERROR, - ) - - def convert_event_name(self, event_name): - if not event_name: - return "" - if event_name == "": - return "" - event_name = event_name.upper() - if event_name == "KEY_LEFTCTRL": - event_name = "KEY_CTRL" - elif event_name == "KEY_RIGHTCTRL": - event_name = "KEY_CTRL" - elif event_name == "KEY_LEFTSHIFT": - event_name = "KEY_SHIFT" - elif event_name == "KEY_RIGHTSHIFT": - event_name = "KEY_SHIFT" - elif event_name == "KEY_LEFTALT": - event_name = "KEY_ALT" - elif event_name == "KEY_RIGHTALT": - event_name = "KEY_ALT" - elif event_name == "KEY_LEFTMETA": - event_name = "KEY_META" - elif event_name == "KEY_RIGHTMETA": - event_name = "KEY_META" - if self.is_fenrir_key(event_name): - event_name = "KEY_FENRIR" - if self.is_script_key(event_name): - event_name = "KEY_SCRIPT" - return event_name - - def clear_event_buffer(self): - try: - self.env["runtime"]["InputDriver"].clear_event_buffer() - except Exception as e: - pass - - def reset_input_state(self): - self.env["input"]["curr_input"] = [] - self.env["input"]["prev_input"] = [] - self.env["input"]["event_buffer"] = [] - self.env["input"]["shortcut_repeat"] = 1 - self.clear_last_deep_input() - self.clear_event_buffer() - - def set_last_deepest_input(self, currentDeepestInput): - self.lastDeepestInput = currentDeepestInput - - def clear_last_deep_input(self): - self.lastDeepestInput = [] - - def get_last_input_time(self): - return self.lastInputTime - - def get_last_deepest_input(self): - return self.lastDeepestInput - - def write_event_buffer(self): - try: - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "keyboard", "grab_devices" - ): - self.env["runtime"]["InputDriver"].write_event_buffer() - self.clear_event_buffer() - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "Error while write_u_input", debug.DebugLevel.ERROR - ) - self.env["runtime"]["DebugManager"].write_debug_out( - str(e), debug.DebugLevel.ERROR - ) - - def no_key_pressed(self): - return self.env["input"]["curr_input"] == [] - - def is_key_press(self): - return (self.env["input"]["prev_input"] == []) and ( - self.env["input"]["curr_input"] != [] - ) - - def get_prev_deepest_shortcut(self): - shortcut = [] - shortcut.append(self.env["input"]["shortcut_repeat"]) - shortcut.append(self.get_last_deepest_input()) - return str(shortcut) - - def get_prev_shortcut(self): - shortcut = [] - shortcut.append(self.env["input"]["shortcut_repeat"]) - shortcut.append(self.env["input"]["prev_input"]) - return str(shortcut) - - def get_curr_shortcut(self, inputSequence=None): - shortcut = [] - shortcut.append(self.env["input"]["shortcut_repeat"]) - - numpad_keys = [ - "KEY_KP0", - "KEY_KP1", - "KEY_KP2", - "KEY_KP3", - "KEY_KP4", - "KEY_KP5", - "KEY_KP6", - "KEY_KP7", - "KEY_KP8", - "KEY_KP9", - "KEY_KPDOT", - "KEY_KPPLUS", - "KEY_KPMINUS", - "KEY_KPASTERISK", - "KEY_KPSLASH", - "KEY_KPENTER", - "KEY_KPEQUAL", - ] - if inputSequence: - # Check if any key in the sequence is a numpad key and numlock is ON - # If numlock is ON and any key in the sequence is a numpad key, - # return an empty shortcut - if not self.env["runtime"][ - "CursorManager" - ].should_process_numpad_commands(): - for key in inputSequence: - if key in numpad_keys: - # Return an empty/invalid shortcut that won't match any - # command - return "[]" - - shortcut.append(inputSequence) - else: - # Same check for current input - - if not self.env["runtime"][ - "CursorManager" - ].should_process_numpad_commands(): - for key in self.env["input"]["curr_input"]: - if key in numpad_keys: - # Return an empty/invalid shortcut that won't match any - # command - return "[]" - - shortcut.append(self.env["input"]["curr_input"]) - - if len(self.env["input"]["prev_input"]) < len( - self.env["input"]["curr_input"] - ): - if self.env["input"][ - "shortcut_repeat" - ] > 1 and not self.shortcut_exists(str(shortcut)): - shortcut = [] - self.env["input"]["shortcut_repeat"] = 1 - shortcut.append(self.env["input"]["shortcut_repeat"]) - shortcut.append(self.env["input"]["curr_input"]) - self.env["runtime"]["DebugManager"].write_debug_out( - "curr_shortcut " + str(shortcut), debug.DebugLevel.INFO - ) - return str(shortcut) - - def curr_key_is_modifier(self): - if len(self.get_last_deepest_input()) != 1: - return False - return (self.env["input"]["curr_input"][0] == "KEY_FENRIR") or ( - self.env["input"]["curr_input"][0] == "KEY_SCRIPT" - ) - - def curr_input_has_command_modifier(self): - current_input = self.env["input"]["curr_input"] - return "KEY_FENRIR" in current_input or "KEY_SCRIPT" in current_input - - def curr_input_is_modifier_prefix(self): - current_input = self.env["input"]["curr_input"] - return bool(current_input) and all( - key_name in SHORTCUT_MODIFIER_KEYS for key_name in current_input - ) - - def is_fenrir_key(self, event_name): - return event_name in self.env["input"]["fenrir_key"] - - def is_script_key(self, event_name): - return event_name in self.env["input"]["script_key"] - - def get_command_for_shortcut(self, shortcut): - if not self.shortcut_exists(shortcut): - return "" - return self.env["bindings"][shortcut] - - def key_echo(self, event_data=None): - if not event_data: - event_data = self.get_last_event() - if not event_data: - return - key_name = "" - if event_data["event_state"] == 1: - key_name = event_data["event_name"].lower() - if key_name.startswith("key_"): - key_name = key_name[4:] - self.env["runtime"]["OutputManager"].present_text( - _(key_name), interrupt=True - ) - - def shortcut_exists(self, shortcut): - return shortcut in self.env["bindings"] - - def load_shortcuts( - self, - kb_config_path=fenrir_path + "/../../config/keyboard/desktop.conf", - ): - kb_config = open(kb_config_path, "r") - while True: - invalid = False - line = kb_config.readline() - if not line: - break - line = line.replace("\n", "") - if line.replace(" ", "") == "": - continue - if line.replace(" ", "").startswith("#"): - continue - if line.count("=") != 1: - continue - sep_line = line.split("=") - command_name = sep_line[1].upper() - sep_line[0] = sep_line[0].replace(" ", "") - sep_line[0] = sep_line[0].replace("'", "") - sep_line[0] = sep_line[0].replace('"', "") - keys = sep_line[0].split(",") - shortcut_keys = [] - shortcut_repeat = 1 - shortcut = [] - for key in keys: - try: - shortcut_repeat = int(key) - except Exception as e: - if not self.is_valid_key(key.upper()): - self.env["runtime"]["DebugManager"].write_debug_out( - "invalid key : " - + key.upper() - + " command:" - + command_name, - debug.DebugLevel.WARNING, - ) - invalid = True - break - shortcut_keys.append(key.upper()) - if invalid: - continue - shortcut.append(shortcut_repeat) - shortcut.append(sorted(shortcut_keys)) - if len(shortcut_keys) != 1 and "KEY_FENRIR" not in shortcut_keys: - self.env["runtime"]["DebugManager"].write_debug_out( - "invalid shortcut (missing KEY_FENRIR): " - + str(shortcut) - + " command:" - + command_name, - debug.DebugLevel.ERROR, - ) - continue - self.env["runtime"]["DebugManager"].write_debug_out( - "Shortcut: " + str(shortcut) + " command:" + command_name, - debug.DebugLevel.INFO, - on_any_level=True, - ) - self.env["bindings"][str(shortcut)] = command_name - self.env["rawBindings"][str(shortcut)] = shortcut - kb_config.close() - # fix bindings - self.env["bindings"][ - str([1, ["KEY_F1", "KEY_FENRIR"]]) - ] = "TOGGLE_TUTORIAL_MODE" - self.env["rawBindings"][ - str([1, ["KEY_F1", "KEY_FENRIR"]]) - ] = [1, ["KEY_F1", "KEY_FENRIR"]] - - def is_valid_key(self, key): - return key in inputData.key_names - - def set_last_detected_devices(self, devices): - self.lastDetectedDevices = devices - - def get_last_detected_devices(self): - return self.lastDetectedDevices - - def reload_shortcuts(self): - """Reload keyboard shortcuts from current layout setting""" - # Clear existing bindings - self.env["bindings"].clear() - self.env["rawBindings"].clear() - - # Get current layout path - layout_setting = self.env["runtime"]["SettingsManager"].get_setting( - "keyboard", "keyboard_layout" - ) - - # Resolve full path if needed - if not os.path.exists(layout_setting): - settings_root = "/etc/fenrirscreenreader/" - if not os.path.exists(settings_root): - import fenrirscreenreader - - fenrir_path = os.path.dirname(fenrirscreenreader.__file__) - settings_root = fenrir_path + "/../../config/" - - layout_path = ( - settings_root + "keyboard/" + layout_setting + ".conf" - ) - if not os.path.exists(layout_path): - # Fallback to default if layout not found - layout_path = settings_root + "keyboard/desktop.conf" - else: - layout_path = layout_setting - - # Reload shortcuts - self.load_shortcuts(layout_path) - - self.env["runtime"]["DebugManager"].write_debug_out( - "Reloaded shortcuts from: " + layout_path, - debug.DebugLevel.INFO, - on_any_level=True, - ) diff --git a/src/fenrirscreenreader/core/memoryManager.py b/src/fenrirscreenreader/core/memoryManager.py deleted file mode 100644 index 23df4ff7..00000000 --- a/src/fenrirscreenreader/core/memoryManager.py +++ /dev/null @@ -1,176 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import time - -from fenrirscreenreader.core import debug - - -class MemoryManager: - def __init__(self): - self.listStorage = {} - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def list_storage_valid(self, name, checkIndex=False): - try: - if checkIndex: - index = self.listStorage[name]["index"] - if index == -1: - return self.listStorage[name]["list"] == [] - return self.listStorage[name]["list"][index] is not None - else: - return isinstance(self.listStorage[name]["list"], list) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "list_storage_valid " + str(e), debug.DebugLevel.ERROR - ) - return False - - def add_value_to_first_index(self, name, value): - if not self.list_storage_valid(name): - return - if self.listStorage[name]["maxLength"] is None: - # Fallback: if maxLength is still None, apply default limit of 1000 - self.listStorage[name]["list"] = [value] + self.listStorage[name][ - "list" - ][:999] - else: - self.listStorage[name]["list"] = [value] + self.listStorage[name][ - "list" - ][: self.listStorage[name]["maxLength"] - 1] - self.listStorage[name]["index"] = 0 - - def add_index_list(self, name, maxLength=1000, currList=[], curr_index=-1): - if len(currList) != 0 and (curr_index == -1): - curr_index = 0 - self.listStorage[name] = { - "list": currList, - "index": curr_index, - "maxLength": maxLength, - } - - def is_last_index(self, name): - if not self.list_storage_valid(name): - return False - return ( - self.listStorage[name]["index"] - == len(self.listStorage[name]["list"]) - 1 - ) - - def is_first_index(self, name): - if not self.list_storage_valid(name): - return False - return self.listStorage[name]["index"] == 0 - - def get_next_index(self, name): - if not self.list_storage_valid(name): - return False - if self.is_index_list_empty(name): - self.listStorage[name]["index"] = -1 - return False - self.listStorage[name]["index"] += 1 - if ( - self.listStorage[name]["index"] - > len(self.listStorage[name]["list"]) - 1 - ): - self.listStorage[name]["index"] = 0 - return True - - def set_pref_index(self, name): - if not self.list_storage_valid(name): - return False - if self.is_index_list_empty(name): - self.listStorage[name]["index"] = -1 - return False - self.listStorage[name]["index"] -= 1 - if self.listStorage[name]["index"] < 0: - self.listStorage[name]["index"] = ( - len(self.listStorage[name]["list"]) - 1 - ) - return True - - def set_first_index(self, name): - if not self.list_storage_valid(name): - return False - if self.is_index_list_empty(name): - self.listStorage[name]["index"] = -1 - return False - self.listStorage[name]["index"] = 0 - return True - - def get_index_list_len(self, name): - if not self.list_storage_valid(name): - return 0 - if self.is_index_list_empty(name): - self.listStorage[name]["index"] = -1 - return 0 - return len(self.listStorage[name]) - - def set_last_index(self, name): - if not self.list_storage_valid(name): - return False - if self.is_index_list_empty(name): - self.listStorage[name]["index"] = -1 - return False - self.listStorage[name]["index"] = ( - len(self.listStorage[name]["list"]) - 1 - ) - return True - - def clear_current_index_list(self, name): - if not self.list_storage_valid(name): - return False - self.listStorage[name]["list"] = [] - self.listStorage[name]["index"] = -1 - - def get_current_index(self, name): - if not self.list_storage_valid(name): - return False - if self.is_index_list_empty(name): - self.listStorage[name]["index"] = -1 - return False - try: - return self.listStorage[name]["index"] - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "MemoryManager get_current_index: Error accessing index for " - + name - + ": " - + str(e), - debug.DebugLevel.ERROR, - ) - return -1 - - def is_index_list_empty(self, name): - if not self.list_storage_valid(name): - return False - return len(self.listStorage[name]["list"]) == 0 - - def get_index_list_element(self, name): - if not self.list_storage_valid(name): - return None - if self.is_index_list_empty(name): - self.listStorage[name]["index"] = -1 - return None - curr_index = self.get_current_index(name) - if curr_index == -1: - return None - try: - return self.listStorage[name]["list"][curr_index] - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "MemoryManager get_index_list_element: Error accessing element for " - + name - + ": " - + str(e), - debug.DebugLevel.ERROR, - ) - return None diff --git a/src/fenrirscreenreader/core/outputData.py b/src/fenrirscreenreader/core/outputData.py deleted file mode 100644 index 24f39846..00000000 --- a/src/fenrirscreenreader/core/outputData.py +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import time - -from fenrirscreenreader.core import debug - -output_data = { - "nextFlush": time.time(), - "messageText": "", - "messageOffset": None, - "cursorOffset": None, -} diff --git a/src/fenrirscreenreader/core/outputManager.py b/src/fenrirscreenreader/core/outputManager.py deleted file mode 100644 index 143e6d8c..00000000 --- a/src/fenrirscreenreader/core/outputManager.py +++ /dev/null @@ -1,541 +0,0 @@ -#!/bin/python -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import re -import string -import threading -import time - -from fenrirscreenreader.core import debug -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import line_utils - - -class OutputManager: - def __init__(self): - self.last_echo = "" - self.interrupt_lock = threading.Lock() - self.interrupt_running = False - self.interrupt_thread = None - self.interrupt_done = None - self.interrupt_wait_timeout = 0.1 - self.speech_driver_lock = threading.Lock() - self.speech_driver_lock_timeout = 0.05 - - def initialize(self, environment): - self.env = environment - self.env["runtime"]["SettingsManager"].load_driver( - self.env["runtime"]["SettingsManager"].get_setting( - "speech", "driver" - ), - "SpeechDriver", - ) - self.env["runtime"]["SettingsManager"].load_driver( - self.env["runtime"]["SettingsManager"].get_setting( - "sound", "driver" - ), - "SoundDriver", - ) - - def shutdown(self): - self.env["runtime"]["SettingsManager"].shutdown_driver("SoundDriver") - self.env["runtime"]["SettingsManager"].shutdown_driver("SpeechDriver") - - def present_text( - self, - text, - interrupt=True, - sound_icon="", - ignore_punctuation=False, - announce_capital=False, - flush=True, - ): - if text == "" and sound_icon == "": - return - if ( - self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "speech", "read_numbers_as_digits" - ) - and len(text.strip()) > 1 - ): - text = re.sub(r"(\d)", r"\1 ", text).rstrip() - self.env["runtime"]["DebugManager"].write_debug_out( - "present_text:\nsoundIcon:'" + sound_icon + "'\nText:\n" + text, - debug.DebugLevel.INFO, - ) - if self.play_sound_icon(sound_icon, interrupt): - self.env["runtime"]["DebugManager"].write_debug_out( - "sound_icon found", debug.DebugLevel.INFO - ) - return - if text == "": - return - if (len(text) > 1) and (text.strip(string.whitespace) == ""): - return - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "speech", "enabled" - ): - speech_history_manager = self.env["runtime"].get( - "SpeechHistoryManager" - ) - if speech_history_manager: - speech_history_manager.add_text(text) - is_capital = self._should_announce_capital(text, announce_capital) - use_pitch_for_capital = False - - if is_capital: - indicator = self.env["runtime"]["SettingsManager"].get_setting( - "speech", "capital_indicator" - ).lower() - - if indicator == "none": - pass # No indication - elif indicator == "beep": - # Play beep with interrupt=True to fix stacking - self.play_sound_icon("capital", True) - elif indicator == "pitch": - use_pitch_for_capital = True - elif indicator == "both": - self.play_sound_icon("capital", True) - use_pitch_for_capital = True - else: - # Default to pitch for unknown values - use_pitch_for_capital = True - - self.last_echo = text - self.speak_text( - text, interrupt, ignore_punctuation, use_pitch_for_capital, flush - ) - - def _should_announce_capital(self, text, announce_capital): - if not announce_capital or not text: - return False - if len(text) == 1: - return text.isupper() - if any(char.isspace() for char in text): - return False - if not any(char.isalpha() for char in text): - return False - return text.isupper() - - def get_last_echo(self): - return self.last_echo - - def process_mid_word_punctuation(self, text): - """ - Process punctuation that appears mid-word to ensure proper pronunciation. - Specifically handles dots between word characters (e.g., "settings.conf" -> "settings dot conf") - and dots at word beginnings (e.g., ".local" -> "dot local") - while preserving sentence-ending periods and other punctuation behavior. - """ - if not text: - return text - - # Handle dots at the beginning of words (like .local, .bashrc, .config) - # Look for non-word character (or start of string), dot, then word characters - text = re.sub(r"(? 20000: - self.env["runtime"]["DebugManager"].write_debug_out( - "OutputManager.play_frequence::Filefrequence is out of range:" - + str(frequence), - debug.DebugLevel.INFO, - ) - return False - - if self.env["runtime"]["SoundDriver"] is None: - self.env["runtime"]["DebugManager"].write_debug_out( - "No SoundDriver in OutputManager.play_frequence: SoundDriver not loaded", - debug.DebugLevel.ERROR, - ) - return False - - try: - self.env["runtime"]["SoundDriver"].set_volume( - self.env["runtime"]["SettingsManager"].get_setting_as_float( - "sound", "volume" - ) - ) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "OutputManager.play_sound_icon::set_volume: " + str(e), - debug.DebugLevel.ERROR, - ) - adjust_volume = 0.0 - try: - adjust_volume = 1.0 - (frequence / 20000) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "OutputManager play_frequence: Error calculating adjust volume: " - + str(e), - debug.DebugLevel.ERROR, - ) - if adjust_volume > 9.0: - adjust_volume = 9.0 - - try: - self.env["runtime"]["SoundDriver"].play_frequence( - frequence, duration, adjust_volume, interrupt - ) - return True - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "OutputManager.play_sound_icon::play_sound_file: " + str(e), - debug.DebugLevel.ERROR, - ) - return False - - return False - - def temp_disable_speech(self): - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "speech", "enabled" - ): - self.present_text( - _("speech temporary disabled"), - sound_icon="SpeechOff", - interrupt=True, - ) - self.env["commandBuffer"]["enableSpeechOnKeypress"] = True - # Also enable prompt watching for automatic speech restoration - if "silenceUntilPrompt" not in self.env["commandBuffer"]: - self.env["commandBuffer"]["silenceUntilPrompt"] = False - self.env["commandBuffer"]["silenceUntilPrompt"] = True - self.env["runtime"]["SettingsManager"].set_setting( - "speech", - "enabled", - str( - not self.env["runtime"][ - "SettingsManager" - ].get_setting_as_bool("speech", "enabled") - ), - ) - self.interrupt_output() - - def announce_active_cursor(self, interrupt_p=False): - if self.env["runtime"]["CursorManager"].is_review_mode(): - self.present_text(_(" review cursor "), interrupt=interrupt_p) - else: - self.present_text(_(" text cursor "), interrupt=interrupt_p) - - def reset_SpeechDriver(self): - """Reset speech driver to clean state - called by settings_manager""" - if ( - "SpeechDriver" in self.env["runtime"] - and self.env["runtime"]["SpeechDriver"] - ): - try: - self.env["runtime"]["SpeechDriver"].reset() - self.env["runtime"]["DebugManager"].write_debug_out( - "Speech driver reset successfully", debug.DebugLevel.INFO - ) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - f"reset_SpeechDriver error: {e}", debug.DebugLevel.ERROR - ) diff --git a/src/fenrirscreenreader/core/processManager.py b/src/fenrirscreenreader/core/processManager.py deleted file mode 100644 index d6835397..00000000 --- a/src/fenrirscreenreader/core/processManager.py +++ /dev/null @@ -1,217 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import signal -import time -from multiprocessing import Process -from threading import Thread - -from fenrirscreenreader.core import debug -from fenrirscreenreader.core.eventData import FenrirEventType - - -# Standalone functions for multiprocessing (cannot be instance methods) -def _heart_beat_timer(running): - """ - Standalone heartbeat timer function for multiprocessing. - Returns current timestamp after a short sleep. - """ - try: - time.sleep(0.5) - except Exception as e: - print(f"ProcessManager _heart_beat_timer: Error during sleep: {e}") - return time.time() - - -def _custom_event_worker_process( - running, event_queue, function, pargs=None, run_once=False -): - """ - Standalone worker function for custom events in multiprocessing. - Cannot use instance methods due to pickle limitations with self.env. - """ - if not callable(function): - return - while running.value: - try: - if pargs: - function(running, event_queue, pargs) - else: - function(running, event_queue) - except Exception as e: - # Cannot use DebugManager in multiprocess context - print( - f"ProcessManager:_custom_event_worker_process:function(" - f"{function}):{e}" - ) - if run_once: - break - - -def _simple_event_worker_process( - running, event_queue, event, function, pargs=None, run_once=False -): - """ - Standalone worker function for simple events in multiprocessing. - Cannot use instance methods due to pickle limitations with self.env. - """ - if not isinstance(event, FenrirEventType): - return - if not callable(function): - return - while running.value: - data = None - try: - if pargs: - data = function(running, pargs) - else: - data = function(running) - except Exception as e: - # Cannot use DebugManager in multiprocess context - print( - f"ProcessManager:_simple_event_worker_process:function(" - f"{function}):{e}" - ) - try: - event_queue.put({"Type": event, "data": data}, timeout=0.1) - except Exception as e: - print(f"ProcessManager: Failed to put event to queue: {e}") - if run_once: - break - - -class ProcessManager: - def __init__(self): - self._Processes = [] - self._Threads = [] - - def initialize(self, environment): - self.env = environment - self.running = self.env["runtime"]["EventManager"].get_running() - self.add_simple_event_thread( - FenrirEventType.heart_beat, - _heart_beat_timer, - multiprocess=True, - ) - - def shutdown(self): - self.terminate_all_processes() - - def terminate_all_processes(self): - for proc in self._Processes: - # try: - # proc.terminate() - # except KeyboardInterrupt: - # pass - # except: - # pass - proc.join(timeout=5.0) # Timeout to prevent hanging shutdown - for t in self._Threads: - t.join(timeout=5.0) # Timeout to prevent hanging shutdown - - def heart_beat_timer(self, active): - try: - time.sleep(0.5) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "ProcessManager heart_beat_timer: Error during sleep: " - + str(e), - debug.DebugLevel.ERROR, - ) - return time.time() - - def add_custom_event_thread( - self, function, pargs=None, multiprocess=False, run_once=False - ): - event_queue = self.env["runtime"]["EventManager"].get_event_queue() - original_sigint_handler = signal.signal(signal.SIGINT, signal.SIG_IGN) - if multiprocess: - t = Process( - target=_custom_event_worker_process, - args=(self.running, event_queue, function, pargs, run_once), - ) - self._Processes.append(t) - else: # use thread instead of process - t = Thread( - target=self.custom_event_worker_thread, - args=(event_queue, function, pargs, run_once), - ) - self._Threads.append(t) - t.start() - signal.signal(signal.SIGINT, original_sigint_handler) - - def add_simple_event_thread( - self, event, function, pargs=None, multiprocess=False, run_once=False - ): - original_sigint_handler = signal.signal(signal.SIGINT, signal.SIG_IGN) - if multiprocess: - # Get event queue reference before creating process - event_queue = self.env["runtime"]["EventManager"].get_event_queue() - t = Process( - target=_simple_event_worker_process, - args=(self.running, event_queue, event, function, pargs, run_once), - ) - self._Processes.append(t) - else: - t = Thread( - target=self.simple_event_worker_thread, - args=(event, function, pargs, run_once), - ) - self._Threads.append(t) - t.start() - signal.signal(signal.SIGINT, original_sigint_handler) - - def custom_event_worker_thread( - self, event_queue, function, pargs=None, run_once=False - ): - # if not isinstance(event_queue, Queue): - # return - if not callable(function): - return - while self.running.value: - try: - if pargs: - function(self.running, event_queue, pargs) - else: - function(self.running, event_queue) - except Exception as e: - # Cannot use DebugManager in multiprocess context due to - # pickle limitations with file handles - print( - "ProcessManager:custom_event_worker_thread:function(" - + str(function) - + "):" - + str(e) - ) - if run_once: - break - - def simple_event_worker_thread( - self, event, function, pargs=None, run_once=False - ): - if not isinstance(event, FenrirEventType): - return - if not callable(function): - return - while self.running.value: - data = None - try: - if pargs: - data = function(self.running, pargs) - else: - data = function(self.running) - except Exception as e: - # Cannot use DebugManager in multiprocess context due to - # pickle limitations with file handles - print( - "ProcessManager:simple_event_worker_thread:function(" - + str(function) - + "):" - + str(e) - ) - self.env["runtime"]["EventManager"].put_to_event_queue(event, data) - if run_once: - break diff --git a/src/fenrirscreenreader/core/punctuationData.py b/src/fenrirscreenreader/core/punctuationData.py deleted file mode 100644 index 5bde4dc1..00000000 --- a/src/fenrirscreenreader/core/punctuationData.py +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import string -from collections import OrderedDict - -from fenrirscreenreader.core import debug - -punctuation_data = { - "LEVELDICT": { - "none": "", - "some": "#-$~+*-/\\@", - "most": ".,:-$~ +*-/\\@!#%^&*()[]}{<>;", - "all": string.punctuation + " §", - }, - "PUNCTDICT": {}, - "CUSTOMDICT": OrderedDict(), - "EMOTICONDICT": OrderedDict(), -} diff --git a/src/fenrirscreenreader/core/punctuationManager.py b/src/fenrirscreenreader/core/punctuationManager.py deleted file mode 100644 index 53a59e30..00000000 --- a/src/fenrirscreenreader/core/punctuationManager.py +++ /dev/null @@ -1,247 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import inspect -import os -import re -import string - -from fenrirscreenreader.core import debug - -currentdir = os.path.dirname( - os.path.realpath(os.path.abspath(inspect.getfile(inspect.currentframe()))) -) -fenrir_path = os.path.dirname(currentdir) - - -class PunctuationManager: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - self.allPunctNone = dict.fromkeys( - map(ord, string.punctuation + "§ "), " " - ) - # replace with None: - # dot, comma, grave, apostrophe - # for char in [ord('`'),ord("'")]: - # self.allPunctNone[char] = None - # dont restore the following (for announce correct pause) - for char in [ - ord("'"), - ord("."), - ord(","), - ord(";"), - ord(":"), - ord("?"), - ord("!"), - ord("-"), - ]: - self.allPunctNone[char] = chr(char) - - def shutdown(self): - pass - - def remove_unused(self, text, curr_level=""): - # dont translate dot and comma because they create a pause - curr_all_punct_none = self.allPunctNone.copy() - - # Check if we should replace undefined punctuation with spaces - replace_with_space = self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "general", "replace_undefined_punctuation_with_space" - ) - - # If the setting is disabled, use the old behavior (remove completely) - if not replace_with_space: - # Create a map that removes undefined punctuation instead of replacing with spaces - curr_all_punct_none = dict.fromkeys( - map(ord, string.punctuation + "§ "), None - ) - # Restore the pause-important characters - for char in [ - ord("'"), - ord("."), - ord(","), - ord(";"), - ord(":"), - ord("?"), - ord("!"), - ord("-"), - ]: - curr_all_punct_none[char] = chr(char) - - # Remove characters that are defined in the current punctuation level - for char in curr_level: - try: - del curr_all_punct_none[ord(char)] - except Exception as e: - pass - - return text.translate(curr_all_punct_none) - - def use_custom_dict(self, text, customDict, seperator=""): - result_text = str(text) - if customDict: - for key, item in customDict.items(): - try: - regex_lbl = "REGEX;" - if key.upper().startswith(regex_lbl) and ( - len(key) > len(regex_lbl) - ): - result_text = re.sub( - str(key[len(regex_lbl) :]), - seperator + str(item) + seperator, - result_text, - ) - else: - result_text = result_text.replace( - str(key), seperator + str(item) + seperator - ) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "use_custom_dict replace:'" - + key - + "' with '" - + item - + "' failed:" - + str(e), - debug.DebugLevel.ERROR, - on_any_level=False, - ) - return result_text - - def use_punctuation_dict(self, text, punctuationDict, punctuation): - result_text = str(text) - - if punctuationDict and punctuation and punctuation != "": - if " " in punctuation: - result_text = result_text.replace( - " ", " " + punctuationDict[" "] + " " - ) - for key, item in punctuationDict.items(): - if ( - punctuation != "" and key in punctuation - ) and key not in " ": - if ( - self.env["runtime"]["SettingsManager"].get_setting( - "general", "respect_punctuation_pause" - ) - and len(key) == 1 - and key in "',.;:?!" - ): - result_text = result_text.replace( - str(key), " " + str(item) + str(key) + " " - ) - else: - result_text = result_text.replace( - str(key), " " + str(item) + " " - ) - return result_text - - def is_puctuation(self, char): - return char in self.env["punctuation"]["PUNCTDICT"] - - def proceed_punctuation(self, text, ignore_punctuation=False): - if ignore_punctuation: - return text - result_text = text - result_text = self.use_custom_dict( - result_text, self.env["punctuation"]["CUSTOMDICT"] - ) - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "general", "emoticons" - ): - result_text = self.use_custom_dict( - result_text, self.env["punctuation"]["EMOTICONDICT"], " " - ) - curr_punct_level = "" - if ( - self.env["runtime"]["SettingsManager"] - .get_setting("general", "punctuation_level") - .lower() - in self.env["punctuation"]["LEVELDICT"] - ): - curr_punct_level = self.env["punctuation"]["LEVELDICT"][ - self.env["runtime"]["SettingsManager"] - .get_setting("general", "punctuation_level") - .lower() - ] - else: - curr_punct_level = string.punctuation + " §" - result_text = self.use_punctuation_dict( - result_text, self.env["punctuation"]["PUNCTDICT"], curr_punct_level - ) - result_text = self.remove_unused(result_text, curr_punct_level) - return result_text - - def cycle_punctuation(self): - punct_list = list(self.env["punctuation"]["LEVELDICT"].keys()) - try: - curr_index = punct_list.index( - self.env["runtime"]["SettingsManager"] - .get_setting("general", "punctuation_level") - .lower() - ) # curr punctuation - except Exception as e: - return False - curr_index += 1 - if curr_index >= len(punct_list): - curr_index = 0 - curr_level = punct_list[curr_index] - self.env["runtime"]["SettingsManager"].set_setting( - "general", "punctuation_level", curr_level.lower() - ) - return True - - def load_dicts( - self, - dictConfigPath=fenrir_path + "/../../config/punctuation/default.conf", - ): - dict_config = open(dictConfigPath, "r") - curr_dict_name = "" - while True: - line = dict_config.readline() - if not line: - break - line = line.replace("\n", "") - if line.replace(" ", "") == "": - continue - if line.replace(" ", "").startswith("#"): - if not line.replace(" ", "").startswith("#:===:"): - continue - if line.replace(" ", "").upper().startswith("[") and line.replace( - " ", "" - ).upper().endswith("DICT]"): - curr_dict_name = line[ - line.find("[") + 1 : line.upper().find("DICT]") + 4 - ].upper() - else: - if curr_dict_name == "": - continue - if ":===:" not in line: - continue - sep_line = line.split(":===:") - if len(sep_line) == 1: - sep_line.append("") - elif len(sep_line) < 1: - continue - elif len(sep_line) > 2: - sep_line[1] = ":===:" - self.env["punctuation"][curr_dict_name][sep_line[0]] = ( - sep_line[1] - ) - self.env["runtime"]["DebugManager"].write_debug_out( - "Punctuation: " - + curr_dict_name - + "." - + str(sep_line[0]) - + " :" - + sep_line[1], - debug.DebugLevel.INFO, - on_any_level=True, - ) - dict_config.close() diff --git a/src/fenrirscreenreader/core/quickMenuManager.py b/src/fenrirscreenreader/core/quickMenuManager.py deleted file mode 100644 index 18258cad..00000000 --- a/src/fenrirscreenreader/core/quickMenuManager.py +++ /dev/null @@ -1,532 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core import debug -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.core.settingsData import settings_data - - -class QuickMenuManager: - - def __init__(self): - self.position = 0 - self.quickMenu = [] - self.settings = settings_data - self._module_request_pending = False - self._voice_requests_pending = set() - - def _select_default_voice(self, voices): - """Select a sensible default voice from list, preferring user's - language. - - Args: - voices (list): List of available voice names - - Returns: - str: Selected default voice (matches user language if possible) - """ - if not voices: - return "" - - # Get current voice to preserve language preference - current_voice = self.env["runtime"]["SettingsManager"].get_setting( - "speech", "voice" - ) - - # Get configured language from settings - configured_lang = self.env["runtime"]["SettingsManager"].get_setting( - "speech", "language" - ) - - # Extract language code from current voice if available - current_lang = None - if current_voice: - # Extract language code (e.g., 'en-gb' from 'en-gb+male') - current_lang = current_voice.split('+')[0].lower() - - # Build preference list: current language, configured language, English - preferences = [] - if current_lang: - preferences.append(current_lang) - if configured_lang: - preferences.append(configured_lang.lower()) - preferences.extend(['en-gb', 'en-us', 'en']) - - # Remove duplicates while preserving order - seen = set() - preferences = [x for x in preferences - if not (x in seen or seen.add(x))] - - # Try exact matches for preferred languages - for pref in preferences: - for voice in voices: - # Extract language if voice is in "name|lang" format - voice_to_check = voice - if "|" in voice: - _, voice_lang = voice.split("|", 1) - voice_to_check = voice_lang - if voice_to_check.lower() == pref: - return voice - - # Try voices starting with preferred language codes - for pref in preferences: - for voice in voices: - # Extract language if voice is in "name|lang" format - voice_to_check = voice - if "|" in voice: - _, voice_lang = voice.split("|", 1) - voice_to_check = voice_lang - if voice_to_check.lower().startswith(pref): - return voice - - # Fall back to first available voice - return voices[0] - - def invalidate_speech_cache(self): - self.env["runtime"]["SpeechDiscoveryManager"].invalidate_cache() - - def initialize(self, environment): - self.env = environment - - # Load base menu from config - menu_string = self.env["runtime"]["SettingsManager"].get_setting( - "menu", "quick_menu" - ) - - # Dynamically add speech-dispatcher specific items - if self._is_speechd_driver_active(): - menu_string = self._add_speechd_menu_items(menu_string) - - self.load_menu(menu_string) - - def shutdown(self): - pass - - def _is_speechd_driver_active(self): - """Check if speechdDriver is currently active. - - Returns: - bool: True if speech driver is speechdDriver - """ - try: - driver = self.env["runtime"]["SettingsManager"].get_setting( - "speech", "driver" - ) - return driver == "speechdDriver" - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - (f"QuickMenuManager _is_speechd_driver_active: " - f"Error checking driver: {e}"), - debug.DebugLevel.ERROR - ) - return False - - def _add_speechd_menu_items(self, menu_string): - """Add speech-dispatcher module and voice to quick menu. - - Args: - menu_string (str): Existing menu string from config - - Returns: - str: Updated menu string with module and voice added - """ - if not menu_string: - return "speech#module;speech#voice" - - # Check if already present (user manually added) - if "speech#module" in menu_string or "speech#voice" in menu_string: - return menu_string - - # Add module and voice after existing items - return f"{menu_string};speech#module;speech#voice" - - def load_menu(self, menuString): - self.position = 0 - self.quickMenu = [] - if menuString == "": - return - entrys = menuString.split(";") - for e in entrys: - entry = e.split("#") - if len(entry) != 2: - continue - try: - t = self.settings[entry[0]][entry[1]] - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "QuickMenuManager load_menu: Setting not found " - + str(entry) - + ": " - + str(e), - debug.DebugLevel.ERROR, - ) - print(entry[0], entry[1], "not found") - continue - self.quickMenu.append({"section": entry[0], "setting": entry[1]}) - - def next_entry(self): - if len(self.quickMenu) == 0: - return False - self.position += 1 - if self.position >= len(self.quickMenu): - self.position = 0 - return True - - def prev_entry(self): - if len(self.quickMenu) == 0: - return False - self.position -= 1 - if self.position < 0: - self.position = len(self.quickMenu) - 1 - return True - - def next_value(self): - if len(self.quickMenu) == 0: - return False - section = self.quickMenu[self.position]["section"] - setting = self.quickMenu[self.position]["setting"] - value_string = "" - try: - value_string = self.env["runtime"]["SettingsManager"].get_setting( - section, setting - ) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "QuickMenuManager next_value: Error getting setting value: " - + str(e), - debug.DebugLevel.ERROR, - ) - return False - - try: - if isinstance(self.settings[section][setting], str): - # Check for special string cycling cases - if section == "speech" and setting == "module": - return self.cycle_speech_module("next") - elif section == "speech" and setting == "voice": - return self.cycle_speech_voice("next") - else: - # Generic strings not supported for cycling - value = str(value_string) - return False - elif isinstance(self.settings[section][setting], bool): - if value_string not in ["True", "False"]: - return False - value = not value - self.env["runtime"]["SettingsManager"].set_setting( - section, setting, str(value) - ) - elif isinstance(self.settings[section][setting], int): - value = int(value_string) - value += 1 - self.env["runtime"]["SettingsManager"].set_setting( - section, setting, str(value) - ) - elif isinstance(self.settings[section][setting], float): - value = float(value_string) - value += 0.05 - if value > 1.0: - value = 1.0 - self.env["runtime"]["SettingsManager"].set_setting( - section, setting, str(value)[:4] - ) - except Exception as e: - return False - return True - - def prev_value(self): - if len(self.quickMenu) == 0: - return False - section = self.quickMenu[self.position]["section"] - setting = self.quickMenu[self.position]["setting"] - value_string = "" - try: - value_string = self.env["runtime"]["SettingsManager"].get_setting( - section, setting - ) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "QuickMenuManager prev_value: Error getting setting value: " - + str(e), - debug.DebugLevel.ERROR, - ) - return False - try: - if isinstance(self.settings[section][setting], str): - # Check for special string cycling cases - if section == "speech" and setting == "module": - return self.cycle_speech_module("prev") - elif section == "speech" and setting == "voice": - return self.cycle_speech_voice("prev") - else: - # Generic strings not supported for cycling - value = str(value_string) - return False - elif isinstance(self.settings[section][setting], bool): - if value_string not in ["True", "False"]: - return False - value = not value - self.env["runtime"]["SettingsManager"].set_setting( - section, setting, str(value) - ) - elif isinstance(self.settings[section][setting], int): - value = int(value_string) - value -= 1 - if value < 0: - value = 0 - self.env["runtime"]["SettingsManager"].set_setting( - section, setting, str(value) - ) - elif isinstance(self.settings[section][setting], float): - value = float(value_string) - value -= 0.05 - if value < 0.0: - value = 0.0 - self.env["runtime"]["SettingsManager"].set_setting( - section, setting, str(value)[:4] - ) - except Exception as e: - return False - return True - - def cycle_speech_module(self, direction): - if self._module_request_pending: - return False - discovery = self.env["runtime"]["SpeechDiscoveryManager"] - modules = discovery.get_cached_modules() - if modules is not None: - return self._continue_module_cycle(direction, modules, False) - - self._module_request_pending = True - self.env["runtime"]["OutputManager"].present_text( - _("Loading speech modules"), interrupt=True - ) - discovery.request_modules( - lambda found, error: self._continue_module_cycle( - direction, found, True, error - ) - ) - return False - - def _continue_module_cycle( - self, direction, modules, announce_result, error="" - ): - if error: - self._module_request_pending = False - self._report_discovery_error("module", error) - return False - if not modules: - self._module_request_pending = False - self.env["runtime"]["OutputManager"].present_text( - _("No modules available"), interrupt=True - ) - return False - - try: - current_module = self.env["runtime"]["SettingsManager"].get_setting( - "speech", "module" - ) - try: - current_index = ( - modules.index(current_module) if current_module else 0 - ) - except ValueError: - current_index = 0 - if direction == "next": - new_index = (current_index + 1) % len(modules) - else: - new_index = (current_index - 1) % len(modules) - new_module = modules[new_index] - discovery = self.env["runtime"]["SpeechDiscoveryManager"] - voices = discovery.get_cached_voices(new_module) - if voices is not None: - self._module_request_pending = False - return self._apply_module( - new_module, voices, announce_result - ) - - self._module_request_pending = True - discovery.request_voices( - new_module, - lambda _module, found, voice_error: self._finish_module_cycle( - new_module, found, voice_error - ), - ) - return False - except Exception as error: - self._module_request_pending = False - self.env["runtime"]["DebugManager"].write_debug_out( - f"QuickMenuManager cycle_speech_module: Error: {error}", - debug.DebugLevel.ERROR, - ) - return False - - def _finish_module_cycle(self, module, voices, error): - self._module_request_pending = False - if error: - self.env["runtime"]["DebugManager"].write_debug_out( - f"QuickMenuManager voice discovery failed: {error}", - debug.DebugLevel.ERROR, - ) - self._apply_module(module, voices, False) - self.env["runtime"]["OutputManager"].present_text( - _("{0}; voice discovery failed").format(module), interrupt=True - ) - return - self._apply_module(module, voices, True) - - def _apply_module(self, module, voices, announce_result): - settings_manager = self.env["runtime"]["SettingsManager"] - settings_manager.set_setting("speech", "module", module) - speech_driver = self.env["runtime"].get("SpeechDriver") - if speech_driver: - speech_driver.set_module(module) - - if voices: - default_voice = self._select_default_voice(voices) - voice_name, voice_language = self._split_voice(default_voice) - settings_manager.set_setting("speech", "voice", voice_name) - if speech_driver: - if voice_language: - speech_driver.set_language(voice_language) - speech_driver.set_voice(voice_name) - - if announce_result: - self.env["runtime"]["OutputManager"].present_text( - module, interrupt=True - ) - return True - - def cycle_speech_voice(self, direction): - try: - current_module = self.env["runtime"]["SettingsManager"].get_setting( - "speech", "module" - ) - if not current_module: - self.env["runtime"]["OutputManager"].present_text( - _("No module selected"), interrupt=True - ) - return False - discovery = self.env["runtime"]["SpeechDiscoveryManager"] - voices = discovery.get_cached_voices(current_module) - if voices is not None: - return self._apply_voice(direction, voices, False) - if current_module in self._voice_requests_pending: - return False - - self._voice_requests_pending.add(current_module) - self.env["runtime"]["OutputManager"].present_text( - _("Loading voices for {0}").format(current_module), interrupt=True - ) - discovery.request_voices( - current_module, - lambda module, found, error: self._finish_voice_cycle( - direction, module, found, error - ), - ) - return False - except Exception as error: - self.env["runtime"]["DebugManager"].write_debug_out( - f"QuickMenuManager cycle_speech_voice: Error: {error}", - debug.DebugLevel.ERROR, - ) - return False - - def _finish_voice_cycle(self, direction, module, voices, error): - self._voice_requests_pending.discard(module) - if error: - self._report_discovery_error("voice", error) - return - self._apply_voice(direction, voices, True) - - def _apply_voice(self, direction, voices, announce_result): - if not voices: - self.env["runtime"]["OutputManager"].present_text( - _("No voices available"), interrupt=True - ) - return False - - settings_manager = self.env["runtime"]["SettingsManager"] - current_voice = settings_manager.get_setting("speech", "voice") - current_index = 0 - for voice_index, voice in enumerate(voices): - voice_name, _language = self._split_voice(voice) - if voice == current_voice or voice_name == current_voice: - current_index = voice_index - break - if direction == "next": - new_index = (current_index + 1) % len(voices) - else: - new_index = (current_index - 1) % len(voices) - - voice_name, voice_language = self._split_voice(voices[new_index]) - settings_manager.set_setting("speech", "voice", voice_name) - speech_driver = self.env["runtime"].get("SpeechDriver") - if speech_driver: - if voice_language: - speech_driver.set_language(voice_language) - speech_driver.set_voice(voice_name) - if announce_result: - self.env["runtime"]["OutputManager"].present_text( - voice_name, interrupt=True - ) - return True - - @staticmethod - def _split_voice(voice): - if "|" in voice: - return tuple(voice.split("|", 1)) - return voice, None - - def _report_discovery_error(self, kind, error): - self.env["runtime"]["DebugManager"].write_debug_out( - f"QuickMenuManager {kind} discovery failed: {error}", - debug.DebugLevel.ERROR, - ) - message = ( - _("Module discovery failed") if kind == "module" - else _("Voice discovery failed") - ) - self.env["runtime"]["OutputManager"].present_text(message, interrupt=True) - - def get_current_entry(self): - if len(self.quickMenu) == 0: - return "" - try: - labels = { - ("speech", "rate"): _("speech rate"), - ("speech", "pitch"): _("speech pitch"), - ("speech", "volume"): _("speech volume"), - ("speech", "module"): _("speech module"), - ("speech", "voice"): _("speech voice"), - ("sound", "volume"): _("sound volume"), - } - entry = self.quickMenu[self.position] - key = (entry["section"], entry["setting"]) - return labels.get(key, " ".join(key)) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "QuickMenuManager get_current_entry: Error formatting entry: " - + str(e), - debug.DebugLevel.ERROR, - ) - return _("setting invalid") - - def get_current_value(self): - if len(self.quickMenu) == 0: - return "" - try: - return self.env["runtime"]["SettingsManager"].get_setting( - self.quickMenu[self.position]["section"], - self.quickMenu[self.position]["setting"], - ) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "QuickMenuManager get_current_value: Error getting setting value: " - + str(e), - debug.DebugLevel.ERROR, - ) - return _("setting value invalid") diff --git a/src/fenrirscreenreader/core/readAllManager.py b/src/fenrirscreenreader/core/readAllManager.py deleted file mode 100644 index d10d0d7c..00000000 --- a/src/fenrirscreenreader/core/readAllManager.py +++ /dev/null @@ -1,128 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core import debug -import threading -import time - - -class ReadAllManager: - """Manages continuous read-all functionality with speech completion callbacks.""" - - def __init__(self): - self.active = False - self.mode = None # 'line' or 'page' - self.stop_requested = False - self.env = None - self.items_read = 0 - self._lock = threading.Lock() - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - self.stop_read_all() - - def start_read_all(self, mode): - """Start continuous reading in line or page mode.""" - with self._lock: - if self.active: - self.stop_read_all() - - self.active = True - self.mode = mode - self.stop_requested = False - self.items_read = 0 - - self.env["runtime"]["DebugManager"].write_debug_out( - f"ReadAllManager: Starting read-all mode '{mode}'", - debug.DebugLevel.INFO - ) - - # Send first navigation key - self._send_navigation_key() - - def stop_read_all(self): - """Stop continuous reading.""" - with self._lock: - if not self.active: - return - - self.env["runtime"]["DebugManager"].write_debug_out( - f"ReadAllManager: Stopping read-all after {self.items_read} {self.mode}s", - debug.DebugLevel.INFO - ) - - self.active = False - self.stop_requested = True - self.mode = None - - # Cancel any pending speech - try: - self.env['runtime']['SpeechDriver'].cancel() - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - f"ReadAllManager: Error canceling speech: {e}", - debug.DebugLevel.ERROR - ) - - def is_active(self): - """Check if read-all is currently active.""" - return self.active - - def get_mode(self): - """Get current read-all mode ('line' or 'page').""" - return self.mode - - def _send_navigation_key(self): - """Send appropriate navigation key based on mode.""" - if not self.active or self.stop_requested: - return - - try: - if self.mode == 'line': - key_sequence = [[1, 'KEY_DOWN'], [0, 'KEY_DOWN']] - key_name = 'KEY_DOWN' - elif self.mode == 'page': - key_sequence = [[1, 'KEY_PAGEDOWN'], [0, 'KEY_PAGEDOWN']] - key_name = 'KEY_PAGEDOWN' - else: - return - - self.env["runtime"]["DebugManager"].write_debug_out( - f"ReadAllManager: Sending {key_name} ({self.items_read + 1})", - debug.DebugLevel.INFO - ) - - self.env['runtime']['InputManager'].send_keys(key_sequence) - self.items_read += 1 - - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - f"ReadAllManager: Error sending key: {e}", - debug.DebugLevel.ERROR - ) - self.stop_read_all() - - def speech_completed(self): - """Called when speech completes - continue reading if active.""" - if not self.active or self.stop_requested: - return - - self.env["runtime"]["DebugManager"].write_debug_out( - f"ReadAllManager: Speech completed, continuing read-all", - debug.DebugLevel.INFO - ) - - # Small delay to prevent overwhelming the system - def continue_reading(): - time.sleep(0.1) - if self.active and not self.stop_requested: - self._send_navigation_key() - - # Use daemon thread to avoid blocking - thread = threading.Thread(target=continue_reading, daemon=True) - thread.start() diff --git a/src/fenrirscreenreader/core/remoteDriver.py b/src/fenrirscreenreader/core/remoteDriver.py deleted file mode 100644 index b582f39c..00000000 --- a/src/fenrirscreenreader/core/remoteDriver.py +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core import debug - - -class RemoteDriver: - def __init__(self): - self._is_initialized = False - - def initialize(self, environment): - self.env = environment - self._is_initialized = True - - def shutdown(self): - self._is_initialized = False diff --git a/src/fenrirscreenreader/core/remoteInstanceRegistry.py b/src/fenrirscreenreader/core/remoteInstanceRegistry.py deleted file mode 100644 index 07d707af..00000000 --- a/src/fenrirscreenreader/core/remoteInstanceRegistry.py +++ /dev/null @@ -1,122 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import errno -import json -import os -import tempfile -import time - - -INSTANCE_TIMEOUT_SEC = 30.0 -REGISTRY_DIR_MODE = 0o1777 - - -def get_registry_dir(): - return os.path.join(tempfile.gettempdir(), "fenrirscreenreader-instances") - - -def get_instance_file(pid=None): - if pid is None: - pid = os.getpid() - return os.path.join(get_registry_dir(), f"{pid}.json") - - -def write_instance(instance_data): - registry_dir = get_registry_dir() - os.makedirs(registry_dir, mode=REGISTRY_DIR_MODE, exist_ok=True) - os.chmod(registry_dir, REGISTRY_DIR_MODE) - prune_stale_instances() - instance_data["updated_at"] = time.time() - instance_path = get_instance_file(instance_data.get("pid")) - with open(instance_path, "w", encoding="utf-8") as instance_file: - json.dump(instance_data, instance_file, sort_keys=True) - instance_file.write("\n") - os.chmod(instance_path, 0o644) - - -def remove_instance(pid=None): - try: - os.unlink(get_instance_file(pid)) - except FileNotFoundError: - pass - - -def process_exists(pid): - try: - os.kill(pid, 0) - return True - except OSError as e: - if e.errno == errno.EPERM: - return True - return False - - -def prune_stale_instances(): - registry_dir = get_registry_dir() - try: - instance_files = os.listdir(registry_dir) - except FileNotFoundError: - return - - now = time.time() - for instance_name in instance_files: - instance_path = os.path.join(registry_dir, instance_name) - try: - with open(instance_path, "r", encoding="utf-8") as instance_file: - instance_data = json.load(instance_file) - pid = int(instance_data.get("pid", 0)) - updated_at = float(instance_data.get("updated_at", 0)) - except (OSError, ValueError, TypeError, json.JSONDecodeError): - try: - os.unlink(instance_path) - except OSError: - pass - continue - - if ( - not pid - or not process_exists(pid) - or now - updated_at > INSTANCE_TIMEOUT_SEC - ): - try: - os.unlink(instance_path) - except OSError: - pass - - -def list_instances(): - registry_dir = get_registry_dir() - instances = [] - try: - instance_files = os.listdir(registry_dir) - except FileNotFoundError: - return instances - - now = time.time() - for instance_name in instance_files: - instance_path = os.path.join(registry_dir, instance_name) - try: - with open(instance_path, "r", encoding="utf-8") as instance_file: - instance_data = json.load(instance_file) - pid = int(instance_data.get("pid", 0)) - updated_at = float(instance_data.get("updated_at", 0)) - except (OSError, ValueError, TypeError, json.JSONDecodeError): - continue - - if ( - not pid - or not process_exists(pid) - or now - updated_at > INSTANCE_TIMEOUT_SEC - ): - try: - os.unlink(instance_path) - except OSError: - pass - continue - instances.append(instance_data) - - return sorted(instances, key=lambda instance: int(instance.get("pid", 0))) diff --git a/src/fenrirscreenreader/core/remoteManager.py b/src/fenrirscreenreader/core/remoteManager.py deleted file mode 100644 index 44dd8925..00000000 --- a/src/fenrirscreenreader/core/remoteManager.py +++ /dev/null @@ -1,532 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -""" -Remote controll: -sectioncommandparameters -sections:command,setting -setting commands: -- set section#setting=value[;section#setting=value] -- reset -command commands: -- say text to speech -- interrupt -examples -settings: -settings set section#setting=value[,section#setting=value] -setting set speech#voice=de -setting reset -setting save /path/settings.conf -command: -command say this is a test -command interrupt -""" - - -import hashlib -import fcntl -import os -import tempfile -import time - -from fenrirscreenreader.core import debug -from fenrirscreenreader.core import remoteInstanceRegistry -from fenrirscreenreader.core.eventData import FenrirEventType -from fenrirscreenreader.core.i18n import _ - - -REMOTE_COMMAND_LOCK_TIMEOUT_SEC = 2.0 -REMOTE_COMMAND_LOCK_FILE = "fenrirscreenreader-remote-command.lock" - - -class RemoteManager: - def __init__(self): - # command controll - self.commandConst = "COMMAND " - self.sayConst = "SAY " - self.vmenuConst = "VMENU " - self.resetVmenuConst = "RESETVMENU" - self.interruptConst = "INTERRUPT" - self.quitAppConst = "QUITAPPLICATION" - self.tempDisableSpeechConst = "TEMPDISABLESPEECH" - self.defineWindowConst = "WINDOW " - self.resetWindowConst = "RESETWINDOW" - self.setClipboardConst = "CLIPBOARD " - self.exportClipboardConst = "EXPORTCLIPBOARD" - self.listInstancesConst = "LS" - self.listInstancesLongConst = "LIST" - # setting controll - self.settingConst = "SETTING " - self.setSettingConst = "SET " - self.saveAsSettingConst = "SAVEAS " - self.saveSettingConst = "SAVE" - self.resetSettingConst = "RESET" - - def initialize(self, environment): - self.env = environment - self.env["runtime"]["SettingsManager"].load_driver( - self.env["runtime"]["SettingsManager"].get_setting( - "remote", "driver" - ), - "RemoteDriver", - ) - - def shutdown(self): - self.env["runtime"]["SettingsManager"].shutdown_driver("RemoteDriver") - - def handle_settings_change_with_response(self, settings_text): - if not self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "remote", "enable_settings_remote" - ): - return { - "success": False, - "message": "Settings remote control is disabled", - } - - upper_settings_text = settings_text.upper() - try: - # set setting - if upper_settings_text.startswith(self.setSettingConst): - parameter_text = settings_text[len(self.setSettingConst) :] - self.set_settings(parameter_text) - return { - "success": True, - "message": f"Setting applied: {parameter_text}", - } - # save as setting - elif upper_settings_text.startswith(self.saveAsSettingConst): - parameter_text = settings_text[len(self.saveAsSettingConst) :] - self.save_settings(parameter_text) - return { - "success": True, - "message": f"Settings saved to: {parameter_text}", - } - # save setting - elif upper_settings_text == self.saveSettingConst: - self.save_settings() - return {"success": True, "message": "Settings saved"} - # reset setting - elif upper_settings_text == self.resetSettingConst: - self.reset_settings() - return { - "success": True, - "message": "Settings reset to defaults", - } - else: - return { - "success": False, - "message": f"Unknown settings command: {settings_text}", - } - except Exception as e: - return {"success": False, "message": f"Settings error: {str(e)}"} - - def handle_settings_change(self, settings_text): - if not self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "remote", "enable_settings_remote" - ): - return - - upper_settings_text = settings_text.upper() - # set setting - if upper_settings_text.startswith(self.setSettingConst): - parameter_text = settings_text[len(self.setSettingConst) :] - self.set_settings(parameter_text) - # save as setting - elif upper_settings_text.startswith(self.saveAsSettingConst): - parameter_text = settings_text[len(self.saveAsSettingConst) :] - self.save_settings(parameter_text) - # save setting - elif upper_settings_text == self.saveSettingConst: - self.save_settings() - # reset setting - elif upper_settings_text == self.resetSettingConst: - self.reset_settings() - - def handle_command_execution_with_response(self, command_text): - if not self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "remote", "enable_command_remote" - ): - return { - "success": False, - "message": "Command remote control is disabled", - } - - upper_command_text = command_text.upper() - - try: - # say - if upper_command_text.startswith(self.sayConst): - parameter_text = command_text[len(self.sayConst) :] - self.say(parameter_text) - return { - "success": True, - "message": f"Speaking: {parameter_text[:50]}{'...' if len(parameter_text) > 50 else ''}", - } - # interrupt - elif upper_command_text == self.interruptConst: - self.interrupt_speech() - return {"success": True, "message": "Speech interrupted"} - # temp disable speech - elif upper_command_text == self.tempDisableSpeechConst: - self.temp_disable_speech() - return { - "success": True, - "message": "Speech temporarily disabled", - } - # set vmenu - elif upper_command_text.startswith(self.vmenuConst): - parameter_text = command_text[len(self.vmenuConst) :] - self.set_vmenu(parameter_text) - return { - "success": True, - "message": f"VMenu set to: {parameter_text}", - } - # reset vmenu - elif upper_command_text == self.resetVmenuConst: - self.reset_vmenu() - return {"success": True, "message": "VMenu reset"} - # quit fenrir - elif upper_command_text == self.quitAppConst: - self.quit_fenrir() - return {"success": True, "message": "Fenrir shutting down"} - # define window - elif upper_command_text.startswith(self.defineWindowConst): - parameter_text = command_text[len(self.defineWindowConst) :] - self.define_window(parameter_text) - return { - "success": True, - "message": f"Window defined: {parameter_text}", - } - # reset window - elif upper_command_text == self.resetWindowConst: - self.reset_window() - return {"success": True, "message": "Window reset"} - # set clipboard - elif upper_command_text.startswith(self.setClipboardConst): - parameter_text = command_text[len(self.setClipboardConst) :] - self.set_clipboard(parameter_text) - return { - "success": True, - "message": f"Clipboard set: {parameter_text[:50]}{'...' if len(parameter_text) > 50 else ''}", - } - elif upper_command_text.startswith(self.exportClipboardConst): - self.export_clipboard() - return { - "success": True, - "message": "Clipboard exported to file", - } - elif upper_command_text in ( - self.listInstancesConst, - self.listInstancesLongConst, - ): - return { - "success": True, - "message": self.list_instances(), - } - else: - return { - "success": False, - "message": f"Unknown command: {command_text}", - } - except Exception as e: - return {"success": False, "message": f"Command error: {str(e)}"} - - def handle_command_execution(self, command_text): - if not self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "remote", "enable_command_remote" - ): - return - - upper_command_text = command_text.upper() - - # say - if upper_command_text.startswith(self.sayConst): - parameter_text = command_text[len(self.sayConst) :] - self.say(parameter_text) - # interrupt - elif upper_command_text == self.interruptConst: - self.interrupt_speech() - # temp disable speech - elif upper_command_text == self.tempDisableSpeechConst: - self.temp_disable_speech() - # set vmenu - elif upper_command_text.startswith(self.vmenuConst): - parameter_text = command_text[len(self.vmenuConst) :] - self.set_vmenu(parameter_text) - # reset vmenu - elif upper_command_text == self.resetVmenuConst: - self.reset_vmenu() - # quit fenrir - elif upper_command_text == self.quitAppConst: - self.quit_fenrir() - # define window - elif upper_command_text.startswith(self.defineWindowConst): - parameter_text = command_text[len(self.defineWindowConst) :] - self.define_window(parameter_text) - # reset window - elif upper_command_text == self.resetWindowConst: - self.reset_window() - # set clipboard - elif upper_command_text.startswith(self.setClipboardConst): - parameter_text = command_text[len(self.setClipboardConst) :] - self.set_clipboard(parameter_text) - elif upper_command_text.startswith(self.exportClipboardConst): - self.export_clipboard() - elif upper_command_text in ( - self.listInstancesConst, - self.listInstancesLongConst, - ): - return - - def _get_remote_command_lock_path(self, event_data): - return os.path.join(tempfile.gettempdir(), REMOTE_COMMAND_LOCK_FILE) - - def _get_remote_command_hash(self, event_data): - return hashlib.sha256(event_data.encode("utf-8")).hexdigest() - - def _read_remote_command_lock(self, lock_file): - lock_file.seek(0) - lock_parts = lock_file.readline().strip().split() - try: - lock_pid = int(lock_parts[0]) if lock_parts else 0 - except ValueError: - lock_pid = 0 - lock_hash = lock_parts[1] if len(lock_parts) > 1 else "" - return lock_pid, lock_hash - - def _write_remote_command_lock(self, lock_file, event_hash, now): - lock_file.seek(0) - lock_file.truncate() - lock_file.write(f"{os.getpid()} {event_hash} {now}\n") - lock_file.flush() - os.fsync(lock_file.fileno()) - - def _open_remote_command_lock(self, lock_path): - try: - lock_fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o666) - except OSError: - return None - try: - os.chmod(lock_path, 0o666) - except OSError: - pass - - return os.fdopen(lock_fd, "r+", encoding="utf-8") - - def _claim_remote_command(self, event_data): - lock_path = self._get_remote_command_lock_path(event_data) - event_hash = self._get_remote_command_hash(event_data) - now = time.time() - lock_file = self._open_remote_command_lock(lock_path) - if lock_file is None: - return False - - with lock_file: - try: - fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) - lock_pid, lock_hash = self._read_remote_command_lock(lock_file) - lock_stat = os.fstat(lock_file.fileno()) - - if lock_pid == os.getpid(): - self._write_remote_command_lock(lock_file, event_hash, now) - return True - - if not lock_pid or lock_hash != event_hash: - self._write_remote_command_lock(lock_file, event_hash, now) - return True - - if now - lock_stat.st_mtime < REMOTE_COMMAND_LOCK_TIMEOUT_SEC: - return False - - self._write_remote_command_lock(lock_file, event_hash, now) - return True - finally: - fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) - - def list_instances(self): - instances = remoteInstanceRegistry.list_instances() - if not instances: - return "No Fenrir instances registered" - - lines = [] - for instance in instances: - socket_files = ", ".join(instance.get("socket_files", [])) - x11_window_id = instance.get("x11_window_id") or "none" - main_socket = "yes" if instance.get("main_socket") else "no" - lines.append( - "pid={pid} ppid={ppid} screen={screen} keyboard={keyboard} " - "main_socket={main_socket} x11_window_id={x11_window_id} " - "sockets={sockets}".format( - pid=instance.get("pid", ""), - ppid=instance.get("ppid", ""), - screen=instance.get("screen_driver", ""), - keyboard=instance.get("keyboard_driver", ""), - main_socket=main_socket, - x11_window_id=x11_window_id, - sockets=socket_files, - ) - ) - return "\n".join(lines) - - def temp_disable_speech(self): - self.env["runtime"]["OutputManager"].temp_disable_speech() - - def set_vmenu(self, vmenu=""): - self.env["runtime"]["VmenuManager"].set_curr_menu(vmenu) - - def reset_vmenu(self): - self.env["runtime"]["VmenuManager"].set_curr_menu() - - def set_clipboard(self, text=""): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", text - ) - - def quit_fenrir(self): - self.env["runtime"]["EventManager"].stop_main_event_loop() - - def define_window(self, window_text): - start = {} - end = {} - try: - window_list = window_text.split(" ") - if len(window_list) < 4: - return - start["x"] = int(window_list[0]) - start["y"] = int(window_list[1]) - end["x"] = int(window_list[2]) - end["y"] = int(window_list[3]) - - self.env["runtime"]["CursorManager"].set_window_for_application( - start, end - ) - except Exception as e: - pass - - def reset_window(self): - self.env["runtime"]["CursorManager"].clear_window_for_application() - - def say(self, text): - if not text: - return - if text == "": - return - self.env["runtime"]["OutputManager"].speak_text(text) - - def interrupt_speech(self): - self.env["runtime"]["OutputManager"].interrupt_output() - - def export_clipboard(self): - clipboard_file_path = self.env["runtime"][ - "SettingsManager" - ].get_setting("clipboard", "clipboard_export_path") - clipboard_file_path = clipboard_file_path.replace( - "$user", self.env["general"]["curr_user"] - ) - clipboard_file_path = clipboard_file_path.replace( - "$USER", self.env["general"]["curr_user"] - ) - clipboard_file_path = clipboard_file_path.replace( - "$User", self.env["general"]["curr_user"] - ) - clipboard_file = open(clipboard_file_path, "w") - try: - if self.env["runtime"]["MemoryManager"].is_index_list_empty( - "clipboardHistory" - ): - self.env["runtime"]["OutputManager"].present_text( - _("clipboard empty"), interrupt=True - ) - return - clipboard = self.env["runtime"][ - "MemoryManager" - ].get_index_list_element("clipboardHistory") - # Fenrir will crash if the clipboard variable is type None - if clipboard is not None: - clipboard_file.write(clipboard) - else: - clipboard_file.write("") - clipboard_file.close() - os.chmod(clipboard_file_path, 0o644) - self.env["runtime"]["OutputManager"].present_text( - _("clipboard exported to file"), interrupt=True - ) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "export_clipboard_to_file:run: Filepath:" - + clipboard_file - + " trace:" - + str(e), - debug.DebugLevel.ERROR, - ) - - def save_settings(self, setting_config_path=None): - self.env["runtime"]["SettingsManager"].save_settings( - setting_config_path - ) - - def reset_settings(self): - self.env["runtime"]["SettingsManager"].reset_setting_arg_dict() - - def set_settings(self, settingsArgs): - self.env["runtime"]["SettingsManager"].parse_setting_args(settingsArgs) - self.env["runtime"]["ScreenManager"].update_screen_ignored() - self.env["runtime"]["InputManager"].handle_device_grab(force=True) - - def handle_remote_incomming_with_response(self, event_data): - if not event_data: - return {"success": False, "message": "No data received"} - - upper_event_data = event_data.upper() - self.env["runtime"]["DebugManager"].write_debug_out( - "RemoteManager:handle_remote_incomming_with_response: event: " - + str(event_data), - debug.DebugLevel.INFO, - ) - - try: - if upper_event_data in ( - self.listInstancesConst, - self.listInstancesLongConst, - ): - return { - "success": True, - "message": self.list_instances(), - } - if upper_event_data.startswith(self.settingConst): - settings_text = event_data[len(self.settingConst) :] - return self.handle_settings_change_with_response(settings_text) - elif upper_event_data.startswith(self.commandConst): - command_text = event_data[len(self.commandConst) :] - return self.handle_command_execution_with_response( - command_text - ) - else: - return { - "success": False, - "message": "Unknown command format. Use 'COMMAND ...' or 'SETTING ...'", - } - except Exception as e: - return {"success": False, "message": f"Exception: {str(e)}"} - - def handle_remote_incomming(self, event_data): - if not event_data: - return - upper_event_data = event_data.upper() - self.env["runtime"]["DebugManager"].write_debug_out( - "RemoteManager:handle_remote_incomming: event: " + str(event_data), - debug.DebugLevel.INFO, - ) - - if not self._claim_remote_command(event_data): - return - - if upper_event_data.startswith(self.settingConst): - settings_text = event_data[len(self.settingConst) :] - self.handle_settings_change(settings_text) - elif upper_event_data.startswith(self.commandConst): - command_text = event_data[len(self.commandConst) :] - self.handle_command_execution(command_text) diff --git a/src/fenrirscreenreader/core/runtimeData.py b/src/fenrirscreenreader/core/runtimeData.py deleted file mode 100644 index 7c9e1d12..00000000 --- a/src/fenrirscreenreader/core/runtimeData.py +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core import debug - -runtime_data = { - "SpeechDriver": None, - "ScreenDriver": None, - "SoundDriver": None, - "InputDriver": None, - "RemoteDriver": None, - "InputManager": None, - "CommandManager": None, - "ClipboardSyncManager": None, - "ScreenManager": None, - "OutputManager": None, - "SpeechHistoryManager": None, - "DebugManager": None, - "SettingsManager": None, - "FenrirManager": None, - "EventManager": None, - "BackgroundTaskManager": None, - "ProcessManager": None, - "SpeechDiscoveryManager": None, - "DiffReviewManager": None, -} diff --git a/src/fenrirscreenreader/core/sayAllManager.py b/src/fenrirscreenreader/core/sayAllManager.py deleted file mode 100644 index ebf75fd1..00000000 --- a/src/fenrirscreenreader/core/sayAllManager.py +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core import debug - - -class SayAllManager: - def __init__(self): - self.isActive = False - self.isActiveLock = None - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def set_is_active(self, isActive): - pass - - def start(self): - pass - - def is_say_all_active(self): - pass - - def say_all_worker(self): - pass - - def stop(self): - pass - - def finish(self): - pass - - def goto_next_page(self): - pass diff --git a/src/fenrirscreenreader/core/screenData.py b/src/fenrirscreenreader/core/screenData.py deleted file mode 100644 index 01bf7be0..00000000 --- a/src/fenrirscreenreader/core/screenData.py +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import time - -from fenrirscreenreader.core import debug - -screen_data = { - "columns": 0, - "lines": 0, - "oldDelta": "", - "oldAttribDelta": "", - "oldNegativeDelta": "", - "oldCursorReview": None, - "oldCursorAttrib": None, - "old_cursor": {"x": 0, "y": 0}, - "oldContentBytes": b"", - "old_content_text": "", - "oldContentAttrib": None, - "oldApplication": "", - "oldTTY": None, - "new_delta": "", - "new_delta_is_typing": False, - "newNegativeDelta": "", - "newAttribDelta": "", - "newCursorReview": None, - "newCursorAttrib": None, - "new_cursor": {"x": 0, "y": 0}, - "newContentBytes": b"", - "new_content_text": "", - "newContentAttrib": None, - "newTTY": "0", - "new_application": "", - "lastScreenUpdate": time.time(), - "autoIgnoreScreens": [], -} diff --git a/src/fenrirscreenreader/core/screenDriver.py b/src/fenrirscreenreader/core/screenDriver.py deleted file mode 100644 index 04708e1d..00000000 --- a/src/fenrirscreenreader/core/screenDriver.py +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core import debug -from fenrirscreenreader.core.i18n import _ - - -class ScreenDriver: - def __init__(self): - self._is_initialized = False - self.bgColorNames = { - 0: _("black"), - 1: _("blue"), - 2: _("green"), - 3: _("cyan"), - 4: _("red"), - 5: _("Magenta"), - 6: _("brown/yellow"), - 7: _("white"), - } - self.fgColorNames = { - 0: _("Black"), - 1: _("Blue"), - 2: _("Green"), - 3: _("Cyan"), - 4: _("Red"), - 5: _("Magenta"), - 6: _("brown/yellow"), - 7: _("Light gray"), - 8: _("Dark gray"), - 9: _("Light blue"), - 10: ("Light green"), - 11: _("Light cyan"), - 12: _("Light red"), - 13: _("Light magenta"), - 14: _("Light yellow"), - 15: _("White"), - } - - def initialize(self, environment): - self.env = environment - self._is_initialized = True - - def shutdown(self): - self._is_initialized = False - - def get_curr_screen(self): - pass - - def inject_text_to_screen(self, text, screen=None): - pass - - def get_curr_application(self): - pass - - def get_session_information(self): - pass diff --git a/src/fenrirscreenreader/core/screenManager.py b/src/fenrirscreenreader/core/screenManager.py deleted file mode 100644 index e45174c5..00000000 --- a/src/fenrirscreenreader/core/screenManager.py +++ /dev/null @@ -1,620 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import difflib -import os -import re -import time - -from fenrirscreenreader.core import debug -from fenrirscreenreader.utils import screen_utils - - -class ScreenManager: - def __init__(self): - self.differ = difflib.Differ() - self.currScreenIgnored = False - self.prevScreenIgnored = False - self.prevScreenText = "" - self.currScreenText = "" - self.colums = None - self.rows = None - # Compile regex once for better performance - self._space_normalize_regex = re.compile(" +") - - def get_rows(self): - return self.rows - - def get_columns(self): - return self.colums - - def initialize(self, environment): - self.env = environment - self.env["runtime"]["SettingsManager"].load_driver( - self.env["runtime"]["SettingsManager"].get_setting( - "screen", "driver" - ), - "ScreenDriver", - ) - self.get_curr_screen() - self.get_curr_screen() - self.get_session_information() - self.update_screen_ignored() - self.update_screen_ignored() - - def reset_screen_text(self, screen_text): - self.prevScreenText = "" - self.currScreenText = screen_text - - def set_screen_text(self, screen_text): - self.prevScreenText = self.currScreenText - self.currScreenText = screen_text - - def get_screen_text(self): - return self.currScreenText - - def get_line_text(self, line_number): - """Get text from a specific line (0-based index)""" - if not self.env["screen"]["new_content_text"]: - return "" - - lines = self.env["screen"]["new_content_text"].split("\n") - if line_number < 0 or line_number >= len(lines): - return "" - - return lines[line_number] - - def get_curr_screen(self): - try: - self.env["runtime"]["ScreenDriver"].get_curr_screen() - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "ScreenManager get_curr_screen: Error getting current screen: " - + str(e), - debug.DebugLevel.ERROR, - ) - - def get_session_information(self): - try: - self.env["runtime"]["ScreenDriver"].get_session_information() - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "ScreenManager get_session_information: Error getting session info: " - + str(e), - debug.DebugLevel.ERROR, - ) - - def shutdown(self): - self.env["runtime"]["SettingsManager"].shutdown_driver("ScreenDriver") - - def is_curr_screen_ignored_changed(self): - return self.get_curr_screen_ignored() != self.get_prev_screen_ignored() - - def handle_screen_change(self, event_data): - self.get_curr_screen() - self.get_session_information() - self.update_screen_ignored() - if self.is_curr_screen_ignored_changed(): - self.env["runtime"]["InputManager"].set_execute_device_grab() - self.env["runtime"]["InputManager"].handle_device_grab() - - # Update current application detection on screen change - try: - self.env["runtime"]["ScreenDriver"].get_curr_application() - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - f"Application detection failed: {str(e)}", debug.DebugLevel.ERROR - ) - - if not self.is_ignored_screen(self.env["screen"]["newTTY"]): - self.update(event_data, "onScreenChange") - self.env["screen"]["lastScreenUpdate"] = time.time() - else: - self.env["runtime"]["OutputManager"].interrupt_output() - - def handle_screen_update(self, event_data): - self.env["screen"]["oldApplication"] = self.env["screen"][ - "new_application" - ] - self.update_screen_ignored() - if self.is_curr_screen_ignored_changed(): - self.env["runtime"]["InputManager"].set_execute_device_grab() - self.env["runtime"]["InputManager"].handle_device_grab() - if not self.get_curr_screen_ignored(): - self.update(event_data, "onScreenUpdate") - self.env["screen"]["lastScreenUpdate"] = time.time() - elif self.is_curr_screen_ignored_changed(): - self.env["runtime"]["OutputManager"].interrupt_output() - - def get_curr_screen_ignored(self): - return self.currScreenIgnored - - def get_prev_screen_ignored(self): - return self.prevScreenIgnored - - def update_screen_ignored(self): - self.prevScreenIgnored = self.currScreenIgnored - self.currScreenIgnored = self.is_ignored_screen( - self.env["screen"]["newTTY"] - ) - - def update(self, event_data, trigger="onUpdate"): - # set new "old" values - self.env["screen"]["oldContentBytes"] = self.env["screen"][ - "newContentBytes" - ] - self.env["screen"]["old_content_text"] = self.env["screen"][ - "new_content_text" - ] - self.env["screen"]["old_cursor"] = self.env["screen"][ - "new_cursor" - ].copy() - self.env["screen"]["oldDelta"] = self.env["screen"]["new_delta"] - self.env["screen"]["oldNegativeDelta"] = self.env["screen"][ - "newNegativeDelta" - ] - self.env["screen"]["newContentBytes"] = event_data["bytes"] - - # get metadata like cursor or screensize - self.env["screen"]["lines"] = int(event_data["lines"]) - self.env["screen"]["columns"] = int(event_data["columns"]) - self.colums = int(event_data["columns"]) - self.rows = int(event_data["lines"]) - self.env["screen"]["new_cursor"]["x"] = int( - event_data["textCursor"]["x"] - ) - self.env["screen"]["new_cursor"]["y"] = int( - event_data["textCursor"]["y"] - ) - self.env["screen"]["newTTY"] = event_data["screen"] - self.env["screen"]["new_content_text"] = event_data["text"] - - - # screen change - if self.is_screen_change(): - self.env["screen"]["oldContentBytes"] = b"" - self.reset_screen_text(event_data["text"]) - self.env["runtime"]["AttributeManager"].reset_attributes( - event_data["attributes"] - ) - self.env["runtime"]["AttributeManager"].reset_attribute_cursor() - self.env["screen"]["old_content_text"] = "" - self.env["screen"]["old_cursor"]["x"] = 0 - self.env["screen"]["old_cursor"]["y"] = 0 - self.env["screen"]["oldDelta"] = "" - self.env["screen"]["oldNegativeDelta"] = "" - else: - self.set_screen_text(event_data["text"]) - self.env["runtime"]["AttributeManager"].set_attributes( - event_data["attributes"] - ) - # initialize current deltas - self.env["screen"]["newNegativeDelta"] = "" - self.env["screen"]["new_delta"] = "" - self.env["screen"]["new_delta_is_typing"] = False - self.env["runtime"]["AttributeManager"].reset_attribute_delta() - - # Diff generation - critical for screen reader functionality - # This code detects and categorizes screen content changes to provide appropriate - # speech feedback (typing echo vs incoming text vs screen updates) - - # Track whether this appears to be typing (user input) vs other screen - # changes - typing = False - diff_list = [] - - if ( - self.env["screen"]["old_content_text"] - != self.env["screen"]["new_content_text"] - ): - # Special case: Initial screen content (going from empty to populated) - # This handles first screen load or TTY switch scenarios - if ( - self.env["screen"]["new_content_text"] != "" - and self.env["screen"]["old_content_text"] == "" - ): - # Pre-process screen text for comparison - collapse multiple spaces to single space - # This normalization prevents spurious diffs from spacing - # inconsistencies - old_screen_text = self._space_normalize_regex.sub( - " ", - self.env["runtime"][ - "ScreenManager" - ].get_window_area_in_text( - self.env["screen"]["old_content_text"] - ), - ) - new_screen_text = self._space_normalize_regex.sub( - " ", - self.env["runtime"][ - "ScreenManager" - ].get_window_area_in_text( - self.env["screen"]["new_content_text"] - ), - ) - if old_screen_text == "" and new_screen_text != "": - self.env["screen"]["new_delta"] = new_screen_text - else: - # Calculate byte positions for the current cursor's line in the flat text buffer - # Formula: (line_number * columns) + line_number accounts for newlines - # Each line contributes 'columns' chars + 1 newline char - cursor_line_start = ( - self.env["screen"]["new_cursor"]["y"] - * self.env["screen"]["columns"] - + self.env["screen"]["new_cursor"]["y"] - ) - cursor_line_end = ( - cursor_line_start + self.env["screen"]["columns"] - ) - cursor_moved_horizontally = ( - abs( - self.env["screen"]["old_cursor"]["x"] - - self.env["screen"]["new_cursor"]["x"] - ) - >= 1 - and self.env["screen"]["old_cursor"]["y"] - == self.env["screen"]["new_cursor"]["y"] - ) - cursor_line_typing_delta = ( - self._get_cursor_line_typing_delta() - ) - cursor_line_is_typing = ( - cursor_line_typing_delta is not None - and ( - cursor_moved_horizontally - or self._is_recent_input() - ) - ) - - # TYPING DETECTION ALGORITHM - # Determines if this screen change is likely user typing vs other content changes - # All conditions must be met for typing detection: - if ( - cursor_line_is_typing - and cursor_line_typing_delta["changed_lines"] - == [cursor_line_typing_delta["cursor_y"]] - ): - diff_list = [ - "+ " + cursor_line_typing_delta["text"] - ] - typing = True - elif ( - cursor_moved_horizontally - and self.env["screen"]["new_content_text"][ - :cursor_line_start - ] - == self.env["screen"]["old_content_text"][ - :cursor_line_start - ] - and self.env["screen"]["new_content_text"][ - cursor_line_end: - ] - == self.env["screen"]["old_content_text"][cursor_line_end:] - ): - # Condition 1: Cursor moved horizontally by at least 1 position (typical of typing) - # Condition 2: Cursor stayed on same line (typing doesn't usually change lines) - # Condition 3: Content BEFORE cursor line is unchanged (text above typing area) - # Condition 4: Content AFTER cursor line is unchanged (text below typing area) - # Together: only the current line changed, cursor moved - # horizontally = likely typing - - # Optimize diff calculation for typing by focusing on a - # small window around cursor - cursor_line_start_offset = cursor_line_start - cursor_line_end_offset = cursor_line_end - - # Limit analysis window to avoid processing entire long lines - # +3 provides safety buffer beyond cursor position to catch edge cases - if ( - cursor_line_end - > cursor_line_start - + self.env["screen"]["new_cursor"]["x"] - + 3 - ): - cursor_line_end_offset = ( - cursor_line_start - + self.env["screen"]["new_cursor"]["x"] - + 3 - ) - - # Extract just the relevant text sections for - # character-level diff - old_screen_text = self.env["screen"]["old_content_text"][ - cursor_line_start_offset:cursor_line_end_offset - ] - new_screen_text = self.env["screen"]["new_content_text"][ - cursor_line_start_offset:cursor_line_end_offset - ] - - # Character-level diff for typing detection (not - # line-level) - diff = self.differ.compare( - old_screen_text, new_screen_text - ) - diff_list = list(diff) - typing = True - - # Validate typing assumption by checking if detected - # changes match cursor movement - temp_new_delta = "".join( - x[2:] for x in diff_list if x[0] == "+" - ) - if temp_new_delta.strip() != "": - # Compare diff result against expected typing pattern - # Expected: characters between old and new cursor - # positions - expected_typing = "".join( - new_screen_text[ - self.env["screen"]["old_cursor"][ - "x" - ] : self.env["screen"]["new_cursor"]["x"] - ].rstrip() - ) - - # Prompt/status repaints can add extra diff text while - # the user's typed characters are still at the cursor. - # Keep those as typing so incoming speech does not - # announce the repainted prompt line. - if temp_new_delta != expected_typing: - if expected_typing.strip() != "": - diff_list = ["+ " + expected_typing] - else: - # Fallback: treat entire current line as new - # content - current_line = self.env["screen"][ - "new_content_text" - ].split("\n")[ - self.env["screen"]["new_cursor"]["y"] - ] - diff_list = [ - "+ " + current_line + "\n" - ] - typing = False - else: - # GENERAL SCREEN CHANGE DETECTION - # Not typing - handle as line-by-line content change - # This catches: incoming messages, screen updates, - # application output, etc. - typing_delta = ( - cursor_line_typing_delta - if cursor_line_is_typing - else None - ) - if ( - typing_delta is not None - and typing_delta["changed_lines"] - == [typing_delta["cursor_y"]] - ): - diff_list = ["+ " + typing_delta["text"]] - typing = True - else: - old_content_text = self.env["screen"][ - "old_content_text" - ] - new_content_text = self.env["screen"][ - "new_content_text" - ] - if typing_delta is not None: - old_lines = old_content_text.split("\n") - new_lines = new_content_text.split("\n") - new_lines[typing_delta["cursor_y"]] = old_lines[ - typing_delta["cursor_y"] - ] - old_content_text = "\n".join(old_lines) - new_content_text = "\n".join(new_lines) - - # Pre-process screen text for comparison - collapse multiple spaces to single space - # This normalization prevents spurious diffs from spacing - # inconsistencies - old_screen_text = self._space_normalize_regex.sub( - " ", - self.env["runtime"][ - "ScreenManager" - ].get_window_area_in_text(old_content_text), - ) - new_screen_text = self._space_normalize_regex.sub( - " ", - self.env["runtime"][ - "ScreenManager" - ].get_window_area_in_text(new_content_text), - ) - - diff = self.differ.compare( - old_screen_text.split("\n"), - new_screen_text.split("\n"), - ) - diff_list = list(diff) - - # Extract added and removed content from diff results - # Output format depends on whether this was detected as typing - # or general change - if not typing: - # Line-based changes: join with newlines for proper speech - # cadence - self.env["screen"]["new_delta"] = "\n".join( - x[2:] for x in diff_list if x[0] == "+" - ) - else: - # Character-based changes: no newlines for smooth typing - # echo - self.env["screen"]["new_delta"] = "".join( - x[2:] for x in diff_list if x[0] == "+" - ) - self.env["screen"]["new_delta_is_typing"] = typing - - # Negative delta (removed content) - used for backspace/delete - # detection - self.env["screen"]["newNegativeDelta"] = "".join( - x[2:] for x in diff_list if x[0] == "-" - ) - - # track highlighted - try: - if self.env["runtime"]["AttributeManager"].is_attribute_change(): - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "focus", "highlight" - ): - attribute_delta, attributeCursor = self.env["runtime"][ - "AttributeManager" - ].track_highlights() - if attributeCursor: - self.env["runtime"][ - "AttributeManager" - ].set_attribute_cursor(attributeCursor) - self.env["runtime"][ - "AttributeManager" - ].set_attribute_delta(attribute_delta) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "ScreenManager:update:highlight: " + str(e), - debug.DebugLevel.ERROR, - ) - - def is_ignored_screen(self, screen=None): - if screen is None: - screen = self.env["screen"]["newTTY"] - # Check if force all screens flag is set - if self.env["runtime"].get("force_all_screens", False): - return False - ignore_screens = [] - fix_ignore_screens = self.env["runtime"][ - "SettingsManager" - ].get_setting("screen", "ignore_screen") - if fix_ignore_screens != "": - ignore_screens.extend(fix_ignore_screens.split(",")) - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "screen", "autodetect_ignore_screen" - ): - ignore_screens.extend(self.env["screen"]["autoIgnoreScreens"]) - self.env["runtime"]["DebugManager"].write_debug_out( - "ScreenManager:is_ignored_screen ignore:" - + str(ignore_screens) - + " current:" - + str(screen), - debug.DebugLevel.INFO, - ) - return screen in ignore_screens - - def _is_recent_input(self): - try: - return ( - time.time() - - self.env["runtime"][ - "InputManager" - ].get_last_input_time() - <= 0.3 - ) - except Exception: - return False - - def _get_cursor_line_typing_delta(self): - try: - old_lines = self.env["screen"]["old_content_text"].split("\n") - new_lines = self.env["screen"]["new_content_text"].split("\n") - cursor_y = self.env["screen"]["new_cursor"]["y"] - if ( - len(old_lines) != len(new_lines) - or cursor_y < 0 - or cursor_y >= len(new_lines) - ): - return None - - changed_lines = [ - index - for index, old_line in enumerate(old_lines) - if index >= len(new_lines) or old_line != new_lines[index] - ] - if cursor_y not in changed_lines: - return None - - old_line = old_lines[cursor_y].rstrip() - new_line = new_lines[cursor_y].rstrip() - if old_line == new_line: - return None - - matcher = difflib.SequenceMatcher( - None, - old_line, - new_line, - autojunk=False, - ) - inserted_parts = [] - for tag, i1, i2, j1, j2 in matcher.get_opcodes(): - if tag == "equal": - continue - if tag == "insert": - inserted_parts.append(new_line[j1:j2]) - continue - if tag == "replace" and old_line[i1:i2].strip() == "": - inserted_parts.append(new_line[j1:j2]) - continue - return None - - typed_text = "".join(inserted_parts).strip() - if typed_text == "" or len(typed_text) > 4: - return None - return { - "text": typed_text, - "cursor_y": cursor_y, - "changed_lines": changed_lines, - } - except Exception: - return None - - def is_screen_change(self): - if not self.env["screen"]["oldTTY"]: - return False - return self.env["screen"]["newTTY"] != self.env["screen"]["oldTTY"] - - def is_delta(self, ignoreSpace=False): - new_delta = self.env["screen"]["new_delta"] - if ignoreSpace: - new_delta = new_delta.strip() - return new_delta != "" - - def is_negative_delta(self): - return self.env["screen"]["newNegativeDelta"] != "" - - def get_window_area_in_text(self, text): - if not self.env["runtime"][ - "CursorManager" - ].is_application_window_set(): - return text - window_text = "" - window_list = text.split("\n") - curr_app = self.env["runtime"][ - "ApplicationManager" - ].get_current_application() - window_list = window_list[ - self.env["commandBuffer"]["windowArea"][curr_app]["1"][ - "y" - ] : self.env["commandBuffer"]["windowArea"][curr_app]["2"]["y"] - + 1 - ] - for line in window_list: - window_text += ( - line[ - self.env["commandBuffer"]["windowArea"][curr_app]["1"][ - "x" - ] : self.env["commandBuffer"]["windowArea"][curr_app]["2"][ - "x" - ] - + 1 - ] - + "\n" - ) - return window_text - - def inject_text_to_screen(self, text, screen=None): - try: - self.env["runtime"]["ScreenDriver"].inject_text_to_screen( - text, screen - ) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "ScreenManager:inject_text_to_screen " + str(e), - debug.DebugLevel.ERROR, - ) diff --git a/src/fenrirscreenreader/core/settingsData.py b/src/fenrirscreenreader/core/settingsData.py deleted file mode 100644 index 67af1681..00000000 --- a/src/fenrirscreenreader/core/settingsData.py +++ /dev/null @@ -1,148 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core import debug - -settings_data = { - "sound": { - "enabled": True, - "driver": "genericDriver", - "theme": "default", - "volume": 1.0, - "generic_play_file_command": ( - "play -q -v fenrir_volume fenrir_sound_file" - ), - "generic_frequency_command": ( - "play -q -v fenrir_volume -n -c1 synth " - "fenrir_duration sine fenrir_frequency" - ), - "progress_monitoring": True, - }, - "speech": { - "enabled": True, - "driver": "genericDriver", - "server_path": "", - "rate": 0.75, - "pitch": 0.5, - "capital_pitch": 0.8, - "capital_indicator": "pitch", - "volume": 1.0, - "module": "", - "voice": "en-us", - "language": "", - "hardware_device": "/dev/ttyS0", - "hardware_baud_rate": 9600, - "auto_read_incoming": True, - "read_numbers_as_digits": False, - "history_size": 50, - "rapid_update_threshold": 5, - "rapid_update_window": 0.3, - "batch_flush_interval": 0.5, - "max_batch_lines": 100, - "flood_line_threshold": 500, - "generic_speech_command": ( - "espeak -a fenrir_volume -s fenrir_rate -p fenrir_pitch " - '-v fenrir_voice "fenrir_text"' - ), - "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": { - "driver": "vcsaDriver", - "encoding": "auto", - "screen_update_delay": 0.1, - "ignore_screen": "", - "autodetect_ignore_screen": False, - }, - "general": { - "debug_level": debug.DebugLevel.DEACTIVE, - "debug_mode": "FILE", - "debug_file": "", - "punctuation_profile": "default", - "punctuation_level": "some", - "respect_punctuation_pause": True, - "replace_undefined_punctuation_with_space": True, - "new_line_pause": True, - "emoticons": True, - "fenrir_keys": "KEY_KP0,KEY_META", - "script_keys": "KEY_COMPOSE", - "time_format": "%I:%M%P", - "date_format": "%A, %B %d, %Y", - "auto_spell_check": False, - "spell_check_language": "en_US", - "script_path": "/usr/share/fenrirscreenreader/scripts", - "command_path": "/usr/share/fenrirscreenreader/commands", - "attribute_format_string": "Background fenrirBGColor,Foreground fenrirFGColor,fenrirUnderline,fenrirBold,fenrirBlink, Font fenrirFont,Fontsize fenrirFontSize", - "auto_present_indent": False, - "auto_present_indent_mode": 1, - "has_attributes": True, - "shell": "", - "diff_presentation": "both", - "diff_verbosity": "compact", - }, - "clipboard": { - "number_of_clipboards": 10, - "clipboard_export_path": "/tmp/fenrirClipboard", - "sync_enabled": False, - "sync_display": "", - "sync_interval": 0.5, - }, - "focus": { - "cursor": True, - "highlight": False, - "tui": False, - }, - "remote": { - "enabled": True, - "driver": "unixDriver", - "port": 22447, - "socket_file": "", - "enable_settings_remote": True, - "enable_command_remote": True, - }, - "barrier": { - "enabled": True, - "left_barriers": "ā”‚ā””ā”Œā”€", - "right_barriers": "ā”‚ā”˜ā”ā”€", - }, - "review": { - "line_break": True, - "end_of_screen": True, - "leave_review_on_cursor_change": True, - "leave_review_on_screen_change": True, - }, - "menu": { - "vmenu_path": "", - "quick_menu": "speech#rate;speech#pitch;speech#volume", - }, - "time": { - "enabled": False, - "present_time": True, - "present_date": True, - "delay_sec": 0, - "on_minutes": "00,30", - "announce": True, - "interrupt": False, - }, - "keyboard": { - "driver": "evdev", - "device": "all", - "grab_devices": True, - "x11_window_id": "", - "ignore_shortcuts": False, - "keyboard_layout": "desktop", - "char_echo_mode": 2, # while capslock - "char_delete_echo": True, - "word_echo": True, - "interrupt_on_key_press": True, - "interrupt_on_key_press_filter": "", - "double_tap_timeout": 0.2, - }, -} diff --git a/src/fenrirscreenreader/core/settingsManager.py b/src/fenrirscreenreader/core/settingsManager.py deleted file mode 100644 index dd23fd2b..00000000 --- a/src/fenrirscreenreader/core/settingsManager.py +++ /dev/null @@ -1,900 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import copy -import inspect -import os -from argparse import Namespace -from configparser import ConfigParser - -from fenrirscreenreader.core import applicationManager -from fenrirscreenreader.core import attributeManager -from fenrirscreenreader.core import barrierManager -from fenrirscreenreader.core import backgroundTaskManager -from fenrirscreenreader.core import clipboardSyncManager -from fenrirscreenreader.core import commandManager -from fenrirscreenreader.core import cursorManager -from fenrirscreenreader.core import debug -from fenrirscreenreader.core import debugManager -from fenrirscreenreader.core import diffReviewManager -from fenrirscreenreader.core import environment -from fenrirscreenreader.core import eventManager -from fenrirscreenreader.core import helpManager -from fenrirscreenreader.core import inputManager -from fenrirscreenreader.core import memoryManager -from fenrirscreenreader.core import outputManager -from fenrirscreenreader.core import processManager -from fenrirscreenreader.core import punctuationManager -from fenrirscreenreader.core import quickMenuManager -from fenrirscreenreader.core import readAllManager -from fenrirscreenreader.core import remoteManager -from fenrirscreenreader.core import sayAllManager -from fenrirscreenreader.core import screenManager -from fenrirscreenreader.core import speechHistoryManager -from fenrirscreenreader.core import speechDiscoveryManager -from fenrirscreenreader.core import tableManager -from fenrirscreenreader.core import textManager -from fenrirscreenreader.core import vmenuManager -from fenrirscreenreader.core.settingsData import settings_data -from fenrirscreenreader.utils import module_utils - -currentdir = os.path.dirname( - os.path.realpath(os.path.abspath(inspect.getfile(inspect.currentframe()))) -) -fenrir_path = os.path.dirname(currentdir) - - -class SettingsManager: - system_settings_root = "/etc/fenrirscreenreader/" - system_settings_file = "/etc/fenrirscreenreader/settings/settings.conf" - user_settings_file = ( - "~/.local/share/stormux/fenrirscreenreader/settings/settings.conf" - ) - user_resource_root = "~/.local/stormux/fenrir/" - system_sound_roots = [ - "/usr/share/sounds/fenrir/", - "/usr/share/sounds/fenrirscreenreader/", - ] - - def __init__(self): - self.settings = settings_data - self.settingArgDict = {} - self.bindingsBackup = None - self.settings_file = "" - self.save_settings_path = "" - self.pending_grab_devices = None - self.active_grab_devices = None - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def format_cli_args(self, cliArgs): - if cliArgs is None: - return "{}" - if isinstance(cliArgs, Namespace): - args = vars(cliArgs) - else: - args = vars(cliArgs) if hasattr(cliArgs, "__dict__") else {} - return str({key: args[key] for key in sorted(args)}) - - def get_binding_backup(self): - return self.bindingsBackup.copy() - - def load_sound_icons(self, soundIconPath, environment=None): - # Use passed environment or fall back to self.env - env = environment if environment is not None else self.env - try: - with open(soundIconPath + "/soundicons.conf", "r") as siConfig: - while True: - line = siConfig.readline() - if not line: - break - line = line.replace("\n", "") - if line.replace(" ", "") == "": - continue - if line.replace(" ", "").startswith("#"): - continue - if line.count("=") != 1: - continue - values = line.split("=") - sound_icon = values[0].upper() - values[1] = values[1].replace("'", "") - values[1] = values[1].replace('"', "") - sound_icon_file = "" - if os.path.exists(values[1]): - sound_icon_file = values[1] - else: - if not soundIconPath.endswith("/"): - soundIconPath += "/" - if os.path.exists(soundIconPath + values[1]): - sound_icon_file = soundIconPath + values[1] - env["soundIcons"][sound_icon] = sound_icon_file - env["runtime"]["DebugManager"].write_debug_out( - "SoundIcon: " + sound_icon + "." + sound_icon_file, - debug.DebugLevel.INFO, - on_any_level=True, - ) - except (IOError, OSError) as e: - env["runtime"]["DebugManager"].write_debug_out( - "load_sound_icons: failed to load sound icons from " - + soundIconPath - + ". Error: " - + str(e), - debug.DebugLevel.ERROR, - ) - - def get_settings_file(self): - return self.settings_file - - def set_settings_file(self, settings_file): - if os.path.exists(settings_file) and not os.access( - settings_file, os.R_OK - ): - return - self.settings_file = settings_file - - def get_user_settings_file(self): - return os.path.expanduser(self.user_settings_file) - - def get_system_settings_file(self): - return self.system_settings_file - - def is_user_settings_mode(self): - return os.geteuid() != 0 - - def get_default_save_settings_file(self): - if self.is_user_settings_mode(): - return self.get_user_settings_file() - return self.get_system_settings_file() - - def get_bundled_settings_root(self): - return os.path.abspath(os.path.join(fenrir_path, "../../config/")) + "/" - - def get_user_resource_root(self): - return os.path.expanduser(self.user_resource_root) - - def get_user_script_path(self): - return self.get_user_resource_root() - - def get_user_sound_root(self): - return os.path.join(self.get_user_resource_root(), "sounds/") - - def get_resource_settings_root(self): - if os.path.exists(self.system_settings_root): - return self.system_settings_root - bundled_settings_root = self.get_bundled_settings_root() - if os.path.exists(bundled_settings_root): - return bundled_settings_root - return "" - - def get_sound_theme_roots(self): - candidate_roots = [ - self.get_user_sound_root(), - *self.system_sound_roots, - os.path.join(self.get_bundled_settings_root(), "sound/"), - ] - sound_roots = [] - seen_roots = set() - for root in candidate_roots: - normalized_root = os.path.abspath(os.path.expanduser(root)) - if normalized_root in seen_roots: - continue - sound_roots.append(normalized_root + "/") - seen_roots.add(normalized_root) - return sound_roots - - def resolve_sound_theme_path(self, theme): - if not theme: - return "" - theme = os.path.expanduser(theme) - if os.path.exists(os.path.join(theme, "soundicons.conf")): - return theme - for sound_root in self.get_sound_theme_roots(): - theme_path = os.path.join(sound_root, theme) - if os.path.exists(os.path.join(theme_path, "soundicons.conf")): - return theme_path - return "" - - def load_keyboard_layout(self, environment): - settings_root = self.get_resource_settings_root() - keyboard_layout = self.get_setting("keyboard", "keyboard_layout") - if os.path.exists(keyboard_layout): - environment["runtime"]["InputManager"].load_shortcuts( - keyboard_layout - ) - return - - layout_path = os.path.join(settings_root, "keyboard", keyboard_layout) - if os.path.exists(layout_path): - self.set_setting("keyboard", "keyboard_layout", layout_path) - environment["runtime"]["InputManager"].load_shortcuts(layout_path) - return - - layout_path = os.path.join( - settings_root, "keyboard", keyboard_layout + ".conf" - ) - if os.path.exists(layout_path): - self.set_setting("keyboard", "keyboard_layout", layout_path) - environment["runtime"]["InputManager"].load_shortcuts(layout_path) - - def resolve_settings_file(self, requested_settings_file=None): - if requested_settings_file and os.path.exists(requested_settings_file): - return requested_settings_file - - if self.is_user_settings_mode() and os.path.exists( - self.get_user_settings_file() - ): - return self.get_user_settings_file() - - if os.path.exists(self.get_system_settings_file()): - return self.get_system_settings_file() - - bundled_settings_file = os.path.join( - self.get_bundled_settings_root(), "settings/settings.conf" - ) - if os.path.exists(bundled_settings_file): - return bundled_settings_file - - return "" - - def load_settings(self, setting_config_path): - if not os.path.exists(setting_config_path): - return False - if not os.access(setting_config_path, os.R_OK): - return False - self.env["settings"] = ConfigParser() - try: - self.env["settings"].read(setting_config_path) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "settings_manager load_settings: Error reading config file: " - + str(e), - debug.DebugLevel.ERROR, - ) - return False - self.set_settings_file(setting_config_path) - return True - - def save_settings(self, setting_config_path=None): - if setting_config_path is None: - if self.save_settings_path: - setting_config_path = self.save_settings_path - else: - setting_config_path = self.get_default_save_settings_file() - - # Ensure directory exists for user-local settings - if setting_config_path and setting_config_path.startswith( - os.path.expanduser("~/.local/") - ): - config_dir = os.path.dirname(setting_config_path) - if not os.path.exists(config_dir): - try: - os.makedirs(config_dir, mode=0o755, exist_ok=True) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "save_settings: failed to create directory " - + config_dir - + ": " - + str(e), - debug.DebugLevel.ERROR, - ) - # set opt dict here - # save file - try: - # print('file: ',setting_config_path) - for section, settings in self.settingArgDict.items(): - if not self.env["settings"].has_section(section): - self.env["settings"].add_section(section) - for setting, value in settings.items(): - # print(section, setting, value) - self.env["settings"].set(section, setting, value) - # print('full',self.env['settings']) - - config_for_save = copy.deepcopy(self.env["settings"]) - if self.pending_grab_devices is not None: - if not config_for_save.has_section("keyboard"): - config_for_save.add_section("keyboard") - config_for_save.set( - "keyboard", "grab_devices", str(self.pending_grab_devices) - ) - with open(setting_config_path, "w") as config_file: - config_for_save.write(config_file) - os.chmod(setting_config_path, 0o644) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "save_settings: save settingsfile:" - + setting_config_path - + "failed. Error:" - + str(e), - debug.DebugLevel.ERROR, - ) - return False - return True - - def get_grab_devices_for_restart(self): - if self.pending_grab_devices is not None: - return self.pending_grab_devices - return self.get_setting_as_bool("keyboard", "grab_devices") - - def stage_grab_devices(self, value): - """Keep input cleanup on its active mode until the next process starts.""" - if not isinstance(value, bool): - raise ValueError("grab_devices must be a boolean") - if self.active_grab_devices is None: - self.active_grab_devices = self.get_setting_as_bool( - "keyboard", "grab_devices" - ) - self.pending_grab_devices = value - - def set_setting(self, section, setting, value): - self.set_option_arg_dict(section, setting, value) - # self.env['settings'].set(section, setting, value) - - def get_setting(self, section, setting): - if (section, setting) == ("keyboard", "grab_devices"): - if self.active_grab_devices is not None: - return str(self.active_grab_devices) - value = "" - try: - value = self.settingArgDict[section][setting] - return value - except Exception as e: - pass - try: - value = self.env["settings"].get(section, setting) - except Exception as e: - value = str(self.settings[section][setting]) - return value - - def get_setting_as_int(self, section, setting): - value = 0 - try: - value = int(self.settingArgDict[section][setting]) - return value - except Exception as e: - pass - try: - value = self.env["settings"].getint(section, setting) - except Exception as e: - value = self.settings[section][setting] - return value - - def get_setting_as_float(self, section, setting): - value = 0.0 - try: - value = float(self.settingArgDict[section][setting]) - return value - except Exception as e: - pass - try: - value = self.env["settings"].getfloat(section, setting) - except Exception as e: - value = self.settings[section][setting] - return value - - def get_setting_as_bool(self, section, setting): - if (section, setting) == ("keyboard", "grab_devices"): - if self.active_grab_devices is not None: - return self.active_grab_devices - value = False - try: - value = self.settingArgDict[section][setting].upper() in [ - "1", - "YES", - "JA", - "TRUE", - ] - return value - except Exception as e: - pass - try: - value = self.env["settings"].getboolean(section, setting) - except Exception as e: - value = self.settings[section][setting] - return value - - def load_driver(self, driverName, driverType): - # Map runtime keys to actual directory names - driver_dir_map = { - "InputDriver": "inputDriver", - "ScreenDriver": "screenDriver", - "SpeechDriver": "speechDriver", - "SoundDriver": "soundDriver", - "RemoteDriver": "remoteDriver", - } - driver_dir = driver_dir_map.get(driverType, driverType) - - try: - if self.env["runtime"][driverType] is not None: - self.env["runtime"][driverType].shutdown() - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "settings_manager load_driver: Error shutting down driver: " - + str(e), - debug.DebugLevel.ERROR, - ) - try: - driver_mod = module_utils.import_module( - driverName, - fenrir_path + "/" + driver_dir + "/" + driverName + ".py", - ) - self.env["runtime"][driverType] = driver_mod.driver() - self.env["runtime"][driverType].initialize(self.env) - self.env["runtime"]["DebugManager"].write_debug_out( - "Loading Driver " + driverType + " (" + driverName + ") OK", - debug.DebugLevel.INFO, - on_any_level=True, - ) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "Loading Driver " - + driverType - + " (" - + driverName - + ") FAILED:" - + str(e), - debug.DebugLevel.ERROR, - ) - try: - driver_mod = module_utils.import_module( - driverName, - fenrir_path + "/" + driver_dir + "/dummyDriver.py", - ) - self.env["runtime"][driverType] = driver_mod.driver() - self.env["runtime"][driverType].initialize(self.env) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "(fallback) Loading Driver " - + driverType - + " (dummyDriver) FAILED:" - + str(e), - debug.DebugLevel.ERROR, - ) - - def shutdown_driver(self, driverType): - try: - self.env["runtime"][driverType].shutdown() - except Exception as e: - pass - del self.env["runtime"][driverType] - - def set_fenrir_keys(self, keys): - keys = keys.upper() - key_list = keys.split(",") - for key in key_list: - if key not in self.env["input"]["fenrir_key"]: - self.env["input"]["fenrir_key"].append(key) - - def set_script_keys(self, keys): - keys = keys.upper() - key_list = keys.split(",") - for key in key_list: - if key not in self.env["input"]["script_key"]: - self.env["input"]["script_key"].append(key) - - def reset_setting_arg_dict(self): - self.settingArgDict = {} - self.env["runtime"]["OutputManager"].reset_SpeechDriver() - - def set_option_arg_dict(self, section, setting, value): - # section = section.lower() - # setting = setting.lower() - try: - e = self.settingArgDict[section] - except KeyError: - self.settingArgDict[section] = {} - try: - t = self.settings[section][setting] - except Exception as e: - print(section, setting, "not found") - return - try: - v = value # Initialize v with the original value - if isinstance(self.settings[section][setting], str): - v = str(value) - elif isinstance(self.settings[section][setting], bool): - if value not in ["True", "False"]: - raise ValueError( - "could not convert string to bool: " + value - ) - v = value == "True" - elif isinstance(self.settings[section][setting], int): - v = int(value) - elif isinstance(self.settings[section][setting], float): - v = float(value) - - # Content validation for critical settings - self._validate_setting_value(section, setting, v) - - self.settingArgDict[section][setting] = str(value) - except Exception as e: - print( - "settings_manager:set_option_arg_dict:Datatype missmatch: " - + section - + "#" - + setting - + "=" - + str(value) - + " Error:" - + str(e) - ) - # self.env['runtime']['DebugManager'].write_debug_out('settings_manager:set_option_arg_dict:Datatype - # missmatch: '+ section + '#' + setting + '=' + value + ' Error:' - # + str(e), debug.DebugLevel.ERROR) - return - - def _validate_setting_value(self, section, setting, value): - """Validate setting values for critical screen reader functionality. - Only validates settings that could cause crashes or accessibility issues. - Invalid values raise ValueError which is caught by the calling method. - """ - - # Speech settings validation - critical for accessibility - if section == "speech": - if setting == "rate": - if not (0.0 <= value <= 3.0): - raise ValueError( - f"Speech rate must be between 0.0 and 3.0, got {value}" - ) - elif setting == "pitch": - if not (0.0 <= value <= 2.0): - raise ValueError( - f"Speech pitch must be between 0.0 and 2.0, got {value}" - ) - elif setting == "volume": - if not (0.0 <= value <= 1.5): - raise ValueError( - f"Speech volume must be between 0.0 and 1.5, got {value}" - ) - elif setting == "driver": - valid_drivers = [ - "speechdDriver", - "genericDriver", - "dectalkDriver", - "dectalkUsbDriver", - "doubletalkDriver", - "litetalkDriver", - "tripletalkDriver", - "dummyDriver", - ] - if value not in valid_drivers: - raise ValueError( - f"Invalid speech driver: {value}. Valid options: {valid_drivers}" - ) - - # Sound settings validation - elif section == "sound": - if setting == "volume": - if not (0.0 <= value <= 1.5): - raise ValueError( - f"Sound volume must be between 0.0 and 1.5, got {value}" - ) - elif setting == "driver": - valid_drivers = [ - "genericDriver", - "gstreamerDriver", - "dummyDriver", - ] - if value not in valid_drivers: - raise ValueError( - f"Invalid sound driver: {value}. Valid options: {valid_drivers}" - ) - - # Screen settings validation - elif section == "screen": - if setting == "driver": - valid_drivers = ["vcsaDriver", "ptyDriver", "dummyDriver"] - if value not in valid_drivers: - raise ValueError( - f"Invalid screen driver: {value}. Valid options: {valid_drivers}" - ) - - # Input settings validation - elif section == "keyboard": - if setting == "driver": - valid_drivers = [ - "evdevDriver", - "x11Driver", - "dummyDriver", - ] - if value not in valid_drivers: - raise ValueError( - f"Invalid input driver: {value}. Valid options: {valid_drivers}" - ) - - # General settings validation - elif section == "general": - if setting == "debug_level": - if not (0 <= value <= 3): - raise ValueError( - f"Debug level must be between 0 and 3, got {value}" - ) - - def parse_setting_args(self, settingArgs): - if settingArgs is None: - return - for optionElem in settingArgs.split(";"): - setting_val_list = [] - section_option_list = [] - section = "" - option = "" - value = "" - setting_val_list = optionElem.split("=", 1) - if len(setting_val_list) != 2: - continue - if "#" in setting_val_list[0]: - section_option_list = setting_val_list[0].split("#", 1) - elif "." in setting_val_list[0]: - section_option_list = setting_val_list[0].split(".", 1) - elif "," in setting_val_list[0]: - section_option_list = setting_val_list[0].split(",", 1) - elif "!" in setting_val_list[0]: - section_option_list = setting_val_list[0].split("!", 1) - else: - continue - if len(section_option_list) != 2: - continue - - section = str(section_option_list[0]) - option = str(section_option_list[1]) - value = str(setting_val_list[1]) - self.set_option_arg_dict(section, option, value) - - def init_fenrir_config( - self, cliArgs, fenrir_manager=None, environment=environment.environment - ): - self.cliArgs = cliArgs - - settings_root = self.get_resource_settings_root() - if not settings_root: - return None - - self.save_settings_path = self.get_default_save_settings_file() - settings_file = self.resolve_settings_file(cliArgs.setting) - if not settings_file: - return None - - environment["runtime"]["SettingsManager"] = self - environment["runtime"]["SettingsManager"].initialize(environment) - - valid_config = environment["runtime"]["SettingsManager"].load_settings( - settings_file - ) - if not valid_config: - return None - if cliArgs.options != "": - self.parse_setting_args(cliArgs.options) - if cliArgs.debug: - self.set_setting("general", "debug_level", 3) - if cliArgs.print: - self.set_setting("general", "debug_level", 3) - self.set_setting("general", "debug_mode", "PRINT") - if cliArgs.x11: - self.set_setting("screen", "driver", "ptyDriver") - self.set_setting("keyboard", "driver", "x11Driver") - if cliArgs.x11_window_id: - self.set_setting( - "keyboard", "x11_window_id", cliArgs.x11_window_id - ) - - self.set_fenrir_keys(self.get_setting("general", "fenrir_keys")) - self.set_script_keys(self.get_setting("general", "script_keys")) - - environment["runtime"]["DebugManager"] = debugManager.DebugManager( - self.env["runtime"]["SettingsManager"].get_setting( - "general", "debug_file" - ) - ) - environment["runtime"]["DebugManager"].initialize(environment) - environment["runtime"]["DebugManager"].write_debug_out( - "Fenrir startup CLI arguments: " + self.format_cli_args(cliArgs), - debug.DebugLevel.INFO, - on_any_level=True, - ) - - if cliArgs.force_all_screens: - environment["runtime"]["force_all_screens"] = True - - if cliArgs.ignore_screen: - current_ignore_screen = self.get_setting("screen", "ignore_screen") - if current_ignore_screen: - ignore_screens = ( - current_ignore_screen.split(",") + cliArgs.ignore_screen - ) - else: - ignore_screens = cliArgs.ignore_screen - self.set_setting( - "screen", "ignore_screen", ",".join(ignore_screens) - ) - - sound_theme_path = self.resolve_sound_theme_path( - self.get_setting("sound", "theme") - ) - if sound_theme_path: - self.set_setting("sound", "theme", sound_theme_path) - environment["runtime"]["SettingsManager"].load_sound_icons( - self.get_setting("sound", "theme"), environment - ) - - environment["runtime"][ - "PunctuationManager" - ] = punctuationManager.PunctuationManager() - environment["runtime"]["PunctuationManager"].initialize(environment) - - environment["runtime"]["TextManager"] = textManager.TextManager() - environment["runtime"]["TextManager"].initialize(environment) - - if not os.path.exists( - self.get_setting("general", "punctuation_profile") - ): - if os.path.exists( - settings_root - + "punctuation/" - + self.get_setting("general", "punctuation_profile") - ): - self.set_setting( - "general", - "punctuation_profile", - settings_root - + "punctuation/" - + self.get_setting("general", "punctuation_profile"), - ) - environment["runtime"]["PunctuationManager"].load_dicts( - self.get_setting("general", "punctuation_profile") - ) - if os.path.exists( - settings_root - + "punctuation/" - + self.get_setting("general", "punctuation_profile") - + ".conf" - ): - self.set_setting( - "general", - "punctuation_profile", - settings_root - + "punctuation/" - + self.get_setting("general", "punctuation_profile") - + ".conf", - ) - environment["runtime"]["PunctuationManager"].load_dicts( - self.get_setting("general", "punctuation_profile") - ) - else: - environment["runtime"]["PunctuationManager"].load_dicts( - self.get_setting("general", "punctuation_profile") - ) - - if fenrir_manager: - environment["runtime"]["FenrirManager"] = fenrir_manager - - environment["runtime"]["MemoryManager"] = memoryManager.MemoryManager() - environment["runtime"]["MemoryManager"].initialize(environment) - - environment["runtime"][ - "AttributeManager" - ] = attributeManager.AttributeManager() - environment["runtime"]["AttributeManager"].initialize(environment) - - environment["runtime"]["EventManager"] = eventManager.EventManager() - environment["runtime"]["EventManager"].initialize(environment) - - environment["runtime"][ - "ProcessManager" - ] = processManager.ProcessManager() - environment["runtime"]["ProcessManager"].initialize(environment) - - environment["runtime"][ - "BackgroundTaskManager" - ] = backgroundTaskManager.BackgroundTaskManager() - environment["runtime"]["BackgroundTaskManager"].initialize(environment) - - environment["runtime"]["OutputManager"] = outputManager.OutputManager() - environment["runtime"]["OutputManager"].initialize(environment) - - environment["runtime"][ - "SpeechHistoryManager" - ] = speechHistoryManager.SpeechHistoryManager() - environment["runtime"]["SpeechHistoryManager"].initialize(environment) - - environment["runtime"]["InputManager"] = inputManager.InputManager() - environment["runtime"]["InputManager"].initialize(environment) - self.load_keyboard_layout(environment) - - environment["runtime"]["ScreenManager"] = screenManager.ScreenManager() - environment["runtime"]["ScreenManager"].initialize(environment) - - environment["runtime"][ - "CommandManager" - ] = commandManager.CommandManager() - environment["runtime"]["CommandManager"].initialize(environment) - - environment["runtime"][ - "ClipboardSyncManager" - ] = clipboardSyncManager.ClipboardSyncManager() - environment["runtime"]["ClipboardSyncManager"].initialize( - environment - ) - - environment["runtime"]["HelpManager"] = helpManager.HelpManager() - environment["runtime"]["HelpManager"].initialize(environment) - - environment["runtime"]["RemoteManager"] = remoteManager.RemoteManager() - environment["runtime"]["RemoteManager"].initialize(environment) - - environment["runtime"][ - "DiffReviewManager" - ] = diffReviewManager.DiffReviewManager() - environment["runtime"]["DiffReviewManager"].initialize(environment) - - environment["runtime"]["CursorManager"] = cursorManager.CursorManager() - environment["runtime"]["CursorManager"].initialize(environment) - environment["runtime"][ - "ApplicationManager" - ] = applicationManager.ApplicationManager() - environment["runtime"]["ApplicationManager"].initialize(environment) - environment["runtime"]["TextManager"] = textManager.TextManager() - environment["runtime"]["TextManager"].initialize(environment) - environment["runtime"]["TableManager"] = tableManager.TableManager() - environment["runtime"]["TableManager"].initialize(environment) - environment["runtime"][ - "BarrierManager" - ] = barrierManager.BarrierManager() - environment["runtime"]["BarrierManager"].initialize(environment) - environment["runtime"]["SayAllManager"] = sayAllManager.SayAllManager() - environment["runtime"]["SayAllManager"].initialize(environment) - environment["runtime"][ - "SpeechDiscoveryManager" - ] = speechDiscoveryManager.SpeechDiscoveryManager() - environment["runtime"]["SpeechDiscoveryManager"].initialize(environment) - environment["runtime"]["VmenuManager"] = vmenuManager.VmenuManager() - environment["runtime"]["VmenuManager"].initialize(environment) - environment["runtime"][ - "QuickMenuManager" - ] = quickMenuManager.QuickMenuManager() - environment["runtime"]["QuickMenuManager"].initialize(environment) - - environment["runtime"][ - "ReadAllManager" - ] = readAllManager.ReadAllManager() - environment["runtime"]["ReadAllManager"].initialize(environment) - - # only possible after having input and screen managers with clean - # buffer - environment["runtime"]["InputManager"].write_event_buffer() - environment["runtime"]["InputManager"].handle_device_grab(force=True) - - environment["runtime"]["DebugManager"].write_debug_out( - r"/-------environment-------/", - debug.DebugLevel.INFO, - on_any_level=True, - ) - environment["runtime"]["DebugManager"].write_debug_out( - str(environment), debug.DebugLevel.INFO, on_any_level=True - ) - environment["runtime"]["DebugManager"].write_debug_out( - r"/-------settings.conf-------/", - debug.DebugLevel.INFO, - on_any_level=True, - ) - environment["runtime"]["DebugManager"].write_debug_out( - str(environment["settings"]._sections), - debug.DebugLevel.INFO, - on_any_level=True, - ) - environment["runtime"]["DebugManager"].write_debug_out( - r"/-------self.settingArgDict-------/", - debug.DebugLevel.INFO, - on_any_level=True, - ) - environment["runtime"]["DebugManager"].write_debug_out( - str(self.settingArgDict), debug.DebugLevel.INFO, on_any_level=True - ) - self.bindingsBackup = environment["bindings"].copy() - - return environment diff --git a/src/fenrirscreenreader/core/soundDriver.py b/src/fenrirscreenreader/core/soundDriver.py deleted file mode 100644 index 07678059..00000000 --- a/src/fenrirscreenreader/core/soundDriver.py +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core import debug - - -class sound_driver: - def __init__(self): - self.volume = None - self._initialized = False - - def initialize(self, environment): - self.env = environment - self._initialized = True - - def shutdown(self): - if not self._initialized: - return - self.cancel() - self._initialized = False - - def play_frequence( - self, frequence, duration, adjust_volume=0.0, interrupt=True - ): - if not self._initialized: - return - if interrupt: - self.cancel() - - def play_sound_file(self, file_path, interrupt=True): - if not self._initialized: - return - if interrupt: - self.cancel() - - def cancel(self): - if not self._initialized: - return - - def set_callback(self, callback): - if not self._initialized: - return - - def set_volume(self, volume): - if not self._initialized: - return - self.volume = volume diff --git a/src/fenrirscreenreader/core/speechDiscoveryManager.py b/src/fenrirscreenreader/core/speechDiscoveryManager.py deleted file mode 100644 index 434bc3ae..00000000 --- a/src/fenrirscreenreader/core/speechDiscoveryManager.py +++ /dev/null @@ -1,241 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import subprocess -import time - -from fenrirscreenreader.core import debug -from fenrirscreenreader.utils.speechd_utils import ( - get_synthesis_voice_name, - parse_synthesis_voice_line, -) - - -MODULE_QUERY_TIMEOUT = 5 -VOICE_QUERY_TIMEOUT = 8 -VOICE_TEST_TIMEOUT = 8 - - -def query_speechd_modules(timeout=MODULE_QUERY_TIMEOUT): - """Return available Speech Dispatcher output modules.""" - result = subprocess.run( - ["spd-say", "-O"], - capture_output=True, - text=True, - timeout=timeout, - check=False, - ) - if result.returncode != 0: - raise RuntimeError(result.stderr.strip() or "module discovery failed") - return [ - line.strip() - for line in result.stdout.splitlines()[1:] - if line.strip() and line.strip().lower() != "dummy" - ] - - -def query_speechd_voices(module, timeout=VOICE_QUERY_TIMEOUT): - """Return the synthesis voices exposed by one output module.""" - result = subprocess.run( - ["spd-say", "-o", module, "-L"], - capture_output=True, - text=True, - timeout=timeout, - check=False, - ) - if result.returncode != 0: - raise RuntimeError( - result.stderr.strip() or f"voice discovery failed for {module}" - ) - - voices = [] - for line in result.stdout.splitlines()[1:]: - if not line.strip(): - continue - voice = get_synthesis_voice_name(module, line) - if module.lower() == "voxin": - voice_data = parse_synthesis_voice_line(line) - if voice_data is not None: - voice_name, language, _variant = voice_data - voice = f"{voice_name}|{language}" if language else None - if voice: - voices.append(voice) - return voices - - -def test_speechd_voice(module, voice, message, timeout=VOICE_TEST_TIMEOUT): - """Play one bounded voice test and return when Speech Dispatcher finishes.""" - result = subprocess.run( - ["spd-say", "-C", "-w", "-o", module, "-y", voice, message], - capture_output=True, - text=True, - timeout=timeout, - check=False, - ) - if result.returncode != 0: - raise RuntimeError(result.stderr.strip() or "voice test failed") - return True - - -class SpeechDiscoveryManager: - """Cache and coordinate asynchronous Speech Dispatcher discovery.""" - - def __init__(self): - self.env = None - self.cache_timeout = 300 - self._modules = None - self._modules_timestamp = 0 - self._voices = {} - self._voice_timestamps = {} - self._module_callbacks = [] - self._voice_callbacks = {} - self._task_ids = set() - self._module_task_id = None - self._voice_task_ids = {} - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - task_manager = self.env["runtime"].get("BackgroundTaskManager") - if task_manager: - for task_id in self._task_ids: - task_manager.cancel_task(task_id) - self._task_ids.clear() - self._module_callbacks = [] - self._voice_callbacks = {} - - def get_cached_modules(self): - if not self._cache_is_current(self._modules_timestamp): - return None - return list(self._modules) if self._modules is not None else None - - def get_cached_voices(self, module): - if not self._cache_is_current(self._voice_timestamps.get(module, 0)): - return None - voices = self._voices.get(module) - return list(voices) if voices is not None else None - - def request_modules(self, callback, refresh=False): - modules = None if refresh else self.get_cached_modules() - if modules is not None: - callback(modules, "") - return None - - self._module_callbacks.append(callback) - if self._module_task_id is not None: - return self._module_task_id - - task_id = self._submit_task(query_speechd_modules, self._finish_modules) - if task_id is None: - self._finish_modules(self._submission_failure()) - return None - self._module_task_id = task_id - return task_id - - def request_voices(self, module, callback, refresh=False): - voices = None if refresh else self.get_cached_voices(module) - if voices is not None: - callback(module, voices, "") - return None - - callbacks = self._voice_callbacks.setdefault(module, []) - callbacks.append(callback) - if module in self._voice_task_ids: - return self._voice_task_ids[module] - - task_id = self._submit_task( - query_speechd_voices, - lambda result: self._finish_voices(module, result), - module, - ) - if task_id is None: - self._finish_voices(module, self._submission_failure()) - return None - self._voice_task_ids[module] = task_id - return task_id - - def request_voice_test(self, module, voice, message, callback): - task_id = self._submit_task( - test_speechd_voice, - lambda result: callback( - bool(result.get("succeeded")), result.get("error", "") - ), - module, - voice, - message, - ) - if task_id is None: - callback(False, "background task manager is unavailable") - return task_id - - def invalidate_cache(self): - self._modules = None - self._modules_timestamp = 0 - self._voices = {} - self._voice_timestamps = {} - - def _cache_is_current(self, timestamp): - return timestamp > 0 and time.time() - timestamp < self.cache_timeout - - def _submit_task(self, function, callback, *args): - task_manager = self.env["runtime"]["BackgroundTaskManager"] - - def finish(result): - self._task_ids.discard(result.get("task_id")) - callback(result) - - task_id = task_manager.submit_task(function, finish, *args) - if task_id is None: - self.env["runtime"]["DebugManager"].write_debug_out( - "SpeechDiscoveryManager could not submit background task", - debug.DebugLevel.ERROR, - ) - return None - self._task_ids.add(task_id) - return task_id - - @staticmethod - def _submission_failure(): - return { - "task_id": None, - "succeeded": False, - "value": None, - "error": "background task manager is unavailable", - } - - def _finish_modules(self, result): - self._module_task_id = None - callbacks = self._module_callbacks - self._module_callbacks = [] - error = result.get("error", "") - modules = [] - if result.get("succeeded"): - modules = list(result.get("value") or []) - self._modules = modules - self._modules_timestamp = time.time() - self._run_callbacks(callbacks, list(modules), error) - - def _finish_voices(self, module, result): - self._voice_task_ids.pop(module, None) - callbacks = self._voice_callbacks.pop(module, []) - error = result.get("error", "") - voices = [] - if result.get("succeeded"): - voices = list(result.get("value") or []) - self._voices[module] = voices - self._voice_timestamps[module] = time.time() - self._run_callbacks(callbacks, module, list(voices), error) - - def _run_callbacks(self, callbacks, *args): - for callback in callbacks: - try: - callback(*args) - except Exception as error: - self.env["runtime"]["DebugManager"].write_debug_out( - "SpeechDiscoveryManager callback failed: " + str(error), - debug.DebugLevel.ERROR, - ) diff --git a/src/fenrirscreenreader/core/speechDriver.py b/src/fenrirscreenreader/core/speechDriver.py deleted file mode 100644 index 0ceba522..00000000 --- a/src/fenrirscreenreader/core/speechDriver.py +++ /dev/null @@ -1,110 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core import debug - - -class speech_driver: - def __init__(self): - pass - - def initialize(self, environment): - self._is_initialized = False - self.language = None - self.voice = None - self.module = None - self.pitch = None - self.rate = None - self.volume = None - self.env = environment - self._is_initialized = True - - def shutdown(self): - if self._is_initialized: - self.cancel() - self._is_initialized = False - - def speak(self, text, queueable=True, ignore_punctuation=False): - if not self._is_initialized: - return - if not queueable: - self.cancel() - - def cancel(self): - if not self._is_initialized: - return - - def set_callback(self, callback): - if not self._is_initialized: - return - if not callback: - return - - def clear_buffer(self): - if not self._is_initialized: - return - - def set_voice(self, voice): - if not self._is_initialized: - return - if voice == "": - return - self.voice = voice - - def set_pitch(self, pitch): - if not self._is_initialized: - return - if not isinstance(pitch, float): - return - if pitch < 0.0: - return - if pitch > 1.0: - return - self.pitch = pitch - - def set_rate(self, rate): - if not self._is_initialized: - return - if not isinstance(rate, float): - return - if rate < 0.0: - return - if rate > 1.0: - return - self.rate = rate - - def set_module(self, module): - if not self._is_initialized: - return - if not isinstance(module, str): - return - if module == "": - return - self.module = module - - def reset(self): - self.shutdown() - self.initialize(self.env) - - def set_language(self, language): - if not self._is_initialized: - return - if not isinstance(language, str): - return - if language == "": - return - self.language = language - - def set_volume(self, volume): - if not self._is_initialized: - return - if not isinstance(volume, float): - return - if volume < 0.0: - return - if volume > 1.0: - return - self.volume = volume diff --git a/src/fenrirscreenreader/core/speechHistoryManager.py b/src/fenrirscreenreader/core/speechHistoryManager.py deleted file mode 100644 index 3121899f..00000000 --- a/src/fenrirscreenreader/core/speechHistoryManager.py +++ /dev/null @@ -1,203 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ - - -class SpeechHistoryManager: - def __init__(self): - self.env = None - self.history = [] - self.curr_index = -1 - self.active = False - self.bindings_backup = None - self.raw_bindings_backup = None - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - self.set_active(False) - - def is_active(self): - return self.active - - def add_text(self, text): - if self.active: - return False - if not isinstance(text, str): - return False - if text == "": - return False - text_key = self._get_history_key(text) - if text_key == "": - return False - if text_key in [self._get_history_key(item) for item in self.history]: - return False - history_size = self._get_history_size() - if history_size <= 0: - return False - self.history.insert(0, text) - del self.history[history_size:] - if self.curr_index >= len(self.history): - self.curr_index = len(self.history) - 1 - return True - - def open_history(self): - if not self.history: - self.env["runtime"]["OutputManager"].present_text( - _("speech history empty"), interrupt=True - ) - return False - self.curr_index = -1 - self.set_active(True) - self.env["runtime"]["OutputManager"].present_text( - _("Speech history"), interrupt=True - ) - return True - - def close_history(self, announce=True): - if announce: - self.env["runtime"]["OutputManager"].present_text( - _("speech history closed"), interrupt=True - ) - self.set_active(False) - - def next_entry(self): - if not self._has_history(): - return - if self.curr_index == -1: - self.curr_index = 0 - self.present_current() - return - if self.curr_index <= 0: - self.curr_index = 0 - self.env["runtime"]["OutputManager"].present_text( - _("First speech history item"), interrupt=True - ) - self.present_current(interrupt=False) - return - self.curr_index -= 1 - self.present_current() - - def prev_entry(self): - if not self._has_history(): - return - if self.curr_index == -1: - self.curr_index = 0 - self.present_current() - return - if self.curr_index >= len(self.history) - 1: - self.curr_index = len(self.history) - 1 - self.env["runtime"]["OutputManager"].present_text( - _("Last speech history item"), interrupt=True - ) - self.present_current(interrupt=False) - return - self.curr_index += 1 - self.present_current() - - def present_current(self, interrupt=True): - if not self._has_history(): - self.env["runtime"]["OutputManager"].present_text( - _("speech history empty"), interrupt=True - ) - return - self.env["runtime"]["OutputManager"].present_text( - self.history[self.curr_index], interrupt=interrupt - ) - - def copy_current_to_clipboard(self): - if not self._has_history(): - self.close_history() - return - text = self.history[self.curr_index] - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", text - ) - self.env["runtime"]["OutputManager"].present_text( - _("copied to clipboard"), - sound_icon="CopyToClipboard", - interrupt=True, - ) - self.set_active(False) - - def set_active(self, active): - if active == self.active: - return - self.active = active - if self.active: - self._install_bindings() - else: - self._restore_bindings() - - def _has_history(self): - if not self.history: - self.curr_index = -1 - return False - if self.curr_index >= len(self.history): - self.curr_index = len(self.history) - 1 - return True - - def _get_history_size(self): - try: - return self.env["runtime"]["SettingsManager"].get_setting_as_int( - "speech", "history_size" - ) - except Exception: - return 50 - - def _get_history_key(self, text): - return " ".join(text.split()) - - def _install_bindings(self): - self.bindings_backup = self.env["bindings"].copy() - self.raw_bindings_backup = self.env["rawBindings"].copy() - self.env["bindings"] = { - str([1, ["KEY_UP"]]): "SPEECH_HISTORY_PREV", - str([1, ["KEY_DOWN"]]): "SPEECH_HISTORY_NEXT", - str([1, ["KEY_SPACE"]]): "SPEECH_HISTORY_CURRENT", - str([1, ["KEY_ENTER"]]): "SPEECH_HISTORY_COPY", - str([1, ["KEY_KPENTER"]]): "SPEECH_HISTORY_COPY", - str([1, ["KEY_ESC"]]): "SPEECH_HISTORY_CLOSE", - } - modal_raw_bindings = { - str([1, ["KEY_UP"]]): [1, ["KEY_UP"]], - str([1, ["KEY_DOWN"]]): [1, ["KEY_DOWN"]], - str([1, ["KEY_SPACE"]]): [1, ["KEY_SPACE"]], - str([1, ["KEY_ENTER"]]): [1, ["KEY_ENTER"]], - str([1, ["KEY_KPENTER"]]): [1, ["KEY_KPENTER"]], - str([1, ["KEY_ESC"]]): [1, ["KEY_ESC"]], - } - self.env["rawBindings"] = self.raw_bindings_backup.copy() - self.env["rawBindings"].update(modal_raw_bindings) - self._refresh_input_bindings() - - def _restore_bindings(self): - if self.bindings_backup is not None: - self.env["bindings"] = self.bindings_backup - if self.raw_bindings_backup is not None: - self.env["rawBindings"] = self.raw_bindings_backup - self.bindings_backup = None - self.raw_bindings_backup = None - self._reset_input_state() - self._refresh_input_bindings() - - def _reset_input_state(self): - try: - self.env["runtime"]["InputManager"].reset_input_state() - except Exception: - pass - - def _refresh_input_bindings(self): - try: - refresh_grabs = getattr( - self.env["runtime"]["InputDriver"], "refresh_grabs", None - ) - if refresh_grabs: - refresh_grabs(force=True) - except Exception: - pass diff --git a/src/fenrirscreenreader/core/tabCompletionManager.py b/src/fenrirscreenreader/core/tabCompletionManager.py deleted file mode 100644 index 27fc503f..00000000 --- a/src/fenrirscreenreader/core/tabCompletionManager.py +++ /dev/null @@ -1,223 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import difflib -import time - - -class TabCompletionManager: - def __init__(self, timeout=0.5): - self.timeout = timeout - - def initialize(self, environment): - self.env = environment - self._ensure_state() - - def _ensure_state(self): - if "tabCompletion" not in self.env["commandBuffer"]: - self.env["commandBuffer"]["tabCompletion"] = {} - state = self.env["commandBuffer"]["tabCompletion"] - state.setdefault("pending", None) - state.setdefault("lastProcessedDelta", "") - state.setdefault("lastProcessedTime", 0) - - def capture_if_tab(self): - if not self._last_event_is_tab_press(): - return - - self._ensure_state() - self.env["commandBuffer"]["tabCompletion"]["pending"] = { - "timestamp": time.time(), - "cursor": self.env["screen"]["new_cursor"].copy(), - "content": self.env["screen"]["new_content_text"], - "screen": self.env["screen"]["newTTY"], - } - - def process_update(self): - self._ensure_state() - state = self.env["commandBuffer"]["tabCompletion"] - if self._was_recently_processed(state): - return "" - - pending = state.get("pending") - if not pending: - pending = self._build_pending_from_recent_tab_update() - if not pending: - return "" - - if time.time() - pending["timestamp"] > self.timeout: - state["pending"] = None - return "" - - if pending.get("screen") != self.env["screen"]["newTTY"]: - state["pending"] = None - return "" - - spoken_text = self._get_completion_text(pending) - if not spoken_text: - return "" - - state["pending"] = None - state["lastProcessedDelta"] = self.env["screen"]["new_delta"] - state["lastProcessedTime"] = time.time() - return spoken_text - - def _was_recently_processed(self, state): - if state.get("lastProcessedDelta") != self.env["screen"]["new_delta"]: - return False - last_time = state.get("lastProcessedTime") - if not last_time: - return False - return time.time() - last_time <= self.timeout - - def _build_pending_from_recent_tab_update(self): - if not self._recent_tab_input(): - return None - return { - "timestamp": time.time(), - "cursor": self.env["screen"]["old_cursor"].copy(), - "content": self.env["screen"]["old_content_text"], - "screen": self.env["screen"]["newTTY"], - } - - def _recent_tab_input(self): - input_manager = self.env["runtime"]["InputManager"] - if input_manager.get_last_deepest_input() in [["KEY_TAB"]]: - return True - - last_event = input_manager.get_last_event() - if not last_event or last_event.get("event_name") != "KEY_TAB": - return False - - try: - return ( - time.time() - input_manager.get_last_input_time() - <= self.timeout - ) - except Exception: - return False - - def _last_event_is_tab_press(self): - input_manager = self.env["runtime"]["InputManager"] - last_event = input_manager.get_last_event() - if not last_event: - return False - return ( - last_event.get("event_name") == "KEY_TAB" - and last_event.get("event_state") == 1 - ) - - def _get_completion_text(self, pending): - old_lines = pending["content"].split("\n") - new_lines = self.env["screen"]["new_content_text"].split("\n") - cursor = pending["cursor"] - cursor_y = cursor["y"] - - if cursor_y < len(old_lines) and cursor_y < len(new_lines): - inserted_text = self._get_cursor_line_inserted_text( - old_lines[cursor_y], - new_lines[cursor_y], - cursor["x"], - self.env["screen"]["new_cursor"]["x"], - ) - if inserted_text: - return self._clean_text(inserted_text) - - candidate_text = self._get_candidate_text( - old_lines, new_lines, cursor_y - ) - if candidate_text: - return self._clean_text(candidate_text) - - return "" - - def _get_cursor_line_inserted_text( - self, old_line, new_line, old_cursor_x, new_cursor_x - ): - if new_cursor_x <= old_cursor_x: - return "" - if old_line == new_line: - return "" - - matcher = difflib.SequenceMatcher( - None, old_line, new_line, autojunk=False - ) - inserted_parts = [] - for ( - tag, - old_start, - old_end, - new_start, - new_end, - ) in matcher.get_opcodes(): - if tag == "equal": - continue - if tag == "insert" and old_cursor_x <= new_start <= new_cursor_x: - inserted_parts.append(new_line[new_start:new_end]) - continue - if ( - tag == "replace" - and old_line[old_start:old_end].strip() == "" - and old_cursor_x <= new_start <= new_cursor_x - ): - inserted_parts.append(new_line[new_start:new_end]) - continue - if ( - tag == "delete" - and old_line[old_start:old_end].strip() == "" - and old_start >= old_cursor_x - ): - continue - if old_start >= old_cursor_x: - return "" - - return "".join(inserted_parts) - - def _get_candidate_text(self, old_lines, new_lines, cursor_y): - old_cursor_line = ( - old_lines[cursor_y].strip() if cursor_y < len(old_lines) else "" - ) - return self._get_inserted_lines( - old_lines, - new_lines, - self.env["screen"]["new_cursor"]["y"], - old_cursor_line, - ) - - def _get_inserted_lines( - self, old_lines, new_lines, new_cursor_y, old_cursor_line - ): - matcher = difflib.SequenceMatcher( - None, old_lines, new_lines, autojunk=False - ) - inserted_lines = [] - for ( - tag, - old_start, - old_end, - new_start, - new_end, - ) in matcher.get_opcodes(): - if tag not in ["insert", "replace"]: - continue - if new_start > new_cursor_y: - continue - if tag == "replace" and any( - line.strip() for line in old_lines[old_start:old_end] - ): - continue - for line in new_lines[new_start:new_end]: - stripped_line = line.strip() - if stripped_line and stripped_line != old_cursor_line: - inserted_lines.append(line.rstrip()) - return "\n".join(inserted_lines) - - def _clean_text(self, text): - if not text: - return "" - if len(text.strip()) != len(text) and text.strip() != "": - return text.strip() - return text diff --git a/src/fenrirscreenreader/core/tableManager.py b/src/fenrirscreenreader/core/tableManager.py deleted file mode 100644 index 8b111dcd..00000000 --- a/src/fenrirscreenreader/core/tableManager.py +++ /dev/null @@ -1,568 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import re - -from fenrirscreenreader.core import debug - - -class TableManager: - def __init__(self): - self.headLine = "" - self.defaultSeparators = ["+", ";", "|", " "] - self.noOfHeadLineColumns = 0 - self.headColumnSep = "" - self.rowColumnSep = "" - self.headerRow = [] - self.tableMode = False - self.currentColumn = 0 - self.currentRow = 0 - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def is_table_mode(self): - return self.tableMode - - def set_table_mode(self, active): - self.tableMode = active - if not active: - self.clear_header_row() - else: - # Initialize current position when entering table mode - self.sync_table_position_from_cursor() - - def set_header_row_from_cursor(self): - """Set header row from current cursor position""" - if not self.env["runtime"]["CursorManager"].is_review_mode(): - return False - - cursor_pos = self.env["runtime"]["CursorManager"].get_review_or_text_cursor() - if not cursor_pos: - return False - - # Get the line text at cursor position - line_text = self.env["runtime"]["ScreenManager"].get_line_text(cursor_pos["y"]) - if not line_text: - return False - - # Parse the line into columns - self.headerRow = self.parse_line_into_columns(line_text) - return len(self.headerRow) > 0 - - def clear_header_row(self): - """Clear the stored header row""" - self.headerRow = [] - - def has_header_row(self): - """Check if header row is set""" - return len(self.headerRow) > 0 - - def get_column_header(self, column_index): - """Get header for a specific column (0-based)""" - if column_index < 0: - return f"Column {column_index + 1}" - - if len(self.headerRow) == 0: - return f"Column {column_index + 1}" - - if column_index >= len(self.headerRow): - return f"Column {column_index + 1}" - - header = self.headerRow[column_index].strip() - if not header: # If header is empty - return f"Column {column_index + 1}" - - return header - - def get_column_count(self): - """Get number of columns based on header row""" - return len(self.headerRow) - - def parse_line_into_columns(self, line_text): - """Parse a line into columns using various separators""" - if not line_text: - return [] - - # Try different separators in order of preference - separators = [',', '|', ';', '\t'] - - for sep in separators: - if sep in line_text: - columns = line_text.split(sep) - if len(columns) > 1: - return columns - - # If no clear separator, try to detect aligned columns - return self.detect_aligned_columns(line_text) - - def detect_aligned_columns(self, line_text): - """Detect columns in space-aligned text""" - if not line_text: - return [] - - # Split on multiple spaces (2 or more) - parts = re.split(r' +', line_text) - if len(parts) > 1: - return parts - - # Fallback: treat as single column - return [line_text] - - def get_cell_content(self, line_text, column_index): - """Get content of a specific cell in a line""" - columns = self.parse_line_into_columns(line_text) - if column_index < 0 or column_index >= len(columns): - return "" - return columns[column_index].strip() - - def get_table_cell_info(self, cursor_pos): - """Get table cell information for speech output""" - line_text = self.env["runtime"]["ScreenManager"].get_line_text(cursor_pos["y"]) - if not line_text: - return None - - columns = self.parse_line_into_columns(line_text) - if not columns: - return None - - # Find which column we're in based on cursor x position - column_index = self.get_column_index_from_x_position(line_text, cursor_pos["x"]) - - if column_index < 0 or column_index >= len(columns): - return None - - # Use header if available, otherwise generic column name - if self.has_header_row(): - column_header = self.get_column_header(column_index) - else: - column_header = f"Column {column_index + 1}" - - return { - 'column_index': column_index, - 'column_header': column_header, - 'cell_content': columns[column_index].strip(), - 'total_columns': len(columns), - 'row_number': cursor_pos["y"] + 1 - } - - def get_column_index_from_x_position(self, line_text, x_pos): - """Determine which column an x position falls into""" - columns = self.parse_line_into_columns(line_text) - if not columns: - return 0 - - # Handle CSV/delimited text - if ',' in line_text or '|' in line_text or ';' in line_text or '\t' in line_text: - current_pos = 0 - for i, column in enumerate(columns): - column_end = current_pos + len(column) - if current_pos <= x_pos <= column_end: - return i - current_pos = column_end + 1 # +1 for separator - else: - # Handle space-aligned text - find the column by position ranges - current_pos = 0 - for i, column in enumerate(columns): - # Find where this column starts in the original text - if i == 0: - column_start = 0 - else: - # Find the column by searching from current position - column_start = line_text.find(column.strip(), current_pos) - if column_start == -1: - column_start = current_pos - - column_end = column_start + len(column.strip()) - if column_start <= x_pos <= column_end: - return i - current_pos = column_end - - # If past the end, return last column - return len(columns) - 1 - - def sync_table_position_from_cursor(self): - """Sync internal table position from current cursor position""" - if not self.env["runtime"]["CursorManager"].is_review_mode(): - return - - cursor_pos = self.env["runtime"]["CursorManager"].get_review_or_text_cursor() - if cursor_pos: - old_column = self.currentColumn - self.currentRow = cursor_pos["y"] - line_text = self.env["runtime"]["ScreenManager"].get_line_text(cursor_pos["y"]) - if line_text: - self.currentColumn = self.get_column_index_from_x_position(line_text, cursor_pos["x"]) - else: - self.currentColumn = 0 - - self.env["runtime"]["DebugManager"].write_debug_out( - f"TableManager sync: old_column={old_column}, new_column={self.currentColumn}, cursor_x={cursor_pos['x']}, cursor_y={cursor_pos['y']}", - debug.DebugLevel.INFO - ) - - def move_to_next_column(self): - """Move to next column in table""" - self.env["runtime"]["DebugManager"].write_debug_out( - f"TableManager move_to_next_column: currentColumn={self.currentColumn}", - debug.DebugLevel.INFO - ) - - if not self.env["runtime"]["CursorManager"].is_review_mode(): - return None - - cursor_pos = self.env["runtime"]["CursorManager"].get_review_or_text_cursor() - if not cursor_pos: - return None - - line_text = self.env["runtime"]["ScreenManager"].get_line_text(cursor_pos["y"]) - if not line_text: - return None - - columns = self.parse_line_into_columns(line_text) - if not columns: - return None - - self.env["runtime"]["DebugManager"].write_debug_out( - f"TableManager: line_text='{line_text}', columns={columns}, currentColumn={self.currentColumn}", - debug.DebugLevel.INFO - ) - - # Don't sync from cursor position - maintain our own tracking - # Check if we're already at the last column - if self.currentColumn >= len(columns) - 1: - # At end of line - return special indicator but keep position - return {"at_end": True, "current_info": self.get_table_cell_info_by_indices(cursor_pos["y"], self.currentColumn)} - - # Move to next column - self.currentColumn += 1 - - self.env["runtime"]["DebugManager"].write_debug_out( - f"TableManager: moved to column {self.currentColumn}", - debug.DebugLevel.INFO - ) - - # Return info for the new column without moving cursor position - return self.get_table_cell_info_by_indices(cursor_pos["y"], self.currentColumn) - - def move_to_prev_column(self): - """Move to previous column in table""" - if not self.env["runtime"]["CursorManager"].is_review_mode(): - return None - - cursor_pos = self.env["runtime"]["CursorManager"].get_review_or_text_cursor() - if not cursor_pos: - return None - - line_text = self.env["runtime"]["ScreenManager"].get_line_text(cursor_pos["y"]) - if not line_text: - return None - - columns = self.parse_line_into_columns(line_text) - if not columns: - return None - - # Don't sync from cursor position - maintain our own tracking - # Check if we're already at the first column - if self.currentColumn <= 0: - # At beginning of line - return special indicator but keep position - return {"at_start": True, "current_info": self.get_table_cell_info_by_indices(cursor_pos["y"], self.currentColumn)} - - # Move to previous column - self.currentColumn -= 1 - - # Return info for the new column without moving cursor position - return self.get_table_cell_info_by_indices(cursor_pos["y"], self.currentColumn) - - def get_current_table_cell_info(self): - """Get current table cell info using internal position tracking""" - if not self.env["runtime"]["CursorManager"].is_review_mode(): - return None - - cursor_pos = self.env["runtime"]["CursorManager"].get_review_or_text_cursor() - if not cursor_pos: - return None - - return self.get_table_cell_info_by_indices(cursor_pos["y"], self.currentColumn) - - def get_table_cell_info_by_indices(self, row, column_index): - """Get table cell info for specific row and column indices""" - line_text = self.env["runtime"]["ScreenManager"].get_line_text(row) - if not line_text: - return None - - columns = self.parse_line_into_columns(line_text) - if not columns or column_index < 0 or column_index >= len(columns): - return None - - # Always get column header (fallback to generic if needed) - column_header = self.get_column_header(column_index) - - cell_content = columns[column_index].strip() - if not cell_content: # Handle empty cells - cell_content = "blank" - - return { - 'column_index': column_index, - 'column_header': column_header, - 'cell_content': cell_content, - 'total_columns': len(columns), - 'row_number': row + 1 - } - - def get_column_start_position(self, line_text, column_index): - """Get the starting x position of a specific column""" - columns = self.parse_line_into_columns(line_text) - if not columns or column_index < 0 or column_index >= len(columns): - return 0 - - # For CSV/delimited text - find the actual position in the original line - if ',' in line_text or '|' in line_text or ';' in line_text or '\t' in line_text: - # Determine the separator being used - separator = ',' - if '|' in line_text and line_text.count('|') > line_text.count(','): - separator = '|' - elif ';' in line_text and line_text.count(';') > line_text.count(','): - separator = ';' - elif '\t' in line_text: - separator = '\t' - - # Find the position by splitting and calculating - if column_index == 0: - return 0 - - # Count characters up to the target column - parts = line_text.split(separator) - position = 0 - for i in range(column_index): - if i < len(parts): - position += len(parts[i]) + 1 # +1 for separator - return position - else: - # For space-aligned text, find the actual position of the column - if column_index == 0: - return 0 - - # Find the column text in the line - target_text = columns[column_index].strip() - search_start = 0 - for i in range(column_index): - prev_text = columns[i].strip() - found_pos = line_text.find(prev_text, search_start) - if found_pos != -1: - search_start = found_pos + len(prev_text) - - column_pos = line_text.find(target_text, search_start) - return column_pos if column_pos != -1 else search_start - - def is_cursor_within_current_cell(self, cursor_x, cursor_y): - """Check if the given cursor position is within the current table cell""" - if not self.is_table_mode(): - return False - - line_text = self.env["runtime"]["ScreenManager"].get_line_text(cursor_y) - if not line_text: - return False - - columns = self.parse_line_into_columns(line_text) - if not columns or self.currentColumn < 0 or self.currentColumn >= len(columns): - return False - - # Get the bounds of the current column - column_start = self.get_column_start_position(line_text, self.currentColumn) - column_text = columns[self.currentColumn] - column_end = column_start + len(column_text) - - # Check if cursor is within the column bounds - return column_start <= cursor_x < column_end - - def move_to_first_cell(self): - """Move to first cell in current row""" - if not self.env["runtime"]["CursorManager"].is_review_mode(): - return None - - cursor_pos = self.env["runtime"]["CursorManager"].get_review_or_text_cursor() - if not cursor_pos: - return None - - line_text = self.env["runtime"]["ScreenManager"].get_line_text(cursor_pos["y"]) - if not line_text: - return None - - columns = self.parse_line_into_columns(line_text) - if not columns: - return None - - # Set current column to first column - self.currentColumn = 0 - - # Return info for the first column - return self.get_table_cell_info_by_indices(cursor_pos["y"], 0) - - def move_to_last_cell(self): - """Move to last cell in current row""" - if not self.env["runtime"]["CursorManager"].is_review_mode(): - return None - - cursor_pos = self.env["runtime"]["CursorManager"].get_review_or_text_cursor() - if not cursor_pos: - return None - - line_text = self.env["runtime"]["ScreenManager"].get_line_text(cursor_pos["y"]) - if not line_text: - return None - - columns = self.parse_line_into_columns(line_text) - if not columns: - return None - - # Set current column to last column - self.currentColumn = len(columns) - 1 - - # Return info for the last column - return self.get_table_cell_info_by_indices(cursor_pos["y"], self.currentColumn) - - def move_to_first_char_in_cell(self): - """Move to first character in current cell""" - if not self.env["runtime"]["CursorManager"].is_review_mode(): - return None - - cursor_pos = self.env["runtime"]["CursorManager"].get_review_or_text_cursor() - if not cursor_pos: - return None - - line_text = self.env["runtime"]["ScreenManager"].get_line_text(cursor_pos["y"]) - if not line_text: - return None - - columns = self.parse_line_into_columns(line_text) - if not columns or self.currentColumn < 0 or self.currentColumn >= len(columns): - return None - - # Get column start position - column_start = self.get_column_start_position(line_text, self.currentColumn) - - # Find first non-space character in the column - column_text = columns[self.currentColumn] - first_char_offset = len(column_text) - len(column_text.lstrip()) - - # Set cursor position to first character in cell - new_x = column_start + first_char_offset - self.env["runtime"]["CursorManager"].set_review_cursor_position(new_x, cursor_pos["y"]) - - # Get the character at the new position - from fenrirscreenreader.utils import char_utils - ( - self.env["screen"]["newCursorReview"]["x"], - self.env["screen"]["newCursorReview"]["y"], - curr_char, - ) = char_utils.get_current_char( - new_x, - cursor_pos["y"], - self.env["screen"]["new_content_text"], - ) - - return { - 'cell_content': column_text.strip(), - 'column_header': self.get_column_header(self.currentColumn), - 'character': curr_char, - 'position': 'first' - } - - def move_to_last_char_in_cell(self): - """Move to last character in current cell""" - if not self.env["runtime"]["CursorManager"].is_review_mode(): - return None - - cursor_pos = self.env["runtime"]["CursorManager"].get_review_or_text_cursor() - if not cursor_pos: - return None - - line_text = self.env["runtime"]["ScreenManager"].get_line_text(cursor_pos["y"]) - if not line_text: - return None - - columns = self.parse_line_into_columns(line_text) - if not columns or self.currentColumn < 0 or self.currentColumn >= len(columns): - return None - - # Get column start position - column_start = self.get_column_start_position(line_text, self.currentColumn) - column_text = columns[self.currentColumn] - - # Find last non-space character in the column - trimmed_text = column_text.rstrip() - if not trimmed_text: - # If empty cell, go to column start - new_x = column_start - else: - # Find the position of the last character - new_x = column_start + len(trimmed_text) - 1 - - # Set cursor position to last character in cell - self.env["runtime"]["CursorManager"].set_review_cursor_position(new_x, cursor_pos["y"]) - - # Get the character at the new position - from fenrirscreenreader.utils import char_utils - ( - self.env["screen"]["newCursorReview"]["x"], - self.env["screen"]["newCursorReview"]["y"], - curr_char, - ) = char_utils.get_current_char( - new_x, - cursor_pos["y"], - self.env["screen"]["new_content_text"], - ) - - return { - 'cell_content': column_text.strip(), - 'column_header': self.get_column_header(self.currentColumn), - 'character': curr_char, - 'position': 'last' - } - - def reset_table_mode(self): - self.set_head_line() - - def set_head_column_sep(self, columnSep=""): - self.headColumnSep = columnSep - if columnSep == "": - self.noOfHeadLineColumns = 0 - else: - self.coun_no_of_head_columns() - - def coun_no_of_head_columns(self): - if self.headLine and self.headColumnSep: - self.noOfHeadLineColumns = len(self.headLine.split(self.headColumnSep)) - - def search_for_head_column_sep(self, headLine): - """Find the most likely column separator in a header line""" - separators = [',', '|', ';', '\t'] - - for sep in separators: - if sep in headLine: - return sep - - # Check for multiple spaces (aligned columns) - if ' ' in headLine: - return ' ' - - return "" - - def set_row_column_sep(self, columnSep=""): - self.rowColumnSep = columnSep - - def set_head_line(self, headLine=""): - self.set_head_column_sep() - self.set_row_column_sep() - if headLine != "": - sep = self.search_for_head_column_sep(headLine) - if sep != "": - self.headLine = headLine - self.set_head_column_sep(sep) diff --git a/src/fenrirscreenreader/core/textManager.py b/src/fenrirscreenreader/core/textManager.py deleted file mode 100644 index a80f781c..00000000 --- a/src/fenrirscreenreader/core/textManager.py +++ /dev/null @@ -1,119 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import re -import string - -from fenrirscreenreader.core import debug - - -class TextManager: - def __init__(self): - # https://regex101.com/ - self.regExSingle = re.compile(r"(([^\w\s])\2{5,})") - self.regExDouble = re.compile(r"([^\w\s]{2,}){5,}") - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def replace_head_lines(self, text): - # fast len check for bigger typing echo - if len(text) < 5: - return text - # more strong check, to not match if not needed: - if ( - len( - text.strip( - string.ascii_letters + string.digits + string.whitespace - ) - ) - < 5 - ): - return text - result = "" - new_text = "" - last_pos = 0 - for match in self.regExDouble.finditer(text): - span = match.span() - new_text += text[last_pos : span[0]] - number_of_chars = len(text[span[0] : span[1]]) - name = text[span[0] : span[1]][:2] - if not self.env["runtime"]["PunctuationManager"].is_puctuation( - name[0] - ): - last_pos = span[1] - continue - if name[0] == name[1]: - new_text += ( - " " - + str(number_of_chars) - + " " - + self.env["runtime"][ - "PunctuationManager" - ].proceed_punctuation(name[0], True) - + " " - ) - else: - new_text += ( - " " - + self.env["runtime"][ - "PunctuationManager" - ].proceed_punctuation(name[0], True) - + " " - + str(int(number_of_chars / 2)) - + " " - + self.env["runtime"][ - "PunctuationManager" - ].proceed_punctuation(name[1], True) - + " " - ) - last_pos = span[1] - if last_pos != 0: - new_text += " " - new_text += text[last_pos:] - last_pos = 0 - for match in self.regExSingle.finditer(new_text): - span = match.span() - result += new_text[last_pos : span[0]] - number_of_chars = len(new_text[span[0] : span[1]]) - name = new_text[span[0] : span[1]][:2] - if not self.env["runtime"]["PunctuationManager"].is_puctuation( - name[0] - ): - last_pos = span[1] - continue - if name[0] == name[1]: - result += ( - " " - + str(number_of_chars) - + " " - + self.env["runtime"][ - "PunctuationManager" - ].proceed_punctuation(name[0], True) - + " " - ) - else: - result += ( - " " - + self.env["runtime"][ - "PunctuationManager" - ].proceed_punctuation(name[0], True) - + " " - + str(int(number_of_chars / 2)) - + " " - + self.env["runtime"][ - "PunctuationManager" - ].proceed_punctuation(name[1], True) - + " " - ) - last_pos = span[1] - if last_pos != 0: - result += " " - result += new_text[last_pos:] - return result diff --git a/src/fenrirscreenreader/core/vmenuManager.py b/src/fenrirscreenreader/core/vmenuManager.py deleted file mode 100755 index 5d542860..00000000 --- a/src/fenrirscreenreader/core/vmenuManager.py +++ /dev/null @@ -1,414 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import inspect -import os -import time - -from fenrirscreenreader.core import debug -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import module_utils - -currentdir = os.path.dirname( - os.path.realpath(os.path.abspath(inspect.getfile(inspect.currentframe()))) -) -fenrir_path = os.path.dirname(currentdir) - - -class VmenuManager: - def __init__(self): - self.menuDict = {} - self.curr_index = None - self.currMenu = "" - self.active = False - self.reset = True - self.useTimeout = True - self.searchText = "" - self.lastSearchTime = time.time() - - def initialize(self, environment): - self.env = environment - # use default path - self.defaultVMenuPath = ( - fenrir_path + "/commands/vmenu-profiles/KEY" - ) - # if there is no user configuration - if ( - self.env["runtime"]["SettingsManager"].get_setting( - "menu", "vmenu_path" - ) - != "" - ): - self.defaultVMenuPath = self.env["runtime"][ - "SettingsManager" - ].get_setting("menu", "vmenu_path") - if not self.defaultVMenuPath.endswith("/"): - self.defaultVMenuPath += "/" - self.defaultVMenuPath += "KEY" - - self.create_menu_tree() - self.closeAfterAction = False - - def shutdown(self): - pass - - def clear_search_text(self): - self.searchText = "" - - def search_entry(self, value, forceReset=False): - if self.curr_index is None: - return "" - if self.reset or forceReset: - self.clear_search_text() - else: - if self.useTimeout: - if time.time() - self.lastSearchTime > 1: - self.clear_search_text() - self.searchText += value.upper() - self.lastSearchTime = time.time() - start_index = self.get_curr_index() - while True: - if not self.next_index(): - return "" - entry = self.get_current_entry() - if entry.upper().startswith(self.searchText): - return entry - if start_index == self.get_curr_index(): - return "" - - def set_curr_menu(self, currMenu=""): - self.curr_index = None - self.currMenu = "" - if currMenu != "": - currMenu += " Menu" - try: - t = self.menuDict[currMenu] - l = list(self.menuDict.keys()) - self.curr_index = [l.index(currMenu)] - except Exception as e: - print(e) - self.currMenu = "" - self.curr_index = None - return - if self.inc_level(): - self.currMenu = currMenu - else: - self.currMenu = "" - self.curr_index = None - - def get_curr_menu(self): - return self.currMenu - - def get_active(self): - return self.active - - def toggle_vmenu_mode(self, closeAfterAction=True): - self.set_active(not self.get_active(), closeAfterAction) - - def set_active(self, active, closeAfterAction=True): - if self.env["runtime"]["HelpManager"].is_tutorial_mode(): - return - self.active = active - if self.active: - self.closeAfterAction = closeAfterAction - try: - self.create_menu_tree() - except Exception as e: - print(e) - try: - if self.currMenu != "": - self.set_curr_menu(self.currMenu) - if self.curr_index is None: - if len(self.menuDict) > 0: - self.curr_index = [0] - except Exception as e: - print(e) - try: - # navigation - self.env["bindings"][ - str([1, ["KEY_ESC"]]) - ] = "TOGGLE_VMENU_MODE" - self.env["bindings"][str([1, ["KEY_UP"]])] = "PREV_VMENU_ENTRY" - self.env["bindings"][ - str([1, ["KEY_DOWN"]]) - ] = "NEXT_VMENU_ENTRY" - self.env["bindings"][ - str([1, ["KEY_SPACE"]]) - ] = "CURR_VMENU_ENTRY" - self.env["bindings"][ - str([1, ["KEY_LEFT"]]) - ] = "DEC_LEVEL_VMENU" - self.env["bindings"][ - str([1, ["KEY_RIGHT"]]) - ] = "INC_LEVEL_VMENU" - self.env["bindings"][ - str([1, ["KEY_ENTER"]]) - ] = "EXEC_VMENU_ENTRY" - # search - self.env["bindings"][str([1, ["KEY_A"]])] = "SEARCH_A" - self.env["bindings"][str([1, ["KEY_B"]])] = "SEARCH_B" - self.env["bindings"][str([1, ["KEY_C"]])] = "SEARCH_C" - self.env["bindings"][str([1, ["KEY_D"]])] = "SEARCH_D" - self.env["bindings"][str([1, ["KEY_E"]])] = "SEARCH_E" - self.env["bindings"][str([1, ["KEY_F"]])] = "SEARCH_F" - self.env["bindings"][str([1, ["KEY_G"]])] = "SEARCH_G" - self.env["bindings"][str([1, ["KEY_H"]])] = "SEARCH_H" - self.env["bindings"][str([1, ["KEY_I"]])] = "SEARCH_I" - self.env["bindings"][str([1, ["KEY_J"]])] = "SEARCH_J" - self.env["bindings"][str([1, ["KEY_K"]])] = "SEARCH_K" - self.env["bindings"][str([1, ["KEY_L"]])] = "SEARCH_L" - self.env["bindings"][str([1, ["KEY_M"]])] = "SEARCH_M" - self.env["bindings"][str([1, ["KEY_N"]])] = "SEARCH_N" - self.env["bindings"][str([1, ["KEY_O"]])] = "SEARCH_O" - self.env["bindings"][str([1, ["KEY_P"]])] = "SEARCH_P" - self.env["bindings"][str([1, ["KEY_Q"]])] = "SEARCH_Q" - self.env["bindings"][str([1, ["KEY_R"]])] = "SEARCH_R" - self.env["bindings"][str([1, ["KEY_S"]])] = "SEARCH_S" - self.env["bindings"][str([1, ["KEY_T"]])] = "SEARCH_T" - self.env["bindings"][str([1, ["KEY_U"]])] = "SEARCH_U" - self.env["bindings"][str([1, ["KEY_V"]])] = "SEARCH_V" - self.env["bindings"][str([1, ["KEY_W"]])] = "SEARCH_W" - self.env["bindings"][str([1, ["KEY_X"]])] = "SEARCH_X" - self.env["bindings"][str([1, ["KEY_Y"]])] = "SEARCH_Y" - self.env["bindings"][str([1, ["KEY_Z"]])] = "SEARCH_Z" - # page navigation - self.env["bindings"][ - str([1, ["KEY_PAGEUP"]]) - ] = "PAGE_UP_VMENU" - self.env["bindings"][ - str([1, ["KEY_PAGEDOWN"]]) - ] = "PAGE_DOWN_VMENU" - except Exception as e: - print(e) - else: - try: - self.curr_index = None - self.env["bindings"] = self.env["runtime"][ - "SettingsManager" - ].get_binding_backup() - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "VmenuManager set_active: Error loading binding backup: " - + str(e), - debug.DebugLevel.ERROR, - ) - - def create_menu_tree(self, resetIndex=True): - if resetIndex: - self.curr_index = None - menu = self.fs_tree_to_dict(self.defaultVMenuPath) - if menu: - self.menuDict = menu - - # Add dynamic voice menus - try: - from fenrirscreenreader.core.dynamicVoiceMenu import ( - add_dynamic_voice_menus, - ) - - add_dynamic_voice_menus(self) - except Exception as e: - print(f"Error adding dynamic voice menus: {e}") - - # Add dynamic keyboard layout menus - try: - from fenrirscreenreader.core.dynamicKeyboardLayoutMenu import ( - add_dynamic_keyboard_layout_menus, - ) - - add_dynamic_keyboard_layout_menus(self) - except Exception as e: - print(f"Error adding dynamic keyboard layout menus: {e}") - - # index still valid? - if self.curr_index is not None: - try: - r = self.get_value_by_path(self.menuDict, self.curr_index) - if r == {}: - self.curr_index = None - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "VmenuManager create_menu_tree: Error checking menu index validity: " - + str(e), - debug.DebugLevel.ERROR, - ) - self.curr_index = None - - def execute_menu(self): - if self.curr_index is None: - return - try: - command = self.get_value_by_path(self.menuDict, self.curr_index) - if command is not None: - command.run() - if self.closeAfterAction: - self.set_active(False) - except Exception as e: - try: - self.inc_level() - text = self.get_current_entry() - self.env["runtime"]["OutputManager"].present_text( - text, interrupt=True - ) - except Exception as ex: - self.env["runtime"]["DebugManager"].write_debug_out( - "VmenuManager execute_menu: Error presenting menu text: " - + str(ex), - debug.DebugLevel.ERROR, - ) - - def inc_level(self): - if self.curr_index is None: - return False - try: - r = self.get_value_by_path(self.menuDict, self.curr_index + [0]) - if r == {}: - return False - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "VmenuManager inc_level: Error accessing menu path: " + str(e), - debug.DebugLevel.ERROR, - ) - return False - self.curr_index.append(0) - return True - - def dec_level(self): - if self.curr_index is None: - return False - if self.currMenu != "": - if len(self.curr_index) <= 2: - return False - elif len(self.curr_index) == 1: - return False - self.curr_index = self.curr_index[: len(self.curr_index) - 1] - return True - - def next_index(self): - if self.curr_index is None: - return False - if self.curr_index[len(self.curr_index) - 1] + 1 >= len( - self.get_nested_by_path(self.menuDict, self.curr_index[:-1]) - ): - self.curr_index[len(self.curr_index) - 1] = 0 - else: - self.curr_index[len(self.curr_index) - 1] += 1 - return True - - def get_curr_index(self): - if self.curr_index is None: - return 0 - return self.curr_index[len(self.curr_index) - 1] - - def prev_index(self): - if self.curr_index is None: - return False - if self.curr_index[len(self.curr_index) - 1] == 0: - self.curr_index[len(self.curr_index) - 1] = ( - len( - self.get_nested_by_path( - self.menuDict, self.curr_index[:-1] - ) - ) - - 1 - ) - else: - self.curr_index[len(self.curr_index) - 1] -= 1 - return True - - def page_up(self): - if self.curr_index is None: - return False - menu_size = len( - self.get_nested_by_path(self.menuDict, self.curr_index[:-1]) - ) - if menu_size <= 1: - return False - jump_size = max(1, int(menu_size * 0.1)) # 10% of menu size, minimum 1 - new_index = self.curr_index[len(self.curr_index) - 1] - jump_size - if new_index < 0: - new_index = 0 - self.curr_index[len(self.curr_index) - 1] = new_index - return True - - def page_down(self): - if self.curr_index is None: - return False - menu_size = len( - self.get_nested_by_path(self.menuDict, self.curr_index[:-1]) - ) - if menu_size <= 1: - return False - jump_size = max(1, int(menu_size * 0.1)) # 10% of menu size, minimum 1 - new_index = self.curr_index[len(self.curr_index) - 1] + jump_size - if new_index >= menu_size: - new_index = menu_size - 1 - self.curr_index[len(self.curr_index) - 1] = new_index - return True - - def get_current_entry(self): - entry = self.get_keys_by_path(self.menuDict, self.curr_index)[ - self.curr_index[-1] - ] - # Keep internal keys stable; only translate their spoken labels. - # The translation generator extracts labels from built-in profiles. - translated = _(entry) - if translated != entry: - return translated - if entry.endswith(" Menu"): - return _("{0} Menu").format(entry[:-5]) - if entry.endswith(" Action"): - return _("{0} Action").format(entry[:-7]) - return entry - - def fs_tree_to_dict(self, path_): - for root, dirs, files in os.walk(path_): - tree = { - d + " Menu": self.fs_tree_to_dict(os.path.join(root, d)) - for d in dirs - if not d.startswith("__") - } - for f in files: - try: - file_name, file_extension = os.path.splitext(f) - file_name = file_name.split("/")[-1] - if file_name.startswith("__"): - continue - # Skip base classes that shouldn't be loaded as commands - if file_name.endswith("_base"): - continue - command = self.env["runtime"]["CommandManager"].load_file( - root + "/" + f - ) - tree.update({file_name + " Action": command}) - except Exception as e: - print(e) - return tree # note we discontinue iteration trough os.walk - - def get_nested_by_path(self, complete, path): - path = path.copy() - if path != []: - index = list(complete.keys())[path[0]] - nested = self.get_nested_by_path(complete[index], path[1:]) - return nested - else: - return complete - - def get_keys_by_path(self, complete, path): - if not isinstance(complete, dict): - return [] - d = complete - for i in path[:-1]: - d = d[list(d.keys())[i]] - return list(d.keys()) - - def get_value_by_path(self, complete, path): - if not isinstance(complete, dict): - return complete - d = complete.copy() - for i in path: - d = d[list(d.keys())[i]] - return d diff --git a/src/fenrirscreenreader/fenrirVersion.py b/src/fenrirscreenreader/fenrirVersion.py deleted file mode 100644 index 81efe395..00000000 --- a/src/fenrirscreenreader/fenrirVersion.py +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -version = "2026.10.10" -code_name = "testing" diff --git a/src/fenrirscreenreader/inputDriver/__init__.py b/src/fenrirscreenreader/inputDriver/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/fenrirscreenreader/inputDriver/debugDriver.py b/src/fenrirscreenreader/inputDriver/debugDriver.py deleted file mode 100644 index c7a047cb..00000000 --- a/src/fenrirscreenreader/inputDriver/debugDriver.py +++ /dev/null @@ -1,79 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import time - -from fenrirscreenreader.core import debug -from fenrirscreenreader.core.inputDriver import InputDriver as inputDriver - - -class driver(inputDriver): - def __init__(self): - inputDriver.__init__(self) - - def initialize(self, environment): - self.env = environment - self._initialized = True - print("Input Debug Driver: Initialized") - - def shutdown(self): - if self._initialized: - self.remove_all_devices() - self._initialized = False - print("Input Debug Driver: Shutdown") - - def get_input_event(self): - time.sleep(0.1) - if not self._initialized: - return None - print("Input Debug Driver: get_input_event") - return None - - def write_event_buffer(self): - if not self._initialized: - return - print("Input Debug Driver: write_event_buffer") - - def clear_event_buffer(self): - if not self._initialized: - return - del self.env["input"]["event_buffer"][:] - print("Input Debug Driver: clear_event_buffer") - - def update_input_devices(self, new_devices=None, init=False): - if not self._initialized: - return - print("Input Debug Driver: update_input_devices") - - def get_led_state(self, led=0): - if not self._initialized: - return False - return False - - def toggle_led_state(self, led=0): - if not self._initialized: - return - print("Input Debug Driver: toggle_led_state") - - def grab_all_devices(self): - if not self._initialized: - return - print("Input Debug Driver: grab_all_devices") - - def ungrab_all_devices(self): - if not self._initialized: - return - print("Input Debug Driver: ungrab_all_devices") - - def remove_all_devices(self): - if not self._initialized: - return - print("Input Debug Driver: remove_all_devices") - - def __del__(self): - if self._initialized: - self.remove_all_devices() - print("Input Debug Driver: __del__") diff --git a/src/fenrirscreenreader/inputDriver/dummyDriver.py b/src/fenrirscreenreader/inputDriver/dummyDriver.py deleted file mode 100644 index 22b1c652..00000000 --- a/src/fenrirscreenreader/inputDriver/dummyDriver.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import time - -from fenrirscreenreader.core import debug -from fenrirscreenreader.core.inputDriver import InputDriver as inputDriver - - -class driver(inputDriver): - def __init__(self): - inputDriver.__init__(self) diff --git a/src/fenrirscreenreader/inputDriver/evdevDriver.py b/src/fenrirscreenreader/inputDriver/evdevDriver.py deleted file mode 100644 index 6cef33c0..00000000 --- a/src/fenrirscreenreader/inputDriver/evdevDriver.py +++ /dev/null @@ -1,965 +0,0 @@ -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import multiprocessing -import threading -import time -from ctypes import c_bool -from multiprocessing.sharedctypes import Value -from select import select - -from fenrirscreenreader.core import debug -from fenrirscreenreader.core import inputData -from fenrirscreenreader.core.eventData import FenrirEventType -from fenrirscreenreader.core.inputDriver import InputDriver as inputDriver - -_evdevAvailable = False -_udevAvailable = False -_evdevAvailableError = "" -_udevAvailableError = "" -try: - import evdev - from evdev import InputDevice - from evdev import UInput - from evdev import ecodes as e - - _evdevAvailable = True - -except Exception as e: - _evdevAvailableError = str(e) - -try: - import pyudev - - _udevAvailable = True -except Exception as e: - _udevAvailableError = str(e) - - -class driver(inputDriver): - """Linux evdev input driver for Fenrir screen reader. - - This driver provides access to Linux input devices through the evdev interface, - allowing Fenrir to capture keyboard input, manage device grabbing for exclusive - access, and inject synthetic input events. - - Features: - - Automatic device detection and hotplug support via udev - - Device grabbing to prevent input from reaching other applications - - Key event mapping and filtering - - UInput support for synthetic key injection - - Multi-device support with thread-safe access - - Attributes: - iDevices (dict): Map of file descriptor to InputDevice objects - iDevicesFD (list): Shared list of file descriptors for multiprocessing - uDevices (dict): Map of file descriptor to UInput devices - gDevices (dict): Map of file descriptor to grab status - iDeviceNo (int): Total number of input devices - UInputinject (UInput): Device for injecting synthetic events - _deviceLock (Lock): Thread lock for device access - """ - def __init__(self): - inputDriver.__init__(self) - self._manager = multiprocessing.Manager() - self.iDevices = {} - self.iDevicesFD = self._manager.list() - self.uDevices = {} - self.gDevices = {} - self.iDeviceNo = 0 - self.watch_dog = Value(c_bool, True) - self.UInputinject = ( - UInput() if _evdevAvailable and "UInput" in globals() else None - ) - self._deviceLock = threading.Lock() - - def initialize(self, environment): - """Initialize the evdev input driver. - - Sets up device monitoring, starts watchdog threads for device hotplug - detection and input monitoring, and configures the input subsystem. - - Args: - environment: Fenrir environment dictionary with runtime managers - - Note: - Requires evdev and optionally pyudev libraries. Falls back gracefully - if libraries are not available. - """ - self.env = environment - global _evdevAvailable - global _udevAvailable - global _evdevAvailableError - global _udevAvailableError - if not _udevAvailable: - self.env["runtime"]["DebugManager"].write_debug_out( - "InputDriver:" + _udevAvailableError, debug.DebugLevel.ERROR - ) - if not _evdevAvailable: - self.env["runtime"]["DebugManager"].write_debug_out( - "InputDriver:" + _evdevAvailableError, debug.DebugLevel.ERROR - ) - return - - if _udevAvailable: - self.env["runtime"]["ProcessManager"].add_custom_event_thread( - self.plug_input_device_watchdog_udev - ) - self.env["runtime"]["ProcessManager"].add_custom_event_thread( - self.input_watchdog - ) - self._initialized = True - - def plug_input_device_watchdog_udev(self, active, event_queue): - """Monitor for input device hotplug events via udev. - - Runs in a separate thread to detect when input devices are - plugged/unplugged and generates appropriate events for device - management. - - Args: - active: Shared boolean controlling the watchdog loop - event_queue: Queue for sending device events to main process - - Events Generated: - FenrirEventType.plug_input_device: When new devices are detected - - Note: - Filters out virtual devices and devices from assistive technologies - like BRLTTY to avoid conflicts. - """ - context = pyudev.Context() - monitor = pyudev.Monitor.from_netlink(context) - monitor.filter_by(subsystem="input") - monitor.start() - ignore_plug = False - while active.value: - valid_devices = [] - device = monitor.poll(1) - while device: - self.env["runtime"]["DebugManager"].write_debug_out( - "plug_input_device_watchdog_udev:" + str(device), - debug.DebugLevel.INFO, - ) - try: - try: - # FIX: Check if attributes exist before accessing them - if ( - hasattr(device, "name") - and device.name - and device.name.upper() - in ["", "SPEAKUP", "FENRIR-UINPUT"] - ): - ignore_plug = True - if ( - hasattr(device, "phys") - and device.phys - and device.phys.upper() - in ["", "SPEAKUP", "FENRIR-UINPUT"] - ): - ignore_plug = True - if ( - hasattr(device, "name") - and device.name - and "BRLTTY" in device.name.upper() - ): - ignore_plug = True - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "plug_input_device_watchdog_udev CHECK NAME CRASH: " - + str(e), - debug.DebugLevel.ERROR, - ) - - if not ignore_plug: - virtual = ( - "/sys/devices/virtual/input/" in device.sys_path - ) - if device.device_node: - valid_devices.append( - { - "device": device.device_node, - "virtual": virtual, - } - ) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "plug_input_device_watchdog_udev APPEND CRASH: " - + str(e), - debug.DebugLevel.ERROR, - ) - try: - poll_timeout = 1 - device = monitor.poll(poll_timeout) - except Exception: - device = None - ignore_plug = False - if valid_devices: - event_queue.put( - { - "Type": FenrirEventType.plug_input_device, - "data": valid_devices, - } - ) - return time.time() - - def input_watchdog(self, active, event_queue): - """Main input monitoring loop. - - Monitors all registered input devices for key events using select(). - Processes key events, maps them to Fenrir's internal format, and - forwards them to the main application. - - Args: - active: Shared boolean controlling the watchdog loop - event_queue: Queue for sending input events to main process - - Events Generated: - FenrirEventType.keyboard_input: For key press/release events - - Note: - Uses thread-safe device access and handles device disconnection - gracefully. Non-keyboard events are forwarded to UInput devices. - """ - try: - while active.value: - # Get a snapshot of devices for select() to avoid lock - # contention - with self._deviceLock: - devices_snapshot = self.iDevices.copy() - - if not devices_snapshot: - time.sleep(0.1) - continue - - r, w, x = select(devices_snapshot, [], [], 0.8) - event = None - found_key_in_sequence = False - foreward = False - event_fired = False - for fd in r: - # Check if device still exists before accessing - with self._deviceLock: - if fd not in self.iDevices: - continue - device = self.iDevices[fd] - udevice = self.uDevices.get(fd) - - try: - event = device.read_one() - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "evdevDriver handle_input_event: Error reading event: " - + str(e), - debug.DebugLevel.ERROR, - ) - self.remove_device(fd) - continue - while event: - self.env["runtime"]["DebugManager"].write_debug_out( - "input_watchdog: EVENT:" + str(event), - debug.DebugLevel.INFO, - ) - self.env["input"]["event_buffer"].append( - [device, udevice, event] - ) - if event.type == evdev.events.EV_KEY: - if not found_key_in_sequence: - found_key_in_sequence = True - if event.code != 0: - curr_map_event = self.map_event(event) - if not curr_map_event: - event = device.read_one() - continue - if not isinstance( - curr_map_event["event_name"], str - ): - event = device.read_one() - continue - if curr_map_event["event_state"] in [0, 1, 2]: - event_queue.put( - { - "Type": FenrirEventType.keyboard_input, - "data": curr_map_event.copy(), - } - ) - event_fired = True - else: - # Forward non-keyboard events: 2=EV_REL, 3=EV_ABS, 17=EV_LED - if event.type in [2, 3, 17]: - foreward = True - - event = device.read_one() - if not found_key_in_sequence: - if foreward and not event_fired: - self.write_event_buffer() - self.clear_event_buffer() - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "INPUT WATCHDOG CRASH: " + str(e), debug.DebugLevel.ERROR - ) - - def write_event_buffer(self): - if not self._initialized: - return - for iDevice, uDevice, event in self.env["input"]["event_buffer"]: - try: - if uDevice: - if self.gDevices[iDevice.fd]: - self.write_u_input(uDevice, event) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "evdevDriver write_event_buffer: Error writing event: " - + str(e), - debug.DebugLevel.ERROR, - ) - - def write_u_input(self, uDevice, event): - if not self._initialized: - return - uDevice.write_event(event) - time.sleep(0.0000002) - uDevice.syn() - - def update_input_devices(self, new_devices=None, init=False): - """Update the list of monitored input devices. - - Scans for available input devices and adds suitable ones based on - the configured device mode. Supports filtering by device type and - name matching. - - Args: - new_devices (list, optional): Specific devices to add - init (bool): Whether this is initial device setup - - Device Modes: - - 'ALL': Monitor all keyboard devices - - 'NOMICE': Monitor keyboards but exclude pointing devices - - Device names: Comma-separated list of specific device names - - Note: - Automatically filters out virtual devices, assistive technology - devices, and devices with insufficient key counts. - """ - if init: - self.remove_all_devices() - - device_file_list = None - - if new_devices and not init: - if not isinstance(new_devices, list): - new_devices = [new_devices] - device_file_list = new_devices - else: - device_file_list = evdev.list_devices() - if len(device_file_list) == self.iDeviceNo: - return - if not device_file_list: - return - - mode = ( - self.env["runtime"]["SettingsManager"] - .get_setting("keyboard", "device") - .upper() - ) - - i_devices_files = [] - for device in self.iDevices: - i_devices_files.append(self.iDevices[device].path) - - event_type = evdev.events - for deviceFile in device_file_list: - try: - if not deviceFile: - continue - if deviceFile == "": - continue - if deviceFile in i_devices_files: - continue - try: - with open(deviceFile) as f: - pass - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "evdevDriver update_input_devices: Error opening device file: " - + str(e), - debug.DebugLevel.ERROR, - ) - continue - # 3 pos absolute - # 2 pos relative - # 1 Keys - try: - curr_device = evdev.InputDevice(deviceFile) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "evdevDriver createDeviceType: Error creating device: " - + str(e), - debug.DebugLevel.ERROR, - ) - continue - try: - # FIX: Check if attributes exist before accessing them - if ( - hasattr(curr_device, "name") - and curr_device.name - and curr_device.name.upper() - in ["", "SPEAKUP", "FENRIR-UINPUT"] - ): - continue - if ( - hasattr(curr_device, "phys") - and curr_device.phys - and curr_device.phys.upper() - in ["", "SPEAKUP", "FENRIR-UINPUT"] - ): - continue - if ( - hasattr(curr_device, "name") - and curr_device.name - and "BRLTTY" in curr_device.name.upper() - ): - continue - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "evdevDriver: Error checking device capabilities: " - + str(e), - debug.DebugLevel.ERROR, - ) - cap = curr_device.capabilities() - if mode in ["ALL", "NOMICE"]: - if event_type.EV_KEY in cap: - if ( - 116 in cap[event_type.EV_KEY] - and len(cap[event_type.EV_KEY]) < 10 - ): - self.env["runtime"][ - "DebugManager" - ].write_debug_out( - "Device Skipped (has 116):" + curr_device.name, - debug.DebugLevel.INFO, - ) - continue - # Check if device has numpad keys - use lower threshold for dedicated numpads - numpad_keys = [ - evdev.ecodes.KEY_KP0, evdev.ecodes.KEY_KP1, evdev.ecodes.KEY_KP2, - evdev.ecodes.KEY_KP3, evdev.ecodes.KEY_KP4, evdev.ecodes.KEY_KP5, - evdev.ecodes.KEY_KP6, evdev.ecodes.KEY_KP7, evdev.ecodes.KEY_KP8, - evdev.ecodes.KEY_KP9, evdev.ecodes.KEY_KPPLUS, evdev.ecodes.KEY_KPMINUS, - evdev.ecodes.KEY_KPASTERISK, evdev.ecodes.KEY_KPSLASH, evdev.ecodes.KEY_KPENTER, - evdev.ecodes.KEY_KPDOT - ] - - has_numpad_keys = any(key in cap[event_type.EV_KEY] for key in numpad_keys) - min_key_threshold = 10 if has_numpad_keys else 60 - - if len(cap[event_type.EV_KEY]) < min_key_threshold: - threshold_type = "numpad" if has_numpad_keys else "keyboard" - self.env["runtime"][ - "DebugManager" - ].write_debug_out( - f"Device Skipped (< {min_key_threshold} keys for {threshold_type}):" - + curr_device.name, - debug.DebugLevel.INFO, - ) - continue - if mode == "ALL": - self.add_device(curr_device) - self.env["runtime"][ - "DebugManager" - ].write_debug_out( - "Device added (ALL):" - + self.iDevices[curr_device.fd].name, - debug.DebugLevel.INFO, - ) - elif mode == "NOMICE": - if not ( - (event_type.EV_REL in cap) - or (event_type.EV_ABS in cap) - ): - self.add_device(curr_device) - self.env["runtime"][ - "DebugManager" - ].write_debug_out( - "Device added (NOMICE):" - + self.iDevices[curr_device.fd].name, - debug.DebugLevel.INFO, - ) - else: - self.env["runtime"][ - "DebugManager" - ].write_debug_out( - "Device Skipped (NOMICE):" - + curr_device.name, - debug.DebugLevel.INFO, - ) - else: - self.env["runtime"]["DebugManager"].write_debug_out( - "Device Skipped (no EV_KEY):" + curr_device.name, - debug.DebugLevel.INFO, - ) - elif curr_device.name.upper() in mode.split(","): - self.add_device(curr_device) - self.env["runtime"]["DebugManager"].write_debug_out( - "Device added (Name):" - + self.iDevices[curr_device.fd].name, - debug.DebugLevel.INFO, - ) - except Exception as e: - try: - device_name = ( - curr_device.name - if hasattr(curr_device, "name") - else "unknown" - ) - self.env["runtime"]["DebugManager"].write_debug_out( - "Device Skipped (Exception): " - + deviceFile - + " " - + device_name - + " " - + str(e), - debug.DebugLevel.INFO, - ) - except Exception as ex: - self.env["runtime"]["DebugManager"].write_debug_out( - "Device Skipped (Exception): " - + deviceFile - + " " - + str(ex), - debug.DebugLevel.INFO, - ) - self.iDeviceNo = len(evdev.list_devices()) - self.update_m_pi_devices_fd() - - def update_m_pi_devices_fd(self): - try: - for fd in self.iDevices: - if fd not in self.iDevicesFD: - self.iDevicesFD.append(fd) - for fd in self.iDevicesFD: - if fd not in self.iDevices: - self.iDevicesFD.remove(fd) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "evdevDriver update_m_pi_devices_fd: Error updating device file descriptors: " - + str(e), - debug.DebugLevel.ERROR, - ) - - def map_event(self, event): - if not self._initialized: - return None - if not event: - return None - m_event = inputData.input_event - try: - # mute is a list = ['KEY_MIN_INTERESTING', 'KEY_MUTE'] - m_event["event_name"] = evdev.ecodes.keys[event.code] - if isinstance(m_event["event_name"], list): - if len(m_event["event_name"]) > 0: - m_event["event_name"] = m_event["event_name"][0] - if isinstance(m_event["event_name"], list): - if len(m_event["event_name"]) > 0: - m_event["event_name"] = m_event["event_name"][0] - m_event["event_value"] = event.code - m_event["event_sec"] = event.sec - m_event["event_usec"] = event.usec - m_event["event_state"] = event.value - m_event["event_type"] = event.type - return m_event - except Exception as e: - return None - - def get_led_state(self, led=0): - if not self.has_i_devices(): - return False - # 0 = Numlock - # 1 = Capslock - # 2 = Rollen - - # Use the first device with LED capability as authoritative source - # to avoid inconsistent readings from multiple devices during initialization - for fd, dev in self.iDevices.items(): - # Check if device has LED capability (capability 17) - if 17 in dev.capabilities(): - return led in dev.leds() - - # Fallback to old behavior if no device has LED capability - for fd, dev in self.iDevices.items(): - if led in dev.leds(): - return True - return False - - def toggle_led_state(self, led=0): - if not self.has_i_devices(): - return False - led_state = self.get_led_state(led) - for i in self.iDevices: - if self.gDevices[i]: - # 17 LEDs - if 17 in self.iDevices[i].capabilities(): - if led_state == 1: - self.iDevices[i].set_led(led, 0) - else: - self.iDevices[i].set_led(led, 1) - - def grab_all_devices(self): - if not self._initialized: - return True - ok = True - for fd in self.iDevices: - if not self.gDevices[fd]: - ok = ok and self.grab_device(fd) - return ok - - def ungrab_all_devices(self): - if not self._initialized: - return True - ok = True - for fd in self.iDevices: - if self.gDevices[fd]: - ok = ok and self.ungrab_device(fd) - return ok - - def create_u_input_dev(self, fd): - if not self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "keyboard", "grab_devices" - ): - self.uDevices[fd] = None - return - try: - test = self.uDevices[fd] - return - except KeyError: - self.uDevices[fd] = None - if self.uDevices[fd] is not None: - return - if "UInput" not in globals(): - return - try: - self.uDevices[fd] = UInput.from_device( - self.iDevices[fd], name="fenrir-uinput", phys="fenrir-uinput" - ) - except Exception as e: - try: - self.env["runtime"]["DebugManager"].write_debug_out( - "InputDriver evdev: compat fallback: " + str(e), - debug.DebugLevel.WARNING, - ) - dev = self.iDevices[fd] - cap = dev.capabilities() - del cap[0] - self.uDevices[fd] = UInput( - cap, name="fenrir-uinput", phys="fenrir-uinput" - ) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "InputDriver evdev: init Uinput not possible: " + str(e), - debug.DebugLevel.ERROR, - ) - return - - def add_device(self, newDevice): - """Add a new input device to the monitoring list. - - Creates the necessary data structures for device monitoring, - sets up UInput forwarding if device grabbing is enabled, and - initializes device state. - - Args: - newDevice: evdev.InputDevice object to add - - Note: - Thread-safe operation. Automatically cleans up on failure. - """ - self.env["runtime"]["DebugManager"].write_debug_out( - "InputDriver evdev: device added: " - + str(newDevice.fd) - + " " - + str(newDevice), - debug.DebugLevel.INFO, - ) - with self._deviceLock: - try: - self.iDevices[newDevice.fd] = newDevice - self.create_u_input_dev(newDevice.fd) - self.gDevices[newDevice.fd] = False - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "InputDriver evdev: error adding device: " + str(e), - debug.DebugLevel.ERROR, - ) - # if it doesnt work clean up - try: - del self.iDevices[newDevice.fd] - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "evdevDriver remove_device: Error removing iDevice: " - + str(e), - debug.DebugLevel.ERROR, - ) - try: - del self.uDevices[newDevice.fd] - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "evdevDriver remove_device: Error removing uDevice: " - + str(e), - debug.DebugLevel.ERROR, - ) - try: - del self.gDevices[newDevice.fd] - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "evdevDriver add_device: Error cleaning up gDevice: " - + str(e), - debug.DebugLevel.ERROR, - ) - - def grab_device(self, fd): - """Grab exclusive access to an input device. - - Takes exclusive control of the device, preventing other applications - from receiving its input. Also resets modifier key states to prevent - stuck keys. - - Args: - fd (int): File descriptor of device to grab - - Returns: - bool: True if grab successful, False otherwise - - Note: - Only effective if grabDevices setting is enabled. - """ - if not self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "keyboard", "grab_devices" - ): - return True - - # FIX: Handle exception variable scope correctly - grab_error = None - try: - self.iDevices[fd].grab() - self.gDevices[fd] = True - self.env["runtime"]["DebugManager"].write_debug_out( - "InputDriver evdev: grab device (" - + str(self.iDevices[fd].name) - + ")", - debug.DebugLevel.INFO, - ) - # Reset modifier keys on successful grab - if self.uDevices[fd]: - modifier_keys = [ - e.KEY_LEFTCTRL, - e.KEY_RIGHTCTRL, - e.KEY_LEFTALT, - e.KEY_RIGHTALT, - e.KEY_LEFTSHIFT, - e.KEY_RIGHTSHIFT, - ] - for key in modifier_keys: - try: - self.uDevices[fd].write(e.EV_KEY, key, 0) # 0 = key up - self.uDevices[fd].syn() - except Exception as mod_error: - self.env["runtime"]["DebugManager"].write_debug_out( - "Failed to reset modifier key: " + str(mod_error), - debug.DebugLevel.WARNING, - ) - except IOError: - if not self.gDevices[fd]: - return False - except Exception as ex: - grab_error = ex - - if grab_error: - self.env["runtime"]["DebugManager"].write_debug_out( - "InputDriver evdev: grabing not possible: " + str(grab_error), - debug.DebugLevel.ERROR, - ) - return False - - return True - - def ungrab_device(self, fd): - """Release exclusive access to an input device. - - Returns control of the device to the system, allowing other - applications to receive its input. - - Args: - fd (int): File descriptor of device to ungrab - - Returns: - bool: True if ungrab successful, False otherwise - """ - if not self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "keyboard", "grab_devices" - ): - return True - - # FIX: Handle exception variable scope correctly - ungrab_error = None - try: - self.iDevices[fd].ungrab() - self.gDevices[fd] = False - self.env["runtime"]["DebugManager"].write_debug_out( - "InputDriver evdev: ungrab device (" - + str(self.iDevices[fd].name) - + ")", - debug.DebugLevel.INFO, - ) - except IOError: - if self.gDevices[fd]: - return False - except Exception as ex: - ungrab_error = ex - - if ungrab_error: - self.env["runtime"]["DebugManager"].write_debug_out( - "InputDriver evdev: ungrabing not possible: " - + str(ungrab_error), - debug.DebugLevel.ERROR, - ) - return False - - return True - - def remove_device(self, fd): - """Remove an input device from monitoring. - - Cleanly removes a device by ungrabbing it, closing file handles, - and cleaning up all associated data structures. - - Args: - fd (int): File descriptor of device to remove - - Note: - Thread-safe operation with comprehensive error handling. - """ - with self._deviceLock: - try: - self.env["runtime"]["DebugManager"].write_debug_out( - "InputDriver evdev: device removed: " - + str(fd) - + " " - + str(self.iDevices[fd]), - debug.DebugLevel.INFO, - ) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "InputDriver evdev: device removed: " - + str(fd) - + " Error: " - + str(e), - debug.DebugLevel.INFO, - ) - self.clear_event_buffer() - try: - self.ungrab_device(fd) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "evdevDriver remove_device: Error ungrabbing device " - + str(fd) - + ": " - + str(e), - debug.DebugLevel.ERROR, - ) - try: - self.iDevices[fd].close() - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "evdevDriver remove_device: Error closing iDevice " - + str(fd) - + ": " - + str(e), - debug.DebugLevel.ERROR, - ) - try: - self.uDevices[fd].close() - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "evdevDriver remove_device: Error closing uDevice " - + str(fd) - + ": " - + str(e), - debug.DebugLevel.ERROR, - ) - try: - del self.iDevices[fd] - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "evdevDriver remove_device: Error deleting iDevice " - + str(fd) - + ": " - + str(e), - debug.DebugLevel.ERROR, - ) - try: - del self.uDevices[fd] - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "evdevDriver remove_device: Error deleting uDevice " - + str(fd) - + ": " - + str(e), - debug.DebugLevel.ERROR, - ) - try: - del self.gDevices[fd] - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "evdevDriver remove_device: Error deleting gDevice " - + str(fd) - + ": " - + str(e), - debug.DebugLevel.ERROR, - ) - self.update_m_pi_devices_fd() - - def has_i_devices(self): - if not self._initialized: - return False - if not self.iDevices: - return False - if len(self.iDevices) == 0: - return False - return True - - def send_key(self, key, state): - """Inject a synthetic key event. - - Sends a key press or release event using UInput. Used for - features like key forwarding and macro execution. - - Args: - key (str): Key name (e.g., 'KEY_A') - state (int): Key state (0=release, 1=press, 2=repeat) - """ - if not self._initialized: - return - if self.UInputinject is None: - return - try: - self.UInputinject.write(e.EV_KEY, e.ecodes[key], state) - self.UInputinject.syn() - except Exception as ex: - self.env["runtime"]["DebugManager"].write_debug_out( - "evdevDriver send_key: Error sending key " - + str(key) - + ": " - + str(ex), - debug.DebugLevel.ERROR, - ) - - def remove_all_devices(self): - if not self.has_i_devices(): - return - devices = self.iDevices.copy() - for fd in devices: - self.remove_device(fd) - self.iDevices.clear() - self.uDevices.clear() - self.gDevices.clear() - self.iDeviceNo = 0 diff --git a/src/fenrirscreenreader/inputDriver/x11Driver.py b/src/fenrirscreenreader/inputDriver/x11Driver.py deleted file mode 100644 index 1258f48e..00000000 --- a/src/fenrirscreenreader/inputDriver/x11Driver.py +++ /dev/null @@ -1,945 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import os -import sys -import time - -from fenrirscreenreader.core import debug -from fenrirscreenreader.core.eventData import FenrirEventType -from fenrirscreenreader.core.inputDriver import InputDriver as inputDriver - -try: - from Xlib import X - from Xlib import XK - from Xlib import display - from Xlib.error import BadAccess - from Xlib.error import BadWindow -except Exception as x_error: - X = None - XK = None - display = None - BadAccess = Exception - BadWindow = Exception - _x_error = x_error -else: - _x_error = None - - -def build_keysym_name_map(): - if XK is None: - return {} - names = {} - for attr_name in dir(XK): - if not attr_name.startswith("XK_"): - continue - value = getattr(XK, attr_name) - if not isinstance(value, int): - continue - names.setdefault(value, attr_name[3:]) - return names - - -KEYSYM_NAME_MAP = build_keysym_name_map() - - -class X11DriverError(RuntimeError): - pass - - -class driver(inputDriver): - """X11 terminal-scoped keyboard input driver.""" - - key_name_overrides = { - "BackSpace": "KEY_BACKSPACE", - "Return": "KEY_ENTER", - "ISO_Enter": "KEY_ENTER", - "Tab": "KEY_TAB", - "Escape": "KEY_ESC", - "space": "KEY_SPACE", - "minus": "KEY_MINUS", - "-": "KEY_MINUS", - "underscore": "KEY_MINUS", - "_": "KEY_MINUS", - "equal": "KEY_EQUAL", - "=": "KEY_EQUAL", - "plus": "KEY_EQUAL", - "+": "KEY_EQUAL", - "bracketleft": "KEY_LEFTBRACE", - "[": "KEY_LEFTBRACE", - "bracketright": "KEY_RIGHTBRACE", - "]": "KEY_RIGHTBRACE", - "backslash": "KEY_BACKSLASH", - "\\": "KEY_BACKSLASH", - "semicolon": "KEY_SEMICOLON", - ";": "KEY_SEMICOLON", - "apostrophe": "KEY_APOSTROPHE", - "'": "KEY_APOSTROPHE", - "grave": "KEY_GRAVE", - "`": "KEY_GRAVE", - "comma": "KEY_COMMA", - ",": "KEY_COMMA", - "period": "KEY_DOT", - ".": "KEY_DOT", - "slash": "KEY_SLASH", - "/": "KEY_SLASH", - "Shift_L": "KEY_LEFTSHIFT", - "Shift_R": "KEY_RIGHTSHIFT", - "Control_L": "KEY_LEFTCTRL", - "Control_R": "KEY_RIGHTCTRL", - "Alt_L": "KEY_LEFTALT", - "Alt_R": "KEY_RIGHTALT", - "Meta_L": "KEY_LEFTMETA", - "Meta_R": "KEY_RIGHTMETA", - "Super_L": "KEY_LEFTMETA", - "Super_R": "KEY_RIGHTMETA", - "Multi_key": "KEY_COMPOSE", - "Caps_Lock": "KEY_CAPSLOCK", - "Num_Lock": "KEY_NUMLOCK", - "Scroll_Lock": "KEY_SCROLLLOCK", - "Insert": "KEY_INSERT", - "Delete": "KEY_DELETE", - "Home": "KEY_HOME", - "End": "KEY_END", - "Prior": "KEY_PAGEUP", - "Next": "KEY_PAGEDOWN", - "Page_Up": "KEY_PAGEUP", - "Page_Down": "KEY_PAGEDOWN", - "Up": "KEY_UP", - "Down": "KEY_DOWN", - "Left": "KEY_LEFT", - "Right": "KEY_RIGHT", - "KP_0": "KEY_KP0", - "KP_Insert": "KEY_KP0", - "KP_1": "KEY_KP1", - "KP_End": "KEY_KP1", - "KP_2": "KEY_KP2", - "KP_Down": "KEY_KP2", - "KP_3": "KEY_KP3", - "KP_Page_Down": "KEY_KP3", - "KP_4": "KEY_KP4", - "KP_Left": "KEY_KP4", - "KP_5": "KEY_KP5", - "KP_Begin": "KEY_KP5", - "KP_6": "KEY_KP6", - "KP_Right": "KEY_KP6", - "KP_7": "KEY_KP7", - "KP_Home": "KEY_KP7", - "KP_8": "KEY_KP8", - "KP_Up": "KEY_KP8", - "KP_9": "KEY_KP9", - "KP_Page_Up": "KEY_KP9", - "KP_Decimal": "KEY_KPDOT", - "KP_Delete": "KEY_KPDOT", - "KP_Add": "KEY_KPPLUS", - "KP_Subtract": "KEY_KPMINUS", - "KP_Multiply": "KEY_KPASTERISK", - "KP_Divide": "KEY_KPSLASH", - "KP_Enter": "KEY_KPENTER", - "KP_Equal": "KEY_KPEQUAL", - } - - modifier_masks = { - "KEY_SHIFT": X.ShiftMask if X else 1, - "KEY_LEFTSHIFT": X.ShiftMask if X else 1, - "KEY_RIGHTSHIFT": X.ShiftMask if X else 1, - "KEY_CTRL": X.ControlMask if X else 4, - "KEY_LEFTCTRL": X.ControlMask if X else 4, - "KEY_RIGHTCTRL": X.ControlMask if X else 4, - "KEY_ALT": X.Mod1Mask if X else 8, - "KEY_LEFTALT": X.Mod1Mask if X else 8, - "KEY_RIGHTALT": X.Mod1Mask if X else 8, - "KEY_META": X.Mod4Mask if X else 64, - "KEY_LEFTMETA": X.Mod4Mask if X else 64, - "KEY_RIGHTMETA": X.Mod4Mask if X else 64, - } - - modifier_key_names = set(modifier_masks.keys()) - - canonical_modifier_masks = ( - ("KEY_SHIFT", X.ShiftMask if X else 1), - ("KEY_CTRL", X.ControlMask if X else 4), - ("KEY_ALT", X.Mod1Mask if X else 8), - ("KEY_META", X.Mod4Mask if X else 64), - ) - - def __init__(self): - inputDriver.__init__(self) - self.display = None - self.root = None - self.window = None - self.window_id = None - self.active = True - self.num_lock_mask = 0 - self.grabbed = set() - self.grab_signature = None - self.interesting_keys = set() - self.fenrir_keys = set() - self.failed_grabs = 0 - self.modifier_state = 0 - self.modifier_interrupt_state = 0 - self.command_key_active = False - self.chord_modifiers = set() - self.chord_keys = set() - self.help_keyboard_grabbed = False - - def initialize(self, environment): - self.env = environment - if display is None: - self.fail_startup("python-xlib is not available: " + str(_x_error)) - self.display = display.Display() - self.root = self.display.screen().root - self.window_id = self.resolve_window_id() - if self.window_id is None: - self.fail_startup( - "No X11 target window found. Use --x11-window-id or launch from an X11 terminal." - ) - self.write_debug( - "x11Driver target window " - + self.format_window_id(self.window_id) - + ", active window " - + self.format_window_id(self.get_active_window_id()) - + ", WINDOWID=" - + os.environ.get("WINDOWID", ""), - debug.DebugLevel.INFO, - ) - self.window = self.display.create_resource_object("window", self.window_id) - self.window.change_attributes( - event_mask=( - X.KeyPressMask - | X.KeyReleaseMask - | X.FocusChangeMask - | X.StructureNotifyMask - ) - ) - self.num_lock_mask = self.find_num_lock_mask() - self.refresh_modifier_state() - self.modifier_interrupt_state = self.modifier_state - self.refresh_interesting_keys() - self.refresh_grabs(force=True) - self.env["runtime"]["ProcessManager"].add_custom_event_thread( - self.input_watchdog - ) - self._initialized = True - - def shutdown(self): - self.set_help_capture(False) - self.ungrab_all_devices() - try: - if self.display: - self.display.close() - except Exception: - pass - self._initialized = False - - def fail_startup(self, message): - print("Fenrir X11 driver error: " + message, file=sys.stderr) - raise SystemExit(1) - - def resolve_window_id(self): - configured_window = self.env["runtime"]["SettingsManager"].get_setting( - "keyboard", "x11_window_id" - ) - for candidate in [configured_window, os.environ.get("WINDOWID", "")]: - window_id = self.parse_window_id(candidate) - if window_id is not None: - return window_id - return self.get_active_window_id() - - def parse_window_id(self, value): - if value is None: - return None - value = str(value).strip() - if value == "": - return None - try: - return int(value, 0) - except ValueError: - raise X11DriverError("Invalid X11 window id: " + value) - - def get_active_window_id(self): - atom = self.display.intern_atom("_NET_ACTIVE_WINDOW") - prop = self.root.get_full_property(atom, X.AnyPropertyType) - if prop is None or len(prop.value) == 0: - return None - return int(prop.value[0]) - - def is_active(self): - return self.active - - def format_window_id(self, window_id): - if window_id is None: - return "None" - return hex(int(window_id)) - - def find_num_lock_mask(self): - num_lock_keysym = XK.string_to_keysym("Num_Lock") - if not num_lock_keysym: - return 0 - num_lock_keycode = self.display.keysym_to_keycode(num_lock_keysym) - if not num_lock_keycode: - return 0 - modifier_map = self.display.get_modifier_mapping() - masks = [ - X.ShiftMask, - X.LockMask, - X.ControlMask, - X.Mod1Mask, - X.Mod2Mask, - X.Mod3Mask, - X.Mod4Mask, - X.Mod5Mask, - ] - for index, keycodes in enumerate(modifier_map): - if num_lock_keycode in keycodes: - return masks[index] - return 0 - - def input_watchdog(self, active, event_queue): - while active.value: - try: - self.refresh_grabs() - self.poll_modifier_interrupt_keys() - if not self.display.pending_events(): - time.sleep(0.01) - continue - event = self.display.next_event() - self.handle_x_event(event, event_queue) - except BadWindow: - self.env["runtime"]["DebugManager"].write_debug_out( - "x11Driver target window disappeared", - debug.DebugLevel.ERROR, - ) - break - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "x11Driver input watchdog error: " + str(e), - debug.DebugLevel.ERROR, - ) - time.sleep(0.1) - - def handle_x_event(self, event, event_queue): - event_type = getattr(event, "type", None) - if event_type in [X.FocusIn, X.FocusOut] and getattr( - event, "mode", X.NotifyNormal - ) in [X.NotifyGrab, X.NotifyUngrab]: - return - if event_type == X.FocusIn: - self.active = True - self.clear_event_buffer() - return - if event_type == X.FocusOut: - self.active = False - self.reset_input_state() - output_manager = self.env["runtime"].get("OutputManager") - if output_manager: - output_manager.interrupt_output_async() - return - if event_type not in [X.KeyPress, X.KeyRelease]: - return - if not self.active and not self.event_is_in_target_tree(event): - return - input_event = self.map_event(event) - if input_event is None: - return - key_name = input_event["event_name"] - if not self.should_emit_key(key_name): - return - self.update_modifier_state_from_event(input_event) - is_command_key = self.is_command_key(key_name) - if is_command_key and input_event["event_state"] == 1: - self.start_command_chord(input_event, event_queue) - elif self.command_key_active and not is_command_key: - self.track_chord_key(input_event) - self.track_chord_modifier(input_event) - self.write_debug( - "x11Driver key event " - + key_name - + " state " - + str(input_event["event_state"]) - + " raw state " - + str(getattr(event, "state", 0)), - debug.DebugLevel.INFO, - ) - self.env["input"]["event_buffer"].append(input_event.copy()) - event_queue.put( - { - "Type": FenrirEventType.keyboard_input, - "data": input_event, - } - ) - if is_command_key and input_event["event_state"] == 0: - self.finish_command_chord(input_event, event_queue) - - def is_command_key(self, key_name): - if key_name in self.fenrir_keys or key_name in self.env["input"].get( - "script_key", [] - ): - return True - converted_name = self.env["runtime"][ - "InputManager" - ].convert_event_name(key_name) - return converted_name in {"KEY_FENRIR", "KEY_SCRIPT"} - - def start_command_chord(self, input_event, event_queue): - self.command_key_active = True - self.chord_keys.clear() - raw_state = input_event.get("event_raw_state", 0) - current_input = self.env["input"]["curr_input"] - for key_name, modifier_mask in self.canonical_modifier_masks: - if not raw_state & modifier_mask: - continue - self.chord_modifiers.add(key_name) - if key_name not in current_input: - self.queue_synthetic_key( - key_name, 1, input_event, event_queue - ) - - def track_chord_modifier(self, input_event): - key_name = self.canonical_modifier_name(input_event["event_name"]) - if key_name is None: - return - if input_event["event_state"] == 1: - self.chord_modifiers.add(key_name) - elif input_event["event_state"] == 0: - self.chord_modifiers.discard(key_name) - - def track_chord_key(self, input_event): - key_name = input_event["event_name"] - if key_name in self.modifier_key_names: - return - if input_event["event_state"] == 1: - self.chord_keys.add(key_name) - elif input_event["event_state"] == 0: - self.chord_keys.discard(key_name) - - def canonical_modifier_name(self, key_name): - modifier_mask = self.modifier_masks.get(key_name, 0) - for canonical_name, canonical_mask in self.canonical_modifier_masks: - if modifier_mask == canonical_mask: - return canonical_name - return None - - def finish_command_chord(self, input_event, event_queue): - for key_name in sorted(self.chord_keys): - self.queue_synthetic_key(key_name, 0, input_event, event_queue) - self.chord_keys.clear() - for key_name in sorted(self.chord_modifiers): - self.queue_synthetic_key(key_name, 0, input_event, event_queue) - self.chord_modifiers.clear() - self.command_key_active = False - - def queue_synthetic_key( - self, key_name, event_state, source_event, event_queue - ): - synthetic_event = { - "event_name": key_name, - "event_value": 0, - "event_sec": source_event["event_sec"], - "event_usec": source_event["event_usec"], - "event_state": event_state, - "event_type": 0, - "event_raw_state": source_event.get("event_raw_state", 0), - "event_x_time": source_event.get("event_x_time", X.CurrentTime), - } - event_queue.put( - { - "Type": FenrirEventType.keyboard_input, - "data": synthetic_event, - } - ) - - def event_is_in_target_tree(self, event): - event_window = getattr(event, "event", None) - if event_window is None: - return False - window_id = getattr(event_window, "id", None) - if window_id is None: - return False - return window_id == self.window_id or self.window_has_ancestor( - window_id, self.window_id - ) - - def window_has_ancestor(self, window_id, ancestor_id): - try: - window = self.display.create_resource_object("window", window_id) - while window.id != self.root.id: - if window.id == ancestor_id: - return True - parent = window.query_tree().parent - if parent is None or parent.id == window.id: - return False - window = parent - except Exception: - return False - return False - - def map_event(self, event): - key_name = self.keycode_to_key_name(event.detail, event.state) - if key_name is None: - return None - event_time = time.time() - return { - "event_name": key_name, - "event_value": event.detail, - "event_sec": int(event_time), - "event_usec": int((event_time % 1) * 1000000), - "event_state": 1 if event.type == X.KeyPress else 0, - "event_type": event.type, - "event_raw_state": getattr(event, "state", 0), - "event_x_time": getattr(event, "time", X.CurrentTime), - } - - def poll_modifier_interrupt_keys(self): - if not self.active or not self.should_poll_modifier_interrupt_keys(): - return - try: - pointer = self.root.query_pointer() - current_state = getattr(pointer, "mask", 0) - except Exception: - return - previous_state = self.modifier_interrupt_state - self.modifier_interrupt_state = current_state - self.modifier_state = current_state - for key_name, modifier_mask in self.interrupt_modifier_masks(): - if current_state & modifier_mask and not previous_state & modifier_mask: - self.interrupt_output_on_modifier_key(key_name) - - def should_poll_modifier_interrupt_keys(self): - try: - settings_manager = self.env["runtime"]["SettingsManager"] - except Exception: - return False - if not settings_manager.get_setting_as_bool( - "keyboard", "interrupt_on_key_press" - ): - return False - return ( - settings_manager.get_setting( - "keyboard", "interrupt_on_key_press_filter" - ).strip() - == "" - ) - - def interrupt_modifier_masks(self): - return [ - ("KEY_CTRL", X.ControlMask), - ("KEY_SHIFT", X.ShiftMask), - ("KEY_ALT", X.Mod1Mask), - ] - - def interrupt_output_on_modifier_key(self, key_name): - try: - self.env["runtime"]["OutputManager"].interrupt_output_async() - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "x11Driver modifier interrupt failed for " - + key_name - + ": " - + str(e), - debug.DebugLevel.ERROR, - ) - - def refresh_modifier_state(self): - try: - pointer = self.root.query_pointer() - self.modifier_state = getattr(pointer, "mask", 0) - except Exception: - self.modifier_state = 0 - - def update_modifier_state_from_event(self, input_event): - key_name = input_event["event_name"] - event_state = input_event["event_state"] - raw_state = input_event.get("event_raw_state", self.modifier_state) - self.modifier_state = raw_state - if key_name == "KEY_NUMLOCK": - self.update_lock_modifier_state(self.num_lock_mask, event_state) - elif key_name == "KEY_CAPSLOCK": - self.update_lock_modifier_state(X.LockMask, event_state) - else: - modifier_mask = self.modifier_masks.get(key_name, 0) - if modifier_mask: - if event_state == 1: - self.modifier_state |= modifier_mask - elif event_state == 0: - self.modifier_state &= ~modifier_mask - - def update_lock_modifier_state(self, modifier_mask, event_state): - if not modifier_mask or event_state != 1: - return - if self.modifier_state & modifier_mask: - self.modifier_state &= ~modifier_mask - else: - self.modifier_state |= modifier_mask - - def keycode_to_key_name(self, keycode, state=0): - key_names = [] - for keysym_name in self.keycode_to_keysym_names(keycode): - key_name = self.keysym_name_to_key_name(keysym_name) - if key_name is None or key_name in key_names: - continue - key_names.append(key_name) - for key_name in key_names: - if key_name.startswith("KEY_KP"): - return key_name - if key_names: - return key_names[0] - return None - - def keycode_to_keysym_names(self, keycode): - names = [] - for column in range(8): - keysym = self.display.keycode_to_keysym(keycode, column) - keysym_name = self.keysym_to_name(keysym) - if keysym_name and keysym_name not in names: - names.append(keysym_name) - return names - - def keysym_to_name(self, keysym): - keysym_name = KEYSYM_NAME_MAP.get(keysym) - if keysym_name and keysym >= 0xFF00: - return keysym_name - keysym_string = XK.keysym_to_string(keysym) - if keysym_string: - return keysym_string - return keysym_name - - def keysym_name_to_key_name(self, keysym_name): - if not keysym_name: - return None - if keysym_name in self.key_name_overrides: - return self.key_name_overrides[keysym_name] - if len(keysym_name) == 1: - if keysym_name.isalpha(): - return "KEY_" + keysym_name.upper() - if keysym_name.isdigit(): - return "KEY_" + keysym_name - if keysym_name.startswith("F") and keysym_name[1:].isdigit(): - return "KEY_" + keysym_name - return None - - def should_emit_key(self, key_name): - return key_name is not None - - def refresh_interesting_keys(self): - input_manager = self.env["runtime"]["InputManager"] - self.fenrir_keys = set(self.env["input"]["fenrir_key"]) - interesting = set(self.fenrir_keys) - interesting.update(self.env["input"]["script_key"]) - interesting.update(self.modifier_key_names) - for shortcut in self.env.get("rawBindings", {}).values(): - for key_name in shortcut[1]: - if key_name == "KEY_FENRIR": - interesting.update(self.fenrir_keys) - elif key_name == "KEY_SCRIPT": - interesting.update(self.env["input"]["script_key"]) - else: - interesting.add(input_manager.convert_event_name(key_name)) - interesting.add(key_name) - self.interesting_keys = interesting - - def refresh_grabs(self, force=False): - signature = ( - tuple(sorted(self.env.get("rawBindings", {}).keys())), - tuple(sorted(self.env["input"]["fenrir_key"])), - tuple(sorted(self.env["input"]["script_key"])), - ) - if not force and signature == self.grab_signature: - return - self.ungrab_all_devices() - self.refresh_interesting_keys() - passive_grabs = self.build_passive_grabs() - failed_before = self.failed_grabs - for key_name, modifier_mask, include_num_lock in passive_grabs: - self.grab_key_name(key_name, modifier_mask, include_num_lock) - self.display.flush() - self.grab_signature = signature - self.write_debug( - "x11Driver passive grabs planned " - + str(len(passive_grabs)) - + ", installed " - + str(len(self.grabbed)) - + ", failed " - + str(self.failed_grabs - failed_before), - debug.DebugLevel.INFO, - ) - - def build_passive_grabs(self): - grabs = set() - for fenrir_key in self.env["input"]["fenrir_key"]: - grabs.add((fenrir_key, 0, True)) - for script_key in self.env["input"]["script_key"]: - grabs.add((script_key, 0, True)) - grabs.add(("KEY_NUMLOCK", 0, True)) - for shortcut in self.env.get("rawBindings", {}).values(): - keys = shortcut[1] - expanded_keys = self.expand_special_keys(keys) - modifier_mask = self.shortcut_modifier_mask(expanded_keys) - non_modifier_keys = [ - key for key in expanded_keys - if key not in self.modifier_key_names - ] - if not non_modifier_keys: - continue - final_key = non_modifier_keys[-1] - if "KEY_FENRIR" in keys: - for fenrir_key in self.env["input"]["fenrir_key"]: - grabs.add((fenrir_key, modifier_mask, True)) - fenrir_modifier_mask = self.modifier_masks.get( - fenrir_key, 0 - ) - if fenrir_modifier_mask: - grabs.add( - ( - final_key, - modifier_mask | fenrir_modifier_mask, - not final_key.startswith("KEY_KP"), - ) - ) - elif "KEY_SCRIPT" in keys: - for script_key in self.env["input"]["script_key"]: - grabs.add((script_key, modifier_mask, True)) - script_modifier_mask = self.modifier_masks.get( - script_key, 0 - ) - if script_modifier_mask: - grabs.add( - ( - final_key, - modifier_mask | script_modifier_mask, - not final_key.startswith("KEY_KP"), - ) - ) - else: - grabs.add( - ( - final_key, - modifier_mask, - not final_key.startswith("KEY_KP"), - ) - ) - return grabs - - def expand_special_keys(self, keys): - expanded = [] - for key_name in keys: - if key_name == "KEY_FENRIR": - expanded.extend(self.env["input"]["fenrir_key"]) - elif key_name == "KEY_SCRIPT": - expanded.extend(self.env["input"]["script_key"]) - else: - expanded.append(key_name) - return expanded - - def shortcut_modifier_mask(self, keys): - modifier_mask = 0 - for key_name in keys: - modifier_mask |= self.modifier_masks.get(key_name, 0) - return modifier_mask - - def grab_key_name( - self, key_name, modifier_mask=0, include_num_lock=True - ): - keysym_names = self.key_name_to_keysym_names(key_name) - for keysym_name in keysym_names: - keysym = XK.string_to_keysym(keysym_name) - if not keysym: - continue - keycode = self.display.keysym_to_keycode(keysym) - if not keycode: - continue - for effective_mask in self.optional_modifier_masks( - modifier_mask, include_num_lock - ): - try: - self.window.grab_key( - keycode, - effective_mask, - False, - X.GrabModeAsync, - X.GrabModeAsync, - ) - self.grabbed.add((keycode, effective_mask)) - except BadAccess: - self.failed_grabs += 1 - self.env["runtime"]["DebugManager"].write_debug_out( - "x11Driver could not grab " - + key_name - + " with modifier mask " - + str(effective_mask), - debug.DebugLevel.WARNING, - ) - - def optional_modifier_masks(self, modifier_mask, include_num_lock=True): - optional_masks = [0, X.LockMask] - if include_num_lock and self.num_lock_mask: - optional_masks += [self.num_lock_mask, self.num_lock_mask | X.LockMask] - return {modifier_mask | optional for optional in optional_masks} - - def key_name_to_keysym_names(self, key_name): - reverse_map = { - value: key for key, value in self.key_name_overrides.items() - } - aliases = { - "KEY_META": ["Super_L", "Super_R", "Meta_L", "Meta_R"], - "KEY_LEFTMETA": ["Super_L", "Meta_L"], - "KEY_RIGHTMETA": ["Super_R", "Meta_R"], - "KEY_COMPOSE": ["Multi_key"], - "KEY_PAGEUP": ["Page_Up", "Prior"], - "KEY_PAGEDOWN": ["Page_Down", "Next"], - "KEY_MINUS": ["minus", "underscore"], - "KEY_EQUAL": ["equal", "plus"], - "KEY_KP0": ["KP_0", "KP_Insert"], - "KEY_KP1": ["KP_1", "KP_End"], - "KEY_KP2": ["KP_2", "KP_Down"], - "KEY_KP3": ["KP_3", "KP_Page_Down"], - "KEY_KP4": ["KP_4", "KP_Left"], - "KEY_KP5": ["KP_5", "KP_Begin"], - "KEY_KP6": ["KP_6", "KP_Right"], - "KEY_KP7": ["KP_7", "KP_Home"], - "KEY_KP8": ["KP_8", "KP_Up"], - "KEY_KP9": ["KP_9", "KP_Page_Up"], - "KEY_KPDOT": ["KP_Decimal", "KP_Delete"], - } - if key_name in aliases: - return aliases[key_name] - if key_name in reverse_map: - return [reverse_map[key_name]] - if key_name.startswith("KEY_F") and key_name[5:].isdigit(): - return [key_name[4:]] - if key_name.startswith("KEY_") and len(key_name) == 5: - return [key_name[4:].lower()] - if key_name.startswith("KEY_") and key_name[4:].isdigit(): - return [key_name[4:]] - return [] - - def reset_input_state(self): - self.command_key_active = False - self.chord_modifiers.clear() - self.chord_keys.clear() - try: - self.env["runtime"]["InputManager"].reset_input_state() - except Exception: - self.clear_event_buffer() - - def write_event_buffer(self): - if self.display: - for event in self.env["input"]["event_buffer"]: - self.replay_key_event(event) - self.clear_event_buffer() - - def replay_key_event(self, event): - if not isinstance(event, dict): - return - if event.get("event_type") != X.KeyPress: - return - try: - self.display.allow_events( - X.ReplayKeyboard, - event.get("event_x_time", X.CurrentTime), - ) - self.display.flush() - except Exception as e: - self.write_debug( - "x11Driver replay key event failed: " + str(e), - debug.DebugLevel.ERROR, - ) - - def clear_event_buffer(self): - if not self._initialized: - return - del self.env["input"]["event_buffer"][:] - - def write_debug(self, message, level): - try: - self.env["runtime"]["DebugManager"].write_debug_out(message, level) - except Exception: - pass - - def update_input_devices(self, new_devices=None, init=False): - return - - def grab_all_devices(self): - return True - - def ungrab_all_devices(self): - if not self.display or not self.window: - return True - for keycode, modifier_mask in list(self.grabbed): - try: - self.window.ungrab_key(keycode, modifier_mask) - except Exception: - pass - self.grabbed.clear() - try: - self.display.flush() - except Exception: - pass - return True - - def set_help_capture(self, enabled): - if not self._initialized or not self.display or not self.window: - return not enabled - if enabled: - if self.help_keyboard_grabbed: - return True - try: - grab_status = self.window.grab_keyboard( - False, - X.GrabModeAsync, - X.GrabModeAsync, - X.CurrentTime, - ) - self.display.flush() - self.help_keyboard_grabbed = grab_status == X.GrabSuccess - if not self.help_keyboard_grabbed: - self.write_debug( - "x11Driver help keyboard grab failed with status " - + str(grab_status), - debug.DebugLevel.WARNING, - ) - return self.help_keyboard_grabbed - except Exception as error: - self.help_keyboard_grabbed = False - self.write_debug( - "x11Driver help keyboard grab failed: " + str(error), - debug.DebugLevel.ERROR, - ) - return False - if self.help_keyboard_grabbed: - for attempt in range(1, 4): - try: - self.display.ungrab_keyboard(X.CurrentTime) - self.display.sync() - break - except Exception as error: - self.write_debug( - "x11Driver help keyboard ungrab attempt " - + str(attempt) - + " failed: " - + str(error), - debug.DebugLevel.ERROR, - ) - else: - return False - self.help_keyboard_grabbed = False - return True - - def remove_all_devices(self): - self.ungrab_all_devices() - - def get_led_state(self, led=0): - if led == 0: - return bool( - self.num_lock_mask - and self.modifier_state & self.num_lock_mask - ) - if led == 1: - return bool(self.modifier_state & X.LockMask) - return False - - def set_led_state(self, led_dict): - return False diff --git a/src/fenrirscreenreader/remoteDriver/__init__.py b/src/fenrirscreenreader/remoteDriver/__init__.py deleted file mode 100755 index e69de29b..00000000 diff --git a/src/fenrirscreenreader/remoteDriver/dummyDriver.py b/src/fenrirscreenreader/remoteDriver/dummyDriver.py deleted file mode 100644 index 1b686fe6..00000000 --- a/src/fenrirscreenreader/remoteDriver/dummyDriver.py +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core import debug -from fenrirscreenreader.core.remoteDriver import RemoteDriver as remoteDriver - - -class driver(remoteDriver): - def __init__(self): - remoteDriver.__init__(self) diff --git a/src/fenrirscreenreader/remoteDriver/tcpDriver.py b/src/fenrirscreenreader/remoteDriver/tcpDriver.py deleted file mode 100644 index bbc3fd95..00000000 --- a/src/fenrirscreenreader/remoteDriver/tcpDriver.py +++ /dev/null @@ -1,86 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import os -import os.path -import select -import socket - -from fenrirscreenreader.core import debug -from fenrirscreenreader.core.eventData import FenrirEventType -from fenrirscreenreader.core.remoteDriver import RemoteDriver as remoteDriver - - -class driver(remoteDriver): - def __init__(self): - remoteDriver.__init__(self) - - def initialize(self, environment): - self.env = environment - # Use threading instead of multiprocessing to avoid pickle issues - # with self.env (which contains unpicklable file handles) - self.env["runtime"]["ProcessManager"].add_custom_event_thread( - self.watch_dog, multiprocess=False - ) - - def watch_dog(self, active, event_queue): - # echo "command say this is a test" | nc localhost 22447 - self.fenrirSock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.fenrirSock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - self.fenrirSock.settimeout(5.0) # Prevent hanging on slow clients - self.host = "127.0.0.1" - self.port = self.env["runtime"]["SettingsManager"].get_setting_as_int( - "remote", "port" - ) - self.fenrirSock.bind((self.host, self.port)) - self.fenrirSock.listen(1) - while active.value: - try: - r, _, _ = select.select([self.fenrirSock], [], [], 0.8) - except select.error: - break - if r == []: - continue - if self.fenrirSock in r: - client_sock, client_addr = self.fenrirSock.accept() - # Ensure client socket is always closed to prevent resource - # leaks - try: - try: - rawdata = client_sock.recv(8129) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "tcpDriver watch_dog: Error receiving data from " - "client: " - + str(e), - debug.DebugLevel.ERROR, - ) - rawdata = b"" # Set default empty data if recv fails - - try: - data = rawdata.decode("utf-8").rstrip().lstrip() - event_queue.put( - {"Type": FenrirEventType.remote_incomming, "data": data} - ) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "tcpDriver watch_dog: Error decoding/queuing data: " - + str(e), - debug.DebugLevel.ERROR, - ) - finally: - # Always close client socket, even if data processing fails - try: - client_sock.close() - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "tcpDriver watch_dog: Error closing client socket: " - + str(e), - debug.DebugLevel.ERROR, - ) - if self.fenrirSock: - self.fenrirSock.close() - self.fenrirSock = None diff --git a/src/fenrirscreenreader/remoteDriver/unixDriver.py b/src/fenrirscreenreader/remoteDriver/unixDriver.py deleted file mode 100644 index 1bb108f5..00000000 --- a/src/fenrirscreenreader/remoteDriver/unixDriver.py +++ /dev/null @@ -1,420 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import os -import os.path -import select -import socket -import struct -import time - -from fenrirscreenreader.core import debug -from fenrirscreenreader.core import remoteInstanceRegistry -from fenrirscreenreader.core.eventData import FenrirEventType -from fenrirscreenreader.core.remoteDriver import RemoteDriver as remoteDriver - - -MAIN_SOCKET_FILE = "/tmp/fenrirscreenreader-daemon.sock" - - -class driver(remoteDriver): - def __init__(self): - remoteDriver.__init__(self) - self.fenrirSocks = [] - self.bound_sockets = [] - - def initialize(self, environment): - self.env = environment - # Use threading instead of multiprocessing to avoid pickle issues - # with self.env (which contains unpicklable file handles) - self.env["runtime"]["ProcessManager"].add_custom_event_thread( - self.watch_dog, multiprocess=False - ) - - def _get_configured_socket_file(self): - socket_file = "" - try: - socket_file = self.env["runtime"]["SettingsManager"].get_setting( - "remote", "socket_file" - ) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "unixDriver watch_dog: Error getting socket file setting: " - + str(e), - debug.DebugLevel.ERROR, - ) - return socket_file - - def _get_socket_candidates(self): - configured_socket_file = self._get_configured_socket_file() - if configured_socket_file: - return [(configured_socket_file, False)] - - screen_driver = self.env["runtime"]["SettingsManager"].get_setting( - "screen", "driver" - ) - if screen_driver == "vcsaDriver": - return [(MAIN_SOCKET_FILE, False)] - - private_socket_file = ( - "/tmp/fenrirscreenreader-" + str(os.getpid()) + ".sock" - ) - return [(private_socket_file, False), (MAIN_SOCKET_FILE, True)] - - def _is_socket_active(self, socket_file): - test_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - try: - test_sock.settimeout(0.2) - test_sock.connect(socket_file) - return True - except OSError: - return False - finally: - test_sock.close() - - def _bind_socket(self, socket_file, optional): - if os.path.exists(socket_file): - if optional and self._is_socket_active(socket_file): - return None - os.unlink(socket_file) - - fenrir_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - try: - fenrir_sock.bind(socket_file) - os.chmod(socket_file, 0o666) # Allow all users to read/write - fenrir_sock.listen(1) - except OSError: - fenrir_sock.close() - if optional: - return None - raise - return fenrir_sock - - def _register_instance(self): - settings_manager = self.env["runtime"]["SettingsManager"] - active_socket_files = [ - socket_file - for fenrir_sock, socket_file in self.bound_sockets - if self._socket_file_matches_socket(fenrir_sock, socket_file) - ] - instance_data = { - "pid": os.getpid(), - "ppid": os.getppid(), - "socket_files": active_socket_files, - "main_socket": MAIN_SOCKET_FILE in active_socket_files, - "screen_driver": settings_manager.get_setting("screen", "driver"), - "keyboard_driver": settings_manager.get_setting("keyboard", "driver"), - "x11_window_id": settings_manager.get_setting( - "keyboard", "x11_window_id" - ), - "created_at": time.time(), - } - remoteInstanceRegistry.write_instance(instance_data) - - def _try_register_instance(self): - try: - self._register_instance() - return True - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "unixDriver could not register instance: " + str(e), - debug.DebugLevel.ERROR, - ) - return False - - def _socket_file_matches_socket(self, fenrir_sock, socket_file): - try: - if os.stat(socket_file).st_uid != os.getuid(): - return False - fd_stat = os.fstat(fenrir_sock.fileno()) - socket_inode = str(fd_stat.st_ino) - with open("/proc/net/unix", "r", encoding="utf-8") as proc_file: - for line in proc_file: - line_parts = line.split() - if ( - len(line_parts) >= 8 - and line_parts[6] == socket_inode - and line_parts[7] == socket_file - ): - return True - except OSError: - return False - return False - - def _socket_file_for_socket(self, fenrir_sock): - for bound_sock, socket_file in self.bound_sockets: - if bound_sock == fenrir_sock: - return socket_file - return "" - - def _get_peer_pid(self, client_sock): - so_peercred = getattr(socket, "SO_PEERCRED", 17) - try: - creds = client_sock.getsockopt( - socket.SOL_SOCKET, so_peercred, struct.calcsize("3i") - ) - pid, _uid, _gid = struct.unpack("3i", creds) - return pid - except OSError: - return 0 - - def _get_peer_uid(self, client_sock): - so_peercred = getattr(socket, "SO_PEERCRED", 17) - try: - creds = client_sock.getsockopt( - socket.SOL_SOCKET, so_peercred, struct.calcsize("3i") - ) - _pid, uid, _gid = struct.unpack("3i", creds) - return uid - except OSError: - return -1 - - def _get_socket_owner_uid(self, socket_file): - try: - return os.stat(socket_file).st_uid - except OSError: - return -1 - - def _get_parent_pid(self, pid): - try: - with open(f"/proc/{pid}/stat", "r", encoding="utf-8") as proc_file: - stat_text = proc_file.read() - except OSError: - return 0 - - end_command = stat_text.rfind(")") - if end_command == -1: - return 0 - stat_fields = stat_text[end_command + 2 :].split() - if len(stat_fields) < 2: - return 0 - try: - return int(stat_fields[1]) - except ValueError: - return 0 - - def _find_ancestor_private_socket(self, pid): - seen_pids = set() - while pid > 1 and pid not in seen_pids: - seen_pids.add(pid) - socket_file = f"/tmp/fenrirscreenreader-{pid}.sock" - if self._is_registered_private_socket(socket_file): - return socket_file - pid = self._get_parent_pid(pid) - return "" - - def _is_registered_private_socket(self, socket_file): - for instance in remoteInstanceRegistry.list_instances(): - if socket_file in instance.get("socket_files", []): - return True - return False - - def _get_registered_private_sockets(self): - socket_files = [] - for instance in remoteInstanceRegistry.list_instances(): - for socket_file in instance.get("socket_files", []): - if socket_file == MAIN_SOCKET_FILE: - continue - if socket_file in socket_files: - continue - socket_files.append(socket_file) - return socket_files - - def _find_available_private_socket( - self, preferred_socket="", owner_uid=None - ): - socket_files = self._get_registered_private_sockets() - if preferred_socket and preferred_socket in socket_files: - socket_files.remove(preferred_socket) - socket_files.insert(0, preferred_socket) - - for socket_file in socket_files: - if ( - owner_uid is not None - and self._get_socket_owner_uid(socket_file) != owner_uid - ): - continue - if self._is_own_socket_file(socket_file): - return socket_file - if self._is_socket_active(socket_file): - return socket_file - return "" - - def _is_own_socket_file(self, socket_file): - return any( - socket_file == bound_socket_file - for _bound_sock, bound_socket_file in self.bound_sockets - ) - - def _has_own_private_socket(self): - return any( - bound_socket_file != MAIN_SOCKET_FILE - for _bound_sock, bound_socket_file in self.bound_sockets - ) - - def _forward_remote_to_socket(self, data, socket_file): - forward_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - try: - forward_sock.settimeout(0.2) - forward_sock.connect(socket_file) - forward_sock.sendall((data + "\n").encode("utf-8")) - return True - except OSError as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "unixDriver watch_dog: Error forwarding remote data to " - + socket_file - + ": " - + str(e), - debug.DebugLevel.ERROR, - ) - return False - finally: - forward_sock.close() - - def _route_main_socket_command(self, data, client_sock, socket_file): - if socket_file != MAIN_SOCKET_FILE: - return False - - owner_uid = None - if not self._has_own_private_socket(): - peer_uid = self._get_peer_uid(client_sock) - main_socket_uid = self._get_socket_owner_uid(MAIN_SOCKET_FILE) - if peer_uid < 0 or peer_uid == main_socket_uid: - return False - owner_uid = peer_uid - - peer_pid = self._get_peer_pid(client_sock) - ancestor_socket = "" - if peer_pid > 1: - ancestor_socket = self._find_ancestor_private_socket(peer_pid) - if owner_uid is None: - target_socket = self._find_available_private_socket( - ancestor_socket - ) - else: - target_socket = self._find_available_private_socket( - ancestor_socket, owner_uid=owner_uid - ) - if not target_socket: - return False - - if self._is_own_socket_file(target_socket): - return False - - return self._forward_remote_to_socket(data, target_socket) - - def _cleanup(self): - for fenrir_sock in self.fenrirSocks: - try: - fenrir_sock.close() - except OSError: - pass - self.fenrirSocks = [] - - for _, socket_file in self.bound_sockets: - try: - if ( - os.path.exists(socket_file) - and not self._is_socket_active(socket_file) - ): - os.unlink(socket_file) - except OSError: - pass - self.bound_sockets = [] - remoteInstanceRegistry.remove_instance() - - def _handle_client(self, client_sock, event_queue, socket_file=""): - try: - rawdata = client_sock.recv(8129) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "unixDriver watch_dog: Error receiving data from client: " - + str(e), - debug.DebugLevel.ERROR, - ) - rawdata = b"" - - try: - data = rawdata.decode("utf-8").rstrip().lstrip() - upper_data = data.upper() - if upper_data in ("LS", "LIST", "COMMAND LS", "COMMAND LIST"): - response = self.env["runtime"][ - "RemoteManager" - ].handle_remote_incomming_with_response(data) - client_sock.sendall((response["message"] + "\n").encode("utf-8")) - return - - if self._route_main_socket_command(data, client_sock, socket_file): - return - - event_queue.put( - { - "Type": FenrirEventType.remote_incomming, - "data": data, - } - ) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "unixDriver watch_dog: Error decoding/queuing data: " - + str(e), - debug.DebugLevel.ERROR, - ) - - def watch_dog(self, active, event_queue): - # echo "command say this is a test" | socat - - # UNIX-CLIENT:/tmp/fenrirscreenreader-daemon.sock - try: - for socket_file, optional in self._get_socket_candidates(): - fenrir_sock = self._bind_socket(socket_file, optional) - if fenrir_sock is None: - continue - self.fenrirSocks.append(fenrir_sock) - self.bound_sockets.append((fenrir_sock, socket_file)) - - if not self.fenrirSocks: - return - - self._try_register_instance() - last_register = time.time() - while active.value: - if time.time() - last_register > 10.0: - self._try_register_instance() - last_register = time.time() - - # Check if the client is still connected and data is available. - try: - r, _, _ = select.select(self.fenrirSocks, [], [], 0.8) - except select.error: - break - if r == []: - continue - for fenrir_sock in r: - client_sock, _client_addr = fenrir_sock.accept() - socket_file = self._socket_file_for_socket(fenrir_sock) - try: - self._handle_client( - client_sock, event_queue, socket_file - ) - finally: - try: - client_sock.close() - except Exception as e: - self.env["runtime"][ - "DebugManager" - ].write_debug_out( - "unixDriver watch_dog: Error closing client " - "socket: " - + str(e), - debug.DebugLevel.ERROR, - ) - finally: - # ProcessManager restarts a failed watchdog. Always release the - # old listeners first so each restart cannot leak another socket. - self._cleanup() - - def shutdown(self): - self._cleanup() - remoteDriver.shutdown(self) diff --git a/src/fenrirscreenreader/screenDriver/__init__.py b/src/fenrirscreenreader/screenDriver/__init__.py deleted file mode 100755 index e69de29b..00000000 diff --git a/src/fenrirscreenreader/screenDriver/dummyDriver.py b/src/fenrirscreenreader/screenDriver/dummyDriver.py deleted file mode 100644 index 09c41cf6..00000000 --- a/src/fenrirscreenreader/screenDriver/dummyDriver.py +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core import debug -from fenrirscreenreader.core.screenDriver import ScreenDriver as screenDriver - - -class driver(screenDriver): - def __init__(self): - screenDriver.__init__(self) diff --git a/src/fenrirscreenreader/screenDriver/ptyDriver.py b/src/fenrirscreenreader/screenDriver/ptyDriver.py deleted file mode 100644 index a2285d39..00000000 --- a/src/fenrirscreenreader/screenDriver/ptyDriver.py +++ /dev/null @@ -1,840 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import fcntl -import getpass -import os -import pty -import shlex -from queue import Full -import signal -import struct -import sys -import termios -import threading -import time -import tty -from select import select - -import pyte - -from fenrirscreenreader.core import debug -from fenrirscreenreader.core.eventData import FenrirEventType -from fenrirscreenreader.core.screenDriver import ScreenDriver as screenDriver -from fenrirscreenreader.utils import screen_utils - -# PTY Driver Constants -class PTYConstants: - # Timeouts (in seconds) - DEFAULT_READ_TIMEOUT = 0.3 - INPUT_READ_TIMEOUT = 0.01 - OUTPUT_READ_TIMEOUT = 0.05 # Faster than default but allows for network lag - SELECT_TIMEOUT = 0.05 - PROCESS_TERMINATION_TIMEOUT = 3.0 - PROCESS_KILL_DELAY = 0.5 - - # Polling intervals (in seconds) - MIN_POLL_INTERVAL = 0.001 - - # Limits - MAX_TERMINAL_LINES = 10000 - DEFAULT_READ_BUFFER_SIZE = 65536 - INPUT_BUFFER_SIZE = 4096 - - # Error codes - IO_ERROR_ERRNO = 5 - - -class FenrirScreen(pyte.Screen): - @property - def display(self): - def render(line): - skip_stub = False - for x in range(self.columns): - if skip_stub: - skip_stub = False - continue - char = line[x].data - if not char: - yield " " - continue - skip_stub = pyte.screens.wcwidth(char[0]) == 2 - yield char - - return ["".join(render(self.buffer[y])) for y in range(self.lines)] - - def set_margins(self, *args, **kwargs): - kwargs.pop("private", None) - super(FenrirScreen, self).set_margins(*args, **kwargs) - - def select_graphic_rendition(self, *args, **kwargs): - kwargs.pop("private", None) - super(FenrirScreen, self).select_graphic_rendition(*args, **kwargs) - - -class Terminal: - def __init__(self, columns, lines, p_in, env=None): - self.text = "" - self.attributes = None - self.screen = FenrirScreen(columns, lines) - self.env = env # Environment for proper logging - - # Pre-create default attribute template to avoid repeated allocation - self._default_attribute = [ - "default", "default", False, False, False, False, False, False, - "default", "default" - ] - self.screen.write_process_input = lambda data: p_in.write( - data.encode() - ) - self.stream = pyte.ByteStream() - self.stream.attach(self.screen) - - def _log_error(self, message, level=None): - """Log error message using proper debug manager if available.""" - if self.env and "runtime" in self.env and "DebugManager" in self.env["runtime"]: - try: - log_level = level if level else debug.DebugLevel.ERROR - self.env["runtime"]["DebugManager"].write_debug_out( - f"PTY Terminal: {message}", - log_level - ) - return - except Exception: - pass # Fallback to print if debug manager fails - - # Fallback logging when debug manager unavailable - print(f"PTY Terminal: {message}") - - def feed(self, data): - self.stream.feed(data) - - def update_attributes(self, initialize=False): - buffer = self.screen.buffer - lines = None - if not initialize: - lines = self.screen.dirty - else: - lines = range(self.screen.lines) - try: - self.attributes = [ - [ - list(attribute[1:]) + [False, "default", "default"] - if len(attribute) > 1 else [False, "default", "default"] - for attribute in line.values() - ] - for line in buffer.values() - ] - except Exception as e: - self._log_error(f"Error initializing attributes: {e}") - # Fallback to empty attributes - self.attributes = [[] for _ in range(self.screen.lines)] - for y in lines: - # Validate y is within reasonable bounds (prevent memory exhaustion) - if y >= PTYConstants.MAX_TERMINAL_LINES: - self._log_error( - f"Line index {y} exceeds maximum {PTYConstants.MAX_TERMINAL_LINES}, " - f"skipping attribute update", - debug.DebugLevel.WARNING - ) - continue - - # Check if line y exists in buffer before accessing it - if y not in buffer: - # Only log this occasionally to prevent spam - if y % 10 == 0: # Log every 10th missing line - # Pre-format string to avoid repeated f-string operations - line_range = f"{y}-{y+9}" - self._log_error( - f"Lines {line_range} not found in buffer, skipping attribute updates", - debug.DebugLevel.WARNING - ) - continue - - # Ensure attributes array is large enough for line y - while len(self.attributes) <= y: - self.attributes.append([]) - - try: - self.attributes[y] = [ - list(attribute[1:]) + [False, "default", "default"] - for attribute in (buffer[y].values()) - ] - except Exception as e: - self._log_error(f"Error updating attributes for line {y}: {e}") - # Initialize with empty attributes if update fails - self.attributes[y] = [] - if len(self.attributes[y]) < self.screen.columns: - diff = self.screen.columns - len(self.attributes[y]) - # Use pre-created template for efficiency - self.attributes[y] += [self._default_attribute[:] for _ in range(diff)] - - def resize(self, lines, columns): - self.screen.resize(lines, columns) - self.set_cursor() - self.update_attributes(True) - - def set_cursor(self, x=-1, y=-1): - # Determine target cursor position - x_pos = x if x != -1 else self.screen.cursor.x - y_pos = y if y != -1 else self.screen.cursor.y - - # Validate and clamp cursor position to screen bounds - max_x = max(0, self.screen.columns - 1) - max_y = max(0, self.screen.lines - 1) - - self.screen.cursor.x = max(0, min(x_pos, max_x)) - self.screen.cursor.y = max(0, min(y_pos, max_y)) - - def get_screen_content(self): - cursor = self.screen.cursor - # Only regenerate text if screen is dirty or text doesn't exist - if not hasattr(self, 'text') or self.screen.dirty: - self.text = "\n".join(self.screen.display) - self.update_attributes(self.attributes is None) - self.screen.dirty.clear() - - # Return screen content without unnecessary copying - # Only copy attributes if they exist and need protection - screen_data = { - "cursor": (cursor.x, cursor.y), - "lines": self.screen.lines, - "columns": self.screen.columns, - "text": self.text, - "attributes": self.attributes[:] if self.attributes else [], # Shallow copy only if needed - "screen": "pty", - "screenUpdateTime": time.time(), - } - return screen_data - - -class driver(screenDriver): - def __init__(self): - screenDriver.__init__(self) - self.signalPipe = os.pipe() - self.p_out = None - self.terminal = None - self.p_pid = -1 - self.terminal_lock = threading.Lock() # Synchronize terminal operations - self.stdin_interrupt_lock = threading.Lock() - self.stdin_interrupt_running = False - self.stdin_interrupt_thread = None - signal.signal(signal.SIGWINCH, self.handle_sigwinch) - - # Runtime configuration storage - self.pty_config = {} - - def _load_pty_settings(self): - """Load PTY-specific settings from configuration with fallbacks to defaults.""" - try: - settings_manager = self.env["runtime"]["SettingsManager"] - - self.pty_config = { - 'input_timeout': self._get_optional_float_setting( - settings_manager, 'screen', 'ptyInputTimeout', - PTYConstants.INPUT_READ_TIMEOUT - ), - 'output_timeout': self._get_optional_float_setting( - settings_manager, 'screen', 'ptyOutputTimeout', - PTYConstants.OUTPUT_READ_TIMEOUT - ), - 'select_timeout': self._get_optional_float_setting( - settings_manager, 'screen', 'ptySelectTimeout', - PTYConstants.SELECT_TIMEOUT - ), - 'process_termination_timeout': self._get_optional_float_setting( - settings_manager, 'screen', 'ptyProcessTimeout', - PTYConstants.PROCESS_TERMINATION_TIMEOUT - ), - 'poll_interval': self._get_optional_float_setting( - settings_manager, 'screen', 'ptyPollInterval', - PTYConstants.MIN_POLL_INTERVAL - ) - } - - self.env["runtime"]["DebugManager"].write_debug_out( - f"PTY screenDriver: Loaded configuration: {self.pty_config}", - debug.DebugLevel.INFO - ) - - except Exception as e: - # Fallback to constants if settings fail - self.env["runtime"]["DebugManager"].write_debug_out( - f"PTY screenDriver: Failed to load settings, using defaults: {e}", - debug.DebugLevel.WARNING - ) - self.pty_config = { - 'input_timeout': PTYConstants.INPUT_READ_TIMEOUT, - 'output_timeout': PTYConstants.OUTPUT_READ_TIMEOUT, - 'select_timeout': PTYConstants.SELECT_TIMEOUT, - 'process_termination_timeout': PTYConstants.PROCESS_TERMINATION_TIMEOUT, - 'poll_interval': PTYConstants.MIN_POLL_INTERVAL - } - - def _get_optional_float_setting( - self, settings_manager, section, setting, default - ): - try: - value = settings_manager.get_setting(section, setting) - if value == "": - return default - return float(value) - except Exception: - return default - - def initialize(self, environment): - self.env = environment - self.command = self.env["runtime"]["SettingsManager"].get_setting( - "general", "shell" - ) - - # Load configurable timeouts from settings - self._load_pty_settings() - - self.env["runtime"]["ProcessManager"].add_custom_event_thread( - self.terminal_emulation - ) - - def get_curr_screen(self): - self.env["screen"]["oldTTY"] = "pty" - self.env["screen"]["newTTY"] = "pty" - - def inject_text_to_screen(self, msg_bytes, screen=None): - if not screen: - screen = self.p_out.fileno() - if isinstance(msg_bytes, str): - msg_bytes = bytes(msg_bytes, "UTF-8") - os.write(screen, msg_bytes) - - def interrupt_output_on_stdin_input(self, msg_bytes): - if not msg_bytes: - return - settings_manager = self.env["runtime"]["SettingsManager"] - if not settings_manager.get_setting_as_bool( - "keyboard", "interrupt_on_key_press" - ): - return - if settings_manager.get_setting( - "keyboard", "interrupt_on_key_press_filter" - ).strip(): - return - self.env["runtime"]["DebugManager"].write_debug_out( - "ptyDriver interrupt_output_on_stdin_input: " - + repr(msg_bytes), - debug.DebugLevel.INFO, - ) - self.start_stdin_interrupt_thread() - - def start_stdin_interrupt_thread(self): - with self.stdin_interrupt_lock: - if self.stdin_interrupt_running: - return - self.stdin_interrupt_running = True - self.stdin_interrupt_thread = threading.Thread( - target=self.run_stdin_interrupt, - daemon=True, - ) - self.stdin_interrupt_thread.start() - - def run_stdin_interrupt(self): - try: - self.env["runtime"]["OutputManager"].interrupt_output() - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "ptyDriver interrupt_output_on_stdin_input: " - + str(e), - debug.DebugLevel.ERROR, - ) - finally: - with self.stdin_interrupt_lock: - self.stdin_interrupt_running = False - - def handle_stdin_input(self, msg_bytes, event_queue): - if self.synthesize_backspace_shortcut(msg_bytes, event_queue): - return - if self.handle_vmenu_stdin_input(msg_bytes, event_queue): - return - if self.suppress_recent_review_stdin_tail(msg_bytes): - return - self.record_stdin_keypress(msg_bytes) - self.interrupt_output_on_stdin_input(msg_bytes) - self.inject_text_to_screen(msg_bytes) - - def suppress_recent_review_stdin_tail(self, msg_bytes): - if not self.is_keyboard_control_sequence(msg_bytes): - return False - try: - command_info = self.env["commandInfo"] - last_command = command_info.get("lastCommand", "") - last_section = command_info.get("lastCommandSection", "") - last_run_time = command_info.get("lastCommandRunTime", 0) - except Exception: - return False - if last_section != "commands" or not last_command.startswith("REVIEW_"): - return False - if time.time() - last_run_time > 0.8: - return False - self.env["runtime"]["DebugManager"].write_debug_out( - "ptyDriver suppressing recent review stdin tail: " - + repr(msg_bytes), - debug.DebugLevel.INFO, - ) - return True - - def is_keyboard_control_sequence(self, msg_bytes): - if not msg_bytes: - return False - if msg_bytes.startswith(b"\x1b"): - return True - if len(msg_bytes) == 1: - value = msg_bytes[0] - return value < 32 and msg_bytes not in [b"\t", b"\n", b"\r"] - return False - - def handle_vmenu_stdin_input(self, msg_bytes, event_queue): - if not self.is_vmenu_active(): - return False - key_name = self.vmenu_stdin_key_name(msg_bytes) - if key_name and not self.vmenu_key_already_handled(key_name): - self.queue_keypress(key_name, event_queue) - return True - - def is_vmenu_active(self): - try: - return self.env["runtime"]["VmenuManager"].get_active() - except Exception: - return False - - def vmenu_stdin_key_name(self, msg_bytes): - key_map = { - b"\x1b": "KEY_ESC", - b"\x1b[A": "KEY_UP", - b"\x1b[B": "KEY_DOWN", - b"\x1b[C": "KEY_RIGHT", - b"\x1b[D": "KEY_LEFT", - b"\x1b[5~": "KEY_PAGEUP", - b"\x1b[6~": "KEY_PAGEDOWN", - b"\r": "KEY_ENTER", - b"\n": "KEY_ENTER", - b" ": "KEY_SPACE", - } - if msg_bytes in key_map: - return key_map[msg_bytes] - if len(msg_bytes) != 1: - return None - char = chr(msg_bytes[0]) - if "a" <= char <= "z" or "A" <= char <= "Z": - return "KEY_" + char.upper() - return None - - def vmenu_key_already_handled(self, key_name): - try: - return key_name in self.env["input"]["curr_input"] - except Exception: - return False - - def queue_keypress(self, key_name, event_queue): - event_time = time.time() - for event_state in [1, 0]: - try: - event_queue.put( - { - "Type": FenrirEventType.keyboard_input, - "data": { - "event_name": key_name, - "event_value": 0, - "event_sec": int(event_time), - "event_usec": int((event_time % 1) * 1000000), - "event_state": event_state, - "event_type": 0, - }, - }, - block=False, - ) - except Full: - self.env["runtime"]["DebugManager"].write_debug_out( - "ptyDriver queue_keypress: Event queue full, dropping " - + key_name, - debug.DebugLevel.WARNING, - ) - - def record_stdin_keypress(self, msg_bytes): - key_name = self.stdin_key_name(msg_bytes) - try: - self.env["runtime"]["InputManager"].record_unmanaged_keypress( - key_name - ) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "ptyDriver record_stdin_keypress: " + str(e), - debug.DebugLevel.ERROR, - ) - - def stdin_key_name(self, msg_bytes): - key_map = { - b"\t": "KEY_TAB", - b"\x08": "KEY_BACKSPACE", - b"\x7f": "KEY_BACKSPACE", - b"\x1b[A": "KEY_UP", - b"\x1bOA": "KEY_UP", - b"\x1b[B": "KEY_DOWN", - b"\x1bOB": "KEY_DOWN", - b"\x1b[C": "KEY_RIGHT", - b"\x1bOC": "KEY_RIGHT", - b"\x1b[D": "KEY_LEFT", - b"\x1bOD": "KEY_LEFT", - b"\x1b[H": "KEY_HOME", - b"\x1bOH": "KEY_HOME", - b"\x1b[F": "KEY_END", - b"\x1bOF": "KEY_END", - b"\x1b[5~": "KEY_PAGEUP", - b"\x1b[6~": "KEY_PAGEDOWN", - b"\x1b[3~": "KEY_DELETE", - b"\r": "KEY_ENTER", - b"\n": "KEY_ENTER", - b" ": "KEY_SPACE", - } - if msg_bytes in key_map: - return key_map[msg_bytes] - if len(msg_bytes) == 1: - char = chr(msg_bytes[0]) - if char.isascii() and char.isalnum(): - return "KEY_" + char.upper() - return "KEY_TERMINAL_INPUT" - - def synthesize_backspace_shortcut(self, msg_bytes, event_queue): - if not self.is_backspace_shortcut_sequence(msg_bytes): - return False - if "KEY_FENRIR" not in self.env["input"]["curr_input"]: - return False - - event_time = time.time() - for event_state in [1, 0]: - try: - event_queue.put( - { - "Type": FenrirEventType.keyboard_input, - "data": { - "event_name": "KEY_BACKSPACE", - "event_value": 0, - "event_sec": int(event_time), - "event_usec": int((event_time % 1) * 1000000), - "event_state": event_state, - "event_type": 0, - }, - }, - block=False, - ) - except Full: - self.env["runtime"]["DebugManager"].write_debug_out( - "ptyDriver synthesize_backspace_shortcut: Event queue full, dropping backspace events", - debug.DebugLevel.WARNING, - ) - return True - - def is_backspace_shortcut_sequence(self, msg_bytes): - if msg_bytes in [b"\x7f", b"\x08"]: - return True - try: - sequence = msg_bytes.decode("ascii") - except UnicodeDecodeError: - return False - if not sequence.startswith("\x1b["): - return False - if sequence.endswith("~"): - parts = sequence[2:-1].split(";") - if parts[0] == "3": - return True - if parts[0] == "27" and parts[-1] in ["8", "127"]: - return True - elif sequence.endswith("u"): - parts = sequence[2:-1].split(";") - if parts[0] in ["8", "127"]: - return True - return False - - def get_session_information(self): - self.env["screen"]["autoIgnoreScreens"] = [] - self.env["general"]["prev_user"] = getpass.getuser() - self.env["general"]["curr_user"] = getpass.getuser() - - def read_all(self, fd, timeout=PTYConstants.DEFAULT_READ_TIMEOUT, interruptFd=None, len=PTYConstants.DEFAULT_READ_BUFFER_SIZE): - """Read all available data from file descriptor with efficient polling. - - Uses progressively longer wait times to balance responsiveness with CPU usage. - """ - msg_bytes = b"" - fd_list = [fd] - if interruptFd: - fd_list.append(interruptFd) - - starttime = time.time() - poll_timeout = self.pty_config.get('poll_interval', PTYConstants.MIN_POLL_INTERVAL) # Use configured interval - - while True: - # Use consistent short polling for responsiveness - r = screen_utils.has_more_what(fd_list, poll_timeout) - - # Nothing more to read - if fd not in r: - # Check overall timeout - if (time.time() - starttime) >= timeout: - break - continue - - try: - data = os.read(fd, len) - if data == b"": - raise EOFError - msg_bytes += data - except OSError as e: - self.env["runtime"]["DebugManager"].write_debug_out( - f"PTY screenDriver read_all: OS error reading from fd {fd}: {e}", - debug.DebugLevel.ERROR - ) - # For I/O errors, exit immediately to prevent endless retry loops - if e.errno == PTYConstants.IO_ERROR_ERRNO: # Input/output error - self.env["runtime"]["DebugManager"].write_debug_out( - "PTY screenDriver: Terminal connection lost, stopping read loop", - debug.DebugLevel.ERROR - ) - raise EOFError("Terminal connection lost") - break - - # Exit on interrupt available - if interruptFd and interruptFd in r: - break - - # Check overall timeout - if (time.time() - starttime) >= timeout: - break - - return msg_bytes - - def open_terminal(self, columns, lines, command): - p_pid, master_fd = pty.fork() - if p_pid == 0: # Child. - argv = shlex.split(command) - env = os.environ.copy() - # values are VT100,xterm-256color,linux - try: - if env["TERM"] == "": - env["TERM"] = "linux" - except Exception as e: - # Child process doesn't have access to debug manager - # Use fallback logging with more context - print( - f"ptyDriver open_terminal (child): TERM environment error: {e}" - ) - env["TERM"] = "linux" - os.execvpe(argv[0], argv, env) - # File-like object for I/O with the child process aka command. - p_out = os.fdopen(master_fd, "w+b", 0) - return Terminal(columns, lines, p_out, self.env), p_pid, p_out - - def resize_terminal(self, fd): - s = struct.pack("HHHH", 0, 0, 0, 0) - s = fcntl.ioctl(0, termios.TIOCGWINSZ, s) - fcntl.ioctl(fd, termios.TIOCSWINSZ, s) - lines, columns, _, _ = struct.unpack("hhhh", s) - return lines, columns - - def get_terminal_size(self, fd): - s = struct.pack("HHHH", 0, 0, 0, 0) - lines, columns, _, _ = struct.unpack( - "HHHH", fcntl.ioctl(fd, termios.TIOCGWINSZ, s) - ) - return lines, columns - - def handle_sigwinch(self, *args): - os.write(self.signalPipe[1], b"w") - - def terminal_emulation(self, active, event_queue): - try: - old_attr = termios.tcgetattr(sys.stdin) - tty.setraw(0) - lines, columns = self.get_terminal_size(0) - if self.command == "": - self.command = screen_utils.get_shell() - self.terminal, self.p_pid, self.p_out = self.open_terminal( - columns, lines, self.command - ) - lines, columns = self.resize_terminal(self.p_out) - self.terminal.resize(lines, columns) - fd_list = [sys.stdin, self.p_out, self.signalPipe[0]] - while active.value: - r, _, _ = select(fd_list, [], [], self.pty_config.get('select_timeout', PTYConstants.SELECT_TIMEOUT)) # Configurable timeout - # none - if r == []: - continue - # signals - if self.signalPipe[0] in r: - os.read(self.signalPipe[0], 1) - lines, columns = self.resize_terminal(self.p_out) - self.terminal.resize(lines, columns) - # input - if sys.stdin in r: - try: - msg_bytes = self.read_all(sys.stdin.fileno(), timeout=self.pty_config.get('input_timeout', PTYConstants.INPUT_READ_TIMEOUT), len=PTYConstants.INPUT_BUFFER_SIZE) - except (EOFError, OSError): - event_queue.put( - { - "Type": FenrirEventType.stop_main_loop, - "data": None, - } - ) - break - try: - self.handle_stdin_input(msg_bytes, event_queue) - except Exception as e: - self.env["runtime"][ - "DebugManager" - ].write_debug_out( - "ptyDriver getInputData: Error injecting text to screen: " - + str(e), - debug.DebugLevel.ERROR, - ) - event_queue.put( - { - "Type": FenrirEventType.stop_main_loop, - "data": None, - } - ) - break - # output - if self.p_out in r: - try: - msg_bytes = self.read_all( - self.p_out.fileno(), - timeout=self.pty_config.get('output_timeout', PTYConstants.OUTPUT_READ_TIMEOUT), - interruptFd=sys.stdin.fileno() - ) - except (EOFError, OSError): - event_queue.put( - { - "Type": FenrirEventType.stop_main_loop, - "data": None, - } - ) - break - # Synchronize terminal operations to prevent race conditions - with self.terminal_lock: - # Feed data to terminal and get consistent screen state - self.terminal.feed(msg_bytes) - screen_content = self.terminal.get_screen_content() - - # Send screen update event with consistent state - event_queue.put( - { - "Type": FenrirEventType.screen_update, - "data": screen_utils.create_screen_event_data( - screen_content - ), - } - ) - - # Inject to actual screen (outside lock to avoid blocking) - self.inject_text_to_screen( - msg_bytes, screen=sys.stdout.fileno() - ) - except Exception as e: # Process died? - self.env["runtime"]["DebugManager"].write_debug_out( - f"PTY screenDriver terminal_emulation: Exception occurred: {e}", - debug.DebugLevel.ERROR - ) - event_queue.put( - {"Type": FenrirEventType.stop_main_loop, "data": None} - ) - finally: - self._safe_cleanup_process() - self._safe_cleanup_resources(old_attr) - event_queue.put( - {"Type": FenrirEventType.stop_main_loop, "data": None} - ) - - def _safe_cleanup_process(self): - """Safely terminate the child process with timeout and fallback to SIGKILL.""" - if not hasattr(self, 'p_pid') or self.p_pid is None: - return - - try: - # Check if process is still alive - os.kill(self.p_pid, 0) # Signal 0 checks if process exists - except OSError: - # Process already dead - self.p_pid = None - return - - try: - # Try graceful termination first - self.env["runtime"]["DebugManager"].write_debug_out( - f"PTY screenDriver: Terminating process {self.p_pid} gracefully", - debug.DebugLevel.INFO - ) - os.kill(self.p_pid, signal.SIGTERM) - - # Wait for graceful termination - timeout = self.pty_config.get('process_termination_timeout', PTYConstants.PROCESS_TERMINATION_TIMEOUT) - start_time = time.time() - while time.time() - start_time < timeout: - try: - os.kill(self.p_pid, 0) # Check if still alive - time.sleep(0.1) - except OSError: - # Process terminated gracefully - self.p_pid = None - return - - # Process didn't terminate gracefully, use SIGKILL - self.env["runtime"]["DebugManager"].write_debug_out( - f"PTY screenDriver: Process {self.p_pid} didn't terminate gracefully, using SIGKILL", - debug.DebugLevel.WARNING - ) - os.kill(self.p_pid, signal.SIGKILL) - time.sleep(PTYConstants.PROCESS_KILL_DELAY) # Give it a moment - - except OSError as e: - self.env["runtime"]["DebugManager"].write_debug_out( - f"PTY screenDriver: Error terminating process {self.p_pid}: {e}", - debug.DebugLevel.ERROR - ) - finally: - self.p_pid = None - - def _safe_cleanup_resources(self, old_attr=None): - """Safely clean up file descriptors and terminal attributes.""" - # Close output pipe safely - if hasattr(self, 'p_out') and self.p_out is not None: - try: - self.p_out.close() - self.env["runtime"]["DebugManager"].write_debug_out( - "PTY screenDriver: Closed output pipe", - debug.DebugLevel.INFO - ) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - f"PTY screenDriver: Error closing output pipe: {e}", - debug.DebugLevel.ERROR - ) - finally: - self.p_out = None - - # Restore terminal attributes safely - if old_attr is not None: - try: - termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_attr) - self.env["runtime"]["DebugManager"].write_debug_out( - "PTY screenDriver: Restored terminal attributes", - debug.DebugLevel.INFO - ) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - f"PTY screenDriver: Error restoring terminal attributes: {e}", - debug.DebugLevel.ERROR - ) - - def get_curr_application(self): - pass diff --git a/src/fenrirscreenreader/screenDriver/vcsaDriver.py b/src/fenrirscreenreader/screenDriver/vcsaDriver.py deleted file mode 100644 index 7013e8d3..00000000 --- a/src/fenrirscreenreader/screenDriver/vcsaDriver.py +++ /dev/null @@ -1,1125 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. -# attrib: -# http://rampex.ihep.su/Linux/linux_howto/html/tutorials/mini/Colour-ls-6.html -# 0 = black, 1 = blue, 2 = green, 3 = cyan, 4 = red, 5 = purple, 6 = brown/yellow, 7 = white. -# https://github.com/jwilk/vcsapeek/blob/master/linuxvt.py -# blink = 5 if attr & 1 else 0 -# bold = 1 if attr & 16 else 0 - -import errno -import fcntl -import glob -import os -import select -import subprocess -import termios -import time -from array import array -from fcntl import ioctl -from struct import pack -from struct import unpack -from struct import unpack_from - -import dbus - -from fenrirscreenreader.core import debug -from fenrirscreenreader.core.eventData import FenrirEventType -from fenrirscreenreader.core.screenDriver import ScreenDriver as screenDriver -from fenrirscreenreader.utils import screen_utils - - -class driver(screenDriver): - """Linux VCSA (Virtual Console Screen Access) driver for Fenrir screen reader. - - This driver provides access to Linux virtual consoles (TTYs) through the VCSA - interface, allowing real-time monitoring of screen content and cursor position. - It supports both text content extraction and color/attribute detection. - - The driver monitors multiple virtual consoles simultaneously and can detect: - - Screen content changes (text updates) - - Cursor movement - - TTY switching - - Text attributes (colors, bold, etc.) - - Session information via D-Bus/logind - - Attributes: - ListSessions: D-Bus method for listing login sessions - sysBus: D-Bus system bus connection - charmap: Character mapping for text decoding - bgColorValues: Background color value mappings - fgColorValues: Foreground color value mappings - hichar: High character mask for Unicode support - """ - read_retry_attempts = 10 - read_retry_delay = 0.05 - - def __init__(self): - screenDriver.__init__(self) - self.ListSessions = None - self.sysBus = None - self.charmap = {} - self.bgColorValues = { - 0: "black", - 1: "blue", - 2: "green", - 3: "cyan", - 4: "red", - 5: "magenta", - 6: "brown/yellow", - 7: "white", - } - self.fgColorValues = { - 0: "black", - 1: "blue", - 2: "green", - 3: "cyan", - 4: "red", - 5: "magenta", - 6: "brown/yellow", - 7: "light gray", - 8: "dark gray", - 9: "light blue", - 10: "light green", - 11: "light cyan", - 12: "light red", - 13: "light magenta", - 14: "light yellow", - 15: "white", - } - self.hichar = None - try: - # set workaround for paste clipboard -> inject_text_to_screen - subprocess.run( - ["sysctl", "dev.tty.legacy_tiocsti=1"], - check=False, - capture_output=True, - timeout=5, - ) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "vcsaDriver shutdown: Error running fgconsole: " + str(e), - debug.DebugLevel.ERROR, - ) - - def initialize(self, environment): - """Initialize the VCSA driver with the given environment. - - Sets up default attributes, starts the screen monitoring watchdog process, - and prepares the driver for screen content monitoring. - - Args: - environment: The Fenrir environment dictionary containing runtime managers - and configuration settings. - """ - self.env = environment - self.env["runtime"]["AttributeManager"].append_default_attributes( - [ - self.fgColorValues[7], # fg - self.bgColorValues[0], # bg - False, # bold - False, # italics - False, # underscore - False, # strikethrough - False, # reverse - False, # blink - "default", # fontsize - "default", # fontfamily - ] - ) # end attribute ) - # Use threading instead of multiprocessing to avoid pickle issues - # with self.env (which contains unpicklable file handles) - self.env["runtime"]["ProcessManager"].add_custom_event_thread( - self.update_watchdog, multiprocess=False - ) - - def get_curr_screen(self): - """Get the currently active TTY number. - - Reads from /sys/devices/virtual/tty/tty0/active to determine which - virtual console is currently active and updates the environment. - - Updates: - env['screen']['oldTTY']: Previous TTY number - env['screen']['newTTY']: Current TTY number - """ - self.env["screen"]["oldTTY"] = self.env["screen"]["newTTY"] - try: - with open( - "/sys/devices/virtual/tty/tty0/active", "r" - ) as currScreenFile: - self.env["screen"]["newTTY"] = str(currScreenFile.read()[3:-1]) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - str(e), debug.DebugLevel.ERROR - ) - - def inject_text_to_screen(self, text, screen=None): - """Inject text into the specified screen as if typed by user. - - Uses the TIOCSTI ioctl to simulate keystrokes on the target TTY. - This is primarily used for clipboard paste functionality. - - Args: - text (str): Text to inject into the screen - screen (str, optional): Target screen device (e.g., '/dev/tty1'). - If None, uses current TTY. - - Note: - Requires appropriate permissions and may need legacy_tiocsti=1 - kernel parameter on newer systems. - """ - use_screen = "/dev/tty" + self.env["screen"]["newTTY"] - if screen is not None: - use_screen = screen - with open(use_screen, "w") as fd: - text_bytes = text.encode('utf-8') - for byte in text_bytes: - fcntl.ioctl(fd, termios.TIOCSTI, bytes([byte])) - - def get_session_information(self): - """Retrieve session information via D-Bus logind interface. - - Connects to systemd-logind to gather information about active sessions, - including session types and TTY assignments. This helps identify which - screens should be automatically ignored (e.g., X11 sessions). - - Updates: - env['screen']['autoIgnoreScreens']: List of screens to ignore - env['general']['curr_user']: Current user for active session - env['general']['prev_user']: Previous user - - Note: - Gracefully handles cases where logind is not available. - """ - self.env["screen"]["autoIgnoreScreens"] = [] - try: - if not self.sysBus: - self.sysBus = dbus.SystemBus() - obj = self.sysBus.get_object( - "org.freedesktop.login1", "/org/freedesktop/login1" - ) - inf = dbus.Interface(obj, "org.freedesktop.login1.Manager") - self.ListSessions = inf.get_dbus_method("ListSessions") - sessions = self.ListSessions() - - for session in sessions: - obj = self.sysBus.get_object( - "org.freedesktop.login1", session[4] - ) - inf = dbus.Interface(obj, "org.freedesktop.DBus.Properties") - session_type = inf.Get( - "org.freedesktop.login1.Session", "Type" - ) - screen = str(inf.Get("org.freedesktop.login1.Session", "VTNr")) - if screen == "": - screen = str( - inf.Get("org.freedesktop.login1.Session", "TTY") - ) - screen = screen[screen.upper().find("TTY") + 3 :] - if screen == "": - self.env["runtime"]["DebugManager"].write_debug_out( - "No TTY found for session:" + session[4], - debug.DebugLevel.ERROR, - ) - return - if session_type.upper() != "TTY": - self.env["screen"]["autoIgnoreScreens"] += [screen] - if screen == self.env["screen"]["newTTY"]: - if self.env["general"]["curr_user"] != session[2]: - self.env["general"]["prev_user"] = self.env["general"][ - "curr_user" - ] - self.env["general"]["curr_user"] = session[2] - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "get_session_information: Maybe no LoginD:" + str(e), - debug.DebugLevel.ERROR, - ) - # self.env['runtime']['DebugManager'].write_debug_out('get_session_information:' + str(self.env['screen']['autoIgnoreScreens']) + ' ' + str(self.env['general']) ,debug.DebugLevel.INFO) - - def read_file(self, file): - """Read content from a file handle with error recovery. - - Attempts to read the entire file content, falling back to - line-by-line reading if the initial read fails. This is used - for reading VCSA/VCSU device files. - - Args: - file: Open file handle to read from - - Returns: - bytes: File content as bytes - """ - d = b"" - for attempt in range(self.read_retry_attempts + 1): - file.seek(0) - try: - return file.read() - except OSError as e: - if ( - e.errno == errno.EINVAL - and attempt < self.read_retry_attempts - ): - time.sleep(self.read_retry_delay) - continue - self.env["runtime"]["DebugManager"].write_debug_out( - "vcsaDriver get_screen_text: Error reading file: " - + str(e), - debug.DebugLevel.ERROR, - ) - break - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "vcsaDriver get_screen_text: Error reading file: " - + str(e), - debug.DebugLevel.ERROR, - ) - break - - file.seek(0) - while True: - try: - chunk = file.readline(1) - if not chunk: - break - d += chunk - except Exception: - break - return d - - def update_watchdog(self, active, event_queue): - """Main watchdog loop for monitoring screen changes. - - This is the core monitoring function that runs in a separate process. - It uses epoll to efficiently monitor multiple VCSA devices and the - active TTY file for changes. When changes are detected, it generates - appropriate events for the main Fenrir process. - - The watchdog monitors: - - Screen content changes (text updates) - - TTY switches (screen changes) - - Cursor position changes - - Args: - active: Shared boolean value controlling the watchdog loop - event_queue: Queue for sending events to the main process - - Events Generated: - - FenrirEventType.screen_changed: When switching TTYs - - FenrirEventType.screen_update: When screen content changes - - Note: - This method runs in a multiprocess context and includes comprehensive - cleanup of file handles in the finally block. - """ - vcsa = {} - vcsu = {} - tty = None - watchdog = None - try: - use_vcsu = os.access("/dev/vcsu", os.R_OK) - vcsa_devices = glob.glob("/dev/vcsa*") - vcsu_devices = None - last_screen_content = b"" - - # Open TTY file with proper cleanup - tty = open("/sys/devices/virtual/tty/tty0/active", "r") - curr_screen = str(tty.read()[3:-1]) - old_screen = curr_screen - - # Open VCSA devices with proper cleanup tracking - for vcsaDev in vcsa_devices: - index = str(vcsaDev[9:]) - vcsa[index] = open(vcsaDev, "rb") - if index == curr_screen: - last_screen_content = self.read_file(vcsa[index]) - - # Open VCSU devices if available - if use_vcsu: - vcsu_devices = glob.glob("/dev/vcsu*") - for vcsuDev in vcsu_devices: - index = str(vcsuDev[9:]) - vcsu[index] = open(vcsuDev, "rb") - - self.update_char_map(curr_screen) - watchdog = select.epoll() - watchdog.register( - vcsa[curr_screen], select.POLLPRI | select.POLLERR - ) - watchdog.register(tty, select.POLLPRI | select.POLLERR) - while active.value: - changes = watchdog.poll(1) - for change in changes: - fileno = change[0] - event = change[1] - if fileno == tty.fileno(): - self.env["runtime"]["DebugManager"].write_debug_out( - "ScreenChange", debug.DebugLevel.INFO - ) - tty.seek(0) - curr_screen = str(tty.read()[3:-1]) - if curr_screen != old_screen: - try: - watchdog.unregister(vcsa[old_screen]) - except Exception as e: - self.env["runtime"][ - "DebugManager" - ].write_debug_out( - "vcsaDriver update_watchdog: Error unregistering watchdog: " - + str(e), - debug.DebugLevel.ERROR, - ) - try: - watchdog.register( - vcsa[curr_screen], - select.POLLPRI | select.POLLERR, - ) - except Exception as e: - self.env["runtime"][ - "DebugManager" - ].write_debug_out( - "vcsaDriver update_watchdog: Error registering watchdog: " - + str(e), - debug.DebugLevel.ERROR, - ) - self.update_char_map(curr_screen) - old_screen = curr_screen - try: - vcsa[curr_screen].seek(0) - last_screen_content = self.read_file( - vcsa[curr_screen] - ) - except Exception as e: - self.env["runtime"][ - "DebugManager" - ].write_debug_out( - "vcsaDriver update_watchdog: Error reading screen content: " - + str(e), - debug.DebugLevel.ERROR, - ) - vcsu_content = None - if use_vcsu: - vcsu[curr_screen].seek(0) - vcsu_content = self.read_file( - vcsu[curr_screen] - ) - event_queue.put( - { - "Type": FenrirEventType.screen_changed, - "data": self.create_screen_event_data( - curr_screen, - last_screen_content, - vcsu_content, - ), - } - ) - else: - self.env["runtime"]["DebugManager"].write_debug_out( - "screen_update", debug.DebugLevel.INFO - ) - vcsa[curr_screen].seek(0) - time.sleep(0.01) - dirty_content = self.read_file(vcsa[curr_screen]) - screen_content = dirty_content - vcsu_content = None - timeout = time.time() - # error case - if screen_content == b"": - continue - if last_screen_content == b"": - last_screen_content = screen_content - if ( - abs( - int(screen_content[2]) - - int(last_screen_content[2]) - ) - in [1, 2] - ) and ( - int(screen_content[3]) - == int(last_screen_content[3]) - ): - # Skip X Movement - pass - elif ( - abs( - int(screen_content[3]) - - int(last_screen_content[3]) - ) - in [1] - ) and ( - int(screen_content[2]) - == int(last_screen_content[2]) - ): - # Skip Y Movement - pass - else: - # anything else? wait for completion - while True: - screen_content = dirty_content - time.sleep(0.02) - # r,_,_ = select.select([vcsa[curr_screen]], [], [], 0.07) - # if not vcsa[curr_screen] in r: - # break - vcsa[curr_screen].seek(0) - dirty_content = self.read_file( - vcsa[curr_screen] - ) - if screen_content == dirty_content: - break - if time.time() - timeout >= 0.1: - screen_content = dirty_content - break - if use_vcsu: - vcsu[curr_screen].seek(0) - vcsu_content = self.read_file(vcsu[curr_screen]) - last_screen_content = screen_content - event_queue.put( - { - "Type": FenrirEventType.screen_update, - "data": self.create_screen_event_data( - curr_screen, screen_content, vcsu_content - ), - } - ) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "VCSA:update_watchdog:" + str(e), debug.DebugLevel.ERROR - ) - time.sleep(0.2) - finally: - # Clean up all file handles - try: - if watchdog: - watchdog.close() - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "vcsaDriver update_watchdog: Error closing watchdog: " - + str(e), - debug.DebugLevel.ERROR, - ) - try: - if tty: - tty.close() - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "vcsaDriver shutdown: Error closing TTY: " + str(e), - debug.DebugLevel.ERROR, - ) - for handle in vcsa.values(): - try: - handle.close() - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "vcsaDriver shutdown: Error closing VCSA handle: " - + str(e), - debug.DebugLevel.ERROR, - ) - for handle in vcsu.values(): - try: - handle.close() - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "vcsaDriver shutdown: Error closing VCSU handle: " - + str(e), - debug.DebugLevel.ERROR, - ) - - def create_screen_event_data(self, screen, vcsaContent, vcsu_content=None): - """Create standardized screen event data from VCSA content. - - Processes raw VCSA bytes into a structured event data dictionary - containing screen dimensions, cursor position, text content, and - color attributes. - - Args: - screen (str): TTY number (e.g., '1' for tty1) - vcsaContent (bytes): Raw VCSA device content - vcsu_content (bytes, optional): VCSU content for Unicode support - - Returns: - dict: Event data with keys: - - bytes: Raw VCSA content - - lines: Screen height - - columns: Screen width - - textCursor: Cursor position {x, y} - - screen: TTY number - - screenUpdateTime: Timestamp - - text: Decoded text content - - attributes: Color/formatting attributes - """ - event_data = { - "bytes": vcsaContent, - "lines": int(vcsaContent[0]), - "columns": int(vcsaContent[1]), - "textCursor": {"x": int(vcsaContent[2]), "y": int(vcsaContent[3])}, - "screen": screen, - "screenUpdateTime": time.time(), - "text": "", - "attributes": [], - } - try: - event_data["text"], event_data["attributes"] = ( - self.auto_decode_vcsa( - vcsaContent[4:], event_data["lines"], event_data["columns"] - ) - ) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "vcsaDriver create_screen_event_data: Error decoding VCSA content: " - + str(e), - debug.DebugLevel.ERROR, - ) - # VCSU seems to give b' ' instead of b'\x00\x00\x00' (tsp), - # deactivated until its fixed - if vcsu_content is not None: - try: - vcsu_content_as_text = vcsu_content.decode("UTF-32") - event_data["text"] = screen_utils.insert_newlines( - vcsu_content_as_text, event_data["columns"] - ) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "vcsaDriver create_screen_event_data: Error decoding VCSU content: " - + str(e), - debug.DebugLevel.ERROR, - ) - return event_data.copy() - - def update_char_map(self, screen): - """Update character mapping for the specified screen. - - Reads the Unicode font mapping from the TTY to properly decode - character data from VCSA. This handles special characters and - Unicode properly. - - Args: - screen (str): TTY number to update mapping for - - Updates: - self.charmap: Dictionary mapping byte values to Unicode characters - self.hichar: High character mask for extended characters - """ - self.charmap = {} - try: - with open("/dev/tty" + screen, "rb") as tty: - GIO_UNIMAP = 0x4B66 - VT_GETHIFONTMASK = 0x560D - himask = array("H", (0,)) - ioctl(tty, VT_GETHIFONTMASK, himask) - (self.hichar,) = unpack_from("@H", himask) - sz = 512 - line = "" - while True: - try: - unipairs = array("H", [0] * (2 * sz)) - unimapdesc = array( - "B", pack("@HP", sz, unipairs.buffer_info()[0]) - ) - ioctl(tty.fileno(), GIO_UNIMAP, unimapdesc) - break - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "VCSA:update_char_map:scaling up sz=" - + str(sz) - + " " - + str(e), - debug.DebugLevel.WARNING, - ) - sz *= 2 - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "VCSA:update_char_map:" + str(e), debug.DebugLevel.ERROR - ) - return - (ncodes,) = unpack_from("@H", unimapdesc) - utable = unpack_from("@%dH" % (2 * ncodes), unipairs) - for u, b in zip(utable[::2], utable[1::2]): - if self.charmap.get(b) is None: - self.charmap[b] = chr(u) - - def auto_decode_vcsa(self, allData, rows, cols): - """Decode raw VCSA data into text and attributes. - - Processes the character and attribute data from VCSA devices, - extracting both the text content and formatting information - (colors, bold, blink, etc.). - - Args: - allData (bytes): Raw character and attribute data from VCSA - rows (int): Number of screen rows - cols (int): Number of screen columns - - Returns: - tuple: (text_content, attributes) - - text_content (str): Decoded text with newlines - - attributes (list): List of attribute arrays for each character - - Note: - Each character in VCSA is stored as 2 bytes: character + attribute. - Attributes encode foreground/background colors, bold, blink, etc. - """ - all_text = "" - all_attrib = [] - i = 0 - for y in range(rows): - line_text = "" - line_attrib = [] - blink = 0 - bold = 0 - ink = 7 - paper = 0 - for x in range(cols): - data = allData[i : i + 2] - i += 2 - if data == b" \x07": - # attr = 7 - # ink = 7 - # paper = 0 - # ch = ' ' - char_attrib = [ - self.fgColorValues[7], # fg - self.bgColorValues[0], # bg - False, # bold - False, # italics - False, # underscore - False, # strikethrough - False, # reverse - False, # blink - "default", # fontsize - "default", - ] # fontfamily - line_attrib.append(char_attrib) - line_text += " " - continue - ch = None - try: - (sh,) = unpack("=H", data) - attr = (sh >> 8) & 0xFF - ch = sh & 0xFF - try: - if sh & self.hichar: - ch |= 0x100 - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "vcsaDriver auto_decode_vcsa: Error processing character: " - + str(e), - debug.DebugLevel.ERROR, - ) - ch = None - if self.hichar == 0x100: - attr >>= 1 - ink = attr & 0x0F - paper = (attr >> 4) & 0x0F - if attr & 1: - blink = 1 - # blink seems to be set always, ignore for now - blink = 0 - bold = 0 - if attr & 16: - bold = 1 - # if (ink != 7) or (paper != 0): - # print(ink,paper) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "vcsaDriver auto_decode_vcsa: Error processing attributes: " - + str(e), - debug.DebugLevel.ERROR, - ) - try: - line_text += self.charmap[ch] - except KeyError: - line_text += "?" - - char_attrib = [ - self.fgColorValues[ink], - self.bgColorValues[paper], - bold == 1, # bold - False, # italics - False, # underscore - False, # strikethrough - False, # reverse - blink == 1, # blink - "default", # fontsize - "default", - ] # fontfamily - line_attrib.append(char_attrib) - all_text += line_text - if y + 1 < rows: - all_text += "\n" - all_attrib.append(line_attrib) - return str(all_text), all_attrib - - def get_screen_process_for_tty(self, tty_num): - """Find the screen process associated with specific TTY""" - try: - result = subprocess.run([ - 'ps', '-eo', 'pid,ppid,comm,tty,stat', '--no-headers' - ], capture_output=True, text=True, timeout=2) - - for line in result.stdout.strip().split('\n'): - if not line.strip(): - continue - parts = line.split() - if len(parts) >= 4 and parts[2] == 'screen' and f'tty{tty_num}' in parts[3]: - return parts[0] # Return PID of screen process - except (subprocess.TimeoutExpired, subprocess.CalledProcessError): - pass - return None - - def get_screen_session_process(self, screen_tty_pid): - """Get the session manager process for a TTY screen process""" - try: - result = subprocess.run([ - 'ps', '-eo', 'pid,ppid,comm', '--no-headers' - ], capture_output=True, text=True, timeout=2) - - for line in result.stdout.strip().split('\n'): - if not line.strip(): - continue - parts = line.split() - if len(parts) >= 3 and parts[1] == screen_tty_pid and parts[2] == 'screen': - return parts[0] # Return session manager PID - except (subprocess.TimeoutExpired, subprocess.CalledProcessError): - pass - return None - - def parse_active_app_from_pstree(self, pstree_output): - """Parse pstree output to find currently active application""" - try: - # Look for processes that indicate active applications - # Example: screen(1786)---bash(1787)---irssi(2016) - import re - - # Find all application processes (non-bash, non-screen) - # Pattern excludes --- prefix from pstree connection lines - app_pattern = r'([a-zA-Z0-9_]+)\((\d+)\)' - matches = re.findall(app_pattern, pstree_output) - - skip_processes = {'screen', 'bash', 'sh', 'grep', 'ps', 'sudo', 'sleep', 'clipboard_sync'} - applications = [] - - for app_name, pid in matches: - if app_name.lower() not in skip_processes: - # Check if this process is in foreground state - try: - ps_result = subprocess.run([ - 'ps', '-p', pid, '-o', 'stat', '--no-headers' - ], capture_output=True, text=True, timeout=1) - - if ps_result.returncode == 0: - stat = ps_result.stdout.strip() - # Prioritize active processes - priority = 0 - if 'S' in stat or 'R' in stat: # Active processes - priority += 10 - if '+' in stat: # Foreground processes - priority += 20 - if 'l' in stat.lower(): # Locked processes - priority += 5 - - applications.append((app_name, pid, priority, stat)) - except: - # If we can't check status, still consider it with low priority - applications.append((app_name, pid, 1, 'unknown')) - - # Sort by priority and return the highest priority application - if applications: - applications.sort(key=lambda x: (x[2], int(x[1])), reverse=True) - best_app = applications[0][0].upper() - - self.env["runtime"]["DebugManager"].write_debug_out( - f"parse_active_app_from_pstree found {len(applications)} apps, selected: {best_app}", - debug.DebugLevel.INFO - ) - return best_app - - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - f"Error parsing pstree output: {str(e)}", debug.DebugLevel.ERROR - ) - return None - - def get_app_from_screen_session(self, tty_num): - """Get current application from active screen window on specific TTY""" - try: - # Find the screen session PID for this TTY - ps_result = subprocess.run([ - 'ps', '-eo', 'pid,ppid,comm,tty,args', '--no-headers' - ], capture_output=True, text=True, timeout=2) - - if ps_result.returncode == 0: - # Find the main screen process for our TTY - screen_pid = None - for line in ps_result.stdout.split('\n'): - if not line.strip(): - continue - parts = line.split(None, 4) - if len(parts) >= 4 and parts[2] == 'screen' and f'tty{tty_num}' in parts[3]: - screen_pid = parts[0] - break - - if not screen_pid: - return None - - # Get the session manager screen process (child of TTY screen) - session_pid = None - for line in ps_result.stdout.split('\n'): - if not line.strip(): - continue - parts = line.split(None, 4) - if len(parts) >= 3 and parts[1] == screen_pid and parts[2] == 'screen': - session_pid = parts[0] - break - - if not session_pid: - return None - - self.env["runtime"]["DebugManager"].write_debug_out( - f"Found screen session PID: {session_pid}", - debug.DebugLevel.INFO - ) - - # Get all bash processes under this screen session - bash_processes = [] - for line in ps_result.stdout.split('\n'): - if not line.strip(): - continue - parts = line.split(None, 4) - if len(parts) >= 3 and parts[1] == session_pid and parts[2] == 'bash': - bash_processes.append(parts[0]) - - # Check each bash for child applications, prioritizing active ones - best_app = None - best_priority = 0 - - for bash_pid in bash_processes: - child_result = subprocess.run([ - 'ps', '--ppid', bash_pid, '-o', 'comm,stat', '--no-headers' - ], capture_output=True, text=True, timeout=1) - - if child_result.returncode == 0: - for child_line in child_result.stdout.split('\n'): - if not child_line.strip(): - continue - child_parts = child_line.split() - if len(child_parts) >= 2: - child_comm, child_stat = child_parts[0], child_parts[1] - if child_comm.lower() not in ['grep', 'ps', 'bash', 'sh', 'sudo', 'sleep']: - # Calculate priority based on process state - priority = 0 - if 'R' in child_stat: # Running - priority += 30 - elif 'S' in child_stat: # Sleeping - priority += 20 - if '+' in child_stat: # Foreground - priority += 20 - - self.env["runtime"]["DebugManager"].write_debug_out( - f"Found child process: {child_comm} ({child_stat}) priority: {priority}", - debug.DebugLevel.INFO - ) - - if priority > best_priority: - best_app = child_comm.upper() - best_priority = priority - - if best_app: - self.env["runtime"]["DebugManager"].write_debug_out( - f"Selected best app: {best_app} (priority: {best_priority})", - debug.DebugLevel.INFO - ) - return best_app - - except (subprocess.TimeoutExpired, subprocess.CalledProcessError, FileNotFoundError) as e: - self.env["runtime"]["DebugManager"].write_debug_out( - f"Error getting app from screen session: {str(e)}", debug.DebugLevel.ERROR - ) - return None - - def parse_latest_active_app_from_pstree(self, pstree_output): - """Parse pstree output to find the most recently active application""" - try: - import re - - # Find all application processes with PIDs - app_pattern = r'([a-zA-Z0-9_]+)\((\d+)\)' - matches = re.findall(app_pattern, pstree_output) - - skip_processes = {'screen', 'bash', 'sh', 'grep', 'ps', 'sudo', 'sleep', 'clipboard_sync'} - applications = [] - - for app_name, pid in matches: - if app_name.lower() not in skip_processes: - # Check if this process is still active and get its start time - try: - ps_result = subprocess.run([ - 'ps', '-p', pid, '-o', 'stat,lstart', '--no-headers' - ], capture_output=True, text=True, timeout=1) - - if ps_result.returncode == 0: - stat_info = ps_result.stdout.strip().split(None, 1) - if len(stat_info) >= 2: - stat = stat_info[0] - start_time = stat_info[1] - - # Prioritize processes that are active or have recent activity - priority = 0 - if 'S' in stat or 'R' in stat: # Running or sleeping (active) - priority += 10 - if '+' in stat: # Foreground process - priority += 20 - - applications.append((app_name, pid, priority, start_time)) - except: - # If we can't get status, still consider it but with low priority - applications.append((app_name, pid, 1, 'unknown')) - - # Sort by priority (highest first), then by PID (most recent) - if applications: - applications.sort(key=lambda x: (x[2], int(x[1])), reverse=True) - best_app = applications[0][0].upper() - - self.env["runtime"]["DebugManager"].write_debug_out( - f"Found {len(applications)} applications, selected: {best_app}", - debug.DebugLevel.INFO - ) - return best_app - - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - f"Error parsing latest active app from pstree: {str(e)}", - debug.DebugLevel.ERROR - ) - return None - - def get_app_from_tmux_session(self, tty_num): - """Get current application from tmux session on specific TTY""" - try: - # Try tmux list-panes to find active application - result = subprocess.run([ - 'tmux', 'list-panes', '-F', '#{pane_active} #{pane_current_command} #{pane_tty}' - ], capture_output=True, text=True, timeout=2) - - if result.returncode == 0: - for line in result.stdout.strip().split('\n'): - if not line.strip(): - continue - parts = line.split() - if len(parts) >= 3 and parts[0] == '1': # Active pane - tty_part = parts[2] - if tty_num in tty_part: - app = parts[1].upper() - if app not in ['BASH', 'SH']: - self.env["runtime"]["DebugManager"].write_debug_out( - f"Found tmux application: {app}", - debug.DebugLevel.INFO - ) - return app - - except (subprocess.TimeoutExpired, subprocess.CalledProcessError, FileNotFoundError): - pass - return None - - def get_curr_application(self): - """Enhanced application detection supporting screen/tmux sessions. - - Multi-method approach: - 1. Try screen session detection via process tree analysis - 2. Try tmux session detection via tmux commands - 3. Fall back to standard ps-based detection - - Updates: - env['screen']['new_application']: Name of current application - - Features: - - Detects applications inside screen/tmux sessions - - Handles multiple screen sessions on different TTYs - - Provides detailed debug logging for troubleshooting - """ - curr_screen = self.env["screen"]["newTTY"] - detected_app = None - - self.env["runtime"]["DebugManager"].write_debug_out( - f"Starting application detection for TTY{curr_screen}", - debug.DebugLevel.INFO - ) - - # Method 1: Try screen session detection - try: - detected_app = self.get_app_from_screen_session(curr_screen) - if detected_app: - self.env["runtime"]["DebugManager"].write_debug_out( - f"Screen session detection found: {detected_app}", - debug.DebugLevel.INFO - ) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - f"Screen session detection failed: {str(e)}", debug.DebugLevel.ERROR - ) - - # Method 2: Try tmux session detection - if not detected_app: - try: - detected_app = self.get_app_from_tmux_session(curr_screen) - if detected_app: - self.env["runtime"]["DebugManager"].write_debug_out( - f"Tmux session detection found: {detected_app}", - debug.DebugLevel.INFO - ) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - f"Tmux session detection failed: {str(e)}", debug.DebugLevel.ERROR - ) - - # Method 3: Fall back to standard ps-based detection - if not detected_app: - try: - detected_app = self.get_app_via_standard_ps(curr_screen) - if detected_app: - self.env["runtime"]["DebugManager"].write_debug_out( - f"Standard ps detection found: {detected_app}", - debug.DebugLevel.INFO - ) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - f"Standard ps detection failed: {str(e)}", debug.DebugLevel.ERROR - ) - - # Update application if we found one and it's different - if detected_app and self.env["screen"]["new_application"] != detected_app: - self.env["screen"]["new_application"] = detected_app - self.env["runtime"]["DebugManager"].write_debug_out( - f"Application changed to: {detected_app}", - debug.DebugLevel.INFO - ) - - def get_app_via_standard_ps(self, curr_screen): - """Fallback ps-based application detection for non-screen/tmux environments""" - try: - # Simple TTY-specific detection as fallback - result = subprocess.run([ - 'ps', '-t', f'tty{curr_screen}', '-o', 'comm,stat', '--no-headers' - ], capture_output=True, text=True, timeout=2) - - if result.returncode == 0: - for line in result.stdout.split('\n'): - if not line.strip(): - continue - parts = line.split() - if len(parts) >= 2: - comm, stat = parts[0], parts[1] - if '+' in stat and comm.upper() not in ['BASH', 'SH', 'SCREEN', 'GREP', 'PS']: - self.env["runtime"]["DebugManager"].write_debug_out( - f"Fallback detection found: {comm}", - debug.DebugLevel.INFO - ) - return comm.upper() - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - f"Standard ps detection error: {str(e)}", debug.DebugLevel.ERROR - ) - return None diff --git a/src/fenrirscreenreader/soundDriver/__init__.py b/src/fenrirscreenreader/soundDriver/__init__.py deleted file mode 100755 index e69de29b..00000000 diff --git a/src/fenrirscreenreader/soundDriver/debugDriver.py b/src/fenrirscreenreader/soundDriver/debugDriver.py deleted file mode 100644 index bafe74a6..00000000 --- a/src/fenrirscreenreader/soundDriver/debugDriver.py +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core import debug -from fenrirscreenreader.core.soundDriver import sound_driver - - -class driver(sound_driver): - def __init__(self): - sound_driver.__init__(self) - - def initialize(self, environment): - self.env = environment - self._initialized = True - print("Sound Debug Driver: Initialized") - - def shutdown(self): - if not self._initialized: - return - self.cancel() - self._initialized = False - print("Sound Debug Driver: Shutdown") - - def play_frequence( - self, frequence, duration, adjust_volume=0.0, interrupt=True - ): - if not self._initialized: - return - if interrupt: - self.cancel() - print( - "Sound Debug Driver: play_frequence:" - + " freq:" - + str(frequence) - + " duration:" - + str(duration) - + " adjust_volume:" - + str(adjust_volume) - ) - print("Sound Debug Driver: -----------------------------------") - - def play_sound_file(self, file_path, interrupt=True): - if not self._initialized: - return - if interrupt: - self.cancel() - print("Sound Debug Driver: play_sound_file:" + str(file_path)) - print("Sound Debug Driver: -----------------------------------") - - def cancel(self): - if not self._initialized: - return - print("Sound Debug Driver: Cancel") - - def set_callback(self, callback): - if not self._initialized: - return - print("Sound Debug Driver: set_callback") - - def set_volume(self, volume): - if not self._initialized: - return - self.volume = volume - print("Sound Debug Driver: set_volume:" + str(self.volume)) diff --git a/src/fenrirscreenreader/soundDriver/dummyDriver.py b/src/fenrirscreenreader/soundDriver/dummyDriver.py deleted file mode 100644 index 68757f52..00000000 --- a/src/fenrirscreenreader/soundDriver/dummyDriver.py +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core import debug -from fenrirscreenreader.core.soundDriver import sound_driver - - -class driver(sound_driver): - def __init__(self): - sound_driver.__init__(self) diff --git a/src/fenrirscreenreader/soundDriver/genericDriver.py b/src/fenrirscreenreader/soundDriver/genericDriver.py deleted file mode 100644 index f2b84042..00000000 --- a/src/fenrirscreenreader/soundDriver/genericDriver.py +++ /dev/null @@ -1,149 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import shlex -import subprocess - -from fenrirscreenreader.core import debug -from fenrirscreenreader.core.soundDriver import sound_driver - - -class driver(sound_driver): - """Generic sound driver for Fenrir screen reader. - - This driver provides sound playback through external command-line tools - like sox, aplay, or other audio utilities. It supports both sound file - playback and frequency/tone generation. - - Features: - - Configurable external command execution - - Sound file playback (WAV, OGG, etc.) - - Frequency/tone generation - - Process management and cancellation - - Attributes: - proc: Currently running subprocess for sound playback - sound_file_command (str): Command template for playing sound files - frequency_command (str): Command template for generating frequencies - """ - def __init__(self): - sound_driver.__init__(self) - self.proc = None - self.sound_type = "" - self.sound_file_command = "" - self.frequency_command = "" - - def initialize(self, environment): - """Initialize the generic sound driver. - - Loads command templates from configuration for sound file playback - and frequency generation. - - Args: - environment: Fenrir environment dictionary with settings - """ - self.env = environment - self.sound_file_command = self.env["runtime"][ - "SettingsManager" - ].get_setting("sound", "generic_play_file_command") - self.frequency_command = self.env["runtime"][ - "SettingsManager" - ].get_setting("sound", "generic_frequency_command") - if self.sound_file_command == "": - self.sound_file_command = ( - "play -q -v fenrir_volume fenrir_sound_file" - ) - if self.frequency_command == "": - self.frequency_command = ( - "play -q -v fenrir_volume -n -c1 synth " - "fenrir_duration sine fenrir_frequency" - ) - self._initialized = True - - def play_frequence( - self, frequence, duration, adjust_volume=0.0, interrupt=True - ): - """Play a tone at the specified frequency. - - Args: - frequence (float): Frequency in Hz - duration (float): Duration in seconds - adjust_volume (float): Volume adjustment - interrupt (bool): Whether to interrupt current sound - """ - if not self._initialized: - return - if interrupt: - self.cancel() - popen_frequency_command = shlex.split(self.frequency_command) - for idx, word in enumerate(popen_frequency_command): - word = word.replace( - "fenrir_volume", str(self.volume * adjust_volume) - ) - word = word.replace("fenrir_duration", str(duration)) - word = word.replace("fenrir_frequency", str(frequence)) - popen_frequency_command[idx] = word - self.proc = subprocess.Popen( - popen_frequency_command, - stdin=None, - stdout=None, - stderr=None, - shell=False, - ) - self.sound_type = "frequence" - - def play_sound_file(self, file_path, interrupt=True): - """Play a sound file. - - Args: - file_path (str): Path to the sound file to play - interrupt (bool): Whether to interrupt current sound - """ - if not self._initialized: - return - if interrupt: - self.cancel() - # Validate file path to prevent injection - import os - - if not os.path.isfile(file_path) or ".." in file_path: - return - popen_sound_file_command = shlex.split(self.sound_file_command) - for idx, word in enumerate(popen_sound_file_command): - word = word.replace("fenrir_volume", str(self.volume)) - word = word.replace("fenrir_sound_file", str(file_path)) - popen_sound_file_command[idx] = word - self.proc = subprocess.Popen(popen_sound_file_command, shell=False) - self.sound_type = "file" - - def cancel(self): - """Cancel currently playing sound. - - Terminates the subprocess playing sound and cleans up resources. - """ - if not self._initialized: - return - if self.sound_type == "": - return - if self.sound_type == "file": - self.proc.kill() - try: - # Wait for process to finish to prevent zombies - self.proc.wait(timeout=1.0) - except subprocess.TimeoutExpired: - pass # Process already terminated - except Exception as e: - pass # Handle any other wait errors - if self.sound_type == "frequence": - self.proc.kill() - try: - # Wait for process to finish to prevent zombies - self.proc.wait(timeout=1.0) - except subprocess.TimeoutExpired: - pass # Process already terminated - except Exception as e: - pass # Handle any other wait errors - self.sound_type = "" diff --git a/src/fenrirscreenreader/soundDriver/gstreamerDriver.py b/src/fenrirscreenreader/soundDriver/gstreamerDriver.py deleted file mode 100644 index 7fb88c8a..00000000 --- a/src/fenrirscreenreader/soundDriver/gstreamerDriver.py +++ /dev/null @@ -1,146 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import threading -import time - -from fenrirscreenreader.core import debug -from fenrirscreenreader.core.soundDriver import sound_driver - -_gstreamerAvailable = False -try: - import gi - from gi.repository import GLib - - gi.require_version("Gst", "1.0") - from gi.repository import Gst - - _gstreamerAvailable, args = Gst.init_check(None) -except Exception as e: - _gstreamerAvailable = False - _availableError = str(e) - - -class driver(sound_driver): - def __init__(self): - sound_driver.__init__(self) - self._source = None - self._sink = None - - def initialize(self, environment): - self.env = environment - global _gstreamerAvailable - self._initialized = _gstreamerAvailable - if not self._initialized: - global _availableError - self.env["runtime"]["DebugManager"].write_debug_out( - "Gstreamer not available " + _availableError, - debug.DebugLevel.ERROR, - ) - return - self._player = Gst.ElementFactory.make("playbin", "player") - bus = self._player.get_bus() - bus.add_signal_watch() - bus.connect("message", self._on_player_message) - - self._pipeline = Gst.Pipeline(name="fenrir-pipeline") - bus = self._pipeline.get_bus() - bus.add_signal_watch() - bus.connect("message", self._on_pipeline_message) - - self._source = Gst.ElementFactory.make("audiotestsrc", "src") - self._volume = Gst.ElementFactory.make("volume", "volume") - self._sink = Gst.ElementFactory.make("autoaudiosink", "output") - self._pipeline.add(self._source) - self._pipeline.add(self._volume) - self._pipeline.add(self._sink) - self._source.link(self._volume) - self._volume.link(self._sink) - self.mainloop = GLib.MainLoop() - self.thread = threading.Thread(target=self.mainloop.run) - self.thread.start() - - def shutdown(self): - if not self._initialized: - return - self.cancel() - self.mainloop.quit() - # Wait for the GLib MainLoop thread to finish to prevent shutdown races - if hasattr(self, "thread") and self.thread.is_alive(): - # 2 second timeout to prevent hanging - self.thread.join(timeout=2.0) - - def _on_player_message(self, bus, message): - if not self._initialized: - return - if message.type == Gst.MessageType.EOS: - self._player.set_state(Gst.State.NULL) - elif message.type == Gst.MessageType.ERROR: - self._player.set_state(Gst.State.NULL) - error, info = message.parse_error() - self.env["runtime"]["DebugManager"].write_debug_out( - "GSTREAMER: _on_player_message" + str(error) + str(info), - debug.DebugLevel.WARNING, - ) - - def _on_pipeline_message(self, bus, message): - if not self._initialized: - return - if message.type == Gst.MessageType.EOS: - self._pipeline.set_state(Gst.State.NULL) - elif message.type == Gst.MessageType.ERROR: - self._pipeline.set_state(Gst.State.NULL) - error, info = message.parse_error() - self.env["runtime"]["DebugManager"].write_debug_out( - "GSTREAMER: _on_pipeline_message" + str(error) + str(info), - debug.DebugLevel.WARNING, - ) - - def _on_timeout(self, element): - if not self._initialized: - return - element.set_state(Gst.State.NULL) - - def play_sound_file(self, file_name, interrupt=True): - if not self._initialized: - return - if interrupt: - self.cancel() - self._player.set_property("volume", self.volume) - self._player.set_property("uri", "file://%s" % file_name) - self._player.set_state(Gst.State.PLAYING) - - def play_frequence( - self, frequence, duration, adjust_volume=0.0, interrupt=True - ): - if not self._initialized: - return - if interrupt: - self.cancel() - # Always reset pipeline to prevent volume accumulation - self._pipeline.set_state(Gst.State.NULL) - duration = duration * 1000 - # Use dedicated volume element for better control - # GStreamer volume property behaves very differently than sox for low frequencies - if adjust_volume > 0.8: # This indicates low frequency indentation beeps - # Extremely aggressive boost - GStreamer really struggles with low frequencies - effective_volume = self.volume * adjust_volume * 50.0 # Ridiculous multiplier to match sox - else: - effective_volume = self.volume * adjust_volume * 3.0 - self._volume.set_property("volume", effective_volume) - self._source.set_property("volume", 1.0) # Set source to full, control via volume element - self._source.set_property("freq", frequence) - self._pipeline.set_state(Gst.State.PLAYING) - GLib.timeout_add(duration, self._on_timeout, self._pipeline) - - def cancel(self, element=None): - if not self._initialized: - return - if element: - element.set_state(Gst.State.NULL) - return - self._player.set_state(Gst.State.NULL) - self._pipeline.set_state(Gst.State.NULL) diff --git a/src/fenrirscreenreader/speechDriver/Readme.md b/src/fenrirscreenreader/speechDriver/Readme.md deleted file mode 100644 index de077fd4..00000000 --- a/src/fenrirscreenreader/speechDriver/Readme.md +++ /dev/null @@ -1,3 +0,0 @@ -espeak = espeak driver -speechd = speech-dispatcher driver -generic = generic driver via /bin/say diff --git a/src/fenrirscreenreader/speechDriver/__init__.py b/src/fenrirscreenreader/speechDriver/__init__.py deleted file mode 100755 index e69de29b..00000000 diff --git a/src/fenrirscreenreader/speechDriver/debugDriver.py b/src/fenrirscreenreader/speechDriver/debugDriver.py deleted file mode 100644 index baf92e48..00000000 --- a/src/fenrirscreenreader/speechDriver/debugDriver.py +++ /dev/null @@ -1,135 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. -# generic driver - -from fenrirscreenreader.core import debug -from fenrirscreenreader.core.speechDriver import speech_driver - - -class driver(speech_driver): - """Debug speech driver for Fenrir development and testing. - - This driver provides speech output to console/debug output instead of - actual audio, making it useful for development, testing, and debugging - scenarios where audio output is not needed or available. - - All speech operations are logged to console with descriptive messages, - allowing developers to trace speech behavior without audio hardware. - """ - def __init__(self): - speech_driver.__init__(self) - - def initialize(self, environment): - """Initialize the debug speech driver. - - Args: - environment: Fenrir environment dictionary - """ - self._is_initialized = True - self.env = environment - print("Speech Debug Driver: Initialized") - - def shutdown(self): - """Shutdown the debug speech driver.""" - if self._is_initialized: - self.cancel() - self._is_initialized = False - print("Speech Debug Driver: Shutdown") - - def speak(self, text, queueable=True, ignore_punctuation=False): - """Output speech text to console for debugging. - - Args: - text (str): Text to speak - queueable (bool): Whether speech can be queued - ignore_punctuation (bool): Whether to ignore punctuation - """ - if not self._is_initialized: - return - if not queueable: - self.cancel() - print("Speech Debug Driver: Speak:" + text) - print("Speech Debug Driver: -----------------------------------") - - def cancel(self): - """Log speech cancellation to console.""" - if not self._is_initialized: - return - print("Speech Debug Driver: Cancel") - - def set_callback(self, callback): - """Log callback setting to console. - - Args: - callback: Callback function (logged but not used) - """ - print("Speech Debug Driver: set_callback") - - def clear_buffer(self): - """Log buffer clearing to console.""" - if not self._is_initialized: - return - print("Speech Debug Driver: clear_buffer") - - def set_voice(self, voice): - """Log voice setting to console. - - Args: - voice: Voice setting (logged but not used) - """ - if not self._is_initialized: - return - print("Speech Debug Driver: set_voice:" + str(voice)) - - def set_pitch(self, pitch): - """Log pitch setting to console. - - Args: - pitch: Pitch setting (logged but not used) - """ - if not self._is_initialized: - return - print("Speech Debug Driver: set_pitch:" + str(pitch)) - - def set_rate(self, rate): - """Log rate setting to console. - - Args: - rate: Rate setting (logged but not used) - """ - if not self._is_initialized: - return - print("Speech Debug Driver: set_rate:" + str(rate)) - - def set_module(self, module): - """Log module setting to console. - - Args: - module: Module setting (logged but not used) - """ - if not self._is_initialized: - return - print("Speech Debug Driver: set_module:" + str(module)) - - def set_language(self, language): - """Log language setting to console. - - Args: - language: Language setting (logged but not used) - """ - if not self._is_initialized: - return - print("Speech Debug Driver: set_language:" + str(language)) - - def set_volume(self, volume): - """Log volume setting to console. - - Args: - volume: Volume setting (logged but not used) - """ - if not self._is_initialized: - return - print("Speech Debug Driver: set_volume:" + str(volume)) diff --git a/src/fenrirscreenreader/speechDriver/dectalkDriver.py b/src/fenrirscreenreader/speechDriver/dectalkDriver.py deleted file mode 100644 index c085d9be..00000000 --- a/src/fenrirscreenreader/speechDriver/dectalkDriver.py +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.speechDriver.hardwareSerialDriver import ( - hardware_serial_driver, -) - - -class driver(hardware_serial_driver): - cancel_command = b"\x03" - use_xon_xoff = True - - def _speak_bytes(self, text): - text = self._neutralize_inline_commands(self._clean_text(text)) - return text.encode("ascii", errors="replace") + b"\x0b" - - def _neutralize_inline_commands(self, text): - return text.replace("[:", "[ :") - - def _rate_command(self, rate): - value = self._scale_around_default(rate, 75, 180, 650) - return self._setting_command("ra", value) - - def _pitch_command(self, pitch): - value = self._scale_around_default(pitch, 50, 122, 350) - return self._setting_command("dv ap", value) - - def _volume_command(self, volume): - return self._setting_command("dv g5", self._scale(volume, 60, 86)) - - def _scale_around_default(self, value, minimum, default, maximum): - value = max(0.0, min(1.0, value)) - if value <= 0.5: - return self._scale(value * 2, minimum, default) - return self._scale((value - 0.5) * 2, default, maximum) - - def _setting_command(self, command, value): - return f"[:{command} {value}]".encode("ascii") diff --git a/src/fenrirscreenreader/speechDriver/dectalkUsbDriver.py b/src/fenrirscreenreader/speechDriver/dectalkUsbDriver.py deleted file mode 100644 index c0a57d6c..00000000 --- a/src/fenrirscreenreader/speechDriver/dectalkUsbDriver.py +++ /dev/null @@ -1,288 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import threading - -from fenrirscreenreader.core import debug -from fenrirscreenreader.speechDriver import dectalkDriver - - -class driver(dectalkDriver.driver): - vendor_id = 0x0DD0 - product_id = 0x2001 - interface = 0 - speech_endpoint = 0x02 - immediate_endpoint = 0x04 - packet_size = 64 - write_timeout = 1000 - buffer_timeout_command = b"[:timeout 1]" - - def __init__(self): - super().__init__() - self._speech_cancel_generation = 0 - self._speech_cancel_lock = threading.Lock() - - def initialize(self, environment): - self.env = environment - self._is_initialized = False - self._reset_setting_cache() - self.usb_device = None - self.usb_core = None - self.usb_util = None - self.immediate_lock = threading.Lock() - self._debug( - "DECtalk USB initialize: requested_device=0dd0:2001", - debug.DebugLevel.INFO, - on_any_level=True, - ) - - try: - self.usb_core, self.usb_util = self._load_usb_modules() - except ImportError as error: - self._debug( - f"DECtalk USB requires PyUSB: {error}", - debug.DebugLevel.ERROR, - on_any_level=True, - ) - raise RuntimeError("DECtalk USB requires PyUSB") from error - - try: - usb_device = self.usb_core.find( - idVendor=self.vendor_id, - idProduct=self.product_id, - ) - except ( - self.usb_core.NoBackendError, - self.usb_core.USBError, - OSError, - ) as error: - self._debug( - f"DECtalk USB discovery failed: {error}", - debug.DebugLevel.ERROR, - on_any_level=True, - ) - raise RuntimeError( - f"DECtalk USB discovery failed: {error}" - ) from error - if usb_device is None: - self._debug( - "DECtalk USB device 0dd0:2001 was not found", - debug.DebugLevel.ERROR, - on_any_level=True, - ) - raise RuntimeError("DECtalk USB device 0dd0:2001 was not found") - - try: - try: - usb_device.get_active_configuration() - except self.usb_core.USBError: - usb_device.set_configuration() - self.usb_util.claim_interface(usb_device, self.interface) - except (self.usb_core.USBError, OSError) as error: - self._dispose_usb_device(usb_device) - self._debug( - f"DECtalk USB open failed: {error}", - debug.DebugLevel.ERROR, - on_any_level=True, - ) - raise RuntimeError(f"DECtalk USB open failed: {error}") from error - - self.usb_device = usb_device - self._write_endpoint( - self.speech_endpoint, - self.buffer_timeout_command, - "buffer timeout", - ) - self._is_initialized = True - self._stop_worker = False - self.worker_thread = threading.Thread( - target=self._worker, - daemon=True, - ) - self.worker_thread.start() - self._debug( - "DECtalk USB device opened: 0dd0:2001", - debug.DebugLevel.INFO, - on_any_level=True, - ) - - def shutdown(self): - if not self._is_initialized: - return - self._stop_worker = True - self.clear_buffer() - self.text_queue.put(None) - if self.worker_thread: - self.worker_thread.join(timeout=0.5) - - try: - with self.lock, self.immediate_lock: - usb_device = self.usb_device - self.usb_device = None - if usb_device is None: - return - try: - self.usb_util.release_interface( - usb_device, - self.interface, - ) - except (self.usb_core.USBError, OSError) as error: - self._debug( - f"DECtalk USB interface release failed: {error}", - debug.DebugLevel.WARNING, - ) - self._dispose_usb_device(usb_device) - finally: - self._is_initialized = False - - def cancel(self): - if not self._is_initialized: - return - self._advance_speech_cancel_generation() - self.clear_buffer() - self._write_endpoint( - self.immediate_endpoint, - b"\x06", - "cancel", - write_lock=self.immediate_lock, - ) - - def speak(self, text, queueable=True, ignore_punctuation=False): - if not self._is_initialized: - return - if not queueable: - self.cancel() - if not isinstance(text, str) or text == "": - return - self._debug( - "Hardware speech queued text: " - f"{len(text)} chars, queue_size={self.text_queue.qsize()}", - debug.DebugLevel.INFO, - on_any_level=True, - ) - self.text_queue.put( - (self._get_speech_cancel_generation(), text) - ) - - def _worker(self): - while not self._stop_worker: - queue_item = self.text_queue.get() - if queue_item is None: - return - cancel_generation, text = queue_item - if cancel_generation != self._get_speech_cancel_generation(): - continue - try: - data = self._speak_bytes(text) - self._debug( - "Hardware speech worker prepared speech bytes: " - f"{len(data)} bytes", - debug.DebugLevel.INFO, - on_any_level=True, - ) - self._write_bytes(data, "speech", cancel_generation) - except Exception as error: - self._debug( - f"Hardware speech worker failed: {error}", - debug.DebugLevel.ERROR, - on_any_level=True, - ) - - def _write_bytes(self, data, description="data", cancel_generation=None): - if description == "speech" and cancel_generation is None: - cancel_generation = self._get_speech_cancel_generation() - return self._write_endpoint( - self.speech_endpoint, - data, - description, - cancel_generation=cancel_generation, - ) - - def _write_endpoint( - self, - endpoint, - data, - description, - write_lock=None, - cancel_generation=None, - ): - if not data: - return False - if write_lock is None: - write_lock = self.lock - with write_lock: - if self.usb_device is None: - return False - try: - total_written = 0 - while total_written < len(data): - if self._speech_cancel_requested(cancel_generation): - self._debug( - "DECtalk USB stopped speech write after " - f"{total_written} bytes because speech was " - "cancelled", - debug.DebugLevel.INFO, - on_any_level=True, - ) - return False - chunk = data[ - total_written : total_written + self.packet_size - ] - bytes_written = self.usb_device.write( - endpoint, - chunk, - timeout=self.write_timeout, - ) - if bytes_written != len(chunk): - raise OSError( - "USB write returned " - f"{bytes_written} of {len(chunk)} bytes" - ) - total_written += bytes_written - preview = self._format_bytes_preview(data) - self._debug( - "DECtalk USB wrote " - f"{total_written} {description} bytes " - f"to endpoint 0x{endpoint:02x}: {preview}", - debug.DebugLevel.INFO, - on_any_level=True, - ) - return True - except (self.usb_core.USBError, OSError) as error: - self._debug( - f"DECtalk USB write failed: {error}", - debug.DebugLevel.ERROR, - on_any_level=True, - ) - return False - - def _advance_speech_cancel_generation(self): - with self._speech_cancel_lock: - self._speech_cancel_generation += 1 - - def _get_speech_cancel_generation(self): - with self._speech_cancel_lock: - return self._speech_cancel_generation - - def _speech_cancel_requested(self, cancel_generation): - if cancel_generation is None: - return False - return cancel_generation != self._get_speech_cancel_generation() - - def _dispose_usb_device(self, usb_device): - try: - self.usb_util.dispose_resources(usb_device) - except Exception as error: - self._debug( - f"DECtalk USB resource disposal failed: {error}", - debug.DebugLevel.WARNING, - ) - - def _load_usb_modules(self): - import usb.core - import usb.util - - return usb.core, usb.util diff --git a/src/fenrirscreenreader/speechDriver/doubletalkDriver.py b/src/fenrirscreenreader/speechDriver/doubletalkDriver.py deleted file mode 100644 index ca4fe804..00000000 --- a/src/fenrirscreenreader/speechDriver/doubletalkDriver.py +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.speechDriver.litetalkDriver import driver diff --git a/src/fenrirscreenreader/speechDriver/dummyDriver.py b/src/fenrirscreenreader/speechDriver/dummyDriver.py deleted file mode 100644 index 72bf61d9..00000000 --- a/src/fenrirscreenreader/speechDriver/dummyDriver.py +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. -# generic driver - -from fenrirscreenreader.core import debug -from fenrirscreenreader.core.speechDriver import speech_driver - - -class driver(speech_driver): - def __init__(self): - speech_driver.__init__(self) diff --git a/src/fenrirscreenreader/speechDriver/genericDriver.py b/src/fenrirscreenreader/speechDriver/genericDriver.py deleted file mode 100644 index 8fbe4028..00000000 --- a/src/fenrirscreenreader/speechDriver/genericDriver.py +++ /dev/null @@ -1,256 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. -# generic driver - -import shlex -import subprocess -from queue import Empty -from queue import Queue -from subprocess import Popen -from threading import Lock -from threading import Thread - -from fenrirscreenreader.core import debug -from fenrirscreenreader.core.speechDriver import speech_driver - - -class SpeakQueue(Queue): - def clear(self): - try: - while True: - self.get_nowait() - except Empty: - pass - - -class driver(speech_driver): - def __init__(self): - speech_driver.__init__(self) - self.proc = None - self.speech_thread = Thread(target=self.worker) - self.lock = Lock() - self.text_queue = SpeakQueue() - - def initialize(self, environment): - self.env = environment - self.min_volume = self.env["runtime"][ - "SettingsManager" - ].get_setting_as_int("speech", "fenrir_min_volume") - self.max_volume = self.env["runtime"][ - "SettingsManager" - ].get_setting_as_int("speech", "fenrir_max_volume") - self.min_pitch = self.env["runtime"][ - "SettingsManager" - ].get_setting_as_int("speech", "fenrir_min_pitch") - self.max_pitch = self.env["runtime"][ - "SettingsManager" - ].get_setting_as_int("speech", "fenrir_max_pitch") - self.min_rate = self.env["runtime"][ - "SettingsManager" - ].get_setting_as_int("speech", "fenrir_min_rate") - self.max_rate = self.env["runtime"][ - "SettingsManager" - ].get_setting_as_int("speech", "fenrir_max_rate") - - self.speech_command = self.env["runtime"][ - "SettingsManager" - ].get_setting("speech", "generic_speech_command") - if self.speech_command == "": - self.speech_command = ( - "espeak -a fenrir_volume -s fenrir_rate -p fenrir_pitch " - '-v fenrir_voice -- "fenrir_text"' - ) - if False: # for debugging overwrite here - # self.speech_command = 'spd-say --wait -r 100 -i 100 "fenrir_text"' - self.speech_command = 'flite -t "fenrir_text"' - - self._is_initialized = True - if self._is_initialized: - self.speech_thread.start() - - def shutdown(self): - if not self._is_initialized: - return - self.cancel() - self.text_queue.put(-1) - - def speak(self, text, queueable=True, ignore_punctuation=False): - if not self._is_initialized: - return - if not queueable: - self.cancel() - utterance = { - "text": text, - "volume": self.volume, - "rate": self.rate, - "pitch": self.pitch, - "module": self.module, - "language": self.language, - "voice": self.voice, - } - self.text_queue.put(utterance.copy()) - - def cancel(self): - if not self._is_initialized: - return - self.clear_buffer() - self.lock.acquire(True) - try: - if self.proc: - try: - self.proc.terminate() - # Wait for process to finish to prevent zombies - try: - self.proc.wait(timeout=1.0) - except subprocess.TimeoutExpired: - # If terminate didn't work, force kill - self.proc.kill() - self.proc.wait(timeout=1.0) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "speech_driver:Cancel:self.proc.terminate():" + str(e), - debug.DebugLevel.WARNING, - ) - try: - self.proc.kill() - # Wait after kill to prevent zombies - self.proc.wait(timeout=1.0) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "speech_driver:Cancel:self.proc.kill():" + str(e), - debug.DebugLevel.WARNING, - ) - self.proc = None - finally: - # Ensure lock is always released, even if process termination fails - self.lock.release() - - - def clear_buffer(self): - if not self._is_initialized: - return - self.text_queue.clear() - - def set_voice(self, voice): - if not self._is_initialized: - return - self.voice = str(voice) - - def set_pitch(self, pitch): - if not self._is_initialized: - return - self.pitch = str( - self.min_pitch + pitch * (self.max_pitch - self.min_pitch) - ) - - def set_rate(self, rate): - if not self._is_initialized: - return - self.rate = str(self.min_rate + rate * (self.max_rate - self.min_rate)) - - def set_module(self, module): - if not self._is_initialized: - return - self.module = str(module) - - def set_language(self, language): - if not self._is_initialized: - return - self.language = str(language) - - def set_volume(self, volume): - if not self._is_initialized: - return - self.volume = str( - self.min_volume + volume * (self.max_volume - self.min_volume) - ) - - def _build_speech_command(self, utterance): - replacements = { - "fenrir_volume": str(utterance["volume"]), - "fenrir_module": str(utterance["module"]), - "fenrir_language": str(utterance["language"]), - "fenrir_voice": str(utterance["voice"]), - "fenrir_pitch": str(utterance["pitch"]), - "fenrir_rate": str(utterance["rate"]), - "fenrir_text": shlex.quote(str(utterance["text"])), - } - speech_command = shlex.split(self.speech_command) - for idx, word in enumerate(speech_command): - for placeholder, value in replacements.items(): - word = word.replace(placeholder, value) - speech_command[idx] = word - return speech_command - - def worker(self): - while True: - utterance = self.text_queue.get() - - if isinstance(utterance, int): - if utterance == -1: - return - else: - continue - elif not isinstance(utterance, dict): - continue - # no text means nothing to speak - if "text" not in utterance: - continue - if not isinstance(utterance["text"], str): - continue - if utterance["text"] == "": - continue - # check for valid data fields - if "volume" not in utterance: - utterance["volume"] = "" - if not isinstance(utterance["volume"], str): - utterance["volume"] = "" - if "module" not in utterance: - utterance["module"] = "" - if not isinstance(utterance["module"], str): - utterance["module"] = "" - if "language" not in utterance: - utterance["language"] = "" - if not isinstance(utterance["language"], str): - utterance["language"] = "" - if "voice" not in utterance: - utterance["voice"] = "" - if not isinstance(utterance["voice"], str): - utterance["voice"] = "" - if "pitch" not in utterance: - utterance["pitch"] = "" - if not isinstance(utterance["pitch"], str): - utterance["pitch"] = "" - if "rate" not in utterance: - utterance["rate"] = "" - if not isinstance(utterance["rate"], str): - utterance["rate"] = "" - - popen_speech_command = self._build_speech_command(utterance) - - try: - self.env["runtime"]["DebugManager"].write_debug_out( - "speech_driver:worker:" + " ".join(popen_speech_command), - debug.DebugLevel.INFO, - ) - self.lock.acquire(True) - self.proc = Popen( - popen_speech_command, - stdin=None, - stdout=None, - stderr=None, - shell=False, - ) - self.lock.release() - self.proc.wait() - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "speech_driver:worker:" + str(e), debug.DebugLevel.ERROR - ) - - self.lock.acquire(True) - self.proc = None - self.lock.release() diff --git a/src/fenrirscreenreader/speechDriver/hardwareSerialDriver.py b/src/fenrirscreenreader/speechDriver/hardwareSerialDriver.py deleted file mode 100644 index 585a5bf0..00000000 --- a/src/fenrirscreenreader/speechDriver/hardwareSerialDriver.py +++ /dev/null @@ -1,318 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import os -import termios -import threading -import tty -from queue import Empty -from queue import Queue - -from fenrirscreenreader.core import debug -from fenrirscreenreader.core.speechDriver import speech_driver - - -class SpeakQueue(Queue): - def clear(self): - try: - while True: - self.get_nowait() - except Empty: - pass - - -class hardware_serial_driver(speech_driver): - cancel_command = b"" - default_baud_rate = 9600 - use_xon_xoff = False - - def __init__(self): - speech_driver.__init__(self) - self.device = "" - self.baud_rate = self.default_baud_rate - self.serial_port = None - self.text_queue = SpeakQueue() - self.lock = threading.Lock() - self.worker_thread = None - self._stop_worker = False - self._setting_cache = {} - - def initialize(self, environment): - self.env = environment - self._is_initialized = False - self._reset_setting_cache() - settings_manager = self.env["runtime"]["SettingsManager"] - self.device = self._clean_device_setting( - settings_manager.get_setting("speech", "hardware_device") - ) - self.baud_rate = settings_manager.get_setting_as_int( - "speech", "hardware_baud_rate" - ) - self._debug( - "Hardware speech initialize: " - f"requested_device={self.device}, baud_rate={self.baud_rate}", - debug.DebugLevel.INFO, - on_any_level=True, - ) - self._open_serial_port() - self._is_initialized = self.serial_port is not None - if not self._is_initialized: - raise RuntimeError("hardware speech device is not available") - if self._is_initialized: - self._stop_worker = False - self.worker_thread = threading.Thread( - target=self._worker, daemon=True - ) - self.worker_thread.start() - - def _clean_device_setting(self, device): - if not isinstance(device, str): - return "" - device = device.split("#", 1)[0].split(";", 1)[0].strip() - return device - - def shutdown(self): - if not self._is_initialized: - return - self._stop_worker = True - self.clear_buffer() - self.text_queue.put(None) - if self.worker_thread: - self.worker_thread.join(timeout=0.5) - self._close_serial_port() - self._is_initialized = False - - def speak(self, text, queueable=True, ignore_punctuation=False): - if not self._is_initialized: - return - if not queueable: - self.cancel() - if not isinstance(text, str) or text == "": - return - self._debug( - "Hardware speech queued text: " - f"{len(text)} chars, queue_size={self.text_queue.qsize()}", - debug.DebugLevel.INFO, - on_any_level=True, - ) - self.text_queue.put(text) - - def cancel(self): - if not self._is_initialized: - return - self.clear_buffer() - if self.cancel_command: - self._write_bytes(self.cancel_command, "cancel") - - def clear_buffer(self): - if not self._is_initialized: - return - self.text_queue.clear() - - def set_rate(self, rate): - if not self._is_initialized: - return - if not isinstance(rate, float): - return - self._write_setting_command("rate", self._rate_command(rate)) - - def set_pitch(self, pitch): - if not self._is_initialized: - return - if not isinstance(pitch, float): - return - self._write_setting_command("pitch", self._pitch_command(pitch)) - - def set_volume(self, volume): - if not self._is_initialized: - return - if not isinstance(volume, float): - return - self._write_setting_command("volume", self._volume_command(volume)) - - def _reset_setting_cache(self): - self._setting_cache = {} - - def _write_setting_command(self, setting, command): - if self._setting_cache.get(setting) == command: - return - if self._write_bytes(command, setting): - self._setting_cache[setting] = command - - def _worker(self): - while not self._stop_worker: - text = self.text_queue.get() - if text is None: - return - try: - data = self._speak_bytes(text) - self._debug( - "Hardware speech worker prepared speech bytes: " - f"{len(data)} bytes", - debug.DebugLevel.INFO, - on_any_level=True, - ) - self._write_bytes(data, "speech") - except Exception as error: - self._debug( - f"Hardware speech worker failed: {error}", - debug.DebugLevel.ERROR, - on_any_level=True, - ) - - def _open_serial_port(self): - if not self.device or self.device == "auto": - self._debug( - "Hardware speech requires an explicit serial device", - debug.DebugLevel.ERROR, - on_any_level=True, - ) - return - - port = self._open_configured_serial_port(self.device) - if port is not None: - self._activate_serial_port(self.device, port) - - def _open_configured_serial_port(self, device): - port = None - try: - port = os.open(device, os.O_RDWR | os.O_NOCTTY) - tty.setraw(port) - attrs = termios.tcgetattr(port) - attrs[2] |= termios.CLOCAL | termios.CREAD - baud_rate = self._termios_baud_rate(self.baud_rate) - attrs[4] = baud_rate - attrs[5] = baud_rate - attrs[6][termios.VMIN] = 0 - attrs[6][termios.VTIME] = 0 - if self.use_xon_xoff: - attrs[0] |= termios.IXON - attrs[0] &= ~(termios.IXOFF | termios.IXANY) - else: - attrs[0] &= ~(termios.IXON | termios.IXOFF | termios.IXANY) - termios.tcsetattr(port, termios.TCSANOW, attrs) - return port - except (OSError, termios.error) as error: - self._close_port(port) - self._debug( - f"Hardware speech device open failed: {device}: {error}", - debug.DebugLevel.ERROR, - on_any_level=True, - ) - return None - - def _activate_serial_port(self, device, port): - self.serial_port = port - self.device = device - self._debug( - "Hardware speech device opened: " - f"{device}, baud_rate={self.baud_rate}", - debug.DebugLevel.INFO, - on_any_level=True, - ) - - def _close_serial_port(self): - with self.lock: - if self.serial_port is None: - return - self._close_port(self.serial_port) - self.serial_port = None - - def _close_port(self, port): - if port is None: - return - try: - os.close(port) - except OSError as error: - self._debug( - f"Hardware speech device close failed: {error}", - debug.DebugLevel.WARNING, - ) - - def _write_bytes(self, data, description="data"): - if not data: - return False - with self.lock: - if self.serial_port is None: - return False - try: - total_written = 0 - while total_written < len(data): - bytes_written = os.write( - self.serial_port, data[total_written:] - ) - if bytes_written == 0: - raise OSError("serial write returned 0 bytes") - total_written += bytes_written - preview = self._format_bytes_preview(data) - self._debug( - "Hardware speech wrote " - f"{total_written} {description} bytes: {preview}", - debug.DebugLevel.INFO, - on_any_level=True, - ) - return True - except OSError as error: - self._debug( - f"Hardware speech write failed: {error}", - debug.DebugLevel.ERROR, - on_any_level=True, - ) - return False - - def _termios_baud_rate(self, baud_rate): - baud_name = f"B{baud_rate}" - if hasattr(termios, baud_name): - return getattr(termios, baud_name) - self._debug( - f"Unsupported hardware speech baud rate {baud_rate}; using 9600", - debug.DebugLevel.WARNING, - ) - return termios.B9600 - - def _clean_text(self, text): - text = text.replace("\r", " ").replace("\n", " ") - return "".join( - char if 0x20 <= ord(char) <= 0x7E else " " - for char in text - ) - - def _scale(self, value, minimum, maximum): - value = max(0.0, min(1.0, value)) - return int(round(minimum + value * (maximum - minimum))) - - def _format_bytes_preview(self, data, limit=32): - preview = data[:limit] - hex_preview = " ".join(f"{byte:02x}" for byte in preview) - ascii_preview = "".join( - chr(byte) if 0x20 <= byte <= 0x7E else "." - for byte in preview - ) - suffix = "" if len(data) <= limit else " ..." - return ( - f"hex=[{hex_preview}{suffix}] " - f"ascii=[{ascii_preview}{suffix}]" - ) - - def _debug(self, message, level, on_any_level=False): - try: - self.env["runtime"]["DebugManager"].write_debug_out( - message, level, on_any_level=on_any_level - ) - except Exception: - pass - - def _speak_bytes(self, text): - raise NotImplementedError - - def _rate_command(self, rate): - return b"" - - def _pitch_command(self, pitch): - return b"" - - def _volume_command(self, volume): - return b"" diff --git a/src/fenrirscreenreader/speechDriver/litetalkDriver.py b/src/fenrirscreenreader/speechDriver/litetalkDriver.py deleted file mode 100644 index 6dadeda2..00000000 --- a/src/fenrirscreenreader/speechDriver/litetalkDriver.py +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.speechDriver.hardwareSerialDriver import ( - hardware_serial_driver, -) - - -class driver(hardware_serial_driver): - cancel_command = b"\x18" - - def _speak_bytes(self, text): - return self._clean_text(text).encode("ascii", errors="replace") + b"\r" - - def _rate_command(self, rate): - return self._setting_command(self._scale(rate, 0, 9), b"S") - - def _pitch_command(self, pitch): - return self._setting_command(self._scale(pitch, 0, 99), b"P") - - def _volume_command(self, volume): - return self._setting_command(self._scale(volume, 0, 9), b"V") - - def _setting_command(self, value, command): - return b"\x01" + str(value).encode("ascii") + command diff --git a/src/fenrirscreenreader/speechDriver/speechdDriver.py b/src/fenrirscreenreader/speechDriver/speechdDriver.py deleted file mode 100644 index f7ae49fd..00000000 --- a/src/fenrirscreenreader/speechDriver/speechdDriver.py +++ /dev/null @@ -1,245 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. -# speech-dispatcher driver - -from fenrirscreenreader.core import debug -from fenrirscreenreader.core.speechDriver import speech_driver - - -class driver(speech_driver): - """Speech-dispatcher driver for Fenrir screen reader. - - This driver provides text-to-speech functionality through - speech-dispatcher, - which acts as a common interface to various TTS engines. It supports voice - selection, speech parameters (rate, pitch, volume), and multiple TTS - modules. - - Features: - - Dynamic voice switching and parameter adjustment - - Support for multiple speech-dispatcher modules (espeak, festival, etc.) - - Real-time speech cancellation and queueing - - Language and voice selection - - Speech callbacks for synchronization - - Attributes: - _sd: Speech-dispatcher client connection - language (str): Current speech language - voice (str): Current voice name - module (str): Current TTS module name - """ - def __init__(self): - speech_driver.__init__(self) - - def initialize(self, environment): - """Initialize the speech-dispatcher connection. - - Establishes connection to speech-dispatcher daemon and configures - initial speech parameters. Sets up callbacks and prepares the - speech subsystem for use. - - Args: - environment: Fenrir environment dictionary with settings and managers - - Note: - Gracefully handles cases where speech-dispatcher is not available. - """ - self._sd = None - self.env = environment - self._is_initialized = False - - # Only set these if they haven't been set yet (preserve existing - # values) - if not hasattr(self, "language") or self.language is None: - self.language = "" - if not hasattr(self, "voice") or self.voice is None: - self.voice = "" - if not hasattr(self, "module") or self.module is None: - self.module = "" - - try: - import speechd - - self._sd = speechd.SSIPClient("fenrir-dev") - self._punct = speechd.PunctuationMode() - self._is_initialized = True - - configured_module = self.env["runtime"][ - "SettingsManager" - ].get_setting("speech", "module") - if not configured_module: - try: - active_module = self._sd.get_output_module() - if active_module: - self.module = active_module - self.env["runtime"]["SettingsManager"].set_setting( - "speech", "module", active_module - ) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "SpeechDriver get_output_module:" + str(e), - debug.DebugLevel.ERROR, - ) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "SpeechDriver initialize:" + str(e), debug.DebugLevel.ERROR - ) - - def shutdown(self): - """Shutdown the speech-dispatcher connection. - - Cleanly closes the connection to speech-dispatcher and releases - any allocated resources. - """ - if not self._is_initialized: - return - self.cancel() - try: - self._sd.close() - except Exception as e: - pass - self._is_initialized = False - - def speak(self, text, queueable=True, ignore_punctuation=False): - """Speak the given text through speech-dispatcher. - - Args: - text (str): Text to speak - queueable (bool): Whether speech can be queued with other speech - ignore_punctuation (bool): Whether to ignore punctuation settings - - Note: - Handles text preprocessing and manages speech queue based on parameters. - """ - if not queueable: - self.cancel() - if not self._is_initialized: - self.initialize(self.env) - if not queueable: - self.cancel() - if not self._is_initialized: - return - - try: - if self.module != "": - self._sd.set_output_module(self.module) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "SpeechDriver set_module:" + str(e), debug.DebugLevel.ERROR - ) - - try: - if self.language != "": - self._sd.set_language(self.language) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "SpeechDriver set_language:" + str(e), debug.DebugLevel.ERROR - ) - - try: - if self.voice != "": - self._sd.set_synthesis_voice(self.voice) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "SpeechDriver set_voice:" + str(e), debug.DebugLevel.ERROR - ) - - try: - if ignore_punctuation: - self._sd.set_punctuation(self._punct.ALL) - else: - self._sd.set_punctuation(self._punct.NONE) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "SpeechDriver set_punctuation:" + str(e), - debug.DebugLevel.ERROR, - ) - - try: - # Check if read-all is active and add callback if needed - if ("ReadAllManager" in self.env["runtime"] and - self.env["runtime"]["ReadAllManager"] and - self.env["runtime"]["ReadAllManager"].is_active()): - - import speechd - - def read_all_callback(msg_id, client_id, event_type, *args): - if (event_type == speechd.CallbackType.END and - self.env["runtime"]["ReadAllManager"] and - self.env["runtime"]["ReadAllManager"].is_active()): - self.env["runtime"]["ReadAllManager"].speech_completed() - - self._sd.speak(text, callback=read_all_callback, - event_types=(speechd.CallbackType.END,)) - else: - self._sd.speak(text) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "SpeechDriver speak:" + str(e), debug.DebugLevel.ERROR - ) - self._is_initialized = False - - def cancel(self): - """Cancel all pending and current speech. - - Immediately stops speech output and clears the speech queue. - """ - if not self._is_initialized: - self.initialize(self.env) - if not self._is_initialized: - return - try: - self._sd.cancel() - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "SpeechDriver cancel:" + str(e), debug.DebugLevel.ERROR - ) - self._is_initialized = False - - def set_pitch(self, pitch): - """Set the speech pitch. - - Args: - pitch (float): Speech pitch (0.0 to 1.0, where 0.5 is normal) - """ - if not self._is_initialized: - return - try: - self._sd.set_pitch(int(-100 + pitch * 200)) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "SpeechDriver set_pitch:" + str(e), debug.DebugLevel.ERROR - ) - - def set_rate(self, rate): - """Set the speech rate. - - Args: - rate (float): Speech rate (0.0 to 1.0, where 0.5 is normal) - """ - if not self._is_initialized: - return - try: - self._sd.set_rate(int(-100 + rate * 200)) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "SpeechDriver set_rate:" + str(e), debug.DebugLevel.ERROR - ) - - def set_volume(self, volume): - """Set the speech volume. - - Args: - volume (float): Volume level (0.0 to 1.0) - """ - if not self._is_initialized: - return - try: - self._sd.set_volume(int(-100 + volume * 200)) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "SpeechDriver set_volume:" + str(e), debug.DebugLevel.ERROR - ) diff --git a/src/fenrirscreenreader/speechDriver/tripletalkDriver.py b/src/fenrirscreenreader/speechDriver/tripletalkDriver.py deleted file mode 100644 index ca4fe804..00000000 --- a/src/fenrirscreenreader/speechDriver/tripletalkDriver.py +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.speechDriver.litetalkDriver import driver diff --git a/src/fenrirscreenreader/utils/__init__.py b/src/fenrirscreenreader/utils/__init__.py deleted file mode 100755 index e69de29b..00000000 diff --git a/src/fenrirscreenreader/utils/char_utils.py b/src/fenrirscreenreader/utils/char_utils.py deleted file mode 100644 index 98074108..00000000 --- a/src/fenrirscreenreader/utils/char_utils.py +++ /dev/null @@ -1,170 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.core import debug - - -def get_prev_char(curr_x, curr_y, curr_text): - line_break = False - end_of_screen = False - if curr_text == "": - return -1, -1, "", end_of_screen, line_break - wrapped_lines = curr_text.split("\n") - x = curr_x - y = curr_y - if x - 1 < 0: - if y - 1 >= 0: - y -= 1 - x = len(wrapped_lines[y]) - 1 - line_break = True - else: - line_break = False - end_of_screen = True - else: - x -= 1 - curr_char = "" - if not end_of_screen: - curr_char = wrapped_lines[y][x] - return x, y, curr_char, end_of_screen, line_break - - -def get_current_char(curr_x, curr_y, curr_text): - if curr_text == "": - return -1, -1, "" - wrapped_lines = curr_text.split("\n") - curr_char = wrapped_lines[curr_y][curr_x] - return curr_x, curr_y, curr_char - - -def get_up_char(curr_x, curr_y, curr_text): - end_of_screen = False - if curr_text == "": - return -1, -1, "", end_of_screen - wrapped_lines = curr_text.split("\n") - curr_y -= 1 - if curr_y < 0: - curr_y = 0 - end_of_screen = True - curr_char = "" - if not end_of_screen: - curr_char = wrapped_lines[curr_y][curr_x] - return curr_x, curr_y, curr_char, end_of_screen - - -def get_down_char(curr_x, curr_y, curr_text): - end_of_screen = False - if curr_text == "": - return -1, -1, "", end_of_screen - wrapped_lines = curr_text.split("\n") - curr_y += 1 - if curr_y >= len(wrapped_lines): - curr_y = len(wrapped_lines) - 1 - end_of_screen = True - curr_char = "" - if not end_of_screen: - curr_char = wrapped_lines[curr_y][curr_x] - return curr_x, curr_y, curr_char, end_of_screen - - -def get_last_char_in_line(curr_y, curr_text): - end_of_screen = False - if curr_text == "": - return -1, -1, "" - wrapped_lines = curr_text.split("\n") - curr_x = len(wrapped_lines[curr_y].rstrip()) - 1 - if curr_x < 0: - curr_x = 0 - curr_char = wrapped_lines[curr_y][curr_x] - return curr_x, curr_y, curr_char - - -def get_next_char(curr_x, curr_y, curr_text): - line_break = False - end_of_screen = False - if curr_text == "": - return -1, -1, "", end_of_screen, line_break - wrapped_lines = curr_text.split("\n") - x = curr_x - y = curr_y - if x + 1 == len(wrapped_lines[y]): - if y + 1 <= len(wrapped_lines) - 1: - y += 1 - x = 0 - line_break = True - else: - line_break = False - end_of_screen = True - else: - x += 1 - curr_char = "" - if not end_of_screen: - curr_char = wrapped_lines[y][x] - - return x, y, curr_char, end_of_screen, line_break - - -def get_phonetic(curr_char): - if len(curr_char) != 1: - return curr_char - phonetics_dict = { - "A": "alpha", - "B": "bravo", - "C": "charlie", - "D": "delta", - "E": "echo", - "F": "foxtrot", - "G": "golf", - "H": "hotel", - "I": "india", - "J": "juliet", - "K": "kilo", - "L": "lima", - "M": "mike", - "N": "november", - "O": "oscar", - "P": "papa", - "Q": "quebec", - "R": "romeo", - "S": "sierra", - "T": "tango", - "U": "uniform", - "V": "victor", - "W": "whisky", - "X": "x ray", - "Y": "yankee", - "Z": "zulu", - } - try: - phon_char = phonetics_dict[curr_char.upper()] - if curr_char.isupper(): - phon_char = phon_char[0].upper() + phon_char[1:] - return phon_char - except Exception as e: - # Utility function, no env access - return fallback - return curr_char - - -def present_char_for_review( - env, char, interrupt=True, announce_capital=True, flush=False -): - """Present a character for explicit review commands only""" - if char == " ": - if " " in env["punctuation"]["PUNCTDICT"]: - announce_char = env["punctuation"]["PUNCTDICT"][" "] - else: - announce_char = _("space") - env["runtime"]["OutputManager"].present_text( - announce_char, interrupt=interrupt, flush=flush - ) - else: - env["runtime"]["OutputManager"].present_text( - char, - interrupt=interrupt, - ignore_punctuation=True, - announce_capital=announce_capital, - flush=flush, - ) diff --git a/src/fenrirscreenreader/utils/fenrir-config.py b/src/fenrirscreenreader/utils/fenrir-config.py deleted file mode 100644 index dbe68ead..00000000 --- a/src/fenrirscreenreader/utils/fenrir-config.py +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env python3 - -import configparser -import os -import sys -from inspect import isfunction -from os import listdir -from os.path import isfile -from os.path import join - -from xdg import BaseDirectory - -# Get configuration directory -if len(sys.argv) > 1: - config_path = sys.argv[1] -elif os.geteuid() == 0: - # Save settings system wide - config_path = "/etc/fenrir.conf" -else: - # Use local settings - config_path = BaseDirectory.xdg_data_home + "/fenrir" - if not os.path.exists(config_path): - os.makedirs(config_path) - config_path = config_path + "/fenrir.conf" diff --git a/src/fenrirscreenreader/utils/line_utils.py b/src/fenrirscreenreader/utils/line_utils.py deleted file mode 100644 index cf508bae..00000000 --- a/src/fenrirscreenreader/utils/line_utils.py +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core import debug - - -def get_prev_line(curr_x, curr_y, curr_text): - end_of_screen = False - if curr_text == "": - return -1, -1, "", end_of_screen - wrapped_lines = curr_text.split("\n") - x = curr_x - y = curr_y - if y - 1 >= 0: - y -= 1 - else: - end_of_screen = True - x = curr_x - curr_line = "" - if not end_of_screen: - curr_line = wrapped_lines[y] - return x, y, curr_line, end_of_screen - - -def get_current_line(curr_x, curr_y, curr_text): - if curr_text == "": - return -1, -1, "" - wrapped_lines = curr_text.split("\n") - x = curr_x - y = curr_y - x = 0 - curr_line = wrapped_lines[y] - return x, y, curr_line - - -def get_next_line(curr_x, curr_y, curr_text): - end_of_screen = False - if curr_text == "": - return -1, -1, "", end_of_screen - wrapped_lines = curr_text.split("\n") - x = curr_x - y = curr_y - if y + 1 < len(wrapped_lines): - y += 1 - else: - end_of_screen = True - x = curr_x - curr_line = "" - if not end_of_screen: - curr_line = wrapped_lines[y] - return x, y, curr_line, end_of_screen diff --git a/src/fenrirscreenreader/utils/mark_utils.py b/src/fenrirscreenreader/utils/mark_utils.py deleted file mode 100644 index ec525cec..00000000 --- a/src/fenrirscreenreader/utils/mark_utils.py +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core import debug - - -def get_text_between_marks(firstMark, secondMark, in_text): - if in_text is None: - return "" - if not isinstance(in_text, list): - in_text = in_text.split("\n") - if len(in_text) < 1: - return "" - if firstMark is None: - return "" - if secondMark is None: - return "" - if (firstMark["y"] + 1) * (firstMark["x"] + 1) <= (secondMark["y"] + 1) * ( - secondMark["x"] + 1 - ): - start_mark = firstMark.copy() - end_mark = secondMark.copy() - else: - end_mark = firstMark.copy() - start_mark = secondMark.copy() - text_part = "" - if start_mark["y"] == end_mark["y"]: - text_part += in_text[start_mark["y"]][ - start_mark["x"] : end_mark["x"] + 1 - ] - else: - curr_y = start_mark["y"] - while curr_y <= end_mark["y"]: - if curr_y < end_mark["y"]: - if curr_y == start_mark["y"]: - text_part += in_text[curr_y][start_mark["x"] :] - else: - text_part += in_text[curr_y] - if len(in_text[curr_y].strip()) != 0: - if len(text_part) - len(text_part.rstrip()) > 0: - text_part = text_part[: len(text_part.rstrip())] + "\n" - else: - text_part += "\n" - else: - text_part += in_text[curr_y][: end_mark["x"] + 1] - curr_y += 1 - return text_part - - -def get_text_before_mark(mark, in_text): - if in_text is None: - return "" - if mark is None: - return "" - return get_text_between_marks({"x": 0, "y": 0}, mark, in_text) - - -def get_text_after_mark(mark, in_text): - if in_text is None: - return "" - if mark is None: - return "" - in_text = in_text.split("\n") - return get_text_between_marks( - mark, {"x": len(in_text[0]) - 1, "y": len(in_text) - 1}, in_text - ) diff --git a/src/fenrirscreenreader/utils/module_utils.py b/src/fenrirscreenreader/utils/module_utils.py deleted file mode 100644 index c0e55d49..00000000 --- a/src/fenrirscreenreader/utils/module_utils.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. -import sys - -version = sys.version[:3] # we only need major.minor version. -if version in ["3.3", "3.4"]: - from importlib.machinery import SourceFileLoader -else: # Python 3.5+, no support for python < 3.3. - import importlib.util - - -def import_module(moduleName, moduleLocation): - if version in ["3.3", "3.4"]: - return SourceFileLoader(moduleName, moduleLocation).load_module() - else: - spec = importlib.util.spec_from_file_location( - moduleName, moduleLocation - ) - driver_mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(driver_mod) - return driver_mod diff --git a/src/fenrirscreenreader/utils/review_utils.py b/src/fenrirscreenreader/utils/review_utils.py deleted file mode 100644 index 4dcb61a6..00000000 --- a/src/fenrirscreenreader/utils/review_utils.py +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core import debug diff --git a/src/fenrirscreenreader/utils/screen_utils.py b/src/fenrirscreenreader/utils/screen_utils.py deleted file mode 100644 index 8810ef94..00000000 --- a/src/fenrirscreenreader/utils/screen_utils.py +++ /dev/null @@ -1,118 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import getpass -import os -import string -import time -from select import select - -from fenrirscreenreader.core import debug - - -def remove_nonprintable(text): - # Get the difference of all ASCII characters from the set of printable - # characters - nonprintable = set([chr(i) for i in range(128)]).difference( - string.printable - ) - # Use translate to remove all non-printable characters - return text.translate({ord(character): None for character in nonprintable}) - - -def insert_newlines(string, every=64): - return "\n".join( - string[i : i + every] for i in range(0, len(string), every) - ) - - -def split_every(toSplit, every=64): - return list(toSplit[i : i + every] for i in range(0, len(toSplit), every)) - - -def create_screen_event_data(content): - event_data = { - "bytes": content, - "lines": content["lines"], - "columns": content["columns"], - "textCursor": { - "x": int(content["cursor"][0]), - "y": int(content["cursor"][1]), - }, - "screen": content["screen"], - "text": content["text"], - "attributes": content["attributes"], - "screenUpdateTime": time.time(), - } - return event_data.copy() - - -def has_more(fd, timetout=0.05): - r, _, _ = select([fd], [], [], timetout) - return fd in r - - -def has_more_what(fd_list, timetout=0.05): - if not isinstance(fd_list, list): - return [] - elif fd_list == []: - return [] - r, _, _ = select(fd_list, [], [], timetout) - return r - - -def is_valid_shell(shell=""): - if not isinstance(shell, str): - return False - if shell == "": - return False - try: - if not os.path.isfile(shell): - return False - if not os.access(shell, os.X_OK): - return False - except Exception as e: - # Utility function, no env access - use fallback logging - print( - f"screen_utils is_valid_shell: Error checking shell {shell}: {e}" - ) - return False - return True - - -def get_shell(): - try: - shell = os.environ["FENRIRSHELL"] - if is_valid_shell(shell): - return shell - except Exception as e: - # Utility function, no env access - continue silently - pass - try: - shell = os.environ["SHELL"] - if is_valid_shell(shell): - return shell - except Exception as e: - # Utility function, no env access - continue silently - pass - try: - if os.access("/etc/passwd", os.R_OK): - with open("/etc/passwd") as f: - users = f.readlines() - for user in users: - (username, encrypwd, uid, gid, gecos, homedir, shell) = ( - user.split(":") - ) - shell = shell.replace("\n", "") - if username == getpass.getuser(): - if is_valid_shell(shell): - return shell - except Exception as e: - # Utility function, no env access - continue silently - pass - if is_valid_shell("/bin/bash"): - return "/bin/bash" - return "/bin/sh" diff --git a/src/fenrirscreenreader/utils/speechd_utils.py b/src/fenrirscreenreader/utils/speechd_utils.py deleted file mode 100644 index 712645ff..00000000 --- a/src/fenrirscreenreader/utils/speechd_utils.py +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -def parse_synthesis_voice_line(voice_line): - """Parse one column-aligned spd-say synthesis voice row.""" - fields = voice_line.rsplit(maxsplit=2) - if len(fields) != 3: - return None - - name, language, variant = (field.strip() for field in fields) - if not name: - return None - return name, language, variant - - -def get_synthesis_voice_name(module, voice_line): - """Return the value Speech Dispatcher expects for voice selection.""" - voice_data = parse_synthesis_voice_line(voice_line) - if voice_data is None: - return None - - name, language, variant = voice_data - if module.lower() != "espeak-ng": - return name - - language = language.lower() - variant = variant.lower() - if not language: - return None - if variant and variant != "none": - return f"{language}+{variant}" - return language diff --git a/src/fenrirscreenreader/utils/word_utils.py b/src/fenrirscreenreader/utils/word_utils.py deleted file mode 100644 index 1e5d9380..00000000 --- a/src/fenrirscreenreader/utils/word_utils.py +++ /dev/null @@ -1,129 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import string - -from fenrirscreenreader.core import debug - - -def get_current_word(curr_x, curr_y, curr_text): - line_break = False - end_of_screen = False - if curr_text == "": - return -1, -1, "", end_of_screen, line_break - if curr_text.strip(string.whitespace) == "": - return curr_x, curr_y, "", end_of_screen, line_break - x = curr_x - y = curr_y - curr_word = "" - wrapped_lines = curr_text.split("\n") - curr_line = wrapped_lines[y] - found = False - while not found: - if not curr_line[x] in string.whitespace: - if x == 0: - found = True - else: - if curr_line[x - 1] in string.whitespace: - found = True - if not found: - if x - 1 < 0: - if y - 1 < 0: - line_break = False - end_of_screen = True - return curr_x, curr_y, "", end_of_screen, line_break - else: - y -= 1 - curr_line = wrapped_lines[y] - x = len(curr_line) - 1 - line_break = True - else: - x -= 1 - if found: - curr_word = curr_line[x:] - for d in string.whitespace: - delimiter_pos = curr_word.find(d) - if delimiter_pos != -1: - curr_word = curr_word[:delimiter_pos] - return x, y, curr_word, end_of_screen, line_break - return curr_x, curr_y, "", False, False - - -def get_prev_word(curr_x, curr_y, curr_text): - line_break = False - end_of_screen = False - if curr_text == "": - return -1, -1, "", end_of_screen, line_break - if curr_text.strip(string.whitespace) == "": - return curr_x, curr_y, "", end_of_screen, line_break - x, y, curr_word, end_of_screen, line_break_curr_word = get_current_word( - curr_x, curr_y, curr_text - ) - if end_of_screen: - return x, y, curr_word, end_of_screen, line_break - wrapped_lines = curr_text.split("\n") - curr_line = wrapped_lines[y] - if x - 1 < 0: - if y - 1 < 0: - line_break = False - end_of_screen = True - return curr_x, curr_y, "", end_of_screen, line_break - else: - y -= 1 - curr_line = wrapped_lines[y] - x = len(curr_line) - 1 - line_break = True - else: - x -= 1 - line_break_curr_word = line_break or line_break_curr_word - x, y, curr_word, end_of_screen, line_break = get_current_word( - x, y, curr_text - ) - line_break = line_break or line_break_curr_word - return x, y, curr_word, end_of_screen, line_break - - -def get_next_word(curr_x, curr_y, curr_text): - line_break = False - end_of_screen = False - if curr_text == "": - return -1, -1, "", end_of_screen, line_break - if curr_text.strip(string.whitespace) == "": - return curr_x, curr_y, "", end_of_screen, line_break - x = curr_x - y = curr_y - curr_word = "" - wrapped_lines = curr_text.split("\n") - curr_line = wrapped_lines[y] - found = False - while not found: - if not found: - if x + 1 > len(curr_line) - 1: - if y + 1 > len(wrapped_lines) - 1: - line_break = False - end_of_screen = True - return curr_x, curr_y, "", end_of_screen, line_break - else: - y += 1 - curr_line = wrapped_lines[y] - x = 0 - line_break = True - else: - x += 1 - if not curr_line[x] in string.whitespace: - if x == 0: - found = True - else: - if curr_line[x - 1] in string.whitespace: - found = True - if found: - curr_word = curr_line[x:] - for d in string.whitespace: - delimiter_pos = curr_word.find(d) - if delimiter_pos != -1: - curr_word = curr_word[:delimiter_pos] - return x, y, curr_word, end_of_screen, line_break - return curr_x, curr_y, "", False, False diff --git a/src/fenrirscreenreader/utils/x_clipboard.py b/src/fenrirscreenreader/utils/x_clipboard.py deleted file mode 100644 index 529f385c..00000000 --- a/src/fenrirscreenreader/utils/x_clipboard.py +++ /dev/null @@ -1,148 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import os -import shutil -import subprocess -import threading - - -_display_lock = threading.RLock() -_CLIPBOARD_TIMEOUT = 2.0 -_ENCODING = "utf-8" - - -def _command_exists(command): - return shutil.which(command) is not None - - -def _env_for_display(display): - env = os.environ.copy() - if display: - env["DISPLAY"] = display - return env - - -def _display_candidates(display="", scan_displays=False): - if display: - return [display] - current_display = os.environ.get("DISPLAY", "") - if current_display: - return [current_display] - candidates = [] - if scan_displays: - candidates.extend(f":{index}" for index in range(10)) - if not candidates: - candidates.append("") - return list(dict.fromkeys(candidates)) - - -def _read_commands(display): - commands = [] - if display: - if _command_exists("xclip"): - commands.append(["xclip", "-selection", "clipboard", "-o"]) - if _command_exists("xsel"): - commands.append(["xsel", "-b", "-o"]) - elif os.environ.get("WAYLAND_DISPLAY") and _command_exists("wl-paste"): - commands.append(["wl-paste", "-n", "-t", "text"]) - return commands - - -def _write_commands(display): - commands = [] - if display: - if _command_exists("xclip"): - commands.append(["xclip", "-selection", "clipboard"]) - if _command_exists("xsel"): - commands.append(["xsel", "-b", "-i"]) - elif os.environ.get("WAYLAND_DISPLAY") and _command_exists("wl-copy"): - commands.append(["wl-copy"]) - return commands - - -def _run_command(command, display, input_text=None): - input_bytes = None - if input_text is not None: - input_bytes = input_text.encode(_ENCODING) - output_target = ( - subprocess.PIPE if input_text is None else subprocess.DEVNULL - ) - return subprocess.run( - command, - input=input_bytes, - stdout=output_target, - stderr=output_target, - env=_env_for_display(display), - timeout=_CLIPBOARD_TIMEOUT, - check=False, - ) - - -def _command_error(command, result): - stderr = (result.stderr or b"").decode( - _ENCODING, errors="replace" - ).strip() - if stderr: - return RuntimeError(stderr) - return RuntimeError( - "clipboard command failed: " - + " ".join(command) - + " exited with " - + str(result.returncode) - ) - - -def read_text(display="", scan_displays=False): - last_error = None - with _display_lock: - for candidate in _display_candidates(display, scan_displays): - commands = _read_commands(candidate) - if not commands: - last_error = RuntimeError("no supported clipboard reader found") - for command in commands: - try: - result = _run_command(command, candidate) - if result.returncode != 0: - # xclip reports a missing text target for both an - # unowned selection and a non-text clipboard. - stderr = (result.stderr or b"").strip() - if command[0] == "xclip" and stderr in ( - b"Error: target STRING not available", - b"Error: target UTF8_STRING not available", - ): - return None - last_error = _command_error(command, result) - continue - text = result.stdout.decode(_ENCODING) - return text or None - except Exception as error: - last_error = error - if last_error: - raise last_error - return None - - -def write_text(text, display="", scan_displays=False): - if not isinstance(text, str) or not text: - return False - last_error = None - with _display_lock: - for candidate in _display_candidates(display, scan_displays): - commands = _write_commands(candidate) - if not commands: - last_error = RuntimeError("no supported clipboard writer found") - for command in commands: - try: - result = _run_command(command, candidate, text) - if result.returncode == 0: - return True - last_error = _command_error(command, result) - except Exception as error: - last_error = error - if last_error: - raise last_error - return False diff --git a/tests/PRE_COMMIT_INTEGRATION.md b/tests/PRE_COMMIT_INTEGRATION.md deleted file mode 100644 index e71eb180..00000000 --- a/tests/PRE_COMMIT_INTEGRATION.md +++ /dev/null @@ -1,208 +0,0 @@ -# Pre-Commit Test Integration - -## Overview - -The test suite is now automatically executed as part of the pre-commit hook, ensuring all commits maintain code quality and passing tests. - -## What Happens on Commit - -When you run `git commit`, the pre-commit hook now performs **5 validation steps**: - -``` -1. Python syntax validation (all files) -2. Common issue detection (modified files) -3. Core module import testing -4. Test suite execution (37 tests) ← NEW! -5. Secret/credential detection -``` - -## Test Execution - -```bash -4. Running test suite... -============================= test session starts ============================== -platform linux -- Python 3.13.7, pytest-8.4.2, pluggy-1.6.0 -rootdir: /home/storm/git/fenrir -configfile: pytest.ini -collected 37 items - -tests/integration/test_remote_control.py .................... [ 54%] -tests/unit/test_settings_validation.py ................. [100%] - -============================== 37 passed in 0.44s ============================== -āœ“ All tests passed -``` - -**Performance:** Tests complete in **< 1 second**, adding minimal overhead to the commit process. - -## Behavior - -### āœ… Tests Pass - Commit Allowed -```bash -$ git commit -m "Add new feature" - -Fenrir Pre-commit Validation -================================== -1. Validating Python syntax... -āœ“ Syntax validation passed - -2. Checking modified files... -āœ“ No common issues found - -3. Testing core module imports... -āœ“ Core module imports successful - -4. Running test suite... -āœ“ All tests passed (37 passed in 0.44s) - -5. Checking for potential secrets... -āœ“ No potential secrets found - -================================================== -āœ“ All pre-commit validations passed -Commit allowed to proceed -``` - -### āŒ Tests Fail - Commit Blocked -```bash -$ git commit -m "Broken feature" - -Fenrir Pre-commit Validation -================================== -[... earlier checks pass ...] - -4. Running test suite... -āœ— Test suite failed -Run: pytest tests/ -v (to see details) - -================================================== -āœ— Pre-commit validation failed -Commit blocked - please fix issues above - -Quick fixes: - • Python syntax: python3 tools/validate_syntax.py --fix - • Run tests: pytest tests/ -v - • Review flagged files manually - • Re-run commit after fixes -``` - -## Installation - -The pre-commit hook is installed via: - -```bash -# One-time setup -./tools/install_validation_hook.sh - -# Or manually -ln -sf ../../tools/pre-commit-hook .git/hooks/pre-commit -``` - -## Requirements - -### Required -- Python 3.7+ -- Git repository - -### Optional but Recommended -- `pytest` - For running tests (gracefully skipped if not installed) - -If pytest is not installed, you'll see: -``` -4. Running test suite... -⚠ pytest not installed - skipping tests - Install with: pip install pytest - Or full test suite: pip install -r tests/requirements.txt -``` - -The commit will still proceed, but tests won't run. - -## Benefits - -1. **Catch bugs early** - Tests run before code reaches the repository -2. **Maintain quality** - Broken code can't be committed -3. **Fast feedback** - Tests complete in < 1 second -4. **Zero overhead** - Gracefully degrades if pytest isn't installed -5. **Confidence** - Know that all commits pass tests - -## Bypassing the Hook - -**Not recommended**, but if you need to commit without running tests: - -```bash -# Skip all pre-commit checks (use with caution!) -git commit --no-verify -m "Emergency hotfix" -``` - -**Warning:** Only use `--no-verify` for legitimate emergencies. Bypassing tests defeats their purpose. - -## Troubleshooting - -### Tests fail on commit but pass manually - -```bash -# Check environment matches -cd /home/storm/git/fenrir -pytest tests/ -v - -# Verify PYTHONPATH -echo $PYTHONPATH -``` - -### Hook doesn't run tests - -```bash -# Check pytest is installed -pytest --version - -# Install if missing -pip install pytest - -# Or full test dependencies -pip install -r tests/requirements.txt -``` - -### Hook takes too long - -The test suite is designed to run in < 1 second. If it's slower: - -```bash -# Check test timing -pytest tests/ --durations=10 - -# Look for slow tests (should all be < 100ms) -``` - -## Statistics - -- **Tests Executed:** 37 -- **Execution Time:** 0.44 seconds -- **Pass Rate:** 100% -- **Coverage:** Unit tests (17) + Integration tests (20) - -## Future Enhancements - -Possible additions to pre-commit validation: - -- **Coverage threshold** - Require minimum test coverage percentage -- **Performance regression** - Warn if tests get slower -- **Incremental testing** - Only run tests for modified code -- **Parallel execution** - Use `pytest -n auto` (requires pytest-xdist) - -## Related Documentation - -- `tests/README.md` - Test strategy overview -- `tests/TESTING_GUIDE.md` - Comprehensive testing guide -- `TESTING_SUMMARY.md` - Test implementation summary -- `tools/pre-commit-hook` - Pre-commit hook source code - -## Summary - -Adding tests to the pre-commit hook ensures: -- āœ… All commits have passing tests -- āœ… Regressions are caught immediately -- āœ… Code quality is maintained -- āœ… Minimal performance impact (< 1 second) -- āœ… Graceful degradation without pytest - -**Result:** Higher code quality with virtually zero developer friction. diff --git a/tests/README.md b/tests/README.md deleted file mode 100644 index dd7ba989..00000000 --- a/tests/README.md +++ /dev/null @@ -1,149 +0,0 @@ -# Fenrir Test Suite - -This directory contains automated tests for the Fenrir screen reader. Testing a screen reader that requires root access and hardware interaction presents unique challenges, so we use a multi-layered testing strategy. - -## Test Strategy - -### 1. Unit Tests (No Root Required) -Test individual components in isolation without requiring hardware access: -- **Core Managers**: Logic testing without driver dependencies -- **Utility Functions**: String manipulation, cursor calculations, text processing -- **Settings Validation**: Configuration parsing and validation -- **Remote Command Parsing**: Command/setting string processing - -### 2. Integration Tests (No Root Required) -Test component interactions using mocked drivers: -- **Remote Control**: Unix socket and TCP communication -- **Command System**: Command loading and execution flow -- **Event Processing**: Event queue and dispatching -- **Settings Manager**: Configuration loading and runtime changes - -### 3. Driver Tests (Root Required, Optional) -Test actual hardware interaction (only run in CI or explicitly by developers): -- **VCSA Driver**: Screen reading on real TTY -- **Evdev Driver**: Keyboard input capture -- **Speech Drivers**: TTS output validation -- **Sound Drivers**: Audio playback testing - -### 4. End-to-End Tests (Root Required, Manual) -Real-world usage scenarios run manually by developers: -- Full Fenrir startup/shutdown cycle -- Remote control from external scripts -- VMenu navigation and command execution -- Speech output for screen changes - -## Running Tests - -```bash -# Install test dependencies -pip install pytest pytest-cov pytest-mock pytest-timeout - -# Run all unit and integration tests (no root required) -pytest tests/ - -# Run with coverage report -pytest tests/ --cov=src/fenrirscreenreader --cov-report=html - -# Run only unit tests -pytest tests/unit/ - -# Run only integration tests -pytest tests/integration/ - -# Run specific test file -pytest tests/unit/test_settings_manager.py - -# Run with verbose output -pytest tests/ -v - -# Run driver tests (requires root) -sudo pytest tests/drivers/ -v -``` - -## Test Organization - -``` -tests/ -ā”œā”€ā”€ README.md # This file -ā”œā”€ā”€ conftest.py # Shared pytest fixtures -ā”œā”€ā”€ unit/ # Unit tests (fast, no mocking needed) -│ ā”œā”€ā”€ test_settings_validation.py -│ ā”œā”€ā”€ test_cursor_utils.py -│ ā”œā”€ā”€ test_text_utils.py -│ └── test_remote_parsing.py -ā”œā”€ā”€ integration/ # Integration tests (require mocking) -│ ā”œā”€ā”€ test_remote_control.py -│ ā”œā”€ā”€ test_command_manager.py -│ ā”œā”€ā”€ test_event_manager.py -│ └── test_settings_manager.py -└── drivers/ # Driver tests (require root) - ā”œā”€ā”€ test_vcsa_driver.py - ā”œā”€ā”€ test_evdev_driver.py - └── test_speech_drivers.py -``` - -## Writing Tests - -### Example Unit Test -```python -def test_speech_rate_validation(): - """Test that speech rate validation rejects out-of-range values.""" - manager = SettingsManager() - - # Valid values should pass - manager._validate_setting_value('speech', 'rate', 0.5) - manager._validate_setting_value('speech', 'rate', 3.0) - - # Invalid values should raise ValueError - with pytest.raises(ValueError): - manager._validate_setting_value('speech', 'rate', -1.0) - with pytest.raises(ValueError): - manager._validate_setting_value('speech', 'rate', 10.0) -``` - -### Example Integration Test -```python -def test_remote_control_unix_socket(tmp_path): - """Test Unix socket remote control accepts commands.""" - socket_path = tmp_path / "test.sock" - - # Start mock remote driver - driver = MockUnixDriver(socket_path) - - # Send command - send_remote_command(socket_path, "command say Hello") - - # Verify command was received - assert driver.received_commands[-1] == "command say Hello" -``` - -## Test Coverage Goals - -- **Unit Tests**: 80%+ coverage on utility functions and validation logic -- **Integration Tests**: 60%+ coverage on core managers and command system -- **Overall**: 70%+ coverage on non-driver code - -Driver code is excluded from coverage metrics as it requires hardware interaction. - -## Continuous Integration - -Tests are designed to run in CI environments without root access: -- Unit and integration tests run on every commit -- Driver tests are skipped in CI (require actual hardware) -- Coverage reports are generated and tracked over time - -## Test Principles - -1. **No Root by Default**: Most tests should run without elevated privileges -2. **Fast Execution**: Unit tests complete in <1 second each -3. **Isolated**: Tests don't depend on each other or external state -4. **Deterministic**: Tests produce same results every run -5. **Documented**: Each test has a clear docstring explaining what it tests -6. **Realistic Mocks**: Mocked components behave like real ones - -## Future Enhancements - -- **Performance Tests**: Measure input-to-speech latency -- **Stress Tests**: Rapid event processing, memory leak detection -- **Accessibility Tests**: Verify all features work without vision -- **Compatibility Tests**: Test across different Linux distributions diff --git a/tests/TESTING_GUIDE.md b/tests/TESTING_GUIDE.md deleted file mode 100644 index a642fe23..00000000 --- a/tests/TESTING_GUIDE.md +++ /dev/null @@ -1,430 +0,0 @@ -# Fenrir Testing Guide - -Complete guide to running and writing tests for the Fenrir screen reader. - -## Quick Start - -### 1. Install Test Dependencies - -```bash -# Install test requirements -pip install -r tests/requirements.txt - -# Or install individually -pip install pytest pytest-cov pytest-mock pytest-timeout -``` - -### 2. Run Tests - -```bash -# Run all tests (unit + integration) -pytest tests/ - -# Run only unit tests (fastest) -pytest tests/unit/ -v - -# Run only integration tests -pytest tests/integration/ -v - -# Run with coverage report -pytest tests/ --cov=src/fenrirscreenreader --cov-report=html -# Then open htmlcov/index.html in a browser - -# Run specific test file -pytest tests/unit/test_settings_validation.py -v - -# Run specific test class -pytest tests/unit/test_settings_validation.py::TestSpeechSettingsValidation -v - -# Run specific test -pytest tests/unit/test_settings_validation.py::TestSpeechSettingsValidation::test_speech_rate_valid_range -v -``` - -### 3. Useful Test Options - -```bash -# Stop on first failure -pytest tests/ -x - -# Show test output (print statements, logging) -pytest tests/ -s - -# Run tests in parallel (faster, requires: pip install pytest-xdist) -pytest tests/ -n auto - -# Show slowest 10 tests -pytest tests/ --durations=10 - -# Run only tests matching a keyword -pytest tests/ -k "remote" - -# Run tests with specific markers -pytest tests/ -m unit # Only unit tests -pytest tests/ -m integration # Only integration tests -pytest tests/ -m "not slow" # Skip slow tests -``` - -## Test Structure - -``` -tests/ -ā”œā”€ā”€ README.md # Test overview and strategy -ā”œā”€ā”€ TESTING_GUIDE.md # This file - detailed usage guide -ā”œā”€ā”€ requirements.txt # Test dependencies -ā”œā”€ā”€ conftest.py # Shared fixtures and pytest config -ā”œā”€ā”€ unit/ # Unit tests (fast, isolated) -│ ā”œā”€ā”€ __init__.py -│ ā”œā”€ā”€ test_settings_validation.py # Settings validation tests -│ ā”œā”€ā”€ test_cursor_utils.py # Cursor calculation tests -│ └── test_text_utils.py # Text processing tests -ā”œā”€ā”€ integration/ # Integration tests (require mocking) -│ ā”œā”€ā”€ __init__.py -│ ā”œā”€ā”€ test_remote_control.py # Remote control functionality -│ ā”œā”€ā”€ test_command_manager.py # Command loading/execution -│ └── test_event_manager.py # Event queue processing -└── drivers/ # Driver tests (require root) - ā”œā”€ā”€ __init__.py - ā”œā”€ā”€ test_vcsa_driver.py # TTY screen reading - └── test_evdev_driver.py # Keyboard input capture -``` - -## Writing New Tests - -### Unit Test Example - -```python -"""tests/unit/test_my_feature.py""" -import pytest - -@pytest.mark.unit -def test_speech_rate_calculation(): - """Test that speech rate is calculated correctly.""" - rate = calculate_speech_rate(0.5) - assert 0.0 <= rate <= 1.0 - assert rate == 0.5 -``` - -### Integration Test Example - -```python -"""tests/integration/test_my_integration.py""" -import pytest - -@pytest.mark.integration -def test_remote_command_execution(mock_environment): - """Test remote command execution flow.""" - manager = RemoteManager() - manager.initialize(mock_environment) - - result = manager.handle_command_execution_with_response("say test") - - assert result["success"] is True - mock_environment["runtime"]["OutputManager"].speak_text.assert_called_once() -``` - -### Using Fixtures - -Common fixtures are defined in `conftest.py`: - -```python -def test_with_mock_environment(mock_environment): - """Use the shared mock environment fixture.""" - # mock_environment provides mocked runtime managers - assert "runtime" in mock_environment - assert "DebugManager" in mock_environment["runtime"] - -def test_with_temp_config(temp_config_file): - """Use a temporary config file.""" - # temp_config_file is a Path object to a valid test config - assert temp_config_file.exists() - content = temp_config_file.read_text() - assert "[speech]" in content -``` - -## Test Markers - -Tests can be marked to categorize them: - -```python -@pytest.mark.unit # Fast, isolated unit test -@pytest.mark.integration # Integration test with mocking -@pytest.mark.driver # Requires root access (skipped by default) -@pytest.mark.slow # Takes > 1 second -@pytest.mark.remote # Tests remote control functionality -@pytest.mark.settings # Tests settings/configuration -@pytest.mark.commands # Tests command system -@pytest.mark.vmenu # Tests VMenu system -``` - -Run tests by marker: -```bash -pytest tests/ -m unit # Only unit tests -pytest tests/ -m "unit or integration" # Unit and integration -pytest tests/ -m "not slow" # Skip slow tests -``` - -## Code Coverage - -### View Coverage Report - -```bash -# Generate HTML coverage report -pytest tests/ --cov=src/fenrirscreenreader --cov-report=html - -# Open report in browser -firefox htmlcov/index.html # Or your preferred browser - -# Terminal coverage report -pytest tests/ --cov=src/fenrirscreenreader --cov-report=term-missing -``` - -### Coverage Goals - -- **Unit Tests**: 80%+ coverage on utility functions and validation logic -- **Integration Tests**: 60%+ coverage on core managers -- **Overall**: 70%+ coverage on non-driver code - -Driver code is excluded from coverage as it requires hardware interaction. - -## Testing Best Practices - -### 1. Test One Thing - -```python -# Good - tests one specific behavior -def test_speech_rate_rejects_negative(): - with pytest.raises(ValueError): - validate_rate(-1.0) - -# Bad - tests multiple unrelated things -def test_speech_settings(): - validate_rate(0.5) # Rate validation - validate_pitch(1.0) # Pitch validation - validate_volume(0.8) # Volume validation -``` - -### 2. Use Descriptive Names - -```python -# Good - clear what's being tested -def test_speech_rate_rejects_values_above_three(): - ... - -# Bad - unclear purpose -def test_rate(): - ... -``` - -### 3. Arrange-Act-Assert Pattern - -```python -def test_remote_command_parsing(): - # Arrange - set up test data - manager = RemoteManager() - command = "say Hello World" - - # Act - execute the code being tested - result = manager.parse_command(command) - - # Assert - verify the result - assert result["action"] == "say" - assert result["text"] == "Hello World" -``` - -### 4. Mock External Dependencies - -```python -def test_clipboard_export(mock_environment, tmp_path): - """Test clipboard export without real file operations.""" - # Use mock environment instead of real Fenrir runtime - manager = RemoteManager() - manager.initialize(mock_environment) - - # Use temporary path instead of /tmp - clipboard_path = tmp_path / "clipboard" - mock_environment["runtime"]["SettingsManager"].get_setting = Mock( - return_value=str(clipboard_path) - ) - - manager.export_clipboard() - - assert clipboard_path.exists() -``` - -### 5. Test Error Paths - -```python -def test_remote_command_handles_invalid_input(): - """Test that invalid commands are handled gracefully.""" - manager = RemoteManager() - - # Test with various invalid inputs - result1 = manager.handle_command_execution_with_response("") - result2 = manager.handle_command_execution_with_response("invalid") - result3 = manager.handle_command_execution_with_response("command unknown") - - # All should return error results, not crash - assert all(not r["success"] for r in [result1, result2, result3]) -``` - -## Debugging Tests - -### Run with More Verbosity - -```bash -# Show test names and outcomes -pytest tests/ -v - -# Show test names, outcomes, and print statements -pytest tests/ -v -s - -# Show local variables on failure -pytest tests/ --showlocals - -# Show full diff on assertion failures -pytest tests/ -vv -``` - -### Use pytest.set_trace() for Debugging - -```python -def test_complex_logic(): - result = complex_function() - pytest.set_trace() # Drop into debugger here - assert result == expected -``` - -### Run Single Test Repeatedly - -```bash -# Useful for debugging flaky tests -pytest tests/unit/test_my_test.py::test_specific_test --count=100 -``` - -## Continuous Integration - -### GitHub Actions Example - -```yaml -name: Tests - -on: [push, pull_request] - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 - with: - python-version: '3.9' - - name: Install dependencies - run: | - pip install -r requirements.txt - pip install -r tests/requirements.txt - - name: Run tests - run: pytest tests/ --cov=src/fenrirscreenreader --cov-report=xml - - name: Upload coverage - uses: codecov/codecov-action@v3 -``` - -## Common Issues - -### ImportError: No module named 'fenrirscreenreader' - -**Solution**: Make sure you're running pytest from the project root, or set PYTHONPATH: -```bash -export PYTHONPATH="${PYTHONPATH}:$(pwd)/src" -pytest tests/ -``` - -### Tests hang or timeout - -**Solution**: Use the timeout decorator or pytest-timeout: -```bash -pytest tests/ --timeout=30 # Global 30s timeout -``` - -Or mark specific tests: -```python -@pytest.mark.timeout(5) -def test_that_might_hang(): - ... -``` - -### Mocks not working as expected - -**Solution**: Check that you're patching the right location: -```python -# Good - patch where it's used -@patch('fenrirscreenreader.core.remoteManager.OutputManager') - -# Bad - patch where it's defined -@patch('fenrirscreenreader.core.outputManager.OutputManager') -``` - -## Advanced Topics - -### Parametrized Tests - -Test multiple inputs with one test: - -```python -@pytest.mark.parametrize("rate,expected", [ - (0.0, True), - (1.5, True), - (3.0, True), - (-1.0, False), - (10.0, False), -]) -def test_rate_validation(rate, expected): - try: - validate_rate(rate) - assert expected is True - except ValueError: - assert expected is False -``` - -### Test Fixtures with Cleanup - -```python -@pytest.fixture -def temp_fenrir_instance(): - """Start a test Fenrir instance.""" - fenrir = FenrirTestInstance() - fenrir.start() - - yield fenrir # Test runs here - - # Cleanup after test - fenrir.stop() - fenrir.cleanup() -``` - -### Testing Async Code - -```python -@pytest.mark.asyncio -async def test_async_speech(): - result = await async_speak("test") - assert result.success -``` - -## Getting Help - -- **Pytest Documentation**: https://docs.pytest.org/ -- **Fenrir Issues**: https://github.com/chrys87/fenrir/issues -- **Test Coverage**: Run with `--cov-report=html` and inspect `htmlcov/index.html` - -## Contributing Tests - -When contributing tests: - -1. **Follow naming conventions**: `test_*.py` for files, `test_*` for functions -2. **Add docstrings**: Explain what each test verifies -3. **Use appropriate markers**: `@pytest.mark.unit`, `@pytest.mark.integration`, etc. -4. **Keep tests fast**: Unit tests should complete in <100ms -5. **Test edge cases**: Empty strings, None, negative numbers, etc. -6. **Update this guide**: If you add new test patterns or fixtures - -Happy testing! 🧪 diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index cf4d32ea..00000000 --- a/tests/conftest.py +++ /dev/null @@ -1,225 +0,0 @@ -""" -Shared pytest fixtures for Fenrir tests. - -This file contains fixtures and configuration used across all test modules. -""" - -import os -import sys -import tempfile -from pathlib import Path -from unittest.mock import MagicMock, Mock - -import pytest - -# Add src directory to Python path for imports -fenrir_root = Path(__file__).parent.parent -sys.path.insert(0, str(fenrir_root / "src")) - - -@pytest.fixture -def mock_environment(): - """Create a minimal mock environment for testing. - - Returns a mock environment dict with required runtime managers mocked. - This allows testing components without initializing the full Fenrir stack. - """ - env = { - "runtime": { - "DebugManager": Mock(write_debug_out=Mock()), - "OutputManager": Mock( - present_text=Mock(), - speak_text=Mock(), - interrupt_output=Mock(), - ), - "SettingsManager": Mock( - get_setting=Mock(return_value="default"), - get_setting_as_int=Mock(return_value=0), - get_setting_as_float=Mock(return_value=0.0), - get_setting_as_bool=Mock(return_value=True), - ), - "InputManager": Mock( - sendKeys=Mock(), - handle_device_grab=Mock(), - ), - "ScreenManager": Mock(update_screen_ignored=Mock()), - "EventManager": Mock(stop_main_event_loop=Mock()), - "MemoryManager": Mock( - add_value_to_first_index=Mock(), - get_index_list_element=Mock(return_value="test clipboard"), - is_index_list_empty=Mock(return_value=False), - ), - "VmenuManager": Mock( - set_curr_menu=Mock(), - ), - "CursorManager": Mock( - set_window_for_application=Mock(), - clear_window_for_application=Mock(), - ), - }, - "settings": Mock(), - "general": { - "curr_user": "testuser", - }, - } - return env - - -@pytest.fixture -def temp_config_file(tmp_path): - """Create a temporary configuration file for testing. - - Returns path to a valid test configuration file. - """ - config_path = tmp_path / "test_settings.conf" - config_content = """[sound] -enabled=True -driver=gstreamerDriver -theme=default -volume=0.7 - -[speech] -enabled=True -driver=speechdDriver -rate=0.5 -pitch=0.5 -volume=1.0 -autoReadIncoming=True - -[screen] -driver=vcsaDriver -encoding=auto -screenUpdateDelay=0.05 - -[keyboard] -driver=evdevDriver -device=ALL -grabDevices=True -keyboardLayout=desktop - -[general] -debugLevel=2 -debugMode=File - -[remote] -enable=True -driver=unixDriver -port=22447 -enableSettingsRemote=True -enableCommandRemote=True -""" - config_path.write_text(config_content) - return config_path - - -@pytest.fixture -def temp_socket_path(tmp_path): - """Create a temporary Unix socket path for testing. - - Returns path that can be used for Unix socket testing. - """ - return tmp_path / "test_fenrir.sock" - - -@pytest.fixture -def temp_clipboard_file(tmp_path): - """Create a temporary clipboard file for testing. - - Returns path to a temporary file for clipboard operations. - """ - clipboard_path = tmp_path / "fenrirClipboard" - clipboard_path.write_text("") - return clipboard_path - - -@pytest.fixture -def sample_screen_data(): - """Return sample screen data for testing screen-related functionality. - - Returns dict with screen dimensions and content. - """ - return { - "columns": 80, - "lines": 24, - "delta": "Hello World", - "cursor": {"x": 0, "y": 0}, - "content": "Sample screen content\nSecond line\nThird line", - } - - -@pytest.fixture -def sample_remote_commands(): - """Return sample remote control commands for testing. - - Returns list of valid remote commands. - """ - return [ - "command say Hello World", - "command interrupt", - "setting set speech#rate=0.8", - "setting set speech#pitch=0.6", - "setting set sound#volume=0.5", - "setting reset", - ] - - -@pytest.fixture -def invalid_remote_commands(): - """Return invalid remote control commands for testing validation. - - Returns list of commands that should be rejected. - """ - return [ - "setting set speech#rate=999", # Out of range - "setting set speech#rate=-1", # Negative value - "setting set speech#pitch=10", # Out of range - "setting set speech#volume=-0.5", # Negative volume - "setting set invalid#setting=value", # Invalid section - "command unknown_command", # Unknown command - ] - - -# Pytest hooks for test session customization - - -def pytest_configure(config): - """Configure pytest with custom settings.""" - # Add custom markers - config.addinivalue_line( - "markers", "unit: Unit tests (fast, no mocking required)" - ) - config.addinivalue_line( - "markers", "integration: Integration tests (require mocking)" - ) - config.addinivalue_line( - "markers", "driver: Driver tests (require root access)" - ) - config.addinivalue_line( - "markers", "slow: Tests that take more than 1 second" - ) - - -def pytest_collection_modifyitems(config, items): - """Modify test collection to skip driver tests unless explicitly run. - - Driver tests require root access and hardware, so skip by default. - Run with: pytest --run-driver-tests - """ - skip_driver = pytest.mark.skip( - reason="Driver tests require root access (use --run-driver-tests)" - ) - run_driver_tests = config.getoption("--run-driver-tests", default=False) - - for item in items: - if "driver" in item.keywords and not run_driver_tests: - item.add_marker(skip_driver) - - -def pytest_addoption(parser): - """Add custom command line options.""" - parser.addoption( - "--run-driver-tests", - action="store_true", - default=False, - help="Run driver tests that require root access", - ) diff --git a/tests/enchant_unavailable.c b/tests/enchant_unavailable.c new file mode 100644 index 00000000..359663f3 --- /dev/null +++ b/tests/enchant_unavailable.c @@ -0,0 +1,19 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +// Private-test preload: simulate failure to load only Enchant, leaving all +// other libraries alone. The optional stub exercises missing ABI symbols. +#define _GNU_SOURCE +#include +#include +#include + +void *dlopen(const char *filename, int flags) { + void *(*realOpen)(const char *, int) = dlsym(RTLD_NEXT, "dlopen"); + if (!realOpen) return NULL; + if (filename && strcmp(filename, "libenchant-2.so.2") == 0) { + const char *stubPath = getenv("FENRIR_TEST_ENCHANT_LIBRARY"); + return stubPath && *stubPath ? realOpen(stubPath, flags) : NULL; + } + return realOpen(filename, flags); +} diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py deleted file mode 100644 index 41b298cc..00000000 --- a/tests/integration/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Integration tests for Fenrir screen reader components.""" diff --git a/tests/integration/test_remote_control.py b/tests/integration/test_remote_control.py deleted file mode 100644 index 9b834be0..00000000 --- a/tests/integration/test_remote_control.py +++ /dev/null @@ -1,424 +0,0 @@ -""" -Integration tests for remote control functionality. - -Tests the remote control system including Unix socket and TCP communication, -command parsing, and settings management. -""" - -import pytest -import socket -import time -from unittest.mock import Mock, patch - -from fenrirscreenreader.core.remoteManager import RemoteManager - - -@pytest.mark.integration -@pytest.mark.remote -class TestRemoteCommandParsing: - """Test remote control command parsing and execution.""" - - def setup_method(self): - """Create RemoteManager instance for each test.""" - self.manager = RemoteManager() - - def test_say_command_parsing(self, mock_environment): - """Test parsing of 'command say' messages.""" - self.manager.initialize(mock_environment) - - result = self.manager.handle_command_execution_with_response( - "say Hello World" - ) - - assert result["success"] is True - assert "Speaking" in result["message"] - mock_environment["runtime"]["OutputManager"].speak_text.assert_called_once_with( - "Hello World" - ) - - def test_interrupt_command(self, mock_environment): - """Test speech interruption command.""" - self.manager.initialize(mock_environment) - - result = self.manager.handle_command_execution_with_response("interrupt") - - assert result["success"] is True - mock_environment["runtime"][ - "OutputManager" - ].interrupt_output.assert_called_once() - - def test_setting_change_parsing(self, mock_environment): - """Test parsing of 'setting set' commands.""" - self.manager.initialize(mock_environment) - - # Mock parse_setting_args to verify it gets called - with patch.object( - mock_environment["runtime"]["SettingsManager"], "parse_setting_args" - ) as mock_parse: - result = self.manager.handle_settings_change_with_response( - "set speech#rate=0.8" - ) - - assert result["success"] is True - mock_parse.assert_called_once_with("speech#rate=0.8") - - def test_clipboard_command(self, mock_environment): - """Test clipboard setting via remote control.""" - self.manager.initialize(mock_environment) - - result = self.manager.handle_command_execution_with_response( - "clipboard Test clipboard content" - ) - - assert result["success"] is True - mock_environment["runtime"][ - "MemoryManager" - ].add_value_to_first_index.assert_called_once_with( - "clipboardHistory", "Test clipboard content" - ) - - def test_quit_command(self, mock_environment): - """Test Fenrir shutdown command.""" - self.manager.initialize(mock_environment) - - result = self.manager.handle_command_execution_with_response( - "quitapplication" - ) - - assert result["success"] is True - mock_environment["runtime"][ - "EventManager" - ].stop_main_event_loop.assert_called_once() - - def test_unknown_command_rejection(self, mock_environment): - """Test that unknown commands are rejected.""" - self.manager.initialize(mock_environment) - - result = self.manager.handle_command_execution_with_response( - "unknown_command" - ) - - assert result["success"] is False - assert "Unknown command" in result["message"] - - -@pytest.mark.integration -@pytest.mark.remote -class TestRemoteSettingsControl: - """Test remote control of settings.""" - - def setup_method(self): - """Create RemoteManager instance for each test.""" - self.manager = RemoteManager() - - def test_setting_reset(self, mock_environment): - """Test resetting settings to defaults.""" - self.manager.initialize(mock_environment) - - with patch.object( - mock_environment["runtime"]["SettingsManager"], "reset_setting_arg_dict" - ) as mock_reset: - result = self.manager.handle_settings_change_with_response("reset") - - assert result["success"] is True - mock_reset.assert_called_once() - - def test_setting_save(self, mock_environment): - """Test saving settings to file.""" - self.manager.initialize(mock_environment) - - mock_environment["runtime"]["SettingsManager"].get_settings_file = Mock( - return_value="/tmp/test.conf" - ) - - with patch.object( - mock_environment["runtime"]["SettingsManager"], "save_settings" - ) as mock_save: - result = self.manager.handle_settings_change_with_response("save") - - assert result["success"] is True - mock_save.assert_called_once() - - def test_settings_remote_disabled(self, mock_environment): - """Test that settings commands are blocked when disabled.""" - mock_environment["runtime"]["SettingsManager"].get_setting_as_bool = Mock( - return_value=False - ) - self.manager.initialize(mock_environment) - - result = self.manager.handle_settings_change_with_response( - "set speech#rate=0.5" - ) - - assert result["success"] is False - assert "disabled" in result["message"].lower() - - -@pytest.mark.integration -@pytest.mark.remote -class TestRemoteDataFormat: - """Test remote control data format handling.""" - - def setup_method(self): - """Create RemoteManager instance for each test.""" - self.manager = RemoteManager() - - def test_command_prefix_case_insensitive(self, mock_environment): - """Test that command prefixes are case-insensitive.""" - self.manager.initialize(mock_environment) - - # All of these should work - result1 = self.manager.handle_remote_incomming_with_response( - "COMMAND say test" - ) - result2 = self.manager.handle_remote_incomming_with_response( - "command say test" - ) - result3 = self.manager.handle_remote_incomming_with_response( - "CoMmAnD say test" - ) - - assert all(r["success"] for r in [result1, result2, result3]) - - def test_setting_prefix_case_insensitive(self, mock_environment): - """Test that setting prefixes are case-insensitive.""" - self.manager.initialize(mock_environment) - - with patch.object( - mock_environment["runtime"]["SettingsManager"], "parse_setting_args" - ): - result1 = self.manager.handle_remote_incomming_with_response( - "SETTING set speech#rate=0.5" - ) - result2 = self.manager.handle_remote_incomming_with_response( - "setting set speech#rate=0.5" - ) - - assert all(r["success"] for r in [result1, result2]) - - def test_empty_data_handling(self, mock_environment): - """Test handling of empty remote data.""" - self.manager.initialize(mock_environment) - - result = self.manager.handle_remote_incomming_with_response("") - - assert result["success"] is False - assert "No data" in result["message"] - - def test_invalid_format_rejection(self, mock_environment): - """Test rejection of invalid command format.""" - self.manager.initialize(mock_environment) - - result = self.manager.handle_remote_incomming_with_response( - "invalid format without prefix" - ) - - assert result["success"] is False - assert "Unknown command format" in result["message"] - - def test_list_instances_top_level_command(self, mock_environment): - """Test listing registered Fenrir instances.""" - self.manager.initialize(mock_environment) - - with patch( - "fenrirscreenreader.core.remoteManager.remoteInstanceRegistry.list_instances", - return_value=[ - { - "pid": 123, - "ppid": 100, - "screen_driver": "ptyDriver", - "keyboard_driver": "x11Driver", - "main_socket": True, - "x11_window_id": "0x123", - "socket_files": [ - "/tmp/fenrirscreenreader-123.sock", - "/tmp/fenrirscreenreader-daemon.sock", - ], - } - ], - ): - result = self.manager.handle_remote_incomming_with_response("ls") - - assert result["success"] is True - assert "pid=123" in result["message"] - assert "x11_window_id=0x123" in result["message"] - assert "/tmp/fenrirscreenreader-123.sock" in result["message"] - - def test_remote_incoming_suppresses_command_claimed_by_other_instance( - self, mock_environment, tmp_path - ): - """Test untargeted duplicate remote commands only run in one process.""" - self.manager.initialize(mock_environment) - lock_path = tmp_path / "remote-command.lock" - event_data = "command say duplicated" - event_hash = self.manager._get_remote_command_hash(event_data) - lock_path.write_text(f"999999 {event_hash} {time.time()}\n") - - with patch.object( - self.manager, - "_get_remote_command_lock_path", - return_value=str(lock_path), - ): - self.manager.handle_remote_incomming(event_data) - - mock_environment["runtime"]["OutputManager"].speak_text.assert_not_called() - - def test_remote_incoming_allows_same_instance_repeat( - self, mock_environment, tmp_path - ): - """Test repeated direct commands to one instance are not suppressed.""" - self.manager.initialize(mock_environment) - lock_path = tmp_path / "remote-command.lock" - - with patch.object( - self.manager, - "_get_remote_command_lock_path", - return_value=str(lock_path), - ): - self.manager.handle_remote_incomming("command say repeat") - self.manager.handle_remote_incomming("command say repeat") - - assert mock_environment["runtime"]["OutputManager"].speak_text.call_count == 2 - - def test_remote_incoming_allows_different_command_after_claim( - self, mock_environment, tmp_path - ): - """Test a fresh different command is not suppressed by the shared lock.""" - self.manager.initialize(mock_environment) - lock_path = tmp_path / "remote-command.lock" - previous_hash = self.manager._get_remote_command_hash("command say first") - lock_path.write_text(f"999999 {previous_hash} {time.time()}\n") - - with patch.object( - self.manager, - "_get_remote_command_lock_path", - return_value=str(lock_path), - ): - self.manager.handle_remote_incomming("command say second") - - mock_environment["runtime"]["OutputManager"].speak_text.assert_called_once() - - -@pytest.mark.integration -@pytest.mark.remote -class TestWindowDefinition: - """Test window definition via remote control.""" - - def setup_method(self): - """Create RemoteManager instance for each test.""" - self.manager = RemoteManager() - - def test_define_window_valid_coordinates(self, mock_environment): - """Test defining a window with valid coordinates.""" - self.manager.initialize(mock_environment) - - result = self.manager.handle_command_execution_with_response( - "window 10 5 70 20" - ) - - assert result["success"] is True - mock_environment["runtime"][ - "CursorManager" - ].set_window_for_application.assert_called_once() - - # Verify the coordinates were parsed correctly - call_args = mock_environment["runtime"][ - "CursorManager" - ].set_window_for_application.call_args - start, end = call_args[0] - assert start == {"x": 10, "y": 5} - assert end == {"x": 70, "y": 20} - - def test_define_window_insufficient_coordinates(self, mock_environment): - """Test that window definition with < 4 coordinates is ignored.""" - self.manager.initialize(mock_environment) - - # Should succeed but not call set_window_for_application - result = self.manager.handle_command_execution_with_response("window 10 20") - - assert result["success"] is True - mock_environment["runtime"][ - "CursorManager" - ].set_window_for_application.assert_not_called() - - def test_reset_window(self, mock_environment): - """Test resetting window to full screen.""" - self.manager.initialize(mock_environment) - - result = self.manager.handle_command_execution_with_response("resetwindow") - - assert result["success"] is True - mock_environment["runtime"][ - "CursorManager" - ].clear_window_for_application.assert_called_once() - - -@pytest.mark.integration -@pytest.mark.remote -class TestVMenuControl: - """Test VMenu control via remote.""" - - def setup_method(self): - """Create RemoteManager instance for each test.""" - self.manager = RemoteManager() - - def test_set_vmenu(self, mock_environment): - """Test setting VMenu to specific menu.""" - self.manager.initialize(mock_environment) - - result = self.manager.handle_command_execution_with_response( - "vmenu /vim/file/save" - ) - - assert result["success"] is True - mock_environment["runtime"]["VmenuManager"].set_curr_menu.assert_called_once_with( - "/vim/file/save" - ) - - def test_reset_vmenu(self, mock_environment): - """Test resetting VMenu to default.""" - self.manager.initialize(mock_environment) - - result = self.manager.handle_command_execution_with_response("resetvmenu") - - assert result["success"] is True - mock_environment["runtime"]["VmenuManager"].set_curr_menu.assert_called_once_with() - - -@pytest.mark.integration -@pytest.mark.remote -@pytest.mark.slow -class TestRemoteControlThroughput: - """Test remote control performance characteristics.""" - - def test_rapid_say_commands(self, mock_environment): - """Test handling of rapid successive say commands.""" - manager = RemoteManager() - manager.initialize(mock_environment) - - # Send 100 rapid commands - for i in range(100): - result = manager.handle_command_execution_with_response(f"say test {i}") - assert result["success"] is True - - # Verify all were queued - assert ( - mock_environment["runtime"]["OutputManager"].speak_text.call_count == 100 - ) - - def test_rapid_setting_changes(self, mock_environment): - """Test handling of rapid setting changes.""" - manager = RemoteManager() - manager.initialize(mock_environment) - - # Rapidly change speech rate - with patch.object( - mock_environment["runtime"]["SettingsManager"], "parse_setting_args" - ) as mock_parse: - for rate in [0.5, 0.6, 0.7, 0.8, 0.9, 1.0]: - result = manager.handle_settings_change_with_response( - f"set speech#rate={rate}" - ) - assert result["success"] is True - - assert mock_parse.call_count == 6 diff --git a/tests/native_clipboard.py b/tests/native_clipboard.py new file mode 100644 index 00000000..67de2321 --- /dev/null +++ b/tests/native_clipboard.py @@ -0,0 +1,319 @@ +#!/usr/bin/env python3 +"""Private X11 clipboard/PTY fixture; never opens live speech or console input. + +Build fenrir first, then run: +xvfb-run -a env FENRIR_PRIVATE_X11=1 python3 tests/native_clipboard.py +""" + +import json +import os +from pathlib import Path +import signal +import subprocess +import sys +import tempfile +import time +import tomllib + +from native_terminal import KeyboardFixture, SpeechServer, wait_for + + +def main(): + if not os.environ.get("DISPLAY") or os.environ.get("FENRIR_PRIVATE_X11") != "1": + raise SystemExit("Run this fixture under xvfb-run with FENRIR_PRIVATE_X11=1") + repo = Path(__file__).resolve().parents[1] + binary = str(repo / "fenrir") + terminalScript = str(repo / "tests/native_terminal.py") + processes = [] + + def owner(data, target="UTF8_STRING"): + process = subprocess.Popen( + ["xclip", "-selection", "clipboard", "-in", "-quiet", "-target", target], + stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + processes.append(process) + process.stdin.write(data) + process.stdin.close() + return process + + def x_text(): + result = subprocess.run(["xclip", "-selection", "clipboard", "-out"], capture_output=True, timeout=2) + return result.stdout.decode() if result.returncode == 0 else "" + + with tempfile.TemporaryDirectory(prefix="fenrir-clipboard-", dir="/tmp") as directory: + root = Path(directory) + server = SpeechServer(root / "speech.sock") + keyboard = KeyboardFixture() + try: + keyboardDir = root / "keyboard" + keyboardDir.mkdir() + for filename in ["desktop.toml", "laptop.toml"]: + (keyboardDir / filename).write_bytes((repo / "config/keyboard" / filename).read_bytes()) + (keyboardDir / "custom.toml").write_bytes((keyboardDir / "desktop.toml").read_bytes()) + clipFile = root / "clipboard.txt" + (root / "settings.toml").write_text( + 'version=3\n[presentation.sound]\nenabled=false\n' + f'[clipboard]\nsync=false\nexport_path="{clipFile}"\n' + f'[remote]\nsocket_file="{root / "remote.sock"}"\n' + f'[general]\nscript_path="{root / "scripts"}"\n' + ) + shell = root / "shell" + shell.write_text( + f'#!{sys.executable}\nimport runpy, sys\nsys.argv = [{terminalScript!r}, "--fixture-shell"]\n' + f'runpy.run_path({terminalScript!r}, run_name="__main__")\n' + ) + shell.chmod(0o700) + (root / "home").mkdir() + environment = dict(os.environ, SHELL=str(shell), FENRIR_FIXTURE_DIR=str(root), HOME=str(root / "home"), + TMPDIR=str(root), + SPEECHD_ADDRESS="unix_socket:" + str(root / "speech.sock")) + + def key(*names): + keyboard.key(*names) + + def spoken(text, start): + wait_for(lambda: text in server.events[start:], f"speech {text[:80]!r}: {[item[:80] for item in server.events[-10:]]}") + + def action(chord, text): + start = len(server.events) + key(chord) + spoken(text, start) + + def start(layout): + for filename in ["started", "finished", "size", "input"]: + (root / filename).unlink(missing_ok=True) + (root / "mode").write_text("laptop" if layout == "laptop" else "fixture") + process = subprocess.Popen(["xterm", "-geometry", "80x25", "-T", "ClipboardFixture", "-e", + sys.executable, terminalScript, "--outer", str(root)], env=environment) + processes.append(process) + wait_for(lambda: (root / "started").exists(), "reader launch") + state = json.loads((root / "started").read_text()) + subprocess.run(["xdotool", "windowfocus", "--sync", state["window"]], check=True) + wait_for(lambda: (root / "size").exists(), "fixture shell") + return state + + def finish(): + key("Insert+q") + wait_for(lambda: (root / "finished").exists(), "reader quit") + assert json.loads((root / "finished").read_text()) == {"status": 0, "restored": True, "flagsRestored": True} + + def emoji_trial(): + baselineInput = (root / "input").read_bytes() + clipFile.write_text("original clipboard") + action("Insert+F5", "original clipboard") + action("Insert+Control_L+e", "Emoji Menu. All tab") + key("Right") + key("Tab", "Down", "Right", "Up", "Left", "Shift_L+Tab") + action("Right", "Nature tab") + key("Escape") + action("Insert+F6", "clipboard exported to file") + assert clipFile.read_text() == "original clipboard", "category browsing changed clipboard" + + action("Insert+Control_L+e", "Emoji Menu. All tab") + key("Tab", "z", "z", "z") + wait_for(lambda: any("No matches" in text for text in server.events[-8:]), "empty emoji results") + key("Return") + # Still captured: clear the filter and select the first J result. + key("Control_L+BackSpace", "j", "Return") + wait_for(lambda: "Emoji Menu closed" in server.events[-8:], "emoji selection closes menu") + action("Insert+F6", "clipboard exported to file") + assert clipFile.read_text() == "šŸŽƒ", clipFile.read_text() + action("Insert+Next", "original clipboard") + action("Insert+Prior", "šŸŽƒ") + + # A ZWJ sequence must survive selection, export and PTY paste. + action("Insert+Control_L+e", "Emoji Menu. All tab") + key("Tab", "b", "l", "a", "c", "k", "space", "c", "a", "t", "Return") + wait_for(lambda: "Emoji Menu closed" in server.events[-8:], "ZWJ emoji selection") + action("Insert+F6", "clipboard exported to file") + assert clipFile.read_text() == "šŸˆā€ā¬›", clipFile.read_text() + assert (root / "input").read_bytes() == baselineInput, "emoji menu keys reached PTY" + action("Insert+v", "paste clipboard") + expectedInput = baselineInput + "šŸˆā€ā¬›".encode() + wait_for(lambda: (root / "input").read_bytes() == expectedInput, "ZWJ emoji paste") + # Cancel a changed filter and ensure selection/history stay intact. + action("Insert+Control_L+e", "Emoji Menu. All tab") + key("Tab", "j", "BackSpace", "j", "Escape") + action("Insert+F6", "clipboard exported to file") + assert clipFile.read_text() == "šŸˆā€ā¬›" + (root / "input").write_bytes(baselineInput) + print("PASS: emoji shortcut, category/grid navigation, prefix/empty search, cancellation, history, exact ZWJ export/paste and no modal key leakage") + + state = start("desktop") + emoji_trial() + action("Insert+Control_L+Shift_L+c", "clipboard cleared") + action("Insert+Shift_L+c", "clipboard empty") + key("Insert+KP_Home", "Insert+KP_Left") + action("Insert+x", "set mark") + key("KP_Next", "KP_Next", "Insert+x") + action("Insert+Shift_L+x", "alp") + action("Insert+c", "alp") + action("Insert+Shift_L+c", "alp") + action("Insert+v", "paste clipboard") + wait_for(lambda: (root / "input").read_bytes() == b"alp", "clipboard paste delivered once") + action("KP_Prior", "bravo") + action("Insert+Control_L+c", "bravo") + action("Insert+Next", "alp") + action("Insert+Prior", "bravo") + action("Insert+End", "alp") + action("Insert+Home", "bravo") + action("Insert+F6", "clipboard exported to file") + assert clipFile.read_text() == "bravo" and clipFile.stat().st_mode & 0o777 == 0o600 + clipFile.write_text("from file Ć©ē•Œ") + action("Insert+F5", "from file Ć©ē•Œ") + action("Insert+F8", "exported to the X session.") + assert x_text() == "from file Ć©ē•Œ" + owner("from X Ć©ē•Œ".encode()) + wait_for(lambda: x_text() == "from X Ć©ē•Œ", "external owner") + action("Insert+F7", "from X Ć©ē•Œ") + largeText = "large Ć©ē•Œ\n" * 150000 + owner(largeText.encode()) + wait_for(lambda: x_text() == largeText, "large external selection") + action("Insert+F7", largeText) + action("Insert+F8", "exported to the X session.") + assert x_text() == largeText + owner("from X Ć©ē•Œ".encode()) + wait_for(lambda: x_text() == "from X Ć©ē•Œ", "external owner after INCR") + action("Insert+F7", "from X Ć©ē•Œ") + owner(b"not-text", "image/png") + time.sleep(0.1) + action("Insert+F7", "No text found in clipboard or no accessible display") + action("Insert+Shift_L+c", "from X Ć©ē•Œ") + print("PASS: desktop mark/preview/copy, last echo, history navigation, clear, PTY paste and file/X import/export shortcuts") + + def set_sync(enabled): + action("Insert+f", "Fenrir Menu. Presentation tab") + key("Right", "Tab") + startIndex = len(server.events) + key("space") + spoken("Clipboard sync " + ("checked" if enabled else "unchecked"), startIndex) + key("Tab", "Tab", "Tab", "Return") + wait_for(lambda: tomllib.loads((root / "settings.toml").read_text())["clipboard"]["sync"] == enabled, "sync setting saved") + + set_sync(True) + owner(b"automatic external") + wait_for(lambda: x_text() == "automatic external", "external owner before sync") + time.sleep(0.7) + action("Insert+Shift_L+c", "automatic external") + action("KP_Up", "bravo") + action("Insert+Control_L+c", "bravo") + wait_for(lambda: x_text() == "bravo", "automatic Fenrir export") + # Kill only this fixture reader's persistent xclip owner. + ownerPids = subprocess.check_output(["pgrep", "-P", str(state["pid"]), "-f", "[x]clip.*-quiet"], text=True).split() + assert len(ownerPids) == 1, ownerPids + os.kill(int(ownerPids[0]), signal.SIGKILL) + owner(b"after owner restart") + wait_for(lambda: x_text() == "after owner restart", "new owner after owner death") + time.sleep(1.3) + action("Insert+Shift_L+c", "after owner restart") + action("Insert+Control_L+Shift_L+c", "clipboard cleared") + time.sleep(0.7) + action("Insert+Shift_L+c", "clipboard empty") + set_sync(False) + owner(b"sync is off") + time.sleep(0.7) + action("Insert+Shift_L+c", "clipboard empty") + finish() + print("PASS: live sync checkbox, both sync directions, owner restart, clear without reimport and disabled sync") + + start("laptop") + emoji_trial() + owner(b"laptop clipboard") + wait_for(lambda: x_text() == "laptop clipboard", "laptop owner") + action("Insert+F7", "laptop clipboard") + action("Insert+Shift_L+c", "laptop clipboard") + action("Insert+Control_L+u", "laptop clipboard") + action("Insert+v", "paste clipboard") + wait_for(lambda: (root / "input").read_bytes() == b"laptop clipboard", "laptop paste") + finish() + historyPath = root / "home/.local/state/fenrir/clipboard/terminal.json" + assert not historyPath.exists(), "default-off clipboard created a history file" + for layout in ["desktop", "laptop"]: + (root / "settings.toml").write_text( + 'version=3\n[presentation.sound]\nenabled=false\n' + f'[clipboard]\nsync=false\npersistent=false\nnumber_of_clipboards=3\nexport_path="{clipFile}"\n' + f'[remote]\nsocket_file="{root / "remote.sock"}"\n' + ) + start(layout) + wait_for(lambda: any("prompt" in item for item in server.events[-10:]), "persistence trial readiness") + for word in ["old", "middle Ć©ē•Œ", "new šŸŽƒ"]: + clipFile.write_text(word) + action("Insert+F5", word) + action("Insert+Next", "middle Ć©ē•Œ") + # Save persistence and a smaller history limit through the form. + action("Insert+f", "Fenrir Menu. Presentation tab") + key("Right", "Tab", "Tab") + startIndex = len(server.events) + key("space") + spoken("Enable Persistent Clipboard checked", startIndex) + key("Tab") + startIndex = len(server.events) + key("Down") + spoken("History items 2", startIndex) + key("Tab", "Return") + wait_for(lambda: historyPath.exists(), "enabled history write") + wait_for(lambda: json.loads(historyPath.read_text())["entries"] == ["new šŸŽƒ", "middle Ć©ē•Œ"], "live history resize") + assert historyPath.stat().st_mode & 0o777 == 0o600 + assert historyPath.parent.stat().st_mode & 0o777 == 0o700 + # Cancel must not apply a smaller limit or change disk history. + action("Insert+f", "Fenrir Menu. Presentation tab") + key("Right", "Tab", "Tab", "Tab") + startIndex = len(server.events) + key("Down") + spoken("History items 1", startIndex) + key("Escape") + action("Insert+Shift_L+c", "middle Ć©ē•Œ") + assert len(json.loads(historyPath.read_text())["entries"]) == 2 + assert tomllib.loads((root / "settings.toml").read_text())["clipboard"]["number_of_clipboards"] == 2 + finish() + start(layout) + action("Insert+Shift_L+c", "middle Ć©ē•Œ") + action("Insert+Prior", "new šŸŽƒ") + action("Insert+Next", "middle Ć©ē•Œ") + action("Insert+Control_L+Shift_L+c", "clipboard cleared") + wait_for(lambda: not historyPath.exists(), "clear deletes saved history") + finish() + start(layout) + action("Insert+Shift_L+c", "clipboard empty") + clipFile.write_text("memory retained") + action("Insert+F5", "memory retained") + wait_for(lambda: historyPath.exists(), "new copy persists after clear") + action("Insert+f", "Fenrir Menu. Presentation tab") + key("Right", "Tab", "Tab") + startIndex = len(server.events) + key("space") + spoken("Enable Persistent Clipboard unchecked", startIndex) + key("Tab", "Tab", "Return") + wait_for(lambda: not historyPath.exists(), "disabling deletes saved history") + action("Insert+Shift_L+c", "memory retained") + finish() + start(layout) + action("Insert+Shift_L+c", "clipboard empty") + clipFile.write_text("disabled copy") + action("Insert+F5", "disabled copy") + finish() + assert not historyPath.exists(), "disabled copy was persisted" + print("PASS: persistent clipboard checkbox and live limit in both layouts, Unicode/order/selection across restarts, private files, clear/restart, disable cleanup and memory-only copies") + assert not server.errors, server.errors + print("PASS: laptop clipboard import, current entry, distinct last-echo shortcut and paste; terminal restoration in both layouts") + except BaseException: + print("Recent speech:", [item[:80] for item in server.events[-30:]], file=sys.stderr) + for filename in ["reader-errors", "timing.log"]: + if (root / filename).exists(): + print(filename + ":", (root / filename).read_text()[-4000:], file=sys.stderr) + raise + finally: + for process in reversed(processes): + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + server.close() + keyboard.close() + + +if __name__ == "__main__": + main() diff --git a/tests/native_hardware_settings.py b/tests/native_hardware_settings.py new file mode 100644 index 00000000..087790be --- /dev/null +++ b/tests/native_hardware_settings.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Private-X11 driver-dependent settings and hardware preview safety trial.""" + +import fcntl +import json +import os +from pathlib import Path +import select +import subprocess +import sys +import tempfile +import tomllib + +from native_terminal import KeyboardFixture, SpeechServer, wait_for + + +def main(): + if not os.environ.get("DISPLAY") or os.environ.get("FENRIR_PRIVATE_X11") != "1": + raise SystemExit("Use xvfb-run -a env FENRIR_PRIVATE_X11=1 python3 tests/native_hardware_settings.py") + repository = Path(__file__).resolve().parents[1] + fixture = str(repository / "tests/native_terminal.py") + binary = os.environ.get("FENRIR_TEST_BINARY", str(repository / "fenrir")) + with tempfile.TemporaryDirectory(prefix="fenrir-hardware-settings-", dir="/tmp") as directory: + suiteRoot = Path(directory) + libraryRoot = suiteRoot / "usb" + libraryRoot.mkdir() + subprocess.run(["cc", "-shared", "-fPIC", "-o", str(libraryRoot / "libusb-1.0.so.0"), + str(repository / "internal/speech/testdata/usb_fixture.c")], check=True) + keyboard = KeyboardFixture() + try: + for layout in ("desktop", "laptop"): + root = suiteRoot / layout + root.mkdir() + (root / "home").mkdir() + (root / "keyboard").mkdir() + layoutName = "laptop.toml" if layout == "laptop" else "custom.toml" + (root / "keyboard" / layoutName).write_bytes((repository / "config/keyboard" / (layout + ".toml")).read_bytes()) + if layout == "laptop": + (root / "mode").write_text("laptop") + shell = root / "shell" + shell.write_text(f'#!{sys.executable}\nimport runpy, sys\nsys.argv = [{fixture!r}, "--fixture-shell"]\nrunpy.run_path({fixture!r}, run_name="__main__")\n') + shell.chmod(0o700) + settings = root / "settings.toml" + settings.write_text('version=3\n[presentation.speech]\nrate=0\n' + '[presentation.sound]\nenabled=false\n' + '[reading]\npunctuation_level="none"\nspellcheck=false\n' + f'[remote]\nsocket_file="{root / "remote.sock"}"\n') + original = settings.read_bytes() + master, slave = os.openpty() + fcntl.fcntl(master, fcntl.F_SETFL, os.O_NONBLOCK) + server = SpeechServer(root / "speech.sock") + environment = dict(os.environ, FENRIR_TEST_BINARY=binary, FENRIR_FIXTURE_DIR=str(root), + FENRIR_TEST_READER_TIMEOUT="120", + HOME=str(root / "home"), TMPDIR=str(root), LOGNAME="Username", USER="Username", + SPEECHD_ADDRESS="unix_socket:" + str(root / "speech.sock"), + LD_LIBRARY_PATH=str(libraryRoot), USB_FIXTURE_MODE="ok", USB_FIXTURE_LOG=str(root / "usb.log")) + terminal = subprocess.Popen(["xterm", "-geometry", "80x25", "-e", sys.executable, fixture, "--outer", str(root)], env=environment) + captured = bytearray() + + def spoken(text, start=0): + wait_for(lambda: any(text in item for item in server.events[start:]), f"speech {text!r}; recent={server.events[-8:]}") + + def focus(label): + for _ in range(40): + start = len(server.events) + keyboard.key("Tab") + text = wait_for(lambda: next((item for item in reversed(server.events[start:]) if item != "CANCEL"), None), "next control") + if label in text: + return text + raise AssertionError("Control not reachable: " + label) + + def output(driver): + if driver == "dectalk-usb": + log = root / "usb.log" + return b"".join(bytes.fromhex(line.partition(":")[2]) for line in log.read_text().splitlines() if line.startswith("write-02:")) if log.exists() else b"" + while select.select([master], [], [], 0)[0]: + captured.extend(os.read(master, 65536)) + return bytes(captured) + + try: + wait_for(lambda: (root / "started").exists(), "reader startup") + state = json.loads((root / "started").read_text()) + subprocess.run(["xdotool", "windowfocus", "--sync", state["window"]], check=True) + spoken("prompt") + for index, (driver, driverLabel) in enumerate((("dectalk", "DECtalk"), ("dectalk-usb", "DECtalk USB"), ("litetalk", "LiteTalk"), ("doubletalk", "DoubleTalk LT"), ("tripletalk", "TripleTalk")), 1): + start = len(server.events) + keyboard.key("Insert+f") + spoken("Fenrir Menu. Presentation tab", start) + focus("Driver combo box Speech Dispatcher") + keyboard.key("Home", *("Down",) * index) + start = len(server.events) + keyboard.key("Tab") + spoken("Pitch" if driver == "dectalk-usb" else "Serial port entry", start) + if driver != "dectalk-usb": + keyboard.key("ctrl+a") + subprocess.run(["xdotool", "type", "--delay", "1", os.ttyname(slave)], check=True) + start = len(server.events) + keyboard.key("Tab") + spoken("Baud rate combo box default", start) + keyboard.key("Home", *("Down",) * 5) + focus("Test synthesizer button") + if driver == "dectalk": + # A missing port must report through the still-working + # Speech Dispatcher client, without saving anything. + focus("Serial port entry") + keyboard.key("ctrl+a") + subprocess.run(["xdotool", "type", "--delay", "1", str(root / "missing-port")], check=True) + focus("Test synthesizer button") + start = len(server.events) + keyboard.key("Return") + spoken("Voice test failed", start) + assert settings.read_bytes() == original + focus("Serial port entry") + keyboard.key("ctrl+a") + subprocess.run(["xdotool", "type", "--delay", "1", os.ttyname(slave)], check=True) + focus("Test synthesizer button") + before = len(output(driver)) + keyboard.key("Return") + phrase = b"Voice test: The quick brown fox jumps over the lazy dog." + wait_for(lambda: phrase in output(driver)[before:], f"{driver} temporary preview bytes") + assert settings.read_bytes() == original, "test wrote settings" + assert not (root / "input").exists() or not (root / "input").read_bytes(), "form keys leaked" + if driver == "tripletalk": + focus("OK button") + start = len(server.events) + keyboard.key("Return") + spoken("Fenrir Menu closed", start) + saved = tomllib.loads(settings.read_text())["presentation"]["speech"] + assert saved["driver"] == driver and saved["hardware_device"] == os.ttyname(slave) and saved["hardware_baud_rate"] == 19200, saved + else: + focus("Driver combo box " + driverLabel) + keyboard.key("Home") + start = len(server.events) + keyboard.key("Tab") + spoken("Speech Dispatcher module combo box", start) + keyboard.key("Shift_L+Tab") + keyboard.key("Escape") + assert settings.read_bytes() == original, "cancel wrote settings" + print(f"PASS: {driver}/{layout}: dynamic fields, temporary hardware test, active speech preserved and save/cancel", flush=True) + start = len(server.events) + keyboard.key("Insert+i" if layout == "laptop" else "KP_Up") + spoken("prompt", start) + keyboard.key("Insert+q") + wait_for(lambda: (root / "finished").exists(), "reader shutdown") + assert json.loads((root / "finished").read_text()) == {"status": 0, "restored": True, "flagsRestored": True} + assert not server.errors, server.errors + finally: + terminal.terminate() + try: + terminal.wait(timeout=3) + except subprocess.TimeoutExpired: + terminal.kill() + terminal.wait() + server.close() + os.close(master) + os.close(slave) + finally: + keyboard.close() + + +if __name__ == "__main__": + main() diff --git a/tests/native_hardware_speech.py b/tests/native_hardware_speech.py new file mode 100644 index 00000000..7b03770a --- /dev/null +++ b/tests/native_hardware_speech.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Private X11 hardware-speech integration using PTYs and fake libusb only.""" + +import fcntl +import json +import os +from pathlib import Path +import select +import subprocess +import sys +import tempfile +import tomllib + +from native_terminal import KeyboardFixture, wait_for + + +def main(): + if not os.environ.get("DISPLAY") or os.environ.get("FENRIR_PRIVATE_X11") != "1": + raise SystemExit("Use xvfb-run -a env FENRIR_PRIVATE_X11=1 python3 tests/native_hardware_speech.py") + repository = Path(__file__).resolve().parents[1] + fixture = str(repository / "tests/native_terminal.py") + binary = os.environ.get("FENRIR_TEST_BINARY", str(repository / "fenrir")) + with tempfile.TemporaryDirectory(prefix="fenrir-hardware-", dir="/tmp") as directory: + suiteRoot = Path(directory) + libraryRoot = suiteRoot / "usb" + libraryRoot.mkdir() + subprocess.run(["cc", "-shared", "-fPIC", "-o", str(libraryRoot / "libusb-1.0.so.0"), + str(repository / "internal/speech/testdata/usb_fixture.c")], check=True) + keyboard = KeyboardFixture() + try: + for driver in ("dectalk", "dectalk-usb", "litetalk", "doubletalk", "tripletalk"): + for layout in ("desktop", "laptop"): + root = suiteRoot / (driver + "-" + layout) + root.mkdir() + (root / "home").mkdir() + (root / "keyboard").mkdir() + layoutName = "laptop.toml" if layout == "laptop" else "custom.toml" + (root / "keyboard" / layoutName).write_bytes((repository / "config/keyboard" / (layout + ".toml")).read_bytes()) + if layout == "laptop": + (root / "mode").write_text("laptop") + shell = root / "shell" + shell.write_text(f'#!{sys.executable}\nimport runpy, sys\nsys.argv = [{fixture!r}, "--fixture-shell"]\nrunpy.run_path({fixture!r}, run_name="__main__")\n') + shell.chmod(0o700) + master, slave = os.openpty() + fcntl.fcntl(master, fcntl.F_SETFL, os.O_NONBLOCK) + settings = root / "settings.toml" + settings.write_text('version=3\n[presentation.sound]\nenabled=false\n' + '[presentation.speech]\n' + f'driver="{driver}"\nhardware_device="{os.ttyname(slave)}"\nrate=0\n' + '[reading]\npunctuation_level="none"\nspellcheck=false\n' + f'[remote]\nsocket_file="{root / "remote.sock"}"\n') + environment = dict(os.environ, FENRIR_TEST_BINARY=binary, FENRIR_FIXTURE_DIR=str(root), + HOME=str(root / "home"), TMPDIR=str(root), LOGNAME="Username", USER="Username", + SPEECHD_ADDRESS="unix_socket:" + str(root / "unused-speech.sock"), + LD_LIBRARY_PATH=str(libraryRoot), USB_FIXTURE_MODE="ok", USB_FIXTURE_LOG=str(root / "usb.log")) + terminal = subprocess.Popen(["xterm", "-geometry", "80x25", "-e", sys.executable, fixture, "--outer", str(root)], env=environment) + captured = bytearray() + + def output(): + if driver == "dectalk-usb": + log = root / "usb.log" + return b"".join(bytes.fromhex(line.partition(":")[2]) for line in log.read_text().splitlines() if line.startswith("write-02:")) if log.exists() else b"" + while select.select([master], [], [], 0)[0]: + captured.extend(os.read(master, 65536)) + return bytes(captured) + + def expect(text, start=0): + wait_for(lambda: text in output()[start:], f"{driver}/{layout}: {text!r}; errors=" + ((root / "reader-errors").read_text() if (root / "reader-errors").exists() else "")) + + try: + wait_for(lambda: (root / "started").exists(), "reader startup") + state = json.loads((root / "started").read_text()) + subprocess.run(["xdotool", "windowfocus", "--sync", state["window"]], check=True) + expect(b"prompt") + start = len(output()) + keyboard.key("Insert+Right", "Insert+Right", "Insert+Right") + expect(b"speech rate 0", start) # Hardware wraps after volume. + start = len(output()) + keyboard.key("Insert+Up", "Insert+ctrl+s") + expect(b"Settings saved", start) + wait_for(lambda: tomllib.loads(settings.read_text())["presentation"]["speech"]["rate"] == 10, "hardware quick save") + start = len(output()) + keyboard.key("Insert+f") + expect(b"Fenrir Menu", start) + terminator = b"\x0b" if driver.startswith("dectalk") else b"\r" + for _ in range(24): + start = len(output()) + keyboard.key("Tab") + wait_for(lambda: terminator in output()[start:], "hardware form control") + if b"Test synthesizer button" in output()[start:]: + break + else: + raise AssertionError("Hardware test button not reachable") + start = len(output()) + keyboard.key("Return") + expect(b"Voice test: The quick brown fox jumps over the lazy dog.", start) + start = len(output()) + keyboard.key("Escape") + expect(b"Fenrir Menu closed", start) + start = len(output()) + keyboard.key("Insert+i" if layout == "laptop" else "KP_Up") + expect(b"prompt", start) + assert not (root / "input").exists() or not (root / "input").read_bytes(), "reader chords leaked to the child" + keyboard.key("Insert+q") + wait_for(lambda: (root / "finished").exists(), "reader shutdown") + result = json.loads((root / "finished").read_text()) + assert result == {"status": 0, "restored": True, "flagsRestored": True}, result + if driver == "dectalk-usb": + assert (root / "usb.log").read_text().endswith("release\nclose\nexit\n") + print(f"PASS: {driver}/{layout}: startup, quick controls, save, owned-connection preview, review, input isolation and cleanup", flush=True) + finally: + terminal.terminate() + try: + terminal.wait(timeout=3) + except subprocess.TimeoutExpired: + terminal.kill() + terminal.wait() + os.close(master) + os.close(slave) + finally: + keyboard.close() + + +if __name__ == "__main__": + main() diff --git a/tests/native_shared_clipboard.py b/tests/native_shared_clipboard.py new file mode 100644 index 00000000..a14a0d84 --- /dev/null +++ b/tests/native_shared_clipboard.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Two real readers with private X11, speech, sockets and shared fixture HOME. + +Run sequentially after the other native fixtures, under xvfb-run with +FENRIR_PRIVATE_X11=1. Does not access live readers or personal clipboard data. +""" + +import json +import os +from pathlib import Path +import socket +import subprocess +import sys +import tempfile +import time + +from native_terminal import KeyboardFixture, SpeechServer, wait_for + + +def main(): + if not os.environ.get("DISPLAY") or os.environ.get("FENRIR_PRIVATE_X11") != "1": + raise SystemExit("Run under xvfb-run with FENRIR_PRIVATE_X11=1") + repo = Path(__file__).resolve().parents[1] + script = repo / "tests/native_terminal.py" + processes, servers = [], [] + keyboard = KeyboardFixture() + with tempfile.TemporaryDirectory(prefix="fenrir-shared-clipboard-", dir="/tmp") as directory: + base = Path(directory) + home = base / "home" + home.mkdir() + historyPath = home / ".local/state/fenrir/clipboard/terminal.json" + + def start(name): + root = base / name + root.mkdir() + (root / "keyboard").mkdir() + (root / "keyboard/custom.toml").write_bytes((repo / "config/keyboard/desktop.toml").read_bytes()) + (root / "settings.toml").write_text( + 'version=3\n[presentation.sound]\nenabled=false\n' + f'[clipboard]\npersistent=true\nsync=false\nexport_path="{root / "export.txt"}"\n' + f'[remote]\nsocket_file="{root / "remote.sock"}"\n' + f'[general]\nscript_path="{root / "scripts"}"\n' + ) + (root / "shell").write_text( + f'#!{sys.executable}\nimport runpy, sys\nsys.argv = [{str(script)!r}, "--fixture-shell"]\n' + f'runpy.run_path({str(script)!r}, run_name="__main__")\n' + ) + (root / "shell").chmod(0o700) + server = SpeechServer(root / "speech.sock") + servers.append(server) + environment = dict(os.environ, HOME=str(home), TMPDIR=str(root), + FENRIR_FIXTURE_DIR=str(root), SPEECHD_ADDRESS="unix_socket:" + str(root / "speech.sock")) + process = subprocess.Popen(["xterm", "-geometry", "80x25", "-T", name, "-e", + sys.executable, str(script), "--outer", str(root)], env=environment) + processes.append(process) + wait_for(lambda: (root / "size").exists(), "reader shell ready") + state = json.loads((root / "started").read_text()) + return root, server, state + + def remote(reader, text): + with socket.socket(socket.AF_UNIX) as connection: + connection.settimeout(2) + connection.connect(str(reader[0] / "remote.sock")) + connection.sendall(("COMMAND " + text + "\n").encode()) + connection.shutdown(socket.SHUT_WR) + reply = connection.recv(4096) + assert not reply.strip(), reply + + def action(reader, chord, expected): + subprocess.run(["xdotool", "windowfocus", "--sync", reader[2]["window"]], check=True) + startIndex = len(reader[1].events) + keyboard.key(chord) + wait_for(lambda: expected in reader[1].events[startIndex:], "speech " + expected) + + def export_matches(reader, expected): + remote(reader, "EXPORTCLIPBOARD") + time.sleep(0.03) + path = reader[0] / "export.txt" + return path.exists() and path.read_text() == expected + + try: + first, second = start("FirstFixture"), start("SecondFixture") + remote(first, "CLIPBOARD first copy") + wait_for(lambda: export_matches(second, "first copy"), "first copy reaches second reader") + remote(second, "CLIPBOARD second copy") + wait_for(lambda: json.loads(historyPath.read_text())["entries"] == ["second copy", "first copy"], + "both copies retained") + wait_for(lambda: export_matches(first, "first copy"), "first reader retains selection") + action(first, "Insert+Home", "second copy") + action(first, "Insert+Next", "first copy") + action(second, "Insert+Control_L+Shift_L+c", "clipboard cleared") + wait_for(lambda: not historyPath.exists(), "shared snapshot removed") + time.sleep(0.3) + action(first, "Insert+Shift_L+c", "clipboard empty") + remote(first, "CLIPBOARD survivor copy") + wait_for(lambda: export_matches(second, "survivor copy"), "copy after peer Clear") + remote(first, "QUITAPPLICATION") + wait_for(lambda: (first[0] / "finished").exists(), "first reader exits") + assert json.loads((first[0] / "finished").read_text())["status"] == 0 + remote(second, "CLIPBOARD after exit") + wait_for(lambda: json.loads(historyPath.read_text())["entries"] == ["after exit", "survivor copy"], + "remaining reader persists after peer exit") + remote(second, "QUITAPPLICATION") + wait_for(lambda: (second[0] / "finished").exists(), "second reader exits") + for reader in [first, second]: + finished = json.loads((reader[0] / "finished").read_text()) + assert finished == {"status": 0, "restored": True, "flagsRestored": True}, finished + assert not reader[1].errors, reader[1].errors + assert "clipboard history" not in (reader[0] / "reader-errors").read_text().lower() + print("PASS: two real readers share persistent copies and Clear, retain local selection, survive peer exit and restore terminals without clipboard errors") + except BaseException: + for server in servers: + print("Recent speech:", server.events[-12:], file=sys.stderr) + raise + finally: + for process in reversed(processes): + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + for server in servers: + server.close() + keyboard.close() + + +if __name__ == "__main__": + main() diff --git a/tests/native_sound.py b/tests/native_sound.py new file mode 100644 index 00000000..e2744516 --- /dev/null +++ b/tests/native_sound.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Private X11 sound settings/startup checks; ALSA null never plays audio.""" + +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import tomllib + +from native_terminal import KeyboardFixture, SpeechServer, wait_for + + +def main(): + if not os.environ.get("DISPLAY") or os.environ.get("FENRIR_PRIVATE_X11") != "1": + raise SystemExit("Use xvfb-run -a env FENRIR_PRIVATE_X11=1 python3 tests/native_sound.py") + repository = Path(__file__).resolve().parents[1] + fixture = str(repository / "tests/native_terminal.py") + binary = os.environ.get("FENRIR_TEST_BINARY", str(repository / "fenrir")) + with tempfile.TemporaryDirectory(prefix="fenrir-sound-", dir="/tmp") as directory: + suiteRoot = Path(directory) + keyboard = KeyboardFixture() + try: + for layout in ("desktop", "laptop"): + for scenario in ("bundled", "pack", "missing-pack", "missing-device"): + root = suiteRoot / (layout + "-" + scenario) + root.mkdir() + (root / "home").mkdir() + (root / "keyboard").mkdir() + layoutName = "laptop.toml" if layout == "laptop" else "custom.toml" + (root / "keyboard" / layoutName).write_bytes((repository / "config/keyboard" / (layout + ".toml")).read_bytes()) + if layout == "laptop": + (root / "mode").write_text("laptop") + shell = root / "shell" + shell.write_text(f'#!{sys.executable}\nimport runpy, sys\nsys.argv = [{fixture!r}, "--fixture-shell"]\nrunpy.run_path({fixture!r}, run_name="__main__")\n') + shell.chmod(0o700) + packRoot = root / "home/.local/stormux/fenrir/sounds" + pack = packRoot / "fixture-pack" + pack.mkdir(parents=True) + # An intentionally partial pack: blank falls back to speech. + (pack / "soundicons.toml").write_text("[icons]\nready='ready.wav'\nblank='missing.wav'\n") + (pack / "ready.wav").write_bytes((repository / "config/sound/default/ScreenReaderOn.wav").read_bytes()) + theme = "default" if scenario == "bundled" else ("unavailable-fixture-pack" if scenario == "missing-pack" else "fixture-pack") + device = "fenrir-nonexistent-fixture" if scenario == "missing-device" else "null" + settings = root / "settings.toml" + settings.write_text('version=3\n[presentation.sound]\nenabled=true\ndriver="alsa"\n' + f'device="{device}"\ntheme="{theme}"\nvolume=0.7\n' + '[reading]\npunctuation_level="none"\nspellcheck=false\n' + f'[remote]\nsocket_file="{root / "remote.sock"}"\n') + server = SpeechServer(root / "speech.sock") + environment = dict(os.environ, FENRIR_TEST_BINARY=binary, FENRIR_FIXTURE_DIR=str(root), + HOME=str(root / "home"), TMPDIR=str(root), LOGNAME="Username", USER="Username", + SPEECHD_ADDRESS="unix_socket:" + str(root / "speech.sock"), + PULSE_SERVER="unix:" + str(root / "unused-pulse.sock")) + terminal = subprocess.Popen(["xterm", "-geometry", "80x25", "-e", sys.executable, fixture, "--outer", str(root)], env=environment, cwd=root) + + def spoken(text, start=0): + wait_for(lambda: any(text in item for item in server.events[start:]), + f"{layout}/{scenario}: {text!r}; recent={server.events[-8:]}") + + def focus_control(label): + for _ in range(64): + before = len(server.events) + keyboard.key("Tab") + text = wait_for(lambda: next((item for item in reversed(server.events[before:]) if item != "CANCEL"), None), "next form control") + if label in text: + return + raise AssertionError("Form control missing: " + label) + + try: + wait_for(lambda: (root / "started").exists(), "reader startup") + state = json.loads((root / "started").read_text()) + subprocess.run(["xdotool", "windowfocus", "--sync", state["window"]], check=True) + spoken("prompt") + before = len(server.events) + keyboard.key("Insert+f") + spoken("Fenrir Menu", before) + focus_control("Select sound theme combo box " + theme) + if scenario != "bundled": + before = len(server.events) + keyboard.key("Home") + spoken("default", before) + focus_control("OK button") + before = len(server.events) + keyboard.key("Return") + spoken("Fenrir Menu closed", before) + saved = tomllib.loads(settings.read_text())["presentation"]["sound"] + assert saved["driver"] == "alsa" and saved["theme"] == "default" and saved["device"] == device, saved + before = len(server.events) + keyboard.key("Insert+i" if layout == "laptop" else "KP_Up") + spoken("prompt", before) + assert not (root / "input").exists() or not (root / "input").read_bytes(), "form/review keys leaked to PTY" + keyboard.key("a") + wait_for(lambda: (root / "input").read_bytes() == b"a", "typing after sound settings") + keyboard.key("Insert+q") + wait_for(lambda: (root / "finished").exists(), "reader shutdown") + result = json.loads((root / "finished").read_text()) + assert result == {"status": 0, "restored": True, "flagsRestored": True}, result + errors = (root / "reader-errors").read_text() + (root / "timing.log").read_text() + if scenario == "bundled": + assert "sound unavailable or incomplete" not in errors, errors + else: + assert "sound unavailable or incomplete" in errors, errors + assert 'SET SELF CLIENT_NAME "fenrir:fenrir:main"' in server.settings, server.settings + assert 'SET SELF CLIENT_NAME "fenrir:fenrir:control"' in server.settings, server.settings + assert not server.errors, server.errors + print(f"PASS: {layout}/{scenario}: audio loading, theme selection/save, review, typing and clean shutdown", flush=True) + finally: + terminal.terminate() + try: + terminal.wait(timeout=3) + except subprocess.TimeoutExpired: + terminal.kill() + terminal.wait() + server.close() + finally: + keyboard.close() + + +if __name__ == "__main__": + main() diff --git a/tests/native_terminal.py b/tests/native_terminal.py new file mode 100644 index 00000000..1b25a719 --- /dev/null +++ b/tests/native_terminal.py @@ -0,0 +1,2110 @@ +#!/usr/bin/env python3 +"""Private-X11 PTY acceptance fixture; never connects to live speech/input. + +Run with: xvfb-run -a env FENRIR_PRIVATE_X11=1 python3 tests/native_terminal.py +This verifies protocol output and XTest input, not audible speech or Cthulhu. +""" + +import fcntl +import ctypes +import json +import os +from pathlib import Path +import signal +import socket +import struct +import subprocess +import sys +import tempfile +import termios +import threading +import time +import tomllib +import tty + + +def wait_for(predicate, label, timeout=8): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + result = predicate() + if result: + return result + time.sleep(0.02) + raise AssertionError("Timed out: " + label) + + +def fixture_shell(): + root = Path(os.environ["FENRIR_FIXTURE_DIR"]) + tty.setraw(0) + + def record_size(*_): + rows, cols, _, _ = struct.unpack("HHHH", fcntl.ioctl(0, termios.TIOCGWINSZ, bytes(8))) + (root / "size").write_text(f"{rows} {cols}") + + signal.signal(signal.SIGWINCH, record_size) + # Inject application output without an X keypress restoring speech first. + def emit_output(*_): + os.write(1, (root / "prompt-output").read_bytes()) + + signal.signal(signal.SIGUSR1, emit_output) + (root / "shell-pid").write_text(str(os.getpid())) + record_size() + mode = (root / "mode").read_text() if (root / "mode").exists() else "fixture" + regionText = b"compose" + if mode == "table": + os.write(1, b"\x1b[2J\x1b[HName | Subject\r\nSender | Hello world\r\nOther | Next subject\x1b[1;1H") + elif mode.startswith("spellcheck"): + os.write(1, b"\x1b[2J\x1b[Hhelo\r\nprompt> ") + elif mode in ("attributes", "attributes-laptop"): + os.write(1, b"\x1b[2J\x1b[Hroot\r\n\x1b[1;4;5;31;44mstyled\r\npeer\x1b[0m\r\nplain\x1b[1;1H") + elif mode in ("indentation", "indentation-laptop"): + os.write(1, b"\x1b[2J\x1b[Hroot\r\n child\r\n peer\r\n\r\n deeper\r\nroot again\x1b[1;1H") + elif mode in ("regions", "regions-laptop"): + os.write(1, b"\x1b[2J\x1b[Hcompose\r\nruler eight\x1b[1;8H") + else: + os.write(1, b"\x1b[2J\x1b[Halpha\r\nbravo\r\ncharlie\r\nprompt> ") + with (root / "input").open("ab", buffering=0) as record: + while True: + data = os.read(0, 4096) + if not data: + return + record.write(data) + if b"\x04" in data: + return + if b"\x01" in data: + os.write(1, b"\x1b[?1049h\x1b[2J\x1b[Heditor") + elif b"\x02" in data: + os.write(1, b"\x1b[?1049l") + elif b"\x05" in data: + os.write(1, b"\x1b[6n") + elif b"\x06" in data: + (root / "flood-ready").write_text("ready") + wait_for(lambda: (root / "flood-release").exists(), "flood release") + remaining = b"flood-line\r\n" * 32768 + while remaining: + remaining = remaining[os.write(1, remaining):] + (root / "flood-done").write_text("done") + elif b"\x07" in data: + os.write(1, b"\r\nprogram-output\r\n") + elif mode in ("regions", "regions-laptop"): + regionText += data + os.write(1, b"\x1b[1;1H" + regionText + b"\x1b[2;1H\x1b[2Kruler nine" + + f"\x1b[1;{len(regionText) + 1}H".encode()) + else: + os.write(1, data) + + +def outer_terminal(): + root = Path(sys.argv[2]) + mode = (root / "mode").read_text() if (root / "mode").exists() else "fixture" + os.environ["SHELL"] = {"shell": "/bin/sh", "failure": str(root / "missing-shell")}.get(mode, str(root / "shell")) + os.environ["PS1"] = "fixture> " + original = termios.tcgetattr(0) + flags = fcntl.fcntl(0, fcntl.F_GETFL) + binary = os.environ.get("FENRIR_TEST_BINARY", str(Path(__file__).resolve().parents[1] / "fenrir")) + layout = root / "keyboard" / ("laptop.toml" if mode in ("laptop", "indentation-laptop", "attributes-laptop", "spellcheck-laptop", "spellcheck-no-enchant-laptop", "regions-laptop") else "custom.toml") + if mode.startswith("spellcheck"): + os.environ["LC_ALL"] = "zz_ZZ.UTF-8" if mode == "spellcheck-missing" else "en_US.UTF-8" + childEnvironment = dict(os.environ) + if mode.startswith("spellcheck-no-enchant"): + childEnvironment["LD_PRELOAD"] = str(root / "no-enchant.so") + childEnvironment["FENRIR_TEST_ENCHANT_LIBRARY"] = str(root / "incomplete-enchant.so") if mode.endswith("incomplete") else "" + with (root / "reader-errors").open("w") as errors: + child = subprocess.Popen([binary, "--punctuation-profile", str(Path(__file__).resolve().parents[1] / "config/punctuation/default.toml"), "-x", "--config", str(root / "settings.toml"), + "--keyboard-layout", str(layout), + "--debug-file", str(root / "timing.log")], stderr=errors, env=childEnvironment) + (root / "started").write_text(json.dumps({"pid": child.pid, "window": os.environ["WINDOWID"]})) + status = child.wait(timeout=float(os.environ.get("FENRIR_TEST_READER_TIMEOUT", "30"))) + (root / "finished").write_text(json.dumps({ + "status": status, + "restored": termios.tcgetattr(0) == original, + "flagsRestored": fcntl.fcntl(0, fcntl.F_GETFL) == flags, + })) + # Remain mapped so the parent can check passive-grab cleanup. + time.sleep(10) + + +class SpeechServer: + def __init__(self, path): + self.events = [] + self.settings = [] + self.errors = [] + self.connections = [] + self.listener = socket.socket(socket.AF_UNIX) + self.listener.bind(str(path)) + self.listener.listen() + self.stopped = False + self.worker = threading.Thread(target=self.accept, daemon=True) + self.worker.start() + + def accept(self): + while not self.stopped: + try: + conn, _ = self.listener.accept() + except OSError: + return + self.connections.append(conn) + threading.Thread(target=self.serve, args=(conn,), daemon=True).start() + + def serve(self, conn): + try: + reader = conn.makefile("rb") + for data in reader: + line = data.decode().rstrip("\r\n") + if line == "LIST OUTPUT_MODULES": + reply = b"200-fixture-module\r\n200-other-module\r\n200 OK\r\n" + elif line == "LIST SYNTHESIS_VOICES": + reply = b"249-Base Voice\ten-US\tnone\r\n249-Base Voice+Variant\ten-US\tVariant\r\n249-German\tde\tnone\r\n249 OK\r\n" + elif line == "HISTORY GET CLIENT_ID": + reply = b"200-41\r\n200 OK\r\n" + elif line == "SPEAK": + conn.sendall(b"230 OK\r\n") + lines = [] + for data in reader: + text = data.decode().rstrip("\r\n") + if text == ".": + break + lines.append(text[1:] if text.startswith("..") else text) + self.events.append("\n".join(lines)) + reply = b"225-1\r\n225 OK\r\n" + elif line.startswith("CHAR "): + self.events.append(line[5:]) + reply = b"200 OK\r\n" + elif line.startswith("CANCEL "): + assert line == "CANCEL 41", line + self.events.append("CANCEL") + reply = b"200 OK\r\n" + elif line.startswith("SET "): + self.settings.append(line) + reply = b"200 OK\r\n" + else: + raise AssertionError("Unexpected SSIP: " + line) + conn.sendall(reply) + except (OSError, ValueError): + return + except BaseException as error: + self.errors.append(str(error)) + + def close(self): + self.stopped = True + self.listener.close() + for conn in self.connections: + try: + conn.shutdown(socket.SHUT_RDWR) + except OSError: + pass + conn.close() + + +class KeyboardFixture: + """Send physical keycodes, without xdotool's implicit NumLock changes.""" + def __init__(self): + self.x11 = ctypes.CDLL("libX11.so.6") + self.xtest = ctypes.CDLL("libXtst.so.6") + self.x11.XOpenDisplay.restype = ctypes.c_void_p + self.x11.XOpenDisplay.argtypes = [ctypes.c_char_p] + self.display = self.x11.XOpenDisplay(None) + self.x11.XStringToKeysym.restype = ctypes.c_ulong + self.x11.XStringToKeysym.argtypes = [ctypes.c_char_p] + self.x11.XKeysymToKeycode.restype = ctypes.c_uint + self.x11.XKeysymToKeycode.argtypes = [ctypes.c_void_p, ctypes.c_ulong] + self.x11.XSync.argtypes = [ctypes.c_void_p, ctypes.c_int] + self.x11.XCloseDisplay.argtypes = [ctypes.c_void_p] + self.x11.XDefaultRootWindow.argtypes = [ctypes.c_void_p] + self.x11.XDefaultRootWindow.restype = ctypes.c_ulong + self.xtest.XTestFakeKeyEvent.argtypes = [ctypes.c_void_p, ctypes.c_uint, ctypes.c_int, ctypes.c_ulong] + + def event(self, name, down): + name = {"ctrl": "Control_L"}.get(name, name) + symbol = self.x11.XStringToKeysym(name.encode()) + code = self.x11.XKeysymToKeycode(self.display, symbol) + assert code, name + self.xtest.XTestFakeKeyEvent(self.display, code, down, 0) + self.x11.XSync(self.display, 0) + time.sleep(0.015) + + def key(self, *names): + for chord in names: + keys = chord.split("+") + for name in keys: + self.event(name, True) + for name in reversed(keys): + self.event(name, False) + + def transient_focus(self, count): + # Another client can destroy its focused window while Fenrir walks + # that window's parent chain. Use real X requests to exercise the race. + xlib = self.x11 + xlib.XDefaultRootWindow.argtypes = [ctypes.c_void_p] + xlib.XDefaultRootWindow.restype = ctypes.c_ulong + xlib.XCreateSimpleWindow.argtypes = [ctypes.c_void_p, ctypes.c_ulong, ctypes.c_int, ctypes.c_int, + ctypes.c_uint, ctypes.c_uint, ctypes.c_uint, ctypes.c_ulong, ctypes.c_ulong] + xlib.XCreateSimpleWindow.restype = ctypes.c_ulong + xlib.XMapWindow.argtypes = [ctypes.c_void_p, ctypes.c_ulong] + xlib.XSetInputFocus.argtypes = [ctypes.c_void_p, ctypes.c_ulong, ctypes.c_int, ctypes.c_ulong] + xlib.XDestroyWindow.argtypes = [ctypes.c_void_p, ctypes.c_ulong] + xlib.XFlush.argtypes = [ctypes.c_void_p] + rootWindow = xlib.XDefaultRootWindow(self.display) + for _ in range(count): + window = xlib.XCreateSimpleWindow(self.display, rootWindow, 0, 0, 40, 40, 0, 0, 0) + xlib.XMapWindow(self.display, window) + xlib.XSetInputFocus(self.display, window, 1, 0) + xlib.XFlush(self.display) + time.sleep(0.0002) + xlib.XDestroyWindow(self.display, window) + xlib.XFlush(self.display) + xlib.XSync(self.display, 0) + + def close(self): + self.x11.XCloseDisplay(self.display) + + def grab_keyboard(self, window): + self.x11.XGrabKeyboard.argtypes = [ctypes.c_void_p, ctypes.c_ulong, ctypes.c_int, + ctypes.c_int, ctypes.c_int, ctypes.c_ulong] + result = self.x11.XGrabKeyboard(self.display, int(window), False, 1, 1, 0) + assert result == 0, result + + def release_keyboard(self): + self.x11.XUngrabKeyboard.argtypes = [ctypes.c_void_p, ctypes.c_ulong] + self.x11.XUngrabKeyboard(self.display, 0) + self.x11.XSync(self.display, 0) + + def advance_grab_time(self, window): + self.grab_keyboard(window) + self.release_keyboard() + + def send_observed_key(self, window, name, down): + # Deliver an already queued key while another client owns the keyboard. + # XTest cannot create that ordering: its input goes to the new owner. + class KeyEvent(ctypes.Structure): + _fields_ = [("type", ctypes.c_int), ("serial", ctypes.c_ulong), + ("send_event", ctypes.c_int), ("display", ctypes.c_void_p), + ("window", ctypes.c_ulong), ("root", ctypes.c_ulong), + ("subwindow", ctypes.c_ulong), ("time", ctypes.c_ulong), + ("x", ctypes.c_int), ("y", ctypes.c_int), + ("x_root", ctypes.c_int), ("y_root", ctypes.c_int), + ("state", ctypes.c_uint), ("keycode", ctypes.c_uint), + ("same_screen", ctypes.c_int)] + event = (ctypes.c_long * 24)() + keyEvent = ctypes.cast(event, ctypes.POINTER(KeyEvent)).contents + keyEvent.type = 2 if down else 3 + keyEvent.display, keyEvent.window = self.display, int(window) + keyEvent.root = self.x11.XDefaultRootWindow(self.display) + keyEvent.keycode = self.x11.XKeysymToKeycode(self.display, self.x11.XStringToKeysym(name.encode())) + keyEvent.same_screen = True + self.x11.XSendEvent.argtypes = [ctypes.c_void_p, ctypes.c_ulong, ctypes.c_int, + ctypes.c_long, ctypes.c_void_p] + assert self.x11.XSendEvent(self.display, int(window), False, 1 if down else 2, event) + self.x11.XSync(self.display, 0) + + +def main(): + if not os.environ.get("DISPLAY") or os.environ.get("FENRIR_PRIVATE_X11") != "1": + raise SystemExit("Use a private display: xvfb-run -a env FENRIR_PRIVATE_X11=1 python3 tests/native_terminal.py") + script = str(Path(__file__).resolve()) + binary = os.environ.get("FENRIR_TEST_BINARY", str(Path(script).parents[1] / "fenrir")) + cliVersion = subprocess.check_output([binary, "--version"], text=True).strip().removeprefix("fenrir ") + with tempfile.TemporaryDirectory(prefix="fenrir-x11-", dir="/tmp") as directory: + root = Path(directory) + keyboardDir = root / "keyboard" + keyboardDir.mkdir() + sourceKeyboardDir = Path(__file__).resolve().parents[1] / "config/keyboard" + (keyboardDir / "custom.toml").write_text( + (sourceKeyboardDir / "desktop.toml").read_text().replace( + 'keys = ["FENRIR", "F"]', 'keys = ["FENRIR", "G"]', 1)) + (keyboardDir / "laptop.toml").write_bytes((sourceKeyboardDir / "laptop.toml").read_bytes()) + multiPressBindings = ('\n[[bindings]]\nname="Multi-press trial"\n' + 'keys=["FENRIR","F1"]\ncommand="time"\n' + 'double_command="date"\ntriple_command="current_quick_menu_entry"\n' + '\n[[bindings]]\nname="Physical brace"\n' + 'keys=["FENRIR","RIGHTBRACE"]\ncommand="current_quick_menu_value"\n' + '\n[[bindings]]\nname="Bare minus"\n' + 'keys=["KPMINUS"]\ncommand="current_quick_menu_value"\n' + '\n[[bindings]]\nname="Bare divide"\n' + 'keys=["KPSLASH"]\ncommand="current_quick_menu_value"\n') + for layoutPath in keyboardDir.iterdir(): + # This fixture intentionally remaps F1 for the multi-press trial. + tutorialAlias = '\n[[bindings]]\nname = "Tutorial"\nkeys = ["FENRIR", "F1"]\ncommand = "help"\n' + layoutPath.write_text(layoutPath.read_text().replace(tutorialAlias, "").replace( + '\n[[bindings]]\nname = "Attributes"\nkeys = ["KPMINUS"]\ncommand = "attribute_cursor"\n', '\n[[bindings]]\nname = "Attributes"\nkeys = ["FENRIR", "A"]\ncommand = "attribute_cursor"\n') + multiPressBindings) + server = SpeechServer(root / "speech.sock") + (root / "settings.toml").write_text( + 'version=3\n[presentation.sound]\nenabled=false\n' + '[presentation.speech]\n' + '[presentation.time_and_date]\ntime_format="24-hour-words"\ndate_format="numbers-ymd"\n' + '[screen]\nignored_vts=[1,7,8,10,63]\n' + f'[remote]\nsocket_file="{root / "remote.sock"}"\n' + f'[general]\nscript_path="{root / "scripts"}"\n' + ) + (root / "scripts").mkdir() + (root / "home").mkdir() + controller = root / "scripts/musicplayer__-__KEY_SHIFT__+__KEY_U.sh" + controller.write_text('#!/bin/sh\necho "$1"\n') + controller.chmod(0o700) + (root / "scripts/musicplayer__-__KEY_X.sh").symlink_to(controller.name) + hanging = root / "scripts/hang__-__KEY_H.sh" + hanging.write_text('#!/bin/sh\nsleep 30\n') + hanging.chmod(0o700) + selfvoice = root / "scripts/selfvoice__-__KEY_V.py" + selfvoice.write_text( + f'#!{sys.executable}\nimport os, socket\n' + 'connection = socket.socket(socket.AF_UNIX)\n' + 'connection.connect(os.environ["FENRIR_FIXTURE_DIR"] + "/remote.sock")\n' + 'connection.sendall(b"command say legacy-self-voice\\n")\n' + 'connection.shutdown(socket.SHUT_WR)\n' + 'connection.recv(1024)\nconnection.close()\n' + ) + selfvoice.chmod(0o700) + subprocess.run(["xmodmap", "-e", "keycode 135 = Multi_key"], check=True) + shell = root / "shell" + shell.write_text(f'#!{sys.executable}\nimport runpy, sys\nsys.argv = [{script!r}, "--fixture-shell"]\nrunpy.run_path({script!r}, run_name="__main__")\n') + shell.chmod(0o700) + environment = dict(os.environ, SHELL=str(shell), FENRIR_FIXTURE_DIR=str(root), + HOME=str(root / "home"), TMPDIR=str(root), LOGNAME="Username", USER="Username", + SPEECHD_ADDRESS="unix_socket:" + str(root / "speech.sock")) + processes = [] + keyboard = KeyboardFixture() + try: + terminal = subprocess.Popen(["xterm", "-geometry", "80x25", "-T", "FenrirFixture", "-e", + sys.executable, script, "--outer", str(root)], env=environment) + processes.append(terminal) + wait_for(lambda: (root / "started").exists(), "reader launch") + state = json.loads((root / "started").read_text()) + window = state["window"] + + def xdo(*args): + subprocess.run(["xdotool", *map(str, args)], check=True, stdout=subprocess.DEVNULL) + + def key(*args): + keyboard.key(*args) + + def spoken(text, start=0): + return wait_for(lambda: any(text in item for item in server.events[start:]), + f"speech {text!r}; recent={server.events[-8:]}") + + def speech_sequence(expected, start): + wait_for(lambda: len([item for item in server.events[start:] if item != "CANCEL"]) >= len(expected), + f"spelled sequence {expected!r}; recent={server.events[-12:]}") + actual = [item for item in server.events[start:] if item != "CANCEL"] + assert actual == expected, (actual, expected) + + def recorded(): + return (root / "input").read_bytes() if (root / "input").exists() else b"" + + def save_menu(): + # Follow announced controls rather than assuming how many + # Reading fields precede OK. Fail if traversal never reaches it. + for _ in range(64): + before = len(server.events) + key("Tab") + text = wait_for(lambda: next((item for item in reversed(server.events[before:]) + if item != "CANCEL"), None), "next menu control") + if text == "OK button": + key("Return") + spoken("Fenrir Menu closed", before) + return + raise AssertionError("Menu traversal did not reach OK") + + xdo("windowfocus", "--sync", window) + spoken("prompt") + + # A delayed reader-modifier press can predate a desktop client's + # keyboard grab. Previously its old timestamp caused fatal EBUSY. + start = len(server.events) + key("KP_Enter") + spoken("speech temporary disabled", start) + os.kill(state["pid"], signal.SIGSTOP) + try: + wait_for(lambda: "State:\tT" in Path(f'/proc/{state["pid"]}/status').read_text(), "reader paused") + keyboard.event("Insert", True) + xdo("windowunmap", window) + keyboard.advance_grab_time(window=keyboard.x11.XDefaultRootWindow(keyboard.display)) + xdo("windowmap", window) + xdo("windowfocus", "--sync", window) + finally: + os.kill(state["pid"], signal.SIGCONT) + time.sleep(0.1) + keyboard.event("Insert", False) + spoken("speech enabled", start) + start = len(server.events) + key("Insert+space") + spoken("speech rate 80", start) + for expected in ["autoread disabled", "autoread enabled"]: + start = len(server.events) + key("Insert+KP_Enter") + spoken(expected, start) + assert not (root / "finished").exists(), "reader exited on delayed capture" + assert recorded() == b"", recorded() + assert (root / "timing.log").read_text().count('msg="reader starting"') == 1 + assert json.loads((root / "started").read_text())["pid"] == state["pid"] + print("PASS: delayed modifier after a competing desktop grab keeps the reader and PTY alive") + + # A desktop client can still own its grab when queued review input + # is processed. Refuse the command before dispatch, then recover. + start = len(server.events) + keyboard.grab_keyboard(keyboard.x11.XDefaultRootWindow(keyboard.display)) + try: + keyboard.send_observed_key(window, "KP_Up", True) + wait_for(lambda: 'msg="X11 keyboard capture refused" status=1' in + (root / "timing.log").read_text(), "competing-grab refusal") + keyboard.send_observed_key(window, "KP_Up", False) + assert server.events[start:] == [], server.events[start:] + assert not (root / "finished").exists(), "competing grab ended reader" + finally: + keyboard.release_keyboard() + start = len(server.events) + key("Insert+space") + spoken("speech rate 80", start) + assert recorded() == b"", recorded() + print("PASS: active competing grab refuses queued review without dispatch or exit; next chord recovers") + + # If the refused key was Fenrir itself, do not reinterpret KPEnter + # as bare temporary silence when the competitor releases mid-chord. + start = len(server.events) + refusalCount = (root / "timing.log").read_text().count('msg="X11 keyboard capture refused"') + keyboard.grab_keyboard(keyboard.x11.XDefaultRootWindow(keyboard.display)) + try: + keyboard.event("Insert", True) + keyboard.send_observed_key(window, "Insert", True) + wait_for(lambda: (root / "timing.log").read_text().count( + 'msg="X11 keyboard capture refused"') > refusalCount, "refused held modifier") + finally: + keyboard.release_keyboard() + try: + key("KP_Enter") + time.sleep(0.1) + assert server.events[start:] == [], server.events[start:] + finally: + keyboard.event("Insert", False) + start = len(server.events) + key("Insert+space") + spoken("speech rate 80", start) + assert recorded() == b"", recorded() + print("PASS: refused modifier discards the remaining held chord without turning KPEnter into temporary silence") + if os.environ.get("FENRIR_CAPTURE_TEST_ONLY") == "1": + key("Insert+q") + wait_for(lambda: (root / "finished").exists(), "capture fixture quit") + assert json.loads((root / "finished").read_text())["status"] == 0 + return + originalSettings = (root / "settings.toml").read_bytes() + # Exercise default punctuation through the actual SSIP boundary, + # then save none for the older text/protocol assertions below. + def say_remote(text): + with socket.socket(socket.AF_UNIX) as connection: + connection.settimeout(2) + connection.connect(str(root / "remote.sock")) + connection.sendall(("COMMAND SAY " + text + "\n").encode()) + connection.shutdown(socket.SHUT_WR) + assert connection.recv(1024) == b"" + + start = len(server.events) + say_remote("a/b#c!") + spoken("a slash b hash c!", start) + assert "SET SELF PUNCTUATION NONE" in server.settings + start = len(server.events) + key("Insert+g") + spoken("Fenrir Menu. Presentation tab", start) + key("Right", "Right") + spoken("Reading tab", start) + key("Tab", "Tab", "Tab", "Tab", "Tab") + spoken("Punctuation level combo box some", start) + key("Home", "Tab") + spoken("Raise pitch for capital letters checked", start) + key("Tab") + spoken("Beep for capital letters unchecked", start) + key("Tab") + spoken("Say ā€œcapā€ before capital letters unchecked", start) + save_menu() + wait_for(lambda: tomllib.loads((root / "settings.toml").read_text()).get("reading", {}).get("punctuation_level") == "none", "punctuation level save") + spoken("Fenrir Menu closed", start) + time.sleep(0.1) + start = len(server.events) + say_remote("a/b#c!") + spoken("a b c!", start) + key("Insert+g") + spoken("Fenrir Menu. Presentation tab", start) + key("Right", "Right", "Tab", "Tab", "Tab", "Tab", "Tab", "Down", "Escape") + start = len(server.events) + say_remote("a/b#c!") + spoken("a b c!", start) + assert recorded() == b"", recorded() + (root / "settings.toml").write_bytes(originalSettings + b'[reading]\npunctuation_level="none"\n') + print("PASS: shipped default punctuation, shared preprocessing, backend punctuation disabled, live level save and cancelled level edit") + start = len(server.events) + key("Insert+t") + spoken(time.strftime("%H hours and %M minutes"), start) + key("Insert+t") + spoken(time.strftime("%Y %m %d"), start) + assert recorded() == b"", recorded() + print("PASS: Fenrir+T time and double-press date through the shared router without PTY input") + # Python Fenrir's desktop chord cycles once per press, with no + # chord bytes reaching the PTY. Return to the default for review. + for mode in ["table mode", "highlight tracking", "cursor tracking"]: + start = len(server.events) + key("Insert+KP_Multiply") + spoken(mode, start) + assert recorded() == b"", recorded() + spoken("prompt") + start = len(server.events) + key("KP_Up") + spoken("prompt", start) + assert recorded() == b"", recorded() + # A tab form must retain the active grab between physical chords. + # Check speech, isolation, decimal steps, combo selections and release + # draining using the same terminal that records ordinary input. + settingsBefore = (root / "settings.toml").read_bytes() + start = len(server.events) + key("Insert+f") + spoken("unbound", start) + start = len(server.events) + key("Insert+g") + spoken("Fenrir Menu. Presentation tab", start) + key("ctrl+c", "Insert+BackSpace", "KP_Up", "F1") + start = len(server.events) + key(*(["Tab"] * 5)) + spoken("Speech. Speech enabled checked", start) + key("Tab", "Up") + spoken("Rate 81", start) + key("Tab") + spoken("Driver combo box Speech Dispatcher", start) + xdo("type", "--delay", "10", "invented-driver") + key("Tab") + spoken("Speech Dispatcher module combo box default", start) + start = len(server.events) + key("Down") + wait_for(lambda: "fixture-module" in server.events[start:], "combo choice without label") + assert not any("Speech Dispatcher module combo box" in item for item in server.events[start:]), server.events[start:] + key("Tab", "Shift_L+Tab") + spoken("Speech Dispatcher module combo box fixture-module", start) + key("Home", "Tab", "Tab", "Tab", "Tab", "Tab", "Tab", "Tab") + spoken("OK button", start) + key("Tab") + spoken("Cancel button", start) + # A held key cannot start forwarding repeats after Cancel closes. + keyboard.event("z", True) + key("Return") + spoken("Fenrir Menu closed", start) + time.sleep(0.08) + keyboard.event("z", False) + assert (root / "settings.toml").read_bytes() == settingsBefore + assert recorded() == b"", recorded() + start = len(server.events) + key("Insert+g") + spoken("Fenrir Menu. Presentation tab", start) + key("Tab", "Tab", "Left") + spoken("Volume 0.6", start) + save_menu() + wait_for(lambda: b"volume = 0.6" in (root / "settings.toml").read_bytes(), "menu save") + time.sleep(0.08) + assert recorded() == b"", recorded() + start = len(server.events) + key("Insert+g") + spoken("Fenrir Menu. Presentation tab", start) + key("Tab", "Tab") + spoken("Volume 0.6", start) + key("Escape") + spoken("Fenrir Menu closed", start) + # Sound volume now applies on save. Restore the original file for + # subsequent persistence checks (sound is disabled in this fixture). + (root / "settings.toml").write_bytes(settingsBefore) + # Quick settings stay in the shared command path, never enter the + # PTY, and configure the speaking client before their feedback. + for chord, expected in [ + ("Insert+space", "speech rate 80"), + ("Insert+Up", "90"), + ("Insert+Right", "speech pitch 0"), + ("Insert+Up", "10"), + ("Insert+Right", "speech volume 100"), + ("Insert+Down", "90"), + ("Insert+space", "speech volume 90"), + ]: + start = len(server.events) + key(chord) + spoken(expected, start) + for setting in ["SET SELF RATE 90", "SET SELF PITCH 10", "SET SELF VOLUME 90"]: + assert setting in server.settings, server.settings + start = len(server.events) + key("Insert+ctrl+s") + spoken("Settings saved.", start) + savedSpeech = tomllib.loads((root / "settings.toml").read_text())["presentation"]["speech"] + assert (savedSpeech["rate"], savedSpeech["pitch"], savedSpeech["volume"]) == (90, 10, 90), savedSpeech + # A later quick save retains the menu-saved sound volume. + assert tomllib.loads((root / "settings.toml").read_text())["presentation"]["sound"]["volume"] == 0.6 + assert recorded() == b"", recorded() + # Restore running values before the remaining established trials. + key("Insert+Up", "Insert+Left", "Insert+Down", "Insert+Left", "Insert+Down") + (root / "settings.toml").write_bytes(settingsBefore) + start = len(server.events) + key("KP_Home") + spoken("charlie", start) + assert recorded() == b"", recorded() + # Bare keypad Enter must be grabbed before xterm forwards it. + # Its release must leave speech silent until a fresh press. + start = len(server.events) + key("KP_Enter") + spoken("speech temporary disabled", start) + assert recorded() == b"", recorded() + start = len(server.events) + key("KP_Up") + spoken("speech enabled", start) + assert recorded() == b"", recorded() + # Both modifier release orders must consume the complete chord. + start = len(server.events) + keyboard.event("Insert", True) + keyboard.event("KP_Begin", True) + keyboard.event("Insert", False) + keyboard.event("KP_Begin", False) + spoken("alpha\nbravo\ncharlie\nprompt", start) + assert recorded() == b"", recorded() + xdo("type", "--delay", "10", "typed") + wait_for(lambda: recorded() == b"typed", "ordinary input once") + # NumLock-on keypad entry is replayed to xterm. + key("Num_Lock", "KP_8", "Num_Lock") + wait_for(lambda: recorded() == b"typed8", "numeric keypad forwarding") + key("Insert+BackSpace", "KP_Up") + wait_for(lambda: recorded().endswith(b"\x1b[A"), "pass-next navigation") + start = len(server.events) + key("Insert+KP_Enter") + spoken("autoread disabled", start) + start = len(server.events) + key("KP_Home") + spoken("bravo", start) + key("Insert+KP_Enter") + key("Insert+F4") + start = len(server.events) + key("KP_Up") + time.sleep(0.08) + assert not [item for item in server.events[start:] if item != "CANCEL"], server.events[start:] + key("Insert+F4") + spoken("speech enabled", start) + assert "bravo" not in server.events[start:], "muted review was replayed" + # Alternate-screen programs still use the same review commands. + key("ctrl+a") + start = len(server.events) + key("KP_Up") + spoken("editor", start) + key("ctrl+b") + start = len(server.events) + key("Insert+KP_Begin") + spoken("alpha", start) + # One outer-terminal query response, with no emulator duplicate. + start = len(server.events) + key("ctrl+e") + wait_for(lambda: b"R" in recorded(), "cursor query response") + time.sleep(0.1) + assert recorded().count(b"R") == 1, recorded() + assert server.events[start:].count("CANCEL") == 2, f"wrong user-key interruption count: {server.events[start:]}" + # Resize propagates to the controlling PTY. + xdo("windowsize", window, "900", "600") + wait_for(lambda: (root / "size").read_text() != "25 80", "PTY resize") + # Keys in another window must not dispatch or cancel reader speech. + other = subprocess.Popen(["xterm", "-T", "OtherFixture", "-e", "sleep", "20"]) + processes.append(other) + other_id = wait_for(lambda: subprocess.run( + ["xdotool", "search", "--name", "^OtherFixture$"], capture_output=True, text=True + ).stdout.strip(), "other window") + start = len(server.events) + key("Insert+g") + spoken("Fenrir Menu.", start) + keyboard.event("Insert", True) + xdo("windowfocus", "--sync", other_id) + time.sleep(0.15) + keyboard.event("Insert", False) + before = len(server.events) + key("Insert+q", "KP_Up", "ctrl") + time.sleep(0.15) + assert len(server.events) == before, server.events[before:] + assert not (root / "finished").exists(), "unfocused quit escaped terminal" + keyboard.transient_focus(3000) + assert not (root / "finished").exists(), "reader exited when another focused window disappeared" + xdo("windowunmap", window) + time.sleep(0.15) + xdo("windowmap", window) + xdo("windowfocus", "--sync", window) + start = len(server.events) + key("KP_Up") + spoken("charlie", start) + print("PASS: 3000 disappearing focus windows, hidden/restored xterm and review recovery without restarting the reader") + key("ctrl+f") + wait_for(lambda: (root / "flood-ready").exists(), "flood ready") + # A stopped xterm cannot drain stdout. The reader must retain + # partial output while continuing to process its own X11 commands. + os.kill(terminal.pid, signal.SIGSTOP) + try: + (root / "flood-release").write_text("release") + time.sleep(0.2) + assert not (root / "flood-done").exists(), "output did not reach backpressure" + start = len(server.events) + key("Insert+space") + spoken("speech rate 80", start) + assert not (root / "finished").exists(), "reader exited with stdout full" + finally: + os.kill(terminal.pid, signal.SIGCONT) + wait_for(lambda: (root / "flood-done").exists(), "sustained PTY output relay") + assert not (root / "finished").exists(), "reader exited during sustained output" + xdo("type", "--delay", "10", "after-flood") + wait_for(lambda: recorded().endswith(b"after-flood"), "typing after output recovery") + key("Insert+q") + wait_for(lambda: (root / "finished").exists(), "reader quit") + result = json.loads((root / "finished").read_text()) + assert result == {"status": 0, "restored": True, "flagsRestored": True}, result + print("PASS: PTY typing, shared review, both release orders, NumLock, pass-next, autoread, mute admission, alternate screen, query relay without interruption, sustained output with a blocked xterm and responsive commands, resize, held-modifier focus isolation and terminal restoration") + print("PASS: Fenrir Menu tab form, retained modal grab, checkbox presentation, integer and decimal adjustments, driver/module combo boxes, OK/Cancel buttons, save/reload, cancellation and held-key isolation") + print("PASS: quick speech rate/pitch/volume, live SSIP parameters, explicit save, preserved next-launch settings and no chord bytes in PTY") + + def restart(mode): + for name in ["started", "finished", "size", "input", "shell-pid"]: + (root / name).unlink(missing_ok=True) + (root / "mode").write_text(mode) + terminal = subprocess.Popen(["xterm", "-geometry", "80x25", "-T", "FenrirExtraFixture", "-e", + sys.executable, script, "--outer", str(root)], env=environment) + processes.append(terminal) + wait_for(lambda: (root / "started").exists(), mode + " launch") + state = json.loads((root / "started").read_text()) + xdo("windowfocus", "--sync", state["window"]) + return state + + def restored(status=0): + wait_for(lambda: (root / "finished").exists(), "exit and terminal restoration") + result = json.loads((root / "finished").read_text()) + assert result == {"status": status, "restored": True, "flagsRestored": True}, result + + for layoutMode in ["fixture", "laptop"]: + (root / "settings.toml").write_bytes(originalSettings) + start = len(server.events) + restart(layoutMode) + spoken("prompt", start) + for chord, expected in [ + ("Insert+space", "speech rate 80"), + ("Insert+ctrl+Right", "Reading. Punctuation level some"), + ("Insert+Up", "most"), + ("Insert+Up", "all"), + ("Insert+Up", "none"), + ("Insert+Down", "all"), + ("Insert+Up", "none"), + ("Insert+ctrl+Right", "Keyboard. Character echo checked"), + ("Insert+Down", "unchecked"), + ("Insert+Right", "Word echo unchecked"), + ("Insert+Up", "checked"), + ("Insert+Right", "Echo on CapsLock unchecked"), + ("Insert+Up", "checked"), + ("Insert+Right", "Character echo unchecked"), + ("Insert+ctrl+Right", "Speech. speech rate 80"), + ("Insert+ctrl+Left", "Keyboard. Character echo unchecked"), + ("Insert+Left", "Echo on CapsLock checked"), + ("Insert+ctrl+Left", "Reading. Punctuation level none"), + ]: + start = len(server.events) + key(chord) + spoken(expected, start) + # Unsaved controls appear in the form; Cancel keeps runtime + # values while leaving the original file untouched. + beforeCancel = (root / "settings.toml").read_bytes() + start = len(server.events) + key("Insert+g" if layoutMode == "fixture" else "Insert+f") + spoken("Fenrir Menu. Presentation tab", start) + key("Escape") + spoken("Fenrir Menu closed", start) + assert (root / "settings.toml").read_bytes() == beforeCancel + start = len(server.events) + key("Insert+space") + spoken("Punctuation level none", start) + key("Insert+ctrl+s") + spoken("Settings saved.", start) + saved = tomllib.loads((root / "settings.toml").read_text()) + assert saved["reading"]["punctuation_level"] == "none", saved + assert saved["keyboard"]["character_echo"] is False, saved + assert saved["keyboard"]["word_echo"] is True, saved + assert saved["keyboard"]["echo_on_capslock"] is True, saved + assert recorded() == b"", recorded() + key("Insert+q") + restored() + start = len(server.events) + restart(layoutMode) + spoken("prompt", start) + for chord, expected in [("Insert+space", "speech rate 80"), + ("Insert+ctrl+Left", "Keyboard. Character echo unchecked"), + ("Insert+Right", "Word echo checked"), + ("Insert+Right", "Echo on CapsLock checked"), + ("Insert+ctrl+Left", "Reading. Punctuation level none")]: + start = len(server.events) + key(chord) + spoken(expected, start) + assert recorded() == b"", recorded() + key("Insert+q") + restored() + print("PASS: categorized quick settings in both layouts, punctuation wrapping, independent echo toggles, selection memory, cancel, explicit save/restart and no chord bytes in PTY") + + # The session automatic-reading toggle must not become the next + # startup default when the quick-settings save shortcut is used. + for layoutMode in ["fixture", "laptop"]: + (root / "settings.toml").write_bytes(originalSettings) + start = len(server.events) + restart(layoutMode) + spoken("prompt", start) + start = len(server.events) + key("Insert+KP_Enter" if layoutMode == "fixture" else "Insert+Return") + spoken("autoread disabled", start) + start = len(server.events) + key("Insert+ctrl+s") + spoken("Settings saved.", start) + saved = tomllib.loads((root / "settings.toml").read_text()) + assert saved["reading"]["automatic"] is True, saved["reading"] + assert saved["presentation"]["speech"]["enabled"] is True + assert recorded() == b"", recorded() + key("Insert+q") + restored() + start = len(server.events) + restart(layoutMode) + # No enable/review key is sent: incoming text must speak by itself. + spoken("prompt", start) + assert recorded() == b"", recorded() + key("Insert+q") + restored() + (root / "settings.toml").write_bytes(settingsBefore) + print("PASS: desktop/laptop automatic reading off, quick save and fresh-process restart preserve enabled startup reading and speech") + + start = len(server.events) + restart("prompts") + spoken("prompt", start) + wait_for(lambda: (root / "shell-pid").exists(), "prompt fixture ready") + + def emit_output(data): + (root / "prompt-output").write_bytes(data) + os.kill(int((root / "shell-pid").read_text()), signal.SIGUSR1) + + for line in ["$", "Password:", "Worked for 19s • 7:46 PM", "Worked for 3m 35s • 8:05 PM"]: + start = len(server.events) + key("KP_Enter") + spoken("speech temporary disabled", start) + start = len(server.events) + emit_output(b"\r\nquiet-output\r\nWorked for 19s") + time.sleep(0.15) + assert not [item for item in server.events[start:] if item != "CANCEL"], server.events[start:] + emit_output(("\r\x1b[2K" + line).encode()) + spoken("Speech restored", start) + # Normal punctuation processing can suppress a bare dollar + # prompt and remove colons/bullets from the spoken line. + if line != "$": + spoken("Password" if line == "Password:" else line.split(" •")[0], start) + assert not any("quiet-output" in item for item in server.events[start:]), server.events[start:] + assert recorded() == b"", recorded() + key("Insert+q") + restored() + print("PASS: KPEnter automatically restores on shell/password prompts and both Worked-for completion formats without key input or backlog replay") + + # A non-default timeout must reach the shared router in each layout. + (root / "settings.toml").write_bytes(originalSettings + b'[reading]\npunctuation_level="none"\n[keyboard]\nmulti_press_timeout_ms=700\n') + for layoutMode in ["fixture", "laptop"]: + start = len(server.events) + restart(layoutMode) + spoken("prompt", start) + for expected in [time.strftime("%H hours and %M minutes"), + time.strftime("%Y %m %d"), "speech rate 80"]: + start = len(server.events) + key("Insert+F1") + spoken(expected, start) + # Longer than the default, shorter than the configured window. + time.sleep(0.3) + # Triple completes the sequence; the next press starts with time. + start = len(server.events) + key("Insert+F1") + spoken(time.strftime("%H hours and %M minutes"), start) + time.sleep(0.75) + start = len(server.events) + key("Insert+F1") + spoken(time.strftime("%H hours and %M minutes"), start) + time.sleep(0.75) + start = len(server.events) + keyboard.event("Insert", True) + keyboard.event("F1", True) + spoken(time.strftime("%H hours and %M minutes"), start) + start = len(server.events) + time.sleep(0.75) + assert not [item for item in server.events[start:] if item != "CANCEL"], server.events[start:] + # Release Fenrir first while F1 is held; neither release may leak. + keyboard.event("Insert", False) + keyboard.event("F1", False) + for chord in ["KP_Subtract", "KP_Divide", "Insert+bracketright"]: + start = len(server.events) + key(chord) + spoken("80", start) + for expected in (["Progress monitoring disabled", "Progress monitoring enabled"] if layoutMode == "fixture" else []): + # Ordinary bare KP+ must acquire the X11 grab itself. + start = len(server.events) + key("KP_Add") + spoken(expected, start) + assert recorded() == b"", recorded() + key("Num_Lock", "KP_Subtract", "KP_Divide", "KP_Add") + wait_for(lambda: recorded() == b"-/+", "NumLock-on keypad operator typing") + key("Num_Lock") + + start = len(server.events) + emit_output("\x1b[2J\x1b[HA book!?Ɖ\x1b[1;3H".encode()) + spoken("A book!?Ɖ", start) + wordKey, lineKey, charKey = (("KP_Begin", "KP_Up", "KP_Down") if layoutMode == "fixture" + else ("Insert+k", "Insert+i", "Insert+comma")) + for chord, sequences in [ + (wordKey, [["book!?Ɖ"], ["b", "o", "o", "k", "bang", "question?", "Ɖ"], + ["bravo", "oscar", "oscar", "kilo", "bang", "question?", "Ɖ"]]), + (lineKey, [["A book!?Ɖ"], ["A", "space", "b", "o", "o", "k", "bang", "question?", "Ɖ"], + ["Alpha", "space", "bravo", "oscar", "oscar", "kilo", "bang", "question?", "Ɖ"]]), + (charKey, [["A"], ["Alpha"]]), + ]: + for count, expected in enumerate(sequences): + start, settingsStart = len(server.events), len(server.settings) + key(chord) + speech_sequence(expected, start) + if count == len(sequences) - 1: + assert not any(item.startswith("SET SELF PITCH ") for item in server.settings[settingsStart:]), server.settings[settingsStart:] + # The selected alphabet action and every release stay consumed. + assert recorded() == b"-/+", recorded() + + # Inclusive screen ranges use detached review, preserve Unicode + # cell positions and never send cursor movement to the child PTY. + beforeKey, afterKey, previousLineKey, nextCharKey, followKey = ( + ("Insert+KP_Up", "Insert+KP_Down", "KP_Home", "KP_Next", "Insert+KP_Delete") + if layoutMode == "fixture" else + ("Insert+Shift_L+i", "Insert+Shift_L+comma", "Insert+u", "Insert+period", "Insert+slash")) + start = len(server.events) + emit_output("\x1b[2J\x1b[Halpha\r\ncafĆ© Ī©!\r\nlast\x1b[2;6H".encode()) + spoken("cafĆ© Ī©!", start) + key(followKey) + for chord, expected in [(beforeKey, "alpha\ncafĆ© Ī©"), (afterKey, "Ī©!\nlast")]: + start = len(server.events) + key(chord) + spoken(expected, start) + # Review on row one, column one; the application stays at Ī©. + key(previousLineKey, "Insert+" + ("KP_Left" if layoutMode == "fixture" else "Shift_L+j"), nextCharKey) + for chord, expected in [(beforeKey, "al"), (afterKey, "lpha\ncafĆ© Ī©!\nlast")]: + start = len(server.events) + key(chord) + spoken(expected, start) + key(followKey) + start = len(server.events) + key(afterKey) + spoken("Ī©!\nlast", start) + # Reading an all-whitespace range uses the blank alternative. + start = len(server.events) + emit_output(b"\x1b[2J\x1b[H") + time.sleep(0.1) + key(beforeKey, afterKey) + spoken("blank", start) + assert recorded() == b"-/+", recorded() + + # Vertical character review keeps its screen column through + # short/blank rows, using each layout's Python chords. + upKey, downKey, bottomKey, modeKey = ( + ("Insert+Control_L+KP_Up", "Insert+Control_L+KP_Down", "Insert+KP_Prior", "Insert+KP_Multiply") + if layoutMode == "fixture" else + ("Insert+Control_L+i", "Insert+Control_L+comma", "Insert+Shift_L+o", "Insert+y")) + start = len(server.events) + emit_output("\x1b[2J\x1b[HabcdƉ!\r\nx\r\n\r\nwxyzĪ©?\x1b[1;5H".encode()) + spoken("abcdƉ!", start) + key(followKey) + for chord, expected in [(downKey, "space"), (downKey, "space"), (downKey, "Ī©"), + (upKey, "space"), (upKey, "space"), (upKey, "Ɖ"), + (upKey, "start of screen")]: + start = len(server.events) + key(chord) + spoken(expected, start) + # Release Fenrir before Control and the action key. + start = len(server.events) + for name in ["Insert", "Control_L", "KP_Down" if layoutMode == "fixture" else "comma"]: + keyboard.event(name, True) + spoken("space", start) + for name in ["Insert", "Control_L", "KP_Down" if layoutMode == "fixture" else "comma"]: + keyboard.event(name, False) + key(bottomKey) + start = len(server.events) + key(downKey) + spoken("end of screen", start) + + # Cell boundaries must not substitute a neighbouring cell or + # change the intended screen column when rows shift. + start = len(server.events) + emit_output(b"\x1b[2J\x1b[HName|Subject\r\nx|long word\r\nwide label|other\r\nonly\r\nx|\r\nName|Subject\x1b[1;6H") + spoken("Name", start) + key(followKey, modeKey) + spoken("table mode", start) + for expected in ["g", "no table data", "no table data", "blank", "S"]: + start = len(server.events) + key(downKey) + spoken(expected, start) + key(modeKey, modeKey) + spoken("cursor tracking", start) + + # Additional reports observe the active cursor; screen-character + # navigation goes to physical corners, including terminal padding. + rowKey, columnKey, lineToKey, lineFromKey, firstKey, lastKey = ( + ("Insert+Control_L+KP_Home", "Insert+Control_L+KP_Prior", + "Insert+Control_L+KP_Left", "Insert+Control_L+KP_Right", + "Insert+Control_L+KP_End", "Insert+Control_L+KP_Next") + if layoutMode == "fixture" else + ("Insert+Control_L+Shift_L+u", "Insert+Control_L+Shift_L+o", + "Insert+Control_L+Shift_L+j", "Insert+Control_L+Shift_L+l", + "Insert+Control_L+m", "Insert+Control_L+period")) + start = len(server.events) + emit_output("\x1b[2J\x1b[HFirst ends Z\r\ncafĆ© Ī©!\x1b[2;6H".encode()) + spoken("cafĆ© Ī©!", start) + key(followKey) + for chord, expected in [(rowKey, ["2", " text cursor line number"]), + (columnKey, ["6", " text cursor column number"]), + (lineToKey, ["cafĆ©", " text cursor "]), + (lineFromKey, ["Ī©!", " text cursor "])]: + start = len(server.events) + key(chord) + speech_sequence(expected, start) + # Reports did not detach: passive application movement is followed. + emit_output(b"\x1b[2;1H") + time.sleep(0.1) + start = len(server.events) + key(lineToKey) + speech_sequence(["blank", " text cursor "], start) + key(modeKey) + start = len(server.events) + emit_output(b"\x1b[2J\x1b[HName|Subject\x1b[1;8H") + time.sleep(0.1) + key(followKey) + start = len(server.events) + key(lineToKey) + speech_sequence(["Name Su", " text cursor "], start) + start = len(server.events) + key(firstKey) + spoken("N first character in screen", start) + # Detached row/column reports and both physical corners. + for chord, expected in [(rowKey, ["1", " review cursor line number"]), + (columnKey, ["1", " review cursor column number"]), + (lastKey, ["space last character in screen"])]: + start = len(server.events) + key(chord) + speech_sequence(expected, start) + start = len(server.events) + key(lineFromKey) + speech_sequence(["blank", " review cursor "], start) + key(modeKey, modeKey, followKey) + + # Existing review now follows Python's row crossings, preceding + # word selection and endpoint context in both bundled layouts. + prevCharKey, nextWordKey, lineStartKey, lineEndKey, firstTextKey, lastTextKey = ( + ("KP_End", "KP_Right", "Insert+KP_Left", "Insert+KP_Right", "Insert+KP_End", "Insert+KP_Next") + if layoutMode == "fixture" else + ("Insert+m", "Insert+l", "Insert+Shift_L+j", "Insert+Shift_L+l", "Insert+Control_L+j", "Insert+Control_L+l")) + start = len(server.events) + emit_output("\x1b[2J\x1b[H Ɖx!\r\n\r\nlast word\x1b[1;4H".encode()) + spoken(" Ɖx!", start) + key(followKey) + for chord, expected in [ + (wordKey, ["Ɖx!"]), + (lineEndKey, ["space", "end of line"]), + (nextCharKey, ["space", "line break"]), + (firstTextKey, ["line is empty"]), + (lastTextKey, ["line is empty"]), + (wordKey, ["Ɖx!", "line break"]), + (nextWordKey, ["last", "line break"]), + (firstTextKey, ["l", "first character in line indent 0"]), + (lastTextKey, ["d", "last character in line"]), + (lineKey, ["last word"]), + (columnKey, ["1", " review cursor column number"]), + (firstKey, ["space first character in screen"]), + (prevCharKey, ["start of screen"]), + (lastKey, ["space last character in screen"]), + (nextCharKey, ["end of screen"]), + ]: + start = len(server.events) + key(chord) + speech_sequence(expected, start) + # Top and Bottom both reset the screen column. + for chord in [bottomKey, "Insert+KP_Home" if layoutMode == "fixture" else "Insert+Shift_L+u"]: + key(chord) + start = len(server.events) + key(columnKey) + speech_sequence(["1", " review cursor column number"], start) + assert recorded() == b"-/+", recorded() + key("Insert+q") + restored() + (root / "settings.toml").write_bytes(originalSettings + b'[reading]\npunctuation_level="none"\n') + print("PASS: triple fresh presses, configured timeout and expiry, held F1, physical brace and bare keypad operators in both layouts; consumed chords stay out of PTY and NumLock-on operators type normally") + print("PASS: bundled word/line spelling and phonetics, character phonetics, repeated letters, capitals, spaces, punctuation and Unicode in both layouts; phonetic output sends no capital pitch override or PTY input") + print("PASS: inclusive screen ranges in both layouts, Unicode cursor cells, detached review, follow-cursor restoration, blank feedback and no leaked PTY input") + print("PASS: vertical character review in both layouts, retained columns through short/blank rows, Unicode, screen and table boundaries, both modifier release orders and no leaked PTY input") + print("PASS: row/column reports, exclusive/inclusive line ranges, active-cursor ownership, Unicode, blank feedback, whole table rows and physical screen corners in both layouts without PTY input") + print("PASS: character/word row crossings, preceding words on blank rows, empty endpoints, line context, Top/Bottom columns and physical screen boundaries in both layouts without PTY input") + + for layoutMode in ["fixture", "laptop"]: + start = len(server.events) + restart(layoutMode) + spoken("prompt", start) + key("Insert+h") + spoken("Entering tutorial mode.", start) + for chord, expected in [ + ("F12", "f12. unbound"), + ("Shift_R", "rightshift. unbound"), + ("Insert+q", "exits Fenrir"), + ("Insert+F4", "enables or disables speech"), + ("Insert+BackSpace", "sends the following keypress"), + ("Insert+g" if layoutMode == "fixture" else "Insert+f", "Fenrir Menu"), + ("Insert+F1", "presents the time"), + ("Multi_key+Shift_L+u", "musicplayer"), + ]: + start = len(server.events) + key(chord) + spoken(expected, start) + start = len(server.events) + key("Right", "space") + spoken("Unbound actions", start) + start = len(server.events) + key("Down") + spoken("Shortcuts unbound" if layoutMode == "laptop" else "Unbound actions", start) + start = len(server.events) + key("Right", "Down") + spoken("Plugins", start) + spoken("Shortcuts compose", start) + start = len(server.events) + key("Left", "Left", "Down", "space") + spoken("Active actions", start) + spoken("Shortcuts", start) + # Closing must consume even keys held across Escape. + keyboard.event("z", True) + start = len(server.events) + key("Escape") + spoken("Exiting tutorial mode.", start) + time.sleep(0.8) + keyboard.event("z", False) + assert recorded() == b"", recorded() + key("a") + wait_for(lambda: recorded() == b"a", "typing after tutorial") + start = len(server.events) + key("Insert+h", "Insert+h") + spoken("Exiting tutorial mode.", start) + key("Insert+q") + restored() + print("PASS: tutorial key identification, custom F1/menu bindings, section browsing, script descriptions without execution, action suppression, held-key cleanup, Escape/toggle exit and restored typing in both layouts") + + start = len(server.events) + restart("laptop") + spoken("prompt", start) + for mode in ["table mode", "highlight tracking", "cursor tracking"]: + start = len(server.events) + key("Insert+y") + spoken(mode, start) + assert recorded() == b"", recorded() + start = len(server.events) + key("Insert+i") + spoken("prompt", start) + key("Insert+u") + spoken("charlie", start) + key("Insert+Shift_L+u") + spoken("Top", start) + start = len(server.events) + key("Insert+i") + spoken("alpha", start) + key("Insert+f") + spoken("Fenrir Menu.", start) + key("Escape") + assert recorded() == b"", recorded() + xdo("type", "--delay", "10", "laptop") + wait_for(lambda: recorded() == b"laptop", "laptop ordinary typing") + key("Insert+q") + restored() + print("PASS: runtime custom layout rebinding, separate laptop layout, modified review chords, modal capture and ordinary typing") + + start = len(server.events) + restart("table") + spoken("Name Subject", start) + key("Insert+KP_Multiply") + spoken("table mode", start) + key("KP_Begin", "Insert+x") + spoken("header row set", start) + key("KP_Prior", "KP_Begin") + spoken("Sender Name", start) + key("KP_Right") + spoken("Hello world Subject", start) + for expected in [["Hello world Subject"], list("Hello") + ["space"] + list("world"), + ["Hotel", "echo", "lima", "lima", "oscar", "space", "whisky", "oscar", "romeo", "lima", "delta"]]: + start = len(server.events) + key("KP_Begin") + speech_sequence(expected, start) + key("Insert+KP_End") + spoken("H first character in cell Subject", start) + key("Insert+KP_Next") + spoken("d last character in cell Subject", start) + key("KP_Next") + spoken("end of cell", start) + key("Insert+KP_Multiply", "Insert+KP_Multiply") + spoken("cursor tracking", start) + start = len(server.events) + key("KP_Begin") + spoken("world", start) + assert recorded() == b"", recorded() + key("Insert+q") + restored() + print("PASS: Python desktop/laptop mode cycle, table headers via shared mark dispatch, column review, cell character boundaries and return to ordinary review without PTY input") + + for echoMode, character, word, caps in [ + ("echo-off", False, False, False), + ("echo-word", False, True, False), + ("echo-both", True, True, False), + ("echo-caps", False, False, True), + ]: + (root / "settings.toml").write_bytes(settingsBefore + ( + f"[keyboard]\ncharacter_echo={str(character).lower()}\n" + f"word_echo={str(word).lower()}\necho_on_capslock={str(caps).lower()}\n" + ).encode()) + start = len(server.events) + restart(echoMode) + spoken("prompt", start) + time.sleep(0.1) + start = len(server.events) + if echoMode == "echo-caps": + key("Shift_L+a") + wait_for(lambda: recorded() == b"A", "shifted uppercase input") + time.sleep(0.1) + assert not [item for item in server.events[start:] if item != "CANCEL"], server.events[start:] + key("Caps_Lock", "b") + spoken("B", start) + start = len(server.events) + key("Shift_L+c") + spoken("c", start) # Actual CapsLock state, even for lowercase. + key("Caps_Lock") + time.sleep(0.1) + start = len(server.events) + key("d") + wait_for(lambda: recorded() == b"ABcd", "CapsLock input forwarding") + time.sleep(0.1) + assert not [item for item in server.events[start:] if item != "CANCEL"], server.events[start:] + else: + xdo("type", "--delay", "80", "hi ") + wait_for(lambda: recorded() == b"hi ", "echo trial typing") + if word: + spoken("hi", start) + time.sleep(0.1) + expected = ["h", "i", "hi"] if character else ["hi"] if word else [] + assert [item for item in server.events[start:] if item != "CANCEL"] == expected, server.events[start:] + # Disabling typing echo must not silence application output. + start = len(server.events) + key("ctrl+g") + spoken("program-output", start) + if echoMode == "echo-word": + start = len(server.events) + key("Insert+g") + spoken("Fenrir Menu. Presentation tab", start) + # Reading precedes the appended Keyboard table in this file. + key("Right", "Right", "Tab", "Tab", "Tab") + spoken("Character echo unchecked", start) + key("space", "Tab", "space", "Tab", "space", "Tab", "Return") + wait_for(lambda: tomllib.loads((root / "settings.toml").read_text())["keyboard"]["character_echo"], "echo settings save") + saved = tomllib.loads((root / "settings.toml").read_text())["keyboard"] + assert saved["character_echo"] and not saved["word_echo"] and saved["echo_on_capslock"], saved + key("Insert+q") + restored() + (root / "settings.toml").write_bytes(settingsBefore) + print("PASS: typing echo off, word-only, character-plus-word, actual CapsLock-only echo, independent application output and checkbox save") + + (root / "settings.toml").write_bytes(originalSettings + b'[reading]\npunctuation_level="none"\n') + start = len(server.events) + restart("capitals") + spoken("prompt", start) + pitchStart = len(server.settings) + start = len(server.events) + key("Shift_L+a") + spoken("A", start) + wait_for(lambda: server.settings[pitchStart:][-2:] == ["SET SELF PITCH 60", "SET SELF PITCH 0"], "capital pitch restoration") + key("Insert+g") + spoken("Fenrir Menu. Presentation tab", start) + key("Right") + spoken("Reading tab", start) + key(*(["Tab"] * 6)) + spoken("Raise pitch for capital letters checked", start) + key("space", "Tab", "space", "Tab", "space") + save_menu() + spoken("Fenrir Menu closed", start) + saved = tomllib.loads((root / "settings.toml").read_text())["reading"] + assert not saved["raise_pitch_for_capitals"] and saved["beep_for_capitals"] and saved["say_cap_for_capitals"], saved + pitchStart = len(server.settings) + start = len(server.events) + key("Shift_L+b") + spoken("cap B", start) + assert not [item for item in server.settings[pitchStart:] if item.startswith("SET SELF PITCH")], server.settings[pitchStart:] + # Cancel retains both the running choices and the saved file. + start = len(server.events) + savedBytes = (root / "settings.toml").read_bytes() + key("Insert+g") + spoken("Fenrir Menu. Presentation tab", start) + key("Right", *(["Tab"] * 6), "space", "Escape") + spoken("Fenrir Menu closed", start) + start = len(server.events) + key("Shift_L+c") + spoken("cap C", start) + assert (root / "settings.toml").read_bytes() == savedBytes + assert not [item for item in server.settings[pitchStart:] if item.startswith("SET SELF PITCH")], server.settings[pitchStart:] + start = len(server.events) + key("Insert+g") + spoken("Fenrir Menu. Presentation tab", start) + key("Right", *(["Tab"] * 7), "space", "Tab", "space") + save_menu() + spoken("Fenrir Menu closed", start) + start = len(server.events) + key("Shift_L+d") + spoken("D", start) + assert "cap D" not in server.events[start:] + assert recorded() == b"ABCD", recorded() + key("Insert+q") + restored() + (root / "settings.toml").write_bytes(settingsBefore) + print("PASS: default capital pitch and restoration, independent capital checkbox persistence, immediate spoken-cap change with sounds disabled, Cancel and all indicators off") + + for modifier in ["Multi_key", "F12"]: + scriptSettings = settingsBefore + if modifier == "F12": + scriptSettings = scriptSettings.replace(b'[general]\n', b'[general]\nscript_keys=["KEY_F12"]\n') + (root / "settings.toml").write_bytes(scriptSettings + b'[scripts]\ntimeout_seconds=1\n') + start = len(server.events) + restart("scripts") + spoken("prompt", start) + start = len(server.events) + key(modifier + "+Shift_L+u") + spoken("Username", start) + start = len(server.events) + key(modifier + "+x") + spoken("Username", start) + assert recorded() == b"", recorded() + if modifier == "Multi_key": + start = len(server.events) + key(modifier + "+v") + spoken("legacy-self-voice", start) + start = len(server.events) + key(modifier + "+h") + key("KP_Home") + spoken("charlie", start) + spoken("Script failed", start) + start = len(server.events) + key(modifier + "+x") + spoken("Username", start) + + def remote(command): + with socket.socket(socket.AF_UNIX) as connection: + connection.settimeout(2) + connection.connect(str(root / "remote.sock")) + connection.sendall((command + "\n").encode()) + connection.shutdown(socket.SHUT_WR) + return connection.recv(1024) + + start = len(server.events) + # Speech choices share the form/quick/remote catalog and + # prepare a silent candidate before changing the active voice. + assert remote("SETTING SET speech#voice=Base Voice+Variant;speech#language=en-US;speech#module=fixture-module") == b"" + assert "SET SELF SYNTHESIS_VOICE Base Voice+Variant" in server.settings + beforeFailed = len(server.settings) + assert remote("SETTING SET speech#voice=Missing;speech#rate=0.1") != b"" + assert "SET SELF RATE -80" not in server.settings[beforeFailed:] + assert remote("COMMAND SAY voice-recovery") == b"" + spoken("voice-recovery", start) + assert remote("SETTING RESET") == b"" + # Navigate all three new quick choices, then reset again. + for chord, expected in [ + ("Insert+Right", "speech pitch 0"), + ("Insert+Right", "speech volume 100"), + ("Insert+Right", "Speech Dispatcher module default"), + ("Insert+Up", "fixture-module"), + ("Insert+Right", "speech language default"), + ("Insert+Up", "en-US"), + ("Insert+Right", "speech voice default"), + ("Insert+Up", "Base Voice"), + ("Insert+Up", "Base Voice Variant"), + ]: + beforeChoice = len(server.events) + key(chord) + spoken(expected, beforeChoice) + assert remote("SETTING RESET") == b"" + key("Insert+Right") + assert recorded() == b"", recorded() + # Linux VT policy must not capture/silence an X11 session + # whose synthetic terminal number is also 1. + assert remote("SETTING SET screen#ignore_screen=1,7,8,10") == b"" + assert remote("SETTING SET screen#ignore_screen=1,64") != b"" + assert remote("COMMAND SAY vt-policy-independent") == b"" + spoken("vt-policy-independent", start) + assert remote("SETTING SET screen#ignore_screen=") == b"" + settingsStart = len(server.settings) + assert remote("SETTING SET speech#rate=0.75;speech#pitch=0.25;speech#volume=0.8;sound#volume=0.3;general#punctuation_level=all") == b"" + for setting in ["SET SELF RATE 50", "SET SELF PITCH -50", "SET SELF VOLUME 60"]: + wait_for(lambda: setting in server.settings[settingsStart:], setting) + start = len(server.events) + assert remote("COMMAND SAY x#y!") == b"" + spoken("x hash y bang", start) + # Atomic validation must reject the whole batch, including + # mute and otherwise valid values preceding a bad field. + for invalid in ["speech#rate=NaN", "speech#pitch=1.1", "speech#volume=-1", "sound#volume=Inf", "general#punctuation_level=bad"]: + assert remote("SETTING SET speech#enabled=False;speech#rate=0.1;" + invalid) != b"" + start = len(server.events) + assert remote("COMMAND SAY rollback#ok!") == b"" + spoken("rollback hash ok bang", start) + start = len(server.events) + key("Insert+ctrl+s") + spoken("Settings space saved dot", start) + saved = tomllib.loads((root / "settings.toml").read_text()) + assert saved["presentation"]["speech"]["rate"] == 50, saved + assert saved["presentation"]["speech"]["pitch"] == -50, saved + assert saved["presentation"]["speech"]["volume"] == 60, saved + assert saved["presentation"]["sound"]["volume"] == 0.3, saved + assert saved["reading"]["punctuation_level"] == "all", saved + assert remote("SETTING SET speech#rate=0.9;speech#pitch=0.5;speech#volume=1;sound#volume=0.7;general#punctuation_level=none") == b"" + assert remote("SETTING SET speech#enabled=False") == b"" + assert remote("COMMAND SAY must-not-replay") == b"" + assert remote("SETTING SET speech#enabled=True") == b"" + assert remote("COMMAND SAY after-unmute") == b"" + spoken("after-unmute", start) + assert "must-not-replay" not in server.events[start:] + # Restore bare-keypad-Enter silence without injecting a + # keypress or waiting for a prompt. + assert remote("COMMAND TEMPDISABLESPEECH") == b"" + start = len(server.events) + assert remote("COMMAND SAY temporary-discarded") == b"" + assert remote("SETTING SET speech#enabled=True;reading#mode=bad") != b"" + assert remote("COMMAND SAY still-discarded") == b"" + assert remote("SETTING SET speech#enabled=True") == b"" + spoken("speech enabled", start) + assert remote("COMMAND SAY remotely-restored") == b"" + spoken("remotely-restored", start) + assert "temporary-discarded" not in server.events[start:] + assert "still-discarded" not in server.events[start:] + for selection, nextMode in [ + ("reading#mode=highlight", "cursor tracking"), + ("focus#cursor=False;focus#highlight=True", "cursor tracking"), + ("focus#highlight=False;focus#cursor=True", "table mode"), + ("reading#mode=table", "highlight tracking"), + ]: + assert remote("SETTING SET " + selection) == b"" + start = len(server.events) + key("Insert+KP_Multiply") + spoken(nextMode, start) + assert remote("SETTING SET reading#mode=cursor") == b"" + key("Insert+q") + restored() + (root / "settings.toml").write_bytes(settingsBefore) + print("PASS: legacy Compose/custom F12 scripts, socket self-voice, normalized remote speech parameters, punctuation, atomic rollback, explicit save, remote temporary-silence restoration and tracking selection, hung-script recovery, mute admission and no script chord bytes in PTY") + + for layoutMode in ["fixture", "laptop"]: + (root / "settings.toml").write_bytes(originalSettings + + b'[reading]\npunctuation_level="none"\nspellcheck=false\n') + start = len(server.events) + restart(layoutMode) + spoken("prompt", start) + assert remote("SETTING SET speech#rate=0.2;sound#volume=0.3;speech#module=fixture-module;speech#language=en-US;speech#voice=Base Voice+Variant") == b"" + exportPath = root / (layoutMode + " exported settings.toml") + assert remote("SETTING SAVEAS " + str(exportPath)) == b"" + exported = exportPath.read_bytes() + assert tomllib.loads(exported.decode())["presentation"]["speech"]["rate"] == -60 + assert tomllib.loads(exported.decode())["presentation"]["speech"]["voice"] == "Base Voice+Variant" + assert exportPath.stat().st_mode & 0o777 == 0o600 + assert "rate" not in tomllib.loads((root / "settings.toml").read_text())["presentation"]["speech"] + assert remote("SETTING SET speech#rate=0.8;sound#volume=0.8;speech#language=de;speech#voice=German") == b"" + assert remote("SETTING RESET") == b"" + start = len(server.events) + key("Insert+space") + spoken("speech rate -60", start) + for request in ["SETTING RESET extra", "SETTING SAVE extra", "SETTING SAVEAS", "SETTING SAVEAS " + str(root)]: + assert remote(request) != b"", request + assert remote("SETTING SET speech#rate=0.9") == b"" + assert remote("SETTING SAVE") == b"" + saved = tomllib.loads((root / "settings.toml").read_text()) + assert saved["presentation"]["speech"]["rate"] == 80, saved + assert saved["presentation"]["sound"]["volume"] == 0.3, saved + assert exportPath.read_bytes() == exported + assert remote("SETTING SET speech#rate=0.1") == b"" + assert remote("SETTING RESET") == b"" + start = len(server.events) + key("Insert+space") + spoken("speech rate 80", start) + assert recorded() == b"", recorded() + key("Insert+q") + restored() + # A fresh reader must load the file saved through the socket. + start = len(server.events) + restart(layoutMode) + spoken("prompt", start) + start = len(server.events) + key("Insert+space") + spoken("speech rate 80", start) + key("Insert+q") + restored() + # A missing saved module recovers to the server default but + # ordinary saves must retain that unavailable saved choice. + unavailable = (root / "settings.toml").read_text() + assert tomllib.loads(unavailable)["presentation"]["speech"]["module"] == "fixture-module", unavailable + unavailable = unavailable.replace("fixture-module", "missing-module") + assert tomllib.loads(unavailable)["presentation"]["speech"]["module"] == "missing-module" + (root / "settings.toml").write_text(unavailable) + start = len(server.events) + restart(layoutMode) + spoken("prompt", start) + start = len(server.events) + key("Insert+Up", "Insert+ctrl+s") + spoken("Settings saved.", start) + savedUnavailable = tomllib.loads((root / "settings.toml").read_text()) + assert savedUnavailable["presentation"]["speech"]["module"] == "missing-module", savedUnavailable + assert remote("SETTING SET speech#module=fixture-module;speech#language=en-US;speech#voice=Base Voice") == b"" + assert remote("SETTING SAVE") == b"" + key("Insert+q") + restored() + (root / "settings.toml").write_bytes(settingsBefore) + print("PASS: desktop/laptop remote SAVE/SAVEAS/RESET, actual disk replies, paths with spaces, private permissions, retained default destination, failed writes, reset to saved values, voice reload, missing-choice recovery and saved-choice preservation without PTY leakage") + + commandDir = root / "commands" + commandDir.mkdir() + commandPath = commandDir / "fixturecmd" + commandPath.write_text('#!/bin/sh\necho unexpected > "' + str(root / "command-executed") + '"\n') + commandPath.chmod(0o700) + environment["PATH"] = str(commandDir) + os.pathsep + environment["PATH"] + exceptionsPath = root / "home/.local/share/stormux/fenrirscreenreader/spelling/exceptions.json" + for layout in ["custom", "laptop"]: + spellMode = "spellcheck-laptop" if layout == "laptop" else "spellcheck" + spellSettings = originalSettings + f'[keyboard]\nlayout="{keyboardDir / (layout + ".toml")}"\n[reading]\nspellcheck=true\n'.encode() + (root / "settings.toml").write_bytes(spellSettings) + start = len(server.events) + restart(spellMode) + spoken("prompt", start) + start = len(server.events) + xdo("type", "--delay", "60", "helo ") + spoken("misspelled", start) + start = len(server.events) + xdo("type", "--delay", "60", "tac fixturecmd cd ") + wait_for(lambda: recorded() == b"helo tac fixturecmd cd ", "spellcheck command typing") + time.sleep(0.2) + assert "misspelled" not in server.events[start:], server.events[start:] + assert not (root / "command-executed").exists() + key("Insert+q") + restored() + + start = len(server.events) + restart(spellMode) + spoken("prompt", start) + key("KP_Home" if layout == "custom" else "Insert+u") + start = len(server.events) + key("Insert+s") + spoken("Spellcheck Menu", start) + spoken("misspelled", start) + key("Tab", "Tab") + xdo("type", "--delay", "30", "hell") + spoken("hello. row", start) + key("Return") + spoken("Added hello to clipboard", start) + assert recorded() == b"", recorded() + + start = len(server.events) + key("Insert+s") + spoken("misspelled", start) + key("Right", "Tab", "ctrl+a") + xdo("type", "--delay", "30", "fenrir") + key("Return") + spoken("fenrir added to dictionary", start) + wait_for(lambda: exceptionsPath.exists() and json.loads(exceptionsPath.read_text()) == ["fenrir"], "persisted exception") + key("Tab", "Tab") + xdo("type", "--delay", "30", "fen") + key("Delete") + wait_for(lambda: json.loads(exceptionsPath.read_text()) == [], "exception removed") + key("Tab", "Return") + wait_for(lambda: json.loads(exceptionsPath.read_text()) == ["fenrir"], "exception undo") + start = len(server.events) + key("Shift_L+Tab", "Return") + spoken("Added fenrir to clipboard", start) + assert recorded() == b"", recorded() + + start = len(server.events) + key("Insert+f" if layout == "laptop" else "Insert+g") + spoken("Fenrir Menu.", start) + for _ in range(8): + before = len(server.events) + key("Right") + text = wait_for(lambda: next((item for item in reversed(server.events[before:]) if item != "CANCEL"), None), "settings tab") + if text == "Reading tab": + break + else: + raise AssertionError("Reading tab not found") + for _ in range(32): + before = len(server.events) + key("Tab") + text = wait_for(lambda: next((item for item in reversed(server.events[before:]) if item != "CANCEL"), None), "Reading control") + if text == "Automatic spell checking checked": + break + else: + raise AssertionError("Spellcheck checkbox not found") + key("space") + save_menu() + assert tomllib.loads((root / "settings.toml").read_text())["reading"]["spellcheck"] is False + key("Insert+q") + restored() + start = len(server.events) + restart(spellMode) + spoken("prompt", start) + start = len(server.events) + xdo("type", "--delay", "50", "helo ") + wait_for(lambda: recorded() == b"helo ", "saved spellcheck off typing") + time.sleep(0.2) + assert "misspelled" not in server.events[start:], server.events[start:] + # Manual checking and the shared exception list remain available. + start = len(server.events) + key("Insert+s") + spoken("misspelled", start) + key("Right", "Tab", "Tab", "Tab") + xdo("type", "--delay", "30", "fen") + key("Delete") + wait_for(lambda: json.loads(exceptionsPath.read_text()) == [], "persisted exception review/removal after restart") + key("Escape", "Insert+q") + restored() + (root / "settings.toml").write_bytes(originalSettings) + start = len(server.events) + restart("spellcheck-missing") + spoken("prompt", start) + key("Insert+s") + spoken("dictionary for zz ZZ is unavailable", start) + key("Escape", "Insert+q") + restored() + print("PASS: spellcheck in both layouts, real Enchant suggestions/filter/copy, review source, automatic feedback, PATH/builtins without execution, add/delete/undo/copy, exception persistence, saved disabled startup, manual menu while disabled and missing dictionary") + + # Simulate an absent library at the actual dlopen boundary, without + # changing the host installation or injecting a fake Go dictionary. + dependencyInfo = subprocess.check_output(["readelf", "-d", os.environ.get("FENRIR_TEST_BINARY", str(Path(script).parents[1] / "fenrir"))]) + assert b"libenchant" not in dependencyInfo, dependencyInfo + subprocess.run(["cc", "-shared", "-fPIC", "-o", str(root / "no-enchant.so"), + str(Path(script).with_name("enchant_unavailable.c")), "-ldl"], check=True) + subprocess.run(["cc", "-shared", "-fPIC", "-x", "c", "-", "-o", str(root / "incomplete-enchant.so")], + input="int fixtureStub;\n", text=True, check=True) + for unavailableMode in ["spellcheck-no-enchant", "spellcheck-no-enchant-laptop", "spellcheck-no-enchant-incomplete"]: + (root / "settings.toml").write_bytes(originalSettings) + start = len(server.events) + state = restart(unavailableMode) + spoken("prompt", start) # Reader and child shell actually started. + start = len(server.events) + xdo("type", "--delay", "50", "helo ") + wait_for(lambda: recorded() == b"helo ", "typing without Enchant") + time.sleep(0.2) + assert "misspelled" not in server.events[start:], server.events[start:] + start = len(server.events) + key("Insert+s") + reason = "has an incompatible interface" if unavailableMode.endswith("incomplete") else "could not be loaded" + spoken("Spell checking is unavailable: Enchant 2 " + reason, start) + key("Right", "Tab", "Return") + spoken("helo added to dictionary", start) + wait_for(lambda: json.loads(exceptionsPath.read_text()) == ["helo"], "exceptions usable without Enchant") + key("Tab", "Tab", "Return") + spoken("Added helo to clipboard", start) + start = len(server.events) + key("Insert+s") + spoken("Spell checking is unavailable: Enchant 2 " + reason, start) + key("Right", "Tab", "Tab", "Tab", "Delete") + wait_for(lambda: json.loads(exceptionsPath.read_text()) == [], "remove exception without Enchant") + key("Escape") + assert recorded() == b"helo ", recorded() + start = len(server.events) + key("Insert+i" if unavailableMode.endswith("laptop") else "KP_Up") + spoken("prompt", start) + assert json.loads((root / "started").read_text())["pid"] == state["pid"] + key("Insert+q") + restored() + print("PASS: missing Enchant in both layouts and incompatible Enchant ABI preserve startup, typing/review, unavailable announcement, exception add/copy/remove, capture cleanup and terminal restoration") + + for layout in ["custom", "laptop"]: + indentationMode = "indentation-laptop" if layout == "laptop" else "indentation" + indentationSettings = originalSettings + f'[keyboard]\nlayout="{keyboardDir / (layout + ".toml")}"\n'.encode() + (root / "settings.toml").write_bytes(indentationSettings) + start = len(server.events) + restart(indentationMode) + spoken("root", start) + for chord, expected in [ + ("Insert+ctrl+Right", "Reading. Punctuation level some"), + ("Insert+Right", "Indentation none"), + ("Insert+Up", "speech"), + ]: + start = len(server.events) + key(chord) + spoken(expected, start) + nextLine = "KP_Prior" if layout == "custom" else "Insert+o" + currentLine = "KP_Up" if layout == "custom" else "Insert+i" + for expected, indent in [("child", "indented 4"), ("peer", None), ("blank", None), ("deeper", "indented 8"), ("root again", "indented 0")]: + start = len(server.events) + key(nextLine) + spoken(expected, start) + if indent: + spoken(indent, start) + else: + assert not [item for item in server.events[start:] if item.startswith("indented ")], server.events[start:] + start = len(server.events) + key(currentLine) + spoken("root again", start) + assert not [item for item in server.events[start:] if item.startswith("indented ")] + # With sound disabled in the fixture, beeps fall back to speech. + for chord, expected in [("Insert+Up", "beeps"), ("Insert+Up", "speech and beeps"), ("Insert+Up", "none"), ("Insert+Down", "speech and beeps")]: + start = len(server.events) + key(chord) + spoken(expected, start) + start = len(server.events) + key("Insert+ctrl+s") + spoken("Settings saved.", start) + assert tomllib.loads((root / "settings.toml").read_text())["reading"]["indentation"] == "both" + assert recorded() == b"", recorded() + key("Insert+q") + restored() + start = len(server.events) + restart(indentationMode) + spoken("root", start) + start = len(server.events) + key(nextLine) + spoken("indented 4", start) + spoken("child", start) + assert recorded() == b"", recorded() + key("Insert+q") + restored() + (root / "settings.toml").write_bytes(settingsBefore) + print("PASS: desktop/laptop indentation quick modes, review increases/dedents, blank and same-level suppression, sound-disabled fallback, save/restart and no PTY chord bytes") + + for layoutMode in ["fixture", "laptop"]: + (root / "settings.toml").write_bytes(originalSettings) + start = len(server.events) + restart(layoutMode) + spoken("prompt", start) + + def processed_speech(expected, start): + wait_for(lambda: any(" ".join(item.split()) == expected for item in server.events[start:]), + f"processed speech {expected!r}; recent={server.events[-8:]}") + + def open_reading(): + start = len(server.events) + key("Insert+f" if layoutMode == "laptop" else "Insert+g") + spoken("Fenrir Menu.", start) + for _ in range(8): + before = len(server.events) + key("Right") + text = wait_for(lambda: next((item for item in reversed(server.events[before:]) + if item != "CANCEL"), None), "settings tab") + if text == "Reading tab": + return + raise AssertionError("Reading tab not found") + + def focus_control(expected): + for _ in range(48): + before = len(server.events) + key("Tab") + text = wait_for(lambda: next((item for item in reversed(server.events[before:]) + if item != "CANCEL"), None), "text processing control") + if text == expected: + return + raise AssertionError("Control not found: " + expected) + + start = len(server.events) + say_remote(":) :) #####") + processed_speech("smile smile hash hash hash hash hash", start) + open_reading() + focus_control("Repeated symbols unchecked") + key("space") + save_menu() + start = len(server.events) + say_remote(":) :) #####") + processed_speech("smile smile 5 number", start) + start = len(server.events) + emit_output(b"\x1b[2J\x1b[H:) :) #####\x1b[1;1H") + processed_speech("smile smile 5 number", start) + start = len(server.events) + key("Insert+i" if layoutMode == "laptop" else "KP_Up") + processed_speech("smile smile 5 number", start) + open_reading() + focus_control("Emoticons checked") + key("space", "Escape") + start = len(server.events) + say_remote(":) :) #####") + processed_speech("smile smile 5 number", start) + open_reading() + focus_control("Emoticons checked") + key("space") + focus_control("Repeated symbols minimum length 4") + key("Up") + save_menu() + saved = tomllib.loads((root / "settings.toml").read_text())["reading"] + assert saved["emoticons"] is False and saved["repeated_symbols"] is True and saved["repeated_symbols_min"] == 5, saved + start = len(server.events) + say_remote(":) :) #####") + processed_speech(": : 5 number", start) + assert recorded() == b"", recorded() + key("Insert+q") + restored() + start = len(server.events) + restart(layoutMode) + spoken("prompt", start) + start = len(server.events) + say_remote(":) :) ##### ####") + processed_speech(": : 5 number hash hash hash hash", start) + key("Insert+q") + restored() + (root / "settings.toml").write_bytes(settingsBefore) + print("PASS: desktop/laptop emoticon adjacency, optional repeated-symbol summaries, automatic output, review, self-voice, form Cancel/OK, minimum length, save/restart and no PTY chord bytes") + + for layout in ["custom", "laptop"]: + layoutPath = keyboardDir / (layout + ".toml") + # End the earlier remapping trial and restore the new desktop + # command; laptop retains its bundled Fenrir+A binding. + layoutPath.write_text(layoutPath.read_text().replace( + 'keys=["KPMINUS"]\ncommand="current_quick_menu_value"', + 'keys=["KPMINUS"]\ncommand="attribute_cursor"')) + attributeMode = "attributes-laptop" if layout == "laptop" else "attributes" + (root / "settings.toml").write_bytes(originalSettings + ( + '[reading]\npunctuation_level="none"\n' + 'attribute_format="fenrirFGColor,fenrirBGColor,fenrirBold,fenrirUnderline,fenrirBlink"\n' + f'[keyboard]\nlayout="{layoutPath}"\n').encode()) + start = len(server.events) + restart(attributeMode) + spoken("root", start) + toggleKey = "Insert+KP_Subtract" + nextLine = "KP_Prior" if layout == "custom" else "Insert+o" + attributeKey = "KP_Subtract" if layout == "custom" else "Insert+a" + followKey = "Insert+KP_Delete" if layout == "custom" else "Insert+slash" + start = len(server.events) + key(toggleKey) + spoken("announcement of attributes enabled", start) + start = len(server.events) + key(nextLine) + spoken("has attribute", start) + start = len(server.events) + key("Insert+ctrl+s") + spoken("Settings saved.", start) + assert tomllib.loads((root / "settings.toml").read_text())["reading"]["attributes"] == "none" + assert recorded() == b"", recorded() + key("Insert+q") + restored() + start = len(server.events) + restart(attributeMode) + spoken("root", start) + start = len(server.events) + key(nextLine) + spoken("styled", start) + assert not [item for item in server.events[start:] if "has attribute" in item or "red,blue" in item] + start = len(server.events) + key(attributeKey) + spoken("red,blue,bold,underline,blink", start) + start = len(server.events) + key(followKey) + spoken("Exiting Review Mode", start) + for chord, expected in [("Insert+ctrl+Right", "Reading. Punctuation level none"), + ("Insert+Right", "Indentation none"), ("Insert+Right", "Attributes none")]: + start = len(server.events) + key(chord) + spoken(expected, start) + start = len(server.events) + key("Insert+Up") + spoken("speech", start) + nextLine = "KP_Prior" if layout == "custom" else "Insert+o" + currentLine = "KP_Up" if layout == "custom" else "Insert+i" + attributeKey = "KP_Subtract" if layout == "custom" else "Insert+a" + followKey = "Insert+KP_Delete" if layout == "custom" else "Insert+slash" + start = len(server.events) + key(nextLine) + spoken("red,blue,bold,underline,blink", start) + spoken("styled", start) + for chord, expected in [(currentLine, "styled"), (nextLine, "peer")]: + start = len(server.events) + key(chord) + spoken(expected, start) + assert not [item for item in server.events[start:] if "red,blue" in item], server.events[start:] + for _ in range(2): + start = len(server.events) + key(attributeKey) + spoken("red,blue,bold,underline,blink", start) + start = len(server.events) + key(followKey) + spoken("Exiting Review Mode", start) + # Redraw the same text under a stationary application cursor. + (root / "prompt-output").write_bytes(b"\x1b[H\x1b[1;4;5;31;44mroot\x1b[0m\x1b[H") + start = len(server.events) + os.kill(int((root / "shell-pid").read_text()), signal.SIGUSR1) + spoken("red,blue,bold,underline,blink", start) + # Beeps use the presence announcement when sound is disabled. + start = len(server.events) + key("Insert+Up") + spoken("beeps", start) + key(nextLine) # Same style: no repeated attribute feedback. + start = len(server.events) + key(nextLine) + spoken("peer", start) + assert not [item for item in server.events[start:] if "has attribute" in item] + key(nextLine) # Defaults: no misleading presence sound. + previousLine = "KP_Home" if layout == "custom" else "Insert+u" + start = len(server.events) + key(previousLine) + spoken("has attribute", start) + start = len(server.events) + key("Insert+Up") + spoken("speech and beeps", start) + start = len(server.events) + key(toggleKey) + spoken("announcement of attributes disabled", start) + start = len(server.events) + key("Insert+space") + spoken("Attributes speech and beeps", start) + key(nextLine) + start = len(server.events) + key(previousLine) + spoken("peer", start) + assert not [item for item in server.events[start:] if "red,blue" in item or "has attribute" in item] + start = len(server.events) + key("Insert+ctrl+s") + spoken("Settings saved.", start) + savedReading = tomllib.loads((root / "settings.toml").read_text())["reading"] + assert savedReading["attributes"] == "both", savedReading + assert savedReading["attribute_format"].startswith("fenrirFGColor,"), savedReading + assert recorded() == b"", recorded() + key("Insert+q") + restored() + start = len(server.events) + restart(attributeMode) + spoken("root", start) + start = len(server.events) + key(nextLine) + spoken("red,blue,bold,underline,blink", start) + assert recorded() == b"", recorded() + key("Insert+q") + restored() + (root / "settings.toml").write_bytes(settingsBefore) + print("PASS: desktop/laptop attribute report, temporary toggle on/off, saved off/on defaults, unrelated quick activity, style-only changes, same-style suppression, quick modes, sound fallback, save/restart and no chord bytes in PTY") + + regionSettingsBefore = (root / "settings.toml").read_bytes() + for layout in ["custom", "laptop"]: + (root / "settings.toml").write_bytes(originalSettings + + b'[keyboard]\ncharacter_echo=true\nword_echo=false\n[reading]\npunctuation_level="none"\nspellcheck=false\n') + start = len(server.events) + restart("regions-laptop" if layout == "laptop" else "regions") + spoken("compose", start) + wait_for(lambda: (root / "shell-pid").exists(), "region shell ready") + start = len(server.events) + key("Insert+Shift_L+v") + spoken("Fenrir screen reader version " + cliVersion, start) + assert recorded() == b"", recorded() + nextLine = "KP_Prior" if layout == "custom" else "Insert+o" + lineStart = "Insert+KP_Left" if layout == "custom" else "Insert+Shift_L+j" + followCursor = "Insert+KP_Delete" if layout == "custom" else "Insert+slash" + readScreen = "Insert+KP_Begin" if layout == "custom" else "Insert+Shift_L+k" + windowKey = "Insert+KP_Divide" if layout == "custom" else "Insert+ctrl+8" + start = len(server.events) + key("Insert+1") + spoken("Live region 1 not set", start) + key(nextLine, lineStart, "Insert+x") + start = len(server.events) + key("Insert+b") + spoken("Live region 1 set", start) + start = len(server.events) + key("Insert+Shift_L+b") + spoken("Live region 1", start) + spoken("end of line", start) + key(followCursor) + start = len(server.events) + key("x") + wait_for(lambda: recorded() == b"x", "typing with ruler") + spoken("x", start) + time.sleep(0.2) + assert [item for item in server.events[start:] if item != "CANCEL"] == ["x"], server.events[start:] + start = len(server.events) + emit_output(b"\x1b7\x1b[2;1H\x1b[2Kruler ten\x1b8") + time.sleep(0.2) + assert not [item for item in server.events[start:] if item != "CANCEL"], server.events[start:] + key("Insert+1") + spoken("ruler ten", start) + start = len(server.events) + key(readScreen) + spoken("ruler ten", start) + start = len(server.events) + key("Insert+Shift_L+1") + spoken("Live region 1 cleared", start) + start = len(server.events) + key("Insert+1") + spoken("Live region 1 not set", start) + start = len(server.events) + emit_output(b"\x1b7\x1b[2;1H\x1b[2Kruler eleven\x1b8") + spoken("eleven", start) + # Window marks are detached from the unchanged application + # cursor. Top-left to the final character of the first row. + key("Insert+KP_Home" if layout == "custom" else "Insert+Shift_L+u", lineStart, "Insert+x") + key("Insert+KP_Right" if layout == "custom" else "Insert+Shift_L+l", "Insert+x") + start = len(server.events) + key(windowKey) + spoken("Window Mode on for terminal", start) + start = len(server.events) + emit_output(b"\x1b7\x1b[2;1H\x1b[2Kruler twelve\x1b8") + time.sleep(0.2) + assert not [item for item in server.events[start:] if item != "CANCEL"], server.events[start:] + start = len(server.events) + key(readScreen) + spoken("ruler twelve", start) + start = len(server.events) + key(windowKey, windowKey) + spoken("Window Mode off for terminal", start) + start = len(server.events) + key("Insert+ctrl+Shift_L+b") + spoken("Reading regions cleared", start) + assert recorded() == b"x", recorded() + key("Insert+q") + restored() + (root / "settings.toml").write_bytes(regionSettingsBefore) + print("PASS: desktop/laptop live-region ruler filtering preserves concurrent typing, latest reads, unrestricted review, listing, individual clearing, reading windows, double-press clear, clear-all and no shortcut bytes in PTY") + + restart("shell") + spoken("fixture") + # A real interactive shell exercises controlling-terminal job + # control, rather than only a raw byte fixture. + xdo("type", "--delay", "1", "sleep 5") + key("Return") + time.sleep(0.1) + key("ctrl+c") + xdo("type", "--delay", "1", f"echo shell-ok > {root}/shell-result") + key("Return") + wait_for(lambda: (root / "shell-result").exists(), "shell after Ctrl+C", timeout=2) + assert (root / "shell-result").read_text().strip() == "shell-ok" + xdo("type", "--delay", "1", "exit") + key("Return") + restored() + print("PASS: real interactive shell, child Ctrl+C, subsequent command and normal shell exit") + + restart("failure") + restored(1) + assert "start PTY shell" in (root / "reader-errors").read_text() + print("PASS: partial startup failure restores terminal attributes and flags") + + state = restart("signal") + wait_for(lambda: (root / "size").exists(), "signal fixture ready") + keyboard.event("Insert", True) + os.kill(state["pid"], signal.SIGTERM) + restored() + keyboard.event("Insert", False) + timing = (root / "timing.log").read_text() + assert 'msg="reader starting"' in timing, timing[-1000:] + assert 'msg="reader received signal" signal=terminated' in timing, timing[-1000:] + assert 'msg="reader stopped" pid=' in timing, timing[-1000:] + print("PASS: SIGTERM with a held reader modifier releases input and restores terminal") + assert not server.errors, server.errors + except BaseException: + print("SSIP events:", server.events[-20:], file=sys.stderr) + if "window" in locals(): + print(subprocess.run(["xwininfo", "-id", window, "-tree", "-events"], capture_output=True, text=True).stdout, file=sys.stderr) + print("focus:", subprocess.run(["xdotool", "getwindowfocus"], capture_output=True, text=True).stdout, file=sys.stderr) + for name in ["started", "finished", "size", "reader-errors", "timing.log"]: + if (root / name).exists(): + print(name + ":", (root / name).read_text()[-6000:], file=sys.stderr) + raise + finally: + for process in reversed(processes): + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + server.close() + keyboard.close() + + +if __name__ == "__main__": + if len(sys.argv) > 1 and sys.argv[1] == "--fixture-shell": + fixture_shell() + elif len(sys.argv) > 1 and sys.argv[1] == "--outer": + outer_terminal() + else: + main() diff --git a/tests/progress_demo.py b/tests/progress_demo.py new file mode 100644 index 00000000..bbfa0478 --- /dev/null +++ b/tests/progress_demo.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""Print progress for a human listening trial inside Fenrir TTY or -x mode. + +This generates terminal output only; Fenrir owns all tone playback. +""" + +import argparse +import time + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--scroll", action="store_true", help="Print each update on a new line to test scrolling") + args = parser.parse_args() + linePrefix = "" if args.scroll else "\r" + lineEnd = "\n" if args.scroll else "" + for percentage in range(0, 101, 10): + filled = percentage // 10 + barText = "#" * filled + " " * (10 - filled) + print(f"{linePrefix}Download {percentage:3}% [{barText}]", end=lineEnd, flush=True) + time.sleep(0.2) + print() + for spinnerChar in "ā ‹ā ™ā ¹ā øā ¼ā “ā ¦ā §ā ‡ā " * 2: + print(f"{linePrefix}{spinnerChar} Preparing files", end=lineEnd, flush=True) + time.sleep(0.25) + print(linePrefix + "Done." + " " * 20, flush=True) + + +if __name__ == "__main__": + main() diff --git a/tests/python_script_contract.py b/tests/python_script_contract.py new file mode 100644 index 00000000..2845e8d4 --- /dev/null +++ b/tests/python_script_contract.py @@ -0,0 +1,45 @@ +"""Read the real Python script loader's contract without executing scripts. + +Invoked by TestPythonLoaderParity when FENRIR_PYTHON_SOURCE names a checkout. +The JSON output is compared with Go discovery over the same fixture files. +""" + +import json +import sys +from types import SimpleNamespace +from unittest.mock import Mock + +sys.path.insert(0, sys.argv[1] + "/src") + +from fenrirscreenreader.core import commandManager, inputData + + +class ScriptCommand: + def initialize(self, environment, path): + self.path = path + + +commandManager.module_utils.import_module = lambda *_: SimpleNamespace(command=ScriptCommand) +settings = Mock() +settings.get_user_script_path.return_value = sys.argv[2] +settings.get_setting.return_value = sys.argv[3] +manager = commandManager.CommandManager() +manager.env = { + "commands": {"commands": {}}, + "bindings": {}, + "rawBindings": {}, + "runtime": { + "SettingsManager": settings, + "DebugManager": Mock(), + "InputManager": Mock(is_valid_key=lambda name: name in inputData.key_names), + }, +} +loaded_names = set() +for path in manager.get_script_paths()[:2]: + manager.load_script_commands_from_path("commands", path, loaded_names) +result = [] +for name, command in manager.env["commands"]["commands"].items(): + chord = next(keys[1] for key, keys in manager.env["rawBindings"].items() + if manager.env["bindings"][key] == name) + result.append({"name": name, "path": command.path, "keys": chord}) +print(json.dumps(result)) diff --git a/tests/requirements.txt b/tests/requirements.txt deleted file mode 100644 index 380b8946..00000000 --- a/tests/requirements.txt +++ /dev/null @@ -1,21 +0,0 @@ -# Test dependencies for Fenrir screen reader -# Install with: pip install -r tests/requirements.txt - -# Core testing framework (required) -pytest>=7.0.0 - -# Optional but recommended plugins -pytest-cov>=4.0.0 # Coverage reporting (pytest --cov) -pytest-mock>=3.10.0 # Enhanced mocking utilities -pytest-timeout>=2.1.0 # Timeout for hanging tests (pytest --timeout) -pytest-xdist>=3.0.0 # Parallel test execution (pytest -n auto) - -# Additional testing utilities (optional) -freezegun>=1.2.0 # Time mocking -responses>=0.22.0 # HTTP mocking (for future web features) - -# Minimal install (just pytest): -# pip install pytest -# -# Full install (all features): -# pip install -r tests/requirements.txt diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py deleted file mode 100644 index 69c07491..00000000 --- a/tests/unit/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Unit tests for Fenrir screen reader components.""" diff --git a/tests/unit/config_test_utils.py b/tests/unit/config_test_utils.py deleted file mode 100644 index ef075db9..00000000 --- a/tests/unit/config_test_utils.py +++ /dev/null @@ -1,16 +0,0 @@ -from configparser import ConfigParser -from pathlib import Path - - -def load_source_settings(): - """Load settings.conf from a checkout or a nested package build tree.""" - for parent in Path(__file__).resolve().parents: - settings_path = parent / "config/settings/settings.conf" - if settings_path.is_file(): - config = ConfigParser(interpolation=None) - config.read(settings_path) - return config - - raise FileNotFoundError( - "Could not find config/settings/settings.conf above the test directory" - ) diff --git a/tests/unit/test_async_voice_workflows.py b/tests/unit/test_async_voice_workflows.py deleted file mode 100644 index 5f0d75fe..00000000 --- a/tests/unit/test_async_voice_workflows.py +++ /dev/null @@ -1,303 +0,0 @@ -from unittest.mock import Mock - -from fenrirscreenreader.commands.commands import apply_tested_voice -from fenrirscreenreader.commands.commands import voice_browser -from fenrirscreenreader.commands.commands import voice_browser_safe -from fenrirscreenreader.core import dynamicVoiceMenu -from fenrirscreenreader.core.quickMenuManager import QuickMenuManager - - -class FakeDiscoveryManager: - def __init__(self): - self.cached_modules = None - self.cached_voices = {} - self.module_callbacks = [] - self.voice_callbacks = {} - self.test_callbacks = [] - - def get_cached_modules(self): - return self.cached_modules - - def get_cached_voices(self, module): - return self.cached_voices.get(module) - - def request_modules(self, callback, refresh=False): - self.module_callbacks.append(callback) - return len(self.module_callbacks) - - def request_voices(self, module, callback, refresh=False): - self.voice_callbacks.setdefault(module, []).append(callback) - return len(self.voice_callbacks[module]) - - def request_voice_test(self, module, voice, message, callback): - self.test_callbacks.append((module, voice, message, callback)) - return len(self.test_callbacks) - - def complete_modules(self, modules, error=""): - self.cached_modules = modules - callbacks = self.module_callbacks - self.module_callbacks = [] - for callback in callbacks: - callback(list(modules), error) - - def complete_voices(self, module, voices, error=""): - self.cached_voices[module] = voices - callbacks = self.voice_callbacks.pop(module, []) - for callback in callbacks: - callback(module, list(voices), error) - - def complete_test(self, succeeded, error=""): - _module, _voice, _message, callback = self.test_callbacks.pop(0) - callback(succeeded, error) - - -class RuntimeSettings: - def __init__( - self, - module="rhvoice", - voice="alan", - language="en-us", - driver="speechdDriver", - ): - self.values = { - ("speech", "driver"): driver, - ("speech", "module"): module, - ("speech", "voice"): voice, - ("speech", "language"): language, - } - - def get_setting(self, section, setting): - return self.values[(section, setting)] - - def set_setting(self, section, setting, value): - self.values[(section, setting)] = value - - -def create_voice_environment(discovery): - return { - "runtime": { - "DebugManager": Mock(), - "OutputManager": Mock(), - "SpeechDiscoveryManager": discovery, - }, - "commandBuffer": {}, - } - - -def test_safe_browser_records_voice_only_after_successful_test(): - discovery = FakeDiscoveryManager() - environment = create_voice_environment(discovery) - browser = voice_browser_safe.command() - browser.initialize(environment) - - browser.run() - assert "lastTestedVoice" not in environment["commandBuffer"] - - discovery.complete_modules(["rhvoice"]) - discovery.complete_voices("rhvoice", ["alan", "slt"]) - assert "lastTestedVoice" not in environment["commandBuffer"] - - discovery.complete_test(True) - assert environment["commandBuffer"]["lastTestedModule"] == "rhvoice" - assert environment["commandBuffer"]["lastTestedVoice"] == "alan" - - -def test_safe_browser_does_not_record_failed_test(): - discovery = FakeDiscoveryManager() - environment = create_voice_environment(discovery) - browser = voice_browser_safe.command() - browser.initialize(environment) - - browser.run() - discovery.complete_modules(["rhvoice"]) - discovery.complete_voices("rhvoice", ["alan"]) - discovery.complete_test(False, "test timed out") - - assert "lastTestedVoice" not in environment["commandBuffer"] - - -def test_safe_browser_keeps_voxin_language_separate(): - discovery = FakeDiscoveryManager() - environment = create_voice_environment(discovery) - browser = voice_browser_safe.command() - browser.initialize(environment) - - browser.run() - discovery.complete_modules(["voxin"]) - discovery.complete_voices("voxin", ["Nathan|en-US"]) - discovery.complete_test(True) - - assert environment["commandBuffer"]["lastTestedVoice"] == "Nathan" - assert environment["commandBuffer"]["lastTestedLanguage"] == "en-US" - - -def test_interactive_browser_keeps_voxin_language_through_test_and_apply(): - discovery = FakeDiscoveryManager() - environment = create_voice_environment(discovery) - environment["runtime"]["SettingsManager"] = Mock( - settings={"speech": {}}, - ) - browser = voice_browser.command() - browser.initialize(environment) - browser.modules = ["voxin"] - browser.voices = ["Nathan|en-US"] - - browser.test_voice() - assert discovery.test_callbacks[0][1] == "Nathan" - discovery.complete_test(True) - assert environment["commandBuffer"]["lastTestedVoice"] == "Nathan" - assert environment["commandBuffer"]["lastTestedLanguage"] == "en-US" - - browser.apply_voice() - speech_settings = environment["runtime"]["SettingsManager"].settings[ - "speech" - ] - assert speech_settings["voice"] == "Nathan" - assert speech_settings["language"] == "en-US" - - -def test_apply_tested_voice_applies_voxin_language(): - settings = RuntimeSettings() - speech_driver = Mock() - environment = { - "runtime": { - "OutputManager": Mock(), - "SettingsManager": settings, - "SpeechDriver": speech_driver, - }, - "commandBuffer": { - "lastTestedModule": "voxin", - "lastTestedVoice": "Nathan", - "lastTestedLanguage": "en-US", - }, - } - command = apply_tested_voice.command() - command.initialize(environment) - - command.run() - - assert settings.get_setting("speech", "language") == "en-US" - speech_driver.set_language.assert_called_once_with("en-US") - - -def test_dynamic_voice_command_commits_result_on_main_loop_callback(): - discovery = FakeDiscoveryManager() - environment = create_voice_environment(discovery) - voice_command = dynamicVoiceMenu.DynamicVoiceCommand( - "rhvoice", "alan", environment - ) - - voice_command.run() - assert environment["commandBuffer"] == {} - - discovery.complete_test(True) - assert environment["commandBuffer"]["pendingVoiceModule"] == "rhvoice" - assert environment["commandBuffer"]["pendingVoiceVoice"] == "alan" - assert environment["commandBuffer"]["voiceTestCompleted"] is True - - -def test_dynamic_voxin_voice_keeps_language_out_of_voice_name(): - discovery = FakeDiscoveryManager() - environment = create_voice_environment(discovery) - voice_command = dynamicVoiceMenu.DynamicVoiceCommand( - "voxin", "Nathan|en-US", environment - ) - - voice_command.run() - assert discovery.test_callbacks[0][1] == "Nathan" - - discovery.complete_test(True) - assert environment["commandBuffer"]["pendingVoiceVoice"] == "Nathan" - assert environment["commandBuffer"]["pendingVoiceLanguage"] == "en-US" - - -def test_quick_menu_waits_for_discovery_then_applies_module(): - discovery = FakeDiscoveryManager() - settings = RuntimeSettings() - output_manager = Mock() - manager = QuickMenuManager() - manager.env = { - "runtime": { - "DebugManager": Mock(), - "OutputManager": output_manager, - "SettingsManager": settings, - "SpeechDriver": Mock(), - "SpeechDiscoveryManager": discovery, - } - } - - assert manager.cycle_speech_module("next") is False - assert settings.get_setting("speech", "module") == "rhvoice" - - discovery.complete_modules(["rhvoice", "espeak-ng"]) - discovery.complete_voices("espeak-ng", ["en-us"]) - - assert settings.get_setting("speech", "module") == "espeak-ng" - assert settings.get_setting("speech", "voice") == "en-us" - output_manager.present_text.assert_any_call("espeak-ng", interrupt=True) - - -def test_quick_menu_deduplicates_voice_request_for_module_cycle(): - discovery = FakeDiscoveryManager() - discovery.cached_modules = ["rhvoice", "espeak-ng"] - settings = RuntimeSettings() - manager = QuickMenuManager() - manager.env = { - "runtime": { - "DebugManager": Mock(), - "OutputManager": Mock(), - "SettingsManager": settings, - "SpeechDriver": Mock(), - "SpeechDiscoveryManager": discovery, - } - } - - assert manager.cycle_speech_module("next") is False - assert manager.cycle_speech_module("next") is False - assert len(discovery.voice_callbacks["espeak-ng"]) == 1 - - -def test_dynamic_vmenu_keeps_loading_entry_until_all_results_arrive(): - discovery = FakeDiscoveryManager() - environment = create_voice_environment(discovery) - vmenu_manager = Mock() - vmenu_manager.env = environment - vmenu_manager.menuDict = {} - vmenu_manager._voice_menu_generation = 0 - vmenu_manager.get_active.return_value = False - - dynamicVoiceMenu.add_dynamic_voice_menus(vmenu_manager) - assert "Loading voices Action" in vmenu_manager.menuDict[ - "Voice Browser Menu" - ] - - discovery.complete_modules(["rhvoice", "espeak-ng"]) - discovery.complete_voices("rhvoice", ["alan"]) - assert "Loading voices Action" in vmenu_manager.menuDict[ - "Voice Browser Menu" - ] - - discovery.complete_voices("espeak-ng", ["en-us"]) - voice_menu = vmenu_manager.menuDict["Voice Browser Menu"] - assert "rhvoice Menu" in voice_menu - assert "espeak-ng Menu" in voice_menu - assert "alan Action" in voice_menu["rhvoice Menu"] - - -def test_dynamic_vmenu_does_not_replace_active_loading_menu(): - discovery = FakeDiscoveryManager() - environment = create_voice_environment(discovery) - vmenu_manager = Mock() - vmenu_manager.env = environment - vmenu_manager.menuDict = {} - vmenu_manager._voice_menu_generation = 0 - vmenu_manager.get_active.return_value = False - - dynamicVoiceMenu.add_dynamic_voice_menus(vmenu_manager) - vmenu_manager.get_active.return_value = True - discovery.complete_modules(["voxin"]) - discovery.complete_voices("voxin", ["Nathan|en-US"]) - - voice_menu = vmenu_manager.menuDict["Voice Browser Menu"] - assert "Loading voices Action" in voice_menu - assert vmenu_manager._voice_menu_refresh_pending is True diff --git a/tests/unit/test_background_task_manager.py b/tests/unit/test_background_task_manager.py deleted file mode 100644 index e8f43edf..00000000 --- a/tests/unit/test_background_task_manager.py +++ /dev/null @@ -1,109 +0,0 @@ -import threading -import time -from unittest.mock import Mock - -from fenrirscreenreader.core.backgroundTaskManager import BackgroundTaskManager -from fenrirscreenreader.core.eventData import FenrirEventType - - -def wait_for_call(mock, timeout=1.0): - deadline = time.monotonic() + timeout - while not mock.called and time.monotonic() < deadline: - time.sleep(0.01) - assert mock.called - - -def test_worker_returns_result_through_event_queue_before_callback_runs(): - event_manager = Mock() - environment = { - "runtime": { - "DebugManager": Mock(), - "EventManager": event_manager, - } - } - manager = BackgroundTaskManager(worker_count=1) - manager.initialize(environment) - callback = Mock() - main_thread_id = threading.get_ident() - - try: - task_id = manager.submit_task(lambda value: value * 2, callback, 21) - wait_for_call(event_manager.put_to_event_queue) - - callback.assert_not_called() - event_type, result = event_manager.put_to_event_queue.call_args.args - assert event_type == FenrirEventType.background_task_result - assert result == { - "task_id": task_id, - "succeeded": True, - "value": 42, - "error": "", - } - - callback.side_effect = lambda _result: setattr( - callback, "thread_id", threading.get_ident() - ) - manager.handle_result(result) - callback.assert_called_once_with(result) - assert callback.thread_id == main_thread_id - finally: - manager.shutdown() - - -def test_cancelled_task_result_is_not_delivered(): - environment = { - "runtime": { - "DebugManager": Mock(), - "EventManager": Mock(), - } - } - manager = BackgroundTaskManager(worker_count=1) - manager.initialize(environment) - callback = Mock() - - try: - task_id = manager.submit_task(lambda: "late", callback) - manager.cancel_task(task_id) - manager.handle_result( - { - "task_id": task_id, - "succeeded": True, - "value": "late", - "error": "", - } - ) - callback.assert_not_called() - finally: - manager.shutdown() - - -def test_external_tasks_cannot_starve_default_tasks(): - event_manager = Mock() - environment = { - "runtime": { - "DebugManager": Mock(), - "EventManager": event_manager, - } - } - manager = BackgroundTaskManager(worker_count=1, external_worker_count=2) - manager.initialize(environment) - release_external = threading.Event() - external_started = [threading.Event(), threading.Event()] - - def block_external(started): - started.set() - release_external.wait(timeout=2) - - try: - for started in external_started: - manager.submit_external_task(block_external, Mock(), started) - for started in external_started: - assert started.wait(timeout=1) - - manager.submit_task(lambda: "voice result", Mock()) - wait_for_call(event_manager.put_to_event_queue) - result = event_manager.put_to_event_queue.call_args.args[1] - assert result["value"] == "voice result" - finally: - release_external.set() - manager.shutdown() diff --git a/tests/unit/test_clipboard_commands_background_result.py b/tests/unit/test_clipboard_commands_background_result.py deleted file mode 100644 index b88ab9d4..00000000 --- a/tests/unit/test_clipboard_commands_background_result.py +++ /dev/null @@ -1,91 +0,0 @@ -from unittest.mock import Mock - -from fenrirscreenreader.commands.commands import export_clipboard_to_x -from fenrirscreenreader.commands.commands import import_clipboard_from_x - - -class FakeTaskManager: - def __init__(self): - self.callback = None - self.function = None - self.args = None - - def submit_task(self, function, callback, *args): - self.function = function - self.callback = callback - self.args = args - return 7 - - def cancel_task(self, _task_id): - pass - - -def test_clipboard_import_changes_history_only_after_result_event(): - task_manager = FakeTaskManager() - memory_manager = Mock() - environment = { - "runtime": { - "BackgroundTaskManager": task_manager, - "MemoryManager": memory_manager, - "OutputManager": Mock(), - "SettingsManager": Mock(get_setting=Mock(return_value=":2")), - "ClipboardSyncManager": Mock(), - } - } - command = import_clipboard_from_x.command() - command.initialize(environment) - - command.run() - assert task_manager.args == (":2",) - memory_manager.add_value_to_first_index.assert_not_called() - - task_manager.callback( - {"task_id": 7, "succeeded": True, "value": "copied text"} - ) - memory_manager.add_value_to_first_index.assert_called_once_with( - "clipboardHistory", "copied text" - ) - sync_manager = environment["runtime"]["ClipboardSyncManager"] - sync_manager.mark_imported_from_x.assert_called_once_with("copied text") - - -def test_clipboard_export_updates_sync_state_only_after_result_event(): - task_manager = FakeTaskManager() - memory_manager = Mock( - is_index_list_empty=Mock(return_value=False), - get_index_list_element=Mock(return_value="copied text"), - ) - sync_manager = Mock() - environment = { - "runtime": { - "BackgroundTaskManager": task_manager, - "ClipboardSyncManager": sync_manager, - "DebugManager": Mock(), - "MemoryManager": memory_manager, - "OutputManager": Mock(), - "SettingsManager": Mock(get_setting=Mock(return_value=":2")), - } - } - command = export_clipboard_to_x.command() - command.initialize(environment) - - command.run() - assert task_manager.args == ("copied text", ":2") - sync_manager.mark_written_to_x.assert_not_called() - - task_manager.callback( - {"task_id": 7, "succeeded": True, "value": True} - ) - sync_manager.mark_written_to_x.assert_called_once_with("copied text") - - -def test_manual_clipboard_workers_use_configured_display(monkeypatch): - read_text = Mock(return_value="text") - write_text = Mock(return_value=True) - monkeypatch.setattr(import_clipboard_from_x.x_clipboard, "read_text", read_text) - monkeypatch.setattr(export_clipboard_to_x.x_clipboard, "write_text", write_text) - - assert import_clipboard_from_x.read_clipboard(":2") == "text" - assert export_clipboard_to_x.write_clipboard("text", ":2") is True - read_text.assert_called_once_with(":2", scan_displays=True) - write_text.assert_called_once_with("text", ":2", scan_displays=True) diff --git a/tests/unit/test_clipboard_sync_manager.py b/tests/unit/test_clipboard_sync_manager.py deleted file mode 100644 index b33da97e..00000000 --- a/tests/unit/test_clipboard_sync_manager.py +++ /dev/null @@ -1,453 +0,0 @@ -from unittest.mock import Mock - -import pytest - -from fenrirscreenreader.core.clipboardSyncManager import ClipboardSyncManager -from fenrirscreenreader.core.clipboardSyncManager import synchronize_clipboards - - -def build_env( - sync_enabled=False, - sync_display="", - screen_driver="vcsaDriver", - keyboard_driver="evdevDriver", - fenrir_text=None, -): - def get_setting(section, setting): - values = { - ("clipboard", "sync_display"): sync_display, - ("screen", "driver"): screen_driver, - ("keyboard", "driver"): keyboard_driver, - } - return values[(section, setting)] - - settings_manager = Mock( - get_setting_as_bool=Mock(return_value=sync_enabled), - get_setting_as_float=Mock(return_value=0.5), - get_setting=Mock(side_effect=get_setting), - ) - memory_manager = Mock( - is_index_list_empty=Mock(return_value=fenrir_text is None), - get_index_list_element=Mock(return_value=fenrir_text), - add_value_to_first_index=Mock(), - ) - return { - "runtime": { - "SettingsManager": settings_manager, - "MemoryManager": memory_manager, - "DebugManager": Mock(write_debug_out=Mock()), - } - } - - -@pytest.mark.unit -def test_clipboard_sync_disabled_by_default(): - manager = ClipboardSyncManager() - manager.start = Mock() - - manager.initialize(build_env(sync_enabled=False)) - - assert manager.enabled is False - manager.start.assert_not_called() - - -@pytest.mark.unit -def test_clipboard_sync_enabled_in_x_mode_with_current_display(monkeypatch): - monkeypatch.setenv("DISPLAY", ":1") - manager = ClipboardSyncManager() - manager.start = Mock() - - manager.initialize( - build_env( - sync_enabled=True, - screen_driver="ptyDriver", - keyboard_driver="x11Driver", - ) - ) - - assert manager.enabled is True - assert manager.display == ":1" - manager.start.assert_called_once_with() - - -@pytest.mark.unit -def test_clipboard_sync_console_skips_empty_display(monkeypatch): - monkeypatch.delenv("DISPLAY", raising=False) - manager = ClipboardSyncManager() - manager.start = Mock() - - manager.initialize(build_env(sync_enabled=True)) - - assert manager.enabled is False - manager.start.assert_not_called() - - -@pytest.mark.unit -def test_clipboard_sync_console_allows_configured_display(monkeypatch): - monkeypatch.delenv("DISPLAY", raising=False) - manager = ClipboardSyncManager() - manager.start = Mock() - - manager.initialize(build_env(sync_enabled=True, sync_display=":0")) - - assert manager.enabled is True - assert manager.display == ":0" - manager.start.assert_called_once_with() - - -@pytest.mark.unit -def test_mark_written_to_x_updates_observed_state(): - manager = ClipboardSyncManager() - - manager.mark_written_to_x("manual export") - - assert manager.last_written_to_x == "manual export" - assert manager.last_observed_fenrir == "manual export" - assert manager.last_observed_x == "manual export" - - -@pytest.mark.unit -def test_fenrir_to_x_write_is_not_reimported(monkeypatch): - env = build_env(fenrir_text="from fenrir") - manager = ClipboardSyncManager() - manager.env = env - manager.display = ":1" - write_text = Mock(return_value=True) - read_text = Mock(side_effect=[None, "from fenrir"]) - monkeypatch.setattr( - "fenrirscreenreader.core.clipboardSyncManager.x_clipboard.write_text", - write_text, - ) - monkeypatch.setattr( - "fenrirscreenreader.core.clipboardSyncManager.x_clipboard.read_text", - read_text, - ) - - manager.poll_once() - manager.poll_once() - - write_text.assert_called_once_with("from fenrir", ":1") - env["runtime"]["MemoryManager"].add_value_to_first_index.assert_not_called() - - -@pytest.mark.unit -def test_conflicting_clipboards_do_not_swap_values(monkeypatch): - env = build_env(fenrir_text="from fenrir") - manager = ClipboardSyncManager() - manager.env = env - manager.display = ":1" - write_text = Mock(return_value=True) - read_text = Mock(return_value="from x") - monkeypatch.setattr( - "fenrirscreenreader.core.clipboardSyncManager.x_clipboard.write_text", - write_text, - ) - monkeypatch.setattr( - "fenrirscreenreader.core.clipboardSyncManager.x_clipboard.read_text", - read_text, - ) - - manager.poll_once() - - write_text.assert_called_once_with("from fenrir", ":1") - env["runtime"]["MemoryManager"].add_value_to_first_index.assert_not_called() - assert manager.last_written_to_x == "from fenrir" - assert manager.last_observed_x == "from fenrir" - assert manager.last_observed_fenrir == "from fenrir" - - -@pytest.mark.unit -def test_matching_clipboards_are_observed_without_duplicate_import(monkeypatch): - env = build_env(fenrir_text="same text") - manager = ClipboardSyncManager() - manager.env = env - manager.display = ":1" - write_text = Mock(return_value=True) - read_text = Mock(return_value="same text") - monkeypatch.setattr( - "fenrirscreenreader.core.clipboardSyncManager.x_clipboard.write_text", - write_text, - ) - monkeypatch.setattr( - "fenrirscreenreader.core.clipboardSyncManager.x_clipboard.read_text", - read_text, - ) - - manager.poll_once() - - write_text.assert_not_called() - env["runtime"]["MemoryManager"].add_value_to_first_index.assert_not_called() - assert manager.last_observed_x == "same text" - assert manager.last_observed_fenrir == "same text" - - -@pytest.mark.unit -def test_x_to_fenrir_import_is_not_reexported(monkeypatch): - env = build_env(fenrir_text=None) - manager = ClipboardSyncManager() - manager.env = env - manager.display = ":1" - write_text = Mock(return_value=True) - read_text = Mock(return_value="from x") - monkeypatch.setattr( - "fenrirscreenreader.core.clipboardSyncManager.x_clipboard.write_text", - write_text, - ) - monkeypatch.setattr( - "fenrirscreenreader.core.clipboardSyncManager.x_clipboard.read_text", - read_text, - ) - - manager.poll_once() - env["runtime"]["MemoryManager"].is_index_list_empty.return_value = False - env["runtime"]["MemoryManager"].get_index_list_element.return_value = ( - "from x" - ) - manager.poll_once() - - env["runtime"]["MemoryManager"].add_value_to_first_index.assert_called_once_with( - "clipboardHistory", "from x" - ) - write_text.assert_not_called() - - -@pytest.mark.unit -def test_selected_imported_text_can_export_after_x_changes(monkeypatch): - env = build_env(fenrir_text=None) - manager = ClipboardSyncManager() - manager.env = env - manager.display = ":1" - write_text = Mock(return_value=True) - read_text = Mock(side_effect=["from x", "other x"]) - monkeypatch.setattr( - "fenrirscreenreader.core.clipboardSyncManager.x_clipboard.write_text", - write_text, - ) - monkeypatch.setattr( - "fenrirscreenreader.core.clipboardSyncManager.x_clipboard.read_text", - read_text, - ) - - manager.poll_once() - env["runtime"]["MemoryManager"].is_index_list_empty.return_value = False - env["runtime"]["MemoryManager"].get_index_list_element.return_value = ( - "from x" - ) - manager.last_observed_fenrir = "different fenrir entry" - manager.poll_once() - - write_text.assert_called_once_with("from x", ":1") - - -@pytest.mark.unit -@pytest.mark.parametrize("x_value", [None, "", object()]) -def test_x_clipboard_non_text_values_are_ignored(monkeypatch, x_value): - env = build_env(fenrir_text=None) - manager = ClipboardSyncManager() - manager.env = env - manager.display = ":1" - monkeypatch.setattr( - "fenrirscreenreader.core.clipboardSyncManager.x_clipboard.read_text", - Mock(return_value=x_value), - ) - - manager.poll_once() - - env["runtime"]["MemoryManager"].add_value_to_first_index.assert_not_called() - - -@pytest.mark.unit -def test_x_clipboard_paste_exception_is_ignored(monkeypatch): - env = build_env(fenrir_text=None) - manager = ClipboardSyncManager() - manager.env = env - manager.display = ":1" - monkeypatch.setattr( - "fenrirscreenreader.core.clipboardSyncManager.x_clipboard.read_text", - Mock(side_effect=RuntimeError("no text target")), - ) - - manager.poll_once() - - env["runtime"]["MemoryManager"].add_value_to_first_index.assert_not_called() - env["runtime"]["DebugManager"].write_debug_out.assert_called_once() - - -@pytest.mark.unit -def test_runtime_sync_applies_worker_result_on_main_loop(): - env = build_env(fenrir_text=None) - task_manager = Mock() - task_manager.submit_task.return_value = 12 - env["runtime"]["BackgroundTaskManager"] = task_manager - manager = ClipboardSyncManager() - manager.env = env - manager.enabled = True - manager.display = ":1" - manager.running = True - - manager.handle_sync_event() - env["runtime"]["MemoryManager"].add_value_to_first_index.assert_not_called() - - callback = task_manager.submit_task.call_args.args[1] - callback( - { - "task_id": 12, - "succeeded": True, - "value": { - "import_text": "from x", - "last_imported_from_x": "from x", - "last_observed_fenrir": "from x", - "last_observed_x": "from x", - }, - "error": "", - } - ) - - env["runtime"]["MemoryManager"].add_value_to_first_index.assert_called_once_with( - "clipboardHistory", "from x" - ) - assert manager.last_imported_from_x == "from x" - - -@pytest.mark.unit -def test_runtime_sync_discards_import_if_fenrir_clipboard_changed(): - env = build_env(fenrir_text=None) - task_manager = Mock() - task_manager.submit_task.return_value = 12 - env["runtime"]["BackgroundTaskManager"] = task_manager - manager = ClipboardSyncManager() - manager.env = env - manager.display = ":1" - manager.running = True - - manager.handle_sync_event() - env["runtime"]["MemoryManager"].is_index_list_empty.return_value = False - env["runtime"]["MemoryManager"].get_index_list_element.return_value = ( - "new fenrir copy" - ) - callback = task_manager.submit_task.call_args.args[1] - callback( - { - "task_id": 12, - "succeeded": True, - "value": { - "import_text": "stale x text", - "last_imported_from_x": "stale x text", - "last_observed_fenrir": "stale x text", - "last_observed_x": "stale x text", - }, - "error": "", - } - ) - - env["runtime"]["MemoryManager"].add_value_to_first_index.assert_not_called() - assert manager.last_imported_from_x is None - - -@pytest.mark.unit -def test_sync_worker_returns_changes_without_manager_access(monkeypatch): - monkeypatch.setattr( - "fenrirscreenreader.core.clipboardSyncManager.x_clipboard.read_text", - Mock(return_value="from x"), - ) - - result = synchronize_clipboards(":1", None, None, None) - - assert result["import_text"] == "from x" - assert result["last_observed_x"] == "from x" - - -def run_sync_task(manager): - task_manager = manager.env['runtime']['BackgroundTaskManager'] - manager.handle_sync_event() - function, callback, *args = task_manager.submit_task.call_args.args - callback({'task_id': 12, 'succeeded': True, 'value': function(*args)}) - - -@pytest.mark.unit -@pytest.mark.parametrize('failure', [False, RuntimeError('temporary failure')]) -def test_runtime_sync_retries_failed_exports(monkeypatch, failure): - manager = ClipboardSyncManager() - manager.env = build_env(fenrir_text='new copy') - manager.env['runtime']['BackgroundTaskManager'] = Mock( - submit_task=Mock(return_value=12), - ) - manager.running = True - manager.display = ':1' - manager.last_observed_fenrir = 'previous copy' - manager.last_observed_x = 'previous copy' - write_text = Mock(side_effect=[failure, True]) - monkeypatch.setattr( - 'fenrirscreenreader.core.clipboardSyncManager.x_clipboard.read_text', - Mock(return_value='previous copy'), - ) - monkeypatch.setattr( - 'fenrirscreenreader.core.clipboardSyncManager.x_clipboard.write_text', - write_text, - ) - - run_sync_task(manager) - assert manager.last_observed_fenrir == 'previous copy' - run_sync_task(manager) - - assert write_text.call_count == 2 - assert manager.last_observed_fenrir == 'new copy' - assert manager.last_observed_x == 'new copy' - manager.env['runtime']['MemoryManager'].add_value_to_first_index.assert_not_called() - - -@pytest.mark.unit -def test_poll_once_retries_failed_export(monkeypatch): - manager = ClipboardSyncManager() - manager.env = build_env(fenrir_text='new copy') - manager.display = ':1' - write_text = Mock(side_effect=[False, True]) - monkeypatch.setattr( - 'fenrirscreenreader.core.clipboardSyncManager.x_clipboard.read_text', - Mock(return_value=None), - ) - monkeypatch.setattr( - 'fenrirscreenreader.core.clipboardSyncManager.x_clipboard.write_text', - write_text, - ) - - manager.poll_once() - assert manager.last_observed_fenrir is None - manager.poll_once() - assert write_text.call_count == 2 - assert manager.last_written_to_x == 'new copy' - - -@pytest.mark.unit -@pytest.mark.parametrize('manual_action', ['export', 'import']) -def test_runtime_sync_discards_result_superseded_by_manual_transfer( - manual_action, -): - manager = ClipboardSyncManager() - manager.env = build_env(fenrir_text='current copy') - task_manager = Mock(submit_task=Mock(return_value=12)) - manager.env['runtime']['BackgroundTaskManager'] = task_manager - manager.running = True - manager.last_observed_fenrir = 'current copy' - manager.last_observed_x = 'current copy' - - manager.handle_sync_event() - if manual_action == 'export': - manager.mark_written_to_x('current copy') - else: - manager.mark_imported_from_x('current copy') - callback = task_manager.submit_task.call_args.args[1] - callback({ - 'task_id': 12, 'succeeded': True, - 'value': { - 'import_text': 'old x copy', - 'last_imported_from_x': 'old x copy', - 'last_observed_fenrir': 'old x copy', - 'last_observed_x': 'old x copy', - }, - }) - - manager.env['runtime']['MemoryManager'].add_value_to_first_index.assert_not_called() - assert manager.last_observed_x == 'current copy' - assert manager._task_id is None - assert not manager._sync_event_pending.is_set() diff --git a/tests/unit/test_debug_manager.py b/tests/unit/test_debug_manager.py deleted file mode 100644 index 9ac75602..00000000 --- a/tests/unit/test_debug_manager.py +++ /dev/null @@ -1,59 +0,0 @@ -from fenrirscreenreader.core.debugManager import DebugManager - - -def test_default_debug_file_uses_flat_name(tmp_path, monkeypatch): - monkeypatch.setattr(DebugManager, "DEFAULT_LOG_DIR", str(tmp_path)) - manager = DebugManager() - try: - manager.open_debug_file() - - assert manager.get_debug_file() == str(tmp_path / "fenrir.log") - assert (tmp_path / "fenrir.log").exists() - finally: - manager.close_debug_file() - - -def test_default_debug_file_uses_next_number_when_locked( - tmp_path, monkeypatch -): - monkeypatch.setattr(DebugManager, "DEFAULT_LOG_DIR", str(tmp_path)) - first_manager = DebugManager() - second_manager = DebugManager() - try: - first_manager.open_debug_file() - second_manager.open_debug_file() - - assert first_manager.get_debug_file() == str(tmp_path / "fenrir.log") - assert second_manager.get_debug_file() == str( - tmp_path / "fenrir2.log" - ) - assert (tmp_path / "fenrir2.log").exists() - finally: - second_manager.close_debug_file() - first_manager.close_debug_file() - - -def test_default_debug_file_reuses_unlocked_flat_name(tmp_path, monkeypatch): - monkeypatch.setattr(DebugManager, "DEFAULT_LOG_DIR", str(tmp_path)) - first_manager = DebugManager() - second_manager = DebugManager() - try: - first_manager.open_debug_file() - first_manager.close_debug_file() - second_manager.open_debug_file() - - assert second_manager.get_debug_file() == str(tmp_path / "fenrir.log") - finally: - second_manager.close_debug_file() - - -def test_explicit_debug_file_uses_exact_path(tmp_path): - debug_file = tmp_path / "custom.log" - manager = DebugManager(str(debug_file)) - try: - manager.open_debug_file() - - assert manager.get_debug_file() == str(debug_file) - assert debug_file.exists() - finally: - manager.close_debug_file() diff --git a/tests/unit/test_dectalk_usb_driver.py b/tests/unit/test_dectalk_usb_driver.py deleted file mode 100644 index ec6ee256..00000000 --- a/tests/unit/test_dectalk_usb_driver.py +++ /dev/null @@ -1,351 +0,0 @@ -import threading -import time -from unittest.mock import Mock - -import pytest - -from fenrirscreenreader.speechDriver import dectalkUsbDriver - - -class FakeUsbDevice: - def __init__(self): - self.writes = [] - - def get_active_configuration(self): - return object() - - def write(self, endpoint, data, timeout=None): - payload = bytes(data) - self.writes.append((endpoint, payload, timeout)) - return len(payload) - - -class FakeUsbCore: - USBError = OSError - - class NoBackendError(ValueError): - pass - - def __init__(self, device, find_error=None): - self.device = device - self.find_error = find_error - self.find_calls = [] - - def find(self, **kwargs): - self.find_calls.append(kwargs) - if self.find_error: - raise self.find_error - return self.device - - -class FakeUsbUtil: - def __init__( - self, - claim_error=None, - release_error=None, - dispose_error=None, - ): - self.claim_error = claim_error - self.release_error = release_error - self.dispose_error = dispose_error - self.claimed = [] - self.released = [] - self.disposed = [] - - def claim_interface(self, device, interface): - if self.claim_error: - raise self.claim_error - self.claimed.append((device, interface)) - - def release_interface(self, device, interface): - if self.release_error: - raise self.release_error - self.released.append((device, interface)) - - def dispose_resources(self, device): - if self.dispose_error: - raise self.dispose_error - self.disposed.append(device) - - -class BlockingUsbDevice(FakeUsbDevice): - def __init__(self): - super().__init__() - self.normal_write_started = threading.Event() - self.release_normal_write = threading.Event() - self.immediate_write_finished = threading.Event() - - def write(self, endpoint, data, timeout=None): - if endpoint == 0x02: - self.normal_write_started.set() - self.release_normal_write.wait(timeout=1.0) - result = super().write(endpoint, data, timeout=timeout) - if endpoint == 0x04: - self.immediate_write_finished.set() - return result - - -class CancelDuringSpeechUsbDevice(FakeUsbDevice): - def __init__(self): - super().__init__() - self.first_normal_write_started = threading.Event() - self.release_first_normal_write = threading.Event() - - def write(self, endpoint, data, timeout=None): - if endpoint == 0x02 and not self.first_normal_write_started.is_set(): - self.first_normal_write_started.set() - self.release_first_normal_write.wait(timeout=1.0) - return super().write(endpoint, data, timeout=timeout) - - -def build_environment(): - return { - "runtime": { - "SettingsManager": Mock(), - "DebugManager": Mock(), - } - } - - -def initialized_driver(monkeypatch, clear_startup_writes=True): - device = FakeUsbDevice() - usb_core = FakeUsbCore(device) - usb_util = FakeUsbUtil() - speech_driver = dectalkUsbDriver.driver() - monkeypatch.setattr( - speech_driver, - "_load_usb_modules", - lambda: (usb_core, usb_util), - ) - speech_driver.initialize(build_environment()) - if clear_startup_writes: - device.writes.clear() - return speech_driver, device, usb_core, usb_util - - -def wait_for_writes(device, count, timeout=1.0): - deadline = time.monotonic() + timeout - while len(device.writes) < count and time.monotonic() < deadline: - time.sleep(0.01) - - -def test_dectalk_usb_driver_finds_and_claims_native_device(monkeypatch): - speech_driver, device, usb_core, usb_util = initialized_driver(monkeypatch) - try: - assert usb_core.find_calls == [ - {"idVendor": 0x0DD0, "idProduct": 0x2001} - ] - assert usb_util.claimed == [(device, 0)] - assert speech_driver._is_initialized - finally: - speech_driver.shutdown() - - assert usb_util.released == [(device, 0)] - assert usb_util.disposed == [device] - - -def test_dectalk_usb_driver_sets_low_buffer_timeout_on_initialize(monkeypatch): - speech_driver, device, _, _ = initialized_driver( - monkeypatch, - clear_startup_writes=False, - ) - try: - assert device.writes[0] == (0x02, b"[:timeout 1]", 1000) - finally: - speech_driver.shutdown() - - -def test_dectalk_usb_driver_writes_commands_and_speech(monkeypatch): - speech_driver, device, _, _ = initialized_driver(monkeypatch) - try: - speech_driver.set_rate(1.0) - speech_driver.set_pitch(1.0) - speech_driver.set_volume(0.5) - speech_driver.speak("Hello\nworld") - wait_for_writes(device, 4) - - assert device.writes == [ - (0x02, b"[:ra 650]", 1000), - (0x02, b"[:dv ap 350]", 1000), - (0x02, b"[:dv g5 73]", 1000), - (0x02, b"Hello world\x0b", 1000), - ] - finally: - speech_driver.shutdown() - - -def test_dectalk_usb_driver_skips_redundant_setting_writes(monkeypatch): - speech_driver, device, _, _ = initialized_driver(monkeypatch) - try: - speech_driver.set_rate(0.5) - speech_driver.set_pitch(0.5) - speech_driver.set_volume(1.0) - speech_driver.set_rate(0.5) - speech_driver.set_pitch(0.5) - speech_driver.set_volume(1.0) - - assert device.writes == [ - (0x02, b"[:ra 180]", 1000), - (0x02, b"[:dv ap 122]", 1000), - (0x02, b"[:dv g5 86]", 1000), - ] - finally: - speech_driver.shutdown() - - -def test_dectalk_usb_driver_chunks_normal_writes(monkeypatch): - speech_driver, device, _, _ = initialized_driver(monkeypatch) - try: - speech_driver._write_bytes(b"x" * 70, "speech") - assert device.writes == [ - (0x02, b"x" * 64, 1000), - (0x02, b"x" * 6, 1000), - ] - finally: - speech_driver.shutdown() - - -def test_dectalk_usb_driver_uses_immediate_cancel_endpoint(monkeypatch): - speech_driver, device, _, _ = initialized_driver(monkeypatch) - try: - speech_driver.cancel() - assert device.writes == [(0x04, b"\x06", 1000)] - finally: - speech_driver.shutdown() - - -def test_dectalk_usb_cancel_bypasses_blocked_normal_write(monkeypatch): - device = BlockingUsbDevice() - usb_core = FakeUsbCore(device) - usb_util = FakeUsbUtil() - speech_driver = dectalkUsbDriver.driver() - monkeypatch.setattr( - speech_driver, - "_load_usb_modules", - lambda: (usb_core, usb_util), - ) - speech_driver.initialize(build_environment()) - device.writes.clear() - normal_thread = threading.Thread( - target=speech_driver._write_bytes, - args=(b"normal speech", "speech"), - ) - cancel_thread = threading.Thread(target=speech_driver.cancel) - try: - normal_thread.start() - assert device.normal_write_started.wait(timeout=0.2) - cancel_thread.start() - assert device.immediate_write_finished.wait(timeout=0.2) - finally: - device.release_normal_write.set() - normal_thread.join(timeout=1.0) - cancel_thread.join(timeout=1.0) - speech_driver.shutdown() - - -def test_dectalk_usb_cancel_stops_current_speech_upload(monkeypatch): - device = CancelDuringSpeechUsbDevice() - usb_core = FakeUsbCore(device) - usb_util = FakeUsbUtil() - speech_driver = dectalkUsbDriver.driver() - monkeypatch.setattr( - speech_driver, - "_load_usb_modules", - lambda: (usb_core, usb_util), - ) - speech_driver.initialize(build_environment()) - device.writes.clear() - device.first_normal_write_started.clear() - normal_thread = threading.Thread( - target=speech_driver._write_bytes, - args=(b"x" * 192, "speech"), - ) - try: - normal_thread.start() - assert device.first_normal_write_started.wait(timeout=0.2) - speech_driver.cancel() - finally: - device.release_first_normal_write.set() - normal_thread.join(timeout=1.0) - speech_driver.shutdown() - - normal_writes = [ - payload for endpoint, payload, _ in device.writes if endpoint == 0x02 - ] - assert normal_writes == [b"x" * 64] - - -def test_dectalk_usb_driver_reports_missing_pyusb(monkeypatch): - speech_driver = dectalkUsbDriver.driver() - - def missing_pyusb(): - raise ImportError("No module named usb") - - monkeypatch.setattr(speech_driver, "_load_usb_modules", missing_pyusb) - - with pytest.raises(RuntimeError, match="PyUSB"): - speech_driver.initialize(build_environment()) - - -def test_dectalk_usb_driver_reports_missing_libusb_backend(monkeypatch): - speech_driver = dectalkUsbDriver.driver() - backend_error = FakeUsbCore.NoBackendError("No backend available") - usb_core = FakeUsbCore(None, find_error=backend_error) - monkeypatch.setattr( - speech_driver, - "_load_usb_modules", - lambda: (usb_core, FakeUsbUtil()), - ) - - with pytest.raises(RuntimeError, match="discovery failed.*backend"): - speech_driver.initialize(build_environment()) - - -def test_dectalk_usb_driver_reports_missing_device(monkeypatch): - speech_driver = dectalkUsbDriver.driver() - usb_core = FakeUsbCore(None) - usb_util = FakeUsbUtil() - monkeypatch.setattr( - speech_driver, - "_load_usb_modules", - lambda: (usb_core, usb_util), - ) - - with pytest.raises(RuntimeError, match="0dd0:2001"): - speech_driver.initialize(build_environment()) - - -def test_dectalk_usb_driver_reports_interface_permission_error(monkeypatch): - speech_driver = dectalkUsbDriver.driver() - device = FakeUsbDevice() - usb_core = FakeUsbCore(device) - usb_util = FakeUsbUtil(claim_error=PermissionError("access denied")) - monkeypatch.setattr( - speech_driver, - "_load_usb_modules", - lambda: (usb_core, usb_util), - ) - - with pytest.raises(RuntimeError, match="open failed.*access denied"): - speech_driver.initialize(build_environment()) - - -def test_dectalk_usb_shutdown_survives_cleanup_errors(monkeypatch): - device = FakeUsbDevice() - usb_core = FakeUsbCore(device) - usb_util = FakeUsbUtil( - release_error=OSError("release failed"), - dispose_error=OSError("dispose failed"), - ) - speech_driver = dectalkUsbDriver.driver() - monkeypatch.setattr( - speech_driver, - "_load_usb_modules", - lambda: (usb_core, usb_util), - ) - speech_driver.initialize(build_environment()) - - speech_driver.shutdown() - - assert not speech_driver._is_initialized - assert speech_driver.usb_device is None diff --git a/tests/unit/test_diff_review_manager.py b/tests/unit/test_diff_review_manager.py deleted file mode 100644 index e11ec346..00000000 --- a/tests/unit/test_diff_review_manager.py +++ /dev/null @@ -1,135 +0,0 @@ -""" -Unit tests for DiffReviewManager parsing and presentation behavior. -""" - -from unittest.mock import Mock - -import pytest - -import fenrirscreenreader.core.diffReviewManager as diff_review_module -from fenrirscreenreader.core.diffReviewManager import DiffReviewManager - - -@pytest.fixture -def diff_manager(monkeypatch): - """Create a DiffReviewManager with a minimal test environment.""" - monkeypatch.setattr(diff_review_module, "_", lambda text: text) - - spoken_messages = [] - output_manager = Mock() - - def _capture_message(message, **_kwargs): - spoken_messages.append(message) - - output_manager.present_text.side_effect = _capture_message - settings_manager = Mock() - settings_manager.get_setting.side_effect = ( - lambda section, setting: "compact" - if (section, setting) == ("general", "diff_verbosity") - else "default" - ) - - env = { - "runtime": { - "DebugManager": Mock(write_debug_out=Mock()), - "OutputManager": output_manager, - "SettingsManager": settings_manager, - "MemoryManager": Mock( - is_index_list_empty=Mock(return_value=True), - get_index_list_element=Mock(return_value=""), - ), - }, - "bindings": {}, - "rawBindings": {}, - } - - manager = DiffReviewManager() - manager.initialize(env) - return manager, spoken_messages - - -@pytest.mark.unit -class TestDiffReviewManager: - """Test diff review parsing and speech presentation edge cases.""" - - def test_parse_unified_diff_tracks_sections_and_counts(self, diff_manager): - manager, _ = diff_manager - lines = [ - "diff --git a/example.txt b/example.txt", - "index 1111111..2222222 100644", - "--- a/example.txt", - "+++ b/example.txt", - "@@ -1,2 +1,3 @@", - " context line", - "-old line", - "+new line one", - "+new line two", - ] - - manager._parse_lines(lines, "/tmp/example.diff") - - assert len(manager.file_sections) == 1 - assert len(manager.hunk_sections) == 1 - assert manager.total_added == 2 - assert manager.total_removed == 1 - assert manager.file_sections[0]["added_count"] == 2 - assert manager.file_sections[0]["removed_count"] == 1 - assert manager.hunk_sections[0]["position_in_file"] == 1 - assert manager.hunk_sections[0]["hunks_in_file"] == 1 - - def test_formats_classic_diff_markers(self, diff_manager): - manager, _ = diff_manager - - assert manager._format_classic_diff_command("17c17") == "line 17 changed" - assert manager._format_classic_diff_command("17d16") == "line 17 deleted" - assert manager._format_classic_diff_command("16a17") == "line 17 added" - assert ( - manager._format_classic_diff_command("20,22c30,32") - == "lines 20 through 22 changed" - ) - assert manager._format_classic_diff_command("not a diff marker") is None - - def test_announces_classic_marker_line_as_plain_language(self, diff_manager): - manager, spoken_messages = diff_manager - - manager._parse_lines(["17c17"], "/tmp/classic.diff") - manager.current_line = 0 - manager._announce_current_line() - - assert spoken_messages[-1] == "line 17 changed" - - def test_marker_only_added_removed_lines_become_words(self, diff_manager): - manager, spoken_messages = diff_manager - - manager._parse_lines(["+++", "---"], "/tmp/markers.diff") - manager.current_line = 0 - manager._announce_current_line() - manager.current_line = 1 - manager._announce_current_line() - - assert spoken_messages[-2] == "added" - assert spoken_messages[-1] == "removed" - - def test_large_diff_parsing_stays_consistent(self, diff_manager): - manager, spoken_messages = diff_manager - line_count = 1500 - lines = [ - "diff --git a/large.txt b/large.txt", - "--- a/large.txt", - "+++ b/large.txt", - "@@ -1,1500 +1,1500 @@", - ] - for index in range(1, line_count + 1): - lines.append(f"-old value {index}") - lines.append(f"+new value {index}") - - manager._parse_lines(lines, "/tmp/large.diff") - manager.next_added() - - assert len(manager.file_sections) == 1 - assert len(manager.hunk_sections) == 1 - assert manager.total_added == line_count - assert manager.total_removed == line_count - assert manager.current_line == manager.added_lines[0] - assert spoken_messages[-1].startswith("Added line.") - diff --git a/tests/unit/test_fenrir_manager_screen_update.py b/tests/unit/test_fenrir_manager_screen_update.py deleted file mode 100644 index 7552e5bb..00000000 --- a/tests/unit/test_fenrir_manager_screen_update.py +++ /dev/null @@ -1,117 +0,0 @@ -import time -from unittest.mock import Mock, call - -import pytest - -from fenrirscreenreader.core.fenrirManager import FenrirManager - - -def _build_manager(input_active=True, deepest_input=None): - manager = FenrirManager.__new__(FenrirManager) - input_manager = Mock( - get_last_input_time=Mock(return_value=time.time()), - get_last_deepest_input=Mock(return_value=deepest_input or []), - ) - command_manager = Mock(execute_default_trigger=Mock()) - screen_manager = Mock(handle_screen_update=Mock()) - cursor_manager = Mock( - is_cursor_vertical_move=Mock(return_value=True), - is_cursor_horizontal_move=Mock(return_value=False), - ) - manager.environment = { - "runtime": { - "InputDriver": Mock(is_active=Mock(return_value=input_active)), - "InputManager": input_manager, - "ScreenManager": screen_manager, - "CursorManager": cursor_manager, - "CommandManager": command_manager, - } - } - return manager, screen_manager, input_manager, command_manager - - -@pytest.mark.unit -def test_screen_update_while_x11_target_is_unfocused_updates_state_silently(): - manager, screen_manager, input_manager, command_manager = _build_manager( - input_active=False, - deepest_input=["KEY_DOWN"], - ) - event = {"data": {"text": "background repaint"}} - - manager.handle_screen_update(event) - - screen_manager.handle_screen_update.assert_called_once_with(event["data"]) - command_manager.execute_default_trigger.assert_not_called() - input_manager.clear_last_deep_input.assert_called_once_with() - - -@pytest.mark.unit -def test_programmatic_cursor_move_does_not_run_cursor_change_triggers(): - manager, _screen_manager, _input_manager, command_manager = _build_manager( - deepest_input=[] - ) - - manager.handle_screen_update({"data": {"text": "spinner repaint"}}) - - command_manager.execute_default_trigger.assert_called_once_with( - "onScreenUpdate" - ) - - -@pytest.mark.unit -def test_navigation_cursor_move_runs_cursor_change_triggers(): - manager, _screen_manager, _input_manager, command_manager = _build_manager( - deepest_input=["KEY_DOWN"] - ) - - manager.handle_screen_update({"data": {"text": "cursor moved"}}) - - assert command_manager.execute_default_trigger.call_args_list == [ - call("onCursorChange"), - call("onScreenUpdate"), - ] - - -@pytest.mark.unit -@pytest.mark.parametrize("key_name", ["KEY_RIGHT", "KEY_BACKSPACE"]) -def test_recent_cursor_input_survives_update_before_cursor_moves(key_name): - manager, _screen_manager, input_manager, command_manager = _build_manager( - deepest_input=[key_name] - ) - manager.environment["runtime"][ - "CursorManager" - ].is_cursor_vertical_move.return_value = False - manager.environment["runtime"][ - "CursorManager" - ].is_cursor_horizontal_move.return_value = False - - manager.handle_screen_update({"data": {"text": "intermediate repaint"}}) - - command_manager.execute_default_trigger.assert_called_once_with( - "onScreenUpdate" - ) - input_manager.clear_last_deep_input.assert_not_called() - - -@pytest.mark.unit -def test_fenrir_review_command_does_not_enable_screen_cursor_speech(): - manager, _screen_manager, _input_manager, command_manager = _build_manager( - deepest_input=["KEY_FENRIR", "KEY_KP8"] - ) - - manager.handle_screen_update({"data": {"text": "background repaint"}}) - - command_manager.execute_default_trigger.assert_called_once_with( - "onScreenUpdate" - ) - - -@pytest.mark.unit -def test_heartbeat_triggers_are_silent_while_x11_target_is_unfocused(): - manager, _screen_manager, _input_manager, command_manager = _build_manager( - input_active=False - ) - - manager.handle_heart_beat({"data": None}) - - command_manager.execute_default_trigger.assert_not_called() diff --git a/tests/unit/test_fenrir_manager_shortcuts.py b/tests/unit/test_fenrir_manager_shortcuts.py deleted file mode 100644 index 545c1c35..00000000 --- a/tests/unit/test_fenrir_manager_shortcuts.py +++ /dev/null @@ -1,422 +0,0 @@ -from unittest.mock import Mock - -import pytest - -from fenrirscreenreader.core.eventData import FenrirEventType -from fenrirscreenreader.core.fenrirManager import FenrirManager -from fenrirscreenreader.core.inputManager import InputManager - - -def create_input_manager(): - input_driver = Mock(get_led_state=Mock(return_value=False)) - settings_manager = Mock() - settings_manager.get_setting_as_float.return_value = 0.2 - manager = InputManager() - manager.env = { - "input": { - "curr_input": [], - "prev_input": [], - "event_buffer": [object()], - "shortcut_repeat": 1, - "old_num_lock": False, - "new_num_lock": False, - "old_caps_lock": False, - "new_caps_lock": False, - "old_scroll_lock": False, - "new_scroll_lock": False, - }, - "runtime": { - "DebugManager": Mock(), - "InputDriver": input_driver, - "SettingsManager": settings_manager, - }, - } - manager.lastDeepestInput = [] - manager.lastInputTime = 0 - manager.handle_led_states = Mock() - return manager - - -def send_input(manager, event_name, event_state): - manager.handle_input_event( - {"event_name": event_name, "event_state": event_state} - ) - - -def create_handle_input_manager(no_key_pressed): - manager = FenrirManager.__new__(FenrirManager) - manager.modifierInput = False - manager.modifier_prefix_input = True - manager.singleKeyCommand = False - manager.command = "" - manager.detect_shortcut_command = Mock() - manager.update_key_forward = Mock() - - input_manager = Mock( - convert_event_name=Mock(side_effect=lambda key_name: key_name), - no_key_pressed=Mock(return_value=no_key_pressed), - ) - manager.environment = { - "input": {"key_forward": 0}, - "runtime": { - "DebugManager": Mock(write_debug_out=Mock()), - "InputManager": input_manager, - "ScreenManager": Mock(is_ignored_screen=Mock(return_value=False)), - "HelpManager": Mock(is_tutorial_mode=Mock(return_value=False)), - "VmenuManager": Mock(get_active=Mock(return_value=False)), - "DiffReviewManager": Mock(is_active=Mock(return_value=False)), - "SpeechHistoryManager": Mock(is_active=Mock(return_value=False)), - "CommandManager": Mock(execute_default_trigger=Mock()), - }, - } - return manager, input_manager - - -@pytest.mark.unit -def test_input_manager_recognizes_conventional_modifier_prefix(): - input_manager = InputManager() - input_manager.env = {"input": {"curr_input": ["KEY_CTRL", "KEY_SHIFT"]}} - - assert input_manager.curr_input_is_modifier_prefix() is True - - input_manager.env["input"]["curr_input"].append("KEY_S") - - assert input_manager.curr_input_is_modifier_prefix() is False - - -@pytest.mark.unit -def test_bare_key_taps_advance_shortcut_repeat(monkeypatch): - manager = create_input_manager() - monkeypatch.setattr( - "fenrirscreenreader.core.inputManager.time.time", lambda: 1.0 - ) - - send_input(manager, "KEY_KP5", 1) - assert manager.env["input"]["shortcut_repeat"] == 1 - send_input(manager, "KEY_KP5", 0) - send_input(manager, "KEY_KP5", 1) - assert manager.env["input"]["shortcut_repeat"] == 2 - send_input(manager, "KEY_KP5", 0) - send_input(manager, "KEY_KP5", 1) - assert manager.env["input"]["shortcut_repeat"] == 3 - - -@pytest.mark.unit -def test_tutorial_toggle_runs_while_tutorial_mode_is_active(): - manager = FenrirManager.__new__(FenrirManager) - command_manager = Mock() - command_manager.command_exists.return_value = False - manager.environment = { - "runtime": { - "HelpManager": Mock( - is_tutorial_mode=Mock(return_value=True) - ), - "CommandManager": command_manager, - "ReadAllManager": Mock(is_active=Mock(return_value=False)), - "VmenuManager": Mock(get_active=Mock(return_value=False)), - "SpeechHistoryManager": Mock(is_active=Mock(return_value=False)), - } - } - - manager.handle_execute_command({"data": "TOGGLE_TUTORIAL_MODE"}) - - command_manager.run_command.assert_called_once_with( - "TOGGLE_TUTORIAL_MODE", "commands" - ) - command_manager.execute_command.assert_not_called() - - -@pytest.mark.unit -def test_tutorial_mode_swallows_input_on_ignored_screen(): - manager, input_manager = create_handle_input_manager( - no_key_pressed=False - ) - manager.environment["runtime"]["HelpManager"].is_tutorial_mode.return_value = ( - True - ) - manager.environment["runtime"]["ScreenManager"].is_ignored_screen.return_value = ( - True - ) - - manager.handle_input( - {"data": {"event_name": "KEY_A", "event_state": 1}} - ) - - input_manager.clear_event_buffer.assert_called() - input_manager.write_event_buffer.assert_not_called() - input_manager.key_echo.assert_called_once_with( - {"event_name": "KEY_A", "event_state": 1} - ) - manager.detect_shortcut_command.assert_called_once_with() - - -@pytest.mark.unit -def test_tutorial_mode_promotes_non_fenrir_chord_to_command(): - manager = FenrirManager.__new__(FenrirManager) - manager.modifierInput = False - manager.modifier_prefix_input = False - manager.singleKeyCommand = False - manager.command = "" - input_manager = Mock( - is_key_press=Mock(return_value=False), - curr_input_has_command_modifier=Mock(return_value=False), - curr_input_is_modifier_prefix=Mock(return_value=False), - no_key_pressed=Mock(return_value=False), - get_curr_shortcut=Mock( - return_value=str([1, ["KEY_CTRL", "KEY_S"]]) - ), - get_command_for_shortcut=Mock(return_value="SPELL_CHECK"), - ) - event_manager = Mock() - manager.environment = { - "input": { - "key_forward": 0, - "prev_input": ["KEY_CTRL"], - "curr_input": ["KEY_CTRL", "KEY_S"], - }, - "runtime": { - "InputManager": input_manager, - "EventManager": event_manager, - "HelpManager": Mock( - is_tutorial_mode=Mock(return_value=True) - ), - "VmenuManager": Mock(get_active=Mock(return_value=False)), - "DiffReviewManager": Mock(is_active=Mock(return_value=False)), - "SpeechHistoryManager": Mock(is_active=Mock(return_value=False)), - }, - } - - manager.detect_shortcut_command() - - event_manager.put_to_event_queue.assert_called_once_with( - FenrirEventType.execute_command, "SPELL_CHECK" - ) - - -@pytest.mark.unit -def test_bare_key_taps_resolve_progressive_shortcuts(monkeypatch): - manager = create_input_manager() - monkeypatch.setattr( - "fenrirscreenreader.core.inputManager.time.time", lambda: 1.0 - ) - manager.env["runtime"]["CursorManager"] = Mock( - should_process_numpad_commands=Mock(return_value=True) - ) - manager.env["bindings"] = { - str([1, ["KEY_KP5"]]): "REVIEW_CURR_WORD", - str([2, ["KEY_KP5"]]): "REVIEW_CURR_WORD_SPELL", - str([3, ["KEY_KP5"]]): "REVIEW_CURR_WORD_PHONETIC", - } - - resolved_commands = [] - for _tap in range(3): - send_input(manager, "KEY_KP5", 1) - shortcut = manager.get_curr_shortcut() - resolved_commands.append(manager.get_command_for_shortcut(shortcut)) - send_input(manager, "KEY_KP5", 0) - - assert resolved_commands == [ - "REVIEW_CURR_WORD", - "REVIEW_CURR_WORD_SPELL", - "REVIEW_CURR_WORD_PHONETIC", - ] - - -@pytest.mark.unit -def test_different_bare_key_starts_new_repeat_sequence(monkeypatch): - manager = create_input_manager() - monkeypatch.setattr( - "fenrirscreenreader.core.inputManager.time.time", lambda: 1.0 - ) - - send_input(manager, "KEY_KP5", 1) - send_input(manager, "KEY_KP5", 0) - send_input(manager, "KEY_KP5", 1) - assert manager.env["input"]["shortcut_repeat"] == 2 - send_input(manager, "KEY_KP5", 0) - send_input(manager, "KEY_KP2", 1) - assert manager.env["input"]["shortcut_repeat"] == 1 - - -@pytest.mark.unit -def test_bare_key_tap_after_timeout_starts_new_sequence(monkeypatch): - manager = create_input_manager() - current_time = [1.0] - monkeypatch.setattr( - "fenrirscreenreader.core.inputManager.time.time", - lambda: current_time[0], - ) - - send_input(manager, "KEY_KP5", 1) - send_input(manager, "KEY_KP5", 0) - current_time[0] = 2.0 - send_input(manager, "KEY_KP5", 1) - - assert manager.env["input"]["shortcut_repeat"] == 1 - - -@pytest.mark.unit -def test_modifier_chord_taps_still_advance_shortcut_repeat(monkeypatch): - manager = create_input_manager() - monkeypatch.setattr( - "fenrirscreenreader.core.inputManager.time.time", lambda: 1.0 - ) - - send_input(manager, "KEY_FENRIR", 1) - send_input(manager, "KEY_T", 1) - assert manager.env["input"]["shortcut_repeat"] == 1 - send_input(manager, "KEY_T", 0) - send_input(manager, "KEY_T", 1) - assert manager.env["input"]["shortcut_repeat"] == 2 - - -@pytest.mark.unit -def test_first_conventional_modifier_starts_deferred_prefix(): - manager = FenrirManager.__new__(FenrirManager) - manager.modifierInput = False - manager.modifier_prefix_input = False - manager.singleKeyCommand = False - manager.command = "" - input_manager = Mock( - is_key_press=Mock(return_value=True), - curr_input_has_command_modifier=Mock(return_value=False), - curr_input_is_modifier_prefix=Mock(return_value=True), - get_curr_shortcut=Mock(return_value=str([1, ["KEY_CTRL"]])), - get_command_for_shortcut=Mock(return_value=""), - ) - manager.environment = { - "input": { - "key_forward": 0, - "prev_input": [], - "curr_input": ["KEY_CTRL"], - }, - "runtime": { - "InputManager": input_manager, - "EventManager": Mock(put_to_event_queue=Mock()), - "VmenuManager": Mock(get_active=Mock(return_value=False)), - "DiffReviewManager": Mock(is_active=Mock(return_value=False)), - "SpeechHistoryManager": Mock(is_active=Mock(return_value=False)), - }, - } - - manager.detect_shortcut_command() - - assert manager.modifier_prefix_input is True - - -@pytest.mark.unit -def test_fenrir_shortcut_is_dispatched_when_ctrl_is_pressed_first(): - manager = FenrirManager.__new__(FenrirManager) - manager.modifierInput = False - manager.modifier_prefix_input = True - manager.singleKeyCommand = False - manager.command = "" - - event_manager = Mock(put_to_event_queue=Mock()) - input_manager = Mock( - is_key_press=Mock(return_value=False), - no_key_pressed=Mock(return_value=False), - curr_input_has_command_modifier=Mock(return_value=True), - get_curr_shortcut=Mock( - side_effect=[ - str([1, ["KEY_CTRL", "KEY_FENRIR"]]), - str([1, ["KEY_CTRL", "KEY_FENRIR", "KEY_S"]]), - ] - ), - get_command_for_shortcut=Mock(side_effect=["", "SAVE_SETTINGS"]), - ) - manager.environment = { - "input": { - "key_forward": 0, - "prev_input": ["KEY_CTRL"], - "curr_input": ["KEY_CTRL", "KEY_FENRIR"], - }, - "runtime": { - "InputManager": input_manager, - "EventManager": event_manager, - "VmenuManager": Mock(get_active=Mock(return_value=False)), - "DiffReviewManager": Mock(is_active=Mock(return_value=False)), - "SpeechHistoryManager": Mock(is_active=Mock(return_value=False)), - }, - } - - manager.detect_shortcut_command() - - assert manager.modifierInput is True - assert manager.modifier_prefix_input is False - event_manager.put_to_event_queue.assert_not_called() - - manager.environment["input"]["prev_input"] = [ - "KEY_CTRL", - "KEY_FENRIR", - ] - manager.environment["input"]["curr_input"] = [ - "KEY_CTRL", - "KEY_FENRIR", - "KEY_S", - ] - manager.detect_shortcut_command() - - event_manager.put_to_event_queue.assert_called_once_with( - FenrirEventType.execute_command, "SAVE_SETTINGS" - ) - - -@pytest.mark.unit -def test_action_key_disarms_modifier_prefix(): - manager = FenrirManager.__new__(FenrirManager) - manager.modifierInput = False - manager.modifier_prefix_input = True - manager.singleKeyCommand = False - manager.command = "" - input_manager = Mock( - is_key_press=Mock(return_value=False), - no_key_pressed=Mock(return_value=False), - curr_input_has_command_modifier=Mock(return_value=False), - curr_input_is_modifier_prefix=Mock(return_value=False), - get_curr_shortcut=Mock(return_value=str([1, ["KEY_C", "KEY_CTRL"]])), - get_command_for_shortcut=Mock(return_value=""), - ) - manager.environment = { - "input": { - "key_forward": 0, - "prev_input": ["KEY_CTRL"], - "curr_input": ["KEY_C", "KEY_CTRL"], - }, - "runtime": { - "InputManager": input_manager, - "EventManager": Mock(put_to_event_queue=Mock()), - "VmenuManager": Mock(get_active=Mock(return_value=False)), - "DiffReviewManager": Mock(is_active=Mock(return_value=False)), - "SpeechHistoryManager": Mock(is_active=Mock(return_value=False)), - }, - } - - manager.detect_shortcut_command() - - assert manager.modifier_prefix_input is False - - -@pytest.mark.unit -def test_modifier_prefix_is_not_forwarded_before_chord_is_known(): - manager, input_manager = create_handle_input_manager(False) - - manager.handle_input( - {"data": {"event_name": "KEY_CTRL", "event_state": 1}} - ) - - input_manager.write_event_buffer.assert_not_called() - input_manager.clear_event_buffer.assert_not_called() - - -@pytest.mark.unit -def test_modifier_prefix_is_forwarded_when_released_unused(): - manager, input_manager = create_handle_input_manager(True) - - manager.handle_input( - {"data": {"event_name": "KEY_CTRL", "event_state": 0}} - ) - - input_manager.write_event_buffer.assert_called_once_with() - input_manager.clear_last_deep_input.assert_not_called() - assert manager.modifier_prefix_input is False diff --git a/tests/unit/test_fenrir_manager_speech_history.py b/tests/unit/test_fenrir_manager_speech_history.py deleted file mode 100644 index 1bc9d05a..00000000 --- a/tests/unit/test_fenrir_manager_speech_history.py +++ /dev/null @@ -1,88 +0,0 @@ -from unittest.mock import Mock - -import pytest - -from fenrirscreenreader.core.eventData import FenrirEventType -from fenrirscreenreader.core.fenrirManager import FenrirManager - - -@pytest.mark.unit -def test_speech_history_plain_key_modal_command_is_dispatched(): - manager = FenrirManager.__new__(FenrirManager) - manager.modifierInput = False - manager.modifier_prefix_input = False - manager.singleKeyCommand = False - manager.command = "" - - event_manager = Mock(put_to_event_queue=Mock()) - input_manager = Mock( - is_key_press=Mock(return_value=False), - no_key_pressed=Mock(return_value=False), - get_curr_shortcut=Mock(return_value=str([1, ["KEY_UP"]])), - get_command_for_shortcut=Mock(return_value="SPEECH_HISTORY_PREV"), - ) - speech_history_manager = Mock(is_active=Mock(return_value=True)) - diff_review_manager = Mock(is_active=Mock(return_value=False)) - vmenu_manager = Mock(get_active=Mock(return_value=False)) - - manager.environment = { - "input": { - "key_forward": 0, - "prev_input": ["KEY_UP"], - "curr_input": ["KEY_UP"], - }, - "runtime": { - "InputManager": input_manager, - "EventManager": event_manager, - "VmenuManager": vmenu_manager, - "DiffReviewManager": diff_review_manager, - "SpeechHistoryManager": speech_history_manager, - }, - } - - manager.detect_shortcut_command() - - event_manager.put_to_event_queue.assert_called_once_with( - FenrirEventType.execute_command, "SPEECH_HISTORY_PREV" - ) - - -@pytest.mark.unit -def test_vmenu_plain_key_modal_command_is_dispatched(): - manager = FenrirManager.__new__(FenrirManager) - manager.modifierInput = False - manager.modifier_prefix_input = False - manager.singleKeyCommand = False - manager.command = "" - - event_manager = Mock(put_to_event_queue=Mock()) - input_manager = Mock( - is_key_press=Mock(return_value=False), - no_key_pressed=Mock(return_value=False), - get_curr_shortcut=Mock(return_value=str([1, ["KEY_UP"]])), - get_command_for_shortcut=Mock(return_value="PREV_VMENU_ENTRY"), - ) - vmenu_manager = Mock(get_active=Mock(return_value=True)) - speech_history_manager = Mock(is_active=Mock(return_value=False)) - diff_review_manager = Mock(is_active=Mock(return_value=False)) - - manager.environment = { - "input": { - "key_forward": 0, - "prev_input": ["KEY_UP"], - "curr_input": ["KEY_UP"], - }, - "runtime": { - "InputManager": input_manager, - "EventManager": event_manager, - "VmenuManager": vmenu_manager, - "DiffReviewManager": diff_review_manager, - "SpeechHistoryManager": speech_history_manager, - }, - } - - manager.detect_shortcut_command() - - event_manager.put_to_event_queue.assert_called_once_with( - FenrirEventType.execute_command, "PREV_VMENU_ENTRY" - ) diff --git a/tests/unit/test_forward_keypress.py b/tests/unit/test_forward_keypress.py deleted file mode 100644 index 40b668fd..00000000 --- a/tests/unit/test_forward_keypress.py +++ /dev/null @@ -1,76 +0,0 @@ -from unittest.mock import Mock - -import pytest - -from fenrirscreenreader.commands.commands.forward_keypress import command -from fenrirscreenreader.core.fenrirManager import FenrirManager - - -@pytest.mark.unit -def test_forward_keypress_arms_when_command_keys_are_still_down(): - env = { - "input": {"key_forward": 0}, - "runtime": { - "InputManager": Mock(no_key_pressed=Mock(return_value=False)), - "OutputManager": Mock(present_text=Mock()), - }, - } - forward_command = command() - forward_command.initialize(env) - - forward_command.run() - - assert env["input"]["key_forward"] == -1 - - -@pytest.mark.unit -def test_forward_keypress_activates_immediately_when_keyboard_is_idle(): - env = { - "input": {"key_forward": 0}, - "runtime": { - "InputManager": Mock(no_key_pressed=Mock(return_value=True)), - "OutputManager": Mock(present_text=Mock()), - }, - } - forward_command = command() - forward_command.initialize(env) - - forward_command.run() - - assert env["input"]["key_forward"] == 1 - - -@pytest.mark.unit -def test_forward_keypress_waits_for_command_release_before_forwarding(): - input_manager = Mock(no_key_pressed=Mock(return_value=False)) - manager = FenrirManager.__new__(FenrirManager) - manager.environment = { - "input": {"key_forward": -1}, - "runtime": {"InputManager": input_manager}, - } - - manager.update_key_forward() - assert manager.environment["input"]["key_forward"] == -1 - - input_manager.no_key_pressed.return_value = True - manager.update_key_forward() - - assert manager.environment["input"]["key_forward"] == 1 - - -@pytest.mark.unit -def test_forward_keypress_stays_active_until_forwarded_key_release(): - input_manager = Mock(no_key_pressed=Mock(return_value=False)) - manager = FenrirManager.__new__(FenrirManager) - manager.environment = { - "input": {"key_forward": 1}, - "runtime": {"InputManager": input_manager}, - } - - manager.update_key_forward() - assert manager.environment["input"]["key_forward"] == 1 - - input_manager.no_key_pressed.return_value = True - manager.update_key_forward() - - assert manager.environment["input"]["key_forward"] == 0 diff --git a/tests/unit/test_generic_sound_driver.py b/tests/unit/test_generic_sound_driver.py deleted file mode 100644 index b14e0fbc..00000000 --- a/tests/unit/test_generic_sound_driver.py +++ /dev/null @@ -1,113 +0,0 @@ -from unittest.mock import Mock - -import pytest - -from fenrirscreenreader.core.settingsData import settings_data -from fenrirscreenreader.soundDriver import genericDriver - -from .config_test_utils import load_source_settings - - -class SettingsManager: - def __init__(self, settings): - self.settings = settings - - def get_setting(self, section, setting): - return self.settings[setting] - - -def _sound_driver(settings): - sound_driver = genericDriver.driver() - sound_driver.initialize( - {"runtime": {"SettingsManager": SettingsManager(settings)}} - ) - sound_driver.set_volume(0.75) - return sound_driver - - -def _configured_sound_settings(): - config = load_source_settings() - return { - "generic_play_file_command": config.get( - "sound", "generic_play_file_command" - ), - "generic_frequency_command": config.get( - "sound", "generic_frequency_command" - ), - } - - -def test_play_frequency_replaces_snake_case_placeholders(monkeypatch): - popen = Mock() - monkeypatch.setattr(genericDriver.subprocess, "Popen", popen) - sound_driver = _sound_driver( - { - "generic_play_file_command": "player fenrir_sound_file", - "generic_frequency_command": ( - "tone --volume fenrir_volume --duration fenrir_duration " - "--frequency fenrir_frequency" - ), - } - ) - - sound_driver.play_frequence( - 440, 0.25, adjust_volume=0.5, interrupt=False - ) - - assert popen.call_args.args[0] == [ - "tone", - "--volume", - "0.375", - "--duration", - "0.25", - "--frequency", - "440", - ] - - -def test_play_sound_file_replaces_snake_case_placeholders( - monkeypatch, tmp_path -): - popen = Mock() - monkeypatch.setattr(genericDriver.subprocess, "Popen", popen) - sound_file = tmp_path / "sound file.ogg" - sound_file.write_bytes(b"") - sound_driver = _sound_driver( - { - "generic_play_file_command": ( - "player --volume fenrir_volume fenrir_sound_file" - ), - "generic_frequency_command": "tone fenrir_frequency", - } - ) - - sound_driver.play_sound_file(str(sound_file), interrupt=False) - - assert popen.call_args.args[0] == [ - "player", - "--volume", - "0.75", - str(sound_file), - ] - - -@pytest.mark.parametrize( - "settings", - [settings_data["sound"], _configured_sound_settings()], -) -def test_default_sound_commands_use_supported_placeholders( - monkeypatch, tmp_path, settings -): - popen = Mock() - monkeypatch.setattr(genericDriver.subprocess, "Popen", popen) - sound_file = tmp_path / "sound.ogg" - sound_file.write_bytes(b"") - sound_driver = _sound_driver(settings) - - sound_driver.play_frequence( - 440, 0.25, adjust_volume=0.5, interrupt=False - ) - sound_driver.play_sound_file(str(sound_file), interrupt=False) - - for call in popen.call_args_list: - assert not any("fenrir_" in argument for argument in call.args[0]) diff --git a/tests/unit/test_generic_speech_driver.py b/tests/unit/test_generic_speech_driver.py deleted file mode 100644 index 92ad9072..00000000 --- a/tests/unit/test_generic_speech_driver.py +++ /dev/null @@ -1,71 +0,0 @@ -import pytest - -from fenrirscreenreader.core.settingsData import settings_data -from fenrirscreenreader.speechDriver import genericDriver - -from .config_test_utils import load_source_settings - - -def test_build_speech_command_replaces_snake_case_placeholders(): - speech_driver = genericDriver.driver() - speech_driver.speech_command = ( - "synth --volume fenrir_volume --module fenrir_module " - "--language fenrir_language --voice fenrir_voice " - "--pitch fenrir_pitch --rate fenrir_rate fenrir_text" - ) - utterance = { - "volume": "150", - "module": "module-name", - "language": "en-US", - "voice": "voice-name", - "pitch": "50", - "rate": "240", - "text": "hello", - } - - assert speech_driver._build_speech_command(utterance) == [ - "synth", - "--volume", - "150", - "--module", - "module-name", - "--language", - "en-US", - "--voice", - "voice-name", - "--pitch", - "50", - "--rate", - "240", - "hello", - ] - - -def _configured_speech_command(): - config = load_source_settings() - return config.get("speech", "generic_speech_command") - - -@pytest.mark.parametrize( - "speech_command", - [ - settings_data["speech"]["generic_speech_command"], - _configured_speech_command(), - ], -) -def test_default_speech_commands_use_supported_placeholders(speech_command): - speech_driver = genericDriver.driver() - speech_driver.speech_command = speech_command - utterance = { - "volume": "150", - "module": "", - "language": "en-US", - "voice": "voice-name", - "pitch": "50", - "rate": "240", - "text": "hello", - } - - built_command = speech_driver._build_speech_command(utterance) - - assert not any("fenrir_" in argument for argument in built_command) diff --git a/tests/unit/test_hardware_speech_drivers.py b/tests/unit/test_hardware_speech_drivers.py deleted file mode 100644 index e794f89e..00000000 --- a/tests/unit/test_hardware_speech_drivers.py +++ /dev/null @@ -1,257 +0,0 @@ -import os -import select -import termios -import time -from unittest.mock import ANY -from unittest.mock import Mock - -import pytest - -from fenrirscreenreader.speechDriver import dectalkDriver -from fenrirscreenreader.speechDriver import doubletalkDriver -from fenrirscreenreader.speechDriver import litetalkDriver -from fenrirscreenreader.speechDriver import tripletalkDriver - - -def build_environment(device): - settings_manager = Mock() - settings_manager.get_setting.side_effect = ( - lambda section, setting: device - if (section, setting) == ("speech", "hardware_device") - else "" - ) - settings_manager.get_setting_as_int.side_effect = ( - lambda section, setting: 9600 - if (section, setting) == ("speech", "hardware_baud_rate") - else 0 - ) - return { - "runtime": { - "SettingsManager": settings_manager, - "DebugManager": Mock(), - } - } - - -def read_available(fd, expected_length, timeout=1.0): - deadline = time.monotonic() + timeout - data = b"" - while len(data) < expected_length and time.monotonic() < deadline: - readable, _, _ = select.select([fd], [], [], 0.05) - if readable: - data += os.read(fd, 1024) - return data - - -@pytest.fixture -def serial_pair(): - master_fd, slave_fd = os.openpty() - try: - yield master_fd, os.ttyname(slave_fd) - finally: - os.close(master_fd) - os.close(slave_fd) - - -def initialized_driver(driver_class, serial_pair): - master_fd, slave_name = serial_pair - speech_driver = driver_class.driver() - speech_driver.initialize(build_environment(slave_name)) - assert speech_driver._is_initialized - return speech_driver, master_fd - - -def test_dectalk_driver_speaks_printable_text(serial_pair): - speech_driver, master_fd = initialized_driver(dectalkDriver, serial_pair) - try: - speech_driver.speak("Hello\nworld ☃") - assert read_available(master_fd, 13) == b"Hello world \x0b" - finally: - speech_driver.shutdown() - - -def test_dectalk_driver_neutralizes_inline_commands_in_speech( - serial_pair, -): - speech_driver, master_fd = initialized_driver(dectalkDriver, serial_pair) - try: - speech_driver.speak("status [:dv g5 10] done") - expected = b"status [ :dv g5 10] done\x0b" - assert read_available(master_fd, len(expected)) == expected - finally: - speech_driver.shutdown() - - -def test_dectalk_driver_writes_settings_and_cancel(serial_pair): - speech_driver, master_fd = initialized_driver(dectalkDriver, serial_pair) - try: - speech_driver.set_rate(1.0) - speech_driver.set_pitch(1.0) - speech_driver.set_volume(0.5) - speech_driver.cancel() - expected = b"[:ra 650][:dv ap 350][:dv g5 73]\x03" - assert read_available(master_fd, len(expected)) == expected - finally: - speech_driver.shutdown() - - -def test_dectalk_driver_skips_redundant_setting_commands(serial_pair): - speech_driver, master_fd = initialized_driver(dectalkDriver, serial_pair) - try: - speech_driver.set_rate(0.5) - speech_driver.set_pitch(0.5) - speech_driver.set_volume(1.0) - speech_driver.set_rate(0.5) - speech_driver.set_pitch(0.5) - speech_driver.set_volume(1.0) - expected = b"[:ra 180][:dv ap 122][:dv g5 86]" - assert read_available(master_fd, len(expected)) == expected - assert read_available(master_fd, 1, timeout=0.2) == b"" - finally: - speech_driver.shutdown() - - -def test_dectalk_driver_uses_native_defaults_at_fenrir_midpoint(serial_pair): - speech_driver, master_fd = initialized_driver(dectalkDriver, serial_pair) - try: - speech_driver.set_rate(0.5) - speech_driver.set_pitch(0.5) - expected = b"[:ra 180][:dv ap 122]" - assert read_available(master_fd, len(expected)) == expected - finally: - speech_driver.shutdown() - - -def test_dectalk_driver_enables_xon_xoff_flow_control(serial_pair): - speech_driver, _ = initialized_driver(dectalkDriver, serial_pair) - try: - input_flags = termios.tcgetattr(speech_driver.serial_port)[0] - assert input_flags & termios.IXON - assert not input_flags & termios.IXOFF - assert not input_flags & termios.IXANY - finally: - speech_driver.shutdown() - - -def test_litetalk_driver_speaks_printable_text(serial_pair): - speech_driver, master_fd = initialized_driver(litetalkDriver, serial_pair) - try: - speech_driver.speak("Ready") - assert read_available(master_fd, 6) == b"Ready\r" - finally: - speech_driver.shutdown() - - -def test_litetalk_driver_writes_settings_and_cancel(serial_pair): - speech_driver, master_fd = initialized_driver(litetalkDriver, serial_pair) - try: - speech_driver.set_rate(1.0) - speech_driver.set_pitch(0.0) - speech_driver.set_volume(0.5) - speech_driver.cancel() - assert read_available(master_fd, 10) == b"\x019S\x010P\x014V\x18" - finally: - speech_driver.shutdown() - - -def test_configured_device_supports_classic_serial(serial_pair): - master_fd, slave_name = serial_pair - speech_driver = litetalkDriver.driver() - speech_driver.initialize(build_environment(slave_name)) - try: - assert speech_driver.device == slave_name - speech_driver.speak("Serial") - assert read_available(master_fd, 7) == b"Serial\r" - finally: - speech_driver.shutdown() - - -def test_configured_device_strips_inline_comment(serial_pair): - master_fd, slave_name = serial_pair - device_setting = f"{slave_name} # built-in serial port" - speech_driver = litetalkDriver.driver() - speech_driver.initialize(build_environment(device_setting)) - try: - assert speech_driver.device == slave_name - speech_driver.speak("Specific") - assert read_available(master_fd, 9) == b"Specific\r" - finally: - speech_driver.shutdown() - - -def test_auto_device_is_rejected(): - speech_driver = litetalkDriver.driver() - - with pytest.raises(RuntimeError, match="hardware speech device"): - speech_driver.initialize(build_environment("auto")) - - debug_manager = speech_driver.env["runtime"]["DebugManager"] - debug_manager.write_debug_out.assert_called_with( - "Hardware speech requires an explicit serial device", - ANY, - on_any_level=True, - ) - - -def test_hardware_driver_retries_partial_serial_writes(monkeypatch): - speech_driver = litetalkDriver.driver() - speech_driver.env = build_environment("/dev/ttyUSB0") - speech_driver.serial_port = 12 - written_chunks = [] - - def fake_write(port, data): - assert port == 12 - chunk = data[:2] - written_chunks.append(chunk) - return len(chunk) - - monkeypatch.setattr( - "fenrirscreenreader.speechDriver.hardwareSerialDriver.os.write", - fake_write, - ) - - speech_driver._write_bytes(b"abcdef", "speech") - - assert written_chunks == [b"ab", b"cd", b"ef"] - - -def test_hardware_driver_retries_setting_after_failed_write(monkeypatch): - speech_driver = dectalkDriver.driver() - speech_driver._is_initialized = True - write_results = [False, True] - written_commands = [] - - def fake_write(data, description): - written_commands.append((data, description)) - return write_results.pop(0) - - monkeypatch.setattr(speech_driver, "_write_bytes", fake_write) - - speech_driver.set_rate(0.5) - speech_driver.set_rate(0.5) - - assert written_commands == [ - (b"[:ra 180]", "rate"), - (b"[:ra 180]", "rate"), - ] - - -@pytest.mark.parametrize("driver_class", [doubletalkDriver, tripletalkDriver]) -def test_litetalk_compatible_alias_drivers(driver_class, serial_pair): - speech_driver, master_fd = initialized_driver(driver_class, serial_pair) - try: - speech_driver.speak("Alias") - speech_driver.set_rate(1.0) - assert read_available(master_fd, 10) == b"\x019SAlias\r" - finally: - speech_driver.shutdown() - - -def test_hardware_driver_ignores_empty_and_non_string_text(serial_pair): - speech_driver, master_fd = initialized_driver(dectalkDriver, serial_pair) - try: - speech_driver.speak("") - speech_driver.speak(None) - assert read_available(master_fd, 1, timeout=0.2) == b"" - finally: - speech_driver.shutdown() diff --git a/tests/unit/test_help_manager.py b/tests/unit/test_help_manager.py deleted file mode 100644 index 1f3d9ff0..00000000 --- a/tests/unit/test_help_manager.py +++ /dev/null @@ -1,202 +0,0 @@ -import ast -from unittest.mock import Mock - -import pytest - -from fenrirscreenreader.core.commandManager import CommandManager -from fenrirscreenreader.core.helpManager import HelpManager - - -def create_help_manager(capture_succeeds=True): - manager = HelpManager() - bindings = { - str([1, ["KEY_F1", "KEY_FENRIR"]]): "TOGGLE_TUTORIAL_MODE", - str([1, ["KEY_FENRIR", "KEY_S"]]): "SPELL_CHECK", - str([1, ["KEY_FENRIR", "KEY_W"]]): "READ_WORD", - str([2, ["KEY_FENRIR", "KEY_W"]]): "READ_WORD", - str([1, ["KEY_SCRIPT", "KEY_U"]]): ( - "MUSICPLAYER__-__KEY_U" - ), - } - raw_bindings = { - shortcut: ast.literal_eval(shortcut) for shortcut in bindings - } - command_manager = Mock() - descriptions = { - "SPELL_CHECK": "spell check", - "READ_WORD": "read current word", - "UNBOUND_ACTION": "an action without a shortcut", - "MUSICPLAYER__-__KEY_U": "music player plugin", - } - command_manager.get_command_description.side_effect = ( - lambda command, section="commands": descriptions.get(command, "") - ) - input_manager = Mock() - input_manager.set_help_capture.return_value = capture_succeeds - internal_command = Mock() - internal_command.help_visible = False - plugin_command = Mock() - plugin_command.help_category = "plugins" - manager.initialize( - { - "general": {"tutorialMode": False}, - "bindings": bindings.copy(), - "rawBindings": { - shortcut: [raw[0], raw[1].copy()] - for shortcut, raw in raw_bindings.items() - }, - "commands": { - "commands": { - "READ_WORD": Mock(), - "SPELL_CHECK": Mock(), - "TOGGLE_TUTORIAL_MODE": Mock(), - "UNBOUND_ACTION": Mock(), - "00_INIT_COMMANDS": internal_command, - "MUSICPLAYER__-__KEY_U": plugin_command, - } - }, - "runtime": { - "CommandManager": command_manager, - "DebugManager": Mock(), - "InputDriver": Mock(refresh_grabs=Mock()), - "InputManager": input_manager, - "VmenuManager": Mock(get_active=Mock(return_value=False)), - }, - } - ) - return manager, bindings, raw_bindings - - -@pytest.mark.unit -def test_help_lists_bound_actions_once_with_all_shortcuts(): - manager, _bindings, raw_bindings = create_help_manager() - - manager.set_tutorial_mode(True) - - active_text = " ".join(manager.help_lists["active"]) - assert active_text.count("read word") == 1 - assert "fenrir, w" in active_text - assert "2 times fenrir, w" in active_text - assert "unbound action" not in active_text - assert "musicplayer" not in active_text - assert "00 init commands" not in active_text - assert all( - "00 init commands" not in entry - for entry in manager.help_lists["unbound"] - ) - assert manager.help_lists["unbound"] == [ - "unbound action, Shortcuts unbound, Description an action without a shortcut" - ] - assert manager.help_lists["plugins"] == [ - "musicplayer, Shortcuts script, u, Description music player plugin" - ] - assert raw_bindings[str([1, ["KEY_FENRIR", "KEY_S"]])] == [ - 1, - ["KEY_FENRIR", "KEY_S"], - ] - - -@pytest.mark.unit -def test_help_navigation_defaults_to_active_and_starts_before_first_item(): - manager, _bindings, _raw_bindings = create_help_manager() - manager.set_tutorial_mode(True) - - assert manager.get_help_section_name() == "Active actions" - assert manager.get_help_for_current_index() == "Active actions" - - manager.next_index() - assert manager.get_help_for_current_index() == manager.help_lists["active"][0] - - assert manager.select_help_section("unbound") == "Unbound actions" - assert manager.get_help_for_current_index() == "Unbound actions" - manager.prev_index() - assert manager.get_help_for_current_index() == manager.help_lists["unbound"][-1] - - assert manager.next_help_section() == "Plugins" - assert manager.next_help_section() == "Active actions" - assert manager.prev_help_section() == "Plugins" - - -@pytest.mark.unit -def test_help_installs_and_restores_modal_bindings_exactly(): - manager, bindings, raw_bindings = create_help_manager() - - manager.set_tutorial_mode(True) - - assert ( - manager.env["bindings"][str([1, ["KEY_LEFT"]])] - == "PREV_HELP_SECTION" - ) - assert ( - manager.env["bindings"][str([1, ["KEY_RIGHT"]])] - == "NEXT_HELP_SECTION" - ) - assert manager.env["bindings"][str([1, ["KEY_ESC"]])] == "TOGGLE_TUTORIAL_MODE" - assert manager.env["rawBindings"][str([1, ["KEY_RIGHT"]])] == [ - 1, - ["KEY_RIGHT"], - ] - manager.env["runtime"]["InputManager"].set_help_capture.assert_called_once_with( - True - ) - - manager.set_tutorial_mode(False) - - assert manager.env["bindings"] == bindings - assert manager.env["rawBindings"] == raw_bindings - manager.env["runtime"]["InputManager"].set_help_capture.assert_called_with( - False - ) - - -@pytest.mark.unit -def test_help_records_degraded_capture_without_refusing_mode(): - manager, _bindings, _raw_bindings = create_help_manager( - capture_succeeds=False - ) - - manager.set_tutorial_mode(True) - - assert manager.is_tutorial_mode() is True - assert manager.is_capture_degraded() is True - - -@pytest.mark.unit -def test_help_does_not_claim_exit_when_capture_release_fails(): - manager, bindings, raw_bindings = create_help_manager() - manager.set_tutorial_mode(True) - manager.env["runtime"]["InputManager"].set_help_capture.return_value = False - - assert manager.set_tutorial_mode(False) is False - - assert manager.is_tutorial_mode() is True - assert manager.env["bindings"] != bindings - assert manager.env["rawBindings"] != raw_bindings - - -@pytest.mark.unit -def test_command_description_is_presented_on_ignored_screen_in_help(): - command_manager = CommandManager() - output_manager = Mock() - command_manager.env = { - "runtime": { - "ScreenManager": Mock( - is_ignored_screen=Mock(return_value=True) - ), - "HelpManager": Mock( - is_tutorial_mode=Mock(return_value=True) - ), - "DebugManager": Mock(), - "OutputManager": output_manager, - } - } - command_manager.command_exists = Mock(return_value=True) - command_manager.get_command_description = Mock( - return_value="spell check" - ) - - command_manager.execute_command("SPELL_CHECK", "commands") - - output_manager.present_text.assert_called_once_with( - "spell check", interrupt=False - ) diff --git a/tests/unit/test_incoming_command.py b/tests/unit/test_incoming_command.py deleted file mode 100644 index bac9c30a..00000000 --- a/tests/unit/test_incoming_command.py +++ /dev/null @@ -1,242 +0,0 @@ -""" -Unit tests for incoming text announcement behavior. -""" - -import importlib.util -from pathlib import Path -from unittest.mock import Mock - -import pytest - - -def _load_incoming_module(): - module_path = ( - Path(__file__).resolve().parents[2] - / "src" - / "fenrirscreenreader" - / "commands" - / "onScreenUpdate" - / "70000-incoming.py" - ) - spec = importlib.util.spec_from_file_location( - "fenrir_incoming_command", module_path - ) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -@pytest.fixture -def incoming_command(): - incoming_module = _load_incoming_module() - output_manager = Mock(present_text=Mock()) - settings_manager = Mock() - settings_manager.get_setting_as_bool.side_effect = ( - lambda section, setting: True - if (section, setting) == ("speech", "auto_read_incoming") - else False - ) - settings_manager.get_setting_as_float.side_effect = ( - lambda section, setting: { - ("speech", "rapid_update_window"): 0.3, - ("speech", "batch_flush_interval"): 0.5, - }[(section, setting)] - ) - settings_manager.get_setting_as_int.side_effect = ( - lambda section, setting: { - ("speech", "rapid_update_threshold"): 5, - ("speech", "flood_line_threshold"): 500, - ("speech", "max_batch_lines"): 100, - }[(section, setting)] - ) - - env = { - "runtime": { - "OutputManager": output_manager, - "SettingsManager": settings_manager, - "ScreenManager": Mock( - is_delta=Mock(return_value=True), - is_screen_change=Mock(return_value=False), - ), - }, - "screen": { - "new_delta": "", - "new_delta_is_typing": False, - "old_content_text": "", - "new_content_text": "", - "old_cursor": {"x": 0, "y": 0}, - "new_cursor": {"x": 0, "y": 0}, - "newNegativeDelta": "", - }, - "commandBuffer": {}, - } - - command = incoming_module.command() - command.initialize(env) - return command, env, output_manager - - -@pytest.mark.unit -class TestIncomingCommand: - """Test incoming text filtering for chat-like screen updates.""" - - def test_prefers_inserted_chat_line_over_changed_headers( - self, incoming_command - ): - command, env, output_manager = incoming_command - env["screen"]["old_content_text"] = "\n".join( - [ - "Status old", - "Users old", - "UserA: hi", - "UserB: hello", - "> ", - ] - ) - env["screen"]["new_content_text"] = "\n".join( - [ - "Status new", - "Users new", - "UserB: hello", - "UserC: test", - "> ", - ] - ) - env["screen"]["new_delta"] = "\n".join( - ["Status new", "Users new", "UserC: test"] - ) - - command.run() - - output_manager.present_text.assert_called_once_with( - "UserC: test", interrupt=False, flush=False - ) - - def test_keeps_header_update_when_no_lower_screen_insert_exists( - self, incoming_command - ): - command, env, output_manager = incoming_command - env["screen"]["old_content_text"] = "\n".join( - [ - "Status old", - "Users old", - "UserA: hi", - "UserB: hello", - ] - ) - env["screen"]["new_content_text"] = "\n".join( - [ - "Status new", - "Users new", - "UserA: hi", - "UserB: hello", - ] - ) - env["screen"]["new_delta"] = "\n".join(["Status new", "Users new"]) - - command.run() - - output_manager.present_text.assert_called_once_with( - "Status new\nUsers new", interrupt=False, flush=False - ) - - def test_skips_typing_delta(self, incoming_command): - command, env, output_manager = incoming_command - env["screen"]["new_delta"] = "x" - env["screen"]["new_delta_is_typing"] = True - - command.run() - - output_manager.present_text.assert_not_called() - - def test_dialog_button_paint_is_read_after_following_message( - self, incoming_command - ): - command, env, output_manager = incoming_command - env["screen"]["new_content_text"] = "\n".join( - [ - "".ljust(20), - "< OK >".center(20), - "".ljust(20), - ] - ) - env["screen"]["new_delta"] = "\n".join( - [ - " ", - " < OK >", - " ", - ] - ) - - command.run() - - output_manager.present_text.assert_not_called() - - env["screen"]["old_content_text"] = env["screen"]["new_content_text"] - env["screen"]["new_content_text"] = "\n".join( - [ - "This is a test".ljust(20), - "< OK >".center(20), - "".ljust(20), - ] - ) - env["screen"]["new_delta"] = "This is a test" - - command.run() - - output_manager.present_text.assert_called_once_with( - "This is a test\n< OK >", interrupt=False, flush=False - ) - - def test_single_word_output_is_not_treated_as_dialog_button( - self, incoming_command - ): - command, env, output_manager = incoming_command - env["screen"]["new_content_text"] = "Ready" - env["screen"]["new_delta"] = "Ready" - - command.run() - - output_manager.present_text.assert_called_once_with( - "Ready", interrupt=False, flush=False - ) - - def test_dialog_button_is_not_preferred_over_message( - self, incoming_command - ): - command, env, output_manager = incoming_command - env["screen"]["old_content_text"] = "\n".join( - [ - 'bash-5.3$ dialog --msgbox "This is a test" -1 -1'.ljust(80), - "".ljust(80), - "".ljust(80), - "".ljust(80), - "".ljust(80), - "".ljust(80), - ] - ) - env["screen"]["new_content_text"] = "\n".join( - [ - "".ljust(80), - " This is a test".ljust(80), - "".ljust(80), - "".ljust(80), - " < OK >".ljust(80), - "".ljust(80), - ] - ) - env["screen"]["new_delta"] = "\n".join( - [ - " ", - " This is a test ", - " < OK > ", - ] - ) - - command.run() - - output_manager.present_text.assert_called_once_with( - " \n This is a test \n < OK > ", - interrupt=False, - flush=False, - ) diff --git a/tests/unit/test_local_resources.py b/tests/unit/test_local_resources.py deleted file mode 100644 index 08e8c54f..00000000 --- a/tests/unit/test_local_resources.py +++ /dev/null @@ -1,153 +0,0 @@ -from unittest.mock import Mock - -import pytest - -from fenrirscreenreader.core.commandManager import CommandManager -from fenrirscreenreader.core.settingsManager import SettingsManager - - -def write_script(path): - path.write_text("#!/bin/sh\n", encoding="utf-8") - path.chmod(0o755) - - -def build_command_environment(local_path, system_path): - settings_manager = Mock() - settings_manager.get_user_script_path.return_value = str(local_path) - settings_manager.get_setting.return_value = str(system_path) - valid_keys = { - "KEY_A", - "KEY_B", - "KEY_C", - "KEY_D", - "KEY_E", - "KEY_SHIFT", - "KEY_SCRIPT", - "KEY_U", - "KEY_X", - } - - return { - "commands": {"commands": {}}, - "commandsIgnore": {"commands": {}}, - "bindings": { - str([1, sorted(["KEY_C", "KEY_SCRIPT"])]): "EXISTING" - }, - "rawBindings": {}, - "general": {"curr_user": "Username"}, - "runtime": { - "SettingsManager": settings_manager, - "DebugManager": Mock(write_debug_out=Mock()), - "InputManager": Mock( - is_valid_key=Mock(side_effect=lambda key: key in valid_keys) - ), - }, - } - - -@pytest.mark.unit -def test_local_scripts_load_before_non_conflicting_system_scripts(tmp_path): - local_path = tmp_path / "local" - system_path = tmp_path / "system" - local_path.mkdir() - system_path.mkdir() - write_script(local_path / "local_only__-__KEY_A") - write_script(local_path / "same_name__-__KEY_B") - write_script(system_path / "same_name__-__KEY_D") - write_script(system_path / "system_only__-__KEY_E") - - env = build_command_environment(local_path, system_path) - command_manager = CommandManager() - command_manager.initialize = lambda environment: None - command_manager.env = env - - command_manager.load_script_commands() - - commands = env["commands"]["commands"] - assert "LOCAL_ONLY__-__KEY_A" in commands - assert "SYSTEM_ONLY__-__KEY_E" in commands - assert commands["SAME_NAME__-__KEY_B"].script_path == str( - local_path / "same_name__-__KEY_B" - ) - assert "SAME_NAME__-__KEY_D" not in commands - - -@pytest.mark.unit -def test_system_scripts_skip_existing_shortcut_bindings(tmp_path): - local_path = tmp_path / "local" - system_path = tmp_path / "system" - local_path.mkdir() - system_path.mkdir() - write_script(system_path / "conflicting_key__-__KEY_C") - write_script(system_path / "system_only__-__KEY_E") - - env = build_command_environment(local_path, system_path) - command_manager = CommandManager() - command_manager.env = env - - command_manager.load_script_commands() - - commands = env["commands"]["commands"] - assert "CONFLICTING_KEY__-__KEY_C" not in commands - assert "SYSTEM_ONLY__-__KEY_E" in commands - - -@pytest.mark.unit -def test_same_directory_script_variants_load_multiple_shortcuts(tmp_path): - local_path = tmp_path / "local" - system_path = tmp_path / "system" - local_path.mkdir() - system_path.mkdir() - write_script(system_path / "musicplayer__-__KEY_SHIFT__+__KEY_U.sh") - write_script(system_path / "musicplayer__-__KEY_SHIFT__+__KEY_X.sh") - - env = build_command_environment(local_path, system_path) - command_manager = CommandManager() - command_manager.env = env - - command_manager.load_script_commands() - - commands = env["commands"]["commands"] - assert "MUSICPLAYER__-__KEY_SHIFT__+__KEY_U" in commands - assert "MUSICPLAYER__-__KEY_SHIFT__+__KEY_X" in commands - assert ( - env["bindings"][str([1, sorted(["KEY_SCRIPT", "KEY_SHIFT", "KEY_U"])])] - == "MUSICPLAYER__-__KEY_SHIFT__+__KEY_U" - ) - assert ( - env["bindings"][str([1, sorted(["KEY_SCRIPT", "KEY_SHIFT", "KEY_X"])])] - == "MUSICPLAYER__-__KEY_SHIFT__+__KEY_X" - ) - - -@pytest.mark.unit -def test_sound_theme_resolution_prefers_local_soundpacks(tmp_path): - manager = SettingsManager() - local_root = tmp_path / "local" / "fenrir" - system_root = tmp_path / "system" / "sounds" / "fenrir" - local_theme = local_root / "sounds" / "default" - system_theme = system_root / "default" - local_theme.mkdir(parents=True) - system_theme.mkdir(parents=True) - (local_theme / "soundicons.conf").write_text( - "Accept=Accept.wav\n", encoding="utf-8" - ) - (system_theme / "soundicons.conf").write_text( - "Accept=SystemAccept.wav\n", encoding="utf-8" - ) - manager.user_resource_root = str(local_root) - manager.system_sound_roots = [str(system_root)] - - assert manager.resolve_sound_theme_path("default") == str(local_theme) - - -@pytest.mark.unit -def test_sound_theme_resolution_accepts_absolute_theme_path(tmp_path): - manager = SettingsManager() - theme_path = tmp_path / "custom" - theme_path.mkdir() - (theme_path / "soundicons.conf").write_text( - "Accept=Accept.wav\n", encoding="utf-8" - ) - - assert manager.resolve_sound_theme_path(str(theme_path)) == str(theme_path) diff --git a/tests/unit/test_lock_key_commands.py b/tests/unit/test_lock_key_commands.py deleted file mode 100644 index c52a3845..00000000 --- a/tests/unit/test_lock_key_commands.py +++ /dev/null @@ -1,106 +0,0 @@ -import importlib.util -from pathlib import Path -from unittest.mock import Mock - -import pytest - - -def load_lock_command(command_file): - command_path = ( - Path(__file__).resolve().parents[2] - / "src" - / "fenrirscreenreader" - / "commands" - / "onKeyInput" - / command_file - ) - spec = importlib.util.spec_from_file_location( - command_file.replace("-", "_"), command_path - ) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module.command - - -def build_env(input_data): - output_manager = Mock() - return { - "input": input_data, - "runtime": { - "OutputManager": output_manager, - }, - }, output_manager - - -@pytest.mark.unit -def test_capslock_command_ignores_non_capslock_state_change(): - command = load_lock_command("80000-capslock.py")() - env, output_manager = build_env( - { - "old_caps_lock": False, - "new_caps_lock": True, - "curr_input": ["KEY_KP8"], - "prev_input": [], - } - ) - command.initialize(env) - - command.run() - - output_manager.present_text.assert_not_called() - - -@pytest.mark.unit -def test_capslock_command_announces_capslock_release_change(): - command = load_lock_command("80000-capslock.py")() - env, output_manager = build_env( - { - "old_caps_lock": True, - "new_caps_lock": False, - "curr_input": [], - "prev_input": ["KEY_CAPSLOCK"], - } - ) - command.initialize(env) - - command.run() - - output_manager.present_text.assert_called_once() - assert output_manager.present_text.call_args.args[0] == "Capslock off" - - -@pytest.mark.unit -def test_scrolllock_command_ignores_non_scrolllock_state_change(): - command = load_lock_command("80300-scrolllock.py")() - env, output_manager = build_env( - { - "old_scroll_lock": False, - "new_scroll_lock": True, - "curr_input": ["KEY_KP8"], - "prev_input": [], - } - ) - command.initialize(env) - - command.run() - - output_manager.present_text.assert_not_called() - - -@pytest.mark.unit -def test_scrolllock_command_announces_scrolllock_release_change(): - command = load_lock_command("80300-scrolllock.py")() - env, output_manager = build_env( - { - "old_scroll_lock": True, - "new_scroll_lock": False, - "curr_input": [], - "prev_input": ["KEY_SCROLLLOCK"], - } - ) - command.initialize(env) - - command.run() - - output_manager.present_text.assert_called_once() - assert output_manager.present_text.call_args.args[0] == "Scrolllock off" diff --git a/tests/unit/test_numlock_command.py b/tests/unit/test_numlock_command.py deleted file mode 100644 index 9ba4b13c..00000000 --- a/tests/unit/test_numlock_command.py +++ /dev/null @@ -1,65 +0,0 @@ -import importlib.util -from pathlib import Path -from unittest.mock import Mock - -import pytest - - -def load_numlock_command(): - command_path = ( - Path(__file__).parents[2] - / "src" - / "fenrirscreenreader" - / "commands" - / "onKeyInput" - / "80500-numlock.py" - ) - spec = importlib.util.spec_from_file_location( - "numlock_command", command_path - ) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module.command - - -@pytest.mark.unit -def test_numlock_off_can_be_announced_from_release_event(): - output_manager = Mock() - command = load_numlock_command()() - command.initialize( - { - "input": { - "old_num_lock": True, - "new_num_lock": False, - "curr_input": [], - "prev_input": ["KEY_NUMLOCK"], - }, - "runtime": {"OutputManager": output_manager}, - } - ) - - command.run() - - output_manager.present_text.assert_called_once() - assert output_manager.present_text.call_args.args[0] == "Numlock off" - - -@pytest.mark.unit -def test_numlock_command_ignores_non_numlock_release(): - output_manager = Mock() - command = load_numlock_command()() - command.initialize( - { - "input": { - "old_num_lock": True, - "new_num_lock": False, - "curr_input": [], - "prev_input": ["KEY_KP1"], - }, - "runtime": {"OutputManager": output_manager}, - } - ) - - command.run() - - output_manager.present_text.assert_not_called() diff --git a/tests/unit/test_output_manager.py b/tests/unit/test_output_manager.py deleted file mode 100644 index eb2e9e48..00000000 --- a/tests/unit/test_output_manager.py +++ /dev/null @@ -1,242 +0,0 @@ -import importlib.util -import threading -import time -from pathlib import Path -from unittest.mock import Mock - -import pytest - -from fenrirscreenreader.core.outputManager import OutputManager - - -def build_output_manager(): - settings_manager = Mock() - settings_manager.get_setting_as_bool.return_value = True - settings_manager.get_setting_as_float.return_value = 1.0 - sound_driver = Mock() - speech_driver = Mock() - output_manager = OutputManager() - output_manager.env = { - "soundIcons": { - "ACCEPT": "/tmp/Accept.wav", - "ERRORSCREEN": "/tmp/ErrorScreen.wav", - }, - "runtime": { - "SettingsManager": settings_manager, - "SoundDriver": sound_driver, - "SpeechDriver": speech_driver, - "DebugManager": Mock(write_debug_out=Mock()), - "TextManager": Mock( - replace_head_lines=Mock(side_effect=lambda text: text) - ), - "PunctuationManager": Mock( - proceed_punctuation=Mock( - side_effect=lambda text, _ignore_punctuation: text - ) - ), - }, - } - return output_manager, sound_driver, speech_driver - - -def load_key_interrupt_module(): - module_path = ( - Path(__file__).resolve().parents[2] - / "src" - / "fenrirscreenreader" - / "commands" - / "onKeyInput" - / "10000-shut_up.py" - ) - spec = importlib.util.spec_from_file_location( - "fenrir_key_interrupt", module_path - ) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -@pytest.mark.unit -def test_present_text_allows_sound_only_feedback(): - output_manager, sound_driver, _speech_driver = build_output_manager() - - output_manager.present_text("", sound_icon="Accept", interrupt=False) - - sound_driver.play_sound_file.assert_called_once_with( - "/tmp/Accept.wav", False - ) - - -@pytest.mark.unit -def test_play_sound_supports_error_alias(): - output_manager, sound_driver, _speech_driver = build_output_manager() - - assert output_manager.play_sound("Error") is True - - sound_driver.play_sound_file.assert_called_once_with( - "/tmp/ErrorScreen.wav", True - ) - - -@pytest.mark.unit -def test_interrupt_output_async_does_not_block_on_slow_cancel(): - output_manager, _sound_driver, speech_driver = build_output_manager() - interrupt_started = threading.Event() - release_interrupt = threading.Event() - - def slow_cancel(): - interrupt_started.set() - release_interrupt.wait(timeout=1.0) - - speech_driver.cancel.side_effect = slow_cancel - - start_time = time.monotonic() - output_manager.interrupt_output_async() - elapsed = time.monotonic() - start_time - - try: - assert interrupt_started.wait(timeout=0.2) - assert elapsed < 0.2 - output_manager.interrupt_output_async() - assert speech_driver.cancel.call_count == 1 - finally: - release_interrupt.set() - output_manager.interrupt_thread.join(timeout=1.0) - - -@pytest.mark.unit -def test_interrupt_output_waits_only_briefly_for_slow_cancel(): - output_manager, _sound_driver, speech_driver = build_output_manager() - interrupt_started = threading.Event() - release_interrupt = threading.Event() - - def slow_cancel(): - interrupt_started.set() - release_interrupt.wait(timeout=1.0) - - speech_driver.cancel.side_effect = slow_cancel - - start_time = time.monotonic() - output_manager.interrupt_output() - elapsed = time.monotonic() - start_time - - try: - assert interrupt_started.wait(timeout=0.2) - assert elapsed < 0.2 - output_manager.interrupt_output() - assert speech_driver.cancel.call_count == 1 - finally: - release_interrupt.set() - output_manager.interrupt_thread.join(timeout=1.0) - - -@pytest.mark.unit -def test_cancel_speech_skips_when_speech_driver_is_busy(): - output_manager, _sound_driver, speech_driver = build_output_manager() - output_manager.speech_driver_lock_timeout = 0.01 - output_manager.speech_driver_lock.acquire() - - try: - start_time = time.monotonic() - output_manager.cancel_speech() - elapsed = time.monotonic() - start_time - finally: - output_manager.speech_driver_lock.release() - - assert elapsed < 0.2 - speech_driver.cancel.assert_not_called() - - -@pytest.mark.unit -def test_speak_text_drops_speech_when_cancel_holds_driver_lock(): - output_manager, _sound_driver, speech_driver = build_output_manager() - output_manager.speech_driver_lock_timeout = 0.01 - output_manager.speech_driver_lock.acquire() - - try: - start_time = time.monotonic() - output_manager.speak_text("hello", interrupt=False, flush=False) - elapsed = time.monotonic() - start_time - finally: - output_manager.speech_driver_lock.release() - - assert elapsed < 0.2 - speech_driver.speak.assert_not_called() - - -@pytest.mark.unit -def test_present_text_records_speech_history_when_enabled(): - output_manager, _sound_driver, speech_driver = build_output_manager() - speech_history_manager = Mock(add_text=Mock()) - output_manager.env["runtime"]["SpeechHistoryManager"] = ( - speech_history_manager - ) - - output_manager.present_text("hello history", interrupt=False) - - speech_history_manager.add_text.assert_called_once_with("hello history") - speech_driver.speak.assert_called_once() - - -@pytest.mark.unit -def test_process_mid_word_punctuation_handles_chained_dotted_words(): - output_manager, _sound_driver, _speech_driver = build_output_manager() - - result = output_manager.process_mid_word_punctuation( - ".config archive.tar.gz settings.conf." - ) - - assert result == ( - "dot config archive dot tar dot gz settings dot conf." - ) - - -@pytest.mark.unit -def test_present_text_does_not_record_when_speech_disabled(): - output_manager, _sound_driver, speech_driver = build_output_manager() - speech_history_manager = Mock(add_text=Mock()) - output_manager.env["runtime"]["SpeechHistoryManager"] = ( - speech_history_manager - ) - - def _get_setting_as_bool(section, setting): - if (section, setting) == ("speech", "enabled"): - return False - return True - - output_manager.env["runtime"][ - "SettingsManager" - ].get_setting_as_bool.side_effect = _get_setting_as_bool - - output_manager.present_text("hello history", interrupt=False) - - speech_history_manager.add_text.assert_not_called() - speech_driver.speak.assert_not_called() - - -@pytest.mark.unit -def test_key_interrupt_command_uses_nonblocking_interrupt(): - module = load_key_interrupt_module() - settings_manager = Mock() - settings_manager.get_setting_as_bool.return_value = True - settings_manager.get_setting.return_value = "" - output_manager = Mock() - env = { - "input": { - "curr_input": ["KEY_A"], - "prev_input": [], - }, - "runtime": { - "InputManager": Mock(no_key_pressed=Mock(return_value=False)), - "OutputManager": output_manager, - "ScreenManager": Mock(is_screen_change=Mock(return_value=False)), - "SettingsManager": settings_manager, - }, - } - command = module.command() - command.initialize(env) - - command.run() - - output_manager.interrupt_output_async.assert_called_once_with() - output_manager.interrupt_output.assert_not_called() diff --git a/tests/unit/test_progress_detector.py b/tests/unit/test_progress_detector.py deleted file mode 100644 index a1443bac..00000000 --- a/tests/unit/test_progress_detector.py +++ /dev/null @@ -1,402 +0,0 @@ -import importlib.util -from pathlib import Path -from unittest.mock import Mock - -import pytest - - -def _load_progress_module(): - module_path = ( - Path(__file__).resolve().parents[2] - / "src" - / "fenrirscreenreader" - / "commands" - / "onScreenUpdate" - / "65000-progress_detector.py" - ) - spec = importlib.util.spec_from_file_location( - "fenrir_progress_detector", module_path - ) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -@pytest.mark.unit -def test_progress_detector_skips_typing_delta(): - progress_module = _load_progress_module() - command = progress_module.command() - command.env = { - "commandBuffer": {"progress_monitoring": True}, - "runtime": {"DebugManager": Mock(write_debug_out=Mock())}, - "screen": {"new_delta": "x", "new_delta_is_typing": True}, - } - command.is_current_line_prompt = Mock(return_value=False) - command.is_real_progress_update = Mock(return_value=True) - command.detect_progress = Mock() - - command.run() - - command.is_real_progress_update.assert_not_called() - command.detect_progress.assert_not_called() - - -@pytest.mark.unit -def test_progress_detector_allows_long_tqdm_transfer_delta(): - progress_module = _load_progress_module() - command = progress_module.command() - sample = ( - "88%|" - "ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆ" - "ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆ" - "ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆ" - "ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–Š " - "| 843M/954M [00:54<00:07, 15.2MB/s]" - ) - command.env = { - "commandBuffer": {"progress_monitoring": True}, - "runtime": { - "DebugManager": Mock(write_debug_out=Mock()), - "ScreenManager": Mock(is_screen_change=Mock(return_value=False)), - "CursorManager": Mock(is_cursor_vertical_move=Mock(return_value=False)), - }, - "screen": { - "new_delta": sample, - "new_content_text": sample, - "old_cursor": {"x": 0, "y": 0}, - "new_cursor": {"x": 0, "y": 0}, - }, - } - - assert len(sample) > 200 - assert command.is_real_progress_update() - - -@pytest.mark.unit -def test_progress_detector_beeps_for_long_tqdm_transfer_delta(): - progress_module = _load_progress_module() - command = progress_module.command() - sample = ( - "90%|" - "ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆ" - "ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆ" - "ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆ" - "ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā– " - "| 856M/954M [00:56<00:14, 6.78MB/s]" - ) - command.env = { - "commandBuffer": { - "progress_monitoring": True, - "lastProgressValue": -1, - "lastProgressTime": 0, - }, - "runtime": { - "DebugManager": Mock(write_debug_out=Mock()), - "ScreenManager": Mock(is_screen_change=Mock(return_value=False)), - "CursorManager": Mock(is_cursor_vertical_move=Mock(return_value=False)), - }, - "screen": { - "new_delta": sample, - "new_delta_is_typing": False, - "new_content_text": sample, - "old_cursor": {"x": 0, "y": 0}, - "new_cursor": {"x": 0, "y": 0}, - }, - } - command.play_progress_tone = Mock() - - command.run() - - command.play_progress_tone.assert_called_once_with(90.0) - assert command.env["commandBuffer"]["lastProgressValue"] == 90.0 - - -@pytest.mark.unit -def test_progress_detector_beeps_for_interruptible_status_without_ellipsis(): - progress_module = _load_progress_module() - command = progress_module.command() - sample = "ā—¦ Files: (1m 04s • esc to interrupt)" - command.env = { - "commandBuffer": { - "progress_monitoring": True, - "lastProgressValue": -1, - "lastProgressTime": 0, - }, - "runtime": { - "DebugManager": Mock(write_debug_out=Mock()), - "ScreenManager": Mock(is_screen_change=Mock(return_value=False)), - "CursorManager": Mock(is_cursor_vertical_move=Mock(return_value=False)), - }, - "screen": { - "new_delta": sample, - "new_delta_is_typing": False, - "new_content_text": sample, - "old_cursor": {"x": 0, "y": 0}, - "new_cursor": {"x": 0, "y": 0}, - }, - } - command.play_activity_beep = Mock() - - command.run() - - command.play_activity_beep.assert_called_once_with() - - -@pytest.mark.unit -def test_progress_detector_beeps_for_plain_working_elapsed_status(): - progress_module = _load_progress_module() - command = progress_module.command() - sample = "Working (21s • esc to interrupt)" - command.env = { - "commandBuffer": { - "progress_monitoring": True, - "lastProgressValue": -1, - "lastProgressTime": 0, - }, - "runtime": { - "DebugManager": Mock(write_debug_out=Mock()), - "ScreenManager": Mock(is_screen_change=Mock(return_value=False)), - "CursorManager": Mock(is_cursor_vertical_move=Mock(return_value=False)), - }, - "screen": { - "new_delta": sample, - "new_delta_is_typing": False, - "new_content_text": sample, - "old_cursor": {"x": 0, "y": 0}, - "new_cursor": {"x": 0, "y": 0}, - }, - } - command.play_activity_beep = Mock() - - command.run() - - command.play_activity_beep.assert_called_once_with() - - -@pytest.mark.unit -def test_progress_detector_allows_long_key_value_status_delta(): - progress_module = _load_progress_module() - command = progress_module.command() - sample = ( - "frame=33480 fps=429 q=28.0 size= 195328KiB " - "time=00:23:16.26 bitrate=1146.0kbits/s dup=0 drop=1 " - "speed=17.9x elapsed=0:01:18.01 " - "filter=scale,format output=archive-video-final-render-pass.mkv " - "metadata=preserve-chapters-and-stream-layout" - ) - command.env = { - "commandBuffer": {"progress_monitoring": True}, - "runtime": { - "DebugManager": Mock(write_debug_out=Mock()), - "ScreenManager": Mock(is_screen_change=Mock(return_value=False)), - "CursorManager": Mock(is_cursor_vertical_move=Mock(return_value=False)), - }, - "screen": { - "new_delta": sample, - "new_content_text": sample, - "old_cursor": {"x": 0, "y": 0}, - "new_cursor": {"x": 0, "y": 0}, - }, - } - - assert len(sample) > 200 - assert command.is_real_progress_update() - - -@pytest.mark.unit -def test_progress_detector_beeps_for_ffmpeg_status_line(): - progress_module = _load_progress_module() - command = progress_module.command() - sample = ( - "frame=33480 fps=429 q=28.0 size= 195328KiB " - "time=00:23:16.26 bitrate=1146.0kbits/s dup=0 drop=1 " - "speed=17.9x elapsed=0:01:18.01" - ) - command.env = { - "commandBuffer": { - "progress_monitoring": True, - "lastProgressValue": -1, - "lastProgressTime": 0, - }, - "runtime": { - "DebugManager": Mock(write_debug_out=Mock()), - "ScreenManager": Mock(is_screen_change=Mock(return_value=False)), - "CursorManager": Mock(is_cursor_vertical_move=Mock(return_value=False)), - }, - "screen": { - "new_delta": sample, - "new_delta_is_typing": False, - "new_content_text": sample, - "old_cursor": {"x": 0, "y": 0}, - "new_cursor": {"x": 0, "y": 0}, - }, - } - command.play_activity_beep = Mock() - - command.run() - - command.play_activity_beep.assert_called_once_with() - - -@pytest.mark.unit -def test_progress_detector_ignores_generic_key_value_text(): - progress_module = _load_progress_module() - command = progress_module.command() - sample = "host=server mode=debug user=Username retry=3 status=ready" - command.env = { - "commandBuffer": { - "progress_monitoring": True, - "lastProgressValue": -1, - "lastProgressTime": 0, - }, - "runtime": { - "DebugManager": Mock(write_debug_out=Mock()), - "ScreenManager": Mock(is_screen_change=Mock(return_value=False)), - "CursorManager": Mock(is_cursor_vertical_move=Mock(return_value=False)), - }, - "screen": { - "new_delta": sample, - "new_delta_is_typing": False, - "new_content_text": sample, - "old_cursor": {"x": 0, "y": 0}, - "new_cursor": {"x": 0, "y": 0}, - }, - } - command.play_activity_beep = Mock() - command.play_progress_tone = Mock() - - command.run() - - command.play_activity_beep.assert_not_called() - command.play_progress_tone.assert_not_called() - - -@pytest.mark.unit -def test_progress_detector_beeps_for_pacman_total_in_multiline_delta(): - progress_module = _load_progress_module() - command = progress_module.command() - total_line = ( - "Total ( 3/749) " - "830.7 MiB 4.70 MiB/s 04:05 " - "[#################################################" - "-----------------------------------------------------------------------] 41%" - ) - sample = ( - "package-one 125.0 MiB 2.10 MiB/s 00:12 " - "[####################--------------------] 50%\n" - + total_line - ) - command.env = { - "commandBuffer": { - "progress_monitoring": True, - "lastProgressValue": -1, - "lastProgressTime": 0, - }, - "runtime": { - "DebugManager": Mock(write_debug_out=Mock()), - "ScreenManager": Mock(is_screen_change=Mock(return_value=False)), - "CursorManager": Mock(is_cursor_vertical_move=Mock(return_value=False)), - }, - "screen": { - "new_delta": sample, - "new_delta_is_typing": False, - "new_content_text": sample, - "old_cursor": {"x": 0, "y": 0}, - "new_cursor": {"x": 0, "y": 0}, - }, - } - command.play_progress_tone = Mock() - - command.run() - - command.play_progress_tone.assert_called_once_with(41.0) - assert command.env["commandBuffer"]["lastProgressValue"] == 41.0 - - -@pytest.mark.unit -def test_explicit_progress_delta_ignores_percentage_in_bracketed_prose(): - progress_module = _load_progress_module() - command = progress_module.command() - - assert not command.is_explicit_progress_delta( - "Release notes [issue #749] are 41% complete" - ) - - -@pytest.mark.unit -def test_progress_detector_beeps_for_curl_classic_repaint_with_trailing_text(): - progress_module = _load_progress_module() - command = progress_module.command() - sample = ( - "53 537.8M 53 285.8M 0 0 9.48M 0 00:56 00:30 00:26 9.94M " - "Executing cabextract -q -d /home/Username/.local/wine64/dosdevices/" - "c:/windows/syswow64 -F dx8vb.dll /home/Username/.cache/winetricks/" - "dx8vb/DX81NTger.exe" - ) - command.env = { - "commandBuffer": { - "progress_monitoring": True, - "lastProgressValue": -1, - "lastProgressTime": 0, - }, - "runtime": { - "DebugManager": Mock(write_debug_out=Mock()), - "ScreenManager": Mock(is_screen_change=Mock(return_value=False)), - "CursorManager": Mock(is_cursor_vertical_move=Mock(return_value=False)), - }, - "screen": { - "new_delta": sample, - "new_delta_is_typing": False, - "new_content_text": sample, - "old_cursor": {"x": 0, "y": 0}, - "new_cursor": {"x": 0, "y": 0}, - }, - } - command.play_progress_tone = Mock() - - assert len(sample) > 200 - - command.run() - - command.play_progress_tone.assert_called_once_with(53.0) - assert command.env["commandBuffer"]["lastProgressValue"] == 53.0 - - -@pytest.mark.unit -def test_progress_detector_ignores_curl_like_fields_with_mismatched_percentages(): - progress_module = _load_progress_module() - command = progress_module.command() - sample = ( - "53 537.8M 52 285.8M 0 0 9.48M 0 00:56 00:30 00:26 9.94M " - "ordinary numeric report with enough trailing text to cross the long " - "delta filter without representing a coherent download progress row " - "or providing trustworthy percentage information" - ) - command.env = { - "commandBuffer": { - "progress_monitoring": True, - "lastProgressValue": -1, - "lastProgressTime": 0, - }, - "runtime": { - "DebugManager": Mock(write_debug_out=Mock()), - "ScreenManager": Mock(is_screen_change=Mock(return_value=False)), - "CursorManager": Mock(is_cursor_vertical_move=Mock(return_value=False)), - }, - "screen": { - "new_delta": sample, - "new_delta_is_typing": False, - "new_content_text": sample, - "old_cursor": {"x": 0, "y": 0}, - "new_cursor": {"x": 0, "y": 0}, - }, - } - command.play_activity_beep = Mock() - command.play_progress_tone = Mock() - - assert len(sample) > 200 - - command.run() - - command.play_activity_beep.assert_not_called() - command.play_progress_tone.assert_not_called() diff --git a/tests/unit/test_pty_terminal_sequences.py b/tests/unit/test_pty_terminal_sequences.py deleted file mode 100644 index 435a2bf2..00000000 --- a/tests/unit/test_pty_terminal_sequences.py +++ /dev/null @@ -1,450 +0,0 @@ -import threading -import time -from unittest.mock import Mock - -import pytest - -from fenrirscreenreader.core.eventData import FenrirEventType -from fenrirscreenreader.screenDriver.ptyDriver import PTYConstants -from fenrirscreenreader.screenDriver.ptyDriver import Terminal -from fenrirscreenreader.screenDriver.ptyDriver import driver as PtyDriver - - -class DummyProcessInput: - def __init__(self): - self.data = [] - - def write(self, data): - self.data.append(data) - - -@pytest.mark.unit -def test_csi_sequences_with_intermediate_characters_do_not_render_final_byte(): - terminal = Terminal(10, 3, DummyProcessInput()) - - terminal.feed(b"\x1b[2026$p\x1b[2048$pX") - screen = terminal.get_screen_content() - - assert screen["text"].splitlines()[0] == "X " - assert "p" not in screen["text"] - - -@pytest.mark.unit -def test_private_sgr_sequence_from_fullscreen_apps_does_not_crash(): - terminal = Terminal(10, 3, DummyProcessInput()) - - terminal.feed(b"\x1b[?25;7mX") - screen = terminal.get_screen_content() - - assert screen["text"].splitlines()[0] == "X " - - -@pytest.mark.unit -def test_overwritten_wide_character_stub_renders_as_space(): - terminal = Terminal(4, 1, DummyProcessInput()) - - terminal.feed("ē•Œ\rX".encode()) - screen = terminal.get_screen_content() - - assert screen["text"] == "X " - - -@pytest.mark.unit -def test_wide_character_and_stub_keep_terminal_width(): - terminal = Terminal(4, 1, DummyProcessInput()) - - terminal.feed("ē•Œ".encode()) - screen = terminal.get_screen_content() - - assert screen["text"] == "ē•Œ " - - -@pytest.mark.unit -def test_optional_float_setting_uses_default_when_missing(): - settings_manager = type( - "SettingsManager", - (), - {"get_setting": lambda self, section, setting: ""}, - )() - pty_driver = PtyDriver() - - assert ( - pty_driver._get_optional_float_setting( - settings_manager, - "screen", - "ptyOutputTimeout", - PTYConstants.OUTPUT_READ_TIMEOUT, - ) - == PTYConstants.OUTPUT_READ_TIMEOUT - ) - - -@pytest.mark.unit -def test_pty_stdin_input_interrupts_output_when_all_keys_interrupt_enabled(): - pty_driver = PtyDriver() - settings_manager = Mock() - settings_manager.get_setting_as_bool.return_value = True - settings_manager.get_setting.return_value = "" - output_manager = Mock() - pty_driver.env = { - "runtime": { - "SettingsManager": settings_manager, - "OutputManager": output_manager, - "DebugManager": Mock(write_debug_out=Mock()), - } - } - - pty_driver.interrupt_output_on_stdin_input(b"a") - pty_driver.stdin_interrupt_thread.join(timeout=1.0) - - output_manager.interrupt_output.assert_called_once_with() - - -@pytest.mark.unit -def test_pty_stdin_input_interrupt_does_not_block_input_injection(): - pty_driver = PtyDriver() - settings_manager = Mock() - settings_manager.get_setting_as_bool.return_value = True - settings_manager.get_setting.return_value = "" - interrupt_started = threading.Event() - release_interrupt = threading.Event() - - def slow_interrupt(): - interrupt_started.set() - release_interrupt.wait(timeout=1.0) - - output_manager = Mock(interrupt_output=Mock(side_effect=slow_interrupt)) - pty_driver.env = { - "input": {"curr_input": []}, - "runtime": { - "SettingsManager": settings_manager, - "OutputManager": output_manager, - "DebugManager": Mock(write_debug_out=Mock()), - }, - } - pty_driver.inject_text_to_screen = Mock() - - start_time = time.monotonic() - pty_driver.handle_stdin_input(b"a", Mock()) - elapsed = time.monotonic() - start_time - - try: - assert interrupt_started.wait(timeout=0.2) - assert elapsed < 0.2 - pty_driver.inject_text_to_screen.assert_called_once_with(b"a") - finally: - release_interrupt.set() - pty_driver.stdin_interrupt_thread.join(timeout=1.0) - - -@pytest.mark.unit -def test_pty_raw_tab_records_recent_tab_keypress(): - pty_driver = PtyDriver() - settings_manager = Mock() - settings_manager.get_setting_as_bool.return_value = False - input_manager = Mock() - pty_driver.env = { - "input": {"curr_input": []}, - "runtime": { - "DebugManager": Mock(write_debug_out=Mock()), - "InputManager": input_manager, - "SettingsManager": settings_manager, - }, - } - pty_driver.inject_text_to_screen = Mock() - - pty_driver.handle_stdin_input(b"\t", Mock()) - - input_manager.record_unmanaged_keypress.assert_called_once_with("KEY_TAB") - pty_driver.inject_text_to_screen.assert_called_once_with(b"\t") - - -@pytest.mark.unit -@pytest.mark.parametrize( - ("sequence", "key_name"), - [ - (b"a", "KEY_A"), - (b"\x7f", "KEY_BACKSPACE"), - (b"\x1b[D", "KEY_LEFT"), - (b"\x1bOD", "KEY_LEFT"), - (b"\x1b[C", "KEY_RIGHT"), - (b"\x1bOC", "KEY_RIGHT"), - (b"\x1b[A", "KEY_UP"), - (b"\x1bOA", "KEY_UP"), - (b"\x1b[B", "KEY_DOWN"), - (b"\x1bOB", "KEY_DOWN"), - ], -) -def test_pty_forwarded_stdin_records_recent_terminal_input(sequence, key_name): - pty_driver = PtyDriver() - settings_manager = Mock() - settings_manager.get_setting_as_bool.return_value = False - input_manager = Mock() - pty_driver.env = { - "input": {"curr_input": []}, - "runtime": { - "DebugManager": Mock(write_debug_out=Mock()), - "InputManager": input_manager, - "SettingsManager": settings_manager, - }, - } - pty_driver.inject_text_to_screen = Mock() - - pty_driver.handle_stdin_input(sequence, Mock()) - - input_manager.record_unmanaged_keypress.assert_called_once_with(key_name) - pty_driver.inject_text_to_screen.assert_called_once_with(sequence) - - -@pytest.mark.unit -@pytest.mark.parametrize( - ("sequence", "key_name"), - [ - (b"\x1b[A", "KEY_UP"), - (b"\x1b[B", "KEY_DOWN"), - (b"\x1b[C", "KEY_RIGHT"), - (b"\x1b[D", "KEY_LEFT"), - (b"\x1b[5~", "KEY_PAGEUP"), - (b"\x1b[6~", "KEY_PAGEDOWN"), - (b"\x1b", "KEY_ESC"), - (b"\r", "KEY_ENTER"), - (b" ", "KEY_SPACE"), - (b"a", "KEY_A"), - (b"Z", "KEY_Z"), - ], -) -def test_pty_vmenu_stdin_is_consumed_and_synthesizes_key_events( - sequence, - key_name, -): - pty_driver = PtyDriver() - event_queue = Mock() - settings_manager = Mock() - settings_manager.get_setting_as_bool.return_value = False - pty_driver.env = { - "input": {"curr_input": []}, - "runtime": { - "DebugManager": Mock(write_debug_out=Mock()), - "SettingsManager": settings_manager, - "VmenuManager": Mock(get_active=Mock(return_value=True)), - }, - } - pty_driver.inject_text_to_screen = Mock() - - pty_driver.handle_stdin_input(sequence, event_queue) - - pty_driver.inject_text_to_screen.assert_not_called() - assert event_queue.put.call_count == 2 - first_event = event_queue.put.call_args_list[0].args[0] - second_event = event_queue.put.call_args_list[1].args[0] - assert first_event["Type"] == FenrirEventType.keyboard_input - assert first_event["data"]["event_name"] == key_name - assert first_event["data"]["event_state"] == 1 - assert second_event["data"]["event_name"] == key_name - assert second_event["data"]["event_state"] == 0 - - -@pytest.mark.unit -def test_pty_vmenu_unknown_stdin_is_consumed_without_injection(): - pty_driver = PtyDriver() - event_queue = Mock() - pty_driver.env = { - "input": {"curr_input": []}, - "runtime": { - "VmenuManager": Mock(get_active=Mock(return_value=True)), - }, - } - pty_driver.inject_text_to_screen = Mock() - - pty_driver.handle_stdin_input(b"\x1b[1;5A", event_queue) - - pty_driver.inject_text_to_screen.assert_not_called() - event_queue.put.assert_not_called() - - -@pytest.mark.unit -def test_pty_vmenu_stdin_does_not_duplicate_current_x11_key(): - pty_driver = PtyDriver() - event_queue = Mock() - pty_driver.env = { - "input": {"curr_input": ["KEY_RIGHT"]}, - "runtime": { - "VmenuManager": Mock(get_active=Mock(return_value=True)), - }, - } - pty_driver.inject_text_to_screen = Mock() - - pty_driver.handle_stdin_input(b"\x1b[C", event_queue) - - pty_driver.inject_text_to_screen.assert_not_called() - event_queue.put.assert_not_called() - - -@pytest.mark.unit -def test_pty_stdin_input_honors_interrupt_disabled(): - pty_driver = PtyDriver() - settings_manager = Mock() - settings_manager.get_setting_as_bool.return_value = False - output_manager = Mock() - pty_driver.env = { - "runtime": { - "SettingsManager": settings_manager, - "OutputManager": output_manager, - "DebugManager": Mock(write_debug_out=Mock()), - } - } - - pty_driver.interrupt_output_on_stdin_input(b"a") - - output_manager.interrupt_output.assert_not_called() - - -@pytest.mark.unit -def test_pty_stdin_input_leaves_filtered_interrupts_to_key_events(): - pty_driver = PtyDriver() - settings_manager = Mock() - settings_manager.get_setting_as_bool.return_value = True - settings_manager.get_setting.return_value = "KEY_ENTER" - output_manager = Mock() - pty_driver.env = { - "runtime": { - "SettingsManager": settings_manager, - "OutputManager": output_manager, - "DebugManager": Mock(write_debug_out=Mock()), - } - } - - pty_driver.interrupt_output_on_stdin_input(b"a") - - output_manager.interrupt_output.assert_not_called() - - -@pytest.mark.unit -def test_pty_recent_review_escape_tail_is_consumed_without_interrupt_or_injection(): - pty_driver = PtyDriver() - event_queue = Mock() - settings_manager = Mock() - settings_manager.get_setting_as_bool.return_value = True - settings_manager.get_setting.return_value = "" - output_manager = Mock() - pty_driver.env = { - "commandInfo": { - "lastCommand": "REVIEW_PREV_LINE", - "lastCommandSection": "commands", - "lastCommandRunTime": time.time(), - }, - "input": {"curr_input": []}, - "runtime": { - "DebugManager": Mock(write_debug_out=Mock()), - "OutputManager": output_manager, - "SettingsManager": settings_manager, - }, - } - pty_driver.inject_text_to_screen = Mock() - - pty_driver.handle_stdin_input(b"\x1b[7~", event_queue) - - output_manager.interrupt_output.assert_not_called() - pty_driver.inject_text_to_screen.assert_not_called() - - -@pytest.mark.unit -def test_pty_recent_review_does_not_consume_plain_text_input(): - pty_driver = PtyDriver() - event_queue = Mock() - settings_manager = Mock() - settings_manager.get_setting_as_bool.return_value = False - pty_driver.env = { - "commandInfo": { - "lastCommand": "REVIEW_PREV_LINE", - "lastCommandSection": "commands", - "lastCommandRunTime": time.time(), - }, - "input": {"curr_input": []}, - "runtime": { - "DebugManager": Mock(write_debug_out=Mock()), - "SettingsManager": settings_manager, - }, - } - pty_driver.inject_text_to_screen = Mock() - - pty_driver.handle_stdin_input(b"a", event_queue) - - pty_driver.inject_text_to_screen.assert_called_once_with(b"a") - - -@pytest.mark.unit -def test_pty_backspace_with_fenrir_key_synthesizes_shortcut_events(): - pty_driver = PtyDriver() - event_queue = Mock() - pty_driver.env = { - "input": {"curr_input": ["KEY_FENRIR"]}, - } - - handled = pty_driver.synthesize_backspace_shortcut(b"\x7f", event_queue) - - assert handled is True - assert event_queue.put.call_count == 2 - first_event = event_queue.put.call_args_list[0].args[0] - second_event = event_queue.put.call_args_list[1].args[0] - assert first_event["Type"] == FenrirEventType.keyboard_input - assert first_event["data"]["event_name"] == "KEY_BACKSPACE" - assert first_event["data"]["event_state"] == 1 - assert second_event["data"]["event_state"] == 0 - - -@pytest.mark.unit -@pytest.mark.parametrize( - "sequence", - [ - b"\x08", - b"\x1b[3~", - b"\x1b[3;5~", - b"\x1b[27;5;8~", - b"\x1b[27;5;127~", - b"\x1b[8;5u", - b"\x1b[127;5u", - ], -) -def test_pty_xterm_backspace_variants_with_fenrir_key_synthesize_shortcut_events( - sequence, -): - pty_driver = PtyDriver() - event_queue = Mock() - pty_driver.env = { - "input": {"curr_input": ["KEY_FENRIR"]}, - } - - handled = pty_driver.synthesize_backspace_shortcut(sequence, event_queue) - - assert handled is True - first_event = event_queue.put.call_args_list[0].args[0] - assert first_event["data"]["event_name"] == "KEY_BACKSPACE" - - -@pytest.mark.unit -def test_pty_plain_backspace_is_not_synthesized(): - pty_driver = PtyDriver() - event_queue = Mock() - pty_driver.env = { - "input": {"curr_input": []}, - } - - handled = pty_driver.synthesize_backspace_shortcut(b"\x7f", event_queue) - - assert handled is False - event_queue.put.assert_not_called() - - -@pytest.mark.unit -def test_pty_plain_delete_sequence_is_not_synthesized(): - pty_driver = PtyDriver() - event_queue = Mock() - pty_driver.env = { - "input": {"curr_input": []}, - } - - handled = pty_driver.synthesize_backspace_shortcut(b"\x1b[3~", event_queue) - - assert handled is False - event_queue.put.assert_not_called() diff --git a/tests/unit/test_remote_instance_registry.py b/tests/unit/test_remote_instance_registry.py deleted file mode 100644 index 1b9aec28..00000000 --- a/tests/unit/test_remote_instance_registry.py +++ /dev/null @@ -1,50 +0,0 @@ -import json -import stat -import time - -from fenrirscreenreader.core import remoteInstanceRegistry - - -def test_write_instance_creates_shared_sticky_registry_directory( - tmp_path, monkeypatch -): - registry_dir = tmp_path / "instances" - monkeypatch.setattr( - remoteInstanceRegistry, "get_registry_dir", lambda: str(registry_dir) - ) - - remoteInstanceRegistry.write_instance({"pid": 456}) - - mode = stat.S_IMODE(registry_dir.stat().st_mode) - assert mode == 0o1777 - - -def test_write_instance_prunes_stale_registry_files(tmp_path, monkeypatch): - monkeypatch.setattr( - remoteInstanceRegistry, "get_registry_dir", lambda: str(tmp_path) - ) - monkeypatch.setattr( - remoteInstanceRegistry, - "process_exists", - lambda pid: pid == 456, - ) - - stale_instance = tmp_path / "123.json" - stale_instance.write_text( - json.dumps( - { - "pid": 123, - "updated_at": time.time(), - } - ) - + "\n", - encoding="utf-8", - ) - invalid_instance = tmp_path / "invalid.json" - invalid_instance.write_text("not json\n", encoding="utf-8") - - remoteInstanceRegistry.write_instance({"pid": 456}) - - assert not stale_instance.exists() - assert not invalid_instance.exists() - assert (tmp_path / "456.json").exists() diff --git a/tests/unit/test_review_screen_boundary_sounds.py b/tests/unit/test_review_screen_boundary_sounds.py deleted file mode 100644 index a64a6ac3..00000000 --- a/tests/unit/test_review_screen_boundary_sounds.py +++ /dev/null @@ -1,126 +0,0 @@ -import importlib.util -from pathlib import Path -from unittest.mock import Mock - -import pytest - -from fenrirscreenreader.utils import char_utils - - -COMMANDS_DIR = ( - Path(__file__).resolve().parents[2] - / "src" - / "fenrirscreenreader" - / "commands" - / "commands" -) - - -def load_command(name): - spec = importlib.util.spec_from_file_location( - f"fenrir_{name}", COMMANDS_DIR / f"{name}.py" - ) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module.command() - - -def build_environment(cursor): - settings_manager = Mock() - settings_manager.get_setting_as_bool.return_value = True - output_manager = Mock() - cursor_manager = Mock() - cursor_manager.enter_review_mode_curr_text_cursor.return_value = None - cursor_manager.get_review_or_text_cursor.return_value = cursor - - return { - "punctuation": {"PUNCTDICT": {" ": "space"}}, - "screen": { - "newCursorReview": cursor.copy(), - "new_cursor": cursor.copy(), - "new_content_text": "abc\ndef", - }, - "runtime": { - "AttributeManager": Mock(has_attributes=Mock(return_value=False)), - "CursorManager": cursor_manager, - "OutputManager": output_manager, - "SettingsManager": settings_manager, - "TableManager": Mock(is_table_mode=Mock(return_value=False)), - }, - } - - -def run_command(name, cursor): - env = build_environment(cursor) - command = load_command(name) - command.initialize(env) - command.run() - return env["runtime"]["OutputManager"] - - -def boundary_call(output_manager): - return output_manager.present_text.call_args_list[-1] - - -@pytest.mark.unit -def test_previous_line_uses_start_of_screen_sound_at_top(): - output_manager = run_command("review_prev_line", {"x": 0, "y": 0}) - - call = boundary_call(output_manager) - assert call.args[0] == "start of screen" - assert call.kwargs["sound_icon"] == "StartOfScreen" - - -@pytest.mark.unit -def test_next_line_uses_end_of_screen_sound_at_bottom(): - output_manager = run_command("review_next_line", {"x": 0, "y": 1}) - - call = boundary_call(output_manager) - assert call.args[0] == "end of screen" - assert call.kwargs["sound_icon"] == "EndOfScreen" - - -@pytest.mark.unit -def test_previous_character_uses_start_of_screen_sound_at_top_left(): - output_manager = run_command("review_prev_char", {"x": 0, "y": 0}) - - call = boundary_call(output_manager) - assert call.args[0] == "start of screen" - assert call.kwargs["sound_icon"] == "StartOfScreen" - - -@pytest.mark.unit -def test_next_character_uses_end_of_screen_sound_at_bottom_right(): - output_manager = run_command("review_next_char", {"x": 2, "y": 1}) - - call = boundary_call(output_manager) - assert call.args[0] == "end of screen" - assert call.kwargs["sound_icon"] == "EndOfScreen" - - -@pytest.mark.unit -def test_vertical_character_navigation_reports_boundaries_only_at_edges(): - assert char_utils.get_up_char(0, 1, "abc\ndef") == ( - 0, - 0, - "a", - False, - ) - assert char_utils.get_up_char(0, 0, "abc\ndef") == ( - 0, - 0, - "", - True, - ) - assert char_utils.get_down_char(0, 0, "abc\ndef") == ( - 0, - 1, - "d", - False, - ) - assert char_utils.get_down_char(0, 1, "abc\ndef") == ( - 0, - 1, - "", - True, - ) diff --git a/tests/unit/test_review_spelling_commands.py b/tests/unit/test_review_spelling_commands.py deleted file mode 100644 index 67faeaf3..00000000 --- a/tests/unit/test_review_spelling_commands.py +++ /dev/null @@ -1,155 +0,0 @@ -import importlib.util -from pathlib import Path -from unittest.mock import Mock - -import pytest - -from fenrirscreenreader.core.inputManager import InputManager - - -PROJECT_ROOT = Path(__file__).resolve().parents[2] -COMMANDS_DIR = ( - PROJECT_ROOT / "src" / "fenrirscreenreader" / "commands" / "commands" -) -KEYBOARD_DIR = PROJECT_ROOT / "config" / "keyboard" - - -def load_command(name): - spec = importlib.util.spec_from_file_location( - f"fenrir_{name}", COMMANDS_DIR / f"{name}.py" - ) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module.command() - - -def run_command(name, content, cursor_x=0, cursor_y=0): - output_manager = Mock() - settings_manager = Mock() - settings_manager.get_setting_as_bool.return_value = True - env = { - "punctuation": {"PUNCTDICT": {" ": "space"}}, - "screen": { - "newCursorReview": {"x": cursor_x, "y": cursor_y}, - "new_content_text": content, - }, - "runtime": { - "CursorManager": Mock(), - "OutputManager": output_manager, - "SettingsManager": settings_manager, - }, - } - command = load_command(name) - command.initialize(env) - command.run() - return output_manager - - -def load_bindings(layout): - manager = InputManager() - manager.env = { - "bindings": {}, - "rawBindings": {}, - "runtime": {"DebugManager": Mock()}, - } - manager.load_shortcuts(KEYBOARD_DIR / f"{layout}.conf") - return manager.env["bindings"] - - -@pytest.mark.unit -def test_current_word_is_spelled_one_character_at_a_time(): - output_manager = run_command( - "review_curr_word_spell", "hello world", cursor_x=2 - ) - - calls = output_manager.present_text.call_args_list - assert [call.args[0] for call in calls] == list("hello") - assert calls[0].kwargs == { - "interrupt": True, - "ignore_punctuation": True, - "announce_capital": True, - "flush": False, - } - assert all(call.kwargs["interrupt"] is False for call in calls[1:]) - - -@pytest.mark.unit -def test_current_word_spelling_preserves_capitals_and_punctuation(): - output_manager = run_command("review_curr_word_spell", "Hi!", cursor_x=1) - - assert [ - call.args[0] for call in output_manager.present_text.call_args_list - ] == ["H", "i", "!"] - - -@pytest.mark.unit -@pytest.mark.parametrize( - "command_name", - ["review_curr_word_spell", "review_curr_word_phonetic"], -) -def test_current_word_spelling_announces_blank_when_no_word_exists( - command_name, -): - output_manager = run_command(command_name, " ") - - output_manager.present_text.assert_called_once_with( - "blank", interrupt=True, flush=False - ) - - -@pytest.mark.unit -def test_current_word_phonetic_spelling_uses_phonetic_names(): - output_manager = run_command( - "review_curr_word_phonetic", "Az!", cursor_x=1 - ) - - calls = output_manager.present_text.call_args_list - assert [call.args[0] for call in calls] == ["Alpha", "zulu", "!"] - assert calls[0].kwargs["interrupt"] is True - assert all(call.kwargs["interrupt"] is False for call in calls[1:]) - - -@pytest.mark.unit -@pytest.mark.parametrize( - ("layout", "character_keys", "word_keys"), - [ - ("desktop", ["KEY_KP2"], ["KEY_KP5"]), - ("laptop", ["KEY_COMMA", "KEY_FENRIR"], ["KEY_FENRIR", "KEY_K"]), - ], -) -def test_progressive_review_bindings(layout, character_keys, word_keys): - bindings = load_bindings(layout) - - assert bindings[str([1, character_keys])] == "REVIEW_CURR_CHAR" - assert bindings[str([2, character_keys])] == "REVIEW_CURR_CHAR_PHONETIC" - assert bindings[str([1, word_keys])] == "REVIEW_CURR_WORD" - assert bindings[str([2, word_keys])] == "REVIEW_CURR_WORD_SPELL" - assert bindings[str([3, word_keys])] == "REVIEW_CURR_WORD_PHONETIC" - - -@pytest.mark.unit -@pytest.mark.parametrize("layout", ["desktop", "laptop"]) -def test_layouts_do_not_bind_removed_keyboard_layout_cycle(layout): - bindings = load_bindings(layout) - - assert "CYCLE_KEYBOARD_LAYOUT" not in bindings.values() - - removed_commands = { - "REVIEW_PREV_CHAR_PHONETIC", - "REVIEW_NEXT_CHAR_PHONETIC", - "REVIEW_PREV_WORD_PHONETIC", - "REVIEW_NEXT_WORD_PHONETIC", - } - assert removed_commands.isdisjoint(bindings.values()) - - -@pytest.mark.unit -def test_obsolete_phonetic_navigation_commands_are_removed(): - removed_commands = [ - "review_prev_char_phonetic.py", - "review_next_char_phonetic.py", - "review_prev_word_phonetic.py", - "review_next_word_phonetic.py", - ] - - assert all(not (COMMANDS_DIR / name).exists() for name in removed_commands) diff --git a/tests/unit/test_screen_manager_typing.py b/tests/unit/test_screen_manager_typing.py deleted file mode 100644 index e11edc05..00000000 --- a/tests/unit/test_screen_manager_typing.py +++ /dev/null @@ -1,197 +0,0 @@ -import time -from unittest.mock import Mock - -import pytest - -from fenrirscreenreader.core.screenManager import ScreenManager - - -def _build_screen_manager(old_text, old_cursor): - manager = ScreenManager() - env = { - "runtime": { - "AttributeManager": Mock( - reset_attributes=Mock(), - reset_attribute_cursor=Mock(), - set_attributes=Mock(), - reset_attribute_delta=Mock(), - is_attribute_change=Mock(return_value=False), - ), - "CursorManager": Mock(is_application_window_set=Mock(return_value=False)), - "DebugManager": Mock(write_debug_out=Mock()), - "InputManager": Mock(get_last_input_time=Mock(return_value=0)), - "ScreenManager": manager, - "SettingsManager": Mock(get_setting_as_bool=Mock(return_value=False)), - }, - "screen": { - "newContentBytes": b"", - "oldContentBytes": b"", - "new_content_text": old_text, - "old_content_text": "", - "new_cursor": old_cursor.copy(), - "old_cursor": {"x": 0, "y": 0}, - "new_delta": "", - "new_delta_is_typing": False, - "oldDelta": "", - "newNegativeDelta": "", - "oldNegativeDelta": "", - "oldTTY": "pty", - "newTTY": "pty", - }, - } - manager.initialize = Mock() - manager.env = env - return manager, env - - -@pytest.mark.unit -def test_prompt_repaint_during_typing_keeps_only_typed_delta(): - manager, env = _build_screen_manager("[0] ", {"x": 4, "y": 0}) - - manager.update( - { - "bytes": b"[1] h", - "lines": 1, - "columns": 20, - "textCursor": {"x": 5, "y": 0}, - "screen": "pty", - "text": "[1] h", - "attributes": [], - }, - "onScreenUpdate", - ) - - assert env["screen"]["new_delta"] == "h" - assert env["screen"]["new_delta_is_typing"] is True - - -@pytest.mark.unit -def test_tui_input_line_append_is_typing_delta(): - manager, env = _build_screen_manager( - "\n".join( - [ - " hi".ljust(20), - "[Username] ".ljust(20), - ] - ), - {"x": 11, "y": 1}, - ) - env["runtime"]["InputManager"].get_last_input_time.return_value = time.time() - - manager.update( - { - "bytes": b"", - "lines": 2, - "columns": 20, - "textCursor": {"x": 12, "y": 1}, - "screen": "pty", - "text": "\n".join( - [ - " hi".ljust(20), - "[Username] l".ljust(20), - ] - ), - "attributes": [], - }, - "onScreenUpdate", - ) - - assert env["screen"]["new_delta"] == "l" - assert env["screen"]["new_delta_is_typing"] is True - - -@pytest.mark.unit -def test_tui_input_line_cursor_jump_keeps_only_typed_delta(): - manager, env = _build_screen_manager( - "[Username] ".ljust(30), - {"x": 0, "y": 0}, - ) - - manager.update( - { - "bytes": b"", - "lines": 1, - "columns": 30, - "textCursor": {"x": 12, "y": 0}, - "screen": "pty", - "text": "[Username] l".ljust(30), - "attributes": [], - }, - "onScreenUpdate", - ) - - assert env["screen"]["new_delta"] == "l" - assert env["screen"]["new_delta_is_typing"] is True - - -@pytest.mark.unit -def test_tui_input_line_insert_with_channel_prefix_is_typing_delta(): - manager, env = _build_screen_manager( - "\n".join( - [ - " hi".ljust(40), - "[#channel] [Username] | 12:00".ljust(40), - ] - ), - {"x": 22, "y": 1}, - ) - env["runtime"]["InputManager"].get_last_input_time.return_value = time.time() - - manager.update( - { - "bytes": b"", - "lines": 2, - "columns": 40, - "textCursor": {"x": 23, "y": 1}, - "screen": "pty", - "text": "\n".join( - [ - " hi".ljust(40), - "[#channel] [Username] l | 12:00".ljust(40), - ] - ), - "attributes": [], - }, - "onScreenUpdate", - ) - - assert env["screen"]["new_delta"] == "l" - assert env["screen"]["new_delta_is_typing"] is True - - -@pytest.mark.unit -def test_tui_input_line_typing_is_filtered_from_mixed_repaint_delta(): - manager, env = _build_screen_manager( - "\n".join( - [ - " hi".ljust(40), - "[#channel] [Username] | 12:00".ljust(40), - "status 12:00".ljust(40), - ] - ), - {"x": 22, "y": 1}, - ) - env["runtime"]["InputManager"].get_last_input_time.return_value = time.time() - - manager.update( - { - "bytes": b"", - "lines": 3, - "columns": 40, - "textCursor": {"x": 23, "y": 1}, - "screen": "pty", - "text": "\n".join( - [ - " hi".ljust(40), - "[#channel] [Username] l | 12:00".ljust(40), - "status 12:01".ljust(40), - ] - ), - "attributes": [], - }, - "onScreenUpdate", - ) - - assert "Username" not in env["screen"]["new_delta"] - assert "#channel" not in env["screen"]["new_delta"] - assert env["screen"]["new_delta_is_typing"] is False diff --git a/tests/unit/test_settings_validation.py b/tests/unit/test_settings_validation.py deleted file mode 100644 index a7a64c4e..00000000 --- a/tests/unit/test_settings_validation.py +++ /dev/null @@ -1,325 +0,0 @@ -""" -Unit tests for settings validation in SettingsManager. - -Tests the _validate_setting_value method to ensure proper input validation -for all configurable settings that could cause crashes or accessibility issues. -""" - -from argparse import Namespace - -import pytest -import sys -from pathlib import Path -from unittest.mock import Mock - -# Import the settings manager -from fenrirscreenreader.core.settingsData import settings_data -from fenrirscreenreader.core.settingsManager import SettingsManager -from fenrirscreenreader.commands.commands import save_settings - - -@pytest.mark.unit -@pytest.mark.settings -class TestSpeechSettingsValidation: - """Test validation of speech-related settings.""" - - def setup_method(self): - """Create a SettingsManager instance for each test.""" - self.manager = SettingsManager() - - def test_speech_rate_valid_range(self): - """Speech rate should accept values between 0.0 and 3.0.""" - # Valid boundary values - self.manager._validate_setting_value("speech", "rate", 0.0) - self.manager._validate_setting_value("speech", "rate", 1.5) - self.manager._validate_setting_value("speech", "rate", 3.0) - - def test_speech_rate_rejects_negative(self): - """Speech rate should reject negative values.""" - with pytest.raises(ValueError, match="must be between 0.0 and 3.0"): - self.manager._validate_setting_value("speech", "rate", -0.1) - - def test_speech_rate_rejects_too_high(self): - """Speech rate should reject values above 3.0.""" - with pytest.raises(ValueError, match="must be between 0.0 and 3.0"): - self.manager._validate_setting_value("speech", "rate", 10.0) - - def test_speech_pitch_valid_range(self): - """Speech pitch should accept values between 0.0 and 2.0.""" - self.manager._validate_setting_value("speech", "pitch", 0.0) - self.manager._validate_setting_value("speech", "pitch", 1.0) - self.manager._validate_setting_value("speech", "pitch", 2.0) - - def test_speech_pitch_rejects_invalid(self): - """Speech pitch should reject out-of-range values.""" - with pytest.raises(ValueError, match="must be between 0.0 and 2.0"): - self.manager._validate_setting_value("speech", "pitch", -1.0) - with pytest.raises(ValueError, match="must be between 0.0 and 2.0"): - self.manager._validate_setting_value("speech", "pitch", 5.0) - - def test_speech_volume_valid_range(self): - """Speech volume should accept values between 0.0 and 1.5.""" - self.manager._validate_setting_value("speech", "volume", 0.0) - self.manager._validate_setting_value("speech", "volume", 1.0) - self.manager._validate_setting_value("speech", "volume", 1.5) - - def test_speech_volume_rejects_negative(self): - """Speech volume should reject negative values.""" - with pytest.raises(ValueError, match="must be between 0.0 and 1.5"): - self.manager._validate_setting_value("speech", "volume", -0.5) - - def test_speech_driver_whitelisted(self): - """Speech driver should only accept whitelisted values.""" - # Valid drivers - self.manager._validate_setting_value("speech", "driver", "speechdDriver") - self.manager._validate_setting_value("speech", "driver", "genericDriver") - self.manager._validate_setting_value("speech", "driver", "dectalkDriver") - self.manager._validate_setting_value( - "speech", "driver", "dectalkUsbDriver" - ) - self.manager._validate_setting_value("speech", "driver", "doubletalkDriver") - self.manager._validate_setting_value("speech", "driver", "litetalkDriver") - self.manager._validate_setting_value("speech", "driver", "tripletalkDriver") - self.manager._validate_setting_value("speech", "driver", "dummyDriver") - - # Invalid driver - with pytest.raises(ValueError, match="Invalid speech driver"): - self.manager._validate_setting_value( - "speech", "driver", "nonexistentDriver" - ) - - -@pytest.mark.unit -@pytest.mark.settings -class TestSoundSettingsValidation: - """Test validation of sound-related settings.""" - - def setup_method(self): - """Create a SettingsManager instance for each test.""" - self.manager = SettingsManager() - - def test_sound_volume_valid_range(self): - """Sound volume should accept values between 0.0 and 1.5.""" - self.manager._validate_setting_value("sound", "volume", 0.0) - self.manager._validate_setting_value("sound", "volume", 0.7) - self.manager._validate_setting_value("sound", "volume", 1.5) - - def test_sound_volume_rejects_invalid(self): - """Sound volume should reject out-of-range values.""" - with pytest.raises(ValueError, match="must be between 0.0 and 1.5"): - self.manager._validate_setting_value("sound", "volume", -0.1) - with pytest.raises(ValueError, match="must be between 0.0 and 1.5"): - self.manager._validate_setting_value("sound", "volume", 2.0) - - def test_sound_driver_whitelisted(self): - """Sound driver should only accept whitelisted values.""" - # Valid drivers - self.manager._validate_setting_value("sound", "driver", "genericDriver") - self.manager._validate_setting_value("sound", "driver", "gstreamerDriver") - self.manager._validate_setting_value("sound", "driver", "dummyDriver") - - # Invalid driver - with pytest.raises(ValueError, match="Invalid sound driver"): - self.manager._validate_setting_value("sound", "driver", "invalidDriver") - - -@pytest.mark.unit -@pytest.mark.settings -class TestDriverValidation: - """Test validation of driver selection settings.""" - - def setup_method(self): - """Create a SettingsManager instance for each test.""" - self.manager = SettingsManager() - - def test_screen_driver_whitelisted(self): - """Screen driver should only accept whitelisted values.""" - # Valid drivers - self.manager._validate_setting_value("screen", "driver", "vcsaDriver") - self.manager._validate_setting_value("screen", "driver", "ptyDriver") - self.manager._validate_setting_value("screen", "driver", "dummyDriver") - - # Invalid driver - with pytest.raises(ValueError, match="Invalid screen driver"): - self.manager._validate_setting_value("screen", "driver", "unknownDriver") - - def test_keyboard_driver_whitelisted(self): - """Keyboard driver should only accept whitelisted values.""" - # Valid drivers - self.manager._validate_setting_value("keyboard", "driver", "evdevDriver") - self.manager._validate_setting_value("keyboard", "driver", "x11Driver") - self.manager._validate_setting_value("keyboard", "driver", "dummyDriver") - - with pytest.raises(ValueError, match="Invalid input driver"): - self.manager._validate_setting_value("keyboard", "driver", "ptyDriver") - with pytest.raises(ValueError, match="Invalid input driver"): - self.manager._validate_setting_value("keyboard", "driver", "atspiDriver") - - # Invalid driver - with pytest.raises(ValueError, match="Invalid input driver"): - self.manager._validate_setting_value("keyboard", "driver", "badDriver") - - -@pytest.mark.unit -@pytest.mark.settings -class TestGeneralSettingsValidation: - """Test validation of general settings.""" - - def setup_method(self): - """Create a SettingsManager instance for each test.""" - self.manager = SettingsManager() - - def test_debug_level_valid_range(self): - """Debug level should accept values 0-3.""" - self.manager._validate_setting_value("general", "debug_level", 0) - self.manager._validate_setting_value("general", "debug_level", 1) - self.manager._validate_setting_value("general", "debug_level", 2) - self.manager._validate_setting_value("general", "debug_level", 3) - - def test_debug_level_rejects_invalid(self): - """Debug level should reject values outside 0-3.""" - with pytest.raises(ValueError, match="must be between 0 and 3"): - self.manager._validate_setting_value("general", "debug_level", -1) - with pytest.raises(ValueError, match="must be between 0 and 3"): - self.manager._validate_setting_value("general", "debug_level", 10) - - -@pytest.mark.unit -@pytest.mark.settings -class TestValidationSkipsUnknownSettings: - """Test that validation doesn't error on unknown settings.""" - - def setup_method(self): - """Create a SettingsManager instance for each test.""" - self.manager = SettingsManager() - - def test_unknown_section_no_error(self): - """Unknown sections should not raise errors during validation.""" - # Should not raise - validation only applies to known critical settings - self.manager._validate_setting_value("unknown_section", "setting", "value") - - def test_unknown_setting_no_error(self): - """Unknown settings in known sections should not raise errors.""" - # Should not raise - only specific critical settings are validated - self.manager._validate_setting_value("speech", "unknown_setting", "value") - - -@pytest.mark.unit -@pytest.mark.settings -def test_focus_settings_define_tui_toggle(): - """Focus settings should include the TUI toggle used by on-screen handlers.""" - assert settings_data["focus"]["tui"] is False - - -@pytest.mark.unit -@pytest.mark.settings -def test_format_cli_args_reports_startup_flags_in_stable_order(): - manager = SettingsManager() - cli_args = Namespace( - debug=True, - foreground=False, - force_all_screens=False, - ignore_screen=["7"], - options="speech#rate=1.2", - print=False, - setting="/tmp/settings.conf", - x11=True, - x11_window_id="0x123", - ) - - assert manager.format_cli_args(cli_args) == ( - "{'debug': True, 'force_all_screens': False, 'foreground': False, " - "'ignore_screen': ['7'], 'options': 'speech#rate=1.2', " - "'print': False, 'setting': '/tmp/settings.conf', 'x11': True, " - "'x11_window_id': '0x123'}" - ) - - -@pytest.mark.unit -@pytest.mark.settings -class TestSettingsPathSelection: - """Test root/user settings load and save path selection.""" - - def setup_method(self): - self.manager = SettingsManager() - - def configure_paths(self, tmp_path): - system_root = tmp_path / "etc" / "fenrirscreenreader" - system_file = system_root / "settings" / "settings.conf" - user_file = ( - tmp_path - / "home" - / "Username" - / ".local" - / "share" - / "stormux" - / "fenrirscreenreader" - / "settings" - / "settings.conf" - ) - self.manager.system_settings_root = str(system_root) + "/" - self.manager.system_settings_file = str(system_file) - self.manager.user_settings_file = str(user_file) - return system_file, user_file - - def test_non_root_loads_user_settings_when_present( - self, tmp_path, monkeypatch - ): - system_file, user_file = self.configure_paths(tmp_path) - system_file.parent.mkdir(parents=True) - system_file.write_text("[general]\n", encoding="utf-8") - user_file.parent.mkdir(parents=True) - user_file.write_text("[general]\n", encoding="utf-8") - monkeypatch.setattr("os.geteuid", lambda: 1000) - - assert self.manager.resolve_settings_file() == str(user_file) - - def test_non_root_falls_back_to_system_settings( - self, tmp_path, monkeypatch - ): - system_file, _user_file = self.configure_paths(tmp_path) - system_file.parent.mkdir(parents=True) - system_file.write_text("[general]\n", encoding="utf-8") - monkeypatch.setattr("os.geteuid", lambda: 1000) - - assert self.manager.resolve_settings_file() == str(system_file) - - def test_root_uses_system_settings_even_when_user_settings_exists( - self, tmp_path, monkeypatch - ): - system_file, user_file = self.configure_paths(tmp_path) - system_file.parent.mkdir(parents=True) - system_file.write_text("[general]\n", encoding="utf-8") - user_file.parent.mkdir(parents=True) - user_file.write_text("[general]\n", encoding="utf-8") - monkeypatch.setattr("os.geteuid", lambda: 0) - - assert self.manager.resolve_settings_file() == str(system_file) - - def test_save_default_path_follows_effective_user( - self, tmp_path, monkeypatch - ): - system_file, user_file = self.configure_paths(tmp_path) - - monkeypatch.setattr("os.geteuid", lambda: 1000) - assert self.manager.get_default_save_settings_file() == str(user_file) - - monkeypatch.setattr("os.geteuid", lambda: 0) - assert self.manager.get_default_save_settings_file() == str(system_file) - - def test_save_settings_command_uses_default_save_path(self): - manager = Mock() - output_manager = Mock() - command = save_settings.command() - command.initialize( - { - "runtime": { - "SettingsManager": manager, - "OutputManager": output_manager, - } - } - ) - - command.run() - - manager.save_settings.assert_called_once_with() diff --git a/tests/unit/test_sound_drivers.py b/tests/unit/test_sound_drivers.py deleted file mode 100644 index 67d9c72d..00000000 --- a/tests/unit/test_sound_drivers.py +++ /dev/null @@ -1,29 +0,0 @@ -from unittest.mock import Mock - -import pytest - -from fenrirscreenreader.core.soundDriver import sound_driver -from fenrirscreenreader.soundDriver import gstreamerDriver - - -@pytest.mark.unit -def test_base_sound_driver_shutdown_clears_initialized_flag(): - driver = sound_driver() - driver.initialize({}) - - driver.shutdown() - - assert driver._initialized is False - - -@pytest.mark.unit -def test_gstreamer_driver_unavailable_logs_without_crashing(monkeypatch): - monkeypatch.setattr(gstreamerDriver, "_gstreamerAvailable", False) - monkeypatch.setattr(gstreamerDriver, "_availableError", "missing", raising=False) - debug_manager = Mock(write_debug_out=Mock()) - driver = gstreamerDriver.driver() - - driver.initialize({"runtime": {"DebugManager": debug_manager}}) - - debug_manager.write_debug_out.assert_called() - assert driver._initialized is False diff --git a/tests/unit/test_speech_discovery_manager.py b/tests/unit/test_speech_discovery_manager.py deleted file mode 100644 index 38a16fc0..00000000 --- a/tests/unit/test_speech_discovery_manager.py +++ /dev/null @@ -1,116 +0,0 @@ -from unittest.mock import Mock - -from fenrirscreenreader.core.speechDiscoveryManager import ( - SpeechDiscoveryManager, - query_speechd_voices, -) - - -class FakeBackgroundTaskManager: - def __init__(self): - self.tasks = [] - - def submit_task(self, function, callback, *args, **kwargs): - task_id = len(self.tasks) + 1 - self.tasks.append((task_id, function, callback, args, kwargs)) - return task_id - - def cancel_task(self, task_id): - pass - - def complete(self, task_id, value=None, error=""): - _, _function, callback, _args, _kwargs = self.tasks[task_id - 1] - callback( - { - "task_id": task_id, - "succeeded": not error, - "value": value, - "error": error, - } - ) - - -def create_manager(): - task_manager = FakeBackgroundTaskManager() - manager = SpeechDiscoveryManager() - manager.initialize( - { - "runtime": { - "BackgroundTaskManager": task_manager, - "DebugManager": Mock(), - } - } - ) - return manager, task_manager - - -def test_module_requests_share_one_worker_and_cache_main_thread_result(): - manager, task_manager = create_manager() - first_callback = Mock() - second_callback = Mock() - - first_task_id = manager.request_modules(first_callback) - second_task_id = manager.request_modules(second_callback) - - assert first_task_id == second_task_id - assert len(task_manager.tasks) == 1 - - task_manager.complete(first_task_id, ["rhvoice", "espeak-ng"]) - - first_callback.assert_called_once_with(["rhvoice", "espeak-ng"], "") - second_callback.assert_called_once_with(["rhvoice", "espeak-ng"], "") - - cached_callback = Mock() - assert manager.request_modules(cached_callback) is None - cached_callback.assert_called_once_with(["rhvoice", "espeak-ng"], "") - assert len(task_manager.tasks) == 1 - - -def test_voice_requests_are_deduplicated_per_module(): - manager, task_manager = create_manager() - rhvoice_callback = Mock() - repeated_callback = Mock() - espeak_callback = Mock() - - rhvoice_task = manager.request_voices("rhvoice", rhvoice_callback) - repeated_task = manager.request_voices("rhvoice", repeated_callback) - espeak_task = manager.request_voices("espeak-ng", espeak_callback) - - assert rhvoice_task == repeated_task - assert espeak_task != rhvoice_task - assert len(task_manager.tasks) == 2 - - task_manager.complete(rhvoice_task, ["alan", "slt"]) - rhvoice_callback.assert_called_once_with("rhvoice", ["alan", "slt"], "") - repeated_callback.assert_called_once_with( - "rhvoice", ["alan", "slt"], "" - ) - espeak_callback.assert_not_called() - - -def test_failing_discovery_callback_does_not_hide_result_from_others(): - manager, task_manager = create_manager() - failing_callback = Mock(side_effect=RuntimeError("consumer failed")) - second_callback = Mock() - - task_id = manager.request_modules(failing_callback) - manager.request_modules(second_callback) - task_manager.complete(task_id, ["rhvoice"]) - - failing_callback.assert_called_once_with(["rhvoice"], "") - second_callback.assert_called_once_with(["rhvoice"], "") - manager.env["runtime"]["DebugManager"].write_debug_out.assert_called_once() - - -def test_voice_query_preserves_multiword_synthesis_voice_names(monkeypatch): - voice_list = ( - " NAME LANGUAGE VARIANT\n" - " Perfect Paul en-US none\n" - " Big Bob en-US none\n" - ) - monkeypatch.setattr( - "fenrirscreenreader.core.speechDiscoveryManager.subprocess.run", - Mock(return_value=Mock(returncode=0, stdout=voice_list, stderr="")), - ) - - assert query_speechd_voices("doubletalk") == ["Perfect Paul", "Big Bob"] diff --git a/tests/unit/test_speech_history_manager.py b/tests/unit/test_speech_history_manager.py deleted file mode 100644 index 291459b4..00000000 --- a/tests/unit/test_speech_history_manager.py +++ /dev/null @@ -1,175 +0,0 @@ -from unittest.mock import Mock - -import pytest - -from fenrirscreenreader.core.speechHistoryManager import SpeechHistoryManager - - -def build_speech_history_manager(history_size=3): - spoken_messages = [] - output_manager = Mock() - - def _capture_message(message, **_kwargs): - spoken_messages.append(message) - - output_manager.present_text.side_effect = _capture_message - settings_manager = Mock() - settings_manager.get_setting_as_int.return_value = history_size - memory_manager = Mock(add_value_to_first_index=Mock()) - input_manager = Mock(reset_input_state=Mock()) - input_driver = Mock(refresh_grabs=Mock()) - env = { - "runtime": { - "OutputManager": output_manager, - "SettingsManager": settings_manager, - "MemoryManager": memory_manager, - "InputManager": input_manager, - "InputDriver": input_driver, - }, - "bindings": {"original": "COMMAND"}, - "rawBindings": {"original": [1, ["KEY_FENRIR"]]}, - } - manager = SpeechHistoryManager() - manager.initialize(env) - return manager, env, spoken_messages, memory_manager - - -@pytest.mark.unit -def test_speech_history_keeps_configured_number_of_items(): - manager, _env, _spoken_messages, _memory_manager = ( - build_speech_history_manager(history_size=2) - ) - - assert manager.add_text("one") - assert manager.add_text("two") - assert manager.add_text("three") - - assert manager.history == ["three", "two"] - - -@pytest.mark.unit -def test_speech_history_suppresses_exact_duplicates_until_item_drops(): - manager, _env, _spoken_messages, _memory_manager = ( - build_speech_history_manager(history_size=2) - ) - - assert manager.add_text("hello world") - assert not manager.add_text("hello world") - assert manager.add_text("other") - assert manager.add_text("third") - assert manager.add_text("hello world") - - assert manager.history == ["hello world", "third"] - - -@pytest.mark.unit -def test_speech_history_dedupe_keeps_case_and_suppresses_whitespace_variants(): - manager, _env, _spoken_messages, _memory_manager = ( - build_speech_history_manager() - ) - - assert manager.add_text("hello") - assert manager.add_text("Hello") - assert not manager.add_text("hello ") - assert not manager.add_text("hello ") - - assert manager.history == ["Hello", "hello"] - - -@pytest.mark.unit -def test_open_empty_history_announces_empty_without_modal_bindings(): - manager, env, spoken_messages, _memory_manager = ( - build_speech_history_manager() - ) - - assert not manager.open_history() - - assert not manager.is_active() - assert spoken_messages == ["speech history empty"] - assert env["bindings"] == {"original": "COMMAND"} - - -@pytest.mark.unit -def test_open_history_installs_modal_bindings_and_replay_is_not_recorded(): - manager, env, spoken_messages, _memory_manager = ( - build_speech_history_manager() - ) - env["rawBindings"]["ctrl_shut_up"] = [1, ["KEY_CTRL"]] - manager.add_text("first") - manager.add_text("second") - - assert manager.open_history() - manager.add_text("replayed") - - assert manager.is_active() - assert spoken_messages == ["Speech history"] - assert manager.curr_index == -1 - assert manager.history == ["second", "first"] - assert "original" not in env["bindings"] - assert env["rawBindings"]["original"] == [1, ["KEY_FENRIR"]] - assert env["rawBindings"]["ctrl_shut_up"] == [1, ["KEY_CTRL"]] - assert env["bindings"][str([1, ["KEY_UP"]])] == "SPEECH_HISTORY_PREV" - assert env["bindings"][str([1, ["KEY_ENTER"]])] == "SPEECH_HISTORY_COPY" - assert env["bindings"][str([1, ["KEY_ESC"]])] == "SPEECH_HISTORY_CLOSE" - assert env["rawBindings"][str([1, ["KEY_UP"]])] == [1, ["KEY_UP"]] - input_driver = env["runtime"]["InputDriver"] - input_driver.refresh_grabs.assert_called_once_with(force=True) - - -@pytest.mark.unit -def test_navigation_moves_between_newer_and_older_items(): - manager, _env, spoken_messages, _memory_manager = ( - build_speech_history_manager() - ) - manager.add_text("oldest") - manager.add_text("middle") - manager.add_text("newest") - manager.open_history() - - manager.prev_entry() - manager.prev_entry() - manager.prev_entry() - manager.next_entry() - - assert spoken_messages[-4:] == ["newest", "middle", "oldest", "middle"] - - -@pytest.mark.unit -def test_copy_current_adds_clipboard_and_restores_bindings(): - manager, env, spoken_messages, memory_manager = ( - build_speech_history_manager() - ) - manager.add_text("first") - manager.add_text("second") - manager.open_history() - manager.prev_entry() - manager.prev_entry() - - manager.copy_current_to_clipboard() - - memory_manager.add_value_to_first_index.assert_called_once_with( - "clipboardHistory", "first" - ) - assert spoken_messages[-1] == "copied to clipboard" - assert not manager.is_active() - assert env["bindings"] == {"original": "COMMAND"} - assert env["rawBindings"] == {"original": [1, ["KEY_FENRIR"]]} - env["runtime"]["InputManager"].reset_input_state.assert_called_once_with() - assert env["runtime"]["InputDriver"].refresh_grabs.call_count == 2 - - -@pytest.mark.unit -def test_close_history_restores_keyboard_state_and_grabs(): - manager, env, _spoken_messages, _memory_manager = ( - build_speech_history_manager() - ) - manager.add_text("first") - manager.open_history() - - manager.close_history() - - assert not manager.is_active() - assert env["bindings"] == {"original": "COMMAND"} - assert env["rawBindings"] == {"original": [1, ["KEY_FENRIR"]]} - env["runtime"]["InputManager"].reset_input_state.assert_called_once_with() - assert env["runtime"]["InputDriver"].refresh_grabs.call_count == 2 diff --git a/tests/unit/test_speechd_voice_selection.py b/tests/unit/test_speechd_voice_selection.py deleted file mode 100644 index 92f7c3a0..00000000 --- a/tests/unit/test_speechd_voice_selection.py +++ /dev/null @@ -1,109 +0,0 @@ -import sys -from types import SimpleNamespace -from unittest.mock import Mock - -from fenrirscreenreader.core.quickMenuManager import QuickMenuManager -from fenrirscreenreader.speechDriver import speechdDriver -from fenrirscreenreader.utils.speechd_utils import get_synthesis_voice_name - - -def test_espeak_voice_selection_keeps_language_and_variant_behavior(): - voice = get_synthesis_voice_name( - "espeak-ng", - "English (America)+female3 en-US female3", - ) - - assert voice == "en-us+female3" - - -class RuntimeSettings: - def __init__(self, module="", voice="en-us", language=""): - self.values = { - ("speech", "module"): module, - ("speech", "voice"): voice, - ("speech", "language"): language, - } - - def get_setting(self, section, setting): - return self.values[(section, setting)] - - def set_setting(self, section, setting, value): - self.values[(section, setting)] = value - - -def test_speechd_driver_resolves_blank_module_from_active_default(monkeypatch): - settings = RuntimeSettings() - client = Mock() - client.get_output_module.return_value = "rhvoice" - speechd = SimpleNamespace( - SSIPClient=Mock(return_value=client), - PunctuationMode=Mock(return_value=Mock()), - ) - monkeypatch.setitem(sys.modules, "speechd", speechd) - environment = { - "runtime": { - "DebugManager": Mock(), - "SettingsManager": settings, - } - } - - driver = speechdDriver.driver() - driver.initialize(environment) - - assert settings.get_setting("speech", "module") == "rhvoice" - assert driver.module == "rhvoice" - - -def test_speechd_driver_keeps_running_when_default_module_query_fails( - monkeypatch, -): - settings = RuntimeSettings() - client = Mock() - client.get_output_module.side_effect = RuntimeError("query failed") - speechd = SimpleNamespace( - SSIPClient=Mock(return_value=client), - PunctuationMode=Mock(return_value=Mock()), - ) - monkeypatch.setitem(sys.modules, "speechd", speechd) - environment = { - "runtime": { - "DebugManager": Mock(), - "SettingsManager": settings, - } - } - - driver = speechdDriver.driver() - driver.initialize(environment) - - assert driver._is_initialized is True - assert settings.get_setting("speech", "module") == "" - - -def test_quick_menu_espeak_voice_can_move_away_and_return(): - settings = RuntimeSettings(module="rhvoice") - discovery = Mock() - discovery.get_cached_modules.return_value = ["rhvoice", "espeak-ng"] - discovery.get_cached_voices.side_effect = lambda module: { - "rhvoice": ["alan"], - "espeak-ng": ["en-gb", "en-us", "en-us+female2"], - }[module] - manager = QuickMenuManager() - manager.env = { - "runtime": { - "DebugManager": Mock(), - "OutputManager": Mock(), - "SettingsManager": settings, - "SpeechDriver": Mock(), - "SpeechDiscoveryManager": discovery, - } - } - - assert manager.cycle_speech_module("next") is True - assert settings.get_setting("speech", "module") == "espeak-ng" - assert settings.get_setting("speech", "voice") == "en-us" - - assert manager.cycle_speech_voice("next") is True - assert settings.get_setting("speech", "voice") == "en-us+female2" - - assert manager.cycle_speech_voice("prev") is True - assert settings.get_setting("speech", "voice") == "en-us" diff --git a/tests/unit/test_subprocess_command.py b/tests/unit/test_subprocess_command.py deleted file mode 100644 index c2b81244..00000000 --- a/tests/unit/test_subprocess_command.py +++ /dev/null @@ -1,26 +0,0 @@ -from unittest.mock import Mock - -import pytest - -from fenrirscreenreader.commands.commands import subprocess as subprocess_command - - -@pytest.mark.unit -def test_script_command_executes_without_shell(monkeypatch): - process = Mock() - process.returncode = 0 - process.communicate.return_value = ("done", "") - popen = Mock(return_value=process) - monkeypatch.setattr(subprocess_command.subprocess, "Popen", popen) - - result = subprocess_command.run_script( - "/tmp/script with spaces", "Username" - ) - - popen.assert_called_once_with( - ["/tmp/script with spaces", "Username"], - stdout=subprocess_command.subprocess.PIPE, - stderr=subprocess_command.subprocess.PIPE, - text=True, - ) - assert result == {"return_code": 0, "stdout": "done", "stderr": ""} diff --git a/tests/unit/test_subprocess_command_background_result.py b/tests/unit/test_subprocess_command_background_result.py deleted file mode 100644 index 761df604..00000000 --- a/tests/unit/test_subprocess_command_background_result.py +++ /dev/null @@ -1,47 +0,0 @@ -import importlib -from unittest.mock import Mock - - -subprocess_command = importlib.import_module( - "fenrirscreenreader.commands.commands.subprocess" -) - - -def test_external_script_output_is_delivered_by_main_loop_callback(tmp_path): - script_path = tmp_path / "helper.sh" - script_path.write_text("#!/bin/sh\necho ready\n", encoding="utf-8") - script_path.chmod(0o755) - task_manager = Mock() - task_manager.submit_external_task.return_value = 7 - output_manager = Mock() - environment = { - "runtime": { - "BackgroundTaskManager": task_manager, - "OutputManager": output_manager, - }, - "general": {"curr_user": "Username"}, - } - script_command = subprocess_command.command() - script_command.initialize(environment, str(script_path)) - - script_command.run() - - output_manager.present_text.assert_not_called() - function, callback, path, current_user = ( - task_manager.submit_external_task.call_args.args - ) - assert function is subprocess_command.run_script - assert path == str(script_path) - assert current_user == "Username" - - callback( - { - "task_id": 7, - "succeeded": True, - "value": {"return_code": 0, "stdout": "ready\n", "stderr": ""}, - "error": "", - } - ) - output_manager.present_text.assert_called_once_with( - "ready\n", sound_icon="", interrupt=False - ) diff --git a/tests/unit/test_tab_completion_manager.py b/tests/unit/test_tab_completion_manager.py deleted file mode 100644 index dbe2a3a9..00000000 --- a/tests/unit/test_tab_completion_manager.py +++ /dev/null @@ -1,433 +0,0 @@ -import importlib.util -import time -from pathlib import Path -from unittest.mock import Mock - -import pytest - -from fenrirscreenreader.core.tabCompletionManager import TabCompletionManager - - -def _build_env(old_text="", cursor=None, screen="pty"): - if cursor is None: - cursor = {"x": 0, "y": 0} - input_manager = Mock() - input_manager.get_last_event.return_value = { - "event_name": "KEY_TAB", - "event_state": 1, - } - input_manager.get_last_deepest_input.return_value = [["KEY_TAB"]] - input_manager.get_last_input_time.return_value = time.time() - env = { - "runtime": { - "InputManager": input_manager, - }, - "screen": { - "new_cursor": cursor.copy(), - "new_content_text": old_text, - "new_delta": "", - "new_delta_is_typing": False, - "newTTY": screen, - }, - "commandBuffer": {}, - } - manager = TabCompletionManager() - manager.initialize(env) - return manager, env, input_manager - - -def _load_large_insertion_module(): - module_path = ( - Path(__file__).resolve().parents[2] - / "src" - / "fenrirscreenreader" - / "commands" - / "onCursorChange" - / "55000-large_insertion_echo.py" - ) - spec = importlib.util.spec_from_file_location( - "fenrir_large_insertion_echo", module_path - ) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def _set_screen_update(env, text, cursor, delta="", typing=False): - env["screen"]["new_content_text"] = text - env["screen"]["new_cursor"] = cursor.copy() - env["screen"]["new_delta"] = delta - env["screen"]["new_delta_is_typing"] = typing - - -@pytest.mark.unit -def test_unique_completion_speaks_inserted_suffix(): - manager, env, _input_manager = _build_env( - "cd Do".ljust(20), {"x": 5, "y": 0} - ) - - manager.capture_if_tab() - _set_screen_update( - env, - "cd Documents/".ljust(20), - {"x": 13, "y": 0}, - delta="cuments/", - typing=True, - ) - - assert manager.process_update() == "cuments/" - state = env["commandBuffer"]["tabCompletion"] - assert state["pending"] is None - assert state["lastProcessedDelta"] == "cuments/" - - -@pytest.mark.unit -def test_small_completion_speaks_inserted_suffix(): - manager, env, _input_manager = _build_env( - "cd gi".ljust(20), {"x": 5, "y": 0} - ) - - manager.capture_if_tab() - _set_screen_update( - env, - "cd git/".ljust(20), - {"x": 7, "y": 0}, - delta="t/", - typing=True, - ) - - assert manager.process_update() == "t/" - - -@pytest.mark.unit -def test_completion_processed_once_for_cursor_and_screen_update(): - manager, env, _input_manager = _build_env( - "cd gi".ljust(20), {"x": 5, "y": 0} - ) - - manager.capture_if_tab() - _set_screen_update( - env, - "cd git/".ljust(20), - {"x": 7, "y": 0}, - delta="t/", - typing=True, - ) - - assert manager.process_update() == "t/" - assert manager.process_update() == "" - - -@pytest.mark.unit -def test_candidate_list_speaks_visible_list_without_cursor_advance(): - old_text = "\n".join(["$ cd Do".ljust(20), "".ljust(20), "".ljust(20)]) - manager, env, _input_manager = _build_env(old_text, {"x": 7, "y": 0}) - - manager.capture_if_tab() - _set_screen_update( - env, - "\n".join( - [ - "$ cd Do".ljust(20), - "Documents/ Downloads/".ljust(20), - "$ cd Do".ljust(20), - ] - ), - {"x": 7, "y": 2}, - delta="Documents/ Downloads/", - ) - - assert manager.process_update() == "Documents/ Downloads/" - - -@pytest.mark.unit -def test_full_screen_scroll_speaks_only_inserted_candidate_list(): - old_text = "\n".join( - [ - "old top".ljust(30), - "old middle".ljust(30), - "old lower".ljust(30), - "old bottom".ljust(30), - "$ ./Toby".ljust(30), - ] - ) - manager, env, _input_manager = _build_env(old_text, {"x": 8, "y": 4}) - - manager.capture_if_tab() - _set_screen_update( - env, - "\n".join( - [ - "old middle".ljust(30), - "old lower".ljust(30), - "old bottom".ljust(30), - "TobyAccMod_V10-0.pk3 TobyConfig.ini".ljust(30), - "$ ./Toby".ljust(30), - ] - ), - {"x": 8, "y": 4}, - delta="\n".join( - [ - "old middle", - "old lower", - "old bottom", - "TobyAccMod_V10-0.pk3 TobyConfig.ini", - "$ ./Toby", - ] - ), - ) - - assert manager.process_update() == "TobyAccMod_V10-0.pk3 TobyConfig.ini" - - -@pytest.mark.unit -def test_same_height_repaint_without_inserted_candidates_stays_silent(): - old_text = "\n".join( - [ - "old top".ljust(30), - "old middle".ljust(30), - "old lower".ljust(30), - "$ ./Toby".ljust(30), - ] - ) - manager, env, _input_manager = _build_env(old_text, {"x": 8, "y": 3}) - - manager.capture_if_tab() - _set_screen_update( - env, - "\n".join( - [ - "status line".ljust(30), - "old prompt history".ljust(30), - "unrelated output".ljust(30), - "$ ./Toby".ljust(30), - ] - ), - {"x": 8, "y": 3}, - delta="status line\nold prompt history\nunrelated output\n$ ./Toby", - ) - - assert manager.process_update() == "" - - -@pytest.mark.unit -def test_recent_tab_does_not_speak_delta_without_detected_completion(): - old_text = "\n".join(["$ ./Toby".ljust(20), "".ljust(20)]) - manager, env, _input_manager = _build_env(old_text, {"x": 8, "y": 0}) - - manager.capture_if_tab() - _set_screen_update( - env, - old_text, - {"x": 8, "y": 0}, - delta="old unrelated screen content\n$ ./Toby", - ) - - assert manager.process_update() == "" - assert env["commandBuffer"]["tabCompletion"]["pending"] is not None - - -@pytest.mark.unit -def test_no_screen_change_stays_silent_and_keeps_pending_briefly(): - manager, env, _input_manager = _build_env( - "cd Do".ljust(20), {"x": 5, "y": 0} - ) - - manager.capture_if_tab() - - assert manager.process_update() == "" - assert env["commandBuffer"]["tabCompletion"]["pending"] is not None - - -@pytest.mark.unit -def test_timeout_clears_pending_without_speech(): - manager, env, _input_manager = _build_env( - "cd Do".ljust(20), {"x": 5, "y": 0} - ) - - manager.capture_if_tab() - env["commandBuffer"]["tabCompletion"]["pending"]["timestamp"] = ( - time.time() - 1 - ) - _set_screen_update( - env, - "unrelated output".ljust(20), - {"x": 0, "y": 0}, - delta="unrelated output", - ) - - assert manager.process_update() == "" - assert env["commandBuffer"]["tabCompletion"]["pending"] is None - - -@pytest.mark.unit -def test_non_tab_key_does_not_capture(): - manager, env, input_manager = _build_env( - "cd Do".ljust(20), {"x": 5, "y": 0} - ) - input_manager.get_last_event.return_value = { - "event_name": "KEY_A", - "event_state": 1, - } - input_manager.get_last_deepest_input.return_value = [["KEY_A"]] - - manager.capture_if_tab() - - assert env["commandBuffer"]["tabCompletion"]["pending"] is None - - -@pytest.mark.unit -def test_recent_tab_screen_update_works_without_key_input_snapshot(): - manager, env, input_manager = _build_env( - "cd Documents/".ljust(20), {"x": 13, "y": 0} - ) - input_manager.get_last_event.return_value = { - "event_name": "KEY_TAB", - "event_state": 0, - } - env["screen"]["old_content_text"] = "cd Do".ljust(20) - env["screen"]["old_cursor"] = {"x": 5, "y": 0} - env["commandBuffer"]["tabCompletion"]["pending"] = None - _set_screen_update( - env, - "cd Documents/".ljust(20), - {"x": 13, "y": 0}, - delta="cuments/", - typing=True, - ) - - assert manager.process_update() == "cuments/" - - -@pytest.mark.unit -def test_recent_raw_pty_tab_speaks_short_completion_without_capture(): - manager, env, input_manager = _build_env( - "cd Documents/".ljust(20), {"x": 13, "y": 0} - ) - input_manager.get_last_deepest_input.return_value = ["KEY_TAB"] - input_manager.get_last_event.return_value = None - env["screen"]["old_content_text"] = "cd Docume".ljust(20) - env["screen"]["old_cursor"] = {"x": 9, "y": 0} - env["commandBuffer"]["tabCompletion"]["pending"] = None - _set_screen_update( - env, - "cd Documents/".ljust(20), - {"x": 13, "y": 0}, - delta="nts/", - typing=True, - ) - - assert manager.process_update() == "nts/" - - -@pytest.mark.unit -def test_large_insertion_echo_speaks_pasted_cursor_text(): - large_insertion_module = _load_large_insertion_module() - output_manager = Mock(present_text=Mock()) - settings_manager = Mock() - settings_manager.get_setting_as_bool.return_value = False - input_manager = Mock() - input_manager.get_last_deepest_input.return_value = [] - input_manager.get_last_event.return_value = None - screen_manager = Mock( - is_screen_change=Mock(return_value=False), - is_delta=Mock(return_value=True), - ) - env = { - "runtime": { - "InputManager": input_manager, - "OutputManager": output_manager, - "ScreenManager": screen_manager, - "SettingsManager": settings_manager, - }, - "screen": { - "old_cursor": {"x": 2, "y": 0}, - "new_cursor": {"x": 13, "y": 0}, - "new_delta": "hello world", - "new_delta_is_typing": True, - }, - } - command = large_insertion_module.command() - command.initialize(env) - - command.run() - - output_manager.present_text.assert_called_once_with( - "hello world", - interrupt=True, - announce_capital=True, - flush=False, - ) - - -@pytest.mark.unit -def test_large_insertion_echo_ignores_incoming_cursor_progress(): - large_insertion_module = _load_large_insertion_module() - output_manager = Mock(present_text=Mock()) - settings_manager = Mock() - settings_manager.get_setting_as_bool.return_value = False - input_manager = Mock() - input_manager.get_last_deepest_input.return_value = [] - input_manager.get_last_event.return_value = None - screen_manager = Mock( - is_screen_change=Mock(return_value=False), - is_delta=Mock(return_value=True), - ) - env = { - "runtime": { - "InputManager": input_manager, - "OutputManager": output_manager, - "ScreenManager": screen_manager, - "SettingsManager": settings_manager, - }, - "screen": { - "old_cursor": {"x": 0, "y": 0}, - "new_cursor": {"x": 12, "y": 0}, - "new_delta": "upgrading 1/3", - "new_delta_is_typing": False, - }, - } - command = large_insertion_module.command() - command.initialize(env) - - command.run() - - output_manager.present_text.assert_not_called() - - -@pytest.mark.unit -def test_large_insertion_echo_defers_recent_tab_to_tab_completion(): - large_insertion_module = _load_large_insertion_module() - output_manager = Mock(present_text=Mock()) - settings_manager = Mock() - settings_manager.get_setting_as_bool.return_value = False - input_manager = Mock() - input_manager.get_last_deepest_input.return_value = [["KEY_TAB"]] - input_manager.get_last_event.return_value = { - "event_name": "KEY_TAB", - "event_state": 1, - } - input_manager.get_last_input_time.return_value = time.time() - screen_manager = Mock( - is_screen_change=Mock(return_value=False), - is_delta=Mock(return_value=True), - ) - env = { - "runtime": { - "InputManager": input_manager, - "OutputManager": output_manager, - "ScreenManager": screen_manager, - "SettingsManager": settings_manager, - }, - "screen": { - "old_cursor": {"x": 5, "y": 0}, - "new_cursor": {"x": 13, "y": 0}, - "new_delta": "cuments/", - }, - } - command = large_insertion_module.command() - command.initialize(env) - - command.run() - - output_manager.present_text.assert_not_called() diff --git a/tests/unit/test_time_command.py b/tests/unit/test_time_command.py deleted file mode 100644 index 08cb6a8a..00000000 --- a/tests/unit/test_time_command.py +++ /dev/null @@ -1,83 +0,0 @@ -""" -Unit tests for automatic time announcement behavior. -""" - -import datetime -import importlib.util -from pathlib import Path -from unittest.mock import Mock - - -def _load_time_module(): - module_path = ( - Path(__file__).resolve().parents[2] - / "src" - / "fenrirscreenreader" - / "commands" - / "onHeartBeat" - / "76000-time.py" - ) - spec = importlib.util.spec_from_file_location("fenrir_time_command", module_path) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def _create_environment(output_manager): - now = datetime.datetime.now() - settings_manager = Mock() - settings_manager.get_setting.side_effect = lambda section, setting: { - ("time", "on_minutes"): str(now.minute).zfill(2), - ("general", "date_format"): "%A, %B %d, %Y", - ("general", "time_format"): "%I:%M%P", - }[(section, setting)] - settings_manager.get_setting_as_int.side_effect = lambda section, setting: { - ("time", "delay_sec"): 0, - }[(section, setting)] - settings_manager.get_setting_as_bool.side_effect = lambda section, setting: { - ("time", "enabled"): True, - ("time", "present_date"): True, - ("time", "present_time"): True, - ("time", "interrupt"): False, - ("time", "announce"): True, - }[(section, setting)] - - return { - "runtime": { - "OutputManager": output_manager, - "SettingsManager": settings_manager, - } - } - - -def test_time_announcement_lock_suppresses_second_instance(tmp_path): - time_module = _load_time_module() - lock_path = tmp_path / "time-announcement.lock" - first_output = Mock( - interrupt_output=Mock(), - play_sound_icon=Mock(), - present_text=Mock(), - ) - second_output = Mock( - interrupt_output=Mock(), - play_sound_icon=Mock(), - present_text=Mock(), - ) - first_command = time_module.command() - second_command = time_module.command() - - first_command.initialize(_create_environment(first_output)) - second_command.initialize(_create_environment(second_output)) - first_command._get_announcement_lock_path = lambda: str(lock_path) - second_command._get_announcement_lock_path = lambda: str(lock_path) - first_command.last_time -= datetime.timedelta(hours=1) - second_command.last_time -= datetime.timedelta(hours=1) - - first_command.run() - second_command.run() - - first_output.play_sound_icon.assert_called_once_with("announce") - first_output.present_text.assert_called() - second_output.interrupt_output.assert_not_called() - second_output.play_sound_icon.assert_not_called() - second_output.present_text.assert_not_called() diff --git a/tests/unit/test_toggle_vmenu_command.py b/tests/unit/test_toggle_vmenu_command.py deleted file mode 100644 index baa536e1..00000000 --- a/tests/unit/test_toggle_vmenu_command.py +++ /dev/null @@ -1,34 +0,0 @@ -import importlib.util -from pathlib import Path -from unittest.mock import Mock - -import pytest - - -COMMAND_PATH = ( - Path(__file__).resolve().parents[2] - / "src" - / "fenrirscreenreader" - / "commands" - / "commands" - / "toggle_vmenu_mode.py" -) - - -def load_command(): - spec = importlib.util.spec_from_file_location( - "fenrir_toggle_vmenu_mode", COMMAND_PATH - ) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module.command() - - -@pytest.mark.unit -def test_get_description_does_not_toggle_vmenu_mode(): - vmenu_manager = Mock() - command = load_command() - command.initialize({"runtime": {"VmenuManager": vmenu_manager}}) - - assert command.get_description() == "Entering or Leaving v menu mode." - vmenu_manager.toggle_vmenu_mode.assert_not_called() diff --git a/tests/unit/test_translations.py b/tests/unit/test_translations.py deleted file mode 100644 index 8649e821..00000000 --- a/tests/unit/test_translations.py +++ /dev/null @@ -1,240 +0,0 @@ -import gettext -import json -import os -from pathlib import Path -import shutil -import subprocess -import sys -from unittest.mock import Mock - -import pytest - -from fenrirscreenreader.commands.commands.adjustment_base import adjustment_command -from fenrirscreenreader.commands.commands import voice_browser -from fenrirscreenreader.core import i18n, vmenuManager -from fenrirscreenreader.core.quickMenuManager import QuickMenuManager - - -@pytest.fixture -def translation_tool(): - if not all( - shutil.which(tool) for tool in ("xgettext", "msgcat", "msgfmt", "msgmerge") - ): - pytest.skip("GNU gettext tools are required") - for parent in Path(__file__).resolve().parents: - path = parent / "tools/generate_translations.py" - if path.is_file(): - return path - pytest.skip("Translation maintenance tool is not included in this build") - - -@pytest.fixture -def catalog(tmp_path): - """A synthetic catalog checks actual gettext, including reordered fields.""" - po = tmp_path / "example.po" - po.write_text( - 'msgid ""\nmsgstr ""\n' - '"Project-Id-Version: example 1\\n"\n' - '"PO-Revision-Date: 2026-01-01 00:00+0000\\n"\n' - '"Last-Translator: Contributor\\n"\n' - '"Language-Team: Example\\n"\n' - '"Language: zz\\n"\n' - '"MIME-Version: 1.0\\n"\n' - '"Content-Type: text/plain; charset=UTF-8\\n"\n' - '"Content-Transfer-Encoding: 8bit\\n"\n' - '"Plural-Forms: nplurals=2; plural=(n != 1);\\n"\n\n' - 'msgid "Increase the speech rate"\nmsgstr "Translated rate increase"\n\n' - 'msgid "Loading voices for {0}"\nmsgstr "{0}: translated loading"\n\n' - 'msgid "speech rate"\nmsgstr "Translated speech rate"\n\n' - 'msgid "fenrir Menu"\nmsgstr "Translated Fenrir menu"\n\n' - 'msgid "{0} Action"\nmsgstr "Translated action {0}"\n\n' - 'msgid "One item"\nmsgid_plural "Many items"\n' - 'msgstr[0] "Translated singular"\nmsgstr[1] "Translated plural"\n', - encoding="utf-8", - ) - if not shutil.which("msgfmt"): - pytest.skip("GNU gettext msgfmt is required") - mo = tmp_path / "locale/zz/LC_MESSAGES/fenrir.mo" - mo.parent.mkdir(parents=True) - subprocess.run(["msgfmt", "--check", "-o", str(mo), str(po)], check=True) - with mo.open("rb") as stream: - translations = gettext.GNUTranslations(stream) - return po, mo, translations - - -@pytest.fixture -def checkout(tmp_path, translation_tool, catalog): - root = tmp_path / "checkout" - sources = { - "src/fenrir": '_("Example reader")\n', - "src/fenrirscreenreader/core/inputData.py": 'key_names = ["KEY_ENTER"]\n', - "src/fenrirscreenreader/screenDriver/vcsaDriver.py": "class Driver:\n def __init__(self):\n" - ' self.fgColorValues = {0: "black"}\n', - "src/fenrirscreenreader/commands/vmenu-profiles/KEY/example/deep/action.py": '_("Loading voices for {0}").format("example")\n', - } - for name, contents in sources.items(): - path = root / name - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(contents, encoding="utf-8") - script = root / "tools/generate_translations.py" - script.parent.mkdir() - shutil.copy2(translation_tool, script) - po = root / "locale/zz/LC_MESSAGES/example.po" - po.parent.mkdir(parents=True) - shutil.copy2(catalog[0], po) - mo = po.parent / "fenrir.mo" - shutil.copy2(catalog[1], mo) - return root, script, po, mo - - -def run_translation_tool(checkout, *arguments): - root, script, _po, _mo = checkout - return subprocess.run( - [sys.executable, str(script), *map(str, arguments)], - cwd=root.parent, - capture_output=True, - text=True, - ) - - -def test_template_and_check_preserve_catalogs_from_another_directory(checkout): - root, _script, po, mo = checkout - original = (po.read_bytes(), mo.read_bytes()) - result = run_translation_tool(checkout) - assert result.returncode == 0, result.stderr - pot = (root / "tools/fenrir.pot").read_text() - assert 'msgid "Loading voices for {0}"' in pot - assert ( - "#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/example/deep/action.py:1" - in pot - ) - assert 'msgid "deep Menu"' in pot - assert 'msgid "action Action"' in pot - assert 'msgid "black"' in pot - assert 'msgid "enter"' in pot - assert "charset=UTF-8" in pot - result = run_translation_tool(checkout, "check") - assert result.returncode == 0, result.stderr - assert (po.read_bytes(), mo.read_bytes()) == original - - -def test_check_rejects_stale_template_without_writing(checkout): - root, _script, po, mo = checkout - assert run_translation_tool(checkout).returncode == 0 - template = root / "tools/fenrir.pot" - original = (template.read_bytes(), po.read_bytes(), mo.read_bytes()) - (root / "src/fenrir").write_text('_("New announcement")\n') - result = run_translation_tool(checkout, "check") - assert result.returncode != 0 - assert "Template is stale" in result.stderr - assert (template.read_bytes(), po.read_bytes(), mo.read_bytes()) == original - - -def test_update_requires_selection_and_preserves_translation(checkout): - _root, _script, po, mo = checkout - original_mo = mo.read_bytes() - original_po = po.read_bytes() - assert run_translation_tool(checkout, "update").returncode != 0 - assert po.read_bytes() == original_po - result = run_translation_tool(checkout, "update", po) - assert result.returncode == 0, result.stderr - assert "{0}: translated loading" in po.read_text() - assert "#, python-brace-format" in po.read_text() - assert 'msgid "deep Menu"' in po.read_text() - assert mo.read_bytes() == original_mo - result = run_translation_tool(checkout, "compile", po) - assert result.returncode == 0, result.stderr - with mo.open("rb") as stream: - translated = gettext.GNUTranslations(stream) - assert translated.gettext("Loading voices for {0}").format("example") == ( - "example: translated loading" - ) - assert translated.gettext("deep Menu") == "deep Menu" - - -def test_compile_preserves_existing_binary_when_placeholders_are_invalid(checkout): - _root, _script, po, mo = checkout - assert run_translation_tool(checkout, "update", po).returncode == 0 - po.write_text(po.read_text().replace("{0}: translated loading", "Broken {1}")) - original = mo.read_bytes() - result = run_translation_tool(checkout, "compile", po) - assert result.returncode != 0 - assert mo.read_bytes() == original - - -def test_singular_and_plural_use_fenrir_catalog(catalog): - _po, mo, _translations = catalog - code = ( - "import gettext, json, runpy; " - "gettext._default_localedir = " + repr(str(mo.parents[2])) + "; " - "module = runpy.run_path(" + repr(i18n.__file__) + "); " - "print(json.dumps([module['_']('Increase the speech rate'), " - "module['ngettext']('One item', 'Many items', 2)]))" - ) - environment = dict(os.environ, LANGUAGE="zz", LC_ALL="C", LANG="C") - result = subprocess.run( - [sys.executable, "-c", code], - env=environment, - check=True, - capture_output=True, - text=True, - ) - assert json.loads(result.stdout) == [ - "Translated rate increase", - "Translated plural", - ] - - -def test_adjustments_and_voice_loading_translate_before_formatting( - catalog, monkeypatch -): - from fenrirscreenreader.commands.commands import adjustment_base - - translated = catalog[2].gettext - monkeypatch.setattr(adjustment_base, "_", translated) - monkeypatch.setattr(voice_browser, "_", translated) - assert adjustment_command("speech", "rate", "inc").get_description() == ( - "Translated rate increase" - ) - browser = voice_browser.command() - browser.env = { - "runtime": {"OutputManager": Mock(), "SpeechDiscoveryManager": Mock()} - } - browser.modules = ["example"] - browser.module_index = 0 - browser._request_current_module_voices(1) - browser.env["runtime"]["OutputManager"].present_text.assert_called_once_with( - "example: translated loading", interrupt=True - ) - - -def test_menu_translation_keeps_lookup_and_settings_identifiers_stable( - catalog, monkeypatch -): - from fenrirscreenreader.core import quickMenuManager - - translated = catalog[2].gettext - monkeypatch.setattr(vmenuManager, "_", translated) - manager = vmenuManager.VmenuManager() - manager.closeAfterAction = False - action = Mock() - manager.menuDict = {"fenrir Menu": {"Example Action": action}} - manager.set_curr_menu("fenrir") - assert manager.get_current_entry() == "Translated action Example" - assert manager.get_value_by_path(manager.menuDict, manager.curr_index) is action - assert ( - manager.search_entry("Translated", forceReset=True) - == "Translated action Example" - ) - manager.execute_menu() - action.run.assert_called_once() - - monkeypatch.setattr(quickMenuManager, "_", translated) - quick_menu = QuickMenuManager() - quick_menu.quickMenu = [{"section": "speech", "setting": "rate"}] - quick_menu.env = {"runtime": {"SettingsManager": Mock()}} - assert quick_menu.get_current_entry() == "Translated speech rate" - quick_menu.get_current_value() - quick_menu.env["runtime"]["SettingsManager"].get_setting.assert_called_once_with( - "speech", "rate" - ) diff --git a/tests/unit/test_unix_remote_driver.py b/tests/unit/test_unix_remote_driver.py deleted file mode 100644 index 60eca82f..00000000 --- a/tests/unit/test_unix_remote_driver.py +++ /dev/null @@ -1,394 +0,0 @@ -import socket -import struct -from unittest.mock import Mock, mock_open, patch - -import pytest - -from fenrirscreenreader.remoteDriver import unixDriver - - -def test_watchdog_keeps_serving_when_instance_registration_fails( - mock_environment, -): - driver = unixDriver.driver() - driver.env = mock_environment - driver.fenrirSocks = [Mock()] - active = Mock(value=True) - event_queue = Mock() - - def stop_after_select(*_args): - active.value = False - return ([], [], []) - - with patch.object(driver, "_get_socket_candidates", return_value=[]), patch.object( - driver, "_register_instance", side_effect=PermissionError("denied") - ), patch( - "fenrirscreenreader.remoteDriver.unixDriver.select.select", - side_effect=stop_after_select, - ) as select_call: - driver.watch_dog(active, event_queue) - - select_call.assert_called_once() - - -def test_watchdog_closes_listeners_when_unexpected_error_escapes( - mock_environment, -): - driver = unixDriver.driver() - driver.env = mock_environment - listener = Mock() - active = Mock(value=True) - - with patch.object( - driver, - "_get_socket_candidates", - return_value=[("/tmp/fenrir-test.sock", False)], - ), patch.object( - driver, "_bind_socket", return_value=listener - ), patch.object( - driver, "_try_register_instance" - ), patch( - "fenrirscreenreader.remoteDriver.unixDriver.select.select", - side_effect=RuntimeError("unexpected failure"), - ), patch( - "fenrirscreenreader.remoteDriver.unixDriver.os.path.exists", - return_value=False, - ), patch( - "fenrirscreenreader.remoteDriver.unixDriver.remoteInstanceRegistry.remove_instance" - ) as remove_instance: - with pytest.raises(RuntimeError, match="unexpected failure"): - driver.watch_dog(active, Mock()) - - listener.close.assert_called_once_with() - assert driver.fenrirSocks == [] - assert driver.bound_sockets == [] - remove_instance.assert_called_once_with() - - -class FakeClientSocket: - def __init__(self, data): - self.data = data - self.sent = b"" - - def recv(self, _size): - return self.data - - def sendall(self, data): - self.sent += data - - -def test_get_peer_uid_from_unix_socket(): - driver = unixDriver.driver() - client_sock = Mock() - client_sock.getsockopt.return_value = struct.pack("3i", 4321, 1000, 100) - - assert driver._get_peer_uid(client_sock) == 1000 - client_sock.getsockopt.assert_called_once_with( - socket.SOL_SOCKET, - socket.SO_PEERCRED, - struct.calcsize("3i"), - ) - - -def test_main_socket_routes_to_ancestor_private_socket(mock_environment): - driver = unixDriver.driver() - driver.env = mock_environment - driver.bound_sockets = [ - (Mock(), "/tmp/fenrirscreenreader-999.sock"), - (Mock(), unixDriver.MAIN_SOCKET_FILE), - ] - client_sock = FakeClientSocket(b"command say routed") - event_queue = Mock() - - with patch.object(driver, "_get_peer_pid", return_value=1234), patch.object( - driver, - "_find_ancestor_private_socket", - return_value="/tmp/fenrirscreenreader-222.sock", - ), patch.object( - driver, - "_find_available_private_socket", - return_value="/tmp/fenrirscreenreader-222.sock", - ), patch.object( - driver, "_forward_remote_to_socket", return_value=True - ) as forward: - driver._handle_client( - client_sock, event_queue, unixDriver.MAIN_SOCKET_FILE - ) - - forward.assert_called_once_with( - "command say routed", "/tmp/fenrirscreenreader-222.sock" - ) - event_queue.put.assert_not_called() - - -def test_main_socket_routes_to_first_available_private_socket( - mock_environment, -): - driver = unixDriver.driver() - driver.env = mock_environment - driver.bound_sockets = [ - (Mock(), "/tmp/fenrirscreenreader-999.sock"), - (Mock(), unixDriver.MAIN_SOCKET_FILE), - ] - client_sock = FakeClientSocket(b"command say fallback") - event_queue = Mock() - - with patch.object(driver, "_get_peer_pid", return_value=1234), patch.object( - driver, - "_find_ancestor_private_socket", - return_value="", - ), patch.object( - driver, - "_find_available_private_socket", - return_value="/tmp/fenrirscreenreader-333.sock", - ), patch.object( - driver, "_forward_remote_to_socket", return_value=True - ) as forward: - driver._handle_client( - client_sock, event_queue, unixDriver.MAIN_SOCKET_FILE - ) - - forward.assert_called_once_with( - "command say fallback", "/tmp/fenrirscreenreader-333.sock" - ) - event_queue.put.assert_not_called() - - -def test_main_socket_handles_command_locally_without_available_target( - mock_environment, -): - driver = unixDriver.driver() - driver.env = mock_environment - client_sock = FakeClientSocket(b"command say local") - event_queue = Mock() - - with patch.object(driver, "_get_peer_pid", return_value=1234), patch.object( - driver, "_get_peer_uid", return_value=1000 - ), patch.object( - driver, "_get_socket_owner_uid", return_value=65534 - ), patch.object( - driver, - "_find_available_private_socket", - return_value="", - ): - driver._handle_client( - client_sock, event_queue, unixDriver.MAIN_SOCKET_FILE - ) - - event_queue.put.assert_called_once_with( - { - "Type": unixDriver.FenrirEventType.remote_incomming, - "data": "command say local", - } - ) - - -def test_vcsa_main_socket_owner_handles_command_locally(mock_environment): - driver = unixDriver.driver() - driver.env = mock_environment - driver.bound_sockets = [(Mock(), unixDriver.MAIN_SOCKET_FILE)] - client_sock = FakeClientSocket(b"command say root") - event_queue = Mock() - - with patch.object(driver, "_get_peer_uid", return_value=1000), patch.object( - driver, "_get_socket_owner_uid", return_value=1000 - ), patch.object( - driver, "_find_available_private_socket" - ) as find_available: - driver._handle_client( - client_sock, event_queue, unixDriver.MAIN_SOCKET_FILE - ) - - find_available.assert_not_called() - event_queue.put.assert_called_once_with( - { - "Type": unixDriver.FenrirEventType.remote_incomming, - "data": "command say root", - } - ) - - -def test_vcsa_main_socket_routes_to_same_user_private_socket( - mock_environment, -): - driver = unixDriver.driver() - driver.env = mock_environment - driver.bound_sockets = [(Mock(), unixDriver.MAIN_SOCKET_FILE)] - client_sock = FakeClientSocket(b"command say x session") - event_queue = Mock() - - with patch.object(driver, "_get_peer_pid", return_value=1234), patch.object( - driver, "_get_peer_uid", return_value=1000 - ), patch.object( - driver, "_get_socket_owner_uid", return_value=65534 - ), patch.object( - driver, "_find_ancestor_private_socket", return_value="" - ), patch.object( - driver, - "_find_available_private_socket", - return_value="/tmp/fenrirscreenreader-222.sock", - ) as find_available, patch.object( - driver, "_forward_remote_to_socket", return_value=True - ) as forward: - driver._handle_client( - client_sock, event_queue, unixDriver.MAIN_SOCKET_FILE - ) - - find_available.assert_called_once_with("", owner_uid=1000) - forward.assert_called_once_with( - "command say x session", "/tmp/fenrirscreenreader-222.sock" - ) - event_queue.put.assert_not_called() - - -def test_vcsa_main_socket_stays_local_without_peer_credentials( - mock_environment, -): - driver = unixDriver.driver() - driver.env = mock_environment - driver.bound_sockets = [(Mock(), unixDriver.MAIN_SOCKET_FILE)] - client_sock = FakeClientSocket(b"command say unknown") - event_queue = Mock() - - with patch.object(driver, "_get_peer_uid", return_value=-1), patch.object( - driver, "_find_available_private_socket" - ) as find_available: - driver._handle_client( - client_sock, event_queue, unixDriver.MAIN_SOCKET_FILE - ) - - find_available.assert_not_called() - event_queue.put.assert_called_once_with( - { - "Type": unixDriver.FenrirEventType.remote_incomming, - "data": "command say unknown", - } - ) - - -def test_private_socket_handles_command_locally(mock_environment): - driver = unixDriver.driver() - driver.env = mock_environment - client_sock = FakeClientSocket(b"command say private") - event_queue = Mock() - - driver._handle_client( - client_sock, event_queue, "/tmp/fenrirscreenreader-222.sock" - ) - - event_queue.put.assert_called_once_with( - { - "Type": unixDriver.FenrirEventType.remote_incomming, - "data": "command say private", - } - ) - - -def test_list_command_still_returns_response_from_main_socket(mock_environment): - driver = unixDriver.driver() - driver.env = mock_environment - mock_environment["runtime"]["RemoteManager"] = Mock() - client_sock = FakeClientSocket(b"command list") - event_queue = Mock() - mock_environment["runtime"][ - "RemoteManager" - ].handle_remote_incomming_with_response.return_value = { - "success": True, - "message": "pid=1", - } - - driver._handle_client(client_sock, event_queue, unixDriver.MAIN_SOCKET_FILE) - - assert client_sock.sent == b"pid=1\n" - event_queue.put.assert_not_called() - - -def test_get_parent_pid_parses_process_names_with_spaces(): - driver = unixDriver.driver() - - stat_file = mock_open( - read_data="1234 (name with spaces) S 567 1 1 0 -1 0\n" - ) - with patch("builtins.open", stat_file): - assert driver._get_parent_pid(1234) == 567 - - -def test_find_available_private_socket_prefers_ancestor_socket( - mock_environment, -): - driver = unixDriver.driver() - driver.env = mock_environment - - with patch( - "fenrirscreenreader.remoteDriver.unixDriver.remoteInstanceRegistry.list_instances", - return_value=[ - { - "pid": 111, - "socket_files": ["/tmp/fenrirscreenreader-111.sock"], - }, - { - "pid": 222, - "socket_files": ["/tmp/fenrirscreenreader-222.sock"], - }, - ], - ), patch.object(driver, "_is_socket_active", return_value=True): - assert ( - driver._find_available_private_socket( - "/tmp/fenrirscreenreader-222.sock" - ) - == "/tmp/fenrirscreenreader-222.sock" - ) - - -def test_find_available_private_socket_skips_main_socket( - mock_environment, -): - driver = unixDriver.driver() - driver.env = mock_environment - - with patch( - "fenrirscreenreader.remoteDriver.unixDriver.remoteInstanceRegistry.list_instances", - return_value=[ - { - "pid": 111, - "socket_files": [ - unixDriver.MAIN_SOCKET_FILE, - "/tmp/fenrirscreenreader-111.sock", - ], - } - ], - ), patch.object(driver, "_is_socket_active", return_value=True): - assert ( - driver._find_available_private_socket() - == "/tmp/fenrirscreenreader-111.sock" - ) - - -def test_find_available_private_socket_filters_by_owner(mock_environment): - driver = unixDriver.driver() - driver.env = mock_environment - - with patch( - "fenrirscreenreader.remoteDriver.unixDriver.remoteInstanceRegistry.list_instances", - return_value=[ - { - "pid": 111, - "socket_files": ["/tmp/fenrirscreenreader-111.sock"], - }, - { - "pid": 222, - "socket_files": ["/tmp/fenrirscreenreader-222.sock"], - }, - ], - ), patch.object( - driver, - "_get_socket_owner_uid", - side_effect=lambda socket_file: { - "/tmp/fenrirscreenreader-111.sock": 1001, - "/tmp/fenrirscreenreader-222.sock": 1000, - }[socket_file], - ), patch.object(driver, "_is_socket_active", return_value=True): - assert ( - driver._find_available_private_socket(owner_uid=1000) - == "/tmp/fenrirscreenreader-222.sock" - ) diff --git a/tests/unit/test_vcsa_driver.py b/tests/unit/test_vcsa_driver.py deleted file mode 100644 index a2f1609d..00000000 --- a/tests/unit/test_vcsa_driver.py +++ /dev/null @@ -1,70 +0,0 @@ -from unittest.mock import Mock - -from fenrirscreenreader.screenDriver.vcsaDriver import driver as VcsaDriver - - -class BulkReadFailure: - def __init__(self, chunks): - self.chunks = iter(chunks) - self.readline_calls = 0 - - def seek(self, offset): - assert offset == 0 - - def read(self): - raise OSError(22, "Invalid argument") - - def readline(self, size): - assert size == 1 - self.readline_calls += 1 - if self.readline_calls > 3: - raise RuntimeError("read continued after EOF") - return next(self.chunks) - - -class TransientInvalidArgumentRead: - def __init__(self, payload): - self.payload = payload - self.read_calls = 0 - self.readline_calls = 0 - - def seek(self, offset): - assert offset == 0 - - def read(self): - self.read_calls += 1 - if self.read_calls == 1: - raise OSError(22, "Invalid argument") - return self.payload - - def readline(self, size): - self.readline_calls += 1 - return b"" - - -def build_vcsa_driver(): - vcsa_driver = VcsaDriver.__new__(VcsaDriver) - vcsa_driver.env = { - "runtime": { - "DebugManager": Mock(), - } - } - vcsa_driver.read_retry_delay = 0 - return vcsa_driver - - -def test_vcsa_read_fallback_stops_at_eof(): - vcsa_driver = build_vcsa_driver() - screen_file = BulkReadFailure([b"a", b"b", b""]) - - assert vcsa_driver.read_file(screen_file) == b"ab" - assert screen_file.readline_calls == 3 - - -def test_vcsa_read_retries_transient_invalid_argument(): - vcsa_driver = build_vcsa_driver() - screen_file = TransientInvalidArgumentRead(b"\x01\x02\x00\x00x") - - assert vcsa_driver.read_file(screen_file) == b"\x01\x02\x00\x00x" - assert screen_file.read_calls == 2 - assert screen_file.readline_calls == 0 diff --git a/tests/unit/test_vmenu_settings_actions.py b/tests/unit/test_vmenu_settings_actions.py deleted file mode 100644 index 866ce315..00000000 --- a/tests/unit/test_vmenu_settings_actions.py +++ /dev/null @@ -1,233 +0,0 @@ -from configparser import ConfigParser -import importlib.util -from pathlib import Path -from unittest.mock import Mock - -import pytest - -from fenrirscreenreader.commands.commands import save_settings -from fenrirscreenreader.core import settingsManager - - -def load_action(relative_path): - candidates = [] - for parent in Path(__file__).resolve().parents: - candidates.append(parent / "src/fenrirscreenreader") - candidates.extend(Path(settingsManager.__file__).resolve().parents) - for root in candidates: - path = root / "commands/vmenu-profiles/KEY/fenrir" / relative_path - if path.is_file(): - spec = importlib.util.spec_from_file_location("vmenu_action", path) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module.command() - pytest.fail("Cannot locate bundled VMenu action " + relative_path) - - -@pytest.fixture -def environment(tmp_path): - config = ConfigParser() - config.read_dict( - { - "keyboard": {"word_echo": "False", "grab_devices": "True"}, - "sound": {"enabled": "True", "volume": "0.7"}, - } - ) - settings = settingsManager.SettingsManager() - settings.settings_file = str(tmp_path / "settings.conf") - settings.save_settings_path = settings.settings_file - env = { - "settings": config, - "runtime": { - "SettingsManager": settings, - "OutputManager": Mock(), - "DebugManager": Mock(), - }, - } - settings.initialize(env) - return env - - -@pytest.mark.parametrize( - "action_path,section,setting", - [ - ("keyboard/toggle_word_echo.py", "keyboard", "word_echo"), - ("sound/toggle_enabled.py", "sound", "enabled"), - ], -) -@pytest.mark.parametrize("initial", [False, True]) -def test_boolean_actions_toggle_effective_setting_twice( - environment, action_path, section, setting, initial -): - settings = environment["runtime"]["SettingsManager"] - settings.set_setting(section, setting, str(initial)) - action = load_action(action_path) - action.initialize(environment) - action.run() - assert settings.get_setting_as_bool(section, setting) is not initial - action.run() - assert settings.get_setting_as_bool(section, setting) is initial - assert not Path(settings.settings_file).exists() - - -@pytest.mark.parametrize( - "action_path,section,setting", - [ - ("keyboard/toggle_word_echo.py", "keyboard", "word_echo"), - ("sound/toggle_enabled.py", "sound", "enabled"), - ], -) -@pytest.mark.parametrize("failure", ["exception", "ignored"]) -def test_boolean_actions_report_failed_write( - environment, monkeypatch, action_path, section, setting, failure -): - settings = environment["runtime"]["SettingsManager"] - initial = settings.get_setting_as_bool(section, setting) - monkeypatch.setattr( - settings, - "set_setting", - Mock( - side_effect=ValueError("Write rejected") if failure == "exception" else None - ), - ) - action = load_action(action_path) - action.initialize(environment) - action.run() - assert settings.get_setting_as_bool(section, setting) is initial - texts = [ - call.args[0] - for call in environment["runtime"]["OutputManager"].present_text.call_args_list - ] - assert any("Failed to change" in text for text in texts) - assert not any( - text.endswith("enabled") or text.endswith("disabled") for text in texts - ) - - -@pytest.mark.parametrize("initial", [False, True]) -def test_grab_action_defers_until_saved_and_restarted(environment, initial): - settings = environment["runtime"]["SettingsManager"] - settings.set_setting("keyboard", "grab_devices", str(initial)) - action = load_action("keyboard/toggle_grab_devices.py") - action.initialize(environment) - action.run() - assert settings.get_grab_devices_for_restart() is not initial - assert settings.get_setting_as_bool("keyboard", "grab_devices") is initial - assert settings.get_setting("keyboard", "grab_devices") == str(initial) - assert not Path(settings.settings_file).exists() - - settings.set_setting("keyboard", "word_echo", "True") - save_action = load_action("management/save_config.py") - save_action.initialize(environment) - save_action.run() - persisted = ConfigParser() - persisted.read(settings.settings_file) - assert persisted.getboolean("keyboard", "grab_devices") is not initial - assert persisted.getboolean("keyboard", "word_echo") is True - assert settings.get_setting_as_bool("keyboard", "grab_devices") is initial - - # Even a reload must leave cleanup using the active device-grabbing mode. - assert settings.load_settings(settings.settings_file) - assert settings.get_setting_as_bool("keyboard", "grab_devices") is initial - restarted = settingsManager.SettingsManager() - restarted.initialize({"settings": persisted}) - assert restarted.get_setting_as_bool("keyboard", "grab_devices") is not initial - - -def test_grab_action_can_cancel_a_pending_change(environment): - settings = environment["runtime"]["SettingsManager"] - action = load_action("keyboard/toggle_grab_devices.py") - action.initialize(environment) - action.run() - action.run() - assert settings.get_grab_devices_for_restart() is True - assert settings.save_settings() - config = ConfigParser() - config.read(settings.settings_file) - assert config.getboolean("keyboard", "grab_devices") is True - - -@pytest.mark.parametrize( - "initial,expected", [(0.0, 0.1), (0.7, 0.8), (0.95, 1.0), (1.0, 0.9)] -) -def test_volume_action_adjusts_and_reverses_at_maximum(environment, initial, expected): - settings = environment["runtime"]["SettingsManager"] - settings.set_setting("sound", "volume", str(initial)) - action = load_action("sound/adjust_volume.py") - action.initialize(environment) - action.run() - assert settings.get_setting_as_float("sound", "volume") == expected - assert settings.get_setting_as_bool("sound", "enabled") is True - assert not Path(settings.settings_file).exists() - - -def test_volume_action_reports_rejected_write(environment, monkeypatch): - settings = environment["runtime"]["SettingsManager"] - monkeypatch.setattr(settings, "set_setting", Mock()) - action = load_action("sound/adjust_volume.py") - action.initialize(environment) - action.run() - assert settings.get_setting_as_float("sound", "volume") == 0.7 - texts = [ - call.args[0] - for call in environment["runtime"]["OutputManager"].present_text.call_args_list - ] - assert "Failed to change sound volume" in texts - assert not any("Sound volume set" in text for text in texts) - - -@pytest.mark.parametrize( - "action_path", - [None, "management/save_config.py", "management/save_session_settings.py"], -) -def test_save_actions_report_failure(environment, action_path): - settings = environment["runtime"]["SettingsManager"] - # Writing over a directory fails without relying on permissions or root. - settings.save_settings_path = str(Path(settings.settings_file).parent) - action = ( - save_settings.command() if action_path is None else load_action(action_path) - ) - action.initialize(environment) - action.run() - texts = [ - call.args[0] - for call in environment["runtime"]["OutputManager"].present_text.call_args_list - ] - assert any("Failed to save" in text for text in texts) - assert not any( - "saved successfully" in text or text == "Settings saved." for text in texts - ) - - -def test_normal_save_command_persists_staged_grab_choice(environment): - settings = environment["runtime"]["SettingsManager"] - settings.stage_grab_devices(False) - action = save_settings.command() - action.initialize(environment) - action.run() - config = ConfigParser() - config.read(settings.settings_file) - assert config.getboolean("keyboard", "grab_devices") is False - assert settings.get_setting_as_bool("keyboard", "grab_devices") is True - - -@pytest.mark.parametrize( - "action_path,setting", - [ - ("keyboard/toggle_word_echo.py", "word_echo"), - ("keyboard/toggle_grab_devices.py", "grab_devices"), - ], -) -def test_keyboard_actions_save_when_config_uses_section_defaults( - environment, action_path, setting -): - environment["settings"].remove_section("keyboard") - settings = environment["runtime"]["SettingsManager"] - action = load_action(action_path) - action.initialize(environment) - initial = settings.get_setting_as_bool("keyboard", setting) - action.run() - assert settings.save_settings() - config = ConfigParser() - config.read(settings.settings_file) - assert config.getboolean("keyboard", setting) is not initial diff --git a/tests/unit/test_x11_terminal_mode.py b/tests/unit/test_x11_terminal_mode.py deleted file mode 100644 index e93f4198..00000000 --- a/tests/unit/test_x11_terminal_mode.py +++ /dev/null @@ -1,651 +0,0 @@ -import importlib.machinery -import importlib.util -from pathlib import Path -from unittest.mock import Mock - -import pytest - -from fenrirscreenreader.inputDriver.x11Driver import X -from fenrirscreenreader.inputDriver.x11Driver import XK -from fenrirscreenreader.inputDriver.x11Driver import driver as X11Driver - - -def load_fenrir_entrypoint(): - fenrir_path = Path(__file__).parents[2] / "src" / "fenrir" - loader = importlib.machinery.SourceFileLoader( - "fenrir_entrypoint_x11", str(fenrir_path) - ) - spec = importlib.util.spec_from_loader(loader.name, loader) - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - return module - - -@pytest.mark.unit -def test_x11_mode_runs_in_foreground(): - fenrir = load_fenrir_entrypoint() - args = fenrir.create_argument_parser().parse_args(["-x"]) - - assert fenrir.should_run_foreground(args) is True - - -@pytest.mark.unit -def test_removed_emulated_pty_flag_is_rejected(): - fenrir = load_fenrir_entrypoint() - with pytest.raises(SystemExit): - fenrir.create_argument_parser().parse_args(["-e"]) - - -@pytest.mark.unit -def test_removed_emulated_evdev_flag_is_rejected(): - fenrir = load_fenrir_entrypoint() - with pytest.raises(SystemExit): - fenrir.create_argument_parser().parse_args(["-E"]) - - -@pytest.mark.unit -def test_x11_cli_accepts_window_id(): - fenrir = load_fenrir_entrypoint() - args = fenrir.create_argument_parser().parse_args( - ["-x", "--x11-window-id", "0x123"] - ) - - assert args.x11_window_id == "0x123" - - -@pytest.mark.unit -def test_x11_window_id_requires_x11_mode(): - fenrir = load_fenrir_entrypoint() - args = fenrir.create_argument_parser().parse_args( - ["--x11-window-id", "0x123"] - ) - - is_valid, error = fenrir.validate_arguments(args) - - assert is_valid is False - assert "--x11-window-id requires --x11" == error - - -@pytest.mark.unit -def test_x11_key_name_mapping_for_keypad_and_capslock(): - x11 = X11Driver() - - assert x11.keysym_name_to_key_name("KP_0") == "KEY_KP0" - assert x11.keysym_name_to_key_name("KP_Insert") == "KEY_KP0" - assert x11.keysym_name_to_key_name("Caps_Lock") == "KEY_CAPSLOCK" - assert x11.keysym_name_to_key_name("Super_L") == "KEY_LEFTMETA" - assert x11.keysym_name_to_key_name("Multi_key") == "KEY_COMPOSE" - assert x11.keysym_name_to_key_name("Next") == "KEY_PAGEDOWN" - assert x11.keysym_name_to_key_name("Prior") == "KEY_PAGEUP" - assert x11.keysym_name_to_key_name("a") == "KEY_A" - assert x11.keysym_name_to_key_name("F10") == "KEY_F10" - assert x11.keysym_name_to_key_name("plus") == "KEY_EQUAL" - assert x11.keysym_name_to_key_name("+") == "KEY_EQUAL" - assert x11.keysym_name_to_key_name("underscore") == "KEY_MINUS" - assert x11.keysym_name_to_key_name("_") == "KEY_MINUS" - - -@pytest.mark.unit -@pytest.mark.parametrize( - ("keysym_name", "key_name"), - [ - ("BackSpace", "KEY_BACKSPACE"), - ("Tab", "KEY_TAB"), - ("Return", "KEY_ENTER"), - ("Escape", "KEY_ESC"), - ("Delete", "KEY_DELETE"), - ], -) -def test_x11_special_keysyms_use_symbolic_names(keysym_name, key_name): - x11 = X11Driver() - resolved_name = x11.keysym_to_name(XK.string_to_keysym(keysym_name)) - - assert resolved_name == keysym_name - assert x11.keysym_name_to_key_name(resolved_name) == key_name - - -@pytest.mark.unit -def test_x11_keycode_mapping_prefers_keypad_aliases(): - x11 = X11Driver() - x11.display = Mock() - keysyms = { - 0: XK.string_to_keysym("Up"), - 1: XK.string_to_keysym("KP_8"), - 2: 0, - 3: 0, - 4: 0, - 5: 0, - 6: 0, - 7: 0, - } - x11.display.keycode_to_keysym.side_effect = lambda keycode, column: keysyms[column] - - assert x11.keycode_to_key_name(80) == "KEY_KP8" - - -@pytest.mark.unit -def test_x11_keycode_mapping_preserves_plain_insert(): - x11 = X11Driver() - x11.display = Mock() - keysyms = { - 0: XK.string_to_keysym("Insert"), - 1: 0, - 2: 0, - 3: 0, - 4: 0, - 5: 0, - 6: 0, - 7: 0, - } - x11.display.keycode_to_keysym.side_effect = lambda keycode, column: keysyms[column] - - assert x11.keycode_to_key_name(118) == "KEY_INSERT" - - -@pytest.mark.unit -def test_x11_keycode_mapping_detects_keypad_insert(): - x11 = X11Driver() - x11.display = Mock() - keysyms = { - 0: XK.string_to_keysym("Insert"), - 1: XK.string_to_keysym("KP_Insert"), - 2: 0, - 3: 0, - 4: 0, - 5: 0, - 6: 0, - 7: 0, - } - x11.display.keycode_to_keysym.side_effect = lambda keycode, column: keysyms[column] - - assert x11.keycode_to_key_name(90) == "KEY_KP0" - - -@pytest.mark.unit -def test_x11_key_name_to_keysym_names_includes_numlock_aliases(): - x11 = X11Driver() - - assert x11.key_name_to_keysym_names("KEY_KP0") == ["KP_0", "KP_Insert"] - assert "KP_Delete" in x11.key_name_to_keysym_names("KEY_KPDOT") - assert "Caps_Lock" in x11.key_name_to_keysym_names("KEY_CAPSLOCK") - assert "Super_L" in x11.key_name_to_keysym_names("KEY_META") - assert "Meta_R" in x11.key_name_to_keysym_names("KEY_META") - assert x11.key_name_to_keysym_names("KEY_COMPOSE") == ["Multi_key"] - assert "Next" in x11.key_name_to_keysym_names("KEY_PAGEDOWN") - assert "Prior" in x11.key_name_to_keysym_names("KEY_PAGEUP") - assert x11.key_name_to_keysym_names("KEY_EQUAL") == ["equal", "plus"] - assert x11.key_name_to_keysym_names("KEY_MINUS") == [ - "minus", - "underscore", - ] - - -@pytest.mark.unit -def test_x11_should_emit_unbound_mapped_keys_for_speech_interrupt(): - x11 = X11Driver() - x11.interesting_keys = set() - - assert x11.should_emit_key("KEY_ESC") is True - assert x11.should_emit_key("KEY_ENTER") is True - - -@pytest.mark.unit -@pytest.mark.parametrize( - ("keysym_name", "key_name"), - [ - ("-", "KEY_MINUS"), - ("=", "KEY_EQUAL"), - ("[", "KEY_LEFTBRACE"), - ("]", "KEY_RIGHTBRACE"), - ("\\", "KEY_BACKSLASH"), - (";", "KEY_SEMICOLON"), - ("'", "KEY_APOSTROPHE"), - ("`", "KEY_GRAVE"), - (",", "KEY_COMMA"), - (".", "KEY_DOT"), - ("/", "KEY_SLASH"), - ], -) -def test_x11_maps_literal_punctuation_keysyms(keysym_name, key_name): - x11 = X11Driver() - - assert x11.keysym_name_to_key_name(keysym_name) == key_name - - -@pytest.mark.unit -def test_x11_build_passive_grabs_for_fenrir_keys_and_shortcuts(): - x11 = X11Driver() - input_manager = Mock(convert_event_name=lambda key: key) - x11.env = { - "input": { - "fenrir_key": ["KEY_KP0", "KEY_META"], - "script_key": [], - }, - "rawBindings": { - "fenrir_combo": [1, ["KEY_FENRIR", "KEY_KP8"]], - "bare_keypad": [1, ["KEY_KP5"]], - "ctrl_keypad": [1, ["KEY_CTRL", "KEY_KP2"]], - "meta_combo": [1, ["KEY_FENRIR", "KEY_BACKSPACE"]], - }, - "runtime": {"InputManager": input_manager}, - } - - grabs = x11.build_passive_grabs() - - assert ("KEY_KP0", 0, True) in grabs - assert ("KEY_META", 0, True) in grabs - assert ("KEY_NUMLOCK", 0, True) in grabs - assert ("KEY_KP5", 0, False) in grabs - assert ("KEY_KP2", X.ControlMask, False) in grabs - assert ("KEY_BACKSPACE", X.Mod4Mask, True) in grabs - - -@pytest.mark.unit -@pytest.mark.parametrize( - "modifier_mask", - [X.ControlMask, X.ShiftMask, X.Mod1Mask], -) -def test_x11_poll_modifier_interrupt_keys_interrupts_without_input_events( - modifier_mask, -): - x11 = X11Driver() - x11.active = True - x11.modifier_interrupt_state = 0 - x11.modifier_state = 0 - x11.root = Mock() - x11.root.query_pointer.return_value = Mock(mask=modifier_mask) - settings_manager = Mock() - settings_manager.get_setting_as_bool.return_value = True - settings_manager.get_setting.return_value = "" - output_manager = Mock() - x11.env = { - "input": {"event_buffer": []}, - "runtime": { - "SettingsManager": settings_manager, - "OutputManager": output_manager, - "DebugManager": Mock(), - }, - } - - x11.poll_modifier_interrupt_keys() - - output_manager.interrupt_output_async.assert_called_once() - assert x11.env["input"]["event_buffer"] == [] - - output_manager.interrupt_output_async.reset_mock() - x11.root.query_pointer.return_value = Mock(mask=0) - - x11.poll_modifier_interrupt_keys() - - output_manager.interrupt_output_async.assert_not_called() - assert x11.env["input"]["event_buffer"] == [] - - -@pytest.mark.unit -def test_x11_optional_modifier_masks_can_exclude_numlock(): - x11 = X11Driver() - x11.num_lock_mask = X.Mod2Mask - - masks = x11.optional_modifier_masks(0, include_num_lock=False) - - assert X.Mod2Mask not in masks - assert X.Mod2Mask | X.LockMask not in masks - - -@pytest.mark.unit -def test_x11_get_led_state_reads_cached_lock_modifiers_without_x_round_trip(): - x11 = X11Driver() - x11.num_lock_mask = X.Mod2Mask - x11.modifier_state = X.Mod2Mask | X.LockMask - x11.root = Mock() - - assert x11.get_led_state(0) is True - assert x11.get_led_state(1) is True - assert x11.get_led_state(2) is False - x11.root.query_pointer.assert_not_called() - - -@pytest.mark.unit -def test_x11_update_modifier_state_tracks_event_masks_and_lock_toggles(): - x11 = X11Driver() - x11.num_lock_mask = X.Mod2Mask - - x11.update_modifier_state_from_event( - { - "event_name": "KEY_KP8", - "event_state": 1, - "event_raw_state": X.Mod2Mask, - } - ) - assert x11.get_led_state(0) is True - - x11.update_modifier_state_from_event( - { - "event_name": "KEY_NUMLOCK", - "event_state": 1, - "event_raw_state": X.Mod2Mask, - } - ) - assert x11.get_led_state(0) is False - - x11.update_modifier_state_from_event( - { - "event_name": "KEY_CAPSLOCK", - "event_state": 1, - "event_raw_state": 0, - } - ) - assert x11.get_led_state(1) is True - - -@pytest.mark.unit -def test_x11_parse_window_id_accepts_decimal_and_hex(): - x11 = X11Driver() - - assert x11.parse_window_id("123") == 123 - assert x11.parse_window_id("0x123") == 0x123 - assert x11.parse_window_id("") is None - - -@pytest.mark.unit -def test_x11_write_event_buffer_replays_grabbed_key_press(): - x11 = X11Driver() - x11._initialized = True - x11.display = Mock() - x11.env = { - "input": { - "event_buffer": [ - { - "event_name": "KEY_LEFTMETA", - "event_state": 1, - "event_type": X.KeyPress, - "event_x_time": 1234, - } - ] - } - } - - x11.write_event_buffer() - - x11.display.allow_events.assert_called_once_with(X.ReplayKeyboard, 1234) - assert x11.env["input"]["event_buffer"] == [] - - -@pytest.mark.unit -def test_x11_write_event_buffer_does_not_replay_key_release(): - x11 = X11Driver() - x11._initialized = True - x11.display = Mock() - x11.env = { - "input": { - "event_buffer": [ - { - "event_name": "KEY_LEFTMETA", - "event_state": 0, - "event_type": X.KeyRelease, - "event_x_time": 1235, - } - ] - } - } - - x11.write_event_buffer() - - x11.display.allow_events.assert_not_called() - assert x11.env["input"]["event_buffer"] == [] - - -@pytest.mark.unit -def test_x11_help_capture_grabs_and_releases_keyboard(): - x11 = X11Driver() - x11._initialized = True - x11.window = Mock() - x11.window.grab_keyboard.return_value = X.GrabSuccess - x11.display = Mock() - x11.env = {"runtime": {"DebugManager": Mock()}} - - assert x11.set_help_capture(True) is True - x11.window.grab_keyboard.assert_called_once_with( - False, - X.GrabModeAsync, - X.GrabModeAsync, - X.CurrentTime, - ) - assert x11.help_keyboard_grabbed is True - - assert x11.set_help_capture(False) is True - x11.display.ungrab_keyboard.assert_called_once_with(X.CurrentTime) - x11.display.sync.assert_called_once_with() - assert x11.help_keyboard_grabbed is False - - -@pytest.mark.unit -def test_x11_help_capture_reports_failed_grab(): - x11 = X11Driver() - x11._initialized = True - x11.window = Mock() - x11.window.grab_keyboard.return_value = X.AlreadyGrabbed - x11.display = Mock() - x11.env = {"runtime": {"DebugManager": Mock()}} - - assert x11.set_help_capture(True) is False - assert x11.help_keyboard_grabbed is False - - -@pytest.mark.unit -def test_x11_help_capture_retries_failed_keyboard_release(): - x11 = X11Driver() - x11._initialized = True - x11.help_keyboard_grabbed = True - x11.window = Mock() - x11.display = Mock() - x11.display.sync.side_effect = RuntimeError("ungrab failed") - x11.env = {"runtime": {"DebugManager": Mock()}} - - assert x11.set_help_capture(False) is False - assert x11.display.ungrab_keyboard.call_count == 3 - assert x11.help_keyboard_grabbed is True - - -@pytest.mark.unit -def test_x11_map_event_keeps_x_event_time_for_replay(): - x11 = X11Driver() - x11.keycode_to_key_name = Mock(return_value="KEY_LEFTMETA") - event = Mock(type=X.KeyPress, detail=133, state=X.Mod4Mask, time=5678) - - input_event = x11.map_event(event) - - assert input_event["event_x_time"] == 5678 - - -@pytest.mark.unit -def test_x11_find_num_lock_mask_uses_modifier_mapping(): - x11 = X11Driver() - x11.display = Mock() - x11.display.keysym_to_keycode.return_value = 77 - x11.display.get_modifier_mapping.return_value = [ - [], - [], - [], - [], - [77], - [], - [], - [], - ] - - assert x11.find_num_lock_mask() == X.Mod2Mask - - -@pytest.mark.unit -def test_x11_handle_key_event_keeps_event_buffer_for_input_manager(): - x11 = X11Driver() - x11.active = True - x11.interesting_keys = {"KEY_KP0"} - x11.env = { - "input": { - "curr_input": [], - "event_buffer": [], - }, - "runtime": { - "InputManager": Mock(convert_event_name=lambda key: key), - "DebugManager": Mock(), - }, - } - event_queue = Mock() - event = Mock(type=X.KeyPress, detail=90, state=0) - x11.keycode_to_key_name = Mock(return_value="KEY_KP0") - - x11.handle_x_event(event, event_queue) - - assert x11.env["input"]["event_buffer"][0]["event_name"] == "KEY_KP0" - event_queue.put.assert_called_once() - - -@pytest.mark.unit -@pytest.mark.parametrize( - ("event_type", "event_mode"), - [ - (X.FocusOut, X.NotifyGrab), - (X.FocusIn, X.NotifyUngrab), - ], -) -def test_x11_transient_grab_focus_events_preserve_input_state( - event_type, event_mode -): - x11 = X11Driver() - x11.active = True - x11.clear_event_buffer = Mock() - x11.reset_input_state = Mock() - event = Mock(type=event_type, mode=event_mode) - - x11.handle_x_event(event, Mock()) - - assert x11.active is True - x11.clear_event_buffer.assert_not_called() - x11.reset_input_state.assert_not_called() - - -@pytest.mark.unit -def test_x11_normal_focus_out_resets_input_state(): - x11 = X11Driver() - x11.active = True - x11.reset_input_state = Mock() - output_manager = Mock() - x11.env = {"runtime": {"OutputManager": output_manager}} - event = Mock(type=X.FocusOut, mode=X.NotifyNormal) - - x11.handle_x_event(event, Mock()) - - assert x11.active is False - x11.reset_input_state.assert_called_once_with() - output_manager.interrupt_output_async.assert_called_once_with() - - -@pytest.mark.unit -def test_x11_reports_target_focus_state(): - x11 = X11Driver() - x11.active = False - - assert x11.is_active() is False - - x11.active = True - - assert x11.is_active() is True - - -@pytest.mark.unit -def test_x11_fenrir_press_emits_modifiers_from_raw_state_first(): - x11 = X11Driver() - x11.active = True - x11.fenrir_keys = {"KEY_KP0"} - x11.interesting_keys = {"KEY_KP0"} - x11.env = { - "input": { - "curr_input": [], - "event_buffer": [], - }, - "runtime": { - "InputManager": Mock(convert_event_name=lambda key: key), - "DebugManager": Mock(), - }, - } - event_queue = Mock() - event = Mock(type=X.KeyPress, detail=90, state=X.ControlMask) - x11.keycode_to_key_name = Mock(return_value="KEY_KP0") - - x11.handle_x_event(event, event_queue) - - modifier_event = event_queue.put.call_args_list[0].args[0] - fenrir_event = event_queue.put.call_args_list[1].args[0] - assert modifier_event["data"]["event_name"] == "KEY_CTRL" - assert modifier_event["data"]["event_state"] == 1 - assert fenrir_event["data"]["event_name"] == "KEY_KP0" - - -@pytest.mark.unit -def test_x11_fenrir_release_balances_synthesized_modifiers(): - x11 = X11Driver() - x11.active = True - x11.fenrir_keys = {"KEY_KP0"} - x11.interesting_keys = {"KEY_KP0"} - x11.command_key_active = True - x11.chord_modifiers = {"KEY_CTRL"} - x11.env = { - "input": { - "curr_input": ["KEY_CTRL", "KEY_FENRIR"], - "event_buffer": [], - }, - "runtime": { - "InputManager": Mock(convert_event_name=lambda key: key), - "DebugManager": Mock(), - }, - } - event_queue = Mock() - event = Mock(type=X.KeyRelease, detail=90, state=X.ControlMask) - x11.keycode_to_key_name = Mock(return_value="KEY_KP0") - - x11.handle_x_event(event, event_queue) - - fenrir_event = event_queue.put.call_args_list[0].args[0] - modifier_event = event_queue.put.call_args_list[1].args[0] - assert fenrir_event["data"]["event_name"] == "KEY_KP0" - assert modifier_event["data"]["event_name"] == "KEY_CTRL" - assert modifier_event["data"]["event_state"] == 0 - - -@pytest.mark.unit -def test_x11_fenrir_release_balances_held_chord_key(): - x11 = X11Driver() - x11.active = True - x11.fenrir_keys = {"KEY_KP0"} - x11.interesting_keys = {"KEY_KP0", "KEY_KP9"} - x11.env = { - "input": { - "curr_input": [], - "event_buffer": [], - }, - "runtime": { - "InputManager": Mock(convert_event_name=lambda key: key), - "DebugManager": Mock(), - }, - } - event_queue = Mock() - key_names = {90: "KEY_KP0", 81: "KEY_KP9"} - x11.keycode_to_key_name = Mock( - side_effect=lambda keycode, state=0: key_names[keycode] - ) - - x11.handle_x_event( - Mock(type=X.KeyPress, detail=90, state=0), event_queue - ) - x11.handle_x_event( - Mock(type=X.KeyPress, detail=81, state=0), event_queue - ) - x11.handle_x_event( - Mock(type=X.KeyRelease, detail=90, state=0), event_queue - ) - - synthetic_release = event_queue.put.call_args_list[-1].args[0] - assert synthetic_release["data"]["event_name"] == "KEY_KP9" - assert synthetic_release["data"]["event_state"] == 0 diff --git a/tests/unit/test_x_clipboard.py b/tests/unit/test_x_clipboard.py deleted file mode 100644 index e271348b..00000000 --- a/tests/unit/test_x_clipboard.py +++ /dev/null @@ -1,161 +0,0 @@ -import os -import subprocess -from unittest.mock import Mock - -import pytest - -from fenrirscreenreader.utils import x_clipboard - - -def command_exists(command): - if command == "xclip": - return "/usr/bin/xclip" - return None - - -@pytest.mark.unit -def test_write_text_uses_display_env_without_mutating_process_env( - monkeypatch, -): - monkeypatch.delenv("DISPLAY", raising=False) - monkeypatch.setattr(x_clipboard.shutil, "which", command_exists) - run_command = Mock( - return_value=subprocess.CompletedProcess( - ["xclip"], 0, stdout=b"", stderr=b"" - ) - ) - monkeypatch.setattr(x_clipboard.subprocess, "run", run_command) - - assert x_clipboard.write_text("clipboard text", ":3") is True - - command = run_command.call_args.args[0] - kwargs = run_command.call_args.kwargs - assert command == ["xclip", "-selection", "clipboard"] - assert kwargs["input"] == b"clipboard text" - assert kwargs["env"]["DISPLAY"] == ":3" - assert kwargs["timeout"] == 2.0 - assert os.environ.get("DISPLAY") is None - - -@pytest.mark.unit -def test_write_text_does_not_capture_daemonized_writer_pipes(monkeypatch): - monkeypatch.setattr(x_clipboard.shutil, "which", command_exists) - - def run_command(command, **kwargs): - if ( - kwargs["stdout"] == subprocess.PIPE - or kwargs["stderr"] == subprocess.PIPE - ): - raise subprocess.TimeoutExpired(command, 2.0) - return subprocess.CompletedProcess( - command, 0, stdout=None, stderr=None - ) - - monkeypatch.setattr(x_clipboard.subprocess, "run", run_command) - - assert x_clipboard.write_text("clipboard text", ":3") is True - - -@pytest.mark.unit -def test_read_text_scans_displays_until_text_is_found(monkeypatch): - monkeypatch.delenv("DISPLAY", raising=False) - monkeypatch.setattr(x_clipboard.shutil, "which", command_exists) - displays = [] - - def run_command(command, **kwargs): - displays.append(kwargs["env"].get("DISPLAY")) - if kwargs["env"].get("DISPLAY") == ":1": - return subprocess.CompletedProcess( - command, 0, stdout=b"from x", stderr=b"" - ) - return subprocess.CompletedProcess( - command, 1, stdout=b"", stderr=b"missing display" - ) - - monkeypatch.setattr(x_clipboard.subprocess, "run", run_command) - - assert x_clipboard.read_text(scan_displays=True) == "from x" - assert displays == [":0", ":1"] - - -@pytest.mark.unit -def test_read_text_empty_success_returns_none(monkeypatch): - monkeypatch.setenv("DISPLAY", ":2") - monkeypatch.setattr(x_clipboard.shutil, "which", command_exists) - monkeypatch.setattr( - x_clipboard.subprocess, - "run", - Mock( - return_value=subprocess.CompletedProcess( - ["xclip"], 0, stdout=b"", stderr=b"" - ) - ), - ) - - assert x_clipboard.read_text() is None - - -@pytest.mark.unit -def test_write_text_propagates_clipboard_timeout(monkeypatch): - monkeypatch.setattr(x_clipboard.shutil, "which", command_exists) - timeout = subprocess.TimeoutExpired(["xclip"], 2.0) - monkeypatch.setattr( - x_clipboard.subprocess, - "run", - Mock(side_effect=timeout), - ) - - with pytest.raises(subprocess.TimeoutExpired): - x_clipboard.write_text("clipboard text", ":3") - - -@pytest.mark.unit -@pytest.mark.parametrize("operation", ["read", "write"]) -def test_current_display_failure_does_not_scan_other_sessions( - monkeypatch, operation, -): - monkeypatch.setenv("DISPLAY", ":0") - monkeypatch.setattr(x_clipboard.shutil, "which", command_exists) - run_command = Mock(return_value=subprocess.CompletedProcess( - ["xclip"], 1, stdout=b"", stderr=b"Can't open display: :0", - )) - monkeypatch.setattr(x_clipboard.subprocess, "run", run_command) - - with pytest.raises(RuntimeError): - if operation == "read": - x_clipboard.read_text(scan_displays=True) - else: - x_clipboard.write_text("text", scan_displays=True) - - assert run_command.call_count == 1 - assert run_command.call_args.kwargs["env"]["DISPLAY"] == ":0" - - -@pytest.mark.unit -def test_empty_clipboard_stops_display_scan(monkeypatch): - monkeypatch.delenv("DISPLAY", raising=False) - monkeypatch.setattr(x_clipboard.shutil, "which", command_exists) - run_command = Mock(return_value=subprocess.CompletedProcess( - ["xclip"], 0, stdout=b"", stderr=b"", - )) - monkeypatch.setattr(x_clipboard.subprocess, "run", run_command) - - assert x_clipboard.read_text(scan_displays=True) is None - assert run_command.call_count == 1 - - -@pytest.mark.unit -@pytest.mark.parametrize("target", ["STRING", "UTF8_STRING"]) -def test_unavailable_text_selection_is_an_empty_clipboard( - monkeypatch, target, -): - monkeypatch.setenv("DISPLAY", ":0") - monkeypatch.setattr(x_clipboard.shutil, "which", command_exists) - run_command = Mock(return_value=subprocess.CompletedProcess( - ["xclip"], 1, stdout=b"", - stderr=f"Error: target {target} not available\n".encode(), - )) - monkeypatch.setattr(x_clipboard.subprocess, "run", run_command) - - assert x_clipboard.read_text(scan_displays=True) is None - assert run_command.call_count == 1 diff --git a/tools/cleanup_cache.py b/tools/cleanup_cache.py deleted file mode 100755 index 72786a5c..00000000 --- a/tools/cleanup_cache.py +++ /dev/null @@ -1,288 +0,0 @@ -#!/usr/bin/env python3 -""" -Fenrir Cache Cleanup Tool - -Removes Python cache files and directories from the repository. -These files should never be committed and can cause issues. - -Usage: - python3 tools/cleanup_cache.py # Show what would be removed - python3 tools/cleanup_cache.py --remove # Actually remove cache files - python3 tools/cleanup_cache.py --check # Exit with error if cache files found -""" - -import os -import sys -import argparse -import shutil -from pathlib import Path - - -class CacheCleanup: - def __init__(self, verbose=True): - self.verbose = verbose - self.cache_dirs = [] - self.cache_files = [] - - def log(self, message, level="INFO"): - """Log a message with appropriate formatting.""" - if not self.verbose and level == "INFO": - return - - colors = { - "INFO": "\033[0;36m", # Cyan - "SUCCESS": "\033[0;32m", # Green - "WARNING": "\033[1;33m", # Yellow - "ERROR": "\033[0;31m", # Red - "HEADER": "\033[1;34m", # Bold Blue - } - - reset = "\033[0m" - color = colors.get(level, "") - - if level == "HEADER": - print(f"\n{color}{'='*60}") - print(f"{message}") - print(f"{'='*60}{reset}") - else: - symbol = { - "SUCCESS": "āœ“", - "ERROR": "āœ—", - "WARNING": "⚠", - "INFO": "•" - }.get(level, "•") - - print(f"{color}{symbol} {message}{reset}") - - def find_cache_files(self, directory): - """Find all Python cache files and directories.""" - directory = Path(directory) - - for root, dirs, files in os.walk(directory): - root_path = Path(root) - - # Skip .git directory entirely - if '.git' in root_path.parts: - continue - - # Find __pycache__ directories - if '__pycache__' in dirs: - cache_dir = root_path / '__pycache__' - self.cache_dirs.append(cache_dir) - # Don't traverse into __pycache__ directories - dirs.remove('__pycache__') - - # Find .pyc files outside of __pycache__ - for file in files: - if file.endswith('.pyc'): - cache_file = root_path / file - self.cache_files.append(cache_file) - - def show_findings(self): - """Display what cache files were found.""" - total_items = len(self.cache_dirs) + len(self.cache_files) - - if total_items == 0: - self.log("No Python cache files found", "SUCCESS") - return True - - self.log(f"Found {total_items} cache items:", "WARNING") - - if self.cache_dirs: - self.log(f"\n__pycache__ directories ({len(self.cache_dirs)}):", "WARNING") - for cache_dir in sorted(self.cache_dirs): - # Show size of directory - size = self.get_directory_size(cache_dir) - self.log(f" {cache_dir} ({size} files)", "WARNING") - - if self.cache_files: - self.log(f"\nLoose .pyc files ({len(self.cache_files)}):", "WARNING") - for cache_file in sorted(self.cache_files): - # Show file size - try: - size = cache_file.stat().st_size - size_str = self.format_size(size) - self.log(f" {cache_file} ({size_str})", "WARNING") - except OSError: - self.log(f" {cache_file} (size unknown)", "WARNING") - - return False - - def get_directory_size(self, directory): - """Get the number of files in a directory.""" - try: - return len(list(directory.rglob('*'))) - except OSError: - return 0 - - def format_size(self, size_bytes): - """Format file size in human-readable format.""" - if size_bytes < 1024: - return f"{size_bytes} B" - elif size_bytes < 1024 * 1024: - return f"{size_bytes // 1024} KB" - else: - return f"{size_bytes // (1024 * 1024)} MB" - - def remove_cache_files(self): - """Actually remove the cache files and directories.""" - removed_count = 0 - errors = [] - - # Remove __pycache__ directories - for cache_dir in self.cache_dirs: - try: - if cache_dir.exists(): - shutil.rmtree(cache_dir) - self.log(f"Removed directory: {cache_dir}", "SUCCESS") - removed_count += 1 - except OSError as e: - error_msg = f"Failed to remove {cache_dir}: {e}" - errors.append(error_msg) - self.log(error_msg, "ERROR") - - # Remove .pyc files - for cache_file in self.cache_files: - try: - if cache_file.exists(): - cache_file.unlink() - self.log(f"Removed file: {cache_file}", "SUCCESS") - removed_count += 1 - except OSError as e: - error_msg = f"Failed to remove {cache_file}: {e}" - errors.append(error_msg) - self.log(error_msg, "ERROR") - - if errors: - self.log(f"Encountered {len(errors)} errors during cleanup", "ERROR") - return False - else: - self.log(f"Successfully removed {removed_count} cache items", "SUCCESS") - return True - - def check_gitignore(self): - """Check if .gitignore properly excludes cache files.""" - gitignore_path = Path('.gitignore') - if not gitignore_path.exists(): - self.log("Warning: No .gitignore file found", "WARNING") - return False - - try: - with open(gitignore_path, 'r') as f: - content = f.read() - - has_pycache = '__pycache__' in content or '__pycache__/' in content - has_pyc = '*.pyc' in content - - if has_pycache and has_pyc: - self.log("āœ“ .gitignore properly excludes Python cache files", "SUCCESS") - return True - else: - missing = [] - if not has_pycache: - missing.append("__pycache__/") - if not has_pyc: - missing.append("*.pyc") - - self.log(f"Warning: .gitignore missing: {', '.join(missing)}", "WARNING") - return False - - except OSError as e: - self.log(f"Could not read .gitignore: {e}", "ERROR") - return False - - def suggest_gitignore_additions(self): - """Suggest additions to .gitignore.""" - self.log("\nRecommended .gitignore entries for Python:", "INFO") - print(""" -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg -""") - - -def main(): - parser = argparse.ArgumentParser(description='Clean Python cache files from Fenrir repository') - parser.add_argument('--remove', action='store_true', - help='Actually remove cache files (default is dry-run)') - parser.add_argument('--check', action='store_true', - help='Exit with non-zero code if cache files found') - parser.add_argument('--quiet', action='store_true', - help='Reduce output verbosity') - parser.add_argument('--directory', default='.', - help='Directory to scan (default: current directory)') - - args = parser.parse_args() - - # Ensure we're in the project root - if not Path("src/fenrirscreenreader").exists(): - print("Error: Must be run from Fenrir project root directory") - sys.exit(1) - - cleanup = CacheCleanup(verbose=not args.quiet) - - cleanup.log("FENRIR CACHE CLEANUP", "HEADER") - cleanup.log(f"Scanning directory: {Path(args.directory).absolute()}") - - # Find cache files - cleanup.find_cache_files(args.directory) - - # Show what we found - no_cache_found = cleanup.show_findings() - - if no_cache_found: - # Check .gitignore anyway - cleanup.check_gitignore() - cleanup.log("\nāœ… Repository is clean of Python cache files", "SUCCESS") - sys.exit(0) - - # Check .gitignore - gitignore_ok = cleanup.check_gitignore() - if not gitignore_ok: - cleanup.suggest_gitignore_additions() - - # Handle different modes - if args.remove: - cleanup.log("\n🧹 REMOVING CACHE FILES", "HEADER") - success = cleanup.remove_cache_files() - if success: - cleanup.log("\nāœ… Cache cleanup completed successfully", "SUCCESS") - sys.exit(0) - else: - cleanup.log("\nāŒ Cache cleanup completed with errors", "ERROR") - sys.exit(1) - - elif args.check: - cleanup.log("\nāŒ Cache files found - validation failed", "ERROR") - cleanup.log("Run with --remove to clean up cache files", "INFO") - sys.exit(1) - - else: - # Dry run mode - cleanup.log("\nšŸ’” DRY RUN MODE", "HEADER") - cleanup.log("Add --remove to actually delete these files", "INFO") - cleanup.log("Add --check to fail if cache files are present", "INFO") - sys.exit(0) - - -if __name__ == '__main__': - main() diff --git a/tools/clipboard_sync.sh b/tools/clipboard_sync.sh deleted file mode 100755 index d0d012fb..00000000 --- a/tools/clipboard_sync.sh +++ /dev/null @@ -1,105 +0,0 @@ -#!/bin/bash - -# Fenrir X11 Clipboard Sync -# Synchronizes between X11 clipboard and Fenrir clipboard file -# Prevents loops using checksums and timestamps - -# Check for root privileges -if [[ $(whoami) != "root" ]]; then - echo "Error: This script must be run as root to access Fenrir's clipboard file" - echo "Run with: sudo DISPLAY=:0 ./clipboard_sync.sh" - exit 1 -fi - -FENRIR_CLIPBOARD_FILE="${1:-/tmp/fenrirClipboard}" -STATE_FILE="/var/tmp/.fenrir_clipboard_state" - -# Simple state tracking without complex locking -get_file_checksum() { - if [[ -f "$FENRIR_CLIPBOARD_FILE" ]]; then - md5sum "$FENRIR_CLIPBOARD_FILE" 2>/dev/null | cut -d' ' -f1 - else - echo "" - fi -} - -get_clipboard_checksum() { - xclip -o -selection clipboard 2>/dev/null | md5sum | cut -d' ' -f1 -} - -# Initialize state -rm -f "$STATE_FILE" 2>/dev/null - -echo "Starting Fenrir clipboard sync..." -echo "Monitoring file: $FENRIR_CLIPBOARD_FILE" - -# Check dependencies -if ! command -v xclip >/dev/null 2>&1; then - echo "Error: xclip is required but not installed" - echo "Install with: sudo apt install xclip" - exit 1 -fi - -if ! command -v inotifywait >/dev/null 2>&1; then - echo "Error: inotify-tools is required but not installed" - echo "Install with: sudo apt install inotify-tools" - exit 1 -fi - -# Create clipboard file if it doesn't exist -touch "$FENRIR_CLIPBOARD_FILE" - -echo "Starting Fenrir clipboard sync..." - -while true; do - # Read last state - if [[ -f "$STATE_FILE" ]]; then - read -r LAST_FILE_CHECKSUM LAST_CLIPBOARD_CHECKSUM LAST_UPDATE_TIME < "$STATE_FILE" - else - LAST_FILE_CHECKSUM="" - LAST_CLIPBOARD_CHECKSUM="" - LAST_UPDATE_TIME="0" - fi - - # Get current checksums - CURRENT_FILE_CHECKSUM=$(get_file_checksum) - CURRENT_CLIPBOARD_CHECKSUM=$(get_clipboard_checksum) - CURRENT_TIME=$(date +%s) - - # Skip update if we just made one (prevent immediate loops) - TIME_SINCE_LAST=$((CURRENT_TIME - LAST_UPDATE_TIME)) - if [[ $TIME_SINCE_LAST -lt 3 ]]; then - sleep 1 - continue - fi - - # Clipboard changed - if [[ "$CURRENT_CLIPBOARD_CHECKSUM" != "$LAST_CLIPBOARD_CHECKSUM" ]]; then - echo "X11 clipboard changed, updating file..." - if xclip -o -selection clipboard > "$FENRIR_CLIPBOARD_FILE" 2>/dev/null; then - CURRENT_FILE_CHECKSUM=$(get_file_checksum) - echo "$CURRENT_FILE_CHECKSUM $CURRENT_CLIPBOARD_CHECKSUM $CURRENT_TIME" > "$STATE_FILE" - echo "File updated successfully" - else - echo "Failed to update file from clipboard" - fi - sleep 1 - continue - fi - - # File changed - if [[ "$CURRENT_FILE_CHECKSUM" != "$LAST_FILE_CHECKSUM" ]]; then - echo "Fenrir clipboard file changed, updating X11 clipboard..." - if cat "$FENRIR_CLIPBOARD_FILE" | xclip -i -selection clipboard 2>/dev/null; then - CURRENT_CLIPBOARD_CHECKSUM=$(get_clipboard_checksum) - echo "$CURRENT_FILE_CHECKSUM $CURRENT_CLIPBOARD_CHECKSUM $CURRENT_TIME" > "$STATE_FILE" - echo "X11 clipboard updated successfully" - else - echo "Failed to update clipboard from file" - fi - sleep 1 - continue - fi - - sleep 1 -done \ No newline at end of file diff --git a/tools/deviceInfo.py b/tools/deviceInfo.py deleted file mode 100755 index a7a96180..00000000 --- a/tools/deviceInfo.py +++ /dev/null @@ -1,4 +0,0 @@ -from pyudev import Context -context = Context() -for device in context.list_devices(subsystem='input'): - print('{0} - {1}'.format(device.sys_name, device.device_type)) diff --git a/tools/fenrir-ignore-screen b/tools/fenrir-ignore-screen deleted file mode 100755 index fe005196..00000000 --- a/tools/fenrir-ignore-screen +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env python3 -import os, argparse - -def addScreenToIgnoreList(ignoreFileName = '/tmp/fenrirSuspend', screen = '1', useCurrentScreen = True): - if useCurrentScreen: - tty = open('/sys/devices/virtual/tty/tty0/active','r') - screen = str(tty.read()[3:-1]) - if not screen: - print('No screen given.') - ignoreScreens = [] - ignoreScreensStr = '' - if ignoreFileName != '': - if os.access(ignoreFileName, os.R_OK): - with open(ignoreFileName, 'r') as fp: - try: - ignoreScreens = [] - content = str(fp.read()) - if content != '': - ignoreScreens = content.split(',') - except Exception as e: - print(e) - added = False - if not screen in ignoreScreens: - ignoreScreens.append(screen) - added = True - ignoreScreensStr = ','.join(ignoreScreens) - - with open(ignoreFileName, 'w') as fp: - fp.write(ignoreScreensStr) - if added: - print('Add screen {0} to ignore file:{1}'.format(screen, ignoreFileName)) - else: - print('Screen {0} already exists in ignore file:{1}'.format(screen, ignoreFileName)) - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description='Ignore screens in fenrir. If no screen is given use current screen.') - parser.add_argument('-s', '--screen', metavar='SCREEN', default=None, help='Ignore a given screen. Use current screen as default.') - parser.add_argument('-f', '--file', metavar='File', default='/tmp/fenrirSuspend', help='Specify the suspendingScreenFile') - try: - args = parser.parse_args() - ignoreFileName = args.file - useCurrentScreen = False - screen = None - if args.screen: - screen = args.screen - else: - useCurrentScreen = True - addScreenToIgnoreList(ignoreFileName, screen, useCurrentScreen) - except Exception as e: - parser.print_help() diff --git a/tools/fenrir-unignore-screen b/tools/fenrir-unignore-screen deleted file mode 100755 index 84727a72..00000000 --- a/tools/fenrir-unignore-screen +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env python3 -import os, argparse - -def removeScreenFromIgnoreList(ignoreFileName = '/tmp/fenrirSuspend', screen = '1', useCurrentScreen = True): - if useCurrentScreen: - tty = open('/sys/devices/virtual/tty/tty0/active','r') - screen = str(tty.read()[3:-1]) - if not screen: - print('No screen given.') - ignoreScreens = [] - ignoreScreensStr = '' - if ignoreFileName != '': - if os.access(ignoreFileName, os.R_OK): - with open(ignoreFileName, 'r') as fp: - try: - ignoreScreens = [] - content = str(fp.read()) - if content != '': - ignoreScreens = content.split(',') - except Exception as e: - print(e) - removed = False - if screen in ignoreScreens: - ignoreScreens.remove(screen) - removed = True - - ignoreScreensStr = ','.join(ignoreScreens) - - with open(ignoreFileName, 'w') as fp: - fp.write(ignoreScreensStr) - if removed: - print('Remove screen {0} to ignore file:{1}'.format(screen, ignoreFileName)) - else: - print('Screen {0} does not exists in ignore file:{1}'.format(screen, ignoreFileName)) - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description='Unignore screens in fenrir to make it active again. If no screen is given use current screen.') - parser.add_argument('-s', '--screen', metavar='SCREEN', default=None, help='Ignore a given screen. Use current screen as default.') - parser.add_argument('-f', '--file', metavar='File', default='/tmp/fenrirSuspend', help='Specify the suspendingScreenFile') - try: - args = parser.parse_args() - ignoreFileName = args.file - useCurrentScreen = False - screen = None - if args.screen: - screen = args.screen - else: - useCurrentScreen = True - removeScreenFromIgnoreList(ignoreFileName, screen, useCurrentScreen) - except Exception as e: - parser.print_help() diff --git a/tools/fenrir.pot b/tools/fenrir.pot deleted file mode 100644 index 8948af5d..00000000 --- a/tools/fenrir.pot +++ /dev/null @@ -1,6466 +0,0 @@ -# SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR Fenrir contributors -# This file is distributed under the same license as the fenrir package. -# FIRST AUTHOR , YEAR. -# -#, fuzzy -msgid "" -msgstr "" -"Project-Id-Version: fenrir\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-10-10 18:03-0400\n" -"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: FULL NAME \n" -"Language-Team: LANGUAGE \n" -"Language: \n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n" - -#: src/fenrirscreenreader/commands/commands/add_word_to_spell_check.py:34 -msgid "adds the current word to the exceptions dictionary" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/add_word_to_spell_check.py:76 -#, python-brace-format -msgid "{0} is already in dictionary" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/add_word_to_spell_check.py:85 -#, python-brace-format -msgid "{0} added to dictionary" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/adjustment_base.py:29 -msgid "Increase the speech rate" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/adjustment_base.py:30 -msgid "Decrease the speech rate" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/adjustment_base.py:31 -msgid "Increase the speech pitch" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/adjustment_base.py:32 -msgid "Decrease the speech pitch" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/adjustment_base.py:33 -msgid "Increase the speech volume" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/adjustment_base.py:34 -msgid "Decrease the speech volume" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/adjustment_base.py:35 -msgid "Increase the sound volume" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/adjustment_base.py:36 -msgid "Decrease the sound volume" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/adjustment_base.py:72 -#, python-brace-format -msgid "{0} percent speech rate" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/adjustment_base.py:73 -#, python-brace-format -msgid "{0} percent speech pitch" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/adjustment_base.py:74 -#, python-brace-format -msgid "{0} percent speech volume" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/adjustment_base.py:106 -#, python-brace-format -msgid "{0} percent sound volume" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/announce_fenrir_version.py:22 -msgid "Present the version of Fenrir currently in use." -msgstr "" - -#: src/fenrirscreenreader/commands/commands/announce_fenrir_version.py:27 -#: src/fenrir:35 -#, python-brace-format -msgid "Fenrir screen reader version {0}-{1}" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/announce_fenrir_version.py:34 -msgid "Version information is unavailable." -msgstr "" - -#: src/fenrirscreenreader/commands/commands/apply_tested_voice.py:17 -msgid "Apply the last tested voice from safe voice browser" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/apply_tested_voice.py:28 -#: src/fenrirscreenreader/core/dynamicVoiceMenu.py:108 -msgid "No voice has been tested yet" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/apply_tested_voice.py:31 -msgid "Use voice browser first" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/apply_tested_voice.py:40 -#: src/fenrirscreenreader/core/dynamicVoiceMenu.py:117 -#, python-brace-format -msgid "Applying {0} from {1}" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/apply_tested_voice.py:76 -msgid "Voice applied successfully!" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/apply_tested_voice.py:79 -#: src/fenrirscreenreader/core/dynamicVoiceMenu.py:180 -msgid "Use save settings to make permanent" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/apply_tested_voice.py:93 -#: src/fenrirscreenreader/core/dynamicVoiceMenu.py:212 -#, python-brace-format -msgid "Failed to apply voice, reverted: {0}" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/apply_tested_voice.py:100 -#: src/fenrirscreenreader/core/dynamicVoiceMenu.py:219 -#, python-brace-format -msgid "Apply voice error: {0}" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/attribute_cursor.py:22 -#: src/fenrirscreenreader/commands/onCursorChange/85000-has_attribute.py:22 -msgid "Reads attributes of current cursor position" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/bookmark_base.py:30 -#, python-brace-format -msgid "read Bookmark {0}" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/bookmark_base.py:32 -#, python-brace-format -msgid "set Bookmark {0}" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/bookmark_base.py:34 -#, python-brace-format -msgid "remove Bookmark {0}" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/bookmark_base.py:35 -#, python-brace-format -msgid "{0} Bookmark {1}" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/bookmark_base.py:52 -#, python-brace-format -msgid "Bookmark {0} not set" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/bookmark_base.py:58 -#: src/fenrirscreenreader/commands/commands/bookmark_base.py:67 -#, python-brace-format -msgid "Bookmark for application {0} not set" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/bookmark_base.py:94 -#: src/fenrirscreenreader/commands/commands/curr_screen_after_cursor.py:36 -#: src/fenrirscreenreader/commands/commands/curr_screen_before_cursor.py:37 -#: src/fenrirscreenreader/commands/commands/cursor_read_line_to_cursor.py:40 -#: src/fenrirscreenreader/commands/commands/cursor_read_to_end_of_line.py:40 -#: src/fenrirscreenreader/commands/commands/indent_curr_line.py:39 -#: src/fenrirscreenreader/commands/commands/marked_text.py:47 -#: src/fenrirscreenreader/commands/commands/present_first_line.py:31 -#: src/fenrirscreenreader/commands/commands/present_last_line.py:33 -#: src/fenrirscreenreader/commands/commands/review_curr_char_phonetic.py:41 -#: src/fenrirscreenreader/commands/commands/review_curr_line.py:41 -#: src/fenrirscreenreader/commands/commands/review_curr_word.py:66 -#: src/fenrirscreenreader/commands/commands/review_curr_word.py:76 -#: src/fenrirscreenreader/commands/commands/review_curr_word_phonetic.py:43 -#: src/fenrirscreenreader/commands/commands/review_curr_word_spell.py:43 -#: src/fenrirscreenreader/commands/commands/review_line_begin.py:66 -#: src/fenrirscreenreader/commands/commands/review_next_line.py:46 -#: src/fenrirscreenreader/commands/commands/review_next_word.py:85 -#: src/fenrirscreenreader/commands/commands/review_prev_line.py:42 -#: src/fenrirscreenreader/commands/commands/review_prev_word.py:87 -#: src/fenrirscreenreader/commands/onCursorChange/65000-present_line_if_cursor_change_vertical.py:56 -#: src/fenrirscreenreader/commands/onScreenUpdate/60000-history.py:84 -#: src/fenrirscreenreader/core/diffReviewManager.py:471 -#: src/fenrirscreenreader/core/diffReviewManager.py:484 -#: src/fenrirscreenreader/core/diffReviewManager.py:632 -#: src/fenrirscreenreader/core/diffReviewManager.py:634 -#: src/fenrirscreenreader/core/diffReviewManager.py:639 -#: src/fenrirscreenreader/core/diffReviewManager.py:686 -#: src/fenrirscreenreader/core/diffReviewManager.py:784 -msgid "blank" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/bookmark_base.py:104 -msgid "No mark found" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/bookmark_base.py:122 -#, python-brace-format -msgid "Bookmark {0} set for application {1}" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/bookmark_base.py:141 -#, python-brace-format -msgid "Bookmark {0} removed for application {1}" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/bookmark_base.py:148 -#, python-brace-format -msgid "Bookmark {0} not set for application {1}" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/clear_clipboard.py:22 -msgid "clears the currently selected clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/clear_clipboard.py:29 -msgid "clipboard cleared" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/clear_window_application.py:22 -msgid "Turn off window mode for application" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/clear_window_application.py:30 -#, python-brace-format -msgid "Window Mode off for application {0}" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/clear_window_application.py:37 -msgid "Not in window Mode" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/copy_last_echo_to_clipboard.py:22 -msgid "copies last presented text to the clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/copy_marked_to_clipboard.py:20 -msgid "copies marked text to the currently selected clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/copy_marked_to_clipboard.py:59 -msgid "One or two marks are needed" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/curr_clipboard.py:22 -msgid "speaks the contents of the currently selected clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/curr_clipboard.py:29 -#: src/fenrirscreenreader/commands/commands/export_clipboard_to_file.py:45 -#: src/fenrirscreenreader/commands/commands/export_clipboard_to_x.py:42 -#: src/fenrirscreenreader/commands/commands/first_clipboard.py:29 -#: src/fenrirscreenreader/commands/commands/last_clipboard.py:29 -#: src/fenrirscreenreader/commands/commands/next_clipboard.py:29 -#: src/fenrirscreenreader/commands/commands/paste_clipboard.py:36 -#: src/fenrirscreenreader/commands/commands/prev_clipboard.py:29 -#: src/fenrirscreenreader/core/remoteManager.py:441 -msgid "clipboard empty" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/curr_screen.py:22 -msgid "reads the contents of the current screen" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/curr_screen.py:27 -msgid "screen is empty" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/curr_screen_after_cursor.py:22 -msgid "reads from the cursor to the bottom of the screen" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/curr_screen_before_cursor.py:22 -msgid "Reads from the top of the screen to the cursor position" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/current_quick_menu_entry.py:22 -#: src/fenrirscreenreader/commands/quickMenu/current_quick_menu_entry.py:22 -msgid "get current quick menu entry" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/current_quick_menu_value.py:22 -#: src/fenrirscreenreader/commands/quickMenu/current_quick_menu_value.py:22 -msgid "get current quick menu value" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/cursor_column.py:22 -msgid "Column number for cursor" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/cursor_column.py:33 -msgid " column number" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/cursor_lineno.py:22 -msgid "Line number for cursor" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/cursor_lineno.py:33 -msgid " line number" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/cursor_position.py:22 -msgid "displays the position of the review cursor" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/cursor_position.py:31 -#, python-brace-format -msgid "line {0}, column {1}, Terminal {2}" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/cursor_read_line_to_cursor.py:23 -msgid "" -"read line to cursor pos, use review cursor if you are in review mode, " -"otherwhise use text cursor" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/cursor_read_to_end_of_line.py:23 -msgid "" -"read to end of line, use review cursor if you are in review mode, otherwhise " -"use text cursor" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/cycle_key_echo.py:22 -msgid "Cycle through key echo modes: character, word, off" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/cycle_key_echo.py:39 -msgid "Echo by word" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/cycle_key_echo.py:46 -msgid "Echo off" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/cycle_key_echo.py:53 -msgid "Echo by character" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/date.py:23 -msgid "presents the date" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/diff_key_help.py:21 -msgid "speak diff review key help" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/diff_next_added.py:21 -msgid "move to next added diff line" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/diff_next_char.py:21 -msgid "move to next character in diff line" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/diff_next_file_section.py:21 -msgid "move to next diff file section" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/diff_next_hunk.py:21 -msgid "move to next diff hunk" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/diff_next_line.py:21 -msgid "move to next diff line" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/diff_next_removed.py:21 -msgid "move to next removed diff line" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/diff_next_word.py:21 -msgid "move to next word in diff line" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/diff_prev_added.py:21 -msgid "move to previous added diff line" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/diff_prev_char.py:21 -msgid "move to previous character in diff line" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/diff_prev_file_section.py:21 -msgid "move to previous diff file section" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/diff_prev_hunk.py:21 -msgid "move to previous diff hunk" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/diff_prev_line.py:21 -msgid "move to previous diff line" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/diff_prev_removed.py:21 -msgid "move to previous removed diff line" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/diff_prev_word.py:21 -msgid "move to previous word in diff line" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/diff_summary.py:21 -msgid "speak diff summary" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/exit_diff_mode.py:21 -msgid "exit diff review mode" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/exit_diff_mode.py:28 -#: src/fenrirscreenreader/core/diffReviewManager.py:55 -msgid "Diff review mode disabled." -msgstr "" - -#: src/fenrirscreenreader/commands/commands/exit_review.py:22 -#: src/fenrirscreenreader/commands/onCursorChange/95000-exit_review_mode.py:22 -msgid "exits review mode" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/exit_review.py:27 -msgid "Not in Review Mode" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/exit_review.py:33 -msgid "Exiting Review Mode" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/export_clipboard_to_file.py:24 -msgid "export the current fenrir clipboard to a file" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/export_clipboard_to_file.py:55 -#: src/fenrirscreenreader/core/remoteManager.py:455 -msgid "clipboard exported to file" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/export_clipboard_to_x.py:33 -msgid "Export current fenrir clipboard to X or GUI clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/export_clipboard_to_x.py:77 -msgid "exported to the X session." -msgstr "" - -#: src/fenrirscreenreader/commands/commands/export_clipboard_to_x.py:88 -msgid "failed to export to X clipboard. No available display found." -msgstr "" - -#: src/fenrirscreenreader/commands/commands/first_clipboard.py:22 -msgid "selects the first clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/forward_keypress.py:22 -msgid "sends the following keypress to the terminal or application" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/forward_keypress.py:30 -msgid "Forward next keypress" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/import_clipboard_from_file.py:24 -msgid "imports text from clipboard file to the clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/import_clipboard_from_file.py:41 -msgid "File does not exist" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/import_clipboard_from_file.py:52 -#: src/fenrirscreenreader/commands/commands/import_clipboard_from_x.py:68 -msgid "Import to Clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/import_clipboard_from_x.py:32 -msgid "imports the graphical clipboard to Fenrir's clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/import_clipboard_from_x.py:49 -msgid "Clipboard import failed" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/import_clipboard_from_x.py:56 -msgid "No text found in clipboard or no accessible display" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/indent_curr_line.py:22 -msgid "Presents the indentation level for the current line" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/indent_curr_line.py:43 -#, python-brace-format -msgid "indent {0}" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/last_clipboard.py:22 -msgid "selects the last clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/marked_text.py:23 -msgid "" -"Presents the currently selected text that will be copied to the clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/marked_text.py:33 -msgid "please set begin and endmark" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/next_clipboard.py:22 -msgid "selects the next clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/next_clipboard.py:44 -#: src/fenrirscreenreader/commands/commands/prev_clipboard.py:44 -msgid "First clipboard " -msgstr "" - -#: src/fenrirscreenreader/commands/commands/next_clipboard.py:48 -#: src/fenrirscreenreader/commands/commands/prev_clipboard.py:48 -msgid "Last clipboard " -msgstr "" - -#: src/fenrirscreenreader/commands/commands/next_quick_menu_entry.py:22 -#: src/fenrirscreenreader/commands/quickMenu/next_quick_menu_entry.py:22 -msgid "get next quick menu entry" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/next_quick_menu_entry.py:38 -#: src/fenrirscreenreader/commands/commands/prev_quick_menu_entry.py:38 -#: src/fenrirscreenreader/commands/quickMenu/next_quick_menu_entry.py:38 -#: src/fenrirscreenreader/commands/quickMenu/prev_quick_menu_entry.py:38 -msgid "Quick menu not available" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/next_quick_menu_value.py:22 -#: src/fenrirscreenreader/commands/quickMenu/next_quick_menu_value.py:22 -msgid "get next quick menu value" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/paste_clipboard.py:29 -msgid "pastes the text from the currently selected clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/paste_clipboard.py:40 -msgid "paste clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/present_first_line.py:22 -msgid "present first line" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/present_last_line.py:22 -#: src/fenrirscreenreader/commands/commands/review_curr_line.py:22 -msgid "current line" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/prev_clipboard.py:22 -msgid "selects the previous clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/prev_quick_menu_entry.py:22 -#: src/fenrirscreenreader/commands/quickMenu/prev_quick_menu_entry.py:22 -msgid "get previous quick menu entry" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/prev_quick_menu_value.py:22 -#: src/fenrirscreenreader/commands/quickMenu/prev_quick_menu_value.py:22 -msgid "get previous quick menu value" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/progress_bar_monitor.py:39 -msgid "Toggle progress bar monitoring with ascending tones" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/progress_bar_monitor.py:51 -msgid "Progress monitoring disabled" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/progress_bar_monitor.py:56 -msgid "Progress monitoring enabled" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/quit_fenrir.py:22 -msgid "exits Fenrir" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/read_all_by_line.py:22 -msgid "Read all text line by line from current position" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/read_all_by_line.py:26 -msgid "Read-all functionality temporarily disabled" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/read_all_by_line.py:35 -#: src/fenrirscreenreader/commands/commands/read_all_by_page.py:30 -msgid "" -"Read all requires speech-dispatcher - not available with current speech " -"driver" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/read_all_by_line.py:49 -#: src/fenrirscreenreader/commands/commands/read_all_by_page.py:44 -msgid "Read all manager not in runtime" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/read_all_by_line.py:55 -#: src/fenrirscreenreader/commands/commands/read_all_by_page.py:50 -msgid "Read all manager is None" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/read_all_by_page.py:22 -msgid "Read all text page by page from current position" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/remove_marks.py:22 -msgid "Removes marks from selected text" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/remove_marks.py:27 -msgid "Remove marks" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/remove_word_from_spell_check.py:34 -msgid "removes the current word from the exceptions dictionary" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/remove_word_from_spell_check.py:49 -#: src/fenrirscreenreader/commands/commands/spell_check.py:49 -msgid "pyenchant is not installed" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/remove_word_from_spell_check.py:80 -#, python-brace-format -msgid "{0} is not in the dictionary" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/remove_word_from_spell_check.py:89 -#, python-brace-format -msgid "{0} removed" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/review_bottom.py:22 -msgid "Move review to the bottom of the screen" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/review_bottom.py:30 -msgid "Bottom" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/review_curr_char.py:22 -msgid "presents the current character." -msgstr "" - -#: src/fenrirscreenreader/commands/commands/review_curr_char.py:79 -#: src/fenrirscreenreader/commands/commands/review_next_char.py:131 -#: src/fenrirscreenreader/commands/commands/review_prev_char.py:137 -#: src/fenrirscreenreader/commands/onCursorChange/85000-has_attribute.py:44 -msgid "has attribute" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/review_curr_char_phonetic.py:22 -msgid "set review and phonetically presents the current character" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/review_curr_word.py:23 -msgid "current word." -msgstr "" - -#: src/fenrirscreenreader/commands/commands/review_curr_word.py:87 -#: src/fenrirscreenreader/commands/commands/review_curr_word_phonetic.py:61 -#: src/fenrirscreenreader/commands/commands/review_curr_word_spell.py:59 -#: src/fenrirscreenreader/commands/commands/review_down.py:50 -#: src/fenrirscreenreader/commands/commands/review_next_char.py:109 -#: src/fenrirscreenreader/commands/commands/review_next_line.py:57 -#: src/fenrirscreenreader/commands/commands/review_next_word.py:96 -msgid "end of screen" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/review_curr_word.py:96 -#: src/fenrirscreenreader/commands/commands/review_curr_word_phonetic.py:70 -#: src/fenrirscreenreader/commands/commands/review_curr_word_spell.py:68 -#: src/fenrirscreenreader/commands/commands/review_next_char.py:118 -#: src/fenrirscreenreader/commands/commands/review_next_word.py:105 -#: src/fenrirscreenreader/commands/commands/review_prev_char.py:124 -#: src/fenrirscreenreader/commands/commands/review_prev_word.py:107 -#: src/fenrirscreenreader/commands/commands/review_up.py:62 -msgid "line break" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/review_curr_word_phonetic.py:23 -msgid "Phonetically spells the current word" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/review_curr_word_spell.py:23 -msgid "Spells the current word" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/review_down.py:22 -msgid "Move review to the character below the current position" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/review_line_begin.py:23 -msgid "set review cursor to begin of current line and display the content" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/review_line_begin.py:39 -msgid "first cell" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/review_line_begin.py:43 -#: src/fenrirscreenreader/commands/commands/review_line_end.py:43 -#: src/fenrirscreenreader/commands/commands/review_line_first_char.py:44 -#: src/fenrirscreenreader/commands/commands/review_line_last_char.py:43 -#: src/fenrirscreenreader/commands/commands/review_next_word.py:61 -#: src/fenrirscreenreader/commands/commands/review_prev_word.py:63 -msgid "no table data" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/review_line_begin.py:77 -msgid "beginning of line" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/review_line_end.py:23 -msgid "Move Review to the end of current line and display the content" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/review_line_end.py:39 -msgid "last cell" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/review_line_end.py:72 -#: src/fenrirscreenreader/commands/commands/review_next_word.py:46 -msgid "end of line" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/review_line_first_char.py:23 -msgid "Move Review to the first character on the line" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/review_line_first_char.py:39 -#, python-brace-format -msgid "first character in cell {0}" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/review_line_first_char.py:59 -msgid "line is empty" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/review_line_first_char.py:83 -#, python-brace-format -msgid "first character in line indent {0}" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/review_line_last_char.py:22 -msgid "Move Review to the last character on the line" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/review_line_last_char.py:38 -#, python-brace-format -msgid "last character in cell {0}" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/review_line_last_char.py:71 -msgid "last character in line" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/review_next_char.py:22 -msgid "Moves review to the next character" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/review_next_char.py:55 -msgid "end of cell" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/review_next_line.py:22 -msgid "moves review to the next line " -msgstr "" - -#: src/fenrirscreenreader/commands/commands/review_next_word.py:23 -msgid "moves review to the next word " -msgstr "" - -#: src/fenrirscreenreader/commands/commands/review_prev_char.py:22 -msgid "moves review to the previous character" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/review_prev_char.py:60 -msgid "start of cell" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/review_prev_char.py:115 -#: src/fenrirscreenreader/commands/commands/review_prev_line.py:53 -#: src/fenrirscreenreader/commands/commands/review_prev_word.py:98 -#: src/fenrirscreenreader/commands/commands/review_up.py:53 -msgid "start of screen" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/review_prev_line.py:22 -msgid "moves review to the previous line " -msgstr "" - -#: src/fenrirscreenreader/commands/commands/review_prev_word.py:23 -msgid "moves review focus to the previous word " -msgstr "" - -#: src/fenrirscreenreader/commands/commands/review_prev_word.py:48 -msgid "start of line" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/review_screen_first_char.py:22 -msgid "Move Review to the first character on the screen (left top)" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/review_screen_first_char.py:46 -msgid "first character in screen" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/review_screen_last_char.py:23 -msgid "Move Review to the last character on the screen (right bottom)" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/review_screen_last_char.py:50 -msgid "last character in screen" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/review_top.py:22 -msgid "move review to top of screen" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/review_top.py:27 -msgid "Top" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/review_up.py:23 -msgid "Move review to the character in the line above the current position" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/save_settings.py:22 -msgid "Saves your current Fenrir settings so they are the default." -msgstr "" - -#: src/fenrirscreenreader/commands/commands/save_settings.py:27 -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/save_session_settings.py:30 -msgid "Failed to save settings." -msgstr "" - -#: src/fenrirscreenreader/commands/commands/save_settings.py:31 -msgid "Settings saved." -msgstr "" - -#: src/fenrirscreenreader/commands/commands/set_mark.py:22 -msgid "places marks to select text to copy to the clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/set_mark.py:27 -msgid "no review cursor" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/set_mark.py:36 -msgid "header row set" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/set_mark.py:40 -msgid "could not set header row" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/set_mark.py:48 -#: src/fenrirscreenreader/commands/commands/set_mark.py:52 -msgid "set mark" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/set_window_application.py:22 -msgid "set Window Mode, needs 2 marks " -msgstr "" - -#: src/fenrirscreenreader/commands/commands/set_window_application.py:30 -#, python-brace-format -msgid "Window Mode on for application {0}" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/set_window_application.py:36 -msgid "Set window begin and end marks" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/shut_up.py:22 -msgid "Interrupts the current presentation" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/silence_until_prompt.py:27 -msgid "Toggle speech silence until shell prompt returns" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/silence_until_prompt.py:38 -msgid "Speech silenced until prompt returns" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/silence_until_prompt.py:55 -#: src/fenrirscreenreader/commands/onScreenUpdate/66000-prompt_detector.py:182 -msgid "Speech restored" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/speech_history.py:21 -msgid "opens speech history" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/spell_check.py:32 -msgid "checks the spelling of the current word" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/spell_check.py:78 -#: src/fenrirscreenreader/commands/onCursorChange/35000-spell_check.py:199 -msgid "misspelled" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/spell_check.py:82 -msgid "correct" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/subprocess.py:45 -#, python-brace-format -msgid "script: {0} fullpath: {1}" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/subprocess.py:52 -msgid "Script file not found" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/subprocess.py:57 -msgid "Script source is not a valid file" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/subprocess.py:64 -msgid "Script file is not executable" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/subprocess.py:85 -msgid "Script failed" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/temp_disable_speech.py:22 -#: src/fenrirscreenreader/commands/onKeyInput/15000-enable_temp_speech.py:22 -msgid "disables speech until next keypress" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/time.py:23 -msgid "presents the time" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/toggle_auto_indent.py:22 -msgid "enables or disables automatic reading of indentation level changes" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/toggle_auto_indent.py:39 -msgid "autoindent enabled" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/toggle_auto_indent.py:43 -msgid "autoindent disabled" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/toggle_auto_read.py:22 -msgid "enables or disables automatic reading of new text as it appears" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/toggle_auto_read.py:39 -msgid "autoread enabled" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/toggle_auto_read.py:43 -msgid "autoread disabled" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/toggle_auto_spell_check.py:22 -msgid "enables or disables automatic spell checking" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/toggle_auto_spell_check.py:38 -msgid "auto spellcheck enabled" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/toggle_auto_spell_check.py:42 -msgid "auto spellcheck disabled" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/toggle_auto_time.py:22 -msgid "Enables or disables automatic reading of time after specified intervals" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/toggle_auto_time.py:40 -msgid "Automatic time announcement enabled" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/toggle_auto_time.py:46 -msgid "Automatic time announcement disabled" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/toggle_barrier.py:21 -msgid "enables or disables the barrier mode" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/toggle_barrier.py:37 -msgid "barrier mode enabled" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/toggle_barrier.py:41 -msgid "barrier mode disabled" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/toggle_diff_mode.py:21 -msgid "toggle diff review mode" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/toggle_emoticons.py:22 -msgid "enables or disables announcement of emoticons instead of chars" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/toggle_emoticons.py:39 -msgid "emoticons enabled" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/toggle_emoticons.py:43 -msgid "emoticons disabled" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/toggle_has_attribute.py:21 -msgid "enables or disables the announcement of attributes" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/toggle_has_attribute.py:37 -msgid "announcement of attributes enabled" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/toggle_has_attribute.py:43 -msgid "announcement of attributes disabled" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/toggle_highlight_tracking.py:22 -msgid "enables or disables tracking of highlighted text" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/toggle_highlight_tracking.py:50 -msgid "cursor tracking" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/toggle_highlight_tracking.py:62 -msgid "table mode" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/toggle_highlight_tracking.py:74 -msgid "highlight tracking" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/toggle_output.py:22 -msgid "toggles all output settings" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/toggle_output.py:31 -msgid "Fenrir muted" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/toggle_output.py:47 -msgid "Fenrir unmuted" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/toggle_punctuation_level.py:35 -msgid "No punctuation found." -msgstr "" - -#: src/fenrirscreenreader/commands/commands/toggle_sound.py:21 -msgid "enables or disables sound" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/toggle_sound.py:28 -msgid "sound disabled" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/toggle_sound.py:43 -msgid "sound enabled" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/toggle_speech.py:22 -msgid "enables or disables speech" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/toggle_speech.py:30 -msgid "speech disabled" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/toggle_speech.py:45 -#: src/fenrirscreenreader/commands/onKeyInput/15000-enable_temp_speech.py:41 -msgid "speech enabled" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/toggle_tutorial_mode.py:22 -msgid "enter or leave tutorial mode" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/toggle_tutorial_mode.py:32 -msgid "" -"Unable to exit tutorial mode because exclusive keyboard capture could not be " -"released. Press Fenrir+F1 or Escape to try again." -msgstr "" - -#: src/fenrirscreenreader/commands/commands/toggle_tutorial_mode.py:41 -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 "" - -#: src/fenrirscreenreader/commands/commands/toggle_tutorial_mode.py:53 -msgid "" -"Warning: full keyboard capture is unavailable. Unbound keys may reach the " -"active application." -msgstr "" - -#: src/fenrirscreenreader/commands/commands/toggle_tutorial_mode.py:61 -msgid "Exiting tutorial mode. To enter tutorial mode again press Fenrir+F1" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/toggle_vmenu_mode.py:22 -msgid "Entering or Leaving v menu mode." -msgstr "" - -#: src/fenrirscreenreader/commands/commands/toggle_vmenu_mode.py:28 -msgid "Entering v menu." -msgstr "" - -#: src/fenrirscreenreader/commands/commands/toggle_vmenu_mode.py:32 -msgid "Leaving v menu." -msgstr "" - -#: src/fenrirscreenreader/commands/commands/voice_browser.py:16 -#: src/fenrirscreenreader/core/dynamicVoiceMenu.py:17 -msgid "This is a voice test. The quick brown fox jumps over the lazy dog." -msgstr "" - -#: src/fenrirscreenreader/commands/commands/voice_browser.py:32 -msgid "Interactive voice browser with arrow key navigation" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/voice_browser.py:40 -msgid "Starting voice browser" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/voice_browser.py:60 -#: src/fenrirscreenreader/commands/commands/voice_browser_safe.py:45 -msgid "No speech modules found" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/voice_browser.py:74 -#: src/fenrirscreenreader/core/quickMenuManager.py:422 -#, python-brace-format -msgid "Loading voices for {0}" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/voice_browser.py:125 -msgid "Voice browser active" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/voice_browser.py:128 -msgid "Up/Down=voices, Left/Right=modules, Enter=test, Space=apply, Esc=exit" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/voice_browser.py:161 -msgid "Voice browser exited" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/voice_browser.py:179 -#, python-brace-format -msgid "{0}: {1} ({2}/{3})" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/voice_browser.py:186 -#, python-brace-format -msgid "{0}: No voices" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/voice_browser.py:222 -msgid "No voice selected" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/voice_browser.py:231 -msgid "Testing..." -msgstr "" - -#: src/fenrirscreenreader/commands/commands/voice_browser.py:287 -msgid "Voice applied!" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/voice_browser.py:293 -msgid "Apply failed" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/voice_browser_apply.py:17 -msgid "Voice browser: apply current voice" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/voice_browser_exit.py:17 -msgid "Voice browser: exit browser mode" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/voice_browser_next_module.py:17 -msgid "Voice browser: next module" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/voice_browser_next_voice.py:17 -msgid "Voice browser: next voice" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/voice_browser_prev_module.py:17 -msgid "Voice browser: previous module" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/voice_browser_prev_voice.py:17 -msgid "Voice browser: previous voice" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/voice_browser_safe.py:15 -msgid "Voice test: The quick brown fox jumps over the lazy dog." -msgstr "" - -#: src/fenrirscreenreader/commands/commands/voice_browser_safe.py:23 -msgid "browse and test Speech Dispatcher voices" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/voice_browser_safe.py:32 -#: src/fenrirscreenreader/core/quickMenuManager.py:305 -msgid "Loading speech modules" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/voice_browser_safe.py:55 -#, python-brace-format -msgid "Loading voices for {module}" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/voice_browser_safe.py:79 -msgid "No voices found" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/voice_browser_safe.py:91 -#, python-brace-format -msgid "Module: {module}, voice: {voice}" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/voice_browser_safe.py:97 -msgid "Testing voice" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/voice_browser_safe.py:137 -msgid "Voice test completed successfully" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/voice_browser_safe.py:140 -msgid "Voice test failed" -msgstr "" - -#: src/fenrirscreenreader/commands/commands/voice_browser_test.py:17 -msgid "Voice browser: test current voice" -msgstr "" - -#: src/fenrirscreenreader/commands/help/curr_help.py:21 -msgid "get current help message" -msgstr "" - -#: src/fenrirscreenreader/commands/help/next_help.py:21 -msgid "get next help message" -msgstr "" - -#: src/fenrirscreenreader/commands/help/next_help_section.py:21 -msgid "show next help category" -msgstr "" - -#: src/fenrirscreenreader/commands/help/prev_help.py:21 -msgid "get prev help message" -msgstr "" - -#: src/fenrirscreenreader/commands/help/prev_help_section.py:21 -msgid "show previous help category" -msgstr "" - -#: src/fenrirscreenreader/commands/onCursorChange/15000-char_echo.py:22 -msgid "Announces characters as they are typed" -msgstr "" - -#: src/fenrirscreenreader/commands/onCursorChange/25000-word_echo_type.py:24 -msgid "Announces completed words during typing" -msgstr "" - -#: src/fenrirscreenreader/commands/onCursorChange/54000-tab_completion.py:23 -msgid "Announces tab completions on cursor movement" -msgstr "" - -#: src/fenrirscreenreader/commands/onCursorChange/55000-large_insertion_echo.py:23 -msgid "Announces large text insertions at the cursor" -msgstr "" - -#: src/fenrirscreenreader/commands/onCursorChange/65000-present_line_if_cursor_change_vertical.py:78 -#: src/fenrirscreenreader/commands/onCursorChange/68000-auto_identation_horizontal.py:83 -msgid "indented " -msgstr "" - -#: src/fenrirscreenreader/commands/onHeartBeat/76000-time.py:153 -#, python-brace-format -msgid "It's {0}" -msgstr "" - -#: src/fenrirscreenreader/commands/onKeyInput/55000-tab_completion_capture.py:23 -msgid "Tracks tab keypresses for completion announcements" -msgstr "" - -#: src/fenrirscreenreader/commands/onKeyInput/80000-capslock.py:37 -msgid "Capslock on" -msgstr "" - -#: src/fenrirscreenreader/commands/onKeyInput/80000-capslock.py:41 -msgid "Capslock off" -msgstr "" - -#: src/fenrirscreenreader/commands/onKeyInput/80300-scrolllock.py:37 -msgid "Scrolllock on" -msgstr "" - -#: src/fenrirscreenreader/commands/onKeyInput/80300-scrolllock.py:41 -msgid "Scrolllock off" -msgstr "" - -#: src/fenrirscreenreader/commands/onKeyInput/80500-numlock.py:51 -msgid "Numlock on" -msgstr "" - -#: src/fenrirscreenreader/commands/onKeyInput/80500-numlock.py:55 -msgid "Numlock off" -msgstr "" - -#: src/fenrirscreenreader/commands/onScreenChanged/80000-screen_change_announcement.py:26 -#, python-brace-format -msgid "screen {0}" -msgstr "" - -#: src/fenrirscreenreader/commands/onScreenUpdate/55000-tui_focus_handler.py:23 -msgid "" -"TUI focus mode handler - suppresses screen update spam for interactive TUI " -"applications" -msgstr "" - -#: src/fenrirscreenreader/commands/onScreenUpdate/56000-highlight_tracking.py:21 -msgid "enables or disables tracking of highlighted" -msgstr "" - -#: src/fenrirscreenreader/commands/onScreenUpdate/64000-tab_completion.py:23 -msgid "Announces visible tab completion results" -msgstr "" - -#: src/fenrirscreenreader/commands/onScreenUpdate/65000-progress_detector.py:22 -msgid "Detects progress patterns for progress bar monitoring" -msgstr "" - -#: src/fenrirscreenreader/commands/onScreenUpdate/66000-prompt_detector.py:22 -msgid "Detects shell prompts for silence until prompt feature" -msgstr "" - -#: src/fenrirscreenreader/commands/onScreenUpdate/70000-incoming.py:30 -msgid "Announces incoming text changes" -msgstr "" - -#: src/fenrirscreenreader/commands/speech-history/speech_history_close.py:21 -msgid "closes speech history" -msgstr "" - -#: src/fenrirscreenreader/commands/speech-history/speech_history_copy.py:21 -msgid "copies current speech history item to the clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/speech-history/speech_history_current.py:21 -msgid "speaks current speech history item" -msgstr "" - -#: src/fenrirscreenreader/commands/speech-history/speech_history_next.py:21 -msgid "selects the next speech history item" -msgstr "" - -#: src/fenrirscreenreader/commands/speech-history/speech_history_prev.py:21 -msgid "selects the previous speech history item" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-navigation/curr_vmenu_entry.py:22 -msgid "get current v menu entry" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-navigation/dec_level_vmenu.py:22 -msgid "leave v menu submenu" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-navigation/exec_vmenu_entry.py:22 -msgid "execute v menu entry" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-navigation/inc_level_vmenu.py:22 -msgid "enter v menu submenu" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-navigation/next_vmenu_entry.py:22 -msgid "get next v menu entry" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-navigation/page_down_vmenu.py:22 -msgid "jump down 10% in v menu" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-navigation/page_up_vmenu.py:22 -msgid "jump up 10% in v menu" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-navigation/prev_vmenu_entry.py:22 -msgid "get prev v menu entry" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-navigation/vmenu_search_base.py:23 -msgid "search for a menu entry" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-navigation/vmenu_search_base.py:35 -msgid "not found" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/argentina.py:15 -msgid "Add Argentina flag emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/argentina.py:22 -msgid "Added Argentina flag to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/australia.py:15 -msgid "Add Australia flag emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/australia.py:22 -msgid "Added Australia flag to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/belgium.py:15 -msgid "Add Belgium flag emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/belgium.py:22 -msgid "Added Belgium flag to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/brazil.py:15 -msgid "Add Brazil flag emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/brazil.py:22 -msgid "Added Brazil flag to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/canada.py:15 -msgid "Add Canada flag emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/canada.py:22 -msgid "Added Canada flag to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/china.py:15 -msgid "Add China flag emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/china.py:22 -msgid "Added China flag to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/denmark.py:15 -msgid "Add Denmark flag emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/denmark.py:22 -msgid "Added Denmark flag to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/finland.py:15 -msgid "Add Finland flag emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/finland.py:22 -msgid "Added Finland flag to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/france.py:15 -msgid "Add France flag emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/france.py:22 -msgid "Added France flag to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/germany.py:15 -msgid "Add Germany flag emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/germany.py:22 -msgid "Added Germany flag to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/greece.py:15 -msgid "Add Greece flag emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/greece.py:22 -msgid "Added Greece flag to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/india.py:15 -msgid "Add India flag emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/india.py:22 -msgid "Added India flag to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/ireland.py:15 -msgid "Add Ireland flag emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/ireland.py:22 -msgid "Added Ireland flag to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/israel.py:15 -msgid "Add Israel flag emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/israel.py:22 -msgid "Added Israel flag to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/italy.py:15 -msgid "Add Italy flag emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/italy.py:22 -msgid "Added Italy flag to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/japan.py:15 -msgid "Add Japan flag emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/japan.py:22 -msgid "Added Japan flag to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/mexico.py:15 -msgid "Add Mexico flag emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/mexico.py:22 -msgid "Added Mexico flag to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/netherlands.py:15 -msgid "Add Netherlands flag emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/netherlands.py:22 -msgid "Added Netherlands flag to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/norway.py:15 -msgid "Add Norway flag emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/norway.py:22 -msgid "Added Norway flag to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/poland.py:15 -msgid "Add Poland flag emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/poland.py:22 -msgid "Added Poland flag to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/portugal.py:15 -msgid "Add Portugal flag emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/portugal.py:22 -msgid "Added Portugal flag to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/russia.py:15 -msgid "Add Russia flag emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/russia.py:22 -msgid "Added Russia flag to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/south_africa.py:15 -msgid "Add South Africa flag emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/south_africa.py:22 -msgid "Added South Africa flag to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/south_korea.py:15 -msgid "Add South Korea flag emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/south_korea.py:22 -msgid "Added South Korea flag to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/spain.py:15 -msgid "Add Spain flag emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/spain.py:22 -msgid "Added Spain flag to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/sweden.py:15 -msgid "Add Sweden flag emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/sweden.py:22 -msgid "Added Sweden flag to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/switzerland.py:15 -msgid "Add Switzerland flag emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/switzerland.py:22 -msgid "Added Switzerland flag to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/turkey.py:15 -msgid "Add Turkey flag emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/turkey.py:22 -msgid "Added Turkey flag to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/uk.py:15 -msgid "Add UK flag emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/uk.py:22 -msgid "Added UK flag to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/ukraine.py:15 -msgid "Add Ukraine flag emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/ukraine.py:22 -msgid "Added Ukraine flag to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/usa.py:15 -msgid "Add USA flag emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/flags/usa.py:22 -msgid "Added USA flag to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/apple.py:15 -msgid "Red apple emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/apple.py:22 -msgid "Added red apple to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/avocado.py:15 -msgid "Avocado emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/avocado.py:22 -msgid "Added avocado to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/beer.py:15 -msgid "Beer emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/beer.py:22 -msgid "Added beer to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/birthday_cake.py:15 -msgid "Birthday cake emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/birthday_cake.py:22 -msgid "Added birthday cake to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/coffee.py:15 -msgid "Add coffee emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/coffee.py:22 -msgid "Added coffee to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/donut.py:15 -msgid "Donut emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/donut.py:22 -msgid "Added donut to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/hamburger.py:15 -msgid "Add hamburger emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/hamburger.py:22 -msgid "Added hamburger to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/pizza.py:15 -msgid "Add pizza emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/pizza.py:22 -msgid "Added pizza to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/strawberry.py:15 -msgid "Strawberry emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/strawberry.py:22 -msgid "Added strawberry to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/taco.py:15 -msgid "Taco emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/food/taco.py:22 -msgid "Added taco to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/alien_monster.py:15 -msgid "Alien monster emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/alien_monster.py:22 -msgid "Added alien monster to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/bat.py:15 -msgid "Add bat emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/bat.py:22 -msgid "Added bat to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/black_cat.py:15 -msgid "Black cat emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/black_cat.py:22 -msgid "Added black cat to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/bunny.py:15 -msgid "Add bunny emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/bunny.py:22 -msgid "Added bunny to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/cauldron.py:15 -msgid "Mage emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/cauldron.py:22 -msgid "Added mage to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/christmas_tree.py:15 -msgid "Add Christmas tree emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/christmas_tree.py:22 -msgid "Added Christmas tree to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/coffin.py:15 -msgid "Coffin emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/coffin.py:22 -msgid "Added coffin to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/easter_egg.py:15 -msgid "Add Easter egg emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/easter_egg.py:22 -msgid "Added Easter egg to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/fireworks.py:15 -msgid "Add fireworks emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/fireworks.py:22 -msgid "Added fireworks to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/ghost.py:15 -msgid "Add ghost emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/ghost.py:22 -msgid "Added ghost to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/gift.py:15 -msgid "Add gift emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/gift.py:22 -msgid "Added gift to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/jack_o_lantern.py:15 -msgid "Add jack o'lantern emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/jack_o_lantern.py:22 -msgid "Added jack o'lantern to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/mummy.py:15 -msgid "Mummy emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/mummy.py:22 -msgid "Added mummy to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/santa.py:15 -msgid "Add Santa emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/santa.py:22 -msgid "Added Santa to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/shamrock.py:15 -msgid "Add shamrock emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/shamrock.py:22 -msgid "Added shamrock to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/skull.py:15 -msgid "Add skull emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/skull.py:22 -msgid "Added skull to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/snowman.py:15 -msgid "Add snowman emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/snowman.py:22 -msgid "Added snowman to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/spider.py:15 -msgid "Add spider emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/spider.py:22 -msgid "Added spider to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/turkey.py:15 -msgid "Add turkey emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/turkey.py:22 -msgid "Added turkey to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/vampire.py:15 -msgid "Vampire emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/vampire.py:22 -msgid "Added vampire to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/web.py:15 -msgid "Spider web emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/web.py:22 -msgid "Added spider web to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/witch.py:15 -msgid "Witch emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/witch.py:22 -msgid "Added witch to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/zombie.py:15 -msgid "Zombie emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/holidays/zombie.py:22 -msgid "Added zombie to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/butterfly.py:15 -msgid "Butterfly emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/butterfly.py:22 -msgid "Added butterfly to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/cat.py:15 -msgid "Cat emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/cat.py:22 -msgid "Added cat to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/cherry_blossom.py:15 -msgid "Cherry blossom emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/cherry_blossom.py:22 -msgid "Added cherry blossom to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/dog.py:15 -msgid "Dog emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/dog.py:22 -msgid "Added dog to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/moon.py:15 -msgid "Add moon emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/moon.py:22 -msgid "Added moon to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/rainbow.py:15 -msgid "Rainbow emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/rainbow.py:22 -msgid "Added rainbow to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/rose.py:15 -msgid "Rose emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/rose.py:22 -msgid "Added rose to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/sun.py:15 -msgid "Add sun emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/sun.py:22 -msgid "Added sun to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/sunflower.py:15 -msgid "Sunflower emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/sunflower.py:22 -msgid "Added sunflower to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/tree.py:15 -msgid "Add tree emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/tree.py:22 -msgid "Added tree to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/wolf.py:15 -msgid "Wolf emoji - The mighty Fenrir!" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/nature/wolf.py:22 -msgid "Added the mighty wolf Fenrir to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/a_grinning.py:15 -msgid "Grinning face emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/a_grinning.py:22 -msgid "Added grinning face to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/angry.py:15 -msgid "Angry face emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/angry.py:22 -msgid "Added angry face to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/beaming.py:15 -msgid "Beaming face with smiling eyes emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/beaming.py:22 -msgid "Added beaming face to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/blowing_kiss.py:15 -msgid "Face blowing a kiss emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/blowing_kiss.py:22 -msgid "Added face blowing kiss to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/cool.py:15 -msgid "Cool face emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/cool.py:22 -msgid "Added cool face to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/crying.py:15 -msgid "Crying face emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/crying.py:22 -msgid "Added crying face to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/devil.py:15 -msgid "Devil face emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/devil.py:22 -msgid "Added devil face to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/dizzy.py:15 -msgid "Dizzy face emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/dizzy.py:22 -msgid "Added dizzy face to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/exploding_head.py:15 -msgid "Exploding head emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/exploding_head.py:22 -msgid "Added exploding head to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/face_with_symbols.py:15 -msgid "Face with symbols over mouth emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/face_with_symbols.py:22 -msgid "Added face with symbols to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/grinning_sweat.py:15 -msgid "Grinning face with sweat emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/grinning_sweat.py:22 -msgid "Added grinning face with sweat to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/heart_eyes.py:15 -msgid "Smiling face with heart-eyes emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/heart_eyes.py:22 -msgid "Added heart-eyes face to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/hearts_around.py:15 -msgid "Smiling face with hearts emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/hearts_around.py:22 -msgid "Added smiling face with hearts to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/imp.py:15 -msgid "Angry face with horns emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/imp.py:22 -msgid "Added angry face with horns to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/laughing.py:15 -msgid "Add laughing face emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/laughing.py:22 -msgid "Added laughing face to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/mindblown.py:15 -msgid "Mind blown emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/mindblown.py:22 -msgid "Added mind blown to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/nauseated.py:15 -msgid "Nauseated face emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/nauseated.py:22 -msgid "Added nauseated face to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/pleading.py:15 -msgid "Pleading face emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/pleading.py:22 -msgid "Added pleading face to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/poop.py:15 -msgid "Poop emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/poop.py:22 -msgid "Added poop to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/rolling_laughing.py:15 -msgid "Rolling on floor laughing emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/rolling_laughing.py:22 -msgid "Added rolling on floor laughing to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/sad.py:15 -msgid "Sad face emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/sad.py:22 -msgid "Added sad face to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/savoring_food.py:15 -msgid "Face savoring food emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/savoring_food.py:22 -msgid "Added face savoring food to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/screaming.py:15 -msgid "Screaming face emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/screaming.py:22 -msgid "Added screaming face to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/shocked.py:15 -msgid "Shocked face emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/shocked.py:22 -msgid "Added shocked face to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/smiling.py:15 -msgid "Add smiling face emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/smiling.py:22 -msgid "Added smiling face to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/smirking.py:15 -msgid "Smirking face emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/smirking.py:22 -msgid "Added smirking face to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/thumbs_up.py:15 -msgid "Add thumbs up emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/thumbs_up.py:22 -msgid "Added thumbs up to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/winking.py:15 -msgid "Add winking face emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/people/winking.py:22 -msgid "Added winking face to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/chains.py:15 -msgid "Chains emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/chains.py:22 -msgid "Added chains to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/checkmark.py:15 -msgid "Add checkmark emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/checkmark.py:22 -msgid "Added checkmark to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/crossbones.py:15 -msgid "Bone emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/crossbones.py:22 -msgid "Added bone to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/dagger.py:15 -msgid "Dagger emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/dagger.py:22 -msgid "Added dagger to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/fire.py:15 -msgid "Fire emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/fire.py:22 -msgid "Added fire to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/folded_hands.py:15 -msgid "Folded hands emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/folded_hands.py:22 -msgid "Added folded hands to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/heart.py:15 -msgid "Add heart emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/heart.py:22 -msgid "Added heart to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/high_voltage.py:15 -msgid "High voltage lightning bolt emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/high_voltage.py:22 -msgid "Added high voltage lightning bolt to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/hundred_points.py:15 -msgid "Hundred points symbol emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/hundred_points.py:22 -msgid "Added hundred points to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/lightning.py:15 -msgid "Lightning emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/lightning.py:22 -msgid "Added lightning to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/peace.py:15 -msgid "Peace sign emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/peace.py:22 -msgid "Added peace sign to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/rock_on.py:15 -msgid "Sign of the horns emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/rock_on.py:22 -msgid "Added sign of the horns to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/skull_crossbones.py:15 -msgid "Skull and crossbones emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/skull_crossbones.py:22 -msgid "Added skull and crossbones to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/sparkles.py:15 -msgid "Sparkles emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/sparkles.py:22 -msgid "Added sparkles to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/star.py:15 -msgid "Add star emoji to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/star.py:22 -msgid "Added star to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/sword.py:16 -msgid "Sword emoji" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/emoji/symbols/sword.py:23 -msgid "Added sword to clipboard" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/config_base.py:62 -msgid "Configuration file not found" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/config_base.py:73 -#, python-brace-format -msgid "Configuration backed up to {0}" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/config_base.py:75 -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/backup_config.py:31 -msgid "Configuration backup created successfully" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/config_base.py:79 -#, python-brace-format -msgid "Failed to backup configuration: {0}" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/config_base.py:93 -#, python-brace-format -msgid "Failed to reload configuration: {0}" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/config_base.py:150 -#, python-brace-format -msgid "Failed to create basic defaults: {0}" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/set_punctuation_all.py:21 -msgid "Set punctuation to All (every punctuation mark)" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/set_punctuation_all.py:27 -msgid "Punctuation level already set to All" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/set_punctuation_all.py:35 -msgid "Punctuation level set to All - every punctuation mark will be spoken" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/set_punctuation_all.py:40 -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/set_punctuation_most.py:38 -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/set_punctuation_none.py:38 -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/set_punctuation_some.py:38 -msgid "Failed to change punctuation level" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/set_punctuation_most.py:21 -msgid "Set punctuation to Most (detailed punctuation)" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/set_punctuation_most.py:27 -msgid "Punctuation level already set to Most" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/set_punctuation_most.py:34 -msgid "Punctuation level set to Most - detailed punctuation will be spoken" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/set_punctuation_none.py:21 -msgid "Set punctuation to None (no punctuation spoken)" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/set_punctuation_none.py:27 -msgid "Punctuation level already set to None" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/set_punctuation_none.py:34 -msgid "Punctuation level set to None - no punctuation will be spoken" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/set_punctuation_some.py:21 -msgid "Set punctuation to Some (basic punctuation only)" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/set_punctuation_some.py:27 -msgid "Punctuation level already set to Some" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/set_punctuation_some.py:34 -msgid "Punctuation level set to Some - basic punctuation will be spoken" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/toggle_debug.py:21 -msgid "Toggle debug mode" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/toggle_debug.py:28 -msgid "Current debug mode: disabled" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/toggle_debug.py:31 -#, python-brace-format -msgid "Current debug level: {0}" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/toggle_debug.py:37 -msgid "Debug mode enabled" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/toggle_debug.py:38 -msgid "Debug mode disabled" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/toggle_debug.py:43 -msgid "Debug output will be written to log file" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/toggle_debug.py:47 -msgid "Debug output will be printed to console" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/toggle_debug.py:51 -msgid "Failed to change debug mode" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/set_char_echo_always.py:21 -msgid "Set character echo to Always (echo all typed characters)" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/set_char_echo_always.py:27 -msgid "Character echo already set to Always" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/set_char_echo_always.py:34 -msgid "Character echo set to Always - all typed characters will be spoken" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/set_char_echo_always.py:38 -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/set_char_echo_caps.py:40 -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/set_char_echo_none.py:38 -msgid "Failed to change character echo mode" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/set_char_echo_caps.py:21 -msgid "Set character echo to Caps Lock (echo only when caps lock is on)" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/set_char_echo_caps.py:27 -msgid "Character echo already set to Caps Lock mode" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/set_char_echo_caps.py:35 -msgid "" -"Character echo set to Caps Lock mode - characters will be spoken only when " -"caps lock is on" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/set_char_echo_none.py:21 -msgid "Set character echo to None (no character echo)" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/set_char_echo_none.py:27 -msgid "Character echo already set to None" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/set_char_echo_none.py:34 -msgid "Character echo set to None - no typed characters will be spoken" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/toggle_grab_devices.py:21 -msgid "Toggle exclusive keyboard access" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/toggle_grab_devices.py:34 -msgid "" -"Exclusive keyboard access will be enabled after saving and restarting Fenrir" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/toggle_grab_devices.py:38 -msgid "" -"Exclusive keyboard access will be disabled after saving and restarting Fenrir" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/toggle_grab_devices.py:42 -msgid "Save settings, then restart Fenrir for this change to take effect" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/toggle_grab_devices.py:46 -msgid "Failed to change keyboard grab setting" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/toggle_word_echo.py:21 -msgid "Toggle word echo when pressing space" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/toggle_word_echo.py:28 -msgid "Word echo enabled" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/toggle_word_echo.py:28 -msgid "Word echo disabled" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/toggle_word_echo.py:31 -msgid "Words will be spoken when you press space" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/toggle_word_echo.py:33 -msgid "Words will not be spoken when you press space" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/toggle_word_echo.py:36 -msgid "Failed to change word echo setting" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/backup_config.py:21 -msgid "Create backup of current configuration" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/backup_config.py:24 -msgid "Creating configuration backup..." -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/backup_config.py:37 -msgid "Failed to create configuration backup" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/reload_config.py:21 -msgid "Reload configuration from file" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/reload_config.py:24 -msgid "Reloading configuration from file..." -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/reload_config.py:29 -msgid "Configuration reloaded successfully" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/reload_config.py:32 -msgid "Failed to reload configuration" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/reset_defaults.py:22 -msgid "Reset configuration to default settings" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/reset_defaults.py:25 -msgid "WARNING: This will reset all settings to defaults" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/reset_defaults.py:26 -msgid "Creating backup before reset..." -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/reset_defaults.py:32 -msgid "Failed to create backup. Reset cancelled for safety." -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/reset_defaults.py:50 -msgid "Configuration reset to defaults successfully" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/reset_defaults.py:55 -msgid "Please restart Fenrir for all changes to take effect" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/reset_defaults.py:62 -#, python-brace-format -msgid "Failed to reset configuration: {0}" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/reset_defaults.py:157 -msgid "Basic default configuration created" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/reset_defaults.py:162 -msgid "Please restart Fenrir for changes to take effect" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/reset_defaults.py:169 -#, python-brace-format -msgid "Failed to create default configuration: {0}" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/revert_to_saved.py:17 -msgid "Discard temporary changes and revert to saved settings" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/revert_to_saved.py:21 -msgid "Reverting to saved configuration..." -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/revert_to_saved.py:52 -msgid "Successfully reverted to saved settings" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/revert_to_saved.py:57 -msgid "All temporary changes have been discarded" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/revert_to_saved.py:64 -#, python-brace-format -msgid "Error reverting settings: {0}" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/revert_to_saved.py:69 -msgid "You may need to restart Fenrir" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/save_config.py:21 -msgid "Save current configuration to file" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/save_config.py:24 -msgid "Saving current configuration..." -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/save_config.py:28 -msgid "Failed to save configuration" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/save_config.py:32 -msgid "Configuration saved successfully" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/save_config.py:36 -#, python-brace-format -msgid "Failed to save configuration: {0}" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/save_session_settings.py:17 -msgid "Save current session settings to configuration file" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/save_session_settings.py:21 -msgid "Saving current session settings to configuration file..." -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/save_session_settings.py:35 -msgid "Session settings saved successfully!" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/save_session_settings.py:40 -msgid "All temporary changes are now permanent." -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/save_session_settings.py:47 -#, python-brace-format -msgid "Failed to save settings: {0}" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/show_unsaved_changes.py:19 -msgid "Show temporary changes not yet saved to file" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/show_unsaved_changes.py:23 -msgid "Checking for unsaved changes..." -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/show_unsaved_changes.py:65 -#, python-brace-format -msgid "found {0} unsaved change:" -msgid_plural "found {0} unsaved changes:" -msgstr[0] "" -msgstr[1] "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/show_unsaved_changes.py:79 -#, python-brace-format -msgid "... and {0} more change" -msgid_plural "... and {0} more changes" -msgstr[0] "" -msgstr[1] "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/show_unsaved_changes.py:87 -msgid "Use save command to make these changes permanent" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/show_unsaved_changes.py:93 -msgid "No unsaved changes found" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/show_unsaved_changes.py:99 -#, python-brace-format -msgid "Error checking for changes: {0}" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/select_driver.py:21 -msgid "Select screen driver" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/select_driver.py:28 -msgid "VCSA Driver - Linux TTY console access" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/select_driver.py:29 -msgid "PTY Driver - terminal emulation" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/select_driver.py:30 -msgid "Dummy Driver - for testing only" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/select_driver.py:31 -msgid "Debug Driver - development/debugging" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/select_driver.py:35 -msgid "Unknown driver" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/select_driver.py:37 -#, python-brace-format -msgid "Current screen driver: {0}" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/select_driver.py:53 -#, python-brace-format -msgid "Screen driver changed to: {0}" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/select_driver.py:56 -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/select_driver.py:46 -msgid "Please restart Fenrir for this change to take effect." -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/select_driver.py:60 -msgid "Failed to change screen driver" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/set_encoding_auto.py:21 -msgid "Set screen encoding to Auto (automatic detection)" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/set_encoding_auto.py:27 -msgid "Screen encoding already set to Auto" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/set_encoding_auto.py:35 -msgid "" -"Screen encoding set to Auto - Fenrir will automatically detect text encoding" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/set_encoding_auto.py:40 -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/set_encoding_cp1252.py:38 -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/set_encoding_iso88591.py:36 -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/set_encoding_utf8.py:36 -msgid "Failed to change screen encoding" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/set_encoding_cp1252.py:21 -msgid "Set screen encoding to CP1252 (Windows Western European)" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/set_encoding_cp1252.py:27 -msgid "Screen encoding already set to CP1252" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/set_encoding_cp1252.py:34 -msgid "Screen encoding set to CP1252 - Windows Western European encoding" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/set_encoding_iso88591.py:21 -msgid "Set screen encoding to ISO-8859-1 (Latin-1)" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/set_encoding_iso88591.py:27 -msgid "Screen encoding already set to ISO-8859-1" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/set_encoding_iso88591.py:33 -msgid "Screen encoding set to ISO-8859-1 - Latin-1 encoding" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/set_encoding_utf8.py:21 -msgid "Set screen encoding to UTF-8 (Unicode)" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/set_encoding_utf8.py:27 -msgid "Screen encoding already set to UTF-8" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/set_encoding_utf8.py:33 -msgid "Screen encoding set to UTF-8 - Unicode text encoding" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/adjust_volume.py:21 -msgid "Adjust sound volume" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/adjust_volume.py:28 -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/adjust_volume.py:41 -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/adjust_volume.py:45 -msgid "Failed to change sound volume" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/adjust_volume.py:34 -#, python-brace-format -msgid "Current sound volume: {0} percent" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/adjust_volume.py:52 -#, python-brace-format -msgid "Sound volume set to {0} percent" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/adjust_volume.py:57 -msgid "Sound volume unchanged" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/select_driver.py:21 -msgid "Select sound driver (genericDriver or gstreamerDriver)" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/select_driver.py:27 -#, python-brace-format -msgid "Current sound driver: {0}" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/select_driver.py:32 -msgid "GStreamer Driver - advanced multimedia framework" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/select_driver.py:35 -msgid "Generic Driver - uses SOX for audio playback" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/select_driver.py:41 -#, python-brace-format -msgid "Sound driver changed to {0}. {1}" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/select_driver.py:50 -msgid "Failed to change sound driver" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/select_theme.py:21 -msgid "Select sound theme" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/select_theme.py:30 -#, python-brace-format -msgid "Current sound theme: {0}" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/select_theme.py:52 -#, python-brace-format -msgid "Sound theme changed to: {0}" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/select_theme.py:55 -msgid "Failed to change sound theme" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/select_theme.py:58 -msgid "Only default sound theme is available" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/toggle_enabled.py:21 -msgid "Toggle sound output on or off" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/toggle_enabled.py:27 -msgid "Sound enabled" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/toggle_enabled.py:27 -msgid "Sound disabled" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/toggle_enabled.py:32 -msgid "Failed to change sound setting" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/adjust_pitch.py:17 -msgid "Adjust speech pitch (voice height)" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/adjust_pitch.py:33 -#, python-brace-format -msgid "Current speech pitch: {0} percent" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/adjust_pitch.py:48 -#, python-brace-format -msgid "Speech pitch set to {0} percent" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/adjust_rate.py:17 -msgid "Adjust speech rate (speed)" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/adjust_rate.py:33 -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/adjust_speech_rate.py:30 -#, python-brace-format -msgid "Current speech rate: {0} percent" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/adjust_rate.py:48 -#, python-brace-format -msgid "Speech rate set to {0} percent" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/adjust_speech_rate.py:17 -msgid "Adjust speech rate (temporary - use save to make permanent)" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/adjust_speech_rate.py:53 -#, python-brace-format -msgid "Speech rate temporarily set to {0} percent" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/adjust_speech_rate.py:58 -msgid "Use save command to make permanent" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/adjust_volume.py:17 -msgid "Adjust speech volume (loudness)" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/adjust_volume.py:33 -#, python-brace-format -msgid "Current speech volume: {0} percent" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/adjust_volume.py:49 -#, python-brace-format -msgid "Speech volume set to {0} percent" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/show_current_settings.py:17 -msgid "Show current speech settings" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/show_current_settings.py:44 -msgid "Current speech settings:" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/show_current_settings.py:49 -#, python-brace-format -msgid "Speech enabled: {0}" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/show_current_settings.py:52 -#, python-brace-format -msgid "Driver: {0}" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/show_current_settings.py:57 -#, python-brace-format -msgid "Module: {0}" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/show_current_settings.py:62 -#, python-brace-format -msgid "Voice: {0}" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/show_current_settings.py:68 -#, python-brace-format -msgid "Rate: {0} percent" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/show_current_settings.py:72 -#, python-brace-format -msgid "Rate: {0}" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/show_current_settings.py:78 -#, python-brace-format -msgid "Pitch: {0} percent" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/show_current_settings.py:82 -#, python-brace-format -msgid "Pitch: {0}" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/show_current_settings.py:88 -#, python-brace-format -msgid "Volume: {0} percent" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/show_current_settings.py:92 -#, python-brace-format -msgid "Volume: {0}" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/mc/file/open.py:26 -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/nano/file/open.py:26 -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/vim/file/open.py:26 -msgid "ok i run open macro" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/mc/file/save.py:26 -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/mutt/file/save.py:26 -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/vim/file/save.py:26 -msgid "ok i run open save" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/mc/search/replace.py:26 -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/mutt/search/replace.py:26 -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/nano/search/replace.py:26 -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/vim/search/replace.py:26 -msgid "ok i run replace macro" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/mc/search/search.py:26 -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/mutt/search/search.py:26 -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/nano/search/search.py:26 -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/vim/search/search.py:26 -msgid "ok i run search macro" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/mc/search/test.py:23 -msgid "Test mc search functionality" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/mc/search/test.py:27 -msgid "MC search test: This demonstrates search functionality" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/mutt/search/test.py:23 -msgid "Test mutt search functionality" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/mutt/search/test.py:27 -msgid "Mutt search test: This demonstrates search functionality" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/nano/Help/about_nano.py:26 -msgid "Learn about the Nano text editor." -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/nano/Help/about_nano.py:30 -msgid "Okay, loading the information about Nano." -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/nano/file/save.py:26 -msgid "Save your work." -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/nano/file/save.py:30 -msgid "Okay, you will now be asked to save your work." -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/nano/search/test.py:23 -msgid "Test nano search functionality" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/nano/search/test.py:27 -msgid "Nano search test: This demonstrates search functionality" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/vim/search/test.py:23 -msgid "Test vim search functionality" -msgstr "" - -#: src/fenrirscreenreader/commands/vmenu-profiles/KEY/vim/search/test.py:27 -msgid "Vim search test: This demonstrates search functionality" -msgstr "" - -#: src/fenrirscreenreader/core/attributeManager.py:209 -msgid "bold" -msgstr "" - -#: src/fenrirscreenreader/core/attributeManager.py:221 -msgid "italic" -msgstr "" - -#: src/fenrirscreenreader/core/attributeManager.py:233 -msgid "underline" -msgstr "" - -#: src/fenrirscreenreader/core/attributeManager.py:245 -msgid "strikethrough" -msgstr "" - -#: src/fenrirscreenreader/core/attributeManager.py:257 -msgid "reverse" -msgstr "" - -#: src/fenrirscreenreader/core/attributeManager.py:269 -msgid "blink" -msgstr "" - -#: src/fenrirscreenreader/core/attributeManager.py:302 -#: src/fenrirscreenreader/core/attributeManager.py:313 -msgid "default" -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:71 -#: src/fenrirscreenreader/core/diffReviewManager.py:101 -msgid "Clipboard does not contain full path to diff file." -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:78 -msgid "Diff review mode enabled." -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:109 -#: src/fenrirscreenreader/core/diffReviewManager.py:403 -#: src/fenrirscreenreader/core/diffReviewManager.py:436 -#: src/fenrirscreenreader/core/diffReviewManager.py:452 -#: src/fenrirscreenreader/core/diffReviewManager.py:550 -#: src/fenrirscreenreader/core/diffReviewManager.py:611 -#: src/fenrirscreenreader/core/diffReviewManager.py:682 -#: src/fenrirscreenreader/core/diffReviewManager.py:705 -#: src/fenrirscreenreader/core/diffReviewManager.py:716 -msgid "No diff file loaded." -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:117 -msgid "Diff file not found: " -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:122 -msgid "Diff path is a directory: " -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:127 -msgid "Cannot read diff file: " -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:140 -msgid "Failed to load diff file: " -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:145 -msgid "Loading " -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:149 -msgid "Diff loaded. " -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:151 -msgid " files changed. " -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:153 -msgid " additions. " -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:155 -msgid " removals." -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:333 -#: src/fenrirscreenreader/core/diffReviewManager.py:342 -msgid "No file sections in loaded diff." -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:334 -msgid "No further file sections." -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:343 -msgid "No previous file sections." -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:351 -#: src/fenrirscreenreader/core/diffReviewManager.py:360 -msgid "No hunks in loaded diff." -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:352 -msgid "No further hunks." -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:361 -msgid "No previous hunks." -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:369 -#: src/fenrirscreenreader/core/diffReviewManager.py:378 -msgid "No added lines in loaded diff." -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:370 -msgid "No further added lines." -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:379 -msgid "No previous added lines." -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:387 -#: src/fenrirscreenreader/core/diffReviewManager.py:396 -msgid "No removed lines in loaded diff." -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:388 -msgid "No further removed lines." -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:397 -msgid "No previous removed lines." -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:407 -msgid "Diff summary. Files changed: " -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:409 -msgid ". Additions: " -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:411 -msgid ". Removals: " -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:425 -msgid "" -"Diff keys: h next hunk, shift h previous hunk, a next addition, shift a " -"previous addition, d next removal, shift d previous removal, f next file " -"section, shift f previous file section, up previous line, down next line, " -"left previous character, right next character, control left previous word, " -"control right next word, s summary, escape exit diff mode." -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:444 -msgid "End of diff." -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:460 -msgid "Start of diff." -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:474 -msgid "End of line." -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:487 -msgid "Start of line." -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:498 -#: src/fenrirscreenreader/core/diffReviewManager.py:522 -msgid "No words on this line." -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:510 -msgid "No next word on this line." -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:535 -msgid "No previous word on this line." -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:597 -msgid "Added line." -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:599 -msgid "Removed line." -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:601 -msgid "Diff line." -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:623 -msgid "Added" -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:624 -msgid "Removed" -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:625 -msgid "Hunk header" -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:626 -msgid "File header" -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:627 -msgid "Note" -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:629 -msgid "Line" -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:642 -#: src/fenrirscreenreader/core/diffReviewManager.py:667 -#: src/fenrirscreenreader/core/diffReviewManager.py:791 -msgid "added" -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:643 -#: src/fenrirscreenreader/core/diffReviewManager.py:796 -msgid "removed" -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:663 -msgid "changed" -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:665 -msgid "deleted" -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:672 -msgid "line " -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:674 -msgid "lines " -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:676 -msgid " through " -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:694 -#: src/fenrirscreenreader/utils/char_utils.py:159 -msgid "space" -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:712 -msgid "No word at current position." -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:732 -msgid "File section." -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:736 -msgid "File section " -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:738 -#: src/fenrirscreenreader/core/diffReviewManager.py:763 -#: src/fenrirscreenreader/core/diffReviewManager.py:767 -#: src/fenrirscreenreader/core/diffReviewManager.py:812 -#: src/fenrirscreenreader/core/diffReviewManager.py:821 -msgid " of " -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:740 -#: src/fenrirscreenreader/core/diffReviewManager.py:769 -msgid ". Plus " -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:742 -#: src/fenrirscreenreader/core/diffReviewManager.py:771 -msgid ", minus " -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:751 -msgid "Hunk section." -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:761 -#: src/fenrirscreenreader/core/diffReviewManager.py:819 -msgid "Hunk " -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:765 -msgid " in file " -msgstr "" - -#: src/fenrirscreenreader/core/diffReviewManager.py:810 -msgid "File " -msgstr "" - -#: src/fenrirscreenreader/core/dynamicKeyboardLayoutMenu.py:27 -#, python-brace-format -msgid "Set keyboard layout to {0}" -msgstr "" - -#: src/fenrirscreenreader/core/dynamicKeyboardLayoutMenu.py:40 -#, python-brace-format -msgid "Keyboard layout already set to {0}" -msgstr "" - -#: src/fenrirscreenreader/core/dynamicKeyboardLayoutMenu.py:56 -#, python-brace-format -msgid "" -"Keyboard layout set to {0}. Please restart Fenrir for this change to take " -"effect." -msgstr "" - -#: src/fenrirscreenreader/core/dynamicKeyboardLayoutMenu.py:65 -#, python-brace-format -msgid "Failed to change keyboard layout to {0}" -msgstr "" - -#: src/fenrirscreenreader/core/dynamicKeyboardLayoutMenu.py:78 -#, python-brace-format -msgid "Keyboard layout change error: {0}" -msgstr "" - -#: src/fenrirscreenreader/core/dynamicVoiceMenu.py:28 -#, python-brace-format -msgid "Select voice: {0}" -msgstr "" - -#: src/fenrirscreenreader/core/dynamicVoiceMenu.py:35 -#, python-brace-format -msgid "Testing voice {0} from {1}. Please wait." -msgstr "" - -#: src/fenrirscreenreader/core/dynamicVoiceMenu.py:51 -#, python-brace-format -msgid "Voice selection error: {0}" -msgstr "" - -#: src/fenrirscreenreader/core/dynamicVoiceMenu.py:61 -#, python-brace-format -msgid "Voice test failed: {0}" -msgstr "" - -#: src/fenrirscreenreader/core/dynamicVoiceMenu.py:78 -msgid "" -"Voice test completed successfully. Navigate to Apply Tested Voice to use " -"this voice." -msgstr "" - -#: src/fenrirscreenreader/core/dynamicVoiceMenu.py:102 -msgid "Apply tested voice to Fenrir" -msgstr "" - -#: src/fenrirscreenreader/core/dynamicVoiceMenu.py:125 -#, python-brace-format -msgid "Current: {0} from {1}" -msgstr "" - -#: src/fenrirscreenreader/core/dynamicVoiceMenu.py:164 -#, python-brace-format -msgid "Speech driver now has module: {0}, voice: {1}" -msgstr "" - -#: src/fenrirscreenreader/core/dynamicVoiceMenu.py:173 -msgid "Voice applied successfully! You should hear this in the new voice." -msgstr "" - -#: src/fenrirscreenreader/core/dynamicVoiceMenu.py:336 -#, python-brace-format -msgid "No voices found for {0}" -msgstr "" - -#: src/fenrirscreenreader/core/dynamicVoiceMenu.py:347 -msgid "Voice information is still loading" -msgstr "" - -#: src/fenrirscreenreader/core/dynamicVoiceMenu.py:356 -#: src/fenrirscreenreader/core/quickMenuManager.py:491 -msgid "Voice discovery failed" -msgstr "" - -#: src/fenrirscreenreader/core/fenrirManager.py:38 -msgid "Start Fenrir" -msgstr "" - -#: src/fenrirscreenreader/core/fenrirManager.py:470 -msgid "Quit Fenrir" -msgstr "" - -#: src/fenrirscreenreader/core/helpManager.py:153 -#, python-brace-format -msgid "{} times " -msgstr "" - -#: src/fenrirscreenreader/core/helpManager.py:183 -msgid "toggles the tutorial mode" -msgstr "" - -#: src/fenrirscreenreader/core/helpManager.py:189 -msgid "no description available" -msgstr "" - -#: src/fenrirscreenreader/core/helpManager.py:196 -msgid "unbound" -msgstr "" - -#: src/fenrirscreenreader/core/helpManager.py:198 -#, python-brace-format -msgid "" -"{command_name}, Shortcuts {command_shortcuts}, Description " -"{command_description}" -msgstr "" - -#: src/fenrirscreenreader/core/helpManager.py:231 -msgid "Plugins" -msgstr "" - -#: src/fenrirscreenreader/core/helpManager.py:233 -msgid "Unbound actions" -msgstr "" - -#: src/fenrirscreenreader/core/helpManager.py:234 -msgid "Active actions" -msgstr "" - -#: src/fenrirscreenreader/core/outputManager.py:501 -msgid "speech temporary disabled" -msgstr "" - -#: src/fenrirscreenreader/core/outputManager.py:523 -msgid " review cursor " -msgstr "" - -#: src/fenrirscreenreader/core/outputManager.py:525 -msgid " text cursor " -msgstr "" - -#: src/fenrirscreenreader/core/quickMenuManager.py:324 -msgid "No modules available" -msgstr "" - -#: src/fenrirscreenreader/core/quickMenuManager.py:376 -#, python-brace-format -msgid "{0}; voice discovery failed" -msgstr "" - -#: src/fenrirscreenreader/core/quickMenuManager.py:410 -msgid "No module selected" -msgstr "" - -#: src/fenrirscreenreader/core/quickMenuManager.py:448 -msgid "No voices available" -msgstr "" - -#: src/fenrirscreenreader/core/quickMenuManager.py:490 -msgid "Module discovery failed" -msgstr "" - -#: src/fenrirscreenreader/core/quickMenuManager.py:500 -msgid "speech rate" -msgstr "" - -#: src/fenrirscreenreader/core/quickMenuManager.py:501 -msgid "speech pitch" -msgstr "" - -#: src/fenrirscreenreader/core/quickMenuManager.py:502 -msgid "speech volume" -msgstr "" - -#: src/fenrirscreenreader/core/quickMenuManager.py:503 -msgid "speech module" -msgstr "" - -#: src/fenrirscreenreader/core/quickMenuManager.py:504 -msgid "speech voice" -msgstr "" - -#: src/fenrirscreenreader/core/quickMenuManager.py:505 -msgid "sound volume" -msgstr "" - -#: src/fenrirscreenreader/core/quickMenuManager.py:516 -msgid "setting invalid" -msgstr "" - -#: src/fenrirscreenreader/core/quickMenuManager.py:532 -msgid "setting value invalid" -msgstr "" - -#: src/fenrirscreenreader/core/screenDriver.py:15 -msgid "black" -msgstr "" - -#: src/fenrirscreenreader/core/screenDriver.py:16 -msgid "blue" -msgstr "" - -#: src/fenrirscreenreader/core/screenDriver.py:17 -msgid "green" -msgstr "" - -#: src/fenrirscreenreader/core/screenDriver.py:18 -msgid "cyan" -msgstr "" - -#: src/fenrirscreenreader/core/screenDriver.py:19 -msgid "red" -msgstr "" - -#: src/fenrirscreenreader/core/screenDriver.py:20 -#: src/fenrirscreenreader/core/screenDriver.py:30 -msgid "Magenta" -msgstr "" - -#: src/fenrirscreenreader/core/screenDriver.py:21 -#: src/fenrirscreenreader/core/screenDriver.py:31 -msgid "brown/yellow" -msgstr "" - -#: src/fenrirscreenreader/core/screenDriver.py:22 -msgid "white" -msgstr "" - -#: src/fenrirscreenreader/core/screenDriver.py:25 -msgid "Black" -msgstr "" - -#: src/fenrirscreenreader/core/screenDriver.py:26 -msgid "Blue" -msgstr "" - -#: src/fenrirscreenreader/core/screenDriver.py:27 -msgid "Green" -msgstr "" - -#: src/fenrirscreenreader/core/screenDriver.py:28 -msgid "Cyan" -msgstr "" - -#: src/fenrirscreenreader/core/screenDriver.py:29 -msgid "Red" -msgstr "" - -#: src/fenrirscreenreader/core/screenDriver.py:32 -msgid "Light gray" -msgstr "" - -#: src/fenrirscreenreader/core/screenDriver.py:33 -msgid "Dark gray" -msgstr "" - -#: src/fenrirscreenreader/core/screenDriver.py:34 -msgid "Light blue" -msgstr "" - -#: src/fenrirscreenreader/core/screenDriver.py:36 -msgid "Light cyan" -msgstr "" - -#: src/fenrirscreenreader/core/screenDriver.py:37 -msgid "Light red" -msgstr "" - -#: src/fenrirscreenreader/core/screenDriver.py:38 -msgid "Light magenta" -msgstr "" - -#: src/fenrirscreenreader/core/screenDriver.py:39 -msgid "Light yellow" -msgstr "" - -#: src/fenrirscreenreader/core/screenDriver.py:40 -msgid "White" -msgstr "" - -#: src/fenrirscreenreader/core/speechHistoryManager.py:52 -#: src/fenrirscreenreader/core/speechHistoryManager.py:106 -msgid "speech history empty" -msgstr "" - -#: src/fenrirscreenreader/core/speechHistoryManager.py:58 -msgid "Speech history" -msgstr "" - -#: src/fenrirscreenreader/core/speechHistoryManager.py:65 -msgid "speech history closed" -msgstr "" - -#: src/fenrirscreenreader/core/speechHistoryManager.py:79 -msgid "First speech history item" -msgstr "" - -#: src/fenrirscreenreader/core/speechHistoryManager.py:96 -msgid "Last speech history item" -msgstr "" - -#: src/fenrirscreenreader/core/speechHistoryManager.py:122 -msgid "copied to clipboard" -msgstr "" - -#: src/fenrirscreenreader/core/vmenuManager.py:362 -#, python-brace-format -msgid "{0} Menu" -msgstr "" - -#: src/fenrirscreenreader/core/vmenuManager.py:364 -#, python-brace-format -msgid "{0} Action" -msgstr "" - -#: src/fenrir:28 -msgid "Fenrir - A console screen reader for Linux" -msgstr "" - -#: src/fenrir:38 -msgid "Show version information and exit" -msgstr "" - -#: src/fenrir:44 -msgid "Run Fenrir in the foreground (default: run as daemon)" -msgstr "" - -#: src/fenrir:51 -msgid "Path to custom settings file" -msgstr "" - -#: src/fenrir:59 -msgid "" -"Override settings file options. Format: SECTION#SETTING=VALUE;... (case " -"sensitive)" -msgstr "" - -#: src/fenrir:63 -msgid "Enable debug mode" -msgstr "" - -#: src/fenrir:66 -msgid "Print debug messages to screen" -msgstr "" - -#: src/fenrir:73 -msgid "Use PTY emulation with X11 keyboard input scoped to the terminal window" -msgstr "" - -#: src/fenrir:80 -msgid "X11 window id to use for --x11 terminal mode" -msgstr "" - -#: src/fenrir:86 -msgid "Force Fenrir to respond on all screens, ignoring ignoreScreen setting" -msgstr "" - -#: src/fenrir:95 -msgid "" -"Ignore specific screen(s). Can be used multiple times. Same as ignoreScreen " -"setting." -msgstr "" - -#: src/fenrir:107 -#, python-brace-format -msgid "" -"Invalid option format: {0}\n" -"Expected format: SECTION#SETTING=VALUE" -msgstr "" - -#: src/fenrir:111 -msgid "--x11-window-id requires --x11" -msgstr "" - -#: src/fenrir:123 -#, python-brace-format -msgid "Error starting Fenrir: {0}" -msgstr "" - -#: src/fenrir:126 -msgid "Interrupted" -msgstr "" - -#: src/fenrir:134 -#, python-brace-format -msgid "Error during cleanup: {0}" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "0" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "1" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "102nd" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "10channelsdown" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "10channelsup" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "2" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "3" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "3d_mode" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "4" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "5" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "6" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "7" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "8" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "9" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "Apply Tested Voice Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "Help Menu" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "Keyboard Layouts Menu" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "Loading voices Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "No voices available Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "Voice Browser Menu" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "Voice discovery failed Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "a" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "a_grinning Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "ab" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "about_nano Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "addressbook" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "adjust_pitch Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "adjust_rate Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "adjust_speech_rate Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "adjust_volume Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "again" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "alien_monster Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "als_toggle" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "alt" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "alterase" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "angle" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "angry Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "apostrophe" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "apple Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "appselect" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "archive" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "argentina Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "attendant_off" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "attendant_on" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "attendant_toggle" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "audio" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "audio_desc" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "australia Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "aux" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "avocado Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "b" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "back" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "backslash" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "backspace" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "backup_config Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "bassboost" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "bat Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "battery" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "beaming Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "beer Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "belgium Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "birthday_cake Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "black_cat Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "blowing_kiss Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "bluetooth" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "bookmarks" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "brazil Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "break" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "brightness_auto" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "brightness_cycle" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "brightness_max" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "brightness_min" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "brightness_toggle" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "brightness_zero" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "brightnessdown" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "brightnessup" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "brl_dot1" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "brl_dot10" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "brl_dot2" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "brl_dot3" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "brl_dot4" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "brl_dot5" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "brl_dot6" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "brl_dot7" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "brl_dot8" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "brl_dot9" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "bunny Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "butterfly Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "buttonconfig" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "c" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "calc" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "calendar" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "camera" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "camera_down" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "camera_focus" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "camera_left" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "camera_right" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "camera_up" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "camera_zoomin" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "camera_zoomout" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "canada Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "cancel" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "capslock" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "cat Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "cauldron Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "cd" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "chains Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "channel" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "channeldown" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "channelup" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "chat" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "checkmark Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "cherry_blossom Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "china Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "christmas_tree Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "clear" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "close" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "closecd" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "coffee" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "coffee Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "coffin Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "comma" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "compose" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "computer" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "config" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "connect" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "context_menu" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "controlpanel" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "cool Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "copy" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "crossbones Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "crying Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "ctrl" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "cut" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "cyclewindows" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "d" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "dagger Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "dark gray" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "dashboard" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "data" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "database" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "del_eol" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "del_eos" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "del_line" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "delete" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "deletefile" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "denmark Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "devil Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "digits" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "direction" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "directory" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "display_off" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "displaytoggle" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "dizzy Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "documents" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "dog Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "dollar" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "donut Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "dot" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "down" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "dvd" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "e" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "easter_egg Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "edit" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "editor" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "ejectcd" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "ejectclosecd" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "email" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "emoji Menu" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "end" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "enter" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "epg" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "equal" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "esc" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "euro" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "exit" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "exploding_head Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "f" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "f1" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "f10" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "f11" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "f12" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "f13" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "f14" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "f15" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "f16" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "f17" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "f18" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "f19" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "f2" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "f20" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "f21" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "f22" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "f23" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "f24" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "f3" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "f4" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "f5" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "f6" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "f7" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "f8" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "f9" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "face_with_symbols Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "fastforward" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "fastreverse" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "favorites" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "fenrir" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "fenrir Menu" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "file" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "file Menu" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "finance" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "find" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "finland Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "fire Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "fireworks Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "first" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "flags Menu" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "fn" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "fn_1" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "fn_2" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "fn_b" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "fn_d" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "fn_e" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "fn_esc" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "fn_f" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "fn_f1" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "fn_f10" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "fn_f11" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "fn_f12" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "fn_f2" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "fn_f3" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "fn_f4" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "fn_f5" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "fn_f6" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "fn_f7" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "fn_f8" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "fn_f9" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "fn_s" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "folded_hands Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "food Menu" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "forward" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "forwardmail" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "frameback" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "frameforward" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "france Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "front" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "g" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "games" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "general Menu" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "germany Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "ghost Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "gift Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "goto" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "graphicseditor" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "grave" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "greece Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "grinning_sweat Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "h" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "hamburger Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "hangeul" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "hanguel" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "hanja" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "heart Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "heart_eyes Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "hearts_around Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "help" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "henkan" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "high_voltage Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "hiragana" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "holidays Menu" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "home" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "homepage" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "hp" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "hundred_points Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "i" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "images" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "imp Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "india Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "info" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "ins_line" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "insert" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "ireland Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "iso" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "israel Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "issue Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "italy Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "j" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "jack_o_lantern Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "japan Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "journal" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "k" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "katakana" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "katakanahiragana" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "kbdillumdown" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "kbdillumtoggle" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "kbdillumup" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "kbdinputassist_accept" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "kbdinputassist_cancel" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "kbdinputassist_next" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "kbdinputassist_nextgroup" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "kbdinputassist_prev" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "kbdinputassist_prevgroup" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "keyboard" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "keyboard Menu" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "keypad 0" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "keypad 1" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "keypad 2" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "keypad 3" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "keypad 4" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "keypad 5" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "keypad 6" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "keypad 7" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "keypad 8" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "keypad 9" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "keypad asterisk" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "keypad comma" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "keypad dot" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "keypad enter" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "keypad equal" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "keypad jpcomma" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "keypad leftparen" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "keypad minus" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "keypad plus" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "keypad plusminus" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "keypad rightparen" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "keypad slash" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "kp0" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "kp1" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "kp2" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "kp3" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "kp4" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "kp5" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "kp6" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "kp7" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "kp8" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "kp9" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "kpasterisk" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "kpcomma" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "kpdot" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "kpenter" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "kpequal" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "kpjpcomma" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "kpleftparen" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "kpminus" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "kpplus" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "kpplusminus" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "kprightparen" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "kpslash" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "l" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "language" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "last" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "laughing Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "left" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "left_down" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "left_up" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "leftalt" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "leftbrace" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "leftctrl" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "leftmeta" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "leftshift" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "light blue" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "light cyan" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "light gray" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "light green" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "light magenta" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "light red" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "light yellow" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "lightning Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "lights_toggle" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "linefeed" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "list" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "logoff" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "m" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "macro" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "magenta" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "mail" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "management Menu" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "mc Menu" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "media" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "media_repeat" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "media_top_menu" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "memo" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "menu" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "messenger" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "meta" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "mexico Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "mhp" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "micmute" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "min_interesting" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "mindblown Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "minus" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "mode" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "moon Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "move" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "mp3" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "msdos" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "muhenkan" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "mummy Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "mute" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "mutt Menu" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "n" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "nano Menu" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "nature Menu" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "nauseated Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "netherlands Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "new" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "news" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "next" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "next_favorite" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "nextsong" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "norway Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "numeric_0" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "numeric_1" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "numeric_11" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "numeric_12" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "numeric_2" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "numeric_3" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "numeric_4" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "numeric_5" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "numeric_6" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "numeric_7" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "numeric_8" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "numeric_9" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "numeric_a" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "numeric_b" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "numeric_c" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "numeric_d" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "numeric_pound" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "numeric_star" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "numlock" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "o" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "ok" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "open" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "open Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "option" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "p" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "pagedown" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "pageup" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "paste" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "pause" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "pause_record" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "pausecd" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "pc" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "peace Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "people Menu" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "phone" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "pizza Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "play" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "playcd" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "player" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "playpause" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "pleading Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "poland Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "poop Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "portugal Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "power" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "power2" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "presentation" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "previous" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "previoussong" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "print" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "prog1" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "prog2" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "prog3" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "prog4" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "program" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "props" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "pvr" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "q" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "question" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "r" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "radio" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "rainbow Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "record" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "redo" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "refresh" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "reload_config Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "replace Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "reply" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "reserved" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "reset_defaults Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "restart" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "revert_to_saved Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "rewind" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "rfkill" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "right" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "right_down" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "right_up" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "rightalt" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "rightbrace" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "rightctrl" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "rightmeta" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "rightshift" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "ro" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "rock_on Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "rolling_laughing Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "root_menu" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "rose Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "rotate_display" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "russia Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "s" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "sad Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "santa Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "sat" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "sat2" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "save" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "save Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "save_config Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "save_session_settings Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "savoring_food Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "scale" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "screaming Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "screen" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "screen Menu" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "screenlock" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "screensaver" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "script" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "scrolldown" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "scrolllock" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "scrollup" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "search" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "search Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "search Menu" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "select" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "select_driver Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "select_theme Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "semicolon" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "send" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "sendfile" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "set_char_echo_always Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "set_char_echo_caps Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "set_char_echo_none Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "set_encoding_auto Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "set_encoding_cp1252 Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "set_encoding_iso88591 Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "set_encoding_utf8 Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "set_punctuation_all Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "set_punctuation_most Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "set_punctuation_none Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "set_punctuation_some Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "setup" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "shamrock Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "shift" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "shocked Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "shop" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "show_current_settings Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "show_unsaved_changes Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "shuffle" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "skull Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "skull_crossbones Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "slash" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "sleep" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "slow" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "slowreverse" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "smiling Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "smirking Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "snowman Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "sound" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "sound Menu" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "south_africa Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "south_korea Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "spain Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "sparkles Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "speech Menu" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "spellcheck" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "spider Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "sport" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "spreadsheet" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "star Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "stop" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "stop_record" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "stopcd" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "strawberry Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "subtitle" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "sun Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "sunflower Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "suspend" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "sweden Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "switchvideomode" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "switzerland Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "sword Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "symbols Menu" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "sysrq" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "t" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "tab" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "taco Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "tape" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "taskmanager" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "teen" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "test Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "text" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "thumbs_up Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "time" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "title" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "toggle_debug Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "toggle_enabled Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "toggle_grab_devices Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "toggle_word_echo Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "touchpad_off" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "touchpad_on" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "touchpad_toggle" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "tree Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "tuner" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "turkey Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "tv" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "tv2" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "twen" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "u" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "uk Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "ukraine Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "undo" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "unknown" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "unmute" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "up" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "usa Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "uwb" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "v" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "vampire Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "vcr" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "vcr2" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "vendor" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "video" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "video_next" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "video_prev" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "videophone" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "vim Menu" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "vod" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "voicecommand" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "voicemail" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "volumedown" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "volumeup" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "w" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "wakeup" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "web Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "wimax" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "winking Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "witch Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "wlan" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "wolf Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "wordprocessor" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "wps_button" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "wwan" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "www" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "x" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "xfer" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "y" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "yellow" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "yen" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "z" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "zenkakuhankaku" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "zombie Action" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "zoom" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "zoomin" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "zoomout" -msgstr "" - -#. TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged. -msgid "zoomreset" -msgstr "" diff --git a/tools/generate_translations.py b/tools/generate_translations.py deleted file mode 100644 index bb722ecf..00000000 --- a/tools/generate_translations.py +++ /dev/null @@ -1,232 +0,0 @@ -#!/usr/bin/env python3 -"""Maintain gettext resources without writing translations for contributors.""" - -import argparse -import ast -import json -from pathlib import Path -import re -import shutil -import subprocess -import sys -import tempfile - - -def run_tool(arguments, project_root, **kwargs): - subprocess.run(arguments, cwd=project_root, check=True, **kwargs) - - -def extract_template(project_root, output): - source_files = sorted( - str(path.relative_to(project_root)) - for path in (project_root / "src").rglob("*.py") - ) - source_files.append("src/fenrir") - options = [ - "xgettext", - "--language=Python", - "--from-code=UTF-8", - "--keyword=_", - "--keyword=ngettext:1,2", - "--add-comments=TRANSLATORS:", - "--package-name=fenrir", - "--copyright-holder=Fenrir contributors", - "--output=" + str(output), - ] - run_tool(options + source_files, project_root) - - # These identifiers are looked up dynamically when labels are presented. - # Extract them here without translating filenames or internal menu keys. - profiles = project_root / "src/fenrirscreenreader/commands/vmenu-profiles/KEY" - labels = { - "Voice Browser Menu", - "Keyboard Layouts Menu", - "Apply Tested Voice Action", - "No voices available Action", - "Loading voices Action", - "Voice discovery failed Action", - } - for path in profiles.rglob("*"): - if any(part.startswith("__") for part in path.relative_to(profiles).parts): - continue - if path.is_dir(): - labels.add(path.name + " Menu") - elif path.suffix == ".py" and not path.stem.endswith("_base"): - labels.add(path.stem + " Action") - - # Color names and key names are also runtime gettext lookups. - colors = project_root / "src/fenrirscreenreader/screenDriver/vcsaDriver.py" - tree = ast.parse(colors.read_text(encoding="utf-8")) - for node in ast.walk(tree): - if isinstance(node, ast.Assign) and any( - isinstance(target, ast.Attribute) - and target.attr in ("bgColorValues", "fgColorValues") - for target in node.targets - ): - labels.update(ast.literal_eval(node.value).values()) - input_data = project_root / "src/fenrirscreenreader/core/inputData.py" - tree = ast.parse(input_data.read_text(encoding="utf-8")) - for node in ast.walk(tree): - if isinstance(node, ast.Constant) and isinstance(node.value, str): - if node.value.startswith("KEY_"): - labels.add(node.value[4:].lower()) - labels.add( - node.value.lower() - .replace("key_kp", "keypad ") - .replace("key_", "") - .strip() - ) - marker_source = "\n".join( - "# TRANSLATORS: Spoken menu, key, or color label; identifiers stay unchanged.\n" - "_(" + json.dumps(label, ensure_ascii=False) + ")" - for label in sorted(labels) - ) - labels_template = output.with_name("labels.pot") - label_options = [option for option in options if not option.startswith("--output=")] - run_tool( - label_options + ["--output=" + str(labels_template), "--no-location", "-"], - project_root, - input=marker_source, - text=True, - ) - combined = output.with_name("combined.pot") - run_tool( - [ - "msgcat", - "--use-first", - "--output-file=" + str(combined), - str(output), - str(labels_template), - ], - project_root, - ) - combined.replace(output) - - -def atomic_write(destination, contents): - """Replace a resource only after gettext has produced it successfully.""" - if destination.exists() and destination.read_bytes() == contents: - return - with tempfile.NamedTemporaryFile(dir=destination.parent, delete=False) as stream: - temporary_path = Path(stream.name) - stream.write(contents) - try: - temporary_path.chmod(0o644) - temporary_path.replace(destination) - finally: - temporary_path.unlink(missing_ok=True) - - -def normalized_template(path): - return re.sub( - r'^"POT-Creation-Date:.*\n', - "", - path.read_text(encoding="utf-8"), - flags=re.MULTILINE, - ) - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "action", - nargs="?", - default="template", - choices=("template", "update", "check", "compile"), - help="template (default) changes only the POT; update merges selected POs; " - "check validates; compile builds selected MOs", - ) - parser.add_argument( - "catalogs", - nargs="*", - type=Path, - help="PO files relative to your working directory; " - "check and compile default to all catalogs", - ) - args = parser.parse_args() - if args.action == "template" and args.catalogs: - parser.error("template does not accept PO files") - if args.action == "update" and not args.catalogs: - parser.error("update requires explicit PO files") - - project_root = Path(__file__).resolve().parent.parent - if not (project_root / "src/fenrir").is_file(): - parser.error("run this tool from a Fenrir source checkout") - required = { - "template": ("xgettext", "msgcat"), - "update": ("xgettext", "msgcat", "msgmerge"), - "check": ("xgettext", "msgcat", "msgfmt"), - "compile": ("msgfmt",), - } - for tool in required[args.action]: - if not shutil.which(tool): - parser.error("install GNU gettext: missing " + tool) - - catalogs = [path.resolve() for path in args.catalogs] or sorted( - (project_root / "locale").glob("*/LC_MESSAGES/*.po") - ) - for path in catalogs: - if not path.is_file() or path.suffix != ".po": - parser.error("not a PO catalog: " + str(path)) - - template = project_root / "tools/fenrir.pot" - with tempfile.TemporaryDirectory(prefix="fenrir-gettext-") as directory: - temporary_dir = Path(directory) - extracted = temporary_dir / "fenrir.pot" - if args.action in ("template", "update", "check"): - extract_template(project_root, extracted) - if args.action == "template": - atomic_write(template, extracted.read_bytes()) - print("Updated tools/fenrir.pot; language catalogs were not changed.") - elif args.action == "update": - for index, catalog in enumerate(catalogs): - merged = temporary_dir / (str(index) + ".po") - run_tool( - [ - "msgmerge", - "--previous", - "--output-file=" + str(merged), - str(catalog), - str(extracted), - ], - project_root, - ) - atomic_write(catalog, merged.read_bytes()) - print("Updated " + str(catalog) + "; review fuzzy and empty entries.") - else: - if args.action == "check" and ( - not template.exists() - or normalized_template(template) != normalized_template(extracted) - ): - parser.exit( - 1, "Template is stale; run tools/generate_translations.sh\n" - ) - compiled = [] - for index, catalog in enumerate(catalogs): - output = temporary_dir / (str(index) + ".mo") - run_tool( - [ - "msgfmt", - "--check", - "--statistics", - "-o", - str(output), - str(catalog), - ], - project_root, - ) - compiled.append((catalog.parent / "fenrir.mo", output.read_bytes())) - if args.action == "compile": - for destination, contents in compiled: - atomic_write(destination, contents) - print("Compiled " + str(destination)) - else: - print("Template is current; catalogs passed gettext checks.") - - -if __name__ == "__main__": - try: - main() - except (OSError, subprocess.CalledProcessError) as error: - print("Translation tool failed: " + str(error), file=sys.stderr) - sys.exit(1) diff --git a/tools/generate_translations.sh b/tools/generate_translations.sh deleted file mode 100755 index e4031c7b..00000000 --- a/tools/generate_translations.sh +++ /dev/null @@ -1,2 +0,0 @@ -#!/usr/bin/env bash -exec python3 "$(dirname -- "${BASH_SOURCE[0]}")/generate_translations.py" "$@" diff --git a/tools/install_validation_hook.sh b/tools/install_validation_hook.sh deleted file mode 100755 index 83951e22..00000000 --- a/tools/install_validation_hook.sh +++ /dev/null @@ -1,110 +0,0 @@ -#!/bin/bash -# Safe Installation of Fenrir Validation Hook -# -# This script safely installs the composite pre-commit hook that combines -# your existing version management with new code quality validation. - -set -e - -# Colors for output -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -RED='\033[0;31m' -BLUE='\033[1;34m' -NC='\033[0m' # No Color - -echo -e "${BLUE}Fenrir Validation Hook Installation${NC}" -echo "====================================" - -# Check we're in the right directory -if [ ! -f "CLAUDE.md" ] || [ ! -d "src/fenrirscreenreader" ]; then - echo -e "${RED}Error: Must be run from Fenrir project root directory${NC}" - exit 1 -fi - -# Check if there's already a pre-commit hook -if [ -f ".git/hooks/pre-commit" ]; then - echo -e "\n${YELLOW}Existing pre-commit hook detected${NC}" - - # Check if it's a symlink (our validation hook) or a regular file (version hook) - if [ -L ".git/hooks/pre-commit" ]; then - echo -e "${YELLOW}Current hook appears to be our validation hook (symlink)${NC}" - read -p "Replace with composite hook that includes version management? (y/N): " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - echo -e "${YELLOW}Installation cancelled${NC}" - exit 0 - fi - rm .git/hooks/pre-commit - else - echo -e "${GREEN}Current hook appears to be the version management hook (regular file)${NC}" - - # Back up the existing hook - backup_name=".git/hooks/pre-commit.backup.$(date +%Y%m%d_%H%M%S)" - cp .git/hooks/pre-commit "$backup_name" - echo -e "${GREEN}āœ“ Existing hook backed up to: $backup_name${NC}" - - # Verify the backup contains version management code - if grep -q "versionFile" "$backup_name"; then - echo -e "${GREEN}āœ“ Backup contains version management logic${NC}" - else - echo -e "${YELLOW}⚠ Backup doesn't appear to contain version management logic${NC}" - echo -e "${YELLOW} You may need to manually restore version management functionality${NC}" - fi - - read -p "Install composite hook (version management + validation)? (Y/n): " -n 1 -r - echo - if [[ $REPLY =~ ^[Nn]$ ]]; then - echo -e "${YELLOW}Installation cancelled${NC}" - exit 0 - fi - fi -else - echo -e "${YELLOW}No existing pre-commit hook found${NC}" - read -p "Install composite hook? (Y/n): " -n 1 -r - echo - if [[ $REPLY =~ ^[Nn]$ ]]; then - echo -e "${YELLOW}Installation cancelled${NC}" - exit 0 - fi -fi - -# Install the composite hook -echo -e "\n${YELLOW}Installing composite pre-commit hook...${NC}" -cp tools/pre-commit-composite .git/hooks/pre-commit -chmod +x .git/hooks/pre-commit -echo -e "${GREEN}āœ“ Composite hook installed${NC}" - -# Test the hook -echo -e "\n${YELLOW}Testing the composite hook...${NC}" -if ./.git/hooks/pre-commit >/dev/null 2>&1; then - echo -e "${GREEN}āœ“ Composite hook test passed${NC}" -else - echo -e "${RED}⚠ Composite hook test found issues (this may be normal)${NC}" - echo " Run manually to see details: ./.git/hooks/pre-commit" -fi - -# Final instructions -echo -e "\n${GREEN}Installation Complete!${NC}" -echo "" -echo "Your composite pre-commit hook now provides:" -echo " 1. āœ“ Version management (existing functionality preserved)" -echo " 2. āœ“ Python syntax validation" -echo " 3. āœ“ Core module import testing" -echo " 4. āœ“ Common issue detection" -echo "" -echo "Development workflow:" -echo " • Make your changes" -echo " • git add . && git commit" -echo " • Hook runs automatically (version update + validation)" -echo "" -echo "Manual validation (optional):" -echo " • python3 tools/validate_syntax.py --fix" -echo " • python3 tools/validate_release.py --quick" -echo "" -echo -e "${BLUE}Environment variables:${NC}" -echo -e "${BLUE} SKIP_VERSION_UPDATE=1 Skip version management${NC}" -echo "" -if [ -f ".git/hooks/pre-commit.backup."* ]; then - echo -e "${YELLOW}Note: Your original hook is backed up and can be restored if needed${NC}" -fi \ No newline at end of file diff --git a/tools/listDevices.py b/tools/listDevices.py deleted file mode 100755 index 48fca74c..00000000 --- a/tools/listDevices.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python3 -import evdev -from evdev import InputDevice, UInput -from select import select -import time - -iDevices = map(evdev.InputDevice, (evdev.list_devices())) -iDevices = {dev.fd: dev for dev in iDevices} -print('----------------------') -for fd in iDevices: - dev = iDevices[fd] - cap = dev.capabilities() - print('Name: ' + str(dev.name)) - print('LEDs: ' + str(dev.leds())) - print('Has Keys: '+ str(evdev.events.EV_KEY in cap)) - if evdev.events.EV_KEY in cap: - print('No. of keys: ' + str(len(cap[evdev.events.EV_KEY]))) - print('has Key 116: ' + str(116 in cap[evdev.events.EV_KEY])) - print('Is Mouse: ' + str(((evdev.events.EV_REL in cap) or (evdev.events.EV_ABS in cap)))) - print('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!') - print(dev.capabilities(verbose=True)) - print('----------------------') - diff --git a/tools/pre-commit-composite b/tools/pre-commit-composite deleted file mode 100755 index 68a01322..00000000 --- a/tools/pre-commit-composite +++ /dev/null @@ -1,268 +0,0 @@ -#!/bin/bash -# Fenrir Composite Pre-commit Hook -# -# This hook combines version management and code quality validation. -# It first runs the version management logic, then runs validation. - -set -e - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[1;34m' -NC='\033[0m' # No Color - -echo -e "${BLUE}Fenrir Pre-commit Validation${NC}" -echo "==================================" - -# Get the repository root -REPO_ROOT=$(git rev-parse --show-toplevel) -cd "$REPO_ROOT" - -# ============================================================================ -# PART 1: VERSION MANAGEMENT (existing logic) -# ============================================================================ - -echo -e "\n${YELLOW}1. Version Management...${NC}" - -# Check if SKIP_VERSION_UPDATE is set -if [[ "${SKIP_VERSION_UPDATE}" = "1" ]]; then - echo -e "${YELLOW}Notice: Skipping version update due to SKIP_VERSION_UPDATE=1${NC}" -else - # Verify .git/versionpath exists - if [[ ! -f ".git/versionpath" ]]; then - echo -e "${RED}Error: .git/versionpath not found. Please create it with contents:${NC}" - echo -e "${YELLOW}versionFile=\"path/to/your/version/file\"${NC}" - exit 1 - fi - - # Source the version path file - source ".git/versionpath" - - # Validate that versionFile variable was set - if [[ -z "$versionFile" ]]; then - echo -e "${RED}Error: versionFile variable not set in .git/versionpath${NC}" - exit 1 - fi - - # Get current date components - year=$(date +%Y) - month=$(date +%m) - day=$(date +%d) - - # Create new version string - newVersion="$year.$month.$day" - - # Get current branch name - branchName=$(git rev-parse --abbrev-ref HEAD) - - # Check if we're in the middle of a merge - if [[ -f ".git/MERGE_HEAD" ]]; then - echo -e "${YELLOW}Warning: In the middle of a merge. Skipping version update.${NC}" - else - # Check if file exists relative to git root - if [[ ! -f "$versionFile" ]]; then - echo -e "${RED}Error: Version file not found at $versionFile${NC}" - exit 1 - fi - - # Store original version file content - originalContent=$(cat "$versionFile") - - # Check if version actually needs updating - if ! grep -q "version = \"$newVersion\"" "$versionFile"; then - # Update the version in the file - sed -i "s/version = [\"']\{0,1\}[0-9.]\+[\"']\{0,1\}/version = \"$newVersion\"/" "$versionFile" - fi - - # Check if code_name exists and isn't "stable" - if grep -q "code_name.*=.*\"stable\"" "$versionFile"; then - # Don't modify stable code_name - : - elif grep -q "code_name.*=.*\"$branchName\"" "$versionFile"; then - # code_name already matches branch name, no need to update - : - elif grep -q "code_name" "$versionFile"; then - # Update existing code_name - sed -i "s/code_name = [\"']\{0,1\}[^\"']*[\"']\{0,1\}/code_name = \"$branchName\"/" "$versionFile" - else - # Add code_name after the version line - sed -i "/version = / a\code_name = \"$branchName\"" "$versionFile" - fi - - # Check if the file was actually modified - if [[ "$(cat "$versionFile")" != "$originalContent" ]]; then - echo -e "${GREEN}āœ“ Version file updated to $newVersion${NC}" - if ! git diff --cached --quiet "$versionFile"; then - echo -e "${YELLOW}Notice: Version file was already staged, updates made to staged version${NC}" - else - git add "$versionFile" - echo -e "${YELLOW}Notice: Version file has been staged${NC}" - fi - else - echo -e "${GREEN}āœ“ No version updates needed${NC}" - fi - fi -fi - -# ============================================================================ -# PART 2: CODE QUALITY VALIDATION (our new logic) -# ============================================================================ - -echo -e "\n${YELLOW}2. Code Quality Validation...${NC}" - -# Track validation results -VALIDATION_FAILED=0 - -# 2a. Python Syntax Validation -echo -e "\n${YELLOW} 2a. Validating Python syntax...${NC}" -if python3 tools/validate_syntax.py --check-only >/dev/null 2>&1; then - echo -e "${GREEN} āœ“ Syntax validation passed${NC}" -else - echo -e "${RED} āœ— Syntax validation failed${NC}" - echo " Run: python3 tools/validate_syntax.py --fix" - VALIDATION_FAILED=1 -fi - -# 2a2. PEP8/flake8 Validation (for staged Python files only) -echo -e "\n${YELLOW} 2a2. Checking PEP8 compliance...${NC}" -if command -v flake8 >/dev/null 2>&1 && [ -n "$STAGED_PYTHON_FILES" ]; then - PEP8_ISSUES=0 - - # Check staged Python files with flake8 - # Focus on critical issues, ignore cosmetic ones for pre-commit - FLAKE8_SELECT="E9,F63,F7,F82" # Critical syntax/import errors only - FLAKE8_IGNORE="E501,W503,E203" # Ignore line length and some formatting - - for file in $STAGED_PYTHON_FILES; do - if [ -f "$file" ]; then - flake8_output=$(flake8 --select="$FLAKE8_SELECT" --ignore="$FLAKE8_IGNORE" "$file" 2>/dev/null || true) - if [ -n "$flake8_output" ]; then - if [ $PEP8_ISSUES -eq 0 ]; then - echo -e "${RED} āœ— Critical PEP8 issues found:${NC}" - fi - echo -e "${RED} $file:${NC}" - echo "$flake8_output" | sed 's/^/ /' - PEP8_ISSUES=1 - fi - fi - done - - if [ $PEP8_ISSUES -eq 0 ]; then - echo -e "${GREEN} āœ“ No critical PEP8 issues in staged files${NC}" - else - echo -e "${RED} āœ— Critical PEP8 issues found${NC}" - echo -e "${YELLOW} Run: flake8 --select=E9,F63,F7,F82 for details${NC}" - VALIDATION_FAILED=1 - fi -elif [ -n "$STAGED_PYTHON_FILES" ]; then - echo -e "${YELLOW} ⚠ flake8 not available (install with: pip install flake8)${NC}" -else - echo -e "${GREEN} āœ“ No Python files to check${NC}" -fi - -# 2b. Check for common issues in modified files -echo -e "\n${YELLOW} 2b. Checking modified files for common issues...${NC}" - -# Get list of staged files (all types) -STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM || true) -STAGED_PYTHON_FILES=$(echo "$STAGED_FILES" | grep '\.py$' || true) - -if [ -n "$STAGED_FILES" ]; then - ISSUES_FOUND=0 - - # Check for cache files being committed - CACHE_FILES=$(echo "$STAGED_FILES" | grep -E '(__pycache__|\.pyc$)' || true) - if [ -n "$CACHE_FILES" ]; then - echo -e "${RED} āœ— Python cache files staged for commit:${NC}" - echo "$CACHE_FILES" | while read cache_file; do - echo -e "${RED} $cache_file${NC}" - done - echo -e "${RED} āœ— Run: python3 tools/cleanup_cache.py --remove${NC}" - ISSUES_FOUND=1 - fi - - # Check Python files for specific issues - if [ -n "$STAGED_PYTHON_FILES" ]; then - for file in $STAGED_PYTHON_FILES; do - if [ -f "$file" ]; then - # Check for unterminated strings (the main issue from the email) - if grep -n 'f".*{$' "$file" >/dev/null 2>&1; then - echo -e "${RED} āœ— $file: Potential unterminated f-string${NC}" - ISSUES_FOUND=1 - fi - - # Check for missing imports that are commonly used - if grep -q 'debug\.DebugLevel\.' "$file" && ! grep -q 'from.*debug' "$file" && ! grep -q 'import.*debug' "$file"; then - echo -e "${YELLOW} ⚠ $file: Uses debug.DebugLevel but no debug import found${NC}" - fi - fi - done - fi - - if [ $ISSUES_FOUND -eq 0 ]; then - echo -e "${GREEN} āœ“ No common issues found in staged files${NC}" - else - echo -e "${RED} āœ— Common issues found in staged files${NC}" - VALIDATION_FAILED=1 - fi -else - echo -e "${GREEN} āœ“ No files staged for commit${NC}" -fi - -# 2c. Quick import test for core modules (informational only) -echo -e "\n${YELLOW} 2c. Testing core module imports...${NC}" -IMPORT_WARNINGS=0 - -# Test core imports that are critical (but don't fail on import issues - might be dependency related) -CORE_MODULES=( - "src.fenrirscreenreader.core.fenrirManager" - "src.fenrirscreenreader.core.commandManager" - "src.fenrirscreenreader.core.eventManager" -) - -cd src -for module in "${CORE_MODULES[@]}"; do - if python3 -c "import $module" 2>/dev/null; then - echo -e "${GREEN} āœ“ $module${NC}" - else - echo -e "${YELLOW} ⚠ $module (import failed - might be dependency related)${NC}" - IMPORT_WARNINGS=1 - fi -done -cd "$REPO_ROOT" - -if [ $IMPORT_WARNINGS -eq 1 ]; then - echo -e "${YELLOW} ⚠ Some core module imports failed (non-blocking)${NC}" - echo -e "${YELLOW} This may be due to missing runtime dependencies${NC}" -else - echo -e "${GREEN} āœ“ Core module imports successful${NC}" -fi - -# ============================================================================ -# FINAL SUMMARY -# ============================================================================ - -echo -e "\n============================================================" -if [ $VALIDATION_FAILED -eq 0 ]; then - echo -e "${GREEN}āœ“ All pre-commit validations passed${NC}" - echo -e "${GREEN}āœ“ Version management completed${NC}" - echo -e "${GREEN}āœ“ Code quality checks passed${NC}" - echo -e "${GREEN}Commit allowed to proceed${NC}" - - # Show skip option - echo -e "\n${BLUE}Tip: You can skip version updates with SKIP_VERSION_UPDATE=1${NC}" - exit 0 -else - echo -e "${RED}āœ— Pre-commit validation failed${NC}" - echo -e "${RED}Commit blocked - please fix issues above${NC}" - echo "" - echo "Quick fixes:" - echo " • Python syntax: python3 tools/validate_syntax.py --fix" - echo " • Review flagged files manually" - echo " • Re-run commit after fixes" - echo "" - echo -e "${BLUE}Note: Version management completed successfully${NC}" - exit 1 -fi \ No newline at end of file diff --git a/tools/pre-commit-hook b/tools/pre-commit-hook deleted file mode 100755 index 3b236cb6..00000000 --- a/tools/pre-commit-hook +++ /dev/null @@ -1,168 +0,0 @@ -#!/bin/bash -# Fenrir Pre-commit Hook -# -# This hook validates Python syntax and basic code quality before commits. -# Install with: ln -sf ../../tools/pre-commit-hook .git/hooks/pre-commit - -set -e - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' # No Color - -echo -e "${GREEN}Fenrir Pre-commit Validation${NC}" -echo "==================================" - -# Get the repository root -REPO_ROOT=$(git rev-parse --show-toplevel) -cd "$REPO_ROOT" - -# Track validation results -VALIDATION_FAILED=0 - -# 1. Python Syntax Validation -echo -e "\n${YELLOW}1. Validating Python syntax...${NC}" -if python3 tools/validate_syntax.py --check-only; then - echo -e "${GREEN}āœ“ Syntax validation passed${NC}" -else - echo -e "${RED}āœ— Syntax validation failed${NC}" - echo "Run: python3 tools/validate_syntax.py --fix" - VALIDATION_FAILED=1 -fi - -# 2. Check for common issues in modified files -echo -e "\n${YELLOW}2. Checking modified files for common issues...${NC}" - -# Get list of staged Python files -STAGED_PYTHON_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep '\.py$' || true) - -if [ -n "$STAGED_PYTHON_FILES" ]; then - ISSUES_FOUND=0 - - for file in $STAGED_PYTHON_FILES; do - if [ -f "$file" ]; then - # Check for broken f-strings (multiline issues that cause syntax errors) - # Pattern 1: f-string with opening brace at end of line (likely broken across lines) - if grep -n 'f"[^"]*{[^}]*$' "$file" >/dev/null 2>&1; then - echo -e "${RED}āœ— $file: Potential broken multiline f-string${NC}" - grep -n 'f"[^"]*{[^}]*$' "$file" | head -3 - ISSUES_FOUND=1 - fi - - # Pattern 2: Lines that end with just an opening brace (common in broken f-strings) - if grep -n '^\s*[^#]*{$' "$file" >/dev/null 2>&1; then - echo -e "${YELLOW}⚠ $file: Lines ending with lone opening brace (check f-strings)${NC}" - fi - - # Check for missing imports that are commonly used - if grep -q 'debug\.DebugLevel\.' "$file" && ! grep -q 'from.*debug' "$file" && ! grep -q 'import.*debug' "$file"; then - echo -e "${YELLOW}⚠ $file: Uses debug.DebugLevel but no debug import found${NC}" - fi - - # Check for extremely long lines (over 120 chars) that might indicate issues - if awk 'length($0) > 120 {print NR ": " $0; exit 1}' "$file" >/dev/null 2>&1; then - # Only warn, don't fail - line_num=$(awk 'length($0) > 120 {print NR; exit}' "$file") - echo -e "${YELLOW}⚠ $file:$line_num: Very long line (>120 chars)${NC}" - fi - fi - done - - if [ $ISSUES_FOUND -eq 0 ]; then - echo -e "${GREEN}āœ“ No common issues found in modified files${NC}" - else - echo -e "${RED}āœ— Common issues found in modified files${NC}" - VALIDATION_FAILED=1 - fi -else - echo -e "${GREEN}āœ“ No Python files modified${NC}" -fi - -# 3. Quick import test for core modules -echo -e "\n${YELLOW}3. Testing core module imports...${NC}" -IMPORT_FAILED=0 - -# Test core imports that are critical -CORE_MODULES=( - "src.fenrirscreenreader.core.fenrirManager" - "src.fenrirscreenreader.core.commandManager" - "src.fenrirscreenreader.core.eventManager" -) - -cd src -for module in "${CORE_MODULES[@]}"; do - if python3 -c "import $module" 2>/dev/null; then - echo -e "${GREEN}āœ“ $module${NC}" - else - echo -e "${RED}āœ— $module (import failed)${NC}" - IMPORT_FAILED=1 - fi -done -cd "$REPO_ROOT" - -if [ $IMPORT_FAILED -eq 1 ]; then - echo -e "${RED}āœ— Core module import test failed${NC}" - VALIDATION_FAILED=1 -else - echo -e "${GREEN}āœ“ Core module imports successful${NC}" -fi - -# 4. Run test suite -echo -e "\n${YELLOW}4. Running test suite...${NC}" -if command -v pytest >/dev/null 2>&1; then - # Run tests quietly, show summary at end - if pytest tests/ -q --tb=short 2>&1 | tail -20; then - echo -e "${GREEN}āœ“ All tests passed${NC}" - else - echo -e "${RED}āœ— Test suite failed${NC}" - echo "Run: pytest tests/ -v (to see details)" - VALIDATION_FAILED=1 - fi -else - echo -e "${YELLOW}⚠ pytest not installed - skipping tests${NC}" - echo " Install with: pip install pytest" - echo " Or full test suite: pip install -r tests/requirements.txt" -fi - -# 5. Check for secrets or sensitive data -echo -e "\n${YELLOW}5. Checking for potential secrets...${NC}" -SECRETS_FOUND=0 - -if [ -n "$STAGED_PYTHON_FILES" ]; then - for file in $STAGED_PYTHON_FILES; do - if [ -f "$file" ]; then - # Check for potential passwords, keys, tokens - if grep -i -E '(password|passwd|pwd|key|token|secret|api_key).*=.*["'"'"'][^"'"'"']{8,}["'"'"']' "$file" >/dev/null 2>&1; then - echo -e "${RED}āœ— $file: Potential hardcoded secret detected${NC}" - SECRETS_FOUND=1 - fi - fi - done -fi - -if [ $SECRETS_FOUND -eq 0 ]; then - echo -e "${GREEN}āœ“ No potential secrets found${NC}" -else - echo -e "${RED}āœ— Potential secrets found - please review${NC}" - VALIDATION_FAILED=1 -fi - -# Summary -echo -e "\n==================================================" -if [ $VALIDATION_FAILED -eq 0 ]; then - echo -e "${GREEN}āœ“ All pre-commit validations passed${NC}" - echo -e "${GREEN}Commit allowed to proceed${NC}" - exit 0 -else - echo -e "${RED}āœ— Pre-commit validation failed${NC}" - echo -e "${RED}Commit blocked - please fix issues above${NC}" - echo "" - echo "Quick fixes:" - echo " • Python syntax: python3 tools/validate_syntax.py --fix" - echo " • Run tests: pytest tests/ -v" - echo " • Review flagged files manually" - echo " • Re-run commit after fixes" - exit 1 -fi \ No newline at end of file diff --git a/tools/setup_validation.sh b/tools/setup_validation.sh deleted file mode 100755 index 663209cf..00000000 --- a/tools/setup_validation.sh +++ /dev/null @@ -1,98 +0,0 @@ -#!/bin/bash -# Fenrir Validation Setup Script -# -# Sets up the validation tools and pre-commit hooks for Fenrir development. -# Run this once after cloning the repository. - -set -e - -# Colors for output -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -RED='\033[0;31m' -NC='\033[0m' # No Color - -echo -e "${GREEN}Fenrir Development Environment Setup${NC}" -echo "======================================" - -# Check we're in the right directory -if [ ! -f "CLAUDE.md" ] || [ ! -d "src/fenrirscreenreader" ]; then - echo -e "${RED}Error: Must be run from Fenrir project root directory${NC}" - exit 1 -fi - -# Make validation scripts executable -echo -e "\n${YELLOW}1. Making validation scripts executable...${NC}" -chmod +x tools/validate_syntax.py -chmod +x tools/validate_pep8.py -chmod +x tools/validate_release.py -chmod +x tools/cleanup_cache.py -chmod +x tools/pre-commit-hook -chmod +x tools/install_validation_hook.sh -chmod +x tools/pre-commit-composite -echo -e "${GREEN}āœ“ Scripts are now executable${NC}" - -# Install pre-commit hook -echo -e "\n${YELLOW}2. Installing composite pre-commit hook...${NC}" -echo -e "${YELLOW}This preserves existing version management functionality.${NC}" - -# Use the safe installation script -if ./tools/install_validation_hook.sh; then - echo -e "${GREEN}āœ“ Composite pre-commit hook installed${NC}" -else - echo -e "${RED}⚠ Hook installation encountered issues${NC}" - echo " You can install manually with: ./tools/install_validation_hook.sh" -fi - -# Test validation tools -echo -e "\n${YELLOW}3. Testing validation tools...${NC}" - -# Test syntax validator -if python3 tools/validate_syntax.py --check-only >/dev/null 2>&1; then - echo -e "${GREEN}āœ“ Syntax validator working${NC}" -else - echo -e "${RED}⚠ Syntax validator found issues${NC}" - echo " Run: python3 tools/validate_syntax.py --fix" -fi - -# Test pre-commit hook -if ./tools/pre-commit-hook >/dev/null 2>&1; then - echo -e "${GREEN}āœ“ Pre-commit hook working${NC}" -else - echo -e "${RED}⚠ Pre-commit hook found issues${NC}" - echo " This is normal if there are uncommitted changes" -fi - -# Verify dependencies for full validation -echo -e "\n${YELLOW}4. Checking validation dependencies...${NC}" -missing_deps=() - -if ! command -v python3 >/dev/null 2>&1; then - missing_deps+=("python3") -fi - -if ! python3 -c "import ast" >/dev/null 2>&1; then - missing_deps+=("python3-ast") -fi - -if [ ${#missing_deps[@]} -eq 0 ]; then - echo -e "${GREEN}āœ“ All validation dependencies available${NC}" -else - echo -e "${RED}Missing dependencies: ${missing_deps[*]}${NC}" -fi - -# Final instructions -echo -e "\n${GREEN}Setup complete!${NC}" -echo "" -echo "Development workflow:" -echo " 1. Make your changes" -echo " 2. python3 tools/validate_syntax.py --fix" -echo " 3. python3 tools/validate_release.py --quick" -echo " 4. git add . && git commit (pre-commit hook runs automatically)" -echo "" -echo "Before releases:" -echo " python3 tools/validate_release.py" -echo " cat RELEASE_CHECKLIST.md" -echo "" -echo -e "${YELLOW}Tip: The pre-commit hook will now run automatically on every commit${NC}" -echo -e "${YELLOW} and prevent syntax errors from being committed.${NC}" \ No newline at end of file diff --git a/tools/startx.sh b/tools/startx.sh deleted file mode 100755 index 32271fac..00000000 --- a/tools/startx.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/bin/bash - -# shellcheck disable=SC2329 -cleanup() { - # Make sure Fenrir is restored on exit of this script - echo -n "setting set screen#suspendingScreen=" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-daemon.sock -} - -# Call the cleanup function on exit of this script -trap cleanup EXIT - -# Get the number of the current terminal -term=$(tty) -term="${term##*tty}" - -# Make sure term is a number, otherwise we're in something like screen, and x will not start. -if ! [[ "$term" =~ ^[1-9]+$ ]]; then - echo "This does not appear to be a terminal from where X may be started. Please make sure you are not in a screen or tmux session." - trap - EXIT - exit 1 -fi - -# Suspend the current terminal for Fenrir -echo -n "setting set screen#suspendingScreen=$term" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-daemon.sock - -# Start the x session -command startx - -exit 0 diff --git a/tools/traceEvdev.py b/tools/traceEvdev.py deleted file mode 100755 index b024c6ee..00000000 --- a/tools/traceEvdev.py +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env python3 -import evdev -from evdev import InputDevice -from select import select -import time - -devices = map(evdev.InputDevice, (evdev.list_devices())) -devices = {dev.fd: dev for dev in devices} - -for fd in devices: - for i in devices[fd].capabilities(True): - print(devices[fd].fn,devices[fd].name,i) -while True: - r, w, x = select(devices, [], []) - if r != []: - for fd in r: - for event in devices[fd].read(): - print('Devicename:'+ devices[fd].name + ' Devicepath:' + devices[fd].fn + ' EventType: ' + str(event.type) + ' Events:' + str(devices[fd].active_keys(verbose=True)) + ' Value:' + str(event.value)) - - diff --git a/tools/translations/dynamic.json b/tools/translations/dynamic.json new file mode 100644 index 00000000..9380b9d5 --- /dev/null +++ b/tools/translations/dynamic.json @@ -0,0 +1,73 @@ +{ + "internal/interaction/presentation.go": [ + "checked", + "unchecked" + ], + "internal/app/tutorial.go": [ + "Active actions", + "Unbound actions", + "Plugins", + "{} times " + ], + "internal/app/spellcheck_menu.go": [ + "{0} added to dictionary", + "{0} removed", + "correct", + "misspelled", + "No word" + ], + "internal/app/scripts.go": [ + "Script file not found", + "Script source is not a valid file", + "Script file is not executable", + "Script failed" + ], + "internal/presentation/attributes.go": [ + "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", + "bold", + "italic", + "underline", + "strikethrough", + "reverse", + "blink" + ], + "internal/command/descriptions.go": [ + "no description available" + ], + "internal/punctuation/profile.go": [ + "none", + "some", + "most", + "all" + ], + "internal/emoji/catalog.go": [ + "People", + "Nature", + "Food", + "Holidays", + "Symbols", + "Flags", + "All" + ], + "internal/punctuation/text_processing.go": [ + "number" + ], + "internal/presentation/text.go": [ + "tab" + ] +} diff --git a/tools/translations/main.go b/tools/translations/main.go new file mode 100644 index 00000000..cf976768 --- /dev/null +++ b/tools/translations/main.go @@ -0,0 +1,242 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +// Run from the repository root: go run ./tools/translations [check|update]. +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "os/exec" + "path/filepath" + "reflect" + "regexp" + "slices" + "strconv" + "strings" + + "github.com/leonelquinteros/gotext" + "github.com/pelletier/go-toml/v2" +) + +const catalogDir = "internal/i18n/locale" + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run() error { + messages, err := extract() + if err != nil { + return err + } + var pot bytes.Buffer + pot.WriteString("# Fenrir reader messages. Generated by go run ./tools/translations.\nmsgid \"\"\nmsgstr \"\"\n\"Project-Id-Version: fenrir\\n\"\n\"Content-Type: text/plain; charset=UTF-8\\n\"\n\"Content-Transfer-Encoding: 8bit\\n\"\n") + ids := make([]string, 0, len(messages)) + for id := range messages { + ids = append(ids, id) + } + slices.Sort(ids) + for _, id := range ids { + refs := messages[id] + slices.Sort(refs) + fmt.Fprintf(&pot, "\n#: %s\nmsgid %s\nmsgstr \"\"\n", strings.Join(slices.Compact(refs), " "), strconv.Quote(id)) + } + mode := "extract" + if len(os.Args) > 1 { + mode = os.Args[1] + } + potPath := filepath.Join(catalogDir, "fenrir.pot") + if mode == "check" { + existing, err := os.ReadFile(potPath) + if err != nil { + return err + } + if !bytes.Equal(existing, pot.Bytes()) { + return fmt.Errorf("%s is stale; run go run ./tools/translations update", potPath) + } + } else if mode == "extract" || mode == "update" { + if err := os.WriteFile(potPath, pot.Bytes(), 0644); err != nil { + return err + } + } else { + return fmt.Errorf("usage: go run ./tools/translations [check|update]") + } + files, err := filepath.Glob(catalogDir + "/*/LC_MESSAGES/fenrir.po") + if err != nil { + return err + } + if len(files) == 0 { + return fmt.Errorf("no language catalogs") + } + fields := regexp.MustCompile(`\{(?:[0-9]+|[a-z_]+)?\}`) + for _, file := range files { + if mode == "update" { + output, err := exec.Command("msgmerge", "--update", "--backup=none", "--no-fuzzy-matching", file, potPath).CombinedOutput() + if err != nil { + return fmt.Errorf("%s: %s: %w", file, output, err) + } + } + if mode != "check" { + continue + } + data, err := os.ReadFile(file) + if err != nil { + return err + } + if regexp.MustCompile(`(?m)^#,.*\bfuzzy\b`).Match(data) { + return fmt.Errorf("%s: fuzzy translations require review", file) + } + output, err := exec.Command("msgfmt", "--check", "--check-format", "-o", os.DevNull, file).CombinedOutput() + if err != nil { + return fmt.Errorf("%s: %s: %w", file, output, err) + } + po := gotext.NewPo() + po.ParseFile(file) + for _, id := range ids { + if !po.IsTranslated(id) { + return fmt.Errorf("%s: untranslated %q", file, id) + } + expected, actual := fields.FindAllString(id, -1), fields.FindAllString(po.Get(id), -1) + slices.Sort(expected) + slices.Sort(actual) + if !slices.Equal(expected, actual) { + return fmt.Errorf("%s: changed fields in %q", file, id) + } + translated := po.Get(id) + if strings.HasPrefix(id, " ") != strings.HasPrefix(translated, " ") || strings.HasSuffix(id, " ") != strings.HasSuffix(translated, " ") { + return fmt.Errorf("%s: changed boundary whitespace in %q", file, id) + } + } + fmt.Printf("%s: %d/%d translated\n", file, len(ids), len(ids)) + } + fmt.Printf("%d reader message IDs, %d languages\n", len(ids), len(files)) + return nil +} + +func extract() (map[string][]string, error) { + messages := map[string][]string{} + add := func(id, ref string) { + if id != "" { + messages[id] = append(messages[id], ref) + } + } + allStrings := map[string]map[string]bool{} + err := filepath.WalkDir("internal", func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + fs := token.NewFileSet() + file, err := parser.ParseFile(fs, path, nil, 0) + if err != nil { + return err + } + literals := map[string]bool{} + allStrings[path] = literals + value := func(expr ast.Expr) string { + if lit, ok := expr.(*ast.BasicLit); ok && lit.Kind == token.STRING { + text, _ := strconv.Unquote(lit.Value) + return text + } + return "" + } + ref := func(n ast.Node) string { return fmt.Sprintf("%s:%d", path, fs.Position(n.Pos()).Line) } + ast.Inspect(file, func(n ast.Node) bool { + switch node := n.(type) { + case *ast.BasicLit: + if node.Kind == token.STRING { + literals[value(node)] = true + } + case *ast.CallExpr: + if fn, ok := node.Fun.(*ast.SelectorExpr); ok && len(node.Args) > 0 { + owner, _ := fn.X.(*ast.Ident) + if owner != nil && owner.Name == "i18n" && (fn.Sel.Name == "T" || fn.Sel.Name == "Format" || fn.Sel.Name == "N") || fn.Sel.Name == "say" || fn.Sel.Name == "failed" { + add(value(node.Args[0]), ref(node)) + if fn.Sel.Name == "N" { + // Plural messages need plural POT entries and checks; fail + // explicitly until the first reader call site is introduced. + err = fmt.Errorf("plural extraction required at %s", ref(node)) + } + } + } + case *ast.KeyValueExpr: + if key, ok := node.Key.(*ast.Ident); ok && (key.Name == "Label" || key.Name == "Title") { + add(value(node.Value), ref(node)) + } + if path == "internal/command/descriptions.go" || path == "internal/punctuation/emoticons.go" { + add(value(node.Value), ref(node)) + } + case *ast.Field: + if node.Tag != nil { + tag := reflect.StructTag(value(node.Tag)) + for _, name := range []string{"label", "quick"} { + add(tag.Get(name), ref(node)) + } + } + case *ast.ValueSpec: + for index, name := range node.Names { + if index < len(node.Values) && (name.Name == "MenuTitle" || strings.HasPrefix(name.Name, "tutorial") && name.Name != "tutorialMode") { + add(value(node.Values[index]), ref(node)) + } + } + } + return true + }) + return err + }) + if err != nil { + return nil, err + } + // Dynamic labels without a directly extractable lookup. Each must still + // occur in its source, so removals/renames cannot silently leave stale IDs. + data, err := os.ReadFile("tools/translations/dynamic.json") + if err != nil { + return nil, err + } + var dynamic map[string][]string + if err := json.Unmarshal(data, &dynamic); err != nil { + return nil, err + } + for path, ids := range dynamic { + for _, id := range ids { + if !allStrings[path][id] { + return nil, fmt.Errorf("stale dynamic message %q in %s", id, path) + } + add(id, path) + } + } + profiles, err := filepath.Glob("config/punctuation/*.toml") + if err != nil { + return nil, err + } + for _, path := range profiles { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var profile struct { + Translate bool + Symbols map[string]string + } + if err := toml.Unmarshal(data, &profile); err != nil { + return nil, err + } + if profile.Translate { + for _, name := range profile.Symbols { + add(name, path) + } + } + } + return messages, nil +} diff --git a/tools/validate_pep8.py b/tools/validate_pep8.py deleted file mode 100755 index 7b7a9b69..00000000 --- a/tools/validate_pep8.py +++ /dev/null @@ -1,365 +0,0 @@ -#!/usr/bin/env python3 -""" -Fenrir PEP8 Validation and Auto-Fix Tool - -Validates Python code style using flake8 and applies safe automatic fixes. -Designed to work with Fenrir's existing codebase while respecting timing-critical code. - -Usage: - python3 tools/validate_pep8.py # Check all Python files - python3 tools/validate_pep8.py --fix-safe # Auto-fix safe issues - python3 tools/validate_pep8.py --check-only # Exit with error if issues found - python3 tools/validate_pep8.py --staged # Check only staged files -""" - -import os -import sys -import argparse -import subprocess -import tempfile -from pathlib import Path - - -class PEP8Validator: - def __init__(self, verbose=True): - self.verbose = verbose - self.errors = [] - self.warnings = [] - self.fixes_applied = [] - - def log(self, message, level="INFO"): - """Log a message with appropriate formatting.""" - if not self.verbose and level == "INFO": - return - - colors = { - "INFO": "\033[0;36m", # Cyan - "SUCCESS": "\033[0;32m", # Green - "WARNING": "\033[1;33m", # Yellow - "ERROR": "\033[0;31m", # Red - "HEADER": "\033[1;34m", # Bold Blue - } - - reset = "\033[0m" - color = colors.get(level, "") - - if level == "HEADER": - print(f"\n{color}{'='*60}") - print(f"{message}") - print(f"{'='*60}{reset}") - else: - symbol = { - "SUCCESS": "āœ“", - "ERROR": "āœ—", - "WARNING": "⚠", - "INFO": "•" - }.get(level, "•") - - print(f"{color}{symbol} {message}{reset}") - - def check_flake8_available(self): - """Check if flake8 is available.""" - try: - result = subprocess.run(["flake8", "--version"], - capture_output=True, text=True, timeout=5) - if result.returncode == 0: - version = result.stdout.strip().split('\n')[0] - self.log(f"Using flake8: {version}") - return True - else: - return False - except (subprocess.TimeoutExpired, FileNotFoundError): - return False - - def get_python_files(self, directory=None, staged_only=False): - """Get list of Python files to check.""" - if staged_only: - try: - result = subprocess.run([ - "git", "diff", "--cached", "--name-only", "--diff-filter=ACM" - ], capture_output=True, text=True, timeout=10) - - if result.returncode == 0: - files = [f for f in result.stdout.strip().split('\n') - if f.endswith('.py') and Path(f).exists()] - return [Path(f) for f in files if f] - else: - self.warnings.append("Could not get staged files, checking all files") - staged_only = False - except subprocess.TimeoutExpired: - self.warnings.append("Git command timed out, checking all files") - staged_only = False - - if not staged_only: - directory = Path(directory or "src/fenrirscreenreader") - if not directory.exists(): - self.errors.append(f"Directory {directory} does not exist") - return [] - - python_files = list(directory.rglob("*.py")) - # Filter out cache and build directories - python_files = [f for f in python_files if not any( - part.startswith(('__pycache__', '.git', 'build', 'dist')) - for part in f.parts)] - return python_files - - return [] - - def run_flake8(self, files, select=None, ignore=None): - """Run flake8 on the given files.""" - if not files: - return True, "" - - cmd = ["flake8"] - - if select: - cmd.extend(["--select", select]) - if ignore: - cmd.extend(["--ignore", ignore]) - - # Add files - cmd.extend([str(f) for f in files]) - - try: - result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) - return result.returncode == 0, result.stdout - except subprocess.TimeoutExpired: - self.errors.append("flake8 command timed out") - return False, "" - except Exception as e: - self.errors.append(f"Failed to run flake8: {e}") - return False, "" - - def categorize_issues(self, flake8_output): - """Categorize flake8 issues by severity and safety for auto-fixing.""" - lines = flake8_output.strip().split('\n') - issues = {'critical': [], 'safe_fixable': [], 'manual': []} - - for line in lines: - if not line.strip(): - continue - - # Parse flake8 output: filename:line:col: code message - parts = line.split(':', 3) - if len(parts) < 4: - continue - - filename = parts[0] - line_num = parts[1] - col = parts[2] - code_msg = parts[3].strip() - - code = code_msg.split()[0] if code_msg else "" - - # Categorize by error code - if code.startswith('E9') or code.startswith('F'): - # Critical syntax/import errors - issues['critical'].append(line) - elif code in ['E111', 'E114', 'E117', 'E121', 'E122', 'E123', 'E124', - 'E125', 'E126', 'E127', 'E128', 'E129', 'E131', 'E133', - 'W291', 'W292', 'W293']: - # Safe indentation and whitespace fixes - # But skip timing-critical files - if not any(critical in filename.lower() for critical in - ['evdevdriver', 'vcsadriver', 'screenmanager', 'inputmanager']): - issues['safe_fixable'].append(line) - else: - issues['manual'].append(line) - else: - # Everything else needs manual review - issues['manual'].append(line) - - return issues - - def apply_safe_fixes(self, files): - """Apply safe automatic fixes using autopep8.""" - try: - # Check if autopep8 is available - result = subprocess.run(["autopep8", "--version"], - capture_output=True, text=True, timeout=5) - if result.returncode != 0: - self.warnings.append("autopep8 not available for auto-fixing") - return False - except (subprocess.TimeoutExpired, FileNotFoundError): - self.warnings.append("autopep8 not available for auto-fixing") - return False - - fixed_count = 0 - - for file_path in files: - # Skip timing-critical files - if any(critical in str(file_path).lower() for critical in - ['evdevdriver', 'vcsadriver', 'screenmanager', 'inputmanager']): - self.log(f"Skipping timing-critical file: {file_path}", "WARNING") - continue - - try: - # Apply safe fixes only - cmd = [ - "autopep8", - "--in-place", - "--select", "E111,E114,E117,E121,E122,E123,E124,E125,E126,E127,E128,E129,E131,E133,W291,W292,W293", - str(file_path) - ] - - result = subprocess.run(cmd, capture_output=True, text=True, timeout=10) - if result.returncode == 0: - self.fixes_applied.append(f"Applied safe PEP8 fixes to {file_path}") - fixed_count += 1 - else: - self.warnings.append(f"Could not auto-fix {file_path}: {result.stderr}") - - except subprocess.TimeoutExpired: - self.warnings.append(f"Auto-fix timed out for {file_path}") - except Exception as e: - self.warnings.append(f"Error auto-fixing {file_path}: {e}") - - return fixed_count > 0 - - def validate_files(self, files, fix_safe=False): - """Validate Python files for PEP8 compliance.""" - if not files: - self.log("No Python files to validate") - return True - - self.log(f"Validating {len(files)} Python files") - - # Run comprehensive flake8 check - success, output = self.run_flake8(files) - - if not output.strip(): - self.log("All files pass PEP8 validation", "SUCCESS") - return True - - # Categorize issues - issues = self.categorize_issues(output) - - # Report critical issues - if issues['critical']: - self.log(f"Critical issues found ({len(issues['critical'])}):", "ERROR") - for issue in issues['critical'][:10]: # Limit output - self.log(f" {issue}", "ERROR") - if len(issues['critical']) > 10: - self.log(f" ... and {len(issues['critical']) - 10} more", "ERROR") - - # Handle safe fixable issues - if issues['safe_fixable']: - if fix_safe: - self.log(f"Auto-fixing {len(issues['safe_fixable'])} safe issues...", "INFO") - # Get unique files from safe_fixable issues - fix_files = set() - for issue in issues['safe_fixable']: - filename = issue.split(':')[0] - fix_files.add(Path(filename)) - - if self.apply_safe_fixes(fix_files): - self.log("Safe auto-fixes applied", "SUCCESS") - # Re-run flake8 to see remaining issues - success, output = self.run_flake8(files) - if output.strip(): - remaining_issues = self.categorize_issues(output) - issues = remaining_issues - else: - issues = {'critical': [], 'safe_fixable': [], 'manual': []} - else: - self.log(f"Safe fixable issues found ({len(issues['safe_fixable'])}):", "WARNING") - for issue in issues['safe_fixable'][:5]: - self.log(f" {issue}", "WARNING") - if len(issues['safe_fixable']) > 5: - self.log(f" ... and {len(issues['safe_fixable']) - 5} more", "WARNING") - self.log("Run with --fix-safe to auto-fix these", "INFO") - - # Report manual issues - if issues['manual']: - self.log(f"Manual review needed ({len(issues['manual'])}):", "WARNING") - for issue in issues['manual'][:5]: - self.log(f" {issue}", "WARNING") - if len(issues['manual']) > 5: - self.log(f" ... and {len(issues['manual']) - 5} more", "WARNING") - - # Return success if only manual issues remain (non-critical) - return len(issues['critical']) == 0 - - def generate_report(self): - """Generate final validation report.""" - total_issues = len(self.errors) + len(self.warnings) - - if self.fixes_applied: - self.log(f"\nAUTO-FIXES APPLIED ({len(self.fixes_applied)}):", "HEADER") - for fix in self.fixes_applied: - self.log(fix, "SUCCESS") - - if self.errors: - self.log(f"\nERRORS ({len(self.errors)}):", "HEADER") - for error in self.errors: - self.log(error, "ERROR") - - if self.warnings: - self.log(f"\nWARNINGS ({len(self.warnings)}):", "HEADER") - for warning in self.warnings: - self.log(warning, "WARNING") - - if len(self.errors) == 0: - self.log("\nāœ… PEP8 VALIDATION PASSED", "SUCCESS") - if self.warnings: - self.log("Non-critical style issues found - consider manual review", "INFO") - return True - else: - self.log("\nāŒ PEP8 VALIDATION FAILED", "ERROR") - self.log("Critical issues must be fixed", "ERROR") - return False - - -def main(): - parser = argparse.ArgumentParser(description='Validate and fix PEP8 compliance in Fenrir') - parser.add_argument('--fix-safe', action='store_true', - help='Apply safe automatic fixes (avoids timing-critical files)') - parser.add_argument('--check-only', action='store_true', - help='Exit with non-zero code if issues found') - parser.add_argument('--staged', action='store_true', - help='Check only staged files') - parser.add_argument('--quiet', action='store_true', - help='Reduce output verbosity') - parser.add_argument('--directory', default='src/fenrirscreenreader', - help='Directory to scan (default: src/fenrirscreenreader)') - - args = parser.parse_args() - - validator = PEP8Validator(verbose=not args.quiet) - - validator.log("FENRIR PEP8 VALIDATION", "HEADER") - - # Check if flake8 is available - if not validator.check_flake8_available(): - validator.log("flake8 is required but not available", "ERROR") - validator.log("Install with: pip install flake8", "INFO") - if args.fix_safe: - validator.log("For auto-fixing, also install: pip install autopep8", "INFO") - sys.exit(1) - - # Get files to validate - files = validator.get_python_files( - directory=args.directory if not args.staged else None, - staged_only=args.staged - ) - - if not files: - validator.log("No Python files found to validate") - sys.exit(0) - - # Validate files - success = validator.validate_files(files, fix_safe=args.fix_safe) - - # Generate report - validation_passed = validator.generate_report() - - if args.check_only and not validation_passed: - sys.exit(1) - elif validation_passed: - sys.exit(0) - else: - sys.exit(1) - - -if __name__ == '__main__': - main() diff --git a/tools/validate_release.py b/tools/validate_release.py deleted file mode 100755 index 3c7c4912..00000000 --- a/tools/validate_release.py +++ /dev/null @@ -1,459 +0,0 @@ -#!/usr/bin/env python3 -""" -Fenrir Release Validation Tool - -Comprehensive validation suite for Fenrir releases, including syntax validation, -dependency checking, import testing, and basic functionality validation. - -Usage: - python3 tools/validate_release.py # Full validation - python3 tools/validate_release.py --quick # Skip slow tests - python3 tools/validate_release.py --fix # Auto-fix issues where possible -""" - -import ast -import os -import sys -import argparse -import subprocess -import tempfile -import importlib.util -from pathlib import Path -import time - - -class ReleaseValidator: - def __init__(self, verbose=True): - self.verbose = verbose - self.errors = [] - self.warnings = [] - self.fixes_applied = [] - self.tests_run = 0 - self.tests_passed = 0 - - def log(self, message, level="INFO"): - """Log a message with appropriate formatting.""" - if not self.verbose and level == "INFO": - return - - colors = { - "INFO": "\033[0;36m", # Cyan - "SUCCESS": "\033[0;32m", # Green - "WARNING": "\033[1;33m", # Yellow - "ERROR": "\033[0;31m", # Red - "HEADER": "\033[1;34m", # Bold Blue - } - - reset = "\033[0m" - color = colors.get(level, "") - - if level == "HEADER": - print(f"\n{color}{'='*60}") - print(f"{message}") - print(f"{'='*60}{reset}") - else: - symbol = { - "SUCCESS": "āœ“", - "ERROR": "āœ—", - "WARNING": "⚠", - "INFO": "•" - }.get(level, "•") - - print(f"{color}{symbol} {message}{reset}") - - def run_test(self, name, test_func, *args, **kwargs): - """Run a test and track results.""" - self.tests_run += 1 - try: - result = test_func(*args, **kwargs) - if result: - self.tests_passed += 1 - self.log(f"{name}: PASSED", "SUCCESS") - else: - self.log(f"{name}: FAILED", "ERROR") - return result - except Exception as e: - self.log(f"{name}: ERROR - {e}", "ERROR") - self.errors.append(f"{name}: {e}") - return False - - def validate_python_syntax(self, directory, fix_mode=False): - """Validate Python syntax across all files.""" - python_files = list(Path(directory).rglob("*.py")) - # Filter out cache and build directories - python_files = [f for f in python_files if not any(part.startswith(('__pycache__', '.git', 'build', 'dist')) for part in f.parts)] - - syntax_errors = [] - fixed_files = [] - - for filepath in python_files: - try: - with open(filepath, 'r', encoding='utf-8') as f: - content = f.read() - ast.parse(content, filename=str(filepath)) - except SyntaxError as e: - syntax_errors.append((filepath, e)) - - if fix_mode: - # Try to fix common f-string issues - fixed_content = self.fix_fstring_issues(content) - if fixed_content != content: - try: - ast.parse(fixed_content, filename=str(filepath)) - # Fix worked, write it back - with open(filepath, 'w', encoding='utf-8') as f: - f.write(fixed_content) - fixed_files.append(filepath) - syntax_errors.pop() # Remove from errors - except SyntaxError: - pass # Fix didn't work - except Exception as e: - syntax_errors.append((filepath, e)) - - if fixed_files: - self.fixes_applied.extend([f"Fixed f-string syntax in {f}" for f in fixed_files]) - - if syntax_errors: - for filepath, error in syntax_errors[:5]: # Show only first 5 - if isinstance(error, SyntaxError): - self.errors.append(f"Syntax error in {filepath}:{error.lineno}: {error.msg}") - else: - self.errors.append(f"Error in {filepath}: {error}") - if len(syntax_errors) > 5: - self.errors.append(f"... and {len(syntax_errors) - 5} more syntax errors") - - return len(syntax_errors) == 0 - - def fix_fstring_issues(self, content): - """Fix common f-string syntax issues.""" - lines = content.split('\n') - - for i, line in enumerate(lines): - # Look for f-strings that span multiple lines incorrectly - if ('f"' in line and line.count('"') % 2 == 1 and - i + 1 < len(lines) and lines[i + 1].strip()): - - next_line = lines[i + 1] - # Common patterns to fix - if (next_line.strip().endswith('}"') or - 'str(e)}' in next_line or - next_line.strip().startswith(('fenrirVersion.', 'self.'))): - - # Merge the lines properly - fixed_line = line.rstrip() + next_line.strip() - lines[i] = fixed_line - lines[i + 1] = '' - - return '\n'.join(line for line in lines if line.strip() or not line) - - def validate_dependencies(self): - """Run the existing dependency checker.""" - try: - result = subprocess.run([ - sys.executable, "check-dependencies.py" - ], capture_output=True, text=True, timeout=30) - - if result.returncode == 0: - return True - else: - self.errors.append(f"Dependency check failed: {result.stderr}") - return False - except subprocess.TimeoutExpired: - self.errors.append("Dependency check timed out") - return False - except Exception as e: - self.errors.append(f"Could not run dependency check: {e}") - return False - - def validate_core_imports(self): - """Test importing core Fenrir modules.""" - # Change to src directory for imports - original_path = sys.path.copy() - src_dir = Path.cwd() / "src" - if src_dir.exists(): - sys.path.insert(0, str(src_dir)) - - core_modules = [ - "fenrirscreenreader.core.fenrirManager", - "fenrirscreenreader.core.commandManager", - "fenrirscreenreader.core.eventManager", - "fenrirscreenreader.core.screenManager", - "fenrirscreenreader.core.inputManager", - "fenrirscreenreader.core.outputManager", - ] - - import_failures = [] - - for module_name in core_modules: - try: - importlib.import_module(module_name) - except ImportError as e: - import_failures.append(f"{module_name}: {e}") - except Exception as e: - import_failures.append(f"{module_name}: Unexpected error: {e}") - - # Restore path - sys.path = original_path - - if import_failures: - self.errors.extend(import_failures) - return False - return True - - def validate_command_structure(self): - """Validate command file structure and naming.""" - commands_dir = Path("src/fenrirscreenreader/commands") - if not commands_dir.exists(): - self.errors.append("Commands directory not found") - return False - - issues = [] - - # Check command directories - expected_dirs = ["commands", "onHeartBeat", "onKeyInput", "onCursorChange", - "onScreenUpdate", "onScreenChanged", "vmenu-profiles"] - - for expected_dir in expected_dirs: - if not (commands_dir / expected_dir).exists(): - issues.append(f"Missing expected directory: {expected_dir}") - - # Check for critical issues only (skip template files and base classes) - for py_file in commands_dir.rglob("*.py"): - if (py_file.name.startswith("__") or - "template" in py_file.name.lower() or - "base" in py_file.name.lower()): - continue - - try: - with open(py_file, 'r', encoding='utf-8') as f: - content = f.read() - - # Critical structure checks only - if "class command" not in content: - issues.append(f"{py_file}: Missing 'class command' definition") - - # Skip method checks for files that inherit from base classes - if ("super().__init__" in content or - "importlib.util" in content or - "_base.py" in content): - continue # These inherit methods from base classes - - # Only check direct implementations - # Special case: Application profile commands use load/unload instead of run - if "onSwitchApplicationProfile" in str(py_file): - if "def load" not in content and "def unload" not in content: - issues.append(f"{py_file}: Missing 'load' or 'unload' method") - else: - critical_methods = ["run"] # Focus on the most critical method - for method in critical_methods: - if (f"def {method}" not in content and - "super()" not in content): # Skip if uses inheritance - issues.append(f"{py_file}: Missing '{method}' method") - - except Exception as e: - issues.append(f"{py_file}: Could not validate structure: {e}") - - # Only report critical issues, not template/base class warnings - critical_issues = [issue for issue in issues if not any(skip in issue.lower() - for skip in ["template", "base", "missing 'initialize'", "missing 'shutdown'"])] - - if critical_issues: - self.warnings.extend(critical_issues[:5]) # Limit warnings - if len(critical_issues) > 5: - self.warnings.append(f"... and {len(critical_issues) - 5} more critical command structure issues") - - # Return success if no critical issues (warnings are acceptable) - return len(critical_issues) == 0 - - def validate_configuration_files(self): - """Validate configuration file structure.""" - config_dir = Path("config") - if not config_dir.exists(): - self.errors.append("Config directory not found") - return False - - required_configs = [ - "settings/settings.conf", - "keyboard/desktop.conf", - "punctuation/default.conf" - ] - - missing_configs = [] - for config_file in required_configs: - if not (config_dir / config_file).exists(): - missing_configs.append(config_file) - - if missing_configs: - self.errors.extend([f"Missing config file: {f}" for f in missing_configs]) - return False - - return True - - def validate_installation_scripts(self): - """Validate installation and setup scripts.""" - required_scripts = ["setup.py", "install.sh", "uninstall.sh"] - missing_scripts = [] - - for script in required_scripts: - if not Path(script).exists(): - missing_scripts.append(script) - - if missing_scripts: - self.warnings.extend([f"Missing installation script: {s}" for s in missing_scripts]) - - # Check setup.py syntax if it exists - if Path("setup.py").exists(): - try: - with open("setup.py", 'r') as f: - content = f.read() - ast.parse(content, filename="setup.py") - except SyntaxError as e: - self.errors.append(f"setup.py syntax error: {e}") - return False - - return len(missing_scripts) == 0 - - def validate_repository_cleanliness(self): - """Check for cache files and other artifacts that shouldn't be in git.""" - # Check for Python cache files in git tracking - try: - result = subprocess.run([ - "git", "ls-files", "--cached" - ], capture_output=True, text=True, timeout=10) - - if result.returncode == 0: - tracked_files = result.stdout.strip().split('\n') - cache_files = [f for f in tracked_files if '__pycache__' in f or f.endswith('.pyc')] - - if cache_files: - self.errors.extend([f"Python cache file tracked in git: {f}" for f in cache_files[:5]]) - if len(cache_files) > 5: - self.errors.append(f"... and {len(cache_files) - 5} more cache files in git") - return False - else: - return True - else: - self.warnings.append("Could not check git tracked files") - return True - - except subprocess.TimeoutExpired: - self.warnings.append("Git check timed out") - return True - except Exception as e: - self.warnings.append(f"Could not check repository cleanliness: {e}") - return True - - def generate_report(self): - """Generate final validation report.""" - self.log("FENRIR RELEASE VALIDATION REPORT", "HEADER") - - # Test Summary - success_rate = (self.tests_passed / self.tests_run * 100) if self.tests_run > 0 else 0 - self.log(f"Tests run: {self.tests_run}") - self.log(f"Tests passed: {self.tests_passed}") - self.log(f"Success rate: {success_rate:.1f}%") - - # Fixes Applied - if self.fixes_applied: - self.log("\nAUTO-FIXES APPLIED:", "HEADER") - for fix in self.fixes_applied: - self.log(fix, "SUCCESS") - - # Errors - if self.errors: - self.log(f"\nERRORS ({len(self.errors)}):", "HEADER") - for error in self.errors: - self.log(error, "ERROR") - - # Warnings - if self.warnings: - self.log(f"\nWARNINGS ({len(self.warnings)}):", "HEADER") - for warning in self.warnings: - self.log(warning, "WARNING") - - # Final Status - if not self.errors and success_rate >= 80: - self.log("\nšŸŽ‰ RELEASE VALIDATION PASSED", "SUCCESS") - self.log("The codebase appears ready for release", "SUCCESS") - return True - elif not self.errors: - self.log("\nāš ļø RELEASE VALIDATION PASSED WITH WARNINGS", "WARNING") - self.log("Release is possible but issues should be addressed", "WARNING") - return True - else: - self.log("\nāŒ RELEASE VALIDATION FAILED", "ERROR") - self.log("Critical issues must be fixed before release", "ERROR") - return False - - -def main(): - parser = argparse.ArgumentParser(description='Comprehensive Fenrir release validation') - parser.add_argument('--quick', action='store_true', - help='Skip slow tests (dependency checking)') - parser.add_argument('--fix', action='store_true', - help='Attempt to fix issues automatically where possible') - parser.add_argument('--quiet', action='store_true', - help='Reduce output verbosity') - - args = parser.parse_args() - - # Ensure we're in the project root - if not Path("src/fenrirscreenreader").exists(): - print("Error: Must be run from Fenrir project root directory") - sys.exit(1) - - validator = ReleaseValidator(verbose=not args.quiet) - - validator.log("FENRIR RELEASE VALIDATION STARTING", "HEADER") - start_time = time.time() - - # Run validation tests - validator.run_test( - "Python syntax validation", - validator.validate_python_syntax, - "src/fenrirscreenreader", - args.fix - ) - - if not args.quick: - validator.run_test( - "Dependency validation", - validator.validate_dependencies - ) - - validator.run_test( - "Core module imports", - validator.validate_core_imports - ) - - validator.run_test( - "Command structure validation", - validator.validate_command_structure - ) - - validator.run_test( - "Configuration files validation", - validator.validate_configuration_files - ) - - validator.run_test( - "Installation scripts validation", - validator.validate_installation_scripts - ) - - validator.run_test( - "Repository cleanliness validation", - validator.validate_repository_cleanliness - ) - - # Generate final report - elapsed_time = time.time() - start_time - validator.log(f"\nValidation completed in {elapsed_time:.1f} seconds") - - success = validator.generate_report() - - sys.exit(0 if success else 1) - - -if __name__ == '__main__': - main() diff --git a/tools/validate_syntax.py b/tools/validate_syntax.py deleted file mode 100755 index ff71c77b..00000000 --- a/tools/validate_syntax.py +++ /dev/null @@ -1,258 +0,0 @@ -#!/usr/bin/env python3 -""" -Fenrir Syntax Validation Tool - -Validates Python syntax across the entire Fenrir codebase without writing -cache files. Designed to catch syntax errors before packaging or releases. - -Usage: - python3 tools/validate_syntax.py # Validate all Python files - python3 tools/validate_syntax.py --fix # Fix common issues automatically - python3 tools/validate_syntax.py --check-only # Exit with non-zero if errors found -""" - -import ast -import os -import re -import sys -import argparse -import tempfile -from pathlib import Path - - -class SyntaxValidator: - def __init__(self): - self.errors = [] - self.warnings = [] - self.fixed = [] - - def check_fstring_issues(self, content): - """Check for common f-string formatting issues that cause syntax errors.""" - issues = [] - lines = content.split('\n') - - for i, line in enumerate(lines, 1): - # Check for f-strings that appear to be broken across lines - # Pattern: f"some text { - if re.search(r'f"[^"]*{[^}]*$', line.strip()): - issues.append(f"Line {i}: Potential broken multiline f-string") - - return issues - - def validate_file(self, filepath): - """Validate syntax of a single Python file.""" - try: - with open(filepath, 'r', encoding='utf-8') as f: - content = f.read() - - # Check for specific f-string issues before AST parsing - fstring_issues = self.check_fstring_issues(content) - if fstring_issues: - # Create a synthetic syntax error for f-string issues - error_msg = f"F-string issues: {'; '.join(fstring_issues)}" - self.errors.append((filepath, SyntaxError(error_msg), content)) - return False, content - - # Parse with AST (catches syntax errors) - ast.parse(content, filename=str(filepath)) - return True, content - - except SyntaxError as e: - error_msg = f"{filepath}:{e.lineno}: {e.msg}" - self.errors.append((filepath, e, content)) - return False, content - - except UnicodeDecodeError as e: - error_msg = f"{filepath}: Unicode decode error: {e}" - self.errors.append((filepath, e, None)) - return False, None - - except Exception as e: - error_msg = f"{filepath}: Unexpected error: {e}" - self.errors.append((filepath, e, None)) - return False, None - - def fix_common_issues(self, filepath, content): - """Attempt to fix common syntax issues automatically.""" - if not content: - return False, content - - original_content = content - fixed_issues = [] - - # Fix unterminated f-strings (the main issue from the email) - lines = content.split('\n') - modified = False - - for i, line in enumerate(lines): - # Look for f-strings that span multiple lines incorrectly - if 'f"' in line and line.count('"') % 2 == 1: - # Check if this looks like a broken multi-line f-string - indent = len(line) - len(line.lstrip()) - - # Look ahead for continuation - j = i + 1 - while j < len(lines) and lines[j].strip(): - next_line = lines[j] - next_indent = len(next_line) - len(next_line.lstrip()) - - # If next line is indented more and has closing brace/quote - if (next_indent > indent and - ('"' in next_line or '}' in next_line)): - - # Try to fix by joining lines properly - combined_line = line.rstrip() - continuation = next_line.strip() - - if continuation.startswith(('"', '}', 'str(e)', 'self.', 'fenrirVersion.')): - # Fix common patterns - if 'str(e)}' in continuation: - fixed_line = line.replace('f"', 'f"').rstrip() + '{' + continuation.replace('"', '') + '}' - elif continuation.startswith('"'): - fixed_line = line + continuation - else: - fixed_line = line.rstrip() + continuation - - lines[i] = fixed_line - lines[j] = '' # Remove the continuation line - modified = True - fixed_issues.append(f"Line {i+1}: Fixed multi-line f-string") - break - j += 1 - - if modified: - content = '\n'.join(lines) - # Clean up empty lines that were created - content = '\n'.join(line for line in content.split('\n') if line.strip() or not line) - - # Verify the fix worked - try: - ast.parse(content, filename=str(filepath)) - self.fixed.append((filepath, fixed_issues)) - return True, content - except SyntaxError: - # Fix didn't work, return original - return False, original_content - - return False, content - - def scan_directory(self, directory, fix_mode=False): - """Scan directory for Python files and validate them.""" - python_files = [] - - # Find all Python files - for root, dirs, files in os.walk(directory): - # Skip cache and build directories - dirs[:] = [d for d in dirs if not d.startswith(('__pycache__', '.git', 'build', 'dist'))] - - for file in files: - if file.endswith('.py'): - python_files.append(Path(root) / file) - - print(f"Validating {len(python_files)} Python files...") - - valid_count = 0 - fixed_count = 0 - - for filepath in sorted(python_files): - is_valid, content = self.validate_file(filepath) - - if is_valid: - valid_count += 1 - print(f"āœ“ {filepath}") - else: - print(f"āœ— {filepath}") - - if fix_mode and content: - # Try to fix the file - was_fixed, fixed_content = self.fix_common_issues(filepath, content) - if was_fixed: - # Write the fixed content back - with open(filepath, 'w', encoding='utf-8') as f: - f.write(fixed_content) - print(f" → Fixed automatically") - fixed_count += 1 - - # Re-validate - is_valid_now, _ = self.validate_file(filepath) - if is_valid_now: - valid_count += 1 - - return valid_count, len(python_files), fixed_count - - def print_summary(self, valid_count, total_count, fixed_count=0): - """Print validation summary.""" - print(f"\n{'='*60}") - print(f"SYNTAX VALIDATION SUMMARY") - print(f"{'='*60}") - print(f"Valid files: {valid_count}/{total_count}") - print(f"Invalid files: {total_count - valid_count}") - if fixed_count > 0: - print(f"Auto-fixed: {fixed_count}") - - if self.errors: - print(f"\nERRORS ({len(self.errors)}):") - for filepath, error, _ in self.errors: - if isinstance(error, SyntaxError): - print(f" {filepath}:{error.lineno}: {error.msg}") - else: - print(f" {filepath}: {error}") - - if self.fixed: - print(f"\nAUTO-FIXES APPLIED ({len(self.fixed)}):") - for filepath, fixes in self.fixed: - print(f" {filepath}:") - for fix in fixes: - print(f" - {fix}") - - success_rate = (valid_count / total_count) * 100 if total_count > 0 else 0 - print(f"\nSuccess rate: {success_rate:.1f}%") - - return len(self.errors) == 0 - - -def main(): - parser = argparse.ArgumentParser(description='Validate Python syntax in Fenrir codebase') - parser.add_argument('--fix', action='store_true', - help='Attempt to fix common syntax issues automatically') - parser.add_argument('--check-only', action='store_true', - help='Exit with non-zero code if syntax errors found') - parser.add_argument('--directory', default='src/fenrirscreenreader', - help='Directory to scan (default: src/fenrirscreenreader)') - - args = parser.parse_args() - - # Find project root - script_dir = Path(__file__).parent - project_root = script_dir.parent - target_dir = project_root / args.directory - - if not target_dir.exists(): - print(f"Error: Directory {target_dir} does not exist") - sys.exit(1) - - print(f"Fenrir Syntax Validator") - print(f"Target directory: {target_dir}") - print(f"Fix mode: {'ON' if args.fix else 'OFF'}") - print() - - validator = SyntaxValidator() - valid_count, total_count, fixed_count = validator.scan_directory(target_dir, fix_mode=args.fix) - - all_valid = validator.print_summary(valid_count, total_count, fixed_count) - - if args.check_only and not all_valid: - print(f"\nValidation failed: {total_count - valid_count} files have syntax errors") - sys.exit(1) - elif not all_valid: - print(f"\nWarning: {total_count - valid_count} files have syntax errors") - if not args.fix: - print("Run with --fix to attempt automatic fixes") - sys.exit(1) - else: - print(f"\nāœ“ All {total_count} files have valid syntax") - sys.exit(0) - - -if __name__ == '__main__': - main() diff --git a/uninstall.sh b/uninstall.sh deleted file mode 100755 index 6b5bcb89..00000000 --- a/uninstall.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/bin/bash -#Basic uninstall script for Fenrir. -cat << EOF -Fenrir is going to remove. -All scripts and settings will be lost. -EOF - -# ask -read -p "This will remove Fenrir and its settings from your system,, press ctrl+C to cancel, or enter to continue." continue - -# do it -unlink /usr/bin/fenrir -unlink /usr/bin/fenrir-daemon -rm -rf /opt/fenrirscreenreader -rm -rf /usr/share/fenrirscreenreader -rm -rf /etc/fenrirscreenreader -rm -rf /usr/share/sounds/fenrirscreenreader -rm -f /etc/systemd/system/fenrir.service - -# success message -cat << EOF -Fenrir has been successfully removed from your system. -EOF